From 2eedf9a3c637812d158b89c03df8cc3dc32c200b Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 22 Jan 2026 17:27:11 -0600 Subject: [PATCH 1/6] (GH-538) Define `SemanticVersion` newtype Prior to this change, the `version` fields in various types for `dsc-lib` used `String`. In order to correctly present the JSON Schema for a semantic version, the library needs a reusable type. This change: - Defines the `SemanticVersion` newtype as a wrapper around the `semver::Version` type. - Implements methods for creating instances of the type from strings and version segments, mirroring the implementation for `semver::Version`. - Implements traits for comparing instances of the type to strings and `semver::Version`. - Defines the JSON Schema for the type with a validation pattern and VS Code vocabulary keywords for documentation. - Adds integration tests for the type's behavior. This change doesn't modify any existing code. Updating types in the library to use `SemanticVersion` must be done in a follow up change. --- Cargo.lock | 46 ++ Cargo.toml | 6 +- lib/dsc-lib/Cargo.toml | 9 +- lib/dsc-lib/locales/schemas.definitions.yaml | 36 + lib/dsc-lib/src/types/mod.rs | 2 + lib/dsc-lib/src/types/semantic_version.rs | 659 ++++++++++++++++++ lib/dsc-lib/tests/integration/types/mod.rs | 2 + .../integration/types/semantic_version.rs | 539 ++++++++++++++ 8 files changed, 1296 insertions(+), 3 deletions(-) create mode 100644 lib/dsc-lib/src/types/semantic_version.rs create mode 100644 lib/dsc-lib/tests/integration/types/semantic_version.rs diff --git a/Cargo.lock b/Cargo.lock index 2404d2c1f..cd7ef3fb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,6 +412,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-str" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e19f68b180ebff43d6d42005c4b5f046c65fcac28369ba8b3beaad633f9ec0" + [[package]] name = "convert_case" version = "0.7.1" @@ -764,6 +770,7 @@ dependencies = [ "cc", "chrono", "clap", + "const-str", "derive_builder", "dsc-lib-jsonschema", "dsc-lib-osinfo", @@ -775,6 +782,7 @@ dependencies = [ "murmurhash64", "num-traits", "path-absolutize", + "pretty_assertions", "regex", "rt-format", "rust-i18n", @@ -783,6 +791,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "test-case", "thiserror 2.0.17", "tokio", "tracing", @@ -2942,6 +2951,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3236,6 +3249,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "test-case-core", +] + [[package]] name = "test_group_resource" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c33ba1a1f..68f6e1a37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,6 +139,8 @@ chrono = { version = "0.4" } clap = { version = "4.5", features = ["derive"] } # dsc clap_complete = { version = "4.5" } +# dsc-lib +const-str = {version = "1.0" } # dsc, registry, dsc-lib-registry, sshdconfig crossterm = { version = "0.29" } # dsc @@ -255,8 +257,10 @@ cc = { version = "1.2" } tonic-prost-build = { version = "0.14" } # test-only dependencies -# dsc-lib-jsonschema +# dsc-lib-jsonschema, dsc-lib pretty_assertions = { version = "1.4.1" } +# dsc-lib +test-case = { version = "3.3" } # Workspace crates as dependencies dsc-lib = { path = "lib/dsc-lib" } diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml index f00193ec2..8add26ce0 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -12,6 +12,7 @@ base32 = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["alloc"] } clap = { workspace = true } +const-str = { workspace = true } derive_builder = { workspace = true } indicatif = { workspace = true } jsonschema = { workspace = true } @@ -28,7 +29,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } thiserror = { workspace = true } -semver = { workspace = true } +semver = { workspace = true, features = ["serde"] } tokio = { workspace = true, features = [ "io-util", "macros", @@ -38,7 +39,7 @@ tokio = { workspace = true, features = [ tracing = { workspace = true } tracing-indicatif = { workspace = true } tree-sitter = { workspace = true } -tree-sitter-rust = { workspace = true} +tree-sitter-rust = { workspace = true } uuid = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } @@ -52,6 +53,10 @@ tree-sitter-dscexpression = { workspace = true } [dev-dependencies] serde_yaml = { workspace = true } +# Helps review complex comparisons, like schemas +pretty_assertions = { workspace = true } +# Enables parameterized test cases +test-case = { workspace = true } [build-dependencies] cc = { workspace = true } diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index 2f50f02d6..907b99b23 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -33,3 +33,39 @@ schemas: a slash, like `Microsoft/OSInfo`. Type names may optionally include the group, area, and subarea segments to namespace the resource under the owner, like `Microsoft.Windows/Registry`. + + semver: + title: + en-us: Semantic version + description: + en-us: |- + Defines a valid semantic version (semver)as a string. + + For reference, see https://semver.org/ + markdownDescription: + en-us: |- + Defines a valid semantic version ([semver][01]) as a string. + + This value uses the [suggested regular expression][02] to validate whether the string is + valid semver. This is the same pattern, made multi-line for easier readability: + + ```regex + ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*) + (?:-( + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)) + *))? + (?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$ + ``` + + The first line matches the `major.minor.patch` components of the version. The middle + lines match the pre-release components. The last line matches the build metadata + component. + + [01]: https://semver.org/ + [02]: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + patternErrorMessage: + en-us: |- + Invalid value, must be a semantic version like `..`, such as `1.2.3`. + + The value may also include pre-release version information and build metadata. diff --git a/lib/dsc-lib/src/types/mod.rs b/lib/dsc-lib/src/types/mod.rs index b046d479b..7ca76be34 100644 --- a/lib/dsc-lib/src/types/mod.rs +++ b/lib/dsc-lib/src/types/mod.rs @@ -3,3 +3,5 @@ mod fully_qualified_type_name; pub use fully_qualified_type_name::FullyQualifiedTypeName; +mod semantic_version; +pub use semantic_version::SemanticVersion; diff --git a/lib/dsc-lib/src/types/semantic_version.rs b/lib/dsc-lib/src/types/semantic_version.rs new file mode 100644 index 000000000..f9fccaf91 --- /dev/null +++ b/lib/dsc-lib/src/types/semantic_version.rs @@ -0,0 +1,659 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{fmt::Display, ops::Deref, str::FromStr}; + +use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema}; +use rust_i18n::t; +use schemars::{json_schema, JsonSchema}; +use serde::{Deserialize, Serialize}; + +/// Defines a semantic version for use with DSC. +/// +/// This type is a wrapper around the [`semver::Version`] type that enables DSC to provide a more +/// complete JSON Schema for semantic versions. +/// +/// A semantic version adheres to the specification defined at [semver.org][01]. +/// +/// ## Syntax +/// +/// A semantic version is composed of the mandatory major, minor, and patch version segments. +/// Optionally, a semantic version may define the prerelease and build metadata segments. The +/// string defining a semantic version must not include any spacing characters. +/// +/// The major, minor, and patch version segments must parse as zero or a positive integer ([`u64`]). +/// These segments must not contain leading zeroes. The string `01.020.0034` isn't a valid semantic +/// version and should be written as `1.20.34` instead. +/// +/// The prerelease segment must be prefixed with a single hyphen (`-`) and define one or more +/// identifiers. The build metadata segment must be prefixed with a single plus sign (`+`) and +/// define one or more identifiers. +/// +/// An identifier for prerelease and build metadata segments must be a string consisting of only +/// ASCII alphanumeric characters underscores (regex `\w`). Identifiers in prerelease and build +/// metadata segments must be separated by a single period (`.`), like `rc.1` or +/// `dev.mac_os.sha123`. +/// +/// ### Syntax parsing examples +/// +/// Stable versions are defined as `..`: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let v1_2_3 = SemanticVersion::parse("1.2.3").unwrap(); +/// assert!(v1_2_3.major == 1); +/// assert!(v1_2_3.minor == 2); +/// assert!(v1_2_3.patch == 3); +/// ``` +/// +/// Omitting a version segment, specifying a non-digit for a version segment, and specifying a +/// leading zero for a version segment all cause parsing errors: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2").is_err()); +/// assert!(SemanticVersion::parse("1.x.3").is_err()); +/// assert!(SemanticVersion::parse("1.2.03").is_err()); +/// ``` +/// +/// Prerelease segments immediately follow the patch version segment and are prefixed with a hyphen +/// (`-`): +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let v1_2_3_rc = SemanticVersion::parse("1.2.3-rc").unwrap(); +/// assert!(v1_2_3_rc.pre.as_str() == "rc"); +/// ``` +/// +/// Build metadata immediately follows either the patch version segment or prerelease segment and +/// are prefixed with a plus sign (`+`): +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let v1_2_3_ci = SemanticVersion::parse("1.2.3+ci").unwrap(); +/// let v1_2_3_rc_ci = SemanticVersion::parse("1.2.3-rc+ci").unwrap(); +/// assert!(v1_2_3_ci.build.as_str() == "ci"); +/// assert!(v1_2_3_rc_ci.build.as_str() == "ci"); +/// ``` +/// +/// Putting build metadata before prerelease causes the intended prerelease segment to parse as +/// part of the build metadata: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let build_first = SemanticVersion::parse("1.2.3+ci-rc").unwrap(); +/// assert!(build_first.build.as_str() == "ci-rc"); +/// assert!(build_first.pre.as_str() == ""); +/// +/// let pre_first = SemanticVersion::parse("1.2.3-rc+ci").unwrap(); +/// assert!(pre_first.build.as_str() == "ci"); +/// assert!(pre_first.pre.as_str() == "rc"); +/// ``` +/// +/// Build metadata and prerelease segments may contain multiple components with a separating +/// period: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3-rc.1").is_ok()); +/// ``` +/// +/// Build metadata and prerelease segments and subsegments may start with a hyphen: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3--rc.-1").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+-ci.-1").is_ok()); +/// ``` +/// +/// Digit-only identifiers for prerelease segments must not have leading zeroes but may consist of +/// a single zero: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3-rc.0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3-rc.01").is_err()); +/// assert!(SemanticVersion::parse("1.2.3-0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3-01").is_err()); +/// assert!(SemanticVersion::parse("1.2.3-rc.0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3-rc.000").is_err()); +/// ``` +/// +/// Digit-only identifiers for build metadata segments can have leading zeroes and consist of +/// multiple zeroes: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3+ci.0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+ci.01").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+01").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+ci.0").is_ok()); +/// assert!(SemanticVersion::parse("1.2.3+ci.000").is_ok()); +/// ``` +/// +/// Specifying any character other than a hyphen (`-`), digit (`0-9`), ASCII alphabetic (`a-z` and +/// `A-Z`), or period (`.`) for a prerelease or build metadata segment causes a parsing error: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// assert!(SemanticVersion::parse("1.2.3-rc@4").is_err()); +/// assert!(SemanticVersion::parse("1.2.3+ci!4").is_err()); +/// ``` +/// +/// ## Semantic version ordering +/// +/// The comparison for semantic versions is performed segment by segment where the segment for the +/// left hand side of the comparison may be equal to, greater than, or less than the segment for +/// the right hand side of the comparison. The comparison logic follows these steps: +/// +/// 1. If the major version segments of the semantic versions are unequal, the version with a +/// higher major version segment is greater. If the major version segments are equal, compare +/// the minor version segments. +/// 1. If the minor version segments of the semantic versions are unequal, the version with a +/// higher minor version segment is greater. If the minor version segments are equal, compare +/// the patch version segments. +/// 1. If the patch version segments of the semantic versions are unequal, the version with a +/// higher patch version segment is greater. If the patch version segments are equal, compare +/// the prerelease segments. +/// 1. If only one version defines a prerelease segment then the stable version, which doesn't +/// define a prerelease segment, is greater. If both versions define a prerelease segment, +/// compare the identifiers for each prerelease segment in their defined order: +/// +/// - If both identifiers contain only digits then the identifiers are compared numerically. +/// - If both identifiers contain non-digit characters then the identifiers are compared +/// in ASCII sort order (hyphen < digits < uppercase letters < lowercase letters). +/// - If the identifiers are identical for both versions, continue to the next identifier, if +/// any. +/// - If only one version defines the next identifier, that version is greater than the other +/// version. For example, `1.2.3-rc.1.a` is greater than `1.2.3-rc.1`. +/// +/// If the prerelease segments are equal, compare the build metadata segments. +/// 1. If only one version defines a build metadata segment then the version with build metadata +/// is greater. If both versions define a build metadata segment, compare the identifiers for +/// each segment in their defined order. The comparison logic for build metadata identifiers is +/// is the same as for prerelease identifiers. +/// 1. If all segments are equal then the versions are equal. +/// +/// Note that build metadata is _always_ ignored when matching against a [`SemanticVersionReq`]. +/// This comparison is _only_ for distinguishing precedence between versions to find the latest +/// version. This behavior can be surprising for end-users, since versions with build metadata +/// are typically seen with development builds, not release builds. Prefer omitting build metadata +/// when defining semantic versions for DSC to make a more consistent experience for users. +/// +/// ### Ordering examples +/// +/// ```rust +/// use dsc_lib::types::SemanticVersion; +/// +/// let v1_0_0: SemanticVersion = "1.0.0".parse().unwrap(); +/// let v2_0_0: SemanticVersion = "2.0.0".parse().unwrap(); +/// let v1_2_3: SemanticVersion = "1.2.3".parse().unwrap(); +/// let v1_2_3_pre: SemanticVersion = "1.2.3-rc.1".parse().unwrap(); +/// let v1_2_3_build: SemanticVersion = "1.2.3+rci.1".parse().unwrap(); +/// let v1_2_3_pre_build: SemanticVersion = "1.2.3-rc.1+ci.1".parse().unwrap(); +/// +/// // Comparisons of stable versions work as expected +/// assert!(v1_0_0 < v1_2_3); +/// assert!(v2_0_0 > v1_2_3); +/// // Stable versions is always greater than prerelease for same version +/// assert!(v1_0_0 < v1_2_3_pre); +/// assert!(v1_2_3 > v1_2_3_pre); +/// // Version with build metadata is greater than same version +/// assert!(v1_2_3_build > v1_2_3); +/// assert!(v1_2_3_pre_build > v1_2_3_pre); +/// // Build metadata is ignored when versions aren't the same +/// assert!(v2_0_0 > v1_2_3_build); +/// +/// let rc: SemanticVersion = "1.2.3-rc".parse().unwrap(); +/// let rc_1: SemanticVersion = "1.2.3-rc.1".parse().unwrap(); +/// let rc_1_2: SemanticVersion = "1.2.3-rc.1.2".parse().unwrap(); +/// // When the first identifier is identical, the version with an extra +/// // identifier is greater +/// assert!(rc < rc_1); +/// assert!(rc_1 < rc_1_2); +/// +/// // To correct sort prerelease and build versions, make sure to separate +/// // the alpha segment like `rc` or `ci`from the numeric. Otherwise, the +/// // ordering may be unexpected, like `rc11` < `rc2` +/// let rc11: SemanticVersion = "1.2.3-rc11".parse().unwrap(); +/// let rc2: SemanticVersion = "1.2.3-rc2".parse().unwrap(); +/// let rc_11: SemanticVersion = "1.2.3-rc.11".parse().unwrap(); +/// let rc_2: SemanticVersion = "1.2.3-rc.2".parse().unwrap(); +/// assert!(rc2 > rc11); +/// assert!(rc_11 > rc_2); +/// +/// // For identifiers, hyphen < digit < uppercase alpha < lowercase alpha +/// // Showing for build but ordering applies to prerelease identifiers too +/// let middle_hyphen: SemanticVersion = "1.2.3+a-a".parse().unwrap(); +/// let middle_digit: SemanticVersion = "1.2.3+a0a".parse().unwrap(); +/// let middle_upper: SemanticVersion = "1.2.3+aAa".parse().unwrap(); +/// let middle_lower: SemanticVersion = "1.2.3+aaa".parse().unwrap(); +/// assert!(middle_hyphen < middle_digit); +/// assert!(middle_digit < middle_upper); +/// assert!(middle_upper < middle_lower); +/// ``` +/// +/// # Determining latest version +/// +/// DSC uses the default ordering for semantic versions where: +/// +/// - A higher version supercedes a lower version, regardless of prerelease and build metadata. +/// - A stable version supercedes the same version with a prerelease segment. +/// - A stable version with build metadata supercedes the same version without build metadata. +/// - Prerelease and build metadata segments are compared lexicographically. +/// +/// Consider the following example: +/// +/// ```rust +/// # use dsc_lib::types::SemanticVersion; +/// let v1_0_0 = SemanticVersion::parse("1.0.0").unwrap(); +/// let v1_2_3 = SemanticVersion::parse("1.2.3").unwrap(); +/// let v2_0_0 = SemanticVersion::parse("2.0.0").unwrap(); +/// let v1_2_3_rc_1 = SemanticVersion::parse("1.2.3-rc.1").unwrap(); +/// let v1_2_3_ci_1 = SemanticVersion::parse("1.2.3+ci.1").unwrap(); +/// +/// let mut versions = vec![ +/// v1_0_0.clone(), +/// v1_2_3.clone(), +/// v2_0_0.clone(), +/// v1_2_3_rc_1.clone(), +/// v1_2_3_ci_1.clone() +/// ]; +/// versions.sort(); +/// +/// assert_eq!( +/// versions, +/// vec![ +/// v1_0_0, +/// v1_2_3_rc_1, +/// v1_2_3, +/// v1_2_3_ci_1, +/// v2_0_0 +/// ] +/// ); +/// ``` +/// +/// When the versions are sorted, the latest version is `2.0.0`. When considering the versions +/// `1.2.3`, `1.2.3-rc.1`, and `1.2.3+ci.1`, they sort as `1.2.3+ci.1 > 1.2.3 > 1.2.3-rc.1`. +/// +/// Versions sorting with build metadata as later than typical stable versions may be surprising to +/// end users. When publishing DSC resources and extensions, authors should always set the version +/// in a manifest to _exclude_ build metadata. +/// +/// # Matching version requirements +/// +/// DSC uses the [`SemanticVersionReq`] type for defining version ranges. This enables users to +/// pin to specific versions or a range of supported versions. For more information, see +/// [`SemanticVersionReq`]. +/// +/// [01]: https://semver.org +/// [`SemanticVersionReq`]: crate::types::SemanticVersionReq +#[derive(Debug, Clone, Hash, Eq, Serialize, Deserialize, DscRepoSchema)] +#[dsc_repo_schema(base_name = "semver", folder_path = "definitions")] +pub struct SemanticVersion(semver::Version); + +impl SemanticVersion { + /// Create an instance of [`SemanticVersion`] with empty prerelease and build segments. + /// + /// # Examples + /// + /// ```rust + /// pub use dsc_lib::types::SemanticVersion; + /// + /// let version = &SemanticVersion::new(1, 2, 3); + /// + /// assert!( + /// semver::VersionReq::parse(">1.0").unwrap().matches(version) + /// ); + /// ``` + pub fn new(major: u64, minor: u64, patch: u64) -> Self { + Self(semver::Version::new(major, minor, patch)) + } + + /// Create an instance of [`SemanticVersion`] from a string representation. + /// + /// # Errors + /// + /// Parsing fails when the input string isn't a valid semantic version. Common parse errors + /// include: + /// + /// - Not specifying major, minor, and patch segments, like `1` or `1.0` instead of `1.0.0`. + /// - Specifying a leading zero before a non-zero digit in a version segment, like `01.02.03` + /// instead of `1.2.3`. + /// - Specifying a non-digit character in a major, minor, or patch version segment, like + /// `1a.2.3`, `1.2b.3`, or `1.2.c3`. + /// - Specifying a hyphen after the version without a prelease segment, like `1.2.3-`. + /// - Specifying a plus sign after the version without a build metadata segment, like `1.2.3+`. + /// - Invalid characters in prerelease or build metadata segments, which only allow the + /// characters `a-z`, `a-Z`, `0-9`, `-`, and `.`, such as `1.2.3-rc_1` or `1.2.3+ci@sha`. + pub fn parse(value: &str) -> Result { + match semver::Version::parse(value) { + Ok(v) => Ok(Self(v)), + Err(e) => Err(DscError::SemVer(e)), + } + } + + /// Defines the validating regular expression for semantic versions. + /// + /// This regular expression is adapted from the officially recommended pattern on [semver.org] + /// and is used for the `pattern` keyword in the JSON Schema for the [`SemanticVersion`] type. + /// + /// The pattern isn't used for validating an instance during or after deserialization. Instead, + /// it provides author-time feedback to manifest maintainers so they can avoid runtime failures. + /// + /// During deserialization, the library first tries to parse the string as a semantic version. + /// If the value parses successfully, it's deserialized as a [`SemanticVersion`] instance. + /// Otherwise, deserialization fails. + /// + /// [semver.org]: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + pub const VALIDATING_PATTERN: &str = const_str::concat!( + "^", // Anchor pattern to start of string. + SemanticVersion::CAPTURING_VERSION_MAJOR_PATTERN, // Must include major version segment, + r"\.", // then a period (`.`), + SemanticVersion::CAPTURING_VERSION_MINOR_PATTERN, // then the minor version segment, + r"\.", // then a period (`.`), + SemanticVersion::CAPTURING_VERSION_PATCH_PATTERN, // then the patch version segment. + "(?:", // Open non-capturing group for prerelease segment. + "-", // Prerelease follows patch version with a hyphen + SemanticVersion::CAPTURING_PRERELEASE_PATTERN, // then the actual segment. + ")", // Close the non-capturing group for prerelease. + "?", // Mark the prerelease segment as optional. + "(?:", // Open non-capturing group for build metadata segment. + r"\+", // Build follows patch or prerelease with a plus + SemanticVersion::CAPTURING_BUILD_METADATA_PATTERN, // then the actual segment. + ")", // Close the non-capturing group for build metadata. + "?", // Mark the build metadata segment as optional. + "$" // Anchor pattern to end of string. + ); + + pub(crate) const VERSION_SEGMENT_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group for version segment + "0", // segments can be either zero + "|", // or + r"[1-9]\d*", // any integer greater than zero + ")", // Close non-capturing group for version segment + ); + pub(crate) const CAPTURING_VERSION_MAJOR_PATTERN: &str = const_str::concat!( + "(?", // Open the named capture group + SemanticVersion::VERSION_SEGMENT_PATTERN, // Capture the version segment + ")" // Close the named capture group + ); + pub(crate) const CAPTURING_VERSION_MINOR_PATTERN: &str = const_str::concat!( + "(?", // Open the named capture group + SemanticVersion::VERSION_SEGMENT_PATTERN, // Capture the version segment + ")" // Close the named capture group + ); + pub(crate) const CAPTURING_VERSION_PATCH_PATTERN: &str = const_str::concat!( + "(?", // Open the named capture group + SemanticVersion::VERSION_SEGMENT_PATTERN, // Capture the version segment + ")" // Close the named capture group + ); + pub(crate) const PRERELEASE_SUBSEGMENT_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group to avoid cluttering results. + SemanticVersion::VERSION_SEGMENT_PATTERN, // Subsegment can either be a version segment + "|", // or + r"\d*[a-zA-Z-]", // any number of digits followed by a letter or hyphen, then + "[0-9a-zA-Z-]*", // any number of digits, letters, or hyphens. + ")" // Close the non-capturing group. + ); + pub(crate) const PRERELEASE_SEGMENT_PATTERN: &str = const_str::concat!( + SemanticVersion::PRERELEASE_SUBSEGMENT_PATTERN, // Start with a valid prerelease subsegment + "(?:", // Open a non-capturing group to avoid cluttering. + r"\.", // First character after prior subsegment must be a `.`, + SemanticVersion::PRERELEASE_SUBSEGMENT_PATTERN, // followed by another valid prerelease segment. + ")", // Close the non-capturing group for extra subsegments. + "*" // Match additional subsegments zero or more times. + ); + pub(crate) const CAPTURING_PRERELEASE_PATTERN: &str = const_str::concat!( + "(?", // Open named capture group. + SemanticVersion::PRERELEASE_SEGMENT_PATTERN, // Capture the segment. + ")" // Close the named capture group. + ); + /// A subsegment of build metadata consists of one or more digits, letters, and hyphens. + pub(crate) const BUILD_METADATA_SUBSEGMENT_PATTERN: &str = "[0-9a-zA-Z-]+"; + pub(crate) const BUILD_METADATA_SEGMENT_PATTERN: &str = const_str::concat!( + SemanticVersion::BUILD_METADATA_SUBSEGMENT_PATTERN, // Start with a valid build metadata subsegment + "(?:", // Open a non-capturing group to avoid cluttering. + r"\.", // First character after prior subsegment must be a `.`, + SemanticVersion::BUILD_METADATA_SUBSEGMENT_PATTERN, // followed by another valid subsegment. + ")", // Close the non-capturing group for extra subsegments. + "*" // Match additional subsegments zero or more times. + ); + pub(crate) const CAPTURING_BUILD_METADATA_PATTERN: &str = const_str::concat!( + "(?", // Open named capture group. + SemanticVersion::BUILD_METADATA_SEGMENT_PATTERN, // Capture the segment. + ")" // Close the named capture group. + ); +} + +impl JsonSchema for SemanticVersion { + fn schema_name() -> std::borrow::Cow<'static, str> { + Self::default_schema_id_uri().into() + } + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "title": t!("schemas.definitions.semver.title"), + "description": t!("schemas.definitions.semver.description"), + "markdownDescription": t!("schemas.definitions.semver.markdownDescription"), + "type": "string", + "pattern": Self::VALIDATING_PATTERN, + "patternErrorMessage": t!("schemas.definitions.semver.patternErrorMessage"), + }) + } +} + +impl Default for SemanticVersion { + fn default() -> Self { + Self(semver::Version::new(0, 0, 0)) + } +} + +impl Display for SemanticVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +// Infallible conversions +impl From for SemanticVersion { + fn from(value: semver::Version) -> Self { + Self(value) + } +} + +impl From for semver::Version { + fn from(value: SemanticVersion) -> Self { + value.0 + } +} + +impl From<&semver::Version> for SemanticVersion { + fn from(value: &semver::Version) -> Self { + Self(value.clone()) + } +} + +impl From<&SemanticVersion> for semver::Version { + fn from(value: &SemanticVersion) -> Self { + value.0.clone() + } +} + +impl From for String { + fn from(value: SemanticVersion) -> Self { + value.to_string() + } +} + +// Fallible conversions +impl FromStr for SemanticVersion { + type Err = DscError; + fn from_str(s: &str) -> Result { + SemanticVersion::parse(s) + } +} + +impl TryFrom for SemanticVersion { + type Error = DscError; + fn try_from(value: String) -> Result { + match semver::Version::parse(value.as_str()) { + Ok(v) => Ok(Self(v)), + Err(e) => Err(DscError::SemVer(e)), + } + } +} + +impl TryFrom<&str> for SemanticVersion { + type Error = DscError; + fn try_from(value: &str) -> Result { + SemanticVersion::from_str(value) + } +} + +// Referencing and dereferencing +impl AsRef for SemanticVersion { + fn as_ref(&self) -> &semver::Version { + &self.0 + } +} + +impl Deref for SemanticVersion { + type Target = semver::Version; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +// Comparison traits +impl PartialEq for SemanticVersion { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl PartialEq for SemanticVersion { + fn eq(&self, other: &semver::Version) -> bool { + &self.0 == other + } +} + +impl PartialEq for semver::Version { + fn eq(&self, other: &SemanticVersion) -> bool { + self == &other.0 + } +} + +impl PartialEq for SemanticVersion { + fn eq(&self, other: &String) -> bool { + match Self::parse(other.as_str()) { + Ok(other_version) => self.eq(&other_version), + Err(_) => false, + } + } +} + +impl PartialEq for String { + fn eq(&self, other: &SemanticVersion) -> bool { + match SemanticVersion::parse(self.as_str()) { + Ok(version) => version.eq(other), + Err(_) => false, + } + } +} + +impl PartialEq for SemanticVersion { + fn eq(&self, other: &str) -> bool { + match Self::parse(other) { + Ok(other_version) => self.eq(&other_version), + Err(_) => false, + } + } +} + +impl PartialEq for str { + fn eq(&self, other: &SemanticVersion) -> bool { + match SemanticVersion::parse(&self) { + Ok(version) => version.eq(other), + Err(_) => false, + } + } +} + +impl PartialEq<&str> for SemanticVersion { + fn eq(&self, other: &&str) -> bool { + match Self::parse(*other) { + Ok(other_version) => self.eq(&other_version), + Err(_) => false, + } + } +} + +impl PartialEq for &str { + fn eq(&self, other: &SemanticVersion) -> bool { + match SemanticVersion::parse(*self) { + Ok(version) => version.eq(other), + Err(_) => false, + } + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &Self) -> Option { + self.0.partial_cmp(&other.0) + } +} + +impl Ord for SemanticVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.cmp(&other.0) + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &semver::Version) -> Option { + self.0.partial_cmp(other) + } +} + +impl PartialOrd for semver::Version { + fn partial_cmp(&self, other: &SemanticVersion) -> Option { + self.partial_cmp(&other.0) + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &String) -> Option { + match Self::parse(other.as_str()) { + Ok(other_version) => self.partial_cmp(&other_version), + Err(_) => None, + } + } +} + +impl PartialOrd for String { + fn partial_cmp(&self, other: &SemanticVersion) -> Option { + match SemanticVersion::parse(self.as_str()) { + Ok(version) => version.partial_cmp(other), + Err(_) => None, + } + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &str) -> Option { + match Self::parse(other) { + Ok(other_version) => self.partial_cmp(&other_version), + Err(_) => None, + } + } +} + +impl PartialOrd for str { + fn partial_cmp(&self, other: &SemanticVersion) -> Option { + match SemanticVersion::parse(self) { + Ok(version) => version.partial_cmp(other), + Err(_) => None, + } + } +} diff --git a/lib/dsc-lib/tests/integration/types/mod.rs b/lib/dsc-lib/tests/integration/types/mod.rs index c45605926..683b954c7 100644 --- a/lib/dsc-lib/tests/integration/types/mod.rs +++ b/lib/dsc-lib/tests/integration/types/mod.rs @@ -3,3 +3,5 @@ #[cfg(test)] mod fully_qualified_type_name; +#[cfg(test)] +mod semantic_version; diff --git a/lib/dsc-lib/tests/integration/types/semantic_version.rs b/lib/dsc-lib/tests/integration/types/semantic_version.rs new file mode 100644 index 000000000..d4b42cd80 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/semantic_version.rs @@ -0,0 +1,539 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::{dscerror::DscError, types::SemanticVersion}; + use test_case::test_case; + + #[test] + fn new() { + let actual = SemanticVersion::new(1, 0, 0); + + pretty_assertions::assert_eq!(actual.to_string(), "1.0.0".to_string()); + } + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit is invalid")] + fn parse(value: &str) -> Result { + SemanticVersion::parse(value) + } +} + +#[cfg(test)] +mod schema { + use std::sync::LazyLock; + + use dsc_lib::types::SemanticVersion; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static SCHEMA: LazyLock = LazyLock::new(|| schema_for!(SemanticVersion)); + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*SCHEMA).as_value()).unwrap()); + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); + + #[test_case("title")] + #[test_case("description")] + #[test_case("markdownDescription")] + #[test_case("patternErrorMessage")] + fn has_documentation_keyword(keyword: &str) { + let schema = &*SCHEMA; + let value = schema + .get_keyword_as_str(keyword) + .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'" + ); + } + + #[test_case(&json!("1.0.0") => true; "valid stable semantic version string value is valid")] + #[test_case(&json!("1.0.0-rc.1") => true; "valid prerelease semantic version string value is valid")] + #[test_case(&json!("1.0.0+ci.123") => true; "valid stable semantic version with build metadata string value is valid")] + #[test_case(&json!("1.0.0-rc.1+ci.123") => true; "valid prerelease semantic version with build metadata string value is valid")] + #[test_case(&json!("1") => false; "major version only string value is invalid")] + #[test_case(&json!("1.0") => false; "missing patch version string value is invalid")] + #[test_case(&json!("1.2.c") => false; "version segment as non-digit string value is invalid")] + #[test_case(&json!(true) => false; "boolean value is invalid")] + #[test_case(&json!(1) => false; "integer value is invalid")] + #[test_case(&json!(1.2) => false; "float value is invalid")] + #[test_case(&json!({"req": "1.2.3"}) => false; "object value is invalid")] + #[test_case(&json!(["1.2.3"]) => false; "array value is invalid")] + #[test_case(&serde_json::Value::Null => false; "null value is invalid")] + fn validation(input_json: &Value) -> bool { + (&*VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::SemanticVersion; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("1.0.0"; "stable semantic version")] + #[test_case("1.0.0-rc.1"; "prerelease semantic version")] + #[test_case("1.0.0+ci.123"; "stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123"; "prerelease semantic version with build metadata")] + fn serializing(version: &str) { + let actual = serde_json::to_string( + &SemanticVersion::parse(version).expect("parse should never fail"), + ) + .expect("serialization should never fail"); + + let expected = format!(r#""{version}""#); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case(json!("1.0.0") => matches Ok(_); "stable semantic version string value is valid")] + #[test_case(json!("1.0.0-rc.1") => matches Ok(_); "prerelease semantic version string value is valid")] + #[test_case(json!("1.0.0+ci.123") => matches Ok(_); "stable semantic version with build metadata string value is valid")] + #[test_case(json!("1.0.0-rc.1+ci.123") => matches Ok(_); "prerelease semantic version with build metadata string value is valid")] + #[test_case(json!("1") => matches Err(_); "major version only string value is invalid")] + #[test_case(json!("1.0") => matches Err(_); "missing patch version string value is invalid")] + #[test_case(json!("1.2.c") => matches Err(_); "version segment as non-digit string value is invalid")] + #[test_case(json!(true) => matches Err(_); "boolean value is invalid")] + #[test_case(json!(1) => matches Err(_); "integer value is invalid")] + #[test_case(json!(1.2) => matches Err(_); "float value is invalid")] + #[test_case(json!({"req": "1.2.3"}) => matches Err(_); "object value is invalid")] + #[test_case(json!(["1.2.3"]) => matches Err(_); "array value is invalid")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value is invalid")] + fn deserializing(value: Value) -> Result { + serde_json::from_value::(value) + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::SemanticVersion; + + #[test] + fn default() { + let actual = SemanticVersion::default(); + let expected = SemanticVersion::new(0, 0, 0); + + pretty_assertions::assert_eq!(actual, expected); + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::SemanticVersion; + use test_case::test_case; + + #[test_case("1.0.0", "1.0.0"; "valid stable semantic version")] + #[test_case("1.0.0-rc.1", "1.0.0-rc.1"; "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123", "1.0.0+ci.123"; "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123", "1.0.0-rc.1+ci.123"; "valid prerelease semantic version with build metadata")] + fn format(version: &str, expected: &str) { + pretty_assertions::assert_eq!( + format!("req: '{}'", SemanticVersion::parse(version).unwrap()), + format!("req: '{}'", expected) + ) + } + + #[test_case("1.0.0", "1.0.0"; "valid stable semantic version")] + #[test_case("1.0.0-rc.1", "1.0.0-rc.1"; "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123", "1.0.0+ci.123"; "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123", "1.0.0-rc.1+ci.123"; "valid prerelease semantic version with build metadata")] + fn to_string(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + SemanticVersion::parse(requirement).unwrap().to_string(), + expected.to_string() + ) + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::SemanticVersion; + use semver::Version; + + #[test] + fn semver_version() { + let _ = SemanticVersion::from(Version::new(1, 0, 0)); + } + } + + #[cfg(test)] + mod from_str { + use std::str::FromStr; + + use dsc_lib::{dscerror::DscError, types::SemanticVersion}; + use test_case::test_case; + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only string value is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version string value is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit string value is invalid")] + fn from_str(text: &str) -> Result { + SemanticVersion::from_str(text) + } + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only string value is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version string value is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit string value is invalid")] + fn parse(text: &str) -> Result { + text.parse() + } + } + + #[cfg(test)] + mod try_from { + use dsc_lib::{dscerror::DscError, types::SemanticVersion}; + use test_case::test_case; + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only string value is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version string value is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit string value is invalid")] + fn string(text: &str) -> Result { + SemanticVersion::try_from(text.to_string()) + } + + #[test_case("1.0.0" => matches Ok(_); "valid stable semantic version")] + #[test_case("1.0.0-rc.1" => matches Ok(_); "valid prerelease semantic version")] + #[test_case("1.0.0+ci.123" => matches Ok(_); "valid stable semantic version with build metadata")] + #[test_case("1.0.0-rc.1+ci.123" => matches Ok(_); "valid prerelease semantic version with build metadata")] + #[test_case("1" => matches Err(_); "major version only string value is invalid")] + #[test_case("1.0" => matches Err(_); "missing patch version string value is invalid")] + #[test_case("1.2.c" => matches Err(_); "version segment as non-digit string value is invalid")] + fn str(text: &str) -> Result { + SemanticVersion::try_from(text) + } + } + + #[cfg(test)] + mod into { + use dsc_lib::types::SemanticVersion; + use semver::Version; + + #[test] + fn semver_version() { + let _: Version = SemanticVersion::new(1, 0, 0).into(); + } + + #[test] + fn string() { + let _: String = SemanticVersion::new(1, 0, 0).into(); + } + } + + #[cfg(test)] + mod as_ref { + use dsc_lib::types::SemanticVersion; + use semver::Version; + + #[test] + fn semver_version() { + let _: &Version = SemanticVersion::new(1, 0, 0).as_ref(); + } + } + + #[cfg(test)] + mod deref { + use dsc_lib::types::SemanticVersion; + + #[test] + fn semver_version() { + let v = SemanticVersion::new(1, 2, 3); + + pretty_assertions::assert_eq!(v.major, 1u64); + pretty_assertions::assert_eq!(v.minor, 2u64); + pretty_assertions::assert_eq!(v.patch, 3u64); + pretty_assertions::assert_eq!(v.pre, semver::Prerelease::EMPTY); + pretty_assertions::assert_eq!(v.build, semver::BuildMetadata::EMPTY); + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::SemanticVersion; + use test_case::test_case; + + #[test_case("1.2.3", "1.2.3", true; "identical semantic versions")] + #[test_case("1.2.3", "3.2.1", false; "different semantic versions")] + #[test_case("1.2.3", "1.2.3-rc.1", false; "semantic version and prerelease")] + #[test_case("1.2.3", "1.2.3+ci.123", false; "semantic version and build metadata")] + fn semantic_version(lhs: &str, rhs: &str, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!( + SemanticVersion::parse(lhs).unwrap(), + SemanticVersion::parse(rhs).unwrap() + ) + } else { + pretty_assertions::assert_ne!( + SemanticVersion::parse(lhs).unwrap(), + SemanticVersion::parse(rhs).unwrap() + ) + } + } + + #[test_case("1.2.3", "1.2.3", true; "SemanticVersion and identical semver::Version")] + #[test_case("1.2.3", "3.2.1", false; "different versions")] + #[test_case("1.2.3", "1.2.3-rc.1", false; "SemanticVersion and semver::Version with prerelease")] + #[test_case("1.2.3", "1.2.3+ci.123", false; "SemanticVersion and semver::Version with build metadata")] + fn semver_version( + semantic_version_string: &str, + semver_version_string: &str, + should_be_equal: bool, + ) { + let semantic_version = SemanticVersion::parse(semantic_version_string).unwrap(); + let semver_version = semver::Version::parse(semver_version_string).unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version == semver_version, + should_be_equal, + "expected comparison of {semantic_version} and {semver_version} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + semver_version == semantic_version, + should_be_equal, + "expected comparison of {semver_version} and {semantic_version} to be {should_be_equal}" + ); + } + + #[test_case("1.2.3", "1.2.3", true; "SemanticVersion and identical string")] + #[test_case("1.2.3", "3.2.1", false; "SemanticVersion and different version string")] + #[test_case("1.2.3", "1.2.3-rc.1", false; "SemanticVersion and version string with prerelease")] + #[test_case("1.2.3", "1.2.3+ci.123", false; "SemanticVersion and version string with build metadata")] + fn string(semantic_version_string: &str, string_slice: &str, should_be_equal: bool) { + let semantic_version = SemanticVersion::parse(semantic_version_string).unwrap(); + let string = string_slice.to_string(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version == string, + should_be_equal, + "expected comparison of {semantic_version} and {string} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string == semantic_version, + should_be_equal, + "expected comparison of {string} and {semantic_version} to be {should_be_equal}" + ); + } + + #[test_case("1.2.3", "1.2.3", true; "SemanticVersion and identical string slice")] + #[test_case("1.2.3", "3.2.1", false; "SemanticVersion and different version string slice")] + #[test_case("1.2.3", "1.2.3-rc.1", false; "SemanticVersion and version string slice with prerelease")] + #[test_case("1.2.3", "1.2.3+ci.123", false; "SemanticVersion and version string slice with build metadata")] + fn str(semantic_version_string: &str, string_slice: &str, should_be_equal: bool) { + let semantic_version = SemanticVersion::parse(semantic_version_string).unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version == string_slice, + should_be_equal, + "expected comparison of {semantic_version} and {string_slice} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string_slice == semantic_version, + should_be_equal, + "expected comparison of {string_slice} and {semantic_version} to be {should_be_equal}" + ); + } + } + + #[cfg(test)] + mod partial_ord { + use std::cmp::Ordering; + + use dsc_lib::types::SemanticVersion; + use test_case::test_case; + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal stable versions")] + #[test_case("1.2.3-rc.1", "1.2.3-rc.1", Ordering::Equal; "equal prerelease versions")] + #[test_case("1.2.3+ci.1", "1.2.3+ci.1", Ordering::Equal; "equal stable versions with build metadata")] + #[test_case("1.2.3-rc.1+ci.1", "1.2.3-rc.1+ci.1", Ordering::Equal; "equal prerelease versions with build metadata")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "newer stable is greater than older stable")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "older stable is less than newer stable")] + #[test_case("1.2.3", "1.2.3-rc.1", Ordering::Greater; "stable is greater than prerelease")] + #[test_case("1.2.3", "1.2.3+ci.1", Ordering::Less; "stable without build is less than stable with build")] + #[test_case("1.2.3-rc.1", "1.2.3-rc.1+ci.1", Ordering::Less; "prerelease without build is less than prerelease with build")] + #[test_case("1.2.3-A-0", "1.2.3-A00", Ordering::Less; "prerelease hyphen is less than digit")] + #[test_case("1.2.3-A0A", "1.2.3-AAA", Ordering::Less; "prerelease digit is less than uppercase")] + #[test_case("1.2.3-AAA", "1.2.3-AaA", Ordering::Less; "prerelease uppercase is less than lowercase")] + #[test_case("1.2.3+A-0", "1.2.3+A00", Ordering::Less; "build metadata hyphen is less than digit")] + #[test_case("1.2.3+A0A", "1.2.3+AAA", Ordering::Less; "build metadata digit is less than uppercase")] + #[test_case("1.2.3+AAA", "1.2.3+AaA", Ordering::Less; "build metadata uppercase is less than lowercase")] + fn semantic_version(lhs: &str, rhs: &str, expected_order: Ordering) { + pretty_assertions::assert_eq!( + SemanticVersion::parse(lhs) + .expect("parsing for lhs should not fail") + .partial_cmp(&SemanticVersion::parse(rhs).expect("parsing for rhs should not fail")) + .expect("comparison should always be an ordering"), + expected_order, + "expected '{lhs}' compared to '{rhs}' to be {expected_order:#?}" + ) + } + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal versions")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "newer lhs")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "newer rhs")] + #[test_case("1.2.3", "1.2.3-rc.1", Ordering::Greater; "stable lhs and prerelease rhs")] + #[test_case("1.2.3", "1.2.3+ci.1", Ordering::Less; "stable lhs and rhs with build metadata")] + fn semver_version( + resource_version_string: &str, + semver_string: &str, + expected_order: Ordering, + ) { + let version: SemanticVersion = resource_version_string.parse().unwrap(); + let semantic: semver::Version = semver_string.parse().unwrap(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(&semantic).unwrap(), + expected_order, + "expected comparison of {version} and {semantic} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + Ordering::Equal => Ordering::Equal, + Ordering::Greater => Ordering::Less, + Ordering::Less => Ordering::Greater, + }; + + pretty_assertions::assert_eq!( + semantic.partial_cmp(&version).unwrap(), + expected_inverted_order, + "expected comparison of {semantic} and {version} to be #{expected_inverted_order:#?}" + ); + } + + #[test_case("1.2.3", "1.2.3", Some(Ordering::Equal); "equal version and string")] + #[test_case("3.2.1", "1.2.3", Some(Ordering::Greater); "newer version and older string")] + #[test_case("1.2.3", "3.2.1", Some(Ordering::Less); "older version and newer string")] + #[test_case("1.2.3", "1.2.3-rc.1", Some(Ordering::Greater); "stable version and prerelease string")] + #[test_case("1.2.3", "1.2.3+ci.1", Some(Ordering::Less); "stable version and string with build metadata")] + #[test_case("1.2.3", "not a version", None; "stable version and non-version string")] + fn string( + resource_version_string: &str, + string_slice: &str, + expected_order: Option, + ) { + let version: SemanticVersion = resource_version_string.parse().unwrap(); + let string = string_slice.to_string(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(&string), + expected_order, + "expected comparison of {version} and {string} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + None => None, + Some(o) => match o { + Ordering::Equal => Some(Ordering::Equal), + Ordering::Greater => Some(Ordering::Less), + Ordering::Less => Some(Ordering::Greater), + }, + }; + + pretty_assertions::assert_eq!( + string.partial_cmp(&version), + expected_inverted_order, + "expected comparison of {string} and {version} to be #{expected_inverted_order:#?}" + ); + } + + #[test_case("1.2.3", "1.2.3", Some(Ordering::Equal); "equal version and string")] + #[test_case("3.2.1", "1.2.3", Some(Ordering::Greater); "newer version and older string")] + #[test_case("1.2.3", "3.2.1", Some(Ordering::Less); "older version and newer string")] + #[test_case("1.2.3", "1.2.3-rc.1", Some(Ordering::Greater); "stable version and prerelease string")] + #[test_case("1.2.3", "1.2.3+ci.1", Some(Ordering::Less); "stable version and string with build metadata")] + #[test_case("1.2.3", "not a version", None; "stable version and non-version string")] + fn str( + resource_version_string: &str, + string_slice: &str, + expected_order: Option, + ) { + let version: SemanticVersion = resource_version_string.parse().unwrap(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(string_slice), + expected_order, + "expected comparison of {version} and {string_slice} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + None => None, + Some(o) => match o { + Ordering::Equal => Some(Ordering::Equal), + Ordering::Greater => Some(Ordering::Less), + Ordering::Less => Some(Ordering::Greater), + }, + }; + + pretty_assertions::assert_eq!( + string_slice.partial_cmp(&version), + expected_inverted_order, + "expected comparison of {string_slice} and {version} to be #{expected_inverted_order:#?}" + ); + } + } + + mod ord { + use dsc_lib::types::SemanticVersion; + + #[test] + fn semantic_version() { + let v1_0_0 = SemanticVersion::parse("1.0.0").unwrap(); + let v1_2_3 = SemanticVersion::parse("1.2.3").unwrap(); + let v2_0_0 = SemanticVersion::parse("2.0.0").unwrap(); + let v1_2_3_rc_1 = SemanticVersion::parse("1.2.3-rc.1").unwrap(); + let v1_2_3_ci_1 = SemanticVersion::parse("1.2.3+ci.1").unwrap(); + + let mut versions = vec![ + v1_0_0.clone(), + v1_2_3.clone(), + v2_0_0.clone(), + v1_2_3_rc_1.clone(), + v1_2_3_ci_1.clone() + ]; + versions.sort(); + + pretty_assertions::assert_eq!( + versions, + vec![ + v1_0_0, + v1_2_3_rc_1, + v1_2_3, + v1_2_3_ci_1, + v2_0_0 + ] + ); + } + } +} From adb726fe269360ac5baa415b7a33d42c763e6386 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Fri, 30 Jan 2026 16:59:45 -0600 Subject: [PATCH 2/6] (GH-538) Define `SemanticVersionReq` as newtype Prior to this change, `dsc-lib` used the `semver::VersionReq` type for pinning versions of resources and extensions. To provide better schema values for specifying semantic version requirements, we need to Define a newtype so we can modify and extend the JSON Schema. This change: - Defines the `SemanticVersionReq` newtype as a wrapper for `semver::VersionReq` and implements traits for conversion and comparison. The wrapping type has stricter validation requirements over the underlying type, given the usage and context for DSC. - Provides thorough documentation with the understanding that this will provide context to the maintainers, to integrating developers working with the library, and eventually be hoisted to CLI user documentation when discussing how to define version requirements in configuration documents. This change does not modify any code in the library to use the wrapping type instead of the underlying type. That will be accomplished in a future changeset. --- lib/dsc-lib/locales/en-us.toml | 3 + lib/dsc-lib/locales/schemas.definitions.yaml | 44 + lib/dsc-lib/src/dscerror.rs | 8 + lib/dsc-lib/src/types/mod.rs | 2 + lib/dsc-lib/src/types/semantic_version_req.rs | 891 ++++++++++++++++++ lib/dsc-lib/tests/integration/types/mod.rs | 2 + .../integration/types/semantic_version_req.rs | 647 +++++++++++++ 7 files changed, 1597 insertions(+) create mode 100644 lib/dsc-lib/src/types/semantic_version_req.rs create mode 100644 lib/dsc-lib/tests/integration/types/semantic_version_req.rs diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 8fb58b2da..55ac6868c 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -753,6 +753,9 @@ resourceManifestNotFound = "Resource manifest not found" schema = "Schema" schemaNotAvailable = "No Schema found and `validate` is not supported" securityContext = "Security context" +semverReqWithBuildMetadataPrefix = "Invalid semantic version requirement: version" +semverReqWithBuildMetadataInfix = "contains the build metadata segment" +semverReqWithBuildMetadataSuffix = "DSC semantic version requirements must not include build metadata." utf8Conversion = "UTF-8 conversion" unknown = "Unknown" validation = "Validation" diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index 907b99b23..e5fdb9870 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -69,3 +69,47 @@ schemas: Invalid value, must be a semantic version like `..`, such as `1.2.3`. The value may also include pre-release version information and build metadata. + + semverReq: + title: + en-us: Semantic version requirement + description: + en-us: >- + Defines one or more limitations for a semantic version to enable version pinning. + markdownDescription: + en-us: |- + Defines one or more limitations for a semantic version to enable version pinning. + + DSC uses the semantic version requirements for Rust, as documented in the + ["Specifying dependencies" section of the Cargo Book][01]. DSC adheres closely to the + Rust syntax for defining semantic version requirements, with the following exceptions: + + 1. DSC semantic version requirements _forbid_ the inclusion of build metadata. + + Rust allows and ignores them. DSC forbids the inclusion of build metadata to limit confusion + and unexpected behavior when specifying a version requirement for a DSC resource or + extension. + 1. DSC semantic version requirements _must_ define a major version segment. All other segments + are optional. + + Rust technically supports specifying a wildcard-only version requirement (`*`). DSC forbids + specifying this version requirement as it maps to the default version selection and is + discouraged when specifying version requirements for production systems. + 1. DSC semantic version requirements only support the asterisk (`*`) character for wildcards, + not `x` or `X`. + + DSC forbids specifying wildcards as non-asterisks to reduce ambiguity and unexpected + behavior for prerelease segments, where `1.2.3-X` and `1.2.3-rc.x` are _valid_ requirements + but where the characters are _not_ interpreted as wildcards. Forbidding the use of these + characters prevents users from accidentally defining a requirement they _believe_ will + wildcard match on a prerelease segment but actually won't. + + For more information about defining semantic version requirements with DSC, see + [Defining semantic version requirements][01]. + + [01]: https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md + patternErrorMessage: + en-us: >- + Invalid semantic version requirement. A semantic version requirement must define one or + more valid comparators. For more information, see + [Defining semantic version requirements](https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md) \ No newline at end of file diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 5051c6cd5..7e3cff2c2 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -127,6 +127,14 @@ pub enum DscError { #[error("semver: {0}")] SemVer(#[from] semver::Error), + #[error( + "{t}: '{0}' {t2} '{1}' - {t3}", + t = t!("dscerror.semverReqWithBuildMetadataPrefix"), + t2 = t!("dscerror.semverReqWithBuildMetadataInfix"), + t3 = t!("dscerror.semverReqWithBuildMetadataSuffix") + )] + SemVerReqWithBuildMetadata(String, String), + #[error("{t}: {0}", t = t!("dscerror.utf8Conversion"))] Utf8Conversion(#[from] Utf8Error), diff --git a/lib/dsc-lib/src/types/mod.rs b/lib/dsc-lib/src/types/mod.rs index 7ca76be34..f3bc4e15f 100644 --- a/lib/dsc-lib/src/types/mod.rs +++ b/lib/dsc-lib/src/types/mod.rs @@ -5,3 +5,5 @@ mod fully_qualified_type_name; pub use fully_qualified_type_name::FullyQualifiedTypeName; mod semantic_version; pub use semantic_version::SemanticVersion; +mod semantic_version_req; +pub use semantic_version_req::SemanticVersionReq; diff --git a/lib/dsc-lib/src/types/semantic_version_req.rs b/lib/dsc-lib/src/types/semantic_version_req.rs new file mode 100644 index 000000000..34bd66101 --- /dev/null +++ b/lib/dsc-lib/src/types/semantic_version_req.rs @@ -0,0 +1,891 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{fmt::Display, ops::Deref, str::FromStr, sync::OnceLock}; + +use regex::Regex; +use rust_i18n::t; +use schemars::{json_schema, JsonSchema}; +use serde::{Deserialize, Serialize}; + +use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::SemanticVersion}; + +/// Defines one or more limitations for a semantic version to enable version pinning. +/// +/// DSC uses the semantic version requirements for Rust, as documented in the +/// ["Specifying dependencies" section of the Cargo Book][01]. DSC adheres closely to the +/// Rust syntax for defining semantic version requirements, with the following exceptions: +/// +/// 1. DSC semantic version requirements _forbid_ the inclusion of build metadata. +/// +/// Rust allows and ignores them. DSC forbids the inclusion of build metadata to limit confusion +/// and unexpected behavior when specifying a version requirement for a DSC resource or +/// extension. +/// 1. DSC semantic version requirements _must_ define a major version segment. All other segments +/// are optional. +/// +/// Rust technically supports specifying a wildcard-only version requirement (`*`). DSC forbids +/// specifying this version requirement as it maps to the default version selection and is +/// discouraged when specifying version requirements for production systems. +/// 1. DSC semantic version requirements only support the asterisk (`*`) character for wildcards, +/// not `x` or `X`. +/// +/// DSC forbids specifying wildcards as non-asterisks to reduce ambiguity and unexpected +/// behavior for prerelease segments, where `1.2.3-X` and `1.2.3-rc.x` are _valid_ requirements +/// but where the characters are _not_ interpreted as wildcards. Forbidding the use of these +/// characters prevents users from accidentally defining a requirement they _believe_ will +/// wildcard match on a prerelease segment but actually won't. +/// +/// # Default requirement +/// +/// The default requirement matches every possible stable version. It only rejects versions with a +/// prerelease segment, like `1.2.3-rc.1`. For DSC, the default requirement is used only when no +/// explicit requirement is given. +/// +/// Effectively, the default requirement is `>=0.0.0`. +/// +/// # Comparators +/// +/// Every requirement defines one or more comparators. A comparator defines an operator and a +/// version for comparing against an instance of [`SemanticVersion`]. A requirement with multiple +/// comparators must separate each pair of comparators with a comma (`,`), like `^1.2, <=1.4`. +/// +/// When a requirement specifies multiple comparators, a given instance of [`SemanticVersion`] only +/// matches the requirement when it matches _every_ comparator. Requirements with multiple +/// comparators effectively apply a logical `AND` for each comparator. If a requirement is defined +/// with incompatible comparators then _no_ version will ever match that requirement. For example, +/// the requirement `<1.2, >=2.3` can never match a version because no version can be less than +/// `1.2.0` _and_ greater than or equal to `2.3.0`. +/// +/// There is no way to define a requirement using logical `OR` for multiple comparators. Instead, +/// define multiple requirements and check them independently in your code. +/// +/// The following example shows how multiple comparators work in practice. +/// +/// ```rust +/// use dsc_lib::types::{SemanticVersion, SemanticVersionReq}; +/// +/// // The requirement acts as a logical AND, matching versions between 1.2.0 and 1.4.0: +/// let valid_req = SemanticVersionReq::parse(">=1.2, <1.4").unwrap(); +/// assert_eq!(valid_req.matches(&SemanticVersion::new(1, 1, 0)), false); +/// assert_eq!(valid_req.matches(&SemanticVersion::new(1, 2, 0)), true); +/// assert_eq!(valid_req.matches(&SemanticVersion::new(1, 3, 0)), true); +/// assert_eq!(valid_req.matches(&SemanticVersion::new(1, 4, 0)), false); +/// +/// // The invalid requirement never matches any versions: +/// let invalid_req = SemanticVersionReq::parse("<=1.2, >1.4").unwrap(); +/// assert_eq!(invalid_req.matches(&SemanticVersion::new(1, 1, 0)), false); +/// assert_eq!(invalid_req.matches(&SemanticVersion::new(1, 2, 0)), false); +/// assert_eq!(invalid_req.matches(&SemanticVersion::new(1, 3, 0)), false); +/// assert_eq!(invalid_req.matches(&SemanticVersion::new(1, 4, 0)), false); +/// +/// // To match two or more incompatible version requirements, use an or statement: +/// let le_req = SemanticVersionReq::parse("<=1.2").unwrap(); +/// let gt_req = SemanticVersionReq::parse(">1.4").unwrap(); +/// let v1_0_0 = &SemanticVersion::new(1, 0, 0); +/// let v1_3_0 = &SemanticVersion::new(1, 3, 0); +/// let v1_5_0 = &SemanticVersion::new(1, 5, 0); +/// assert_eq!( +/// le_req.matches(v1_0_0) || gt_req.matches(v1_0_0), +/// true +/// ); +/// assert_eq!( +/// le_req.matches(v1_3_0) || gt_req.matches(v1_3_0), +/// false +/// ); +/// assert_eq!( +/// le_req.matches(v1_5_0) || gt_req.matches(v1_5_0), +/// true +/// ); +/// ``` +/// +/// ## Specifying comparator versions +/// +/// Every comparator in a version requirement must define a version. Only the major version segment +/// is required. The minor, patch, and prerelease segments are optional. The build metadata segment +/// is forbidden. +/// +/// ### Omitting version segments +/// +/// When defining a version for a comparator, you must define the major version segment. You can +/// omit either or both the minor and version segments. The following comparators define valid +/// versions: +/// +/// - `>=1` - Matches all versions greater than or equal to `1.0.0`. +/// - `>=1.2` - Matches all versions greater than or equal to `1.2.0`. +/// +/// ### Wildcard version segments +/// +/// You can specify the minor and patch version segments as a wildcard with the asterisk (`*`) +/// character, indicating that it should match any version for that segment. If the minor version +/// segment is a wildcard,the patch version segment must either be a wildcard or omitted. +/// +/// When specifying an explicit operator, specifying the version for a comparator with wildcards is +/// equivalent to omitting those version segments. When you define a comparator without an explicit +/// operator and with a version that defines one or more wildcard segments, the implicit operator +/// for that comparator is the _wildcard operator_ instead of the _caret operator_. For more +/// information about the behavior of comparators without an explicit operator, see +/// [Specifying comparators with implicit operators](#specifying-comparators-with-implicit-operators). +/// +/// The following table shows how comparators behave depending on whether they specify an operator, +/// omit version segments, and use wildcards. Each row defines a literal comparator, the effective +/// requirement for that comparator, and a set of equivalent comparators. +/// +/// | Comparator | Effective requirement | Equivalent comparators | +/// |:----------:|:---------------------:|:---------------------------------------------------------------| +/// | `1` | `>=1.0.0, <2.0.0` | `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `1.2` | `>=1.2.0, <2.0.0` | `^1.2`, `^1.2.*` | +/// | `1.*` | `>=1.0.0, <2.0.0` | `1`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `1.*.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `1.2.*` | `>=1.2.0, <1.3.0` | `=1.2`, `=1.2.*` | +/// | `^1` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1.*`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `^1.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `^1.*.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `=1`, `=1.*`, `=1.*.*` | +/// | `^1.2` | `>=1.2.0, <2.0.0` | `1.2`, `^1.2.*` | +/// | `^1.2.*` | `>=1.2.0, <2.0.0` | `1.2` | +/// | `=1` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1.*`, `=1.*.*` | +/// | `=1.*` | `>=1,0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*.*` | +/// | `=1.*.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*` | +/// | `=1.2` | `>=1.2.0, <1.3.0` | `1.2.*`, `=1.2.*` | +/// | `=1.2.*` | `>=1.2.0, <1.3.0` | `1.2.*`, `=1.2` | +/// +/// Effectively, not specifying the minor or patch version segments is equivalent to specifying +/// the missing segments as wildcards in most cases. That means that the comparators `1`, `1.*`, +/// and `1.*.*` are equivalent. +/// +/// The exception to this rule is when the comparator defines a version with literal major and minor +/// version segments, a wildcard for the patch version segment, and no explicit operator, like +/// `1.2.*`. In that case, because the implicit operator is the wildcard operator, the effective +/// requirement becomes `>=1.2.0, <1.3.0` instead of `>=1.2.0, <2.0.0`. +/// +/// To reduce ambiguity and unexpected version matching, _always_ specify an explicit operator. +/// +/// ### Prerelease version segments +/// +/// A comparator only ever matches a version with a prerelease segment when the comparator version +/// also defines a prerelease segment. Prerelease segments are only compared when the comparator +/// version and the target version have identical major, minor, and patch version segments. +/// Prerelease segments are compared as strings for ordering. +/// +/// The comparator `^1` can never match `1.2.3-rc.1` or `1.3.0-pre`. To define a prerelease +/// segment, you must define the major, minor, and patch version segments as literals without any +/// wildcards, like `1.2.3-rc`. +/// +/// To define a comparator with a version that matches any valid prerelease for that version, +/// specify the prerelease segment as `0`, like `1.2.3-0`. +/// +/// To help show how prerelease segments affect version matching, the following table defines +/// a series of comparators and whether different versions match those comparators. +/// +/// | Comparator version | Matching versions | Non-matching versions | +/// |:------------------:|:--------------------------------------------------------------------------|:------------------------------------------------------| +/// | `>=2.0.0` | `2.0.0`, `2.1.0`, `3.0.0` | `1.2.3`, `2.0.0-alpha.1`, `2.1.0-beta.2`, `3.0.0-rc1` | +/// | `>=2.0.0-alpha` | `2.0.0`, `2.1.0`, `3.0.0`, `2.0.0-alpha`, `2.0.0-alpha.1`, `2.0.0-beta.1` | `1.2.3`, `2.0.0-0` | +/// | `>=2.0.0-beta` | `2.0.0`, `2.1.0`, `3.0.0`, `2.0.0-beta`, `2.0.0-beta.1` | `1.2.3`, `2.0.0-alpha` | +/// | `>=2.0.0-0` | `2.0.0`, `2.1.0`, `3.0.0`, `2.0.0-1`, `2.0.0-alpha`, `2.0.0-beta` | `1.2.3` | +/// +/// ### Forbidding build metadata in comparator versions +/// +/// DSC forbids the inclusion of build metadata in comparator versions to reduce ambiguity. While +/// the underlying Rust implementation for version requirements ([`semver::VersionReq`]) allows +/// versions with build metadata, like `1.2.3+sha123` or `1.2.3-rc.1+dev.debug.linux`, it ignores +/// those segments entirely when matching a semantic version against the requirement. +/// +/// To prevent users from assuming that a version requirement might operate on the build metadata, +/// DSC forbids its inclusion in a version requirement string and raises the +/// [`SemVerReqWithBuildMetadata`] error during parsing if one is specified. +/// +/// ### Examples of invalid comparator versions +/// +/// The following list enumerates a series of invalid versions for comparators with the reasons why +/// each version is invalid for a comparator: +/// +/// - `*.*.*` - The major version segment must be a literal number, not a wildcard, like `1.*.*`. +/// If you want to allow any version, do not specify a version requirement explicitly. +/// - `1.*.3` - When the minor version segment is a wildcard, the patch version segment must either +/// be a wildcard or omitted, like `1.*.*` or `1.*`. +/// - `1.2-rc` - A prerelease segment is only valid when the major, minor, and patch version +/// segments are all defined as literals, like `1.2.0-rc`. +/// - `1.2.*-rc` - A prerelease segment is only valid when the major, minor, and patch version +/// segments are literals without any wildcards, like `1.2.0-rc`. +/// - `1.2.3-*` - Wildcards aren't permitted for prerelease version segments. To effectively +/// specify a prerelease segment that matches any prerelease versions for a given version, +/// define the prerelease segment as `0`. +/// - `1.2.3+sha123` - Build metadata segments aren't permitted for versions in comparators. +/// +/// ## Specifying comparator operators +/// +/// An operator defines how to compare a given [`SemanticVersion`] against the version component +/// of the comparator. The operator for a comparator is optional. For more information about how +/// comparators behave without an explicit operator, see +/// [Specifying comparators with implicit operators](#specifying-comparators-with-implicit-operators). +/// +/// The following list enumerates the available operators. Each definition includes a table of +/// examples demonstrating how the operator behaves. +/// +/// - Caret (`^`) - Indicates that the [`SemanticVersion`] must be +/// semantically compatible with the version for this comparator. The version must be equal to or +/// greater than the given version in the comparator and less than the next major version. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:----------------------:|:---------------------------------------------|:---------------------------------------------| +/// | `^1` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.*.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.2` | `>=1.2.0, <2.0.0` | `1.2.0`, `1.2.3`, `1.3.0` | `1.0.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.2.*` | `>=1.2.0, <2.0.0` | `1.2.0`, `1.2.3`, `1.3.0` | `1.0.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.2.3` | `>=1.2.3, <2.0.0` | `1.2.3`, `1.2.4`, `1.3.0` | `1.2.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `^1.2.3-rc.2` | `>=1.2.3-rc.2, <2.0.0` | `1.2.3`, `1.3.0`, `1.2.3-rc.2`, `1.2.3-rc.3` | `1.2.0`, `2.0.0`, `1.2.3-rc.1`, `1.3.0-rc.2` | +/// +/// - Tilde (`~`) - Indicates that the [`SemanticVersion`] must be +/// greater than or equal to the version for this comparator. The upper bound of matching +/// versions depends on how many components the version of the comparator defines: +/// +/// - If the comparator defines only the major version segment, like `~ 1`, the comparator +/// matches any version less than the next major version. +/// - If the comparator defines the major and minor version segments, like `~ 1.2` or `~ 1.2.3`, +/// the comparator matches any version less than the next minor version. +/// +/// The patch and prerelease segments of the version for the comparator only affect the minimum +/// version bound for the requirement. They don't affect the upper bound. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:----------------------:|:---------------------------------------------|:-------------------------------| +/// | `~1` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `~1.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `~1.*.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.0`, `1.3.0` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `~1.2` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.0.0`, `1.3.0`, `1.2.3-rc.1` | +/// | `~1.2.*` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.0.0`, `1.3.0`, `1.2.3-rc.1` | +/// | `~1.2.3` | `>=1.2.3, <1.3.0` | `1.2.3`, `1.2.9` | `1.2.0`, `1.3.0`, `1.2.3-rc.1` | +/// | `~1.2.3-rc.2` | `>=1.2.3-rc.2, <1.3.0` | `1.2.3`, `1.2.9`, `1.2.3-rc.2`, `1.2.3-rc.3` | `1.2.0`, `1.3.0`, `1.2.3-rc.1` | +/// +/// - Less than (`<`) - Indicates that the [`SemanticVersion`] must +/// be less than the version for this comparator. Versions equal to or greater than the +/// comparator version don't match the comparator. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:---------------------------------------|:------------------------------------------------| +/// | `<1` | `<1.0.0` |`0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | +/// | `<1.*` | `<1.0.0` |`0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | +/// | `<1.*.*` | `<1.0.0` |`0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | +/// | `<1.2` | `<1.2.0` | 0.1.0`, `1.0.0`, `1.1.1` | `1.2.0`, `1.2.3`, `1.3.0`, `1.2.0-rc.1`, | +/// | `<1.2.*` | `<1.2.0` | 0.1.0`, `1.0.0`, `1.1.1` | `1.2.0`, `1.2.3`, `1.3.0`, `1.2.0-rc.1`, | +/// | `<1.2.3` | `<1.2.3` | 0.1.0`, `1.0.0`, `1.2.0` | `1.2.3`, `1.3.0`, `1.2.3-rc.1` | +/// | `<1.2.3-rc.2` | `<1.2.3-rc.2` | 0.1.0`, `1.0.0`, `1.2.0`, `1.2.3-rc.1` | `1.2.3`, `1.3.0`, `1.0.0-rc.1`, ``1.2.3-rc.2 | +/// +/// - Less than or equal to (`<=`) - Indicates that the +/// [`SemanticVersion`] must be any version up to the version for this comparator. Versions +/// greater than the comparator version don't match the comparator. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:------------------------------------|:---------------------------------------------| +/// | `<=1` | `<2.0.0` | `0.1.0`, `1.0.0`, `1.2.3` | `2.0.0`, `0.1.0-rc.1`, `1.0.0-rc.1` | +/// | `<=1.*` | `<2.0.0` | `0.1.0`, `1.0.0`, `1.2.3` | `2.0.0`, `0.1.0-rc.1`, `1.0.0-rc.1` | +/// | `<=1.*.*` | `<2.0.0` | `0.1.0`, `1.0.0`, `1.2.3` | `2.0.0`, `0.1.0-rc.1`, `1.0.0-rc.1` | +/// | `<=1.2` | `<1.3.0` | `0.1.0`, `1.0.0`, `1.2.0`, `1.2.3` | `1.3.0`, `1.0.0-rc.1`, `1.2.0-rc.1` | +/// | `<=1.2.*` | `<1.3.0` | `0.1.0`, `1.0.0`, `1.2.0`, `1.2.3` | `1.3.0`, `1.0.0-rc.1`, `1.2.0-rc.1` | +/// | `<=1.2.3` | `<=1.2.3` | `0.1.0`, `1.0.0`, `1.2.3` | `1.2.4`, `1.3.0`, `1.2.0-rc.1`, `1.2.3-rc.1` | +/// | `<=1.2.3-rc.2` | `<=1.2.3-rc.2` | `0.1.0`, `1.2.3-rc.1`, `1.2.3-rc.2` | `1.2.3`, `1.3.0`, `1.0.0-rc.1`, `1.2.3-rc.3` | +/// +/// - Exact (`=`) - Indicates that the [`SemanticVersion`] must be +/// the same as the given version for this comparator. If the comparator version omits +/// version segments or specifies them as wildcards, then the comparator matches a range of +/// versions. A comparator that defines a literal patch version only matches that exact +/// version. A comparator that defines a prerelease segment only matches that exact patch version +/// and prerelease segment. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:-----------------|:--------------------------------| +/// | `=1` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.0.0-rc.2` | +/// | `=1.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.0.0-rc.2` | +/// | `=1.*.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.0.0-rc.2` | +/// | `=1.2` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.0.0`, `1.3.0`, `1.2.3-rc.2` | +/// | `=1.2.*` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.0.0`, `1.3.0`, `1.2.3-rc.2` | +/// | `=1.2.3` | `=1.2.3` | `1.2.3` | `1.2.0`, `1.3.0`, `1.2.3-rc.2` | +/// | `=1.2.3-rc.2` | `=1.2.3-rc.2` | `1.2.3-rc.2` | `1.2.3`, `1.3.0`, `1.2.3-rc.1` | +/// +/// - Greater than (`>`) - Indicates that the +/// [`SemanticVersion`] must be greater than the version for this comparator. Versions equal to +/// or less than the comparator version don't match the comparator. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:------------------------------|:------------------------------------| +/// | `>1` | `>=2.0.0` | `2.0.0`, `2.3.4` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.*` | `>=2.0.0` | `2.0.0`, `2.3.4` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.*.*` | `>=2.0.0` | `2.0.0`, `2.3.4` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.2` | `>=1.3.0` | `1.3.0`, `2.0.0` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.2.*` | `>=1.3.0` | `1.3.0`, `2.0.0` | `1.0.0`, `1.2.3`, `2.0.0-rc.2` | +/// | `>1.2.3` | `>=1.2.4` | `1.2.4`, `2.0.0` | `1.2.3`, `2.0.0-rc.2` | +/// | `>1.2.3-rc.1` | `>=1.2.3-rc.2` | `1.2.3`,`2.0.0`, `1.2.3-rc.3` | `1.2.0`, `1.2.3-rc.1`, `2.0.0-rc.2` | +/// +/// - Greater than or equal to (>=) - Indicates that +/// the [`SemanticVersion`] must be the same as the version for this comparator or newer. +/// Versions less than the comparator version don't match the comparator. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:---------------------------------------------|:------------------------------------| +/// | `>=1` | `>=1.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `1.2.3-rc.2` | +/// | `>=1.*` | `>=1.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `1.2.3-rc.2` | +/// | `>=1.*.*` | `>=1.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `1.2.3-rc.2` | +/// | `>=1.2` | `>=1.2.0` | `1.2.0`, `1.2.3` | `1.1.1`, `1.2.3-rc.2` | +/// | `>=1.2.*` | `>=1.2.0` | `1.2.0`, `1.2.3` | `1.1.1`, `1.2.3-rc.2` | +/// | `>=1.2.3` | `>=1.2.3` | `1.2.3`, `1.3.0` | `1.2.2`, `1.2.3-rc.2`, `2.0.0-rc.2` | +/// | `>=1.2.3-rc.1` | `>=1.2.3-rc.2` | `1.2.3`, `2.0.0`, `1.2.3-rc.2`, `1.2.3-rc.3` | `1.2.0`, `1.2.3-rc.1`, `2.0.0-rc.2` | +/// +/// - Wildcard - The wildcard operator is a purely implicit operator. +/// A comparator uses the wildcard operator when it defines a version that includes at least one +/// wildcard without an explicit operator. +/// +/// The wildcard operator is equivalent to the [exact operator (`=`)](#operator-exact). +/// +/// Because a comparator with a wildcard operator _always_ defines a version with one or more +/// wildcard segments, these comparators can _never_ match a prerelease version. +/// +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:---------------------:|:-----------------|:-------------------------------| +/// | `1.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `1.*.*` | `>=1.0.0, <2.0.0` | `1.0.0`, `1.2.3` | `0.1.0`, `2.0.0`, `1.2.3-rc.1` | +/// | `1.2.*` | `>=1.2.0, <1.3.0` | `1.2.0`, `1.2.3` | `1.1.1`, `1.3.0`, `1.2.3-rc.1` | +/// +/// ### Specifying comparators with implicit operators +/// +/// When you don't specify an explicit operator, the version requirement implicitly defaults to one +/// of two operators: +/// +/// 1. If the version doesn't define any wildcards, the implicit operator for the comparator is +/// the caret operator. The following sets of comparators are parsed identically: +/// +/// - `1` and `^ ` +/// - `1.2` and `^1.2` +/// - `1.2.3` and `^1.2.3` +/// - `1.2.3-rc.1` and `^1.2.3-rc.1` +/// +/// 1. If the version defines one or more wildcards, the implicit operator for the comparator is +/// the wildcard operator, which behaves like the exact operator (`=`). The following pairs of +/// comparators are equivalent: +/// +/// - `1.*` and `=1.*` +/// - `1.*.*` and `=1.*.*` +/// - `1.2.*` and `=1.2.*` +/// +/// A potentially confusing and ambiguous effect of the underlying implementation is that, except +/// for one case, omitting a version segment and specifying it as a wildcard have identical +/// behaviors. The exception is for defining a version with an implicit operator. The comparators +/// `1.2` and `1.2.*` are _not_ equivalent. +/// +/// The comparator `1.2` effectively expands to the comparator pair `>=1.2.0, <2.0.0` while the +/// comparator `1.2.*` effectively expands to `>=1.2.0, <1.3.0`. +/// +/// To avoid this ambiguity and potentially unexpected matching (or _not_ matching) of versions, +/// always explicitly define an operator for your comparators. +/// +/// # Serialization +/// +/// Note that during serialization instances of [`SemanticVersionReq`]: +/// +/// 1. If the originally parsed requirement uses an implicit operator and a version without any +/// wildcards, like `1.2.3`, it serializes with the caret operator as `^1.2.3`. +/// 1. If the originally parsed requirement defines an explicit operator and a version with any +/// wildcards, it serializes with the wildcard segments omitted. For example, consider the +/// following table showing how different comparators serialize: +/// +/// | Originally parsed comparator | Serialized comparator | +/// |:----------------------------:|:---------------------:| +/// | `^1.*` | `~1` | +/// | `^1.*.*` | `~1` | +/// | `^1.2.*` | `~1.2` | +/// +/// 1. If the originally parsed requirement has any separating spaces between an operator and +/// version, like `>= 1.2` or `>= 1.2`, it serializes without any spaces as `>= 1.2`. +/// 1. If the originally parsed requirement defines a pair of comparators, it always serializes the +/// pair separated by a comma followed by a single space. For example, all of the originally +/// parsed requirements in the following list serialize as `>=1.2, <1.5`: +/// +/// - `>=1.2,<1.5` +/// - `>=1.2 ,<1.5` +/// - `>=1.2, <1.5` +/// - `>=1.2 , <1.5` +/// +/// This can make it difficult to effectively round-trip a requirement when deserializing and +/// reserializing. To define a version requirement that will round-trip without any changes: +/// +/// 1. Always define an operator for each comparator. +/// 1. Always omit version segments rather than specifying a wildcard. +/// 1. Never separate operators and versions in a comparator with any spaces. +/// 1. When defining a requirement with multiple comparators, always follow the preceding +/// comparator with a comma followed by a single space before the succeeding comparator. +/// +/// The following table shows requirements that won't correctly round-trip with an equivalent +/// requirement that _does_ round trip. +/// +/// | Non-round-tripping requirement | Round-tripping requirement | +/// |:------------------------------:|:--------------------------:| +/// | `1` | `^1` | +/// | `1.2` | `^1.2` | +/// | `1.2.3` | `^1.2.3` | +/// | `^1.2.*` | `^1.2` | +/// | `> 1.2 , <= 1.5.*` | `>1.2, <=1.5` | +/// +/// # Best practices for defining version requirements +/// +/// When defining a comparator for a version requirement, always: +/// +/// 1. Define an explicit operator for every comparator, like `^1` or `^1.2` instead of `1` or +/// `1.2`. +/// +/// This reduces ambiguity in the behavior for the comparators and reduces the likelihood of +/// changing the requirement string when round-tripping through serialization and +/// deserialization. +/// 1. Immediately follow the explicit operator with the version, like `>1.2` instead of `> 1.2`. +/// +/// This reduces the likelihood of changing the requirement string when round-tripping through +/// serialization and deserialization. +/// 1. Omit version segments instead of using wildcards, like `>1.2` instead of `>1.2.*`. +/// +/// This reduces the likelihood of changing the requirement string when round-tripping through +/// serialization and deserialization. +/// 1. Separate subsequent comparators from previous comparators in the requirement with a comma +/// followed by a single space, like `>=1.2, <1.5` instead of `>=1.2,<1.5`, `>=1.2 ,<1.5`, +/// or `>=1.2 , <1.5`. +/// +/// This reduces the likelihood of changing the requirement string when round-tripping through +/// serialization and deserialization. +/// 1. Define the requirement without leading or trailing spaces, like `^1.2` instead of +/// ` ^1.2 `. +/// +/// This reduces the likelihood of changing the requirement when round-tripping through +/// serialization and deserialization. +/// +/// [01]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#version-requirement-syntax +/// [`SemVerReqWithBuildMetadata`]: DscError::SemVerReqWithBuildMetadata +#[derive(Debug, Clone, Hash, Eq, Serialize, Deserialize, DscRepoSchema)] +#[dsc_repo_schema(base_name = "semverRequirement", folder_path = "definitions")] +pub struct SemanticVersionReq(semver::VersionReq); + +/// This static lazily defines the validating regex for [`SemanticVersionReq`]. It enables the +/// [`Regex`] instance to be constructed once, the first time it's used, and then reused on all +/// subsequent validation calls. It's kept private, since the API usage is to invoke the +/// [`SemanticVersionReq::parse()`] method to validate and parse a string into a version requirement. +/// +/// This pattern is used to forbid the inclusion of build metadata in a version requirement for DSC, +/// since Rust allows but ignores that segment of a semantic version. +static FORBIDDING_BUILD_METADATA_REGEX: OnceLock = OnceLock::new(); + +impl SemanticVersionReq { + /// Returns the [`Regex`] for [`FORBIDDING_BUILD_METADATA_PATTERN`]. + /// + /// This private method is used to initialize the [`FORBIDDING_BUILD_METADATA_REGEX`] private + /// static to reduce the number of times the regular expression is compiled from the pattern + /// string. + fn init_pattern() -> Regex { + Regex::new(Self::FORBIDDING_BUILD_METADATA_PATTERN).expect("pattern is valid") + } + + /// Parses a given string into a semantic version requirement. + /// + /// # Errors + /// + /// The parse function returns an error when the string isn't a valid version requirement. + /// Common parse failures include: + /// + /// - Specifying a literal version segment after a wildcard, like `*.1` or `1.x.3`. + /// - Specifying a wildcard in the prerelease segment, like `1.2.3-*` or `2.0.0-rc.*`. Note that + /// specifying an `x` or `X` as a wildcard for the prerelease segment parses but is treated + /// as a literal `x` or `X` in the comparison logic because singular alphabetic characters are + /// valid prerelease segments. + /// - Specifying the build metadata segment, like `1.2.3+dev` or `1.2.3-rc.1+dev`. + /// - Specifying an invalid comparison operator, like `!3.0.0`. + /// - Specifying an invalid character for a version segment, like `>a.b`. + /// - Not specifying an additional comparator after a comma, like `>=1.*,`, + /// - Not specifying a comma between comparators, like `>=1.2 <1.9`. + pub fn parse(text: &str) -> Result { + // Check first for build metadata and error if discovered + let pattern = FORBIDDING_BUILD_METADATA_REGEX.get_or_init(Self::init_pattern); + if let Some(captures) = pattern.captures(text) { + let version = captures.get_match().as_str().to_string(); + let build = captures + .name("buildmetadata") + .map_or("", |m| m.as_str()) + .to_string(); + + return Err(DscError::SemVerReqWithBuildMetadata(version, build)); + } + + // Parse as underlying type and raise wrapped error if invalid + match semver::VersionReq::parse(text) { + Ok(requirement) => Ok(Self(requirement)), + Err(e) => Err(DscError::SemVer(e)), + } + } + + /// Checks whether a given [`SemanticVersion`] is valid for defined requirement. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::{SemanticVersion, SemanticVersionReq}; + /// + /// let requirement = SemanticVersionReq::parse("^1.2.3").unwrap(); + /// + /// // 1.3.0 is compatible with the requirement. + /// assert!(requirement.matches(&SemanticVersion::new(1, 3, 0))); + /// // 2.0.0 isn't compatible with the requirement. + /// assert!(!requirement.matches(&SemanticVersion::new(2, 0, 0))); + /// ``` + pub fn matches(&self, version: &SemanticVersion) -> bool { + self.0.matches(version.as_ref()) + } + + /// Defines the validating regular expression for semantic version requirements. + /// + /// This regular expression is used for the `pattern` keyword in the JSON Schema for the + /// [`SemanticVersionReq`] type. + /// + /// The pattern is also used for validating an instance during parsing and deserialization. DSC + /// uses a stricter subset of valid syntax for a version requirement: + /// + /// - DSC forbids the inclusion of build metadata, which the underlying version requirement + /// silently ignores. + /// - DSC forbids the use of `x` and `X` as wildcards for version segments. Only an asterisk + /// (`*`) is a valid wildcard. + pub const VALIDATING_PATTERN: &str = const_str::concat!( + "^", // Anchor to start of string + SemanticVersionReq::COMPARATOR_PATTERN, // Capture first comparator + "(?:", // Open non-capturing group for additional comparators + r"\s*,\s*", // Additional comparators must follow a comma with optional spacing around it + SemanticVersionReq::COMPARATOR_PATTERN, // Capture the additional comparator + ")", // Close the non-capturing group for additional comparators + "*", // Mark additional comparators as allowed any number of times + "$", // Anchor to end of string + ); + + /// Defines the regular expression for matching a literal version with build metadata. + /// + /// DSC forbids the inclusion of build metadata in a version requirement. To provide better + /// error messaging, DSC uses this pattern to discover the inclusion of build metadata during + /// parsing and report it to the user. + pub const FORBIDDING_BUILD_METADATA_PATTERN: &str = const_str::concat!( + SemanticVersionReq::LITERAL_VERSION_PATTERN, // Match a literal version + "(?:", // Open non-capturing group for build metadata and prefix + r"\+", // Build metadata is always preceded by a plus sign + SemanticVersion::CAPTURING_BUILD_METADATA_PATTERN, // Capture the build metadata + ")", // Close non-capturing group for build metadata and prefix + ); + + /// Defines the regular expression for matching a wildcard instead of a version segment. + /// + /// While Rust supports specifying the wildcard as `x`, `X`, or `*`, DSC only supports `*` to + /// minimize ambiguity. + pub const WILDCARD_SYMBOL_PATTERN: &str = r"\*"; + + /// Defines the regular expression for matching a version requirement operator. + /// + /// Rust and DSC both support the following table of operators: + /// + /// | Operator | Name | + /// |:--------:|:------------------------:| + /// | `^` | Caret | + /// | `~` | Tilde | + /// | `=` | Equals | + /// | `<` | Less than | + /// | `<=` | Less than or equal to | + /// | `>` | Greater than | + /// | `>=` | Greater than or equal to | + pub const OPERATOR_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group + ">=", // Requirements can be greater than or equal to + "|", // or + ">", // greater than + "|", // or + "<", // less than + "|", // or + "<=", // less than or equal to + "|", // or + "=", // exactly equal + "|", // or + r"\^", // semver-compatible (caret, also default when no prefix defined) + "|", // or + "~", // minimal-version (tilde) + ")", // Close the non-capturing group + "?", // Mark the operator as optional + ); + + /// Defines the regular expression for matching a comparator with optional leading operator + /// followed by a literal or wildcard version. + pub const COMPARATOR_PATTERN: &str = const_str::concat!( + SemanticVersionReq::OPERATOR_PATTERN, // Match the operator, if any + r"\s*", // allow any number of spaces after operator + "(?:", // Open non-capturing group for wildcard-literal version selection + SemanticVersionReq::LITERAL_VERSION_PATTERN, // Match literal version + "|", // or + SemanticVersionReq::WILDCARD_VERSION_PATTERN, // Match version with wildcard + ")", // Close non-capturing group for wildcard-literal version selection + ); + + /// Defines the regular expression for matching a literal version. + /// + /// Literal versions must define the major version segment. The minor, patch, and prerelease + /// segments are optional. The build metadata segment is forbidden. + pub const LITERAL_VERSION_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group for literal version + SemanticVersion::VERSION_SEGMENT_PATTERN, // Must define the major version. + "(?:", // Open non-capturing group for optional minor and patch segments + r"\.", // Major version must be followed by a period if minor is specified. + SemanticVersion::VERSION_SEGMENT_PATTERN, // Match the minor version. + "(?:", // Open non-capturing group for optional patch segment + r"\.", // Minor version must be followed by a period if patch is specified. + SemanticVersion::VERSION_SEGMENT_PATTERN, // Match the patch version. + SemanticVersionReq::PRERELEASE_PATTERN, // Match prerelease, if any - only valid with patch + ")?", // Open non-capturing group for optional patch segment + ")?", // Close non-capturing group for optional minor and patch segments + ")", // Close non-capturing group for literal version + ); + + /// Defines the regular expression for matching a version with a wildcard segment. + /// + /// Wildcard versions must define the major version segment. The minor and patch segments are + /// optional. The prerelease and build metadata segments are forbidden. + /// + /// If the wildcard version defines the minor version segment as a wildcard, it must not define + /// the patch segment. If the wildcard version defines the minor version segment as a literal + /// version segment, it may define the patch version segment as a wildcard. + /// + /// The following table shows a few example wildcard versions, whether they are valid, and why + /// an example version is invalid. + /// + /// | Wildcard version | Valid | Notes | + /// |:----------------:|:-----:|:------------------------------------------------------------------------------------| + /// | `1.*` | Yes | Defines a literal major version segment followed by a wildcard minor version. | + /// | `1.2.*` | Yes | Defines literal major and minor segments followed by a wildcard patch version. | + /// | `1.*.*` | No | Defines more than one wildcard, which is forbidden. | + /// | `1.*.3` | No | If the version includes any wildcards, it must be the last defined version segment. | + /// | `1.2.3-*` | No | Defines the prerelease segment as a wildcard, which is forbidden. | + pub const WILDCARD_VERSION_PATTERN: &str = const_str::concat!( + SemanticVersion::VERSION_SEGMENT_PATTERN, // Must match the (literal) major version + "(?:", // Open non-capturing group for optional minor and patch segments + r"\.", // Must follow major version with period before minor version + "(?:", // Open non-capturing group for literal-or-wildcard minor + "(?:", // Open non-capturing group for literal minor followed by optional patch + SemanticVersion::VERSION_SEGMENT_PATTERN, // Match literal minor version + "(?:", // Open non-capturing group for optional literal-or-wildcard patch + r"\.", // Must follow minor version with period before patch version + "(?:", // Open non-capturing group to select between wildcard-or-literal patch + SemanticVersion::VERSION_SEGMENT_PATTERN, // Match literal patch version + "|", // or + SemanticVersionReq::WILDCARD_SYMBOL_PATTERN, // Match patch version as wildcard + ")", // Close non-capturing group to select between wildcard-or-literal patch + ")?", // Close non-capturing group for optional literal-or-wildcard patch + ")", // Close non-capturing group for literal minor followed by optional patch + "|", // or + SemanticVersionReq::WILDCARD_SYMBOL_PATTERN, // Match minor version as wildcard, must not have following patch + ")", // Close non-capturing group for literal-or-wildcard minor + ")?", // Close non-capturing group for optional minor and patch segments + ); + + /// Defines the regular expression for matching a literal prerelease segment. + /// + /// Prerelease segments are only valid after the patch version in a literal version. A version + /// requirement must not specify a prerelease segment with any wildcards in it or after a + /// wildcard version. + pub const PRERELEASE_PATTERN: &str = const_str::concat!( + "(?:", // Open non-capturing group for optional prerelease + "-", // Must precede prerelease segment with a hyphen + SemanticVersion::PRERELEASE_SEGMENT_PATTERN, // Match literal prerelease segment, wildcards not allowed + ")?", // Close non-capturing group for optional prerelease + ); +} + +impl JsonSchema for SemanticVersionReq { + fn schema_name() -> std::borrow::Cow<'static, str> { + Self::default_schema_id_uri().into() + } + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "title": t!("schemas.definitions.semverReq.title"), + "description": t!("schemas.definitions.semverReq.description"), + "markdownDescription": t!("schemas.definitions.semverReq.markdownDescription"), + "type": "string", + "pattern": SemanticVersionReq::VALIDATING_PATTERN, + "patternErrorMessage": t!("schemas.definitions.semverReq.patternErrorMessage"), + "examples": [ + "1.2.3", + ">=1.2.3, <2.0.0", + "^1.2", + "~2.3", + ] + }) + } +} + +impl Default for SemanticVersionReq { + fn default() -> Self { + Self(semver::VersionReq::default()) + } +} + +impl Display for SemanticVersionReq { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +// Infallible conversions +impl From for SemanticVersionReq { + fn from(value: semver::VersionReq) -> Self { + Self(value) + } +} + +impl From for semver::VersionReq { + fn from(value: SemanticVersionReq) -> Self { + value.0 + } +} + +impl From<&semver::VersionReq> for SemanticVersionReq { + fn from(value: &semver::VersionReq) -> Self { + Self(value.clone()) + } +} + +impl From<&SemanticVersionReq> for semver::VersionReq { + fn from(value: &SemanticVersionReq) -> Self { + value.0.clone() + } +} + +impl From for String { + fn from(value: SemanticVersionReq) -> Self { + value.to_string() + } +} + +// Fallible conversions +impl FromStr for SemanticVersionReq { + type Err = DscError; + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl TryFrom for SemanticVersionReq { + type Error = DscError; + fn try_from(value: String) -> Result { + match semver::VersionReq::parse(value.as_str()) { + Ok(r) => Ok(Self(r)), + Err(e) => Err(DscError::SemVer(e)), + } + } +} + +impl TryFrom<&str> for SemanticVersionReq { + type Error = DscError; + fn try_from(value: &str) -> Result { + SemanticVersionReq::from_str(value) + } +} + +// Referencing and dereferencing +impl AsRef for SemanticVersionReq { + fn as_ref(&self) -> &semver::VersionReq { + &self.0 + } +} + +impl Deref for SemanticVersionReq { + type Target = semver::VersionReq; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +// Comparison traits +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &semver::VersionReq) -> bool { + &self.0 == other + } +} + +impl PartialEq for semver::VersionReq { + fn eq(&self, other: &SemanticVersionReq) -> bool { + self == &other.0 + } +} + +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &String) -> bool { + match Self::parse(other.as_str()) { + Ok(o) => self == &o, + Err(_) => false + } + } +} + +impl PartialEq for String { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match SemanticVersionReq::parse(self.as_str()) { + Ok(s) => &s == other, + Err(_) => false + } + } +} + +impl PartialEq<&String> for SemanticVersionReq { + fn eq(&self, other: &&String) -> bool { + match Self::parse(other.as_str()) { + Ok(o) => self == &o, + Err(_) => false + } + } +} + +impl PartialEq for &String { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match SemanticVersionReq::parse(self.as_str()) { + Ok(s) => &s == other, + Err(_) => false + } + } +} + +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &str) -> bool { + match Self::parse(other) { + Ok(o) => self == &o, + Err(_) => false + } + } +} + +impl PartialEq for str { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match SemanticVersionReq::parse(self) { + Ok(s) => &s == other, + Err(_) => false + } + } +} + +impl PartialEq<&str> for SemanticVersionReq { + fn eq(&self, other: &&str) -> bool { + match Self::parse(*other) { + Ok(o) => self == &o, + Err(_) => false + } + } +} + +impl PartialEq for &str { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match SemanticVersionReq::parse(*self) { + Ok(s) => &s == other, + Err(_) => false + } + } +} diff --git a/lib/dsc-lib/tests/integration/types/mod.rs b/lib/dsc-lib/tests/integration/types/mod.rs index 683b954c7..cda985580 100644 --- a/lib/dsc-lib/tests/integration/types/mod.rs +++ b/lib/dsc-lib/tests/integration/types/mod.rs @@ -5,3 +5,5 @@ mod fully_qualified_type_name; #[cfg(test)] mod semantic_version; +#[cfg(test)] +mod semantic_version_req; diff --git a/lib/dsc-lib/tests/integration/types/semantic_version_req.rs b/lib/dsc-lib/tests/integration/types/semantic_version_req.rs new file mode 100644 index 000000000..de4167e27 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/semantic_version_req.rs @@ -0,0 +1,647 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + #[cfg(test)] + mod parse { + use dsc_lib::{dscerror::DscError, types::SemanticVersionReq}; + use test_case::test_case; + + #[test_case("1" => matches Ok(_); "major is valid")] + #[test_case("1.2" => matches Ok(_); "major.minor is valid")] + #[test_case("1.2.3" => matches Ok(_); "major.minor.patch is valid")] + #[test_case("1.2.3-pre" => matches Ok(_); "major.minor.patch-pre is valid")] + #[test_case("1-pre" => matches Err(_); "major-pre is invalid")] + #[test_case("1.2-pre" => matches Err(_); "major.minor-pre is invalid")] + #[test_case("1.2.3+build" => matches Err(_); "major.minor.patch+build is invalid")] + #[test_case("1.2.3-pre+build" => matches Err(_); "major.minor.patch-pre+build is invalid")] + #[test_case("a" => matches Err(_); "invalid_char is invalid")] + #[test_case("1.b" => matches Err(_); "major.invalid_char is invalid")] + #[test_case("1.2.c" => matches Err(_); "major.minor.invalid_char is invalid")] + fn literal_version(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + + #[test_case("1.*" => matches Ok(_); "major.wildcard is valid")] + #[test_case("1.*.*" => matches Ok(_); "major.wildcard.wildcard is valid")] + #[test_case("1.2.*" => matches Ok(_); "major.minor.wildcard is valid")] + #[test_case("1.*.3" => matches Err(_); "major.wildcard.patch is invalid")] + #[test_case("1.2.*-pre" => matches Err(_); "major.minor.wildcard-pre is invalid")] + #[test_case("1.*.*-pre" => matches Err(_); "major.wildcard.wildcard-pre is invalid")] + #[test_case("1.2.3-*" => matches Err(_); "major.minor.patch-wildcard is invalid")] + #[test_case("1.2.3-pre.*" => matches Err(_); "major.minor.patch-pre.wildcard is invalid")] + fn wildcard_version(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + + #[test_case("1.2.3" => matches Ok(_); "implicit operator is valid")] + #[test_case("^ 1.2.3" => matches Ok(_); "caret operator is valid")] + #[test_case("~ 1.2.3" => matches Ok(_); "tilde operator is valid")] + #[test_case("= 1.2.3" => matches Ok(_); "exact operator is valid")] + #[test_case("> 1.2.3" => matches Ok(_); "greater than operator is valid")] + #[test_case(">= 1.2.3" => matches Ok(_); "greater than or equal to operator is valid")] + #[test_case("< 1.2.3" => matches Ok(_); "less than operator is valid")] + #[test_case("<= 1.2.3" => matches Ok(_); "less than or equal to operator is valid")] + #[test_case("== 1.2.3" => matches Err(_); "invalid operator is invalid")] + fn operators(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + + #[test_case("1.2.3, < 1.5" => matches Ok(_); "pair with separating comma is valid")] + #[test_case("1, 1.2, 1.2.3" => matches Ok(_); "triple with separating comma is valid")] + #[test_case("<= 1, >= 2" => matches Ok(_); "incompatible pair is valid")] + #[test_case(", 1, 1.2" => matches Err(_); "leading comma is invalid")] + #[test_case("1, 1.2," => matches Err(_); "trailing comma is invalid")] + #[test_case("1 1.2" => matches Err(_); "omitted separating comma is invalid")] + #[test_case("1.*, < 1.3.*" => matches Ok(_); "multiple comparators with wildcard is valid")] + fn multiple_comparators(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + + #[test_case("^1.2" => matches Ok(_); "operator and version without spacing is valid")] + #[test_case("^ 1.2" => matches Ok(_); "operator and version with extra spacing is valid")] + #[test_case(" ^ 1.2" => matches Ok(_); "leading space is valid")] + #[test_case("^ 1.2 " => matches Ok(_); "trailing space is valid")] + #[test_case("^1.2,<1.5" => matches Ok(_); "pair of comparators without spacing is valid")] + #[test_case(" ^ 1.2 , < 1.5 " => matches Ok(_); "pair of comparators with extra spacing is valid")] + fn spacing(requirement_string: &str) -> Result { + SemanticVersionReq::parse(requirement_string) + } + } + + #[cfg(test)] + mod matches { + use dsc_lib::types::SemanticVersionReq; + use test_case::test_case; + + fn check(requirement: &str, versions: Vec<&str>, should_match: bool) { + let req = SemanticVersionReq::parse(requirement).unwrap(); + let expected = if should_match { "match" } else { "not match" }; + for version in versions { + pretty_assertions::assert_eq!( + req.matches(&version.parse().unwrap()), + should_match, + "expected version '{version}' to {expected} requirement '{requirement}'" + ); + } + } + + #[test_case("1", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major")] + #[test_case("1", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major")] + #[test_case("1.2", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching major.minor")] + #[test_case("1.2", vec!["1.0.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor")] + #[test_case("1.2.3", vec!["1.2.3", "1.2.4", "1.3.0"], true; "matching major.minor.patch")] + #[test_case("1.2.3", vec!["1.2.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case("1.2.3-rc.2", vec!["1.2.0", "2.0.0", "1.2.3-rc.1", "1.3.0-rc.2"], false; "not matching major.minor.patch-pre")] + fn implicit(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("^1", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major")] + #[test_case("^1", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major")] + #[test_case("^1.*", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major.wildcard")] + #[test_case("^1.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.wildcard")] + #[test_case("^1.*.*", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major.wildcard.wildcard")] + #[test_case("^1.*.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.wildcard.wildcard")] + #[test_case("^1.2", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching major.minor")] + #[test_case("^1.2", vec!["1.0.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor")] + #[test_case("^1.2.*", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching major.minor.wildcard")] + #[test_case("^1.2.*", vec!["1.0.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor.wildcard")] + #[test_case("^1.2.3", vec!["1.2.3", "1.2.4", "1.3.0"], true; "matching major.minor.patch")] + #[test_case("^1.2.3", vec!["1.2.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("^1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case("^1.2.3-rc.2", vec!["1.2.0", "2.0.0", "1.2.3-rc.1", "1.3.0-rc.2"], false; "not matching major.minor.patch-pre")] + fn caret(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("~1", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major")] + #[test_case("~1", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major")] + #[test_case("~1.*", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major.wildcard")] + #[test_case("~1.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.wildcard")] + #[test_case("~1.*.*", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major.wildcard.wildcard")] + #[test_case("~1.*.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matching major.wildcard.wildcard")] + #[test_case("~1.2", vec!["1.2.0", "1.2.3"], true; "matching major.minor")] + #[test_case("~1.2", vec!["1.0.0", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor")] + #[test_case("~1.2.*", vec!["1.2.0", "1.2.3"], true; "matching major.minor.wildcard")] + #[test_case("~1.2.*", vec!["1.0.0", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.wildcard")] + #[test_case("~1.2.3", vec!["1.2.3", "1.2.9"], true; "matching major.minor.patch")] + #[test_case("~1.2.3", vec!["1.2.0", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("~1.2.3-rc.2", vec!["1.2.3", "1.2.9", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case("~1.2.3-rc.2", vec!["1.2.0", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.patch-pre")] + fn tilde(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("<1", vec!["0.1.0"], true; "matching major")] + #[test_case("<1", vec!["1.0.0", "1.2.3", "0.1.0-rc.1"], false; "not matching major")] + #[test_case("<1.*", vec!["0.1.0"], true; "matching major.wildcard")] + #[test_case("<1.*", vec!["1.0.0", "1.2.3", "0.1.0-rc.1"], false; "not matching major.wildcard")] + #[test_case("<1.*.*", vec!["0.1.0"], true; "matching major.wildcard.wildcard")] + #[test_case("<1.*.*", vec!["1.0.0", "1.2.3", "0.1.0-rc.1"], false; "not matching major.wildcard.wildcard")] + #[test_case("<1.2", vec!["0.1.0", "1.0.0", "1.1.1"], true; "matching major.minor")] + #[test_case("<1.2", vec!["1.2.0", "1.2.3", "1.3.0", "1.2.0-rc.1"], false; "not matching major.minor")] + #[test_case("<1.2.3", vec!["0.1.0", "1.0.0", "1.2.0"], true; "matching major.minor.patch")] + #[test_case("<1.2.3", vec!["1.2.3", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("<1.2.3-rc.2", vec!["0.1.0", "1.2.0", "1.2.3-rc.1"], true; "matching major.minor.patch-pre")] + #[test_case("<1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.0.0-rc.1", "1.2.3-rc.2"], false; "not matching major.minor.patch-pre")] + fn less_than(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("<=1", vec!["0.1.0", "1.0.0", "1.2.3"], true; "matching major")] + #[test_case("<=1", vec!["2.0.0", "0.1.0-rc.1", "1.0.0-rc.1"], false; "not matching major")] + #[test_case("<=1.*", vec!["0.1.0", "1.0.0", "1.2.3"], true; "matching major.wildcard")] + #[test_case("<=1.*", vec!["2.0.0", "0.1.0-rc.1", "1.0.0-rc.1"], false; "not matching major.wildcard")] + #[test_case("<=1.*.*", vec!["0.1.0", "1.0.0", "1.2.3"], true; "matching major.wildcard.wildcard")] + #[test_case("<=1.*.*", vec!["2.0.0", "0.1.0-rc.1", "1.0.0-rc.1"], false; "not matching major.wildcard.wildcard")] + #[test_case("<=1.2", vec!["0.1.0", "1.0.0", "1.2.0", "1.2.3"], true; "matching major.minor")] + #[test_case("<=1.2", vec!["1.3.0", "1.0.0-rc.1", "1.2.0-rc.1"], false; "not matching major.minor")] + #[test_case("<=1.2.*", vec!["0.1.0", "1.0.0", "1.2.0", "1.2.3"], true; "matching major.minor.wildcard")] + #[test_case("<=1.2.*", vec!["1.3.0", "1.0.0-rc.1", "1.2.0-rc.1"], false; "not matching major.minor.wildcard")] + #[test_case("<=1.2.3", vec!["0.1.0", "1.0.0", "1.2.3"], true; "matching major.minor.patch")] + #[test_case("<=1.2.3", vec!["1.2.4", "1.3.0", "1.2.0-rc.1", "1.2.3-rc.1"], false; "not matching major.minor.patch")] + #[test_case("<=1.2.3-rc.2", vec!["0.1.0", "1.2.3-rc.1", "1.2.3-rc.2"], true; "matching major.minor.patch-pre")] + #[test_case("<=1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.0.0-rc.1", "1.2.3-rc.3"], false; "not matching major.minor.patch-pre")] + fn less_than_or_equal_to(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("=1", vec!["1.0.0", "1.2.3"], true; "matching major")] + #[test_case("=1", vec!["0.1.0", "2.0.0", "1.0.0-rc.2"], false; "not matching major")] + #[test_case("=1.*", vec!["1.0.0", "1.2.3"], true; "matching major.wildcard")] + #[test_case("=1.*", vec!["0.1.0", "2.0.0", "1.0.0-rc.2"], false; "not matching major.wildcard")] + #[test_case("=1.*.*", vec!["1.0.0", "1.2.3"], true; "matching major.wildcard.wildcard")] + #[test_case("=1.*.*", vec!["0.1.0", "2.0.0", "1.0.0-rc.2"], false; "not matching major.wildcard.wildcard")] + #[test_case("=1.2", vec!["1.2.0", "1.2.3"], true; "matching major.minor")] + #[test_case("=1.2", vec!["1.0.0", "1.3.0", "1.2.3-rc.2"], false; "not matching major.minor")] + #[test_case("=1.2.*", vec!["1.2.0", "1.2.3"], true; "matching major.minor.wildcard")] + #[test_case("=1.2.*", vec!["1.0.0", "1.3.0", "1.2.3-rc.2"], false; "not matching major.minor.wildcard")] + #[test_case("=1.2.3", vec!["1.2.3"], true; "matching major.minor.patch")] + #[test_case("=1.2.3", vec!["1.2.0", "1.3.0", "1.2.3-rc.2"], false; "not matching major.minor.patch")] + #[test_case("=1.2.3-rc.2", vec!["1.2.3-rc.2"], true; "matching major.minor.patch-pre")] + #[test_case("=1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.2.3-rc.1"], false; "not matching major.minor.patch-pre")] + fn equal_to(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case(">1", vec!["2.0.0", "2.3.4"], true; "matching major")] + #[test_case(">1", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major")] + #[test_case(">1.*", vec!["2.0.0", "2.3.4"], true; "matching major.wildcard")] + #[test_case(">1.*", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major.wildcard")] + #[test_case(">1.*.*", vec!["2.0.0", "2.3.4"], true; "matching major.wildcard.wildcard")] + #[test_case(">1.*.*", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major.wildcard.wildcard")] + #[test_case(">1.2", vec!["1.3.0", "2.0.0"], true; "matching major.minor")] + #[test_case(">1.2", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major.minor")] + #[test_case(">1.2.*", vec!["1.3.0", "2.0.0"], true; "matching major.minor.wildcard")] + #[test_case(">1.2.*", vec!["1.0.0", "1.2.3", "2.0.0-rc.2"], false; "not matching major.minor.wildcard")] + #[test_case(">1.2.3", vec!["1.2.4", "2.0.0"], true; "matching major.minor.patch")] + #[test_case(">1.2.3", vec!["1.2.3", "2.0.0-rc.2"], false; "not matching major.minor.patch")] + #[test_case(">1.2.3-rc.2", vec!["1.2.3","2.0.0", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case(">1.2.3-rc.2", vec!["1.2.0", "1.2.3-rc.1", "2.0.0-rc.2"], false; "not matching major.minor.patch-pre")] + fn greater_than(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case(">=1", vec!["1.0.0", "1.2.3"], true; "matching major")] + #[test_case(">=1", vec!["0.1.0", "1.2.3-rc.2"], false; "not matching major")] + #[test_case(">=1.*", vec!["1.0.0", "1.2.3"], true; "matching major.wildcard")] + #[test_case(">=1.*", vec!["0.1.0", "1.2.3-rc.2"], false; "not matching major.wildcard")] + #[test_case(">=1.*.*", vec!["1.0.0", "1.2.3"], true; "matching major.wildcard.wildcard")] + #[test_case(">=1.*.*", vec!["0.1.0", "1.2.3-rc.2"], false; "not matching major.wildcard.wildcard")] + #[test_case(">=1.2", vec!["1.2.0", "1.2.3"], true; "matching major.minor")] + #[test_case(">=1.2", vec!["1.1.1", "1.2.3-rc.2"], false; "not matching major.minor")] + #[test_case(">=1.2.*", vec!["1.2.0", "1.2.3"], true; "matching major.minor.wildcard")] + #[test_case(">=1.2.*", vec!["1.1.1", "1.2.3-rc.2"], false; "not matching major.minor.wildcard")] + #[test_case(">=1.2.3", vec!["1.2.3", "1.3.0"], true; "matching major.minor.patch")] + #[test_case(">=1.2.3", vec!["1.2.2", "1.2.3-rc.2", "2.0.0-rc.2"], false; "not matching major.minor.patch")] + #[test_case(">=1.2.3-rc.2", vec!["1.2.3", "2.0.0", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case(">=1.2.3-rc.2", vec!["1.2.0", "1.2.3-rc.1", "2.0.0-rc.2"], false; "not matching major.minor.patch-pre")] + fn greater_than_or_equal_to(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case(">= 2.0.0", vec!["2.0.0", "2.1.0", "3.0.0"], true; "matching major.minor.patch")] + #[test_case(">= 2.0.0", vec!["1.2.3", "2.0.0-0", "2.0.0-alpha.1", "2.1.0-beta.2", "3.0.0-rc.1"], false; "not matching major.minor.patch")] + #[test_case(">= 2.0.0-alpha", vec!["2.0.0", "2.1.0", "3.0.0", "2.0.0-alpha","2.0.0-alpha.1", "2.0.0-beta.1"], true; "matching major.minor.patch-alpha")] + #[test_case(">= 2.0.0-alpha", vec!["1.2.3", "2.0.0-0", "3.0.0-alpha.1"], false; "not matching major.minor.patch-alpha")] + #[test_case(">= 2.0.0-beta.2", vec!["2.0.0", "2.1.0", "3.0.0", "2.0.0-beta.2", "2.0.0-beta.3", "2.0.0-rc.1"], true; "matching major.minor.patch-beta.2")] + #[test_case(">= 2.0.0-beta.2", vec!["1.2.3", "2.0.0-alpha", "2.0.0-beta.1", "3.0.0-rc.1"], false; "not matching major.minor.patch-beta.2")] + #[test_case(">= 2.0.0-0", vec!["2.0.0", "2.1.0", "3.0.0", "2.0.0-0", "2.0.0-1", "2.0.0-alpha", "2.0.0-beta.1"], true; "matching major.minor.patch-0")] + #[test_case(">= 2.0.0-0", vec!["1.2.3", "3.0.0-rc.1"], false; "not matching major.minor.patch-0")] + fn prerelease(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("1.*", vec!["1.0.0", "1.2.3"], true; "matches major.wildcard")] + #[test_case("1.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matches major.wildcard")] + #[test_case("1.*.*", vec!["1.0.0", "1.2.3"], true; "matches major.wildcard.wildcard")] + #[test_case("1.*.*", vec!["0.1.0", "2.0.0", "1.2.3-rc.1"], false; "not matches major.wildcard.wildcard")] + #[test_case("1.2.*", vec!["1.2.0", "1.2.3"], true; "matches major.minor.wildcard")] + #[test_case("1.2.*", vec!["1.1.1", "1.3.0", "1.2.3-rc.1"], false; "not matches major.minor.wildcard")] + fn wildcard(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case(">=1.2, <1.4.0", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching multiple compatible requirements")] + #[test_case(">=1.2, <1.4.0", vec!["1.1.0", "1.4.0", "1.3.0-rc.1"], false; "not matching multiple compatible requirements")] + #[test_case("<=1.2, >1.4.0", vec!["1.0.0", "1.2.3", "1.3.0", "1.4.0", "2.0.0", "2.3.4-rc.1"], false; "never matching multiple incompatible requirements")] + fn multiple(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + } +} + +#[cfg(test)] +mod patterns { + use dsc_lib::types::SemanticVersionReq; + use test_case::test_case; + + #[test_case(SemanticVersionReq::PRERELEASE_PATTERN; "PRERELEASE_PATTERN")] + #[test_case(SemanticVersionReq::WILDCARD_VERSION_PATTERN; "WILDCARD_VERSION_PATTERN")] + #[test_case(SemanticVersionReq::LITERAL_VERSION_PATTERN; "LITERAL_VERSION_PATTERN")] + #[test_case(SemanticVersionReq::COMPARATOR_PATTERN; "COMPARATOR_PATTERN")] + #[test_case(SemanticVersionReq::OPERATOR_PATTERN; "OPERATOR_PATTERN")] + #[test_case(SemanticVersionReq::WILDCARD_SYMBOL_PATTERN; "WILDCARD_SYMBOL_PATTERN")] + fn partial_pattern_compiles(pattern: &str) { + regex::Regex::new(pattern).unwrap(); + } + + #[test_case("1"; "major")] + #[test_case("1.2"; "major.minor")] + #[test_case("1.2.3"; "major.minor.patch")] + #[test_case("1.*"; "major.wildcard_asterisk")] + #[test_case("1.2.*"; "major.minor.wildcard_asterisk")] + #[test_case("1.2.3-alpha"; "major.minor.patch-prerelease")] + #[test_case("^1"; "caret operator")] + #[test_case("~1"; "tilde operator")] + #[test_case("=1"; "equals operator")] + #[test_case(">1"; "greater than operator")] + #[test_case("<1"; "less than operator")] + #[test_case(">=1"; "greater than or equal to operator")] + #[test_case("<=1"; "less than or equal to operator")] + #[test_case("~1,1.2.3,<2"; "multiple comparators without spacing")] + #[test_case("~ 1 , 1.2.3 , < 2"; "multiple comparators with extra spacing")] + fn validating_pattern(requirement: &str) { + let pattern = SemanticVersionReq::VALIDATING_PATTERN; + let r = regex::Regex::new(pattern).unwrap(); + + if r.is_match(requirement) { + match semver::VersionReq::parse(requirement) { + Ok(_) => {} + Err(e) => panic!("failed to parse '{requirement}': {e}"), + } + } else { + panic!("Expected '{requirement}' to be valid requirement pattern but didn't match regex '{pattern}'") + } + } +} + +#[cfg(test)] +mod schema { + use std::sync::LazyLock; + + use dsc_lib::types::SemanticVersionReq; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static SCHEMA: LazyLock = LazyLock::new(|| schema_for!(SemanticVersionReq)); + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*SCHEMA).as_value()).unwrap()); + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); + + #[test_case("title")] + #[test_case("description")] + #[test_case("markdownDescription")] + #[test_case("patternErrorMessage")] + fn has_documentation_keyword(keyword: &str) { + let schema = &*SCHEMA; + let value = schema + .get_keyword_as_str(keyword) + .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'" + ); + } + + #[test_case(&json!("1") => true; "major is valid")] + #[test_case(&json!("1.2") => true; "major.minor is valid")] + #[test_case(&json!("1.2.3") => true; "major.minor.patch is valid")] + #[test_case(&json!("1.2.3-pre") => true; "major.minor.patch-pre is valid")] + #[test_case(&json!("1.*") => true; "major.wildcard is valid")] + #[test_case(&json!("1.2.*") => true; "major.minor.wildcard is valid")] + #[test_case(&json!("^1") => true; "caret operator is valid")] + #[test_case(&json!("~1") => true; "tilde operator is valid")] + #[test_case(&json!("=1") => true; "equals operator is valid")] + #[test_case(&json!(">1") => true; "greater than operator is valid")] + #[test_case(&json!("<1") => true; "less than operator is valid")] + #[test_case(&json!(">=1") => true; "greater than or equal to operator is valid")] + #[test_case(&json!("<=1") => true; "less than or equal to operator is valid")] + #[test_case(&json!("~1,1.2.3,<2") => true; "multiple comparators without spacing is valid")] + #[test_case(&json!("~ 1 , 1.2.3 , < 2") => true; "multiple comparators with extra spacing is valid")] + #[test_case(&json!("1.2.3+build") => false; "major.minor.patch+build is invalid")] + #[test_case(&json!("1.2.3-pre+build") => false; "major.minor.patch-pre+build is invalid")] + #[test_case(&json!("!3.0.0") => false; "unknown operator is invalid")] + #[test_case(&json!("3.0.0.0") => false; "non-semantic version is invalid")] + #[test_case(&json!("1.a") => false; "version with alphabetic segment is invalid")] + #[test_case(&json!("*.2") => false; "wildcard.major is invalid")] + #[test_case(&json!("1.*.3") => false; "major.wildcard.patch is invalid")] + #[test_case(&json!("1.2.3-*") => false; "major.minor.patch-wildcard is invalid")] + #[test_case(&json!("1.2.3-pre.*") => false; "major.minor.patch-pre.wildcard is invalid")] + #[test_case(&json!(">=1.2,") => false; "comma without following comparator is invalid")] + #[test_case(&json!(">=1.2 < 1.4") => false; "multiple comparators without separating comma is invalid")] + #[test_case(&json!(true) => false; "boolean value is invalid")] + #[test_case(&json!(1) => false; "integer value is invalid")] + #[test_case(&json!(1.2) => false; "float value is invalid")] + #[test_case(&json!({"req": "1.2.3"}) => false; "object value is invalid")] + #[test_case(&json!(["1.2.3"]) => false; "array value is invalid")] + #[test_case(&serde_json::Value::Null => false; "null value is invalid")] + fn validation(input_json: &Value) -> bool { + (&*VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::SemanticVersionReq; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("^1.2.3"; "single comparator req string serializes to string")] + #[test_case("^1.2.3, <1.4"; "multi comparator req serializes to string")] + fn serializing(requirement: &str) { + let actual = serde_json::to_string( + &SemanticVersionReq::parse(requirement).expect("parse should never fail"), + ) + .expect("serialization should never fail"); + let expected = format!(r#""{requirement}""#); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case(json!("1.2.3") => matches Ok(_); "valid req string value succeeds")] + #[test_case(json!("a.b") => matches Err(_); "invalid req string value fails")] + #[test_case(json!(true) => matches Err(_); "boolean value is invalid")] + #[test_case(json!(1) => matches Err(_); "integer value is invalid")] + #[test_case(json!(1.2) => matches Err(_); "float value is invalid")] + #[test_case(json!({"req": "1.2.3"}) => matches Err(_); "object value is invalid")] + #[test_case(json!(["1.2.3"]) => matches Err(_); "array value is invalid")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value is invalid")] + fn deserializing(value: Value) -> Result { + serde_json::from_value::(value) + } + + #[test_case("^1", true; "major with explicit operator round trips")] + #[test_case("1", false; "major with implicit operator does not round trip")] + #[test_case("^1.2", true; "major.minor with explicit operator round trips")] + #[test_case("1.2", false; "major.minor with implicit operator does not round trip")] + #[test_case("^1.2.3", true; "major.minor.patch with explicit operator round trips")] + #[test_case("1.2.3", false; "major.minor.patch with implicit operator does not round trip")] + #[test_case("^1.2.3-pre", true; "major.minor.patch-pre with explicit operator round trips")] + #[test_case("1.2.3-pre", false; "major.minor.patch-pre with implicit operator does not round trip")] + #[test_case("^1.*", false; "major.wildcard with explicit operator does not round trip")] + #[test_case("1.*", true; "major.wildcard with implicit operator round trips")] + #[test_case("^1.*.*", false; "major.wildcard.wildcard with explicit operator does not round trip")] + #[test_case("1.*.*", false; "major.wildcard.wildcard with implicit operator does not round trip")] + #[test_case("^1.2.*", false; "major.minor.wildcard version with explicit operator round trips")] + #[test_case("1.2.*", true; "major.minor.wildcard version with implicit operator round trips")] + #[test_case(" ^1.2.3", false; "requirement with leading spaces does not round trip")] + #[test_case("^1.2.3 ", false; "requirement with trailing spaces does not round trip")] + #[test_case("^1.2.3, <1.5", true; "multi-comparators with single space after comma round trips")] + #[test_case("^1.2.3,<1.5", false; "multi-comparators without space after comma does not round trip")] + #[test_case("^1.2.3 , <1.5", false; "multi-comparators with space before and after comma does not round trip")] + #[test_case("^1.2.3, <1.5", false; "multi-comparators with multiple spaces after comma does not round trip")] + fn round_tripping(requirement: &str, should_round_trip: bool) { + let json_value = json!(requirement); + // let json_string = json_value.clone().to_string(); + let serialized: SemanticVersionReq = serde_json::from_value(json_value.clone()).unwrap(); + let deserialized = serde_json::to_value(&serialized).unwrap(); + + if should_round_trip { + pretty_assertions::assert_eq!( + json_value, + deserialized, + "expected requirement '{requirement}' to roundtrip without munging" + ); + } else { + pretty_assertions::assert_ne!( + json_value, + deserialized, + "expected requirement '{requirement}' serialize as a munged string" + ); + } + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::SemanticVersionReq; + + #[test] + fn default() { + pretty_assertions::assert_eq!( + SemanticVersionReq::default().as_ref(), + &semver::VersionReq::default(), + ) + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::SemanticVersionReq; + use test_case::test_case; + + #[test_case("1.2", "^1.2"; "valid req with single comparator")] + #[test_case("1.2, < 1.4", "^1.2, <1.4"; "valid req with multiple comparators")] + #[test_case("1.*", "1.*"; "valid req with a wildcard")] + fn format(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + format!("req: '{}'", SemanticVersionReq::parse(requirement).unwrap()), + format!("req: '{}'", expected) + ) + } + + #[test_case("1.2", "^1.2"; "valid req with single comparator")] + #[test_case("1.2, < 1.4", "^1.2, <1.4"; "valid req with multiple comparators")] + #[test_case("1.*", "1.*"; "valid req with a wildcard")] + fn to_string(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + SemanticVersionReq::parse(requirement).unwrap().to_string(), + expected.to_string() + ) + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::{SemanticVersion, SemanticVersionReq}; + + #[test] + fn semver_version_req() { + let semver_req = semver::VersionReq::parse("1.2.3").unwrap(); + SemanticVersionReq::from(semver_req).matches(&SemanticVersion::new(1, 2, 3)); + } + } + + #[cfg(test)] + mod from_str { + use dsc_lib::{dscerror::DscError, types::SemanticVersionReq}; + use test_case::test_case; + + // Minimal test suite, since full parsing tests are on the associated `parse` function. + #[test_case("1.2.3" => matches Ok(_); "valid requirement returns ok")] + #[test_case("!1.2.3" => matches Err(_); "invalid requirement returns err")] + fn parse(input: &str) -> Result { + input.parse() + } + } + + #[cfg(test)] + mod try_from { + use dsc_lib::{dscerror::DscError, types::SemanticVersionReq}; + use test_case::test_case; + + // Minimal test suite, since full parsing tests are on the associated `parse` function. + #[test_case("1.2.3" => matches Ok(_); "valid requirement returns ok")] + #[test_case("!1.2.3" => matches Err(_); "invalid requirement returns err")] + fn string(input: &str) -> Result { + SemanticVersionReq::try_from(input.to_string()) + } + + // Minimal test suite, since full parsing tests are on the associated `parse` function. + #[test_case("1.2.3" => matches Ok(_); "valid requirement returns ok")] + #[test_case("!1.2.3" => matches Err(_); "invalid requirement returns err")] + fn string_slice(input: &str) -> Result { + SemanticVersionReq::try_from(input) + } + } + + // While technically we implemented the traits as `From for `, it's easier to + // reason about what we're converting _into_ - otherwise the functions would have names like + // `type_version_for_semver_version`. When you implement `From`, you automatically implement + // `Into` for the reversing of the type pair. + #[cfg(test)] + mod into { + use dsc_lib::types::SemanticVersionReq; + + #[test] + fn semver_version_req() { + let _: semver::VersionReq = SemanticVersionReq::parse("1.2").unwrap().into(); + } + + #[test] + fn string() { + let _: String = SemanticVersionReq::parse("1.2").unwrap().into(); + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::SemanticVersionReq; + use test_case::test_case; + + #[test_case("1.2", "1.2", true; "identical requirements")] + #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[test_case("^1.2", "~1.2", false; "differing operator requirements")] + #[test_case("1.2", "3.4", false; "differing version requirements")] + #[test_case("1.2", "1.2, <3.4", false; "single and multi version requirements")] + fn semantic_version_req(lhs: &str, rhs: &str, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!( + SemanticVersionReq::parse(lhs).unwrap(), + SemanticVersionReq::parse(rhs).unwrap() + ) + } else { + pretty_assertions::assert_ne!( + SemanticVersionReq::parse(lhs).unwrap(), + SemanticVersionReq::parse(rhs).unwrap() + ) + } + } + + #[test_case("1.2", "1.2", true; "identical requirements")] + #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[test_case("^1.2", "~1.2", false; "differing operator requirements")] + #[test_case("1.2", "3.4", false; "differing version requirements")] + #[test_case("1.2", "1.2, <3.4", false; "single and multi version requirements")] + fn semver_version_req( + semantic_version_req_string: &str, + semver_version_req_string: &str, + should_be_equal: bool, + ) { + let semantic_version_req = + SemanticVersionReq::parse(semantic_version_req_string).unwrap(); + let semver_version_req = semver::VersionReq::parse(semver_version_req_string).unwrap(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version_req == semver_version_req, + should_be_equal, + "expected comparison of {semantic_version_req} and {semver_version_req} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + semver_version_req == semantic_version_req, + should_be_equal, + "expected comparison of {semver_version_req} and {semantic_version_req} to be {should_be_equal}" + ); + } + + #[test_case("1.2", "1.2", true; "identical requirements")] + #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[test_case("^1.2", "~1.2", false; "differing operator requirements")] + #[test_case("1.2", "3.4", false; "differing version requirements")] + #[test_case("1.2", "1.2, <3.4", false; "single and multi version requirements")] + #[test_case("1.2", "invalid", false; "requirement and arbitrary string")] + fn string(semantic_version_req_string: &str, string_slice: &str, should_be_equal: bool) { + let semantic_version_req = + SemanticVersionReq::parse(semantic_version_req_string).unwrap(); + let string = string_slice.to_string(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version_req == string, + should_be_equal, + "expected comparison of {semantic_version_req} and {string} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string == semantic_version_req, + should_be_equal, + "expected comparison of {string} and {semantic_version_req} to be {should_be_equal}" + ); + } + + #[test_case("1.2", "1.2", true; "identical requirements")] + #[test_case("1.2", "^ 1.2", true; "equivalent requirements")] + #[test_case("^1.2", "~1.2", false; "differing operator requirements")] + #[test_case("1.2", "3.4", false; "differing version requirements")] + #[test_case("1.2", "1.2, <3.4", false; "single and multi version requirements")] + #[test_case("1.2", "invalid", false; "requirement and arbitrary string")] + fn str(semantic_version_req_string: &str, string_slice: &str, should_be_equal: bool) { + let semantic_version_req = + SemanticVersionReq::parse(semantic_version_req_string).unwrap(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + semantic_version_req == string_slice, + should_be_equal, + "expected comparison of {semantic_version_req} and {string_slice} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string_slice == semantic_version_req, + should_be_equal, + "expected comparison of {string_slice} and {semantic_version_req} to be {should_be_equal}" + ); + } + } +} From f9303861b26ba0069d12abcc47fec346203e9921 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Mon, 2 Feb 2026 11:03:53 -0600 Subject: [PATCH 3/6] (GH-538) Define `ResourceVersion` as newtype Prior to this change, the version fields for DSC used an arbitrary `String` for both resources and extensions. The generated schema for those types then reports that any string is valid and DSC must check every time it needs to process the version for whether the version is semantic or an arbitrary string. This change follows the Rust "parse, don't validate pattern" by defining a `ResourceVersion` enum with the `Semantic` and `String` variants. If the version can be parsed as a semantic version, the type creates it as an instance of `ResourceVersion::Semantic` and otherwise creates it as `ResourceVersion::Arbitrary`. The `ResourceVersion` enum implements several conversion and comparison traits to make using the newtype more ergonomic. It also defines helper methods `is_arbitrary()`, `is_semver()` and `matches_semver_req()` for easier usage. When comparing an instance of `ResourceVersion`: - A semantic version is always greater than an arbitrary string version. This applies both when comparing `ResourceVersion::Arbitrary` instances to `ResourceVersion::Semantic` and to `semver::Version`. - Arbitrary string version comparisons use Rust's underlying [lexicographic comparison logic][01]. Arbitrary string versions are only equivalent when the strings are exactly the same. If the strings differ by case, spacing, or any other characters, they are unequal. Because ordering is lexicographic, arbitrary string version `Foo` is greater than both `foo` and `Bar`. - You can directly compare instances of `ResourceVersion` to string slices and instances of `ResourceVersion`, `SemanticVersion`, `String`, and `semver::Version`. - The trait implementations support using `==`, `>`, and `<` operators for easier reading. The newtype overhauls the JSON Schema for resource versions to help users get better validation and IntelliSense when authoring manifests. Finally, this change adds comprehensive integration tests for the newtype and its implementations as well as documentation for the type and its public methods. --- lib/dsc-lib/locales/en-us.toml | 1 + lib/dsc-lib/locales/schemas.definitions.yaml | 85 +++ lib/dsc-lib/src/dscerror.rs | 3 + lib/dsc-lib/src/types/mod.rs | 2 + lib/dsc-lib/src/types/resource_version.rs | 541 ++++++++++++++++++ lib/dsc-lib/tests/integration/types/mod.rs | 2 + .../integration/types/resource_version.rs | 504 ++++++++++++++++ 7 files changed, 1138 insertions(+) create mode 100644 lib/dsc-lib/src/types/resource_version.rs create mode 100644 lib/dsc-lib/tests/integration/types/resource_version.rs diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 55ac6868c..d0d470448 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -750,6 +750,7 @@ parser = "Parser" progress = "Progress" resourceNotFound = "Resource not found" resourceManifestNotFound = "Resource manifest not found" +resourceVersionToSemverConversion = "Unable to convert arbitrary string resource version to semantic version" schema = "Schema" schemaNotAvailable = "No Schema found and `validate` is not supported" securityContext = "Security context" diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index e5fdb9870..56e019e00 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -34,6 +34,91 @@ schemas: subarea segments to namespace the resource under the owner, like `Microsoft.Windows/Registry`. + resourceVersion: + title: + en-us: Resource version + description: + en-us: >- + Defines the version of a DSC resource as a semantic version or arbitrary string. + markdownDescription: + en-us: |- + Defines the version of a DSC resource. + + DSC supports both semantic versioning and arbitrary versioning for resources. Semantic + versioning is the preferred and recommended versioning strategy. DSC only supports + arbitrary versioning for compatibility scenarios. + + When the version is defined as a valid [semantic version][01], DSC can correctly compare + versions to determine the latest version or match a [semantic version requirement][02]. + Where possible, resource and extension authors should follow semantic versioning for the + best user experience. + + When the version is an arbitrary string, DSC compares the strings + [lexicographically][03]. Arbitrary string versions are only equivalent when they contain + exactly the same characters - the comparison is case-sensitive. If you're defining a + resource that doesn't follow semantic versioning, consider defining the version as an + [ISO 8601 date][04], like `2026-01-15`. When you do, DSC can correctly determine that a + later date should be treated as a newer version. stringVariant: title: en-us: Arbitrary + version string description: en-us: >- Defines the version for the type as an arbitrary + string. deprecationMessage: en-us: >- Arbitrary string versions for resources are only in + place for compatibility with ARM. Resource authors should define their resources with + valid semantic versions, like `1.2.3`. markdownDescription: en-us: |- Defines the version + for the type as an arbitrary string. When the version for the type isn't a valid semantic + version, DSC treats the version as a string. This enables DSC to support + non-semantically-versioned types, such as using a release date as the version. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semver + [02]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semverReq + [03]: https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison + [04]: https://www.iso.org/iso-8601-date-and-time-format.html + semanticVariant: + title: + en-us: Semantic resource version + description: + en-us: >- + Defines the resource's version as a valid semantic version. + markdownDescription: + en-us: |- + Defines the resource's version as a valid semantic version. This is + the preferred and recommended versioning scheme for DSC resources. + + For more information about defining semantic versions, see + [Semantic version JSON Schema reference][01]. For more information + about semantic versioning, see [semver.org][02]. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semver + [02]: https://semver.org + arbitraryVariant: + title: + en-us: Arbitrary string resource version + description: + en-us: >- + Defines the resource's version as an arbitrary string. + deprecationMessage: + en-us: >- + Defining a resource version as an arbitrary string is supported only for compatibility + purposes. If possible, define your resource version as a valid semantic version. For + more information about defining a semantic version, see [semver.org](https://semver.org). + markdownDescription: + en-us: |- + Defines the resource's version as an arbitrary string. + + DSC uses this variant for the version of any DSC resource that defines its version as a + string that can't be parsed as a semantic version. This variant remains supported for + compatibility purposes but is _not_ recommended for production usage. + + When a resource defines the version as an arbitrary string: + + 1. You can only use exact match version requirements for that resource. + 1. When a resource defines the version as an arbitrary string, DSC uses Rust's + [lexicographic comparison][01] logic to determine the "latest" version of the + resource to use as the default version when no version requirement is specified. + 1. When DSC discovers a multiple manifests for a resource, DSC always treats + semantically versioned resources as newer than resources with an arbitrary string + version. + + [01]: https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison + semver: title: en-us: Semantic version diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 7e3cff2c2..3120512e7 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -115,6 +115,9 @@ pub enum DscError { #[error("{t}: {0}", t = t!("dscerror.resourceManifestNotFound"))] ResourceManifestNotFound(String), + #[error("{t}: '{0}'", t = t!("dscerror.resourceVersionToSemverConversion"))] + ResourceVersionToSemverConversion(String), + #[error("{t}: {0}", t = t!("dscerror.schema"))] Schema(String), diff --git a/lib/dsc-lib/src/types/mod.rs b/lib/dsc-lib/src/types/mod.rs index f3bc4e15f..09406cfdf 100644 --- a/lib/dsc-lib/src/types/mod.rs +++ b/lib/dsc-lib/src/types/mod.rs @@ -3,6 +3,8 @@ mod fully_qualified_type_name; pub use fully_qualified_type_name::FullyQualifiedTypeName; +mod resource_version; +pub use resource_version::ResourceVersion; mod semantic_version; pub use semantic_version::SemanticVersion; mod semantic_version_req; diff --git a/lib/dsc-lib/src/types/resource_version.rs b/lib/dsc-lib/src/types/resource_version.rs new file mode 100644 index 000000000..ef33325d4 --- /dev/null +++ b/lib/dsc-lib/src/types/resource_version.rs @@ -0,0 +1,541 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{convert::Infallible, fmt::Display, str::FromStr}; + +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{SemanticVersion, SemanticVersionReq}}; + +/// Defines the version of a DSC resource. +/// +/// DSC supports both semantic versioning and arbitrary versioning for resources. Semantic +/// versioning is the preferred and recommended versioning strategy. DSC only supports arbitrary +/// versioning for compatibility scenarios. +/// +/// When the version is defined as a valid semantic version ([`ResourceVersion::Semantic`]), DSC +/// can correctly compare versions to determine the latest version or match a +/// [`SemanticVersionReq`]. Where possible, resource and extension authors should follow semantic +/// versioning for the best user experience. +/// +/// When the version is an arbitrary string (`ResourceVersion::Arbitrary`]), DSC compares the strings +/// using Rust's default string comparison logic. This means that arbitrary string versions are +/// compared [lexicographically][01]. Arbitrary string versions are only equivalent when they +/// contain exactly the same characters - the comparison is case-sensitive. If you're defining a +/// resource that doesn't follow semantic versioning, consider defining the version as an +/// [ISO 8601 date][02], like `2026-01-15`. When you do, DSC can correctly determine that a later +/// date should be treated as a newer version. +/// +/// # Examples +/// +/// The following example shows how different instances of [`ResourceVersion`] compare to other +/// instances of `ResourceVersion`, [`SemanticVersion`], [`String`], and [`str`]. +/// +/// ```rust +/// use dsc_lib::types::{ResourceVersion, SemanticVersion}; +/// +/// let semantic = ResourceVersion::new("1.2.3"); +/// let arbitrary = ResourceVersion::new("Foo"); +/// let date = ResourceVersion::new("2026-01-15"); +/// +/// // You can compare instances of `ResourceVersion::Semantic` to strings, string slices, and +/// // semantic versions. +/// assert_eq!(semantic, SemanticVersion::parse("1.2.3").unwrap()); +/// assert_eq!(semantic, "1.2.3"); +/// assert_ne!(semantic, "1.2.*".to_string()); +/// +/// // When comparing arbitrary string versions to strings, you can compare `String` instances and +/// // literal strings. The comparisons are case-sensitive. +/// assert_eq!(arbitrary, "Foo"); +/// assert_ne!(arbitrary, "foo".to_string()); +/// +/// // When a semantic version is compared to an arbitrary string version, the semantic version is +/// // always treated as being higher: +/// assert!(semantic > arbitrary); +/// assert!(semantic > date); +/// assert!(arbitrary < SemanticVersion::parse("0.1.0").unwrap()); +/// +/// // Semantic version comparisons work as expected. +/// assert!(semantic < SemanticVersion::parse("1.2.4").unwrap()); +/// assert!(semantic >= SemanticVersion::parse("1.0.0").unwrap()); +/// +/// // When comparing a semantic version to a string, the comparison uses semantic version ordering +/// // if the string can be parsed as a semantic version. +/// assert!(semantic < "1.2.4"); +/// assert!(semantic > "foo".to_string()); +/// +/// // Arbitrary string version comparisons are lexicographic. DSC has no way of knowing whether +/// // `Bar` should be treated as a newer version than `Foo`: +/// assert!(arbitrary <= "foo"); +/// assert_ne!(arbitrary < "Bar", true); +/// +/// // String version comparisons for ISO 8601 dates are deterministic: +/// assert!(date < "2026-02-01"); +/// assert!(date >= "2026-01"); +/// ``` +/// +/// You can freely convert between strings and `ResourceVersion`: +/// +/// ```rust +/// use dsc_lib::types::ResourceVersion; +/// +/// let semantic: ResourceVersion = "1.2.3".parse().unwrap(); +/// let arbitrary = ResourceVersion::from("foo"); +/// let date = ResourceVersion::new("2026-01-15"); +/// +/// let stringified_semantic = String::from(semantic.clone()); +/// +/// // Define a function that expects a string: +/// fn expects_string(input: &str) { +/// println!("Input was: '{input}'") +/// } +/// +/// // You can pass the `ResourceVersion` in a few ways: +/// expects_string(&semantic.to_string()); +/// expects_string(date.to_string().as_str()); +/// ``` +/// +/// [01]: https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison +/// [02]: https://www.iso.org/iso-8601-date-and-time-format.html +#[derive(Debug, Clone, Eq, Serialize, Deserialize, JsonSchema, DscRepoSchema)] +#[dsc_repo_schema(base_name = "resourceVersion", folder_path = "definitions")] +#[serde(untagged)] +#[schemars( + title = t!("schemas.definitions.resourceVersion.title"), + description = t!("schemas.definitions.resourceVersion.description"), + extend( + "markdownDescription" = t!("schemas.definitions.resourceVersion.markdownDescription") + ) +)] +pub enum ResourceVersion { + /// Defines the resource's version as a semantic version, containing an inner [`SemanticVersion`]. + /// This is the preferred and recommended versioning scheme for DSC resources. + /// + /// For more information about defining semantic versions, see [`SemanticVersion`]. For more + /// information about semantic versioning, see [semver.org][01]. + /// + /// [01]: https://semver.org + #[schemars( + title = t!("schemas.definitions.resourceVersion.semanticVariant.title"), + description = t!("schemas.definitions.resourceVersion.semanticVariant.description"), + extend( + "markdownDescription" = t!("schemas.definitions.resourceVersion.semanticVariant.markdownDescription") + ) + )] + Semantic(SemanticVersion), + /// Defines the resource's version as an arbitrary string. + /// + /// DSC uses this variant for the version of any DSC resource that defines its + /// version as a string that can't be parsed as a semantic version. This variant remains + /// supported for compatibility purposes but is _not_ recommended for production usage. + /// + /// When a resource defines the version as an arbitrary string: + /// + /// 1. You can only use exact match version requirements for that resource. + /// 1. When a resource defines the version as an arbitrary string, DSC uses Rust's + /// [lexicographic comparison][01] logic to determine the "latest" version of the resource + /// to use as the default version when no version requirement is specified. + /// 1. When DSC discovers a multiple manifests for a resource, DSC always treats semantically + /// versioned resources as newer than resources with an arbitrary string version. + /// + /// [01]: https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison + #[schemars( + title = t!("schemas.definitions.resourceVersion.arbitraryVariant.title"), + description = t!("schemas.definitions.resourceVersion.arbitraryVariant.description"), + extend( + "deprecated" = true, + "deprecationMessage" = t!("schemas.definitions.resourceVersion.arbitraryVariant.deprecationMessage"), + "markdownDescription" = t!("schemas.definitions.resourceVersion.arbitraryVariant.markdownDescription"), + ) + )] + Arbitrary(String), +} + +impl ResourceVersion { + /// Creates a new instance of [`ResourceVersion`]. + /// + /// If the input string is a valid semantic version, the function returns the [`Semantic`] + /// variant. Otherwise, the function returns the [`Arbitrary`] variant for arbitrary version + /// strings. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersion; + /// + /// fn print_version_message(version: ResourceVersion) { + /// match ResourceVersion::new("1.2.3") { + /// ResourceVersion::Semantic(v) => println!("Semantic version: {v}"), + /// ResourceVersion::Arbitrary(s) => println!("Arbitrary string version: '{s}'"), + /// } + /// } + /// + /// // Print for semantic version + /// print_version_message(ResourceVersion::new("1.2.3")); + /// + /// // Print for arbitrary version + /// print_version_message(ResourceVersion::new("2026-01")); + /// ``` + /// + /// [`Semantic`]: ResourceVersion::Semantic + /// [`Arbitrary`]: ResourceVersion::Arbitrary + pub fn new(version_string: &str) -> Self { + match SemanticVersion::parse(version_string) { + Ok(v) => Self::Semantic(v), + Err(_) => Self::Arbitrary(version_string.to_string()), + } + } + + /// Indicates whether the resource version is semantic. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersion; + /// + /// let semantic = ResourceVersion::new("1.2.3"); + /// let arbitrary = ResourceVersion::new("2026-01"); + /// + /// assert_eq!(semantic.is_semver(), true); + /// assert_eq!(arbitrary.is_semver(), false); + /// ``` + pub fn is_semver(&self) -> bool { + match self { + Self::Semantic(_) => true, + _ => false, + } + } + + /// Indicates whether the resource version is an arbitrary string. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersion; + /// + /// let semantic = ResourceVersion::new("1.2.3"); + /// let arbitrary = ResourceVersion::new("2026-01"); + /// + /// assert_eq!(semantic.is_semver(), true); + /// assert_eq!(arbitrary.is_semver(), false); + /// ``` + pub fn is_arbitrary(&self) -> bool { + match self { + Self::Arbitrary(_) => true, + _ => false, + } + } + + /// Returns the version as a reference to the underlying [`SemanticVersion`] if possible. + /// + /// If the underlying version is [`Semantic`], this method returns some semantic version. + /// Otherwise, it returns [`None`]. + /// + /// # Examples + /// + /// The following examples show how `as_semver()` behaves for different versions. + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersion, SemanticVersion}; + /// + /// let semantic = ResourceVersion::new("1.2.3"); + /// let date = ResourceVersion::new("2026-01-15"); + /// let arbitrary = ResourceVersion::new("arbitrary_version"); + /// + /// assert_eq!( + /// semantic.as_semver(), + /// Some(&SemanticVersion::new(1, 2, 3)) + /// ); + /// assert_eq!( + /// date.as_semver(), + /// None + /// ); + /// assert_eq!( + /// arbitrary.as_semver(), + /// None + /// ); + /// ``` + /// + /// [`Semantic`]: ResourceVersion::Semantic + pub fn as_semver(&self) -> Option<&SemanticVersion> { + match self { + Self::Semantic(v) => Some(v), + _ => None, + } + } + + /// Compares an instance of [`ResourceVersion`] with [`SemanticVersionReq`]. + /// + /// When the instance is [`ResourceVersion::Semantic`], this method applies the canonical matching + /// logic from [`SemanticVersionReq`] for the version. When the instance is + /// [`ResourceVersion::Arbitrary`], this method always returns `false`. + /// + /// For more information about semantic version requirements and syntax, see + /// ["Specifying Dependencies" in _The Cargo Book_][semver-req]. + /// + /// # Examples + /// + /// The following example shows how comparisons work for different instances of + /// [`ResourceVersion`]. + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersion, SemanticVersionReq}; + /// + /// let semantic = ResourceVersion::new("1.2.3"); + /// let date = ResourceVersion::new("2026-01-15"); + /// + /// let ref le_v2_0: SemanticVersionReq = "<=2.0".parse().unwrap(); + /// assert!(semantic.matches_semver_req(le_v2_0)); + /// assert!(!date.matches_semver_req(le_v2_0)); + /// let ref tilde_v1: SemanticVersionReq = "~1".parse().unwrap(); + /// assert!(semantic.matches_semver_req(tilde_v1)); + /// assert!(!date.matches_semver_req(tilde_v1)); + /// ``` + /// + /// [semver-req]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#version-requirement-syntax + pub fn matches_semver_req(&self, requirement: &SemanticVersionReq) -> bool { + match self { + Self::Semantic(v) => requirement.matches(v), + Self::Arbitrary(_) => false, + } + } +} + +// Default to semantic version `0.0.0` rather than an empty string. +impl Default for ResourceVersion { + fn default() -> Self { + Self::Semantic(SemanticVersion::default()) + } +} + +// Enable using `ResourceVersion` in `format!` and similar macros. +impl Display for ResourceVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Semantic(v) => write!(f, "{}", v), + Self::Arbitrary(s) => write!(f, "{}", s), + } + } +} + +// Parsing from a string is just calling `Self::new()` +impl FromStr for ResourceVersion { + type Err = Infallible; + fn from_str(s: &str) -> Result { + Ok(Self::new(s)) + } +} + +// Implemented various conversion traits to move between `ResourceVersion`, `SemanticVersion`, +// `String`, and string slice (`str`). +impl From<&String> for ResourceVersion { + fn from(value: &String) -> Self { + match SemanticVersion::parse(value) { + Ok(v) => ResourceVersion::Semantic(v), + Err(_) => ResourceVersion::Arbitrary(value.clone()), + } + } +} + +impl From for ResourceVersion { + fn from(value: String) -> Self { + match SemanticVersion::parse(&value) { + Ok(v) => ResourceVersion::Semantic(v), + Err(_) => ResourceVersion::Arbitrary(value), + } + } +} + +impl From for String { + fn from(value: ResourceVersion) -> Self { + value.to_string() + } +} + +// We can't bidirectionally convert string slices, because we can't return a temporary reference. +// We can still convert _from_ string slices, but not _into_ them. +impl From<&str> for ResourceVersion { + fn from(value: &str) -> Self { + ResourceVersion::from(value.to_string()) + } +} + +impl From for ResourceVersion { + fn from(value: SemanticVersion) -> Self { + Self::Semantic(value) + } +} + +impl From<&SemanticVersion> for ResourceVersion { + fn from(value: &SemanticVersion) -> Self { + Self::Semantic(value.clone()) + } +} + +// Creating an instance of `SemanticVersion` from `ResourceVersion` is the only fallible +// conversion, since `ResourceVersion` can define non-semantic versions. +impl TryFrom for SemanticVersion { + type Error = DscError; + + fn try_from(value: ResourceVersion) -> Result { + match value { + ResourceVersion::Semantic(v) => Ok(v), + ResourceVersion::Arbitrary(s) => Err(DscError::ResourceVersionToSemverConversion(s)), + } + } +} + +// Implement traits for comparing `ResourceVersion` to strings and semantic versions bi-directionally. +impl PartialEq for ResourceVersion { + fn eq(&self, other: &Self) -> bool { + match self { + Self::Semantic(version) => match other { + Self::Semantic(other_version) => version == other_version, + Self::Arbitrary(_) => false, + }, + Self::Arbitrary(string) => !other.is_semver() && *string == other.to_string(), + } + } +} + +impl PartialEq for ResourceVersion { + fn eq(&self, other: &SemanticVersion) -> bool { + match self { + Self::Semantic(v) => v == other, + Self::Arbitrary(_) => false, + } + } +} + +impl PartialEq for SemanticVersion { + fn eq(&self, other: &ResourceVersion) -> bool { + match other { + ResourceVersion::Semantic(v) => self == v, + ResourceVersion::Arbitrary(_) => false, + } + } +} + +impl PartialEq<&str> for ResourceVersion { + fn eq(&self, other: &&str) -> bool { + self == &Self::new(*other) + } +} + +impl PartialEq for &str { + fn eq(&self, other: &ResourceVersion) -> bool { + &ResourceVersion::new(self) == other + } +} + +impl PartialEq for ResourceVersion { + fn eq(&self, other: &String) -> bool { + self == &Self::new(other) + } +} + +impl PartialEq for String { + fn eq(&self, other: &ResourceVersion) -> bool { + &ResourceVersion::new(self) == other + } +} + +impl PartialEq for ResourceVersion { + fn eq(&self, other: &str) -> bool { + self == &Self::new(other) + } +} + +impl PartialEq for str { + fn eq(&self, other: &ResourceVersion) -> bool { + &ResourceVersion::new(self) == other + } +} + +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &Self) -> Option { + match self { + Self::Semantic(version) => match other { + Self::Semantic(other_version) => version.partial_cmp(other_version), + Self::Arbitrary(_) => Some(std::cmp::Ordering::Greater), + }, + Self::Arbitrary(string) => match other { + Self::Semantic(_) => Some(std::cmp::Ordering::Less), + Self::Arbitrary(other_string) => string.partial_cmp(other_string), + }, + } + } +} + +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &SemanticVersion) -> Option { + match self { + Self::Semantic(v) => v.partial_cmp(other), + Self::Arbitrary(_) => Some(std::cmp::Ordering::Less), + } + } +} + +impl PartialOrd for SemanticVersion { + fn partial_cmp(&self, other: &ResourceVersion) -> Option { + match other { + ResourceVersion::Semantic(v) => self.partial_cmp(v), + ResourceVersion::Arbitrary(_) => Some(std::cmp::Ordering::Greater), + } + } +} + +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &String) -> Option { + self.partial_cmp(&ResourceVersion::new(other.as_str())) + } +} + +impl PartialOrd for String { + fn partial_cmp(&self, other: &ResourceVersion) -> Option { + ResourceVersion::new(self.as_str()).partial_cmp(other) + } +} + +impl PartialOrd<&str> for ResourceVersion { + fn partial_cmp(&self, other: &&str) -> Option { + self.partial_cmp(&Self::new(other)) + } +} + +impl PartialOrd for ResourceVersion { + fn partial_cmp(&self, other: &str) -> Option { + self.partial_cmp(&Self::new(other)) + } +} + +impl PartialOrd for &str { + fn partial_cmp(&self, other: &ResourceVersion) -> Option { + ResourceVersion::new(self).partial_cmp(other) + } +} + +impl PartialOrd for str { + fn partial_cmp(&self, other: &ResourceVersion) -> Option { + ResourceVersion::new(self).partial_cmp(other) + } +} + +// Manually implement total ordering, because partial and total ordering are different for semantic +// versions. See the implementation on `semver::Version` for details. +impl Ord for ResourceVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match self { + Self::Semantic(version) => match other { + Self::Semantic(other_version) => version.cmp(other_version), + Self::Arbitrary(_) => std::cmp::Ordering::Greater, + }, + Self::Arbitrary(version) => match other { + Self::Semantic(_) => std::cmp::Ordering::Less, + Self::Arbitrary(other_version) => version.cmp(other_version), + } + } + } +} diff --git a/lib/dsc-lib/tests/integration/types/mod.rs b/lib/dsc-lib/tests/integration/types/mod.rs index cda985580..c6dafeec0 100644 --- a/lib/dsc-lib/tests/integration/types/mod.rs +++ b/lib/dsc-lib/tests/integration/types/mod.rs @@ -4,6 +4,8 @@ #[cfg(test)] mod fully_qualified_type_name; #[cfg(test)] +mod resource_version; +#[cfg(test)] mod semantic_version; #[cfg(test)] mod semantic_version_req; diff --git a/lib/dsc-lib/tests/integration/types/resource_version.rs b/lib/dsc-lib/tests/integration/types/resource_version.rs new file mode 100644 index 000000000..d6f1e1f62 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/resource_version.rs @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::types::{ResourceVersion, SemanticVersion, SemanticVersionReq}; + use test_case::test_case; + + #[test_case("1.2.3" => matches ResourceVersion::Semantic(_); "for valid semantic version")] + #[test_case("1.2.3a" => matches ResourceVersion::Arbitrary(_); "for invalid semantic version")] + #[test_case("2026-01-15" => matches ResourceVersion::Arbitrary(_); "for full ISO8601 date")] + #[test_case("2026-01" => matches ResourceVersion::Arbitrary(_); "for partial ISO8601 date")] + #[test_case("arbitrary_string" => matches ResourceVersion::Arbitrary(_); "for arbitrary string")] + fn new(version_string: &str) -> ResourceVersion { + ResourceVersion::new(version_string) + } + + #[test_case("1.2.3" => true; "for valid semantic version")] + #[test_case("1.2.3a" => false; "for invalid semantic version")] + #[test_case("2026-01-15" => false; "for full ISO8601 date")] + #[test_case("2026-01" => false; "for partial ISO8601 date")] + #[test_case("arbitrary_string" => false; "for arbitrary string")] + fn is_semver(version_string: &str) -> bool { + ResourceVersion::new(version_string).is_semver() + } + + #[test_case("1.2.3" => false; "for valid semantic version")] + #[test_case("1.2.3a" => true; "for invalid semantic version")] + #[test_case("2026-01-15" => true; "for full ISO8601 date")] + #[test_case("2026-01" => true; "for partial ISO8601 date")] + #[test_case("arbitrary_string" => true; "for arbitrary string")] + fn is_arbitrary(version_string: &str) -> bool { + ResourceVersion::new(version_string).is_arbitrary() + } + + #[test_case(ResourceVersion::new("1.2.3") => matches Some(_); "for valid semantic version")] + #[test_case(ResourceVersion::new("1.2.3a") => matches None; "for invalid semantic version")] + #[test_case(ResourceVersion::new("2026-01-15") => matches None; "for full ISO8601 date")] + #[test_case(ResourceVersion::new("2026-01") => matches None; "for partial ISO8601 date")] + #[test_case(ResourceVersion::new("arbitrary_string") => matches None; "for arbitrary string")] + fn as_semver(version: ResourceVersion) -> Option { + version.as_semver().cloned() + } + + #[test_case("1.2.3", ">1.0" => true; "semantic version matches gt req")] + #[test_case("1.2.3", "<=1.2.2" => false; "semantic version not matches le req")] + #[test_case("1.2.3", "~1" => true; "semantic version matches tilde req")] + #[test_case("arbitrary", "*" => false; "arbitrary string version never matches")] + fn matches_semver_req(version_string: &str, requirement_string: &str) -> bool { + ResourceVersion::new(version_string) + .matches_semver_req(&SemanticVersionReq::parse(requirement_string).unwrap()) + } +} + +#[cfg(test)] +mod schema { + use std::{ops::Index, sync::LazyLock}; + + use dsc_lib::types::ResourceVersion; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static ROOT_SCHEMA: LazyLock = LazyLock::new(|| schema_for!(ResourceVersion)); + static SEMVER_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + (&*ROOT_SCHEMA) + .get_keyword_as_array("anyOf") + .unwrap() + .index(0) + .as_object() + .unwrap() + .clone() + .into() + }); + static STRING_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + (&*ROOT_SCHEMA) + .get_keyword_as_array("anyOf") + .unwrap() + .index(1) + .as_object() + .unwrap() + .clone() + .into() + }); + + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*ROOT_SCHEMA).as_value()).unwrap()); + + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); + + #[test_case("title", &*ROOT_SCHEMA; "title")] + #[test_case("description", &*ROOT_SCHEMA; "description")] + #[test_case("markdownDescription", &*ROOT_SCHEMA; "markdownDescription")] + #[test_case("title", &*SEMVER_VARIANT_SCHEMA; "semver.title")] + #[test_case("description", &*SEMVER_VARIANT_SCHEMA; "semver.description")] + #[test_case("markdownDescription", &*SEMVER_VARIANT_SCHEMA; "semver.markdownDescription")] + #[test_case("title", &*STRING_VARIANT_SCHEMA; "arbitrary.title")] + #[test_case("description", &*STRING_VARIANT_SCHEMA; "arbitrary.description")] + #[test_case("markdownDescription", &*STRING_VARIANT_SCHEMA; "arbitrary.markdownDescription")] + fn has_documentation_keyword(keyword: &str, schema: &Schema) { + let value = schema + .get_keyword_as_str(keyword) + .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'" + ); + } + + #[test] + fn semver_subschema_is_reference() { + assert!( + (&*SEMVER_VARIANT_SCHEMA).get_keyword_as_string("$ref").is_some_and(|kv| !kv.is_empty()) + ) + } + + #[test_case(&json!("1.2.3") => true ; "valid semantic version string value is valid")] + #[test_case(&json!("1.2.3a") => true ; "invalid semantic version string value is valid")] + #[test_case(&json!("2026-01-15") => true ; "iso8601 date full string value is valid")] + #[test_case(&json!("2026-01") => true ; "iso8601 date year month string value is valid")] + #[test_case(&json!("arbitrary_string") => true ; "arbitrary string value is valid")] + #[test_case(&json!(true) => false; "boolean value is invalid")] + #[test_case(&json!(1) => false; "integer value is invalid")] + #[test_case(&json!(1.2) => false; "float value is invalid")] + #[test_case(&json!({"version": "1.2.3"}) => false; "object value is invalid")] + #[test_case(&json!(["1.2.3"]) => false; "array value is invalid")] + #[test_case(&serde_json::Value::Null => false; "null value is invalid")] + fn validation(input_json: &Value) -> bool { + (&*VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::ResourceVersion; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("1.2.3"; "valid semantic version")] + #[test_case("1.2.3a"; "invalid semantic version")] + #[test_case("2026-01-15"; "ISO8601 date full")] + #[test_case("2026-01"; "ISO8601 date year and month only")] + #[test_case("arbitrary_string"; "arbitrary string")] + fn serializing_resource_version_to_string(version_string: &str) { + let actual = serde_json::to_string(&ResourceVersion::new(version_string)) + .expect("serialization should never fail"); + let expected = format!(r#""{version_string}""#); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case("1.2.3"; "valid semantic version")] + #[test_case("1.2.3a"; "invalid semantic version")] + #[test_case("2026-01-15"; "ISO8601 date full")] + #[test_case("2026-01"; "ISO8601 date year and month only")] + #[test_case("arbitrary_string"; "arbitrary string")] + fn serializing_to_json_value_returns_string(version_string: &str) { + let expected = Value::String(version_string.to_string()); + let actual = serde_json::to_value(&ResourceVersion::new(version_string)) + .expect("serialization should never fail"); + + pretty_assertions::assert_eq!(actual, expected); + } + + + #[test_case(json!(true); "boolean value fails")] + #[test_case(json!(1); "integer value fails")] + #[test_case(json!(1.2); "float value fails")] + #[test_case(json!({"version": "1.2.3"}); "object value fails")] + #[test_case(json!(["1.2.3"]); "array value fails")] + #[test_case(serde_json::Value::Null; "null value fails")] + fn deserializing_invalid(input_value: Value) { + serde_json::from_value::(input_value) + .expect_err("json value '{input_value}' should be invalid"); + } + + #[test_case(json!("1.2.3") => matches ResourceVersion::Semantic(_); "valid semantic version string value succeeds")] + #[test_case(json!("1.2.3a") => matches ResourceVersion::Arbitrary(_) ; "invalid semantic version string value succeeds")] + #[test_case(json!("2026-01-15") => matches ResourceVersion::Arbitrary(_) ; "iso8601 date full string value succeeds")] + #[test_case(json!("2026-01") => matches ResourceVersion::Arbitrary(_) ; "iso8601 date year month string value succeeds")] + #[test_case(json!("arbitrary_string") => matches ResourceVersion::Arbitrary(_) ; "arbitrary string value succeeds")] + fn deserializing_valid(input_value: Value) -> ResourceVersion { + serde_json::from_value::(input_value) + .expect("deserialization for '{input_value}' should never fail") + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::ResourceVersion; + + #[test] + fn default() { + pretty_assertions::assert_eq!( + ResourceVersion::default(), + ResourceVersion::new("0.0.0") + ); + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::ResourceVersion; + use test_case::test_case; + + #[test_case("1.2.3"; "valid semantic version")] + #[test_case("1.2.3a"; "invalid semantic version")] + #[test_case("2026-01-15"; "ISO8601 date full")] + #[test_case("2026-01"; "ISO8601 date year and month only")] + #[test_case("arbitrary_string"; "arbitrary string")] + fn format(version_string: &str) { + pretty_assertions::assert_eq!( + format!("version: {}", ResourceVersion::new(version_string)), + format!("version: {version_string}") + ) + } + + #[test_case("1.2.3"; "valid semantic version")] + #[test_case("1.2.3a"; "invalid semantic version")] + #[test_case("2026-01-15"; "ISO8601 date full")] + #[test_case("2026-01"; "ISO8601 date year and month only")] + #[test_case("arbitrary_string"; "arbitrary string")] + fn to_string(version_string: &str) { + pretty_assertions::assert_eq!( + ResourceVersion::new(version_string).to_string(), + version_string.to_string() + ) + } + } + + #[cfg(test)] + mod from_str { + use dsc_lib::types::ResourceVersion; + use test_case::test_case; + + #[test_case("1.2.3" => ResourceVersion::new("1.2.3"); "valid semantic version")] + #[test_case("1.2.3a" => ResourceVersion::new("1.2.3a"); "invalid semantic version")] + #[test_case("2026-01-15" => ResourceVersion::new("2026-01-15"); "ISO8601 date full")] + #[test_case("2026-01" => ResourceVersion::new("2026-01"); "ISO8601 date year and month only")] + #[test_case("arbitrary_string" => ResourceVersion::new("arbitrary_string"); "arbitrary string")] + fn parse(input: &str) -> ResourceVersion { + input.parse().expect("parse should be infallible") + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::{ResourceVersion, SemanticVersion}; + use test_case::test_case; + + #[test] + fn semantic_version() { + let semantic_version = SemanticVersion::parse("1.2.3").unwrap(); + match ResourceVersion::from(semantic_version.clone()) { + ResourceVersion::Semantic(v) => pretty_assertions::assert_eq!(v, semantic_version), + ResourceVersion::Arbitrary(_) => { + panic!("should never fail to convert as Semantic version") + } + } + } + + #[test_case("1.2.3" => matches ResourceVersion::Semantic(_); "valid semantic version")] + #[test_case("1.2.3a" => matches ResourceVersion::Arbitrary(_); "invalid semantic version")] + #[test_case("2026-01-15" => matches ResourceVersion::Arbitrary(_); "ISO8601 date full")] + #[test_case("2026-01" => matches ResourceVersion::Arbitrary(_); "ISO8601 date year and month only")] + #[test_case("arbitrary_string" => matches ResourceVersion::Arbitrary(_); "arbitrary string")] + fn string(version_string: &str) -> ResourceVersion { + ResourceVersion::from(version_string.to_string()) + } + } + + // While technically we implemented the traits as `From for `, it's easier to + // reason about what we're converting _into_ - otherwise the functions would have names like + // `resource_version_for_semver_version`. When you implement `From`, you automatically implement + // `Into` for the reversing of the type pair. + #[cfg(test)] + mod into { + use dsc_lib::types::ResourceVersion; + use test_case::test_case; + + #[test_case("1.2.3"; "semantic version")] + #[test_case("arbitrary_version"; "arbitrary string version")] + fn string(version_string: &str) { + let actual: String = ResourceVersion::new(version_string).into(); + let expected = version_string.to_string(); + + pretty_assertions::assert_eq!(actual, expected) + } + } + + #[cfg(test)] + mod try_into { + use dsc_lib::{dscerror::DscError, types::{ResourceVersion, SemanticVersion}}; + use test_case::test_case; + + #[test_case("1.2.3" => matches Ok(_); "valid semantic version converts")] + #[test_case("1.2.3a" => matches Err(_); "invalid semantic version fails")] + #[test_case("2026-01-15" => matches Err(_); "ISO8601 date full fails")] + #[test_case("2026-01" => matches Err(_); "ISO8601 date year and month only fails")] + #[test_case("arbitrary_string" => matches Err(_); "arbitrary string fails")] + fn semantic_version(version_string: &str) -> Result { + TryInto::::try_into(ResourceVersion::new(version_string)) + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::{ResourceVersion, SemanticVersion}; + use test_case::test_case; + + #[test_case("1.2.3", "1.2.3", true; "equal semantic versions")] + #[test_case("1.2.3", "3.2.1", false; "unequal semantic versions")] + #[test_case("Arbitrary", "Arbitrary", true; "identical string versions")] + #[test_case("Arbitrary", "arbitrary", false; "differently cased string versions")] + #[test_case("foo", "bar", false; "unequal string versions")] + fn resource_version(lhs: &str, rhs: &str, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!(ResourceVersion::new(lhs), ResourceVersion::new(rhs)) + } else { + pretty_assertions::assert_ne!(ResourceVersion::new(lhs), ResourceVersion::new(rhs)) + } + } + + #[test_case("1.2.3", "1.2.3", true; "equal semantic versions")] + #[test_case("1.2.3", "3.2.1", false; "unequal semantic versions")] + #[test_case("arbitrary_string", "3.2.1", false; "arbitrary string with semantic version")] + fn semantic_version( + resource_version_string: &str, + semantic_version_string: &str, + should_be_equal: bool, + ) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + let semantic: SemanticVersion = semantic_version_string.parse().unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + version == semantic, + should_be_equal, + "expected comparison of {version} and {semantic} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + semantic == version, + should_be_equal, + "expected comparison of {semantic} and {version} to be #{should_be_equal}" + ); + } + + #[test_case("1.2.3", "1.2.3", true; "semantic version and equivalent string")] + #[test_case("1.2.3", "3.2.1", false; "semantic version and differing string")] + #[test_case("Arbitrary", "Arbitrary", true; "arbitrary string version and identical string")] + #[test_case("Arbitrary", "arbitrary", false; "arbitrary string version and string with differing case")] + #[test_case("foo", "bar", false; "arbitrary string version and different string")] + fn str(resource_version_string: &str, string_slice: &str, should_be_equal: bool) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + version == string_slice, + should_be_equal, + "expected comparison of {version} and {string_slice} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string_slice == version, + should_be_equal, + "expected comparison of {string_slice} and {version} to be #{should_be_equal}" + ); + } + + #[test_case("1.2.3", "1.2.3", true; "semantic version and equivalent string")] + #[test_case("1.2.3", "3.2.1", false; "semantic version and differing string")] + #[test_case("Arbitrary", "Arbitrary", true; "arbitrary string version and identical string")] + #[test_case("Arbitrary", "arbitrary", false; "arbitrary string version and string with differing case")] + #[test_case("foo", "bar", false; "arbitrary string version and different string")] + fn string(resource_version_string: &str, string_slice: &str, should_be_equal: bool) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + let string = string_slice.to_string(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + version == string, + should_be_equal, + "expected comparison of {version} and {string} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string == version, + should_be_equal, + "expected comparison of {string} and {version} to be #{should_be_equal}" + ); + } + } + + #[cfg(test)] + mod partial_ord { + use std::cmp::Ordering; + + use dsc_lib::types::{ResourceVersion, SemanticVersion}; + use test_case::test_case; + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal semantic versions")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "semantic versions with newer lhs")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "semantic versions with newer rhs")] + #[test_case("1.2.3", "arbitrary", Ordering::Greater; "semantic version to string version")] + #[test_case("arbitrary", "1.2.3", Ordering::Less; "string version to semantic version")] + #[test_case("arbitrary", "arbitrary", Ordering::Equal; "string version to same string version")] + #[test_case("arbitrary", "ARBITRARY", Ordering::Greater; "lowercased string version to uppercased string version")] + #[test_case("foo", "bar", Ordering::Greater; "string version to earlier alphabetic string version")] + #[test_case("a", "b", Ordering::Less; "string version to later alphabetic string version")] + #[test_case("2026-01-15", "2026-01-15", Ordering::Equal; "full date string version to same string version")] + #[test_case("2026-01", "2026-01", Ordering::Equal; "partial date string version to same string version")] + #[test_case("2026-01-15", "2026-02-15", Ordering::Less; "full date string version to later full date")] + #[test_case("2026-01-15", "2026-02", Ordering::Less; "full date string version to later partial date")] + #[test_case("2026-01", "2026-02-15", Ordering::Less; "partial date string version to later full date")] + #[test_case("2026-01", "2026-02", Ordering::Less; "partial date string version to later partial date")] + fn resource_version(lhs: &str, rhs: &str, expected_order: Ordering) { + pretty_assertions::assert_eq!( + ResourceVersion::new(lhs) + .partial_cmp(&ResourceVersion::new(rhs)) + .unwrap(), + expected_order, + "expected '{lhs}' compared to '{rhs}' to be {expected_order:#?}" + ) + } + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal semantic versions")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "semantic versions with newer lhs")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "semantic versions with newer rhs")] + #[test_case("arbitrary", "1.2.3", Ordering::Less; "string version to semantic version")] + fn semantic_version( + resource_version_string: &str, + semantic_version_string: &str, + expected_order: Ordering, + ) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + let semantic: SemanticVersion = semantic_version_string.parse().unwrap(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(&semantic).unwrap(), + expected_order, + "expected comparison of {version} and {semantic} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + Ordering::Equal => Ordering::Equal, + Ordering::Greater => Ordering::Less, + Ordering::Less => Ordering::Greater, + }; + + pretty_assertions::assert_eq!( + semantic.partial_cmp(&version).unwrap(), + expected_inverted_order, + "expected comparison of {semantic} and {version} to be #{expected_inverted_order:#?}" + ); + } + + #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal semantic versions")] + #[test_case("3.2.1", "1.2.3", Ordering::Greater; "semantic versions with newer lhs")] + #[test_case("1.2.3", "3.2.1", Ordering::Less; "semantic versions with newer rhs")] + #[test_case("1.2.3", "arbitrary", Ordering::Greater; "semantic version to string version")] + #[test_case("arbitrary", "1.2.3", Ordering::Less; "string version to semantic version")] + #[test_case("arbitrary", "arbitrary", Ordering::Equal; "string version to same string version")] + #[test_case("arbitrary", "ARBITRARY", Ordering::Greater; "lowercased string version to uppercased string version")] + #[test_case("foo", "bar", Ordering::Greater; "string version to earlier alphabetic string version")] + #[test_case("a", "b", Ordering::Less; "string version to later alphabetic string version")] + #[test_case("2026-01-15", "2026-01-15", Ordering::Equal; "full date string version to same string version")] + #[test_case("2026-01", "2026-01", Ordering::Equal; "partial date string version to same string version")] + #[test_case("2026-01-15", "2026-02-15", Ordering::Less; "full date string version to later full date")] + #[test_case("2026-01-15", "2026-02", Ordering::Less; "full date string version to later partial date")] + #[test_case("2026-01", "2026-02-15", Ordering::Less; "partial date string version to later full date")] + #[test_case("2026-01", "2026-02", Ordering::Less; "partial date string version to later partial date")] + fn string(resource_version_string: &str, string_slice: &str, expected_order: Ordering) { + let version: ResourceVersion = resource_version_string.parse().unwrap(); + let string = string_slice.to_string(); + + // Test comparison bidirectionally + pretty_assertions::assert_eq!( + version.partial_cmp(&string).unwrap(), + expected_order, + "expected comparison of {version} and {string} to be #{expected_order:#?}" + ); + + let expected_inverted_order = match expected_order { + Ordering::Equal => Ordering::Equal, + Ordering::Greater => Ordering::Less, + Ordering::Less => Ordering::Greater, + }; + + pretty_assertions::assert_eq!( + string.partial_cmp(&version).unwrap(), + expected_inverted_order, + "expected comparison of {string} and {version} to be #{expected_inverted_order:#?}" + ); + } + } +} From fd2ae52e5550e3301310b279a2941328dc81e909 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 5 Feb 2026 15:27:59 -0600 Subject: [PATCH 4/6] (GH-538) Define `ResourceVersionReq` as newtype Prior to this change, the `apiVersion` field for resource instances in configuration documents used the rust type `Option` to represent a version pin for a DSC Resource. This change: - Defines the `ResourceVersionReq` enum, which can contain either a `SemanticVersionReq` or an arbitrary string. Like the `ResourceVersion` enum, this enables us to distinguish between resources that are semantically versioned and those using an arbitrary string version. If the string can be parsed as a semantic version requirement the created instance is the `Semantic` variant and otherwise `Arbitrary`. - Implements the `matches` method to compare an instance of `ResourceVersion` against a version requirement. When both the version and requirement are semantic, it uses the underlying implementation for semantic version requirements in `semver::VersionReq`. When either the version or requirement is semantic and the other is arbitrary the match fails. When both the version and requirement are arbitrary, the version matches the requirement only when the strings are equal. The comparison is case sensitive. - Implements traits for converting and comparing instances of `ResourceVersionReq` to string slices and instances of `String` and `SemanticVersionReq`. - Implements the `JsonSchema` and `DscRepoSchema` traits for the newtype to enable sharing a canonical JSON Schema for this field. --- lib/dsc-lib/locales/en-us.toml | 1 + lib/dsc-lib/locales/schemas.definitions.yaml | 77 +++ lib/dsc-lib/src/dscerror.rs | 3 + lib/dsc-lib/src/types/mod.rs | 2 + lib/dsc-lib/src/types/resource_version_req.rs | 415 +++++++++++++++ lib/dsc-lib/tests/integration/types/mod.rs | 2 + .../integration/types/resource_version_req.rs | 477 ++++++++++++++++++ 7 files changed, 977 insertions(+) create mode 100644 lib/dsc-lib/src/types/resource_version_req.rs create mode 100644 lib/dsc-lib/tests/integration/types/resource_version_req.rs diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index d0d470448..98ef41f8d 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -751,6 +751,7 @@ progress = "Progress" resourceNotFound = "Resource not found" resourceManifestNotFound = "Resource manifest not found" resourceVersionToSemverConversion = "Unable to convert arbitrary string resource version to semantic version" +resourceVersionReqToSemverConversion = "Unable to convert arbitrary string resource version requirement to semantic version requirement" schema = "Schema" schemaNotAvailable = "No Schema found and `validate` is not supported" securityContext = "Security context" diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index 56e019e00..a9da9779a 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -119,6 +119,83 @@ schemas: [01]: https://doc.rust-lang.org/std/cmp/trait.Ord.html#lexicographical-comparison + resourceVersionReq: + title: + en-us: Resource version requirement + description: + en-us: >- + Defines one or more limitations for a resource version to enable version pinning. + markdownDescription: + en-us: |- + Defines one or more limitations for a [resource version][01] to enable version pinning. + + DSC supports both semantic versioning and arbitrary versioning for resources. Semantic + versioning is the preferred and recommended versioning strategy. DSC only supports + arbitrary versioning for compatibility scenarios. + + Because DSC supports arbitrary string versions for compatibility, version requirements + must also support arbitrary string versions. + + When a resource version requirement is semantic, it behaves like a + [semantic version requirement][02] and only matches resource versions that are semantic + _and_ valid for the given requirement. Arbitrary string versions never match a semantic + resource version requirement. + + Similarly, when a resource version requirement is an arbitrary string, it can never match + a semantically versioned resource. Instead, it matches an arbitrary resource version when + the arbitrary string version is _exactly_ the same as the arbitrary resource version + requirement. + + Arbitrary resource versions and resource version requirements are only defined for + compatibility scenarios. You should use semantic versions for resources and resource + version requirements. + + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/resourceVersion + [02]:https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semverReq + semanticVariant: + title: + en-us: Semantic resource version requirement + description: + en-us: >- + Defines the required version range for a resource using the Rust version requirement + syntax. + markdownDescription: + en-us: |- + Defines the required version range for a resource using the Rust version requirement + syntax. This is the preferred and recommended way to pin versions for DSC resources. + + For more information about defining semantic version requirements with DSC, see + [Defining semantic version requirements][01]. + + [01]: https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md + arbitraryVariant: + title: + en-us: Arbitrary resource version requirement + description: + en-us: >- + Defines the required version for the resource as an arbitrary string. + deprecationMessage: + en-us: >- + Defining a resource version requirement as an arbitrary string is supported only for + compatibility purposes. If possible, define your version requirement as a valid + semantic version requirement. For more information about defining semantic version + requirements with DSC, see + [Defining semantic version requirements](https://learn.microsoft.com/en-us/powershell/dsc/concepts/defining-semver-reqs.md) + markdownDescription: + en-us: |- + Defines the required version for the resource as an arbitrary string. + + DSC considers any requirement that can't be parsed as a semantic version requirement as + an arbitrary resource version requirement. This kind of requirement remains supported + for compatibility purposes but is _not_ recommended for production usage. + + When a resource version requirement is defined as an arbitrary string: + + 1. It can never match a semantically versioned resource. + 1. It only matches a resource with an [arbitrary string version][01] when the resource + version and this version requirement are exactly the same. The comparison is + case-sensitive. + semver: title: en-us: Semantic version diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 3120512e7..942816b89 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -118,6 +118,9 @@ pub enum DscError { #[error("{t}: '{0}'", t = t!("dscerror.resourceVersionToSemverConversion"))] ResourceVersionToSemverConversion(String), + #[error("{t}: '{0}'", t = t!("dscerror.resourceVersionReqToSemverConversion"))] + ResourceVersionReqToSemverConversion(String), + #[error("{t}: {0}", t = t!("dscerror.schema"))] Schema(String), diff --git a/lib/dsc-lib/src/types/mod.rs b/lib/dsc-lib/src/types/mod.rs index 09406cfdf..e7899a903 100644 --- a/lib/dsc-lib/src/types/mod.rs +++ b/lib/dsc-lib/src/types/mod.rs @@ -5,6 +5,8 @@ mod fully_qualified_type_name; pub use fully_qualified_type_name::FullyQualifiedTypeName; mod resource_version; pub use resource_version::ResourceVersion; +mod resource_version_req; +pub use resource_version_req::ResourceVersionReq; mod semantic_version; pub use semantic_version::SemanticVersion; mod semantic_version_req; diff --git a/lib/dsc-lib/src/types/resource_version_req.rs b/lib/dsc-lib/src/types/resource_version_req.rs new file mode 100644 index 000000000..15025771a --- /dev/null +++ b/lib/dsc-lib/src/types/resource_version_req.rs @@ -0,0 +1,415 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{convert::Infallible, fmt::Display, str::FromStr}; + +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::{ResourceVersion, SemanticVersionReq}}; + +/// Defines one or more limitations for a [`ResourceVersion`] to enable version pinning. +/// +/// DSC supports both semantic versioning and arbitrary versioning for resources. Semantic +/// versioning is the preferred and recommended versioning strategy. DSC only supports arbitrary +/// versioning for compatibility scenarios. +/// +/// Because DSC supports arbitrary string versions for compatibility, version requirements must +/// also support arbitrary string versions. +/// +/// When a [`ResourceVersionReq`] is semantic, it behaves like a [`SemanticVersionReq`] and only +/// matches resource versions that are semantic _and_ valid for the given requirement. Arbitrary +/// string versions never match a semantic resource version requirement. +/// +/// Similarly, when a [`ResourceVersionReq`] is an arbitrary string, it can never match a +/// semantically versioned [`ResourceVersion`]. Instead, it matches an arbitrary `ResourceVersion` +/// when the arbitrary string version is _exactly_ the same as the arbitrary resource version +/// requirement. +/// +/// Arbitrary resource versions and resource version requirements are only defined for +/// compatibility scenarios. You should use semantic versions for resources and resource version +/// requirements. +/// +/// ## Defining a resource version requirement +/// +/// All strings are valid resource version requirements. However, to usefully define a resource +/// version requirement that supports correctly matching semantic versions, you must define the +/// requirement as valid `SemanticVersionReq`. See the [`SemanticVersionReq` documentation][01] for +/// full details on defining semantic version requirements. +/// +/// ## Examples +/// +/// When you create a new instance of [`ResourceVersionReq`], the variant is `Semantic` when the +/// input string parses as a [`SemanticVersionReq`]. Otherwise, the new instance is `Arbitrary`. +/// +/// ```rust +/// use dsc_lib::types::{ResourceVersion, ResourceVersionReq}; +/// +/// let semantic_req = ResourceVersionReq::new("^1.2, <1.5"); +/// let arbitrary_req = ResourceVersionReq::new("foo"); +/// +/// let v1_2_3 = &ResourceVersion::new("1.2.3"); +/// let v1_5_1 = &ResourceVersion::new("1.5.1"); +/// let v_arbitrary = &ResourceVersion::new("foo"); +/// +/// // Semantic requirement uses underlying semantic version requirement logic: +/// assert!(semantic_req.matches(v1_2_3)); +/// assert!(!semantic_req.matches(v1_5_1)); +/// // Semantic requirements never match arbitrary versions: +/// assert!(!semantic_req.matches(v_arbitrary)); +/// +/// // Arbitrary requirements only match arbitrary versions _exactly_: +/// assert!(arbitrary_req.matches(v_arbitrary)); +/// // Differing casing causes the match to fail: +/// assert!(!arbitrary_req.matches(&ResourceVersion::new("FOO"))); +/// ``` +/// +/// [01]: SemanticVersionReq +#[derive(Debug, Clone, Eq, Serialize, Deserialize, JsonSchema, DscRepoSchema)] +#[dsc_repo_schema(base_name = "resourceVersionReq", folder_path = "definitions")] +#[serde(untagged)] +#[schemars( + title = t!("schemas.definitions.resourceVersionReq.title"), + description = t!("schemas.definitions.resourceVersionReq.description"), + extend( + "markdownDescription" = t!("schemas.definitions.resourceVersionReq.markdownDescription") + ) +)] +pub enum ResourceVersionReq { + /// Defines the version requirement for the resource as a semantic version requirement, + /// containing an inner [`SemanticVersionReq`]. This is the preferred and recommended way to pin + /// versions for DSC resources. + /// + /// For more information about defining semantic version requirements, see + /// [`SemanticVersionReq`]. For more information about semantic versioning, see + /// [semver.org][01]. + /// + /// [01]: https://semver.org + #[schemars( + title = t!("schemas.definitions.resourceVersionReq.semanticVariant.title"), + description = t!("schemas.definitions.resourceVersionReq.semanticVariant.description"), + extend( + "markdownDescription" = t!("schemas.definitions.resourceVersionReq.semanticVariant.markdownDescription") + ) + )] + Semantic(SemanticVersionReq), + /// Defines the required version for the resource as an arbitrary string. + /// + /// DSC uses this variant for any requirement that can't be parsed as a semantic version + /// requirement. This variant remains supported for compatibility purposes but is _not_ + /// recommended for production usage. + /// + /// When a resource version requirement is defined as an arbitrary string: + /// + /// 1. It can never match a semantically versioned resource. + /// 1. It only matches a resource with an arbitrary string version + /// ([`ResourceVersion::Arbitrary`]) when the resource version and this version requirement + /// are exactly the same. The comparison is case-sensitive. + #[schemars( + title = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.title"), + description = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.description"), + extend( + "deprecated" = true, + "deprecationMessage" = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.deprecationMessage"), + "markdownDescription" = t!("schemas.definitions.resourceVersionReq.arbitraryVariant.markdownDescription"), + "examples" = [ + "2026-02", + "1.2.0.0" + ] + ) + )] + Arbitrary(String), +} + +impl ResourceVersionReq { + /// Creates a new instance of [`ResourceVersionReq`]. + /// + /// If the input string is a valid semantic version requirement, the function returns the + /// [`Semantic`] variant. Otherwise, the function returns the [`Arbitrary`] variant for + /// arbitrary version requirement strings. + /// + /// [`Semantic`]: ResourceVersionReq::Semantic + /// [`Arbitrary`]: ResourceVersionReq::Arbitrary + pub fn new(requirement_string: &str) -> Self { + match SemanticVersionReq::parse(requirement_string) { + Ok(req) => Self::Semantic(req), + Err(_) => Self::Arbitrary(requirement_string.to_string()), + } + } + + /// Indicates whether the resource version requirement is semantic. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersionReq; + /// + /// let semantic = ResourceVersionReq::new("^1.2, <1.5"); + /// let arbitrary = ResourceVersionReq::new("2026-01"); + /// + /// assert_eq!(semantic.is_semver(), true); + /// assert_eq!(arbitrary.is_semver(), false); + /// ``` + pub fn is_semver(&self) -> bool { + match self { + Self::Semantic(_) => true, + _ => false, + } + } + + /// Indicates whether the resource version requirement is an arbitrary string. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::ResourceVersionReq; + /// + /// let arbitrary = ResourceVersionReq::new("2026-01"); + /// let semantic = ResourceVersionReq::new("^1.2, <1.5"); + /// + /// assert_eq!(arbitrary.is_arbitrary(), true); + /// assert_eq!(semantic.is_arbitrary(), false); + /// ``` + pub fn is_arbitrary(&self) -> bool { + match self { + Self::Arbitrary(_) => true, + _ => false, + } + } + + /// Returns the requirement as a reference to the underlying [`SemanticVersionReq`] if possible. + /// + /// If the underlying requirement is [`Semantic`], this method returns some semantic version + /// requirement. Otherwise, it returns [`None`]. + /// + /// # Examples + /// + /// The following examples show how `as_semver_req()` behaves for different requirements. + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + /// + /// let semantic = ResourceVersionReq::new("1.2.3"); + /// let date = ResourceVersionReq::new("2026-01-15"); + /// let arbitrary = ResourceVersionReq::new("arbitrary_version"); + /// + /// assert_eq!( + /// semantic.as_semver_req(), + /// Some(&SemanticVersionReq::parse("^1.2.3").unwrap()) + /// ); + /// assert_eq!( + /// date.as_semver_req(), + /// None + /// ); + /// assert_eq!( + /// arbitrary.as_semver_req(), + /// None + /// ); + /// ``` + /// + /// [`Semantic`]: ResourceVersionReq::Semantic + pub fn as_semver_req(&self) -> Option<&SemanticVersionReq> { + match self { + Self::Semantic(req) => Some(req), + _ => None, + } + } + + /// Compares an instance of [`ResourceVersion`] to the requirement, returning `true` if the + /// version is valid for the requirement and otherwise `false`. + /// + /// The comparison depends on whether the requirement and version are semantic or arbitrary: + /// + /// - When both the requirement and version are semantic, this function uses the logic for + /// comparing versions and requirements defined by [`SemanticVersionReq`]. + /// - When both the requirement and version are arbitrary, the version is only valid for the + /// requirement when it is exactly the same string as the requirement. + /// - Otherwise, this function returns `false` because an arbitrary version can never match a + /// semantic requirement and a semantic version can never match an arbitrary requirement. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::{ResourceVersion, ResourceVersionReq}; + /// + /// let semantic_req = ResourceVersionReq::new("^1.2.3, <1.5"); + /// assert!(semantic_req.matches(&ResourceVersion::new("1.2.3"))); + /// assert!(semantic_req.matches(&ResourceVersion::new("1.3.0"))); + /// assert!(!semantic_req.matches(&ResourceVersion::new("1.0.0"))); + /// assert!(!semantic_req.matches(&ResourceVersion::new("1.5.0"))); + /// assert!(!semantic_req.matches(&ResourceVersion::new("2026-02"))); + /// + /// let arbitrary_req = ResourceVersionReq::new("February 2026"); + /// assert!(arbitrary_req.matches(&ResourceVersion::new("February 2026"))); + /// assert!(!arbitrary_req.matches(&ResourceVersion::new("February2026"))); + /// assert!(!arbitrary_req.matches(&ResourceVersion::new("february 2026"))); + /// ``` + pub fn matches(&self, resource_version: &ResourceVersion) -> bool { + match self { + Self::Semantic(req) => { + match resource_version { + ResourceVersion::Semantic(version) => req.matches(version), + ResourceVersion::Arbitrary(_) => false, + } + }, + Self::Arbitrary(req) => { + match resource_version { + ResourceVersion::Semantic(_) => false, + ResourceVersion::Arbitrary(version) => req == version, + } + } + } + } +} + +// Default to matching any stable semantic version rather than an empty string. +impl Default for ResourceVersionReq { + fn default() -> Self { + Self::Semantic(SemanticVersionReq::default()) + } +} + +// Enable using `ResourceVersionReq` in `format!` and similar macros. +impl Display for ResourceVersionReq { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Semantic(req) => write!(f, "{}", req), + Self::Arbitrary(s) => write!(f, "{}", s), + } + } +} + +// Parsing from a string is just calling `Self::new()` +impl FromStr for ResourceVersionReq { + type Err = Infallible; + fn from_str(s: &str) -> Result { + Ok(Self::new(s)) + } +} + +// Implemented various conversion traits to move between `ResourceVersionReq`, `SemanticVersionReq`, +// `String`, and string slice (`str`). +impl From for ResourceVersionReq { + fn from(value: String) -> Self { + match SemanticVersionReq::parse(&value) { + Ok(req) => ResourceVersionReq::Semantic(req), + Err(_) => ResourceVersionReq::Arbitrary(value), + } + } +} + +impl From for String { + fn from(value: ResourceVersionReq) -> Self { + value.to_string() + } +} + +impl From<&String> for ResourceVersionReq { + fn from(value: &String) -> Self { + match SemanticVersionReq::parse(value) { + Ok(req) => ResourceVersionReq::Semantic(req), + Err(_) => ResourceVersionReq::Arbitrary(value.clone()), + } + } +} +// We can't bidirectionally convert string slices, because we can't return a temporary reference. +// We can still convert _from_ string slices, but not _into_ them. +impl From<&str> for ResourceVersionReq { + fn from(value: &str) -> Self { + ResourceVersionReq::from(value.to_string()) + } +} + +impl From for ResourceVersionReq { + fn from(value: SemanticVersionReq) -> Self { + Self::Semantic(value) + } +} + +impl From<&SemanticVersionReq> for ResourceVersionReq { + fn from(value: &SemanticVersionReq) -> Self { + Self::Semantic(value.clone()) + } +} + +// Creating an instance of `SemanticVersionReq` from `ResourceVersionReq` is the only fallible +// conversion, since `ResourceVersionReq` can define non-semantic version requirements. +impl TryFrom for SemanticVersionReq { + type Error = DscError; + + fn try_from(value: ResourceVersionReq) -> Result { + match value { + ResourceVersionReq::Semantic(req) => Ok(req), + ResourceVersionReq::Arbitrary(s) => Err(DscError::ResourceVersionReqToSemverConversion(s)) + } + } +} + +// Implement traits for comparing `ResourceVersionReq` to strings and semantic version requirements +// bi-directionally. +impl PartialEq for ResourceVersionReq { + fn eq(&self, other: &Self) -> bool { + match self { + Self::Semantic(req) => match other { + Self::Semantic(other_req) => req == other_req, + Self::Arbitrary(_) => false, + }, + Self::Arbitrary(string) => !other.is_semver() && *string == other.to_string(), + } + } +} + +impl PartialEq for ResourceVersionReq { + fn eq(&self, other: &SemanticVersionReq) -> bool { + match self { + Self::Semantic(req) => req == other, + Self::Arbitrary(_) => false, + } + } +} + +impl PartialEq for SemanticVersionReq { + fn eq(&self, other: &ResourceVersionReq) -> bool { + match other { + ResourceVersionReq::Semantic(req) => self == req, + ResourceVersionReq::Arbitrary(_) => false, + } + } +} + +impl PartialEq<&str> for ResourceVersionReq { + fn eq(&self, other: &&str) -> bool { + self == &Self::new(*other) + } +} + +impl PartialEq for &str { + fn eq(&self, other: &ResourceVersionReq) -> bool { + &ResourceVersionReq::new(self) == other + } +} + +impl PartialEq for ResourceVersionReq { + fn eq(&self, other: &str) -> bool { + self == &Self::new(other) + } +} + +impl PartialEq for str { + fn eq(&self, other: &ResourceVersionReq) -> bool { + &ResourceVersionReq::new(self) == other + } +} + +impl PartialEq for ResourceVersionReq { + fn eq(&self, other: &String) -> bool { + self == &Self::new(other) + } +} + +impl PartialEq for String { + fn eq(&self, other: &ResourceVersionReq) -> bool { + &ResourceVersionReq::new(self) == other + } +} diff --git a/lib/dsc-lib/tests/integration/types/mod.rs b/lib/dsc-lib/tests/integration/types/mod.rs index c6dafeec0..8a07e79cb 100644 --- a/lib/dsc-lib/tests/integration/types/mod.rs +++ b/lib/dsc-lib/tests/integration/types/mod.rs @@ -6,6 +6,8 @@ mod fully_qualified_type_name; #[cfg(test)] mod resource_version; #[cfg(test)] +mod resource_version_req; +#[cfg(test)] mod semantic_version; #[cfg(test)] mod semantic_version_req; diff --git a/lib/dsc-lib/tests/integration/types/resource_version_req.rs b/lib/dsc-lib/tests/integration/types/resource_version_req.rs new file mode 100644 index 000000000..a764288ee --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/resource_version_req.rs @@ -0,0 +1,477 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + use test_case::test_case; + + #[cfg(test)] + mod new { + use dsc_lib::types::ResourceVersionReq; + use dsc_lib::types::ResourceVersionReq::*; + use test_case::test_case; + + #[test_case("1" => matches Semantic(_); "major is semantic")] + #[test_case("1.2" => matches Semantic(_); "major.minor is semantic")] + #[test_case("1.2.3" => matches Semantic(_); "major.minor.patch is semantic")] + #[test_case("1.2.3-pre" => matches Semantic(_); "major.minor.patch-pre is semantic")] + #[test_case("1-pre" => matches Arbitrary(_); "major-pre is arbitrary")] + #[test_case("1.2-pre" => matches Arbitrary(_); "major.minor-pre is arbitrary")] + #[test_case("1.2.3+build" => matches Arbitrary(_); "major.minor.patch+build is arbitrary")] + #[test_case("1.2.3-pre+build" => matches Arbitrary(_); "major.minor.patch-pre+build is arbitrary")] + #[test_case("a" => matches Arbitrary(_); "invalid_char is arbitrary")] + #[test_case("1.b" => matches Arbitrary(_); "major.invalid_char is arbitrary")] + #[test_case("1.2.c" => matches Arbitrary(_); "major.minor.invalid_char is arbitrary")] + fn literal_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + + #[test_case("1.*" => matches Semantic(_); "major.wildcard is semantic")] + #[test_case("1.*.*" => matches Semantic(_); "major.wildcard.wildcard is semantic")] + #[test_case("1.2.*" => matches Semantic(_); "major.minor.wildcard is semantic")] + #[test_case("1.*.3" => matches Arbitrary(_); "major.wildcard.patch is arbitrary")] + #[test_case("1.2.*-pre" => matches Arbitrary(_); "major.minor.wildcard-pre is arbitrary")] + #[test_case("1.*.*-pre" => matches Arbitrary(_); "major.wildcard.wildcard-pre is arbitrary")] + #[test_case("1.2.3-*" => matches Arbitrary(_); "major.minor.patch-wildcard is arbitrary")] + #[test_case("1.2.3-pre.*" => matches Arbitrary(_); "major.minor.patch-pre.wildcard is arbitrary")] + fn wildcard_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + + #[test_case("1.2.3" => matches Semantic(_); "implicit operator is semantic")] + #[test_case("^ 1.2.3" => matches Semantic(_); "caret operator is semantic")] + #[test_case("~ 1.2.3" => matches Semantic(_); "tilde operator is semantic")] + #[test_case("= 1.2.3" => matches Semantic(_); "exact operator is semantic")] + #[test_case("> 1.2.3" => matches Semantic(_); "greater than operator is semantic")] + #[test_case(">= 1.2.3" => matches Semantic(_); "greater than or equal to operator is semantic")] + #[test_case("< 1.2.3" => matches Semantic(_); "less than operator is semantic")] + #[test_case("<= 1.2.3" => matches Semantic(_); "less than or equal to operator is semantic")] + #[test_case("== 1.2.3" => matches Arbitrary(_); "invalid operator is arbitrary")] + fn operators_in_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + + #[test_case("1.2.3, < 1.5" => matches Semantic(_); "pair with separating comma is semantic")] + #[test_case("1, 1.2, 1.2.3" => matches Semantic(_); "triple with separating comma is semantic")] + #[test_case("<= 1, >= 2" => matches Semantic(_); "incompatible pair is semantic")] + #[test_case(", 1, 1.2" => matches Arbitrary(_); "leading comma is arbitrary")] + #[test_case("1, 1.2," => matches Arbitrary(_); "trailing comma is arbitrary")] + #[test_case("1 1.2" => matches Arbitrary(_); "omitted separating comma is arbitrary")] + #[test_case("1.*, < 1.3.*" => matches Semantic(_); "multiple comparators with wildcard is semantic")] + fn multiple_comparator_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + + #[test_case("^1.2" => matches Semantic(_); "operator and version without spacing is semantic")] + #[test_case("^ 1.2" => matches Semantic(_); "operator and version with extra spacing is semantic")] + #[test_case(" ^ 1.2" => matches Semantic(_); "leading space is semantic")] + #[test_case("^ 1.2 " => matches Semantic(_); "trailing space is semantic")] + #[test_case("^1.2,<1.5" => matches Semantic(_); "pair of comparators without spacing is semantic")] + #[test_case(" ^ 1.2 , < 1.5 " => matches Semantic(_); "pair of comparators with extra spacing is semantic")] + fn spacing_in_version_req(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::new(requirement_string) + } + } + + #[test_case("1.2.3" => true; "single comparator is semver")] + #[test_case("^1.2, >1.5" => true; "multi comparator is semver")] + #[test_case("2026-02-01" => false; "date string is not semver")] + #[test_case("arbitrary" => false; "arbitrary string is not semver")] + fn is_semver(requirement_string: &str) -> bool { + ResourceVersionReq::new(requirement_string).is_semver() + } + + #[test_case("1.2.3" => false; "single comparator is not arbitrary")] + #[test_case("^1.2, >1.5" => false; "multi comparator is not arbitrary")] + #[test_case("2026-02-01" => true; "date string is arbitrary")] + #[test_case("arbitrary" => true; "arbitrary string is arbitrary")] + fn is_arbitrary(requirement_string: &str) -> bool { + ResourceVersionReq::new(requirement_string).is_arbitrary() + } + + #[test_case("1.2.3" => matches Some(_); "single comparator returns some")] + #[test_case("^1.2, >1.5" => matches Some(_); "multi comparator returns some")] + #[test_case("2026-02-01" => matches None; "date string returns none")] + #[test_case("arbitrary" => matches None; "arbitrary string returns none")] + fn as_semver_req(requirement_string: &str) -> Option { + ResourceVersionReq::new(requirement_string).as_semver_req().cloned() + } + + #[cfg(test)] + mod matches { + use dsc_lib::types::{ResourceVersion, ResourceVersionReq}; + use test_case::test_case; + + fn check(requirement: &str, versions: Vec<&str>, should_match: bool) { + let req = ResourceVersionReq::new(requirement); + let expected = if should_match { "match" } else { "not match" }; + for version in versions { + pretty_assertions::assert_eq!( + req.matches(&ResourceVersion::new(version)), + should_match, + "expected version '{version}' to {expected} requirement '{requirement}'" + ); + } + } + + // Only test a subset of valid semantic reqs since the matches method for SemanticVersionReq + // more thoroughly covers these cases + #[test_case("1", vec!["1.0.0", "1.2.0", "1.3.0"], true; "matching major")] + #[test_case("1", vec!["0.1.0", "2.0.0", "1.2.3-rc.1", "2026-02-01", "arbitrary"], false; "not matching major")] + #[test_case("1.2", vec!["1.2.0", "1.2.3", "1.3.0"], true; "matching major.minor")] + #[test_case("1.2", vec!["1.0.0", "2.0.0", "1.2.3-rc.1", "2026-02-01", "arbitrary"], false; "not matching major.minor")] + #[test_case("1.2.3", vec!["1.2.3", "1.2.4", "1.3.0"], true; "matching major.minor.patch")] + #[test_case("1.2.3", vec!["1.2.0", "2.0.0", "1.2.3-rc.1", "2026-02-01", "arbitrary"], false; "not matching major.minor.patch")] + #[test_case("1.2.3-rc.2", vec!["1.2.3", "1.3.0", "1.2.3-rc.2", "1.2.3-rc.3"], true; "matching major.minor.patch-pre")] + #[test_case("1.2.3-rc.2", vec!["1.2.0", "2.0.0", "1.2.3-rc.1", "1.3.0-rc.2", "2026-02-01", "arbitrary"], false; "not matching major.minor.patch-pre")] + fn semantic(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + + #[test_case("2026-02-01", vec!["2026-02-01"], true; "matching version as date")] + #[test_case("2026-02-01", vec!["2026-02-02", "2026-02", "arbitrary", "2026.02.01", "1.2.3"], false; "not matching version as date")] + #[test_case("Arbitrary", vec!["Arbitrary"], true; "matching version as arbitrary string")] + #[test_case("Arbitrary", vec!["arbitrary", " Arbitrary", "Arbitrary ", "2026-02-01", "1.2.3"], false; "not matching version as arbitrary string")] + fn arbitrary(requirement: &str, versions: Vec<&str>, should_match: bool) { + check(requirement, versions, should_match); + } + } +} + +#[cfg(test)] +mod schema { + use std::{ops::Index, sync::LazyLock}; + + use dsc_lib::types::ResourceVersionReq; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static ROOT_SCHEMA: LazyLock = LazyLock::new(|| schema_for!(ResourceVersionReq)); + static SEMANTIC_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + (&*ROOT_SCHEMA) + .get_keyword_as_array("anyOf") + .unwrap() + .index(0) + .as_object() + .unwrap() + .clone() + .into() + }); + static ARBITRARY_VARIANT_SCHEMA: LazyLock = LazyLock::new(|| { + (&*ROOT_SCHEMA) + .get_keyword_as_array("anyOf") + .unwrap() + .index(1) + .as_object() + .unwrap() + .clone() + .into() + }); + + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*ROOT_SCHEMA).as_value()).unwrap()); + + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is semantic")); + + #[test_case("title", &*ROOT_SCHEMA; "title")] + #[test_case("description", &*ROOT_SCHEMA; "description")] + #[test_case("markdownDescription", &*ROOT_SCHEMA; "markdownDescription")] + #[test_case("title", &*SEMANTIC_VARIANT_SCHEMA; "semver.title")] + #[test_case("description", &*SEMANTIC_VARIANT_SCHEMA; "semver.description")] + #[test_case("markdownDescription", &*SEMANTIC_VARIANT_SCHEMA; "semver.markdownDescription")] + #[test_case("title", &*ARBITRARY_VARIANT_SCHEMA; "arbitrary.title")] + #[test_case("description", &*ARBITRARY_VARIANT_SCHEMA; "arbitrary.description")] + #[test_case("deprecationMessage", &*ARBITRARY_VARIANT_SCHEMA; "arbitrary.deprecationMessage")] + #[test_case("markdownDescription", &*ARBITRARY_VARIANT_SCHEMA; "arbitrary.markdownDescription")] + fn has_documentation_keyword(keyword: &str, schema: &Schema) { + let value = schema + .get_keyword_as_str(keyword) + .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'" + ); + } + + #[test] + fn semver_subschema_is_reference() { + assert!( + (&*SEMANTIC_VARIANT_SCHEMA).get_keyword_as_string("$ref").is_some_and(|kv| !kv.is_empty()) + ) + } + + #[test_case(&json!("^1.2.3") => true ; "single comparator semantic version req string value is semantic")] + #[test_case(&json!("^1.2.3, <1.5") => true ; "multi comparator semantic version req string value is semantic")] + #[test_case(&json!("=1.2.3a") => true ; "invalid semantic version req string value is semantic")] + #[test_case(&json!("2026-01-15") => true ; "iso8601 date full string value is semantic")] + #[test_case(&json!("2026-01") => true ; "iso8601 date year month string value is semantic")] + #[test_case(&json!("arbitrary_string") => true ; "arbitrary string value is semantic")] + #[test_case(&json!(true) => false; "boolean value is arbitrary")] + #[test_case(&json!(1) => false; "integer value is arbitrary")] + #[test_case(&json!(1.2) => false; "float value is arbitrary")] + #[test_case(&json!({"version": "1.2.3"}) => false; "object value is arbitrary")] + #[test_case(&json!(["1.2.3"]) => false; "array value is arbitrary")] + #[test_case(&serde_json::Value::Null => false; "null value is arbitrary")] + fn validation(input_json: &Value) -> bool { + (&*VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::ResourceVersionReq; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("^1.2.3"; "single comparator semantic req string serializes to string")] + #[test_case("^1.2.3, <1.4"; "multi comparator semantic req serializes to string")] + #[test_case("2026-02-1"; "arbitrary req formatted as date serializes to string")] + #[test_case("arbitrary"; "arbitrary req serializes to string")] + fn serializing(requirement: &str) { + let actual = serde_json::to_string( + &ResourceVersionReq::new(requirement) + ).expect("serialization should never fail"); + + let expected = format!(r#""{requirement}""#); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case(json!("1.2.3") => matches Ok(_); "valid req string value succeeds")] + #[test_case(json!("a.b") => matches Ok(_); "invalid req string value succeeds")] + #[test_case(json!(true) => matches Err(_); "boolean value is invalid")] + #[test_case(json!(1) => matches Err(_); "integer value is invalid")] + #[test_case(json!(1.2) => matches Err(_); "float value is invalid")] + #[test_case(json!({"req": "1.2.3"}) => matches Err(_); "object value is invalid")] + #[test_case(json!(["1.2.3"]) => matches Err(_); "array value is invalid")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value is invalid")] + fn deserializing(value: Value) -> Result { + serde_json::from_value::(value) + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + + #[test] + fn default() { + pretty_assertions::assert_eq!( + ResourceVersionReq::default(), + SemanticVersionReq::default(), + ) + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::ResourceVersionReq; + use test_case::test_case; + + #[test_case("1.2", "^1.2"; "semantic req with single comparator")] + #[test_case("1.2, < 1.4", "^1.2, <1.4"; "semantic req with multiple comparators")] + #[test_case("1.*", "1.*"; "semantic req with a wildcard")] + #[test_case("2020-02-01", "2020-02-01"; "arbitrary req as date")] + #[test_case("Arbitrary", "Arbitrary"; "arbitrary req as string")] + fn format(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + format!("req: '{}'", ResourceVersionReq::new(requirement)), + format!("req: '{}'", expected) + ) + } + + #[test_case("1.2", "^1.2"; "semantic req with single comparator")] + #[test_case("1.2, < 1.4", "^1.2, <1.4"; "semantic req with multiple comparators")] + #[test_case("1.*", "1.*"; "semantic req with a wildcard")] + #[test_case("2020-02-01", "2020-02-01"; "arbitrary req as date")] + #[test_case("Arbitrary", "Arbitrary"; "arbitrary req as string")] + fn to_string(requirement: &str, expected: &str) { + pretty_assertions::assert_eq!( + ResourceVersionReq::new(requirement).to_string(), + expected.to_string() + ) + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + use dsc_lib::types::ResourceVersionReq::*; + use test_case::test_case; + + #[test] + fn semantic_version_req() { + let semantic = SemanticVersionReq::parse("^1.2.3").unwrap(); + match ResourceVersionReq::from(semantic.clone()) { + Semantic(req) => pretty_assertions::assert_eq!(req, semantic), + Arbitrary(_) => { + panic!("should never fail to convert as Semantic version requirement") + } + } + } + #[test_case("^1.2.3" => matches Semantic(_); "single comparator semantic req")] + #[test_case("^1.2, <1.5" => matches Semantic(_); "multi comparator semantic req")] + #[test_case("2020-02-01" => matches Arbitrary(_); "date-formatted arbitrary req")] + #[test_case("arbitrary" => matches Arbitrary(_); "arbitrary string req")] + fn string(requirement_string: &str) -> ResourceVersionReq { + ResourceVersionReq::from(requirement_string.to_string()) + } + + #[test_case("^1.2.3" => matches Semantic(_); "single comparator semantic req")] + #[test_case("^1.2, <1.5" => matches Semantic(_); "multi comparator semantic req")] + #[test_case("2020-02-01" => matches Arbitrary(_); "date-formatted arbitrary req")] + #[test_case("arbitrary" => matches Arbitrary(_); "arbitrary string req")] + fn str(string_slice: &str) -> ResourceVersionReq { + ResourceVersionReq::from(string_slice) + } + } + + #[cfg(test)] + mod from_str { + use dsc_lib::types::ResourceVersionReq; + use dsc_lib::types::ResourceVersionReq::*; + use test_case::test_case; + + #[test_case("^1.2.3" => matches Semantic(_); "single comparator semantic req")] + #[test_case("^1.2, <1.5" => matches Semantic(_); "multi comparator semantic req")] + #[test_case("2020-02-01" => matches Arbitrary(_); "date-formatted arbitrary req")] + #[test_case("arbitrary" => matches Arbitrary(_); "arbitrary string req")] + fn parse(input: &str) -> ResourceVersionReq { + input.parse().expect("parse should be infallible") + } + } + + #[cfg(test)] + mod into { + use dsc_lib::types::ResourceVersionReq; + use test_case::test_case; + + #[test_case("^1.2.3"; "single comparator semantic req")] + #[test_case("^1.2, <1.5"; "multi comparator semantic req")] + #[test_case("2020-02-01"; "date-formatted arbitrary req")] + #[test_case("arbitrary"; "arbitrary string req")] + fn string(requirement_string: &str) { + let actual: String = ResourceVersionReq::new(requirement_string).into(); + let expected = requirement_string.to_string(); + + pretty_assertions::assert_eq!(actual, expected) + } + } + + #[cfg(test)] + mod try_into { + use dsc_lib::{dscerror::DscError, types::{ResourceVersionReq, SemanticVersionReq}}; + use test_case::test_case; + + #[test_case("^1.2.3" => matches Ok(_); "single comparator semantic req converts")] + #[test_case("^1.2, <1.5" => matches Ok(_); "multi comparator semantic req converts")] + #[test_case("2020-02-01" => matches Err(_); "date-formatted arbitrary req fails")] + #[test_case("arbitrary" => matches Err(_); "arbitrary string req fails")] + fn semantic_version_req(requirement: &str) -> Result { + TryInto::::try_into(ResourceVersionReq::new(requirement)) + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::{ResourceVersionReq, SemanticVersionReq}; + use test_case::test_case; + + #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2.3", "^1.2.3", true; "identical semantic reqs")] + #[test_case(">1.2.3", "<1.2.3", false; "different semantic reqs")] + #[test_case("Arbitrary", "Arbitrary", true; "identical arbitrary reqs")] + #[test_case("Arbitrary", "arbitrary", false; "differently cased arbitrary reqs")] + #[test_case("foo", "bar", false; "different arbitrary reqs")] + fn resource_version_req(lhs: &str, rhs: &str, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!(ResourceVersionReq::new(lhs), ResourceVersionReq::new(rhs)) + } else { + pretty_assertions::assert_ne!(ResourceVersionReq::new(lhs), ResourceVersionReq::new(rhs)) + } + } + + #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2.3", "^1.2.3", true; "identical semantic reqs")] + #[test_case(">1.2.3", "<1.2.3", false; "different semantic reqs")] + #[test_case("Arbitrary", "1.2.3", false; "arbitrary req and semantic req")] + fn semantic_version_req( + resource_version_req_string: &str, + semantic_version_req_string: &str, + should_be_equal: bool, + ) { + let req = ResourceVersionReq::new(resource_version_req_string); + let semantic = SemanticVersionReq::parse(semantic_version_req_string).unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + req == semantic, + should_be_equal, + "expected comparison of {req} and {semantic} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + semantic == req, + should_be_equal, + "expected comparison of {semantic} and {req} to be #{should_be_equal}" + ); + } + + #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2.3", "^1.2.3", true; "identical semantic reqs")] + #[test_case(">1.2.3", "<1.2.3", false; "different semantic reqs")] + #[test_case("Arbitrary", "1.2.3", false; "arbitrary req and semantic req")] + #[test_case("Arbitrary", "Arbitrary", true; "identical arbitrary reqs")] + #[test_case("Arbitrary", "arbitrary", false; "differently cased arbitrary reqs")] + #[test_case("foo", "bar", false; "different arbitrary reqs")] + fn str(resource_version_req_string: &str, string_slice: &str, should_be_equal: bool) { + let req: ResourceVersionReq = resource_version_req_string.parse().unwrap(); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + req == string_slice, + should_be_equal, + "expected comparison of {req} and {string_slice} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string_slice == req, + should_be_equal, + "expected comparison of {string_slice} and {req} to be #{should_be_equal}" + ); + } + + #[test_case("1.2.3", "^1.2.3", true; "equivalent semantic reqs")] + #[test_case("^1.2.3", "^1.2.3", true; "identical semantic reqs")] + #[test_case(">1.2.3", "<1.2.3", false; "different semantic reqs")] + #[test_case("Arbitrary", "1.2.3", false; "arbitrary req and semantic req")] + #[test_case("Arbitrary", "Arbitrary", true; "identical arbitrary reqs")] + #[test_case("Arbitrary", "arbitrary", false; "differently cased arbitrary reqs")] + #[test_case("foo", "bar", false; "different arbitrary reqs")] + fn string(resource_version_req_string: &str, string_slice: &str, should_be_equal: bool) { + let req: ResourceVersionReq = resource_version_req_string.parse().unwrap(); + let string = string_slice.to_string(); + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + req == string, + should_be_equal, + "expected comparison of {req} and {string} to be #{should_be_equal}" + ); + + pretty_assertions::assert_eq!( + string == req, + should_be_equal, + "expected comparison of {string} and {req} to be #{should_be_equal}" + ); + } + } +} From 76e7a06f0d5e5ed2a5defff22af17f490f67fe6c Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Wed, 4 Feb 2026 13:53:36 -0600 Subject: [PATCH 5/6] (MAINT) fix broken rustdocs link --- lib/dsc-lib/src/types/fully_qualified_type_name.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/dsc-lib/src/types/fully_qualified_type_name.rs b/lib/dsc-lib/src/types/fully_qualified_type_name.rs index 8c19468e7..101f4bb8a 100644 --- a/lib/dsc-lib/src/types/fully_qualified_type_name.rs +++ b/lib/dsc-lib/src/types/fully_qualified_type_name.rs @@ -86,6 +86,8 @@ impl FullyQualifiedTypeName { /// Creates a new instance of [`FullyQualifiedTypeName`] from a string if the input is valid for the /// [`VALIDATING_PATTERN`]. If the string is invalid, the method raises the /// [`DscError::InvalidTypeName`] error. + /// + /// [`VALIDATING_PATTERN`]: Self::VALIDATING_PATTERN pub fn new(name: &str) -> Result { Self::validate(name)?; Ok(Self(name.to_string())) From 5c29625e13bc1eebdc5f5f8e7cc02d9f526a4f9b Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 5 Feb 2026 16:32:33 -0600 Subject: [PATCH 6/6] (MAINT) Address copilot review --- lib/dsc-lib/locales/schemas.definitions.yaml | 15 +++------ lib/dsc-lib/src/types/semantic_version.rs | 4 +-- lib/dsc-lib/src/types/semantic_version_req.rs | 31 +++++++++---------- .../integration/types/resource_version_req.rs | 26 ++++++++-------- 4 files changed, 34 insertions(+), 42 deletions(-) diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index a9da9779a..ce9b16320 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -58,14 +58,7 @@ schemas: exactly the same characters - the comparison is case-sensitive. If you're defining a resource that doesn't follow semantic versioning, consider defining the version as an [ISO 8601 date][04], like `2026-01-15`. When you do, DSC can correctly determine that a - later date should be treated as a newer version. stringVariant: title: en-us: Arbitrary - version string description: en-us: >- Defines the version for the type as an arbitrary - string. deprecationMessage: en-us: >- Arbitrary string versions for resources are only in - place for compatibility with ARM. Resource authors should define their resources with - valid semantic versions, like `1.2.3`. markdownDescription: en-us: |- Defines the version - for the type as an arbitrary string. When the version for the type isn't a valid semantic - version, DSC treats the version as a string. This enables DSC to support - non-semantically-versioned types, such as using a release date as the version. + later date should be treated as a newer version. [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semver [02]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semverReq @@ -151,7 +144,7 @@ schemas: version requirements. [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/resourceVersion - [02]:https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semverReq + [02]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/semverReq semanticVariant: title: en-us: Semantic resource version requirement @@ -196,12 +189,14 @@ schemas: version and this version requirement are exactly the same. The comparison is case-sensitive. + [01]: https://learn.microsoft.com/powershell/dsc/reference/schemas/definitions/resourceVersion + semver: title: en-us: Semantic version description: en-us: |- - Defines a valid semantic version (semver)as a string. + Defines a valid semantic version (semver) as a string. For reference, see https://semver.org/ markdownDescription: diff --git a/lib/dsc-lib/src/types/semantic_version.rs b/lib/dsc-lib/src/types/semantic_version.rs index f9fccaf91..f116252fe 100644 --- a/lib/dsc-lib/src/types/semantic_version.rs +++ b/lib/dsc-lib/src/types/semantic_version.rs @@ -190,7 +190,7 @@ use serde::{Deserialize, Serialize}; /// let v2_0_0: SemanticVersion = "2.0.0".parse().unwrap(); /// let v1_2_3: SemanticVersion = "1.2.3".parse().unwrap(); /// let v1_2_3_pre: SemanticVersion = "1.2.3-rc.1".parse().unwrap(); -/// let v1_2_3_build: SemanticVersion = "1.2.3+rci.1".parse().unwrap(); +/// let v1_2_3_build: SemanticVersion = "1.2.3+ci.1".parse().unwrap(); /// let v1_2_3_pre_build: SemanticVersion = "1.2.3-rc.1+ci.1".parse().unwrap(); /// /// // Comparisons of stable versions work as expected @@ -573,7 +573,7 @@ impl PartialEq for SemanticVersion { impl PartialEq for str { fn eq(&self, other: &SemanticVersion) -> bool { - match SemanticVersion::parse(&self) { + match SemanticVersion::parse(self) { Ok(version) => version.eq(other), Err(_) => false, } diff --git a/lib/dsc-lib/src/types/semantic_version_req.rs b/lib/dsc-lib/src/types/semantic_version_req.rs index 34bd66101..8c52d32e2 100644 --- a/lib/dsc-lib/src/types/semantic_version_req.rs +++ b/lib/dsc-lib/src/types/semantic_version_req.rs @@ -118,7 +118,7 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// /// You can specify the minor and patch version segments as a wildcard with the asterisk (`*`) /// character, indicating that it should match any version for that segment. If the minor version -/// segment is a wildcard,the patch version segment must either be a wildcard or omitted. +/// segment is a wildcard, the patch version segment must either be a wildcard or omitted. /// /// When specifying an explicit operator, specifying the version for a comparator with wildcards is /// equivalent to omitting those version segments. When you define a comparator without an explicit @@ -144,7 +144,7 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// | `^1.2` | `>=1.2.0, <2.0.0` | `1.2`, `^1.2.*` | /// | `^1.2.*` | `>=1.2.0, <2.0.0` | `1.2` | /// | `=1` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1.*`, `=1.*.*` | -/// | `=1.*` | `>=1,0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*.*` | +/// | `=1.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*.*` | /// | `=1.*.*` | `>=1.0.0, <2.0.0` | `1`, `1.*`, `1.*.*`, `^1`, `^1.*`, `^1.*.*`, `=1`, `=1.*` | /// | `=1.2` | `>=1.2.0, <1.3.0` | `1.2.*`, `=1.2.*` | /// | `=1.2.*` | `>=1.2.0, <1.3.0` | `1.2.*`, `=1.2` | @@ -263,15 +263,15 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// be less than the version for this comparator. Versions equal to or greater than the /// comparator version don't match the comparator. /// -/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | -/// |:------------------:|:---------------------:|:---------------------------------------|:------------------------------------------------| -/// | `<1` | `<1.0.0` |`0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | -/// | `<1.*` | `<1.0.0` |`0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | -/// | `<1.*.*` | `<1.0.0` |`0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | -/// | `<1.2` | `<1.2.0` | 0.1.0`, `1.0.0`, `1.1.1` | `1.2.0`, `1.2.3`, `1.3.0`, `1.2.0-rc.1`, | -/// | `<1.2.*` | `<1.2.0` | 0.1.0`, `1.0.0`, `1.1.1` | `1.2.0`, `1.2.3`, `1.3.0`, `1.2.0-rc.1`, | -/// | `<1.2.3` | `<1.2.3` | 0.1.0`, `1.0.0`, `1.2.0` | `1.2.3`, `1.3.0`, `1.2.3-rc.1` | -/// | `<1.2.3-rc.2` | `<1.2.3-rc.2` | 0.1.0`, `1.0.0`, `1.2.0`, `1.2.3-rc.1` | `1.2.3`, `1.3.0`, `1.0.0-rc.1`, ``1.2.3-rc.2 | +/// | Literal comparator | Effective requirement | Valid versions | Invalid versions | +/// |:------------------:|:----------------------:|:----------------------------------------|:---------------------------------------------| +/// | `<1` | `<1.0.0` | `0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | +/// | `<1.*` | `<1.0.0` | `0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | +/// | `<1.*.*` | `<1.0.0` | `0.1.0` | `1.0.0`, `1.2.0`, `1.2.3`, `0.1.0-rc.1` | +/// | `<1.2` | `<1.2.0` | `0.1.0`, `1.0.0`, `1.1.1` | `1.2.0`, `1.2.3`, `1.3.0`, `1.2.0-rc.1`, | +/// | `<1.2.*` | `<1.2.0` | `0.1.0`, `1.0.0`, `1.1.1` | `1.2.0`, `1.2.3`, `1.3.0`, `1.2.0-rc.1`, | +/// | `<1.2.3` | `<1.2.3` | `0.1.0`, `1.0.0`, `1.2.0` | `1.2.3`, `1.3.0`, `1.2.3-rc.1` | +/// | `<1.2.3-rc.2` | `<1.2.3-rc.2` | `0.1.0`, `1.0.0`, `1.2.0`, `1.2.3-rc.1` | `1.2.3`, `1.3.0`, `1.0.0-rc.1`, `1.2.3-rc.2` | /// /// - Less than or equal to (`<=`) - Indicates that the /// [`SemanticVersion`] must be any version up to the version for this comparator. Versions @@ -355,7 +355,7 @@ use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema, types::Semanti /// 1. If the version doesn't define any wildcards, the implicit operator for the comparator is /// the caret operator. The following sets of comparators are parsed identically: /// -/// - `1` and `^ ` +/// - `1` and `^1` /// - `1.2` and `^1.2` /// - `1.2.3` and `^1.2.3` /// - `1.2.3-rc.1` and `^1.2.3-rc.1` @@ -657,7 +657,7 @@ impl SemanticVersionReq { /// |:----------------:|:-----:|:------------------------------------------------------------------------------------| /// | `1.*` | Yes | Defines a literal major version segment followed by a wildcard minor version. | /// | `1.2.*` | Yes | Defines literal major and minor segments followed by a wildcard patch version. | - /// | `1.*.*` | No | Defines more than one wildcard, which is forbidden. | + /// | `1.*.*` | Yes | Equivalent to `1.*` - both wildcards match any minor and patch version. | /// | `1.*.3` | No | If the version includes any wildcards, it must be the last defined version segment. | /// | `1.2.3-*` | No | Defines the prerelease segment as a wildcard, which is forbidden. | pub const WILDCARD_VERSION_PATTERN: &str = const_str::concat!( @@ -771,10 +771,7 @@ impl FromStr for SemanticVersionReq { impl TryFrom for SemanticVersionReq { type Error = DscError; fn try_from(value: String) -> Result { - match semver::VersionReq::parse(value.as_str()) { - Ok(r) => Ok(Self(r)), - Err(e) => Err(DscError::SemVer(e)), - } + Self::parse(value.as_str()) } } diff --git a/lib/dsc-lib/tests/integration/types/resource_version_req.rs b/lib/dsc-lib/tests/integration/types/resource_version_req.rs index a764288ee..2f66ac860 100644 --- a/lib/dsc-lib/tests/integration/types/resource_version_req.rs +++ b/lib/dsc-lib/tests/integration/types/resource_version_req.rs @@ -177,7 +177,7 @@ mod schema { LazyLock::new(|| Validator::new((&*ROOT_SCHEMA).as_value()).unwrap()); static KEYWORD_PATTERN: LazyLock = - LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is semantic")); + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); #[test_case("title", &*ROOT_SCHEMA; "title")] #[test_case("description", &*ROOT_SCHEMA; "description")] @@ -207,18 +207,18 @@ mod schema { ) } - #[test_case(&json!("^1.2.3") => true ; "single comparator semantic version req string value is semantic")] - #[test_case(&json!("^1.2.3, <1.5") => true ; "multi comparator semantic version req string value is semantic")] - #[test_case(&json!("=1.2.3a") => true ; "invalid semantic version req string value is semantic")] - #[test_case(&json!("2026-01-15") => true ; "iso8601 date full string value is semantic")] - #[test_case(&json!("2026-01") => true ; "iso8601 date year month string value is semantic")] - #[test_case(&json!("arbitrary_string") => true ; "arbitrary string value is semantic")] - #[test_case(&json!(true) => false; "boolean value is arbitrary")] - #[test_case(&json!(1) => false; "integer value is arbitrary")] - #[test_case(&json!(1.2) => false; "float value is arbitrary")] - #[test_case(&json!({"version": "1.2.3"}) => false; "object value is arbitrary")] - #[test_case(&json!(["1.2.3"]) => false; "array value is arbitrary")] - #[test_case(&serde_json::Value::Null => false; "null value is arbitrary")] + #[test_case(&json!("^1.2.3") => true ; "single comparator semantic version req string value is valid")] + #[test_case(&json!("^1.2.3, <1.5") => true ; "multi comparator semantic version req string value is valid")] + #[test_case(&json!("=1.2.3a") => true ; "invalid semantic version req string value is valid")] + #[test_case(&json!("2026-01-15") => true ; "iso8601 date full string value is valid")] + #[test_case(&json!("2026-01") => true ; "iso8601 date year month string value is valid")] + #[test_case(&json!("arbitrary_string") => true ; "arbitrary string value is valid")] + #[test_case(&json!(true) => false; "boolean value is invalid")] + #[test_case(&json!(1) => false; "integer value is invalid")] + #[test_case(&json!(1.2) => false; "float value is invalid")] + #[test_case(&json!({"version": "1.2.3"}) => false; "object value is invalid")] + #[test_case(&json!(["1.2.3"]) => false; "array value is invalid")] + #[test_case(&serde_json::Value::Null => false; "null value is invalid")] fn validation(input_json: &Value) -> bool { (&*VALIDATOR).validate(input_json).is_ok() }