From a25beb83f461ccbd9cb552c8683ecabd0ec4496a Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Mon, 12 Jan 2026 16:04:59 -0600 Subject: [PATCH 1/4] (GH-538) Define `TypeVersion` 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 `TypeVersion` 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 `TypeVersion::Semantic` and otherwise creates it as `TypeVersion::String`. The `TypeVersion` enum implements several conversion and comparison traits to make using the newtype more ergonomic. It also defines helper methods `is_semver()` and `matches_semver_req()` for easier usage. When comparing an instance of `TypeVersion`: - A semantic version is always greater than an arbitrary string version. This applies both when comparing `TypeVersion::String` instances to `TypeVersion::Semantic` and to `semver::Version`. - Arbitrary string version comparisons are case-insensitive. `Foo` and `foo` are equal but `Foo` is greater than `Bar`. - You can directly compare instances of `TypeVersion` to string slices, `String` instances, and `semver::Version` instances in addition to other instances of `TypeVersion`. - The trait implementations support using `==`, `>`, and `<` operators for easier reading. The newtype overhauls the JSON Schema for 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. --- Cargo.lock | 39 ++ Cargo.toml | 4 +- lib/dsc-lib/Cargo.toml | 8 +- lib/dsc-lib/locales/en-us.toml | 1 + lib/dsc-lib/locales/schemas.definitions.yaml | 134 +++-- lib/dsc-lib/src/dscerror.rs | 3 + lib/dsc-lib/src/types/mod.rs | 2 + lib/dsc-lib/src/types/type_version.rs | 514 ++++++++++++++++++ lib/dsc-lib/tests/integration/types/mod.rs | 2 + .../tests/integration/types/type_version.rs | 482 ++++++++++++++++ 10 files changed, 1155 insertions(+), 34 deletions(-) create mode 100644 lib/dsc-lib/src/types/type_version.rs create mode 100644 lib/dsc-lib/tests/integration/types/type_version.rs diff --git a/Cargo.lock b/Cargo.lock index a5462091c..43c1679b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -685,6 +685,7 @@ dependencies = [ "murmurhash64", "num-traits", "path-absolutize", + "pretty_assertions", "regex", "rt-format", "rust-i18n", @@ -693,6 +694,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "test-case", "thiserror 2.0.17", "tokio", "tracing", @@ -2664,6 +2666,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" @@ -2958,6 +2964,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 7099b4d29..63caa0b35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -225,8 +225,10 @@ ipnetwork = { version = "0.21" } cc = { version = "1.2" } # 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..902da7e06 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -28,7 +28,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 +38,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 +52,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/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 034a2ca81..f6d56f928 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -733,6 +733,7 @@ resourceManifestNotFound = "Resource manifest not found" schema = "Schema" schemaNotAvailable = "No Schema found and `validate` is not supported" securityContext = "Security context" +typeVersionToSemverConversion = "Can't convert arbitrary string `Version` to `semver::Version`" 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 2f50f02d6..af3e3fe8d 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -2,34 +2,106 @@ _version: 2 schemas: definitions: resourceType: - title: Fully qualified type name - description: >- - Uniquely identifies a DSC resource or extension. - markdownDescription: |- - The fully qualified type name of a DSC resource or extension uniquely identifies a resource - or extension. - - Fully qualified type names use the following syntax: - - ```yaml - [....]/ - ``` - - Where the type may have zero or more namespace segments for organizing the type. The - `owner`, `namespace`, and `name` segments must consist only of alphanumeric characters and - underscores. - - Conventionally, the first character of each segment is capitalized. When a segment - contains a brand or proper name, use the correct casing for that word, like - `TailspinToys/Settings`, not `Tailspintoys/Settings`. - - Example fully qualified type names include: - - - `Microsoft/OSInfo` - - `Microsoft.SqlServer/Database` - - `Microsoft.Windows.IIS/WebApp` - patternErrorMessage: >- - Invalid type name. Valid resource type names always define an owner and a name separated by - 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`. + title: + en-us: Fully qualified type name + description: + en-us: >- + Uniquely identifies a DSC resource or extension. + markdownDescription: + en-us: |- + The fully qualified type name of a DSC resource or extension uniquely identifies a resource + or extension. + + Fully qualified type names use the following syntax: + + ```yaml + [....]/ + ``` + + Where the type may have zero or more namespace segments for organizing the type. The + `owner`, `namespace`, and `name` segments must consist only of alphanumeric characters and + underscores. + + Conventionally, the first character of each segment is capitalized. When a segment + contains a brand or proper name, use the correct casing for that word, like + `TailspinToys/Settings`, not `Tailspintoys/Settings`. + + Example fully qualified type names include: + + - `Microsoft/OSInfo` + - `Microsoft.SqlServer/Database` + - `Microsoft.Windows.IIS/WebApp` + patternErrorMessage: + en-us: >- + Invalid type name. Valid resource type names always define an owner and a name separated + by 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: |- + A valid semantic version (semver) string. + + For reference, see https://semver.org/ + markdownDescription: + en-us: |- + A valid semantic version ([semver][01]) 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. + + version: + title: + en-us: Version + description: + en-us: >- + Defines the version for the type as either a semantic version (semver) or arbitrary string. + markdownDescription: + en-us: |- + Defines the version for the type as either a semantic version (semver) or arbitrary string. + + If the type adheres to [semantic versioning][01], its manifest should define the version as + a valid semantic version like `1.2.3`. When a resource or extension specifies a semantic + version, DSC uses the latest available version of that resource or extension by default. + + Instead of specifying a semantic version, the type can specify any arbitrary string. In + that case, DSC uses simple string sorting to determine the default version to use. + + Users can override the default behavior and require a specific version of a resource with + the `something` field. + stringVariant: + title: + en-us: Arbitrary version string + description: + en-us: >- + Defines the version for the type as an arbitrary string. + 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. diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 4da9b4d13..f23df9359 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -121,6 +121,9 @@ pub enum DscError { #[error("semver: {0}")] SemVer(#[from] semver::Error), + #[error("{t}: '{0}'", t = t!("dscerror.typeVersionToSemverConversion"))] + TypeVersionToSemverConversion(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 b046d479b..2d54cad28 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 type_version; +pub use type_version::TypeVersion; diff --git a/lib/dsc-lib/src/types/type_version.rs b/lib/dsc-lib/src/types/type_version.rs new file mode 100644 index 000000000..c66b4adae --- /dev/null +++ b/lib/dsc-lib/src/types/type_version.rs @@ -0,0 +1,514 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{convert::Infallible, fmt::Display, str::FromStr}; + +use crate::{dscerror::DscError, schemas::dsc_repo::DscRepoSchema}; +use rust_i18n::t; +use schemars::{json_schema, JsonSchema}; +use serde::{Deserialize, Serialize}; + +/// Defines the version of a DSC resource or extension. +/// +/// DSC supports both semantic versioning and arbitrary versioning for types. When the version is +/// defined as a valid semantic version ([`TypeVersion::Semantic`]), DSC can correctly compare +/// versions to determine the latest version or match a semantic version requirement. Where +/// possible, resource and extension authors should consider following semantic versioning for the +/// best user experience. +/// +/// When the version is an arbitrary string, DSC compares the strings after lower-casing them. If +/// a type defines the current version as `Foo` and the next version as `Bar`, DSC's comparison +/// logic will treat `Foo` as newer than `Bar`. If you're defining a type that doesn't follow +/// semantic versioning, consider defining the version as an [ISO 8601 date], 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 [`TypeVersion`] compare to other +/// instances of `TypeVersion`, [`String`], and [`semver::Version`]. +/// +/// ```rust +/// use dsc_lib::types::TypeVersion; +/// use semver::Version; +/// +/// let semantic = TypeVersion::new("1.2.3"); +/// let arbitrary = TypeVersion::new("Foo"); +/// let date = TypeVersion::new("2026-01-15"); +/// +/// // You can compare instances of `TypeVersion::Semantic` to strings and semantic versions. +/// assert_eq!(semantic, semver::Version::parse("1.2.3").unwrap()); +/// assert_eq!(semantic, "1.2.3"); +/// +/// // When comparing to strings, you can compare `String` instances and literal strings. Casing +/// // is ignored for these comparisons. +/// assert_eq!(arbitrary, "Foo"); +/// assert_eq!(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 < semver::Version::parse("0.1.0").unwrap()); +/// +/// // Semantic version comparisons work as expected. +/// assert!(semantic < semver::Version::parse("1.2.4").unwrap()); +/// assert!(semantic >= semver::Version::parse("1.0.0").unwrap()); +/// +/// // String version comparisons are case-insensitive but rely on Rust's underlying string +/// // comparison logic. 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 `TypeVersion`: +/// +/// ```rust +/// use dsc_lib::types::TypeVersion; +/// +/// let semantic: TypeVersion = "1.2.3".parse().unwrap(); +/// let arbitrary = TypeVersion::from("foo"); +/// let date = TypeVersion::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 `TypeVersion` in a few ways: +/// expects_string(&semantic.to_string()); +/// expects_string(date.to_string().as_str()); +/// ``` +/// +/// [ISO 8601 date]: https://www.iso.org/iso-8601-date-and-time-format.html +#[derive(Debug, Clone, Eq, Ord, Serialize, Deserialize, JsonSchema, DscRepoSchema)] +#[dsc_repo_schema(base_name = "version", folder_path = "definitions")] +#[serde(untagged)] +#[schemars( + title = t!("schemas.definitions.version.title"), + description = t!("schemas.definitions.version.description"), + extend( + "markdownDescription" = t!("schemas.definitions.version.markdownDescription") + ) +)] +pub enum TypeVersion { + /// Defines the type's version as a semantic version, containing an inner [`semver::Version`]. + /// This is the preferred and recommended versioning scheme for DSC resources and extensions. + /// + /// For more information about defining semantic versions, see [semver.org]. + /// + /// [semver.org]: https://semver.org + #[schemars(schema_with = "TypeVersion::semver_schema")] + Semantic(semver::Version), + /// Defines the type's version as an arbitrary string. + /// + /// DSC uses this variant for the version of any DSC resource or extension that defines its + /// version as a string that can't be parsed as a semantic version. + /// + /// If you're defining a version for a resource or extension that doesn't use semantic + /// versioning, consider specifying the version as an [ISO-8601 date], like `2026-01-01`. When + /// you do, DSC can still correctly discover the latest version for that type by string + /// comparisons. + /// + /// [ISO 8601 date]: https://www.iso.org/iso-8601-date-and-time-format.html + #[schemars( + title = t!("schemas.definitions.version.stringVariant.title"), + description = t!("schemas.definitions.version.stringVariant.description"), + extend( + "markdownDescription" = t!("schemas.definitions.version.stringVariant.markdownDescription") + ) + )] + String(String), +} + +impl TypeVersion { + /// Defines the validating regular expression for semantic versions. + /// + /// This regular expression was retrieved from [semver.org] and is used for the `pattern` + /// keyword in the JSON Schema for the semantic version variant ([`TypeVersion::Semantic`]). + /// + /// 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 [`TypeVersion::Semantic`] instance. + /// Otherwise, it's deserialized as a [`TypeVersion::String`] instance. + /// + /// [semver.org]: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + const SEMVER_PATTERN: &str = r"^(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-]+)*))?$"; + + /// Creates a new instance of [`TypeVersion`]. + /// + /// If the input string is a valid semantic version, the function returns the [`Semantic`] + /// variant. Otherwise, the function returns the [`String`] variant for arbitrary version + /// strings. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::TypeVersion; + /// + /// fn print_version_message(version: TypeVersion) { + /// match TypeVersion::new("1.2.3") { + /// TypeVersion::Semantic(v) => println!("Semantic version: {v}"), + /// TypeVersion::String(s) => println!("Arbitrary string version: '{s}'"), + /// } + /// } + /// + /// // Print for semantic version + /// print_version_message(TypeVersion::new("1.2.3")); + /// + /// // Print for arbitrary version + /// print_version_message(TypeVersion::new("2026-01")); + /// ``` + /// + /// [`Semantic`]: TypeVersion::Semantic + /// [`String`]: TypeVersion::String + pub fn new(version_string: &str) -> Self { + Self::from_str(version_string).unwrap() + } + + /// Indicates whether the version is semantic or an arbitrary string. + /// + /// # Examples + /// + /// ```rust + /// use dsc_lib::types::TypeVersion; + /// + /// let semantic = TypeVersion::new("1.2.3"); + /// let arbitrary = TypeVersion::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, + } + } + + /// Returns the version as a reference to the underlying [`semver::Version`] 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::TypeVersion; + /// + /// let semantic = TypeVersion::new("1.2.3"); + /// let date = TypeVersion::new("2026-01-15"); + /// let arbitrary = TypeVersion::new("arbitrary_version"); + /// + /// assert_eq!( + /// semantic.as_semver(), + /// Some(&semver::Version::parse("1.2.3").unwrap()) + /// ); + /// assert_eq!( + /// date.as_semver(), + /// None + /// ); + /// assert_eq!( + /// arbitrary.as_semver(), + /// None + /// ); + /// ``` + /// + /// [`Semantic`]: TypeVersion::Semantic + pub fn as_semver(&self) -> Option<&semver::Version> { + match self { + Self::Semantic(v) => Some(v), + _ => None, + } + } + + /// Returns the JSON schema for semantic version strings. + pub fn semver_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": TypeVersion::SEMVER_PATTERN, + "patternErrorMessage": t!("schemas.definitions.semver.patternErrorMessage"), + }) + } + + /// Compares an instance of [`TypeVersion`] with [`semver::VersionReq`]. + /// + /// When the instance is [`TypeVersion::Semantic`], this method applies the canonical matching + /// logic from [`semver`] for the version. When the instance is [`TypeVersion::String`], 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 [`TypeVersion`]. + /// + /// ```rust + /// use dsc_lib::types::TypeVersion; + /// use semver::VersionReq; + /// + /// let semantic = TypeVersion::new("1.2.3"); + /// let date = TypeVersion::new("2026-01-15"); + /// + /// let ref le_v2_0: VersionReq = "<=2.0".parse().unwrap(); + /// assert!(semantic.matches_semver_req(le_v2_0)); + /// assert!(!date.matches_semver_req(le_v2_0)); + /// let ref tilde_v1: VersionReq = "~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: &semver::VersionReq) -> bool { + match self { + Self::Semantic(v) => requirement.matches(v), + Self::String(_) => false, + } + } +} + +// Default to semantic version `0.0.0` rather than an empty string. +impl Default for TypeVersion { + fn default() -> Self { + Self::Semantic(semver::Version { + major: 0, + minor: 0, + patch: 0, + pre: semver::Prerelease::EMPTY, + build: semver::BuildMetadata::EMPTY, + }) + } +} + +// Enable using `TypeVersion` in `format!` and similar macros. +impl Display for TypeVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Semantic(v) => write!(f, "{}", v), + Self::String(s) => write!(f, "{}", s), + } + } +} + +// Parse a `TypeVersion` from a string literal +impl FromStr for TypeVersion { + type Err = Infallible; + fn from_str(s: &str) -> Result { + match semver::Version::parse(s) { + Ok(v) => Ok(TypeVersion::Semantic(v)), + Err(_) => Ok(TypeVersion::String(s.to_string())), + } + } +} + +// Implemented various conversion traits to move between `TypeVersion`, `String`, and +// `semver::Version`. +impl From<&String> for TypeVersion { + fn from(value: &String) -> Self { + match semver::Version::parse(value) { + Ok(v) => TypeVersion::Semantic(v), + Err(_) => TypeVersion::String(value.clone()), + } + } +} + +impl From for TypeVersion { + fn from(value: String) -> Self { + match semver::Version::parse(&value) { + Ok(v) => TypeVersion::Semantic(v), + Err(_) => TypeVersion::String(value), + } + } +} + +impl From for String { + fn from(value: TypeVersion) -> 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 _into_ them. +impl From<&str> for TypeVersion { + fn from(value: &str) -> Self { + TypeVersion::from(value.to_string()) + } +} + +impl From for TypeVersion { + fn from(value: semver::Version) -> Self { + Self::Semantic(value) + } +} +impl From<&semver::Version> for TypeVersion { + fn from(value: &semver::Version) -> Self { + Self::Semantic(value.clone()) + } +} + +// Creating an instance of `semver::Version` from `TypeVersion` is the only fallible conversion, +// since `TypeVersion` can define non-semantic versions. +impl TryFrom for semver::Version { + type Error = DscError; + + fn try_from(value: TypeVersion) -> Result { + match value { + TypeVersion::Semantic(v) => Ok(v), + TypeVersion::String(s) => Err(DscError::TypeVersionToSemverConversion(s)), + } + } +} + +// Implement traits for comparing `TypeVersion` to strings and semantic versions bi-directionally. +impl PartialEq for TypeVersion { + fn eq(&self, other: &Self) -> bool { + match self { + Self::Semantic(version) => match other { + Self::Semantic(other_version) => version == other_version, + Self::String(_) => false, + }, + Self::String(string) => { + !other.is_semver() && *string.to_lowercase() == other.to_string().to_lowercase() + } + } + } +} + +impl PartialEq for TypeVersion { + fn eq(&self, other: &semver::Version) -> bool { + match self { + Self::Semantic(v) => v == other, + Self::String(_) => false, + } + } +} + +impl PartialEq for semver::Version { + fn eq(&self, other: &TypeVersion) -> bool { + match other { + TypeVersion::Semantic(v) => self == v, + TypeVersion::String(_) => false, + } + } +} + +impl PartialEq<&str> for TypeVersion { + fn eq(&self, other: &&str) -> bool { + self.to_string().to_lowercase() == *other.to_lowercase() + } +} + +impl PartialEq for &str { + fn eq(&self, other: &TypeVersion) -> bool { + self.to_lowercase() == other.to_string().to_lowercase() + } +} + +impl PartialEq for TypeVersion { + fn eq(&self, other: &String) -> bool { + self.to_string().to_lowercase() == other.to_lowercase() + } +} + +impl PartialEq for String { + fn eq(&self, other: &TypeVersion) -> bool { + self.to_lowercase() == other.to_string().to_lowercase() + } +} + +impl PartialEq for TypeVersion { + fn eq(&self, other: &str) -> bool { + self.eq(&other.to_string()) + } +} + +impl PartialEq for str { + fn eq(&self, other: &TypeVersion) -> bool { + self.to_string().eq(other) + } +} + +impl PartialOrd for TypeVersion { + fn partial_cmp(&self, other: &Self) -> Option { + match self { + Self::Semantic(version) => match other { + Self::Semantic(other_version) => version.partial_cmp(other_version), + Self::String(_) => Some(std::cmp::Ordering::Greater), + }, + Self::String(string) => match other { + Self::Semantic(_) => Some(std::cmp::Ordering::Less), + Self::String(other_string) => string + .to_lowercase() + .partial_cmp(&other_string.to_lowercase()), + }, + } + } +} + +impl PartialOrd for TypeVersion { + fn partial_cmp(&self, other: &semver::Version) -> Option { + match self { + Self::Semantic(v) => v.partial_cmp(other), + Self::String(_) => Some(std::cmp::Ordering::Less), + } + } +} + +impl PartialOrd for semver::Version { + fn partial_cmp(&self, other: &TypeVersion) -> Option { + match other { + TypeVersion::Semantic(v) => self.partial_cmp(v), + TypeVersion::String(_) => Some(std::cmp::Ordering::Greater), + } + } +} + +impl PartialOrd for TypeVersion { + fn partial_cmp(&self, other: &String) -> Option { + self.partial_cmp(&TypeVersion::new(other.as_str())) + } +} + +impl PartialOrd for String { + fn partial_cmp(&self, other: &TypeVersion) -> Option { + TypeVersion::new(self.as_str()).partial_cmp(other) + } +} + +impl PartialOrd<&str> for TypeVersion { + fn partial_cmp(&self, other: &&str) -> Option { + self.partial_cmp(&other.to_string()) + } +} + +impl PartialOrd for TypeVersion { + fn partial_cmp(&self, other: &str) -> Option { + self.partial_cmp(&other.to_string()) + } +} + +impl PartialOrd for &str { + fn partial_cmp(&self, other: &TypeVersion) -> Option { + self.to_string().partial_cmp(other) + } +} + +impl PartialOrd for str { + fn partial_cmp(&self, other: &TypeVersion) -> Option { + self.to_string().partial_cmp(other) + } +} diff --git a/lib/dsc-lib/tests/integration/types/mod.rs b/lib/dsc-lib/tests/integration/types/mod.rs index c45605926..162251807 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 type_version; diff --git a/lib/dsc-lib/tests/integration/types/type_version.rs b/lib/dsc-lib/tests/integration/types/type_version.rs new file mode 100644 index 000000000..76d7d9c11 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/type_version.rs @@ -0,0 +1,482 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::types::TypeVersion; + use test_case::test_case; + + #[test_case("1.2.3" => matches TypeVersion::Semantic(_); "for valid semantic version")] + #[test_case("1.2.3a" => matches TypeVersion::String(_); "for invalid semantic version")] + #[test_case("2026-01-15" => matches TypeVersion::String(_); "for full ISO8601 date")] + #[test_case("2026-01" => matches TypeVersion::String(_); "for partial ISO8601 date")] + #[test_case("arbitrary_string" => matches TypeVersion::String(_); "for arbitrary string")] + fn new(version_string: &str) -> TypeVersion { + TypeVersion::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 { + TypeVersion::new(version_string).is_semver() + } + + #[test_case(TypeVersion::new("1.2.3") => matches Some(_); "for valid semantic version")] + #[test_case(TypeVersion::new("1.2.3a") => matches None; "for invalid semantic version")] + #[test_case(TypeVersion::new("2026-01-15") => matches None; "for full ISO8601 date")] + #[test_case(TypeVersion::new("2026-01") => matches None; "for partial ISO8601 date")] + #[test_case(TypeVersion::new("arbitrary_string") => matches None; "for arbitrary string")] + fn as_semver(version: TypeVersion) -> 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 { + TypeVersion::new(version_string) + .matches_semver_req(&semver::VersionReq::parse(requirement_string).unwrap()) + } +} + +#[cfg(test)] +mod schema { + use std::{ops::Index, sync::LazyLock}; + + use dsc_lib::types::TypeVersion; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static ROOT_SCHEMA: LazyLock = LazyLock::new(|| schema_for!(TypeVersion)); + 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 ROOT_VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*ROOT_SCHEMA).as_value()).unwrap()); + static SEMVER_VARIANT_VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*SEMVER_VARIANT_SCHEMA).as_value()).unwrap()); + + #[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("patternErrorMessage", &*SEMVER_VARIANT_SCHEMA; "semver.patternErrorMessage")] + #[test_case("title", &*STRING_VARIANT_SCHEMA; "string.title")] + #[test_case("description", &*STRING_VARIANT_SCHEMA; "string.description")] + #[test_case("markdownDescription", &*STRING_VARIANT_SCHEMA; "string.markdownDescription")] + fn has_documentation_keyword(keyword: &str, schema: &Schema) { + assert!(schema + .get_keyword_as_string(keyword) + .is_some_and(|k| !k.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 { + (&*ROOT_VALIDATOR).validate(input_json).is_ok() + } + + #[test_case(&json!("1.2.3") => true ; "valid semantic version string value is valid")] + #[test_case(&json!("1.2.3a") => false ; "invalid semantic version string value is invalid")] + #[test_case(&json!("2026-01-15") => false ; "iso8601 date full string value is invalid")] + #[test_case(&json!("2026-01") => false ; "iso8601 date year month string value is invalid")] + #[test_case(&json!("arbitrary_string") => false ; "arbitrary 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!({"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 semver_validation(input_json: &Value) -> bool { + (&*SEMVER_VARIANT_VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::TypeVersion; + 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_type_version_to_string(version_string: &str) { + let actual = serde_json::to_string(&TypeVersion::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(&TypeVersion::new(version_string)) + .expect("serialization should never fail"); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case(json!("1.2.3") => matches Ok(_); "valid semantic version string value succeeds")] + #[test_case(json!("1.2.3a") => matches Ok(_) ; "invalid semantic version string value isucceeds")] + #[test_case(json!("2026-01-15") => matches Ok(_) ; "iso8601 date full string value isucceeds")] + #[test_case(json!("2026-01") => matches Ok(_) ; "iso8601 date year month string value isucceeds")] + #[test_case(json!("arbitrary_string") => matches Ok(_) ; "arbitrary string value isucceeds")] + #[test_case(json!(true) => matches Err(_); "boolean value fails")] + #[test_case(json!(1) => matches Err(_); "integer value fails")] + #[test_case(json!(1.2) => matches Err(_); "float value fails")] + #[test_case(json!({"version": "1.2.3"}) => matches Err(_); "object value fails")] + #[test_case(json!(["1.2.3"]) => matches Err(_); "array value fails")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value fails")] + fn deserializing_value(input_value: Value) -> Result { + serde_json::from_value::(input_value) + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::TypeVersion; + + #[test] + fn default() { + pretty_assertions::assert_eq!(TypeVersion::default(), TypeVersion::new("0.0.0")); + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::TypeVersion; + 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: {}", TypeVersion::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!( + TypeVersion::new(version_string).to_string(), + version_string.to_string() + ) + } + } + + #[cfg(test)] + mod from_str { + use dsc_lib::types::TypeVersion; + use test_case::test_case; + + #[test_case("1.2.3" => TypeVersion::new("1.2.3"); "valid semantic version")] + #[test_case("1.2.3a" => TypeVersion::new("1.2.3a"); "invalid semantic version")] + #[test_case("2026-01-15" => TypeVersion::new("2026-01-15"); "ISO8601 date full")] + #[test_case("2026-01" => TypeVersion::new("2026-01"); "ISO8601 date year and month only")] + #[test_case("arbitrary_string" => TypeVersion::new("arbitrary_string"); "arbitrary string")] + fn parse(input: &str) -> TypeVersion { + input.parse().expect("parse should be infallible") + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::TypeVersion; + use test_case::test_case; + + #[test] + fn semver_version() { + let semantic_version = semver::Version::parse("1.2.3").unwrap(); + match TypeVersion::from(semantic_version.clone()) { + TypeVersion::Semantic(v) => pretty_assertions::assert_eq!(v, semantic_version), + TypeVersion::String(_) => { + panic!("should never fail to convert as Semantic version") + } + } + } + + #[test_case("1.2.3" => matches TypeVersion::Semantic(_); "valid semantic version")] + #[test_case("1.2.3a" => matches TypeVersion::String(_); "invalid semantic version")] + #[test_case("2026-01-15" => matches TypeVersion::String(_); "ISO8601 date full")] + #[test_case("2026-01" => matches TypeVersion::String(_); "ISO8601 date year and month only")] + #[test_case("arbitrary_string" => matches TypeVersion::String(_); "arbitrary string")] + fn string(version_string: &str) -> TypeVersion { + TypeVersion::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 + // `type_version_for_semver_version`. When you implement `From`, you automaticlly implementat + // `Into` for the reversing of the type pair. + #[cfg(test)] + mod into { + use dsc_lib::types::TypeVersion; + 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 = TypeVersion::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::TypeVersion}; + 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 semver_version(version_string: &str) -> Result { + TryInto::::try_into(TypeVersion::new(version_string)) + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::TypeVersion; + 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", true; "differently cased string versions")] + #[test_case("foo", "bar", false; "unequal string versions")] + fn type_version(lhs: &str, rhs: &str, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!(TypeVersion::new(lhs), TypeVersion::new(rhs)) + } else { + pretty_assertions::assert_ne!(TypeVersion::new(lhs), TypeVersion::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 semver_version(type_version_string: &str, semver_string: &str, should_be_equal: bool) { + let version: TypeVersion = type_version_string.parse().unwrap(); + let semantic: semver::Version = semver_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", true; "arbitrary string version and string with differing case")] + #[test_case("foo", "bar", false; "arbitrary string version and different string")] + fn str(type_version_string: &str, string_slice: &str, should_be_equal: bool) { + let version: TypeVersion = type_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", true; "arbitrary string version and string with differing case")] + #[test_case("foo", "bar", false; "arbitrary string version and different string")] + fn string(type_version_string: &str, string_slice: &str, should_be_equal: bool) { + let version: TypeVersion = type_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::TypeVersion; + 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::Equal; "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 type_version(lhs: &str, rhs: &str, expected_order: Ordering) { + pretty_assertions::assert_eq!( + TypeVersion::new(lhs) + .partial_cmp(&TypeVersion::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 semver_version( + type_version_string: &str, + semver_string: &str, + expected_order: Ordering, + ) { + let version: TypeVersion = type_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", 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::Equal; "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(type_version_string: &str, string_slice: &str, expected_order: Ordering) { + let version: TypeVersion = type_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 537f04991d1ea27ad33b2d85ee382c079f1a8097 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Tue, 13 Jan 2026 10:44:31 -0600 Subject: [PATCH 2/4] (MAINT) Fix broken rustdocs link --- lib/dsc-lib/src/types/fully_qualified_type_name.rs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) 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..7dd0c259f 100644 --- a/lib/dsc-lib/src/types/fully_qualified_type_name.rs +++ b/lib/dsc-lib/src/types/fully_qualified_type_name.rs @@ -17,16 +17,7 @@ use crate::schemas::dsc_repo::DscRepoSchema; /// Defines the fully qualified type name for a DSC resource or extension. The fully qualified name /// uniquely identifies each resource and extension. #[derive( - Clone, - Debug, - Eq, - PartialOrd, - Ord, - Hash, - Serialize, - Deserialize, - JsonSchema, - DscRepoSchema, + Clone, Debug, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, DscRepoSchema, )] #[serde(try_from = "String")] #[schemars( @@ -86,6 +77,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`]: FullyQualifiedTypeName::VALIDATING_PATTERN pub fn new(name: &str) -> Result { Self::validate(name)?; Ok(Self(name.to_string())) From 73dcd38b14fc74ef8a1ac0f5480e7133a1766aac Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Tue, 13 Jan 2026 11:13:34 -0600 Subject: [PATCH 3/4] (MAINT) Hoist `types` to crate root Prior to this change, the shared type definitions for `TypeVersion` and `FullyQualifiedTypeName` were accessed throughout the code as `dsc_lib::types::`. This change converts the `types` module to private and re-exports the types from the crate root, making them accessible as `dsc_lib::` instead, which is more ergonomic and maps better to describing basic reusable types for DSC that aren't specific to a particular module. --- dsc/src/mcp/invoke_dsc_resource.rs | 2 +- dsc/src/mcp/list_dsc_resources.rs | 2 +- dsc/src/mcp/show_dsc_resource.rs | 2 +- lib/dsc-lib/src/configure/config_doc.rs | 2 +- lib/dsc-lib/src/configure/config_result.rs | 2 +- lib/dsc-lib/src/configure/depends_on.rs | 2 +- .../src/dscresources/command_resource.rs | 2 +- lib/dsc-lib/src/dscresources/dscresource.rs | 2 +- .../src/dscresources/resource_manifest.rs | 2 +- lib/dsc-lib/src/extensions/dscextension.rs | 2 +- .../src/extensions/extension_manifest.rs | 2 +- lib/dsc-lib/src/lib.rs | 3 ++- lib/dsc-lib/src/progress.rs | 2 +- lib/dsc-lib/src/types/type_version.rs | 10 ++++----- .../types/fully_qualified_type_name.rs | 2 +- .../tests/integration/types/type_version.rs | 22 +++++++++---------- 16 files changed, 31 insertions(+), 30 deletions(-) diff --git a/dsc/src/mcp/invoke_dsc_resource.rs b/dsc/src/mcp/invoke_dsc_resource.rs index 1285455a6..404ec0b6d 100644 --- a/dsc/src/mcp/invoke_dsc_resource.rs +++ b/dsc/src/mcp/invoke_dsc_resource.rs @@ -13,7 +13,7 @@ use dsc_lib::{ SetResult, TestResult, }, - }, types::FullyQualifiedTypeName + }, FullyQualifiedTypeName }; use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters}; use rust_i18n::t; diff --git a/dsc/src/mcp/list_dsc_resources.rs b/dsc/src/mcp/list_dsc_resources.rs index 77e93599d..f33269d5a 100644 --- a/dsc/src/mcp/list_dsc_resources.rs +++ b/dsc/src/mcp/list_dsc_resources.rs @@ -6,7 +6,7 @@ use dsc_lib::{ DscManager, discovery::{ command_discovery::ImportedManifest::Resource, discovery_trait::{DiscoveryFilter, DiscoveryKind}, - }, dscresources::resource_manifest::Kind, progress::ProgressFormat, types::FullyQualifiedTypeName + }, dscresources::resource_manifest::Kind, progress::ProgressFormat, FullyQualifiedTypeName }; use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters}; use rust_i18n::t; diff --git a/dsc/src/mcp/show_dsc_resource.rs b/dsc/src/mcp/show_dsc_resource.rs index ae9dc50d4..636358803 100644 --- a/dsc/src/mcp/show_dsc_resource.rs +++ b/dsc/src/mcp/show_dsc_resource.rs @@ -8,7 +8,7 @@ use dsc_lib::{ dscresources::{ dscresource::{Capability, Invoke}, resource_manifest::Kind - }, types::FullyQualifiedTypeName, + }, FullyQualifiedTypeName, }; use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters}; use rust_i18n::t; diff --git a/lib/dsc-lib/src/configure/config_doc.rs b/lib/dsc-lib/src/configure/config_doc.rs index df960a538..e50ad1b81 100644 --- a/lib/dsc-lib/src/configure/config_doc.rs +++ b/lib/dsc-lib/src/configure/config_doc.rs @@ -11,7 +11,7 @@ use std::{collections::HashMap, fmt::Display}; use crate::{schemas::{ dsc_repo::DscRepoSchema, transforms::{idiomaticize_externally_tagged_enum, idiomaticize_string_enum} -}, types::FullyQualifiedTypeName}; +}, FullyQualifiedTypeName}; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] #[serde(rename_all = "camelCase")] diff --git a/lib/dsc-lib/src/configure/config_result.rs b/lib/dsc-lib/src/configure/config_result.rs index 31fae6e8b..77fb6f6bc 100644 --- a/lib/dsc-lib/src/configure/config_result.rs +++ b/lib/dsc-lib/src/configure/config_result.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value}; use crate::dscresources::invoke_result::{GetResult, SetResult, TestResult}; use crate::configure::config_doc::{Configuration, Metadata}; use crate::schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}; -use crate::types::FullyQualifiedTypeName; +use crate::FullyQualifiedTypeName; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] diff --git a/lib/dsc-lib/src/configure/depends_on.rs b/lib/dsc-lib/src/configure/depends_on.rs index dae5fca9d..52bb1d53c 100644 --- a/lib/dsc-lib/src/configure/depends_on.rs +++ b/lib/dsc-lib/src/configure/depends_on.rs @@ -5,7 +5,7 @@ use crate::configure::config_doc::Resource; use crate::configure::{Configuration, IntOrExpression, ProcessMode, invoke_property_expressions}; use crate::DscError; use crate::parser::Statement; -use crate::types::FullyQualifiedTypeName; +use crate::FullyQualifiedTypeName; use rust_i18n::t; use serde_json::Value; diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index 082895ccf..bb6d1d037 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -7,7 +7,7 @@ use rust_i18n::t; use serde::Deserialize; use serde_json::{Map, Value}; use std::{collections::HashMap, env, path::Path, process::Stdio}; -use crate::{configure::{config_doc::ExecutionKind, config_result::{ResourceGetResult, ResourceTestResult}}, types::FullyQualifiedTypeName, util::canonicalize_which}; +use crate::{configure::{config_doc::ExecutionKind, config_result::{ResourceGetResult, ResourceTestResult}}, FullyQualifiedTypeName, util::canonicalize_which}; use crate::dscerror::DscError; use super::{dscresource::{get_diff, redact}, invoke_result::{ExportResult, GetResult, ResolveResult, SetResult, TestResult, ValidateResult, ResourceGetResponse, ResourceSetResponse, ResourceTestResponse, get_in_desired_state}, resource_manifest::{ArgKind, InputKind, Kind, ResourceManifest, ReturnKind, SchemaKind}}; use tracing::{error, warn, info, debug, trace}; diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index e5b1fcb37..6404818a5 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::{configure::{Configurator, config_doc::{Configuration, ExecutionKind, Resource}, context::ProcessMode, parameters::{SECURE_VALUE_REDACTED, is_secure_value}}, dscresources::resource_manifest::{AdapterInputKind, Kind}, types::FullyQualifiedTypeName}; +use crate::{configure::{Configurator, config_doc::{Configuration, ExecutionKind, Resource}, context::ProcessMode, parameters::{SECURE_VALUE_REDACTED, is_secure_value}}, dscresources::resource_manifest::{AdapterInputKind, Kind}, FullyQualifiedTypeName}; use crate::discovery::discovery_trait::DiscoveryFilter; use crate::dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse}; use crate::schemas::transforms::idiomaticize_string_enum; diff --git a/lib/dsc-lib/src/dscresources/resource_manifest.rs b/lib/dsc-lib/src/dscresources/resource_manifest.rs index d0dd3b48e..bc6e5728c 100644 --- a/lib/dsc-lib/src/dscresources/resource_manifest.rs +++ b/lib/dsc-lib/src/dscresources/resource_manifest.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use crate::{ dscerror::DscError, schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}, - types::FullyQualifiedTypeName, + FullyQualifiedTypeName, }; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] diff --git a/lib/dsc-lib/src/extensions/dscextension.rs b/lib/dsc-lib/src/extensions/dscextension.rs index ad57a9762..c0b3b56ce 100644 --- a/lib/dsc-lib/src/extensions/dscextension.rs +++ b/lib/dsc-lib/src/extensions/dscextension.rs @@ -3,7 +3,7 @@ use crate::extensions::import::ImportMethod; use crate::schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}; -use crate::types::FullyQualifiedTypeName; +use crate::FullyQualifiedTypeName; use serde::{Deserialize, Serialize}; use serde_json::Value; use schemars::JsonSchema; diff --git a/lib/dsc-lib/src/extensions/extension_manifest.rs b/lib/dsc-lib/src/extensions/extension_manifest.rs index c0bad512f..0ec950b2f 100644 --- a/lib/dsc-lib/src/extensions/extension_manifest.rs +++ b/lib/dsc-lib/src/extensions/extension_manifest.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use crate::dscerror::DscError; use crate::extensions::{discover::DiscoverMethod, import::ImportMethod, secret::SecretMethod}; use crate::schemas::dsc_repo::DscRepoSchema; -use crate::types::FullyQualifiedTypeName; +use crate::FullyQualifiedTypeName; #[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] #[serde(deny_unknown_fields)] diff --git a/lib/dsc-lib/src/lib.rs b/lib/dsc-lib/src/lib.rs index 093500963..128580fb3 100644 --- a/lib/dsc-lib/src/lib.rs +++ b/lib/dsc-lib/src/lib.rs @@ -18,7 +18,8 @@ pub mod extensions; pub mod functions; pub mod parser; pub mod progress; -pub mod types; +mod types; +pub use types::*; pub mod util; // Re-export the dependency crate to minimize dependency management. diff --git a/lib/dsc-lib/src/progress.rs b/lib/dsc-lib/src/progress.rs index 66857d01a..65c718218 100644 --- a/lib/dsc-lib/src/progress.rs +++ b/lib/dsc-lib/src/progress.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. use crate::DscError; -use crate::types::FullyQualifiedTypeName; +use crate::FullyQualifiedTypeName; use clap::ValueEnum; use indicatif::ProgressStyle; diff --git a/lib/dsc-lib/src/types/type_version.rs b/lib/dsc-lib/src/types/type_version.rs index c66b4adae..28fa54562 100644 --- a/lib/dsc-lib/src/types/type_version.rs +++ b/lib/dsc-lib/src/types/type_version.rs @@ -28,7 +28,7 @@ use serde::{Deserialize, Serialize}; /// instances of `TypeVersion`, [`String`], and [`semver::Version`]. /// /// ```rust -/// use dsc_lib::types::TypeVersion; +/// use dsc_lib::TypeVersion; /// use semver::Version; /// /// let semantic = TypeVersion::new("1.2.3"); @@ -68,7 +68,7 @@ use serde::{Deserialize, Serialize}; /// You can freely convert between strings and `TypeVersion`: /// /// ```rust -/// use dsc_lib::types::TypeVersion; +/// use dsc_lib::TypeVersion; /// /// let semantic: TypeVersion = "1.2.3".parse().unwrap(); /// let arbitrary = TypeVersion::from("foo"); @@ -152,7 +152,7 @@ impl TypeVersion { /// # Examples /// /// ```rust - /// use dsc_lib::types::TypeVersion; + /// use dsc_lib::TypeVersion; /// /// fn print_version_message(version: TypeVersion) { /// match TypeVersion::new("1.2.3") { @@ -179,7 +179,7 @@ impl TypeVersion { /// # Examples /// /// ```rust - /// use dsc_lib::types::TypeVersion; + /// use dsc_lib::TypeVersion; /// /// let semantic = TypeVersion::new("1.2.3"); /// let arbitrary = TypeVersion::new("2026-01"); @@ -258,7 +258,7 @@ impl TypeVersion { /// The following example shows how comparisons work for different instances of [`TypeVersion`]. /// /// ```rust - /// use dsc_lib::types::TypeVersion; + /// use dsc_lib::TypeVersion; /// use semver::VersionReq; /// /// let semantic = TypeVersion::new("1.2.3"); diff --git a/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs b/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs index 961494d12..d15a97f80 100644 --- a/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs +++ b/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs @@ -5,7 +5,7 @@ use jsonschema::Validator; use schemars::schema_for; use serde_json::{json, Value}; -use dsc_lib::{dscerror::DscError, types::FullyQualifiedTypeName}; +use dsc_lib::{dscerror::DscError, FullyQualifiedTypeName}; #[test] fn test_schema_without_segments() { diff --git a/lib/dsc-lib/tests/integration/types/type_version.rs b/lib/dsc-lib/tests/integration/types/type_version.rs index 76d7d9c11..f76460c30 100644 --- a/lib/dsc-lib/tests/integration/types/type_version.rs +++ b/lib/dsc-lib/tests/integration/types/type_version.rs @@ -3,7 +3,7 @@ #[cfg(test)] mod methods { - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; use test_case::test_case; #[test_case("1.2.3" => matches TypeVersion::Semantic(_); "for valid semantic version")] @@ -47,7 +47,7 @@ mod methods { mod schema { use std::{ops::Index, sync::LazyLock}; - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; use jsonschema::Validator; use schemars::{schema_for, Schema}; @@ -129,7 +129,7 @@ mod schema { #[cfg(test)] mod serde { - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; use serde_json::{json, Value}; use test_case::test_case; @@ -179,7 +179,7 @@ mod serde { mod traits { #[cfg(test)] mod default { - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; #[test] fn default() { @@ -189,7 +189,7 @@ mod traits { #[cfg(test)] mod display { - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; use test_case::test_case; #[test_case("1.2.3"; "valid semantic version")] @@ -219,7 +219,7 @@ mod traits { #[cfg(test)] mod from_str { - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; use test_case::test_case; #[test_case("1.2.3" => TypeVersion::new("1.2.3"); "valid semantic version")] @@ -234,7 +234,7 @@ mod traits { #[cfg(test)] mod from { - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; use test_case::test_case; #[test] @@ -264,7 +264,7 @@ mod traits { // `Into` for the reversing of the type pair. #[cfg(test)] mod into { - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; use test_case::test_case; #[test_case("1.2.3"; "semantic version")] @@ -279,7 +279,7 @@ mod traits { #[cfg(test)] mod try_into { - use dsc_lib::{dscerror::DscError, types::TypeVersion}; + use dsc_lib::{dscerror::DscError, TypeVersion}; use test_case::test_case; #[test_case("1.2.3" => matches Ok(_); "valid semantic version converts")] @@ -294,7 +294,7 @@ mod traits { #[cfg(test)] mod partial_eq { - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; use test_case::test_case; #[test_case("1.2.3", "1.2.3", true; "equal semantic versions")] @@ -380,7 +380,7 @@ mod traits { mod partial_ord { use std::cmp::Ordering; - use dsc_lib::types::TypeVersion; + use dsc_lib::TypeVersion; use test_case::test_case; #[test_case("1.2.3", "1.2.3", Ordering::Equal; "equal semantic versions")] From 5955b07b8a353375f67dc58098d5d266d70c9105 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Tue, 13 Jan 2026 13:31:53 -0600 Subject: [PATCH 4/4] (GH-538) Update resource fields to use `TypeVersion` Prior to this change, the `version` fields for `ResourceManifest` and `DscResource` were defined as the `String` type. This change updates those fields to use `TypeVersion` to more correctly support distinguishing between semantic versions and arbitrary string versions. This change also adds the `semantic_version()` method to `DscResource` to return an `Option` containing a reference to the semantic version if possible. This enables retrieving the semantic version more easily instead of re-parsing the version string as a semantic version. This change updates references to the `version` field across the code base as needed. --- dsc/src/mcp/show_dsc_resource.rs | 6 ++++-- dsc/src/subcommand.rs | 2 +- lib/dsc-lib/src/discovery/command_discovery.rs | 16 ++++++++-------- lib/dsc-lib/src/discovery/mod.rs | 2 +- lib/dsc-lib/src/dscresources/dscresource.rs | 17 ++++++++++++++--- .../src/dscresources/resource_manifest.rs | 10 ++++------ tools/test_group_resource/src/main.rs | 10 +++++----- 7 files changed, 37 insertions(+), 26 deletions(-) diff --git a/dsc/src/mcp/show_dsc_resource.rs b/dsc/src/mcp/show_dsc_resource.rs index 636358803..2b5266233 100644 --- a/dsc/src/mcp/show_dsc_resource.rs +++ b/dsc/src/mcp/show_dsc_resource.rs @@ -4,11 +4,13 @@ use crate::mcp::mcp_server::McpServer; use dsc_lib::{ DscManager, + FullyQualifiedTypeName, + TypeVersion, discovery::discovery_trait::DiscoveryFilter, dscresources::{ dscresource::{Capability, Invoke}, resource_manifest::Kind - }, FullyQualifiedTypeName, + } }; use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters}; use rust_i18n::t; @@ -25,7 +27,7 @@ pub struct DscResource { /// The kind of resource. pub kind: Kind, /// The version of the resource. - pub version: String, + pub version: TypeVersion, /// The capabilities of the resource. pub capabilities: Vec, /// The description of the resource. diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 1ba90ac1c..175b295c9 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -840,7 +840,7 @@ pub fn list_resources(dsc: &mut DscManager, resource_name: Option<&String>, adap table.add_row(vec![ resource.type_name.to_string(), format!("{:?}", resource.kind), - resource.version, + resource.version.to_string(), capabilities, resource.require_adapter.unwrap_or_default().to_string(), resource.description.unwrap_or_default() diff --git a/lib/dsc-lib/src/discovery/command_discovery.rs b/lib/dsc-lib/src/discovery/command_discovery.rs index a10768a85..fadf3f46f 100644 --- a/lib/dsc-lib/src/discovery/command_discovery.rs +++ b/lib/dsc-lib/src/discovery/command_discovery.rs @@ -558,7 +558,7 @@ impl ResourceDiscovery for CommandDiscovery { fn filter_resources(found_resources: &mut BTreeMap>, required_resources: &mut HashMap, resources: &[DscResource], filter: &DiscoveryFilter) { for resource in resources { if let Some(required_version) = filter.version() { - if let Ok(resource_version) = Version::parse(&resource.version) { + if let Some(resource_version) = resource.semantic_version() { if let Ok(version_req) = VersionReq::parse(required_version) { if version_req.matches(&resource_version) && matches_adapter_requirement(resource, filter) { found_resources.entry(filter.resource_type().to_string()).or_default().push(resource.clone()); @@ -595,15 +595,15 @@ fn insert_resource(resources: &mut BTreeMap>, resource: // compare the resource versions and insert newest to oldest using semver let mut insert_index = resource_versions.len(); for (index, resource_instance) in resource_versions.iter().enumerate() { - let resource_instance_version = match Version::parse(&resource_instance.version) { - Ok(v) => v, - Err(_err) => { + let resource_instance_version = match resource_instance.semantic_version() { + Some(v) => v, + None => { continue; }, }; - let resource_version = match Version::parse(&resource.version) { - Ok(v) => v, - Err(_err) => { + let resource_version = match resource.semantic_version() { + Some(v) => v, + None => { continue; }, }; @@ -737,7 +737,7 @@ pub fn load_manifest(path: &Path) -> Result, DscError> { } fn load_resource_manifest(path: &Path, manifest: &ResourceManifest) -> Result { - if let Err(err) = validate_semver(&manifest.version) { + if let Err(err) = validate_semver(&manifest.version.to_string()) { warn!("{}", t!("discovery.commandDiscovery.invalidManifestVersion", path = path.to_string_lossy(), err = err).to_string()); } diff --git a/lib/dsc-lib/src/discovery/mod.rs b/lib/dsc-lib/src/discovery/mod.rs index 7e5069cd1..533ca451f 100644 --- a/lib/dsc-lib/src/discovery/mod.rs +++ b/lib/dsc-lib/src/discovery/mod.rs @@ -99,7 +99,7 @@ impl Discovery { let version = fix_semver(version); if let Ok(version_req) = VersionReq::parse(&version) { for resource in resources { - if let Ok(resource_version) = Version::parse(&resource.version) { + if let Some(resource_version) = resource.semantic_version() { if version_req.matches(&resource_version) && matches_adapter_requirement(resource, filter) { return Ok(Some(resource)); } diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 6404818a5..7772e76ee 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::{configure::{Configurator, config_doc::{Configuration, ExecutionKind, Resource}, context::ProcessMode, parameters::{SECURE_VALUE_REDACTED, is_secure_value}}, dscresources::resource_manifest::{AdapterInputKind, Kind}, FullyQualifiedTypeName}; +use crate::{configure::{Configurator, config_doc::{Configuration, ExecutionKind, Resource}, context::ProcessMode, parameters::{SECURE_VALUE_REDACTED, is_secure_value}}, dscresources::resource_manifest::{AdapterInputKind, Kind}, FullyQualifiedTypeName, TypeVersion}; use crate::discovery::discovery_trait::DiscoveryFilter; use crate::dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse}; use crate::schemas::transforms::idiomaticize_string_enum; @@ -38,7 +38,7 @@ pub struct DscResource { /// The kind of resource. pub kind: Kind, /// The version of the resource. - pub version: String, + pub version: TypeVersion, /// The capabilities of the resource. pub capabilities: Vec, /// The file path to the resource. @@ -101,7 +101,7 @@ impl DscResource { Self { type_name: FullyQualifiedTypeName::default(), kind: Kind::Resource, - version: String::new(), + version: TypeVersion::default(), capabilities: Vec::new(), description: None, path: PathBuf::new(), @@ -283,6 +283,17 @@ impl DscResource { } Err(DscError::Operation(t!("dscresources.dscresource.adapterResourceNotFound", adapter = adapter).to_string())) } + + /// Tries to retrieve the resource version as a semantic version. + /// + /// This method creates an instance of [`semver::Version`] from the [`version`] field, if + /// possible. If the underlying version is [`TypeVersion::Semantic`], it returns some + /// [`semver::Version`]. Otherwise, it returns [`None`]. + /// + /// [`version`]: DscResource::version + pub fn semantic_version(&self) -> Option<&semver::Version> { + self.version.as_semver() + } } impl Default for DscResource { diff --git a/lib/dsc-lib/src/dscresources/resource_manifest.rs b/lib/dsc-lib/src/dscresources/resource_manifest.rs index bc6e5728c..bfececb50 100644 --- a/lib/dsc-lib/src/dscresources/resource_manifest.rs +++ b/lib/dsc-lib/src/dscresources/resource_manifest.rs @@ -9,9 +9,7 @@ use serde_json::{Map, Value}; use std::collections::HashMap; use crate::{ - dscerror::DscError, - schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}, - FullyQualifiedTypeName, + FullyQualifiedTypeName, TypeVersion, dscerror::DscError, schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum} }; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] @@ -53,7 +51,7 @@ pub struct ResourceManifest { #[serde(skip_serializing_if = "Option::is_none")] pub kind: Option, /// The version of the resource using semantic versioning. - pub version: String, + pub version: TypeVersion, /// The description of the resource. pub description: Option, /// Tags for the resource. @@ -334,7 +332,7 @@ mod test { let manifest = ResourceManifest{ schema_version: invalid_uri.clone(), resource_type: "Microsoft.Dsc.Test/InvalidSchemaUri".parse().unwrap(), - version: "0.1.0".to_string(), + version: "0.1.0".into(), ..Default::default() }; @@ -355,7 +353,7 @@ mod test { let manifest = ResourceManifest{ schema_version: ResourceManifest::default_schema_id_uri(), resource_type: "Microsoft.Dsc.Test/ValidSchemaUri".parse().unwrap(), - version: "0.1.0".to_string(), + version: "0.1.0".into(), ..Default::default() }; diff --git a/tools/test_group_resource/src/main.rs b/tools/test_group_resource/src/main.rs index f685eab94..41eb3e899 100644 --- a/tools/test_group_resource/src/main.rs +++ b/tools/test_group_resource/src/main.rs @@ -17,7 +17,7 @@ fn main() { let resource1 = DscResource { type_name: "Test/TestResource1".parse().unwrap(), kind: Kind::Resource, - version: "1.0.0".to_string(), + version: "1.0.0".into(), capabilities: vec![Capability::Get, Capability::Set], description: Some("This is a test resource.".to_string()), implemented_as: ImplementedAs::Custom("TestResource".to_string()), @@ -32,7 +32,7 @@ fn main() { schema_version: dsc_lib::dscresources::resource_manifest::ResourceManifest::default_schema_id_uri(), resource_type: "Test/TestResource1".parse().unwrap(), kind: Some(Kind::Resource), - version: "1.0.0".to_string(), + version: "1.0.0".into(), get: Some(GetMethod { executable: String::new(), ..Default::default() @@ -43,7 +43,7 @@ fn main() { let resource2 = DscResource { type_name: "Test/TestResource2".parse().unwrap(), kind: Kind::Resource, - version: "1.0.1".to_string(), + version: "1.0.1".into(), capabilities: vec![Capability::Get, Capability::Set], description: Some("This is a test resource.".to_string()), implemented_as: ImplementedAs::Custom("TestResource".to_string()), @@ -58,7 +58,7 @@ fn main() { schema_version: dsc_lib::dscresources::resource_manifest::ResourceManifest::default_schema_id_uri(), resource_type: "Test/TestResource2".parse().unwrap(), kind: Some(Kind::Resource), - version: "1.0.1".to_string(), + version: "1.0.1".into(), get: Some(GetMethod { executable: String::new(), ..Default::default() @@ -73,7 +73,7 @@ fn main() { let resource1 = DscResource { type_name: "Test/InvalidResource".parse().unwrap(), kind: Kind::Resource, - version: "1.0.0".to_string(), + version: "1.0.0".into(), capabilities: vec![Capability::Get], description: Some("This is a test resource.".to_string()), implemented_as: ImplementedAs::Custom("TestResource".to_string()),