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/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..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 - }, types::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/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/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/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/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/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..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}, 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, 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 d0dd3b48e..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}, - types::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/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/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())) 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..28fa54562 --- /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::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::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::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::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::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/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/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..f76460c30 --- /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::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::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::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::TypeVersion; + + #[test] + fn default() { + pretty_assertions::assert_eq!(TypeVersion::default(), TypeVersion::new("0.0.0")); + } + } + + #[cfg(test)] + mod display { + use dsc_lib::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::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::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::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, 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::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::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:#?}" + ); + } + } +} 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()),