From 836711ac0722cef1a8c0ed008053a08e2aa4ae21 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Wed, 15 May 2024 13:23:14 -0700 Subject: [PATCH] Refactor --- Cargo.toml | 6 +- src/fetcher.rs | 17 +- src/lib.rs | 330 +++------------------- src/locator.rs | 617 +++++++++++++++++++++++++++++++++++++++++ src/locator_package.rs | 381 +++++++++++++++++++++++++ src/locator_strict.rs | 363 ++++++++++++++++++++++++ src/test.rs | 288 ------------------- 7 files changed, 1420 insertions(+), 582 deletions(-) create mode 100644 src/locator.rs create mode 100644 src/locator_package.rs create mode 100644 src/locator_strict.rs delete mode 100644 src/test.rs diff --git a/Cargo.toml b/Cargo.toml index 5f15a12..72d2262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,16 @@ [package] name = "locator" -version = "1.0.0" +version = "1.1.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +alphanumeric-sort = "1.5.3" getset = "0.1.2" lazy_static = "1.4.0" +pretty_assertions = "1.4.0" regex = "1.6.0" +schemars = "0.8.19" serde = { version = "1.0.140", features = ["derive"] } strum = { version = "0.24.1", features = ["derive"] } thiserror = "1.0.31" diff --git a/src/fetcher.rs b/src/fetcher.rs index 3e06624..3679cb2 100644 --- a/src/fetcher.rs +++ b/src/fetcher.rs @@ -1,5 +1,6 @@ use std::str::FromStr; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use strum::{AsRefStr, Display, EnumIter, EnumString}; @@ -10,7 +11,21 @@ use strum::{AsRefStr, Display, EnumIter, EnumString}; /// /// For more information on the background of `Locator` and fetchers generally, /// refer to [Fetchers and Locators](https://go/fetchers-doc). -#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Display, EnumString, EnumIter, AsRefStr)] +#[derive( + Copy, + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + Debug, + Display, + EnumString, + EnumIter, + AsRefStr, + JsonSchema, +)] #[non_exhaustive] pub enum Fetcher { /// Archive locators are FOSSA specific. diff --git a/src/lib.rs b/src/lib.rs index 979bcaf..b309285 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,304 +3,21 @@ #![deny(missing_docs)] #![warn(rust_2018_idioms)] -use std::fmt::Display; - -use getset::{CopyGetters, Getters}; use lazy_static::lazy_static; use regex::Regex; -use serde::{Deserialize, Serialize}; -use typed_builder::TypedBuilder; mod error; mod fetcher; +mod locator; +mod locator_package; +mod locator_strict; + pub use error::*; pub use fetcher::*; -/// Core, and most services that interact with Core, -/// refer to open source packages via the `Locator` type. -/// -/// This type is nearly universally rendered to a string -/// before being serialized to the database or sent over the network. -/// -/// This type represents a _validly-constructed_ `Locator`, but does not -/// validate whether a `Locator` is actually valid. This means that a -/// given `Locator` is guaranteed to be correctly formatted data, -/// but that the actual repository or revision to which the `Locator` -/// refers is _not_ guaranteed to exist or be accessible. -/// Currently the canonical method for validating whether a given `Locator` is -/// accessible is to run it through the Core fetcher system. -/// -/// For more information on the background of `Locator` and fetchers generally, -/// FOSSA employees may refer to -/// [Fetchers and Locators](https://go/fetchers-doc). -#[derive(Clone, Eq, PartialEq, Hash, Debug, TypedBuilder, Getters, CopyGetters)] -pub struct Locator { - /// Determines which fetcher is used to download this project. - #[getset(get_copy = "pub")] - fetcher: Fetcher, - - /// Specifies the organization ID to which this project is namespaced. - #[builder(default, setter(strip_option))] - #[getset(get_copy = "pub")] - org_id: Option, - - /// Specifies the unique identifier for the project by fetcher. - /// - /// For example, the `git` fetcher fetching a github project - /// uses a value in the form of `{user_name}/{project_name}`. - #[builder(setter(transform = |project: impl ToString| project.to_string()))] - #[getset(get = "pub")] - project: String, - - /// Specifies the version for the project by fetcher. - /// - /// For example, the `git` fetcher fetching a github project - /// uses a value in the form of `{git_sha}` or `{git_tag}`, - /// and the fetcher disambiguates. - #[builder(default, setter(transform = |revision: impl ToString| Some(revision.to_string())))] - #[getset(get = "pub")] - revision: Option, -} - -impl Locator { - /// Parse a `Locator`. - /// - /// The input string must be in one of the following forms: - /// - `{fetcher}+{project}` - /// - `{fetcher}+{project}$` - /// - `{fetcher}+{project}${revision}` - /// - /// Projects may also be namespaced to a specific organization; - /// in such cases the organization ID is at the start of the `{project}` field - /// separated by a slash. The ID can be any non-negative integer. - /// This yields the following formats: - /// - `{fetcher}+{org_id}/{project}` - /// - `{fetcher}+{org_id}/{project}$` - /// - `{fetcher}+{org_id}/{project}${revision}` - /// - /// This parse function is based on the function used in FOSSA Core for maximal compatibility. - pub fn parse(locator: &str) -> Result { - lazy_static! { - static ref RE: Regex = Regex::new( - r"^(?:(?P[a-z-]+)\+|)(?P[^$]+)(?:\$|)(?P.+|)$" - ) - .expect("Locator parsing expression must compile"); - } - - let mut captures = RE.captures_iter(locator); - let capture = captures.next().ok_or_else(|| ParseError::Syntax { - input: locator.to_string(), - })?; - - let fetcher = - capture - .name("fetcher") - .map(|m| m.as_str()) - .ok_or_else(|| ParseError::Field { - input: locator.to_owned(), - field: "fetcher".to_string(), - })?; - - let fetcher = Fetcher::try_from(fetcher).map_err(|error| ParseError::Fetcher { - input: locator.to_owned(), - fetcher: fetcher.to_string(), - error, - })?; - - let project = capture - .name("project") - .map(|m| m.as_str().to_owned()) - .ok_or_else(|| ParseError::Field { - input: locator.to_owned(), - field: "project".to_string(), - })?; - - let revision = capture.name("revision").map(|m| m.as_str()).and_then(|s| { - if s.is_empty() { - None - } else { - Some(s.to_string()) - } - }); - - match parse_org_project(&project) { - Ok((org_id @ Some(_), project)) => Ok(Locator { - fetcher, - org_id, - project: String::from(project), - revision, - }), - Ok((org_id @ None, _)) => Ok(Locator { - fetcher, - org_id, - project, - revision, - }), - Err(error) => Err(Error::Parse(ParseError::Project { - input: locator.to_owned(), - project, - error, - })), - } - } - - /// Converts the locator into a [`PackageLocator`] by discarding the `revision` component. - /// Equivalent to the `From` implementation, but offered as a method for convenience. - pub fn into_package(self) -> PackageLocator { - self.into() - } -} - -impl Display for Locator { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let fetcher = &self.fetcher; - write!(f, "{fetcher}+")?; - - let project = &self.project; - if let Some(org_id) = &self.org_id { - write!(f, "{org_id}/")?; - } - write!(f, "{project}")?; - - if let Some(revision) = &self.revision { - write!(f, "${revision}")?; - } - - Ok(()) - } -} - -impl<'de> Deserialize<'de> for Locator { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let raw = String::deserialize(deserializer)?; - Locator::parse(&raw).map_err(serde::de::Error::custom) - } -} - -impl Serialize for Locator { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.to_string().serialize(serializer) - } -} - -/// A [`Locator`] specialized to not include the `revision` component. -/// -/// Any [`Locator`] may be converted to a `PackageLocator` by simply discarding the `revision` component. -/// To create a [`Locator`] from a `PackageLocator`, the value for `revision` must be provided; see [`Locator`] for details. -#[derive(Clone, Eq, PartialEq, Hash, Debug, TypedBuilder)] -pub struct PackageLocator { - /// Determines which fetcher is used to download this dependency - /// from the internet. - fetcher: Fetcher, - - /// Specifies the organization ID to which this project is namespaced. - org_id: Option, - - /// Specifies the unique identifier for the project by fetcher. - /// - /// For example, the `git` fetcher fetching a github project - /// uses a value in the form of `{user_name}/{project_name}`. - #[builder(setter(transform = |project: impl ToString| project.to_string()))] - project: String, -} - -impl PackageLocator { - /// Parse a `PackageLocator`. - /// - /// The input string must be in one of the following forms: - /// - `{fetcher}+{project}` - /// - `{fetcher}+{project}$` - /// - `{fetcher}+{project}${revision}` - /// - /// Projects may also be namespaced to a specific organization; - /// in such cases the organization ID is at the start of the `{project}` field - /// separated by a slash. The ID can be any non-negative integer. - /// This yields the following formats: - /// - `{fetcher}+{org_id}/{project}` - /// - `{fetcher}+{org_id}/{project}$` - /// - `{fetcher}+{org_id}/{project}${revision}` - /// - /// This parse function is based on the function used in FOSSA Core for maximal compatibility. - /// - /// This implementation ignores the `revision` segment if it exists. If this is not preferred, use [`Locator`] instead. - pub fn parse(locator: &str) -> Result { - let full = Locator::parse(locator)?; - Ok(Self { - fetcher: full.fetcher, - org_id: full.org_id, - project: full.project, - }) - } - - /// Promote a `PackageLocator` to a [`Locator`] by providing the value to use for the `revision` component. - pub fn promote(self, revision: Option) -> Locator { - Locator { - fetcher: self.fetcher, - org_id: self.org_id, - project: self.project, - revision, - } - } -} - -impl Display for PackageLocator { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let converted = Locator::from(self); - write!(f, "{converted}") - } -} - -impl<'de> Deserialize<'de> for PackageLocator { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let raw = String::deserialize(deserializer)?; - PackageLocator::parse(&raw).map_err(serde::de::Error::custom) - } -} - -impl Serialize for PackageLocator { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.to_string().serialize(serializer) - } -} - -impl From for PackageLocator { - fn from(full: Locator) -> Self { - Self { - fetcher: full.fetcher, - org_id: full.org_id, - project: full.project, - } - } -} - -impl From for Locator { - fn from(package: PackageLocator) -> Self { - Self { - fetcher: package.fetcher, - org_id: package.org_id, - project: package.project, - revision: None, - } - } -} - -impl From<&PackageLocator> for Locator { - fn from(package: &PackageLocator) -> Self { - package.clone().into() - } -} +pub use locator::*; +pub use locator_package::*; +pub use locator_strict::*; /// Optionally parse an org ID and trimmed project out of a project string. fn parse_org_project(project: &str) -> Result<(Option, &str), ProjectParseError> { @@ -337,4 +54,35 @@ fn parse_org_project(project: &str) -> Result<(Option, &str), ProjectPars } #[cfg(test)] -mod test; +mod tests { + use itertools::izip; + + use super::*; + + #[test] + fn parses_org_project() { + let orgs = [0usize, 1, 9809572]; + let names = ["name", "name/foo"]; + + for (org, name) in izip!(orgs, names) { + let test = format!("{org}/{name}"); + let Ok((Some(org_id), project)) = parse_org_project(&test) else { + panic!("must parse '{test}'") + }; + assert_eq!(org_id, org, "'org_id' must match in '{test}'"); + assert_eq!(project, name, "'project' must match in '{test}"); + } + } + + #[test] + fn parses_org_project_no_org() { + let names = ["/name/foo", "/name", "abcd/1234/name", "1abc2/name"]; + for test in names { + let Ok((org_id, project)) = parse_org_project(test) else { + panic!("must parse '{test}'") + }; + assert_eq!(org_id, None, "'org_id' must be None in '{test}'"); + assert_eq!(project, test, "'project' must match in '{test}"); + } + } +} diff --git a/src/locator.rs b/src/locator.rs new file mode 100644 index 0000000..b29f920 --- /dev/null +++ b/src/locator.rs @@ -0,0 +1,617 @@ +use std::{cmp::Ordering, fmt::Display}; + +use getset::{CopyGetters, Getters}; +use lazy_static::lazy_static; +use regex::Regex; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use typed_builder::TypedBuilder; + +use crate::{parse_org_project, Error, Fetcher, PackageLocator, ParseError, StrictLocator}; + +/// Core, and most services that interact with Core, +/// refer to open source packages via the `Locator` type. +/// +/// This type is nearly universally rendered to a string +/// before being serialized to the database or sent over the network. +/// +/// This type represents a _validly-constructed_ `Locator`, but does not +/// validate whether a `Locator` is actually valid. This means that a +/// given `Locator` is guaranteed to be correctly formatted data, +/// but that the actual repository or revision to which the `Locator` +/// refers is _not_ guaranteed to exist or be accessible. +/// Currently the canonical method for validating whether a given `Locator` is +/// accessible is to run it through the Core fetcher system. +/// +/// For more information on the background of `Locator` and fetchers generally, +/// FOSSA employees may refer to +/// [Fetchers and Locators](https://go/fetchers-doc). +#[derive(Clone, Eq, PartialEq, Hash, Debug, TypedBuilder, Getters, CopyGetters, JsonSchema)] +pub struct Locator { + /// Determines which fetcher is used to download this project. + #[getset(get_copy = "pub")] + fetcher: Fetcher, + + /// Specifies the organization ID to which this project is namespaced. + #[builder(default, setter(strip_option))] + #[getset(get_copy = "pub")] + org_id: Option, + + /// Specifies the unique identifier for the project by fetcher. + /// + /// For example, the `git` fetcher fetching a github project + /// uses a value in the form of `{user_name}/{project_name}`. + #[builder(setter(transform = |project: impl ToString| project.to_string()))] + #[getset(get = "pub")] + project: String, + + /// Specifies the version for the project by fetcher. + /// + /// For example, the `git` fetcher fetching a github project + /// uses a value in the form of `{git_sha}` or `{git_tag}`, + /// and the fetcher disambiguates. + #[builder(default, setter(transform = |revision: impl ToString| Some(revision.to_string())))] + #[getset(get = "pub")] + revision: Option, +} + +impl Locator { + /// Parse a `Locator`. + /// + /// The input string must be in one of the following forms: + /// - `{fetcher}+{project}` + /// - `{fetcher}+{project}$` + /// - `{fetcher}+{project}${revision}` + /// + /// Projects may also be namespaced to a specific organization; + /// in such cases the organization ID is at the start of the `{project}` field + /// separated by a slash. The ID can be any non-negative integer. + /// This yields the following formats: + /// - `{fetcher}+{org_id}/{project}` + /// - `{fetcher}+{org_id}/{project}$` + /// - `{fetcher}+{org_id}/{project}${revision}` + /// + /// This parse function is based on the function used in FOSSA Core for maximal compatibility. + pub fn parse(locator: &str) -> Result { + lazy_static! { + static ref RE: Regex = Regex::new( + r"^(?:(?P[a-z-]+)\+|)(?P[^$]+)(?:\$|)(?P.+|)$" + ) + .expect("Locator parsing expression must compile"); + } + + let mut captures = RE.captures_iter(locator); + let capture = captures.next().ok_or_else(|| ParseError::Syntax { + input: locator.to_string(), + })?; + + let fetcher = + capture + .name("fetcher") + .map(|m| m.as_str()) + .ok_or_else(|| ParseError::Field { + input: locator.to_owned(), + field: "fetcher".to_string(), + })?; + + let fetcher = Fetcher::try_from(fetcher).map_err(|error| ParseError::Fetcher { + input: locator.to_owned(), + fetcher: fetcher.to_string(), + error, + })?; + + let project = capture + .name("project") + .map(|m| m.as_str().to_owned()) + .ok_or_else(|| ParseError::Field { + input: locator.to_owned(), + field: "project".to_string(), + })?; + + let revision = capture.name("revision").map(|m| m.as_str()).and_then(|s| { + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }); + + match parse_org_project(&project) { + Ok((org_id @ Some(_), project)) => Ok(Locator { + fetcher, + org_id, + project: String::from(project), + revision, + }), + Ok((org_id @ None, _)) => Ok(Locator { + fetcher, + org_id, + project, + revision, + }), + Err(error) => Err(Error::Parse(ParseError::Project { + input: locator.to_owned(), + project, + error, + })), + } + } + + /// Promote a `Locator` to a [`StrictLocator`] by providing the default value to use + /// for the `revision` component, if one is not specified in the locator already. + /// + /// The `ToString` implementation is lazily evaluated if the locator doesn't already contain a revision. + pub fn promote_strict(self, revision: impl ToString) -> StrictLocator { + let locator = StrictLocator::builder() + .fetcher(self.fetcher) + .project(self.project) + .revision(self.revision.unwrap_or_else(|| revision.to_string())); + + match self.org_id { + None => locator.build(), + Some(org_id) => locator.org_id(org_id).build(), + } + } + + /// Promote a `Locator` to a [`StrictLocator`] by providing the default value to use + /// for the `revision` component, if one is not specified in the locator already. + /// + /// The revision is lazily evaluated if the locator doesn't already contain a revision. + pub fn promote_strict_with String>(self, revision: F) -> StrictLocator { + let locator = StrictLocator::builder() + .fetcher(self.fetcher) + .project(self.project) + .revision(self.revision.unwrap_or_else(revision)); + + match self.org_id { + None => locator.build(), + Some(org_id) => locator.org_id(org_id).build(), + } + } + + /// Converts the locator into a [`PackageLocator`] by discarding the `revision` component. + /// Equivalent to the `From` implementation, but offered as a method for convenience. + pub fn into_package(self) -> PackageLocator { + self.into() + } + + /// Explodes the locator into its (owned) parts. + /// Used for conversions without cloning. + pub(crate) fn explode(self) -> (Fetcher, Option, String, Option) { + (self.fetcher, self.org_id, self.project, self.revision) + } +} + +impl Ord for Locator { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self.fetcher.cmp(&other.fetcher) { + Ordering::Equal => {} + ord => return ord, + } + match alphanumeric_sort::compare_str(&self.project, &other.project) { + Ordering::Equal => {} + ord => return ord, + } + match (&self.revision, &other.revision) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => alphanumeric_sort::compare_str(a, b), + } + } +} + +impl PartialOrd for Locator { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Display for Locator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let fetcher = &self.fetcher; + write!(f, "{fetcher}+")?; + + let project = &self.project; + if let Some(org_id) = &self.org_id { + write!(f, "{org_id}/")?; + } + write!(f, "{project}")?; + + if let Some(revision) = &self.revision { + write!(f, "${revision}")?; + } + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for Locator { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Locator::parse(&raw).map_err(serde::de::Error::custom) + } +} + +impl Serialize for Locator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl From for Locator { + fn from(package: PackageLocator) -> Self { + let (fetcher, org_id, project) = package.explode(); + Self { + fetcher, + org_id, + project, + revision: None, + } + } +} + +impl From<&PackageLocator> for Locator { + fn from(package: &PackageLocator) -> Self { + Self { + fetcher: package.fetcher(), + org_id: package.org_id(), + project: package.project().clone(), + revision: None, + } + } +} + +impl From for Locator { + fn from(strict: StrictLocator) -> Self { + let (fetcher, org_id, project, revision) = strict.explode(); + Self { + fetcher, + org_id, + project, + revision: Some(revision), + } + } +} + +impl From<&StrictLocator> for Locator { + fn from(strict: &StrictLocator) -> Self { + Self { + fetcher: strict.fetcher(), + org_id: strict.org_id(), + project: strict.project().clone(), + revision: Some(strict.revision().clone()), + } + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use itertools::{izip, Itertools}; + use pretty_assertions::assert_eq; + use proptest::prelude::*; + use serde::Deserialize; + use strum::IntoEnumIterator; + + use super::*; + + #[test] + fn parse_render_successful() { + let input = "git+github.com/foo/bar"; + let parsed = Locator::parse(input).expect("must parse locator"); + let expected = Locator::builder() + .fetcher(Fetcher::Git) + .project("github.com/foo/bar") + .build(); + assert_eq!(expected, parsed); + assert_eq!(&parsed.to_string(), input); + + let input = "git+github.com/foo/bar"; + let parsed = Locator::parse(input).expect("must parse locator"); + let expected = Locator::builder() + .fetcher(Fetcher::Git) + .project("github.com/foo/bar") + .build(); + assert_eq!(expected, parsed); + assert_eq!(&parsed.to_string(), input); + + let input = "git+github.com/foo/bar$abcd"; + let parsed = Locator::parse(input).expect("must parse locator"); + let expected = Locator::builder() + .fetcher(Fetcher::Git) + .project("github.com/foo/bar") + .revision("abcd") + .build(); + assert_eq!(expected, parsed); + assert_eq!(&parsed.to_string(), input); + } + + #[test] + fn parse_invalid_fetcher() { + let input = "foo+github.com/foo/bar"; + let parsed = Locator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Fetcher { .. }))); + } + + #[test] + fn parse_missing_project() { + let input = "git+"; + let parsed = Locator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_invalid_syntax() { + let input = ""; + let parsed = Locator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Syntax { .. }))); + + let input = "git+$"; + let parsed = Locator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_with_org() { + let fetchers = Fetcher::iter().map(|fetcher| format!("{fetcher}")); + let orgs = [0usize, 1, 1234, 2385028, 19847938492847928]; + let projects = ["github.com/foo/bar", "some-name"]; + let revisions = ["", "$", "$1", "$1234abcd1234"]; + + for (fetcher, org, project, revision) in izip!(fetchers, orgs, projects, revisions) { + let input = format!("{fetcher}+{org}/{project}{revision}"); + let Ok(parsed) = Locator::parse(&input) else { + panic!("must parse '{input}'") + }; + + assert_eq!( + parsed.fetcher().to_string(), + fetcher, + "'fetcher' in '{input}' must match" + ); + assert_eq!( + parsed.org_id(), + Some(org), + "'org_id' in '{input}' must match" + ); + assert_eq!( + parsed.project().as_str(), + project, + "'project' in '{input}' must match" + ); + + let revision = if revision.is_empty() || revision == "$" { + None + } else { + Some(revision) + }; + assert_eq!( + parsed.revision().as_ref().map(|r| r.as_str()), + revision, + "'revision' in '{input}' must match", + ); + } + } + + #[test] + fn render_with_org() { + let locator = Locator::builder() + .fetcher(Fetcher::Custom) + .org_id(1234) + .project("foo/bar") + .revision("123abc") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+1234/foo/bar$123abc", rendered); + + let package_only = locator.into_package(); + let rendered = package_only.to_string(); + assert_eq!("custom+1234/foo/bar", rendered); + } + + #[test] + fn render_with_revision() { + let locator = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo/bar") + .revision("123abc") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+foo/bar$123abc", rendered); + } + + #[test] + fn render_project() { + let locator = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo/bar") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+foo/bar", rendered); + } + + #[test] + fn roundtrip_serialization() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .revision("bar") + .org_id(1) + .build(); + + let serialized = serde_json::to_string(&input).expect("must serialize"); + let deserialized = serde_json::from_str(&serialized).expect("must deserialize"); + assert_eq!(input, deserialized); + } + + #[test] + fn serde_deserialization() { + #[derive(Debug, Deserialize, PartialEq)] + struct Test { + locator: Locator, + } + + let input = r#"{ "locator": "custom+1/foo$bar" }"#; + let expected = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .revision("bar") + .org_id(1) + .build(); + let expected = Test { locator: expected }; + + let deserialized = serde_json::from_str(input).expect("must deserialize"); + assert_eq!(expected, deserialized, "deserialize {input}"); + } + + #[test] + fn demotes() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .revision("abcd") + .build(); + + let expected = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .build(); + let demoted = input.clone().into_package(); + assert_eq!(expected, demoted, "demote {input}"); + } + + #[test] + fn promotes_strict() { + let input = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .build(); + + let expected = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .revision("bar") + .build(); + let promoted = input.clone().promote_strict("bar"); + assert_eq!(expected, promoted, "promote {input}"); + } + + #[test] + fn ordering() { + let locators = vec![ + "git+github.com/foo/bar", + "git+github.com/foo/bar$1234", + "custom+baz$1234", + "custom+1/bam$1234", + "custom+2/bam$1234", + "custom+2/bam", + ] + .into_iter() + .map(Locator::parse) + .collect::, _>>() + .expect("must parse locators"); + + let expected = vec![ + "custom+1/bam$1234", + "custom+2/bam$1234", + "custom+2/bam", + "custom+baz$1234", + "git+github.com/foo/bar$1234", + "git+github.com/foo/bar", + ]; + let sorted = locators + .iter() + .sorted() + .map(Locator::to_string) + .collect_vec(); + assert_eq!(expected, sorted, "sort {locators:?}"); + } + + /// Regular expression that matches any unicode string that is: + /// - Prefixed with `git+` + /// - Contains at least one character that is not a control character and not the literal `$` + /// - Contains a literal `$` + /// - Contains at least one character that is not a control character and not the literal `$` + const VALID_INPUTS_GIT: &str = r"git\+[^\pC$]+\$[^\pC$]+"; + + proptest! { + /// Tests randomly generated strings that match the provided regular expression against the parser. + /// Validates that the parser succeeds by converting the locator back into a string again. + #[test] + fn parses_arbitrary_locator_git(input in VALID_INPUTS_GIT) { + let parsed = Locator::parse(&input).expect("must parse locator"); + assert_eq!(parsed.to_string(), input); + } + } + + /// Regular expression that matches any unicode string that is: + /// - Prefixed with `git+` + /// - Contains a literal `/` + /// - Contains zero or more digits + /// - Contains a literal `/` + /// - Contains at least one character that is not a control character and not the literal `$` + /// - Contains a literal `$` + /// - Contains at least one character that is not a control character and not the literal `$` + const VALID_INPUTS_GIT_WITH_ORG: &str = r"git\+/\d*/[^\pC$]+\$[^\pC$]+"; + + proptest! { + /// Tests randomly generated strings that match the provided regular expression against the parser. + /// Validates that the parser succeeds by converting the locator back into a string again. + #[test] + fn parses_arbitrary_locator_git_with_org(input in VALID_INPUTS_GIT_WITH_ORG) { + let parsed = Locator::parse(&input).expect("must parse locator"); + assert_eq!(parsed.to_string(), input); + } + } + + /// Regular expression that matches any unicode string that is: + /// - Prefixed with `custom+` + /// - Contains at least one character that is not a control character and not the literal `$` + /// - Contains a literal `$` + /// - Contains at least one character that is not a control character and not the literal `$` + const VALID_INPUTS_CUSTOM: &str = r"custom\+[^\pC$]+\$[^\pC$]+"; + + proptest! { + /// Tests randomly generated strings that match the provided regular expression against the parser. + /// Validates that the parser succeeds by converting the locator back into a string again. + #[test] + fn parses_arbitrary_locator_custom(input in VALID_INPUTS_CUSTOM) { + let parsed = Locator::parse(&input).expect("must parse locator"); + assert_eq!(parsed.to_string(), input); + } + } + + /// Regular expression that matches any unicode string that is: + /// - Prefixed with `custom+` + /// - Contains a literal `/` + /// - Contains zero or more digits + /// - Contains a literal `/` + /// - Contains at least one character that is not a control character and not the literal `$` + /// - Contains a literal `$` + /// - Contains at least one character that is not a control character and not the literal `$` + const VALID_INPUTS_CUSTOM_WITH_ORG: &str = r"custom\+/\d*/[^\pC$]+\$[^\pC$]+"; + + proptest! { + /// Tests randomly generated strings that match the provided regular expression against the parser. + /// Validates that the parser succeeds by converting the locator back into a string again. + #[test] + fn parses_arbitrary_locator_custom_with_org(input in VALID_INPUTS_CUSTOM_WITH_ORG) { + let parsed = Locator::parse(&input).expect("must parse locator"); + assert_eq!(parsed.to_string(), input); + } + } +} diff --git a/src/locator_package.rs b/src/locator_package.rs new file mode 100644 index 0000000..61ff1d4 --- /dev/null +++ b/src/locator_package.rs @@ -0,0 +1,381 @@ +use std::{cmp::Ordering, fmt::Display}; + +use getset::{CopyGetters, Getters}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use typed_builder::TypedBuilder; + +use crate::{Error, Fetcher, Locator, StrictLocator}; + +/// A [`Locator`] specialized to not include the `revision` component. +/// +/// Any [`Locator`] may be converted to a `PackageLocator` by simply discarding the `revision` component. +/// To create a [`Locator`] from a `PackageLocator`, the value for `revision` must be provided; see [`Locator`] for details. +#[derive(Clone, Eq, PartialEq, Hash, Debug, TypedBuilder, Getters, CopyGetters, JsonSchema)] +pub struct PackageLocator { + /// Determines which fetcher is used to download this project. + #[getset(get_copy = "pub")] + fetcher: Fetcher, + + /// Specifies the organization ID to which this project is namespaced. + #[builder(default, setter(strip_option))] + #[getset(get_copy = "pub")] + org_id: Option, + + /// Specifies the unique identifier for the project by fetcher. + /// + /// For example, the `git` fetcher fetching a github project + /// uses a value in the form of `{user_name}/{project_name}`. + #[builder(setter(transform = |project: impl ToString| project.to_string()))] + #[getset(get = "pub")] + project: String, +} + +impl PackageLocator { + /// Parse a `PackageLocator`. + /// + /// The input string must be in one of the following forms: + /// - `{fetcher}+{project}` + /// - `{fetcher}+{project}$` + /// - `{fetcher}+{project}${revision}` + /// + /// Projects may also be namespaced to a specific organization; + /// in such cases the organization ID is at the start of the `{project}` field + /// separated by a slash. The ID can be any non-negative integer. + /// This yields the following formats: + /// - `{fetcher}+{org_id}/{project}` + /// - `{fetcher}+{org_id}/{project}$` + /// - `{fetcher}+{org_id}/{project}${revision}` + /// + /// This implementation ignores the `revision` segment if it exists. If this is not preferred, use [`Locator`] instead. + pub fn parse(locator: &str) -> Result { + let full = Locator::parse(locator)?; + Ok(full.into_package()) + } + + /// Promote a `PackageLocator` to a [`Locator`] by providing the value to use for the `revision` component. + pub fn promote(self, revision: Option) -> Locator { + let locator = Locator::builder() + .fetcher(self.fetcher) + .project(self.project); + + match (self.org_id, revision) { + (None, None) => locator.build(), + (None, Some(revision)) => locator.revision(revision).build(), + (Some(org_id), None) => locator.org_id(org_id).build(), + (Some(org_id), Some(revision)) => locator.org_id(org_id).revision(revision).build(), + } + } + + /// Promote a `PackageLocator` to a [`StrictLocator`] by providing the value to use for the `revision` component. + pub fn promote_strict(self, revision: impl ToString) -> StrictLocator { + let locator = StrictLocator::builder() + .fetcher(self.fetcher) + .project(self.project) + .revision(revision); + + match self.org_id { + None => locator.build(), + Some(org_id) => locator.org_id(org_id).build(), + } + } + + /// Explodes the locator into its (owned) parts. + /// Used for conversions without cloning. + pub(crate) fn explode(self) -> (Fetcher, Option, String) { + (self.fetcher, self.org_id, self.project) + } +} + +impl Ord for PackageLocator { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self.fetcher.cmp(&other.fetcher) { + Ordering::Equal => {} + ord => return ord, + } + alphanumeric_sort::compare_str(&self.project, &other.project) + } +} + +impl PartialOrd for PackageLocator { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Display for PackageLocator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let converted = Locator::from(self); + write!(f, "{converted}") + } +} + +impl<'de> Deserialize<'de> for PackageLocator { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + PackageLocator::parse(&raw).map_err(serde::de::Error::custom) + } +} + +impl Serialize for PackageLocator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl From for PackageLocator { + fn from(full: Locator) -> Self { + let (fetcher, org_id, project, _) = full.explode(); + Self { + fetcher, + org_id, + project, + } + } +} + +impl From<&Locator> for PackageLocator { + fn from(full: &Locator) -> Self { + Self { + fetcher: full.fetcher(), + org_id: full.org_id(), + project: full.project().clone(), + } + } +} + +impl From for PackageLocator { + fn from(strict: StrictLocator) -> Self { + let (fetcher, org_id, project, _) = strict.explode(); + Self { + fetcher, + org_id, + project, + } + } +} + +impl From<&StrictLocator> for PackageLocator { + fn from(strict: &StrictLocator) -> Self { + Self { + fetcher: strict.fetcher(), + org_id: strict.org_id(), + project: strict.project().clone(), + } + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use itertools::{izip, Itertools}; + use pretty_assertions::assert_eq; + use serde::Deserialize; + use strum::IntoEnumIterator; + + use crate::ParseError; + + use super::*; + + #[test] + fn parse_render_successful() { + let input = "git+github.com/foo/bar"; + let parsed = PackageLocator::parse(input).expect("must parse locator"); + let expected = PackageLocator::builder() + .fetcher(Fetcher::Git) + .project("github.com/foo/bar") + .build(); + assert_eq!(expected, parsed); + assert_eq!(&parsed.to_string(), input); + } + + #[test] + fn parse_drops_revision() { + let input = "git+github.com/foo/bar$abcd"; + let parsed = PackageLocator::parse(input).expect("must parse locator"); + let expected = PackageLocator::builder() + .fetcher(Fetcher::Git) + .project("github.com/foo/bar") + .build(); + assert_eq!(expected, parsed); + } + + #[test] + fn parse_invalid_fetcher() { + let input = "foo+github.com/foo/bar"; + let parsed = PackageLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Fetcher { .. }))); + } + + #[test] + fn parse_missing_project() { + let input = "git+"; + let parsed = PackageLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_invalid_syntax() { + let input = ""; + let parsed = PackageLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Syntax { .. }))); + + let input = "git+$"; + let parsed = PackageLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_with_org() { + let fetchers = Fetcher::iter().map(|fetcher| format!("{fetcher}")); + let orgs = [0usize, 1, 1234, 2385028, 19847938492847928]; + let projects = ["github.com/foo/bar", "some-name"]; + let revisions = ["", "$", "$1", "$1234abcd1234"]; + + for (fetcher, org, project, revision) in izip!(fetchers, orgs, projects, revisions) { + let input = format!("{fetcher}+{org}/{project}{revision}"); + let Ok(parsed) = PackageLocator::parse(&input) else { + panic!("must parse '{input}'") + }; + + assert_eq!( + parsed.fetcher().to_string(), + fetcher, + "'fetcher' in '{input}' must match" + ); + assert_eq!( + parsed.org_id(), + Some(org), + "'org_id' in '{input}' must match" + ); + assert_eq!( + parsed.project().as_str(), + project, + "'project' in '{input}' must match" + ); + } + } + + #[test] + fn render_with_org() { + let locator = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .org_id(1234) + .project("foo/bar") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+1234/foo/bar", rendered); + } + + #[test] + fn roundtrip_serialization() { + let input = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .build(); + + let serialized = serde_json::to_string(&input).expect("must serialize"); + let deserialized = serde_json::from_str(&serialized).expect("must deserialize"); + assert_eq!(input, deserialized); + } + + #[test] + fn serde_deserialization() { + #[derive(Debug, Deserialize, PartialEq)] + struct Test { + locator: PackageLocator, + } + + let input = r#"{ "locator": "custom+1/foo" }"#; + let locator = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .build(); + + let expected = Test { locator }; + let deserialized = serde_json::from_str(input).expect("must deserialize"); + assert_eq!(expected, deserialized, "deserialize {input}"); + } + + #[test] + fn promotes_locator() { + let input = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .build(); + + let expected = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .build(); + let promoted = input.clone().promote(None); + assert_eq!(expected, promoted, "promote {input}"); + + let expected = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .revision("bar") + .build(); + let promoted = input.clone().promote(Some(String::from("bar"))); + assert_eq!(expected, promoted, "promote {input}"); + } + + #[test] + fn promotes_strict() { + let input = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .build(); + + let expected = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .revision("bar") + .build(); + let promoted = input.clone().promote_strict("bar"); + assert_eq!(expected, promoted, "promote {input}"); + } + + #[test] + fn ordering() { + let locators = vec![ + "git+github.com/foo/bar", + "git+github.com/foo/bar$1234", + "custom+baz$1234", + "custom+1/bam$1234", + "custom+2/bam$1234", + ] + .into_iter() + .map(PackageLocator::parse) + .collect::, _>>() + .expect("must parse locators"); + + let expected = vec![ + "custom+1/bam", + "custom+2/bam", + "custom+baz", + "git+github.com/foo/bar", + "git+github.com/foo/bar", + ]; + let sorted = locators + .iter() + .sorted() + .map(PackageLocator::to_string) + .collect_vec(); + assert_eq!(expected, sorted, "sort {locators:?}"); + } +} diff --git a/src/locator_strict.rs b/src/locator_strict.rs new file mode 100644 index 0000000..861b582 --- /dev/null +++ b/src/locator_strict.rs @@ -0,0 +1,363 @@ +use std::{cmp::Ordering, fmt::Display}; + +use getset::{CopyGetters, Getters}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use typed_builder::TypedBuilder; + +use crate::{Error, Fetcher, Locator, PackageLocator, ParseError}; + +/// A [`Locator`] specialized to **require** the `revision` component. +#[derive(Clone, Eq, PartialEq, Hash, Debug, TypedBuilder, Getters, CopyGetters, JsonSchema)] +pub struct StrictLocator { + /// Determines which fetcher is used to download this project. + #[getset(get_copy = "pub")] + fetcher: Fetcher, + + /// Specifies the organization ID to which this project is namespaced. + #[builder(default, setter(strip_option))] + #[getset(get_copy = "pub")] + org_id: Option, + + /// Specifies the unique identifier for the project by fetcher. + /// + /// For example, the `git` fetcher fetching a github project + /// uses a value in the form of `{user_name}/{project_name}`. + #[builder(setter(transform = |project: impl ToString| project.to_string()))] + #[getset(get = "pub")] + project: String, + + /// Specifies the version for the project by fetcher. + /// + /// For example, the `git` fetcher fetching a github project + /// uses a value in the form of `{git_sha}` or `{git_tag}`, + /// and the fetcher disambiguates. + #[builder(setter(transform = |revision: impl ToString| revision.to_string()))] + #[getset(get = "pub")] + revision: String, +} + +impl StrictLocator { + /// Parse a `StrictLocator`. + /// + /// The input string must be in the following format: + /// ```ignore + /// {fetcher}+{project}${revision} + /// ``` + /// + /// Projects may also be namespaced to a specific organization; + /// in such cases the organization ID is at the start of the `{project}` field + /// separated by a slash. The ID can be any non-negative integer. + /// This yields the following format: + /// ```ignore + /// {fetcher}+{org_id}/{project}${revision} + /// ``` + pub fn parse(locator: &str) -> Result { + let (fetcher, org_id, project, revision) = Locator::parse(locator)?.explode(); + + let Some(revision) = revision else { + return Err(Error::Parse(ParseError::Field { + input: locator.to_owned(), + field: String::from("revision"), + })); + }; + + Ok(Self { + fetcher, + org_id, + project, + revision, + }) + } + + /// Converts the instance into a [`PackageLocator`] by discarding the `revision` component. + /// Equivalent to the `From` implementation, but offered as a method for convenience. + pub fn into_package(self) -> PackageLocator { + self.into() + } + + /// Converts the instance into a [`Locator`]. + /// Equivalent to the `From` implementation, but offered as a method for convenience. + pub fn into_locator(self) -> Locator { + self.into() + } + + /// Explodes the locator into its (owned) parts. + /// Used for conversions without cloning. + pub(crate) fn explode(self) -> (Fetcher, Option, String, String) { + (self.fetcher, self.org_id, self.project, self.revision) + } +} + +impl Ord for StrictLocator { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self.fetcher.cmp(&other.fetcher) { + Ordering::Equal => {} + ord => return ord, + } + match alphanumeric_sort::compare_str(&self.project, &other.project) { + Ordering::Equal => {} + ord => return ord, + } + alphanumeric_sort::compare_str(&self.revision, &other.revision) + } +} + +impl PartialOrd for StrictLocator { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Display for StrictLocator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let fetcher = &self.fetcher; + write!(f, "{fetcher}+")?; + + if let Some(org_id) = &self.org_id { + write!(f, "{org_id}/")?; + } + + let project = &self.project; + let revision = &self.revision; + write!(f, "{project}${revision}")?; + + Ok(()) + } +} + +impl<'de> Deserialize<'de> for StrictLocator { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + StrictLocator::parse(&raw).map_err(serde::de::Error::custom) + } +} + +impl Serialize for StrictLocator { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use itertools::{izip, Itertools}; + use pretty_assertions::assert_eq; + use serde::Deserialize; + use strum::IntoEnumIterator; + + use super::*; + + #[test] + fn parse_render_successful() { + let input = "git+github.com/foo/bar$abcd"; + let parsed = StrictLocator::parse(input).expect("must parse locator"); + let expected = StrictLocator::builder() + .fetcher(Fetcher::Git) + .project("github.com/foo/bar") + .revision("abcd") + .build(); + assert_eq!(expected, parsed); + assert_eq!(&parsed.to_string(), input); + } + + #[test] + fn parse_invalid_fetcher() { + let input = "foo+github.com/foo/bar"; + let parsed = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Fetcher { .. }))); + } + + #[test] + fn parse_missing_project() { + let input = "git+"; + let parsed = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_missing_revision() { + let input = "git+project"; + let parsed = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_invalid_syntax() { + let input = ""; + let parsed = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Syntax { .. }))); + + let input = "git+$"; + let parsed = StrictLocator::parse(input); + assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); + } + + #[test] + fn parse_with_org() { + let fetchers = Fetcher::iter().map(|fetcher| format!("{fetcher}")); + let orgs = [0usize, 1, 1234, 2385028, 19847938492847928]; + let projects = ["github.com/foo/bar", "some-name"]; + let revisions = ["1", "1234abcd1234"]; + + for (fetcher, org, project, revision) in izip!(fetchers, orgs, projects, revisions) { + let input = format!("{fetcher}+{org}/{project}${revision}"); + let Ok(parsed) = StrictLocator::parse(&input) else { + panic!("must parse '{input}'") + }; + + assert_eq!( + parsed.fetcher().to_string(), + fetcher, + "'fetcher' in '{input}' must match" + ); + assert_eq!( + parsed.org_id(), + Some(org), + "'org_id' in '{input}' must match" + ); + assert_eq!( + parsed.project().as_str(), + project, + "'project' in '{input}' must match" + ); + assert_eq!( + parsed.revision().as_str(), + revision, + "'revision' in '{input}' must match", + ); + } + } + + #[test] + fn render_with_org() { + let locator = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .org_id(1234) + .project("foo/bar") + .revision("123abc") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+1234/foo/bar$123abc", rendered); + } + + #[test] + fn render_with_revision() { + let locator = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo/bar") + .revision("123abc") + .build(); + + let rendered = locator.to_string(); + assert_eq!("custom+foo/bar$123abc", rendered); + } + + #[test] + fn roundtrip_serialization() { + let input = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .revision("bar") + .org_id(1) + .build(); + + let serialized = serde_json::to_string(&input).expect("must serialize"); + let deserialized = serde_json::from_str(&serialized).expect("must deserialize"); + assert_eq!(input, deserialized); + } + + #[test] + fn serde_deserialization() { + #[derive(Debug, Deserialize, PartialEq)] + struct Test { + locator: StrictLocator, + } + + let input = r#"{ "locator": "custom+1/foo$bar" }"#; + let locator = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .revision("bar") + .org_id(1) + .build(); + + let expected = Test { locator }; + let deserialized = serde_json::from_str(input).expect("must deserialize"); + assert_eq!(expected, deserialized, "deserialize {input}"); + } + + #[test] + fn demotes_package() { + let input = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .revision("bar") + .org_id(1) + .build(); + + let expected = PackageLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .build(); + let demoted = input.clone().into_package(); + assert_eq!(expected, demoted, "demote {input}"); + } + + #[test] + fn demotes_locator() { + let input = StrictLocator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .revision("bar") + .org_id(1) + .build(); + + let expected = Locator::builder() + .fetcher(Fetcher::Custom) + .project("foo") + .org_id(1) + .revision("bar") + .build(); + let demoted = input.clone().into_locator(); + assert_eq!(expected, demoted, "demote {input}"); + } + + #[test] + fn ordering() { + let locators = vec![ + "git+github.com/foo/bar$abcd10", + "git+github.com/foo/bar$abcd11", + "custom+baz$1234", + "custom+1/bam$1234", + "custom+2/bam$1234", + ] + .into_iter() + .map(StrictLocator::parse) + .collect::, _>>() + .expect("must parse locators"); + + let expected = vec![ + "custom+1/bam$1234", + "custom+2/bam$1234", + "custom+baz$1234", + "git+github.com/foo/bar$abcd10", + "git+github.com/foo/bar$abcd11", + ]; + let sorted = locators + .iter() + .sorted() + .map(StrictLocator::to_string) + .collect_vec(); + assert_eq!(expected, sorted, "sort {locators:?}"); + } +} diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index 6148279..0000000 --- a/src/test.rs +++ /dev/null @@ -1,288 +0,0 @@ -use assert_matches::assert_matches; -use itertools::izip; -use proptest::prelude::*; -use serde::Deserialize; -use strum::IntoEnumIterator; - -use super::*; - -#[test] -fn parse_render_successful() { - let input = "git+github.com/foo/bar"; - let parsed = Locator::parse(input).expect("must parse locator"); - let expected = Locator::builder() - .fetcher(Fetcher::Git) - .project("github.com/foo/bar") - .build(); - assert_eq!(expected, parsed); - assert_eq!(&parsed.to_string(), input); - - let input = "git+github.com/foo/bar"; - let parsed = Locator::parse(input).expect("must parse locator"); - let expected = Locator::builder() - .fetcher(Fetcher::Git) - .project("github.com/foo/bar") - .build(); - assert_eq!(expected, parsed); - assert_eq!(&parsed.to_string(), input); - - let input = "git+github.com/foo/bar$abcd"; - let parsed = Locator::parse(input).expect("must parse locator"); - let expected = Locator::builder() - .fetcher(Fetcher::Git) - .project("github.com/foo/bar") - .revision("abcd") - .build(); - assert_eq!(expected, parsed); - assert_eq!(&parsed.to_string(), input); -} - -#[test] -fn parse_invalid_fetcher() { - let input = "foo+github.com/foo/bar"; - let parsed = Locator::parse(input); - assert_matches!(parsed, Err(Error::Parse(ParseError::Fetcher { .. }))); -} - -#[test] -fn parse_missing_project() { - let input = "git+"; - let parsed = Locator::parse(input); - assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); -} - -#[test] -fn parse_invalid_syntax() { - let input = ""; - let parsed = Locator::parse(input); - assert_matches!(parsed, Err(Error::Parse(ParseError::Syntax { .. }))); - - let input = "git+$"; - let parsed = Locator::parse(input); - assert_matches!(parsed, Err(Error::Parse(ParseError::Field { .. }))); -} - -#[test] -fn parse_with_org() { - let fetchers = Fetcher::iter().map(|fetcher| format!("{fetcher}")); - let orgs = [0usize, 1, 1234, 2385028, 19847938492847928]; - let projects = ["github.com/foo/bar", "some-name"]; - let revisions = ["", "$", "$1", "$1234abcd1234"]; - - for (fetcher, org, project, revision) in izip!(fetchers, orgs, projects, revisions) { - let input = format!("{fetcher}+{org}/{project}{revision}"); - let Ok(parsed) = Locator::parse(&input) else { - panic!("must parse '{input}'") - }; - - assert_eq!( - parsed.fetcher().to_string(), - fetcher, - "'fetcher' in '{input}' must match" - ); - assert_eq!( - parsed.org_id(), - Some(org), - "'org_id' in '{input}' must match" - ); - assert_eq!( - parsed.project().as_str(), - project, - "'project' in '{input}' must match" - ); - - let revision = if revision.is_empty() || revision == "$" { - None - } else { - Some(revision) - }; - assert_eq!( - parsed.revision().as_ref().map(|r| r.as_str()), - revision, - "'revision' in '{input}' must match", - ); - } -} - -#[test] -fn render_with_org() { - let locator = Locator::builder() - .fetcher(Fetcher::Custom) - .org_id(1234) - .project("foo/bar") - .revision("123abc") - .build(); - - let rendered = locator.to_string(); - assert_eq!("custom+1234/foo/bar$123abc", rendered); - - let package_only = locator.into_package(); - let rendered = package_only.to_string(); - assert_eq!("custom+1234/foo/bar", rendered); -} - -#[test] -fn render_with_revision() { - let locator = Locator::builder() - .fetcher(Fetcher::Custom) - .project("foo/bar") - .revision("123abc") - .build(); - - let rendered = locator.to_string(); - assert_eq!("custom+foo/bar$123abc", rendered); - - let package_only = locator.into_package(); - let rendered = package_only.to_string(); - assert_eq!("custom+foo/bar", rendered); -} - -#[test] -fn render_project() { - let locator = Locator::builder() - .fetcher(Fetcher::Custom) - .project("foo/bar") - .build(); - - let rendered = locator.to_string(); - assert_eq!("custom+foo/bar", rendered); - - let package_only = locator.into_package(); - let rendered = package_only.to_string(); - assert_eq!("custom+foo/bar", rendered); -} - -#[test] -fn roundtrip_serialization() { - let input = Locator::builder() - .fetcher(Fetcher::Custom) - .project("foo") - .revision("bar") - .org_id(1) - .build(); - - let serialized = serde_json::to_string(&input).expect("must serialize"); - let deserialized = serde_json::from_str(&serialized).expect("must deserialize"); - assert_eq!(input, deserialized); -} - -#[test] -fn serde_deserialization() { - #[derive(Debug, Deserialize, PartialEq)] - struct Test { - locator: Locator, - } - - let input = r#"{ "locator": "custom+1/foo$bar" }"#; - let expected = Locator::builder() - .fetcher(Fetcher::Custom) - .project("foo") - .revision("bar") - .org_id(1) - .build(); - let expected = Test { locator: expected }; - - let deserialized = serde_json::from_str(input).expect("must deserialize"); - assert_eq!(expected, deserialized, "deserialize {input}"); -} - -/// Regular expression that matches any unicode string that is: -/// - Prefixed with `git+` -/// - Contains at least one character that is not a control character and not the literal `$` -/// - Contains a literal `$` -/// - Contains at least one character that is not a control character and not the literal `$` -const VALID_INPUTS_GIT: &str = r"git\+[^\pC$]+\$[^\pC$]+"; - -proptest! { - /// Tests randomly generated strings that match the provided regular expression against the parser. - /// Validates that the parser succeeds by converting the locator back into a string again. - #[test] - fn parses_arbitrary_locator_git(input in VALID_INPUTS_GIT) { - let parsed = Locator::parse(&input).expect("must parse locator"); - assert_eq!(parsed.to_string(), input); - } -} - -/// Regular expression that matches any unicode string that is: -/// - Prefixed with `git+` -/// - Contains a literal `/` -/// - Contains zero or more digits -/// - Contains a literal `/` -/// - Contains at least one character that is not a control character and not the literal `$` -/// - Contains a literal `$` -/// - Contains at least one character that is not a control character and not the literal `$` -const VALID_INPUTS_GIT_WITH_ORG: &str = r"git\+/\d*/[^\pC$]+\$[^\pC$]+"; - -proptest! { - /// Tests randomly generated strings that match the provided regular expression against the parser. - /// Validates that the parser succeeds by converting the locator back into a string again. - #[test] - fn parses_arbitrary_locator_git_with_org(input in VALID_INPUTS_GIT_WITH_ORG) { - let parsed = Locator::parse(&input).expect("must parse locator"); - assert_eq!(parsed.to_string(), input); - } -} - -/// Regular expression that matches any unicode string that is: -/// - Prefixed with `custom+` -/// - Contains at least one character that is not a control character and not the literal `$` -/// - Contains a literal `$` -/// - Contains at least one character that is not a control character and not the literal `$` -const VALID_INPUTS_CUSTOM: &str = r"custom\+[^\pC$]+\$[^\pC$]+"; - -proptest! { - /// Tests randomly generated strings that match the provided regular expression against the parser. - /// Validates that the parser succeeds by converting the locator back into a string again. - #[test] - fn parses_arbitrary_locator_custom(input in VALID_INPUTS_CUSTOM) { - let parsed = Locator::parse(&input).expect("must parse locator"); - assert_eq!(parsed.to_string(), input); - } -} - -/// Regular expression that matches any unicode string that is: -/// - Prefixed with `custom+` -/// - Contains a literal `/` -/// - Contains zero or more digits -/// - Contains a literal `/` -/// - Contains at least one character that is not a control character and not the literal `$` -/// - Contains a literal `$` -/// - Contains at least one character that is not a control character and not the literal `$` -const VALID_INPUTS_CUSTOM_WITH_ORG: &str = r"custom\+/\d*/[^\pC$]+\$[^\pC$]+"; - -proptest! { - /// Tests randomly generated strings that match the provided regular expression against the parser. - /// Validates that the parser succeeds by converting the locator back into a string again. - #[test] - fn parses_arbitrary_locator_custom_with_org(input in VALID_INPUTS_CUSTOM_WITH_ORG) { - let parsed = Locator::parse(&input).expect("must parse locator"); - assert_eq!(parsed.to_string(), input); - } -} - -#[test] -fn parses_org_project() { - let orgs = [0usize, 1, 9809572]; - let names = ["name", "name/foo"]; - - for (org, name) in izip!(orgs, names) { - let test = format!("{org}/{name}"); - let Ok((Some(org_id), project)) = parse_org_project(&test) else { - panic!("must parse '{test}'") - }; - assert_eq!(org_id, org, "'org_id' must match in '{test}'"); - assert_eq!(project, name, "'project' must match in '{test}"); - } -} - -#[test] -fn parses_org_project_no_org() { - let names = ["/name/foo", "/name", "abcd/1234/name", "1abc2/name"]; - for test in names { - let Ok((org_id, project)) = parse_org_project(test) else { - panic!("must parse '{test}'") - }; - assert_eq!(org_id, None, "'org_id' must be None in '{test}'"); - assert_eq!(project, test, "'project' must match in '{test}"); - } -}