diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 98ef41f8d..e4e85a1d5 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -729,6 +729,8 @@ forExecutable = "for executable" function = "Function" integerConversion = "Function integer argument conversion" invalidConfiguration = "Invalid configuration" +invalidExitCode = "Invalid key in 'exitCodes' map" +invalidExitCodePlusPrefix = "Exit codes must not begin with a plus sign (+)" invalidTypeNamePrefix = "Invalid type name" invalidTypeNameSuffix = "valid resource type names must match the following pattern" unsupportedManifestVersion = "Unsupported manifest version" @@ -777,6 +779,9 @@ notFoundSetting = "Setting '%{name}' not found in %{path}" failedToGetExePath = "Can't get 'dsc' executable path" settingNotFound = "Setting '%{name}' not found" failedToAbsolutizePath = "Failed to absolutize path '%{path}'" -invalidExitCodeKey = "Invalid exit code key '%{key}'" executableNotFoundInWorkingDirectory = "Executable '%{executable}' not found with working directory '%{cwd}'" executableNotFound = "Executable '%{executable}' not found" + +[types.exit_codes_map] +successText = "Success" +failureText = "Error" diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index ce9b16320..7b8348dd8 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -1,6 +1,36 @@ _version: 2 schemas: definitions: + exitCodes: + title: + en-us: Exit codes + description: + en-us: >- + Defines a map of valid exit codes for operation commands. + markdownDescription: + en-us: |- + Defines a map of valid exit codes for operation commands. DSC always interprets exit code + `0` as a successful operation and any other exit code as an error. Use this property to + indicate human-readable semantic meanings for the DSC resource's exit codes. + + When this field isn't defined, DSC can only report "Success" (for exit code `0`) and + "Error" (for all other exit codes). + + Define the keys in this property as strings representing a valid 32-bit signed integer. + You can't use alternate formats for the exit code. For example, instead of the + hexadecimal value `0x80070005` for "Access denied", specify the exit code as + `-2147024891`. + + If you're authoring your resource manifest in YAML, be sure to wrap the exit code in + single quotes, like `'0': Success` instead of `0: Success` to ensure the YAML file can be + parsed correctly. + + Define the value for each key as a string explaining what the exit code indicates. + invalidKeyErrorMessage: + en-us: >- + Invalid exit code. Each exit code must be defined as a string representing a 32-bit + signed integer, like `5` or `-2147024891`. + resourceType: title: Fully qualified type name description: >- diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 942816b89..43173bae7 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -52,6 +52,12 @@ pub enum DscError { #[error("{t} '{0}', {t2} {1}, {t3} {2}", t = t!("dscerror.invalidFunctionParameterCount"), t2 = t!("dscerror.expected"), t3 = t!("dscerror.got"))] InvalidFunctionParameterCount(String, usize, usize), + #[error("{t} '{0}': {1}", t = t!("dscerror.invalidExitCode"))] + InvalidExitCode(String, core::num::ParseIntError), + + #[error("{t} '{0}': {t2}", t = t!("dscerror.invalidExitCode"), t2 = t!("dscerror.invalidExitCodePlusPrefix"))] + InvalidExitCodePlusPrefix(String), + #[error("{0}")] InvalidManifest(String), diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index 860de07bb..24b6be91a 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, PathBuf}, 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}}, types::{ExitCodesMap, FullyQualifiedTypeName}, util::canonicalize_which}; use crate::dscerror::DscError; use super::{ dscresource::{get_diff, redact, DscResource}, @@ -747,13 +747,14 @@ pub fn invoke_resolve(resource: &DscResource, input: &str) -> Result>, input: Option<&str>, cwd: Option<&Path>, env: Option>, exit_codes: Option<&HashMap>) -> Result<(i32, String, String), DscError> { +async fn run_process_async(executable: &str, args: Option>, input: Option<&str>, cwd: Option<&Path>, env: Option>, exit_codes: &ExitCodesMap) -> Result<(i32, String, String), DscError> { // use somewhat large initial buffer to avoid early string reallocations; // the value is based on list result of largest of built-in adapters - WMI adapter ~500KB @@ -843,9 +844,15 @@ async fn run_process_async(executable: &str, args: Option>, input: O debug!("{}", t!("dscresources.commandResource.processChildExit", executable = executable, id = child_id, code = code)); if code != 0 { - if let Some(exit_codes) = exit_codes { - if let Some(error_message) = exit_codes.get(&code) { - return Err(DscError::CommandExitFromManifest(executable.to_string(), code, error_message.to_string())); + // Only use manifest-provided exit code mappings when the map is not empty/default, + // so that default mappings do not suppress stderr-based diagnostics. + if !exit_codes.is_empty_or_default() { + if let Some(error_message) = exit_codes.get_code(code) { + return Err(DscError::CommandExitFromManifest( + executable.to_string(), + code, + error_message.clone() + )); } } return Err(DscError::Command(executable.to_string(), code, stderr_result)); @@ -858,22 +865,6 @@ async fn run_process_async(executable: &str, args: Option>, input: O } } -fn convert_hashmap_string_keys_to_i32(input: Option<&HashMap>) -> Result>, DscError> { - if input.is_none() { - return Ok(None); - } - - let mut output: HashMap = HashMap::new(); - for (key, value) in input.unwrap() { - if let Ok(key_int) = key.parse::() { - output.insert(key_int, value.clone()); - } else { - return Err(DscError::NotSupported(t!("util.invalidExitCodeKey", key = key).to_string())); - } - } - Ok(Some(output)) -} - /// Invoke a command and return the exit code, stdout, and stderr. /// /// # Arguments @@ -883,7 +874,8 @@ fn convert_hashmap_string_keys_to_i32(input: Option<&HashMap>) - /// * `input` - Optional input to pass to the command /// * `cwd` - Optional working directory to execute the command in /// * `env` - Optional environment variable mappings to add or update -/// * `exit_codes` - Optional descriptions of exit codes +/// * `exit_codes` - Descriptions of exit codes, either defined by the manifest or using the +/// default descriptions for success and failure. /// /// # Errors /// @@ -894,8 +886,7 @@ fn convert_hashmap_string_keys_to_i32(input: Option<&HashMap>) - /// Will panic if tokio runtime can't be created. /// #[allow(clippy::implicit_hasher)] -pub fn invoke_command(executable: &str, args: Option>, input: Option<&str>, cwd: Option<&Path>, env: Option>, exit_codes: Option<&HashMap>) -> Result<(i32, String, String), DscError> { - let exit_codes = convert_hashmap_string_keys_to_i32(exit_codes)?; +pub fn invoke_command(executable: &str, args: Option>, input: Option<&str>, cwd: Option<&Path>, env: Option>, exit_codes: &ExitCodesMap) -> Result<(i32, String, String), DscError> { let executable = canonicalize_which(executable, cwd)?; let run_async = async { @@ -904,7 +895,7 @@ pub fn invoke_command(executable: &str, args: Option>, input: Option trace!("{}", t!("dscresources.commandResource.commandCwd", cwd = cwd.display())); } - match run_process_async(&executable, args, input, cwd, env, exit_codes.as_ref()).await { + match run_process_async(&executable, args, input, cwd, env, exit_codes).await { Ok((code, stdout, stderr)) => { Ok((code, stdout, stderr)) }, diff --git a/lib/dsc-lib/src/dscresources/resource_manifest.rs b/lib/dsc-lib/src/dscresources/resource_manifest.rs index aeb35b814..d6df701cc 100644 --- a/lib/dsc-lib/src/dscresources/resource_manifest.rs +++ b/lib/dsc-lib/src/dscresources/resource_manifest.rs @@ -6,11 +6,10 @@ use schemars::JsonSchema; use semver::Version; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use std::collections::HashMap; use crate::{ schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}, - types::FullyQualifiedTypeName, + types::{ExitCodesMap, FullyQualifiedTypeName}, }; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] @@ -84,8 +83,8 @@ pub struct ResourceManifest { #[serde(skip_serializing_if = "Option::is_none")] pub adapter: Option, /// Mapping of exit codes to descriptions. Zero is always success and non-zero is always failure. - #[serde(rename = "exitCodes", skip_serializing_if = "Option::is_none")] - pub exit_codes: Option>, // we have to make this a string key instead of i32 due to https://github.com/serde-rs/json/issues/560 + #[serde(rename = "exitCodes", skip_serializing_if = "ExitCodesMap::is_empty_or_default", default)] + pub exit_codes: ExitCodesMap, /// Details how to get the schema of the resource. #[serde(skip_serializing_if = "Option::is_none")] pub schema: Option, diff --git a/lib/dsc-lib/src/extensions/extension_manifest.rs b/lib/dsc-lib/src/extensions/extension_manifest.rs index c0bad512f..fa767d3f6 100644 --- a/lib/dsc-lib/src/extensions/extension_manifest.rs +++ b/lib/dsc-lib/src/extensions/extension_manifest.rs @@ -6,12 +6,11 @@ use schemars::JsonSchema; use semver::Version; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -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::types::{ExitCodesMap, FullyQualifiedTypeName}; #[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] #[serde(deny_unknown_fields)] @@ -52,8 +51,8 @@ pub struct ExtensionManifest { /// Details how to call the Secret method of the extension. pub secret: Option, /// Mapping of exit codes to descriptions. Zero is always success and non-zero is always failure. - #[serde(rename = "exitCodes", skip_serializing_if = "Option::is_none")] - pub exit_codes: Option>, + #[serde(rename = "exitCodes", skip_serializing_if = "ExitCodesMap::is_empty_or_default", default)] + pub exit_codes: ExitCodesMap, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option>, } diff --git a/lib/dsc-lib/src/types/exit_code.rs b/lib/dsc-lib/src/types/exit_code.rs new file mode 100644 index 000000000..6f7b82951 --- /dev/null +++ b/lib/dsc-lib/src/types/exit_code.rs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{borrow::Borrow, fmt::Display, ops::Deref, str::FromStr}; + +use serde::{Deserialize, Serialize}; + +use crate::dscerror::DscError; + +/// Defines a program exit code as a 32-bit integer ([`i32`]). +/// +/// DSC uses exit codes to determine whether invoked commands, including resource and extension +/// operations, are successful. DSC treats exit code `0` as successful and all other exit codes +/// as indicating a failure. +#[derive(Debug, Copy, Clone, Hash, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ExitCode(i32); + +impl ExitCode { + /// Creates an instance of [`ExitCode`] from an [`i32`]. + pub fn new(code: i32) -> Self { + Self(code) + } + + /// Parses a string into an [`ExitCode`]. + /// + /// If the string can be parsed as an [`i32`] and doesn't have a leading plus sign (`+`), the + /// function returns an [`ExitCode`]. + /// + /// DSC forbids a leading plus sign for parsing integers into exit codes to support fully + /// round-tripping the data from string representation to wrapped-integer and back. While Rust + /// supports parsing a string like `+123` as the [`i32`] value `123`, it serializes that value + /// to a string as `123`, not `+123`. + /// + /// # Errors + /// + /// The function raises an error when: + /// + /// - The input text has a leading plus sign ([`DscError::InvalidExitCodePlusPrefix`]) + /// - The input text can't be parsed as an [`i32`] ([`DscError::InvalidExitCode`]) + pub fn parse(text: &str) -> Result { + match i32::from_str(text) { + Ok(code) => { + // If text parsed as an exit code but has a leading plus sign, reject it. This + // only affects parsing directly, since DSC only deserializes after validating + // the data against the JSON Schema, which forbids a leading plus-sign for the + // exit codes map. + if text.starts_with("+") { + Err(DscError::InvalidExitCodePlusPrefix(text.to_string())) + } else { + Ok(Self(code)) + } + }, + Err(err) => Err(DscError::InvalidExitCode(text.to_string(), err)), + } + } +} + +impl AsRef for ExitCode { + fn as_ref(&self) -> &i32 { + &self.0 + } +} + +impl Deref for ExitCode { + type Target = i32; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Borrow for ExitCode { + fn borrow(&self) -> &i32 { + &self.0 + } +} + +impl Display for ExitCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl FromStr for ExitCode { + type Err = DscError; + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl TryFrom for ExitCode { + type Error = DscError; + fn try_from(value: String) -> Result { + Self::parse(value.as_str()) + } +} + +impl TryFrom<&str> for ExitCode { + type Error = DscError; + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +impl From for String { + fn from(value: ExitCode) -> Self { + value.to_string() + } +} + +impl From for ExitCode { + fn from(value: i32) -> Self { + Self(value) + } +} + +impl From for i32 { + fn from(value: ExitCode) -> Self { + value.0 + } +} + +impl PartialEq for ExitCode { + fn eq(&self, other: &Self) -> bool { + self.0.eq(&other.0) + } +} + +impl PartialEq for ExitCode { + fn eq(&self, other: &i32) -> bool { + self.0.eq(other) + } +} + +impl PartialEq for i32 { + fn eq(&self, other: &ExitCode) -> bool { + self.eq(&other.0) + } +} diff --git a/lib/dsc-lib/src/types/exit_codes_map.rs b/lib/dsc-lib/src/types/exit_codes_map.rs new file mode 100644 index 000000000..613dee40d --- /dev/null +++ b/lib/dsc-lib/src/types/exit_codes_map.rs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{collections::HashMap, ops::{Deref, DerefMut}, sync::LazyLock}; + +use rust_i18n::t; +use schemars::{JsonSchema, json_schema}; +use serde::{Deserialize, Serialize}; + +use crate::{schemas::dsc_repo::DscRepoSchema, types::ExitCode}; + +/// Defines a map of exit codes to their semantic meaning for operation commands. +/// +/// DSC resources and extensions may define any number of operation commands like `get` or +/// `secret`. DSC always considers commands that exit with code `0` to be successful operations and +/// commands that exit with any nonzero code to have failed. +/// +/// Resource and extension authors can provide more useful information to users by defining an +/// [`ExitCodesMap`]. When a resource or extension defines the `exitCodes` field in its manifest, +/// DSC surfaces the associated string as part of the error message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DscRepoSchema)] +#[dsc_repo_schema(base_name = "exitCodes", folder_path = "definitions")] +pub struct ExitCodesMap(HashMap); + +/// Defines the default map as a private static to use with the `get_code_or_default` method, +/// minimizing the performance hit compared to reconstructing the default map on every method +/// invocation. +static DEFAULT_MAP: LazyLock = LazyLock::new(|| ExitCodesMap::default()); + +impl ExitCodesMap { + /// Defines the regular expression for validating a string as an exit code. + /// + /// The string must consist only of ASCII digits (`[0-9]`) with an optional leading hyphen + /// (`-`). If the string can't be parsed as an [`i32`], the value is invalid. + /// + /// This value is only used in the JSON Schema for validating the property names for the map + /// of exit codes to their descriptions. For JSON and YAML, DSC expects the keys to always be + /// strings but they _must_ map to 32-bit integers. + pub const KEY_VALIDATING_PATTERN: &str = r"^-?[0-9]+$"; + + /// Creates a new instance of [`ExitCodesMap`] with the default capacity. + pub fn new() -> Self { + Self(HashMap::new()) + } + + /// Creates a new instance of [`ExitCodesMap`] with the given capacity. + pub fn with_capacity(capacity: usize) -> Self { + Self(HashMap::with_capacity(capacity)) + } + + /// Looks up an [`ExitCode`] in the map and returns a reference to its description, if the map + /// contains the given exit code. + pub fn get_code(&self, code: i32) -> Option<&String> { + self.0.get(&ExitCode::new(code)) + } + + /// Looks up an [`ExitCode`] in the map and returns its description, if the map contains the + /// given exit code, or the default description. + /// + /// The default description is retrieved from the `default()` map: + /// + /// - Exit code `0` returns the description for `0` in the default map. + /// - All other exit codes return the description for `1` in the default map. + pub fn get_code_or_default(&self, code: i32) -> String { + match self.0.get(&ExitCode::new(code)) { + Some(description) => description.clone(), + None => match code { + 0 => (&*DEFAULT_MAP).get_code(0).expect("default always defines exit code 0").clone(), + _ => (&*DEFAULT_MAP).get_code(1).expect("default always defines exit code 1").clone(), + } + } + } + + /// Indicates whether the [`ExitCodesMap`] is identical to the default map. + pub fn is_default(&self) -> bool { + self == &*DEFAULT_MAP + } + + /// Indicates whether the [`ExitCodesMap`] is empty or identical to the default map. + /// + /// Use this method with the `skip_serializing_if` attribute for serde to avoid serializing + /// empty and default maps. + pub fn is_empty_or_default(&self) -> bool { + self.is_empty() || self.is_default() + } +} + +impl Default for ExitCodesMap { + fn default() -> Self { + let mut map: HashMap = HashMap::with_capacity(2); + map.insert(ExitCode::new(0), t!("types.exit_codes_map.successText").into()); + map.insert(ExitCode::new(1), t!("types.exit_codes_map.failureText").into()); + + Self(map) + } +} + +impl JsonSchema for ExitCodesMap { + fn schema_name() -> std::borrow::Cow<'static, str> { + Self::default_schema_id_uri().into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": t!("schemas.definitions.exitCodes.title"), + "description": t!("schemas.definitions.exitCodes.description"), + "markdownDescription": t!("schemas.definitions.exitCodes.markdownDescription"), + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": Self::KEY_VALIDATING_PATTERN, + "patternErrorMessage": t!("schemas.definitions.exitCodes.invalidKeyErrorMessage") + }, + "patternProperties": { + Self::KEY_VALIDATING_PATTERN: { + "type": "string" + } + }, + "unevaluatedProperties": false, + "default": Self::default(), + "examples": [{ + "0": "Success", + "1": "Invalid parameter", + "2": "Invalid input", + "3": "Registry error", + "4": "JSON serialization failed" + }] + }) + } +} + +impl AsRef for ExitCodesMap { + fn as_ref(&self) -> &ExitCodesMap { + &self + } +} + +impl Deref for ExitCodesMap { + type Target = HashMap; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ExitCodesMap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/lib/dsc-lib/src/types/mod.rs b/lib/dsc-lib/src/types/mod.rs index e7899a903..63e118a3a 100644 --- a/lib/dsc-lib/src/types/mod.rs +++ b/lib/dsc-lib/src/types/mod.rs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod exit_code; +pub use exit_code::ExitCode; +mod exit_codes_map; +pub use exit_codes_map::ExitCodesMap; mod fully_qualified_type_name; pub use fully_qualified_type_name::FullyQualifiedTypeName; mod resource_version; diff --git a/lib/dsc-lib/tests/integration/types/exit_code.rs b/lib/dsc-lib/tests/integration/types/exit_code.rs new file mode 100644 index 000000000..7bad26d86 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/exit_code.rs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::{dscerror::DscError, types::ExitCode}; + use test_case::test_case; + + #[test] + fn new() { + let _ = ExitCode::new(0); + } + + #[test_case("0" => matches Ok(_); "zero is valid exit code")] + #[test_case("1" => matches Ok(_); "positive integer is valid exit code")] + #[test_case("-1" => matches Ok(_); "negative integer is valid exit code")] + #[test_case("a" => matches Err(_); "arbitrary string raises error")] + #[test_case("1.5" => matches Err(_); "floating point number raises error")] + #[test_case("9223372036854775807" => matches Err(_); "integer outside 32-bit range raises error")] + #[test_case("+123" => matches Err(_); "leading plus sign raises error")] + fn parse(text: &str) -> Result { + ExitCode::parse(text) + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::ExitCode; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case("0"; "exit code zero")] + #[test_case("1"; "positive integer exit code")] + #[test_case("-1"; "negative integer exit code")] + fn serializing(code: &str) { + let actual = serde_json::to_string( + &ExitCode::parse(code).expect("parse should never fail"), + ) + .expect("serialization should never fail"); + + let expected = format!(r#""{code}""#); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case(json!("0") => matches Ok(_); "zero as string value is valid")] + #[test_case(json!("1") => matches Ok(_); "positive integer as string value is valid")] + #[test_case(json!("-1") => matches Ok(_); "negative integer string value is valid")] + #[test_case(json!("1.2") => matches Err(_); "float as string value is invalid")] + #[test_case(json!("abc") => matches Err(_); "arbitrary string value is invalid")] + #[test_case(json!(true) => matches Err(_); "boolean value is invalid")] + #[test_case(json!(1) => matches Err(_); "integer value is invalid")] + #[test_case(json!(1.2) => matches Err(_); "float value is invalid")] + #[test_case(json!({"code": "0"}) => matches Err(_); "object value is invalid")] + #[test_case(json!(["0"]) => matches Err(_); "array value is invalid")] + #[test_case(serde_json::Value::Null => matches Err(_); "null value is invalid")] + fn deserializing(value: Value) -> Result { + serde_json::from_value::(value) + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod as_ref { + use dsc_lib::types::ExitCode; + + #[test] + fn i32() { + let _: &i32 = ExitCode::new(0).as_ref(); + } + } + + #[cfg(test)] + mod deref { + use dsc_lib::types::ExitCode; + + #[test] + fn i32() { + let c = ExitCode::new(-1); + + pretty_assertions::assert_eq!(c.abs(), 1); + } + } + + #[cfg(test)] + mod borrow { + use std::borrow::Borrow; + + use dsc_lib::types::ExitCode; + + #[test] + fn i32() { + let _: &i32 = ExitCode::new(0).borrow(); + } + } + + #[cfg(test)] + mod display { + use dsc_lib::types::ExitCode; + use test_case::test_case; + + #[test_case(0, "0"; "zero exit code")] + #[test_case(1, "1"; "positive integer exit code")] + #[test_case(-1, "-1"; "negative integer exit code")] + fn format(code: i32, expected: &str) { + pretty_assertions::assert_eq!( + format!("code: '{}'", ExitCode::new(code)), + format!("code: '{}'", expected) + ) + } + + #[test_case(0, "0"; "zero exit code")] + #[test_case(1, "1"; "positive integer exit code")] + #[test_case(-1, "-1"; "negative integer exit code")] + fn to_string(code: i32, expected: &str) { + pretty_assertions::assert_eq!( + ExitCode::new(code).to_string(), + expected.to_string() + ) + } + } + + #[cfg(test)] + mod from_str { + use std::str::FromStr; + + use dsc_lib::{dscerror::DscError, types::ExitCode}; + use test_case::test_case; + + #[test_case("0" => matches Ok(_); "zero is valid exit code")] + #[test_case("1" => matches Ok(_); "positive integer is valid exit code")] + #[test_case("-1" => matches Ok(_); "negative integer is valid exit code")] + #[test_case("a" => matches Err(_); "arbitrary string raises error")] + #[test_case("1.5" => matches Err(_); "floating point number raises error")] + #[test_case("9223372036854775807" => matches Err(_); "integer outside 32-bit range raises error")] + fn from_str(text: &str) -> Result { + ExitCode::from_str(text) + } + + #[test_case("0" => matches Ok(_); "zero is valid exit code")] + #[test_case("1" => matches Ok(_); "positive integer is valid exit code")] + #[test_case("-1" => matches Ok(_); "negative integer is valid exit code")] + #[test_case("a" => matches Err(_); "arbitrary string raises error")] + #[test_case("1.5" => matches Err(_); "floating point number raises error")] + #[test_case("9223372036854775807" => matches Err(_); "integer outside 32-bit range raises error")] + fn parse(text: &str) -> Result { + text.parse() + } + } + + #[cfg(test)] + mod from { + use dsc_lib::types::ExitCode; + + #[test] + fn i32() { + let _ = ExitCode::from(0); + } + } + + #[cfg(test)] + mod try_from { + use dsc_lib::{dscerror::DscError, types::ExitCode}; + use test_case::test_case; + + #[test_case("0" => matches Ok(_); "zero is valid exit code")] + #[test_case("1" => matches Ok(_); "positive integer is valid exit code")] + #[test_case("-1" => matches Ok(_); "negative integer is valid exit code")] + #[test_case("a" => matches Err(_); "arbitrary string raises error")] + #[test_case("1.5" => matches Err(_); "floating point number raises error")] + #[test_case("9223372036854775807" => matches Err(_); "integer outside 32-bit range raises error")] + fn string(text: &str) -> Result { + ExitCode::try_from(text.to_string()) + } + + #[test_case("0" => matches Ok(_); "zero is valid exit code")] + #[test_case("1" => matches Ok(_); "positive integer is valid exit code")] + #[test_case("-1" => matches Ok(_); "negative integer is valid exit code")] + #[test_case("a" => matches Err(_); "arbitrary string raises error")] + #[test_case("1.5" => matches Err(_); "floating point number raises error")] + #[test_case("9223372036854775807" => matches Err(_); "integer outside 32-bit range raises error")] + fn str(text: &str) -> Result { + ExitCode::try_from(text) + } + } + + #[cfg(test)] + mod into { + use dsc_lib::types::ExitCode; + + #[test] + fn i32() { + let _: i32 = ExitCode::new(0).into(); + } + + #[test] + fn string() { + let _: String = ExitCode::new(0).into(); + } + } + + #[cfg(test)] + mod partial_eq { + use dsc_lib::types::ExitCode; + use test_case::test_case; + + #[test_case(0, 0, true; "identical codes are equal")] + #[test_case(-1, 1, false; "different codes are unequal")] + fn exit_code(lhs: i32, rhs: i32, should_be_equal: bool) { + if should_be_equal { + pretty_assertions::assert_eq!( + ExitCode::new(lhs), + ExitCode::new(rhs) + ) + } else { + pretty_assertions::assert_ne!( + ExitCode::new(lhs), + ExitCode::new(rhs) + ) + } + } + + #[test_case(0, 0, true; "identical codes are equal")] + #[test_case(-1, 1, false; "different codes are unequal")] + fn i32(code_int: i32, int: i32, should_be_equal: bool) { + let code = ExitCode::new(code_int); + + // Test equivalency bidirectionally + pretty_assertions::assert_eq!( + code == int, + should_be_equal, + "expected comparison of {code} and {int} to be {should_be_equal}" + ); + + pretty_assertions::assert_eq!( + int == code, + should_be_equal, + "expected comparison of {int} and {code} to be {should_be_equal}" + ); + } + } +} diff --git a/lib/dsc-lib/tests/integration/types/exit_code_map.rs b/lib/dsc-lib/tests/integration/types/exit_code_map.rs new file mode 100644 index 000000000..2ad650546 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/exit_code_map.rs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Concisely defines a static map for use in tests. For example, create a map with exit codes `0` +/// and `1` as: +/// +/// ```ignore +/// define_static_map!(CUSTOM_MAP: { +/// 0 => "successful operation", +/// 1 => "unhandled failure", +/// 2 => "unauthorized operation" +/// }); +/// ``` +/// +/// Which expands to: +/// +/// ```ignore +/// static CUSTOM_MAP: std::sync::LazyLock = std::sync::LazyLock::new(|| { +/// let mut map = dsc_lib::types::ExitCodesMap::new(); +/// map.insert(dsc_lib::types::ExitCode::new(0), "successful operation".to_string()); +/// map.insert(dsc_lib::types::ExitCode::new(1), "unhandled failure".to_string()); +/// map.insert(dsc_lib::types::ExitCode::new(2), "unauthorized operation".to_string()); +/// }); +/// ``` +/// +/// This macro is only intended for use in these integration tests. +macro_rules! define_static_map { + ( $name:ident: {$( $key:expr => $value:expr ),* $(,)?} ) => { + static $name: std::sync::LazyLock = std::sync::LazyLock::new(|| { + let mut map = dsc_lib::types::ExitCodesMap::new(); + $( + map.insert(dsc_lib::types::ExitCode::new($key), $value.to_string()); + )* + map + }); + } +} + +#[cfg(test)] +mod methods { + use std::sync::LazyLock; + + use dsc_lib::types::ExitCodesMap; + use test_case::test_case; + + #[test] + fn new() { + let _ = ExitCodesMap::new(); + } + + #[test] + fn with_capacity() { + let _ = ExitCodesMap::with_capacity(1); + } + + define_static_map!(CUSTOM_MAP: { + 0 => "okay", + 10 => "oops" + }); + + static DEFAULT_FAILURE_DESC: LazyLock = LazyLock::new(|| ExitCodesMap::default().get_code(1).cloned().unwrap()); + + #[test_case(0 => matches Some(_); "returns description for a defined exit code")] + #[test_case(100 => matches None; "returns none for an undefined exit code")] + fn get_code(code: i32) -> Option { + ExitCodesMap::default().get_code(code).cloned() + } + + #[test_case(&*CUSTOM_MAP, 0, "okay"; "returns description for success from non-default map")] + #[test_case(&*CUSTOM_MAP, 10, "oops"; "returns description for failure from non-default map")] + #[test_case(&*CUSTOM_MAP, 5, DEFAULT_FAILURE_DESC.as_str())] + fn get_code_or_default(map: &ExitCodesMap, code: i32, expected: &str) { + pretty_assertions::assert_eq!( + map.get_code_or_default(code).as_str(), + expected + ) + } + + #[test_case(ExitCodesMap::new() => true; "map without codes returns true")] + #[test_case(ExitCodesMap::default() => false; "map with any codes returns false")] + fn is_empty(map: ExitCodesMap) -> bool { + map.is_empty() + } + + #[test_case(&ExitCodesMap::default() => true; "default map returns true")] + #[test_case(&ExitCodesMap::new() => false; "empty map returns false")] + #[test_case(&*CUSTOM_MAP => false; "non-default map returns false")] + fn is_default(map: &ExitCodesMap) -> bool { + map.is_default() + } + + #[test_case(&ExitCodesMap::default() => true; "default map returns true")] + #[test_case(&ExitCodesMap::new() => true; "empty map returns true")] + #[test_case(&*CUSTOM_MAP => false; "non-default map returns false")] + fn is_empty_or_default(map: &ExitCodesMap) -> bool { + map.is_empty_or_default() + } +} + +#[cfg(test)] +mod schema { + use std::sync::LazyLock; + + use dsc_lib::types::ExitCodesMap; + use dsc_lib_jsonschema::schema_utility_extensions::SchemaUtilityExtensions; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static SCHEMA: LazyLock = LazyLock::new(|| schema_for!(ExitCodesMap)); + static PROPERTY_NAMES_SUBSCHEMA: LazyLock = LazyLock::new(|| { + (&*SCHEMA).get_keyword_as_subschema("propertyNames").unwrap().clone() + }); + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*SCHEMA).as_value()).unwrap()); + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); + + #[test_case("title", &*SCHEMA)] + #[test_case("description", &*SCHEMA)] + #[test_case("markdownDescription", &*SCHEMA)] + #[test_case("patternErrorMessage", &*PROPERTY_NAMES_SUBSCHEMA)] + fn has_documentation_keyword(keyword: &str, schema: &Schema) { + let value = schema + .get_keyword_as_str(keyword) + .expect(format!("expected keyword '{keyword}' to be defined").as_str()); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'" + ); + } + + #[test_case(&json!({ "0": "okay"}) => true; "object with zero exit code and description is valid")] + #[test_case(&json!({ "1": "oops"}) => true; "object with positive integer exit code and description is valid")] + #[test_case(&json!({ "-1": "oops"}) => true; "object with negative integer exit code and description is valid")] + #[test_case(&json!({ "0": "okay", "-1": "oops"}) => true; "object with multiple exit code and description pairs is valid")] + #[test_case(&json!({}) => false; "empty object value is invalid")] + #[test_case(&json!({"invalid": "map"}) => false; "object with non-parseable key is invalid")] + #[test_case(&json!("0") => false; "string value is invalid")] + #[test_case(&json!(true) => false; "boolean value is invalid")] + #[test_case(&json!(1) => false; "integer value is invalid")] + #[test_case(&json!(1.2) => false; "float value is invalid")] + #[test_case(&json!({"req": "1.2.3"}) => false; "object value is invalid")] + #[test_case(&json!(["1.2.3"]) => false; "array value is invalid")] + #[test_case(&serde_json::Value::Null => false; "null value is invalid")] + fn validation(input_json: &Value) -> bool { + (&*VALIDATOR).validate(input_json).is_ok() + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::types::ExitCodesMap; + use serde_json::{json, Value}; + use test_case::test_case; + + define_static_map!(CUSTOM_MAP: { + 0 => "okay", + 1 => "oops", + }); + + #[test_case(&ExitCodesMap::new(), json!({}); "can serialize empty map")] + #[test_case(&ExitCodesMap::default(), json!({"0": "Success", "1": "Error"}); "can serialize default map")] + #[test_case(&*CUSTOM_MAP, json!({"0": "okay", "1": "oops"}); "can serialize custom map")] + fn serializing(map: &ExitCodesMap, expected: Value) { + let actual = serde_json::to_value(map.clone()) + .expect("serialization should never fail"); + + pretty_assertions::assert_eq!( + actual, + expected + ) + } + + #[test_case(json!({}), Some(&ExitCodesMap::new()); "can deserialize empty object")] + #[test_case(json!({"0": "Success", "1": "Error"}), Some(&ExitCodesMap::default()); "can deserialize object containing default map")] + #[test_case(json!({"0": "okay", "1": "oops"}), Some(&*CUSTOM_MAP); "can deserialize valid object")] + #[test_case(json!({"0": "okay", "foo": "fails"}), None; "object with invalid key fails to deserialize")] + #[test_case(json!({"0": "okay", "1": false}), None; "object with invalid value fails to deserialize")] + #[test_case(json!(true), None; "boolean value is invalid")] + #[test_case(json!(1), None; "integer value is invalid")] + #[test_case(json!(1.2), None; "float value is invalid")] + #[test_case(json!("okay"), None; "string value is invalid")] + #[test_case(json!(["okay"]), None; "array value is invalid")] + #[test_case(serde_json::Value::Null, None; "null value is invalid")] + fn deserializing(value: Value, expected: Option<&ExitCodesMap>) { + match serde_json::from_value::(value.clone()) { + Ok(actual_map) => match expected { + None => panic!( + "expected value {value:?} to fail deserializing but is {actual_map:?}" + ), + Some(expected_map) => { + pretty_assertions::assert_eq!(&actual_map, expected_map); + } + }, + Err(_) => match expected { + None => {}, + Some(expected_map) => panic!( + "expected value {value:?} to deserialize to {expected_map:?}" + ), + }, + } + } +} + +#[cfg(test)] +mod traits { + #[cfg(test)] + mod default { + use dsc_lib::types::ExitCodesMap; + + #[test] + fn default() { + let actual = ExitCodesMap::default(); + + pretty_assertions::assert_eq!(actual.len(), 2); + + assert!(actual.get_code(0).is_some()); + assert!(actual.get_code(1).is_some()); + } + } + + #[cfg(test)] + mod as_ref { + use dsc_lib::types::ExitCodesMap; + + + #[test] + fn exit_codes_map() { + let _: &ExitCodesMap = ExitCodesMap::default().as_ref(); + } + } + + #[cfg(test)] + mod deref { + use dsc_lib::types::ExitCodesMap; + + #[test] + fn hashmap_exit_code_string() { + let map = ExitCodesMap::default(); + + pretty_assertions::assert_eq!(map.len(), 2); + } + } + + #[cfg(test)] + mod deref_mut { + use dsc_lib::types::{ExitCode, ExitCodesMap}; + + #[test] + fn hashmap_exit_code_string() { + let mut map = ExitCodesMap::new(); + + map.insert(ExitCode::new(2), "new error".to_string()); + } + } +} diff --git a/lib/dsc-lib/tests/integration/types/mod.rs b/lib/dsc-lib/tests/integration/types/mod.rs index 8a07e79cb..da49cde68 100644 --- a/lib/dsc-lib/tests/integration/types/mod.rs +++ b/lib/dsc-lib/tests/integration/types/mod.rs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(test)] +mod exit_code; +#[cfg(test)] +mod exit_code_map; #[cfg(test)] mod fully_qualified_type_name; #[cfg(test)]