diff --git a/Cargo.toml b/Cargo.toml index 826a00a..f09d316 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,10 @@ categories = ["caching", "config", "parsing", "os::linux-apis"] figment = { version = "0.10.6", features = ["env", "test", "toml" ] } num_cpus = "1.13.1" serde = { version = "1.0.144", features = ["derive"] } +chrono = { version = "0.4.22", features = ["serde", "clock", "std"], default-features = false } +serde_derive = "1.0.144" +serde_json = "1.0.85" +error-stack = "0.2.2" [dev-dependencies] rstest = "0.15.0" diff --git a/examples/store_crud.rs b/examples/store_crud.rs new file mode 100644 index 0000000..73f21d0 --- /dev/null +++ b/examples/store_crud.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; + +use chrono::NaiveDateTime; +use error_stack::Result; +use libpacstall::model::{InstallState, Kind, PacBuild, Repository, Version}; +use libpacstall::store::base::Store; +use libpacstall::store::errors::StoreError; +use libpacstall::store::filters; +use libpacstall::store::query_builder::{PacBuildQuery, RepositoryQuery, StringClause}; + +fn main() { + let mut store = Store::in_memory(); + + example_entity_insertion(&mut store).unwrap(); + example_entity_query(&store); + example_entity_update(&mut store).unwrap(); + example_entity_deletion(&mut store).unwrap(); +} + +fn example_entity_query(store: &Store) { + println!("## Running [example_entity_query]"); + + println!("\n\tSearching for all pacbuilds that contain the word 'discord' in their name."); + + let pacbuilds = store.query_pacbuilds(|store| { + store.find( + PacBuildQuery::select().where_name(StringClause::Contains(String::from("discord"))), + ) + }); + + println!( + "\n\tWe are expecting to find 1 result. Found: {}.", + pacbuilds.len() + ); + assert_eq!(pacbuilds.len(), 1); + println!("\tDone!"); + + let pacbuild = pacbuilds.first().unwrap(); + + println!( + "\n\tWe are expecting to find 'discord-deb' from 'https://awesome-repository.local' \ + repository." + ); + println!( + "\t\tFound '{}' from repository '{}'", + &pacbuild.name, &pacbuild.repository + ); + assert_eq!(pacbuild.name, String::from("discord-deb")); + assert_eq!( + pacbuild.repository, + String::from("https://awesome-repository.local") + ); + println!("\tDone!\n"); +} + +#[allow(clippy::redundant_pattern_matching)] +fn example_entity_insertion(store: &mut Store) -> Result<(), StoreError> { + println!("\n## Running [example_entity_insertion]\n"); + + // Create dummy data + let repository = create_repository( + String::from("My Awesome Repository"), + String::from("https://awesome-repository.local"), + ); + + let pacbuild = create_pacbuild( + String::from("discord-deb"), + InstallState::None, + Kind::DebFile(String::from("some hash")), + repository.url.clone(), + ); + + // Insert repository first, because the pacbuild depends on it. + println!("\n\tAttempting to insert the new repository into the store."); + store.mutate_repositories(|store| store.insert(repository.clone()))?; + println!("\tDone!\n"); + + // Repository exists so it is safe to add the pacbuild. + println!("\tAttempting to insert the new pacbuild into the store."); + store.mutate_pacbuilds(|store| store.insert(pacbuild.clone()))?; + println!("\tDone!\n"); + + // PacBuild is already inserted, so trying to insert it again would result in a + // conflict error. + println!("\tAttempting to insert the same pacbuild into the store."); + let result = store.mutate_pacbuilds(|store| store.insert(pacbuild.clone())); + if let Err(_) = &result { + println!("\t\tInserting the same pacbuild failed as expected."); + + // Uncomment the next line to see how the stacktrace looks :) + // result.unwrap(); + } else { + panic!("\t\tThis will never be printed.") + } + println!("\tDone!\n"); + + Ok(()) +} + +fn example_entity_update(store: &mut Store) -> Result<(), StoreError> { + println!("## Running [example_entity_update]\n"); + + // Search for the discord package. + println!("\tSearching for a single package called 'discord-deb'."); + let mut pacbuild = store + .query_pacbuilds(|store| { + store.single( + PacBuildQuery::select().where_name("discord-deb".into()) // Same as StringClause::Equals(String::from("discord-deb")) + ) + }) + .unwrap(); + + println!("\tFound: {:#?}\n", pacbuild); + assert_eq!(pacbuild.install_state, InstallState::None); + + // Assume we installed it + println!("\tWe update it so it looks like it is installed."); + pacbuild.install_state = InstallState::Direct(current_time(), Version::single(1)); + store.mutate_pacbuilds(|store| store.update(pacbuild.clone()))?; + println!("\tUpdated pacbuild: {:#?}\n", pacbuild); + + // Search again + println!("\tWe search for the same package again."); + let same_pacbuild = store + .query_pacbuilds(|store| { + store.single(PacBuildQuery::select().where_install_state(filters::InstallState::Direct)) + }) + .unwrap(); + println!( + "\tValue after re-querying the store: {:#?}\n", + same_pacbuild + ); + + println!("\tAsserting that the change propagated."); + assert_eq!(pacbuild, same_pacbuild); + println!("\tDone!"); + + Ok(()) +} + +fn example_entity_deletion(store: &mut Store) -> Result<(), StoreError> { + println!("## Running [example_entity_deletion]\n"); + + // Select the first repository + println!("\tFetching a repository."); + let repository = store + .query_repositories(|store| store.single(RepositoryQuery::select())) + .unwrap(); + println!("\tFound: {:?}\n", repository); + + let pacbuilds = store.query_pacbuilds(|store| { + store.find(PacBuildQuery::select().where_repository_url(repository.url.as_str().into())) + }); + println!( + "\tThis repository has a total of **{}** pacbuilds.\n", + pacbuilds.len() + ); + + // We attempt to delete it + println!("\tAttempting to delete it."); + store.mutate_repositories(|store| { + store.remove(RepositoryQuery::select().where_url(repository.url.as_str().into())) + })?; + println!("\tDone!\n"); + + // Selecting the same repository again + println!("\tSelecting the same repository again."); + let found = store.query_repositories(|store| { + store.single(RepositoryQuery::select().where_url(repository.url.as_str().into())) + }); + assert!(found.is_none()); + println!("\tFound no match.\n"); + + // Find any pacbuild from that repository. + println!("\tAttempting to find any pacbuild from that repository."); + let pacbuilds = store.query_pacbuilds(|store| { + store.find(PacBuildQuery::select().where_repository_url(repository.url.as_str().into())) + }); + + println!("\tWe expect to find none. Found: **{}**\n", pacbuilds.len()); + assert_eq!(pacbuilds.len(), 0); + + Ok(()) +} + +fn create_pacbuild( + name: String, + install_state: InstallState, + kind: Kind, + repository_url: String, +) -> PacBuild { + println!( + "\tCreating dummy PacBuild[name = '{}', install_state = '{:?}', kind = '{:?}', repository \ + = '{}']", + name, install_state, kind, repository_url + ); + + PacBuild { + name, + last_updated: current_time(), + repository: repository_url, + maintainers: Vec::new(), + package_names: Vec::new(), + description: String::from(""), + homepage: String::from(""), + repology_version: Version::single(1), + repology: String::from(""), + install_state, + dependencies: Vec::new(), + optional_dependencies: HashMap::new(), + licenses: vec![String::from("MIT")], + conflicts: Vec::new(), + epoch: 0, + groups: Vec::new(), + make_dependencies: Vec::new(), + package_base: None, + ppas: Vec::new(), + provides: Vec::new(), + replaces: Vec::new(), + url: String::from("https://pacbuild.pac"), + kind, + } +} + +fn create_repository(name: String, url: String) -> Repository { + println!( + "\tCreating dummy Repository[name = '{}', url = '{}']", + name, url + ); + Repository { + name, + url, + preference: 0, + } +} + +fn current_time() -> NaiveDateTime { + NaiveDateTime::from_timestamp(chrono::Utc::now().timestamp(), 0) +} diff --git a/src/config.rs b/src/config.rs index fc8f430..29f5ef9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,6 +35,8 @@ use figment::value::{Dict, Map}; use figment::{Error, Figment, Metadata, Profile, Provider}; use serde::{Deserialize, Serialize}; +use crate::model::{default_repository, Repository}; + /// Pacstall's configuration. /// /// Gives access to the [configuration](Config) extracted, and the [Figment] @@ -191,39 +193,6 @@ impl Default for Settings { } } -/// The extracted `repositories` array of tables. -/// -/// Defaults to the official repository. -#[derive(Deserialize, Debug, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct Repository { - /// The name of the repository. - pub name: String, - /// URL of the repository. - /// - /// Note that the URL **isn't verified** during extraction! - pub url: String, - /// Preference of the repository. - /// - /// Specifies which repository to look into first during certain operations - /// like installing a package. If the package isn't present in the first - /// preferred repository, then the second preferred repository is looked - /// into. - pub preference: u32, -} - -fn default_repository() -> Vec { vec![Repository::default()] } - -impl Default for Repository { - fn default() -> Self { - Self { - name: "official".into(), - url: "https://github.com/pacstall/pacstall-programs".into(), - preference: 1, - } - } -} - #[cfg(test)] mod tests { use std::fs::{self, File}; diff --git a/src/lib.rs b/src/lib.rs index 7b8a174..eabe0a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,5 @@ )] #![allow(clippy::must_use_candidate)] pub mod config; +pub mod model; +pub mod store; diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..e212776 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,7 @@ +//! Provides structs to handle Pacstall's data models. + +mod pacbuild; +mod repository; + +pub use crate::model::pacbuild::*; +pub use crate::model::repository::{default_repository, Repository}; diff --git a/src/model/pacbuild.rs b/src/model/pacbuild.rs new file mode 100644 index 0000000..060dc9b --- /dev/null +++ b/src/model/pacbuild.rs @@ -0,0 +1,343 @@ +//! + +use std::collections::HashMap; + +use chrono::NaiveDateTime as DateTime; +use serde_derive::{Deserialize, Serialize}; + +use crate::store::errors::InvalidVersionError; + +/// Representation of the PACBUILD file. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PacBuild { + /// PacBuild unique name per [`Repository`](crate::model::Repository). + pub name: PackageId, + + /// Last time it was changed. + pub last_updated: DateTime, + + /// [`Repository`](crate::model::Repository) url. + pub repository: URL, + + /// List of maintainers. + /// + /// # Example + /// `Paul Cosma ` + pub maintainers: Vec, + + /// Canonical package name. Usually the `name` without the type extension. + /// + /// # Example + /// - `PacBuild { name: "discord-deb", package_name: vec!["discord"] }` + pub package_names: Vec, + + /// Short package description. + pub description: String, + + /// Official homepage [URL]. + pub homepage: URL, + + /// Latest version fetched from Repology. + pub repology_version: Version, + + /// Repology filter. + /// + /// # Example + /// **TBA** + pub repology: String, + + /// When building a split package, this variable can be used to explicitly + /// specify the name to be used to refer to the group of packages in the + /// output and in the naming of source-only tarballs. + pub package_base: Option, + + /// Installation state. + pub install_state: InstallState, + + /// An array of packages that must be installed for the software to build + /// and run. + pub dependencies: Vec, + + /// Used to force the package to be seen as newer than any previous version + /// with a lower epoch. This value is required to be a non-negative + /// integer; the default is 0. It is used when the version numbering + /// scheme of a package changes (or is alphanumeric), breaking normal + /// version comparison logic. + pub epoch: i32, + + /// An array of additional packages that the software provides the features + /// of (or a virtual package such as cron or sh). Packages providing the + /// same item can be installed side-by-side, unless at least one of them + /// uses a conflicts array + pub provides: Vec, + + /// An array of packages that conflict with, or cause problems with the + /// package, if installed. All these packages and packages providing + /// this item will need to be removed + pub conflicts: Vec, + + /// An array of obsolete packages that are replaced by the package, e.g. + /// `wireshark-qt` uses `replaces=('wireshark')` + pub replaces: Vec, + + /// An array of PPAs that provide the package. + pub ppas: Vec, + + /// The group the package belongs in. For instance, when installing + /// `plasma`, it installs all packages belonging in that group. + pub groups: Vec, + + /// Optional dependencies. Each Key:Pair is meant to describe the package + /// identifier and the reason for installing. + pub optional_dependencies: HashMap, + + /// An array of packages that are only required to build the software. + pub make_dependencies: Vec, + + /// The license under which the software is distributed. + pub licenses: Vec, + + /// File required to build the package. + pub url: URL, + + /// [`PacBuild`] type, deduced from the name suffix. + pub kind: Kind, +} + +/// Represents a `SemVer` version. +/// # Examples +/// +/// ``` +/// use libpacstall::model::Version; +/// +/// let ver: Version = Version::semver(1, 0, 0); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Version { + pub major: i32, + pub minor: i32, + pub patch: i32, + pub suffix: Option, +} + +impl Version { + pub fn single(major: i32) -> Self { + Version { + major, + minor: 0, + patch: 0, + suffix: None, + } + } + + pub fn double(major: i32, minor: i32) -> Self { + Version { + major, + minor, + patch: 0, + suffix: None, + } + } + + pub fn semver(major: i32, minor: i32, patch: i32) -> Self { + Version { + major, + minor, + patch, + suffix: None, + } + } + + pub fn semver_extended(major: i32, minor: i32, patch: i32, suffix: &str) -> Self { + Version { + major, + minor, + patch, + suffix: Some(suffix.to_string()), + } + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + match self.major.partial_cmp(&other.major) { + Some(core::cmp::Ordering::Equal) => {}, + ord => return ord, + } + + match self.minor.partial_cmp(&other.minor) { + Some(core::cmp::Ordering::Equal) => {}, + ord => return ord, + } + + match self.patch.partial_cmp(&other.patch) { + Some(core::cmp::Ordering::Equal) => {}, + ord => return ord, + } + + self.suffix.partial_cmp(&other.suffix) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self.partial_cmp(other) { + Some(ord) => ord, + None => panic!("unreachable"), + } + } +} + +impl TryFrom for Version { + type Error = InvalidVersionError; + + fn try_from(value: String) -> Result { + let parts: Vec<&str> = value.split('.').collect(); + + if parts.is_empty() { + return Err(InvalidVersionError {}); + } + + let major: i32 = parts[0].parse().map_err(|_| InvalidVersionError {})?; + + let minor: i32 = if parts.len() >= 2 { + parts[1].parse().map_err(|_| InvalidVersionError {})? + } else { + 0 + }; + + let patch: i32 = if parts.len() >= 3 { + parts[2].parse().map_err(|_| InvalidVersionError {})? + } else { + 0 + }; + + let suffix = if parts.len() >= 4 { + Some(parts[3..].join(".")) + } else { + None + }; + + Ok(Version { + major, + minor, + patch, + suffix, + }) + } +} + +/// Represents a [`PacBuild`] or Apt package name. +/// # Examples +/// +/// ``` +/// use libpacstall::model::PackageId; +/// +/// let identifier: PackageId = "discord-deb".into(); +/// ``` +pub type PackageId = String; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum VersionConstrainedPackageId { + Any(PackageId), + GreaterThan(Version, PackageId), + GreaterThanEquals(Version, PackageId), + LessThan(Version, PackageId), + LessThanEquals(Version, PackageId), + Between(Version, Version, PackageId), + BetweenInclusive(Version, Version, PackageId), +} + +#[allow(clippy::derive_hash_xor_eq)] +impl std::hash::Hash for VersionConstrainedPackageId { + fn hash(&self, state: &mut H) { + match &self { + Self::Any(p_id) + | Self::GreaterThan(_, p_id) + | Self::Between(_, _, p_id) + | Self::BetweenInclusive(_, _, p_id) + | Self::GreaterThanEquals(_, p_id) + | Self::LessThanEquals(_, p_id) + | Self::LessThan(_, p_id) => p_id.hash(state), + }; + } +} + +/// The group the package belongs in. For instance, when installing `plasma`, it +/// installs all packages belonging in that group. +pub type GroupId = String; +/// Represents an URL +/// # Examples +/// +/// ``` +/// use libpacstall::model::URL; +/// +/// let url: URL = "https://example.com".into(); +/// ``` +pub type URL = String; +/// Represents a file checksum +/// # Examples +/// +/// ``` +/// use libpacstall::model::Hash; +/// +/// let hash: Hash = "b5c9710f33204498efb64cf8257cd9b19e9d3e6b".into(); +/// ``` +pub type Hash = String; + +/// Represents the install state of a package. +/// # Examples +/// +/// ``` +/// use chrono::NaiveDate; +/// use libpacstall::model::{InstallState, Version}; +/// +/// let installed_directly = InstallState::Direct( +/// NaiveDate::from_ymd(2016, 7, 8).and_hms(9, 10, 11), +/// Version::semver(0, 9, 2), +/// ); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum InstallState { + /// Package is installed directly, meaning the user wanted it. + Direct(DateTime, Version), + + /// Package is installed as a dependency. + Indirect(DateTime, Version), + + /// Package is not installed. + None, +} + +impl InstallState { + /// Returns `true` if the package is installed otherwise `false`. + pub fn is_installed(&self) -> bool { !matches!(self, Self::None) } +} + +/// Represents the type of the package. Usually deduced by the [PacBuild#name] +/// suffix. +/// +/// # Examples +/// +/// ``` +/// use libpacstall::model::Kind; +/// +/// let git_release = Kind::GitRelease; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum Kind { + /// [`PacBuild`] will install an `AppImage`. + AppImage(Hash), + + /// [`PacBuild`] will install a prebuilt, usually `tar.gz`, package. + Binary(Hash), + + /// [`PacBuild`] will install an existing `.deb` file. + DebFile(Hash), + + /// [`PacBuild`] will install the source of a given Git branch. + GitBranch, + + /// [`PacBuild`] will install the source of a given Git release. + GitRelease, +} diff --git a/src/model/repository.rs b/src/model/repository.rs new file mode 100644 index 0000000..0395507 --- /dev/null +++ b/src/model/repository.rs @@ -0,0 +1,35 @@ +use serde_derive::{Deserialize, Serialize}; + +/// Representation of a Pacstall repository. +/// +/// Defaults to the official repository. +#[derive(Deserialize, Debug, Eq, PartialEq, Serialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct Repository { + /// The name of the repository. + pub name: String, + /// URL of the repository. + /// + /// Note that the URL **isn't verified** during extraction! + pub url: String, + /// Preference of the repository. + /// + /// Specifies which repository to look into first during certain operations + /// like installing a package. If the package isn't present in the first + /// preferred repository, then the second preferred repository is looked + /// into. + pub preference: u32, +} + +#[allow(clippy::module_name_repetitions)] +pub fn default_repository() -> Vec { vec![Repository::default()] } + +impl Default for Repository { + fn default() -> Self { + Self { + name: "official".into(), + url: "https://github.com/pacstall/pacstall-programs".into(), + preference: 1, + } + } +} diff --git a/src/store/base.rs b/src/store/base.rs new file mode 100644 index 0000000..60e60e1 --- /dev/null +++ b/src/store/base.rs @@ -0,0 +1,726 @@ +//! Abstraction over the caching implementation + +use std::collections::HashMap; +use std::fmt::Debug; +use std::fs; +use std::path::Path; + +use error_stack::{ensure, report, IntoReport, Result, ResultExt}; +use serde::{Deserialize, Serialize}; + +use super::errors::{ + EntityAlreadyExistsError, EntityMutationError, EntityNotFoundError, IOError, NoQueryMatchError, + StoreError, +}; +use super::query_builder::{Mutable, PacBuildQuery, Queryable, RepositoryQuery}; +use crate::model::{PacBuild, Repository}; + +/// Shorthand alias for [`Result`]. +pub type StoreResult = Result; + +/// Path of the database. +#[cfg(not(test))] +const FSS_PATH: &str = "/etc/pacstall/fss.json"; + +/// Path of the database. +#[cfg(test)] +const FSS_PATH: &str = "./fss.json"; + +/// Store implementation for metadata caching. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Store { + repositories: Vec, + packages: HashMap>, + + #[serde(skip)] + in_memory: bool, +} + +impl Store { + /// Loads the store from the disk. + /// + /// # Errors + /// + /// The following errors may occur: + /// + /// - [`StoreError`](crate::store::errors::StoreError) - Wrapper for all the + /// other [`Store`] errors + /// - [`IOError`](crate::store::errors::IOError) - When attempting database + /// import fails + pub fn load() -> StoreResult { + let contents = fs::read_to_string(Path::new(FSS_PATH)) + .into_report() + .attach_printable_lazy(|| format!("failed to read file {FSS_PATH:?}")) + .change_context(IOError) + .change_context(StoreError)?; + + let obj: Self = serde_json::from_str(&contents) + .into_report() + .attach_printable_lazy(|| { + format!("failed to deserialize database contents: '{contents:?}'") + }) + .change_context(IOError) + .change_context(StoreError)?; + + Ok(obj) + } + + pub fn in_memory() -> Self { + Store { + repositories: Vec::new(), + packages: HashMap::new(), + in_memory: true, + } + } + + /// # Private + fn save_to_disk(&self) -> StoreResult<()> { + if self.in_memory { + return Ok(()); + } + + let json = serde_json::to_vec_pretty(self) + .into_report() + .attach_printable_lazy(|| "failed to serialize database".to_string()) + .change_context(IOError) + .change_context(StoreError)?; + + fs::write(Path::new(FSS_PATH), &json) + .into_report() + .attach_printable_lazy(|| { + format!("failed to write serialized database to {FSS_PATH:?}") + }) + .change_context(IOError) + .change_context(StoreError)?; + + Ok(()) + } +} + +impl Store { + /// Searches for [`PacBuild`]s based on the given query. + pub fn query_pacbuilds(&self, handler: F) -> R + where + F: Fn(Box>) -> R, + { + let query_resolver = Box::new(PacBuildQueryResolver { + packages: self.packages.clone(), + repositories: self.repositories.clone(), + }); + + handler(query_resolver) + } + + /// Searches for [`Repository`]s based on the given query. + pub fn query_repositories(&self, handler: F) -> R + where + F: Fn(Box>) -> R, + { + let query_resolver = Box::new(RepositoryQueryResolver { + packages: self.packages.clone(), + repositories: self.repositories.clone(), + }); + + handler(query_resolver) + } + + /// Mutates [`PacBuild`]s based on the given query. + /// + /// # Errors + /// + /// The following errors may occur: + /// + /// - [`StoreError`](crate::store::errors::StoreError) - Wrapper for all the + /// other [`Store`] errors + /// - [`EntityNotFoundError`](crate::store::errors::EntityNotFoundError) - + /// When attempting to query a [`PacBuild`] or related entity that does + /// not exist + /// - [`EntityAlreadyExistsError`](crate::store::errors::EntityAlreadyExistsError) - When attempting insert a [`PacBuild`] or related entity that already exists + /// - [`NoQueryMatchError`](crate::store::errors::NoQueryMatchError) - When + /// attempting to remove a [`PacBuild`] that does not exist + /// - [`IOError`](crate::store::errors::IOError) - When attempting database + /// export fails + pub fn mutate_pacbuilds(&mut self, mut handler: F) -> StoreResult + where + F: FnMut(&mut dyn Mutable) -> StoreResult, + { + let mut query_resolver = PacBuildQueryResolver { + packages: self.packages.clone(), + repositories: self.repositories.clone(), + }; + + let res = handler(&mut query_resolver); + self.packages = query_resolver.packages; + self.repositories = query_resolver.repositories; + self.save_to_disk()?; + + res + } + + /// Mutates [`Repository`]s based on the given query. + /// + /// # Errors + /// + /// The following errors may occur: + /// + /// - [`StoreError`](crate::store::errors::StoreError) - Wrapper for all the + /// other [`Store`] errors + /// - [`EntityNotFoundError`](crate::store::errors::EntityNotFoundError) - + /// When attempting to query a [`Repository`] or related entity that does + /// not exist + /// - [`EntityAlreadyExistsError`](crate::store::errors::EntityAlreadyExistsError) - When attempting insert a [`Repository`] or related entity that already exists + /// - [`NoQueryMatchError`](crate::store::errors::NoQueryMatchError) - When + /// attempting to remove a [`Repository`] that does not exist + /// - [`IOError`](crate::store::errors::IOError) - When attempting database + /// export fails + pub fn mutate_repositories(&mut self, mut handler: F) -> StoreResult + where + F: FnMut(&mut dyn Mutable) -> StoreResult, + { + let mut query_resolver = RepositoryQueryResolver { + packages: self.packages.clone(), + repositories: self.repositories.clone(), + }; + + let res = handler(&mut query_resolver); + self.packages = query_resolver.packages; + self.repositories = query_resolver.repositories; + self.save_to_disk()?; + + res + } +} + +struct PacBuildQueryResolver { + pub(super) repositories: Vec, + pub(super) packages: HashMap>, +} + +struct RepositoryQueryResolver { + pub(super) repositories: Vec, + pub(super) packages: HashMap>, +} + +impl Queryable for RepositoryQueryResolver { + fn single(&self, query: RepositoryQuery) -> Option { + let all = self.find(query); + all.first().cloned() + } + + fn find(&self, query: RepositoryQuery) -> Vec { + self.repositories + .clone() + .into_iter() + .filter(|it| query.matches(it)) + .collect() + } + + fn page(&self, query: RepositoryQuery, page_no: usize, page_size: usize) -> Vec { + let start_idx = page_no * page_size; + let mut end_idx = start_idx + page_size; + + let found = self.find(query); + + if start_idx > found.len() - 1 { + return Vec::new(); + } + + if found.len() < end_idx { + end_idx = found.len(); + } + + found[start_idx..end_idx].to_vec() + } +} + +impl Mutable for RepositoryQueryResolver { + fn insert(&mut self, entity: Repository) -> StoreResult<()> { + let found = self.single( + RepositoryQuery::select() + .where_name(entity.name.as_str().into()) + .where_url(entity.url.as_str().into()), + ); + + ensure!( + found.is_none(), + report!(EntityAlreadyExistsError) + .attach_printable(format!("repository '{entity:?}' already exists")) + .change_context(EntityMutationError) + .change_context(StoreError) + ); + + self.repositories.push(entity); + + Ok(()) + } + + fn update(&mut self, entity: Repository) -> StoreResult<()> { + let repo = self.single(RepositoryQuery::select().where_url(entity.name.as_str().into())); + + ensure!( + repo.is_some(), + report!(EntityNotFoundError) + .attach_printable(format!("repository '{entity:?}' does not exist")) + .change_context(EntityMutationError) + .change_context(StoreError) + ); + + let found = repo.unwrap(); + self.repositories.swap_remove( + self.repositories + .iter() + .position(|it| it.url == found.url) + .unwrap(), + ); + self.repositories.push(entity); + + Ok(()) + } + + fn remove(&mut self, query: RepositoryQuery) -> StoreResult<()> { + let to_remove: Vec = self + .repositories + .clone() + .into_iter() + .filter(|it| query.matches(it)) + .collect(); + + ensure!( + !to_remove.is_empty(), + report!(NoQueryMatchError) + .attach_printable(format!("query '{query:?}' found no results")) + .change_context(EntityMutationError) + .change_context(StoreError) + ); + + let new_repos: Vec = self + .repositories + .clone() + .into_iter() + .filter(|it| !query.matches(it)) + .collect(); + + self.repositories = new_repos; + + if let Some(clause) = query.url { + for repo in to_remove { + if clause.matches(&repo.url) { + self.packages.remove(&repo.url); + } + } + } + + Ok(()) + } +} + +impl Queryable for PacBuildQueryResolver { + fn single(&self, query: PacBuildQuery) -> Option { + let all = self.find(query); + all.first().cloned() + } + + fn find(&self, query: PacBuildQuery) -> Vec { + self.packages + .clone() + .into_iter() + .flat_map(|(_, it)| it) + .filter(|it| query.matches(it)) + .collect() + } + + fn page(&self, query: PacBuildQuery, page_no: usize, page_size: usize) -> Vec { + let start_idx = page_no * page_size; + let mut end_idx = start_idx + page_size; + + let found = self.find(query); + + if start_idx > found.len() - 1 { + return Vec::new(); + } + + if found.len() < end_idx { + end_idx = found.len(); + } + + found[start_idx..end_idx].to_vec() + } +} + +impl Mutable for PacBuildQueryResolver { + fn insert(&mut self, pacbuild: PacBuild) -> StoreResult<()> { + ensure!( + self.repositories + .iter() + .any(|it| it.url == pacbuild.repository), + report!(EntityNotFoundError) + .attach_printable(format!( + "repository of pacbuild {pacbuild:?} does not exist" + )) + .change_context(EntityMutationError) + .change_context(StoreError) + ); + + let found = self.single( + PacBuildQuery::select() + .where_name(pacbuild.name.as_str().into()) + .where_repository_url(pacbuild.repository.as_str().into()), + ); + + ensure!( + found.is_none(), + report!(EntityAlreadyExistsError) + .attach_printable(format!("pacbuild {found:?} already exists")) + .change_context(EntityMutationError) + .change_context(StoreError) + ); + + if let Some(packages) = self.packages.get_mut(&pacbuild.repository) { + packages.push(pacbuild); + } else { + self.packages + .insert(pacbuild.repository.clone(), vec![pacbuild]); + } + + Ok(()) + } + + fn update(&mut self, pacbuild: PacBuild) -> StoreResult<()> { + ensure!( + self.repositories + .iter() + .any(|it| it.url == pacbuild.repository), + report!(EntityNotFoundError) + .attach_printable(format!( + "repository of pacbuild {pacbuild:?} does not exist" + )) + .change_context(EntityMutationError) + .change_context(StoreError) + ); + + let found = self.single( + PacBuildQuery::select() + .where_name(pacbuild.name.as_str().into()) + .where_repository_url(pacbuild.repository.as_str().into()), + ); + + ensure!( + found.is_some(), + report!(EntityNotFoundError) + .attach_printable(format!( + "repository of pacbuild {pacbuild:?} does not exist" + )) + .change_context(EntityMutationError) + .change_context(StoreError) + ); + + let pkg = found.unwrap(); + let repo = self.packages.get_mut(&pkg.repository).unwrap(); + let pos = repo.iter().position(|it| it.name == pkg.name).unwrap(); + repo.remove(pos); + repo.push(pacbuild); + + Ok(()) + } + + fn remove(&mut self, query: PacBuildQuery) -> StoreResult<()> { + let mut did_remove = false; + for packages in &mut self.packages.values_mut() { + let pkgs: Vec = packages + .iter() + .cloned() + .filter(|it| !query.matches(it)) + .collect(); + + if packages.len() != pkgs.len() { + did_remove = true; + } + + *packages = pkgs; + } + + ensure!( + did_remove, + report!(NoQueryMatchError) + .attach_printable(format!("query {query:?} found no results")) + .change_context(EntityMutationError) + .change_context(StoreError) + ); + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::Store; + use crate::model::Repository; + use crate::store::filters::{InstallState, Kind}; + use crate::store::query_builder::{PacBuildQuery, RepositoryQuery, StringClause}; + + mod util { + use std::collections::HashMap; + + use chrono::NaiveDateTime; + + use crate::model::{InstallState, Kind, PacBuild, Repository, Version}; + use crate::store::base::Store; + + pub fn create_store_with_sample_data() -> (Store, Repository, PacBuild) { + let mut fss = Store::in_memory(); + let repo = Repository::default(); + let pacbuild_to_add = PacBuild { + name: "dummy-pacbuild-deb".into(), + package_names: vec!["dummy-pacbuild".to_string()], + description: "blah".into(), + dependencies: Vec::new(), + homepage: "https://example.com".into(), + install_state: InstallState::Direct( + NaiveDateTime::from_timestamp(chrono::Utc::now().timestamp(), 0), + Version::single(1), + ), + kind: Kind::DebFile("hashash".into()), + last_updated: NaiveDateTime::from_timestamp(chrono::Utc::now().timestamp(), 0), + licenses: vec![String::from("BSD")], + maintainers: vec![String::from("saenai255")], + optional_dependencies: HashMap::new(), + repology: "filter".into(), + repology_version: Version::semver(1, 0, 1), + conflicts: Vec::new(), + epoch: 0, + groups: Vec::new(), + make_dependencies: Vec::new(), + package_base: None, + ppas: Vec::new(), + provides: Vec::new(), + replaces: Vec::new(), + repository: repo.url.clone(), + url: "https://example.com/dummy-pacbuild-1.0.0.deb".into(), + }; + + fss.mutate_repositories(|store| store.insert(repo.clone())) + .unwrap(); + fss.mutate_pacbuilds(|store| store.insert(pacbuild_to_add.clone())) + .unwrap(); + + (fss, repo, pacbuild_to_add) + } + } + + #[test] + fn new_creates_empty_fs_store() { + let fss = Store::in_memory(); + let pacbuilds = fss.query_pacbuilds(|store| store.find(PacBuildQuery::select())); + let repos = fss.query_repositories(|store| store.find(RepositoryQuery::select())); + + assert_eq!(pacbuilds.len(), 0); + assert_eq!(repos.len(), 0); + } + + #[test] + fn add_repository_works() { + let mut fss = Store::in_memory(); + + fss.mutate_repositories(|store| store.insert(Repository::default())) + .unwrap(); + let repos = fss.query_repositories(|store| store.find(RepositoryQuery::select())); + + assert_eq!(repos.len(), 1); + } + + #[test] + fn get_repository_by_name_works() { + let mut fss = Store::in_memory(); + let repo = Repository::default(); + + fss.mutate_repositories(|store| store.insert(repo.clone())) + .unwrap(); + let found_repo = fss + .query_repositories(|store| { + store.single(RepositoryQuery::select().where_name(repo.name.as_str().into())) + }) + .unwrap(); + + assert_eq!(repo, found_repo); + } + + #[test] + fn get_repository_by_url_works() { + let mut fss = Store::in_memory(); + let repo = Repository::default(); + + fss.mutate_repositories(|store| store.insert(repo.clone())) + .unwrap(); + let found_repo = fss + .query_repositories(|store| { + store.single(RepositoryQuery::select().where_url(repo.url.as_str().into())) + }) + .unwrap(); + + assert_eq!(repo, found_repo); + } + + #[test] + fn add_pacbuild_works() { + let (fss, ..) = util::create_store_with_sample_data(); + let pbs = fss.query_pacbuilds(|store| store.find(PacBuildQuery::select())); + + println!("{:#?}", pbs); + + assert_eq!(pbs.len(), 1); + } + + #[test] + fn get_pacbuild_by_name_and_url_works() { + let (fss, _, pacbuild) = util::create_store_with_sample_data(); + let found = fss + .query_pacbuilds(|store| { + store.single( + PacBuildQuery::select() + .where_name(pacbuild.name.as_str().into()) + .where_repository_url(pacbuild.repository.as_str().into()), + ) + }) + .unwrap(); + + assert_eq!(found, pacbuild); + } + + #[test] + fn get_all_pacbuilds_works() { + let (fss, ..) = util::create_store_with_sample_data(); + let found = fss.query_pacbuilds(|store| store.find(PacBuildQuery::select())); + + assert_eq!(found.len(), 1); + } + + #[test] + fn get_all_pacbuilds_by_name_like_works() { + let (fss, _, pb) = util::create_store_with_sample_data(); + let found = fss.query_pacbuilds(|store| { + store.find(PacBuildQuery::select().where_name(StringClause::Contains(pb.name.clone()))) + }); + + assert_eq!(found.len(), 1); + } + + #[test] + fn get_all_pacbuilds_by_name_like_works_when_no_results() { + let (fss, ..) = util::create_store_with_sample_data(); + let found = fss.query_pacbuilds(|store| { + store.find( + PacBuildQuery::select().where_name(StringClause::Contains("blablabla".into())), + ) + }); + + assert_eq!(found.len(), 0); + } + + #[test] + fn get_all_pacbuilds_by_install_state_works() { + let (fss, ..) = util::create_store_with_sample_data(); + let found = fss.query_pacbuilds(|store| { + store.find(PacBuildQuery::select().where_install_state(InstallState::Direct)) + }); + + assert_eq!(found.len(), 1); + } + + #[test] + fn get_all_pacbuilds_by_install_state_works_when_no_results() { + let (fss, ..) = util::create_store_with_sample_data(); + let found = fss.query_pacbuilds(|store| { + store.find(PacBuildQuery::select().where_install_state(InstallState::Indirect)) + }); + + assert_eq!(found.len(), 0); + } + + #[test] + fn get_all_pacbuilds_by_kind_works() { + let (fss, ..) = util::create_store_with_sample_data(); + let found = fss + .query_pacbuilds(|store| store.find(PacBuildQuery::select().where_kind(Kind::DebFile))); + + assert_eq!(found.len(), 1); + } + + #[test] + fn get_all_pacbuilds_by_kind_works_when_no_results() { + let (fss, ..) = util::create_store_with_sample_data(); + let found = fss + .query_pacbuilds(|store| store.find(PacBuildQuery::select().where_kind(Kind::Binary))); + + assert_eq!(found.len(), 0); + } + + #[test] + fn get_all_pacbuilds_by_repository_url_works() { + let (fss, repo, _) = util::create_store_with_sample_data(); + let found = fss.query_pacbuilds(|store| { + store.find(PacBuildQuery::select().where_repository_url(repo.url.as_str().into())) + }); + + assert_eq!(found.len(), 1); + } + + #[test] + fn get_all_pacbuilds_by_repository_url_works_when_no_results() { + let (fss, ..) = util::create_store_with_sample_data(); + let found = fss.query_pacbuilds(|store| { + store.find(PacBuildQuery::select().where_repository_url("does not exist".into())) + }); + + assert_eq!(found.len(), 0); + } + + #[test] + fn update_pacbuild_works() { + let (mut fss, _, mut pb) = util::create_store_with_sample_data(); + pb.description = "something else".into(); + + fss.mutate_pacbuilds(|query| query.update(pb.clone())) + .unwrap(); + + let results = fss.query_pacbuilds(|query| { + query.find( + PacBuildQuery::select() + .where_name(pb.name.as_str().into()) + .where_repository_url(pb.repository.as_str().into()), + ) + }); + let found = results.first().unwrap(); + + assert_eq!(pb, *found); + } + + #[test] + #[should_panic] + fn update_pacbuild_panics_when_pacbuild_not_found() { + let (mut fss, _, mut pb) = util::create_store_with_sample_data(); + pb.name = "lala".into(); + pb.description = "something else".into(); + + fss.mutate_pacbuilds(|query| query.update(pb.clone())) + .unwrap(); + } + + #[test] + #[should_panic] + fn remove_pacbuild_panics_when_pacbuild_not_found() { + let (mut fss, ..) = util::create_store_with_sample_data(); + + fss.mutate_pacbuilds(|query| { + query.remove(PacBuildQuery::select().where_name("asd".into())) + }) + .unwrap(); + } + + #[test] + #[should_panic] + fn add_pacbuild_panics_when_pacbuild_already_exists() { + let (mut fss, _, pb) = util::create_store_with_sample_data(); + fss.mutate_pacbuilds(|query| query.insert(pb.clone())) + .unwrap(); + } +} diff --git a/src/store/errors.rs b/src/store/errors.rs new file mode 100644 index 0000000..b696fd2 --- /dev/null +++ b/src/store/errors.rs @@ -0,0 +1,85 @@ +//! Errors used by the store + +use std::fmt; + +use error_stack::Context; + +/// Given store query yielded no results +#[derive(Debug, Clone)] +pub struct NoQueryMatchError; + +impl fmt::Display for NoQueryMatchError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("query yielded no results") + } +} + +impl Context for NoQueryMatchError {} + +/// Store mutation failed +#[derive(Debug, Clone)] +pub struct EntityMutationError; + +impl fmt::Display for EntityMutationError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("store mutation failed") + } +} + +impl Context for EntityMutationError {} + +/// Error representation of a failed IO operation. +#[derive(Debug, Clone)] +pub struct IOError; + +impl fmt::Display for IOError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("failed to do IO operation") + } +} + +impl Context for IOError {} + +/// Generic store error representation. +#[derive(Debug, Clone)] +pub struct StoreError; + +impl fmt::Display for StoreError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("store operation failed") + } +} + +impl Context for StoreError {} + +/// Error representation for entities that are not found. +#[derive(Debug, Clone)] +pub struct EntityNotFoundError; + +impl fmt::Display for EntityNotFoundError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { fmt.write_str("entity not found") } +} + +impl Context for EntityNotFoundError {} + +/// Error representation for entities that already exist, but shouldn't. +#[derive(Debug, Clone)] +pub struct EntityAlreadyExistsError; + +impl fmt::Display for EntityAlreadyExistsError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("entity already exists") + } +} + +impl Context for EntityAlreadyExistsError {} + +/// Error representation for invalid versions. +#[derive(Debug, Clone)] +pub struct InvalidVersionError; + +impl fmt::Display for InvalidVersionError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { fmt.write_str("invalid version") } +} + +impl Context for InvalidVersionError {} diff --git a/src/store/filters.rs b/src/store/filters.rs new file mode 100644 index 0000000..13b3809 --- /dev/null +++ b/src/store/filters.rs @@ -0,0 +1,69 @@ +//! Provides various structs for querying and filtering +//! [`PacBuild`](crate::model::PacBuild)s. + +/// Used to query [`PacBuild`](crate::model::PacBuild)s by installation state. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum InstallState { + /// [`PacBuild`](crate::model::PacBuild) is installed directly. + Direct, + + /// [`PacBuild`](crate::model::PacBuild) is installed, but as a dependency + /// of another. + Indirect, + + /// [`PacBuild`](crate::model::PacBuild) is not installed. + None, +} + +impl From<&crate::model::InstallState> for InstallState { + fn from(other: &crate::model::InstallState) -> Self { + InstallState::from_model_install_state(other) + } +} + +impl InstallState { + pub fn from_model_install_state(other: &crate::model::InstallState) -> InstallState { + match other { + crate::model::InstallState::Indirect(..) => InstallState::Indirect, + crate::model::InstallState::Direct(..) => InstallState::Direct, + crate::model::InstallState::None => InstallState::None, + } + } +} + +/// Used to query [`PacBuild`](crate::model::PacBuild)s by kind. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Kind { + /// [`PacBuild`](crate::model::PacBuild) is a prebuilt AppImage. + AppImage, + + /// [`PacBuild`](crate::model::PacBuild) is a prebuilt binary. Usually a + /// compressed in a tar or zip file. + Binary, + + /// [`PacBuild`](crate::model::PacBuild) is a prebuilt `.deb` file. + DebFile, + + /// [`PacBuild`](crate::model::PacBuild) will be built from a git branch. + GitBranch, + + /// [`PacBuild`](crate::model::PacBuild) will be built from a fixed git + /// release. + GitRelease, +} + +impl From<&crate::model::Kind> for Kind { + fn from(other: &crate::model::Kind) -> Self { Kind::from_model_kind(other) } +} + +impl Kind { + pub fn from_model_kind(other: &crate::model::Kind) -> Kind { + match other { + crate::model::Kind::GitRelease => Kind::GitRelease, + crate::model::Kind::GitBranch => Kind::GitBranch, + crate::model::Kind::AppImage(_) => Kind::AppImage, + crate::model::Kind::Binary(_) => Kind::Binary, + crate::model::Kind::DebFile(_) => Kind::DebFile, + } + } +} diff --git a/src/store/mod.rs b/src/store/mod.rs new file mode 100644 index 0000000..24b6f2b --- /dev/null +++ b/src/store/mod.rs @@ -0,0 +1,6 @@ +//! Provides traits and structs to handle Pacstall's cache. + +pub mod base; +pub mod errors; +pub mod filters; +pub mod query_builder; diff --git a/src/store/query_builder.rs b/src/store/query_builder.rs new file mode 100644 index 0000000..212b603 --- /dev/null +++ b/src/store/query_builder.rs @@ -0,0 +1,264 @@ +//! Provides query utilities for the cache store + +use super::base::StoreResult; +use super::filters::{InstallState, Kind}; +use crate::model::{PacBuild, Repository}; + +/// Defines the common methods for querying entities. +pub trait Queryable { + /// Finds a single entity that matches the given query. + fn single(&self, query: Q) -> Option; + + /// Finds all entities that match the given query. + fn find(&self, query: Q) -> Vec; + + /// Finds a selection of entities that match the given query. + fn page(&self, query: Q, page_no: usize, page_size: usize) -> Vec; +} + +/// Defines the common methods for mutating entities +pub trait Mutable { + /// Removes all entities that match the given + /// + /// # Errors + /// + /// The following errors may occur: + /// + /// - [`StoreError`](crate::store::errors::StoreError) - Wrapper for all the + /// other [`Store`](crate::store::base::Store) errors + /// - [`NoQueryMatchError`](crate::store::errors::NoQueryMatchError) - When + /// attempting to remove an entity that does not exist + /// - [`IOError`](crate::store::errors::IOError) - When attempting database + /// export fails + fn remove(&mut self, query: Q) -> StoreResult<()>; + + /// Inserts a single entity + /// + /// # Errors + /// + /// The following errors may occur: + /// + /// - [`StoreError`](crate::store::errors::StoreError) - Wrapper for all the + /// other [`Store`](crate::store::base::Store) errors + /// - [`EntityNotFoundError`](crate::store::errors::EntityNotFoundError) - + /// When attempting to query an entity or related entity that does not + /// exist + /// - [`EntityAlreadyExistsError`](crate::store::errors::EntityAlreadyExistsError) - When attempting insert an entity or related entity that already exists + /// - [`IOError`](crate::store::errors::IOError) - When attempting database + /// export fails + fn insert(&mut self, entity: T) -> StoreResult<()>; + + /// Removes all entities that match the given + /// + /// # Errors + /// + /// The following errors may occur: + /// + /// - [`StoreError`](crate::store::errors::StoreError) - Wrapper for all the + /// other [`Store`](crate::store::base::Store) errors + /// - [`EntityNotFoundError`](crate::store::errors::EntityNotFoundError) - + /// When attempting to query a [`Repository`] or related entity that does + /// not exist + /// - [`EntityAlreadyExistsError`](crate::store::errors::EntityAlreadyExistsError) - When attempting insert a [`Repository`] or related entity that already exists + /// - [`IOError`](crate::store::errors::IOError) - When attempting database + /// export fails + fn update(&mut self, entity: T) -> StoreResult<()>; +} + +/// Represents a query utility for common verbs. +#[derive(Debug, Clone)] +pub enum QueryClause { + /// Represents logical `NOT`. + Not(T), + + /// Represents logical `AND`. + And(Vec), + + /// Represents logical `OR`. + Or(Vec), +} + +/// Represents a string query utility. +#[derive(Debug, Clone)] +pub enum StringClause { + /// Equivalent of `==`. + Equals(String), + + /// Matches all strings starting with the wrapped string. + StartsWith(String), + + /// Matches all strings ending with the wrapped string. + EndsWith(String), + + /// Matches all strings containing the wrapped string. + Contains(String), + + /// Represents a list of query conditionals. + Composite(Box>), +} + +impl StringClause { + pub fn matches(&self, value: &str) -> bool { + match self { + Self::Equals(it) => it == value, + Self::Contains(it) => value.contains(it), + Self::StartsWith(it) => value.starts_with(it), + Self::EndsWith(it) => value.ends_with(it), + Self::Composite(query) => match &**query { + QueryClause::Not(str_clause) => !str_clause.matches(value), + QueryClause::And(str_clauses) => str_clauses.iter().all(|it| it.matches(value)), + QueryClause::Or(str_clauses) => str_clauses.iter().any(|it| it.matches(value)), + }, + } + } +} + +impl From for StringClause { + fn from(it: String) -> Self { StringClause::Equals(it) } +} + +impl From<&str> for StringClause { + fn from(it: &str) -> Self { StringClause::Equals(String::from(it)) } +} + +impl From<&String> for StringClause { + fn from(it: &String) -> Self { StringClause::Equals(it.clone()) } +} + +/// Query representation for [`PacBuild`]s. +#[derive(Debug, Clone)] +pub struct PacBuildQuery { + pub name: Option, + pub install_state: Option, + pub kind: Option, + pub repository_url: Option, +} + +impl PacBuildQuery { + pub(super) fn matches(&self, pacbuild: &PacBuild) -> bool { + if let Some(clause) = &self.name { + if !clause.matches(&pacbuild.name) { + return false; + } + } + + if let Some(clause) = &self.repository_url { + if !clause.matches(&pacbuild.repository) { + return false; + } + } + + if let Some(kind) = &self.kind { + if kind != &Kind::from_model_kind(&pacbuild.kind.clone()) { + return false; + } + } + + if let Some(install_state) = &self.install_state { + if install_state + != &InstallState::from_model_install_state(&pacbuild.install_state.clone()) + { + return false; + } + } + + true + } +} + +/// Query representation for [`Repository`]s. +#[derive(Debug, Clone)] +pub struct RepositoryQuery { + pub name: Option, + pub url: Option, +} + +impl RepositoryQuery { + pub(super) fn matches(&self, repository: &Repository) -> bool { + if let Some(clause) = &self.name { + if !clause.matches(&repository.name) { + return false; + } + } + + if let Some(clause) = &self.url { + if !clause.matches(&repository.url) { + return false; + } + } + + true + } +} + +#[allow(clippy::return_self_not_must_use)] +impl RepositoryQuery { + /// Initializes the query. + pub fn select() -> Self { + RepositoryQuery { + name: None, + url: None, + } + } + + /// Adds a name clause. + pub fn where_name(&self, name: StringClause) -> Self { + let mut query = self.clone(); + query.name = Some(name); + + query + } + + /// Adds a repository url clause. + pub fn where_url(&self, url: StringClause) -> Self { + let mut query = self.clone(); + query.url = Some(url); + + query + } +} + +#[allow(clippy::return_self_not_must_use)] +impl PacBuildQuery { + /// Initializes the query. + pub fn select() -> Self { + PacBuildQuery { + name: None, + install_state: None, + kind: None, + repository_url: None, + } + } + + /// Adds a name clause. + pub fn where_name(&self, name: StringClause) -> Self { + let mut query = self.clone(); + query.name = Some(name); + + query + } + + /// Adds an [`InstallState`] clause. + pub fn where_install_state(&self, install_state: InstallState) -> Self { + let mut query = self.clone(); + query.install_state = Some(install_state); + + query + } + + /// Adds a [`Kind`] clause. + pub fn where_kind(&self, kind: Kind) -> Self { + let mut query = self.clone(); + query.kind = Some(kind); + + query + } + + /// Adds a repository url clause. + pub fn where_repository_url(&self, repository_url: StringClause) -> Self { + let mut query = self.clone(); + query.repository_url = Some(repository_url); + + query + } +}