From acb9dae9cb96807350655f550a92c330cad879d5 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Fri, 9 Oct 2026 10:52:42 -0500 Subject: [PATCH 1/8] (GH-1081) Define structured settings for DSC Prior to this change, DSC accepted settings from multiple sources and scopes but retrieved those settings adhoc when needed. The settings were not structured or consistently retrieved and resolved. This change starts the work for #1081 by: - Adding a new `settings` module to `dsc-lib` to contain the structured cascading hierarchy of settings. - Defining reusable patterns and guidance for maintaining and extending the settings that DSC recognizes. - Providing stubs for writing rust tests where we need to define fixtures to replicate reading from files and environment variables. --- Cargo.lock | 26 + Cargo.toml | 2 + lib/dsc-lib/Cargo.toml | 2 + lib/dsc-lib/locales/en-us.toml | 50 + .../locales/schemas.settings.test.yaml | 74 + lib/dsc-lib/locales/schemas.settings.yaml | 612 +++++++ lib/dsc-lib/src/lib.rs | 6 + .../src/settings/constants_and_statics.rs | 136 ++ lib/dsc-lib/src/settings/dsc_settings.rs | 1614 +++++++++++++++++ .../src/settings/dsc_settings_scope.rs | 358 ++++ lib/dsc-lib/src/settings/errors.rs | 331 ++++ .../fields/forbid_ignore_settings_file.rs | 38 + .../settings/fields/ignore_settings_file.rs | 54 + .../fields/macros/define_boolean_field.rs | 253 +++ .../fields/macros/define_container_field.rs | 383 ++++ .../fields/macros/define_string_enum_field.rs | 307 ++++ lib/dsc-lib/src/settings/fields/macros/mod.rs | 31 + .../src/settings/fields/macros/tests.rs | 298 +++ .../src/settings/fields/macros/utility.rs | 84 + lib/dsc-lib/src/settings/fields/mod.rs | 207 +++ .../src/settings/fields/resource_path.rs | 243 +++ lib/dsc-lib/src/settings/fields/tracing.rs | 247 +++ lib/dsc-lib/src/settings/mod.rs | 342 ++++ lib/dsc-lib/src/settings/resolved/field.rs | 209 +++ lib/dsc-lib/src/settings/resolved/mod.rs | 26 + lib/dsc-lib/src/settings/resolved/settings.rs | 211 +++ .../src/settings/sources/code_defaults.rs | 176 ++ .../src/settings/sources/command_line.rs | 213 +++ .../src/settings/sources/environment.rs | 661 +++++++ lib/dsc-lib/src/settings/sources/mod.rs | 91 + .../src/settings/sources/policy_file.rs | 215 +++ .../src/settings/sources/preference_file.rs | 212 +++ lib/dsc-lib/src/tests/mod.rs | 15 + .../src/tests/settings/dsc_settings.rs | 915 ++++++++++ lib/dsc-lib/src/tests/settings/mod.rs | 40 + .../src/tests/settings/sources/environment.rs | 458 +++++ .../src/tests/settings/sources/policy_file.rs | 243 +++ .../tests/settings/sources/preference_file.rs | 242 +++ lib/dsc-lib/src/tests/stubs/env.rs | 149 ++ lib/dsc-lib/src/tests/stubs/fs.rs | 22 + lib/dsc-lib/src/tests/stubs/mod.rs | 15 + lib/dsc-lib/src/tests/stubs/path.rs | 520 ++++++ xtask/src/schemas/export.rs | 14 + 43 files changed, 10345 insertions(+) create mode 100644 lib/dsc-lib/locales/schemas.settings.test.yaml create mode 100644 lib/dsc-lib/locales/schemas.settings.yaml create mode 100644 lib/dsc-lib/src/settings/constants_and_statics.rs create mode 100644 lib/dsc-lib/src/settings/dsc_settings.rs create mode 100644 lib/dsc-lib/src/settings/dsc_settings_scope.rs create mode 100644 lib/dsc-lib/src/settings/errors.rs create mode 100644 lib/dsc-lib/src/settings/fields/forbid_ignore_settings_file.rs create mode 100644 lib/dsc-lib/src/settings/fields/ignore_settings_file.rs create mode 100644 lib/dsc-lib/src/settings/fields/macros/define_boolean_field.rs create mode 100644 lib/dsc-lib/src/settings/fields/macros/define_container_field.rs create mode 100644 lib/dsc-lib/src/settings/fields/macros/define_string_enum_field.rs create mode 100644 lib/dsc-lib/src/settings/fields/macros/mod.rs create mode 100644 lib/dsc-lib/src/settings/fields/macros/tests.rs create mode 100644 lib/dsc-lib/src/settings/fields/macros/utility.rs create mode 100644 lib/dsc-lib/src/settings/fields/mod.rs create mode 100644 lib/dsc-lib/src/settings/fields/resource_path.rs create mode 100644 lib/dsc-lib/src/settings/fields/tracing.rs create mode 100644 lib/dsc-lib/src/settings/mod.rs create mode 100644 lib/dsc-lib/src/settings/resolved/field.rs create mode 100644 lib/dsc-lib/src/settings/resolved/mod.rs create mode 100644 lib/dsc-lib/src/settings/resolved/settings.rs create mode 100644 lib/dsc-lib/src/settings/sources/code_defaults.rs create mode 100644 lib/dsc-lib/src/settings/sources/command_line.rs create mode 100644 lib/dsc-lib/src/settings/sources/environment.rs create mode 100644 lib/dsc-lib/src/settings/sources/mod.rs create mode 100644 lib/dsc-lib/src/settings/sources/policy_file.rs create mode 100644 lib/dsc-lib/src/settings/sources/preference_file.rs create mode 100644 lib/dsc-lib/src/tests/mod.rs create mode 100644 lib/dsc-lib/src/tests/settings/dsc_settings.rs create mode 100644 lib/dsc-lib/src/tests/settings/mod.rs create mode 100644 lib/dsc-lib/src/tests/settings/sources/environment.rs create mode 100644 lib/dsc-lib/src/tests/settings/sources/policy_file.rs create mode 100644 lib/dsc-lib/src/tests/settings/sources/preference_file.rs create mode 100644 lib/dsc-lib/src/tests/stubs/env.rs create mode 100644 lib/dsc-lib/src/tests/stubs/fs.rs create mode 100644 lib/dsc-lib/src/tests/stubs/mod.rs create mode 100644 lib/dsc-lib/src/tests/stubs/path.rs diff --git a/Cargo.lock b/Cargo.lock index 463a32b57..09b6d0363 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -879,6 +879,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "serial_test", "test-case", "thiserror 2.0.21", "tokio", @@ -3200,6 +3201,31 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial_test" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6df5ed973ad8d834e09f824f9e9f449af6b9a3745f78dec7cc752770bd3bf11" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index 5504aa256..cdcc4e408 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -234,6 +234,8 @@ rust-i18n = { version = "4.2.4" } schemars = { version = "1.2.2", features = ["preserve_order"] } # dsc, dsc-lib semver = { version = "1.0.28" } +# dsc-lib +serial_test = { version = "4.0.1" } # dsc, dsc-lib, dscecho, dsc-lib-osinfo, process, registry, dsc-lib-registry, runcommandonset, sshdconfig, dsctest, test_group_resource serde = { version = "1.0.229", features = ["derive"] } # dsc, dsc-lib, dscecho, dsc-lib-osinfo, osinfo, process, registry, dsc-lib-registry, runcommandonset, sshdconfig, dsctest, test_group_resource, y2j diff --git a/lib/dsc-lib/Cargo.toml b/lib/dsc-lib/Cargo.toml index 19b4b84d9..ea1de3f57 100644 --- a/lib/dsc-lib/Cargo.toml +++ b/lib/dsc-lib/Cargo.toml @@ -62,6 +62,8 @@ serde_yaml = { workspace = true } pretty_assertions = { workspace = true } # Enables parameterized test cases test-case = { workspace = true } +# Enables serializing test execution for shared context stubs +serial_test = { workspace = true } [target.'cfg(not(target_os = "windows"))'.dev-dependencies] nix = { workspace = true, features = ["user"] } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 7ec10b494..efd65db22 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -962,6 +962,56 @@ utf16Conversion = "Failed to convert UTF-16 bytes to string" [progress] failedToSerialize = "Failed to serialize progress JSON: %{json}" +[settings.dsc_settings] +debugLoadEnvNoneDefined = "no environment settings defined, skipping" +debugLoadEnvStart = "attempting to load environment settings" +debugLoadEnvSuccess = "loaded environment settings" +debugLoadIgnoringSettingsFiles = "skipping loading preference file settings scopes (%{skipping_scopes}) as defined by %{setting} in %{scope}" +debugLoadPolicyFileNotFound = "policy settings file `%{path}` does not exist, skipping" +debugLoadPolicyStart = "attempting to load policy settings from `%{path}`..." +debugLoadPolicySuccess = "loaded policy settings from `%{path}`" +debugLoadPreferenceFileNotFound = "%{scope} preference settings file `%{path}` does not exist, skipping" +debugLoadPreferenceStart = "attempting to load %{scope} preference settings from `%{path}`..." +debugLoadPreferenceSuccess = "loaded %{scope} preference settings from `%{path}`" +debugResolved = "resolved settings from all loaded sources" +debugResolvedScope = "resolved settings for %{scope} scope" +debugResolving = "resolving settings from loaded sources..." +debugResolvingScope = "resolving settings for %{scope} scope..." +debugSkippingScope = "skipping resolution for %{scope} scope - no settings defined and loaded for this scope" +policyFolderNotSecureLinux = "Only root should have write access (no group/other write bits)" +policyFolderNotSecureWindows = "Only SYSTEM and Administrators should have write access" +traceResolved = "resolved settings from all loaded sources: %{effective}" +traceResolvedScope = "resolved settings after applying %{scope}: %{effective}" +warnCliIgnoreSetting = "ignoring the command line argument `%{cli_arg}` because '%{policy_field}' is set to true" +warnEnvIgnoreSetting = "ignoring the environment variable `%{env_var}` because '%{policy_field}' is set to true" +warnLoadScopeFailed = "failed to load %{scope} settings: %{error}" +warnPolicyIgnoreSetting = "ignoring the policy setting `%{ignore_settings_file}` because `%{forbid_ignore_settings_file}` is set to true" + +[settings.errors] +invalidScope = "unable to case-insensitively parse '%{text}' as a valid DSC settings scope - valid scopes are: %{variant_list}, and %{variant_last}" +invalidTracingLevel = "unable to case-insensitively parse '%{text}' as a valid DSC settings trace level - valid levels are: %{variant_list}, and %{variant_last}" +invalidTracingFormat = "unable to case-insensitively parse '%{text}' as a valid DSC settings trace format - valid formats are: %{variant_list}, and %{variant_last}" +dataFileEmpty = "unable to load the %{scope} data file at '%{file_path}' - file contains no text" +dataFileReadError = "unable to read the %{scope} data file at '%{file_path}' - %{err}" +dataFileUnparseable = "unable to parse the %{scope} data file at '%{file_path}' - %{err}" +envVarUnparseableBoolean = "unable to parse the value `%{value}` as a boolean environment variable - expected %{expected_list}, or %{expected_last}" +loadMultipleErrors = "failed to load settings due to one or more errors - %{err}" +loadEnvironmentError = "failed to load environment variable `%{env_var}` for settings - %{err}" +loadEnvironmentMultipleErrors = "failed to load environment variables for settings due to one or more errors - %{err}" +policyFolderNotSecure = "Policy folder '%{path}' is not secure, settings file will not be used. Required permissions: %{required}" + +[settings.sources.environment] +debugLoadingVar = "trying to load `%{env_var}` for settings..." +debugUndefinedVar = "skipping `%{env_var}` for settings - variable not defined" +debugLoadedVar = "loaded `%{env_var}` for settings with effective value `%{value}`" +warnIgnoreInvalidVar = "ignoring invalid environment variable `%{env_var}` for settings: %{err}" + +[settings.sources.policy_file] +debugLoadingFile = "trying to load %{scope} file from '%{file_path}' for settings..." + +[settings.sources.preference_file] +debugLoadingFile = "trying to load preference file for %{scope} scope from '%{file_path}' for settings..." + [util] foundSetting = "Found setting '%{name}' in %{path}" notFoundSetting = "Setting '%{name}' not found in %{path}" diff --git a/lib/dsc-lib/locales/schemas.settings.test.yaml b/lib/dsc-lib/locales/schemas.settings.test.yaml new file mode 100644 index 000000000..f1b1e82f4 --- /dev/null +++ b/lib/dsc-lib/locales/schemas.settings.test.yaml @@ -0,0 +1,74 @@ +_version: 2 +schemas.settings.test: + container: + title: + en-us: Container title + description: + en-us: Container description + markdownDescription: + en-us: Container markdown description + fields: + foo: + title: + en-us: Container 'foo' field title + description: + en-us: Container 'foo' field description + markdownDescription: + en-us: Container 'foo' field markdown description + bar: + title: + en-us: Container 'bar' field title + description: + en-us: Container 'bar' field description + markdownDescription: + en-us: Container 'bar' field markdown description + baz: + title: + en-us: Container 'baz' field title + description: + en-us: Container 'baz' field description + markdownDescription: + en-us: Container 'baz' field markdown description + + boolean: + title: + en-us: Boolean title + description: + en-us: Boolean description + markdownDescription: + en-us: Boolean markdown description + markdownEnumDescriptions: + 'true': + en-us: Boolean markdown enum description for true + 'false': + en-us: Boolean markdown enum description for false + + string: + title: + en-us: String title + description: + en-us: String description + markdownDescription: + en-us: String markdown description + variants: + foo: + title: + en-us: String 'foo' variant title + description: + en-us: String 'foo' variant description + markdownDescription: + en-us: String 'foo' variant markdown description + bar: + title: + en-us: String 'bar' variant title + description: + en-us: String 'bar' variant description + markdownDescription: + en-us: String 'bar' variant markdown description + baz: + title: + en-us: String 'baz' variant title + description: + en-us: String 'baz' variant description + markdownDescription: + en-us: String 'baz' variant markdown description diff --git a/lib/dsc-lib/locales/schemas.settings.yaml b/lib/dsc-lib/locales/schemas.settings.yaml new file mode 100644 index 000000000..4d522e7d1 --- /dev/null +++ b/lib/dsc-lib/locales/schemas.settings.yaml @@ -0,0 +1,612 @@ +_version: 2 +schemas: + settings: + scope: + title: + en-us: "Scope" + description: + en-us: "The source of a setting value" + markdownDescription: + en-us: | + The source of a setting value. This defines where the setting is coming from, such as the default settings, machine settings, user settings, workspace settings, environment variables, command line arguments, or policy file. + variants: + default: + title: + en-us: "Default" + description: + en-us: "The default settings statically defined in the DSC codebase" + markdownDescription: + en-us: | + The default settings statically defined in the DSC codebase. + machine: + title: + en-us: "Machine" + description: + en-us: "The settings defined for all users on the machine in a preference settings file" + markdownDescription: + en-us: | + The settings defined for all users on the machine in a preference settings file. + user: + title: + en-us: "User" + description: + en-us: "The settings defined for the current user in a preference settings file" + markdownDescription: + en-us: | + The settings defined for the current user in a preference settings file. + workspace: + title: + en-us: "Workspace" + description: + en-us: "The settings defined for the current workspace in a preference settings file" + markdownDescription: + en-us: | + The settings defined for the current workspace in a preference settings file. + environment: + title: + en-us: "Environment" + description: + en-us: "Settings defined as environment variables" + markdownDescription: + en-us: | + Settings defined as environment variables. + cli: + title: + en-us: "Command Line" + description: + en-us: "Settings defined as command line arguments" + markdownDescription: + en-us: | + Settings defined as command line arguments. + policy: + title: + en-us: "Policy" + description: + en-us: "The system policy file. Fields defined as policy cannot be overridden." + markdownDescription: + en-us: | + The system policy file. Fields defined as policy cannot be overridden. + + fields: + forbidIgnoreSettingsFile: + title: + en-us: "Forbid ignore settings file" + description: + en-us: "Forbid ignoring the settings file" + markdownDescription: + en-us: | + Forbid ignoring the settings file. If this is set to true, the settings file will not be ignored even if the `--ignore-settings-file` flag is passed. + markdownEnumDescriptions: + 'true': + en-us: | + Forbid ignoring the settings file. If this is set to true, the settings file will not be ignored even if the `--ignore-settings-file` flag is passed. + 'false': + en-us: | + Allow ignoring the settings file. If this is set to false, the settings file can be ignored if the `--ignore-settings-file` flag is passed. + ignoreSettingsFile: + title: + en-us: "Ignore settings file" + description: + en-us: "Ignore the settings file" + markdownDescription: + en-us: | + Ignore the settings file. If this is set to true, the settings file will be ignored even if the `--ignore-settings-file` flag is not passed. + markdownEnumDescriptions: + 'true': + en-us: | + Ignore the settings file. If this is set to true, the settings file will be ignored even if the `--ignore-settings-file` flag is not passed. + 'false': + en-us: | + Do not ignore the settings file. If this is set to false, the settings file will not be ignored even if the `--ignore-settings-file` flag is passed. + tracing: + title: + en-us: "Tracing" + description: + en-us: "Settings related to tracing" + markdownDescription: + en-us: | + Settings related to tracing. + fields: + level: &tracing_level_field + title: + en-us: "Level" + description: + en-us: "Tracing level" + markdownDescription: + en-us: | + Tracing level. This is the level of tracing that will be used. The possible values are `off`, `error`, `warn`, `info`, `debug`, and `trace`. + format: &tracing_format_field + title: + en-us: "Format" + description: + en-us: "Tracing format" + markdownDescription: + en-us: | + Tracing format. This is the format that will be used for tracing output. The possible values are `default`, `json`, and `plaintext`. + + level: # Reuse the overview for the tracing level field + <<: *tracing_level_field + variants: + error: + title: + en-us: "Error" + description: + en-us: "Tracing level for errors" + markdownDescription: + en-us: | + Tracing level for errors. This is the level of tracing that will be used for errors. The possible values are `off`, `error`, `warn`, `info`, `debug`, and `trace`. + warn: + title: + en-us: "Warn" + description: + en-us: "Tracing level for warnings" + markdownDescription: + en-us: | + Tracing level for warnings. This is the level of tracing that will be used for warnings. The possible values are `off`, `error`, `warn`, `info`, `debug`, and `trace`. + info: + title: + en-us: "Info" + description: + en-us: "Tracing level for informational messages" + markdownDescription: + en-us: | + Tracing level for informational messages. This is the level of tracing that will be used for informational messages. The possible values are `off`, `error`, `warn`, `info`, `debug`, and `trace`. + debug: + title: + en-us: "Debug" + description: + en-us: "Tracing level for debugging" + markdownDescription: + en-us: | + Tracing level for debugging. This is the level of tracing that will be used for debugging. The possible values are `off`, `error`, `warn`, `info`, `debug`, and `trace`. + trace: + title: + en-us: "Trace" + description: + en-us: "Tracing level for trace messages" + markdownDescription: + en-us: | + Tracing level for trace messages. This is the level of tracing that will be used for trace messages. The possible values are `off`, `error`, `warn`, `info`, `debug`, and `trace`. + + format: + <<: *tracing_format_field + variants: + default: + title: + en-us: "Default" + description: + en-us: "Default tracing format" + markdownDescription: + en-us: | + Default tracing format. This is the default format that will be used for tracing output. The possible values are `default` and `json`. + json: + title: + en-us: "JSON" + description: + en-us: "JSON tracing format" + markdownDescription: + en-us: | + JSON tracing format. This is the format that will be used for tracing output in JSON. The possible values are `default` and `json`. + plaintext: + title: + en-us: "Plaintext" + description: + en-us: "Plaintext tracing format" + markdownDescription: + en-us: | + Plaintext tracing format. This is the format that will be used for tracing output in plaintext. The possible values are `default` and `json`. + resourcePath: + title: + en-us: "Resource path" + description: + en-us: "Settings related to resource paths" + markdownDescription: + en-us: | + Settings related to resource paths. + fields: + directories: + title: + en-us: "Directories" + description: + en-us: "Directories to search for resources" + markdownDescription: + en-us: | + Directories to search for resources. This is a list of directories that will be searched for resources. + restricted: + title: + en-us: "Restricted" + description: + en-us: "Restricted resource paths" + markdownDescription: + en-us: | + Restricted resource paths. This is a list of resource paths that are restricted. + append_env_path: + title: + en-us: "Append environment path" + description: + en-us: "Append environment path to resource paths" + markdownDescription: + en-us: | + Append environment path to resource paths. If this is set to true, the environment path will be appended to the resource paths. + + sources: + default: + title: + en-us: "Default" + description: + en-us: "Default source for settings" + markdownDescription: + en-us: | + Default source for settings. This represents the default values for the settings. + + cli: + title: + en-us: "CLI" + description: + en-us: "Settings provided via the command line interface" + markdownDescription: + en-us: | + Settings provided via the command line interface. This represents the values for the settings that are specified through the CLI. + trace_level: + title: + en-us: "Trace level" + description: + en-us: "Settings related to the trace level" + markdownDescription: + en-us: | + Settings related to the trace level. This represents the values for the settings that control the trace level. + trace_format: + title: + en-us: "Trace format" + description: + en-us: "Settings related to the trace format" + markdownDescription: + en-us: | + Settings related to the trace format. This represents the values for the settings that control the trace format. + ignore_settings_file: + title: + en-us: "Ignore settings file" + description: + en-us: "Settings related to ignoring the settings file" + markdownDescription: + en-us: | + Settings related to ignoring the settings file. This represents the values for the settings that control whether the settings file should be ignored. + environment: + title: + en-us: "Environment" + description: + en-us: "Settings provided via environment variables" + markdownDescription: + en-us: | + Settings provided via environment variables. This represents the values for the settings that are specified through environment variables. + dsc_trace_level: + title: + en-us: "Trace level" + description: + en-us: "Settings related to the trace level" + markdownDescription: + en-us: | + Settings related to the trace level. This represents the values for the settings that control the trace level. + dsc_trace_format: + title: + en-us: "Trace format" + description: + en-us: "Settings related to the trace format" + markdownDescription: + en-us: | + Settings related to the trace format. This represents the values for the settings that control the trace format. + dsc_resource_path: + title: + en-us: "Resource path" + description: + en-us: "Settings related to the resource path" + markdownDescription: + en-us: | + Settings related to the resource path. This represents the values for the settings that control the resource path. + dsc_restricted_path: + title: + en-us: "Restricted path" + description: + en-us: "Settings related to the restricted path" + markdownDescription: + en-us: | + Settings related to the restricted path. This represents the values for the settings that control the restricted path. + dsc_ignore_settings_file: + title: + en-us: "Ignore settings file" + description: + en-us: "Settings related to ignoring the settings file" + markdownDescription: + en-us: | + Settings related to ignoring the settings file. This represents the values for the settings that control whether the settings file should be ignored. + preferenceFile: + title: + en-us: "Preference settings file" + description: + en-us: "Settings provided via user preferences" + markdownDescription: + en-us: | + Settings provided via user preferences. This represents the values for the settings that are specified through user preferences. + tracing: + title: + en-us: "Tracing" + description: + en-us: "Settings related to tracing" + markdownDescription: + en-us: | + Settings related to tracing. This represents the values for the settings that control tracing behavior. + resource_path: + title: + en-us: "Resource path" + description: + en-us: "Settings related to the resource path" + markdownDescription: + en-us: | + Settings related to the resource path. This represents the values for the settings that control the resource path. + policyFile: + title: + en-us: "Policy settings file" + description: + en-us: "Settings enforced by policy" + markdownDescription: + en-us: | + Settings enforced by policy. This represents the values for the settings that are enforced by policy. + forbid_ignore_settings_file: + title: + en-us: "Forbid ignore settings file" + description: + en-us: "Settings related to forbidding the ignore settings file" + markdownDescription: + en-us: | + Settings related to forbidding the ignore settings file. This represents the values for the settings that control whether the ignore settings file is forbidden. + ignore_settings_file: + title: + en-us: "Ignore settings file" + description: + en-us: "Settings related to the ignore settings file" + markdownDescription: + en-us: | + Settings related to the ignore settings file. This represents the values for the settings that control whether the ignore settings file is used. + tracing: + title: + en-us: "Tracing" + description: + en-us: "Settings related to tracing" + markdownDescription: + en-us: | + Settings related to tracing. This represents the values for the settings that control tracing behavior. + resource_path: + title: + en-us: "Resource path" + description: + en-us: "Settings related to the resource path" + markdownDescription: + en-us: | + Settings related to the resource path. This represents the values for the settings that control the resource path. + + resolved: + field: + title: + en-us: Resolved settings field + description: + en-us: >- + Represents a leaf field in resolved settings, showing both the resolved value and the + scope that the value was resolved from. + markdownDescription: + en-us: | + Represents a leaf field in resolved settings, showing both the resolved value and the + scope that the value was resolved from. + settings: + title: + en-us: "Resolved" + description: + en-us: "Resolved settings" + markdownDescription: + en-us: | + Resolved settings. This represents the final values for the settings after considering all sources. + forbid_ignore_settings_file: + title: + en-us: "Forbid ignore settings file" + description: + en-us: "Settings related to forbidding the ignore settings file" + markdownDescription: + en-us: | + Settings related to forbidding the ignore settings file. This represents the values for the settings that control whether the ignore settings file is forbidden. + ignore_settings_file: + title: + en-us: "Ignore settings file" + description: + en-us: "Settings related to the ignore settings file" + markdownDescription: + en-us: | + Settings related to the ignore settings file. This represents the values for the settings that control whether the ignore settings file is used. + tracing: + title: + en-us: "Tracing" + description: + en-us: "Settings related to tracing" + markdownDescription: + en-us: | + Settings related to tracing. This represents the values for the settings that control tracing behavior. + resource_path: + title: + en-us: "Resource path" + description: + en-us: "Settings related to the resource path" + markdownDescription: + en-us: | + Settings related to the resource path. This represents the values for the settings that control the resource path. + all: + title: + en-us: "DSC settings" + description: + en-us: "Represents DSC settings from all sources and includes the resolved settings" + markdownDescription: + en-us: | + Represents the complete set of DSC settings, including all sources and the resolved effective + settings. + + During initialization, DSC loads settings from various sources. The following list defines the + sources for settings in order of precedence, from lowest to highest where later sources override + earlier sources: + + - The hardcoded defaults defined in the source code. + - The machine settings file, if it exists. + - The user settings file, if it exists. + - The workspace settings file, if it exists. + - The environment variables, if they are set. + - The command line arguments, if they are provided. + - The policy settings file, if it exists. + + fields: + default: + title: + en-us: "Code default settings" + description: + en-us: The hardcoded default settings defined in the source code. + markdownDescription: + en-us: | + The hardcoded default settings defined in the source code. + + These defaults have the lowest precedence and will be overridden by any other settings + source if a value is provided. + + The following snippet shows the effective code defaults as YAML data: + + ```yaml + forbid_ignore_settings_file: false + ignore_settings_file: false + tracing: + level: warn + format: default + resource_path: + directories: [] + append_env_path: true + restricted: false + ``` + machine: + title: + en-us: "Machine settings" + description: + en-us: "The settings loaded from the machine settings file, if it exists." + markdownDescription: + en-us: | + The settings loaded from the machine settings file, if it exists. + + This field is omitted if the machine settings file doesn't exist or if it couldn't + be loaded. + + The machine settings file has the lowest precedence for settings after the code + defaults. Any settings defined in a different scope will override the values defined + in the machine settings file. + + The location for the machine settings file depends on the operating system: + + - On Windows: `{ProgramData}\dsc\dsc.settings.json` + - On macOS: `/Library/Application Support/dsc/dsc.settings.json` + - On Linux and other Unix-like systems: `/etc/dsc/dsc.settings.json` + user: + title: + en-us: "User settings" + description: + en-us: "The settings loaded from the user settings file, if it exists." + markdownDescription: + en-us: | + The settings loaded from the user settings file, if it exists. + + This field is omitted if the user settings file doesn't exist or if it couldn't + be loaded. + + The user settings file has higher precedence than the machine settings file but lower + precedence than the workspace, environment, command line, and policy settings. + + The location for the user settings file depends on the operating system: + + - On Windows: `{APPDATA}\dsc\dsc.settings.json` + - On macOS: `{HOME}/Library/Application Support/dsc/dsc.settings.json` + - On Linux and other Unix-like systems: `{HOME}/.config/dsc/dsc.settings.json` + workspace: + title: + en-us: "Workspace settings" + description: + en-us: "The settings loaded from the workspace settings file, if it exists." + markdownDescription: + en-us: | + The settings loaded from the workspace settings file, if it exists. + + This field is omitted if the workspace settings file doesn't exist or if it couldn't + be loaded. + + The workspace settings file has higher precedence than the machine and user settings + files but lower precedence than the environment, command line, and policy settings. + + The location for the workspace settings file is the current working directory: + + - `{CWD}/dsc.settings.json` + environment: + title: + en-us: "Environment settings" + description: + en-us: "The settings loaded from the environment variables, if they exist." + markdownDescription: + en-us: | + The settings loaded from the environment variables, if they exist. + + This field is omitted if no relevant environment variables are set. + + The environment settings have higher precedence than the machine, user, and workspace + settings but lower precedence than the command line and policy settings. + + DSC uses the following environment variables for settings: + + - `DSC_TRACE_LEVEL`: Defines the trace level to use. + - `DSC_TRACE_FORMAT`: Defines the trace format to use. + - `DSC_RESOURCE_PATH`: Defines the resource directories to use. + - `DSC_RESTRICTED_PATH`: Defines the resource directories to use and restricts all DSC + invocations to those directories exclusively. + - `DSC_IGNORE_SETTINGS_FILE`: Defines whether to ignore settings files. + command_line: + title: + en-us: "Command line settings" + description: + en-us: "The settings loaded from the command line arguments, if they were specified." + markdownDescription: + en-us: | + The settings loaded from the command line arguments, if they were specified. + + This field is omitted if no relevant command line arguments are provided. + + The command line settings have higher precedence than the machine, user, workspace, and environment + settings but lower precedence than the policy settings. + + DSC uses the following command line arguments for settings: + + - `--trace-level`: Defines the trace level to use. + - `--trace-format`: Defines the trace format to use. + - `--ignore-settings-file`: Defines whether to ignore settings files. + policy: + title: + en-us: "Policy settings" + description: + en-us: The settings loaded from the policy settings file, if it exists. + markdownDescription: + en-us: | + The settings loaded from the policy settings file, if it exists. + + This field is omitted if the policy settings file does not exist or couldn't be + loaded. + + The policy settings have the highest precedence among all sources. Settings defined + as policy cannot be overridden. + resolved: + title: + en-us: "Resolved settings" + description: + en-us: "The effective settings after considering all sources and their precedence." + markdownDescription: + en-us: | + The effective settings after considering all sources and their precedence. This represents the final + values that DSC will use during its operation. + + Every leaf field in the resolved settings contains both the resolved value and the scope from + which the value was resolved. diff --git a/lib/dsc-lib/src/lib.rs b/lib/dsc-lib/src/lib.rs index 9540b2385..715420a23 100644 --- a/lib/dsc-lib/src/lib.rs +++ b/lib/dsc-lib/src/lib.rs @@ -20,9 +20,15 @@ pub mod functions; pub mod parser; pub mod progress; pub mod schemas; +pub mod settings; pub mod types; pub mod util; +// Define module for unit tests and stubs +#[cfg(test)] +#[macro_use] +pub(crate) mod tests; + i18n!("locales", fallback = "en-us"); pub struct DscManager { diff --git a/lib/dsc-lib/src/settings/constants_and_statics.rs b/lib/dsc-lib/src/settings/constants_and_statics.rs new file mode 100644 index 000000000..32203606f --- /dev/null +++ b/lib/dsc-lib/src/settings/constants_and_statics.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines static lazy-initialized paths to the various settings files used by DSC. +//! +//! These paths are determined at runtime based on the operating system and environment variables, +//! and they provide a consistent way to access the settings files across different platforms. + +use std::sync::LazyLock; + +#[cfg(not(test))] +use std::path::{PathBuf, Path}; + +#[cfg(test)] +use crate::tests::stubs::path::{PathBuf, Path}; + +/// Name of the settings file used for the machine, user, and workspace scopes. +pub const SETTINGS_PREFERENCE_FILE_NAME: &str = "dsc.settings.json"; +/// Name of the policy file used for the policy scope. +pub const SETTINGS_POLICY_FILE_NAME: &str = "dsc.policy.json"; + +/// Defines the full path to the policy settings file, which is located in a +/// platform-specific folder. +/// +/// The pseudo-path for this file depends on the platform: +/// +/// - On Windows: `{ProgramData}\dsc\dsc.policy.json` +/// - On macOS: `/Library/Application Support/dsc/dsc.policy.json` +/// - On Linux and other Unix-like systems: `/etc/dsc/dsc.policy.json` +pub static POLICY_SETTINGS_FILE_PATH: LazyLock = LazyLock::new(|| { + machine_folder_path().join(SETTINGS_POLICY_FILE_NAME) +}); + +/// Defines the full path to the machine settings file, which is located in a +/// platform-specific folder. +/// +/// The pseudo-path for this file depends on the platform: +/// +/// - On Windows: `{ProgramData}\dsc\dsc.settings.json` +/// - On macOS: `/Library/Application Support/dsc/dsc.settings.json` +/// - On Linux and other Unix-like systems: `/etc/dsc/dsc.settings.json` +pub static MACHINE_SETTINGS_FILE_PATH: LazyLock = LazyLock::new(|| { + machine_folder_path().join(SETTINGS_PREFERENCE_FILE_NAME) +}); + +/// Defines the full path to the user settings file, which is located in a +/// platform-specific folder based on the user's home directory or environment +/// variables. +/// +/// The pseudo-path for this file depends on the platform and whether the +/// `XDG_CONFIG_HOME` environment variable is set. When `XDG_CONFIG_HOME` is +/// set, the path is always `{XDG_CONFIG_HOME}/dsc/dsc.settings.json` (using +/// `\` instead of `/` on Windows). +/// +/// Otherwise, the path varies by platform: +/// +/// - On Windows: `{APPDATA}\dsc\dsc.settings.json` +/// - On macOS: `{HOME}/Library/Application Support/dsc/dsc.settings.json` +/// - On Linux and other Unix-like systems: `{HOME}/.config/dsc/dsc.settings.json` +pub static USER_SETTINGS_FILE_PATH: LazyLock> = LazyLock::new(|| { + user_folder_path().map(|path| path.join(SETTINGS_PREFERENCE_FILE_NAME)) +}); + +fn user_folder_path() -> Option { + if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(Path::new(&xdg_config_home).join("dsc")); + } + + #[cfg(target_os = "windows")] + { + match std::env::var_os("APPDATA") { + Some(app_data) => Some(Path::new(&app_data).join("dsc")), + None => None + } + } + + #[cfg(target_os = "macos")] + { + match std::env::var_os("HOME") { + Some(home) => Some(Path::new(&home) + .join("Library") + .join("Application Support") + .join("dsc") + ), + None => None + } + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + match std::env::var_os("HOME") { + Some(home) => Some(Path::new(&home).join(".config").join("dsc")), + None => None + } + } +} + +/// Defines the full path to the workspace settings file, which is located in +/// the current working directory. +/// +/// The pseudo-path for this file is `{CWD}/dsc.settings.json`. +pub static WORKSPACE_SETTINGS_FILE_PATH: LazyLock = LazyLock::new(|| { + Path::new( + &std::env::current_dir() + .expect("Couldn't retrieve the current working directory"), + ) + .join(SETTINGS_PREFERENCE_FILE_NAME) +}); + +/// Helper function for retrieving the platform-specific folder path for +/// machine-level settings files. +/// +/// Both the policy and machine scope settings files are located in the same +/// folder. This helper just reduces the duplication for determining the folder +/// path. +/// +/// # Returns +/// +/// A [`PathBuf`] representing the platform-specific folder path for +/// machine-level settings files. +fn machine_folder_path() -> PathBuf { + #[cfg(target_os = "windows")] + { + let program_data = std::env::var_os("ProgramData") + .expect("Couldn't retrieve the ProgramData environment variable"); + Path::new(&program_data).join("dsc") + } + #[cfg(target_os = "macos")] + { + Path::new("/Library").join("Application Support").join("dsc") + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + Path::new("/etc").join("dsc") + } +} diff --git a/lib/dsc-lib/src/settings/dsc_settings.rs b/lib/dsc-lib/src/settings/dsc_settings.rs new file mode 100644 index 000000000..0e9923720 --- /dev/null +++ b/lib/dsc-lib/src/settings/dsc_settings.rs @@ -0,0 +1,1614 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use schemars::JsonSchema; +use serde::Serialize; +use tracing::{debug, trace,warn}; + +#[cfg(not(test))] +use std::path::Path; + +#[cfg(test)] +use crate::tests::stubs::path::Path; + +use crate::{ + schemas::dsc_repo::{DscRepoSchema, schema_i18n}, + settings::{ + DscSettingsError, DscSettingsResolved, DscSettingsResolvedField, DscSettingsScope, + MACHINE_SETTINGS_FILE_PATH, POLICY_SETTINGS_FILE_PATH, USER_SETTINGS_FILE_PATH, WORKSPACE_SETTINGS_FILE_PATH, + sources::* + } +}; + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! The public API for [`DscSettings`] should rarely require updates, even when defining new + //! fields. Unless the newly defined field impacts settings resolution and loading _generally_, + //! it only needs to be addressed in the various `resolve_*` private methods. + //! + //! When a new field is defined, ensure that: + //! + //! 1. You update the [`resolve_policy()`] method to resolve the field using the `resolve_field` + //! macro defined in the method: + //! + //! - If the new field is a top-level leaf field, add a line like: + //! + //! ```rust, ignore + //! resolve_field!(new_field_name); + //! ``` + //! + //! - If the new field is a top-level container field, add a line like: + //! + //! ```rust, ignore + //! resolve_field!(container_name [first_leaf_field, second_leaf_field]); + //! ``` + //! + //! - If the new field is a leaf in an existing container, find the line for the container + //! and add the new leaf field to the list of fields to resolve. + //! + //! 1. If the field is represented in preference file data, update the [`resolve_preference()`] + //! method following the same instructions as for [`resolve_policy()`]. + //! 1. If the field is represented in environment variables, update the + //! [`resolve_environment()`] method to resolve the field. Follow the established pattern in + //! the method. + //! 1. If the field is represented in command line arguments, update the + //! [`resolve_command_line()`] method to resolve the field. Follow the established pattern + //! in the method. + //! + //! [`DscSettings`]: super::DscSettings + //! [`resolve_policy()`]: super::DscSettings::resolve_policy + //! [`resolve_preference()`]: super::DscSettings::resolve_preference + //! [`resolve_environment()`]: super::DscSettings::resolve_environment + //! [`resolve_command_line()`]: super::DscSettings::resolve_command_line +} + +/// Represents the complete set of DSC settings, including all sources and the +/// resolved effective settings. +/// +/// During initialization, DSC loads settings from various sources. The +/// following list defines the sources for settings in order of precedence, +/// from lowest to highest where later sources override earlier sources: +/// +/// - The hardcoded defaults defined in the source code. +/// - The machine preference settings file, if it exists. +/// - The user preference settings file, if it exists. +/// - The workspace preference settings file, if it exists. +/// - The environment variables, if they are set. +/// - The command line arguments, if they are provided. +/// - The policy settings file, if it exists. +/// +/// You can use the [`load()`] or [`try_load()`] methods to load settings from +/// all sources except for the command line, which must be manually provided +/// with the [`new_with_command_line()`] constructor. +/// +/// You can access the resolved effective settings by calling the [`resolved()`] +/// method, which returns an instance of [`DscSettingsResolved`] containing the +/// final values for each setting after considering all sources. +/// +/// For more information about DSC settings, refer to the +/// [module documentation](crate::settings). +/// +/// [`load()`]: Self::load +/// [`try_load()`]: Self::try_load +/// [`new_with_command_line()`]: Self::new_with_command_line +/// [`resolved()`]: Self::resolved +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema, DscRepoSchema)] +#[dsc_repo_schema(base_name = "all", folder_path = "settings")] +#[serde(rename_all = "camelCase")] +#[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = DscSettings::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription") + ) +)] +pub struct DscSettings { + /// The hardcoded default settings defined in the source code. + /// + /// These defaults have the lowest precedence and will be overridden by any + /// other settings source if a value is provided. + /// + /// The following snippet shows the effective code defaults as YAML data: + /// + /// ```yaml + /// forbid_ignore_settings_file: false + /// ignore_settings_file: false + /// tracing: + /// level: warn + /// format: default + /// resource_path: + /// directories: [] + /// append_env_path: true + /// restricted: false + /// ``` + #[schemars( + title = schema_i18n!("fields.default.title"), + description = schema_i18n!("fields.default.description"), + extend("markdownDescription" = schema_i18n!("fields.default.markdownDescription")) + )] + default: DscSettingsCodeDefaults, + + /// The settings loaded from the machine settings file, if it exists. + /// + /// This field is set to [`None`] if the machine settings file does not + /// exist or if it couldn't be loaded. + /// + /// The machine settings file has the lowest precedence for settings after + /// the code defaults. Any settings defined in a different scope will + /// override the values defined in the machine settings file. + /// + /// The location for the machine settings file depends on the operating + /// system: + /// + /// - On Windows: `%PROGRAMDATA%\dsc\dsc.settings.json`. + /// - On macOS: `/Library/Application Support/dsc/dsc.settings.json`. + /// - On Linux: `/etc/dsc/dsc.settings.json`. + /// + /// For more information about defining settings in a machine preference + /// file, see [`PreferenceFileData`]. + #[schemars( + title = schema_i18n!("fields.machine.title"), + description = schema_i18n!("fields.machine.description"), + extend("markdownDescription" = schema_i18n!("fields.machine.markdownDescription")) + )] + pub machine: Option, + + /// The settings loaded from the user settings file, if it exists. + /// + /// This field is set to [`None`] if the user settings file does not exist + /// or if it couldn't be loaded. + /// + /// The user settings file has higher precedence than the machine settings + /// file, but lower precedence than the workspace settings file, + /// environment variables, and command line arguments. Any settings defined + /// in those scopes will override the values defined in the user settings + /// file. + /// + /// The location for the user settings file depends on the operating system + /// and whether the `XDG_CONFIG_HOME` environment variable is set. + /// + /// - When the `XDG_CONFIG_HOME` environment variable is set, the user + /// settings file is located at `$XDG_CONFIG_HOME/dsc/dsc.settings.json`. + /// - When the `XDG_CONFIG_HOME` environment variable is not set, the + /// location depends on the operating system: + /// + /// - On Windows: `%APPDATA%\dsc\dsc.settings.json`. + /// - On macOS: `~/Library/Application Support/dsc/dsc.settings.json`. + /// - On Linux: `~/.config/dsc/dsc.settings.json`. + /// + /// For more information about defining settings in a user preference file, + /// see [`PreferenceFileData`]. + #[schemars( + title = schema_i18n!("fields.user.title"), + description = schema_i18n!("fields.user.description"), + extend("markdownDescription" = schema_i18n!("fields.user.markdownDescription")) + )] + pub user: Option, + + /// The settings loaded from the workspace settings file, if it exists. + /// + /// This field is set to [`None`] if the workspace settings file does not + /// exist or if it couldn't be loaded. + /// + /// The workspace settings file has higher precedence than the machine and + /// user settings files but lower precedence than environment variables and + /// command line arguments. Any settings defined in those scopes will + /// override the values defined in the workspace settings file. + /// + /// The location for the workspace settings file is always relative to the + /// current working directory: `{CWD}/dsc.settings.json`. + /// + /// For more information about defining settings in a workspace preference + /// file, see [`PreferenceFileData`]. + #[schemars( + title = schema_i18n!("fields.workspace.title"), + description = schema_i18n!("fields.workspace.description"), + extend("markdownDescription" = schema_i18n!("fields.workspace.markdownDescription")) + )] + pub workspace: Option, + + /// The settings loaded from the environment variables, if they exist. + /// + /// This field is set to [`None`] if none of the relevant environment + /// variables are set. DSC uses the following environment variables for + /// settings: + /// + /// - `DSC_TRACE_LEVEL`: Defines the trace level to use. + /// - `DSC_TRACE_FORMAT`: Defines the trace format to use. + /// - `DSC_RESOURCE_PATH`: Defines the resource directories to use. + /// - `DSC_RESTRICTED_PATH`: Defines the resource directories to use and + /// restricts all DSC invocations to those directories exclusively. + /// - `DSC_IGNORE_SETTINGS_FILE`: Defines whether to ignore settings files. + /// + /// The environment variables have higher precedence than the machine, + /// user, and workspace settings files but lower precedence than command + /// line arguments. Any settings defined in the command line arguments will + /// override the values defined in the environment variables. + /// + /// For more information about how environment variables affect DSC + /// settings, see [`EnvironmentData`]. + #[schemars( + title = schema_i18n!("fields.environment.title"), + description = schema_i18n!("fields.environment.description"), + extend("markdownDescription" = schema_i18n!("fields.environment.markdownDescription")) + )] + pub environment: Option, + + /// The settings loaded from the command line arguments, if they were + /// specified. + /// + /// This field is set to [`None`] if no command line arguments were + /// provided relating to DSC settings. DSC uses the following command line + /// arguments for settings: + /// + /// - `--trace-level`: Defines the trace level to use. + /// - `--trace-format`: Defines the trace format to use. + /// - `--ignore-settings-file`: Defines whether to ignore settings files. + /// + /// The command line arguments have the highest precedence for settings, + /// overriding any values defined in the machine, user, and workspace + /// settings files, as well as any values defined in the environment + /// variables. Only policy settings have higher precedence than command + /// line arguments, and they cannot be overridden. + /// + /// For more information about how command line arguments affect DSC + /// settings, see [`CommandLineData`]. + #[serde(rename = "cli")] + #[schemars( + title = schema_i18n!("fields.command_line.title"), + description = schema_i18n!("fields.command_line.description"), + extend("markdownDescription" = schema_i18n!("fields.command_line.markdownDescription")) + )] + pub command_line: Option, + + /// The settings loaded from the policy settings file, if it exists. + /// + /// This field is set to [`None`] if the policy settings file doesn't exist + /// or if it couldn't be loaded. + /// + /// The policy settings file has the highest precedence for settings, + /// overriding any values defined in other sources, including command line + /// arguments. Policy settings cannot be overridden. + /// + /// For more information about defining policy settings, see + /// [`PolicyFileData`]. + #[schemars( + title = schema_i18n!("fields.policy.title"), + description = schema_i18n!("fields.policy.description"), + extend("markdownDescription" = schema_i18n!("fields.policy.markdownDescription")) + )] + pub policy: Option, + + /// The resolved settings after considering all sources and their precedence. + /// + /// This field is set to [`None`] if the resolved settings have not been + /// computed yet. It represents the final effective settings that DSC will + /// use during its operation. + #[schemars( + title = schema_i18n!("fields.resolved.title"), + description = schema_i18n!("fields.resolved.description"), + extend("markdownDescription" = schema_i18n!("fields.resolved.markdownDescription")) + )] + resolved: Option, +} + +/// Public API for interacting with [`DscSettings`] +impl DscSettings { + /// Creates a new instance of `DscSettings` with all fields except `default` + /// initialized to `None`. + /// + /// The `default` field is initialized with the hardcoded defaults defined + /// in [`DSC_SETTINGS_CODE_DEFAULTS`]. + /// + /// # Returns + /// + /// A new instance of `DscSettings` with only the default settings loaded. + pub fn new() -> Self { + Self { + default: DSC_SETTINGS_CODE_DEFAULTS, + machine: None, + user: None, + workspace: None, + environment: None, + command_line: None, + policy: None, + resolved: None, + } + } + + /// Creates a new instance of `DscSettings` and loads the provided command + /// line data into it. + /// + /// The only fields populated in the returned instance are the default + /// settings and [`command_line`]. All other fields are defined as `None`. + /// + /// # Arguments + /// + /// - `cli_data`: The command line data to load into the new instance. + /// + /// [`command_line`]: Self::command_line + pub fn new_with_command_line(cli_data: CommandLineData) -> Self { + let mut settings = Self::new(); + settings.command_line = Some(cli_data); + + settings + } + + /// Returns the resolved settings from all loaded sources. + /// + /// This method acts as a getter for the resolved settings, lazily + /// computing the resolution if it hasn't been computed yet. + /// + /// This method _only_ works from currently loaded settings. You must use + /// the [`load()`] or [`try_load()`] methods to populate the settings from + /// external sources before calling this method. + /// + /// # Returns + /// + /// Returns a reference to the resolved settings. + /// + /// # Example + /// + /// ```rust, ignore + /// let mut settings = DscSettings::new(); + /// settings.load(); + /// let resolved = settings.resolved(); + /// ``` + /// + /// [`load()`]: Self::load + /// [`try_load()`]: Self::try_load + pub fn resolved(&mut self) -> &DscSettingsResolved { + if self.resolved.is_none() { + self.resolve_all(); + } + + self.resolved.as_ref().unwrap() + } + + /// Indicates whether the policy settings forbid ignoring settings files. + /// + /// # Returns + /// + /// This method returns `true` if the policy settings forbid ignoring + /// settings files, and `false` otherwise. If the policy settings haven't + /// been loaded, this method returns `false`. + pub fn policy_forbids_ignoring_settings_files(&self) -> bool { + self.policy.as_ref().is_some_and(|p| { + p.forbid_ignore_settings_file.is_some_and(|v| v == true) + }) + } + + /// Indicates whether settings files should be ignored based on the current + /// settings sources. + /// + /// # Returns + /// + /// This return value for this method depends on the loaded policy, + /// environment, and command line settings. The precedence rules are as + /// follows: + /// + /// 1. If `policy.forbid_ignore_settings_file` is set to `true`, this + /// method always returns `false`, regardless of the other sources. + /// 1. If `command_line.ignore_settings_file` is set, this method returns + /// its value. + /// 1. If `environment.dsc_ignore_settings_file` is set, this method + /// returns its value. + /// 1. If none of the above conditions are met, this method returns `false`. + pub fn ignoring_settings_files(&self) -> bool { + // If the policy forbids ignoring settings files, always return false. + if self.policy_forbids_ignoring_settings_files() { + return false; + } + if let Some(policy_ignore) = self.policy.as_ref() + .and_then(|p| p.ignore_settings_file) { + return *policy_ignore; + } + if let Some(cli_ignore) = self.command_line.as_ref() + .and_then(|cli| cli.ignore_settings_file) { + return *cli_ignore; + } + if let Some(env_ignore) = self.environment.as_ref() + .and_then(|env| env.dsc_ignore_settings_file) { + return *env_ignore; + } + + false + } + + /// Checks whether the policy folder has secure permissions. + /// + /// DSC requires that the policy folder has secure permissions to prevent + /// unauthorized access or modifications. If the folder doesn't have secure + /// permissions, DSC won't load the policy file. + /// + /// This function checks the permissions for the folder depending on the + /// platform: + /// + /// - On Windows: + /// + /// 1. The folder must be owned by the Administrators group or the Local + /// System account. + /// 1. The write permissions for the folder must be limited to the owner. + /// 1. No inheritedd ACEs (Access Control Entries) should grant write + /// permissions to other users. + /// + /// - On Linux and macOS: + /// + /// 1. The folder must be owned by the root user. + /// 1. The folder must not have group or other write permissions. + /// + /// # Returns + /// + /// If the policy folder has secure permissions, this function returns + /// `Ok(())`. If the folder doesn't have secure permissions, the function + /// returns an `Err` with [`DscSettingsError::PolicyFolderNotSecure`]. + pub fn validate_policy_folder_security() -> Result<(), DscSettingsError> { + let folder_path = POLICY_SETTINGS_FILE_PATH.as_path().parent() + .expect("policy settings file always has a parent folder"); + + // The folder not existing is fine, since we won't try to load the + // policy file. + if !folder_path.exists() { + return Ok(()); + } + + if !Self::policy_folder_has_secure_permissions(folder_path) { + #[cfg(target_os = "windows")] + let required = t!( + "settings.dsc_settings.policyFolderNotSecureWindows", + path = folder_path.display() + ); + #[cfg(not(target_os = "windows"))] + let required = t!( + "settings.dsc_settings.policyFolderNotSecureLinux", + path = folder_path.display() + ); + + return Err(DscSettingsError::PolicyFolderNotSecure { + path: folder_path.display().to_string(), + required: required.to_string(), + }); + } + + Ok(()) + } + + /// Resolves the effective settings by applying the precedence rules to all + /// loaded sources. + /// + /// The resolution steps are: + /// + /// 1. Initialize with default settings from source code. + /// 1. If policy file settings are loaded, apply them next, as they have + /// the highest precedence. + /// 1. Apply settings file sources in order of precedence ([`Machine`], + /// then [`User`], then [`Workspace`]). + /// 1. If environment settings are loaded, apply them next, overriding any + /// non-policy settings. + /// 1. If command line settings are loaded, apply them last, overriding any + /// non-policy settings. + /// + /// Resolution is performed for every setting, _not_ by container. If the + /// machine settings file defines both `tracing.level` and `tracing.format`, + /// and the user settings file defines only `tracing.format`, the final + /// resolved settings will have `tracing.level` from the machine settings + /// file and `tracing.format` from the user settings file. + /// + /// Settings must be explicitly overridden by a higher-precedence source to + /// be applied. DSC doesn't support effectively "undefining" a setting in + /// a higher precedence source. + /// + /// [`Machine`]: DscSettingsScope::Machine + /// [`User`]: DscSettingsScope::User + /// [`Workspace`]: DscSettingsScope::Workspace + /// [`Environment`]: DscSettingsScope::Environment + /// [`Cli`]: DscSettingsScope::CommandLine + /// [`Policy`]: DscSettingsScope::Policy + pub fn resolve_all(&mut self) { + debug!("{}", t!("settings.dsc_settings.debugResolving")); + let mut resolving = DscSettingsResolved::default(); + + macro_rules! emit_resolving_msg { + ($scope:expr) => { + debug!("{}", t!("settings.dsc_settings.debugResolvingScope", scope = $scope)); + }; + } + macro_rules! emit_skipping_msg { + ($scope:expr) => { + debug!("{}", t!("settings.dsc_settings.debugSkippingScope", scope = $scope)); + }; + } + macro_rules! emit_resolved_msgs { + ($scope:expr) => { + debug!("{}", t!("settings.dsc_settings.debugResolvedScope", scope = $scope)); + trace!("{}", t!( + "settings.dsc_settings.traceResolvedScope", + scope = $scope, + effective = serde_json::to_string(&resolving).unwrap() + )); + }; + } + + // First, resolve policy settings, as they have the highest precedence. + if self.scope_is_loaded(DscSettingsScope::Policy) { + emit_resolving_msg!("policy"); + self.resolve_policy(&mut resolving); + emit_resolved_msgs!("policy"); + } else { + emit_skipping_msg!("policy"); + } + + // Only resolve file-based settings if ignoring settings files is not forbidden by policy + // and not specified in the policy, command line, or environment. + if !self.ignoring_settings_files() { + // Resolve file-based settings in order of precedence (Machine, User, Workspace). + for source in DscSettingsScope::FOR_PREFERENCE_FILES.iter().cloned() { + if self.scope_is_loaded(source) { + emit_resolving_msg!(source.to_string()); + self.resolve_preference(source, &mut resolving); + emit_resolved_msgs!(source.to_string()); + } else { + emit_skipping_msg!(source.to_string()); + } + } + } + // Resolve environment settings, which have higher precedence than file-based settings. + if self.scope_is_loaded(DscSettingsScope::Environment) { + emit_resolving_msg!("environment"); + self.resolve_environment(&mut resolving); + emit_resolved_msgs!("environment"); + } else { + emit_skipping_msg!("environment"); + } + // Finally, resolve command line settings, which have the highest precedence (except for policy). + if self.scope_is_loaded(DscSettingsScope::CommandLine) { + emit_resolving_msg!("cli"); + self.resolve_command_line(&mut resolving); + emit_resolved_msgs!("cli"); + } else { + emit_skipping_msg!("cli"); + } + + debug!("{}", t!("settings.dsc_settings.debugResolved")); + trace!("{}", t!( + "settings.dsc_settings.traceResolved", + effective = serde_json::to_string(&resolving).unwrap() + )); + self.resolved = Some(resolving); + } + + /// Attempts to load settings from all non-CLI sources and converts loading + /// errors into warnings. + /// + /// When loading a source raises an error, this method logs the error as a + /// warning and continues loading the remaining sources. Only sources that + /// are successfully loaded are stored in the instance. All other sources + /// remain set to [`None`]. + pub fn load(&mut self) { + // Always load policy first. + if let Err(e) = self.load_policy() { + warn!("{}", t!( + "settings.dsc_settings.warnLoadScopeFailed", + scope = "policy", + error = e + )); + } + // Load environment so we can check whether to load preferences. + self.load_environment(); + + // If ignoring settings files, emit debug and return early + if self.ignoring_settings_files() { + self.emit_debug_load_ignoring_settings_files(); + // Clear all loaded preference file data + self.machine = None; + self.user = None; + self.workspace = None; + return; + } + + if let Err(e) = self.load_machine() { + warn!("{}", t!( + "settings.dsc_settings.warnLoadScopeFailed", + scope = "machine", + error = e + )); + } + if let Err(e) = self.load_user() { + warn!("{}", t!( + "settings.dsc_settings.warnLoadScopeFailed", + scope = "user", + error = e + )); + } + if let Err(e) = self.load_workspace() { + warn!("{}", t!( + "settings.dsc_settings.warnLoadScopeFailed", + scope = "workspace", + error = e + )); + } + } + + /// Attempts to load settings from all non-CLI sources and collects any + /// errors when loading each source. + /// + /// To load settings and ignore sources with errors, use the [`load()`] + /// method instead. + /// + /// # Errors + /// + /// If loading any source raises an error, this method returns an instance + /// of [`LoadMultipleErrors`] containing a vector of all errors encountered. + /// + /// [`load()`]: Self::load + /// [`LoadMultipleErrors`]: DscSettingsError::LoadMultipleErrors + pub fn try_load(&mut self) -> Result<(), DscSettingsError> { + let mut errors : Vec = Vec::new(); + if let Err(e) = self.load_policy() { + errors.push(e); + } + + // Load environment first to determine whether to load preferences + if let Err(e) = self.try_load_environment() { + errors.push(e); + } + + if self.ignoring_settings_files() { + // If ignoring settings files, we only emit debug information and + // don't attempt to load preference files. + self.emit_debug_load_ignoring_settings_files(); + // Clear all loaded preference file data + self.machine = None; + self.user = None; + self.workspace = None; + } else { + if let Err(e) = self.load_machine() { + errors.push(e); + } + if let Err(e) = self.load_user() { + errors.push(e); + } + if let Err(e) = self.load_workspace() { + errors.push(e); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(DscSettingsError::LoadMultipleErrors{errors}) + } + } +} + +#[macro_use] +pub(super) mod macros { + //! Defines convenience macros for resolving settings. + + /// Helper to define the `resolve_field` macro for preference file data + /// with resolved hygeine. + /// + /// This macro defines the `resolve_field` macro with the given resolving + /// context and file data, ensuring that the macro works correctly with + /// macro hygiene. + /// + /// Without this helper, using the `resolve_field` macro would require + /// passing the `resolving` and `policy` variables to every invocation. + macro_rules! define_preference_resolver { + ($resolving:ident, $file_data:ident, $source:ident, $dollar:tt) => { + /// Helper for resolving fields for preference file data. + /// + /// This macro is a convenience for performing the following + /// operations: + /// + /// 1. Checking whether a given container field is defined in the + /// data. + /// 1. Checking each nested field within the container field to see + /// if it is defined. + /// 1. If the nested field is defined, compare the scope of the + /// previously resolved field with the current source. + /// 1. If the current source has higher precedence than the + /// previously resolved field, update the resolved field with + /// the new value and scope. + /// + /// # Syntax + /// + /// ## Leaf field + /// + /// ```rust, ignore + /// resolve_field!(); + /// ``` + /// + /// - `` must be the name of a top-level field in the + /// file data. + /// + /// ## Container field + /// + /// ```rust, ignore + /// resolve_field!( => [, ...]); + /// ``` + /// + /// - `container_field` must be the name of a field in the file + /// data that contains the subfields listed in the brackets. + /// - `nested_field` must be the name of a subfield within the + /// container field. Define every nested field that you want to + /// resolve. + /// + /// # Example + /// + /// The following macro invocation demonstrates how to resolve the + /// `tracing` container field with its nested fields `level` and + /// `format`: + /// + /// ```rust, ignore + /// resolve_field!(tracing => [level, format]); + /// ``` + /// + /// It expands to the following code: + /// + /// ```rust, ignore + /// if let Some(tracing) = &file_data.tracing { + /// if let Some(level) = tracing.level.as_ref() { + /// if resolving.tracing.level.scope < source { + /// resolving.tracing.level = DscSettingsResolvedField::new( + /// level.clone(), + /// source + /// ); + /// } + /// } + /// if let Some(format) = tracing.format.as_ref() { + /// if resolving.tracing.format.scope < source { + /// resolving.tracing.format = DscSettingsResolvedField::new( + /// format.clone(), + /// source + /// ); + /// } + /// } + /// } + /// ``` + /// + /// # Note + /// + /// This macro doesn't currently support deeply resolving deeply + /// nested fields. + macro_rules! resolve_field { + ($field:ident) => { + if let Some($field) = $file_data.$field.as_ref() + && $resolving.$field.scope < $source { + $resolving.$field = DscSettingsResolvedField::new( + $field.clone(), + $source + ); + } + }; + ($container_field:ident => [$dollar($field:ident),*]) => { + if let Some($container_field) = &$file_data.$container_field { + $dollar( + if let Some($field) = $container_field.$field.as_ref() + && $resolving.$container_field.$field.scope < $source { + $resolving.$container_field.$field = DscSettingsResolvedField::new( + $field.clone(), + $source + ); + } + )* + } + }; + } + }; + } + pub(super) use define_preference_resolver; + + /// Helper to define the `resolve_field` macro for policy file data with + /// resolved hygiene. + /// + /// This macro defines the `resolve_field` macro with the given resolving + /// context and file data, ensuring that the macro works correctly with + /// macro hygiene. + /// + /// Without this helper, using the `resolve_field` macro would require + /// passing the `resolving` and `policy` variables to every invocation. + macro_rules! define_policy_resolver { + ($resolving:ident, $file_data:ident, $dollar:tt) => { + /// Helper for resolving policy fields. + /// + /// This macro is a convenience for performing the following + /// operations: + /// + /// 1. Checking whether a given field is defined in the data. + /// 1. If the field is a leaf field, override the previously + /// resolved value with the new value from policy. + /// 1. If the field is a container field, check each nested field + /// within the container field to see if it is defined. + /// 1. If the nested field is defined, update the resolved field + /// with the new value from policy. + /// + /// # Syntax + /// + /// ## Leaf field + /// + /// ```rust, ignore + /// resolve_field!(); + /// ``` + /// + /// - `` must be the name of a top-level field in the + /// file data. + /// + /// ## Container field + /// + /// ```rust, ignore + /// resolve_field!( => [, ...]); + /// ``` + /// + /// - `container_field` must be the name of a field in the file + /// data that contains the subfields listed in the brackets. + /// - `nested_field` must be the name of a subfield within the + /// container field. Define every nested field that you want to + /// resolve. + /// + /// # Example + /// + /// The following macro invocation demonstrates how to resolve the + /// `tracing` container field with its nested fields `level` and + /// `format`: + /// + /// ```rust, ignore + /// resolve_field!(tracing => [level, format]); + /// ``` + /// + /// It expands to the following code: + /// + /// ```rust, ignore + /// if let Some(tracing) = &file_data.tracing { + /// if let Some(level) = tracing.level.as_ref() { + /// resolving.tracing.level = DscSettingsResolvedField::new( + /// level.clone(), + /// source + /// ); + /// } + /// if let Some(format) = tracing.format.as_ref() { + /// resolving.tracing.format = DscSettingsResolvedField::new( + /// format.clone(), + /// source + /// ); + /// } + /// } + /// ``` + /// + /// # Note + /// + /// This macro doesn't currently support deeply resolving deeply + /// nested fields. + macro_rules! resolve_field { + ($field:ident) => { + if let Some($field) = $file_data.$field.as_ref() { + $resolving.$field = DscSettingsResolvedField::for_policy($field.clone()); + } + }; + ($container_field:ident => [$dollar($field:ident),+]) => { + if let Some($container_field) = &$file_data.$container_field { + $dollar( + if let Some($field) = $container_field.$field.as_ref() { + $resolving.$container_field.$field = DscSettingsResolvedField::for_policy( + $field.clone() + ); + } + )+ + } + }; + } + }; + } + pub(super) use define_policy_resolver; +} + +/// Private API for [`DscSettings`] +impl DscSettings { + /// Indicates whether a setting scope has been loaded with any data. + /// + /// After loading settings from all sources, it may be the case that one + /// or more scopes loaded without errors but no fields in the data for that + /// scope were defined. + /// + /// This method provides a short way to check if any data has been loaded + /// for a given scope before invoking the resolution logic. + /// + /// # Arguments + /// + /// * `scope` - The setting scope to check for loaded data. + /// + /// # Returns + /// + /// `true` if the specified setting scope has been loaded with any data, + /// otherwise `false`. + pub(crate) fn scope_is_loaded(&self, scope: DscSettingsScope) -> bool { + match scope { + DscSettingsScope::Default => true, + DscSettingsScope::Machine => self.machine.as_ref().is_some_and(|data| data.any_defined()), + DscSettingsScope::User => self.user.as_ref().is_some_and(|data| data.any_defined()), + DscSettingsScope::Workspace => self.workspace.as_ref().is_some_and(|data| data.any_defined()), + DscSettingsScope::Environment => self.environment.as_ref().is_some_and(|data| data.any_defined()), + DscSettingsScope::CommandLine => self.command_line.as_ref().is_some_and(|data| data.any_defined()), + DscSettingsScope::Policy => self.policy.as_ref().is_some_and(|data| data.any_defined()), + } + } + + /// Emits a debug message when skipping loading preference file settings. + /// + /// This convenience method is only intended for use in the [`load()`] and + /// [`try_load()`] methods. + /// + /// [`load()`]: Self::load + /// [`try_load()`]: Self::try_load + fn emit_debug_load_ignoring_settings_files(&self) { + macro_rules! check_ignore { + ($source:ident, $field:ident) => { + self.$source.as_ref() + .is_some_and(|d| d.$field.is_some_and(|v| v == true)) + }; + } + + let scope = if check_ignore!(policy, ignore_settings_file) { + DscSettingsScope::Policy + } else if check_ignore!(environment, dsc_ignore_settings_file) { + DscSettingsScope::Environment + } else { + DscSettingsScope::CommandLine + }; + let setting = match scope { + DscSettingsScope::Policy => "`ignoreSettingsFile`", + DscSettingsScope::Environment => "`DSC_IGNORE_SETTINGS_FILE`", + DscSettingsScope::CommandLine => "`--ignore-settings-file`", + _ => unreachable!(), + }; + debug!("{}", t!( + "settings.dsc_settings.debugLoadIgnoringSettingsFiles", + skipping_scopes = "`machine`, `user`, `workspace`", + scope = scope, + setting = setting + )); + } + + /// Loads the environment variables into the instance, ignoring invalidly + /// defined variables. + /// + /// This method loads the environment variables into the instance if any + /// are validly defined. When trying to load an environment variable with + /// an invalid value, this method emits warning messages. + /// + /// To capture errors when loading the environment instead of ignoring them, + /// use the [`try_load_environment()`] method instead. + /// + /// [`try_load_environment()`]: Self::try_load_environment + fn load_environment(&mut self) { + debug!("{}", t!("settings.dsc_settings.debugLoadEnvStart")); + let data = EnvironmentData::from_env(); + if data.any_defined() { + debug!("{}", t!("settings.dsc_settings.debugLoadEnvSuccess")); + self.environment = Some(data); + } else { + debug!("{}", t!("settings.dsc_settings.debugLoadEnvNoneDefined")); + } + } + + /// Attempts to load the environment settings data into the instance. + /// + /// This method loads the environment variables into the instance if every + /// defined variable has a valid value. This method collects the errors + /// for invalidly defined variables and returns them together for a more + /// comprehensive error report. + /// + /// To raise warnings for invalid variables when loading the environment + /// instead of raising an error, use the [`load_environment()`] method + /// instead. + /// + /// # Errors + /// + /// If the environment settings cannot be loaded from the environment + /// variables, this method raises the [`LoadEnvironmentMultipleErrors`] + /// error, which contains every error for invalidly defined environment + /// variables. + /// + /// Undefined environment variables don't raise an error. + /// + /// [`LoadEnvironmentMultipleErrors`]: DscSettingsError::LoadEnvironmentMultipleErrors + /// [`load_environment()`]: Self::load_environment + fn try_load_environment(&mut self) -> Result<(), DscSettingsError> { + debug!("{}", t!("settings.dsc_settings.debugLoadEnvStart")); + let data = EnvironmentData::try_from_env()?; + if data.any_defined() { + debug!("{}", t!("settings.dsc_settings.debugLoadEnvSuccess")); + self.environment = Some(data); + } else { + debug!("{}", t!("settings.dsc_settings.debugLoadEnvNoneDefined")); + } + Ok(()) + } + + /// Attempts to load the machine settings file into the instance + /// + /// If the machine settings file doesn't exist, this method does nothing + /// and returns `Ok(())`. If the file exists and is validly defined, this + /// method loads the settings into the instance. + /// + /// # Errors + /// + /// If the file exists with invalid data, this method returns an error + /// indicating the failure to load the machine settings file. + fn load_machine(&mut self) -> Result<(), DscSettingsError> { + let machine_settings_path = MACHINE_SETTINGS_FILE_PATH.as_path(); + debug!("{}", t!( + "settings.dsc_settings.debugLoadPreferenceStart", + scope = "machine", + path = machine_settings_path.to_string_lossy() + )); + if machine_settings_path.exists() { + let data = PreferenceFileData::from_file(machine_settings_path)?; + debug!("{}", t!( + "settings.dsc_settings.debugLoadPreferenceSuccess", + scope = "machine", + path = machine_settings_path.to_string_lossy() + )); + self.machine = Some(data); + } else { + debug!("{}", t!( + "settings.dsc_settings.debugLoadPreferenceFileNotFound", + scope = "machine", + path = machine_settings_path.to_string_lossy() + )); + } + + Ok(()) + } + + /// Attempts to load the user settings file into the instance + /// + /// If the user settings file doesn't exist, this method does nothing and + /// returns `Ok(())`. If the file exists and is validly defined, this + /// method loads the settings into the instance. + /// + /// # Errors + /// + /// If the file exists with invalid data, this method returns an error + /// indicating the failure to load the user settings file. + fn load_user(&mut self) -> Result<(), DscSettingsError> { + let user_settings_path = match USER_SETTINGS_FILE_PATH.as_ref() { + Some(path) => path.as_path(), + None => { + debug!("{}", t!("settings.dsc_settings.debugLoadUserForNonUserAccount")); + return Ok(()); + } + }; + debug!("{}", t!( + "settings.dsc_settings.debugLoadPreferenceStart", + scope = "user", + path = user_settings_path.to_string_lossy() + )); + if user_settings_path.exists() { + let data = PreferenceFileData::from_file(user_settings_path)?; + debug!("{}", t!( + "settings.dsc_settings.debugLoadPreferenceSuccess", + scope = "user", + path = user_settings_path.to_string_lossy() + )); + self.user = Some(data); + } else { + debug!("{}", t!( + "settings.dsc_settings.debugLoadPreferenceFileNotFound", + scope = "user", + path = user_settings_path.to_string_lossy() + )); + } + + Ok(()) + } + + /// Attempts to load the workspace settings file into the instance + /// + /// If the workspace settings file doesn't exist, this method does nothing + /// and returns `Ok(())`. If the file exists and is validly defined, this + /// method loads the settings into the instance. + /// + /// # Errors + /// + /// If the file exists with invalid data, this method returns an error + /// indicating the failure to load the workspace settings file. + fn load_workspace(&mut self) -> Result<(), DscSettingsError> { + let workspace_settings_path = WORKSPACE_SETTINGS_FILE_PATH.as_path(); + debug!("{}", t!( + "settings.dsc_settings.debugLoadPreferenceStart", + scope = "workspace", + path = workspace_settings_path.to_string_lossy() + )); + if workspace_settings_path.exists() { + let data = PreferenceFileData::from_file(workspace_settings_path)?; + debug!("{}", t!( + "settings.dsc_settings.debugLoadPreferenceSuccess", + scope = "workspace", + path = workspace_settings_path.to_string_lossy() + )); + self.workspace = Some(data); + } else { + debug!("{}", t!( + "settings.dsc_settings.debugLoadPreferenceFileNotFound", + scope = "workspace", + path = workspace_settings_path.to_string_lossy() + )); + } + + Ok(()) + } + + /// Attempts to load the policy settings file into the instance + /// + /// If the policy settings file doesn't exist, this method does nothing and + /// returns `Ok(())`. If the file exists and is validly defined, this + /// method loads the settings into the instance. + /// + /// # Errors + /// + /// If the file exists with invalid data, this method returns an error + /// indicating the failure to load the policy settings file. + fn load_policy(&mut self) -> Result<(), DscSettingsError> { + let policy_settings_path = POLICY_SETTINGS_FILE_PATH.as_path(); + + debug!("{}", t!( + "settings.dsc_settings.debugLoadPolicyStart", + path = policy_settings_path.to_string_lossy() + )); + if policy_settings_path.exists() { + Self::validate_policy_folder_security()?; + + let data = PolicyFileData::from_file(policy_settings_path)?; + debug!("{}", t!( + "settings.dsc_settings.debugLoadPolicySuccess", + path = policy_settings_path.to_string_lossy() + )); + self.policy = Some(data); + } else { + debug!("{}", t!( + "settings.dsc_settings.debugLoadPolicyFileNotFound", + path = policy_settings_path.to_string_lossy() + )); + } + + Ok(()) + } + + /// Applies the settings from the [`CommandLine`] scope to the resolved + /// settings. + /// + /// If the command line settings aren't loaded, this method returns + /// immediately without modifying the resolved settings. If the command + /// line settings are loaded, this method applies them to the resolved + /// settings, overriding any values from lower-precedence sources. + /// + /// # Arguments + /// + /// - `resolving` - A mutable reference to the instance of + /// [`DscSettingsResolved`] that represents the intermediate state of the + /// resolved settings. + /// + /// [`CommandLine`]: DscSettingsScope::CommandLine + fn resolve_command_line(&mut self, resolving: &mut DscSettingsResolved) { + let Some(cli_data) = self.command_line.as_ref() else { + return; + }; + let scope = DscSettingsScope::CommandLine; + + if let Some(level) = cli_data.trace_level.as_ref() + && resolving.tracing.level.scope < scope { + resolving.tracing.level = DscSettingsResolvedField::for_command_line(level.clone()); + } + + if let Some(format) = cli_data.trace_format.as_ref() + && resolving.tracing.format.scope < scope { + resolving.tracing.format = DscSettingsResolvedField::for_command_line(format.clone()); + } + + if let Some(ignore_settings_file) = cli_data.ignore_settings_file.as_ref() { + // check if this option is forbidden by policy + if self.policy_forbids_ignoring_settings_files() && **ignore_settings_file { + warn!("{}", t!( + "settings.dsc_settings.warnCliIgnoreSetting", + cli_arg = "--ignore-settings-file", + policy_field = "forbid_ignore_settings_file" + )); + } else { + if resolving.ignore_settings_file.scope < scope { + resolving.ignore_settings_file = DscSettingsResolvedField::for_command_line(*ignore_settings_file); + } + } + } + } + + /// Applies the settings from the [`Environment`] scope to the resolved + /// settings. + /// + /// If the environment settings aren't loaded, this method returns + /// immediately without modifying the resolved settings. If the environment + /// settings are loaded, this method applies them to the resolved settings, + /// overriding any values from lower-precedence sources. + /// + /// # Arguments + /// + /// - `resolving` - A mutable reference to the instance of + /// [`DscSettingsResolved`] that represents the intermediate state of the + /// resolved settings. + /// + /// [`Environment`]: DscSettingsScope::Environment + fn resolve_environment(&mut self, resolving: &mut DscSettingsResolved) { + let Some(env_data) = self.environment.as_ref() else { + return; + }; + let scope = DscSettingsScope::Environment; + + if let Some(level) = env_data.dsc_trace_level.as_ref() + && resolving.tracing.level.scope < scope { + resolving.tracing.level = DscSettingsResolvedField::for_environment(level.clone()); + } + + if let Some(format) = env_data.dsc_trace_format.as_ref() + && resolving.tracing.format.scope < scope { + resolving.tracing.format = DscSettingsResolvedField::for_environment(format.clone()); + } + + + if let Some(restricted_path) = env_data.dsc_restricted_path.as_ref() { + if resolving.resource_path.restricted.scope < scope && resolving.resource_path.directories.scope < scope { + let directories = restricted_path.clone(); + resolving.resource_path.directories = DscSettingsResolvedField::for_environment(directories); + resolving.resource_path.restricted = DscSettingsResolvedField::for_environment(true); + } + } else if let Some(resource_path) = env_data.dsc_resource_path.as_ref() + && resolving.resource_path.directories.scope < scope { + let directories = resource_path.clone(); + resolving.resource_path.directories = DscSettingsResolvedField::for_environment(directories); + } + + if let Some(ignore_settings_file) = env_data.dsc_ignore_settings_file.as_ref() { + // Only override the ignore_settings_file setting if it's not forbidden by policy + if self.policy_forbids_ignoring_settings_files() && **ignore_settings_file { + warn!("{}", t!( + "settings.dsc_settings.warnEnvIgnoreSetting", + env_var = "DSC_IGNORE_SETTINGS_FILE", + policy_field = "forbid_ignore_settings_file" + )); + } else { + if resolving.ignore_settings_file.scope < scope { + resolving.ignore_settings_file = DscSettingsResolvedField::for_environment(*ignore_settings_file); + } + } + } + } + + /// Resolves the settings from preference file sources and applies them to + /// the resolved settings. + /// + /// # Arguments + /// + /// - `source` - The source scope from which to resolve the settings. This + /// scope must be one of the file-based sources: [`Machine`], [`User`], + /// or [`Workspace`]. Specifying any other scope returns early from the + /// method without modifying the resolved settings or raising any errors. + /// - `resolving` - A mutable reference to the instance of + /// [`DscSettingsResolved`] that represents the intermediate state of the + /// resolved settings. + /// + /// [`Machine`]: DscSettingsScope::Machine + /// [`User`]: DscSettingsScope::User + /// [`Workspace`]: DscSettingsScope::Workspace + fn resolve_preference(&mut self, source: DscSettingsScope, resolving: &mut DscSettingsResolved) { + let file_data = match source { + DscSettingsScope::Machine => self.machine.as_ref(), + DscSettingsScope::User => self.user.as_ref(), + DscSettingsScope::Workspace => self.workspace.as_ref(), + _ => None, + }; + let Some(file_data) = file_data else { + return; + }; + + // Define the `resolve_field!` macro for the current preference file + // and resolving context. This is required to work around macro hygiene + // issues. + macros::define_preference_resolver!(resolving, file_data, source, $); + + resolve_field!(tracing => [level, format]); + resolve_field!(resource_path => [append_env_path, directories, restricted]); + } + + /// Resolves the policy settings and applies them to the resolved settings. + /// + /// If the [`Policy`] settings aren't loaded, this method returns + /// immediately without modifying the resolved settings. If the policy + /// settings are loaded, this method applies them to the resolved settings, + /// overriding any values from lower-precedence sources. + /// + /// # Arguments + /// + /// - `resolving` - A mutable reference to the instance of + /// [`DscSettingsResolved`] that represents the intermediate state of the + /// resolved settings. + /// + /// [`Policy`]: DscSettingsScope::Policy + pub(in crate::settings) fn resolve_policy(&mut self, resolving: &mut DscSettingsResolved) { + let Some(policy) = self.policy.as_ref() else { + return; + }; + // Define the policy resolver macro for the current policy and resolving + // context. This is required to work around macro hygiene issues. + macros::define_policy_resolver!(resolving, policy, $); + + // Resolve the `forbid_ignore_settings_file` setting first, since it + // affects resolution of `ignore_settings_file`. + resolve_field!(forbid_ignore_settings_file); + + // Need to handle `ignore_settings_file` manually because it has a + // dependency on `forbid_ignore_settings_file` and some complex + // interactions that can't be generically modeled with the macro. + if let Some(ignore) = policy.ignore_settings_file { + if let Some(forbid_ignore) = policy.forbid_ignore_settings_file { + // If the settings are incompatible, prefer forbidding and + // don't ignore the settings files. + if *forbid_ignore && *ignore{ + warn!("{}", t!( + "settings.dsc_settings.warnPolicyIgnoreSetting", + ignore_settings_file = "ignore_settings_file", + forbid_ignore_settings_file = "forbid_ignore_settings_file" + )); + } else { + resolving.ignore_settings_file = DscSettingsResolvedField::for_policy(ignore); + } + } else { + resolving.ignore_settings_file = DscSettingsResolvedField::for_policy(ignore); + } + } + + // Resolve remaining fields as normal. + resolve_field!(tracing => [level, format]); + resolve_field!(resource_path => [append_env_path, directories, restricted]); + } + + /// Indicates whether the folder containing the DSC policy settings file has + /// secure permissions. + /// + /// This function verifies the following ACL rules: + /// + /// 1. The folder is owned by the Administrators group or the Local System + /// account. + /// 1. Only the owner has write permissions. + /// 1. No inherited ACEs grant write permissions to other users. + /// + /// # Arguments + /// + /// - `folder_path`: The path to the folder containing the DSC policy + /// settings file. + /// + /// # Returns + /// + /// `true` if the folder has secure permissions according to the rules and + /// `false` otherwise. + #[cfg(all(target_os = "windows", not(test)))] + fn policy_folder_has_secure_permissions(folder_path: &Path) -> bool { + use windows::Win32::Security::{ + Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}, + IsWellKnownSid, GetAce, + WinBuiltinAdministratorsSid, WinLocalSystemSid, + ACL, ACCESS_ALLOWED_ACE, PSECURITY_DESCRIPTOR, + }; + use windows::Win32::Foundation::{LocalFree, HLOCAL}; + use windows::core::PCWSTR; + use std::ptr; + + const FILE_WRITE_DATA: u32 = 0x0002; + const FILE_APPEND_DATA: u32 = 0x0004; + const WRITE_DAC: u32 = 0x0004_0000; + const WRITE_OWNER: u32 = 0x0008_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + const GENERIC_ALL: u32 = 0x1000_0000; + const WRITE_MASK: u32 = FILE_WRITE_DATA | FILE_APPEND_DATA | WRITE_DAC | WRITE_OWNER | GENERIC_WRITE | GENERIC_ALL; + + const INHERIT_ONLY_ACE: u8 = 0x08; + + let folder_wide: Vec = folder_path + .to_string_lossy() + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + + let mut p_sd: PSECURITY_DESCRIPTOR = PSECURITY_DESCRIPTOR(ptr::null_mut()); + let mut p_dacl: *mut ACL = ptr::null_mut(); + + let result = unsafe { + GetNamedSecurityInfoW( + PCWSTR(folder_wide.as_ptr()), + SE_FILE_OBJECT, + windows::Win32::Security::DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut p_dacl), + None, + &mut p_sd, + ) + }; + + // Fail closed: if we can't read the security descriptor, treat as insecure + if result.is_err() { + return false; + } + + // A NULL DACL means full access to everyone + if p_dacl.is_null() { + unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } + return false; + } + + let ace_count = unsafe { (*p_dacl).AceCount }; + + for i in 0..ace_count { + let mut ace_ptr: *mut core::ffi::c_void = ptr::null_mut(); + let ok = unsafe { GetAce(p_dacl, i as u32, &mut ace_ptr) }; + if ok.is_err() { + // Fail closed: if we can't read an ACE, treat as insecure + unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } + return false; + } + + let header = unsafe { &*(ace_ptr as *const windows::Win32::Security::ACE_HEADER) }; + + // Skip inherit-only ACEs as they don't apply to this folder + if (header.AceFlags & INHERIT_ONLY_ACE) != 0 { + continue; + } + + // Skip deny ACEs (types 1, 6, 10) - they restrict access, not grant it + if header.AceType == 1 || header.AceType == 6 || header.AceType == 10 { + continue; + } + + // For ACCESS_ALLOWED_ACE_TYPE (0), check the SID + if header.AceType == 0 { + let ace = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) }; + if (ace.Mask & WRITE_MASK) == 0 { + continue; + } + + let sid_ptr = &ace.SidStart as *const u32 as *const core::ffi::c_void; + let psid = windows::Win32::Security::PSID(sid_ptr as *mut core::ffi::c_void); + + let is_system = unsafe { IsWellKnownSid(psid, WinLocalSystemSid).as_bool() }; + let is_admin = unsafe { IsWellKnownSid(psid, WinBuiltinAdministratorsSid).as_bool() }; + + if !is_system && !is_admin { + unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } + return false; + } + continue; + } + + // For any other allow-type ACE (callback, object, etc.) we cannot + // reliably extract the SID, so fail closed to be safe + let ace = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) }; + if (ace.Mask & WRITE_MASK) != 0 { + unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } + return false; + } + } + + unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } + true + } + + /// Indicates whether the folder containing the DSC policy settings file has + /// secure permissions. + /// + /// This function verifies the following permissions per Unix conventions: + /// + /// - The folder must be owned by the root user. + /// - The folder must not have group or other write permissions. + /// + /// # Arguments + /// + /// - `folder_path`: The path to the folder containing the DSC policy + /// settings file. + /// + /// # Returns + /// + /// `true` if the folder has secure permissions according to the rules and + /// `false` otherwise. + #[cfg(all(not(target_os = "windows"), not(test)))] + fn policy_folder_has_secure_permissions(folder_path: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + + // Fail closed: if we can't read metadata, treat as insecure + let Ok(metadata) = std::os::unix::fs::metadata(folder_path) else { + return false; + }; + + let mode = metadata.mode(); + let uid = metadata.uid(); + + // Verify owner is root and no group or other write bits are set + uid == 0 && (mode & 0o020) == 0 && (mode & 0o002) == 0 + } + + /// Indicates whether the folder containing the DSC policy settings file has + /// secure permissions. + #[cfg(test)] + fn policy_folder_has_secure_permissions(_folder_path: &Path) -> bool { + // In tests, we can assume the folder has secure permissions + match TEST_POLICY_FOLDER_IS_SECURE.get() { + Some(mutex) => { + *mutex.lock().unwrap() + }, + None => { + panic!("TEST_POLICY_FOLDER_IS_SECURE has not been initialized"); + } + } + } +} + +/// Helper static for stubbed unit tests to mock the security status of the +/// policy folder. +#[cfg(test)] +pub(crate) static TEST_POLICY_FOLDER_IS_SECURE: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Helper macro for stubbed unit tests to mock the security status of the +/// policy folder. +/// +/// This macro sets the result for the [`policy_folder_has_secure_permissions`] +/// function. +/// +/// # Arguments +/// +/// - `$value` (optional): The boolean value indicating whether the policy +/// folder has secure permissions. If omitted, it defaults to `true`. +/// +/// # Examples +/// +/// Invoking the macro without any arguments sets the result to true: +/// +/// ```rust, ignore +/// set_test_policy_folder_is_secure!(); +/// assert!(DscSettings::validate_policy_folder_security().is_ok()); +/// ``` +/// +/// You can also provide a boolean value to set the security status explicitly: +/// +/// ```rust, ignore +/// set_test_policy_folder_is_secure!(false); +/// assert!(DscSettings::validate_policy_folder_security().is_err()); +/// set_test_policy_folder_is_secure!(true); +/// assert!(DscSettings::validate_policy_folder_security().is_ok()); +/// ``` +#[cfg(test)] +macro_rules! set_test_policy_folder_is_secure { + () => { + $crate::settings::dsc_settings::set_test_policy_folder_is_secure!(true); + }; + ($value:literal) => { + { + match $crate::settings::TEST_POLICY_FOLDER_IS_SECURE.get() { + Some(mutex) => { + let mut guard = mutex.lock().unwrap(); + *guard = $value; + } + None => { + $crate::settings::TEST_POLICY_FOLDER_IS_SECURE.get_or_init(|| std::sync::Mutex::new($value)); + } + } + } + }; +} +#[cfg(test)] +pub(crate) use set_test_policy_folder_is_secure; + +impl Default for DscSettings { + fn default() -> Self { + Self::new() + } +} \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/dsc_settings_scope.rs b/lib/dsc-lib/src/settings/dsc_settings_scope.rs new file mode 100644 index 000000000..ac45d62bb --- /dev/null +++ b/lib/dsc-lib/src/settings/dsc_settings_scope.rs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{fmt::Display, str::FromStr}; + +use crate::{ + schemas::{dsc_repo::{DscRepoSchema, schema_i18n}, transforms::idiomaticize_string_enum}, + settings::DscSettingsError +}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Defines the source scope of a setting value for DSC. +/// +/// DSC supports multiple sources for settings. This enum represents the scope +/// of a setting value. The scopes are ordered by precedence, with the highest +/// precedence scope being the one that DSC uses: +/// +/// 1. [`Default`] +/// 1. [`Machine`] +/// 1. [`User`] +/// 1. [`Workspace`] +/// 1. [`Environment`] +/// 1. [`CommandLine`] +/// 1. [`Policy`] +/// +/// The highest precedence source is [`Policy`], which is defined in the +/// machine policy file. Fields defined as policy cannot be overridden by any +/// other source, including environment variables or command line arguments. +/// +/// [`Default`]: Self::Default +/// [`Machine`]: Self::Machine +/// [`User`]: Self::User +/// [`Workspace`]: Self::Workspace +/// [`Environment`]: Self::Environment +/// [`CommandLine`]: Self::CommandLine +/// [`Policy`]: Self::Policy +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, DscRepoSchema)] +#[serde(rename_all = "camelCase", try_from = "String", into = "String")] +#[schemars(!try_from, !into, transform = idiomaticize_string_enum)] +#[dsc_repo_schema(base_name = "scope", folder_path = "settings")] +#[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = DscSettingsScope::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription") + ) + +)] +pub enum DscSettingsScope { + /// The default settings staticalally defined in the DSC codebase. + /// + /// For more information about the default settings, see + /// [`DscSettingsCodeDefaults`] and [`DSC_SETTINGS_CODE_DEFAULTS`]. + /// + /// [`DscSettingsCodeDefaults`]: crate::settings::sources::DscSettingsCodeDefaults + /// [`DSC_SETTINGS_CODE_DEFAULTS`]: crate::settings::sources::DSC_SETTINGS_CODE_DEFAULTS + #[schemars( + title = schema_i18n!("variants.default.title"), + description = schema_i18n!("variants.default.description"), + extend("markdownDescription" = schema_i18n!("variants.default.markdownDescription")) + )] + Default, + /// The settings defined for all users on the machine in a preference + /// settings file. + /// + /// The location for the settings file in this scope depends on the + /// operating system: + /// + /// - On Windows: `{ProgramData}\dsc\dsc.settings.json` + /// - On macOS: `/Library/Application Support/dsc/dsc.settings.json` + /// - On Linux and other Unix-like systems: `/etc/dsc/dsc.settings.json` + /// + /// For more information on the settings you can define for all users, see + /// [`PreferenceFileData`]. + /// + /// [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + #[schemars( + title = schema_i18n!("variants.machine.title"), + description = schema_i18n!("variants.machine.description"), + extend("markdownDescription" = schema_i18n!("variants.machine.markdownDescription")) + )] + Machine, + /// The settings defined for the current user in a preference settings file. + /// + /// The location for the settings file in this scope depends on the + /// operating system and whether the `XDG_CONFIG_HOME` environment variable + /// is set: + /// + /// - When `XDG_CONFIG_HOME` is set, DSC looks for the settings file at + /// `{XDG_CONFIG_HOME}/dsc/user_settings.json` (using `\` instead of `/` + /// on Windows). + /// - When `XDG_CONFIG_HOME` is not set, DSC looks for the settings file + /// depending on the operating system: + /// + /// - On Windows: `{APPDATA}\dsc\dsc.settings.json` + /// - On macOS: `{HOME}/Library/Application Support/dsc/dsc.settings.json` + /// - On Linux and other Unix-like systems: `{HOME}/.config/dsc/dsc.settings.json` + /// + /// For more information on the settings you can define for a user, see + /// [`PreferenceFileData`]. + /// + /// [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + #[schemars( + title = schema_i18n!("variants.user.title"), + description = schema_i18n!("variants.user.description"), + extend("markdownDescription" = schema_i18n!("variants.user.markdownDescription")) + )] + User, + /// The settings defined for the current workspace in a preference settings + /// file. + /// + /// The location for the settings file in this scope is always + /// `{CWD}/dsc.settings.json`, where `CWD` is the current working directory. + /// + /// For more information on the settings you can define for a workspace, + /// see [`PreferenceFileData`]. + /// + /// [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + #[schemars( + title = schema_i18n!("variants.workspace.title"), + description = schema_i18n!("variants.workspace.description"), + extend("markdownDescription" = schema_i18n!("variants.workspace.markdownDescription")) + )] + Workspace, + /// Settings defined as environment variables. + /// + /// For more information on the settings you can define as environment + /// variables, see [`EnvironmentData`]. + /// + /// [`EnvironmentData`]: crate::settings::sources::EnvironmentData + #[schemars( + title = schema_i18n!("variants.environment.title"), + description = schema_i18n!("variants.environment.description"), + extend("markdownDescription" = schema_i18n!("variants.environment.markdownDescription")) + )] + Environment, + /// Settings defined as command line arguments. + /// + /// For more information on the settings you can define as command line + /// arguments, see [`CommandLineData`]. + /// + /// [`CommandLineData`]: crate::settings::sources::CommandLineData + #[schemars( + title = schema_i18n!("variants.cli.title"), + description = schema_i18n!("variants.cli.description"), + extend("markdownDescription" = schema_i18n!("variants.cli.markdownDescription")) + )] + #[serde(rename = "cli")] + CommandLine, + /// The system policy file. Fields defined as policy cannot be overridden. + /// + /// Every setting for DSC can be defined in the system policy file. The + /// location for the policy file depends on the operating system: + /// + /// - On Windows: `{ProgramData}\dsc\dsc.policy.json` + /// - On macOS: `/Library/Application Support/dsc/dsc.policy.json` + /// - On Linux and other Unix-like systems: `/etc/dsc/dsc.policy.json` + /// + /// For more information on the settings you can define as policy, see + /// [`PolicyFileData`]. + /// + /// [`PolicyFileData`]: crate::settings::sources::PolicyFileData + #[schemars( + title = schema_i18n!("variants.policy.title"), + description = schema_i18n!("variants.policy.description"), + extend("markdownDescription" = schema_i18n!("variants.policy.markdownDescription")) + )] + Policy, +} + +// Public API +impl DscSettingsScope { + /// Defines all possible scopes for the `DscSettingsScope` enum for + /// convenience when iterating. + pub const ALL: [DscSettingsScope; 7] = [ + DscSettingsScope::Default, + DscSettingsScope::Machine, + DscSettingsScope::User, + DscSettingsScope::Workspace, + DscSettingsScope::Environment, + DscSettingsScope::CommandLine, + DscSettingsScope::Policy, + ]; + /// Defines the scopes that read from a settings preference file. + pub const FOR_PREFERENCE_FILES: [DscSettingsScope; 3] = [ + DscSettingsScope::Machine, + DscSettingsScope::User, + DscSettingsScope::Workspace, + ]; + + /// Parses a string into a `DscSettingsScope`. + /// + /// This method parses the input string case insensitively. + /// + /// # Arguments + /// + /// * `s` - A string slice that holds the name of the scope. + /// + /// # Returns + /// + /// Returns the matching variant for [`DscSettingsScope`] if the string is + /// parseable. + /// + /// # Errors + /// + /// Returns a [`DscSettingsError::InvalidScope`] if the string doesn't + /// match any valid scope. + pub fn parse(s: &str) -> Result { + match s.to_lowercase().as_ref() { + "default" => Ok(DscSettingsScope::Default), + "machine" => Ok(DscSettingsScope::Machine), + "user" => Ok(DscSettingsScope::User), + "workspace" => Ok(DscSettingsScope::Workspace), + "environment" => Ok(DscSettingsScope::Environment), + "cli" => Ok(DscSettingsScope::CommandLine), + "policy" => Ok(DscSettingsScope::Policy), + _ => Err(DscSettingsError::InvalidScope { text: s.to_string() }), + } + } + + /// Indicates whether the scope is loaded by reading a settings file. + /// + /// # Returns + /// + /// If the scope is [`Policy`], [`Machine`], [`User`], or [`Workspace`], + /// this method returns `true`. Otherwise, it returns `false`. + /// + /// [`Policy`]: Self::Policy + /// [`Machine`]: Self::Machine + /// [`User`]: Self::User + /// [`Workspace`]: Self::Workspace + pub fn is_file_based(&self) -> bool { + matches!(self, + DscSettingsScope::Machine + | DscSettingsScope::User + | DscSettingsScope::Workspace + | DscSettingsScope::Policy + ) + } + + /// Indicates whether the scope is a preference settings file. + /// + /// # Returns + /// + /// If the scope is [`Machine`], [`User`], or [`Workspace`], this method + /// returns `true`. Otherwise, it returns `false`. + /// + /// [`Machine`]: Self::Machine + /// [`User`]: Self::User + /// [`Workspace`]: Self::Workspace + pub fn is_preference(&self) -> bool { + matches!(self, + DscSettingsScope::Machine + | DscSettingsScope::User + | DscSettingsScope::Workspace + ) + } + + /// Indicates whether the scope is the system policy file. + /// + /// # Returns + /// + /// If the scope is [`Policy`], this method returns `true`. Otherwise, it + /// returns `false`. + /// + /// [`Policy`]: Self::Policy + pub fn is_policy(&self) -> bool { + matches!(self, DscSettingsScope::Policy) + } +} + +// Private API +impl DscSettingsScope { + /// A list of all variant names for the `DscSettingsScope` enum, excluding + /// the last variant. + /// + /// Used in error messaging only. + pub(super) const VARIANT_LIST: &str = "`default`, `machine`, `user`, `workspace`, `environment`, `cli`"; + /// The last variant name for the `DscSettingsScope` enum. + /// + /// Used in error messaging only. + pub(super) const VARIANT_LAST: &str = "`policy`"; +} + +// Enables `to_string()` +impl Display for DscSettingsScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let source_str = match self { + DscSettingsScope::Default => "default", + DscSettingsScope::Machine => "machine", + DscSettingsScope::User => "user", + DscSettingsScope::Workspace => "workspace", + DscSettingsScope::Environment => "environment", + DscSettingsScope::CommandLine => "cli", + DscSettingsScope::Policy => "policy", + }; + write!(f, "{}", source_str) + } +} + +// Enables `"text".parse()` +impl FromStr for DscSettingsScope { + type Err = DscSettingsError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl TryFrom for DscSettingsScope { + type Error = DscSettingsError; + + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(scope: DscSettingsScope) -> Self { + scope.to_string() + } +} + +impl TryFrom<&str> for DscSettingsScope { + type Error = DscSettingsError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +// Remaining traits enable comparison +impl PartialEq<&str> for DscSettingsScope { + fn eq(&self, other: &&str) -> bool { + self.to_string().as_str() == other.to_lowercase().as_str() + } +} + +impl PartialEq for &str { + fn eq(&self, other: &DscSettingsScope) -> bool { + self.to_lowercase().as_str() == other.to_string().as_str() + } +} + +impl PartialEq for DscSettingsScope { + fn eq(&self, other: &String) -> bool { + self.to_string().as_str() == other.to_lowercase().as_str() + } +} + +impl PartialEq for String { + fn eq(&self, other: &DscSettingsScope) -> bool { + self.to_lowercase().as_str() == other.to_string().as_str() + } +} diff --git a/lib/dsc-lib/src/settings/errors.rs b/lib/dsc-lib/src/settings/errors.rs new file mode 100644 index 000000000..75c670f69 --- /dev/null +++ b/lib/dsc-lib/src/settings/errors.rs @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use miette::Diagnostic; +use thiserror::Error; +use rust_i18n::t; + +use crate::settings::{DscSettingsScope, fields::*}; + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! Any failure state that can occur while working with DSC settings should have a + //! corresponding error variant in `DscSettingsError`. + //! + //! # General Guidance + //! + //! Every variant should: + //! + //! 1. Be defined as a struct variant with named fields, even if it only contains a single + //! field. This improves discoverability and enables us to pass the context upward without + //! needing to return to the error construction site to discover what the arbitrary string + //! indicates. + //! + //! 1. Define the `error` attribute for the variant with a translated error. Use the `t!` macro + //! to provide the display string. Use the following pattern: + //! + //! ```rust, ignore + //! #[error("{t}", t = t!("settings.errors.", ...))] + //! ``` + //! + //! Always define the translation lookup key as the camelCased variant name. + //! + //! When you need to interpolate text from the error variant into the translation, always + //! pass it as a named argument to the `t!` macro. Don't append it to the error format + //! string itself. + //! + //! For example, use this construction: + //! + //! ```ignore + //! #[error("{t}", t = t!("settings.errors.invalidTraceLevel", text = text))] + //! InvalidTraceLevel { + //! text: String, + //! } + //! ``` + //! + //! Not this construction: + //! + //! ```ignore + //! #[error("{t}: {text}", t = t!("settings.errors.invalidTraceLevel"))] + //! InvalidTraceLevel { + //! text: String, + //! } + //! ``` + //! + //! This ensures that all error messages are consistently translated and formatted and makes + //! the translation definition the source of truth for error message content. + //! + //! The rest of the guidance relates to specific kinds of error variants. + //! + //! # String enum errors + //! + //! These error variants are raised when parsing a string value into a specific enum type, + //! like [`TracingLevelField`] or [`TracingFormatField`]. + //! + //! The shape of the error variant for string enums should always look like the following: + //! + //! ```rust, ignore + //! /// Indicates that an input string failed to parse case-insensitively into a valid variant + //! /// of [``]. + //! #[error("{t}", t = t!( + //! "settings.errors.invalid", + //! text = text, + //! variant_list = ::VARIANT_LIST, + //! variant_last = ::VARIANT_LAST, + //! ))] + //! Invalid { + //! text: String, + //! }, + //! ``` + //! + //! Where: + //! + //! 1. The variant is named is the type name of the string enum type prefixed with `Invalid` + //! and with the `Field` suffix removed, like [`InvalidTracingLevel`] for + //! [`TracingLevelField`]. + //! 1. The i18n key used in the `t!` macro should be `"settings.errors."`, + //! like `"settings.errors.invalidTracingLevel"` for [`InvalidTracingLevel`]. + //! 1. The `t!` macro should always pass the text from the error variant and the list of valid + //! variants so the translation can correctly construct the error message. + //! + //! You can use the `string_enum_error` snippet to quickly insert a new error variant for an + //! invalid string enum value. + //! + //! ## Collected errors + //! + //! These errors are used to aggregate multiple errors that occur while processing a single + //! operation. Where possible, we want to _collect_ errors for user visibility rather than + //! failing fast and leaving the user to fix one error at a time. + //! + //! For example, [`LoadMultipleErrors`] collects all errors encountered when reading a settings + //! file, allowing the user to see all issues at once rather than failing at the first error. + //! + //! The shape of a collected errors variant should look like the following: + //! + //! ```rust, ignore + //! #[error("{t}", t = t!( + //! "settings.errors.", + //! err = DscSettingsError::collect_errors_for_message(&errors), + //! // hoist additional context from the variant fields as needed. + //! ))] + //! MultipleErrors { + //! /// Collected errors encountered while . + //! #[related] + //! errors: Vec, + //! // Define additional fields as needed to provide context for the collected errors. + //! }, + //! ``` + //! + //! Where: + //! + //! 1. The variant is named coherently for the type of errors it collects and has the suffix + //! `MultipleErrors`. + //! 1. The variant always defines a field named `errors` of type `Vec` and + //! has the `#[related]` attribute. + //! 1. The i18n key used in the `t!` macro should be `"settings.errors."`, + //! like `"settings.errors.loadMultipleErrors"` for [`LoadMultipleErrors`]. + //! 1. The `t!` macro must include the `err` argument, which should be generated using + //! `DscSettingsError::collect_errors_for_message(errors)` to concatenate the error + //! messages from all collected errors into a single string. + //! 1. If additional context is useful, the variant defines fields for that context and passes + //! them to the `t!` macro as additional arguments if needed. + //! + //! [`TracingLevelField`]: crate::settings::fields::TracingLevelField + //! [`TracingFormatField`]: crate::settings::fields::TracingFormatField + //! [`InvalidTracingLevel`]: super::DscSettingsError::InvalidTracingLevel + //! [`LoadMultipleErrors`]: super::DscSettingsError::LoadMultipleErrors +} + +#[non_exhaustive] +#[derive(Error, Debug, Diagnostic)] +pub enum DscSettingsError { + /// Indicates that an invalid string representation of [`DscSettingsScope`] + /// was provided. + #[error("{t}", t = t!( + "settings.errors.invalidScope", + text = text, + variant_list = DscSettingsScope::VARIANT_LIST, + variant_last = DscSettingsScope::VARIANT_LAST, + ))] + InvalidScope { + /// The text that failed to parse as a [`DscSettingsScope`] variant. + text: String, + }, + + /// Indicates that an invalid string representation of [`TracingLevelField`] + /// was provided. + #[error("{t}", t = t!( + "settings.errors.invalidTracingLevel", + text = text, + variant_list = TracingLevelField::VARIANT_LIST, + variant_last = TracingLevelField::VARIANT_LAST, + ))] + InvalidTracingLevel{ + /// The text that failed to parse as a [`TracingLevelField`] variant. + text: String, + }, + + /// Indicates that an invalid string representation of [`TracingFormatField`] + /// was provided. + #[error("{t}", t = t!( + "settings.errors.invalidTracingFormat", + text = text, + variant_list = TracingFormatField::VARIANT_LIST, + variant_last = TracingFormatField::VARIANT_LAST, + ))] + InvalidTracingFormat{ + /// The text that failed to parse as a [`TracingFormatField`] variant. + text: String, + }, + + /// Indicates that a settings preference or policy file contained no text + /// and couldn't be parsed into settings. + #[error("{t}", t = t!("settings.errors.dataFileEmpty", file_path = file_path, scope = scope))] + DataFileEmpty { + /// The path to the settings preference or policy file that was empty. + file_path: String, + /// The scope for the settings file + scope: String, + }, + + /// Indicates that a settings preference or policy file couldn't be read + /// from the file system. + /// + /// This error is only raised when the file exists but reading it raises a + /// [`std::io::Error`]. + #[error("{t}", t = t!( + "settings.errors.dataFileReadError", + file_path = file_path, + scope = scope, + err = source, + ))] + DataFileReadError { + /// The path to the settings preference or policy file that couldn't be + /// read. + file_path: String, + /// The scope for the settings file. + scope: String, + /// The underlying I/O error that occurred while trying to read the file. + #[source] + source: std::io::Error, + }, + + /// Indicates that the settings preference or policy file was defined with + /// some content that couldn't be parsed as the appropriate data. + #[error("{t}", t = t!( + "settings.errors.dataFileUnparseable", + file_path = file_path, + scope = scope, + err = source, + ))] + DataFileUnparseable{ + /// The path to the settings preference or policy file that couldn't be + /// parsed. + file_path: String, + /// The scope for the settings file. + scope: String, + /// The underlying error that occurred while trying to parse the file. + #[source] + source: serde_json::Error, + }, + + /// Indicates that an environment variable's value couldn't be parsed as a + /// boolean. + /// + /// DSC recognizes (case-insensitively): + /// + /// - `true` and `1` as boolean true values + /// - `false` and `0` as boolean false values. + /// + /// Any other value meant to parse as a boolean from the environment + /// variable raises this error. + #[error("{t}", t = t!( + "settings.errors.envVarUnparseableBoolean", + value = value, + expected_list = "`true`, `false`, `1`", + expected_last = "`0`" + ))] + EnvVarUnparseableBoolean{ + /// The value of the environment variable that couldn't be parsed as a + /// boolean. + value: String, + }, + + /// Indicates that multiple errors occurred while loading settings. + /// + /// DSC aggregates load errors to return them together instead of failing + /// immediately on the first error. This helps users more quickly address + /// issues in their settings definitions. + #[error("{t}", t = t!( + "settings.errors.loadMultipleErrors", + err = DscSettingsError::collect_errors_for_message(errors) + ))] + LoadMultipleErrors{ + /// The collection of errors raised while loading settings. + #[related] + errors: Vec, + }, + + /// Indicates an error when loading an environment variable as a setting. + #[error("{t}", t = t!( + "settings.errors.loadEnvironmentError", + env_var = env_var, + err = source, + ))] + LoadEnvironmentError{ + /// The name of the environment variable that failed to load. + env_var: &'static str, + /// The underlying error that occurred while trying to load the + /// environment variable. + #[source] + source: Box, + }, + + /// Indicates that multiple errors occurred while loading settings + /// environment variables. + /// + /// DSC aggregates load errors from environment variables to return them + /// together instead of failing immediately on the first error. This helps + /// users more quickly address issues in their environment variable settings. + #[error("{t}", t = t!( + "settings.errors.loadEnvironmentMultipleErrors", + err = DscSettingsError::collect_errors_for_message(errors) + ))] + LoadEnvironmentMultipleErrors{ + /// The collection of errors raised while loading environment variables + /// as settings. + #[related] + errors: Vec, + }, + #[error("{t}", t = t!( + "settings.errors.policyFolderNotSecure", + path = path, + required = required + ))] + PolicyFolderNotSecure { + /// The path to the policy folder that is not secure. + path: String, + /// The required security configuration for the policy folder. + required: String, + } +} + +impl DscSettingsError { + /// Collects the error messages from a list of `DscSettingsError` instances into a single string. + /// + /// # Arguments + /// + /// * `errors` - A slice of `DscSettingsError` instances to collect messages from. + /// + /// # Returns + /// + /// A single string containing the concatenated error messages, separated by commas followed by + /// a single space. + pub fn collect_errors_for_message(errors: &[DscSettingsError]) -> String { + errors + .iter() + .map(|e| e.to_string()) + .collect::>() + .join(", ") + } +} \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/fields/forbid_ignore_settings_file.rs b/lib/dsc-lib/src/settings/fields/forbid_ignore_settings_file.rs new file mode 100644 index 000000000..1ecc0a999 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/forbid_ignore_settings_file.rs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::schemas::dsc_repo::DscRepoSchema; + +super::macros::define_boolean_field! { + base_name: "forbidIgnoreSettingsFile", + folder_path: "settings/fields", + field_definition: { + /// Indicates whether to allow users to ignore settings files. + /// + /// This setting field is only available in the [`Policy`] scope. The\ + /// default setting is `false`. + /// + /// When this setting is `false`, users can define the + /// [`--ignore-settings-file`] CLI option or the [`DSC_IGNORE_SETTINGS_FILE`] + /// environment variable to indicate that DSC shouldn't load and + /// resolve preference settings files. For more information on ignoring + /// settings files, see [`IgnoreSettingsFileField`]. + /// + /// The [`Policy`] scope is always processed regardless of this + /// setting. For more information on defining this setting in the + /// policy file, see [`forbid_ignore_settings_file`] in the policy file + /// documentation. + /// + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`--ignore-settings-file`]: crate::settings::sources::CommandLineData::ignore_settings_file + /// [`DSC_IGNORE_SETTINGS_FILE`]: crate::settings::sources::EnvironmentData::dsc_ignore_settings_file + /// [`IgnoreSettingsFileField`]: crate::settings::fields::IgnoreSettingsFileField + /// [`forbid_ignore_settings_file`]: crate::settings::sources::PolicyFileData::forbid_ignore_settings_file + struct ForbidIgnoreSettingsFileField; + }, + code_default_const: { + /// Defines the default value for the `forbid_ignore_settings_file` + /// field in DSC settings. + CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE = false; + }, +} diff --git a/lib/dsc-lib/src/settings/fields/ignore_settings_file.rs b/lib/dsc-lib/src/settings/fields/ignore_settings_file.rs new file mode 100644 index 000000000..62abeef3d --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/ignore_settings_file.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::schemas::dsc_repo::DscRepoSchema; + +super::macros::define_boolean_field!( + base_name: "ignoreSettingsFile", + folder_path: "settings/fields", + field_definition: { + /// Indicates whether DSC should ignore settings files. + /// + /// This setting is available in the [`Policy`], [`Environment`], and + /// [`Cli`] scopes. The default setting is `false`. + /// + /// When this setting is `false`, DSC loads and resolves the preference + /// settings files for the [`Machine`], [`User`], and [`Workspace`] + /// settings scopes if they exist. + /// + /// When this setting is `true`, DSC ignores the [`Machine`], [`User`], + /// and [`Workspace`] settings scopes. DSC won't automatically load + /// those settings files and will ignore them during settings + /// resolution even if they were manually loaded. + /// + /// The [`Policy`] scope is always processed regardless of this setting. + /// + /// You can override this setting by: + /// + /// - Defining the [`ignore_settings_file`] field in the policy + /// settings file. + /// - Defining the [`DSC_IGNORE_SETTINGS_FILE`] environment variable. + /// - Specifying the [`--ignore-settings-file`] CLI option. + /// + /// If the policy settings file defines [`forbid_ignore_settings_file`] + /// as `true`, DSC effectively ignores this setting and will always + /// load and resolve the preference settings files if they exist. + /// + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`Environment`]: crate::settings::DscSettingsScope::Environment + /// [`Cli`]: crate::settings::DscSettingsScope::CommandLine + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`ignore_settings_file`]: crate::settings::sources::PolicyFileData::ignore_settings_file + /// [`DSC_IGNORE_SETTINGS_FILE`]: crate::settings::sources::EnvironmentData::dsc_ignore_settings_file + /// [`--ignore-settings-file`]: crate::settings::sources::CommandLineData::ignore_settings_file + /// [`forbid_ignore_settings_file`]: crate::settings::sources::PolicyFileData::forbid_ignore_settings_file + struct IgnoreSettingsFileField; + }, + code_default_const: { + /// Defines the code default value for the `ignore_settings_file` field. + CODE_DEFAULT_IGNORE_SETTINGS_FILE = false; + }, +); + diff --git a/lib/dsc-lib/src/settings/fields/macros/define_boolean_field.rs b/lib/dsc-lib/src/settings/fields/macros/define_boolean_field.rs new file mode 100644 index 000000000..9af2f21ba --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/macros/define_boolean_field.rs @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Defines a boolean settings field and optionally the default code constant +/// for the field. +/// +/// This macro generates a public struct wrapping an inner [`bool`] that +/// derives the following traits: +/// +/// - [`Clone`], [`Copy`], [`Debug`], [`PartialEq`], and [`Eq`] for usability. +/// - [`Serialize`], [`Deserialize`], [`JsonSchema`] and [`DscRepoSchema`] as +/// required for every settings field. +/// +/// It inserts the appropriate attribute for [`DscRepoSchema`] to set the base +/// name and folder path. For [`JsonSchema`], it inserts a schemars attribute +/// that ensures: +/// +/// 1. The schema includes `$id` and `$schema`. +/// 1. The schema includes the `title`, `description`, and `markdownDescription` +/// docs keywords. +/// 1. To provide better hover help in editors like VS Code, it adds the `enum` +/// keyword with the boolean values and the `markdownEnumDescriptions` +/// keyword to describe how the setting behaves when set to `true` or +/// `false`. +/// +/// It also implements the [`AsRef`], [`Deref`], and [`From`] traits for +/// ergonomic usage, since those can't be derived. +/// +/// If you specify the code default constant, it will be generated along with +/// the struct. +/// +///
+/// +/// To use this macro, you _must_ ensure that [`DscRepoSchema`] is imported in +/// the current module. Due to limitations in declarative macros and +/// referencing the trait by path, this macro **does not** insert the `use` +/// statement for [`DscRepoSchema`] - otherwise, multiple uses of this macro +/// in the same module would raise a compilation error on duplicate `use` +/// statements. +/// +///
+/// +/// # Syntax +/// +/// The macro uses the following syntax: +/// +/// ```ignore +/// define_boolean_field! { +/// base_name: "", +/// folder_path: "settings/fields[/]", +/// field_definition: { +/// /// Optional documentation for the field and any additional +/// /// attributes. +/// struct Field; +/// }, +/// code_default_const: { +/// /// Optional documentation for the code default constant. +/// CODE_DEFAULT_ = ; +/// }, +/// } +/// ``` +/// +/// Every argument pair for the macro consists of a key and a value, separated +/// by a colon. The following list describes each argument: +/// +/// - `base_name`: Defines the base name for the repository schema of the +/// settings field. This value should always be a string representing the +/// camel-cased field name. +/// +/// - `folder_path`: Defines the folder path for the settings field in the +/// repository schema. For top-level fields this should always be +/// `"settings/fields"`. For nested fields, include the optional subfolder +/// path with the container field names for each segment in the nested +/// structure. +/// +/// For example, consider `ExampleFooBarField`, which is nested under +/// `ExampleFooField`, which itself is nested under `ExampleFileData`. The +/// folder path for `ExampleFooBarField` would be +/// `"settings/fields/example/foo"`. +/// +/// - `field_definition`: Defines the field as a unit struct to get the type +/// name for the struct that represents the field. The type name should +/// always be the PascalCase name of the field with the suffix `Field`, like +/// `TopLevelField` for `top_level`. +/// +/// Before the unit struct definition, you can add triple slash documentation +/// and additional attributes. +/// +/// - `code_default_const`: Defines the code default constant for the field to +/// get the constant name and default value. The name should follow the +/// convention `CODE_DEFAULT_`. The default value must be `true` +/// or `false`. +/// +/// Before the constant definition, you can add triple slash documentation +/// and additional attributes. +/// +/// # Examples +/// +/// ## Top-level field +/// +/// For any invocation you _must_ define the the values for the `base_name` and +/// `folder_path` [`DscRepoSchema`] attribute and the field definition as a +/// unit struct with the type name for the field. +/// +/// This defines the field struct and derives/implements the required traits. +/// +/// ```ignore +/// define_boolean_field! { +/// base_name: "topLevelExample", +/// folder_path: "settings/fields", +/// field_definition: { +/// /// Documentation explaining how the setting affects DSC. +/// struct TopLevelExample; +/// }, +/// code_default_const: { +/// /// Defines the code default for the `top_level_example` field. +/// CODE_DEFAULT_TOP_LEVEL_EXAMPLE = true; +/// } +/// } +/// ``` +/// +/// ## Nested field +/// +/// This example shows how you can define a nested boolean field for a +/// container field. It omits the documentation lines for brevity. +/// +/// For this example, the top-level container field is named `top_level`. +/// +/// ```ignore +/// define_boolean_field! { +/// base_name: "nestedField", +/// folder_path: "settings/fields/topLevel", +/// field_definition: { +/// struct NestedField; +/// }, +/// code_default_const: { +/// CODE_DEFAULT_NESTED_FIELD = true; +/// } +/// } +/// ``` +/// +/// [`Debug`]: std::fmt::Debug +/// [`Serialize`]: serde::Serialize +/// [`Deserialize`]: serde::Deserialize +/// [`JsonSchema`]: schemars::JsonSchema +/// [`DscRepoSchema`]: crate::schemas::dsc_repo::DscRepoSchema +/// [`AsRef`]: std::convert::AsRef +/// [`Deref`]: std::ops::Deref +/// [`From`]: std::convert::From +macro_rules! define_boolean_field { + ( + base_name: $base_name:literal, + folder_path: $folder_path:literal, + field_definition: { + $(#[$meta_type:meta])* + struct $type:ident; + }$(, + code_default_const: { + $(#[$meta_const:meta])* + $default_const_name:ident = $default_const_value:literal; + }$(,)?)? + ) => { + #[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + DscRepoSchema + )] + #[dsc_repo_schema(base_name = $base_name, folder_path = $folder_path)] + #[schemars( + title = crate::schemas::dsc_repo::schema_i18n!("title"), + description = crate::schemas::dsc_repo::schema_i18n!("description"), + extend( + "$schema" = Self::default_export_meta_schema_uri(), + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = crate::schemas::dsc_repo::schema_i18n!("markdownDescription"), + "enum" = [true, false], + "markdownEnumDescriptions" = [ + crate::schemas::dsc_repo::schema_i18n!("markdownEnumDescriptions.true"), + crate::schemas::dsc_repo::schema_i18n!("markdownEnumDescriptions.false") + ] + ) + )] + $(#[$meta_type])* + pub struct $type(bool); + + impl $type { + #[doc = concat!( + "Creates a new instance of [`", + stringify!($type), + "`] with the given boolean value." + )] + pub fn new(value: bool) -> Self { + $type(value) + } + } + + // Enable accessing the inner bool value as reference + impl AsRef for $type { + fn as_ref(&self) -> &bool { + &self.0 + } + } + // Enable dereferencing to the inner bool value + impl std::ops::Deref for $type { + type Target = bool; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + // Enable writing as string + impl std::fmt::Display for $type { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_ref()) + } + } + // Enable conversion to/from bool values + impl From for $type { + fn from(value: bool) -> Self { + $type(value) + } + } + impl From<$type> for bool { + fn from(value: $type) -> Self { + value.0 + } + } + // Enable comparing directly with bool values + impl PartialEq for $type { + fn eq(&self, other: &bool) -> bool { + self.0 == *other + } + } + impl PartialEq<$type> for bool { + fn eq(&self, other: &$type) -> bool { + *self == other.0 + } + } + + $( + $(#[$meta_const])* + pub const $default_const_name: $type = $type($default_const_value); + )? + }; +} + +pub(crate) use define_boolean_field; \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/fields/macros/define_container_field.rs b/lib/dsc-lib/src/settings/fields/macros/define_container_field.rs new file mode 100644 index 000000000..0297dd886 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/macros/define_container_field.rs @@ -0,0 +1,383 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Defines a container field with associated types and default code constant. +/// +/// This macro provides a compact way to define container fields and the +/// required types to reduce boilerplate code and drifting implementations. +/// +///
+/// +/// To use this macro, you'll need to add a `use` statement to import the +/// [`DscRepoSchema`] trait. Recommend adding the following snippet to the +/// submodule for the field before using the macro: +/// +/// ```rust, ignore +/// use crate::schemas::dsc_repo::DscRepoSchema; +/// use super::macros::*; +/// ``` +/// +///
+/// +///
+/// +/// Currently, the implementation doesn't support defining a container field +/// that has one or more nested container fields. For container fields with +/// nested containers, manually define the field. +/// +///
+/// +/// This macro generates a public struct that derives the following traits: +/// +/// - [`Clone`], [`Debug`], [`PartialEq`], and [`Eq`] for usability. +/// - [`Serialize`], [`Deserialize`], [`JsonSchema`] and [`DscRepoSchema`] as +/// required for every settings field. +/// +/// When it generates the struct, it also: +/// +/// 1. Applies the `#[serde(rename_all = "camelCase")]` attribute to the +/// struct, ensuring the container's fields use the correct case. +/// 1. Applies the `#[dsc_repo_schema]` attribute to pass through the base name +/// and folder path. +/// 1. Automatically defines the `schemars` attribute on the struct to set the +/// `title`, `description`, and `markdownDescription` keywords to the +/// localized strings. It also sets the `$schema` and `$id` keywords to the +/// default export URIs from the implementation of [`DscRepoSchema`]. +/// 1. Adds every specified field from the `field_definition` argument: +/// +/// - With the given field name and the type [`Option`], where `T` is the +/// type specified in the `field_definition` argument. +/// - With the `schemars` attribute to set the `title`, `description`, and +/// `markdownDescription` keywords to the localized strings. It expects +/// the translation file to define entries for each field under the +/// `fields` key for the struct. +/// - With the `#[serde(skip_serializing_if = "Option::is_none")]` attribute +/// to skip serializing the field when undefined. +/// +/// It also generates the required `*ResolvedSettings` and `*CodeDefaults` +/// structs as well as the `CODE_DEFAULT_*` constant. It generates the required +/// trait implementations for the additional types. +/// +/// When the macro generates the struct for the code defaults, it: +/// +/// 1. Derives [`Clone`], [`Debug`], [`PartialEq`], [`Eq`], [`Serialize`], and +/// [`Deserialize`]. +/// 1. Applies the `#[serde(rename_all = "camelCase")]` attribute to ensure +/// consistent casing. +/// 1. Defines every specified field from the `field_definition` argument with +/// the same name and the specified type. +/// 1. Implements the [`Default`] trait to return the code default constant. +/// +/// When the macro generates the code default constant, it defines the constant +/// with the specified name and uses the default values from the +/// `field_definition` argument to populate each field. +/// +/// When the macro generates the resolved settings struct, it: +/// +/// 1. Derives [`Clone`], [`Debug`], [`PartialEq`], [`Eq`], [`Serialize`], +/// [`Deserialize`], and [`JsonSchema`]. +/// 1. Applies the `#[serde(rename_all = "camelCase")]` attribute to ensure +/// consistent casing. +/// 1. Defines every specified field from the `field_definition` argument with +/// the same name and and the [`DscSettingsResolvedField`] type, where +/// `T` is the type specified in the `field_definition` argument. +/// 1. Implements the [`Default`] trait to return the resolved settings with all +/// fields set to their default values and the scope set to code defaults. +/// +/// # Syntax +/// +/// The following snippet shows the high-level syntax for the macro: +/// +/// ```rust, ignore +/// define_container_field! { +/// base_name: , +/// folder_path: , +/// field_definition: { +/// /// Optional docs +/// #[optional_attribute] +/// struct FileData { +/// /// Optional docs +/// #[optional_attribute] +/// : = , +/// ... +/// } +/// }, +/// resolved_container_type: { +/// /// Optional docs +/// #[optional_attribute] +/// ResolvedSettings +/// }, +/// code_default_type: { +/// /// Optional docs +/// #[optional_attribute] +/// CodeDefaults +/// }, +/// code_default_const: { +/// /// Optional docs +/// #[optional_attribute] +/// CODE_DEFAULT +/// }, +/// } +/// ``` +/// +/// All key-value pairs are required. +/// +/// ## `base_name` +/// +/// Define this a a string literal containing the camel-cased name of the new +/// container field. For example, `my_new_field` would be defined as +/// `"myNewField"`. +/// +/// ## `folder_path` +/// +/// Define this as a string literal containing the folder path where the schema +/// file for the new container field will be located. +/// +/// If the new container field is a top-level field this value should be +/// `"settings/fields"`. +/// +/// If it's a nested field, this value should include the +/// ancestors in the folder path. For example: +/// +/// - If the new container field is nested under `parent_field`, the folder +/// path would be be`"settings/fields/parentField"`. +/// - If the new container field is nested under `parent_field`, which is under +/// `grand_parent_field`, the folder path would be +/// `"settings/fields/parentField/childField"`. +/// +/// ## `field_definition` +/// +/// Inside the curly braces, specify the `struct` keyword followed by the +/// pascal-case name of the new container field with the suffix `FileData`. For +/// example, `my_new_field` would be defined as `struct MyNewFieldFileData`. +/// +/// Immediately preceding the struct definition, you can optionally include +/// documentation comments and any attributes. In particular, the `serde` and +/// `schemars` attributes are supported, but so are any other generic +/// attributes. +/// +/// After the struct keyword and type name, include curly braces to contain the +/// nested field definitions for the new container. +/// +/// For each field: +/// +/// 1. Specify the nested field name in snake_case, followed by a colon and the +/// field type. +/// 1. Assign a default value to the field using the equals sign. The default +/// value _must_ be constant function compatible. +/// 1. Optionally include documentation comments and attributes immediately +/// preceding the field definition. +/// +/// ## `resolved_container_type` +/// +/// Inside the curly braces, specify the resolved container type name as the +/// pascal cased field name with the suffix `ResolvedSettings`. You can +/// optionally include documentation comments and attributes immediately +/// preceding the resolved container type name. +/// +/// ## `code_default_type` +/// +/// Inside the curly braces, specify the code default type as the pascal cased +/// field name with the suffix `CodeDefaults`. You can optionally include +/// documentation comments and attributes immediately preceding the code +/// default type name. +/// +/// ## `code_default_const` +/// +/// Inside the curly braces, specify the code default constant name as the +/// screaming snake case version of the field name with the prefix +/// `CODE_DEFAULT_`. You can optionally include documentation comments and +/// attributes immediately preceding the code default constant name. +/// +/// # Examples +/// +/// ## Minimal +/// +/// The following snippet shows how you can use the macro to define a new +/// top-level container field. It doesn't define any optional documentation +/// or attributes. +/// +/// ```rust, ignore +/// define_container_field! { +/// base_name: "newArea", +/// folder_path: "settings/fields", +/// field_definition: { +/// struct NewAreaFileData { +/// foo: bool = false, +/// bar: u32 = 0, +/// baz: String = String::from("Example"), +/// } +/// }, +/// resolved_container_type: { +/// NewAreaResolvedSettings +/// }, +/// code_default_type: { +/// NewAreaCodeDefaults +/// }, +/// code_default_const: { +/// CODE_DEFAULT_NEW_AREA +/// }, +/// } +/// ``` +/// +/// ## With documentation and attributes +/// +/// The following snippet shows how you can use the macro to define a new +/// nested container field. It includes documentation for the types and fields, +/// as well as additional attributes. +/// +/// ```rust, ignore +/// define_container_field! { +/// base_name: "nestedContainer", +/// folder_path: "settings/fields/parentContainer", +/// field_definition: { +/// /// Defines settings for `parent_container.nested_container`. +/// struct ParentContainerNestedContainerFileData { +/// /// Indicates whether to enable the functionality. +/// enabled: bool = false, +/// /// Indicates the groups to process. +/// #[schemars(extend( +/// "items" = { +/// "type" = "string", +/// "pattern" = "^[a-zA-Z0-9_-]+$" +/// } +/// ))] +/// groups: Vec = Vec::new(), +/// } +/// }, +/// resolved_container_type: { +/// /// Resolved settings for `parent_container.nested_container`. +/// ParentContainerNestedContainerResolvedSettings +/// }, +/// code_default_type: { +/// /// Code default structure for `parent_container.nested_container`. +/// ParentContainerNestedContainerCodeDefaults +/// }, +/// code_default_const: { +/// /// Defines the default values for `parent_container.nested_container`. +/// CODE_DEFAULT_PARENT_CONTAINER_NESTED_CONTAINER +/// }, +/// } +/// ``` +/// +/// [`Debug`]: std::fmt::Debug +/// [`Serialize`]: serde::Serialize +/// [`Deserialize`]: serde::Deserialize +/// [`JsonSchema`]: schemars::JsonSchema +/// [`DscRepoSchema`]: crate::schemas::dsc_repo::DscRepoSchema +/// [`DscSettingsResolvedField`]: crate::settings::DscSettingsResolvedField +macro_rules! define_container_field { + ( + base_name: $base_name:tt, + folder_path: $folder_path:tt, + field_definition: { + $(#[$meta_type:meta])* + struct $type:ident { + $( + $(#[$meta_field:meta])* + $field_name:ident: $field_type:ty = $field_default:expr, + )* + $(,)? + } + }, + resolved_container_type: { + $(#[$meta_resolved:meta])* + $resolved_type:ident + }, + code_default_type: { + $(#[$meta_default_const:meta])* + $code_default_type:ident + }, + code_default_const: { + $(#[$meta_const:meta])* + $default_const_name:ident + } + $(,)? + ) => { + #[derive( + Clone, Debug, Default, PartialEq, Eq, + serde::Serialize, serde::Deserialize, + schemars::JsonSchema, DscRepoSchema + )] + #[serde(rename_all = "camelCase")] + #[dsc_repo_schema(base_name = $base_name, folder_path = $folder_path)] + #[schemars( + title = crate::schemas::dsc_repo::schema_i18n!("title"), + description = crate::schemas::dsc_repo::schema_i18n!("description"), + extend( + "$schema" = Self::default_export_meta_schema_uri(), + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = crate::schemas::dsc_repo::schema_i18n!("markdownDescription"), + ) + )] + $(#[$meta_type])* + pub struct $type { + $( + #[schemars( + title = crate::schemas::dsc_repo::schema_i18n!( + &format!("fields.{}.title", stringify!($field_name)) + ), + description = crate::schemas::dsc_repo::schema_i18n!( + &format!("fields.{}.description", stringify!($field_name)) + ), + extend( + "markdownDescription" = crate::schemas::dsc_repo::schema_i18n!( + &format!("fields.{}.markdownDescription", stringify!($field_name)) + ), + ) + )] + #[serde(skip_serializing_if = "Option::is_none")] + $(#[$meta_field])* + pub $field_name: Option<$field_type>, + )* + } + + $(#[$meta_default_const])* + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct $code_default_type { + $( + pub $field_name: $field_type, + )* + } + + impl Default for $code_default_type { + fn default() -> Self { + $default_const_name + } + } + + $(#[$meta_const])* + pub const $default_const_name: $code_default_type = $code_default_type { + $( + $field_name: $field_default, + )* + }; + + $(#[$meta_resolved])* + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] + #[serde(rename_all = "camelCase")] + #[schemars(inline)] + pub struct $resolved_type { + $( + pub $field_name: crate::settings::DscSettingsResolvedField<$field_type>, + )* + } + + impl Default for $resolved_type { + fn default() -> Self { + let scope = crate::settings::DscSettingsScope::Default; + Self { + $( + $field_name: crate::settings::DscSettingsResolvedField::new( + $default_const_name.$field_name.clone(), + scope + ), + )* + } + } + } + }; +} + +pub(crate) use define_container_field; diff --git a/lib/dsc-lib/src/settings/fields/macros/define_string_enum_field.rs b/lib/dsc-lib/src/settings/fields/macros/define_string_enum_field.rs new file mode 100644 index 000000000..007be8f74 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/macros/define_string_enum_field.rs @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Defines a string enum settings field and optionally the default code +/// constant for the field. +/// +/// This macro generates a public enum that derives the following traits: +/// +/// - [`Clone`], [`Debug`], [`PartialEq`], and [`Eq`] for usability. +/// - [`Serialize`], [`Deserialize`], [`JsonSchema`] and [`DscRepoSchema`] as +/// required for every settings field. +/// +/// The macro implements the following for the public API: +/// +/// - `ALL` - constant array containing every variant of the enum. +/// - `parse(text: &str) -> Result` - function to +/// parse a string into the enum case insensitively. +/// +/// The macro implements the following for the private (crate public) API: +/// +/// - `VARIANT_LIST` - constant string containing the display string for all but +/// the last variant wrapped in backticks and separated by commas, like +/// `` "`foo`, `bar`, `baz`" ``. +/// - `VARIANT_LAST` - constant string containing the display string for the +/// last variant wrapped in backticks. +/// +/// Those constants are used for surfacing useful error messages to users. +/// +/// The macro also implements the following traits for ergonomic usage: +/// +/// - [`FromStr`] and [`TryFrom`] for parsing from strings, calling the +/// public API `parse()` function instead of duplicating logic. +/// - [`Display`] and [`From for String`] for converting to strings. +/// +/// It inserts the appropriate attribute for [`DscRepoSchema`] to set the base +/// name and folder path. For [`JsonSchema`], it inserts a schemars attribute +/// that ensures: +/// +/// 1. The schema includes `$id` and `$schema`. +/// 1. The schema includes the `title`, `description`, and `markdownDescription` +/// docs keywords. +/// 1. To provide better hover help in editors like VS Code, it adds the +/// `markdownEnumDescriptions` keyword to provide per-variant help text. +/// +/// For the purposes of serialization and deserialization, the enum is treated +/// as a string and: +/// +/// 1. Treats the camelCase variant name as the canonical value for +/// serialization. +/// 1. Parses the string case-insensitively for deserialization. +/// +/// If you specify the code default constant, it will be generated along with +/// the struct. +/// +///
+/// +/// To use this macro, you _must_ ensure that [`DscRepoSchema`] is imported in +/// the current module. Due to limitations in declarative macros and +/// referencing the trait by path, this macro **does not** insert the `use` +/// statement for [`DscRepoSchema`] - otherwise, multiple uses of this macro in +/// the same module would raise a compilation error on duplicate `use` +/// statements. +/// +/// Note that until you define the error variant for [`DscSettingsError`] +/// specified in `parse_error_variant`, the macro will raise a compile error +/// indicating that the variant doesn't exist. +/// +///
+/// +/// # Syntax +/// +/// The macro uses the following syntax: +/// +/// ```ignore +/// define_string_enum_field! { +/// base_name: "", +/// folder_path: "settings/fields[/]", +/// parse_error_variant: , +/// field_definition: { +/// /// Optional documentation for the field and any additional +/// /// attributes +/// enum Field { +/// , +/// , +/// ... +/// } +/// } +/// } +/// ``` +/// +/// # Examples +/// +/// ## Top-level field +/// +/// The following snippet shows how you would use the macro to define a string +/// enum field for the top-level setting field `top_level`. +/// +/// ```ignore +/// define_string_enum_field! { +/// base_name: "topLevel", +/// folder_path: "settings/fields", +/// parse_error_variant: InvalidTopLevel, +/// field_definition: { +/// enum TopLevelField { +/// Foo { display: "foo", lowercase: "foo" }, +/// Bar { display: "bar", lowercase: "bar" }, +/// Baz { display: "baz", lowercase: "baz" }, +/// VeryLongVariant { +/// display: "veryLongVariant", +/// lowercase: "verylongvariant" +/// }, +/// } +/// }, +/// code_default_const: { +/// CODE_DEFAULT_TOP_LEVEL = Foo; +/// } +/// } +/// ``` +/// +/// ## Nested field +/// +/// The following snippet shows how you would use the macro to define the field +/// `nested` as a string enum nested inside the top-level container field +/// `top_level`. +/// +/// ```ignore +/// define_string_enum_field! { +/// base_name: "nested", +/// folder_path: "settings/fields/topLevel", +/// parse_error_variant: InvalidNested, +/// field_definition: { +/// enum NestedField { +/// Foo { display: "foo", lowercase: "foo" }, +/// Bar { display: "bar", lowercase: "bar" }, +/// Baz { display: "baz", lowercase: "baz" }, +/// } +/// } +/// } +/// ``` +/// +/// [`Debug`]: std::fmt::Debug +/// [`Serialize`]: serde::Serialize +/// [`Deserialize`]: serde::Deserialize +/// [`JsonSchema`]: schemars::JsonSchema +/// [`DscRepoSchema`]: crate::schemas::dsc_repo::DscRepoSchema +/// [`FromStr`]: std::str::FromStr +/// [`TryFrom`]: std::convert::TryFrom +/// [`Display`]: std::fmt::Display +/// [`From for String`]: std::convert::From +/// [`DscSettingsError`]: crate::settings::DscSettingsError +macro_rules! define_string_enum_field { + ( + base_name: $base_name:literal, + folder_path:$folder_path:literal, + parse_error_variant: $parse_error_variant:ident, + field_definition: { + $(#[$meta_type:meta])* + enum $type:ident{ + $( + $(#[$meta_variant:meta])* + $variant_name:ident { + display: $variant_display:literal, + lowercase: $variant_lowercase:literal $( , )? + }, + )+ + } + }$(, + code_default_const: { + $(#[$meta_const:meta])* + $default_const_name:ident = $default_const_variant:ident; + }$(,)? + )? + ) => { + #[derive( + Clone, + Debug, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + DscRepoSchema + )] + #[serde(rename_all = "camelCase", try_from = "String", into = "String")] + #[dsc_repo_schema(base_name = $base_name, folder_path = $folder_path)] + #[schemars( + !try_from, !into, + transform = crate::schemas::transforms::idiomaticize_string_enum, + title = crate::schemas::dsc_repo::schema_i18n!("title"), + description = crate::schemas::dsc_repo::schema_i18n!("description"), + extend( + "$schema" = Self::default_export_meta_schema_uri(), + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = crate::schemas::dsc_repo::schema_i18n!("markdownDescription") + ) + )] + $(#[$meta_type])* + pub enum $type { + $( + $(#[$meta_variant])* + #[schemars( + title = crate::schemas::dsc_repo::schema_i18n!("variants", $variant_display, "title"), + description = crate::schemas::dsc_repo::schema_i18n!("variants", $variant_display, "description"), + extend( + "markdownDescription" = crate::schemas::dsc_repo::schema_i18n!("variants", $variant_display, "markdownDescription") + ) + )] + $variant_name, + )+ + } + + // Public API + impl $type { + #[doc = concat!("An array containing every variant of [`", stringify!($type), "`].")] + pub const ALL: [$type; $crate::settings::fields::macros::utility::count_idents!($($variant_name),+)] = [ + $( + Self::$variant_name, + )+ + ]; + + #[doc = concat!("Parse a string into an instance of [`", stringify!($type), "`].")] + /// + /// This function parses the input string case-insensitively. If the downcased input + /// string matches the downcased display for a variant, the function returns that + /// variant. + /// + /// # Arguments + /// + /// - `s` - The string to parse into an instance. + /// + /// # Errors + /// + /// If the string doesn't match any variant, this function emits the + #[doc = concat!("[`", stringify!($parse_error_variant), "`] error.")] + /// + /// # Returns + /// + #[doc = concat!("This function returns an instance of [`", stringify!($type), "`]")] + /// if the input text matches a variant and otherwise returns a parse error. + /// + #[doc = concat!( + "[`", stringify!($parse_error_variant), "`]: ", + "crate::settings::DscSettingsError::", stringify!($parse_error_variant), + )] + pub fn parse(s: &str) -> Result { + match s.to_lowercase().as_str() { + $( + $variant_lowercase => Ok(Self::$variant_name), + )+ + _ => Err(crate::settings::DscSettingsError::$parse_error_variant{text: s.to_string()}), + } + } + } + + // Private API + impl $type { + $crate::settings::fields::macros::utility::define_variant_consts!($($variant_display),+); + } + + impl core::fmt::Display for $type { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let format_str = match self { + $( + Self::$variant_name => $variant_display, + )+ + }; + write!(f, "{}", format_str) + } + } + + impl std::str::FromStr for $type { + type Err = crate::settings::DscSettingsError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } + } + + impl TryFrom for $type { + type Error = crate::settings::DscSettingsError; + + fn try_from(value: String) -> Result>::Error> { + Self::parse(&value) + } + } + + impl From<$type> for String { + fn from(value: $type) -> Self { + value.to_string() + } + } + + impl TryFrom<&str> for $type { + type Error = crate::settings::DscSettingsError; + + fn try_from(value: &str) -> Result>::Error> { + Self::parse(value) + } + } + + $( + $(#[$meta_const])* + pub const $default_const_name: $type = $type::$default_const_variant; + )? + }; +} +pub(crate) use define_string_enum_field; diff --git a/lib/dsc-lib/src/settings/fields/macros/mod.rs b/lib/dsc-lib/src/settings/fields/macros/mod.rs new file mode 100644 index 000000000..9700cef59 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/macros/mod.rs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines helper macros for generating fields. +//! +//! These macros are only usable within the `fields` module, and are not intended to be used +//! outside of this module. They simplify repetitive patterns for defining fields. The available +//! macros intended for direct use are: +//! +//! - [`define_boolean_field!`]: Defines a boolean settings field and the code default constant for +//! the field. +//! - [`define_string_enum_field!`]: Defines a string enum settings field and the code default +//! constant for the field. +//! - [`define_container_field!`]: Defines a container settings field, the resolved settings +//! struct, the code default struct, and the code default constant for the container field. +//! +//! This module also defines [`utility`] macros that are not intended for direct use. They simplify +//! internal processing for the field definition macros. + +pub(super) mod utility; + +mod define_boolean_field; +pub(crate) use define_boolean_field::define_boolean_field; + +mod define_string_enum_field; +pub(crate) use define_string_enum_field::define_string_enum_field; + +mod define_container_field; +pub(crate) use define_container_field::define_container_field; + +#[cfg(test)] mod tests; \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/fields/macros/tests.rs b/lib/dsc-lib/src/settings/fields/macros/tests.rs new file mode 100644 index 000000000..7895ccef2 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/macros/tests.rs @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![allow(dead_code)] + +use crate::schemas::schema_utility_extensions::SchemaUtilityExtensions; +use crate::schemas::dsc_repo::DscRepoSchema; + +use super::*; + +#[cfg(test)] mod define_container_field { + use super::*; + use pretty_assertions::assert_eq; + + define_container_field!{ + base_name: "container", + folder_path: "settings/test", + field_definition: { + /// Manages `example` data. + #[schemars(extend("x-extra-keyword" = true))] + struct ExampleFileData { + /// The `foo` field + foo: bool = false, + /// The `bar` field + bar: String = String::new(), + /// The `baz` field + baz: Vec = Vec::new(), + } + }, + resolved_container_type: { + ExampleResolvedSettings + }, + code_default_type: { + /// The structure of the default values for the `example` field. + ExampleCodeDefaults + }, + code_default_const: { + /// The default settings for the `example` field. + CODE_DEFAULT_EXAMPLE + } + } + + #[test] + fn check_schema() { + let schema = schemars::schema_for!(ExampleFileData); + assert_eq!( + schema.get("x-extra-keyword"), + Some(&serde_json::json!(true)) + ); + assert_eq!( + schema.get_keyword_as_string("$schema"), + Some(ExampleFileData::default_export_meta_schema_uri()) + ); + assert_eq!( + schema.get_keyword_as_string("$id"), + Some(ExampleFileData::default_export_schema_id_uri()) + ); + for property_name in ["foo", "bar", "baz"] { + let property_schema = schema + .get_property_subschema(property_name) + .unwrap_or_else(|| panic!("Property schema for '{property_name}' not found")); + + assert_eq!( + property_schema.get_keyword_as_string("title"), + Some(format!("Container '{}' field title", property_name)) + ); + assert_eq!( + property_schema.get_keyword_as_string("description"), + Some(format!("Container '{}' field description", property_name)) + ); + assert_eq!( + property_schema.get_keyword_as_string("markdownDescription"), + Some(format!("Container '{}' field markdown description", property_name)) + ); + } + } + + #[test] fn check_emitted() { + let default = CODE_DEFAULT_EXAMPLE.clone(); + assert_eq!(default.foo, false); + assert_eq!(default.bar, String::new()); + assert_eq!(default.baz, Vec::::new()); + + let resolved = ExampleResolvedSettings::default(); + assert_eq!(resolved.foo.value, false); + assert_eq!(resolved.bar.value, String::new()); + assert_eq!(resolved.baz.value, Vec::::new()); + assert_eq!(resolved.foo.scope, crate::settings::DscSettingsScope::Default); + assert_eq!(resolved.bar.scope, crate::settings::DscSettingsScope::Default); + assert_eq!(resolved.baz.scope, crate::settings::DscSettingsScope::Default); + } +} + +#[cfg(test)] mod define_boolean_field { + use super::*; + use pretty_assertions::assert_eq; + + define_boolean_field! { + base_name: "boolean", + folder_path: "settings/test", + field_definition: { + /// The boolean field + #[schemars(extend("x-extra-keyword" = true))] + struct ExampleField; + }, + code_default_const: { + /// The default settings for the `boolean` field. + CODE_DEFAULT_BOOLEAN = true; + } + } + + #[test] + fn check_schema() { + let schema = schemars::schema_for!(ExampleField); + assert_eq!( + schema.get("x-extra-keyword"), + Some(&serde_json::json!(true)) + ); + assert_eq!( + schema.get_keyword_as_string("$schema"), + Some(ExampleField::default_export_meta_schema_uri()) + ); + assert_eq!( + schema.get_keyword_as_string("$id"), + Some(ExampleField::default_export_schema_id_uri()) + ); + assert_eq!( + schema.get_keyword_as_str("title"), + Some("Boolean title") + ); + assert_eq!( + schema.get_keyword_as_str("description"), + Some("Boolean description") + ); + assert_eq!( + schema.get_keyword_as_str("markdownDescription"), + Some("Boolean markdown description") + ); + assert_eq!( + schema.get_keyword_as_array("enum"), + Some(&vec![serde_json::json!(true), serde_json::json!(false)]) + ); + assert_eq!( + schema.get_keyword_as_array("markdownEnumDescriptions"), + Some(&vec![ + serde_json::json!("Boolean markdown enum description for true"), + serde_json::json!("Boolean markdown enum description for false") + ]) + ); + } + + #[test] fn check_emitted() { + let default = CODE_DEFAULT_BOOLEAN; + assert_eq!(default, ExampleField::new(true)); + // From impls + let from_bool: ExampleField = true.into(); + let from_field: bool = ExampleField::new(true).into(); + // Equality checks + assert!(from_bool == true); + assert!(false != from_bool); + assert!(from_field == ExampleField::new(true)); + assert!(ExampleField::new(false) != from_field); + // deref + let field = ExampleField::new(true); + assert!(*field); + // as_ref + let field_ref= field.as_ref(); + assert_eq!(field_ref, &true); + // display + assert_eq!(&format!("{}", field), "true"); + } +} + +#[cfg(test)] mod define_string_field { + use crate::settings::DscSettingsError; + +use super::*; + use pretty_assertions::assert_eq; + + define_string_enum_field!{ + base_name: "string", + folder_path: "settings/test", + parse_error_variant: InvalidTracingFormat, // Reuse real error to avoid cluttering the enum + field_definition: { + /// The string enum field + #[schemars(extend("x-extra-keyword" = true))] + enum ExampleField { + Foo { display: "foo", lowercase: "foo" }, + Bar { display: "bar", lowercase: "bar" }, + Baz { display: "baz", lowercase: "baz" }, + } + } + } + + #[test] + fn check_schema() { + let schema = schemars::schema_for!(ExampleField); + assert_eq!( + schema.get("x-extra-keyword"), + Some(&serde_json::json!(true)) + ); + assert_eq!( + schema.get_keyword_as_string("$schema"), + Some(ExampleField::default_export_meta_schema_uri()) + ); + assert_eq!( + schema.get_keyword_as_string("$id"), + Some(ExampleField::default_export_schema_id_uri()) + ); + assert_eq!( + schema.get_keyword_as_str("title"), + Some("String title") + ); + assert_eq!( + schema.get_keyword_as_str("description"), + Some("String description") + ); + assert_eq!( + schema.get_keyword_as_str("markdownDescription"), + Some("String markdown description") + ); + assert_eq!( + schema.get_keyword_as_array("enum"), + Some(&vec![ + serde_json::json!("foo"), + serde_json::json!("bar"), + serde_json::json!("baz"), + ]) + ); + assert_eq!( + schema.get_keyword_as_array("markdownEnumDescriptions"), + Some(&vec![ + serde_json::json!("String 'foo' variant markdown description"), + serde_json::json!("String 'bar' variant markdown description"), + serde_json::json!("String 'baz' variant markdown description"), + ]) + ); + } + + #[test] + fn check_emitted() { + assert_eq!( + ExampleField::ALL, + [ExampleField::Foo, ExampleField::Bar, ExampleField::Baz] + ); + // Verify parser + let parse_cases = [ + (ExampleField::Foo, ["foo", "FOO", "FoO"]), + (ExampleField::Bar, ["bar", "BAR", "BaR"]), + (ExampleField::Baz, ["baz", "BAZ", "BaZ"]), + ]; + for (variant, texts) in parse_cases { + for text in texts { + assert_eq!( + ExampleField::parse(text).unwrap_or_else(|_| panic!("{text} should parse as {variant:?}")), + variant + ); + // Ensure FromStr is implemented to use parser + assert_eq!( + text.parse::().unwrap_or_else(|_| panic!("{text} should parse as {variant:?}")), + variant + ); + // Ensure TryFrom reuses parser + assert_eq!( + ExampleField::try_from(String::from(text)).unwrap_or_else(|_| panic!("{text} should convert as {variant:?}")), + variant + ); + // Ensure TryFrom<&str> reuses parser + assert_eq!( + ExampleField::try_from(text).unwrap_or_else(|_| panic!("{text} should convert as {variant:?}")), + variant + ); + } + } + assert!( + matches!( + ExampleField::parse("invalid").expect_err("invalid should not parse"), + DscSettingsError::InvalidTracingFormat{text} if text == String::from("invalid") + ) + ); + // Verify display and to string + assert_eq!(ExampleField::Foo.to_string(), "foo"); + assert_eq!(&format!("Variant {}", ExampleField::Foo), "Variant foo"); + assert_eq!(&format!("Variant {}", ExampleField::Bar), "Variant bar"); + assert_eq!(&format!("Variant {}", ExampleField::Baz), "Variant baz"); + assert_eq!(ExampleField::Bar.to_string(), "bar"); + assert_eq!(ExampleField::Baz.to_string(), "baz"); + // Verify variant constants for error messages + assert_eq!( + ExampleField::VARIANT_LIST, + "`foo`, `bar`" + ); + assert_eq!( + ExampleField::VARIANT_LAST, + "`baz`" + ); + } +} \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/fields/macros/utility.rs b/lib/dsc-lib/src/settings/fields/macros/utility.rs new file mode 100644 index 000000000..008d8be68 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/macros/utility.rs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Utility macro for counting identifiers, needed for constant array +/// definitions. Not intended for direct use. +/// +/// This macro counts the number of identifiers passed to it. It is primarily +/// used for defining constant arrays where the length needs to be determined +/// at compile time. +/// +/// Used by [`define_string_enum_field!`]. +/// +/// [`define_string_enum_field!`]: super::define_string_enum_field! +macro_rules! count_idents { + ($($ident:ident),*) => { + <[()]>::len(&[$($crate::settings::fields::macros::utility::count_idents!(@replace $ident)),*]) + }; + (@replace $ident:ident) => { () }; +} + +pub(crate) use count_idents; + +/// Utility macro for generating a comma-separated list of literals enclosed in +/// backticks. Not intended for direct use. +/// +/// This macro is primarily used for defining the `VARIANT_LIST` and +/// `VARIANT_LAST` constants in the [`define_variant_consts!`] macro. +/// +/// Used by [`define_variant_consts!`] in [`define_string_enum_field!`]. +/// +/// [`define_string_enum_field!`]: super::define_string_enum_field! +macro_rules! backtick_list { + ($last:literal) => { + concat!("`", $last, "`") + }; + ($first:literal, $($rest:literal),+) => { + concat!( + "`", + $first, + "`, ", + $crate::settings::fields::macros::utility::backtick_list!($($rest),+) + ) + }; +} + +pub(crate) use backtick_list; + +/// Defines constants for the variants of a string enum field. Not intended for +/// direct use. +/// +/// This macro generates two private (crate public) constants: +/// +/// - `VARIANT_LIST`: A comma-separated list of all variant names except the +/// last one, formatted with backticks. +/// - `VARIANT_LAST`: The last variant name, formatted with backticks. +/// +/// Used by [`define_string_enum_field!`]. +/// +/// [`define_string_enum_field!`]: super::define_string_enum_field! +macro_rules! define_variant_consts { + ($($item:literal),+ $(,)?) => { + $crate::settings::fields::macros::utility::define_variant_consts!(@split [] $($item),+); + }; + + // Only one identifier remains, so it is the final variant. + (@split [$($preceding:literal),+] $last:literal) => { + /// A comma-separated list of all variant names except the last one, + /// formatted with backticks. Only intended for error messages. + pub(crate) const VARIANT_LIST: &'static str = + $crate::settings::fields::macros::utility::backtick_list!($($preceding),+); + + /// The last variant name, formatted with backticks. Only intended for + /// error messages. + pub(crate) const VARIANT_LAST: &'static str = + $crate::settings::fields::macros::utility::backtick_list!($last); + }; + + // Move identifiers into the accumulator until one remains. + (@split [$($preceding:literal),*] $next:literal, $($rest:literal),+) => { + $crate::settings::fields::macros::utility::define_variant_consts!(@split [$($preceding,)* $next] $($rest),+); + }; +} + +pub(crate) use define_variant_consts; diff --git a/lib/dsc-lib/src/settings/fields/mod.rs b/lib/dsc-lib/src/settings/fields/mod.rs new file mode 100644 index 000000000..66049f3d6 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/mod.rs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines the fields used in DSC settings. + +#[cfg(doc)] +pub(super) mod _implementation_guidance { + //! This documentation describes the structure and provides guidance for implementing new + //! fields in DSC settings. + //! + //! Follow this guidance when defining new top-level fields in DSC settings: + //! + //! 1. Define a private submodule for the setting field in this module. The submodule should + //! have the same name as the field. + //! 1. Re-export all public items from the submodule in this module. + //! + //! For example, if the field is `new_area`, you would add the following lines to this file: + //! + //! ```ignore + //! mod new_area; + //! pub use new_area::*; + //! ``` + //! + //! Additional guidance is provided in the following sections for defining leaf fields and + //! container fields. For the purposes of this guidance, a container field is a field that has + //! one or more subfields (like [`resource_path`]), while a leaf field is a field that doesn't + //! have any subfields (like [`forbid_ignore_settings_file`]). + //! + //! # Leaf fields + //! + //! ## Macro implementation + //! + //! If the new leaf field is for either a boolean value or a string enum, you can use the + //! [`define_boolean_field!`] or [`define_string_enum_field!`] macros. + //! + //! The macros generate the required `*Field` type, `CODE_DEFAULT_*` constant, and every + //! required trait implementation. + //! + //! For more information, see the [`define_boolean_field!`] and [`define_string_enum_field!`] + //! macro documentation. + //! + //! ## Manual implementation + //! + //! In the submodule for a leaf field, follow this guidance to define the types and constants + //! needed to represent the field in DSC settings: + //! + //! 1. Define a new type for the field: + //! + //! - Use the naming convention `Field`, like `NewAreaField`. + //! - Implement (or derive) [`Clone`], [`Debug`], [`PartialEq`], [`Eq`], [`Serialize`], + //! [`Deserialize`], [`JsonSchema`], and [`DscRepoSchema`] for the type. + //! - If the type is a newtype wrapper around a standard Rust type, implement the following + //! traits for the newtype to ensure that the newtype can be used seamlessly in contexts + //! where the underlying type is expected: [`AsRef`], [`Deref`], [`From`] (bidirectionally), + //! and [`PartialEq`] (bidirectionally). + //! + //! 1. Define a constant for the code default value of the field: + //! + //! - Use the naming convention `CODE_DEFAULT_`, like `CODE_DEFAULT_NEW_AREA`. + //! + //! 1. Ensure that the appropriate field is defined in the following structs for the setting: + //! + //! - [`PolicyFileData`] + //! - [`PreferenceFileData`] + //! + //! 1. Ensure that the [`DscSettingsCodeDefaults`] struct defines the field with the + //! appropriate value type and update the [`DSC_SETTINGS_CODE_DEFAULTS`] constant by setting + //! the field to the code default constant for the field. + //! 1. Ensure that the [`DscSettingsResolved`] struct defines the field as a + //! [`DscSettingsResolvedField`] with the appropriate value type and update the + //! [`Default`] implementation for the struct to initialize the field with the code default + //! constant. + //! 1. Every top-level leaf field in DSC settings must be definable as an environment variable. + //! Follow the guidance in `settings::sources::environment` to define the appropriate field + //! in [`EnvironmentData`] + //! 1. If the field is definable in the command line arguments, follow the guidance in + //! `settings::sources::cli` to define the command line argument for the field in + //! [`CommandLineData`]. + //! + //! # Container fields + //! + //! In the submodule for a container field, follow this guidance to define the types and + //! constants needed to represent the field in DSC settings: + //! + //! ## Macro implementation + //! + //! The simplest option for defining a container field is to use the + //! [`define_container_field!`] macro. + //! + //! The macro generates the required `*FileData`, `*CodeDefaults`, and `*ResolvedSettings` + //! structs as well as the `CODE_DEFAULT_*` constant for the container field. It also generates + //! all the required trait implementations. + //! + //! For more information, see the [`define_container_field!`] macro documentation. + //! + //! ## Manual implementation + //! + //! 1. Define a struct to represent the container setting in settings files: + //! + //! - If the setting is identical in both the preference file and the policy file, define a + //! single struct for the setting container using the naming convention + //! `FileData`, like `NewAreaFileData`. + //! - If the setting is different between the preference file and the policy file, define + //! separate structs for each using the following naming conventions: + //! + //! - `PreferenceFileData`, like `NewAreaPreferenceFileData` + //! - `PolicyFileData`, like `NewAreaPolicyFileData + //! + //! - When defining both file data structs, ensure that the `*PolicyFileData` struct is + //! _always_ a superset of the `*PreferenceFileData` struct. This ensures that any field + //! defined in the preference file can also be defined in the policy file. Policy must + //! _always_ be able to override preferences. + //! - Define _every_ field in file data structs as an `Option`. No field in a data file must + //! be required. If a field is not defined in a data file, it will be `None` in the + //! corresponding struct. + //! - As needed, define types for leaf fields in the file data structs. Use the naming + //! convention `Field`, like `NewAreaFooField`. + //! - As needed, define structs for nested container fields in the file data structs. Use + //! the naming convention `FileData`, like + //! `NewAreaFooFileData`. + //! + //! 1. Define a struct to represent the code defaults for the setting container: + //! + //! - Use the naming convention `CodeDefaults`, like + //! `NewAreaCodeDefaults`. + //! - Define every leaf field in the code defaults struct as the appropriate value type. + //! Don't define any fields as `Option` in the code defaults struct. + //! - If you defined any structs for nested container fields in the file data structs, + //! define a corresponding struct for the code defaults. Use the naming convention + //! `CodeDefaults`, like `NewAreaFooCodeDefaults`. + //! + //! 1. Define a constant for the code defaults: + //! - Use the naming convention `CODE_DEFAULT_`, like + //! `CODE_DEFAULT_NEW_AREA`. + //! - Define the constant with the appropriate values for every field. + //! + //! 1. Define a struct to represent the resolved settings for the setting container: + //! + //! - Use the naming convention `ResolvedSettings`, like + //! `NewAreaResolvedSettings`. + //! - Define every leaf field in the resolved settings struct as a + //! [`DscSettingsResolvedField`] with the appropriate value type. + //! - If you defined any structs for nested container fields in the file data structs, + //! define a corresponding struct for the resolved settings. Use the naming convention + //! `ResolvedSettings`, like `NewAreaFooResolvedSettings`. + //! + //! 1. Ensure that the following traits are implemented for every type defined in this module: + //! + //! - Always implement (or derive) [`Clone`], [`Debug`], [`PartialEq`], [`Eq`], + //! [`Serialize`], [`Deserialize`], and [`JsonSchema`]. + //! - Implement [`Default`] for every `*FileData` and `*ResolvedSettings` struct. You can + //! derive the implementation for `*FileData` structs, but you must implement it manually + //! for `*ResolvedSettings` structs. The implementation for `*ResolvedSettings` structs + //! must initialize every field with the appropriate code default value and a scope of + //! [`DscSettingsScope::Default`]. + //! + //! 1. Ensure that the appropriate field is defined in the following structs for the settings + //! container: + //! + //! - [`PolicyFileData`] - define the field with the `*PolicyFileData` struct type or the + //! `*FileData` struct type. + //! - [`PreferenceFileData`] - define the field with the `*PreferenceFileData` struct type + //! or the `*FileData` struct type. + //! - [`DscSettingsCodeDefaults`] - define the field with the `*CodeDefaults` struct type. + //! - [`DscSettingsResolved`] - define the field with the `*ResolvedSettings` struct type. + //! 1. If any settings for the container are definable in the environment, follow the guidance + //! in `crate::settings::sources::environment` to define the appropriate field in + //! [`EnvironmentData`]. + //! 1. If any settings for the container are definable in the command line arguments, follow + //! the guidance in `crate::settings::sources::command_line` to define the appropriate + //! field in [`CommandLineData`]. + //! + // + //! [`CommandLineData`]: crate::settings::sources::CommandLineData + //! [`Debug`]: std::fmt::Debug + //! [`define_boolean_field!`]: crate::settings::fields::macros::define_boolean_field + //! [`define_container_field!`]: crate::settings::fields::macros::define_container_field + //! [`define_string_enum_field!`]: crate::settings::fields::macros::define_string_enum_field + //! [`Deref`]: std::ops::Deref + //! [`Deserialize`]: serde::Deserialize + //! [`DSC_SETTINGS_CODE_DEFAULTS`]: crate::settings::sources::DSC_SETTINGS_CODE_DEFAULTS + //! [`DscRepoSchema`]: crate::schemas::dsc_repo::DscRepoSchema + //! [`DscSettingsCodeDefaults`]: crate::settings::sources::DscSettingsCodeDefaults + //! [`DscSettingsResolved`]: crate::settings::DscSettingsResolved + //! [`DscSettingsResolvedField`]: crate::settings::DscSettingsResolvedField + //! [`DscSettingsScope::Default`]: crate::settings::DscSettingsScope::Default + //! [`EnvironmentData`]: crate::settings::sources::EnvironmentData + //! [`forbid_ignore_settings_file`]: super::ForbidIgnoreSettingsFileField + //! [`JsonSchema`]: schemars::JsonSchema + //! [`PolicyFileData`]: crate::settings::sources::PolicyFileData + //! [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + //! [`resource_path`]: super::ResourcePathFileData + //! [`Serialize`]: serde::Serialize +} + +// Define macros for ergonomic settings definitions +mod macros; + +// Define a module for every top-level field and re-export directly. +mod forbid_ignore_settings_file; +pub use forbid_ignore_settings_file::*; +mod ignore_settings_file; +pub use ignore_settings_file::*; +mod resource_path; +pub use resource_path::*; +mod tracing; +pub use tracing::*; diff --git a/lib/dsc-lib/src/settings/fields/resource_path.rs b/lib/dsc-lib/src/settings/fields/resource_path.rs new file mode 100644 index 000000000..b41dfbfe6 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/resource_path.rs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::schemas::dsc_repo::DscRepoSchema; +use super::macros::*; + +define_container_field!{ + base_name: "resourcePath", + folder_path: "settings/fields", + field_definition: { + /// Defines the available settings for the resource path configuration + /// in DSC. + /// + /// This struct is used to represent the settings as they are defined + /// in both [`PolicyFileData`] and [`PreferenceFileData`]. + /// + /// DSC discovers manifest files by searching a collection of + /// directories for files with known extensions. By default, DSC only + /// searches the `PATH` environment variable and the directory that DSC + /// is installed in. + /// + /// When invoking commands for a manifest, DSC supports invoking + /// commands that exist in `PATH` or that can be resolved relative to + /// the manifest file. + /// + /// The resource path settings define how DSC discovers manifest files + /// and invokes commands: + /// + /// - When [`directories`] is resolved, DSC always searches the + /// specified directories for manifests and can invoke commands from + /// those directories. + /// - When [`append_env_path`] is resolved as `true`, DSC appends the + /// `PATH` environment variable to the resolved value for + /// [`directories`] when searching for manifests. + /// - When [`restricted`] is resolved as `true`, DSC restricts the + /// paths from which it can invoke binaries to the resolved value for + /// [`directories`]. When this setting is `true` it effectively + /// ignores the `append_env_path` setting and doesn't append the + /// `PATH` environment variable to set of directories. DSC will only + /// discover and invoke commands from resolved value of + /// [`directories`]. + /// + ///
+ ///
Restricted path behavior + /// + /// When you set [`restricted`] to `true`, DSC will _only_ discover and + /// invoke commands from the resolved value of [`directories`]. This + /// explicitly _does not_ consider the `PATH` environment variable or + /// the DSC installation directory. + /// + /// To use built-in resources and extensions, you need to ensure that + /// [`directories`] includes the DSC installation directory. + /// + /// To use resources and extensions that require invoking commands that + /// aren't adjacent to the manifest file, you need to ensure that + /// [`directories`] includes the directories containing those commands. + /// + ///
+ ///
+ /// + /// To configure how DSC discovers manifests and invokes commands, + /// users can: + /// + /// 1. Define the `resourcePath.directories`, + /// `resourcePath.appendEnvPath`, and `resourcePath.restricted` + /// fields in the [`Policy`], [`Machine`], [`User`], or [`Workspace`] + /// settings files. + /// 1. Define the [`DSC_RESOURCE_PATH`] environment variable as a + /// string containing a collection of directories separated by the + /// platform specific path separator for `PATH`-like environment + /// variables. This effectively populates the [`directories`] field + /// in the resolved settings. + /// 1. Define the [`DSC_RESTRICTED_PATH`] environment variable as a + /// string containing a collection of directories separated by the + /// platform specific path separator for `PATH`-like environment + /// variables. This effectively populates the [`directories`] field + /// in the resolved settings and sets the [`restricted`] field in + /// the resolved settings to `true`. + /// + /// # Examples + /// + /// The following examples clarify the effective behavior of DSC + /// depending on the resource path settings. Each scenario shows the + /// effective resolved settings for the resource path as a YAML snippet + /// before explaining how DSC behaves when discovering manifests and + /// invoking commands. + /// + /// 1. ```yaml + /// resourcePath: + /// directories: [] + /// append_env_path: true + /// restricted: false + /// ``` + /// + /// DSC searches the directories in the `PATH` environment variable + /// and the directory that DSC is installed in for manifests. When + /// invoking commands for a manifest, DSC can invoke commands that + /// exist in `PATH`, the DSC installation directory, and relative to + /// any discovered manifest file. + /// + /// 1. ```yaml + /// resourcePath: + /// directories: ["D:\infra\resources", "D:\infra\tools"] + /// append_env_path: true + /// restricted: false + /// ``` + /// + /// DSC searches the speciied directories, the directories in the + /// `PATH` environment variable, and the directory that DSC is + /// installed in for manifest files. When invoking commands for a + /// manifest, DSC can invoke commands that exist those same + /// directories and relative to any discovered manifest file. + /// + /// 1. ```yaml + /// resourcePath: + /// directories: ["D:\infra\resources", "D:\infra\tools"] + /// append_env_path: false + /// restricted: true + /// ``` + /// + /// DSC _only_ searches the specified directories for manifest files + /// and does not consider the `PATH` environment variable or the DSC + /// installation directory. When invoking commands for a manifest, + /// DSC can only invoke commands that exist in the specified + /// directories. Attempting to invoke commands outside of the + /// specified directories raises an error. + /// + /// [`PolicyFileData`]: crate::settings::sources::PolicyFileData + /// [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + /// [`directories`]: Self::directories + /// [`append_env_path`]: Self::append_env_path + /// [`restricted`]: Self::restricted + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`DSC_RESOURCE_PATH`]: crate::settings::sources::EnvironmentData::dsc_resource_path + /// [`DSC_RESTRICTED_PATH`]: crate::settings::sources::EnvironmentData::dsc_restricted_path + #[schemars(extend( + "unevaluatedProperties" = false, + "allOf" = [ + { // if restricted is true, appendEnvPath must be false or not defined + "if": { + "required": ["restricted"], + "properties": { "restricted": { "const": true } } + }, + "then": { + "oneOf": [ + { "not": { "required": ["appendEnvPath"] } }, + { "required": ["appendEnvPath"], "properties": { "appendEnvPath": { "const": false } } } + ] + }, + }, + { // if appendEnvPath is true, restricted must be false or not defined + "if": { + "required": ["appendEnvPath"], + "properties": { "appendEnvPath": { "const": true } } + }, + "then": { + "oneOf": [ + { "not": { "required": ["restricted"] } }, + { "required": ["restricted"], "properties": { "restricted": { "const": false } } } + ] + }, + } + ] + ))] + struct ResourcePathFileData { + /// Directories that DSC should search for executables and + /// manifests. + /// + /// This field specifies the list of directories that DSC will + /// search for executables and manifests. + /// + /// If [`restricted`] is set to `true`, DSC will only invoke + /// executables in the specified directories. + /// + /// [`restricted`]: Self::restricted + directories: Vec = Vec::new(), + /// Whether to append the `PATH` environment variable. + /// + /// If set to `false`, DSC will _not_ append the `PATH` environment + /// variable to the list of directories. + /// + /// If [`restricted`] is set to `true`, DSC ignores this setting. + /// DSC will only search the paths specified in the [`directories`] + /// field. Attempts to invoke any executables outside of those + /// directories will raise an error. + /// + /// [`restricted`]: Self::restricted + /// [`directories`]: Self::directories + append_env_path: bool = true, + /// Whether to restrict execution to the specified directories. + /// + /// If set to `true`, DSC will restrict execution to the paths + /// specified in the [`directories`] field and ignore the + /// [`append_env_path`] setting. + /// + /// [`directories`]: Self::directories + /// [`append_env_path`]: Self::append_env_path + restricted: bool = false, + } + }, + resolved_container_type: { + /// Defines the resolved settings for the resource path settings + /// in DSC. + /// + /// For more information about the available settings, see + /// [`ResourcePathFileData`]. + ResourcePathResolvedSettings + }, + code_default_type: { + /// Defines the structure of the code defaults for the resource path + /// settings in DSC. + /// + /// For more information about the available settings, see + /// [`ResourcePathFileData`]. For more information about the code + /// defaults, see [`CODE_DEFAULT_RESOURCE_PATH`]. + ResourcePathCodeDefaults + }, + code_default_const: { + /// Defines the default values for the resource path configuration in + /// DSC. + /// + /// The following snippet shows the effective code defaults as YAML + /// data: + /// + /// ```yaml + /// resource_path: + /// directories: [] + /// append_env_path: true + /// restricted: false + /// ``` + /// + /// With these code defaults, DSC will append the `PATH` environment + /// to the empty set of directories and won't restrict execution to + /// those directories. + /// + /// For more information about defining the settings, see + /// [`ResourcePathFileData`]. + CODE_DEFAULT_RESOURCE_PATH + } +} diff --git a/lib/dsc-lib/src/settings/fields/tracing.rs b/lib/dsc-lib/src/settings/fields/tracing.rs new file mode 100644 index 000000000..24c250747 --- /dev/null +++ b/lib/dsc-lib/src/settings/fields/tracing.rs @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::macros::*; + +use crate::schemas::dsc_repo::DscRepoSchema; + +define_container_field!{ + base_name: "tracing", + folder_path: "settings/fields", + field_definition: { + /// Defines the available settings for the tracing configuration in DSC. + /// + /// This struct is used to represent the settings as they are defined + /// in both [`PolicyFileData`] and [`PreferenceFileData`]. + /// + /// DSC emits messages to `stderr` to provide information about the + /// execution lifecycle. Every message is emitted with a different + /// severity level. DSC supports emitting trace messages in multiple + /// formats. + /// + /// # Trace level + /// + /// By default, DSC emits only [`Warn`] and [`Error`] messages to + /// stderr. + /// + /// Users can define what level of messages to emit by: + /// + /// 1. Defining the `tracing.level` field in the [`Policy`], + /// [`Machine`], [`User`], or [`Workspace`] settings files. + /// 1. Defining the [`DSC_TRACE_LEVEL`] environment variable. + /// 1. Specifying the [`--trace-level`] global command line argument. + /// + /// When the trace level is set, DSC will emit messages of the + /// specified severity and higher to stderr. For example, if the trace + /// level is set to [`Info`], DSC will emit messages with [`Info`], + /// [`Warn`], and [`Error`] severity levels. DSC won't emit messages + /// with [`Debug`] or [`Trace`] severity levels. + /// + /// # Trace format + /// + /// By default, DSC emits messages to stderr as colorized human-readable + /// text. + /// + /// Users can override the default trace format by: + /// + /// 1. Defining the `tracing.format` field in the [`Policy`], + /// [`Machine`], [`User`], or [`Workspace`] settings files. + /// 1. Defining the [`DSC_TRACE_FORMAT`] environment variable. + /// 1. Specifying the [`--trace-format`] global command line argument. + /// + /// The available trace formats include: + /// + /// - [`Default`]: DSC emits messages to stderr in the default colorized + /// human-readable text format. + /// - [`Plaintext`]: DSC emits messages to stderr as human readable text + /// without colorization. + /// - [`Json`]: DSC emits messages to stderr as compressed JSON objects. + /// + /// [`PolicyFileData`]: crate::settings::sources::PolicyFileData + /// [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + /// [`Error`]: TracingLevelField::Error + /// [`Warn`]: TracingLevelField::Warn + /// [`Info`]: TracingLevelField::Info + /// [`Debug`]: TracingLevelField::Debug + /// [`Trace`]: TracingLevelField::Trace + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`Default`]: TracingFormatField::Default + /// [`Plaintext`]: TracingFormatField::Plaintext + /// [`Json`]: TracingFormatField::Json + /// [`DSC_TRACE_LEVEL`]: crate::settings::sources::EnvironmentData::dsc_trace_level + /// [`DSC_TRACE_FORMAT`]: crate::settings::sources::EnvironmentData::dsc_trace_format + /// [`--trace-level`]: crate::settings::sources::CommandLineData::trace_level + /// [`--trace-format`]: crate::settings::sources::CommandLineData::trace_format + #[schemars(extend("unevaluatedProperties" = false))] + struct TracingFileData { + /// Specifies the trace level to use for logging and diagnostics. + /// + /// This field controls the verbosity of the logs generated by DSC. + /// Messages with a lower level than the specified trace level will + /// be filtered out and not emitted. + /// + /// The available trace levels, in order of increasing verbosity, + /// are: + /// + /// - `error` + /// - `warn` + /// - `info` + /// - `debug` + /// - `trace` + level: TracingLevelField = TracingLevelField::Warn, + /// Specifies the trace format to use for logging and diagnostics. + /// + /// This field controls the text that DSC emits to stderr for trace + /// messages. + /// + /// The available trace formats are: + /// + /// - `default` - Colorized human-readable text with timestamps and + /// log levels. + /// - `plaintext` - Human-readable text without colorization. + /// - `json` - Structured output as compressed JSON objects + /// suitable for machine parsing. + format: TracingFormatField = TracingFormatField::Default, + } + }, + resolved_container_type: { + /// Defines the resolved settings for tracing in DSC. + /// + /// For more information about the available settings, see + /// [`TracingFileData`]. + TracingResolvedSettings + }, + code_default_type: { + /// Defines the structure of the code defaults for the tracing settings + /// in DSC. + /// + /// For more information about the available settings, see + /// [`TracingFileData`]. For more information about the defaults, see + /// [`CODE_DEFAULT_TRACING`]. + TracingCodeDefaults + }, + code_default_const: { + /// Defines the default values for the tracing configuration in DSC. + /// + /// The following snippet shows the effective code defaults as YAML + /// data: + /// + /// ```yaml + /// tracing: + /// level: warn + /// format: default + /// ``` + /// + /// With these defaults, DSC will emit warnings and higher-severity + /// messages in the default trace format, which emits human-readable + /// text to `stderr` with colorization and timestamps. + /// + /// For more information about defining the settings, see + /// [`TracingFileData`]. + CODE_DEFAULT_TRACING + } +} + +define_string_enum_field!{ + base_name: "level", + folder_path: "settings/fields/tracing", + parse_error_variant: InvalidTracingLevel, + field_definition: { + /// Represents the levels of trace messages that DSC can emit. + /// + /// The levels are ordered from least to most verbose: + /// + /// 1. [`Error`] + /// 1. [`Warn`] + /// 1. [`Info`] + /// 1. [`Debug`] + /// 1. [`Trace`] + /// + /// When emitting trace messages to stderr, DSC filters messages based + /// on the configured trace level. DSC only emits messages at or above + /// the configured level. For example, if the trace level is set to + /// [`Warn`], DSC will emit warning and error messages, but not + /// informational, debug, or trace messages. + /// + /// [`Error`]: Self::Error + /// [`Warn`]: Self::Warn + /// [`Info`]: Self::Info + /// [`Debug`]: Self::Debug + /// [`Trace`]: Self::Trace + enum TracingLevelField { + /// Indicates that DSC should only emit error messages. + Error { display: "error", lowercase: "error" }, + /// Indicates that DSC should emit warning and error messages. + Warn { display: "warn", lowercase: "warn" }, + /// Indicates that DSC should emit informational, warning, and + /// error messages. + Info { display: "info", lowercase: "info" }, + /// Indicates that DSC should emit debug, informational, warning, + /// and error messages. + Debug { display: "debug", lowercase: "debug" }, + /// Indicates that DSC should emit all messages, including trace + /// messages. + Trace { display: "trace", lowercase: "trace" }, + } + } +} + +impl From for tracing::Level { + fn from(level: TracingLevelField) -> Self { + match level { + TracingLevelField::Error => tracing::Level::ERROR, + TracingLevelField::Warn => tracing::Level::WARN, + TracingLevelField::Info => tracing::Level::INFO, + TracingLevelField::Debug => tracing::Level::DEBUG, + TracingLevelField::Trace => tracing::Level::TRACE, + } + } +} + +impl From for TracingLevelField { + fn from(level: tracing::Level) -> Self { + match level { + tracing::Level::ERROR => TracingLevelField::Error, + tracing::Level::WARN => TracingLevelField::Warn, + tracing::Level::INFO => TracingLevelField::Info, + tracing::Level::DEBUG => TracingLevelField::Debug, + tracing::Level::TRACE => TracingLevelField::Trace, + } + } +} + +define_string_enum_field!{ + base_name: "format", + folder_path: "settings/fields/tracing", + parse_error_variant: InvalidTracingFormat, + field_definition: { + /// Indicates how DSC should format trace messages when emitting them + /// to `stderr`. + /// + /// The available trace formats are: + /// + /// - [`Default`] - Colorized human-readable text with timestamps and + /// log levels. + /// - [`Plaintext`] - Human-readable text without colorization. + /// - [`Json`] - Structured output as compressed JSON objects suitable + /// for machine parsing. + /// + /// [`Default`]: Self::Default + /// [`Plaintext`]: Self::Plaintext + /// [`Json`]: Self::Json + enum TracingFormatField { + /// Indicates that DSC should emit trace messages to `stderr` in + /// the default human-readable format. + Default { display: "default", lowercase: "default" }, + /// Indicates that DSC should emit trace messages to `stderr` in a + /// plain text format. + Plaintext { display: "plaintext", lowercase: "plaintext" }, + /// Indicates that DSC should emit trace messages to `stderr` in a + /// structured JSON format. + Json { display: "json", lowercase: "json" }, + } + } +} diff --git a/lib/dsc-lib/src/settings/mod.rs b/lib/dsc-lib/src/settings/mod.rs new file mode 100644 index 000000000..592a985c1 --- /dev/null +++ b/lib/dsc-lib/src/settings/mod.rs @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines how to load and resolve settings for DSC. +//! +//! DSC uses a layered approach to settings, composing a set of resolved settings from multiple +//! sources. The [`DscSettings`] struct represents the complete model of settings, including all +//! sources and the resolved effective settings. +//! +//! When DSC starts, it loads settings from various [scopes]. Every scope maps to a specific source. +//! After loading the settings from all sources, DSC resolves the effective settings, starting with +//! the default settings defined in the source code and then ensuring that the resolved settings +//! reflect the precedence scope that defined each setting. +//! +//! # Scopes and sources +//! +//! The following table defines the scopes and their corresponding sources, in order of precedence +//! from lowest to highest, where later scopes override settings defined in earlier scopes: +//! +//! | Scope | Source | Description | +//! |:---------------:|:------------------------------:|-------------| +//! | [`Default`] | [`DSC_SETTINGS_CODE_DEFAULTS`] | The hardcoded default settings defined in the source code. | +//! | [`Machine`] | [`PreferenceFileData`] | A settings file defining preferences for every user on the machine. | +//! | [`User`] | [`PreferenceFileData`] | A settings file defining preferences for the current user. | +//! | [`Workspace`] | [`PreferenceFileData`] | A settings file defining preferences for the current workspace. | +//! | [`Environment`] | [`EnvironmentData`] | Settings loaded from the environment variables | +//! | [`CommandLine`] | [`CommandLineData`] | Settings loaded from the command line arguments. | +//! | [`Policy`] | [`PolicyFileData`] | A settings file defining policy for every user. | +//! +//! Every available setting for DSC can be defined in the [`Policy`] scope, which has the highest +//! precedence and can't be overridden by any other scope. Users can define a policy file to enforce +//! specific settings for all users on a machine. +//! +//! The [`Machine`], [`User`], and [`Workspace`] scopes all support users defining a preference file +//! that contains settings for DSC. Preference files use the same data structure as policy files, +//! but not every field in a policy file is supported in a preference file. For example, the +//! [`forbid_ignore_settings_file`] field is only supported in a policy file because it controls +//! whether DSC should load and resolve settings from the preference files. Defining this field in +//! a preference file wouldn't make sense. +//! +//! The [`Environment`] scope allows users to define settings for DSC as environment variables. Not +//! every setting is supported as an environment variable,but every environment variable setting +//! maps to either a specific field in the policy file or a combination of fields in the policy +//! file. For example, users can define either the [`DSC_RESOURCE_PATH`] or the +//! [`DSC_RESTRICTED_PATH`] environment variable as a collection of directories separated by the +//! platform specific path separator for `PATH`-like environment variables. When DSC processes these +//! environment variables, DSC splits the values using the platform specific path separator and uses +//! the resulting collection to populate the [`resource_path.directories`] field in the resolved +//! settings. Additionally, the [`DSC_RESTRICTED_PATH`] environment variable populates the +//! [`resource_path.restricted`] field in the resolved settings. +//! +//! The [`CommandLine`] scope allows users to define settings for DSC as global command line +//! arguments. Only a few settings are supported as command line arguments to minimize the clutter +//! and cognitive load when invoking DSC commands. +//! +//! ## Loading settings +//! +//! With the exception of the [`Default`] and [`CommandLine`] scopes, DSC automatically attempts to +//! load settings from all other scopes during initialization. The [`Default`] scope is statically +//! defined in the source code and the [`CommandLine`] scope must be passed to the +//! [`new_with_command_line()`] constructor. +//! +//! The other scopes are loaded automatically by calling either the [`load()`] or [`try_load()`] +//! methods. The [`load()`] method attempts to load all sources and converts any errors into +//! warnings, while the [`try_load()`] method attempts to load all sources and collects any errors +//! into a single error value. +//! +//! When loading settings for the [`Policy`], [`Machine`], [`User`], and [`Workspace`] scopes, DSC +//! checks whether the corresponding settings file exists. If the file exists, DSC tries to read +//! and parse the file into the appropriate data structure ([`PolicyFileData`] for [`Policy`] +//! and [`PreferenceFileData`] for the others). +//! +//! When loading settings for the [`Environment`] scope, DSC checks whether the relevant environment +//! variables are defined and reads their values if they exist, parsing them into the appropriate +//! data type for the backing field in [`EnvironmentData`]. +//! +//! # Resolving settings +//! +//! DSC represents the resolved effective settings in an instance of [`DscSettingsResolved`]. Every +//! field in this struct is either a _leaf_ field where the type is [`DscSettingsResolvedField`], or +//! a _container_ field where the type is another struct with its own leaf and container fields. The +//! leaf fields contain the final value for that setting and the scope that defined it, so users +//! can understand which settings were defined in which scope with what value. +//! +//! When resolving settings, DSC supports overriding any _leaf_ field in the settings data structure +//! when that field is defined in a higher-precedence scope. It doesn't replace the entire container +//! with the value from the higher-precedence scope. This ensures that users can define only the +//! specific settings they want to override in a higher-precedence scope without needing to redefine +//! the entire collection of settings in that scope. +//! +//! For example, if the machine settings file defines both [`tracing.level`] and [`tracing.format`], +//! and the user settings file defines only [`tracing.format`], the final resolved settings will +//! match the following YAML snippet: +//! +//! ```yaml +//! tracing: +//! level: +//! scope: machine +//! value: +//! format: +//! scope: user +//! value: +//! ``` +//! +//! ## Resolution steps +//! +//! After loading settings from all sources, DSC follows these steps to resolve the effective +//! settings: +//! +//! 1. Initialize the resolved settings with the default settings defined in the source code. +//! 1. Any settings defined in the [`Policy`] scope override the code defaults. These settings are +//! applied first because they have the highest precedence and can't be overridden by any other +//! scope. +//! 1. Process the settings defined in the remaining scopes in precedence order, overriding any +//! settings from the code defaults or prior scopes unless they were defined in the [`Policy`] +//! scope. The order of precedence for these scopes is as follows: +//! +//! - [`Machine`] +//! - [`User`] +//! - [`Workspace`] +//! - [`Environment`] +//! - [`CommandLine`] +//! +//! After the initial resolution, DSC caches the resolved settings in a private field of the +//! [`DscSettings`] instance. Repeated access to the resolved settings with [`resolved()`] will use +//! the cached values, ensuring efficient retrieval without reprocessing every loaded source. +//! +//! # Available settings +//! +//! The following sections provide an overview of the available settings, how they affect DSC, and +//! how to define them in the various scopes. +//! +//! ## Ignoring settings files +//! +//! By default, DSC automatically laods and resolves settings from the [`Machine`], [`User`], and +//! [`Workspace`] settings files if they exist. +//! +//! DSC defines two opposing settings that control whether DSC should load and resolve settings +//! from the [`Machine`], [`User`], and [`Workspace`] settings files. By default, DSC loads and +//! resolves settings from these files if they exist. +//! +//! A user can control whether DSC should ignore the preference settings files by: +//! +//! 1. Defining the [`ignore_settings_file`] field in the [`Policy`] settings file. +//! 1. Defining the [`DSC_IGNORE_SETTINGS_FILE`] environment variable. +//! 1. Specifying the [`--ignore-settings-file`] global command line argument. +//! +//! When the resolved value for the field is `true`, DSC will ignore the preference settings files +//! when loading and resolving settings. +//! +//! Additionally, a user can forbid ignoring the preference settings files by defining the +//! [`forbid_ignore_settings_file`] field in the [`Policy`] settings file. When this field is +//! defined as `true`, DSC will always load and resolve settings from the preference files if +//! they exist. +//! +//! ## Tracing settings +//! +//! DSC emits messages to `stderr` to provide information about the execution lifecycle. Every +//! message is emitted with a different severity level. DSC supports emitting trace messages in +//! multiple formats. +//! +//! ### Trace level +//! +//! By default, DSC emits only [`Warn`] and [`Error`] messages to stderr. +//! +//! Users can define what level of messages to emit by: +//! +//! 1. Defining the [`tracing.level`] field in the [`Policy`], [`Machine`], [`User`], or +//! [`Workspace`] settings files. +//! 1. Defining the [`DSC_TRACE_LEVEL`] environment variable. +//! 1. Specifying the [`--trace-level`] global command line argument. +//! +//! When the trace level is set, DSC will emit messages of the specified severity and higher to +//! stderr. For example, if the trace level is set to [`Info`], DSC will emit messages with +//! [`Info`], [`Warn`], and [`Error`] severity levels. DSC won't emit messages with [`Debug`] +//! or [`Trace`] severity levels. +//! +//! ### Trace format +//! +//! By default, DSC emits messages to stderr as colorized human-readable text. +//! +//! Users can override the default trace format by: +//! +//! 1. Defining the [`tracing.format`] field in the [`Policy`], [`Machine`], [`User`], or +//! [`Workspace`] settings files. +//! 1. Defining the [`DSC_TRACE_FORMAT`] environment variable. +//! 1. Specifying the [`--trace-format`] global command line argument. +//! +//! The available trace formats include: +//! +//! - `default`: DSC emits messages to stderr in the default colorized human-readable text format. +//! - `plaintext`: DSC emits messages to stderr as human readable text without colorization. +//! - `json`: DSC emits messages to stderr as compressed JSON objects. +//! +//! ## Resource path settings +//! +//! DSC discovers manifest files by searching a collection of directories for files with known +//! extensions. By default, DSC only searches the `PATH` environment variable and the directory +//! that DSC is installed in. +//! +//! When invoking commands for a manifest, DSC supports invoking commands that exist in `PATH` or +//! that can be resolved relative to the manifest file. +//! +//! Unlike the tracing settings, which are independent of each other, the resource path settings +//! define how DSC discovers manifest files and invokes commands: +//! +//! - When [`directories`] is resolved, DSC always searches the specified directories for manifests +//! and can invoke commands from those directories. +//! - When [`append_env_path`] is resolved as `true`, DSC appends the `PATH` environment variable +//! to the resolved value for [`directories`] when searching for manifests. +//! - When [`restricted`] is resolved as `true`, DSC restricts the paths from which it can invoke +//! binaries to the resolved value for [`directories`]. When this setting is `true` it effectively +//! ignores the `append_env_path` setting and doesn't append the `PATH` environment variable to +//! set of directories. DSC will only discover and invoke commands from resolved value of +//! [`directories`]. +//! +//!
+//!
Restricted path behavior +//! +//! When you set [`restricted`] to `true`, DSC will _only_ discover and invoke commands from the +//! resolved value of [`directories`]. This explicitly _does not_ consider the `PATH` environment +//! variable or the DSC installation directory. +//! +//! To use built-in resources and extensions, you need to ensure that [`directories`] includes the +//! DSC installation directory. +//! +//! To use resources and extensions that require invoking commands that aren't adjacent to the +//! manifest file, you need to ensure that [`directories`] includes the directories containing +//! those commands. +//! +//!
+//!
+//! +//! To configure how DSC discovers manifests and invokes commands, users can: +//! +//! 1. Define the [`resourcePath.directories`], [`resourcePath.appendEnvPath`], and +//! [`resourcePath.restricted`] fields in the [`Policy`], [`Machine`], [`User`], or [`Workspace`] +//! settings files. +//! 1. Define the [`DSC_RESOURCE_PATH`] environment variable as a string containing a collection of +//! directories separated by the platform specific path separator for `PATH`-like environment +//! variables. This effectively populates the [`directories`] field in the resolved settings. +//! 1. Define the [`DSC_RESTRICTED_PATH`] environment variable as a string containing a collection +//! of directories separated by the platform specific path separator for `PATH`-like environment +//! variables. This effectively populates the [`directories`] field in the resolved settings and +//! sets the [`restricted`] field in the resolved settings to `true`. +//! +//! ### Resource path xamples +//! +//! The following examples clarify the effective behavior of DSC depending on the resource path +//! settings. Each scenario shows the effective resolved settings for the resource path as a YAML +//! snippet before explaining how DSC behaves when discovering manifests and invoking commands. +//! +//! 1. ```yaml +//! resourcePath: +//! directories: [] +//! append_env_path: true +//! restricted: false +//! ``` +//! +//! DSC searches the directories in the `PATH` environment variable and the directory that DSC +//! is installed in for manifests. When invoking commands for a manifest, DSC can invoke +//! commands that exist in `PATH`, the DSC installation directory, and relative to any +//! discovered manifest file. +//! 1. ```yaml +//! resourcePath: +//! directories: ["D:\infra\resources", "D:\infra\tools"] +//! append_env_path: true +//! restricted: false +//! ``` +//! +//! DSC searches the speciied directories, the directories in the `PATH` environment variable, +//! and the directory that DSC is installed in for manifest files. When invoking commands for a +//! manifest, DSC can invoke commands that exist those same directories and relative to any +//! discovered manifest file. +//! 1. ```yaml +//! resourcePath: +//! directories: ["D:\infra\resources", "D:\infra\tools"] +//! append_env_path: true +//! restricted: true +//! ``` +//! +//! DSC _only_ searches the specified directories for manifest files and does not consider the +//! `PATH` environment variable or the DSC installation directory. When invoking commands for a +//! manifest, DSC can only invoke commands that exist in the specified directories. Attempting +//! to invoke commands outside of the specified directories raises an error. +//! +//! [scopes]: DscSettingsScope +//! [`Default`]: DscSettingsScope::Default +//! [`Machine`]: DscSettingsScope::Machine +//! [`User`]: DscSettingsScope::User +//! [`Workspace`]: DscSettingsScope::Workspace +//! [`Environment`]: DscSettingsScope::Environment +//! [`CommandLine`]: DscSettingsScope::CommandLine +//! [`Policy`]: DscSettingsScope::Policy +//! [`DSC_RESOURCE_PATH`]: sources::EnvironmentData::dsc_resource_path +//! [`DSC_RESTRICTED_PATH`]: sources::EnvironmentData::dsc_restricted_path +//! [`--ignore-settings-file`]: sources::CommandLineData::ignore_settings_file +//! [`ignore_settings_file`]: sources::PolicyFileData::ignore_settings_file +//! [`DSC_IGNORE_SETTINGS_FILE`]: sources::EnvironmentData::dsc_ignore_settings_file +//! [`Error`]: fields::TracingLevelField::Error +//! [`Warn`]: fields::TracingLevelField::Warn +//! [`Info`]: fields::TracingLevelField::Info +//! [`Debug`]: fields::TracingLevelField::Debug +//! [`Trace`]: fields::TracingLevelField::Trace +//! [`tracing.level`]: fields::TracingFileData::level +//! [`tracing.format`]: fields::TracingFileData::format +//! [`DSC_TRACE_LEVEL`]: sources::EnvironmentData::dsc_trace_level +//! [`DSC_TRACE_FORMAT`]: sources::EnvironmentData::dsc_trace_format +//! [`--trace-level`]: sources::CommandLineData::trace_level +//! [`--trace-format`]: sources::CommandLineData::trace_format +//! [`resourcePath.directories`]: fields::ResourcePathFileData::directories +//! [`resourcePath.appendEnvPath`]: fields::ResourcePathFileData::append_env_path +//! [`resourcePath.restricted`]: fields::ResourcePathFileData::restricted +//! [`directories`]: fields::ResourcePathResolvedSettings::directories +//! [`restricted`]: fields::ResourcePathResolvedSettings::restricted +//! [`append_env_path`]: fields::ResourcePathResolvedSettings::append_env_path +//! [`resource_path.directories`]: fields::ResourcePathResolvedSettings::directories +//! [`resource_path.restricted`]: fields::ResourcePathResolvedSettings::restricted +//! [`new_with_command_line()`]: DscSettings::new_with_command_line +//! [`load()`]: DscSettings::load +//! [`try_load()`]: DscSettings::try_load +//! [`resolved()`]: DscSettings::resolved +//! [`DSC_SETTINGS_CODE_DEFAULTS`]: sources::DSC_SETTINGS_CODE_DEFAULTS +//! [`PreferenceFileData`]: sources::PreferenceFileData +//! [`EnvironmentData`]: sources::EnvironmentData +//! [`CommandLineData`]: sources::CommandLineData +//! [`PolicyFileData`]: sources::PolicyFileData +//! [`forbid_ignore_settings_file`]: sources::PolicyFileData::forbid_ignore_settings_file + +pub mod fields; +pub mod sources; +mod constants_and_statics; +pub use constants_and_statics::*; +mod dsc_settings_scope; +pub use dsc_settings_scope::DscSettingsScope; +#[cfg_attr(test, macro_use)] // Only enable macros for testing +mod dsc_settings; +pub use dsc_settings::*; +mod resolved; +pub use resolved::*; +mod errors; +pub use errors::DscSettingsError; diff --git a/lib/dsc-lib/src/settings/resolved/field.rs b/lib/dsc-lib/src/settings/resolved/field.rs new file mode 100644 index 000000000..dc641e492 --- /dev/null +++ b/lib/dsc-lib/src/settings/resolved/field.rs @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::schemas::dsc_repo::DscRepoSchema; + +use schemars::{JsonSchema, json_schema}; +use serde::{Deserialize, Serialize}; + +use crate::schemas::dsc_repo::schema_i18n; +use crate::settings::DscSettingsScope; + +/// A resolved setting field value with the scope it was defined in. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DscSettingsResolvedField { + /// The resolved value for the field. + pub value: T, + /// The scope the value was defined in. + pub scope: DscSettingsScope, +} + +impl JsonSchema for DscSettingsResolvedField where T: JsonSchema { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Owned(format!("Resolved{}", T::schema_name())) + } + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "title": schema_i18n!("title"), + "description": schema_i18n!("description"), + "markdownDescription": schema_i18n!("markdownDescription"), + "readOnly": true, + "type": "object", + "required": ["value", "scope"], + "properties": { + "value": generator.subschema_for::(), + "scope": generator.subschema_for::(), + }, + }) + } + fn inline_schema() -> bool { + true + } +} + +impl DscRepoSchema for DscSettingsResolvedField where T: JsonSchema { + const SCHEMA_FILE_BASE_NAME: &'static str = "field"; + const SCHEMA_FOLDER_PATH: &'static str = "settings/resolved"; + const SCHEMA_I18N_ROOT_KEY: &'static str = "schemas.settings.resolved.field"; + const SCHEMA_SHOULD_BUNDLE: bool = false; + fn schema_property_metadata() -> schemars::Schema { + json_schema!({}) + } + fn schema_i18n(suffix: &str) -> Result { + let i18n_key = format!("{}.{}", Self::SCHEMA_I18N_ROOT_KEY, suffix); + if let Some(translated) = crate::_rust_i18n_try_translate(&rust_i18n::locale(), &i18n_key) { + Ok(translated.into()) + } else { + Err(crate::schemas::dsc_repo::DscRepoSchemaMissingTranslationError { i18n_key }) + } + } +} + +impl DscSettingsResolvedField where T: JsonSchema { + /// Creates a new resolved field with the given value and scope. + /// + /// # Arguments + /// + /// - `value`: The value for the resolved field. + /// - `scope`: The scope for the resolved field as a variant of + /// [`DscSettingsScope`]. + /// + /// # Returns + /// + /// An instance of [`DscSettingsResolvedField`] with the given value and + /// scope. + pub fn new(value: T, scope: DscSettingsScope) -> Self { + Self { value, scope } + } + + /// Creates a new resolved field with the given value and the [`Default`] + /// scope. + /// + /// # Arguments + /// + /// - `value`: The value for the resolved field. + /// + /// # Returns + /// + /// An instance of [`DscSettingsResolvedField`] with the [`Default`] scope + /// and the given value. + /// + /// [`Default`]: crate::settings::DscSettingsScope::Default + pub fn for_code_default(value: T) -> Self { + Self { value, scope: DscSettingsScope::Default } + } + + /// Creates a new resolved field with the given value and the + /// [`Environment`] scope. + /// + /// # Arguments + /// + /// - `value`: The value for the resolved field. + /// + /// # Returns + /// + /// An instance of [`DscSettingsResolvedField`] with the [`Environment`] + /// scope and the given value. + /// + /// [`Environment`]: crate::settings::DscSettingsScope::Environment + pub fn for_environment(value: T) -> Self { + Self { value, scope: DscSettingsScope::Environment } + } + + /// Creates a new resolved field with the given value and the + /// [`CommandLine`] scope. + /// + /// # Arguments + /// + /// - `value`: The value for the resolved field. + /// + /// # Returns + /// + /// An instance of [`DscSettingsResolvedField`] with the [`CommandLine`] + /// scope and the given value. + /// + /// [`CommandLine`]: crate::settings::DscSettingsScope::CommandLine + pub fn for_command_line(value: T) -> Self { + Self { value, scope: DscSettingsScope::CommandLine } + } + + /// Creates a new resolved field with the given value and the [`Machine`] + /// scope. + /// + /// # Arguments + /// + /// - `value`: The value for the resolved field. + /// + /// # Returns + /// + /// An instance of [`DscSettingsResolvedField`] with the [`Machine`] scope + /// and the given value. + /// + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + pub fn for_machine(value: T) -> Self { + Self { value, scope: DscSettingsScope::Machine } + } + + /// Creates a new resolved field with the given value and the [`User`] + /// scope. + /// + /// # Arguments + /// + /// - `value`: The value for the resolved field. + /// + /// # Returns + /// + /// An instance of [`DscSettingsResolvedField`] with the [`User`] scope + /// and the given value. + /// + /// [`User`]: crate::settings::DscSettingsScope::User + pub fn for_user(value: T) -> Self { + Self { value, scope: DscSettingsScope::User } + } + + /// Creates a new resolved field with the given value and the [`Workspace`] + /// scope. + /// + /// # Arguments + /// + /// - `value`: The value for the resolved field. + /// + /// # Returns + /// + /// An instance of [`DscSettingsResolvedField`] with the [`Workspace`] + /// scope and the given value. + /// + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + pub fn for_workspace(value: T) -> Self { + Self { value, scope: DscSettingsScope::Workspace } + } + + /// Creates a new resolved field with the given value and the [`Policy`] + /// scope. + /// + /// # Arguments + /// + /// - `value`: The value for the resolved field. + /// + /// # Returns + /// + /// An instance of [`DscSettingsResolvedField`] with the [`Policy`] scope + /// and the given value. + /// + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + pub fn for_policy(value: T) -> Self { + Self { value, scope: DscSettingsScope::Policy } + } + + /// Returns true if the field is enforced by policy and must not be + /// overridden by environment variables or CLI options. + /// + /// # Returns + /// + /// `true` if the field is enforced by policy, `false` otherwise. + #[must_use] + pub fn is_policy(&self) -> bool { + self.scope == DscSettingsScope::Policy + } +} diff --git a/lib/dsc-lib/src/settings/resolved/mod.rs b/lib/dsc-lib/src/settings/resolved/mod.rs new file mode 100644 index 000000000..686678ee1 --- /dev/null +++ b/lib/dsc-lib/src/settings/resolved/mod.rs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines the types for the resolved settings. +//! +//! This module defines two types: +//! +//! - [`DscSettingsResolvedField`] is a generic struct that colocates a resolved setting value +//! with the highest precedence scope it was defined in. +//! - [`DscSettingsResolved`] is a struct that contains all the resolved settings for DSC. Every +//! leaf field in this struct is a [`DscSettingsResolvedField`] and every container field is +//! a struct that contains other container fields and/or leaf fields. + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! Generally, only the [`DscSettingsResolved`] type should require any modification when + //! updating settings definitions. For more information, see the + //! [implementation guidance](super::settings::_implementation_guidance). + //! + //! [`DscSettingsResolved`]: super::DscSettingsResolved +} + +mod field; +pub use field::*; +mod settings; +pub use settings::*; diff --git a/lib/dsc-lib/src/settings/resolved/settings.rs b/lib/dsc-lib/src/settings/resolved/settings.rs new file mode 100644 index 000000000..31aa1bb00 --- /dev/null +++ b/lib/dsc-lib/src/settings/resolved/settings.rs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use dsc_lib_jsonschema::dsc_repo::{DscRepoSchema, schema_i18n}; +use schemars::JsonSchema; +use serde::Serialize; + +use crate::settings::{ + fields::*, + DscSettingsResolvedField +}; + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! This module provides guidance for updating the [`DscSettingsResolved`]struct. + //! + //! When adding a new top-level field to [`DscSettingsResolved`], follow these steps: + //! + //! 1. Ensure that the field is defined in the [`fields`] module following that + //! [module guidance](crate::settings::fields::_implementation_guidance). + //! 1. Add the new field to the [`DscSettingsResolved`] struct. + //! + //! - If the field is a container field, define the field in the struct as the appropriate + //! `*ResolvedSettings` struct type. + //! - If the field is a leaf field, define the field in the struct as a + //! [`DscSettingsResolvedField`] type with `T` as the appropriate type. + //! + //! [`DscSettingsResolved`]: super::DscSettingsResolved + //! [`fields`]: crate::settings::fields + //! [`DscSettingsResolvedField`]: super::DscSettingsResolvedField +} + +/// Defines the effective settings for DSC after resolving all the settings +/// sources. +/// +/// The resolved settings are the effective values that DSC uses during command +/// execution. These settings are derived from the various settings sources, +/// including the policy settings file ([`Policy`] scope), preference settings +/// files ([`Machine`], [`User`], and [`Workspace`] scopes), environment +/// variables ([`Environment`] scope), and command-line options +/// ([`CommandLine`] scope). +/// +/// The resolved settings represent the final configuration that DSC uses, +/// taking into account the precedence of the different settings sources. +/// +/// Every leaf field in the resolved settings contains both the resolved +/// value and the scope from which the value was resolved. +/// +/// [`Policy`]: crate::settings::DscSettingsScope::Policy +/// [`Machine`]: crate::settings::DscSettingsScope::Machine +/// [`User`]: crate::settings::DscSettingsScope::User +/// [`Workspace`]: crate::settings::DscSettingsScope::Workspace +/// [`Environment`]: crate::settings::DscSettingsScope::Environment +/// [`CommandLine`]: crate::settings::DscSettingsScope::CommandLine +#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema, DscRepoSchema)] +#[dsc_repo_schema(base_name = "settings", folder_path = "settings/resolved")] +#[serde(rename_all = "camelCase")] +#[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$schema" = Self::default_export_meta_schema_uri(), + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription") + ) +)] +pub struct DscSettingsResolved { + /// Indicates whether to allow users to ignore settings files. + /// + /// This setting is only available in the [`Policy`] scope. The default + /// setting is `false`. + /// + /// When this setting is `false`, users can indicate that DSC should not + /// load and resolve settings files by specifying the + /// [`--ignore-settings-file`] CLI option or defining the + /// [`DSC_IGNORE_SETTINGS_FILE`] environment variable. Doing so effectively + /// skips processing the [`Machine`], [`User`], and [`Workspace`] settings + /// scopes. The [`Policy`] scope is always processed regardless of this + /// setting. + /// + /// When the policy scope defines this setting as `true`, users aren't + /// allowed to ignore settings files. If a user attempts to ignore settings + /// files, DSC raises a warning and indicates that the command is + /// processing settings files as normal. + /// + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`--ignore-settings-file`]: crate::settings::sources::CommandLineData::ignore_settings_file + /// [`DSC_IGNORE_SETTINGS_FILE`]: crate::settings::sources::EnvironmentData::dsc_ignore_settings_file + #[schemars( + title = schema_i18n!("forbid_ignore_settings_file.title"), + description = schema_i18n!("forbid_ignore_settings_file.description"), + extend( + "markdownDescription" = schema_i18n!("forbid_ignore_settings_file.markdownDescription") + ) + )] + pub forbid_ignore_settings_file: DscSettingsResolvedField, + /// Indicates whether to ignore settings files. + /// + /// This setting is available in the [`Environment`] and [`Cli`] scopes. + /// The default setting is `false`. + /// + /// When this setting is `true`, DSC ignores the [`Machine`], [`User`], and + /// [`Workspace`] settings scopes. DSC won't automatically load those + /// settings files and will ignore them during settings resolution even if + /// they were manually loaded. The [`Policy`] scope is always processed + /// regardless of this setting. + /// + /// If the [`forbid_ignore_settings_file`] setting is defined as `true` in + /// the [`Policy`] scope, this setting is effectively ignored and DSC will + /// always load and resolve settings files if they exist. + /// + /// [`Environment`]: crate::settings::DscSettingsScope::Environment + /// [`Cli`]: crate::settings::DscSettingsScope::CommandLine + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`forbid_ignore_settings_file`]: Self::forbid_ignore_settings_file + #[schemars( + title = schema_i18n!("ignore_settings_file.title"), + description = schema_i18n!("ignore_settings_file.description"), + extend( + "markdownDescription" = schema_i18n!("ignore_settings_file.markdownDescription") + ) + )] + pub ignore_settings_file: DscSettingsResolvedField, + /// Indicates how DSC should emit messages during command execution. + /// + /// These settings control which messages DSC emits to stderr and the + /// format it emits them in. + /// + /// The following snippet shows the effective code defaults as YAML data: + /// + /// ```yaml + /// tracing: + /// level: warn + /// format: default + /// ``` + /// + /// For more information on defining these settings, see: + /// + /// - [`TracingFileData`] for defining them in the [policy settings file] or + /// [preference settings files]. + /// - [`EnvironmentData`] for defining them as environment variables. + /// - [`CommandLineData`] for defining them as CLI options. + /// + /// [`TracingFileData`]: crate::settings::fields::TracingFileData + /// [`EnvironmentData`]: crate::settings::sources::EnvironmentData + /// [`CommandLineData`]: crate::settings::sources::CommandLineData + /// [policy settings file]: crate::settings::sources::PolicyFileData + /// [preference settings files]: crate::settings::sources::PreferenceFileData + #[schemars( + title = schema_i18n!("tracing.title"), + description = schema_i18n!("tracing.description"), + extend( + "markdownDescription" = schema_i18n!("tracing.markdownDescription") + ) + )] + pub tracing: TracingResolvedSettings, + /// Indicates how DSC should discover manifests and binaries during command + /// execution. + /// + /// These settings control which directories DSC searches for manifests and + /// binaries, whether to include the system `PATH` environment variable in + /// the search, and whether to restrict the search to only the specified + /// directories. + /// + /// The following snippet shows the effective code defaults as YAML data: + /// + /// ```yaml + /// resourcePath: + /// appendEnvPath: true + /// restricted: false + /// directories: [] + /// ``` + /// + /// For more information on defining these settings, see: + /// + /// - [`ResourcePathFileData`] for defining them in the + /// [policy settings file] or[preference settings files]. + /// - [`EnvironmentData`] for defining them as environment variables. + /// - [`CommandLineData`] for defining them as CLI options. + /// + /// [`ResourcePathFileData`]: crate::settings::fields::ResourcePathFileData + /// [`EnvironmentData`]: crate::settings::sources::EnvironmentData + /// [`CommandLineData`]: crate::settings::sources::CommandLineData + /// [policy settings file]: crate::settings::sources::PolicyFileData + /// [preference settings files]: crate::settings::sources::PreferenceFileData + #[schemars( + title = schema_i18n!("resource_path.title"), + description = schema_i18n!("resource_path.description"), + extend( + "markdownDescription" = schema_i18n!("resource_path.markdownDescription") + ) + )] + pub resource_path: ResourcePathResolvedSettings, +} + +impl Default for DscSettingsResolved { + fn default() -> Self { + Self { + forbid_ignore_settings_file: DscSettingsResolvedField::for_code_default(CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE), + ignore_settings_file: DscSettingsResolvedField::for_code_default(CODE_DEFAULT_IGNORE_SETTINGS_FILE), + tracing: TracingResolvedSettings::default(), + resource_path: ResourcePathResolvedSettings::default(), + } + } +} diff --git a/lib/dsc-lib/src/settings/sources/code_defaults.rs b/lib/dsc-lib/src/settings/sources/code_defaults.rs new file mode 100644 index 000000000..cb37940a2 --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/code_defaults.rs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines the default values for DSC settings fields. + +use schemars::{JsonSchema, json_schema}; +use serde::Serialize; + +use crate::schemas::dsc_repo::{DscRepoSchema, schema_i18n}; +use crate::settings::fields::*; + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! The [`DscSettingsCodeDefaults`] struct should mirror the structure of + //! [`DscSettingsResolved`], except that instead of using [`DscSettingsResolvedField`] for + //! each field, it should use the underlying value type for each field. + //! + //! The [`DSC_SETTINGS_CODE_DEFAULTS`] constant is a static representation of the code defaults + //! and every field should be initialized with the appropriate constant from the [`fields`] + //! module. + //! + //! [`DscSettingsCodeDefaults`]: super::DscSettingsCodeDefaults + //! [`DSC_SETTINGS_CODE_DEFAULTS`]: super::DSC_SETTINGS_CODE_DEFAULTS + //! [`DscSettingsResolved`]: crate::settings::DscSettingsResolved + //! [`DscSettingsResolvedField`]: crate::settings::DscSettingsResolvedField + //! [`fields`]: crate::settings::fields +} + +/// Defines the default values for DSC settings fields. +/// +/// DSC uses a layered approach to resolving settings values. The code +/// defaults, which this struct represents, are the lowest precedence in the +/// settings hierarchy. They are defined in the [`DSC_SETTINGS_CODE_DEFAULTS`] +/// constant. +/// +/// These defaults are used when no other sources define a value for a setting. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, DscRepoSchema)] +#[dsc_repo_schema(base_name = "default", folder_path = "settings/sources")] +pub struct DscSettingsCodeDefaults { + /// Indicates whether to allow users to ignore settings files. + /// + /// The code default for this setting is `false`, which means that users + /// are allowed to ignore settings files. This setting can only be + /// overridden by the [`Policy`] scope. + /// + /// For more information, see [`forbid_ignore_settings_file`] in the policy + /// file documentation. + /// + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`forbid_ignore_settings_file`]: crate::settings::sources::PolicyFileData::forbid_ignore_settings_file + pub forbid_ignore_settings_file: ForbidIgnoreSettingsFileField, + /// Indicates whether to ignore settings files. + /// + /// The code default for this setting is `false`, which means that DSC will + /// load and resolve settings files. This setting can be overridden by the + /// [`Environment`] and [`CLI`] scopes. + /// + /// If the [`forbid_ignore_settings_file`] setting is defined as `true` in + /// the [`Policy`] scope, this setting is effectively ignored and DSC will + /// always load and resolve settings files. + /// + /// For more information, see [`DscSettingsResolved::ignore_settings_file`]. + /// + /// [`Environment`]: crate::settings::DscSettingsScope::Environment + /// [`CLI`]: crate::settings::DscSettingsScope::CommandLine + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`forbid_ignore_settings_file`]: crate::settings::sources::PolicyFileData::forbid_ignore_settings_file + /// [`DscSettingsResolved::ignore_settings_file`]: crate::settings::DscSettingsResolved::ignore_settings_file + pub ignore_settings_file: IgnoreSettingsFileField, + /// Defines how DSC should emit trace messages for logging and diagnostics. + /// + /// The code default for this container field is defined by + /// [`CODE_DEFAULT_TRACING`]. + /// + /// You can override these settings in every scope. For more information, + /// see: + /// + /// - [`PolicyFileData::tracing`] to define these settings in the + /// [`Policy`] scope. + /// - [`PreferenceFileData::tracing`] to define these settings as + /// preferences in the [`Machine`], [`User`], and [`Workspace`] scopes. + /// - [`dsc_trace_level`] and [`dsc_trace_format`] in [`EnvironmentData`] + /// to define these settings in the [`Environment`] scope. + /// - [`trace_format`] and [`trace_level`] in [`CommandLineData`] to define + /// these settings in the [`CommandLine`] scope. + /// + /// [`dsc_trace_level`]: crate::settings::sources::EnvironmentData::dsc_trace_level + /// [`dsc_trace_format`]: crate::settings::sources::EnvironmentData::dsc_trace_format + /// [`Environment`]: crate::settings::DscSettingsScope::Environment + /// [`CommandLine`]: crate::settings::DscSettingsScope::CommandLine + /// [`trace_format`]: crate::settings::sources::CommandLineData::trace_format + /// [`trace_level`]: crate::settings::sources::CommandLineData::trace_level + /// [`CommandLineData`]: crate::settings::sources::CommandLineData + /// [`EnvironmentData`]: crate::settings::sources::EnvironmentData + /// [`PolicyFileData::tracing`]: crate::settings::sources::PolicyFileData::tracing + /// [`PreferenceFileData::tracing`]: crate::settings::sources::PreferenceFileData::tracing + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + pub tracing: TracingCodeDefaults, + /// Defines the paths to use when searching for and invoking resources, + /// extensions, and other executables. + /// + /// The code default for this container field is defined by + /// [`CODE_DEFAULT_RESOURCE_PATH`]. + /// + /// You can override this setting in every scope except for [`CommandLine`]. + /// For more information, see: + /// + /// - [`PolicyFileData::resource_path`] to define this setting in the + /// [`Policy`] scope. + /// - [`PreferenceFileData::resource_path`] to define this setting as a + /// preference in the [`Machine`], [`User`], and [`Workspace`] scopes. + /// - [`dsc_resource_path`] and [`dsc_restricted_path`] in + /// [`EnvironmentData`] to define this setting in the [`Environment`] + /// scope. + /// + /// [`CommandLine`]: crate::settings::DscSettingsScope::CommandLine + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`PolicyFileData::resource_path`]: crate::settings::sources::PolicyFileData::resource_path + /// [`PreferenceFileData::resource_path`]: crate::settings::sources::PreferenceFileData::resource_path + /// [`dsc_resource_path`]: crate::settings::sources::EnvironmentData::dsc_resource_path + /// [`dsc_restricted_path`]: crate::settings::sources::EnvironmentData::dsc_restricted_path + /// [`Environment`]: crate::settings::DscSettingsScope::Environment + /// [`EnvironmentData`]: crate::settings::sources::EnvironmentData + pub resource_path: ResourcePathCodeDefaults, +} + +impl JsonSchema for DscSettingsCodeDefaults { + fn schema_name() -> std::borrow::Cow<'static, str> { + "DscSettingsCodeDefaults".into() + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "$schema": Self::default_export_meta_schema_uri(), + "$id": Self::default_export_schema_id_uri(), + "title": schema_i18n!("title"), + "description": schema_i18n!("description"), + "markdownDescription": schema_i18n!("markdownDescription"), + "const": DSC_SETTINGS_CODE_DEFAULTS, + }) + } +} + +/// Defines the default values for DSC settings fields. +/// +/// The following snippet shows the effective code defaults as YAML data: +/// +/// ```yaml +/// forbid_ignore_settings_file: false +/// ignore_settings_file: false +/// tracing: +/// level: warn +/// format: default +/// resource_path: +/// append_env_path: true +/// directories: [] +/// restricted: false +/// ``` +pub const DSC_SETTINGS_CODE_DEFAULTS: DscSettingsCodeDefaults = DscSettingsCodeDefaults { + forbid_ignore_settings_file: CODE_DEFAULT_FORBID_IGNORE_SETTINGS_FILE, + ignore_settings_file: CODE_DEFAULT_IGNORE_SETTINGS_FILE, + tracing: CODE_DEFAULT_TRACING, + resource_path: CODE_DEFAULT_RESOURCE_PATH, +}; + +impl Default for DscSettingsCodeDefaults { + fn default() -> Self { + DSC_SETTINGS_CODE_DEFAULTS + } +} diff --git a/lib/dsc-lib/src/settings/sources/command_line.rs b/lib/dsc-lib/src/settings/sources/command_line.rs new file mode 100644 index 000000000..e92ea9aa4 --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/command_line.rs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! This module defines the [`CommandLineData`] struct, which represents the command line +//! arguments related to DSC settings. + +use serde::Serialize; +use schemars::JsonSchema; + +use crate::schemas::dsc_repo::{DscRepoSchema, schema_i18n}; +use crate::settings::fields::*; + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! This documentation provides guidance for defining new command line arguments related to DSC + //! settings. When adding a new command line argument, follow this guidance: + //! + //! 1. Ensure that the field is defined in the [`fields`] module following that + //! [module guidance](crate::settings::fields::_implementation_guidance). + //! 1. Ensure that the field is defined in the [`DscSettingsResolved`] struct, following the + //! [module guidance](crate::settings::resolved::_implementation_guidance). + //! 1. Add the field to the [`CommandLineData`] struct in this module: + //! + //! - Name the field the same as the command line argument's long name, using snake case. + //! For example, the `--trace-level` command line argument would correspond to a field + //! named `trace_level`. + //! - Define the field's type as [`Option`], where `T` is the type of the field defined + //! in the [`fields`] module (or the externally defined type if the field doesn't require + //! a new type). + //! + //! 1. Update the [`DscSettings::resolve_command_line()`] method to appropriately resolve the + //! field. + //! + //! For example, when defining a setting for a top-level field named `new_area`, you would + //! add the following snippet to the `resolve_command_line()` method: + //! + //! ```ignore + //! if let Some(value) = cli_data.new_area.as_ref() { + //! if resolving.new_area.scope < DscSettingsScope::CommandLine { + //! resolving.new_area = DscSettingsResolvedField::new( + //! value.clone(), + //! DscSettingsScope::CommandLine + //! ); + //! } + //! } + //! ``` + //! + //! When defining a setting for a nested leaf field, you would add a similar snippet, but + //! with the appropriate dot notation to access the nested field. For example, if the field + //! is `new_area.foo.bar`, you would add the following snippet: + //! + //! ```ignore + //! if let Some(value) = cli_data.new_area.foo.bar.as_ref() { + //! if resolving.new_area.foo.bar.scope < DscSettingsScope::CommandLine { + //! resolving.new_area.foo.bar = DscSettingsResolvedField::new( + //! value.clone(), + //! DscSettingsScope::CommandLine + //! ); + //! } + //! } + //! ``` + //! 1. Ensure that the argument in DSC is defined in the CLI argument parser. + //! + //! - If the argument is a boolean flag, ensure that the Clap attribute defines the + //! following fields: + //! + //! - `num_args=0..=1` - Makes the argument accept zero or one value. This allows the + //! argument to be specified as a flag without an explicit value, or with an explicit + //! value of `true` or `false`. + //! - `default_missing_value="true"` - Ensures that if the argument is specified without a + //! value, it will be treated as `true`. + //! - `require_equals = true` - Ensures that if the argument is specified with a value, it + //! must be specified using an equals sign, like `--ignore-settings-file=true`. + //! + //! This is necessary to distinguish between the argument not being specified and being + //! specified with a value of `false`. Otherwise, the CLI argument will _always_ supercede + //! lower precedence sources. For example, consider the `--ignore-settings-file` argument: + //! + //! ```sh + //! DSC_IGNORE_SETTINGS_FILE=true dsc config get -f ./example.dsc.config.yaml + //! ``` + //! + //! In this case, even though the user specified the environment variable to ignore + //! settings files, the argument parser interprets the `--ignore-settings-file` argument + //! as `false` and DSC will load settings files during resolution. + //! + //! When the argument is defined with the above attributes, the parser can indicate that + //! the argument wasn't specified, and DSC will correctly resolve the setting to `true` + //! based on the environment variable. + //! + //! This also enables the user to effectively override the environment variable with the + //! `--ignore-settings-file=false` argument. + //! - If the argument is for a defined type, ensure that _either_: + //! + //! 1. The CLI code defines the `From` trait to convert between the CLI argument type and + //! the type defined in the [`fields`] module, or + //! 1. The CLI code uses the type defined in the [`fields`] module for the argument. + //! + //! 1. Ensure that the CLI call to initialize the settings includes the new argument in the + //! [`CommandLineData`] struct. + //! + //! [`fields`]: crate::settings::fields + //! [`CommandLineData`]: super::CommandLineData + //! [`DscSettings::resolve_command_line()`]: crate::settings::DscSettings::resolve_command_line + //! [`DscSettingsResolved`]: crate::settings::DscSettingsResolved +} + +/// Represents the command line arguments related to DSC settings. +/// +/// DSC defines several global command line arguments that can be used to +/// override settings defined in preference files or environment variables. +/// This struct captures the values of those command line arguments. +/// +/// See the field documentation for more information about each of the settings +/// you can control with environment variables. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema, DscRepoSchema)] +#[serde(rename_all = "camelCase")] +#[dsc_repo_schema(base_name = "cli", folder_path = "settings/sources")] +#[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = CommandLineData::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription") + ) +)] +pub struct CommandLineData { + /// Defines the trace level to use. + /// + /// Retrieved from the global `--trace-level` command line argument, this + /// value maps to the [`tracing.level`] setting. + /// + /// It controls the minimum level of messages that DSC emits. + /// + /// [`tracing.level`]: crate::settings::fields::TracingLevelField + #[serde(rename = "--trace-level", skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("trace_level.title"), + description = schema_i18n!("trace_level.description"), + extend( + "markdownDescription" = schema_i18n!("trace_level.markdownDescription") + ) + )] + pub trace_level: Option, + + /// Defines the trace format to use. + /// + /// Retrieved from the global `--trace-format` command line argument, this + /// value maps to the [`tracing.format`] setting. + /// + /// It controls the format that DSC uses when emitting messages. + /// + /// [`tracing.format`]: crate::settings::fields::TracingFormatField + #[serde(rename = "--trace-format", skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("trace_format.title"), + description = schema_i18n!("trace_format.description"), + extend( + "markdownDescription" = schema_i18n!("trace_format.markdownDescription") + ) + )] + pub trace_format: Option, + + /// Whether to ignore settings files. + /// + /// Retrieved from the global `--ignore-settings-file` command line + /// argument, this value maps to the [`ignore_settings_file`] setting. + /// + /// When this is set to `true`, DSC will ignore all settings files, + /// including machine, user, and workspace settings files. When resolving + /// settings, DSC will ignore the settings files and only consider the + /// following sources, in order of precedence: + /// + /// 1. The system policy file, if it exists. + /// 2. The command line arguments, if they're provided. + /// 3. The environment variables, if they're set. + /// 4. The code defaults, which are the built-in default values for each + /// setting. + /// + /// Defining this setting as `true` will raise a warning if the policy + /// file defines [`forbid_ignore_settings_file`] as `true`. + /// + /// [`ignore_settings_file`]: crate::settings::fields::IgnoreSettingsFileField + /// [`forbid_ignore_settings_file`]: crate::settings::fields::ForbidIgnoreSettingsFileField + #[serde(rename = "--ignore-settings-file", skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("ignore_settings_file.title"), + description = schema_i18n!("ignore_settings_file.description"), + extend( + "markdownDescription" = schema_i18n!("ignore_settings_file.markdownDescription") + ) + )] + pub ignore_settings_file: Option, +} + +/// Private API +impl CommandLineData { + /// Indicates whether any of the fields were populated from command line + /// arguments. + /// + /// This method is used when deciding whether to serialize the command line + /// data and for processing to enable skipping when no fields were defined. + /// + /// # Returns + /// + /// `true` if any of the command line data fields are populated, otherwise + /// `false`. + pub(crate) fn any_defined(&self) -> bool { + self.trace_level.is_some() || + self.trace_format.is_some() || + self.ignore_settings_file.is_some() + } +} \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/sources/environment.rs b/lib/dsc-lib/src/settings/sources/environment.rs new file mode 100644 index 000000000..eb03a0013 --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/environment.rs @@ -0,0 +1,661 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines the [`EnvironmentData`] struct, which represents the DSC settings that can be +//! configured via environment variables. + +use std::{path::PathBuf, str::FromStr}; + +#[cfg(not(test))] +use std::env::var as get_env_var; + +#[cfg(test)] +use crate::tests::stubs::env::var as get_env_var; + +use rust_i18n::t; +use schemars::JsonSchema; +use serde::Serialize; +use tracing::{debug, warn}; + +use crate::settings::{DscSettingsError, fields::*}; +use crate::schemas::dsc_repo::{DscRepoSchema, schema_i18n}; + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! When defining a new environment variable for a DSC setting, follow these guidelines: + //! + //! 1. Ensure that the field is defined in the [`fields`] module following that + //! [module guidance](crate::settings::fields::_implementation_guidance). + //! 1. Ensure that the field is defined in the [`DscSettingsResolved`] struct, following the + //! [module guidance](crate::settings::resolved::_implementation_guidance). + //! 1. If the field is defined as a new type in the [`fields`] module, ensure that the type + //! implements the [`FromStr`] trait to enable parsing from a string. + //! 1. Determine the appropriate name for the environment variable: + //! + //! - Always prefix the name with `DSC_` and use `SCREAMING_SNAKE_CASE`. + //! - If the field is defined as a command line argument, use the long name of the argument, + //! like `DSC_TRACE_LEVEL` for the `--trace-level` argument. + //! - If the field isn't defined as a command line argument, choose a semantically + //! meaningful name that clearly indicates the purpose of the environment variable. If + //! you're not sure, default to using the field name for top-level fields. For nested + //! fields, like `my_new_area.foo.bar`, use the field name with underscores, like + //! `DSC_MY_NEW_AREA_FOO_BAR`. + //! + //! 1. Add a field to the [`EnvironmentData`] struct in this module: + //! + //! - Name the field the same as the environment variable, using snake case. For example, + //! the `DSC_TRACE_LEVEL` environment variable would correspond to a field named + //! `dsc_trace_level`. + //! - Define the field's type as `Option`, where `T` is the type of the field defined in + //! the [`fields`] module (or the externally defined type if the field doesn't require a + //! new type). + //! + //! 1. Implement a method to retrieve the value of the environment variable and parse it into + //! the appropriate type: + //! - Name the method `get_env_`, like `get_env_trace_level` for the + //! `dsc_trace_level` field. + //! - If the parsing for the field is infallible, define the return type as [`Option`] to + //! handle the case where the environment variable is not set. + //! - If the parsing for the field is fallible, define the return type as + //! [`Result, DscSettingsError>`] to surface parsing errors. + //! 1. Update the [`from_env()`] method to call the new `get_env_` method and set + //! the corresponding field in the [`EnvironmentData`] struct. + //! + //! If the method is infallible, you can just set the field to the return value of the + //! method. For example, if the new variable is `DSC_NEW_FIELD`, you would add the following + //! snippet: + //! + //! ```ignore + //! dsc_new_field: Self::get_env_new_field(), + //! ``` + //! + //! If the method is fallible, set the field to a match statement that handles the [`Ok`] + //! and [`Err`] cases, emitting a warning for the invalid value and setting the field to + //! [`None`] in the [`Err`] case. For example, if the new variable is `DSC_NEW_FIELD`, you + //! would add the following snippet: + //! + //! ```ignore + //! dsc_new_field: match Self::get_env_new_field() { + //! Ok(value) => value, + //! Err(err) => { + //! warn!("ignoring invalid DSC_NEW_FIELD environment variable: {}", err); + //! None + //! } + //! }, + //! ``` + //! + //! 1. Update the [`try_from_env()`] method to call the new `get_env_` method and + //! set the corresponding field in the [`EnvironmentData`] struct. + //! + //! If the method is infallible, you can just set the field to the return value of the + //! method. For example, if the new variable is `DSC_NEW_FIELD`, you would add the following + //! snippet: + //! + //! ```ignore + //! data.dsc_new_field = Self::get_env_new_field(); + //! ``` + //! + //! If the method is fallible, use a match statement to handle the [`Ok`] and [`Err`] cases, + //! pushing any errors to the `errors` vector. For example, if the new variable is + //! `DSC_NEW_FIELD`, you would add the following snippet: + //! + //! ```ignore + //! match Self::get_env_new_field() { + //! Ok(value) => data.dsc_new_field = value, + //! Err(err) => errors.push(err), + //! }; + //! ``` + //! + //! [`fields`]: crate::settings::fields + //! [`FromStr`]: std::str::FromStr + //! [`EnvironmentData`]: super::EnvironmentData + //! [`from_env()`]: super::EnvironmentData::from_env + //! [`try_from_env()`]: super::EnvironmentData::try_from_env + //! [`DscSettingsResolved`]: crate::settings::DscSettingsResolved +} + +#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, JsonSchema, DscRepoSchema)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[dsc_repo_schema(base_name = "environment", folder_path = "settings/sources")] +#[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = EnvironmentData::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription") + ) +)] +pub struct EnvironmentData { + /// `DSC_TRACE_LEVEL` - Defines the trace level to use. + /// + /// This environment variable can be set to one of the following values + /// (case insensitive): + /// + /// - [`error`] - Only emit error messages. + /// - [`warn`] - Only emit warning and error messages. + /// - [`info`] - Only emit informational, warning, and error messages. + /// - [`debug`] - Emit all messages except for trace messages. + /// - [`trace`] - Emit all messages, including trace messages. + /// + /// Setting this environment variable to an invalid value will raise a + /// [`LoadEnvironmentError`] error when initializing the settings. DSC will + /// raise a warning and ignore the environment variable when loading the + /// environment settings data. + /// + /// [`error`]: TracingLevelField::Error + /// [`warn`]: TracingLevelField::Warn + /// [`info`]: TracingLevelField::Info + /// [`debug`]: TracingLevelField::Debug + /// [`trace`]: TracingLevelField::Trace + /// [`LoadEnvironmentError`]: crate::settings::DscSettingsError::LoadEnvironmentError + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("dsc_trace_level.title"), + description = schema_i18n!("dsc_trace_level.description"), + extend( + "markdownDescription" = schema_i18n!("dsc_trace_level.markdownDescription") + ) + )] + pub dsc_trace_level: Option, + /// `DSC_TRACE_FORMAT` - Defines the trace format to use. + /// + /// This environment variable can be set to one of the following values + /// (case insensitive): + /// + /// - [`default`] - Emit trace messages in the default format, which is a + /// human-readable format that includes the timestamp, log level, and + /// message. + /// - [`json`] - Emit trace messages in JSON format. + /// - [`plaintext`] - Emit trace messages in plain text format. + /// + /// Setting this environment variable to an invalid value will result in a + /// [`DscSettingsError::LoadEnvironmentError`] error when initializing the + /// settings. DSC will raise a warning and ignore the environment variable + /// when loading the environment settings data. + /// + /// [`default`]: TracingFormatField::Default + /// [`json`]: TracingFormatField::Json + /// [`plaintext`]: TracingFormatField::Plaintext + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("dsc_trace_format.title"), + description = schema_i18n!("dsc_trace_format.description"), + extend( + "markdownDescription" = schema_i18n!("dsc_trace_format.markdownDescription") + ) + )] + pub dsc_trace_format: Option, + /// `DSC_RESOURCE_PATH` - Defines a list of paths to use when searching for + /// DSC resource, extension, and other manifests. + /// + /// When defined, DSC will search for resources, extensions, and other + /// manifests in the specified paths. Effectively, this environment maps to + /// the following settings in the [`PreferenceFileData`] struct: + /// + /// ```ignore + /// directories: dsc_resource_path + /// ``` + /// + /// If either the [`restricted`] or [`append_env_path`] settings are + /// defined in the [`Policy`], [`Machine`], [`User`], or [`Workspace`] + /// scopes, the behavior of this environment variable may be affected. See + /// the documentation for those settings for more information. + /// + /// If this environment variable is defined with the [`DSC_RESTRICTED_PATH`] + /// environment variable, the value of that variable takes precedence. + /// + /// [`restricted`]: crate::settings::fields::ResourcePathFileData::restricted + /// [`append_env_path`]: crate::settings::fields::ResourcePathFileData::append_env_path + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`DSC_RESTRICTED_PATH`]: Self::dsc_restricted_path + /// [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("dsc_resource_path.title"), + description = schema_i18n!("dsc_resource_path.description"), + extend( + "markdownDescription" = schema_i18n!("dsc_resource_path.markdownDescription") + ) + )] + pub dsc_resource_path: Option>, + /// `DSC_RESTRICTED_PATH` - Defines a list of paths to use when searching + /// for and invoking resources, extensions, and other executables. + /// + /// When defined, DSC will only search for resources, extensions, and other + /// executables in the specified paths. DSC will _not_ allow invoking any + /// executables outside of the specified paths. Effectively, this + /// environment maps to the following settings in the [`PreferenceFileData`] + /// struct: + /// + /// ```ignore + /// directories: dsc_restricted_path + /// restricted: true + /// ``` + /// + /// This environment variable should be defined as a list of paths + /// separated by the platform-specific path separator (`;` on Windows, + /// `:` on Unix-like systems). + /// + /// If this environment variable is defined with the [`DSC_RESOURCE_PATH`] + /// environment variable, the value of this variable takes precedence. + /// + /// [`DSC_RESOURCE_PATH`]: Self::dsc_resource_path + /// [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("dsc_restricted_path.title"), + description = schema_i18n!("dsc_restricted_path.description"), + extend( + "markdownDescription" = schema_i18n!("dsc_restricted_path.markdownDescription") + ) + )] + pub dsc_restricted_path: Option>, + /// `DSC_IGNORE_SETTINGS_FILE` - Indicates whether to ignore settings files. + /// + /// When this environment variable is set to `true` or `1`, DSC will not + /// automatically load settings files. When resolving settings, DSC will + /// ignore the settings files even if they were manually loaded. This + /// effectively skips processing the [`Machine`], [`User`], and + /// [`Workspace`] settings scopes. + /// + /// When this environment variable is set to `false` or `0`, DSC will load + /// and resolve settings files as normal. This is the default behavior when + /// the environment variable is not set. + /// + /// The [`Policy`] scope is always processed regardless of this setting. + /// + /// This environment variable can be overridden by the + /// [`--ignore-settings-file`] CLI argument, which has a higher precedence. + /// + /// [`Machine`]: crate::settings::DscSettingsScope::Machine + /// [`User`]: crate::settings::DscSettingsScope::User + /// [`Workspace`]: crate::settings::DscSettingsScope::Workspace + /// [`Policy`]: crate::settings::DscSettingsScope::Policy + /// [`--ignore-settings-file`]: crate::settings::sources::CommandLineData::ignore_settings_file + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("dsc_ignore_settings_file.title"), + description = schema_i18n!("dsc_ignore_settings_file.description"), + extend( + "markdownDescription" = schema_i18n!("dsc_ignore_settings_file.markdownDescription") + ) + )] + pub dsc_ignore_settings_file: Option, +} + +impl EnvironmentData { + /// The name of the environment variable for the `DSC_TRACE_LEVEL` setting. + pub const DSC_TRACE_LEVEL_ENV_VAR: &str = "DSC_TRACE_LEVEL"; + /// The name of the environment variable for the `DSC_TRACE_FORMAT` setting. + pub const DSC_TRACE_FORMAT_ENV_VAR: &str = "DSC_TRACE_FORMAT"; + /// The name of the environment variable for the `DSC_RESOURCE_PATH` setting. + pub const DSC_RESOURCE_PATH_ENV_VAR: &str = "DSC_RESOURCE_PATH"; + /// The name of the environment variable for the `DSC_RESTRICTED_PATH` setting. + pub const DSC_RESTRICTED_PATH_ENV_VAR: &str = "DSC_RESTRICTED_PATH"; + /// The name of the environment variable for the `DSC_IGNORE_SETTINGS_FILE` setting. + pub const DSC_IGNORE_SETTINGS_FILE_ENV_VAR: &str = "DSC_IGNORE_SETTINGS_FILE"; + + /// Retrieves the value of the `DSC_TRACE_LEVEL` environment variable and + /// parses it into a [`TracingLevelField`]. + /// + /// # Returns + /// + /// - [`Some`] [`TracingLevelField`] if the environment variable is set and + /// valid. + /// - [`None`] if the environment variable is not set. + /// + /// # Errors + /// + /// If the environment variable is set but contains an invalid value, this + /// function returns a [`DscSettingsError::LoadEnvironmentError`] error. + pub fn get_env_trace_level() -> Result, DscSettingsError> { + debug!("{}", t!( + "settings.sources.environment.debugLoadingVar", + env_var = Self::DSC_TRACE_LEVEL_ENV_VAR + )); + let Ok(level) = get_env_var(Self::DSC_TRACE_LEVEL_ENV_VAR) else { + debug!("{}", t!( + "settings.sources.environment.debugUndefinedVar", + env_var = Self::DSC_TRACE_LEVEL_ENV_VAR + )); + return Ok(None) + }; + + match TracingLevelField::from_str(&level) { + Ok(trace_level) => { + debug!("{}", t!( + "settings.sources.environment.debugLoadedVar", + env_var = Self::DSC_TRACE_LEVEL_ENV_VAR, + value = trace_level.to_string() + )); + Ok(Some(trace_level)) + }, + Err(source ) => { + Err(DscSettingsError::LoadEnvironmentError { + env_var: Self::DSC_TRACE_LEVEL_ENV_VAR, + source: Box::new(source), + }) + } + } + } + + /// Retrieves the value of the `DSC_TRACE_FORMAT` environment variable and + /// parses it into a [`TracingFormatField`]. + /// + /// # Returns + /// + /// - [`Some`] [`TracingFormatField`] if the environment variable is set + /// and valid. + /// - [`None`] if the environment variable is not set. + /// + /// # Errors + /// + /// If the environment variable is set but contains an invalid value, this + /// function returns a [`DscSettingsError::LoadEnvironmentError`] error. + pub fn get_env_trace_format() -> Result, DscSettingsError> { + debug!("{}", t!( + "settings.sources.environment.debugLoadingVar", + env_var = Self::DSC_TRACE_FORMAT_ENV_VAR + )); + let Ok(format) = get_env_var(Self::DSC_TRACE_FORMAT_ENV_VAR) else { + debug!("{}", t!( + "settings.sources.environment.debugUndefinedVar", + env_var = Self::DSC_TRACE_FORMAT_ENV_VAR + )); + return Ok(None) + }; + + match TracingFormatField::from_str(&format) { + Ok(trace_format) => { + debug!("{}", t!( + "settings.sources.environment.debugLoadedVar", + env_var = Self::DSC_TRACE_FORMAT_ENV_VAR, + value = trace_format.to_string() + )); + Ok(Some(trace_format)) + }, + Err(source) => Err(DscSettingsError::LoadEnvironmentError { + env_var: Self::DSC_TRACE_FORMAT_ENV_VAR, + source: Box::new(source), + }), + } + } + + /// Retrieves the value of the `DSC_RESOURCE_PATH` environment variable and + /// parses it into a vector of [`PathBuf`]. + /// + /// # Returns + /// + /// - [`Some`] [`Vec`] if the environment variable is set. + /// - [`None`] if the environment variable is not set. + pub fn get_env_resource_path() -> Option> { + debug!("{}", t!( + "settings.sources.environment.debugLoadingVar", + env_var = Self::DSC_RESOURCE_PATH_ENV_VAR + )); + + if let Ok(path) = get_env_var(Self::DSC_RESOURCE_PATH_ENV_VAR) { + let paths = std::env::split_paths(&path).collect::>(); + debug!("{}", t!( + "settings.sources.environment.debugLoadedVar", + env_var = Self::DSC_RESOURCE_PATH_ENV_VAR, + value = serde_json::to_string(&paths).unwrap() + )); + Some(paths) + } else { + debug!("{}", t!( + "settings.sources.environment.debugUndefinedVar", + env_var = Self::DSC_RESOURCE_PATH_ENV_VAR + )); + None + } + } + + /// Retrieves the value of the `DSC_RESTRICTED_PATH` environment variable + /// and parses it into a vector of [`PathBuf`]. + /// + /// # Returns + /// + /// - [`Some`] [`Vec`] if the environment variable is set. + /// - [`None`] if the environment variable is not set. + pub fn get_env_restricted_path() -> Option> { + debug!("{}", t!( + "settings.sources.environment.debugLoadingVar", + env_var = Self::DSC_RESTRICTED_PATH_ENV_VAR + )); + + if let Ok(path) = get_env_var(Self::DSC_RESTRICTED_PATH_ENV_VAR) { + let paths = std::env::split_paths(&path).collect::>(); + debug!("{}", t!( + "settings.sources.environment.debugLoadedVar", + env_var = Self::DSC_RESTRICTED_PATH_ENV_VAR, + value = serde_json::to_string(&paths).unwrap() + )); + Some(paths) + } else { + debug!("{}", t!( + "settings.sources.environment.debugUndefinedVar", + env_var = Self::DSC_RESTRICTED_PATH_ENV_VAR + )); + None + } + } + + /// Retrieves the value of the `DSC_IGNORE_SETTINGS_FILE` environment + /// variable and parses it into a boolean. + /// + /// # Parsing + /// + /// This function interprets the following values case insensitively: + /// + /// - `"true"` or `"1"` as `true` + /// - `"false"` or `"0"` as `false` + /// + /// Any other value is invalid. + /// + /// # Returns + /// + /// - [`Some`] `true` if the environment variable is set to "true" or "1". + /// - [`Some`] `false` if the environment variable is set to "false" or "0". + /// - [`None`] if the environment variable is not set. + /// + /// # Errors + /// + /// If the environment variable is set but contains an invalid value, this + /// function returns a [`DscSettingsError::LoadEnvironmentError`] error. + pub fn get_env_ignore_settings_file() -> Result, DscSettingsError> { + debug!("{}", t!( + "settings.sources.environment.debugLoadingVar", + env_var = Self::DSC_IGNORE_SETTINGS_FILE_ENV_VAR + )); + let Ok(value) = get_env_var(Self::DSC_IGNORE_SETTINGS_FILE_ENV_VAR) else { + debug!("{}", t!( + "settings.sources.environment.debugUndefinedVar", + env_var = Self::DSC_IGNORE_SETTINGS_FILE_ENV_VAR + )); + return Ok(None); + }; + + match Self::parse_boolean_env_var(&value) { + Ok(boolean_value) => { + debug!("{}", t!( + "settings.sources.environment.debugLoadedVar", + env_var = Self::DSC_IGNORE_SETTINGS_FILE_ENV_VAR, + value = boolean_value + )); + Ok(Some(boolean_value.into())) + }, + Err(source) => Err(DscSettingsError::LoadEnvironmentError { + env_var: Self::DSC_IGNORE_SETTINGS_FILE_ENV_VAR, + source: Box::new(source), + }), + } + } + + /// Creates a new instance of [`EnvironmentData`] by reading the relevant + /// environment variables. + /// + /// This function reads the following environment variables: + /// - `DSC_TRACE_LEVEL`: The trace level to use. + /// - `DSC_TRACE_FORMAT`: The trace format to use. + /// - `DSC_RESOURCE_PATH`: A list of paths to use when searching for + /// resources, separated by the platform-specific path separator + /// (`;` on Windows, `:` on Unix-like systems). + /// - `DSC_RESTRICTED_PATH`: A list of paths to use when searching for and + /// invoking resources, extensions, and other executables, separated by + /// the platform-specific path separator. + /// - `DSC_IGNORE_SETTINGS_FILE`: Whether to ignore the settings file. + /// + /// When an environment variable is not set or is invalid, the corresponding + /// field will be `None`. This function emits warnings for any invalid + /// environment variable values, but will not return an error. Use + /// [`try_from_env()`] if you want to handle errors instead of emitting + /// warnings and setting the field to `None`. + /// + /// [`try_from_env()`]: Self::try_from_env + pub fn from_env() -> Self { + Self { + dsc_trace_level: match Self::get_env_trace_level() { + Ok(level) => level, + Err(err) => { + warn!("{}", t!( + "settings.sources.environment.warnIgnoreInvalidVar", + env_var = Self::DSC_TRACE_LEVEL_ENV_VAR, + err = err + )); + None + } + }, + dsc_trace_format: match Self::get_env_trace_format() { + Ok(format) => format, + Err(err) => { + warn!("{}", t!( + "settings.sources.environment.warnIgnoreInvalidVar", + env_var = Self::DSC_TRACE_FORMAT_ENV_VAR, + err = err + )); + None + } + }, + dsc_resource_path: Self::get_env_resource_path(), + dsc_restricted_path: Self::get_env_restricted_path(), + dsc_ignore_settings_file: match Self::get_env_ignore_settings_file() { + Ok(val) => val, + Err(err) => { + warn!("{}", t!( + "settings.sources.environment.warnIgnoreInvalidVar", + env_var = Self::DSC_IGNORE_SETTINGS_FILE_ENV_VAR, + err = err + )); + None + } + }, + } + } + + /// Creates a new instance of [`EnvironmentData`] by reading the relevant + /// environment variables. + /// + /// This function reads the same environment variables as [`from_env()`], + /// but returns an error if any of them are defined with invalid values. + /// + /// # Errors + /// + /// Returns a [`DscSettingsError::LoadEnvironmentMultipleErrors`] if any of + /// the environment variables are defined with invalid values. + /// + /// [`from_env()`]: Self::from_env + pub fn try_from_env() -> Result { + let mut errors = Vec::new(); + let mut data = Self::default(); + + match Self::get_env_trace_level() { + Ok(level) => data.dsc_trace_level = level, + Err(err) => errors.push(err), + } + match Self::get_env_trace_format() { + Ok(format) => data.dsc_trace_format = format, + Err(err) => errors.push(err), + } + data.dsc_resource_path = Self::get_env_resource_path(); + data.dsc_restricted_path = Self::get_env_restricted_path(); + match Self::get_env_ignore_settings_file() { + Ok(val) => data.dsc_ignore_settings_file = val, + Err(err) => errors.push(err), + } + + if errors.is_empty() { + Ok(data) + } else { + Err(DscSettingsError::LoadEnvironmentMultipleErrors{errors}) + } + } + + /// Parses a boolean environment variable value. + /// + /// This function interprets the following values case insensitively: + /// + /// - `"true"` or `"1"` as `true` + /// - `"false"` or `"0"` as `false` + /// + /// # Errors + /// + /// If the value isn't one of the recognized boolean representations, this + /// function returns a [`EnvVarUnparseableBoolean`]. + /// + /// # Examples + /// + /// The following example shows how different string values are parsed into + /// boolean values: + /// + /// ```rust + /// use dsc_lib::settings::sources::EnvironmentData; + /// assert_eq!(EnvironmentData::parse_boolean_env_var("true").unwrap(), true); + /// assert_eq!(EnvironmentData::parse_boolean_env_var("True").unwrap(), true); + /// assert_eq!(EnvironmentData::parse_boolean_env_var("TRUE").unwrap(), true); + /// assert_eq!(EnvironmentData::parse_boolean_env_var("tRuE").unwrap(), true); + /// assert_eq!(EnvironmentData::parse_boolean_env_var("1").unwrap(), true); + /// assert_eq!(EnvironmentData::parse_boolean_env_var("false").unwrap(), false); + /// assert_eq!(EnvironmentData::parse_boolean_env_var("False").unwrap(), false); + /// assert_eq!(EnvironmentData::parse_boolean_env_var("FALSE").unwrap(), false); + /// assert_eq!(EnvironmentData::parse_boolean_env_var("fAlSe").unwrap(), false); + /// assert_eq!(EnvironmentData::parse_boolean_env_var("0").unwrap(), false); + /// assert!(EnvironmentData::parse_boolean_env_var("invalid").is_err()); + /// ``` + /// + /// [`EnvVarUnparseableBoolean`]: DscSettingsError::EnvVarUnparseableBoolean + pub fn parse_boolean_env_var(value: &str) -> Result { + match value.to_lowercase().as_str() { + "true" | "1" => Ok(true), + "false" | "0" => Ok(false), + _ => Err(DscSettingsError::EnvVarUnparseableBoolean { + value: value.to_string(), + }), + } + } +} + +// Private API +impl EnvironmentData { + /// Indicates whether any of the fields were populated from environment + /// variables. + /// + /// This method is used when deciding whether to serialize the environment + /// data and for processing to enable skipping when no variables were + /// defined. + /// + /// # Returns + /// + /// `true` if any of the environment variable fields are populated, + /// otherwise `false`. + pub(crate) fn any_defined(&self) -> bool { + self.dsc_trace_format.is_some() || + self.dsc_trace_level.is_some() || + self.dsc_resource_path.is_some() || + self.dsc_restricted_path.is_some() || + self.dsc_ignore_settings_file.is_some() + } +} \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/sources/mod.rs b/lib/dsc-lib/src/settings/sources/mod.rs new file mode 100644 index 000000000..51c960743 --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/mod.rs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines sources for DSC settings, including code defaults, policy and preference files, +//! environment variables, and command line arguments. +//! +//! DSC settings are resolved for multiple scopes, each of which has a specific source. The +//! following table shows how every scope maps to a source: +//! +//! | Scope | Source | +//! |:---------------:|:---------------------------:| +//! | [`Default`] | [`DscSettingsCodeDefaults`] | +//! | [`Machine`] | [`PreferenceFileData`] | +//! | [`User`] | [`PreferenceFileData`] | +//! | [`Workspace`] | [`PreferenceFileData`] | +//! | [`Environment`] | [`EnvironmentData`] | +//! | [`CommandLine`] | [`CommandLineData`] | +//! | [`Policy`] | [`PolicyFileData`] | +//! +//! The sources defined in this module depend on the types defined in the [`fields`] module. Every +//! top-level field is defined a struct or enum in the [`fields`] module. +//! +//! Every setting field is included in the [`PolicyFileData`] struct to enable systems +//! administrators to fully control how DSC behaves. Most settings are defined in the +//! [`PreferenceFileData`] struct to enable users to define their own preferences. A smaller set +//! of settings are defined in the [`EnvironmentData`] struct to enable users to override settings +//! on a per-command basis. An even smaller set of settings are defined in the [`CommandLineData`] +//! struct to support overriding a setting for a single command invocation. +//! +//! [`Default`]: super::DscSettingsScope::Default +//! [`Machine`]: super::DscSettingsScope::Machine +//! [`User`]: super::DscSettingsScope::User +//! [`Workspace`]: super::DscSettingsScope::Workspace +//! [`Environment`]: super::DscSettingsScope::Environment +//! [`CommandLine`]: super::DscSettingsScope::CommandLine +//! [`Policy`]: super::DscSettingsScope::Policy +//! [`fields`]: super::fields + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! Every setting field _must_ be defined in the [`PolicyFileData`] struct to enable systems + //! administrators to fully control how DSC behaves in production environments. Fields may be + //! defined in other sources using the following guidelines: + //! + //! 1. Define the field in the [`PreferenceFileData`] struct unless the field is strictly + //! applicable as policy. For example, the `forbid_ignore_settings_file` only makes sense in + //! the policy file. + //! 1. If the field controls behavior that a user may want to override on a per-command *or + //! per-terminal-session basis, define the field in the [`EnvironmentData`] struct to enable + //! users to override the field using an environment variable. + //! + //! Non-Windows platforms allow users to prepend environment variables to a command to + //! affect behavior for that invocation only. We want to support this idiomatic behavior. + //! For example: + //! + //! ```sh + //! # Uses trace level from defaults/files + //! dsc config --parameter-file ./example.dsc.params.yaml get -f ./example.dsc.config.yaml + //! # Overrides trace level for this invocation only + //! DSC_TRACE_LEVEL=debug dsc config get -f ./example.dsc.config.yaml + //! ``` + //! + //! For guidance on how to define the environment variable for a field, see the + //! [`environment`] module [guidance](super::environment::_implementation_guidance). + //! 1. Only define the field as a command line argument if it improves the user experience + //! _substantially_ and helps with discoverability. Any field represented in the CLI must be + //! defined for the root command. The more options available on the root command, the higher + //! the cognitive load for users. + //! + //! Only surface critical settings and extremely common settings in the CLI. + //! + //! For guidance on how to define the command line argument for a field, see the + //! [`command_line`] module. + //! + //! [`PreferenceFileData`]: super::PreferenceFileData + //! [`PolicyFileData`]: super::PolicyFileData + //! [`EnvironmentData`]: super::EnvironmentData + //! [`environment`]: super::environment + //! [`command_line`]: super::command_line +} + +mod command_line; +pub use command_line::*; +mod code_defaults; +pub use code_defaults::*; +mod environment; +pub use environment::*; +mod preference_file; +pub use preference_file::*; +mod policy_file; +pub use policy_file::*; diff --git a/lib/dsc-lib/src/settings/sources/policy_file.rs b/lib/dsc-lib/src/settings/sources/policy_file.rs new file mode 100644 index 000000000..bcd5d47bb --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/policy_file.rs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines the [`PolicyFileData`] struct, which represents the data structure of the policy +//! file for DSC settings. + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! Every field defined in [`DscSettingsResolved`] struct should also be defined in the + //! [`PolicyFileData`] struct. + //! + //! When adding a new top-level field to the policy file, follow these guidelines: + //! + //! 1. Ensure that the field is defined in the [`fields`] module following that + //! [module guidance](crate::settings::fields::_implementation_guidance). + //! 1. Ensure that the field is defined in the [`DscSettingsResolved`] struct, following the + //! [module guidance](crate::settings::resolved::_implementation_guidance). + //! 1. Add the field to the [`PolicyFileData`] struct: + //! + //! - If the field is a container field, define the field in the struct as the appropriate + //! `*PolicyFileData` or `*FileData` struct type. + //! - If the field is a leaf field, define the field in the struct as an + //! [`Option`] with the appropriate type. + //! + //! No changes are required for the `from_file()` method, as it deserializes the field from the + //! policy file if it's defined. + //! + //! [`DscSettingsResolved`]: crate::settings::DscSettingsResolved + //! [`PolicyFileData`]: super::PolicyFileData + //! [`fields`]: crate::settings::fields +} + +#[cfg(not(test))] +use std::{fs::read_to_string, path::Path}; + +#[cfg(test)] +use crate::tests::stubs::{fs::read_to_string, path::Path}; + +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use crate::settings::{DscSettingsError, DscSettingsScope, fields::*}; +use crate::schemas::dsc_repo::{DscRepoSchema, schema_i18n}; + +/// Defines all settings for DSC as policy, which cannot be overridden. +/// +/// Any settings defined in the system policy settings file take precedence +/// over all other settings. Every setting that controls how DSC behaves is +/// represented in the policy file data. +#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, DscRepoSchema)] +#[serde(rename_all = "camelCase")] +#[dsc_repo_schema(base_name = "policyFile", folder_path = "settings/sources", should_bundle = true)] +#[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription") + ) +)] +pub struct PolicyFileData { + /// Indicates whether DSC should allow ignoring preference settings files + /// when loading and resolving settings. + /// + /// When this field is set to `true`, DSC will raise a warning if the user + /// defines the [`DSC_IGNORE_SETTINGS_FILE`] environment variable as `true` + /// or passes the [`--ignore-settings-file`] command-line argument. DSC + /// will load and resolve the preference files as if those options weren't + /// specified. + /// + /// This setting supercedes [`ignore_settings_file`]. If + /// `forbid_ignore_settings_file` is set to `true`, DSC will ignore the + /// `ignore_settings_file` policy setting. + /// + /// [`DSC_IGNORE_SETTINGS_FILE`]: crate::settings::sources::EnvironmentData::dsc_ignore_settings_file + /// [`--ignore-settings-file`]: crate::settings::sources::CommandLineData::ignore_settings_file + /// [`ignore_settings_file`]: Self::ignore_settings_file + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("forbid_ignore_settings_file.title"), + description = schema_i18n!("forbid_ignore_settings_file.description"), + extend( + "markdownDescription" = schema_i18n!("forbid_ignore_settings_file.markdownDescription") + ), + )] + pub forbid_ignore_settings_file: Option, + /// Indicates whether DSC should ignore preference settings files when + /// loading and resolving settings. + /// + /// When this field is set to `true`, DSC will ignore preference settings + /// files. It won't automatically load them and will ignore them when + /// resolving settings. + /// + /// If you define the `forbid_ignore_settings_file` field as `true`, DSC + /// ignores this setting. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("ignore_settings_file.title"), + description = schema_i18n!("ignore_settings_file.description"), + extend( + "markdownDescription" = schema_i18n!("ignore_settings_file.markdownDescription") + ), + )] + pub ignore_settings_file: Option, + /// Defines settings that affect how DSC emits trace messages. + /// + /// You can control the level of the messages DSC emits and the format that + /// DSC emits the messages as. + /// + /// For more information, see [`TracingFileData`]. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("tracing.title"), + description = schema_i18n!("tracing.description"), + extend( + "markdownDescription" = schema_i18n!("tracing.markdownDescription") + ), + )] + pub tracing: Option, + /// Defines settings that affect how DSC discovers and invokes resources + /// and other manifests. + /// + /// You can control the directories that DSC searches for executables and + /// manifests, whether to append the `PATH` environment variable, and + /// whether to restrict execution to the specified directories. + /// + /// For more information, see [`ResourcePathFileData`]. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("resource_path.title"), + description = schema_i18n!("resource_path.description"), + extend( + "markdownDescription" = schema_i18n!("resource_path.markdownDescription") + ), + )] + pub resource_path: Option, +} + +/// Public API +impl PolicyFileData { + /// Creates an instance of [`PolicyFileData`] from the specified file. + /// + /// This method reads the contents of the specified file and attempts to + /// deserialize it into an instance of [`PolicyFileData`]. + /// + /// # Arguments + /// + /// - `file_path` - The path to the preference file. + /// + /// # Returns + /// + /// A [`Result`] containing the instance of [`PolicyFileData`] if + /// successful, or a [`DscSettingsError`] if an error occurred. + /// + /// # Errors + /// + /// This method raises an error under the following conditions: + /// + /// 1. The file can't be read. + /// 1. The file is empty or contains only spacing characters. + /// 1. The file contains text that can't be parsed as valid JSON into the + /// the [`PolicyFileData`] structure. + pub fn from_file(file_path: &Path) -> Result { + // Make a string representation of the file path and scope for error + // reporting to avoid repeating the method chains throughout. + let file_path_str = file_path.to_string_lossy().to_string(); + let scope = DscSettingsScope::Policy.to_string(); + debug!("{}", t!( + "settings.sources.policy_file.debugLoadingFile", + file_path = file_path_str.clone(), + scope = scope.clone(), + )); + // Retrieve the file contents as a string or error + let contents = read_to_string(file_path) + .map_err(|err| DscSettingsError::DataFileReadError { + file_path: file_path_str.clone(), + scope: scope.clone(), + source: err, + })?; + // Error early if file is empty or contains only spacing characters + if contents.trim().is_empty() { + return Err(DscSettingsError::DataFileEmpty { + file_path: file_path_str.clone(), + scope: scope.clone(), + }); + } + + serde_json::from_str::(&contents) + .map_err(|err| DscSettingsError::DataFileUnparseable{ + file_path: file_path_str.clone(), + scope: scope.clone(), + source: err, + }) + } +} + +/// Private API +impl PolicyFileData { + /// Indicates whether any of the fields were populated from file data. + /// + /// This method is used when deciding whether to serialize the file data + /// and for processing to enable skipping when no fields were defined. + /// + /// # Returns + /// + /// `true` if any of the file data fields are populated, otherwise `false`. + pub(crate) fn any_defined(&self) -> bool { + self.forbid_ignore_settings_file.is_some() || + self.ignore_settings_file.is_some() || + self.resource_path.is_some() || + self.tracing.is_some() + } +} \ No newline at end of file diff --git a/lib/dsc-lib/src/settings/sources/preference_file.rs b/lib/dsc-lib/src/settings/sources/preference_file.rs new file mode 100644 index 000000000..7d3113873 --- /dev/null +++ b/lib/dsc-lib/src/settings/sources/preference_file.rs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines the [`PreferenceFileData`] struct, which represents the data structure of the +//! preference file for DSC settings. +//! +//! The [`Machine`], [`User`], and [`Workspace`] scopes all read their settings from a preference +//! file. +//! +//! [`Machine`]: crate::settings::DscSettingsScope::Machine +//! [`User`]: crate::settings::DscSettingsScope::User +//! [`Workspace`]: crate::settings::DscSettingsScope::Workspace + +#[cfg(not(test))] +use std::{fs::read_to_string, path::Path}; + +#[cfg(test)] +use crate::tests::stubs::{fs::read_to_string, path::Path}; + +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tracing::debug; + +use crate::settings::{DscSettingsError, DscSettingsScope, fields::*}; +use crate::schemas::dsc_repo::{DscRepoSchema, schema_i18n}; + +#[cfg(doc)] +pub(in crate::settings) mod _implementation_guidance { + //! Most fields defined in the [`DscSettingsResolved`] struct should also be defined in the + //! [`PreferenceFileData`] struct. The only current exceptions are [`IgnoreSettingsFileField`] + //! and [`ForbidIgnoreSettingsFileField`], which affect the loading and resolution for settings + //! files. + //! + //! When adding a new top-level field to the preference file, follow these guidelines: + //! + //! 1. Ensure that the field is defined in the [`fields`] module following that + //! [module guidance](crate::settings::fields::_implementation_guidance). + //! 1. Ensure that the field is defined in the [`DscSettingsResolved`] struct, following the + //! [module guidance](crate::settings::resolved::_implementation_guidance). + //! 1. Add the field to the [`PreferenceFileData`] struct: + //! + //! - If the field is a container field, define the field in the struct as the appropriate + //! `*PreferenceFileData` or `*FileData` struct type. + //! - If the field is a leaf field, define the field in the struct as an [`Option`] with + //! the appropriate type. + //! + //! No changes are required for the `from_file()` method, as it deserializes the field from the + //! preference file if it's defined. + //! + //! [`DscSettingsResolved`]: crate::settings::DscSettingsResolved + //! [`IgnoreSettingsFileField`]: crate::settings::fields::IgnoreSettingsFileField + //! [`ForbidIgnoreSettingsFileField`]: crate::settings::fields::ForbidIgnoreSettingsFileField + //! [`fields`]: crate::settings::fields + //! [`PreferenceFileData`]: super::PreferenceFileData +} + +/// Defines the structure of the preference file for DSC settings. +/// +/// The preference file allows users to specify their preferred settings for +/// DSC, which can be overridden by higher-priority sources such as the policy +/// file. +#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, DscRepoSchema)] +#[serde(rename_all = "camelCase")] +#[dsc_repo_schema(base_name = "preferenceFile", folder_path = "settings/sources", should_bundle = true)] +#[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = PreferenceFileData::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription") + ) +)] +pub struct PreferenceFileData { + /// Defines settings that affect how DSC emits trace messages. + /// + /// You can control the level of the messages DSC emits and the format that + /// DSC emits the messages as. + /// + /// For more information, see [`TracingFileData`]. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("tracing.title"), + description = schema_i18n!("tracing.description"), + extend( + "markdownDescription" = schema_i18n!("tracing.markdownDescription") + ), + )] + pub tracing: Option, + /// Defines settings that affect how DSC discovers and invokes resources + /// and other manifests. + /// + /// You can control the directories that DSC searches for executables and + /// manifests, whether to append the `PATH` environment variable, and + /// whether to restrict execution to the specified directories. + /// + /// For more information, see [`ResourcePathFileData`]. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars( + title = schema_i18n!("resource_path.title"), + description = schema_i18n!("resource_path.description"), + extend( + "markdownDescription" = schema_i18n!("resource_path.markdownDescription") + ), + )] + pub resource_path: Option, +} + +/// Public API +impl PreferenceFileData { + /// Creates an instance of [`PreferenceFileData`] from the specified file. + /// + /// This method reads the contents of the specified file and attempts to + /// deserialize it into an instance of [`PreferenceFileData`]. + /// + /// # Arguments + /// + /// - `file_path` - The path to the preference file. + /// + /// # Returns + /// + /// A [`Result`] containing the instance of [`PreferenceFileData`] if + /// successful, or a [`DscSettingsError`] if an error occurred. + /// + /// # Errors + /// + /// This method raises an error under the following conditions: + /// + /// 1. The file can't be read. + /// 1. The file is empty or contains only spacing characters. + /// 1. The file contains text that can't be parsed as valid JSON into the + /// the [`PreferenceFileData`] structure. + pub fn from_file(file_path: &Path) -> Result { + // Make a string representation of the file path for error reporting + // to avoid repeating the method chains throughout. + let file_path_str = file_path.to_string_lossy().to_string(); + let scope = Self::scope_for_path(file_path); + debug!("{}", t!( + "settings.sources.preference_file.debugLoadingFile", + file_path = file_path_str.clone(), + scope = scope.clone(), + )); + // Retrieve the file contents as a string or error + let contents = read_to_string(file_path) + .map_err(|err| DscSettingsError::DataFileReadError { + file_path: file_path_str.clone(), + scope: scope.clone(), + source: err, + })?; + // Error early if file is empty or contains only spacing characters + if contents.trim().is_empty() { + return Err(DscSettingsError::DataFileEmpty { + file_path: file_path_str.clone(), + scope: scope.clone(), + }); + } + + serde_json::from_str::(&contents) + .map_err(|err| DscSettingsError::DataFileUnparseable{ + file_path: file_path_str.clone(), + scope: scope.clone(), + source: err, + }) + } +} + +/// Private API +impl PreferenceFileData { + /// Indicates whether any of the fields were populated from file data. + /// + /// This method is used when deciding whether to serialize the file data + /// and for processing to enable skipping when no fields were defined. + /// + /// # Returns + /// + /// `true` if any of the file data fields are populated, otherwise `false`. + pub(crate) fn any_defined(&self) -> bool { + self.resource_path.is_some() || + self.tracing.is_some() + } + + /// Determines the scope of the preference file based on its file path. + /// + /// If the file path matches one of the known settings file paths, the + /// corresponding scope is returned. Otherwise, the scope is returned + /// as `unknown`. + /// + /// This is used for error messaging. + /// + /// # Arguments + /// + /// - `file_path` - The path to the preference file. + /// + /// # Returns + /// + /// A string representing the scope of the preference file. + fn scope_for_path(file_path: &Path) -> String { + if file_path == crate::settings::MACHINE_SETTINGS_FILE_PATH.as_path() { + DscSettingsScope::Machine.to_string() + } else if file_path == crate::settings::WORKSPACE_SETTINGS_FILE_PATH.as_path() { + DscSettingsScope::Workspace.to_string() + } else { + // Special handling for the user settings file, which may not exist for non-user accounts + if let Some(user_path) = crate::settings::USER_SETTINGS_FILE_PATH.as_ref() + && file_path == user_path.as_path() { + return DscSettingsScope::User.to_string(); + } + + String::from("unknown") + } + } +} diff --git a/lib/dsc-lib/src/tests/mod.rs b/lib/dsc-lib/src/tests/mod.rs new file mode 100644 index 000000000..bb9fdce7f --- /dev/null +++ b/lib/dsc-lib/src/tests/mod.rs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines unit tests and testing stubs. +//! +//! Generally, instead of defining unit tests in the library source code, we should instead define +//! integration tests in `tests/integration`. +//! +//! Only private API tests and tests that require stubbing external systems, like the file system +//! or environment variables, should be included here. + +#[macro_use] +pub(crate) mod stubs; + +mod settings; diff --git a/lib/dsc-lib/src/tests/settings/dsc_settings.rs b/lib/dsc-lib/src/tests/settings/dsc_settings.rs new file mode 100644 index 000000000..9d905ea2d --- /dev/null +++ b/lib/dsc-lib/src/tests/settings/dsc_settings.rs @@ -0,0 +1,915 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::settings::{DscSettings, DscSettingsError}; +use crate::settings::fields::*; + +#[macro_use] +pub(super) mod macros { + /// Defines a macro to panic with the current settings and a custom message. + /// + /// This helps provide more context to a test failure to reduce the need + /// for debugging to understand the state of the settings at the time of + /// the panic. + /// + /// This macro is only intended to be used in tests for [`DscSettings`]. + /// + /// # Examples + /// + /// ## For specific variable + /// + /// This example shows how to panic with the current settings and a + /// custom message. + /// + /// ```rust, ignore + /// let foo_settings = DscSettings::default(); + /// let msg = "example info"; + /// panic_with_settings!(foo_settings, "Custom panic message: {}", msg); + /// ``` + /// + /// The first arugment to the macro is the variable containing the settings + /// instance. The second argument is a format string. All remaining + /// arguments are used to format the custom panic message. + /// + /// ## For generic variable + /// + /// This example shows how to panic with the current settings and a + /// custom message. + /// + /// ```rust, ignore + /// let settings = DscSettings::default(); + /// let msg = "example info"; + /// panic_with_settings!("Custom panic message: {}", msg); + /// ``` + /// + /// In this example, because the variable is already defined as the + /// default identity `settings`, the invocation omits the variable name + /// for the first argument and passes only the format string and arguments. + macro_rules! panic_with_settings { + ($settings:ident, $fmt:expr $(, $arg:tt)*) => { + std::panic::panic_any( + $crate::tests::settings::dsc_settings::helpers::SettingsPanic{ + settings: $settings.clone(), + message: format!($fmt $(, $arg)*), + } + ) + }; + ($fmt:expr $(, $arg:tt)*) => { + std::panic::panic_any( + $crate::tests::settings::dsc_settings::helpers::SettingsPanic{ + settings: settings.clone(), + message: format!($fmt $(, $arg)*), + } + ); + }; + } + + /// Defines the testing context for the current test, only intended for + /// testing [`DscSettings`]. + /// + /// It provides a quick way to set up the stubs for environment variables + /// and settings files, which [`DscSettings`] reads for the load methods. + /// + /// This macro should be invoked at the beginning of each test to ensure a + /// clean and consistent testing environment. + /// + ///
+ /// + /// When using this macro, make sure to annotate the test functions with + /// `#[serial]` to ensure the tests aren't run in parallel. Parallel + /// execution can cause race conditions and poison the mutexes for the + /// stubs. + /// + ///
+ /// + /// ## Syntax + /// + /// This macro accepts zero or more key-value pairs. Each key represents a + /// source scope: + /// + /// - `policy`: Repesents the policy settings file. + /// + /// Define the value as a JSON object to stub the file as existing with + /// the given data. + /// + /// Define the value as `read_error("custom error message")` to simulate + /// a read error. + /// - `machine`: Represents the machine preference settings file. + /// + /// Define the value as a JSON object to stub the file as existing with + /// the given data. + /// + /// Define the value as `read_error("custom error message")` to simulate + /// a read error. + /// - `user`: Represents the user preference settings file. + /// + /// Define the value as a JSON object to stub the file as existing with + /// the given data. + /// + /// Define the value as `read_error("custom error message")` to simulate + /// a read error. + /// - `workspace`: Represents the workspace preference settings file. + /// + /// Define the value as a JSON object to stub the file as existing with + /// the given data. + /// + /// Define the value as `read_error("custom error message")` to simulate + /// a read error. + /// - `environment`: Represents the environment variables. + /// + /// Define the value as a key-value map to stub the environment variables + /// as existing with the given data. Omit any variables you want to treat + /// as undefined. + /// + /// # Examples + /// + /// ## Resetting the context + /// + /// For some tests, you may not want _any_ settings files or environment variables defined. + /// In such cases, you can simply invoke `define_context!{}` at the beginning of your test: + /// + /// ```rust, ignore + /// #[serial] + /// #[test] + /// fn without_any_defined() { + /// define_context!{} + /// + /// let mut settings = DscSettings::default(); + /// settings.load(); + /// + /// pretty_assertions::assert_eq!( + /// settings, + /// DscSettings::default() + /// ); + /// } + /// ``` + /// + /// ## Defining multiple sources + /// + /// To define multiple sources, specify each source as a separate key-value pair within the + /// macro. The order doesn't matter. + /// + /// ```rust, ignore + /// define_context!( + /// policy: { "forbidIgnoreSettingsFile": true }, + /// machine: { "tracing": { "level": "info" } }, + /// environment: { + /// DSC_TRACE_FORMAT => "json" + /// } + /// ) + /// ``` + macro_rules! define_context { + () => { + let _old_hook = helpers::install_custom_hook(); + $crate::tests::stubs::env::StubbedEnvContext::reset(); + $crate::tests::stubs::path::StubbedPathContextMap::reset(); + }; + ($($all:tt)+) => { + define_context!(); + add_context!($($all)+); + }; + } + + /// Semi-private macro used internally by `define_context!`. Do not define + /// directly. + macro_rules! add_context { + () => {}; + (policy: {$($json:tt)*} $(, $($rest:tt)*)?) => { + define_policy_file!(json: {$($json)*}); + $(add_context!($($rest)*);)? + }; + (policy: read_error($($policy_err:tt)+) $(, $($rest:tt)*)?) => { + define_policy_file!(read_err: $($policy_err)+); + $(add_context!($($rest)*);)? + }; + (machine: {$($json:tt)*} $(, $($rest:tt)*)?) => { + define_preference_file!(machine, json: {$($json)*}); + $(add_context!($($rest)*);)? + }; + (machine: read_err($($machine_err:tt)+) $(, $($rest:tt)*)?) => { + define_preference_file!(machine, read_err: $($machine_err)+); + $(add_context!($($rest)*);)? + }; + (user: {$($json:tt)*} $(, $($rest:tt)*)?) => { + define_preference_file!(user, json: {$($json)*}); + $(add_context!($($rest)*);)? + }; + (user: read_err($($user_err:tt)+) $(, $($rest:tt)*)?) => { + define_preference_file!(user, read_err: $($user_err)+); + $(add_context!($($rest)*);)? + }; + (workspace: {$($json:tt)*} $(, $($rest:tt)*)?) => { + define_preference_file!(workspace, json: {$($json)*}); + $(add_context!($($rest)*);)? + }; + (workspace: read_err($($workspace_err:tt)+) $(, $($rest:tt)*)?) => { + define_preference_file!(workspace, read_err: $($workspace_err)+); + $(add_context!($($rest)*);)? + }; + ( + environment: { + $($env_var:ident: $env_var_value:literal),* + $(,)? + } + $(, $($rest:tt)*)? + ) => { + $( + define_env_var!(name: stringify!($env_var), value: $env_var_value.to_string()); + )* + $(add_context!($($rest)*);)? + }; + } + + /// Defines an environment variable for testing purposes. + /// + /// Use this macro in a test function when you want to update the environment after initial + /// setup. For example, to reload settings with new environment variables. + /// + /// Prefer using the [`define_context!`] macro for initial setup in a test function over + /// composing the environment with multiple invocations of this macro. + /// + /// # Examples + /// + /// ## Variable with value + /// + /// This example shows how you can define an environment variable with a + /// specific value. + /// + /// ```rust, ignore + /// define_env_var!(name: "MY_ENV_VAR", value: "my_value"); + /// ``` + /// + /// ## Variable with error + /// + /// This example shows how you can define an environment variable that + /// results in a [`std::env::VarError::NotPresent`] error when accessed. + /// + /// ```rust, ignore + /// define_env_var!(name: "MY_ENV_VAR", err: NotPresent); + /// ``` + macro_rules! define_env_var { + (name: $name:expr, value: $value:expr) => { + $crate::tests::stubs::env::StubbedEnvContext::add($name, Ok($value)); + }; + (name: $name:expr, err: $err_variant:ident) => { + $crate::tests::stubs::env::StubbedEnvContext::add($name, Err(std::env::VarError::$err_variant)); + }; + (name: $name:expr, err: $err:expr) => { + $crate::tests::stubs::env::StubbedEnvContext::add($name, Err($err)); + }; + } + + /// Defines a stub for the machine policy file in the test environment. + /// + /// # Examples + /// + /// ## With JSON data + /// + /// In this example, the macro defines an entry for the policy file in the + /// stubbed context map that: + /// + /// - indicates that the policy file should exist. + /// - specifies that the policy file is a regular file. + /// - provides the JSON content to be returned when the file is read. + /// + /// ```rust, ignore + /// define_policy_file!(json: { "key": "value" }); + /// ``` + /// + /// ## With a not-found read error + /// + /// In this example, the macro defines an entry for the policy file in the + /// stubbed context map that: + /// + /// - indicates that the policy file should exist. + /// - specifies that the policy file is a regular file. + /// - provides a not-found error with the specified message attempting to + /// read the file. + /// + /// ```rust, ignore + /// // String literal + /// define_policy_file!(read_err: "custom message"); + /// // Expression + /// define_policy_file!(read_err: format!("custom message: {}", 42)); + /// ``` + /// + /// ## With a custom read error and generic message + /// + /// In this example, the macro defines an entry for the policy file in the + /// stubbed context map that: + /// + /// - indicates that the policy file should exist. + /// - specifies that the policy file is a regular file. + /// - provides a custom read error with the specified kind and message. + /// + /// ```rust, ignore + /// define_policy_file!(read_err: PermissionDenied}); + /// ``` + /// + /// ## With a custom read error and message + /// + /// In this example, the macro defines an entry for the policy file in the + /// stubbed context map that: + /// + /// - indicates that the policy file should exist. + /// - specifies that the policy file is a regular file. + /// - provides a custom read error with the specified kind and message. + /// + /// ```rust, ignore + /// define_policy_file!(read_err: { + /// kind: PermissionDenied, + /// msg: "custom message" + /// }); + /// ``` + macro_rules! define_policy_file { + (json: {$($json:tt)*}) => { + $crate::tests::stubs::path::StubbedPathContextMap::add( + $crate::settings::POLICY_SETTINGS_FILE_PATH.to_string(), + $crate::tests::stubs::path::StubbedPathContext { + should_exist: true, + is_file: true, + read_result: Ok( + serde_json::to_string_pretty( + &serde_json::json!({$($json)*}) + ).unwrap() + ), + ..Default::default() + } + ); + }; + (read_err: $err_kind:ident) => { + define_policy_file!{ + read_err: { + kind: $err_kind, + msg: format!("error reading '{}'", $crate::settings::POLICY_SETTINGS_FILE_PATH.to_string_lossy()).as_str() + } + } + }; + (read_err: $err_msg:literal) => { + define_policy_file! { + read_err: { + kind: NotFound, + msg: $err_msg + } + } + }; + (read_err: {kind: $err_kind:ident, msg: $err_msg:expr}) => { + $crate::tests::stubs::path::StubbedPathContextMap::add( + $crate::settings::POLICY_SETTINGS_FILE_PATH.to_string(), + $crate::tests::stubs::path::StubbedPathContext { + should_exist: true, + is_file: true, + read_result: Err(std::io::Error::new(std::io::ErrorKind::$err_kind, $err_msg)), + ..Default::default() + } + ); + }; + (read_err: $err_msg:expr) => { + define_policy_file! { + read_err: { + kind: NotFound, + msg: $err_msg + } + } + }; + } + + /// Defines a preference file for testing purposes in the stubbed context. + /// + /// # Syntax + /// + /// The macro uses the following patterns: + /// + /// 1. Define with JSON data: + /// + /// ```rust, ignore + /// define_preference_file!(, json: { "key": "value" }); + /// ``` + /// + /// 1. Define with a read error: + /// + /// ```rust, ignore + /// define_preference_file!(, read_err: ); + /// ``` + /// + /// Where `` is either a variant of [`std::io::ErrorKind`] or a + /// custom error message as a string. + /// + /// For both patterns, the first argument, ``, indicates which + /// preference file to stub: + /// + /// - `machine` + /// - `user` + /// - `workspace` + /// + /// # Examples + /// + /// ## Machine preference file with JSON data + /// + /// Define a machine preference file with JSON data: + /// + /// ```rust, ignore + /// define_preference_file!(machine, json: { "key": "value" }); + /// ``` + /// + /// ## User with error kind + /// + /// Define a user preference file with a read error: + /// + /// ```rust, ignore + /// define_preference_file!(user, read_err: NotFound); + /// ``` + /// + /// ## Workspace with custom error message + /// + /// Define a workspace preference file with a read error using a custom + /// error message: + /// + /// ```rust, ignore + /// define_preference_file!(workspace, read_err: "custom error message"); + /// ``` + macro_rules! define_preference_file { + (machine, json: {$($json:tt)*}) => { + define_preference_file!{ + path: $crate::settings::MACHINE_SETTINGS_FILE_PATH, + json: {$($json)*} + } + }; + (machine, read_err: $($err:tt)+) => { + define_preference_file!{ + path: $crate::settings::MACHINE_SETTINGS_FILE_PATH, + read_err: $($err)+ + } + }; + (user, json: {$($json:tt)*}) => { + define_preference_file!{ + path: $crate::settings::USER_SETTINGS_FILE_PATH.as_ref().unwrap(), + json: {$($json)*} + } + }; + (user, read_err: $($err:tt)+) => { + define_preference_file!{ + path: $crate::settings::USER_SETTINGS_FILE_PATH.as_ref().unwrap(), + read_err: $($err)+ + } + }; + (workspace, json: {$($json:tt)*}) => { + define_preference_file!{ + path: $crate::settings::WORKSPACE_SETTINGS_FILE_PATH, + json: {$($json)*} + } + }; + (workspace, read_err: $($err:tt)+) => { + define_preference_file!{ + path: $crate::settings::WORKSPACE_SETTINGS_FILE_PATH, + read_err: $($err)+ + } + }; + (path: $path:expr, json: {$($json:tt)*}) => { + $crate::tests::stubs::path::StubbedPathContextMap::add( + $path.to_string(), + $crate::tests::stubs::path::StubbedPathContext { + should_exist: true, + is_file: true, + read_result: Ok( + serde_json::to_string_pretty( + &serde_json::json!({$($json)*}) + ).unwrap() + ), + ..Default::default() + } + ); + }; + (path: $path:expr, read_err: $err_kind:ident) => { + define_preference_file!{ + path: $path, + read_err: { + kind: $err_kind, + msg: format!("error reading '{}'", $path.to_string()).as_str() + } + } + }; + (path: $path:expr, read_err: $err_msg:literal) => { + define_preference_file! { + path: $path, + read_err: { + kind: NotFound, + msg: $err_msg + } + } + }; + (path: $path:expr, read_err: {kind: $err_kind:ident, msg: $err_msg:expr}) => { + $crate::tests::stubs::path::StubbedPathContextMap::add( + $path.to_string_lossy().to_string(), + $crate::tests::stubs::path::StubbedPathContext { + should_exist: true, + is_file: true, + read_result: Err(std::io::Error::new(std::io::ErrorKind::$err_kind, $err_msg)), + ..Default::default() + } + ); + }; + (path: $path:expr, read_err: $err_msg:expr) => { + define_preference_file! { + path: $path, + read_err: { + kind: NotFound, + msg: $err_msg + } + } + }; + } +} + +pub(super) mod helpers { + use std::panic::{self, PanicHookInfo}; + + /// Defines information for the custom panic hook used in these tests. + pub struct SettingsPanic { + /// The panic message associated with this panic. + pub message: String, + /// The settings associated with this panic. + pub settings: crate::settings::DscSettings, + } + + /// Defines a custom panic hook for testing the DSC settings. + /// + /// This provides an alternate failure view for the tests in this file that + /// captures additional context about the panic, such as the current + /// settings and stubbed environment. + /// + /// This makes it easier to understand why a test failed. Without this + /// hook, you need to debug the test and inspect the stubbed context and + /// settings carefully before the panic. + /// + /// # Arguments + /// + /// - `info` - A reference to the [`PanicHookInfo`] containing information + /// about the panic. + fn custom_panic_hook(info: &PanicHookInfo) { + let mut context = serde_json::Map::new(); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "unknown location".to_string()); + + let message = if let Some(sp) = info.payload().downcast_ref::() { + context.insert(String::from("settings"), serde_json::to_value(&sp.settings).unwrap()); + &sp.message + } else if let Some(s) = info.payload_as_str() { + s + } else if let Some(s) = info.payload().downcast_ref::>() { + &s.to_string() + } else { + "unknown panic payload" + }; + + if let Ok(file_context) = crate::tests::stubs::path::StubbedPathContextMap::get().lock() { + context.insert(String::from("stubbed_files"), file_context.as_json()); + } + if let Ok(env_context) = crate::tests::stubs::env::StubbedEnvContext::get().lock() { + context.insert(String::from("stubbed_env"), env_context.as_json()); + } + + let context = serde_json::to_string_pretty(&context).unwrap(); + let thread = std::thread::current().name().unwrap_or("main").to_string(); + let color_start = "\x1b[31;1m"; + let color_end = "\x1b[0m"; + eprintln!("{color_start}thread '{thread}' panicked{color_end}"); + eprintln!("{color_start}at:{color_end} {location}"); + eprintln!("{color_start}message:{color_end} {message}"); + eprintln!("{color_start}context:{color_end} {context}"); + // Emit backtrace if needed + if let Ok(bt) = std::env::var("RUST_BACKTRACE") { + if bt != "0" { + eprintln!("Backtrace:\n{}", std::backtrace::Backtrace::force_capture()); + } + } + } + /// Install the custom hook and return the previous one so it can be restored later + pub(super) fn install_custom_hook() -> Box { + let old_hook = panic::take_hook(); + panic::set_hook(Box::new(custom_panic_hook)); + old_hook + } +} + +/// Tests for [`DscSettings::load()`] with stubbed environment and filesystem. +mod load { + use super::*; + use pretty_assertions::assert_eq; + use serial_test::serial; + + #[serial] + #[test] + fn when_no_settings_defined() { + define_context!{} + + let mut actual = DscSettings::new(); + actual.load(); + + assert_eq!(actual.policy, None); + assert_eq!(actual.machine, None); + assert_eq!(actual.user, None); + assert_eq!(actual.workspace, None); + assert_eq!(actual.environment, None); + assert_eq!(actual.command_line, None); + } + + #[serial] + #[test] + fn when_all_defined_valid() { + define_context! { + policy: { + "forbidIgnoreSettingsFile": false, + "ignoreSettingsFile": false, + }, + machine: { + "tracing": { + "level": "info", + }, + }, + user: { + "resourcePath": { + "directories": [ + "/usr/resources", + ], + }, + }, + workspace: { + "tracing": { + "format": "json", + }, + }, + environment: { + DSC_TRACE_LEVEL: "debug", + DSC_IGNORE_SETTINGS_FILE: "true", + } + } + + let mut settings = DscSettings::new(); + settings.load(); + + if let Some(policy) = settings.policy.as_ref() { + assert_eq!(policy.forbid_ignore_settings_file.expect("policy should define forbidIgnoreSettingsFile"), false); + assert_eq!(policy.ignore_settings_file.expect("policy should define ignoreSettingsFile"), false); + assert_eq!(policy.resource_path, None); + assert_eq!(policy.tracing, None) + } else { + panic_with_settings!(settings, "expected policy settings to be defined") + } + + if let Some(machine) = settings.machine.as_ref() { + assert_eq!(machine.resource_path, None); + let tracing = machine.tracing.as_ref().expect("machine should define tracing"); + assert_eq!( + tracing.level.as_ref().expect("machine should define tracing.level"), + &TracingLevelField::Info + ); + assert_eq!(tracing.format, None); + } else { + panic_with_settings!(settings, "expected machine settings to be defined") + } + + if let Some(user) = settings.user.as_ref() { + assert_eq!(user.tracing, None); + let resource_path = user.resource_path.as_ref().expect("user should define 'resourcePath'"); + assert_eq!( + resource_path.directories.as_ref().expect("user should define resourcePath.directories"), + &vec![std::path::PathBuf::from("/usr/resources")] + ); + assert_eq!(resource_path.append_env_path, None); + assert_eq!(resource_path.restricted, None); + } else { + panic_with_settings!(settings, "expected user settings to be defined") + } + + if let Some(workspace) = settings.workspace.as_ref() { + assert_eq!(workspace.resource_path, None); + let tracing = workspace.tracing.as_ref().expect("workspace should define tracing"); + assert_eq!( + tracing.format.as_ref().expect("workspace should define tracing.format"), + &TracingFormatField::Json + ); + assert_eq!(tracing.level, None); + } else { + panic_with_settings!(settings, "expected workspace settings to be defined") + } + + if let Some(environment) = settings.environment.as_ref() { + assert_eq!(environment.dsc_ignore_settings_file, Some(true.into())); + assert_eq!(environment.dsc_trace_level, Some(TracingLevelField::Debug)); + assert_eq!(environment.dsc_trace_format, None); + assert_eq!(environment.dsc_resource_path, None); + assert_eq!(environment.dsc_restricted_path, None); + } else { + panic_with_settings!(settings, "expected environment settings to be defined"); + } + } + + #[test] + #[serial] + fn when_all_defined_with_invalid_fields() { + define_context! { + policy: { + "forbidIgnoreSettingsFile": "invalid", + }, + machine: { + "tracing": { + "level": "invalid", + }, + }, + user: { + "resourcePath": { + "directories": "invalid" + }, + }, + workspace: { + "tracing": { + "format": "invalid", + }, + }, + environment: { + DSC_TRACE_LEVEL: "invalid", + DSC_IGNORE_SETTINGS_FILE: "invalid", + } + } + + let mut settings = DscSettings::new(); + settings.load(); + + assert_eq!(settings.policy, None); + assert_eq!(settings.machine, None); + assert_eq!(settings.user, None); + assert_eq!(settings.workspace, None); + assert_eq!(settings.environment, None); + assert_eq!(settings.command_line, None); + } +} + +/// Tests for [`DscSettings::try_load()`] with stubbed environment and +/// filesystem. +mod try_load { + use std::ops::Index; + + use super::*; + use pretty_assertions::assert_eq; + use serial_test::serial; + + #[serial] + #[test] + fn when_no_settings_defined() { + define_context!{} + + let mut actual = DscSettings::new(); + actual.try_load().expect("settings should load without error"); + + assert_eq!(actual.policy, None); + assert_eq!(actual.machine, None); + assert_eq!(actual.user, None); + assert_eq!(actual.workspace, None); + assert_eq!(actual.environment, None); + assert_eq!(actual.command_line, None); + } + + #[serial] + #[test] + fn when_all_defined_valid() { + define_context! { + policy: { + "forbidIgnoreSettingsFile": false, + "ignoreSettingsFile": false, + }, + machine: { + "tracing": { + "level": "info", + }, + }, + user: { + "resourcePath": { + "directories": [ + "/usr/resources", + ], + }, + }, + workspace: { + "tracing": { + "format": "json", + }, + }, + environment: { + DSC_TRACE_LEVEL: "debug", + DSC_IGNORE_SETTINGS_FILE: "true", + } + } + + let mut settings = DscSettings::new(); + settings.try_load().expect("settings should load without error"); + + if let Some(policy) = settings.policy.as_ref() { + assert_eq!(policy.forbid_ignore_settings_file.expect("policy should define forbidIgnoreSettingsFile"), false); + assert_eq!(policy.ignore_settings_file.expect("policy should define ignoreSettingsFile"), false); + assert_eq!(policy.resource_path, None); + assert_eq!(policy.tracing, None) + } else { + panic_with_settings!(settings, "expected policy settings to be defined") + } + + if let Some(machine) = settings.machine.as_ref() { + assert_eq!(machine.resource_path, None); + let tracing = machine.tracing.as_ref().expect("machine should define tracing"); + assert_eq!( + tracing.level.as_ref().expect("machine should define tracing.level"), + &TracingLevelField::Info + ); + assert_eq!(tracing.format, None); + } else { + panic_with_settings!(settings, "expected machine settings to be defined") + } + + if let Some(user) = settings.user.as_ref() { + assert_eq!(user.tracing, None); + let resource_path = user.resource_path.as_ref().expect("user should define 'resourcePath'"); + assert_eq!( + resource_path.directories.as_ref().expect("user should define resourcePath.directories"), + &vec![std::path::PathBuf::from("/usr/resources")] + ); + assert_eq!(resource_path.append_env_path, None); + assert_eq!(resource_path.restricted, None); + } else { + panic_with_settings!(settings, "expected user settings to be defined") + } + + if let Some(workspace) = settings.workspace.as_ref() { + assert_eq!(workspace.resource_path, None); + let tracing = workspace.tracing.as_ref().expect("workspace should define tracing"); + assert_eq!( + tracing.format.as_ref().expect("workspace should define tracing.format"), + &TracingFormatField::Json + ); + assert_eq!(tracing.level, None); + } else { + panic_with_settings!(settings, "expected workspace settings to be defined") + } + + if let Some(environment) = settings.environment.as_ref() { + assert_eq!(environment.dsc_ignore_settings_file, Some(true.into())); + assert_eq!(environment.dsc_trace_level, Some(TracingLevelField::Debug)); + assert_eq!(environment.dsc_trace_format, None); + assert_eq!(environment.dsc_resource_path, None); + assert_eq!(environment.dsc_restricted_path, None); + } else { + panic_with_settings!(settings, "expected environment settings to be defined"); + } + } + + #[test] + #[serial] + fn when_all_defined_with_invalid_fields() { + define_context! { + policy: { + "forbidIgnoreSettingsFile": "invalid", + }, + machine: { + "tracing": { + "level": "invalid", + }, + }, + user: { + "resourcePath": { + "directories": "invalid" + }, + }, + workspace: { + "tracing": { + "format": "invalid", + }, + }, + environment: { + DSC_TRACE_LEVEL: "invalid", + DSC_IGNORE_SETTINGS_FILE: "invalid", + } + } + + let mut settings = DscSettings::new(); + + match settings.try_load().expect_err("expected an error due to invalid fields") { + DscSettingsError::LoadMultipleErrors{ errors } => { + assert_eq!(errors.len(), 5); + assert!(matches!(errors.index(0), DscSettingsError::DataFileUnparseable { .. })); + assert!(matches!(errors.index(1), DscSettingsError::DataFileUnparseable { .. })); + assert!(matches!(errors.index(2), DscSettingsError::DataFileUnparseable { .. })); + assert!(matches!(errors.index(3), DscSettingsError::DataFileUnparseable { .. })); + assert!(matches!(errors.index(4), DscSettingsError::LoadEnvironmentMultipleErrors { .. })); + }, + e => panic!("expected DscSettingsError::LoadMultipleErrors but got {:#?}", e), + } + + assert_eq!(settings.policy, None); + assert_eq!(settings.machine, None); + assert_eq!(settings.user, None); + assert_eq!(settings.workspace, None); + assert_eq!(settings.environment, None); + assert_eq!(settings.command_line, None); + } +} diff --git a/lib/dsc-lib/src/tests/settings/mod.rs b/lib/dsc-lib/src/tests/settings/mod.rs new file mode 100644 index 000000000..3043e202f --- /dev/null +++ b/lib/dsc-lib/src/tests/settings/mod.rs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Unit tests for DSC Settings +//! +//! This module contains unit tests for the DSC Settings functionality, including tests for +//! loading settings from various sources such as environment variables, policy files, and +//! preference files. +//! +//! These unit tests are defined to take advantage of mocking the filesystem and environment +//! variables to enable isolated testing for the behavior of the public API for [`DscSettings`], +//! [`PreferenceFileData`], [`PolicyFileData`], and [`EnvironmentData`]. +//! +//! [`DscSettings`]: crate::settings::DscSettings +//! [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData +//! [`PolicyFileData`]: crate::settings::sources::PolicyFileData +//! [`EnvironmentData`]: crate::settings::sources::EnvironmentData +//! +//! The majority of tests for the [`crate::settings`] module are located in the integration test +//! suite. + +/// Testing for the [`DscSettings`] struct. +/// +/// [`DscSettings`]: crate::settings::DscSettings +mod dsc_settings; +/// Testing for the various sources that DSC settings uses. +mod sources { + /// Testing for [`EnvironmentData`], which loads settings from environment variables. + /// + /// [`EnvironmentData`]: crate::settings::sources::EnvironmentData + mod environment; + /// Testing for [`PolicyFileData`], which loads settings from the machine policy file. + /// + /// [`PolicyFileData`]: crate::settings::sources::PolicyFileData + mod policy_file; + /// Testing for [`PreferenceFileData`], which loads settings from the user preference file. + /// + /// [`PreferenceFileData`]: crate::settings::sources::PreferenceFileData + mod preference_file; +} \ No newline at end of file diff --git a/lib/dsc-lib/src/tests/settings/sources/environment.rs b/lib/dsc-lib/src/tests/settings/sources/environment.rs new file mode 100644 index 000000000..84abe4604 --- /dev/null +++ b/lib/dsc-lib/src/tests/settings/sources/environment.rs @@ -0,0 +1,458 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::env::VarError; +use std::path::PathBuf; + +use crate::settings::DscSettingsError; +use crate::settings::fields::{IgnoreSettingsFileField, TracingFormatField, TracingLevelField}; +use crate::settings::sources::EnvironmentData; +use crate::tests::stubs::env::*; + +/// Tests for [`EnvironmentData::get_env_trace_level()`] with stubbed +/// environment variables. +mod get_env_trace_level { + use test_case::test_case; + use serial_test::serial; + + use super::*; + + #[serial] + #[test] fn when_not_defined() { + stubbed_env_context_map! { + "TRACE_LEVEL" => Err(VarError::NotPresent), + }; + + let result = EnvironmentData::get_env_trace_level(); + assert!(matches!(result, Ok(None))); + } + + #[test_case("trace" => TracingLevelField::Trace; "trace lower case")] + #[test_case("TRACE" => TracingLevelField::Trace; "trace upper case")] + #[test_case("tRaCe" => TracingLevelField::Trace; "trace mixed case")] + #[test_case("debug" => TracingLevelField::Debug; "debug lower case")] + #[test_case("DEBUG" => TracingLevelField::Debug; "debug upper case")] + #[test_case("dEbUg" => TracingLevelField::Debug; "debug mixed case")] + #[test_case("info" => TracingLevelField::Info; "info lower case")] + #[test_case("INFO" => TracingLevelField::Info; "info upper case")] + #[test_case("iNfO" => TracingLevelField::Info; "info mixed case")] + #[test_case("warn" => TracingLevelField::Warn; "warn lower case")] + #[test_case("WARN" => TracingLevelField::Warn; "warn upper case")] + #[test_case("wArN" => TracingLevelField::Warn; "warn mixed case")] + #[test_case("error" => TracingLevelField::Error; "error lower case")] + #[test_case("ERROR" => TracingLevelField::Error; "error upper case")] + #[test_case("eRrOr" => TracingLevelField::Error; "error mixed case")] + #[serial] + fn when_defined_with_valid_value(value: &str) -> TracingLevelField { + stubbed_env_context_map! { + "DSC_TRACE_LEVEL" => Ok(value.to_string()), + }; + + EnvironmentData::get_env_trace_level() + .expect("TRACE_LEVEL should be defined and parseable") + .expect("TRACE_LEVEL should be defined and parseable") + } + + #[test_case("invalid"; "invalid value")] + #[test_case(" "; "space-only value")] + #[test_case(" debug"; "leading space before valid value")] + #[test_case("debug "; "trailing space after valid value")] + #[serial] + fn when_defined_with_invalid_value(value: &str) { + stubbed_env_context_map! { + "DSC_TRACE_LEVEL" => Ok(value.to_string()), + }; + + let err = EnvironmentData::get_env_trace_level() + .expect_err("DSC_TRACE_LEVEL should be defined with invalid value"); + + match err { + DscSettingsError::LoadEnvironmentError{env_var, source} => { + assert_eq!(env_var, "DSC_TRACE_LEVEL"); + + match *source { + DscSettingsError::InvalidTracingLevel{text} => { + assert_eq!(text, value); + }, + _ => panic!("Expected InvalidTracingLevel as source but was {source:?}"), + } + } + _ => panic!("Expected LoadEnvironmentError with InvalidTracingLevel source but was {err:?}"), + } + } +} + +/// Tests for [`EnvironmentData::get_env_trace_format()`] with stubbed +/// environment variables. +mod get_env_trace_format { + use test_case::test_case; + use serial_test::serial; + + use super::*; + + #[serial] + #[test] + fn when_not_defined() { + stubbed_env_context_map! { + "DSC_TRACE_FORMAT" => Err(std::env::VarError::NotPresent), + }; + + let result = EnvironmentData::get_env_trace_format(); + assert!(matches!(result, Ok(None))); + } + + #[test_case("default" => TracingFormatField::Default; "default lower case")] + #[test_case("DEFAULT" => TracingFormatField::Default; "default upper case")] + #[test_case("dEfAuLt" => TracingFormatField::Default; "default mixed case")] + #[test_case("json" => TracingFormatField::Json; "json lower case")] + #[test_case("JSON" => TracingFormatField::Json; "json upper case")] + #[test_case("jSoN" => TracingFormatField::Json; "json mixed case")] + #[test_case("plaintext" => TracingFormatField::Plaintext; "plaintext lower case")] + #[test_case("PLAINTEXT" => TracingFormatField::Plaintext; "plaintext upper case")] + #[test_case("pLaInTeXt" => TracingFormatField::Plaintext; "plaintext mixed case")] + #[serial] + fn when_defined_with_valid_value(value: &str) -> TracingFormatField { + stubbed_env_context_map! { + "DSC_TRACE_FORMAT" => Ok(value.to_string()), + }; + + EnvironmentData::get_env_trace_format() + .expect("TRACE_FORMAT should be defined and parseable") + .expect("TRACE_FORMAT should be defined and parseable") + } + + #[test_case("invalid"; "invalid value")] + #[test_case(" "; "space-only value")] + #[test_case(" json"; "leading space before valid value")] + #[test_case("json "; "trailing space after valid value")] + #[serial] + fn when_defined_with_invalid_value(value: &str) { + stubbed_env_context_map! { + "DSC_TRACE_FORMAT" => Ok(value.to_string()), + }; + + let err = EnvironmentData::get_env_trace_format() + .expect_err("DSC_TRACE_FORMAT should be defined with invalid value"); + + match err { + DscSettingsError::LoadEnvironmentError{env_var, source} => { + assert_eq!(env_var, "DSC_TRACE_FORMAT"); + + match *source { + DscSettingsError::InvalidTracingFormat{text} => { + assert_eq!(text, value); + }, + _ => panic!("Expected InvalidTracingFormat as source but was {source:?}"), + } + } + _ => panic!("Expected LoadEnvironmentError with InvalidTracingFormat source but was {err:?}"), + } + } +} + +/// Tests for [`EnvironmentData::get_env_resource_path()`] with stubbed +/// environment variables. +mod get_env_resource_path { + use test_case::test_case; + use serial_test::serial; + + use super::*; + + #[serial] + #[test] + fn when_not_defined() { + stubbed_env_context_map! { + "DSC_RESOURCE_PATH" => Err(std::env::VarError::NotPresent), + }; + + let result = EnvironmentData::get_env_resource_path(); + assert!(matches!(result, None)); + } + + #[test_case(vec!["/test/example"]; "single valid path")] + #[test_case(vec!["/test/example", "/another/path"]; "multiple valid paths")] + #[serial] + fn when_defined(paths: Vec<&str>) { + let paths = std::env::join_paths(paths.iter()).unwrap(); + let expected = std::env::split_paths(&paths) + .collect::>(); + stubbed_env_context_map! { + "DSC_RESOURCE_PATH" => Ok(paths.to_string_lossy().into_owned()), + }; + + let result = EnvironmentData::get_env_resource_path() + .expect("DSC_RESOURCE_PATH should be populated"); + pretty_assertions::assert_eq!(result, expected); + } +} + +/// Tests for [`EnvironmentData::get_env_restricted_path()`] with stubbed +/// environment variables. +mod get_env_restricted_path { + use test_case::test_case; + use serial_test::serial; + + use super::*; + + #[serial] + #[test] + fn when_not_defined() { + stubbed_env_context_map! { + "DSC_RESTRICTED_PATH" => Err(std::env::VarError::NotPresent), + }; + + let result = EnvironmentData::get_env_restricted_path(); + assert!(matches!(result, None)); + } + + #[test_case(vec!["/test/example"]; "single valid path")] + #[test_case(vec!["/test/example", "/another/path"]; "multiple valid paths")] + #[serial] + fn when_defined(paths: Vec<&str>) { + let paths = std::env::join_paths(paths.iter()).unwrap(); + let expected = std::env::split_paths(&paths) + .collect::>(); + stubbed_env_context_map! { + "DSC_RESTRICTED_PATH" => Ok(paths.to_string_lossy().into_owned()), + }; + + let result = EnvironmentData::get_env_restricted_path() + .expect("DSC_RESTRICTED_PATH should be populated"); + pretty_assertions::assert_eq!(result, expected); + } +} + +/// Tests for [`EnvironmentData::get_env_ignore_settings_file()`] with stubbed +/// environment variables. +mod get_env_ignore_settings_file { + use super::*; + + use test_case::test_case; + use serial_test::serial; + + #[serial] + #[test] + fn when_not_defined() { + stubbed_env_context_map! { + "DSC_IGNORE_SETTINGS_FILE" => Err(std::env::VarError::NotPresent), + }; + + let result = EnvironmentData::get_env_ignore_settings_file(); + assert!(matches!(result, Ok(None))); + } + + #[test_case("true" => true; "true lower case")] + #[test_case("TRUE" => true; "true upper case")] + #[test_case("tRuE" => true; "true mixed case")] + #[test_case("1" => true; "true as numeral 1")] + #[test_case("false" => false; "false lower case")] + #[test_case("FALSE" => false; "false upper case")] + #[test_case("fAlSe" => false; "false mixed case")] + #[test_case("0" => false; "false as numeral 0")] + #[serial] + fn when_defined_with_valid_value(value: &str) -> bool { + stubbed_env_context_map! { + "DSC_IGNORE_SETTINGS_FILE" => Ok(value.to_string()), + }; + + EnvironmentData::get_env_ignore_settings_file() + .expect("DSC_IGNORE_SETTINGS_FILE should be defined and valid") + .expect("DSC_IGNORE_SETTINGS_FILE should be defined and valid") + .into() + } + + #[test_case("invalid" ; "non-boolean string")] + #[test_case("2" ; "numerical value greater than 1")] + #[test_case("-1" ; "numerical value less than 0")] + #[test_case("0.5"; "non-integer numerical value")] + #[test_case(" " ; "whitespace only value")] + #[test_case(" true" ; "valid value with leading whitespace")] + #[test_case("true " ; "valid value with trailing whitespace")] + #[serial] + fn when_defined_with_invalid_value(env_var_value: &str) { + stubbed_env_context_map! { + "DSC_IGNORE_SETTINGS_FILE" => Ok(env_var_value.to_string()), + }; + + let err = EnvironmentData::get_env_ignore_settings_file() + .expect_err("DSC_IGNORE_SETTINGS_FILE should be defined but invalid"); + + match err { + DscSettingsError::LoadEnvironmentError { env_var, source } => { + assert_eq!(env_var, "DSC_IGNORE_SETTINGS_FILE"); + match *source { + DscSettingsError::EnvVarUnparseableBoolean { value } => { + assert_eq!(value, env_var_value); + } + _ => panic!("expected EnvVarUnparseableBoolean"), + } + }, + _ => panic!("expected LoadEnvironmentError error"), + } + } +} + +/// Tests for [`EnvironmentData::from_env()`] with stubbed environment +/// variables. +mod from_env { + use serial_test::serial; + use super::*; + + #[serial] + #[test] + fn with_no_env_var_defined() { + StubbedEnvContext::reset(); + + let env_data = EnvironmentData::from_env(); + pretty_assertions::assert_eq!(env_data.dsc_resource_path, None); + pretty_assertions::assert_eq!(env_data.dsc_restricted_path, None); + pretty_assertions::assert_eq!(env_data.dsc_trace_format, None); + pretty_assertions::assert_eq!(env_data.dsc_trace_level, None); + pretty_assertions::assert_eq!(env_data.dsc_ignore_settings_file, None); + } + + #[serial] + #[test] + fn with_all_env_var_defined_with_valid_value() { + stubbed_env_context_map! { + "DSC_IGNORE_SETTINGS_FILE" => Ok("true".to_string()), + "DSC_RESOURCE_PATH" => Ok("/some/resource/path".to_string()), + "DSC_RESTRICTED_PATH" => Ok("/some/restricted/path".to_string()), + "DSC_TRACE_FORMAT" => Ok("json".to_string()), + "DSC_TRACE_LEVEL" => Ok("info".to_string()), + }; + + let env_data = EnvironmentData::from_env(); + pretty_assertions::assert_eq!( + env_data.dsc_ignore_settings_file, + Some(IgnoreSettingsFileField::new(true)) + ); + pretty_assertions::assert_eq!( + env_data.dsc_resource_path, + Some(vec![PathBuf::from("/some/resource/path")]) + ); + pretty_assertions::assert_eq!( + env_data.dsc_restricted_path, + Some(vec![PathBuf::from("/some/restricted/path")]) + ); + pretty_assertions::assert_eq!( + env_data.dsc_trace_format, + Some(TracingFormatField::Json) + ); + pretty_assertions::assert_eq!( + env_data.dsc_trace_level, + Some(TracingLevelField::Info) + ); + } + + #[serial] + #[test] + fn with_some_env_var_defined_with_invalid_value() { + stubbed_env_context_map! { + "DSC_IGNORE_SETTINGS_FILE" => Ok("not_a_boolean".to_string()), + "DSC_RESOURCE_PATH" => Ok("/some/resource/path".to_string()), + "DSC_RESTRICTED_PATH" => Ok("/some/restricted/path".to_string()), + "DSC_TRACE_FORMAT" => Ok("json".to_string()), + "DSC_TRACE_LEVEL" => Ok("invalid".to_string()), + }; + + let env_data = EnvironmentData::from_env(); + pretty_assertions::assert_eq!(env_data.dsc_ignore_settings_file, None); + pretty_assertions::assert_eq!(env_data.dsc_trace_level, None); + pretty_assertions::assert_eq!( + env_data.dsc_resource_path, + Some(vec![PathBuf::from("/some/resource/path")]) + ); + pretty_assertions::assert_eq!( + env_data.dsc_restricted_path, + Some(vec![PathBuf::from("/some/restricted/path")]) + ); + pretty_assertions::assert_eq!( + env_data.dsc_trace_format, + Some(TracingFormatField::Json) + ); + } +} + +/// Tests for [`EnvironmentData::try_from_env()`] with stubbed environment +/// variables. +mod try_from_env { + use serial_test::serial; + use super::*; + + #[serial] + #[test] + fn with_no_env_var_defined() { + StubbedEnvContext::reset(); + + let env_data = EnvironmentData::try_from_env() + .expect("EnvironmentData should be successfully created even with no environment variables defined"); + pretty_assertions::assert_eq!(env_data.dsc_resource_path, None); + pretty_assertions::assert_eq!(env_data.dsc_restricted_path, None); + pretty_assertions::assert_eq!(env_data.dsc_trace_format, None); + pretty_assertions::assert_eq!(env_data.dsc_trace_level, None); + pretty_assertions::assert_eq!(env_data.dsc_ignore_settings_file, None); + } + + #[serial] + #[test] + fn with_all_env_var_defined_with_valid_value() { + stubbed_env_context_map! { + "DSC_IGNORE_SETTINGS_FILE" => Ok("true".to_string()), + "DSC_RESOURCE_PATH" => Ok("/some/resource/path".to_string()), + "DSC_RESTRICTED_PATH" => Ok("/some/restricted/path".to_string()), + "DSC_TRACE_FORMAT" => Ok("json".to_string()), + "DSC_TRACE_LEVEL" => Ok("info".to_string()), + }; + + let env_data = EnvironmentData::try_from_env() + .expect("All DSC variables should be validly defined"); + pretty_assertions::assert_eq!( + env_data.dsc_ignore_settings_file, + Some(IgnoreSettingsFileField::new(true)) + ); + pretty_assertions::assert_eq!( + env_data.dsc_resource_path, + Some(vec![PathBuf::from("/some/resource/path")]) + ); + pretty_assertions::assert_eq!( + env_data.dsc_restricted_path, + Some(vec![PathBuf::from("/some/restricted/path")]) + ); + pretty_assertions::assert_eq!( + env_data.dsc_trace_format, + Some(TracingFormatField::Json) + ); + pretty_assertions::assert_eq!( + env_data.dsc_trace_level, + Some(TracingLevelField::Info) + ); + } + + #[serial] + #[test] + fn with_some_env_var_defined_with_invalid_value() { + stubbed_env_context_map! { + "DSC_IGNORE_SETTINGS_FILE" => Ok("not_a_boolean".to_string()), + "DSC_RESOURCE_PATH" => Ok("/some/resource/path".to_string()), + "DSC_RESTRICTED_PATH" => Ok("/some/restricted/path".to_string()), + "DSC_TRACE_FORMAT" => Ok("json".to_string()), + "DSC_TRACE_LEVEL" => Ok("invalid".to_string()), + }; + + let err = EnvironmentData::try_from_env() + .expect_err("At least one DSC variable should raise an error"); + match err { + DscSettingsError::LoadEnvironmentMultipleErrors{errors} => { + assert_eq!(errors.len(), 2); + assert!(errors.iter().any(|e| matches!(e, + DscSettingsError::LoadEnvironmentError{env_var, ..} if *env_var == "DSC_IGNORE_SETTINGS_FILE" + ))); + assert!(errors.iter().any(|e| matches!(e, + DscSettingsError::LoadEnvironmentError{env_var, ..} if *env_var == "DSC_TRACE_LEVEL" + ))); + }, + _ => panic!("Expected LoadEnvironmentMultipleErrors"), + } + } +} diff --git a/lib/dsc-lib/src/tests/settings/sources/policy_file.rs b/lib/dsc-lib/src/tests/settings/sources/policy_file.rs new file mode 100644 index 000000000..c88c74949 --- /dev/null +++ b/lib/dsc-lib/src/tests/settings/sources/policy_file.rs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::settings::DscSettingsError; +use crate::settings::fields::{ + ForbidIgnoreSettingsFileField, + IgnoreSettingsFileField, + TracingFileData, + TracingLevelField, + TracingFormatField, + ResourcePathFileData +}; +use crate::settings::sources::PolicyFileData; +use crate::tests::stubs::path::*; + +/// Tests for [`PolicyFileData::from_file()`] with stubbed file system context. +mod from_file { + use serial_test::serial; + + use crate::settings::DscSettingsScope; + +use super::*; + + #[test] + #[serial] + fn when_file_not_exist() { + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: false, + ..Default::default() + } + ); + + let err = PolicyFileData::from_file(Path::new("/test/file.json")) + .expect_err("should raise an error"); + + match err { + DscSettingsError::DataFileReadError{file_path, scope, source} => { + assert_eq!(file_path, String::from("/test/file.json")); + assert_eq!(scope, DscSettingsScope::Policy); + assert_eq!(source.kind(), std::io::ErrorKind::NotFound); + }, + _ => panic!("expected FileReadError but got {err:?}"), + } + } + + #[serial] + #[test] + fn when_file_empty() { + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(String::new()), + ..Default::default() + } + ); + + let err = PolicyFileData::from_file(Path::new("/test/file.json")) + .expect_err("should raise an error"); + + match err { + DscSettingsError::DataFileEmpty{file_path, scope} => { + assert_eq!(file_path, String::from("/test/file.json")); + assert_eq!(scope, DscSettingsScope::Policy); + }, + _ => panic!("expected DataFileEmpty but got {err:?}"), + } + } + + #[serial] + #[test] + fn when_file_only_whitespace() { + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(String::from(" ")), + ..Default::default() + } + ); + + let err = PolicyFileData::from_file(Path::new("/test/file.json")) + .expect_err("should raise an error"); + + match err { + DscSettingsError::DataFileEmpty{file_path, scope} => { + assert_eq!(file_path, String::from("/test/file.json")); + assert_eq!(scope, DscSettingsScope::Policy); + }, + _ => panic!("expected DataFileEmpty but got {err:?}"), + } + } + + #[serial] + #[test] + fn when_file_has_unparseable_json() { + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(String::from("{ invalid json: true }")), + ..Default::default() + } + ); + + let err = PolicyFileData::from_file(Path::new("/test/file.json")) + .expect_err("should raise an error"); + + match err { + DscSettingsError::DataFileUnparseable{file_path, scope, source} => { + assert_eq!(file_path, String::from("/test/file.json")); + assert_eq!(scope, DscSettingsScope::Policy); + assert!(source.is_syntax()); + }, + _ => panic!("expected ParseDataFileError but got {err:?}"), + } + } + + #[serial] + #[test] + fn when_file_is_empty_object() { + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(String::from("{}")), + ..Default::default() + } + ); + + let data = PolicyFileData::from_file(Path::new("/test/file.json")) + .expect("should parse successfully"); + + pretty_assertions::assert_eq!(data, PolicyFileData::default()); + } + + #[serial] + #[test] + fn when_file_defines_unknown_field() { + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(String::from("{ \"unknown_field\": true }")), + ..Default::default() + } + ); + + let data = PolicyFileData::from_file(Path::new("/test/file.json")) + .expect("should parse successfully even with unknown fields"); + + pretty_assertions::assert_eq!(data, PolicyFileData::default()); + } + + #[serial] + #[test] + fn when_file_has_all_valid_data() { + let json = serde_json::json!({ + "forbidIgnoreSettingsFile": false, + "ignoreSettingsFile": true, + "tracing": { + "level": "info", + "format": "json" + }, + "resourcePath": { + "appendEnvPath": true, + "directories": ["dir1", "dir2"], + "restricted": false, + } + }); + let json_str = serde_json::to_string_pretty(&json).unwrap(); + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(json_str), + ..Default::default() + } + ); + + let data = PolicyFileData::from_file(Path::new("/test/file.json")) + .expect("should parse successfully"); + + // Replace with the actual expected data structure + let expected_data = PolicyFileData { + forbid_ignore_settings_file: Some(ForbidIgnoreSettingsFileField::new(false)), + ignore_settings_file: Some(IgnoreSettingsFileField::new(true)), + tracing: Some(TracingFileData { + level: Some(TracingLevelField::Info), + format: Some(TracingFormatField::Json), + }), + resource_path: Some(ResourcePathFileData { + append_env_path: Some(true), + directories: Some(vec![ + PathBuf::from("dir1").into(), + PathBuf::from("dir2").into() + ]), + restricted: Some(false), + }), + ..Default::default() + }; + + pretty_assertions::assert_eq!(data, expected_data); + } + + #[serial] + #[test] + fn when_file_has_invalid_data() { + let json_data = serde_json::json!({ + "forbidIgnoreSettingsFile": "not_a_boolean", + "ignoreSettingsFile": "not_a_boolean", + "tracing": { + "level": "invalid_level", + "format": "invalid_format" + }, + "resourcePath": { + "appendEnvPath": "not_a_boolean", + "directories": "not_an_array", + "restricted": "not_a_boolean" + } + }); + let json_str = &serde_json::to_string_pretty(&json_data).unwrap(); + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(json_str.clone()), + ..Default::default() + } + ); + + match PolicyFileData::from_file(Path::new("/test/file.json")) { + Ok(data) => panic!("expected {json_str} to fail parsing, but got {data:?}"), + Err(e) => match e { + DscSettingsError::DataFileUnparseable { file_path, scope, source } => { + assert_eq!(file_path, String::from("/test/file.json")); + assert_eq!(scope, DscSettingsScope::Policy.to_string()); + assert!(source.is_data()); + // Only the first failing field reports invalid + assert!( + source.to_string().contains("not_a_boolean"), + "expected error message to contain 'not_a_boolean': {source}" + ); + }, + _ => panic!("expected ParseDataFileError, but got {e:?}"), + } + } + } +} diff --git a/lib/dsc-lib/src/tests/settings/sources/preference_file.rs b/lib/dsc-lib/src/tests/settings/sources/preference_file.rs new file mode 100644 index 000000000..3722da09f --- /dev/null +++ b/lib/dsc-lib/src/tests/settings/sources/preference_file.rs @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::settings::DscSettingsError; +use crate::settings::fields::{ + TracingFileData, + TracingLevelField, + TracingFormatField, + ResourcePathFileData +}; +use crate::settings::sources::PreferenceFileData; +use crate::tests::stubs::path::*; + +/// Tests for [`PreferenceFileData::from_file()`] with stubbed file system +/// context. +mod from_file { + use serial_test::serial; + + use crate::settings::DscSettingsScope; + +use super::*; + + #[test] + #[serial] + fn when_file_not_exist() { + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: false, + ..Default::default() + }, + "another/file" => StubbedPathContext { + should_exist: false, + read_result: Err(std::io::Error::new(std::io::ErrorKind::NotFound, "file not found")), + ..Default::default() + } + ); + let _ = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + + let err = PreferenceFileData::from_file(Path::new("/test/file.json")) + .expect_err("should raise an error"); + + match err { + DscSettingsError::DataFileReadError{file_path, scope, source} => { + assert_eq!(file_path, String::from("/test/file.json")); + assert_eq!(scope, "unknown"); + assert_eq!(source.kind(), std::io::ErrorKind::NotFound); + }, + _ => panic!("expected FileReadError but got {err:?}"), + } + } + + #[serial] + #[test] + fn when_file_empty() { + let machine_path = crate::settings::MACHINE_SETTINGS_FILE_PATH.as_path(); + stubbed_path_context_map!( + machine_path => StubbedPathContext { + should_exist: true, + read_result: Ok(String::new()), + ..Default::default() + } + ); + + let err = PreferenceFileData::from_file(machine_path) + .expect_err("should raise an error"); + + match err { + DscSettingsError::DataFileEmpty{file_path, scope} => { + assert_eq!(file_path, machine_path.to_string_lossy().to_string()); + assert_eq!(scope, DscSettingsScope::Machine.to_string()); + }, + _ => panic!("expected DataFileEmpty but got {err:?}"), + } + } + + #[serial] + #[test] + fn when_file_only_whitespace() { + let user_path = crate::settings::USER_SETTINGS_FILE_PATH.as_ref().unwrap().as_path(); + stubbed_path_context_map!( + user_path => StubbedPathContext { + should_exist: true, + read_result: Ok(String::from(" ")), + ..Default::default() + } + ); + + let err = PreferenceFileData::from_file(user_path) + .expect_err("should raise an error"); + + match err { + DscSettingsError::DataFileEmpty{file_path, scope} => { + assert_eq!(file_path, user_path.to_string_lossy().to_string()); + assert_eq!(scope, DscSettingsScope::User.to_string()); + }, + _ => panic!("expected DataFileEmpty but got {err:?}"), + } + } + + #[serial] + #[test] + fn when_file_has_unparseable_json() { + let workspace_path = crate::settings::WORKSPACE_SETTINGS_FILE_PATH.as_path(); + stubbed_path_context_map!( + workspace_path => StubbedPathContext { + should_exist: true, + read_result: Ok(String::from("{ invalid json: true }")), + ..Default::default() + } + ); + + let err = PreferenceFileData::from_file(workspace_path) + .expect_err("should raise an error"); + + match err { + DscSettingsError::DataFileUnparseable{file_path, scope, source} => { + assert_eq!(file_path, workspace_path.to_string_lossy().to_string()); + assert_eq!(scope, DscSettingsScope::Workspace.to_string()); + assert!(source.is_syntax()); + }, + _ => panic!("expected ParseDataFileError but got {err:?}"), + } + } + + #[serial] + #[test] + fn when_file_is_empty_object() { + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(String::from("{}")), + ..Default::default() + } + ); + + let data = PreferenceFileData::from_file(Path::new("/test/file.json")) + .expect("should parse successfully"); + + pretty_assertions::assert_eq!(data, PreferenceFileData::default()); + } + + #[serial] + #[test] + fn when_file_defines_unknown_field() { + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(String::from("{ \"unknown_field\": true }")), + ..Default::default() + } + ); + + let data = PreferenceFileData::from_file(Path::new("/test/file.json")) + .expect("should parse successfully even with unknown fields"); + + pretty_assertions::assert_eq!(data, PreferenceFileData::default()); + } + + #[serial] + #[test] + fn when_file_has_all_valid_data() { + let json = serde_json::json!({ + "tracing": { + "level": "info", + "format": "json" + }, + "resourcePath": { + "appendEnvPath": true, + "directories": ["dir1", "dir2"], + "restricted": false, + } + }); + let json_str = serde_json::to_string_pretty(&json).unwrap(); + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(json_str), + ..Default::default() + } + ); + + let data = PreferenceFileData::from_file(Path::new("/test/file.json")) + .expect("should parse successfully"); + + // Replace with the actual expected data structure + let expected_data = PreferenceFileData { + tracing: Some(TracingFileData { + level: Some(TracingLevelField::Info), + format: Some(TracingFormatField::Json), + }), + resource_path: Some(ResourcePathFileData { + append_env_path: Some(true), + directories: Some(vec![ + PathBuf::from("dir1").into(), + PathBuf::from("dir2").into() + ]), + restricted: Some(false), + }), + ..Default::default() + }; + + pretty_assertions::assert_eq!(data, expected_data); + } + + #[serial] + #[test] + fn when_file_has_invalid_data() { + let json_data = serde_json::json!({ + "tracing": { + "level": "invalid_level", + "format": "invalid_format" + }, + "resourcePath": { + "appendEnvPath": "not_a_boolean", + "directories": "not_an_array", + "restricted": "not_a_boolean" + } + }); + let json_str = &serde_json::to_string_pretty(&json_data).unwrap(); + stubbed_path_context_map!( + "/test/file.json" => StubbedPathContext { + should_exist: true, + read_result: Ok(json_str.clone()), + ..Default::default() + } + ); + + match PreferenceFileData::from_file(Path::new("/test/file.json")) { + Ok(data) => panic!("expected {json_str} to fail parsing, but got {data:?}"), + Err(e) => match e { + DscSettingsError::DataFileUnparseable { file_path, scope, source } => { + assert_eq!(file_path, String::from("/test/file.json")); + assert_eq!(scope, "unknown"); + assert!(source.is_data()); + // Only the first failing field reports invalid + assert!(source.to_string().contains("'invalid_level'")); + }, + _ => panic!("expected ParseDataFileError, but got {e:?}"), + } + } + } +} diff --git a/lib/dsc-lib/src/tests/stubs/env.rs b/lib/dsc-lib/src/tests/stubs/env.rs new file mode 100644 index 000000000..133d4d0a6 --- /dev/null +++ b/lib/dsc-lib/src/tests/stubs/env.rs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::ffi::{OsStr, OsString}; +use std::env::VarError; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +/// Reads the value of an environment variable according to the stubbed context. +/// +/// This function simulates [`std::env::var`] relying on the stubbed environment +/// context provided by [`StubbedEnvContext`]. +/// +/// # Arguments +/// +/// - `key` - The name of the environment variable to read. +/// +/// # Returns +/// +/// A [`Result`] containing the value of the environment +/// variable if present, or an error otherwise. +pub(crate) fn var>(key: K) -> Result { + StubbedEnvContext::find(&key.as_ref().to_string_lossy().into_owned()) +} + +/// Reads the value of an environment variable as an `OsString` according to +/// the stubbed context. +/// +/// # Arguments +/// +/// - `key` - The name of the environment variable to read. +/// +/// # Returns +/// +/// An [`Option`] containing the value of the environment variable if +/// present, or `None` otherwise. +#[allow(dead_code)] +pub(crate) fn var_os>(key: K) -> Option { + StubbedEnvContext::find(&key.as_ref().to_string_lossy().into_owned()) + .map(|res| OsString::from(res)) + .ok() +} + +/// Macro to set up a stubbed environment context map for testing. +/// +/// Use this macro to define environment variable stubs when you need to read +/// them for testing purposes. Each environment variable must be either: +/// +/// - `Ok("value")` - The environment variable is defined with the specified +/// value. +/// - `Err(VarError::NotPresent)` - The environment variable isn't defined. +/// +/// # Example +/// +/// ```rust, ignore +/// stubbed_env_context_map! { +/// "DSC_TRACE_LEVEL" => Err(VarError::NotPresent), +/// "DSC_TRACE_FORMAT" => Ok("json"), +/// }; +/// ``` +macro_rules! stubbed_env_context_map { + ($($key:expr => $result:expr),* $(,)?) => { + $crate::tests::stubs::env::StubbedEnvContext::reset(); + $( + $crate::tests::stubs::env::StubbedEnvContext::add($key, $result); + )* + }; +} + +/// Static storage for the global stubbed environment context. +/// +/// This is used internally to manage the singleton instance of the stubbed +/// environment context. +static STUB_CONTEXT: OnceLock> = OnceLock::new(); + +/// Represents the stubbed environment context used for testing. +/// +/// The stubbed environment context stores a mapping of environment variable +/// names to the result for reading them from the environment. +#[derive(Debug)] +pub struct StubbedEnvContext(HashMap>); + +/// Static management API +impl StubbedEnvContext { + /// Retrieves the global stubbed environment context, initializing it if necessary. + /// + /// # Returns + /// + /// A reference to the global [`Mutex`]-protected [`StubbedEnvContext`]. + pub(crate) fn get() -> &'static std::sync::Mutex { + STUB_CONTEXT + .get_or_init(|| Mutex::new(StubbedEnvContext::new())) + } + /// Resets the global stubbed environment context by clearing all entries. + pub(crate) fn reset() { + let mut context = Self::get().lock().unwrap(); + context.0.clear(); + } + /// Adds a new entry to the global stubbed environment context. + /// + /// # Arguments + /// + /// - `variable` - The name of the environment variable. + /// - `context` - The result to associate with the environment variable. + pub(crate) fn add>(variable: K, context: Result) { + let mut context_map = Self::get().lock().unwrap(); + context_map.0.insert(variable.as_ref().to_string_lossy().into_owned(), context); + } + /// Finds the result associated with the given environment variable in the + /// global stubbed environment context. + /// + /// # Returns + /// + /// The string value for the environment variable or [`VarError::NotPresent`]. + pub(crate) fn find>(variable: K) -> Result { + let context_map = Self::get().lock().unwrap(); + context_map.0 + .get(&variable.as_ref().to_string_lossy().into_owned()) + .cloned() + .unwrap_or(Err(VarError::NotPresent)) + } +} + +// Internal management API +impl StubbedEnvContext { + /// Creates a new instance of the stubbed environment context. + /// + /// # Returns + /// + /// A new [`StubbedEnvContext`] with an empty internal map. + pub(crate) fn new() -> Self { + StubbedEnvContext(HashMap::new()) + } + /// Converts the stubbed environment context into a JSON representation. + /// + /// # Returns + /// + /// A [`serde_json::Value`] object representing the stubbed environment + /// context. + pub(crate) fn as_json(&self) -> serde_json::Value { + serde_json::json!(self.0.iter().map(|(k, v)| { + let value = match v { + Ok(val) => serde_json::json!(val), + Err(e) => serde_json::json!({"error": e.to_string()}), + }; + (k.clone(), value) + }).collect::>()) + } +} diff --git a/lib/dsc-lib/src/tests/stubs/fs.rs b/lib/dsc-lib/src/tests/stubs/fs.rs new file mode 100644 index 000000000..e305b2aef --- /dev/null +++ b/lib/dsc-lib/src/tests/stubs/fs.rs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::tests::stubs::path::{Path, StubbedPathContextMap}; + +/// Reads the contents of a file at the given path according to the stubbed +/// context. +/// +/// This function simulates [`std::fs::read_to_string`] relying on the +/// stubbed path context provided by [`StubbedPathContextMap`]. +/// +/// # Arguments +/// +/// - `path` - The path to the file to read. +/// +/// # Returns +/// +/// A [`std::io::Result`] containing the file contents if successful, +/// or an error otherwise. +pub(crate) fn read_to_string>(path: P) -> std::io::Result { + StubbedPathContextMap::find(path.as_ref()).read_result +} diff --git a/lib/dsc-lib/src/tests/stubs/mod.rs b/lib/dsc-lib/src/tests/stubs/mod.rs new file mode 100644 index 000000000..9276e2947 --- /dev/null +++ b/lib/dsc-lib/src/tests/stubs/mod.rs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Provides test stubs for standard library modules like [`std::fs`] and [`std::path`]. + +/// Provides stubs for [`std::env`] +#[macro_use] +pub(crate) mod env; + +/// Provides stubs for [`std::fs`] +pub(crate) mod fs; + +/// Provides stubs for [`std::path`] +#[macro_use] +pub(crate) mod path; diff --git a/lib/dsc-lib/src/tests/stubs/path.rs b/lib/dsc-lib/src/tests/stubs/path.rs new file mode 100644 index 000000000..796b7759b --- /dev/null +++ b/lib/dsc-lib/src/tests/stubs/path.rs @@ -0,0 +1,520 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::ops::Deref; +use std::sync::{Mutex, OnceLock}; + +/// Context for a stubbed path, used to simulate file system behavior in tests. +#[derive(Debug)] +pub struct StubbedPathContext { + /// Indicates whether the [`exists`] method would return `true` for this path. + /// + /// [`exists`]: Path::exists + pub should_exist: bool, + /// Indicates whether the [`is_file`] method would return `true` for this path. + /// + /// [`is_file`]: Path::is_file + pub is_file: bool, + /// Indicates whether the [`is_dir`] method would return `true` for this path. + /// + /// [`is_dir`]: Path::is_dir + pub is_dir: bool, + /// Indicates the result to return from [`fs::read_to_string`] for this path. + /// + /// [`fs::read_to_string`]: crate::tests::stubs::fs::read_to_string + pub read_result: Result, +} + +impl Clone for StubbedPathContext { + fn clone(&self) -> Self { + Self { + should_exist: self.should_exist, + is_file: self.is_file, + is_dir: self.is_dir, + read_result: match self.read_result { + Ok(ref s) => Ok(s.clone()), + Err(ref e) => Err(std::io::Error::new(e.kind(), e.to_string())), + } + } + } +} + +impl StubbedPathContext { + /// Converts the stubbed path context into a JSON representation. + /// + /// # Returns + /// + /// A [`serde_json::Value`] object representing the stubbed path context. + fn as_json(&self) -> serde_json::Value { + let read_result = match &self.read_result { + Ok(s) => serde_json::json!({"ok": s}), + Err(e) => serde_json::json!({ + "err": { + "kind": e.kind().to_string(), + "message": e.to_string(), + } + }), + }; + serde_json::json!({ + "should_exist": self.should_exist, + "is_file": self.is_file, + "is_dir": self.is_dir, + "read_result": read_result, + }) + } +} + +impl Default for StubbedPathContext { + fn default() -> Self { + Self { + should_exist: false, + is_file: false, + is_dir: false, + read_result: Err( + std::io::Error::new( + std::io::ErrorKind::NotFound, + "Stub not initialized" + ) + ), + } + } +} + +/// A map of stubbed path contexts, keyed by path strings. +/// +/// This structure provides methods for managing and querying stubbed path contexts, +/// which are used to simulate file system behavior in tests. +#[derive(Debug, Clone)] +pub struct StubbedPathContextMap(HashMap); + +// Static management API +impl StubbedPathContextMap { + /// Retrieves the global stubbed path context map, initializing it if necessary. + /// + /// # Returns + /// + /// A reference to a [`std::sync::Mutex`] guarding the global [`StubbedPathContextMap`]. + pub fn get() -> &'static std::sync::Mutex { + STUB_CONTEXT + .get_or_init(|| Mutex::new(StubbedPathContextMap::new())) + } + /// Resets the global stubbed path context map, clearing all entries. + pub fn reset() { + let mut context = Self::get().lock().unwrap(); + context.clear(); + } + /// Adds a stubbed path context to the global map. + /// + /// # Arguments + /// + /// - `path` - The path string to associate with the stubbed context. + /// - `context` - The stubbed path context to add. + pub fn add(path: String, context: StubbedPathContext) { + let mut context_map = Self::get().lock().unwrap(); + context_map.insert(path, context); + } + /// Finds the stubbed path context associated with the given path. + /// + /// # Arguments + /// + /// - `path` - The path to look up in the global stubbed context map. + /// + /// # Returns + /// + /// The stubbed path context associated with the path, or the default context if not found. + pub fn find(path: &Path) -> StubbedPathContext { + let context_map = Self::get().lock().unwrap(); + if let Some(context) = context_map.0.get(path.to_string_lossy().as_ref()) { + return context.clone(); + } + StubbedPathContext::default() + } + + /// Converts the stubbed path context map into a JSON representation. + /// + /// # Returns + /// + /// A [`serde_json::Value`] object representing the stubbed path context map. + pub fn as_json(&self) -> serde_json::Value { + serde_json::json!(self.0.iter().map(|(k, v)| { + (k.clone(), v.as_json()) + }).collect::>()) + } +} + +// Internal management API +impl StubbedPathContextMap { + /// Creates a new, empty stubbed path context map. + /// + /// # Returns + /// + /// A new instance of [`StubbedPathContextMap`] with no entries. + fn new() -> Self { + Self(HashMap::new()) + } + + /// Inserts a stubbed path context into the map. + /// + /// # Arguments + /// + /// - `path` - The path string to associate with the stubbed context. + /// - `context` - The stubbed path context to insert. + fn insert(&mut self, path: String, context: StubbedPathContext) { + self.0.insert(path, context); + } + + /// Clears all entries from the stubbed path context map. + fn clear(&mut self) { + self.0.clear(); + } +} + +/// Macro to initialize the stubbed path context map for testing purposes. +/// +/// # Example +/// +/// ```rust, ignore +/// #[cfg(test)] mod tests { +/// +/// } +/// +/// stubbed_path_context_map! { +/// "/some/path" => StubbedPathContext::default(), +/// "/another/path" => StubbedPathContext { +/// should_exist: true, +/// is_file: true, +/// is_dir: false, +/// read_result: Ok(String::from("Text in the file\n")) +/// } +/// }; +/// ``` +#[allow(unused_macros)] +macro_rules! stubbed_path_context_map { + ($($path:expr => $context:expr),+ $(,)?) => { + { + $crate::tests::stubs::path::StubbedPathContextMap::reset(); + $( + $crate::tests::stubs::path::StubbedPathContextMap::add($path.to_string(), $context); + )+ + } + }; +} + +static STUB_CONTEXT: OnceLock> = OnceLock::new(); + +/// A transparent stand-in for [`std::path::Path`]. +#[repr(transparent)] +pub struct Path { + pub inner: std::path::Path, +} + +// Public Stub API - these methods provide the minimal interface required for testing purposes. +impl Path { + /// Indicates whether the path exists according to the stubbed context. + /// + /// # Returns + /// + /// `true` if the path exists according to the stubbed context, `false` otherwise. + #[must_use] + pub fn exists(&self) -> bool { + StubbedPathContextMap::find(self).should_exist + } + + /// Indicates whether the path is a file according to the stubbed context. + /// + /// # Returns + /// + /// `true` if the path is a file according to the stubbed context, `false` otherwise. + #[must_use] + pub fn is_file(&self) -> bool { + StubbedPathContextMap::find(self).is_file + } + + /// Indicates whether the path is a directory according to the stubbed context. + /// + /// # Returns + /// + /// `true` if the path is a directory according to the stubbed context, `false` otherwise. + #[must_use] + pub fn is_dir(&self) -> bool { + StubbedPathContextMap::find(self).is_dir + } +} + +// Private Stub API - these methods are used internally and are not exposed publicly. +impl Path { + fn from_std(value: &std::path::Path) -> &Self { + // `Path` is transparent over `std::path::Path`, including its metadata. + unsafe { &*(std::ptr::from_ref(value) as *const Self) } + } +} + +// Public Passthru API - these methods simply delegate to the inner `std::path::Path` instance. +impl Path { + #[must_use] + pub fn new + ?Sized>(value: &S) -> &Self { + Self::from_std(std::path::Path::new(value)) + } + + #[must_use] + pub fn as_os_str(&self) -> &OsStr { + self.inner.as_os_str() + } + + #[must_use] + pub fn to_str(&self) -> Option<&str> { + self.inner.to_str() + } + + #[must_use] + pub fn to_string_lossy(&self) -> Cow<'_, str> { + self.inner.to_string_lossy() + } + + #[must_use] + pub fn to_path_buf(&self) -> PathBuf { + PathBuf { + inner: self.inner.to_path_buf(), + } + } + + #[must_use] + pub fn join>(&self, path: P) -> PathBuf { + PathBuf { + inner: self.inner.join(&path.as_ref().inner), + } + } + + #[must_use] + pub fn parent(&self) -> Option<&Self> { + self.inner.parent().map(Self::from_std) + } + + #[must_use] + pub fn file_name(&self) -> Option<&OsStr> { + self.inner.file_name() + } + + #[must_use] + pub fn file_stem(&self) -> Option<&OsStr> { + self.inner.file_stem() + } + + #[must_use] + pub fn extension(&self) -> Option<&OsStr> { + self.inner.extension() + } + + #[must_use] + pub fn is_absolute(&self) -> bool { + self.inner.is_absolute() + } + + #[must_use] + pub fn is_relative(&self) -> bool { + self.inner.is_relative() + } + + #[must_use] + pub fn starts_with>(&self, base: P) -> bool { + self.inner.starts_with(&base.as_ref().inner) + } + + #[must_use] + pub fn ends_with>(&self, child: P) -> bool { + self.inner.ends_with(&child.as_ref().inner) + } + + pub fn strip_prefix>( + &self, + base: P, + ) -> Result<&Self, std::path::StripPrefixError> { + self.inner + .strip_prefix(&base.as_ref().inner) + .map(Self::from_std) + } + + #[must_use] + pub fn display(&self) -> std::path::Display<'_> { + self.inner.display() + } +} + +impl AsRef for Path { + fn as_ref(&self) -> &Path { + &self + } +} + +impl AsRef for Path { + fn as_ref(&self) -> &OsStr { + self.as_os_str() + } +} + +impl AsRef for OsStr { + fn as_ref(&self) -> &Path { + Path::new(self) + } +} + +impl AsRef for OsString { + fn as_ref(&self) -> &Path { + Path::new(self) + } +} + +impl AsRef for str { + fn as_ref(&self) -> &Path { + Path::new(self) + } +} + +impl AsRef for String { + fn as_ref(&self) -> &Path { + Path::new(self) + } +} + +impl fmt::Debug for Path { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.inner.fmt(formatter) + } +} + +impl fmt::Display for Path { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.inner.display().fmt(formatter) + } +} + +impl PartialEq for Path { + fn eq(&self, other: &Self) -> bool { + self.inner == other.inner + } +} + +impl Eq for Path {} + +/// An owned stand-in for [`std::path::PathBuf`]. +#[derive(Clone, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PathBuf { + pub inner: std::path::PathBuf, +} + +impl PathBuf { + #[must_use] + pub fn new() -> Self { + Self { + inner: std::path::PathBuf::new(), + } + } + + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + Self { + inner: std::path::PathBuf::with_capacity(capacity), + } + } + + #[must_use] + pub fn as_path(&self) -> &Path { + Path::from_std(self.inner.as_path()) + } + + pub fn push>(&mut self, path: P) { + self.inner.push(&path.as_ref().inner); + } + + pub fn pop(&mut self) -> bool { + self.inner.pop() + } + + pub fn set_file_name>(&mut self, file_name: S) { + self.inner.set_file_name(file_name); + } + + pub fn set_extension>(&mut self, extension: S) -> bool { + self.inner.set_extension(extension) + } + + #[must_use] + pub fn into_os_string(self) -> OsString { + self.inner.into_os_string() + } + + #[must_use] + pub fn into_std(self) -> std::path::PathBuf { + self.inner + } +} + +impl Deref for PathBuf { + type Target = Path; + + fn deref(&self) -> &Self::Target { + self.as_path() + } +} + +impl + ?Sized> From<&T> for PathBuf { + fn from(value: &T) -> Self { + Self { + inner: std::path::PathBuf::from(value.as_ref()), + } + } +} + +impl From for PathBuf { + fn from(value: OsString) -> Self { + Self { + inner: value.into(), + } + } +} + +impl From for PathBuf { + fn from(value: std::path::PathBuf) -> Self { + Self { inner: value } + } +} + +impl From for std::path::PathBuf { + fn from(value: PathBuf) -> Self { + value.inner + } +} + +impl AsRef for PathBuf { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl AsRef for PathBuf { + fn as_ref(&self) -> &std::path::Path { + self.inner.as_path() + } +} + +impl AsRef for PathBuf { + fn as_ref(&self) -> &OsStr { + self.inner.as_os_str() + } +} + +impl fmt::Debug for PathBuf { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.inner.fmt(formatter) + } +} + +impl fmt::Display for PathBuf { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.inner.display().fmt(formatter) + } +} diff --git a/xtask/src/schemas/export.rs b/xtask/src/schemas/export.rs index fdfb4a8b8..211efc91c 100644 --- a/xtask/src/schemas/export.rs +++ b/xtask/src/schemas/export.rs @@ -149,6 +149,20 @@ pub(crate) fn export_schemas( dsc_lib::functions::FunctionArgKind, dsc_lib::functions::FunctionCategory, dsc_lib::functions::FunctionDefinition, + dsc_lib::settings::fields::ForbidIgnoreSettingsFileField, + dsc_lib::settings::fields::IgnoreSettingsFileField, + dsc_lib::settings::fields::ResourcePathFileData, + dsc_lib::settings::fields::TracingFileData, + dsc_lib::settings::fields::TracingFormatField, + dsc_lib::settings::fields::TracingLevelField, + dsc_lib::settings::sources::CommandLineData, + dsc_lib::settings::sources::DscSettingsCodeDefaults, + dsc_lib::settings::sources::EnvironmentData, + dsc_lib::settings::sources::PolicyFileData, + dsc_lib::settings::sources::PreferenceFileData, + dsc_lib::settings::DscSettingsResolved, + dsc_lib::settings::DscSettingsScope, + dsc_lib::settings::DscSettings, dsc_lib::types::DateVersion, dsc_lib::types::ExitCodesMap, dsc_lib::types::FullyQualifiedTypeName, From d93fe9b419c2644c92e80a626fdd5b9ea1b08484 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Fri, 9 Oct 2026 10:57:18 -0500 Subject: [PATCH 2/8] (GH-1081) Define integration tests for DSC settings structure This change adds the rust integration tests for the `settings` module. It also introduces helper macros for validating DSC repo schemas and updates the schema generation tests to cover the newly defined types. --- .../src/tests/settings/dsc_settings.rs | 66 +- .../src/tests/settings/sources/policy_file.rs | 2 +- .../macros/define_schema_statics.rs | 268 ++++++ .../macros/test_dsc_repo_schema.rs | 501 ++++++++++ .../integration/macros/test_meta_schema.rs | 114 +++ .../integration/macros/test_schema_docs.rs | 251 +++++ .../macros/test_schema_validation.rs | 108 +++ lib/dsc-lib/tests/integration/main.rs | 32 +- lib/dsc-lib/tests/integration/schemas/mod.rs | 110 +++ .../tests/integration/schemas/schema_for.rs | 23 +- .../tests/integration/settings/builders.rs | 878 ++++++++++++++++++ .../integration/settings/dsc_settings.rs | 540 +++++++++++ .../settings/dsc_settings_scope.rs | 358 +++++++ .../fields/forbid_ignore_settings_file.rs | 181 ++++ .../settings/fields/ignore_settings_file.rs | 181 ++++ .../settings/fields/resource_path.rs | 230 +++++ .../integration/settings/fields/tracing.rs | 604 ++++++++++++ lib/dsc-lib/tests/integration/settings/mod.rs | 27 + .../integration/settings/resolved/field.rs | 290 ++++++ .../integration/settings/resolved/settings.rs | 105 +++ .../settings/sources/code_defaults.rs | 30 + .../settings/sources/command_line.rs | 96 ++ .../settings/sources/environment.rs | 151 +++ .../settings/sources/policy_file.rs | 139 +++ .../settings/sources/preference_file.rs | 111 +++ 25 files changed, 5386 insertions(+), 10 deletions(-) create mode 100644 lib/dsc-lib/tests/integration/macros/define_schema_statics.rs create mode 100644 lib/dsc-lib/tests/integration/macros/test_dsc_repo_schema.rs create mode 100644 lib/dsc-lib/tests/integration/macros/test_meta_schema.rs create mode 100644 lib/dsc-lib/tests/integration/macros/test_schema_docs.rs create mode 100644 lib/dsc-lib/tests/integration/macros/test_schema_validation.rs create mode 100644 lib/dsc-lib/tests/integration/settings/builders.rs create mode 100644 lib/dsc-lib/tests/integration/settings/dsc_settings.rs create mode 100644 lib/dsc-lib/tests/integration/settings/dsc_settings_scope.rs create mode 100644 lib/dsc-lib/tests/integration/settings/fields/forbid_ignore_settings_file.rs create mode 100644 lib/dsc-lib/tests/integration/settings/fields/ignore_settings_file.rs create mode 100644 lib/dsc-lib/tests/integration/settings/fields/resource_path.rs create mode 100644 lib/dsc-lib/tests/integration/settings/fields/tracing.rs create mode 100644 lib/dsc-lib/tests/integration/settings/mod.rs create mode 100644 lib/dsc-lib/tests/integration/settings/resolved/field.rs create mode 100644 lib/dsc-lib/tests/integration/settings/resolved/settings.rs create mode 100644 lib/dsc-lib/tests/integration/settings/sources/code_defaults.rs create mode 100644 lib/dsc-lib/tests/integration/settings/sources/command_line.rs create mode 100644 lib/dsc-lib/tests/integration/settings/sources/environment.rs create mode 100644 lib/dsc-lib/tests/integration/settings/sources/policy_file.rs create mode 100644 lib/dsc-lib/tests/integration/settings/sources/preference_file.rs diff --git a/lib/dsc-lib/src/tests/settings/dsc_settings.rs b/lib/dsc-lib/src/tests/settings/dsc_settings.rs index 9d905ea2d..bc421c681 100644 --- a/lib/dsc-lib/src/tests/settings/dsc_settings.rs +++ b/lib/dsc-lib/src/tests/settings/dsc_settings.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::settings::{DscSettings, DscSettingsError}; +use crate::settings::{DscSettings, DscSettingsError, set_test_policy_folder_is_secure}; use crate::settings::fields::*; #[macro_use] @@ -336,6 +336,7 @@ pub(super) mod macros { ..Default::default() } ); + define_policy_file!(@define_folder); }; (read_err: $err_kind:ident) => { define_policy_file!{ @@ -363,6 +364,7 @@ pub(super) mod macros { ..Default::default() } ); + define_policy_file!(@define_folder); }; (read_err: $err_msg:expr) => { define_policy_file! { @@ -372,6 +374,16 @@ pub(super) mod macros { } } }; + (@define_folder) => { + $crate::tests::stubs::path::StubbedPathContextMap::add( + $crate::settings::POLICY_SETTINGS_FILE_PATH.as_path().parent().unwrap().to_string(), + $crate::tests::stubs::path::StubbedPathContext { + should_exist: true, + is_dir: true, + ..Default::default() + } + ); + }; } /// Defines a preference file for testing purposes in the stubbed context. @@ -570,6 +582,9 @@ pub(super) mod helpers { if let Ok(env_context) = crate::tests::stubs::env::StubbedEnvContext::get().lock() { context.insert(String::from("stubbed_env"), env_context.as_json()); } + if let Ok(policy_folder_is_secure) = crate::settings::TEST_POLICY_FOLDER_IS_SECURE.get().unwrap().lock() { + context.insert(String::from("policy_folder_is_secure"), serde_json::to_value(*policy_folder_is_secure).unwrap()); + } let context = serde_json::to_string_pretty(&context).unwrap(); let thread = std::thread::current().name().unwrap_or("main").to_string(); @@ -603,6 +618,7 @@ mod load { #[serial] #[test] fn when_no_settings_defined() { + set_test_policy_folder_is_secure!(true); define_context!{} let mut actual = DscSettings::new(); @@ -619,6 +635,7 @@ mod load { #[serial] #[test] fn when_all_defined_valid() { + set_test_policy_folder_is_secure!(true); define_context! { policy: { "forbidIgnoreSettingsFile": false, @@ -707,9 +724,26 @@ mod load { } } + #[test] + #[serial] + fn when_policy_folder_is_not_secure() { + set_test_policy_folder_is_secure!(false); + define_context! { + policy: { + "forbidIgnoreSettingsFile": false, + "ignoreSettingsFile": false, + }, + } + assert!(DscSettings::validate_policy_folder_security().is_err()); + let mut settings = DscSettings::new(); + settings.load(); + assert_eq!(settings.policy, None); + } + #[test] #[serial] fn when_all_defined_with_invalid_fields() { + set_test_policy_folder_is_secure!(true); define_context! { policy: { "forbidIgnoreSettingsFile": "invalid", @@ -759,6 +793,7 @@ mod try_load { #[serial] #[test] fn when_no_settings_defined() { + set_test_policy_folder_is_secure!(true); define_context!{} let mut actual = DscSettings::new(); @@ -775,6 +810,7 @@ mod try_load { #[serial] #[test] fn when_all_defined_valid() { + set_test_policy_folder_is_secure!(true); define_context! { policy: { "forbidIgnoreSettingsFile": false, @@ -863,9 +899,33 @@ mod try_load { } } + #[test] + #[serial] + fn when_policy_folder_is_not_secure() { + set_test_policy_folder_is_secure!(false); + define_context! { + policy: { + "forbidIgnoreSettingsFile": false, + "ignoreSettingsFile": false, + }, + } + + assert!(DscSettings::validate_policy_folder_security().is_err()); + + let mut settings = DscSettings::new(); + match settings.try_load().expect_err("expected an error due to invalid policy folder permissions") { + DscSettingsError::LoadMultipleErrors{ errors } => { + assert_eq!(errors.len(), 1); + assert!(matches!(errors.index(0), DscSettingsError::PolicyFolderNotSecure { .. })); + }, + e => panic!("expected DscSettingsError::LoadMultipleErrors but got {:#?}", e), + } + } + #[test] #[serial] fn when_all_defined_with_invalid_fields() { + set_test_policy_folder_is_secure!(true); define_context! { policy: { "forbidIgnoreSettingsFile": "invalid", @@ -897,10 +957,10 @@ mod try_load { DscSettingsError::LoadMultipleErrors{ errors } => { assert_eq!(errors.len(), 5); assert!(matches!(errors.index(0), DscSettingsError::DataFileUnparseable { .. })); - assert!(matches!(errors.index(1), DscSettingsError::DataFileUnparseable { .. })); + assert!(matches!(errors.index(1), DscSettingsError::LoadEnvironmentMultipleErrors { .. })); assert!(matches!(errors.index(2), DscSettingsError::DataFileUnparseable { .. })); assert!(matches!(errors.index(3), DscSettingsError::DataFileUnparseable { .. })); - assert!(matches!(errors.index(4), DscSettingsError::LoadEnvironmentMultipleErrors { .. })); + assert!(matches!(errors.index(4), DscSettingsError::DataFileUnparseable { .. })); }, e => panic!("expected DscSettingsError::LoadMultipleErrors but got {:#?}", e), } diff --git a/lib/dsc-lib/src/tests/settings/sources/policy_file.rs b/lib/dsc-lib/src/tests/settings/sources/policy_file.rs index c88c74949..a7e4524d8 100644 --- a/lib/dsc-lib/src/tests/settings/sources/policy_file.rs +++ b/lib/dsc-lib/src/tests/settings/sources/policy_file.rs @@ -19,7 +19,7 @@ mod from_file { use crate::settings::DscSettingsScope; -use super::*; + use super::*; #[test] #[serial] diff --git a/lib/dsc-lib/tests/integration/macros/define_schema_statics.rs b/lib/dsc-lib/tests/integration/macros/define_schema_statics.rs new file mode 100644 index 000000000..5f0d8864f --- /dev/null +++ b/lib/dsc-lib/tests/integration/macros/define_schema_statics.rs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Defines static variables for JSON schemas to use in tests. +/// +/// This macro always generates the `ROOT_SCHEMA` and `VALIDATOR` static +/// variables, which are used by the other helper macros to validate the JSON +/// schema for a defined type. +/// +///
+/// +/// This macro is best used through the [`test_dsc_repo_schema!`] macro. Only +/// use this macro directly when the higher level macro is unsuitable. +/// +///
+/// +/// ## Syntax +/// +/// The first argument to this macro must be one of the following: +/// +/// - A type path, e.g., `my_crate::MyType` +/// - `dsc_repo_schema_for: ` - generate the root schema with the +/// [`DscRepoSchema`] trait's `generate_exportable_schema` method as the +/// latest recognized schema version and the VS Code schema form to retain +/// the VS Code schema documentation keywords. +/// +/// For the validator, it generates the schema in the bundled form, which +/// strips the VS Code keywords and canonicalizes the schema while ensuring +/// that references to the bundled schema resources are usable for validation. +/// - `json_schema_for: ` - generate the root schema with the +/// [`schemars::schema_for!`] macro. This should be used rarely since most +/// types should implement the [`DscRepoSchema`] trait. +/// +/// The macro supports the following arguments after the first argument, which +/// must always be included to define the `ROOT_SCHEMA` and `VALIDATOR` statics: +/// +/// - `properties: { : "", ... }` +/// +/// Use the `properties` argument to define one or more additional statics +/// for the schema properties, where each static corresponds to a property +/// name in the JSON schema. +/// +/// This is primarily useful for ensuring that the property subschema has the +/// expected documentation keywords. +/// +/// # Examples +/// +/// ## With type only +/// +/// When you use this macro with a single type as the only argument, it will +/// generate the `ROOT_SCHEMA` and `VALIDATOR` static variables for that type +/// as a DSC repository schema. +/// +/// ```rust, ignore +/// define_schema_statics!(my_crate::MyType); +/// ``` +/// +///
+/// +/// ```rust, ignore +/// pub(super) static ROOT_SCHEMA: std::sync::LazyLock = +/// std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::dsc_repo::DscRepoSchema; +/// ::generate_exportable_schema( +/// dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), +/// dsc_lib::schemas::dsc_repo::SchemaForm::VSCode, +/// ) +/// }); +/// pub(super) static VALIDATOR: std::sync::LazyLock = +/// std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::dsc_repo::DscRepoSchema; +/// let ref schema = +/// ::generate_exportable_schema( +/// dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), +/// dsc_lib::schemas::dsc_repo::SchemaForm::Bundled, +/// ); +/// match jsonschema::Validator::new(schema.as_value()) { +/// Ok(validator) => validator, +/// Err(err) => panic!( +/// "Failed to create JSON schema validator:\n---\nERROR: {:#?}\n---\nSCHEMA: {}\n---", +/// err, +/// serde_json::to_string_pretty(schema).unwrap(), +/// ) +/// } +/// }); +/// ``` +/// +///
+/// +/// ## With type as DSC repository schema +/// +/// When you use this macro with the `dsc_repo_schema_for` argument, it will +/// generate the `ROOT_SCHEMA` and `VALIDATOR` static variables for that type +/// as a DSC repository schema. +/// +/// ```rust, ignore +/// define_schema_statics!{dsc_repo_schema_for: my_crate::MyType} +/// ``` +/// +///
+/// +/// ```rust, ignore +/// pub(super) static ROOT_SCHEMA: std::sync::LazyLock = +/// std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::dsc_repo::DscRepoSchema; +/// ::generate_exportable_schema( +/// dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), +/// dsc_lib::schemas::dsc_repo::SchemaForm::VSCode, +/// ) +/// }); +/// pub(super) static VALIDATOR: std::sync::LazyLock = +/// std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::dsc_repo::DscRepoSchema; +/// let ref schema = +/// ::generate_exportable_schema( +/// dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), +/// dsc_lib::schemas::dsc_repo::SchemaForm::Bundled, +/// ); +/// match jsonschema::Validator::new(schema.as_value()) { +/// Ok(validator) => validator, +/// Err(err) => panic!( +/// "Failed to create JSON schema validator:\n---\nERROR: {:#?}\n---\nSCHEMA: {}\n---", +/// err, +/// serde_json::to_string_pretty(schema).unwrap(), +/// ) +/// } +/// }); +/// ``` +/// +///
+/// +/// ## With type as general JSON schema +/// +/// When you use this macro with the `json_schema_for` argument, it will +/// generate the `ROOT_SCHEMA` and `VALIDATOR` static variables for that type +/// as a general JSON schema, using [`schemars::schema_for!`]. +/// +/// ```rust, ignore +/// define_schema_statics!(json_schema_for: my_crate::MyType); +/// ``` +/// +///
+/// +/// ```rust, ignore +/// static ROOT_SCHEMA: std::sync::LazyLock = +/// std::sync::LazyLock::new(|| { +/// schemars::schema_for!(my_crate::MyType) +/// }); +/// static VALIDATOR: std::sync::LazyLock = +/// std::sync::LazyLock::new(|| { +/// jsonschema::Validator::new((&*ROOT_SCHEMA).as_value()).unwrap() +/// }); +/// ``` +/// +///
+/// +/// ## With additional property schemas +/// +/// When you use this macro with the `properties` argument, it will +/// generate static variables for each specified property schema. +/// +/// ```rust, ignore +/// define_schema_statics!{ +/// my_crate::MyType, +/// properties: { +/// MY_PROPERTY_SCHEMA => "myProperty", +/// OTHER_PROPERTY_SCHEMA => "otherProperty", +/// }, +/// } +/// ``` +/// +///
+/// +/// ```rust, ignore +/// // ROOT_SCHEMA and VALIDATOR are elided for brevity +/// +/// static MY_PROPERTY_SCHEMA: std::sync::LazyLock = std::sync::LazyLock::new(|| { +/// std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::schema_utility_extensions::SchemaUtilityExtensions; +/// ROOT_SCHEMA +/// .clone() +/// .get_property_subschema("myProperty") +/// .expect(&format!( +/// "properties.{} should be defined", +/// "myProperty" +/// )) +/// .clone() +/// }); +/// static OTHER_PROPERTY_SCHEMA: std::sync::LazyLock = std::sync::LazyLock::new(|| { +/// std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::schema_utility_extensions::SchemaUtilityExtensions; +/// ROOT_SCHEMA +/// .clone() +/// .get_property_subschema("otherProperty") +/// .expect(&format!( +/// "properties.{} should be defined", +/// "otherProperty" +/// )) +/// .clone() +/// }); +/// ``` +/// +///
+/// +/// [`test_dsc_repo_schema!`]: crate::macros::test_dsc_repo_schema +/// [`DscRepoSchema`]: dsc_lib::schemas::dsc_repo::DscRepoSchema +macro_rules! define_schema_statics { + ($typ:ty $(, $($rest:tt)*)?) => { + $crate::macros::define_schema_statics!( + dsc_repo_schema_for: $typ + $(, $($rest)*)? + ); + }; + (json_schema_for: $type:ty $(, $($rest:tt)*)?) => { + static ROOT_SCHEMA: std::sync::LazyLock = std::sync::LazyLock::new(|| { + schemars::schema_for!($type) + }); + static VALIDATOR: std::sync::LazyLock = std::sync::LazyLock::new(|| { + jsonschema::Validator::new((&*ROOT_SCHEMA).as_value()).unwrap() + }); + + $($crate::macros::define_schema_statics!($($rest)*);)? + }; + (dsc_repo_schema_for: $type:ty $(, $($rest:tt)*)?) => { + static ROOT_SCHEMA: std::sync::LazyLock = std::sync::LazyLock::new(|| { + use dsc_lib::schemas::dsc_repo::DscRepoSchema; + <$type>::generate_exportable_schema( + dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), + dsc_lib::schemas::dsc_repo::SchemaForm::VSCode + ) + }); + + static VALIDATOR: std::sync::LazyLock = std::sync::LazyLock::new(|| { + use dsc_lib::schemas::dsc_repo::DscRepoSchema; + let ref schema = <$type>::generate_exportable_schema( + dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), + dsc_lib::schemas::dsc_repo::SchemaForm::Bundled + ); + match jsonschema::Validator::new(schema.as_value()) { + Ok(validator) => validator, + Err(err) => panic!( + "Failed to create JSON schema validator:\n---\nERROR: {:#?}\n---\nSCHEMA: {}\n---", + err, + serde_json::to_string_pretty(schema).unwrap(), + ), + } + }); + + $($crate::macros::define_schema_statics!($($rest)*);)? + }; + (properties: { + $($static_ident:ident => $property_name:literal),+ $(,)? + } $(, $($rest:tt)*)?) => { + $( + static $static_ident: std::sync::LazyLock = std::sync::LazyLock::new(|| { + use dsc_lib::schemas::schema_utility_extensions::SchemaUtilityExtensions; + ROOT_SCHEMA + .clone() + .get_property_subschema($property_name) + .expect(&format!("properties.{} should be defined", $property_name)) + .clone() + }); + )+ + $( $crate::macros::define_schema_statics!($($rest)*);)? + }; +} + +pub(crate) use define_schema_statics; \ No newline at end of file diff --git a/lib/dsc-lib/tests/integration/macros/test_dsc_repo_schema.rs b/lib/dsc-lib/tests/integration/macros/test_dsc_repo_schema.rs new file mode 100644 index 000000000..e275e0361 --- /dev/null +++ b/lib/dsc-lib/tests/integration/macros/test_dsc_repo_schema.rs @@ -0,0 +1,501 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Defines tests for DSC repository schemas. +/// +/// This macro generates a test module that includes the schema statics, +/// meta schema tests, documentation tests, and validation tests for a DSC +/// repository schema. It is intended to be used in integration tests for +/// DSC repository schemas. +/// +/// # Syntax +/// +/// The macro takes four arguments, all of which are _required_: +/// +/// - `define_statics`: Defines the schema statics using the +/// [`define_schema_statics!`] macro. +/// +/// These statics are leveraged by the rest of the macro to test the schema. +/// +/// - `test_meta_schema`: Defines the meta schema tests using the +/// [`test_meta_schema!`] macro. +/// +/// These tests ensure that the generated JSON Schema is valid against its own +/// meta schema using the [`jsonschema::meta::validate`] function. +/// +/// - `test_docs`: Defines the documentation tests using the +/// [`test_schema_docs!`] macro. +/// +/// These tests ensure that the documentation keywords are defined both in the +/// schema and in translation files. +/// - `test_validation`: Defines the validation tests using the +/// [`test_schema_validation!`] macro. +/// +/// These tests provide a set of test cases to validate valid and invalid +/// data against the schema. +/// +/// ## `define_statics` +/// +/// ```rust, ignore +/// test_dsc_repo_schema! { +/// define_statics: { +/// , +/// [properties: { +/// => "", +/// ... +/// },] +/// }, +/// ... +/// } +/// ``` +/// +/// - `` - the path to the type that should be tested, like +/// `my_crate::MyType`. +/// - `properties: {...}` defines a set of additional statics for the property +/// subschemas, which are required for validating documentation keywords in +/// those properties. Every property must be defined with the name of the +/// static variable to create and the property name to retrieve as a subschema. +/// +/// ## `test_meta_schema` +/// +/// ```rust, ignore +/// test_dsc_repo_schema! { +/// // Elided for brevity +/// test_meta_schema: {}, +/// ... +/// } +/// ``` +/// +/// When using this macro, always define `test_meta_schema` with an empty block. +/// The [`test_meta_schema!`] macro supports specifying an alternate static +/// for validation but this macro should always validate the root schema. +/// +/// ## `test_docs` +/// +/// ```rust, ignore +/// test_dsc_repo_schema! { +/// // Elided for brevity +/// test_docs: { +/// => "", +/// => (, ""), +/// }, +/// ... +/// } +/// ``` +/// +/// If you define `test_docs` with an empty block, the macro generates tests for +/// the `title`, `description`, and `markdownDescription` keywords on the root +/// schema. +/// +/// To test additional keywords on the root schema or on a subschema, you must +/// specify a case for every keyword you want to test: +/// +/// - When you define a case as ` => ""`, it tests for the +/// keyword on the root schema. +/// - When you define a case as ` => (, "")`, +/// it tests for the keyword on the subschema identified by ``. +/// +/// ## `test_validation` +/// +/// ```rust, ignore +/// test_dsc_repo_schema! { +/// // Elided for brevity +/// test_validation: { +/// => { +/// input_json: json!(), +/// expected_valid: , +/// }, +/// ... +/// }, +/// } +/// ``` +/// +/// - ``: The name of the test case function. +/// - `input_json`: The JSON input to be validated against the schema. The macro +/// expects you to define a JSON value with the `json!` macro, but you don't +/// need to define a using statement, because the macro automatically handles +/// expanding to the `serde_json::json!` macro internally. +/// - `expected_valid`: A boolean indicating whether the input is expected to +/// be valid. +/// +/// # Examples +/// +/// ## Minimal +/// +/// The following snippet shows a minimal definition without any additional +/// property subschemas or documentation keywords. +/// +/// ```rust, ignore +/// test_dsc_repo_schema! { +/// define_statics: { +/// my_crate::MyType +/// }, +/// test_meta_schema: {}, +/// test_docs: {}, +/// test_validation: { +/// string_value_is_valid => { +/// input_json: json!("string value"), +/// expected_valid: true, +/// }, +/// boolean_value_is_invalid => { +/// input_json: json!(true), +/// expected_valid: false, +/// }, +/// }, +/// } +/// ``` +/// +///
Expanded macro usage +/// +/// ```rust, ignore +/// #[cfg(test)] +/// mod schemas { +/// static ROOT_SCHEMA: std::sync::LazyLock = std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::dsc_repo::DscRepoSchema; +/// ::generate_exportable_schema( +/// dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), +/// dsc_lib::schemas::dsc_repo::SchemaForm::VSCode, +/// ) +/// }); +/// static VALIDATOR: std::sync::LazyLock = std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::dsc_repo::DscRepoSchema; +/// let ref schema = ::generate_exportable_schema( +/// dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), +/// dsc_lib::schemas::dsc_repo::SchemaForm::Bundled, +/// ); +/// match jsonschema::Validator::new(schema.as_value()) { +/// Ok(validator) => validator, +/// Err(err) => { +/// core::panicking::panic_fmt(core::const_format_args!( +/// "Failed to create JSON schema validator:\n---\nERROR: {:#?}\n---\nSCHEMA: {}\n---", +/// err, +/// serde_json::to_string_pretty(schema).unwrap(), +/// )); +/// } +/// } +/// }); +/// #[cfg(test)] +/// mod meta_schema { +/// use super::*; +/// #[test] +/// fn is_valid() { +/// let schema = ROOT_SCHEMA.as_value(); +/// let result = jsonschema::meta::validate(schema); +/// { +/// if !(result.is_ok()) { +/// { +/// core::panicking::panic_fmt(core::const_format_args!( +/// "expected schema to be valid but got error: {:#?}", +/// result.unwrap_err() +/// )); +/// }; +/// } +/// }; +/// } +/// } +/// #[cfg(test)] +/// mod docs_keywords { +/// use super::*; +/// #[test] +/// fn title() { +/// crate::schemas::assert_schema_has_docs_keyword("title", &ROOT_SCHEMA.clone()) +/// } +/// #[test] +/// fn description() { +/// crate::schemas::assert_schema_has_docs_keyword("description", &ROOT_SCHEMA.clone()) +/// } +/// #[test] +/// fn markdown_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownDescription", +/// &ROOT_SCHEMA.clone(), +/// ) +/// } +/// } +/// #[cfg(test)] +/// mod validation { +/// use super::*; +/// #[test] +/// fn string_value_is_valid() { +/// crate::schemas::assert_schema_validation( +/// &serde_json::to_value(&"string value").unwrap(), +/// true, +/// &VALIDATOR.clone(), +/// &ROOT_SCHEMA.clone(), +/// ); +/// } +/// #[test] +/// fn boolean_value_is_invalid() { +/// crate::schemas::assert_schema_validation( +/// &serde_json::Value::Bool(true), +/// false, +/// &VALIDATOR.clone(), +/// &ROOT_SCHEMA.clone(), +/// ); +/// } +/// } +/// } +/// ``` +/// +///
+/// +/// ## With additional properties and keywords +/// +/// The following snippet shows a definition that includes additional property +/// subschemas and documentation keywords. +/// +/// ```rust, ignore +/// test_dsc_repo_schema! { +/// define_statics: { +/// my_crate::MyType, +/// properties: { +/// FOO_PROPERTY_SCHEMA => "foo", +/// BAR_PROPERTY_SCHEMA => "bar", +/// } +/// }, +/// test_meta_schema: {}, +/// test_docs: { +/// title => "title", +/// description => "description", +/// markdown_description => "markdownDescription", +/// deprecation_message => "deprecationMessage", +/// foo_title => (FOO_PROPERTY_SCHEMA, "title"), +/// foo_description => (FOO_PROPERTY_SCHEMA, "description"), +/// foo_markdown_description => (FOO_PROPERTY_SCHEMA, "markdownDescription"), +/// foo_error_message => (FOO_PROPERTY_SCHEMA, "errorMessage"), +/// bar_title => (BAR_PROPERTY_SCHEMA, "title"), +/// bar_description => (BAR_PROPERTY_SCHEMA, "description"), +/// bar_markdown_description => (BAR_PROPERTY_SCHEMA, "markdownDescription"), +/// bar_markdown_enum_descriptions => (BAR_PROPERTY_SCHEMA, "markdownEnumDescriptions"), +/// }, +/// test_validation: { +/// empty_object_is_invalid => { +/// input_json: json!({}), +/// expected_valid: false, +/// }, +/// foo_as_true_and_bar_as_baz_is_valid => { +/// input_json: json!({ +/// "foo": true, +/// "bar": "baz" +/// }), +/// expected_valid: true, +/// }, +/// }, +/// } +/// ``` +/// +///
Expanded macro usage +/// +/// ```rust, ignore +/// #[cfg(test)] +/// mod schemas { +/// static ROOT_SCHEMA: std::sync::LazyLock = std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::dsc_repo::DscRepoSchema; +/// ::generate_exportable_schema( +/// dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), +/// dsc_lib::schemas::dsc_repo::SchemaForm::VSCode, +/// ) +/// }); +/// static VALIDATOR: std::sync::LazyLock = std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::dsc_repo::DscRepoSchema; +/// let ref schema = ::generate_exportable_schema( +/// dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion::latest(), +/// dsc_lib::schemas::dsc_repo::SchemaForm::Bundled, +/// ); +/// match jsonschema::Validator::new(schema.as_value()) { +/// Ok(validator) => validator, +/// Err(err) => { +/// core::panicking::panic_fmt(core::const_format_args!( +/// "Failed to create JSON schema validator:\n---\nERROR: {:#?}\n---\nSCHEMA: {}\n---", +/// err, +/// serde_json::to_string_pretty(schema).unwrap(), +/// )); +/// } +/// } +/// }); +/// static FOO_PROPERTY_SCHEMA: std::sync::LazyLock = +/// std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::schema_utility_extensions::SchemaUtilityExtensions; +/// ROOT_SCHEMA +/// .clone() +/// .get_property_subschema("foo") +/// .expect(&alloc::__export::must_use({ +/// alloc::fmt::format(alloc::__export::format_args!( +/// "properties.{} should be defined", +/// "foo" +/// )) +/// })) +/// .clone() +/// }); +/// static BAR_PROPERTY_SCHEMA: std::sync::LazyLock = +/// std::sync::LazyLock::new(|| { +/// use dsc_lib::schemas::schema_utility_extensions::SchemaUtilityExtensions; +/// ROOT_SCHEMA +/// .clone() +/// .get_property_subschema("bar") +/// .expect(&alloc::__export::must_use({ +/// alloc::fmt::format(alloc::__export::format_args!( +/// "properties.{} should be defined", +/// "bar" +/// )) +/// })) +/// .clone() +/// }); +/// #[cfg(test)] +/// mod meta_schema { +/// use super::*; +/// #[test] +/// fn is_valid() { +/// let schema = ROOT_SCHEMA.as_value(); +/// let result = jsonschema::meta::validate(schema); +/// { +/// if !(result.is_ok()) { +/// { +/// core::panicking::panic_fmt(core::const_format_args!( +/// "expected schema to be valid but got error: {:#?}", +/// result.unwrap_err() +/// )); +/// }; +/// } +/// }; +/// } +/// } +/// #[cfg(test)] +/// mod docs_keywords { +/// use super::*; +/// #[test] +/// fn title() { +/// crate::schemas::assert_schema_has_docs_keyword("title", &ROOT_SCHEMA.clone()) +/// } +/// #[test] +/// fn description() { +/// crate::schemas::assert_schema_has_docs_keyword("description", &ROOT_SCHEMA.clone()) +/// } +/// #[test] +/// fn markdown_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownDescription", +/// &ROOT_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn deprecation_message() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "deprecationMessage", +/// &ROOT_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn foo_title() { +/// crate::schemas::assert_schema_has_docs_keyword("title", &FOO_PROPERTY_SCHEMA.clone()) +/// } +/// #[test] +/// fn foo_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "description", +/// &FOO_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn foo_markdown_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownDescription", +/// &FOO_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn foo_error_message() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "errorMessage", +/// &FOO_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn bar_title() { +/// crate::schemas::assert_schema_has_docs_keyword("title", &BAR_PROPERTY_SCHEMA.clone()) +/// } +/// #[test] +/// fn bar_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "description", +/// &BAR_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn bar_markdown_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownDescription", +/// &BAR_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn bar_markdown_enum_descriptions() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownEnumDescriptions", +/// &BAR_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// } +/// #[cfg(test)] +/// mod validation { +/// use super::*; +/// #[test] +/// fn empty_object_is_invalid() { +/// crate::schemas::assert_schema_validation( +/// &serde_json::Value::Object(serde_json::Map::new()), +/// false, +/// &VALIDATOR.clone(), +/// &ROOT_SCHEMA.clone(), +/// ); +/// } +/// #[test] +/// fn foo_as_true_and_bar_as_baz_is_valid() { +/// crate::schemas::assert_schema_validation( +/// &serde_json::Value::Object({ +/// let mut object = serde_json::Map::new(); +/// let _ = object.insert(("foo").into(), (serde_json::Value::Bool(true))); +/// let _ = object.insert(("bar").into(), (serde_json::to_value(&"baz").unwrap())); +/// object +/// }), +/// true, +/// &VALIDATOR.clone(), +/// &ROOT_SCHEMA.clone(), +/// ); +/// } +/// } +/// } +/// ``` +/// +///
+/// +/// [`define_schema_statics!`]: crate::macros::define_schema_statics! +/// [`test_meta_schema!`]: crate::macros::test_meta_schema! +/// [`test_schema_docs!`]: crate::macros::test_schema_docs! +/// [`test_schema_validation!`]: crate::macros::test_schema_validation! +macro_rules! test_dsc_repo_schema { + ( + define_statics: {$($definition:tt)*}, + test_meta_schema: {$($test_meta_schema:tt)*}, + test_docs: {$($test_docs:tt)*}, + test_validation: {$($test_validation:tt)*} + $(,)? + ) => { + #[cfg(test)] + mod schemas { + $crate::macros::define_schema_statics! { + $($definition)* + } + $crate::macros::test_meta_schema! { + $($test_meta_schema)* + } + $crate::macros::test_schema_docs! { + $($test_docs)* + } + $crate::macros::test_schema_validation! { + $($test_validation)* + } + } + }; +} + +pub(crate) use test_dsc_repo_schema; diff --git a/lib/dsc-lib/tests/integration/macros/test_meta_schema.rs b/lib/dsc-lib/tests/integration/macros/test_meta_schema.rs new file mode 100644 index 000000000..38d02e7c7 --- /dev/null +++ b/lib/dsc-lib/tests/integration/macros/test_meta_schema.rs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Generates tests to ensure that a given JSON schema is valid according to +/// the meta-schema. +/// +/// This macro generates the `meta_schema::is_valid` test function. It uses the +/// [`jsonschema::meta::validate`] function. Without any arguments, the macro +/// tests the `ROOT_SCHEMA`. To test a specific schema, pass the static variable +/// representing that schema as an argument to the macro. +/// +///
+/// +/// This macro is best used through the [`test_dsc_repo_schema!`] macro. Only +/// use this macro directly when the higher level macro is unsuitable. +/// +///
+/// +/// # Examples +/// +/// When you use the macro without any arguments, it defaults to testing the +/// `ROOT_SCHEMA`. +/// +/// ```rust, ignore +/// crate::macros::test_meta_schema!(); +/// ``` +/// +///
Expanded macro +/// +/// ```rust, ignore +/// #[cfg(test)] +/// mod meta_schema { +/// use super::*; +/// #[test] +/// fn is_valid() { +/// let schema = ROOT_SCHEMA.as_value(); +/// let result = jsonschema::meta::validate(schema); +/// { +/// if !(result.is_ok()) { +/// { +/// core::panicking::panic_fmt(core::const_format_args!( +/// "expected schema to be valid but got error: {:#?}", +/// result.unwrap_err() +/// )); +/// }; +/// } +/// }; +/// } +/// } +/// ``` +/// +///
+/// +/// When you specify a specific static schema, the macro will generate a test +/// for that schema instead of the `ROOT_SCHEMA`. +/// +/// ```rust, ignore +/// crate::macros::test_meta_schema!(FOO_PROPERTY_SCHEMA); +/// ``` +/// +///
Expanded macro +/// +/// ```rust, ignore +/// #[cfg(test)] +/// mod meta_schema { +/// use super::*; +/// #[test] +/// fn is_valid() { +/// let schema = FOO_PROPERTY_SCHEMA.as_value(); +/// let result = jsonschema::meta::validate(schema); +/// { +/// if !(result.is_ok()) { +/// { +/// core::panicking::panic_fmt(core::const_format_args!( +/// "expected schema to be valid but got error: {:#?}", +/// result.unwrap_err() +/// )); +/// }; +/// } +/// }; +/// } +/// } +/// ``` +/// +///
+/// +/// [`test_dsc_repo_schema!`]: crate::macros::test_dsc_repo_schema +macro_rules! test_meta_schema { + () => { + $crate::macros::test_meta_schema!(@fn ROOT_SCHEMA); + }; + ($schema_static:ident) => { + $crate::macros::test_meta_schema!(@fn $schema_static); + }; + (@fn $schema_static:ident) => { + #[cfg(test)] + mod meta_schema { + use super::*; + + #[test] + fn is_valid() { + let schema = $schema_static.as_value(); + let result = jsonschema::meta::validate(schema); + assert!( + result.is_ok(), + "expected schema to be valid but got error: {:#?}", + result.unwrap_err() + ); + } + } + }; +} + +pub(crate) use test_meta_schema; diff --git a/lib/dsc-lib/tests/integration/macros/test_schema_docs.rs b/lib/dsc-lib/tests/integration/macros/test_schema_docs.rs new file mode 100644 index 000000000..64f113856 --- /dev/null +++ b/lib/dsc-lib/tests/integration/macros/test_schema_docs.rs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Generates tests for schema documentation keywords. +/// +/// This macro generates tests for schema documentation keywords, ensuring that +/// they're defined and have valid translations. +/// +///
+/// +/// This macro is best used through the [`test_dsc_repo_schema!`] macro. Only +/// use this macro directly when the higher level macro is unsuitable. +/// +///
+/// +/// The available keywords are: +/// +/// - `title` +/// - `description` +/// - `markdownDescription` +/// - `enumDescriptions` +/// - `markdownEnumDescriptions` +/// - `errorMessage` +/// - `patternErrorMessage` +/// - `deprecationMessage` +/// +/// The default set is `[title, description, markdownDescription]`. +/// +/// # Examples +/// +/// ## Without arguments +/// +/// ```rust, ignore +/// test_schema_docs!(); +/// ``` +/// +///
Expanded macro +/// +/// ```rust, ignore +/// #[cfg(test)] +/// mod docs_keywords { +/// use super::*; +/// #[test] +/// fn title() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "title", +/// &ROOT_SCHEMA.clone() +/// ) +/// } +/// #[test] +/// fn description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "description", +/// &ROOT_SCHEMA.clone() +/// ) +/// } +/// #[test] +/// fn markdown_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownDescription", +/// &ROOT_SCHEMA.clone(), +/// ) +/// } +/// } +/// ``` +/// +///
+/// +/// ## With specific keywords +/// +/// ```rust, ignore +/// test_schema_docs!{ +/// title => "title", +/// description => "description", +/// markdown_description => "markdownDescription", +/// deprecation_message => "deprecationMessage", +/// } +/// ``` +/// +///
Expanded macro +/// +/// ```rust, ignore +/// #[cfg(test)] +/// mod docs_keywords { +/// use super::*; +/// #[test] +/// fn title() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "title", +/// &ROOT_SCHEMA.clone() +/// ) +/// } +/// #[test] +/// fn description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "description", +/// &ROOT_SCHEMA.clone() +/// ) +/// } +/// #[test] +/// fn markdown_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownDescription", +/// &ROOT_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn deprecation_message() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "deprecationMessage", +/// &ROOT_SCHEMA.clone(), +/// ) +/// } +/// } +/// ``` +/// +/// ## With specific keywords and schemas +/// +/// ```rust, ignore +/// test_schema_docs!{ +/// title => "title", +/// description => "description", +/// markdown_description => "markdownDescription", +/// tracing_title => (TRACING_PROPERTY_SCHEMA, "title"), +/// tracing_description => (TRACING_PROPERTY_SCHEMA, "description"), +/// tracing_markdown_description => (TRACING_PROPERTY_SCHEMA, "markdownDescription"), +/// resource_path_title => (RESOURCE_PATH_PROPERTY_SCHEMA, "title"), +/// resource_path_description => (RESOURCE_PATH_PROPERTY_SCHEMA, "description"), +/// resource_path_markdown_description => (RESOURCE_PATH_PROPERTY_SCHEMA, "markdownDescription"), +/// } +/// ``` +/// +///
Expanded macro +/// +/// ```rust, ignore +/// #[cfg(test)] +/// mod docs_keywords { +/// use super::*; +/// #[test] +/// fn title() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "title", +/// &ROOT_SCHEMA.clone() +/// ) +/// } +/// #[test] +/// fn description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "description", +/// &ROOT_SCHEMA.clone() +/// ) +/// } +/// #[test] +/// fn markdown_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownDescription", +/// &ROOT_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn tracing_title() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "title", +/// &TRACING_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn tracing_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "description", +/// &TRACING_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn tracing_markdown_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownDescription", +/// &TRACING_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn resource_path_title() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "title", +/// &RESOURCE_PATH_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn resource_path_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "description", +/// &RESOURCE_PATH_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// #[test] +/// fn resource_path_markdown_description() { +/// crate::schemas::assert_schema_has_docs_keyword( +/// "markdownDescription", +/// &RESOURCE_PATH_PROPERTY_SCHEMA.clone(), +/// ) +/// } +/// } +/// ``` +/// +/// [`test_dsc_repo_schema!`]: crate::macros::test_dsc_repo_schema +macro_rules! test_schema_docs { + // `test_schema_docs!()` uses the default set of keywords for the root schema. + () => { + $crate::macros::test_schema_docs!{ + title => "title", + description => "description", + markdown_description => "markdownDescription" + } + }; + // `test_schema_docs!(title => "title", property_title => (PROPERTY_SCHEMA, "title"))` + // Generates a test for each case provided. + ($($test_name:ident => $test_info:tt),+ $(,)?) => { + #[cfg(test)] mod docs_keywords { + use super::*; + $crate::macros::test_schema_docs!{ + $($test_name: $test_info),+ + } + } + }; + /**************** Incremental munchers for each test case *****************/ + ( // Muncher for a keyword in the root schema + $test_name:ident: $keyword:literal + $(, $($rest:tt)*)? + ) => { + $crate::macros::test_schema_docs!{ + $test_name: (ROOT_SCHEMA, $keyword) + } + + $($crate::macros::test_schema_docs!($($rest)*);)? + }; + ( // Muncher for a keyword in a specific static schema + $test_name:ident: ($static_schema:ident, $keyword:literal) + $(, $($rest:tt)*)? + ) => { + #[test] fn $test_name() { + $crate::schemas::assert_schema_has_docs_keyword( + $keyword, + &$static_schema.clone() + ) + } + + $($crate::macros::test_schema_docs!($($rest)*);)? + }; +} + +pub(crate) use test_schema_docs; diff --git a/lib/dsc-lib/tests/integration/macros/test_schema_validation.rs b/lib/dsc-lib/tests/integration/macros/test_schema_validation.rs new file mode 100644 index 000000000..a7a653538 --- /dev/null +++ b/lib/dsc-lib/tests/integration/macros/test_schema_validation.rs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// Generate tests for validating data against the root schema. +/// +/// This macro creates a `validation` module that contains tests for each +/// provided case. Every case must specify the input json and whether the data +/// is expected to be valid against the schema. +/// +///
+/// +/// This macro is best used through the [`test_dsc_repo_schema!`] macro. Only +/// use this macro directly when the higher level macro is unsuitable. +/// +///
+/// +/// # Syntax +/// +/// Every test case must be defined with the following structure: +/// +/// ```text +/// => { +/// input_json: json!(...), +/// expected_valid: true|false, +/// } +/// ``` +/// +/// - ``: The name of the test case function. +/// - `input_json`: The JSON input to be validated against the schema. The macro +/// expects you to define a JSON value with the `json!` macro, but you don't +/// need to define a using statement, because the macro automatically handles +/// expanding to the `serde_json::json!` macro internally. +/// - `expected_valid`: A boolean indicating whether the input is expected to +/// be valid. +/// +/// # Example +/// +/// This example shows how to define test cases for valid and invalid data. +/// +/// ```rust, ignore +/// test_schema_validation! { +/// valid_case => { +/// input_json: json!({"key": "value"}), +/// expected_valid: true, +/// }, +/// invalid_case => { +/// input_json: json!({"key": 123}), +/// expected_valid: false, +/// }, +/// } +/// ``` +/// +///
+/// +/// ```rust, ignore +/// #[cfg(test)] +/// mod validation { +/// use super::*; +/// #[test] +/// fn valid_case() { +/// $crate::schemas::assert_schema_validation( +/// &serde_json::json!({"key": "value"}), +/// true, +/// &VALIDATOR.clone(), +/// &ROOT_SCHEMA.clone() +/// ); +/// } +/// +/// #[test] +/// fn invalid_case() { +/// $crate::schemas::assert_schema_validation( +/// &serde_json::json!({"key": 123}) , +/// false, +/// &VALIDATOR.clone(), +/// &ROOT_SCHEMA.clone() +/// ); +/// } +/// } +/// ``` +/// +///
+/// +/// [`test_dsc_repo_schema!`]: crate::macros::test_dsc_repo_schema +macro_rules! test_schema_validation { + ($( + $case_description:ident => { + input_json: json!($($case_input:tt)*), + expected_valid: $case_expected_valid:literal, + } + ),+ $(,)?) => { + #[cfg(test)] mod validation { + use super::*; + $( + #[test] + fn $case_description() { + $crate::schemas::assert_schema_validation( + &serde_json::json!($($case_input)*), + $case_expected_valid, + &VALIDATOR.clone(), + &ROOT_SCHEMA.clone() + ); + } + )+ + } + }; +} + +pub(crate) use test_schema_validation; diff --git a/lib/dsc-lib/tests/integration/main.rs b/lib/dsc-lib/tests/integration/main.rs index 3a005a0ac..17209b4e8 100644 --- a/lib/dsc-lib/tests/integration/main.rs +++ b/lib/dsc-lib/tests/integration/main.rs @@ -5,14 +5,36 @@ //! //! Instead of defining tests in each of the module files for the crate, we define them here as //! integration tests to improve compilation times. -//! +//! //! The tests in this module are for public code. The tests should validate expected behaviors at //! the public API level. Don't add tests to this module for inner code behaviors. -//! +//! //! We organize the tests in the `tests/integration` folder instead of directly in `tests` to //! minimize compilation times. If we defined the tests one level higher in the `tests` folder, //! Rust would generate numerous binaries to execute our tests. -#[cfg(test)] mod command_resource; -#[cfg(test)] mod schemas; -#[cfg(test)] mod types; +#[macro_use] +pub(crate) mod macros { + //! Defines macros for simplifying integration tests. + //! + //! This module includes helper macros to reduce boiler plate and ensure consistent + //! implementation across integration tests. While each of the macros can be used independently, + //! the most ergonomic option is to use [`test_dsc_repo_schema!`] to handle the creation of the + //! test module for verifying schema correctness and documentation. + + mod define_schema_statics; + pub(crate) use define_schema_statics::define_schema_statics; + mod test_schema_docs; + pub(crate) use test_schema_docs::test_schema_docs; + mod test_meta_schema; + pub(crate) use test_meta_schema::test_meta_schema; + mod test_schema_validation; + pub(crate) use test_schema_validation::test_schema_validation; + mod test_dsc_repo_schema; + pub(crate) use test_dsc_repo_schema::test_dsc_repo_schema; +} +#[macro_use] +pub(crate) mod schemas; +mod command_resource; +mod types; +mod settings; diff --git a/lib/dsc-lib/tests/integration/schemas/mod.rs b/lib/dsc-lib/tests/integration/schemas/mod.rs index e1abf0e1c..5d6aff0df 100644 --- a/lib/dsc-lib/tests/integration/schemas/mod.rs +++ b/lib/dsc-lib/tests/integration/schemas/mod.rs @@ -4,3 +4,113 @@ //! Defines integration tests for generating JSON Schemas for the public types in [`dsc-lib`]. #[cfg(test)] mod schema_for; + +/// Defines helper macros for testing JSON Schemas. +#[allow(unused_imports)] +#[macro_use] +pub(crate) mod macros { + pub(crate) use crate::macros::define_schema_statics; + pub(crate) use crate::macros::test_dsc_repo_schema; + pub(crate) use crate::macros::test_meta_schema; + pub(crate) use crate::macros::test_schema_docs; + pub(crate) use crate::macros::test_schema_validation; +} + +/// Defines a pattern that matches an unresolved i18n key. +/// +/// Used in the [`assert_schema_has_docs_keyword`] function. +static KEYWORD_PATTERN: std::sync::LazyLock = std::sync::LazyLock::new(|| { + regex::Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid") +}); + +/// Asserts that a given schema contains the specified documentation keyword +/// and that its value is properly defined in the translation files. +/// +/// This helper function is only intended to be used within the integration +/// tests for schemas. Generally, it is invoked through the tests automatically +/// created by the [`test_schema_docs!`] macro. +/// +/// # Arguments +/// +/// - `keyword`: The documentation keyword to check in the schema. +/// - `schema`: The schema to be validated. +/// +/// # Panics +/// +/// Panics if the specified keyword isn't found in the schema or if its value +/// appears to be a translation key, like `some.schema.title`. +/// +/// [`test_schema_docs!`]: crate::macros::test_schema_docs +pub(crate) fn assert_schema_has_docs_keyword(keyword: &str, schema: &schemars::Schema) { + use dsc_lib::schemas::schema_utility_extensions::SchemaUtilityExtensions; + match keyword { + "enumDescriptions" | "markdownEnumDescriptions" => { + schema.get_keyword_as_array(keyword) + .expect(&format!("expected keyword '{keyword}' to be defined as an array")) + .iter() + .for_each(|value| { + let string_value = value.as_str() + .expect("expected each item in array value to be a string"); + assert!( + !(&*KEYWORD_PATTERN).is_match(string_value), + "expected each item in `{}` to be defined in translation, but was set to i18n key '{}'", + keyword, + value, + ) + }) + } + _ => { + let value = schema + .get_keyword_as_str(keyword) + .expect(&format!("expected keyword '{keyword}' to be defined")); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'", + ); + } + } +} + +/// Asserts that a given JSON input is valid or invalid against the provided +/// schema. +/// +/// This helper function is only intended to be used within the integration +/// tests for schemas. Generally, it is invoked through the tests automatically +/// created by the [`test_schema_validation!`] macro. +/// +/// # Arguments +/// +/// - `input_json`: The JSON input to validate against the schema. +/// - `expected_valid`: A boolean indicating whether the input is expected to be valid. +/// - `validator`: The JSON schema validator to use. +/// - `root_schema`: The root schema against which the input is validated. +/// +/// # Panics +/// +/// Panics if the validation result does not match the expected validity. +/// +/// [`test_schema_validation!`]: crate::macros::test_schema_validation +pub(crate)fn assert_schema_validation( + input_json: &serde_json::Value, + expected_valid: bool, + validator: &jsonschema::Validator, + root_schema: &schemars::Schema, +) { + let validator = validator.clone(); + let result = validator.validate(input_json); + if expected_valid { + assert!( + result.is_ok(), + "expected input to be valid for schema:\n---\ninput: {}\n---\nerror: {:#?}\n---", + serde_json::to_string_pretty(input_json).unwrap(), + result.unwrap_err()); + } else { + assert!( + result.is_err(), + "expected input to be invalid for schema:\n---\ninput: {}\n---\nschema: {}\n---", + serde_json::to_string_pretty(input_json).unwrap(), + serde_json::to_string_pretty(&*root_schema).unwrap() + ); + } +} diff --git a/lib/dsc-lib/tests/integration/schemas/schema_for.rs b/lib/dsc-lib/tests/integration/schemas/schema_for.rs index ebf81f927..5e7dc2486 100644 --- a/lib/dsc-lib/tests/integration/schemas/schema_for.rs +++ b/lib/dsc-lib/tests/integration/schemas/schema_for.rs @@ -159,5 +159,26 @@ macro_rules! test_schema_for { test_schema_for!(dsc_lib::extensions::secret::SecretMethod); } } - + mod settings { + test_schema_for!(dsc_lib::settings::DscSettings); + test_schema_for!(dsc_lib::settings::DscSettingsScope); + test_schema_for!(dsc_lib::settings::DscSettingsResolved); + mod fields { + test_schema_for!(dsc_lib::settings::fields::ForbidIgnoreSettingsFileField); + test_schema_for!(dsc_lib::settings::fields::IgnoreSettingsFileField); + test_schema_for!(dsc_lib::settings::fields::ResourcePathFileData); + test_schema_for!(dsc_lib::settings::fields::ResourcePathResolvedSettings); + test_schema_for!(dsc_lib::settings::fields::TracingFileData); + test_schema_for!(dsc_lib::settings::fields::TracingFormatField); + test_schema_for!(dsc_lib::settings::fields::TracingLevelField); + test_schema_for!(dsc_lib::settings::fields::TracingResolvedSettings); + } + mod sources { + test_schema_for!(dsc_lib::settings::sources::DscSettingsCodeDefaults); + test_schema_for!(dsc_lib::settings::sources::CommandLineData); + test_schema_for!(dsc_lib::settings::sources::EnvironmentData); + test_schema_for!(dsc_lib::settings::sources::PolicyFileData); + test_schema_for!(dsc_lib::settings::sources::PreferenceFileData); + } + } } diff --git a/lib/dsc-lib/tests/integration/settings/builders.rs b/lib/dsc-lib/tests/integration/settings/builders.rs new file mode 100644 index 000000000..8ea7781ac --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/builders.rs @@ -0,0 +1,878 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines builders for constructing settings data structures for testing purposes. +//! +//! The implementation for DSC retrieves settings from both the file system and the environment. However, for testing +//! purposes, it's useful to construct settings data structures directly in memory without relying on external files or +//! environment variables. +//! +//! This makes it easier to validate behavior for settings resolution, precedence, and policy enforcement without +//! needing to set up specific files or environment states. +//! +//! Acceptance tests written in Pester are more suited to testing the file system and environment interactions, while +//! these builders are intended for integration tests that focus on the correctness of the public API. + +#![allow(dead_code)] + +use std::path::PathBuf; +use dsc_lib::settings::fields::*; +use dsc_lib::settings::sources::*; +use dsc_lib::settings::{DscSettings, DscSettingsResolved, DscSettingsResolvedField, DscSettingsScope}; + +/// Builder for [`TracingFileData`] +pub struct TracingFileDataBuilder { + level: Option, + format: Option, +} + +impl TracingFileDataBuilder { + /// Creates a new instance of the builder with all fields undefined. + /// + /// # Returns + /// + /// An instance of [`TracingFileDataBuilder`]. + pub fn new() -> Self { + Self { + level: None, + format: None, + } + } + + /// Adds the specified [`TracingLevelField`] to the builder. + /// + /// # Arguments + /// + /// - `level` - The tracing level to be set in the builder. + /// + /// # Returns + /// + /// The updated instance of [`TracingFileDataBuilder`]. + pub fn with_level(mut self, level: TracingLevelField) -> Self { + self.level = Some(level); + self + } + + /// Adds the specified [`TracingFormatField`] to the builder. + /// + /// # Arguments + /// + /// - `format` - The tracing format to be set in the builder. + /// + /// # Returns + /// + /// The updated instance of [`TracingFileDataBuilder`]. + pub fn with_format(mut self, format: TracingFormatField) -> Self { + self.format = Some(format); + self + } + + /// Builds the [`TracingFileData`] instance from the specified fields. + /// + /// # Returns + /// + /// An instance of [`TracingFileData`]. + pub fn build(self) -> TracingFileData { + TracingFileData { + level: self.level, + format: self.format, + } + } +} + +/// Builder for [`ResourcePathFileData`] +pub struct ResourcePathFileDataBuilder { + append_env_path: Option, + directories: Option>, + restricted: Option, +} + +impl ResourcePathFileDataBuilder { + /// Creates a new instance of the builder with all fields undefined. + /// + /// # Returns + /// + /// An instance of [`ResourcePathFileDataBuilder`]. + pub fn new() -> Self { + Self { + append_env_path: None, + directories: None, + restricted: None, + } + } + + /// Adds the specified append environment path flag to the builder. + /// + /// # Arguments + /// + /// - `append` - A boolean indicating whether to append the environment path. + /// + /// # Returns + /// + /// The updated instance of [`ResourcePathFileDataBuilder`]. + pub fn with_append_env_path(mut self, append: bool) -> Self { + self.append_env_path = Some(append); + self + } + + /// Adds the specified directories to the builder. + /// + /// # Arguments + /// + /// - `dirs` - A vector of directory paths as strings. + /// + /// # Returns + /// + /// The updated instance of [`ResourcePathFileDataBuilder`]. + pub fn with_directories(mut self, dirs: Vec<&str>) -> Self { + self.directories = Some(dirs.into_iter().map(PathBuf::from).collect()); + self + } + + /// Adds the specified restricted flag to the builder. + /// + /// # Arguments + /// + /// - `restrict` - A boolean indicating whether the resource path is restricted. + /// + /// # Returns + /// + /// The updated instance of [`ResourcePathFileDataBuilder`]. + pub fn with_restricted(mut self, restrict: bool) -> Self { + self.restricted = Some(restrict); + self + } + + /// Builds the [`ResourcePathFileData`] instance from the specified fields. + /// + /// # Returns + /// + /// An instance of [`ResourcePathFileData`]. + pub fn build(self) -> ResourcePathFileData { + ResourcePathFileData { + append_env_path: self.append_env_path, + directories: self.directories, + restricted: self.restricted, + } + } +} + +/// Builder for [`PolicyFileData`] +pub struct PolicyFileDataBuilder { + pub forbid_ignore_settings_file: Option, + pub ignore_settings_file: Option, + pub tracing: Option, + pub resource_path: Option, +} + +impl PolicyFileDataBuilder { + /// Creates a new instance of the builder with all fields undefined. + /// + /// # Returns + /// + /// An instance of [`PolicyFileDataBuilder`]. + pub fn new() -> Self { + Self { + forbid_ignore_settings_file: None, + ignore_settings_file: None, + tracing: None, + resource_path: None, + } + } + /// Adds the specified forbid ignore settings file flag to the builder. + /// + /// # Arguments + /// + /// - `forbid` - A boolean indicating whether to forbid ignoring the settings file. + /// + /// # Returns + /// + /// The updated instance of [`PolicyFileDataBuilder`]. + pub fn with_forbid_ignore_settings_file(mut self, forbid: bool) -> Self { + self.forbid_ignore_settings_file = Some(forbid.into()); + self + } + /// Adds the specified ignore settings file flag to the builder. + /// + /// # Arguments + /// + /// - `ignore` - A boolean indicating whether to ignore the settings file. + /// + /// # Returns + /// + /// The updated instance of [`PolicyFileDataBuilder`]. + pub fn with_ignore_settings_file(mut self, ignore: bool) -> Self { + self.ignore_settings_file = Some(ignore.into()); + self + } + /// Adds the specified tracing configuration to the builder. + /// + /// # Arguments + /// + /// - `tracing` - An instance of [`TracingFileData`]. + /// + /// # Returns + /// + /// The updated instance of [`PolicyFileDataBuilder`]. + pub fn with_tracing(mut self, tracing: TracingFileData) -> Self { + self.tracing = Some(tracing); + self + } + /// Adds the specified resource path configuration to the builder. + /// + /// # Arguments + /// + /// - `resource_path` - An instance of [`ResourcePathFileData`]. + /// + /// # Returns + /// + /// The updated instance of [`PolicyFileDataBuilder`]. + pub fn with_resource_path(mut self, resource_path: ResourcePathFileData) -> Self { + self.resource_path = Some(resource_path); + self + } + /// Builds the [`PolicyFileData`] instance from the specified fields. + /// + /// # Returns + /// + /// An instance of [`PolicyFileData`]. + pub fn build(self) -> PolicyFileData { + PolicyFileData { + forbid_ignore_settings_file: self.forbid_ignore_settings_file, + ignore_settings_file: self.ignore_settings_file, + tracing: self.tracing, + resource_path: self.resource_path, + } + } +} + +/// Builder for [`PreferenceFileData`] +pub struct PreferenceFileDataBuilder { + pub tracing: Option, + pub resource_path: Option, +} +impl PreferenceFileDataBuilder { + /// Creates a new instance of the [`PreferenceFileDataBuilder`] with default values. + /// + /// # Returns + /// + /// An instance of [`PreferenceFileDataBuilder`]. + pub fn new() -> Self { + Self { + tracing: None, + resource_path: None, + } + } + + /// Adds the specified tracing configuration to the builder. + /// + /// # Arguments + /// + /// - `tracing` - An instance of [`TracingFileData`]. + /// + /// # Returns + /// + /// The updated instance of [`PreferenceFileDataBuilder`]. + pub fn with_tracing(mut self, tracing: TracingFileData) -> Self { + self.tracing = Some(tracing); + self + } + + /// Adds the specified resource path configuration to the builder. + /// + /// # Arguments + /// + /// - `resource_path` - An instance of [`ResourcePathFileData`]. + /// + /// # Returns + /// + /// The updated instance of [`PreferenceFileDataBuilder`]. + pub fn with_resource_path(mut self, resource_path: ResourcePathFileData) -> Self { + self.resource_path = Some(resource_path); + self + } + + /// Builds the [`PreferenceFileData`] instance from the specified fields. + /// + /// # Returns + /// + /// An instance of [`PreferenceFileData`]. + pub fn build(self) -> PreferenceFileData { + PreferenceFileData { + tracing: self.tracing, + resource_path: self.resource_path, + } + } +} + +/// Builder for [`CommandLineData`] +pub struct CommandLineDataBuilder { + pub trace_level: Option, + pub trace_format: Option, + pub ignore_settings_file: Option, +} + +impl CommandLineDataBuilder { + /// Creates a new instance of the [`CommandLineDataBuilder`] with default values. + /// + /// # Returns + /// + /// An instance of [`CommandLineDataBuilder`]. + pub fn new() -> Self { + Self { + trace_level: None, + trace_format: None, + ignore_settings_file: None, + } + } + + /// Adds the specified trace level configuration to the builder. + /// + /// # Arguments + /// + /// - `level` - An instance of [`TracingLevelField`]. + /// + /// # Returns + /// + /// The updated instance of [`CommandLineDataBuilder`]. + pub fn with_trace_level(mut self, level: TracingLevelField) -> Self { + self.trace_level = Some(level); + self + } + + /// Adds the specified trace format configuration to the builder. + /// + /// # Arguments + /// + /// - `format` - An instance of [`TracingFormatField`]. + /// + /// # Returns + /// + /// The updated instance of [`CommandLineDataBuilder`]. + pub fn with_trace_format(mut self, format: TracingFormatField) -> Self { + self.trace_format = Some(format); + self + } + + /// Adds the specified ignore settings file configuration to the builder. + /// + /// # Arguments + /// + /// - `ignore` - A boolean indicating whether to ignore the settings file. + /// + /// # Returns + /// + /// The updated instance of [`CommandLineDataBuilder`]. + pub fn with_ignore_settings_file(mut self, ignore: bool) -> Self { + self.ignore_settings_file = Some(ignore.into()); + self + } + + /// Builds the [`CommandLineData`] instance from the specified fields. + /// + /// # Returns + /// + /// An instance of [`CommandLineData`]. + pub fn build(self) -> CommandLineData { + CommandLineData { + trace_level: self.trace_level, + trace_format: self.trace_format, + ignore_settings_file: self.ignore_settings_file, + } + } +} + +/// Builder for [`EnvironmentData`] +pub struct EnvironmentDataBuilder { + dsc_trace_level: Option, + dsc_trace_format: Option, + dsc_resource_path: Option>, + dsc_restricted_path: Option>, + dsc_ignore_settings_file: Option, +} + +impl EnvironmentDataBuilder { + /// Creates a new instance of the [`EnvironmentDataBuilder`] with default values. + /// + /// # Returns + /// + /// An instance of [`EnvironmentDataBuilder`]. + pub fn new() -> Self { + Self { + dsc_trace_level: None, + dsc_trace_format: None, + dsc_resource_path: None, + dsc_restricted_path: None, + dsc_ignore_settings_file: None, + } + } + /// Adds the specified trace level configuration to the builder. + /// + /// # Arguments + /// + /// - `level` - An instance of [`TracingLevelField`]. + /// + /// # Returns + /// + /// The updated instance of [`EnvironmentDataBuilder`]. + pub fn with_trace_level(mut self, level: TracingLevelField) -> Self { + self.dsc_trace_level = Some(level); + self + } + + /// Adds the specified trace format configuration to the builder. + /// + /// # Arguments + /// + /// - `format` - An instance of [`TracingFormatField`]. + /// + /// # Returns + /// + /// The updated instance of [`EnvironmentDataBuilder`]. + pub fn with_trace_format(mut self, format: TracingFormatField) -> Self { + self.dsc_trace_format = Some(format); + self + } + + /// Adds the specified resource path configuration to the builder. + /// + /// # Arguments + /// + /// - `path` - A vector of [`PathBuf`] representing the resource paths. + /// + /// # Returns + /// + /// The updated instance of [`EnvironmentDataBuilder`]. + pub fn with_resource_path(mut self, path: Vec) -> Self { + self.dsc_resource_path = Some(path); + self + } + + /// Adds the specified restricted path configuration to the builder. + /// + /// # Arguments + /// + /// - `path` - A vector of [`PathBuf`] representing the restricted paths. + /// + /// # Returns + /// + /// The updated instance of [`EnvironmentDataBuilder`]. + pub fn with_restricted_path(mut self, path: Vec) -> Self { + self.dsc_restricted_path = Some(path); + self + } + + /// Adds the specified ignore settings file configuration to the builder. + /// + /// # Arguments + /// + /// - `ignore` - A boolean indicating whether to ignore the settings file. + /// + /// # Returns + /// + /// The updated instance of [`EnvironmentDataBuilder`]. + pub fn with_ignore_settings_file(mut self, ignore: bool) -> Self { + self.dsc_ignore_settings_file = Some(ignore.into()); + self + } + /// Builds the [`EnvironmentData`] instance from the specified fields. + /// + /// # Returns + /// + /// An instance of [`EnvironmentData`]. + pub fn build(self) -> EnvironmentData { + EnvironmentData { + dsc_trace_level: self.dsc_trace_level, + dsc_trace_format: self.dsc_trace_format, + dsc_resource_path: self.dsc_resource_path, + dsc_restricted_path: self.dsc_restricted_path, + dsc_ignore_settings_file: self.dsc_ignore_settings_file, + } + } +} + +/// Builder for creating instances of [`DscSettings`]. +pub struct DscSettingsBuilder { + machine: Option, + user: Option, + workspace: Option, + environment: Option, + command_line: Option, + policy: Option, +} + +impl DscSettingsBuilder { + /// Creates a new instance of [`DscSettingsBuilder`] with all fields set to `None`. + /// + /// # Returns + /// + /// A new instance of [`DscSettingsBuilder`]. + pub fn new() -> Self { + Self { + machine: None, + user: None, + workspace: None, + environment: None, + command_line: None, + policy: None, + } + } + + /// Adds the specified machine scope preferences to the builder. + /// + /// # Arguments + /// + /// - `machine_data` - The machine scope preferences to add. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsBuilder`]. + pub fn with_machine(mut self, machine_data: PreferenceFileData) -> Self { + self.machine = Some(machine_data); + self + } + + /// Adds the specified user scope preferences to the builder. + /// + /// # Arguments + /// + /// - `user_data` - The user scope preferences to add. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsBuilder`]. + pub fn with_user(mut self, user_data: PreferenceFileData) -> Self { + self.user = Some(user_data); + self + } + + /// Adds the specified workspace scope preferences to the builder. + /// + /// # Arguments + /// + /// - `workspace_data` - The workspace scope preferences to add. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsBuilder`]. + pub fn with_workspace(mut self, workspace_data: PreferenceFileData) -> Self { + self.workspace = Some(workspace_data); + self + } + /// Adds the specified environment scope preferences to the builder. + /// + /// # Arguments + /// + /// - `environment_data` - The environment scope preferences to add. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsBuilder`]. + pub fn with_environment(mut self, environment_data: EnvironmentData) -> Self { + self.environment = Some(environment_data); + self + } + /// Adds the specified command line scope preferences to the builder. + /// + /// # Arguments + /// + /// - `command_line_data` - The command line scope preferences to add. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsBuilder`]. + pub fn with_command_line(mut self, command_line_data: CommandLineData) -> Self { + self.command_line = Some(command_line_data); + self + } + /// Adds the specified policy settings to the builder. + /// + /// # Arguments + /// + /// - `policy_data` - The policy scope preferences to add. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsBuilder`]. + pub fn with_policy(mut self, policy_data: PolicyFileData) -> Self { + self.policy = Some(policy_data); + self + } + /// Builds the final instance of [`DscSettings`] from the builder. + /// + /// # Returns + /// + /// The constructed instance of [`DscSettings`]. + pub fn build(self) -> DscSettings { + let mut settings = DscSettings::new(); + + settings.machine = self.machine; + settings.user = self.user; + settings.workspace = self.workspace; + settings.environment = self.environment; + settings.command_line = self.command_line; + settings.policy = self.policy; + + settings + } +} + +/// Builder for constructing instances of [`ResourcePathResolvedSettings`]. +pub struct ResourcePathResolvedSettingsBuilder { + pub append_env_path: Option>, + pub directories: Option>>, + pub restricted: Option>, +} + +impl ResourcePathResolvedSettingsBuilder { + /// Creates a new instance of the builder with default values. + /// + /// # Returns + /// + /// The newly created instance of [`ResourcePathResolvedSettingsBuilder`]. + pub fn new() -> Self { + Self { + append_env_path: None, + directories: None, + restricted: None, + } + } + + /// Adds the specified append environment path setting to the builder. + /// + /// # Arguments + /// + /// - `value` - The value to set for the append environment path setting. + /// - `scope` - The scope of the setting. + /// + /// # Returns + /// + /// The updated instance of [`ResourcePathResolvedSettingsBuilder`]. + pub fn with_append_env_path(mut self, value: bool, scope: DscSettingsScope) -> Self { + self.append_env_path = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + /// Adds the specified directories setting to the builder. + /// + /// # Arguments + /// + /// - `value` - The value to set for the directories setting. + /// - `scope` - The scope of the setting. + /// + /// # Returns + /// + /// The updated instance of [`ResourcePathResolvedSettingsBuilder`]. + pub fn with_directories(mut self, value: Vec<&str>, scope: DscSettingsScope) -> Self { + self.directories = Some(DscSettingsResolvedField::new( + value.into_iter().map(PathBuf::from).collect(), + scope, + )); + self + } + + /// Adds the specified restricted setting to the builder. + /// + /// # Arguments + /// + /// - `value` - The value to set for the restricted setting. + /// - `scope` - The scope of the setting. + /// + /// # Returns + /// + /// The updated instance of [`ResourcePathResolvedSettingsBuilder`]. + pub fn with_restricted(mut self, value: bool, scope: DscSettingsScope) -> Self { + self.restricted = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + /// Builds the final instance of [`ResourcePathResolvedSettings`] from the builder. + /// + /// # Returns + /// + /// The constructed instance of [`ResourcePathResolvedSettings`]. + pub fn build(self) -> ResourcePathResolvedSettings { + let mut resolved = ResourcePathResolvedSettings::default(); + if let Some(append_env_path) = self.append_env_path { + resolved.append_env_path = append_env_path; + } + if let Some(directories) = self.directories { + resolved.directories = directories; + } + if let Some(restricted) = self.restricted { + resolved.restricted = restricted; + } + + resolved + } +} + +/// Builder for constructing instances of [`TracingResolvedSettings`]. +pub struct TracingResolvedSettingsBuilder { + pub level: Option>, + pub format: Option>, +} + +impl TracingResolvedSettingsBuilder { + /// Creates a new instance of the builder with default values. + /// + /// # Returns + /// + /// The newly created instance of [`TracingResolvedSettingsBuilder`]. + pub fn new() -> Self { + Self { + level: None, + format: None, + } + } + + /// Adds the specified tracing level setting to the builder. + /// + /// # Arguments + /// + /// - `value` - The value to set for the tracing level setting. + /// - `scope` - The scope of the setting. + /// + /// # Returns + /// + /// The updated instance of [`TracingResolvedSettingsBuilder`]. + pub fn with_level(mut self, value: TracingLevelField, scope: DscSettingsScope) -> Self { + self.level = Some(DscSettingsResolvedField::new(value, scope)); + self + } + + /// Adds the specified tracing format setting to the builder. + /// + /// # Arguments + /// + /// - `value` - The value to set for the tracing format setting. + /// - `scope` - The scope of the setting. + /// + /// # Returns + /// + /// The updated instance of [`TracingResolvedSettingsBuilder`]. + pub fn with_format(mut self, value: TracingFormatField, scope: DscSettingsScope) -> Self { + self.format = Some(DscSettingsResolvedField::new(value, scope)); + self + } + /// Builds the final instance of [`TracingResolvedSettings`] from the builder. + /// + /// # Returns + /// + /// The constructed instance of [`TracingResolvedSettings`]. + pub fn build(self) -> TracingResolvedSettings { + let mut resolved = TracingResolvedSettings::default(); + if let Some(level) = self.level { + resolved.level = level; + } + if let Some(format) = self.format { + resolved.format = format; + } + + resolved + } +} + +/// Builder for constructing instances of [`DscSettingsResolved`]. +pub struct DscSettingsResolvedBuilder { + pub forbid_ignore_settings_file: Option>, + pub ignore_settings_file: Option>, + pub tracing: Option, + pub resource_path: Option, +} + +impl DscSettingsResolvedBuilder { + /// Creates a new instance of the builder with default values. + /// + /// # Returns + /// + /// The newly created instance of [`DscSettingsResolvedBuilder`]. + pub fn new() -> Self { + Self { + forbid_ignore_settings_file: None, + ignore_settings_file: None, + tracing: None, + resource_path: None, + } + } + + /// Adds the specified forbid ignore settings file setting to the builder. + /// + /// # Arguments + /// + /// - `value` - The value to set for the forbid ignore settings file setting. + /// - `scope` - The scope of the setting. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsResolvedBuilder`]. + pub fn with_forbid_ignore_settings_file(mut self, value: bool, scope: DscSettingsScope) -> Self { + self.forbid_ignore_settings_file = Some(DscSettingsResolvedField::new(value.into(), scope)); + self + } + + /// Adds the specified ignore settings file setting to the builder. + /// + /// # Arguments + /// + /// - `value` - The value to set for the ignore settings file setting. + /// - `scope` - The scope of the setting. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsResolvedBuilder`]. + pub fn with_ignore_settings_file(mut self, value: bool, scope: DscSettingsScope) -> Self { + self.ignore_settings_file = Some(DscSettingsResolvedField::new(value.into(), scope)); + self + } + + /// Adds the specified tracing settings to the builder. + /// + /// # Arguments + /// + /// - `tracing` - The tracing settings to set. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsResolvedBuilder`]. + pub fn with_tracing(mut self, tracing: TracingResolvedSettings) -> Self { + self.tracing = Some(tracing); + self + } + + /// Adds the specified resource path settings to the builder. + /// + /// # Arguments + /// + /// - `resource_path` - The resource path settings to set. + /// + /// # Returns + /// + /// The updated instance of [`DscSettingsResolvedBuilder`]. + pub fn with_resource_path(mut self, resource_path: ResourcePathResolvedSettings) -> Self { + self.resource_path = Some(resource_path); + self + } + /// Builds the final instance of [`DscSettingsResolved`] using the settings provided to the builder. + /// + /// # Returns + /// + /// The constructed instance of [`DscSettingsResolved`]. + pub fn build(self) -> DscSettingsResolved { + let mut settings = DscSettingsResolved::default(); + if let Some(forbid_ignore_settings_file) = self.forbid_ignore_settings_file { + settings.forbid_ignore_settings_file = forbid_ignore_settings_file; + } + if let Some(ignore_settings_file) = self.ignore_settings_file { + settings.ignore_settings_file = ignore_settings_file; + } + if let Some(tracing) = self.tracing { + settings.tracing = tracing; + } + if let Some(resource_path) = self.resource_path { + settings.resource_path = resource_path; + } + + settings + } +} diff --git a/lib/dsc-lib/tests/integration/settings/dsc_settings.rs b/lib/dsc-lib/tests/integration/settings/dsc_settings.rs new file mode 100644 index 000000000..dd78bf7e2 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/dsc_settings.rs @@ -0,0 +1,540 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +pub(super) mod fixtures { + //! Defines test fixtures for the integration tests. + + use dsc_lib::settings::sources::*; + use dsc_lib::settings::fields::*; + use std::path::PathBuf; + use std::sync::LazyLock; + + use super::super::builders::*; + + /// Defines policy data with the following settings: + /// + /// - `forbid_ignore_settings_file`: `true` + /// - `resource_path.append_env_path`: `false` + /// - `resource_path.directories`: `["/etc/dsc"]` + /// - `resource_path.restrict_path`: `true` + /// + /// It doesn't define any values for `tracing`. + pub(super) static POLICY_DATA: LazyLock = LazyLock::new(|| { + PolicyFileDataBuilder::new() + .with_forbid_ignore_settings_file(true) + .with_resource_path( + ResourcePathFileDataBuilder::new() + .with_append_env_path(false) + .with_directories(vec!["/etc/dsc"]) + .with_restricted(true) + .build() + ) + .build() + }); + + /// Defines machine data with the following settings: + /// + /// - `tracing.level`: `warn` + /// - `resource_path.append_env_path`: `true` + /// - `resource_path.directories`: `["/usr/local/bin"]` + pub(super) static MACHINE_DATA: LazyLock = LazyLock::new(|| { + PreferenceFileDataBuilder::new() + .with_tracing( + TracingFileDataBuilder::new() + .with_level(TracingLevelField::Warn) + .build() + ) + .with_resource_path( + ResourcePathFileDataBuilder::new() + .with_append_env_path(true) + .with_directories(vec!["/usr/local/bin"]) + .build() + ) + .build() + }); + + /// Defines workspace data with the following settings: + /// + /// - `tracing.level`: `warn` + /// - `resource_path.directories`: `["~/infra/dsc/resources", "~/infra/dsc/extensions"]` + pub(super) static WORKSPACE_DATA: LazyLock = LazyLock::new(|| { + PreferenceFileDataBuilder::new() + .with_tracing( + TracingFileDataBuilder::new() + .with_level(TracingLevelField::Warn) + .build() + ) + .with_resource_path( + ResourcePathFileDataBuilder::new() + .with_directories(vec![ + "~/infra/dsc/resources", + "~/infra/dsc/extensions" + ]) + .build() + ) + .build() + }); + + /// Defines user data with the following settings: + /// + /// - `tracing.level`: `debug` + pub(super) static USER_DATA: LazyLock = LazyLock::new(|| { + PreferenceFileDataBuilder::new() + .with_tracing( + TracingFileDataBuilder::new() + .with_level(TracingLevelField::Debug) + .build() + ) + .build() + }); + + /// Defines environment data with the following settings: + /// + /// - `dsc_resource_path`: `["/usr/bin"]` + /// - `dsc_ignore_settings_file`: `true` + /// - `dsc_trace_level`: `info` + pub(super) static ENVIRONMENT_DATA: LazyLock = LazyLock::new(|| { + EnvironmentDataBuilder::new() + .with_resource_path(vec![PathBuf::from("/usr/bin")]) + .with_ignore_settings_file(true) + .with_trace_level(TracingLevelField::Info) + .build() + }); + + pub(super) static COMMAND_LINE_DATA: LazyLock = LazyLock::new(|| { + CommandLineDataBuilder::new() + .with_ignore_settings_file(false) + .with_trace_level(TracingLevelField::Debug) + .build() + }); +} + +mod methods { + use super::fixtures::*; + use super::super::builders::*; + use dsc_lib::settings::{DscSettings, DscSettingsScope, fields::*}; + + #[test] fn new() { + let actual = DscSettings::new(); + pretty_assertions::assert_eq!(actual.policy, None); + pretty_assertions::assert_eq!(actual.machine, None); + pretty_assertions::assert_eq!(actual.user, None); + pretty_assertions::assert_eq!(actual.environment, None); + pretty_assertions::assert_eq!(actual.command_line, None); + } + + #[test] fn new_with_command_line() { + let cli_data = COMMAND_LINE_DATA.clone(); + let actual = DscSettingsBuilder::new() + .with_command_line(cli_data) + .build(); + + pretty_assertions::assert_eq!(actual.machine, None); + pretty_assertions::assert_eq!(actual.user, None); + pretty_assertions::assert_eq!(actual.environment, None); + pretty_assertions::assert_eq!(actual.command_line, Some(COMMAND_LINE_DATA.clone())); + pretty_assertions::assert_eq!(actual.policy, None); + } + mod resolved { + use super::*; + #[test] fn with_code_defaults_only() { + let settings = &mut DscSettingsBuilder::new().build(); + let expected = DscSettingsResolvedBuilder::new().build(); + let actual = settings.resolved().clone(); + pretty_assertions::assert_eq!(actual, expected) + } + + #[test] fn machine_overrides_code_defaults() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .build(); + let expected = DscSettingsResolvedBuilder::new() + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(true, DscSettingsScope::Machine) + .with_directories(vec!["/usr/local/bin"], DscSettingsScope::Machine) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TracingLevelField::Warn, DscSettingsScope::Machine) + .build() + ) + .build(); + let actual = settings.resolved().clone(); + pretty_assertions::assert_eq!(actual, expected) + } + + #[test] fn user_overrides_machine() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .build(); + let expected = DscSettingsResolvedBuilder::new() + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(true, DscSettingsScope::Machine) + .with_directories(vec!["/usr/local/bin"], DscSettingsScope::Machine) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TracingLevelField::Debug, DscSettingsScope::User) + .build() + ) + .build(); + let actual = settings.resolved().clone(); + pretty_assertions::assert_eq!(actual, expected) + } + + #[test] fn workspace_overrides_user() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_workspace(WORKSPACE_DATA.clone()) + .build(); + let expected = DscSettingsResolvedBuilder::new() + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(true, DscSettingsScope::Machine) + .with_directories( + vec!["~/infra/dsc/resources", "~/infra/dsc/extensions"], + DscSettingsScope::Workspace + ) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TracingLevelField::Warn, DscSettingsScope::Workspace) + .build() + ) + .build(); + let actual = settings.resolved().clone(); + pretty_assertions::assert_eq!(actual, expected) + } + + #[test] fn environment_overrides_workspace() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_workspace(WORKSPACE_DATA.clone()) + .with_environment(ENVIRONMENT_DATA.clone()) + .build(); + let expected = DscSettingsResolvedBuilder::new() + .with_ignore_settings_file(true, DscSettingsScope::Environment) + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_directories( + vec!["/usr/bin"], + DscSettingsScope::Environment + ) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TracingLevelField::Info, DscSettingsScope::Environment) + .build() + ) + .build(); + let actual = settings.resolved().clone(); + pretty_assertions::assert_eq!(actual, expected) + } + + #[test] fn command_line_overrides_environment() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_workspace(WORKSPACE_DATA.clone()) + .with_environment(ENVIRONMENT_DATA.clone()) + .with_command_line(COMMAND_LINE_DATA.clone()) + .build(); + let expected = DscSettingsResolvedBuilder::new() + .with_ignore_settings_file(false, DscSettingsScope::CommandLine) + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(true, DscSettingsScope::Machine) + .with_directories( + vec!["/usr/bin"], + DscSettingsScope::Environment + ) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TracingLevelField::Debug, DscSettingsScope::CommandLine) + .build() + ) + .build(); + let actual = settings.resolved().clone(); + pretty_assertions::assert_eq!(actual, expected) + } + + #[test] fn policy_overrides_all() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_workspace(WORKSPACE_DATA.clone()) + .with_environment(ENVIRONMENT_DATA.clone()) + .with_command_line(COMMAND_LINE_DATA.clone()) + .with_policy(POLICY_DATA.clone()) + .build(); + let expected = DscSettingsResolvedBuilder::new() + .with_forbid_ignore_settings_file(true, DscSettingsScope::Policy) + .with_ignore_settings_file(false, DscSettingsScope::CommandLine) + .with_resource_path( + ResourcePathResolvedSettingsBuilder::new() + .with_append_env_path(false, DscSettingsScope::Policy) + .with_directories(vec!["/etc/dsc"], DscSettingsScope::Policy) + .with_restricted(true, DscSettingsScope::Policy) + .build() + ) + .with_tracing( + TracingResolvedSettingsBuilder::new() + .with_level(TracingLevelField::Debug, DscSettingsScope::CommandLine) + .with_format(TracingFormatField::Default, DscSettingsScope::Default) + .build() + ) + .build(); + let actual = settings.resolved().clone(); + pretty_assertions::assert_eq!(actual, expected) + } + } + + mod policy_forbids_ignoring_settings_files { + use super::*; + + #[test] fn without_policy_returns_false() { + let settings = &mut DscSettingsBuilder::new().build(); + let expected = false; + let actual = settings.policy_forbids_ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + #[test] fn with_policy_field_undefined_returns_false() { + let settings = DscSettingsBuilder::new() + .with_policy( + PolicyFileDataBuilder::new() + .with_resource_path( + ResourcePathFileDataBuilder::new() + .with_append_env_path(false) + .with_directories(vec!["/etc/dsc"]) + .with_restricted(true) + .build() + ) + .build() + ) + .build(); + let expected = false; + let actual = settings.policy_forbids_ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + #[test] fn with_policy_field_false_returns_false() { + let settings = DscSettingsBuilder::new() + .with_policy( + PolicyFileDataBuilder::new() + .with_forbid_ignore_settings_file(false) + .build() + ) + .build(); + let expected = false; + let actual = settings.policy_forbids_ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn with_policy_field_true_returns_true() { + let settings = DscSettingsBuilder::new() + .with_policy( + PolicyFileDataBuilder::new() + .with_forbid_ignore_settings_file(true) + .build() + ) + .build(); + let expected = true; + let actual = settings.policy_forbids_ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + } + + mod ignoring_settings_files { + use super::*; + + #[test] fn code_defaults_only_returns_false() { + let settings = DscSettingsBuilder::new().build(); + let expected = false; + let actual = settings.ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn with_env_var_true_without_policy_or_cli_returns_true() { + let settings = DscSettingsBuilder::new() + .with_environment( + EnvironmentDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build(); + let expected = true; + let actual = settings.ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn with_env_var_false_without_policy_or_cli_returns_false() { + let settings = DscSettingsBuilder::new() + .with_environment( + EnvironmentDataBuilder::new() + .with_ignore_settings_file(false) + .build() + ) + .build(); + let expected = false; + let actual = settings.ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn with_cli_arg_true_without_policy_or_env_var_returns_true() { + let settings = DscSettingsBuilder::new() + .with_command_line( + CommandLineDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build(); + let expected = true; + let actual = settings.ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn with_policy_forbidding_and_env_var_true_returns_false() { + let settings = DscSettingsBuilder::new() + .with_policy( + PolicyFileDataBuilder::new() + .with_forbid_ignore_settings_file(true) + .build() + ) + .with_environment( + EnvironmentDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build(); + let expected = false; + let actual = settings.ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn with_policy_forbidding_and_cli_arg_true_returns_false() { + let settings = DscSettingsBuilder::new() + .with_policy( + PolicyFileDataBuilder::new() + .with_forbid_ignore_settings_file(true) + .build() + ) + .with_command_line( + CommandLineDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build(); + let expected = false; + let actual = settings.ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + + #[test] fn with_policy_field_true_returns_true() { + let settings = DscSettingsBuilder::new() + .with_policy( + PolicyFileDataBuilder::new() + .with_ignore_settings_file(true) + .build() + ) + .build(); + let expected = true; + let actual = settings.ignoring_settings_files(); + pretty_assertions::assert_eq!(actual, expected); + } + } + + #[test] fn resolve_all() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_environment(ENVIRONMENT_DATA.clone()) + .with_command_line(COMMAND_LINE_DATA.clone()) + .with_policy(POLICY_DATA.clone()) + .build(); + + settings.resolve_all(); + } + + // No testing for load() or try_load() - those are handled in the unit tests + // at `src/tests/settings/dsc_settings.rs` to use the stubs for reading files + // and environment variables. +} + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::DscSettings, + properties: { + DEFAULT_SCHEMA => "default", + MACHINE_SCHEMA => "machine", + USER_SCHEMA => "user", + WORKSPACE_SCHEMA => "workspace", + ENVIRONMENT_SCHEMA => "environment", + COMMAND_LINE_SCHEMA => "cli", + POLICY_SCHEMA => "policy", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + default_title => (DEFAULT_SCHEMA, "title"), + default_description => (DEFAULT_SCHEMA, "description"), + default_markdown_description => (DEFAULT_SCHEMA, "markdownDescription"), + machine_title => (MACHINE_SCHEMA, "title"), + machine_description => (MACHINE_SCHEMA, "description"), + machine_markdown_description => (MACHINE_SCHEMA, "markdownDescription"), + user_title => (USER_SCHEMA, "title"), + user_description => (USER_SCHEMA, "description"), + user_markdown_description => (USER_SCHEMA, "markdownDescription"), + workspace_title => (WORKSPACE_SCHEMA, "title"), + workspace_description => (WORKSPACE_SCHEMA, "description"), + workspace_markdown_description => (WORKSPACE_SCHEMA, "markdownDescription"), + environment_title => (ENVIRONMENT_SCHEMA, "title"), + environment_description => (ENVIRONMENT_SCHEMA, "description"), + environment_markdown_description => (ENVIRONMENT_SCHEMA, "markdownDescription"), + command_line_title => (COMMAND_LINE_SCHEMA, "title"), + command_line_description => (COMMAND_LINE_SCHEMA, "description"), + command_line_markdown_description => (COMMAND_LINE_SCHEMA, "markdownDescription"), + policy_title => (POLICY_SCHEMA, "title"), + policy_description => (POLICY_SCHEMA, "description"), + policy_markdown_description => (POLICY_SCHEMA, "markdownDescription"), + }, + test_validation: { + with_only_code_defaults_is_valid => { + input_json: json!({ + "default": dsc_lib::settings::sources::DSC_SETTINGS_CODE_DEFAULTS, + }), + expected_valid: true, + } + } +} + +mod serde { + use super::super::builders::*; + use super::fixtures::*; + #[test] fn serializing() { + let settings = &mut DscSettingsBuilder::new() + .with_machine(MACHINE_DATA.clone()) + .with_user(USER_DATA.clone()) + .with_environment(ENVIRONMENT_DATA.clone()) + .with_command_line(COMMAND_LINE_DATA.clone()) + .with_policy(POLICY_DATA.clone()) + .build(); + + settings.resolve_all(); + + serde_json::to_value(settings) + .expect("serialization should never fail"); + } +} diff --git a/lib/dsc-lib/tests/integration/settings/dsc_settings_scope.rs b/lib/dsc-lib/tests/integration/settings/dsc_settings_scope.rs new file mode 100644 index 000000000..168d2a3fc --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/dsc_settings_scope.rs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use dsc_lib::settings::DscSettingsScope; + +#[cfg(test)] +mod methods { + use super::*; + use dsc_lib::settings::DscSettingsError; + use test_case::test_case; + + // Minimal testing, see `traits::from_str` for more comprehensive tests. + #[test_case("default" => matches Ok(DscSettingsScope::Default); "default scope is valid")] + #[test_case("machine" => matches Ok(DscSettingsScope::Machine); "machine scope is valid")] + #[test_case("user" => matches Ok(DscSettingsScope::User); "user scope is valid")] + #[test_case("workspace" => matches Ok(DscSettingsScope::Workspace); "workspace scope is valid")] + #[test_case("environment" => matches Ok(DscSettingsScope::Environment); "environment scope is valid")] + #[test_case("cli" => matches Ok(DscSettingsScope::CommandLine); "command line scope is valid")] + #[test_case("policy" => matches Ok(DscSettingsScope::Policy); "policy scope is valid")] + #[test_case("unknown" => matches Err(DscSettingsError::InvalidScope{ text: _ }); "unknown scope is invalid")] + #[test_case("" => matches Err(DscSettingsError::InvalidScope{ text: _ }); "empty string is invalid")] + #[test_case(" " => matches Err(DscSettingsError::InvalidScope{ text: _ }); "whitespace string is invalid")] + fn parse(input: &str) -> Result { + DscSettingsScope::parse(input) + } + + #[test_case(DscSettingsScope::Machine => true; "machine scope is")] + #[test_case(DscSettingsScope::User => true; "user scope is")] + #[test_case(DscSettingsScope::Workspace => true; "workspace scope is")] + #[test_case(DscSettingsScope::Default => false; "default scope is not")] + #[test_case(DscSettingsScope::Environment => false; "environment scope is not")] + #[test_case(DscSettingsScope::CommandLine => false; "command line scope is not")] + #[test_case(DscSettingsScope::Policy => true; "policy scope is")] + fn is_file_based(scope: DscSettingsScope) -> bool { + scope.is_file_based() + } + + #[test_case(DscSettingsScope::Machine => true; "machine scope is")] + #[test_case(DscSettingsScope::User => true; "user scope is")] + #[test_case(DscSettingsScope::Workspace => true; "workspace scope is")] + #[test_case(DscSettingsScope::Default => false; "default scope is not")] + #[test_case(DscSettingsScope::Environment => false; "environment scope is not")] + #[test_case(DscSettingsScope::CommandLine => false; "command line scope is not")] + #[test_case(DscSettingsScope::Policy => false; "policy scope is not")] + fn is_preference(scope: DscSettingsScope) -> bool { + scope.is_preference() + } + + #[test_case(DscSettingsScope::Machine => false; "machine scope is not")] + #[test_case(DscSettingsScope::User => false; "user scope is not")] + #[test_case(DscSettingsScope::Workspace => false; "workspace scope is not")] + #[test_case(DscSettingsScope::Default => false; "default scope is not")] + #[test_case(DscSettingsScope::Environment => false; "environment scope is not")] + #[test_case(DscSettingsScope::CommandLine => false; "command line scope is not")] + #[test_case(DscSettingsScope::Policy => true; "policy scope is")] + fn is_policy(scope: DscSettingsScope) -> bool { + scope.is_policy() + } +} + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::DscSettingsScope + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + markdown_enum_descriptions => "markdownEnumDescriptions" + }, + test_validation: { + lower_case_default_is_valid => { + input_json: json!("default"), + expected_valid: true, + }, + upper_case_default_is_invalid => { + input_json: json!("DEFAULT"), + expected_valid: false, + }, + mixed_case_default_is_invalid => { + input_json: json!("DeFaUlT"), + expected_valid: false, + }, + lower_case_machine_is_valid => { + input_json: json!("machine"), + expected_valid: true, + }, + upper_case_machine_is_invalid => { + input_json: json!("MACHINE"), + expected_valid: false, + }, + mixed_case_machine_is_invalid => { + input_json: json!("MaChInE"), + expected_valid: false, + }, + lower_case_user_is_valid => { + input_json: json!("user"), + expected_valid: true, + }, + upper_case_user_is_invalid => { + input_json: json!("USER"), + expected_valid: false, + }, + mixed_case_user_is_invalid => { + input_json: json!("UsEr"), + expected_valid: false, + }, + lower_case_workspace_is_valid => { + input_json: json!("workspace"), + expected_valid: true, + }, + upper_case_workspace_is_invalid => { + input_json: json!("WORKSPACE"), + expected_valid: false, + }, + mixed_case_workspace_is_invalid => { + input_json: json!("WoRkSpAcE"), + expected_valid: false, + }, + lower_case_environment_is_valid => { + input_json: json!("environment"), + expected_valid: true, + }, + upper_case_environment_is_invalid => { + input_json: json!("ENVIRONMENT"), + expected_valid: false, + }, + mixed_case_environment_is_invalid => { + input_json: json!("EnViRoNmEnT"), + expected_valid: false, + }, + lower_case_cli_is_valid => { + input_json: json!("cli"), + expected_valid: true, + }, + upper_case_cli_is_invalid => { + input_json: json!("CLI"), + expected_valid: false, + }, + mixed_case_cli_is_invalid => { + input_json: json!("ClI"), + expected_valid: false, + }, + lower_case_policy_is_valid => { + input_json: json!("policy"), + expected_valid: true, + }, + upper_case_policy_is_invalid => { + input_json: json!("POLICY"), + expected_valid: false, + }, + mixed_case_policy_is_invalid => { + input_json: json!("PoLiCy"), + expected_valid: false, + }, + unknown_scope_string_is_invalid => { + input_json: json!("unknown"), + expected_valid: false, + }, + empty_string_is_invalid => { + input_json: json!(""), + expected_valid: false, + }, + null_value_is_invalid => { + input_json: json!(null), + expected_valid: false, + }, + integer_value_is_invalid => { + input_json: json!(42), + expected_valid: false, + }, + number_value_is_invalid => { + input_json: json!(3.14), + expected_valid: false, + }, + boolean_value_is_invalid => { + input_json: json!(true), + expected_valid: false, + }, + array_value_is_invalid => { + input_json: json!(["default"]), + expected_valid: false, + }, + object_value_is_invalid => { + input_json: json!({"scope": "default"}), + expected_valid: false, + }, + } +} + +#[cfg(test)] +mod serde { + use test_case::test_case; + use serde_json::{json, Value}; + + use super::*; + + #[test_case(&DscSettingsScope::Default, json!("default"); "default scope")] + #[test_case(&DscSettingsScope::Machine, json!("machine"); "machine scope")] + #[test_case(&DscSettingsScope::User, json!("user"); "user scope")] + #[test_case(&DscSettingsScope::Workspace, json!("workspace"); "workspace scope")] + #[test_case(&DscSettingsScope::Environment, json!("environment"); "environment scope")] + #[test_case(&DscSettingsScope::CommandLine, json!("cli"); "command line scope")] + #[test_case(&DscSettingsScope::Policy, json!("policy"); "policy scope")] + fn serializing(scope: &DscSettingsScope, expected: Value) { + let actual = serde_json::to_value(scope) + .expect("serialization should never fail"); + + pretty_assertions::assert_eq!(actual, expected); + } + + #[test_case(vec!["default", "Default", "DEFAULT"], &DscSettingsScope::Default; "default scope")] + #[test_case(vec!["machine", "Machine", "MACHINE"], &DscSettingsScope::Machine; "machine scope")] + #[test_case(vec!["user", "User", "USER"], &DscSettingsScope::User; "user scope")] + #[test_case(vec!["workspace", "Workspace", "WORKSPACE"], &DscSettingsScope::Workspace; "workspace scope")] + #[test_case(vec!["environment", "Environment", "ENVIRONMENT"], &DscSettingsScope::Environment; "environment scope")] + #[test_case(vec!["cli", "Cli", "CLI"], &DscSettingsScope::CommandLine; "command line scope")] + #[test_case(vec!["policy", "Policy", "POLICY"], &DscSettingsScope::Policy; "policy scope")] + fn deserializing_valid(values: Vec<&str>, expected: &DscSettingsScope) { + for value in values { + let json = json!(value); + match serde_json::from_value::(json) { + Ok(actual_scope) => pretty_assertions::assert_eq!(&actual_scope, expected), + Err(err) => panic!("Expected '{value}' to parse as {expected}, but got error: {err}"), + } + } + } + + #[test_case(json!("unknown"); "unknown string variant")] + #[test_case(json!(""); "empty string")] + #[test_case(json!(" "); "whitespace string")] + #[test_case(json!(null); "null value")] + #[test_case(json!(123); "numeric value")] + #[test_case(json!(["default"]); "array value")] + #[test_case(json!({ "scope": "default" }); "object value")] + fn deserializing_invalid(value: Value) { + assert!(serde_json::from_value::(value).is_err()); + } +} + +#[cfg(test)] +mod traits { + use super::*; + + #[cfg(test)] + mod display { + use super::*; + use test_case::test_case; + + #[test_case(DscSettingsScope::Default => "default"; "default scope")] + #[test_case(DscSettingsScope::Machine => "machine"; "machine scope")] + #[test_case(DscSettingsScope::User => "user"; "user scope")] + #[test_case(DscSettingsScope::Workspace => "workspace"; "workspace scope")] + #[test_case(DscSettingsScope::Environment => "environment"; "environment scope")] + #[test_case(DscSettingsScope::CommandLine => "cli"; "command line scope")] + #[test_case(DscSettingsScope::Policy => "policy"; "policy scope")] + fn to_string(scope: DscSettingsScope) -> String { + scope.to_string() + } + } + + #[cfg(test)] + mod from_str { + use super::*; + use dsc_lib::settings::DscSettingsError; + use test_case::test_case; + + #[test_case(vec!["default", "Default", "DEFAULT"], DscSettingsScope::Default; "default scope")] + #[test_case(vec!["machine", "Machine", "MACHINE"], DscSettingsScope::Machine; "machine scope")] + #[test_case(vec!["user", "User", "USER"], DscSettingsScope::User; "user scope")] + #[test_case(vec!["workspace", "Workspace", "WORKSPACE"], DscSettingsScope::Workspace; "workspace scope")] + #[test_case(vec!["environment", "Environment", "ENVIRONMENT"], DscSettingsScope::Environment; "environment scope")] + #[test_case(vec!["cli", "Cli", "CLI"], DscSettingsScope::CommandLine; "command line scope")] + #[test_case(vec!["policy", "Policy", "POLICY"], DscSettingsScope::Policy; "policy scope")] + fn parse_valid(inputs: Vec<&str>, expected: DscSettingsScope) { + for s in inputs { + let actual = s.parse::().expect("should parse"); + assert_eq!(actual, expected); + } + } + + #[test_case("invalid"; "invalid_scope")] + #[test_case(""; "empty_string")] + #[test_case(" "; "whitespace_string")] + #[test_case("1"; "numeric_string")] + fn parse_invalid(input: &str) { + let err = input.parse::().expect_err("shouldn't parse"); + assert!(matches!(err, DscSettingsError::InvalidScope { text } if text == input)); + } + } + + #[cfg(test)] + mod partial_eq { + use super::*; + use test_case::test_case; + + #[test_case(DscSettingsScope::Default, "Default"; "default scope")] + #[test_case(DscSettingsScope::Machine, "Machine"; "machine scope")] + #[test_case(DscSettingsScope::User, "User"; "user scope")] + #[test_case(DscSettingsScope::Workspace, "Workspace"; "workspace scope")] + #[test_case(DscSettingsScope::Environment, "Environment"; "environment scope")] + #[test_case(DscSettingsScope::CommandLine, "Cli"; "command line scope")] + #[test_case(DscSettingsScope::Policy, "Policy"; "policy scope")] + fn dsc_settings_scope_eq_str(scope: DscSettingsScope, s: &str) { + assert!(scope == s); + assert!(scope == s.to_lowercase().as_ref()); + assert!(scope == s.to_uppercase().as_ref()); + assert!(scope != "unknown"); + assert!(scope != ""); + } + + #[test_case("Default", DscSettingsScope::Default; "default scope")] + #[test_case("Machine", DscSettingsScope::Machine; "machine scope")] + #[test_case("User", DscSettingsScope::User; "user scope")] + #[test_case("Workspace", DscSettingsScope::Workspace; "workspace scope")] + #[test_case("Environment", DscSettingsScope::Environment; "environment scope")] + #[test_case("Cli", DscSettingsScope::CommandLine; "command line scope")] + #[test_case("Policy", DscSettingsScope::Policy; "policy scope")] + fn str_eq_dsc_settings_scope(s: &str, scope: DscSettingsScope) { + assert!(s == scope); + assert!(s.to_lowercase().as_ref() == scope); + assert!(s.to_uppercase().as_ref() == scope); + assert!("unknown" != scope); + assert!("" != scope); + } + + #[test_case(DscSettingsScope::Default, "Default".to_string(); "default scope")] + #[test_case(DscSettingsScope::Machine, "Machine".to_string(); "machine scope")] + #[test_case(DscSettingsScope::User, "User".to_string(); "user scope")] + #[test_case(DscSettingsScope::Workspace, "Workspace".to_string(); "workspace scope")] + #[test_case(DscSettingsScope::Environment, "Environment".to_string(); "environment scope")] + #[test_case(DscSettingsScope::CommandLine, "Cli".to_string(); "command line scope")] + #[test_case(DscSettingsScope::Policy, "Policy".to_string(); "policy scope")] + fn dsc_settings_scope_eq_string(scope: DscSettingsScope, s: String) { + assert!(scope == s); + assert!(scope == s.to_lowercase()); + assert!(scope == s.to_uppercase()); + assert!(scope != "unknown".to_string()); + assert!(scope != "".to_string()); + } + + #[test_case("Default".to_string(), DscSettingsScope::Default; "default scope")] + #[test_case("Machine".to_string(), DscSettingsScope::Machine; "machine scope")] + #[test_case("User".to_string(), DscSettingsScope::User; "user scope")] + #[test_case("Workspace".to_string(), DscSettingsScope::Workspace; "workspace scope")] + #[test_case("Environment".to_string(), DscSettingsScope::Environment; "environment scope")] + #[test_case("Cli".to_string(), DscSettingsScope::CommandLine; "command line scope")] + #[test_case("Policy".to_string(), DscSettingsScope::Policy; "policy scope")] + fn string_eq_dsc_settings_scope(s: String, scope: DscSettingsScope) { + assert!(s == scope); + assert!(s.to_lowercase() == scope); + assert!(s.to_uppercase() == scope); + assert!("unknown".to_string() != scope); + assert!("".to_string() != scope); + } + } +} diff --git a/lib/dsc-lib/tests/integration/settings/fields/forbid_ignore_settings_file.rs b/lib/dsc-lib/tests/integration/settings/fields/forbid_ignore_settings_file.rs new file mode 100644 index 000000000..fb7ae9c70 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/fields/forbid_ignore_settings_file.rs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::settings::fields::ForbidIgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn new(value: bool) { + let actual = ForbidIgnoreSettingsFileField::new(value); + pretty_assertions::assert_eq!(actual.as_ref(), &value); + } +} + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::fields::ForbidIgnoreSettingsFileField + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + markdown_enum_descriptions => "markdownEnumDescriptions" + }, + test_validation: { + boolean_true_is_valid => { + input_json: json!(true), + expected_valid: true, + }, + boolean_false_is_valid => { + input_json: json!(false), + expected_valid: true, + }, + null_is_invalid => { + input_json: json!(null), + expected_valid: false, + }, + empty_object_is_invalid => { + input_json: json!({}), + expected_valid: false, + }, + empty_array_is_invalid => { + input_json: json!([]), + expected_valid: false, + }, + number_is_invalid => { + input_json: json!(42), + expected_valid: false, + }, + string_is_invalid => { + input_json: json!("string"), + expected_valid: false, + }, + non_empty_object_is_invalid => { + input_json: json!({"key": true}), + expected_valid: false, + }, + non_empty_array_is_invalid => { + input_json: json!([true, false, true]), + expected_valid: false, + }, + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::settings::fields::ForbidIgnoreSettingsFileField; + use test_case::test_case; + use serde_json::{json, Value}; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn serializing_to_string(value: bool) { + let actual = serde_json::to_string(&ForbidIgnoreSettingsFileField::new(value)) + .expect("serialization should never fail"); + let expected = format!("{value}"); + assert_eq!(actual, expected); + } + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn serializing_to_value(value: bool) { + let actual = serde_json::to_value(&ForbidIgnoreSettingsFileField::new(value)) + .expect("serialization should never fail"); + let expected = Value::Bool(value); + assert_eq!(actual, expected); + } + + #[test_case(json!("true"); "string value")] + #[test_case(json!(1); "integer value")] + #[test_case(json!(1.5); "number value")] + #[test_case(json!([true]); "array value")] + #[test_case(json!({"key": true}); "object value")] + #[test_case(json!(null); "null value")] + fn deserializing_invalid(input_value: Value) { + let result = serde_json::from_value::(input_value.clone()); + assert!(result.is_err(), "expected deserialization to fail for input: {:?}", input_value); + } + + #[test_case(json!(true); "when true")] + #[test_case(json!(false); "when false")] + fn deserializing_valid(input_value: Value) { + let result = serde_json::from_value::(input_value.clone()); + assert!(result.is_ok(), "expected deserialization to succeed for input: {:?}", input_value); + } +} + +#[cfg(test)] +mod traits { + mod as_ref { + use dsc_lib::settings::fields::ForbidIgnoreSettingsFileField; + + #[test] + fn bool() { + let _: &bool = ForbidIgnoreSettingsFileField::new(true).as_ref(); + } + } + mod deref { + use dsc_lib::settings::fields::ForbidIgnoreSettingsFileField; + use std::ops::Deref; + + #[test] + fn bool() { + let f = ForbidIgnoreSettingsFileField::new(true); + let b = f.deref(); + pretty_assertions::assert_eq!(b, &true) + } + } + mod display { + use dsc_lib::settings::fields::ForbidIgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true, "true"; "when true")] + #[test_case(false, "false"; "when false")] + fn format(value: bool, expected: &str) { + pretty_assertions::assert_eq!( + format!("field: '{}'", ForbidIgnoreSettingsFileField::new(value)), + format!("field: '{}'", expected) + ) + } + } + mod from { + use dsc_lib::settings::fields::ForbidIgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn bool(value: bool) { + let actual = ForbidIgnoreSettingsFileField::from(value); + let expected = ForbidIgnoreSettingsFileField::new(value); + pretty_assertions::assert_eq!(actual, expected); + } + } + mod into { + use dsc_lib::settings::fields::ForbidIgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn bool(value: bool) { + let actual: bool = ForbidIgnoreSettingsFileField::new(value).into(); + let expected = value; + pretty_assertions::assert_eq!(actual, expected); + } + } + mod partial_eq { + use dsc_lib::settings::fields::ForbidIgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn eq_bool(value: bool) { + let field = ForbidIgnoreSettingsFileField::new(value); + pretty_assertions::assert_eq!(field == value, true); + pretty_assertions::assert_eq!(value == field, true); + } + } +} diff --git a/lib/dsc-lib/tests/integration/settings/fields/ignore_settings_file.rs b/lib/dsc-lib/tests/integration/settings/fields/ignore_settings_file.rs new file mode 100644 index 000000000..cb22bdb93 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/fields/ignore_settings_file.rs @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::settings::fields::IgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn new(value: bool) { + let actual = IgnoreSettingsFileField::new(value); + pretty_assertions::assert_eq!(actual.as_ref(), &value); + } +} + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::fields::IgnoreSettingsFileField + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + markdown_enum_descriptions => "markdownEnumDescriptions" + }, + test_validation: { + boolean_true_is_valid => { + input_json: json!(true), + expected_valid: true, + }, + boolean_false_is_valid => { + input_json: json!(false), + expected_valid: true, + }, + null_is_invalid => { + input_json: json!(null), + expected_valid: false, + }, + empty_object_is_invalid => { + input_json: json!({}), + expected_valid: false, + }, + empty_array_is_invalid => { + input_json: json!([]), + expected_valid: false, + }, + number_is_invalid => { + input_json: json!(42), + expected_valid: false, + }, + string_is_invalid => { + input_json: json!("string"), + expected_valid: false, + }, + non_empty_object_is_invalid => { + input_json: json!({"key": true}), + expected_valid: false, + }, + non_empty_array_is_invalid => { + input_json: json!([true, false, true]), + expected_valid: false, + } + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::settings::fields::IgnoreSettingsFileField; + use test_case::test_case; + use serde_json::{json, Value}; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn serializing_to_string(value: bool) { + let actual = serde_json::to_string(&IgnoreSettingsFileField::new(value)) + .expect("serialization should never fail"); + let expected = format!("{value}"); + assert_eq!(actual, expected); + } + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn serializing_to_value(value: bool) { + let actual = serde_json::to_value(&IgnoreSettingsFileField::new(value)) + .expect("serialization should never fail"); + let expected = Value::Bool(value); + assert_eq!(actual, expected); + } + + #[test_case(json!("true"); "string value")] + #[test_case(json!(1); "integer value")] + #[test_case(json!(1.5); "number value")] + #[test_case(json!([true]); "array value")] + #[test_case(json!({"key": true}); "object value")] + #[test_case(json!(null); "null value")] + fn deserializing_invalid(input_value: Value) { + let result = serde_json::from_value::(input_value.clone()); + assert!(result.is_err(), "expected deserialization to fail for input: {:?}", input_value); + } + + #[test_case(json!(true); "when true")] + #[test_case(json!(false); "when false")] + fn deserializing_valid(input_value: Value) { + let result = serde_json::from_value::(input_value.clone()); + assert!(result.is_ok(), "expected deserialization to succeed for input: {:?}", input_value); + } +} + +#[cfg(test)] +mod traits { + mod as_ref { + use dsc_lib::settings::fields::IgnoreSettingsFileField; + + #[test] + fn bool() { + let _: &bool = IgnoreSettingsFileField::new(true).as_ref(); + } + } + mod deref { + use dsc_lib::settings::fields::IgnoreSettingsFileField; + use std::ops::Deref; + + #[test] + fn bool() { + let f = IgnoreSettingsFileField::new(true); + let b = f.deref(); + pretty_assertions::assert_eq!(b, &true) + } + } + mod display { + use dsc_lib::settings::fields::IgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true, "true"; "when true")] + #[test_case(false, "false"; "when false")] + fn format(value: bool, expected: &str) { + pretty_assertions::assert_eq!( + format!("field: '{}'", IgnoreSettingsFileField::new(value)), + format!("field: '{}'", expected) + ) + } + } + mod from { + use dsc_lib::settings::fields::IgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn bool(value: bool) { + let actual = IgnoreSettingsFileField::from(value); + let expected = IgnoreSettingsFileField::new(value); + pretty_assertions::assert_eq!(actual, expected); + } + } + mod into { + use dsc_lib::settings::fields::IgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn bool(value: bool) { + let actual: bool = IgnoreSettingsFileField::new(value).into(); + let expected = value; + pretty_assertions::assert_eq!(actual, expected); + } + } + mod partial_eq { + use dsc_lib::settings::fields::IgnoreSettingsFileField; + use test_case::test_case; + + #[test_case(true; "when true")] + #[test_case(false; "when false")] + fn eq_bool(value: bool) { + let field = IgnoreSettingsFileField::new(value); + pretty_assertions::assert_eq!(field == value, true); + pretty_assertions::assert_eq!(value == field, true); + } + } +} diff --git a/lib/dsc-lib/tests/integration/settings/fields/resource_path.rs b/lib/dsc-lib/tests/integration/settings/fields/resource_path.rs new file mode 100644 index 000000000..85c1788c1 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/fields/resource_path.rs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::fields::ResourcePathFileData, + properties: { + DIRECTORIES_SCHEMA => "directories", + APPEND_ENV_PATH_SCHEMA => "appendEnvPath", + RESTRICTED_SCHEMA => "restricted", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + directories_title => (DIRECTORIES_SCHEMA, "title"), + directories_description => (DIRECTORIES_SCHEMA, "description"), + directories_markdown_description => (DIRECTORIES_SCHEMA, "markdownDescription"), + append_env_path_title => (APPEND_ENV_PATH_SCHEMA, "title"), + append_env_path_description => (APPEND_ENV_PATH_SCHEMA, "description"), + append_env_path_markdown_description => (APPEND_ENV_PATH_SCHEMA, "markdownDescription"), + restricted_title => (RESTRICTED_SCHEMA, "title"), + restricted_description => (RESTRICTED_SCHEMA, "description"), + restricted_markdown_description => (RESTRICTED_SCHEMA, "markdownDescription") + }, + test_validation: { + empty_object_is_valid => { + input_json: json!({}), + expected_valid: true, + }, + with_just_directories_is_valid => { + input_json: json!({"directories": ["/first", "/second"]}), + expected_valid: true, + }, + with_just_append_env_path_is_valid => { + input_json: json!({"appendEnvPath": true}), + expected_valid: true, + }, + with_just_restricted_is_valid => { + input_json: json!({"restricted": true}), + expected_valid: true, + }, + with_directories_and_append_env_path_fields_is_valid => { + input_json: json!({ + "directories": ["/first", "/second"], + "appendEnvPath": true + }), + expected_valid: true, + }, + with_directories_and_restricted_fields_is_valid => { + input_json: json!({ + "directories": ["/first", "/second"], + "restricted": true + }), + expected_valid: true, + }, + with_append_env_path_as_false_and_restricted_as_true_is_valid => { + input_json: json!({ + "appendEnvPath": false, + "restricted": true + }), + expected_valid: true, + }, + with_append_env_path_as_true_and_restricted_as_false_is_valid => { + input_json: json!({ + "appendEnvPath": true, + "restricted": false + }), + expected_valid: true, + }, + with_append_env_path_as_true_and_restricted_as_true_is_invalid => { + input_json: json!({ + "appendEnvPath": true, + "restricted": true + }), + expected_valid: false, + }, + with_an_unexpected_field_is_invalid => { + input_json: json!({ + "unexpectedField": true + }), + expected_valid: false, + } + }, +} + +#[cfg(test)] +mod serde { + use std::path::PathBuf; + + use dsc_lib::settings::fields::ResourcePathFileData; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case( + ResourcePathFileData::default() => json!({}); + "default serializes as empty object" + )] + #[test_case( + ResourcePathFileData { + directories: Some(vec![]), + ..Default::default() + } => json!({ + "directories": [] + }); "directories as an empty vec serializes as an empty array" + )] + #[test_case( + ResourcePathFileData { + directories: Some(vec![PathBuf::from("/foo"), PathBuf::from("/bar")]), + ..Default::default() + } => json!({ + "directories": ["/foo", "/bar"] + }); "directories as vec of pathbuf serializes as array of strings" + )] + #[test_case( + ResourcePathFileData { + append_env_path: Some(true), + ..Default::default() + } => json!({ + "appendEnvPath": true + }); "append_env_path serializes as boolean" + )] + #[test_case( + ResourcePathFileData { + restricted: Some(true), + ..Default::default() + } => json!({ + "restricted": true + }); "restricted serializes as boolean" + )] + #[test_case( + ResourcePathFileData { + directories: Some(vec![PathBuf::from("/foo"), PathBuf::from("/bar")]), + append_env_path: Some(false), + restricted: Some(false), + } => json!({ + "directories": ["/foo", "/bar"], + "appendEnvPath": false, + "restricted": false + }); "fully defined data serializes correctly" + )] + fn serializing(data: ResourcePathFileData) -> Value { + serde_json::to_value(data).expect("serialization should never fail") + } + + #[test_case(&json!("string"); "string value")] + #[test_case(&json!(123); "integer value")] + #[test_case(&json!(1.2); "number value")] + #[test_case(&json!(true); "boolean value")] + #[test_case(&json!(null); "null value")] + #[test_case(&json!([{"restricted": true}]); "array value")] + #[test_case(&json!({"directories": "invalid"}); "object with directories as string value")] + #[test_case(&json!({"directories": 123}); "object with directories as number value")] + #[test_case(&json!({"directories": true}); "object with directories as boolean value")] + #[test_case(&json!({"directories": {"nested": "object"}}); "object with directories as object value")] + #[test_case(&json!({"directories": ["valid", 123]}); "object with directories as array containing a non-string")] + #[test_case(&json!({"appendEnvPath": "invalid"}); "object with appendEnvPath as string value")] + #[test_case(&json!({"appendEnvPath": 123}); "object with appendEnvPath as number value")] + #[test_case(&json!({"appendEnvPath": {"nested": "object"}}); "object with appendEnvPath as object value")] + #[test_case(&json!({"appendEnvPath": [false]}); "object with appendEnvPath as array value")] + #[test_case(&json!({"restricted": "invalid"}); "object with restricted as string value")] + #[test_case(&json!({"restricted": 123}); "object with restricted as number value")] + #[test_case(&json!({"restricted": {"nested": "object"}}); "object with restricted as object value")] + #[test_case(&json!({"restricted": [false]}); "object with restricted as array value")] + fn deserializing_invalid(input_value: &Value) { + serde_json::from_value::(input_value.clone()) + .expect_err(&format!("json value '{input_value}' should be invalid")); + } + + #[test_case( + &json!({}) => ResourcePathFileData::default(); + "empty object deserializes as default" + )] + #[test_case( + &json!({ + "directories": [] + }) => ResourcePathFileData { + directories: Some(vec![]), + ..Default::default() + }; + "empty directories array deserializes as empty vec" + )] + #[test_case( + &json!({ + "directories": ["/foo", "/bar"] + }) => ResourcePathFileData { + directories: Some(vec![PathBuf::from("/foo"), PathBuf::from("/bar")]), + ..Default::default() + }; + "non-empty directories array deserializes as vec of pathbufs" + )] + #[test_case( + &json!({ + "appendEnvPath": true + }) => ResourcePathFileData { + append_env_path: Some(true), + ..Default::default() + }; + "append_env_path deserializes as boolean" + )] + #[test_case( + &json!({ + "restricted": true + }) => ResourcePathFileData { + restricted: Some(true), + ..Default::default() + }; + "restricted deserializes as boolean" + )] + #[test_case( + &json!({ + "directories": ["/foo", "/bar"], + "appendEnvPath": false, + "restricted": false + }) => ResourcePathFileData { + directories: Some(vec![PathBuf::from("/foo"), PathBuf::from("/bar")]), + append_env_path: Some(false), + restricted: Some(false), + }; "fully defined data deserializes correctly" + )] + fn deserializing_valid(input_value: &Value) -> ResourcePathFileData { + serde_json::from_value::(input_value.clone()) + .expect(&format!( + "expected input to serialize but failed for input: {}", + serde_json::to_string_pretty(input_value).unwrap(), + )) + } +} diff --git a/lib/dsc-lib/tests/integration/settings/fields/tracing.rs b/lib/dsc-lib/tests/integration/settings/fields/tracing.rs new file mode 100644 index 000000000..1966336e9 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/fields/tracing.rs @@ -0,0 +1,604 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod file_data { + crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::fields::TracingFileData, + properties: { + LEVEL_SCHEMA => "level", + FORMAT_SCHEMA => "format", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + level_title => (LEVEL_SCHEMA, "title"), + level_description => (LEVEL_SCHEMA, "description"), + level_markdown_description => (LEVEL_SCHEMA, "markdownDescription"), + format_title => (FORMAT_SCHEMA, "title"), + format_description => (FORMAT_SCHEMA, "description"), + format_markdown_description => (FORMAT_SCHEMA, "markdownDescription"), + }, + test_validation: { + empty_object_is_valid => { + input_json: json!({}), + expected_valid: true, + }, + with_just_level_is_valid => { + input_json: json!({"level": "info"}), + expected_valid: true, + }, + with_just_format_is_valid => { + input_json: json!({"format": "json"}), + expected_valid: true, + }, + with_level_and_format_is_valid => { + input_json: json!({"level": "info", "format": "json"}), + expected_valid: true, + }, + with_an_unknown_level_is_invalid => { + input_json: json!({"level": "unknown"}), + expected_valid: false, + }, + with_an_unknown_format_is_invalid => { + input_json: json!({"format": "unknown"}), + expected_valid: false, + }, + array_value_is_invalid => { + input_json: json!([]), + expected_valid: false, + }, + string_value_is_invalid => { + input_json: json!("invalid"), + expected_valid: false, + }, + null_value_is_invalid => { + input_json: json!(null), + expected_valid: false, + }, + boolean_value_is_invalid => { + input_json: json!(true), + expected_valid: false, + }, + integer_value_is_invalid => { + input_json: json!(123), + expected_valid: false, + }, + number_value_is_invalid => { + input_json: json!(1.2), + expected_valid: false, + }, + }, + } + + mod serde { + use dsc_lib::settings::fields::{TracingFileData, TracingLevelField, TracingFormatField}; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case( + TracingFileData::default() => json!({}); + "default serializes as empty object" + )] + #[test_case( + TracingFileData { + level: Some(TracingLevelField::Info), + ..Default::default() + } => json!({ + "level": "info" + }); "level serializes as a string" + )] + #[test_case( + TracingFileData { + format: Some(TracingFormatField::Json), + ..Default::default() + } => json!({ + "format": "json" + }); "format serializes as a string" + )] + #[test_case( + TracingFileData { + level: Some(TracingLevelField::Info), + format: Some(TracingFormatField::Json), + } => json!({ + "level": "info", + "format": "json" + }); "fully defined data serializes correctly" + )] + fn serializing(data: TracingFileData) -> Value { + serde_json::to_value(data).expect("serialization should never fail") + } + + #[test_case(&json!("string"); "string value")] + #[test_case(&json!(123); "integer value")] + #[test_case(&json!(1.2); "number value")] + #[test_case(&json!(true); "boolean value")] + #[test_case(&json!(null); "null value")] + #[test_case(&json!([{"level": "info"}]); "array value")] + #[test_case(&json!({"level": "invalid"}); "object with level as invalid string value")] + #[test_case(&json!({"level": 123}); "object with level as number value")] + #[test_case(&json!({"level": true}); "object with level as boolean value")] + #[test_case(&json!({"level": {"nested": "object"}}); "object with level as object value")] + #[test_case(&json!({"level": ["info"]}); "object with level as array value")] + #[test_case(&json!({"format": "invalid"}); "object with format as invalid string value")] + #[test_case(&json!({"format": 123}); "object with format as number value")] + #[test_case(&json!({"format": true}); "object with format as boolean value")] + #[test_case(&json!({"format": {"nested": "object"}}); "object with format as object value")] + #[test_case(&json!({"format": ["json"]}); "object with format as array value")] + fn deserializing_invalid(input_value: &Value) { + serde_json::from_value::(input_value.clone()) + .expect_err(&format!("json value '{input_value}' should be invalid")); + } + + #[test_case( + &json!({}) => TracingFileData::default(); + "empty object deserializes as default" + )] + #[test_case( + &json!({ + "level": "info" + }) => TracingFileData { + level: Some(TracingLevelField::Info), + ..Default::default() + }; + "level deserializes as string enum field" + )] + #[test_case( + &json!({ + "format": "json" + }) => TracingFileData { + format: Some(TracingFormatField::Json), + ..Default::default() + }; + "format deserializes as string enum field" + )] + #[test_case( + &json!({ + "level": "info", + "format": "json" + }) => TracingFileData { + level: Some(TracingLevelField::Info), + format: Some(TracingFormatField::Json), + }; "fully defined data deserializes correctly" + )] + fn deserializing_valid(input_value: &Value) -> TracingFileData { + serde_json::from_value::(input_value.clone()) + .expect(&format!( + "expected input to serialize but failed for input: {}", + serde_json::to_string_pretty(input_value).unwrap(), + )) + } + } +} + +mod level_field { + mod methods { + mod parse { + use dsc_lib::settings::DscSettingsError; + use dsc_lib::settings::fields::TracingLevelField; + use test_case::test_case; + + #[test_case("error" => matches TracingLevelField::Error ; "lower case error")] + #[test_case("ERROR" => matches TracingLevelField::Error ; "upper case error")] + #[test_case("eRrOr" => matches TracingLevelField::Error ; "mixed case error")] + #[test_case("warn" => matches TracingLevelField::Warn ; "lower case warn")] + #[test_case("WARN" => matches TracingLevelField::Warn ; "upper case warn")] + #[test_case("wArN" => matches TracingLevelField::Warn ; "mixed case warn")] + #[test_case("info" => matches TracingLevelField::Info ; "lower case info")] + #[test_case("INFO" => matches TracingLevelField::Info ; "upper case info")] + #[test_case("iNfO" => matches TracingLevelField::Info ; "mixed case info")] + #[test_case("debug" => matches TracingLevelField::Debug ; "lower case debug")] + #[test_case("DEBUG" => matches TracingLevelField::Debug ; "upper case debug")] + #[test_case("dEbUg" => matches TracingLevelField::Debug ; "mixed case debug")] + #[test_case("trace" => matches TracingLevelField::Trace ; "lower case trace")] + #[test_case("TRACE" => matches TracingLevelField::Trace ; "upper case trace")] + #[test_case("tRaCe" => matches TracingLevelField::Trace ; "mixed case trace")] + fn valid(input: &str) -> TracingLevelField { + TracingLevelField::parse(input) + .expect(&format!("expected input '{}' to be valid", input)) + } + + #[test_case(""; "empty string")] + #[test_case(" "; "space only string")] + #[test_case("invalid"; "unrecognized variant")] + fn invalid(input: &str) { + let err = TracingLevelField::parse(input) + .expect_err(&format!("expected input '{input}' to be invalid")); + + match err { + DscSettingsError::InvalidTracingLevel { text } => { + pretty_assertions::assert_eq!(text, input); + } + _ => panic!("expected InvalidTracingLevel error but got: {err:?}"), + } + } + } + } + + crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::fields::TracingLevelField + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + markdown_enum_descriptions => "markdownEnumDescriptions", + }, + test_validation: { + error_string_value_is_valid => { + input_json: json!("error"), + expected_valid: true, + }, + warn_string_value_is_valid => { + input_json: json!("warn"), + expected_valid: true, + }, + info_string_value_is_valid => { + input_json: json!("info"), + expected_valid: true, + }, + debug_string_value_is_valid => { + input_json: json!("debug"), + expected_valid: true, + }, + trace_string_value_is_valid => { + input_json: json!("trace"), + expected_valid: true, + }, + unknown_string_value_is_invalid => { + input_json: json!("unknown"), + expected_valid: false, + }, + boolean_value_is_invalid => { + input_json: json!(true), + expected_valid: false, + }, + null_value_is_invalid => { + input_json: json!(null), + expected_valid: false, + }, + empty_string_value_is_invalid => { + input_json: json!(""), + expected_valid: false, + }, + integer_value_is_invalid => { + input_json: json!(42), + expected_valid: false, + }, + number_value_is_invalid => { + input_json: json!(3.14), + expected_valid: false, + } + } + } + + mod serde { + use dsc_lib::settings::fields::TracingLevelField; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case(&TracingLevelField::Error => json!("error"); "error variant")] + #[test_case(&TracingLevelField::Warn => json!("warn"); "warn variant")] + #[test_case(&TracingLevelField::Info => json!("info"); "info variant")] + #[test_case(&TracingLevelField::Debug => json!("debug"); "debug variant")] + #[test_case(&TracingLevelField::Trace => json!("trace"); "trace variant")] + fn serializing(input: &TracingLevelField) -> serde_json::Value { + serde_json::to_value(input) + .expect("serialization should never fail") + } + + #[test_case(&json!("invalid"); "unrecognized variant string value")] + #[test_case(&json!(123); "integer value")] + #[test_case(&json!(true); "boolean value")] + #[test_case(&json!(null); "null value")] + #[test_case(&json!(["info"]); "array value")] + #[test_case(&json!({"level": "info"}); "object value")] + fn deserializing_invalid(input_value: &Value) { + serde_json::from_value::(input_value.clone()) + .expect_err(&format!("json value '{input_value}' should be invalid")); + } + + #[test_case(&json!("error") => TracingLevelField::Error; "error variant")] + #[test_case(&json!("warn") => TracingLevelField::Warn; "warn variant")] + #[test_case(&json!("info") => TracingLevelField::Info; "info variant")] + #[test_case(&json!("debug") => TracingLevelField::Debug; "debug variant")] + #[test_case(&json!("trace") => TracingLevelField::Trace; "trace variant")] + fn deserializing_valid(input_value: &Value) -> TracingLevelField { + serde_json::from_value::(input_value.clone()) + .expect(&format!( + "expected input to serialize but failed for input: {}", + serde_json::to_string_pretty(input_value).unwrap(), + )) + } + } + mod traits { + mod display { + use dsc_lib::settings::fields::TracingLevelField; + use test_case::test_case; + + #[test_case("error"; "error variant")] + #[test_case("warn"; "warn variant")] + #[test_case("info"; "info variant")] + #[test_case("debug"; "debug variant")] + #[test_case("trace"; "trace variant")] + fn format(variant: &str) { + pretty_assertions::assert_eq!( + format!("level: {}", TracingLevelField::parse(variant).unwrap()), + format!("level: {variant}"), + ) + } + + #[test_case("error"; "error variant")] + #[test_case("warn"; "warn variant")] + #[test_case("info"; "info variant")] + #[test_case("debug"; "debug variant")] + #[test_case("trace"; "trace variant")] + fn to_string(variant: &str) { + pretty_assertions::assert_eq!( + TracingLevelField::parse(variant).unwrap().to_string(), + variant.to_string(), + ); + } + } + mod from_str { + use dsc_lib::settings::{DscSettingsError, fields::TracingLevelField}; + // use std::str::FromStr; + use test_case::test_case; + + #[test_case("error" => matches Ok(_); "error variant")] + #[test_case("warn" => matches Ok(_); "warn variant")] + #[test_case("info" => matches Ok(_); "info variant")] + #[test_case("debug" => matches Ok(_); "debug variant")] + #[test_case("trace" => matches Ok(_); "trace variant")] + #[test_case("invalid" => matches Err(_); "invalid variant string")] + #[test_case("" => matches Err(_); "empty string")] + #[test_case(" " => matches Err(_); "space only string")] + fn parse(input: &str) -> Result { + input.parse() + } + } + mod try_from { + use dsc_lib::settings::{DscSettingsError, fields::TracingLevelField}; + use std::convert::TryFrom; + use test_case::test_case; + + #[test_case("error" => matches Ok(_); "error variant")] + #[test_case("warn" => matches Ok(_); "warn variant")] + #[test_case("info" => matches Ok(_); "info variant")] + #[test_case("debug" => matches Ok(_); "debug variant")] + #[test_case("trace" => matches Ok(_); "trace variant")] + #[test_case("invalid" => matches Err(_); "invalid variant string")] + #[test_case("" => matches Err(_); "empty string")] + #[test_case(" " => matches Err(_); "space only string")] + fn string(input: &str) -> Result { + TracingLevelField::try_from(input.to_string()) + } + } + mod from { + use dsc_lib::settings::fields::TracingLevelField; + use test_case::test_case; + + #[test_case(tracing::Level::ERROR => TracingLevelField::Error; "error variant")] + #[test_case(tracing::Level::WARN => TracingLevelField::Warn; "warn variant")] + #[test_case(tracing::Level::INFO => TracingLevelField::Info; "info variant")] + #[test_case(tracing::Level::DEBUG => TracingLevelField::Debug; "debug variant")] + #[test_case(tracing::Level::TRACE => TracingLevelField::Trace; "trace variant")] + fn tokio_tracing_level(input: tracing::Level) -> TracingLevelField { + TracingLevelField::from(input) + } + } + mod into { + use dsc_lib::settings::fields::TracingLevelField; + use test_case::test_case; + + #[test_case(TracingLevelField::Error => tracing::Level::ERROR; "error variant")] + #[test_case(TracingLevelField::Warn => tracing::Level::WARN; "warn variant")] + #[test_case(TracingLevelField::Info => tracing::Level::INFO; "info variant")] + #[test_case(TracingLevelField::Debug => tracing::Level::DEBUG; "debug variant")] + #[test_case(TracingLevelField::Trace => tracing::Level::TRACE; "trace variant")] + fn tokio_tracing_level(input: TracingLevelField) -> tracing::Level { + input.into() + } + + #[test_case(TracingLevelField::Error => "error".to_string(); "error variant")] + #[test_case(TracingLevelField::Warn => "warn".to_string(); "warn variant")] + #[test_case(TracingLevelField::Info => "info".to_string(); "info variant")] + #[test_case(TracingLevelField::Debug => "debug".to_string(); "debug variant")] + #[test_case(TracingLevelField::Trace => "trace".to_string(); "trace variant")] + fn string(input: TracingLevelField) -> String { + input.into() + } + } + } +} + +mod format_field { + mod methods { + mod parse { + use dsc_lib::settings::DscSettingsError; + use dsc_lib::settings::fields::TracingFormatField; + use test_case::test_case; + + #[test_case("default" => matches TracingFormatField::Default ; "lower case default")] + #[test_case("DEFAULT" => matches TracingFormatField::Default ; "upper case default")] + #[test_case("dEfAuLt" => matches TracingFormatField::Default ; "mixed case default")] + #[test_case("plaintext" => matches TracingFormatField::Plaintext ; "lower case warn")] + #[test_case("PLAINTEXT" => matches TracingFormatField::Plaintext ; "upper case warn")] + #[test_case("pLaInTeXt" => matches TracingFormatField::Plaintext ; "mixed case warn")] + #[test_case("json" => matches TracingFormatField::Json ; "lower case info")] + #[test_case("JSON" => matches TracingFormatField::Json ; "upper case info")] + #[test_case("jSoN" => matches TracingFormatField::Json ; "mixed case info")] + fn valid(input: &str) -> TracingFormatField { + TracingFormatField::parse(input) + .expect(&format!("expected input '{}' to be valid", input)) + } + + #[test_case(""; "empty string")] + #[test_case(" "; "space only string")] + #[test_case("invalid"; "unrecognized variant")] + fn invalid(input: &str) { + let err = TracingFormatField::parse(input) + .expect_err(&format!("expected input '{input}' to be invalid")); + + match err { + DscSettingsError::InvalidTracingFormat { text } => { + pretty_assertions::assert_eq!(text, input); + } + _ => panic!("expected InvalidTracingFormat error but got: {err:?}"), + } + } + } + } + + crate::macros::test_dsc_repo_schema! { + define_statics: { dsc_lib::settings::fields::TracingFormatField }, + test_meta_schema: {}, + test_docs: {}, + test_validation: { + default_string_value_is_valid => { + input_json: json!("default"), + expected_valid: true, + }, + plaintext_string_value_is_valid => { + input_json: json!("plaintext"), + expected_valid: true, + }, + json_string_value_is_valid => { + input_json: json!("json"), + expected_valid: true, + }, + unknown_string_value_is_invalid => { + input_json: json!("unknown"), + expected_valid: false, + }, + empty_string_value_is_invalid => { + input_json: json!(""), + expected_valid: false, + }, + boolean_value_is_invalid => { + input_json: json!(true), + expected_valid: false, + }, + integer_value_is_invalid => { + input_json: json!(42), + expected_valid: false, + }, + number_value_is_invalid => { + input_json: json!(3.14), + expected_valid: false, + }, + null_value_is_invalid => { + input_json: json!(null), + expected_valid: false, + }, + } + } + + mod serde { + use dsc_lib::settings::fields::TracingFormatField; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case(&TracingFormatField::Default => json!("default"); "default variant")] + #[test_case(&TracingFormatField::Plaintext => json!("plaintext"); "plaintext variant")] + #[test_case(&TracingFormatField::Json => json!("json"); "json variant")] + fn serializing(input: &TracingFormatField) -> serde_json::Value { + serde_json::to_value(input) + .expect("serialization should never fail") + } + + #[test_case(&json!("invalid"); "unrecognized variant string value")] + #[test_case(&json!(123); "integer value")] + #[test_case(&json!(true); "boolean value")] + #[test_case(&json!(null); "null value")] + #[test_case(&json!(["info"]); "array value")] + #[test_case(&json!({"format": "json"}); "object value")] + fn deserializing_invalid(input_value: &Value) { + serde_json::from_value::(input_value.clone()) + .expect_err(&format!("json value '{input_value}' should be invalid")); + } + + #[test_case(&json!("default") => TracingFormatField::Default; "default variant")] + #[test_case(&json!("plaintext") => TracingFormatField::Plaintext; "plaintext variant")] + #[test_case(&json!("json") => TracingFormatField::Json; "json variant")] + fn deserializing_valid(input_value: &Value) -> TracingFormatField { + serde_json::from_value::(input_value.clone()) + .expect(&format!( + "expected input to serialize but failed for input: {}", + serde_json::to_string_pretty(input_value).unwrap(), + )) + } + } + mod traits { + mod display { + use dsc_lib::settings::fields::TracingFormatField; + use test_case::test_case; + + #[test_case("default"; "default variant")] + #[test_case("plaintext"; "plaintext variant")] + #[test_case("json"; "json variant")] + fn format(variant: &str) { + pretty_assertions::assert_eq!( + format!("format: {}", TracingFormatField::parse(variant).unwrap()), + format!("format: {variant}"), + ) + } + + #[test_case("default"; "default variant")] + #[test_case("plaintext"; "plaintext variant")] + #[test_case("json"; "json variant")] + fn to_string(variant: &str) { + pretty_assertions::assert_eq!( + TracingFormatField::parse(variant).unwrap().to_string(), + variant.to_string(), + ); + } + } + mod from_str { + use dsc_lib::settings::{DscSettingsError, fields::TracingFormatField}; + // use std::str::FromStr; + use test_case::test_case; + + #[test_case("default" => matches Ok(_); "default variant")] + #[test_case("plaintext" => matches Ok(_); "plaintext variant")] + #[test_case("json" => matches Ok(_); "json variant")] + #[test_case("invalid" => matches Err(_); "invalid variant string")] + #[test_case("" => matches Err(_); "empty string")] + #[test_case(" " => matches Err(_); "space only string")] + fn parse(input: &str) -> Result { + input.parse() + } + } + mod try_from { + use dsc_lib::settings::{DscSettingsError, fields::TracingFormatField}; + use std::convert::TryFrom; + use test_case::test_case; + + #[test_case("default" => matches Ok(_); "default variant")] + #[test_case("plaintext" => matches Ok(_); "plaintext variant")] + #[test_case("json" => matches Ok(_); "json variant")] + #[test_case("invalid" => matches Err(_); "invalid variant string")] + #[test_case("" => matches Err(_); "empty string")] + #[test_case(" " => matches Err(_); "space only string")] + fn string(input: &str) -> Result { + TracingFormatField::try_from(input.to_string()) + } + } + mod into { + use dsc_lib::settings::fields::TracingFormatField; + use test_case::test_case; + + #[test_case(TracingFormatField::Default => "default".to_string(); "default variant")] + #[test_case(TracingFormatField::Plaintext => "plaintext".to_string(); "plaintext variant")] + #[test_case(TracingFormatField::Json => "json".to_string(); "json variant")] + fn string(input: TracingFormatField) -> String { + input.into() + } + } + } +} diff --git a/lib/dsc-lib/tests/integration/settings/mod.rs b/lib/dsc-lib/tests/integration/settings/mod.rs new file mode 100644 index 000000000..17b3d7a32 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/mod.rs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod builders; + +mod fields { + mod ignore_settings_file; + mod forbid_ignore_settings_file; + mod resource_path; + mod tracing; +} + +mod resolved { + mod field; + mod settings; +} + +mod sources { + mod command_line; + mod code_defaults; + mod environment; + mod preference_file; + mod policy_file; +} + +mod dsc_settings_scope; +mod dsc_settings; diff --git a/lib/dsc-lib/tests/integration/settings/resolved/field.rs b/lib/dsc-lib/tests/integration/settings/resolved/field.rs new file mode 100644 index 000000000..9528bb7b3 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/resolved/field.rs @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +pub(super) mod fixture { + use std::fmt::Display; + use std::ops::Deref; + + #[derive( + Debug, Clone, PartialEq, Eq, + serde::Serialize, serde::Deserialize, + schemars::JsonSchema + )] + pub(crate) struct TestingField(pub String); + pub(crate) type Resolved = dsc_lib::settings::DscSettingsResolvedField; + + impl AsRef for TestingField { + fn as_ref(&self) -> &str { + self.0.as_ref() + } + } + impl Deref for TestingField { + type Target = str; + fn deref(&self) -> &Self::Target { + self.0.deref() + } + } + impl Display for TestingField { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + + macro_rules! field { + () => { + TestingField(String::from("test")) + }; + ($text:literal) => { + TestingField(String::from($text)) + }; + } + pub(crate) use field; + macro_rules! resolved_field { + () => { + Resolved::for_code_default(field!()) + }; + ($text:literal) => { + Resolved::for_code_default(field!($text)) + }; + ($text:literal, $scope:ident) => { + Resolved::new(field!($text), dsc_lib::settings::DscSettingsScope::$scope) + }; + ($scope:ident) => { + Resolved::new(field!(), dsc_lib::settings::DscSettingsScope::$scope) + } + } + pub(crate) use resolved_field; +} + + +#[cfg(test)] +mod methods { + use test_case::test_case; + + use dsc_lib::settings::DscSettingsScope; + use super::fixture::*; + + #[test] + fn new() { + Resolved::new(TestingField(String::from("test")), DscSettingsScope::Default); + } + + #[test] + fn for_code_default() { + let resolved = Resolved::for_code_default( + TestingField(String::from("test")) + ); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::Default); + pretty_assertions::assert_eq!(resolved.value.as_ref(), "test"); + } + + #[test] + fn for_environment() { + let resolved = Resolved::for_environment( + TestingField(String::from("test")) + ); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::Environment); + pretty_assertions::assert_eq!(resolved.value.as_ref(), "test"); + } + + #[test] + fn for_command_line() { + let resolved = Resolved::for_command_line( + TestingField(String::from("test")) + ); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::CommandLine); + pretty_assertions::assert_eq!(resolved.value.as_ref(), "test"); + } + + #[test] + fn for_machine() { + let resolved = Resolved::for_machine( + TestingField(String::from("test")) + ); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::Machine); + pretty_assertions::assert_eq!(resolved.value.as_ref(), "test"); + } + + #[test] + fn for_user() { + let resolved = Resolved::for_user( + TestingField(String::from("test")) + ); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::User); + pretty_assertions::assert_eq!(resolved.value.as_ref(), "test"); + } + + #[test] + fn for_workspace() { + let resolved = Resolved::for_workspace( + TestingField(String::from("test")) + ); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::Workspace); + pretty_assertions::assert_eq!(resolved.value.as_ref(), "test"); + } + + #[test] + fn for_policy() { + let resolved = Resolved::for_policy( + TestingField(String::from("test")) + ); + pretty_assertions::assert_eq!(resolved.scope, DscSettingsScope::Policy); + pretty_assertions::assert_eq!(resolved.value.as_ref(), "test"); + } + + #[test_case(resolved_field!("test", Default) => false; "default scope")] + #[test_case(resolved_field!("test", Policy) => true; "policy scope")] + #[test_case(resolved_field!("test", Machine) => false; "machine scope")] + #[test_case(resolved_field!("test", User) => false; "user scope")] + #[test_case(resolved_field!("test", Workspace) => false; "workspace scope")] + #[test_case(resolved_field!("test", Environment) => false; "environment scope")] + #[test_case(resolved_field!("test", CommandLine) => false; "command line scope")] + fn is_policy(setting: Resolved) -> bool { + setting.is_policy() + } +} + +#[cfg(test)] +mod schema { + use std::sync::LazyLock; + + use super::fixture::*; + + use dsc_lib::{schemas::schema_utility_extensions::SchemaUtilityExtensions}; + use jsonschema::Validator; + use regex::Regex; + use schemars::{schema_for, Schema}; + use serde_json::{json, Value}; + use test_case::test_case; + + static ROOT_SCHEMA: LazyLock = LazyLock::new(|| schema_for!(Resolved)); + static VALIDATOR: LazyLock = + LazyLock::new(|| Validator::new((&*ROOT_SCHEMA).as_value()).unwrap()); + static KEYWORD_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\w+(\.\w+)+$").expect("pattern is valid")); + + #[test]fn is_valid_for_meta_schema() { + let schema = ROOT_SCHEMA.as_value(); + let result = jsonschema::meta::validate(schema); + assert!( + result.is_ok(), + "expected schema to be valid but got error: {:#?}", + result.unwrap_err() + ); + } + + #[test_case("title", &*ROOT_SCHEMA; "title")] + #[test_case("description", &*ROOT_SCHEMA; "description")] + #[test_case("markdownDescription", &*ROOT_SCHEMA; "markdown description")] + fn has_documentation_keyword(keyword: &str, schema: &Schema) { + match keyword { + "markdownEnumDescriptions" => { + schema.get_keyword_as_array(keyword) + .expect(&format!("expected keyword '{keyword}' to be defined as an array")) + .iter() + .for_each(|value| { + let string_value = value.as_str() + .expect("expected each item in array value to be a string"); + assert!( + !(&*KEYWORD_PATTERN).is_match(string_value), + "expected each item in `{}` to be defined in translation, but was set to i18n key '{}'", + keyword, + value, + ) + }) + } + _ => { + let value = schema + .get_keyword_as_str(keyword) + .expect(&format!( + "expected keyword '{keyword}' to be defined in schema: {}", + serde_json::to_string_pretty(schema).unwrap() + )); + + assert!( + !(&*KEYWORD_PATTERN).is_match(value), + "Expected keyword '{keyword}' to be defined in translation, but was set to i18n key '{value}'", + ); + } + } + } + + #[test] + fn value_schema_is_for_wrapped_type() { + let value_ref = (&*ROOT_SCHEMA) + .get_property_subschema("value") + .expect("expected 'value' property to have a subschema") + .get_keyword_as_str("$ref") + .expect("expected '$ref' keyword to be defined"); + let expected_ref = "#/$defs/TestingField"; + pretty_assertions::assert_eq!(value_ref, expected_ref); + } + + #[test] + fn scope_schema_is_for_setings_scope() { + let scope_ref = (&*ROOT_SCHEMA) + .get_property_subschema("scope") + .expect("expected 'scope' property to have a subschema") + .get_keyword_as_str("$ref") + .expect("expected '$ref' keyword to be defined"); + let expected_ref = "#/$defs/DscSettingsScope"; + pretty_assertions::assert_eq!(scope_ref, expected_ref); + } + + #[test_case(&json!({"value": "test", "scope": "default"}), true; "valid value and default scope")] + #[test_case(&json!({"value": "test", "scope": "machine"}), true; "valid value and machine scope")] + #[test_case(&json!({"value": "test", "scope": "user"}), true; "valid value and user scope")] + #[test_case(&json!({"value": "test", "scope": "workspace"}), true; "valid value and workspace scope")] + #[test_case(&json!({"value": "test", "scope": "environment"}), true; "valid value and environment scope")] + #[test_case(&json!({"value": "test", "scope": "cli"}), true; "valid value and cli scope")] + #[test_case(&json!({"value": "test", "scope": "unknown"}), false; "valid value and unknown scope")] + #[test_case(&json!({"value": ["invalid"], "scope": "default"}), false; "invalid value and valid scope")] + fn validation(input_json: &Value, expected_valid: bool) { + let result = (&*VALIDATOR).validate(input_json); + if expected_valid { + assert!(result.is_ok(), "expected input to be valid but got error {:#?}", result.unwrap_err()); + } else { + assert!(result.is_err(), "expected input to be invalid: {:#?}", input_json); + } + } +} + +#[cfg(test)] +mod serde { + use serde_json::{json, Value}; + use test_case::test_case; + use super::fixture::*; + + #[test_case(&resolved_field!("test", Default) => json!({"value": "test", "scope": "default"}); "default scope")] + #[test_case(&resolved_field!("test", Policy) => json!({"value": "test", "scope": "policy"}); "policy scope")] + #[test_case(&resolved_field!("test", Machine) => json!({"value": "test", "scope": "machine"}); "machine scope")] + #[test_case(&resolved_field!("test", User) => json!({"value": "test", "scope": "user"}); "user scope")] + #[test_case(&resolved_field!("test", Workspace) => json!({"value": "test", "scope": "workspace"}); "workspace scope")] + #[test_case(&resolved_field!("test", Environment) => json!({"value": "test", "scope": "environment"}); "environment scope")] + #[test_case(&resolved_field!("test", CommandLine) => json!({"value": "test", "scope": "cli"}); "command line scope")] + fn serializing(value: &Resolved) -> Value { + serde_json::to_value(value) + .expect("serialization should never fail") + } + + + #[test_case(&json!({"value": false, "scope": "default"}); "invalid value")] + #[test_case(&json!({"value": "test", "scope": "unknown"}); "invalid scope")] + fn deserialize_invalid(input_json: &Value) { + let result: Result = serde_json::from_value(input_json.clone()); + assert!(result.is_err(), "expected input to be invalid: {:#?}", input_json); + } + + #[test_case(&json!({"value": "test", "scope": "default"}), &resolved_field!("test", Default); "default scope")] + #[test_case(&json!({"value": "test", "scope": "policy"}), &resolved_field!("test", Policy); "policy scope")] + #[test_case(&json!({"value": "test", "scope": "machine"}), &resolved_field!("test", Machine); "machine scope")] + #[test_case(&json!({"value": "test", "scope": "user"}), &resolved_field!("test", User); "user scope")] + #[test_case(&json!({"value": "test", "scope": "workspace"}), &resolved_field!("test", Workspace); "workspace scope")] + #[test_case(&json!({"value": "test", "scope": "environment"}), &resolved_field!("test", Environment); "environment scope")] + #[test_case(&json!({"value": "test", "scope": "cli"}), &resolved_field!("test", CommandLine); "command line scope")] + fn deserialize_valid(input_json: &Value, expected_value: &Resolved) { + let result: Result = serde_json::from_value(input_json.clone()); + assert!(result.is_ok(), "expected input to be valid but got error {:#?}", result.unwrap_err()); + pretty_assertions::assert_eq!(&result.unwrap(), expected_value, "deserialized value does not match expected"); + } +} diff --git a/lib/dsc-lib/tests/integration/settings/resolved/settings.rs b/lib/dsc-lib/tests/integration/settings/resolved/settings.rs new file mode 100644 index 000000000..11a78b4c1 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/resolved/settings.rs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::DscSettingsResolved, + properties: { + FORBID_IGNORE_SETTINGS_FILE_SCHEMA => "forbidIgnoreSettingsFile", + IGNORE_SETTINGS_FILE_SCHEMA => "ignoreSettingsFile", + TRACING_SCHEMA => "tracing", + RESOURCE_PATH_SCHEMA => "resourcePath", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + forbid_ignore_settings_file_title => (FORBID_IGNORE_SETTINGS_FILE_SCHEMA, "title"), + forbid_ignore_settings_file_description => (FORBID_IGNORE_SETTINGS_FILE_SCHEMA, "description"), + forbid_ignore_settings_file_markdown_description => (FORBID_IGNORE_SETTINGS_FILE_SCHEMA, "markdownDescription"), + ignore_settings_file_title => (IGNORE_SETTINGS_FILE_SCHEMA, "title"), + ignore_settings_file_description => (IGNORE_SETTINGS_FILE_SCHEMA, "description"), + ignore_settings_file_markdown_description => (IGNORE_SETTINGS_FILE_SCHEMA, "markdownDescription"), + tracing_title => (TRACING_SCHEMA, "title"), + tracing_description => (TRACING_SCHEMA, "description"), + tracing_markdown_description => (TRACING_SCHEMA, "markdownDescription"), + resource_path_title => (RESOURCE_PATH_SCHEMA, "title"), + resource_path_description => (RESOURCE_PATH_SCHEMA, "description"), + resource_path_markdown_description => (RESOURCE_PATH_SCHEMA, "markdownDescription"), + }, + test_validation: { + with_all_defined_is_valid => { + input_json: json!({ + "forbidIgnoreSettingsFile": { + "value": false, + "scope": "policy" + }, + "ignoreSettingsFile": { + "value": false, + "scope": "default" + }, + "tracing": { + "level": { + "value": "warn", + "scope": "default" + }, + "format": { + "value": "json", + "scope": "user" + } + }, + "resourcePath": { + "appendEnvPath": { + "value": false, + "scope": "policy" + }, + "directories": { + "value": ["/dsc/resources", "/dsc/extensions"], + "scope": "policy" + }, + "restricted": { + "value": true, + "scope": "policy" + }, + } + }), + expected_valid: true, + } + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::settings::{ + DscSettingsResolved, + DscSettingsResolvedField, + fields::*, + }; + + + #[test] fn serializing() { + let settings = DscSettingsResolved { + forbid_ignore_settings_file: DscSettingsResolvedField::for_policy(false.into()), + ignore_settings_file: DscSettingsResolvedField::for_code_default(false.into()), + tracing: TracingResolvedSettings { + level: DscSettingsResolvedField::for_code_default(TracingLevelField::Warn), + format: DscSettingsResolvedField::for_user(TracingFormatField::Json) + }, + resource_path: ResourcePathResolvedSettings { + append_env_path: DscSettingsResolvedField::for_policy(false.into()), + directories: DscSettingsResolvedField::for_policy(vec!["/dsc/resources".into(), "/dsc/extensions".into()]), + restricted: DscSettingsResolvedField::for_policy(true.into()), + }, + }; + + serde_json::to_value(&settings) + .expect("serialization should never fail"); + } + + // No tests for deserializing, as the type only implements Serialize. +} + +#[cfg(test)] +mod traits {} diff --git a/lib/dsc-lib/tests/integration/settings/sources/code_defaults.rs b/lib/dsc-lib/tests/integration/settings/sources/code_defaults.rs new file mode 100644 index 000000000..47634ebbc --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/sources/code_defaults.rs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::sources::DscSettingsCodeDefaults + }, + test_meta_schema: {}, + test_docs: {}, + test_validation: { + defaults_is_valid => { + input_json: json!(dsc_lib::settings::sources::DSC_SETTINGS_CODE_DEFAULTS), + expected_valid: true, + } + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::settings::sources::DscSettingsCodeDefaults; + + #[test] + fn serialization() { + serde_json::to_string(&DscSettingsCodeDefaults::default()) + .expect("serialization should never fail"); + } + + // No test for deserialization since the code defaults are only serialized, + // never deserialized, and the type doesn't implement `Deserialize`. +} diff --git a/lib/dsc-lib/tests/integration/settings/sources/command_line.rs b/lib/dsc-lib/tests/integration/settings/sources/command_line.rs new file mode 100644 index 000000000..0dd12e8ed --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/sources/command_line.rs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::sources::CommandLineData, + properties: { + TRACE_LEVEL_SCHEMA => "--trace-level", + TRACE_FORMAT_SCHEMA => "--trace-format", + IGNORE_SETTINGS_FILE_SCHEMA => "--ignore-settings-file" + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + trace_level_title => (TRACE_LEVEL_SCHEMA, "title"), + trace_level_description => (TRACE_LEVEL_SCHEMA, "description"), + trace_level_markdown_description => (TRACE_LEVEL_SCHEMA, "markdownDescription"), + trace_format_title => (TRACE_FORMAT_SCHEMA, "title"), + trace_format_description => (TRACE_FORMAT_SCHEMA, "description"), + trace_format_markdown_description => (TRACE_FORMAT_SCHEMA, "markdownDescription"), + ignore_settings_file_title => (IGNORE_SETTINGS_FILE_SCHEMA, "title"), + ignore_settings_file_description => (IGNORE_SETTINGS_FILE_SCHEMA, "description"), + ignore_settings_file_markdown_description => (IGNORE_SETTINGS_FILE_SCHEMA, "markdownDescription"), + }, + test_validation: { + empty_object_is_valid => { + input_json: json!({}), + expected_valid: true, + }, + with_valid_trace_level_is_valid => { + input_json: json!({ + "--trace-level": "info" + }), + expected_valid: true, + }, + with_invalid_trace_level_is_invalid => { + input_json: json!({ + "--trace-level": "invalid" + }), + expected_valid: false, + }, + with_valid_trace_format_is_valid => { + input_json: json!({ + "--trace-format": "json" + }), + expected_valid: true, + }, + with_invalid_trace_format_is_invalid => { + input_json: json!({ + "--trace-format": "invalid" + }), + expected_valid: false, + }, + with_valid_ignore_settings_file_is_valid => { + input_json: json!({ + "--ignore-settings-file": true + }), + expected_valid: true, + }, + with_invalid_ignore_settings_file_is_invalid => { + input_json: json!({ + "--ignore-settings-file": "invalid" + }), + expected_valid: false, + }, + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::settings::{fields::TracingLevelField, sources::CommandLineData}; + use test_case::test_case; + use serde_json::{json, Value}; + use crate::settings::builders::*; + + #[test_case( + &CommandLineDataBuilder::new().build(), + json!({}); + "without any defined fields serializes as empty object" + )] + #[test_case( + &CommandLineDataBuilder::new().with_trace_level(TracingLevelField::Debug).build(), + json!({ + "--trace-level": "debug" + }); + "with defined fields only serializes those properties" + )] + fn serializing(data: &CommandLineData, expected: Value) { + let serialized = serde_json::to_value(data) + .expect("serialization should never fail"); + pretty_assertions::assert_eq!(serialized, expected); + } +} diff --git a/lib/dsc-lib/tests/integration/settings/sources/environment.rs b/lib/dsc-lib/tests/integration/settings/sources/environment.rs new file mode 100644 index 000000000..6639c7df6 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/sources/environment.rs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + use dsc_lib::settings::{DscSettingsError, sources::EnvironmentData}; + use test_case::test_case; + + #[test_case("true" => matches Ok(v) if v == true; "lower case true parses as true")] + #[test_case("TRUE" => matches Ok(v) if v == true; "upper case true parses as true")] + #[test_case("tRuE" => matches Ok(v) if v == true; "mixed case true parses as true")] + #[test_case("false" => matches Ok(v) if v == false; "lower case false parses as false")] + #[test_case("FALSE" => matches Ok(v) if v == false; "upper case false parses as false")] + #[test_case("fAlSe" => matches Ok(v) if v == false; "mixed case false parses as false")] + #[test_case("1" => matches Ok(v) if v == true; "numeric 1 parses as true")] + #[test_case("0" => matches Ok(v) if v == false; "numeric 0 parses as false")] + #[test_case("invalid" => matches Err(_); "invalid value returns parse error")] + fn parse_boolean_env_var(input: &str) -> Result { + EnvironmentData::parse_boolean_env_var(input) + } +} + +crate::macros::test_dsc_repo_schema!{ + define_statics: { + dsc_lib::settings::sources::EnvironmentData, + properties: { + TRACE_LEVEL_SCHEMA => "DSC_TRACE_LEVEL", + TRACE_FORMAT_SCHEMA => "DSC_TRACE_FORMAT", + RESOURCE_PATH_SCHEMA => "DSC_RESOURCE_PATH", + RESTRICTED_PATH_SCHEMA => "DSC_RESTRICTED_PATH", + IGNORE_SETTINGS_FILE_SCHEMA => "DSC_IGNORE_SETTINGS_FILE", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + trace_level_title => (TRACE_LEVEL_SCHEMA, "title"), + trace_level_description => (TRACE_LEVEL_SCHEMA, "description"), + trace_level_markdown_description => (TRACE_LEVEL_SCHEMA, "markdownDescription"), + trace_format_title => (TRACE_FORMAT_SCHEMA, "title"), + trace_format_description => (TRACE_FORMAT_SCHEMA, "description"), + trace_format_markdown_description => (TRACE_FORMAT_SCHEMA, "markdownDescription"), + resource_path_title => (RESOURCE_PATH_SCHEMA, "title"), + resource_path_description => (RESOURCE_PATH_SCHEMA, "description"), + resource_path_markdown_description => (RESOURCE_PATH_SCHEMA, "markdownDescription"), + restricted_path_title => (RESTRICTED_PATH_SCHEMA, "title"), + restricted_path_description => (RESTRICTED_PATH_SCHEMA, "description"), + restricted_path_markdown_description => (RESTRICTED_PATH_SCHEMA, "markdownDescription"), + ignore_settings_file_title => (IGNORE_SETTINGS_FILE_SCHEMA, "title"), + ignore_settings_file_description => (IGNORE_SETTINGS_FILE_SCHEMA, "description"), + ignore_settings_file_markdown_description => (IGNORE_SETTINGS_FILE_SCHEMA, "markdownDescription"), + }, + test_validation: { + empty_object_is_valid => { + input_json: json!({}), + expected_valid: true, + }, + with_valid_trace_level_is_valid => { + input_json: json!({ + "DSC_TRACE_LEVEL": "info" + }), + expected_valid: true, + }, + with_invalid_trace_level_is_invalid => { + input_json: json!({ + "DSC_TRACE_LEVEL": "invalid_value" + }), + expected_valid: false, + }, + with_valid_trace_format_is_valid => { + input_json: json!({ + "DSC_TRACE_FORMAT": "json" + }), + expected_valid: true, + }, + with_invalid_trace_format_is_invalid => { + input_json: json!({ + "DSC_TRACE_FORMAT": "invalid_value" + }), + expected_valid: false, + }, + with_resource_path_as_string_array_value_is_valid => { + input_json: json!({ + "DSC_RESOURCE_PATH": ["/valid/path"] + }), + expected_valid: true, + }, + with_resource_path_as_non_array_is_invalid => { + input_json: json!({ + "DSC_RESOURCE_PATH": "invalid_path" + }), + expected_valid: false, + }, + with_restricted_path_as_string_array_value_is_valid => { + input_json: json!({ + "DSC_RESTRICTED_PATH": ["/valid/path"] + }), + expected_valid: true, + }, + with_restricted_path_as_non_array_is_invalid => { + input_json: json!({ + "DSC_RESTRICTED_PATH": "invalid_path" + }), + expected_valid: false, + }, + with_ignore_settings_file_as_boolean_is_valid => { + input_json: json!({ + "DSC_IGNORE_SETTINGS_FILE": true + }), + expected_valid: true, + }, + with_ignore_settings_file_as_non_boolean_is_invalid => { + input_json: json!({ + "DSC_IGNORE_SETTINGS_FILE": "invalid_value" + }), + expected_valid: false, + }, + }, +} + +#[cfg(test)] +mod serde { + use dsc_lib::settings::sources::EnvironmentData; + use dsc_lib::settings::fields::TracingLevelField; + use serde_json::{json, Value}; + use test_case::test_case; + + use crate::settings::builders::EnvironmentDataBuilder; + + #[test_case( + &EnvironmentData::default(), + &json!({}); + "Without any data serializes as empty object" + )] + #[test_case( + &EnvironmentDataBuilder::new().with_trace_level(TracingLevelField::Info).build(), + &json!({ + "DSC_TRACE_LEVEL": "info" + }); + "With defined properties only serializes those properties" + )] + fn serializing(input: &EnvironmentData, expected: &Value) { + let serialized = serde_json::to_value(input).unwrap(); + assert_eq!(&serialized, expected); + } + + // No deserialization tests because we don't implement deserialize since the + // data is read from environment variables. +} diff --git a/lib/dsc-lib/tests/integration/settings/sources/policy_file.rs b/lib/dsc-lib/tests/integration/settings/sources/policy_file.rs new file mode 100644 index 000000000..3b78eb436 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/sources/policy_file.rs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + //! These tests are handled in the unit tests in + //! `src/tests/settings/sources/policy_file.rs` + //! + //! We use some stubs to simulate the behavior of the preference file, but + //! unfortunately can't do so in integration tests because we can't access + //! the code built for `#[cfg(test)]`. +} + +crate::macros::test_dsc_repo_schema! { + define_statics: { + dsc_lib::settings::sources::PolicyFileData, + properties: { + FORBID_IGNORE_PROPERTY_SCHEMA => "forbidIgnoreSettingsFile", + IGNORE_PROPERTY_SCHEMA => "ignoreSettingsFile", + TRACING_PROPERTY_SCHEMA => "tracing", + RESOURCE_PATH_PROPERTY_SCHEMA => "resourcePath", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + forbid_ignore_settings_file_title => (FORBID_IGNORE_PROPERTY_SCHEMA, "title"), + forbid_ignore_settings_file_description => (FORBID_IGNORE_PROPERTY_SCHEMA, "description"), + forbid_ignore_settings_file_markdown_description => (FORBID_IGNORE_PROPERTY_SCHEMA, "markdownDescription"), + ignore_settings_file_title => (IGNORE_PROPERTY_SCHEMA, "title"), + ignore_settings_file_description => (IGNORE_PROPERTY_SCHEMA, "description"), + ignore_settings_file_markdown_description => (IGNORE_PROPERTY_SCHEMA, "markdownDescription"), + resource_path_title => (RESOURCE_PATH_PROPERTY_SCHEMA, "title"), + resource_path_description => (RESOURCE_PATH_PROPERTY_SCHEMA, "description"), + resource_path_markdown_description => (RESOURCE_PATH_PROPERTY_SCHEMA, "markdownDescription"), + tracing_title => (TRACING_PROPERTY_SCHEMA, "title"), + tracing_description => (TRACING_PROPERTY_SCHEMA, "description"), + tracing_markdown_description => (TRACING_PROPERTY_SCHEMA, "markdownDescription"), + }, + test_validation: { + empty_object_is_valid => { + input_json: json!({}), + expected_valid: true, + }, + forbid_ignore_settings_file_field_as_boolean_is_valid => { + input_json: json!({"forbidIgnoreSettingsFile": true}), + expected_valid: true, + }, + forbid_ignore_settings_file_field_as_invalid_type_is_invalid => { + input_json: json!({"forbidIgnoreSettingsFile": "invalid"}), + expected_valid: false, + }, + ignore_settings_file_field_as_boolean_is_valid => { + input_json: json!({"ignoreSettingsFile": true}), + expected_valid: true, + }, + ignore_settings_file_field_as_invalid_type_is_invalid => { + input_json: json!({"ignoreSettingsFile": "invalid"}), + expected_valid: false, + }, + tracing_field_as_empty_object_is_valid => { + input_json: json!({"tracing": {}}), + expected_valid: true, + }, + resource_path_field_as_empty_object_is_valid => { + input_json: json!({"resourcePath": {}}), + expected_valid: true, + }, + resource_path_field_with_valid_data_is_valid => { + input_json: json!({"resourcePath": {"appendEnvPath": true}}), + expected_valid: true, + }, + resource_path_field_with_invalid_data_is_invalid => { + input_json: json!({"resourcePath": {"appendEnvPath": "invalid"}}), + expected_valid: false, + }, + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::settings::{fields::{ForbidIgnoreSettingsFileField, ResourcePathFileData}, sources::PolicyFileData}; + use std::path::PathBuf; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case(&PolicyFileData::default(), json!({}); "without any defined fields is empty object")] + #[test_case( + &PolicyFileData { + forbid_ignore_settings_file: Some(ForbidIgnoreSettingsFileField::new(true)), + ignore_settings_file: None, + tracing: None, + resource_path: Some(ResourcePathFileData{ + directories: Some(vec![ + PathBuf::from("/dsc/resources"), + PathBuf::from("/dsc/extensions") + ]), + ..Default::default() + }) + }, + json!({ + "forbidIgnoreSettingsFile": true, + "resourcePath": { + "directories": ["/dsc/resources", "/dsc/extensions"] + } + }); + "with defined fields only includes those defined fields" + )] + fn serializing(data: &PolicyFileData, expected: Value) { + let serialized = serde_json::to_value(data) + .expect("serialization should never fail"); + pretty_assertions::assert_eq!(serialized, expected); + } + + #[test_case(json!({"forbidIgnoreSettingsFile": "invalid"}); "forbid_ignore_settings_file as non-boolean is invalid")] + #[test_case(json!({"ignoreSettingsFile": "invalid"}); "ignore_settings_file as non-boolean is invalid")] + #[test_case(json!({"resourcePath": {"appendEnvPath": "invalid"}}); "resource_path with invalid data is invalid")] + #[test_case(json!({"tracing": {"level": "invalid"}}); "tracing with invalid data is invalid")] + fn derserializing_invalid(input_value: Value) { + let result = serde_json::from_value::(input_value.clone()); + assert!(result.is_err(), "expected deserialization to fail for input: {:?}", input_value); + } + #[test_case(json!({}); "empty object is valid")] + #[test_case(json!({"forbidIgnoreSettingsFile": true}); "forbid_ignore_settings_file as boolean is valid")] + #[test_case(json!({"ignoreSettingsFile": true}); "ignore_settings_file as boolean is valid")] + #[test_case(json!({"resourcePath": {"directories": ["/dsc/resources"]}}); "resource_path with valid data is valid")] + #[test_case(json!({"tracing": {"level": "info"}}); "tracing with valid data is valid")] + fn deserializing_valid(input_value: Value) { + let result = serde_json::from_value::(input_value.clone()); + assert!( + result.is_ok(), + "expected deserialization to succeed for input: {:?} but got error: {:#?}", + input_value, + result.unwrap_err() + ); + } +} diff --git a/lib/dsc-lib/tests/integration/settings/sources/preference_file.rs b/lib/dsc-lib/tests/integration/settings/sources/preference_file.rs new file mode 100644 index 000000000..84ec08526 --- /dev/null +++ b/lib/dsc-lib/tests/integration/settings/sources/preference_file.rs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod methods { + //! These tests are handled in the unit tests in + //! `src/tests/settings/sources/preference_file.rs` + //! + //! We use some stubs to simulate the behavior of the preference file, but + //! unfortunately can't do so in integration tests because we can't access + //! the code built for `#[cfg(test)]`. +} + +crate::macros::test_dsc_repo_schema!{ + define_statics: { + dsc_lib::settings::sources::PreferenceFileData, + properties: { + TRACING_PROPERTY_SCHEMA => "tracing", + RESOURCE_PATH_PROPERTY_SCHEMA => "resourcePath", + } + }, + test_meta_schema: {}, + test_docs: { + title => "title", + description => "description", + markdown_description => "markdownDescription", + tracing_title => (TRACING_PROPERTY_SCHEMA, "title"), + tracing_description => (TRACING_PROPERTY_SCHEMA, "description"), + tracing_markdown_description => (TRACING_PROPERTY_SCHEMA, "markdownDescription"), + resource_path_title => (RESOURCE_PATH_PROPERTY_SCHEMA, "title"), + resource_path_description => (RESOURCE_PATH_PROPERTY_SCHEMA, "description"), + resource_path_markdown_description => (RESOURCE_PATH_PROPERTY_SCHEMA, "markdownDescription"), + }, + test_validation: { + empty_object_is_valid => { + input_json: json!({}), + expected_valid: true, + }, + tracing_field_as_empty_object_is_valid => { + input_json: json!({"tracing": {}}), + expected_valid: true, + }, + resource_path_field_as_empty_object_is_valid => { + input_json: json!({"resourcePath": {}}), + expected_valid: true, + }, + resource_path_field_with_valid_data_is_valid => { + input_json: json!({"resourcePath": {"appendEnvPath": true}}), + expected_valid: true, + }, + resource_path_field_with_invalid_data_is_invalid => { + input_json: json!({"resourcePath": {"appendEnvPath": "invalid"}}), + expected_valid: false, + }, + } +} + +#[cfg(test)] +mod serde { + use dsc_lib::settings::{fields::ResourcePathFileData, sources::PreferenceFileData}; + use std::path::PathBuf; + use serde_json::{json, Value}; + use test_case::test_case; + + #[test_case(&PreferenceFileData::default(), json!({}); "without any defined fields is empty object")] + #[test_case( + &PreferenceFileData { + tracing: None, + resource_path: Some(ResourcePathFileData{ + directories: Some(vec![ + PathBuf::from("/dsc/resources"), + PathBuf::from("/dsc/extensions") + ]), + ..Default::default() + }) + }, + json!({ + "resourcePath": { + "directories": ["/dsc/resources", "/dsc/extensions"] + } + }); + "with defined fields only includes those defined fields" + )] + fn serializing(data: &PreferenceFileData, expected: Value) { + let serialized = serde_json::to_value(data) + .expect("serialization should never fail"); + pretty_assertions::assert_eq!(serialized, expected); + } + + #[test_case(json!({"tracing": {"level": "invalid"}}); "tracing with invalid data is invalid")] + #[test_case(json!({"resourcePath": {"appendEnvPath": "invalid"}}); "resource_path with invalid data is invalid")] + fn derserializing_invalid(input_value: Value) { + let result = serde_json::from_value::(input_value.clone()); + assert!(result.is_err(), "expected deserialization to fail for input: {:?}", input_value); + } + #[test_case(json!({}); "empty object is valid")] + #[test_case(json!({"resourcePath": {"directories": ["/dsc/resources"]}}); "resource_path with valid data is valid")] + #[test_case(json!({"tracing": {"level": "info"}}); "tracing with valid data is valid")] + fn deserializing_valid(input_value: Value) { + let result = serde_json::from_value::(input_value.clone()); + assert!( + result.is_ok(), + "expected deserialization to succeed for input: {:?} but got error: {:#?}", + input_value, + result.unwrap_err() + ); + } +} + +#[cfg(test)] +mod traits {} From 63aa7f40283a5dbb614f20ef4d5be41f903dfe97 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Fri, 9 Oct 2026 11:00:48 -0500 Subject: [PATCH 3/8] (MAINT) Fix idiomaticize_string_enum Prior to this change, the implementation for `idiomaticize_string_enum` incorrectly used the `enumMarkdownDescriptions` keyword instead of the correct `markdownEnumDescriptions` keyword. This change fixes the implementation and tests. --- lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs | 2 +- .../src/transforms/idiomaticize_string_enum.rs | 6 +++--- .../integration/transforms/idiomaticize_string_enum.rs | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs index acc018c03..33f65e598 100644 --- a/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs @@ -134,7 +134,7 @@ pub(crate) fn get_recognized_uris_subschema( "title", "description", "markdownDescription", - "enumMarkdownDescriptions", + "markdownEnumDescriptions", "enumDescriptions", "completionDetail", "defaultSnippets", diff --git a/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_string_enum.rs b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_string_enum.rs index 084b57eac..c50a5e9de 100644 --- a/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_string_enum.rs +++ b/lib/dsc-lib-jsonschema/src/transforms/idiomaticize_string_enum.rs @@ -17,7 +17,7 @@ use serde_json::{self, json}; /// /// This transformer returns the schema as a single string schema with each of the variants defined /// as an item in the `enum` keyword. It hoists the per-variant documentation to the extended -/// keywords recognized by VS Code: `enumDescriptions` and `enumMarkdownDescriptions`. This is more +/// keywords recognized by VS Code: `enumDescriptions` and `markdownEnumDescriptions`. This is more /// idiomatic, shorter to read and parse, easier to reason about, and matches the underlying data /// semantics more accurately. /// @@ -117,7 +117,7 @@ use serde_json::{self, json}; /// "bar-description", /// "baz-description", /// ], -/// "enumMarkdownDescriptions": [ +/// "markdownEnumDescriptions": [ /// "foo-description", /// "bar-description", /// "baz-description", @@ -259,7 +259,7 @@ pub fn idiomaticize_string_enum(schema: &mut Schema) { } if enum_markdown_descriptions.iter().any(|e| !e.is_empty()) { schema.insert( - "enumMarkdownDescriptions".to_string(), + "markdownEnumDescriptions".to_string(), serde_json::to_value(enum_markdown_descriptions).unwrap() ); } diff --git a/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_string_enum.rs b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_string_enum.rs index f60100032..96b9fcec3 100644 --- a/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_string_enum.rs +++ b/lib/dsc-lib-jsonschema/tests/integration/transforms/idiomaticize_string_enum.rs @@ -95,7 +95,7 @@ enum StringEnum { "markdownDescription": "enum-markdown", "enum": ["foo", "bar", "baz"], "enumDescriptions": ["foo-description", "bar-description", "baz-description"], - "enumMarkdownDescriptions": ["foo-markdown", "bar-markdown", "baz-markdown"] + "markdownEnumDescriptions": ["foo-markdown", "bar-markdown", "baz-markdown"] }); assert_pretty_eq!( serde_json::to_string_pretty(schema).unwrap(), @@ -152,7 +152,7 @@ enum StringEnum { "type": "string", "enum": ["foo", "bar", "baz"], "enumDescriptions": ["Foo-description", "Bar-description", "Baz-description"], - "enumMarkdownDescriptions": ["Foo-description", "Bar-description", "Baz-description"], + "markdownEnumDescriptions": ["Foo-description", "Bar-description", "Baz-description"], }); assert_pretty_eq!( serde_json::to_string_pretty(schema).unwrap(), @@ -185,7 +185,7 @@ enum StringEnum { "type": "string", "enum": ["foo", "bar", "baz"], "enumDescriptions": ["Foo-description", "Bar-title", "Baz-title"], - "enumMarkdownDescriptions": ["Foo-markdown", "Bar-markdown", "Baz-title"], + "markdownEnumDescriptions": ["Foo-markdown", "Bar-markdown", "Baz-title"], }); assert_pretty_eq!( serde_json::to_string_pretty(schema).unwrap(), @@ -217,7 +217,7 @@ enum StringEnum { "type": "string", "enum": ["baz", "foo", "bar"], "enumDescriptions": ["", "Foo-description", "Bar-description"], - "enumMarkdownDescriptions": ["", "Foo-description", "Bar-description"], + "markdownEnumDescriptions": ["", "Foo-description", "Bar-description"], }); assert_pretty_eq!( serde_json::to_string_pretty(schema).unwrap(), From 802eed2eebfa817421394eabbbb7bc0e1c64f329 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Fri, 9 Oct 2026 11:04:13 -0500 Subject: [PATCH 4/8] (MAINT) Improve flexibility of `schema_i18n!` Prior to this change, the `schema_i18n` macro only supported passing a single string literal as the input value. This made it impossible to correctly construct calls to the macro when needing to compose the dotpath through a macro or dynamic lookup. This change: - Enables the macro to accept multiple string literals or an arbitrary expression. - Adds documentation to the macro. - Adds the missing header comments to the source file. --- lib/dsc-lib-jsonschema/src/dsc_repo/macros.rs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/macros.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/macros.rs index 8d05a3b9e..0d29c67f0 100644 --- a/lib/dsc-lib-jsonschema/src/dsc_repo/macros.rs +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/macros.rs @@ -1,6 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/// A macro for retrieving internationalized schema strings from a dotpath. +/// +/// This macro is a helper for calling [`DscRepoSchema::schema_i18n`] to +/// retrieve the translated string for schema documentation keywords. It's +/// intended to be used either in the `schemars` attribute or directly in the +/// manual implementation for [`schemars::JsonSchema`]. +/// +/// # Syntax +/// +/// This macro accepts three forms of input: +/// +/// - `schema_i18n!("dot.path.to.translation_key")` - Uses the provided string +/// as the suffix to [`DscRepoSchema::SCHEMA_I18N_ROOT_KEY`]. +/// - `schema_i18n!("root", "suffix", "another_suffix")` - Concatenates the +/// root and suffixes with dots and uses the result as the suffix to +/// [`DscRepoSchema::SCHEMA_I18N_ROOT_KEY`]. +/// - `schema_i18n!(expression)` - Passes the expression directly as the suffix +/// to [`DscRepoSchema::SCHEMA_I18N_ROOT_KEY`]. +/// +/// # Example +/// +/// This example demonstrates the alternate forms for using the macro when +/// defining a type that derives `JsonSchema` and `DscRepoSchema`, which +/// is the most common case for types in the DSC repository. +/// +/// ```rust, ignore +/// #[derive(Debug, Clone, JsonSchema, DscRepoSchema)] +/// #[dsc_repo_schema(base_name = "document", folder_path = "examples/macros")] +/// #[serde(rename_all = "camelCase")] +/// #[schemars( +/// title = schema_i18n!("title"), +/// description = schema_i18n("description"), +/// extend("markdownDescription" = schema_i18n("markdownDescription")) +/// )] +/// pub struct Example { +/// /// The `foo` field passes the dot-path literal for the macro +/// #[schemars( +/// title = schema_i18n!("fields.foo.title"), +/// description = schema_i18n!("foo.description"), +/// extend("markdownDescription" = schema_i18n!("foo.markdownDescription")) +/// )] +/// pub foo: String +/// /// The `bar` field passes each segment as a separate literal to the +/// /// macro. +/// #[schemars( +/// title = schema_i18n!("fields", "bar", "title"), +/// description = schema_i18n!("fields", "bar", "description"), +/// extend("markdownDescription" = schema_i18n!("fields", "bar", "markdownDescription")) +/// )] +/// pub bar: bool, +/// /// The `baz` field demonstrates uses the [`format!`] macro to construct +/// /// the dot-path for the macro dynamically. +/// #[schemars( +/// title = schema_i18n!(format!("fields.{}.title", "baz")), +/// description = schema_i18n!(format!("fields.{}.description", "baz")), +/// extend( +/// "markdownDescription" = schema_i18n!( +/// format!("fields.{}.markdownDescription", "baz") +/// ) +/// ) +/// )] +/// pub baz: usize, +/// } +/// ``` +/// +/// The type is defined with the following `dsc_repo_schema` attribute: +/// +/// ```rust, ignore +/// #[dsc_repo_schema(base_name = "document", folder_path = "examples/macros")] +/// ``` +/// +/// The derive macro therefore defines [`DscRepoSchema::SCHEMA_I18N_ROOT_KEY`] +/// as `"schemas.examples.macros.document"`. +/// +/// With that, we can see the full dot paths for the translation entries that +/// need to be defined for this schema: +/// +/// | Macro call | Full dot path | +/// |:----------------------------------------------------------------|:------------------------------------------------------------------| +/// | `schema_i18n!("title")` | `schemas.examples.macros.document.title` | +/// | `schema_i18n!("description")` | `schemas.examples.macros.document.description` | +/// | `schema_i18n!("markdownDescription")` | `schemas.examples.macros.document.markdownDescription` | +/// | `schema_i18n!("fields.foo.title")` | `schemas.examples.macros.document.fields.foo.title` | +/// | `schema_i18n!("fields.foo.description")` | `schemas.examples.macros.document.fields.foo.description` | +/// | `schema_i18n!("fields.foo.markdownDescription")` | `schemas.examples.macros.document.fields.foo.markdownDescription` | +/// | `schema_i18n!("fields", "bar", "title")` | `schemas.examples.macros.document.fields.bar.title` | +/// | `schema_i18n!("fields", "bar", "description")` | `schemas.examples.macros.document.fields.bar.description` | +/// | `schema_i18n!(format!("fields.{}.title", "baz"))` | `schemas.examples.macros.document.fields.baz.title` | +/// | `schema_i18n!(format!("fields.{}.description", "baz"))` | `schemas.examples.macros.document.fields.baz.description` | +/// | `schema_i18n!(format!("fields.{}.markdownDescription", "baz"))` | `schemas.examples.macros.document.fields.baz.markdownDescription` | +/// +/// Which should be structured in YAML using the `_version: 2` option for +/// [`rust_i18n`] like so: +/// +/// ```yaml +/// _version: 2 +/// schemas: +/// examples: +/// macros: +/// document: +/// title: +/// en-us: +/// description: +/// en-us: <description> +/// markdownDescription: +/// en-us: <markdownDescription> +/// fields: +/// foo: +/// title: +/// en-us: <fields.foo.title> +/// description: +/// en-us: <fields.foo.description> +/// markdownDescription: +/// en-us: <fields.foo.markdownDescription> +/// bar: +/// title: +/// en-us: <fields.bar.title> +/// description: +/// en-us: <fields.bar.description> +/// markdownDescription: +/// en-us: <fields.bar.markdownDescription> +/// baz: +/// title: +/// en-us: <fields.baz.title> +/// description: +/// en-us: <fields.baz.description> +/// markdownDescription: +/// en-us: <fields.baz.markdownDescription> +/// ``` +/// +/// [`DscRepoSchema::schema_i18n`]: crate::dsc_repo::DscRepoSchema::schema_i18n +/// [`DscRepoSchema::SCHEMA_I18N_ROOT_KEY`]: crate::dsc_repo::DscRepoSchema::SCHEMA_I18N_ROOT_KEY #[macro_export] macro_rules! schema_i18n { + // When the dotpath is just a literal, pass it through to `Self::schema_i18n` directly. ($dot_path: literal) => { Self::schema_i18n($dot_path).unwrap() }; + // When the dotpath is a root with one or more suffixes, concatenate them with dots. + ($root: literal, $($suffix: literal),+ ) => { + Self::schema_i18n(concat!($root, $( ".", $suffix ),+)).unwrap() + }; + // When the dotpath is an arbitrary expression, pass it through to `Self::schema_i18n` directly. + ($dot_path: expr) => { + Self::schema_i18n($dot_path).unwrap() + }; } From 15e78c4faa08ba1857f49e6dca3b9036b8184b46 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi <mlombardi@microsoft.com> Date: Fri, 9 Oct 2026 11:04:55 -0500 Subject: [PATCH 5/8] (MAINT) Add root doc integration test for `DscRepoSchema` derive --- .../dsc_repo/derive_dsc_repo_schema.rs | 435 ++++++++++++++++++ .../tests/integration_locales/schemas.yaml | 216 +++++++++ 2 files changed, 651 insertions(+) diff --git a/lib/dsc-lib-jsonschema/tests/integration/dsc_repo/derive_dsc_repo_schema.rs b/lib/dsc-lib-jsonschema/tests/integration/dsc_repo/derive_dsc_repo_schema.rs index e008377d7..ad2b28a92 100644 --- a/lib/dsc-lib-jsonschema/tests/integration/dsc_repo/derive_dsc_repo_schema.rs +++ b/lib/dsc-lib-jsonschema/tests/integration/dsc_repo/derive_dsc_repo_schema.rs @@ -770,3 +770,438 @@ macro_rules! testing_title { } } } + + +#[cfg(test)] mod for_root_document { + #![allow(dead_code)] + + use std::sync::LazyLock; + + use pretty_assertions::assert_eq; + use schemars::{JsonSchema, Schema, schema_for}; + use dsc_lib_jsonschema::{ + dsc_repo::{ + DscRepoSchema, RecognizedSchemaVersion, SchemaForm, UnrecognizedSchemaUriError, schema_i18n + }, schema_utility_extensions::SchemaUtilityExtensions + }; + + #[derive(Clone, Debug, Default, JsonSchema, DscRepoSchema)] + #[dsc_repo_schema(base_name = "string_enum", folder_path = "example/root/fields")] + #[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription"), + ), + rename_all = "camelCase" + )] + enum ExampleStringEnum { + #[schemars( + title = schema_i18n!("variants.foo.title"), + description = schema_i18n!("variants.foo.description"), + extend( + "markdownDescription" = schema_i18n!("variants.foo.markdownDescription"), + ), + )] + #[default] + Foo, + #[schemars( + title = schema_i18n!("variants.bar.title"), + description = schema_i18n!("variants.bar.description"), + extend( + "markdownDescription" = schema_i18n!("variants.bar.markdownDescription"), + ), + )] + Bar, + #[schemars( + title = schema_i18n!("variants.baz.title"), + description = schema_i18n!("variants.baz.description"), + extend( + "markdownDescription" = schema_i18n!("variants.baz.markdownDescription"), + ), + )] + Baz, + } + + #[derive(Clone, Debug, JsonSchema, DscRepoSchema)] + #[dsc_repo_schema(base_name = "tagged_variant_enum", folder_path = "example/root/fields")] + #[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription"), + ), + rename_all = "camelCase", + )] + enum ExampleTaggedVariantEnum { + #[schemars( + title = schema_i18n!("variants.string.title"), + description = schema_i18n!("variants.string.description"), + extend( + "markdownDescription" = schema_i18n!("variants.string.markdownDescription"), + ), + )] + String(String), + #[schemars( + title = schema_i18n!("variants.boolean.title"), + description = schema_i18n!("variants.boolean.description"), + extend( + "markdownDescription" = schema_i18n!("variants.boolean.markdownDescription"), + ), + )] + Boolean(bool), + } + impl Default for ExampleTaggedVariantEnum { + fn default() -> Self { + Self::String(String::default()) + } + } + + #[derive(Clone, Debug, JsonSchema, DscRepoSchema)] + #[dsc_repo_schema(base_name = "untagged_variant_enum", folder_path = "example/root/fields")] + #[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription"), + ), + rename_all = "camelCase", + untagged + )] + enum ExampleUntaggedVariantEnum { + #[schemars( + title = schema_i18n!("variants.string.title"), + description = schema_i18n!("variants.string.description"), + extend( + "markdownDescription" = schema_i18n!("variants.string.markdownDescription"), + ), + )] + String(String), + #[schemars( + title = schema_i18n!("variants.boolean.title"), + description = schema_i18n!("variants.boolean.description"), + extend( + "markdownDescription" = schema_i18n!("variants.boolean.markdownDescription"), + ), + )] + Boolean(bool), + } + impl Default for ExampleUntaggedVariantEnum { + fn default() -> Self { + Self::String(String::default()) + } + } + + #[derive(Clone, Debug, Default, JsonSchema, DscRepoSchema)] + #[dsc_repo_schema(base_name = "boolean_struct", folder_path = "example/root/fields")] + #[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription"), + ), + rename_all = "camelCase" + )] + struct ExampleBooleanStruct(bool); + #[derive(Clone, Debug, Default, JsonSchema, DscRepoSchema)] + #[dsc_repo_schema(base_name = "string_struct", folder_path = "example/root/fields")] + #[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription"), + ), + rename_all = "camelCase" + )] + struct ExampleStringStruct(String); + + #[derive(Clone, Debug, Default, JsonSchema, DscRepoSchema)] + #[dsc_repo_schema(base_name = "object_struct", folder_path = "example/root/fields")] + #[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription"), + ), + rename_all = "camelCase" + )] + struct ExampleObjectStruct { + #[schemars( + title = schema_i18n!("fields.nested_string_enum.title"), + description = schema_i18n!("fields.nested_string_enum.description"), + extend( + "markdownDescription" = schema_i18n!("fields.nested_string_enum.markdownDescription"), + ), + )] + nested_string_enum: ExampleStringEnum, + #[schemars( + title = schema_i18n!("fields.nested_boolean_struct.title"), + description = schema_i18n!("fields.nested_boolean_struct.description"), + extend( + "markdownDescription" = schema_i18n!("fields.nested_boolean_struct.markdownDescription"), + ), + )] + nested_boolean_struct: ExampleBooleanStruct, + } + + #[derive(Clone, Debug, JsonSchema, DscRepoSchema)] + #[dsc_repo_schema( + base_name = "document", + folder_path = "example/root", + should_bundle = true, + schema_field( + name = schema_version, + title = schema_i18n!("fields.schema_version.title"), + description = schema_i18n!("fields.schema_version.description"), + markdown_description = schema_i18n!("fields.schema_version.markdownDescription") + ) + )] + #[schemars( + title = schema_i18n!("title"), + description = schema_i18n!("description"), + extend( + "$id" = Self::default_export_schema_id_uri(), + "markdownDescription" = schema_i18n!("markdownDescription"), + ), + rename_all = "camelCase" + )] + struct ExampleRootDocument { + pub schema_version: String, + #[schemars( + title = schema_i18n!("fields.string_enum.title"), + description = schema_i18n!("fields.string_enum.description"), + extend( + "markdownDescription" = schema_i18n!("fields.string_enum.markdownDescription"), + ), + )] + string_enum: ExampleStringEnum, + #[schemars( + title = schema_i18n!("fields.tagged_variant_enum.title"), + description = schema_i18n!("fields.tagged_variant_enum.description"), + extend( + "markdownDescription" = schema_i18n!("fields.tagged_variant_enum.markdownDescription"), + ), + )] + tagged_variant_enum: ExampleTaggedVariantEnum, + #[schemars( + title = schema_i18n!("fields.untagged_variant_enum.title"), + description = schema_i18n!("fields.untagged_variant_enum.description"), + extend( + "markdownDescription" = schema_i18n!("fields.untagged_variant_enum.markdownDescription"), + ), + )] + untagged_variant_enum: ExampleUntaggedVariantEnum, + #[schemars( + title = schema_i18n!("fields.boolean_struct.title"), + description = schema_i18n!("fields.boolean_struct.description"), + extend( + "markdownDescription" = schema_i18n!("fields.boolean_struct.markdownDescription"), + ), + )] + boolean_struct: ExampleBooleanStruct, + #[schemars( + title = schema_i18n!("fields.string_struct.title"), + description = schema_i18n!("fields.string_struct.description"), + extend( + "markdownDescription" = schema_i18n!("fields.string_struct.markdownDescription"), + ), + )] + string_struct: ExampleStringStruct, + #[schemars( + title = schema_i18n!("fields.object_struct.title"), + description = schema_i18n!("fields.object_struct.description"), + extend( + "markdownDescription" = schema_i18n!("fields.object_struct.markdownDescription"), + ), + )] + object_struct: ExampleObjectStruct, + } + impl Default for ExampleRootDocument { + fn default() -> Self { + Self { + schema_version: Self::default_schema_id_uri(), + string_enum: ExampleStringEnum::default(), + tagged_variant_enum: ExampleTaggedVariantEnum::default(), + untagged_variant_enum: ExampleUntaggedVariantEnum::default(), + boolean_struct: ExampleBooleanStruct::default(), + string_struct: ExampleStringStruct::default(), + object_struct: ExampleObjectStruct::default(), + } + } + } + + static ROOT_SCHEMA_CANONICAL: LazyLock<Schema> = LazyLock::new(|| { + ExampleRootDocument::generate_exportable_schema( + RecognizedSchemaVersion::VNext, + SchemaForm::Canonical + ) + }); + + #[test] fn test_default_schema_id_uri() { + assert_eq!( + ExampleRootDocument::default_schema_id_uri(), + "https://aka.ms/dsc/schemas/v3/bundled/example/root/document.json".to_string() + ) + } + + #[test] fn test_get_canonical_schema_id_uri() { + assert_eq!( + ExampleRootDocument::get_canonical_schema_id_uri(RecognizedSchemaVersion::V3), + "https://aka.ms/dsc/schemas/v3/example/root/document.json".to_string() + ) + } + + #[test] fn test_get_bundled_schema_id_uri() { + assert_eq!( + ExampleRootDocument::get_bundled_schema_id_uri(RecognizedSchemaVersion::V3), + Some("https://aka.ms/dsc/schemas/v3/bundled/example/root/document.json".to_string()) + ) + } + + #[test] fn test_get_enhanced_schema_id_uri() { + assert_eq!( + ExampleRootDocument::get_enhanced_schema_id_uri(RecognizedSchemaVersion::V3), + Some("https://aka.ms/dsc/schemas/v3/bundled/example/root/document.vscode.json".to_string()) + ) + } + + #[test] fn test_recognized_schema_uris_subschema() { + let ref mut generator = schemars::SchemaGenerator::default(); + let subschema = ExampleRootDocument::recognized_schema_uris_subschema(generator); + + let enum_subschema = subschema.get_keyword_as_array("enum").unwrap(); + let enum_count = enum_subschema.len(); + let expected_count = RecognizedSchemaVersion::all().len() * 6; + assert_eq!( + enum_count, + expected_count + ); + + assert_eq!( + subschema.get_keyword_as_str("type"), + Some("string") + ); + + assert_eq!( + subschema.get_keyword_as_str("format"), + Some("uri") + ); + + assert_eq!( + subschema.get_keyword_as_str("title"), + Some("Schema version") + ); + + assert_eq!( + subschema.get_keyword_as_str("description"), + Some("Defines the schema version with the DscRepoSchema trait.") + ); + assert_eq!( + subschema.get_keyword_as_str("markdownDescription"), + Some("Defines the schema version with the `DscRepoSchema` trait.") + ); + } + + #[test] fn test_is_recognized_schema_uri() { + assert_eq!( + ExampleRootDocument::is_recognized_schema_uri(&"https://incorrect/uri.json".to_string()), + false + ); + + assert_eq!( + ExampleRootDocument::is_recognized_schema_uri(&ExampleRootDocument::default_schema_id_uri()), + true + ); + } + + #[test] fn test_validate_schema_uri() { + let valid_instance = ExampleRootDocument::default(); + + assert_eq!( + valid_instance.validate_schema_uri(), + Ok(()) + ); + + let invalid_uri = "https://incorrect/uri.json".to_string(); + let invalid_instance = ExampleRootDocument { + schema_version: invalid_uri.clone(), + ..Default::default() + }; + + assert_eq!( + invalid_instance.validate_schema_uri(), + Err(UnrecognizedSchemaUriError(invalid_uri, ExampleRootDocument::recognized_schema_uris())) + ) + } + + #[test] fn test_schema_docs() { + let schema = &schema_for!(ExampleRootDocument); + assert_eq!( + schema.get_keyword_as_str("title"), + Some("Example document title") + ); + assert_eq!( + schema.get_keyword_as_str("description"), + Some("Example document description") + ); + assert_eq!( + schema.get_keyword_as_str("markdownDescription"), + Some("Example document markdown description.") + ); + + let fields = vec![ + "string_enum", + "tagged_variant_enum", + "untagged_variant_enum", + "boolean_struct", + "string_struct", + "object_struct" + ]; + fn camel_case_field(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut uppercase_next = false; + + for character in s.chars() { + if character == '_' { + uppercase_next = !result.is_empty(); + } else if result.is_empty() { + result.extend(character.to_lowercase()); + uppercase_next = false; + } else if uppercase_next { + result.extend(character.to_uppercase()); + uppercase_next = false; + } else { + result.push(character); + } + } + + result + } + + for field in fields { + let field_text = field.replace("_", " "); + let property_name = camel_case_field(field); + let property_schema = schema.get_property_subschema(&property_name).unwrap(); + + assert_eq!( + property_schema.get_keyword_as_string("title"), + Some(format!("Example document {field_text} field")) + ); + assert_eq!( + property_schema.get_keyword_as_string("description"), + Some(format!("Example document {field_text} description.")) + ); + assert_eq!( + property_schema.get_keyword_as_string("markdownDescription"), + Some(format!("Example document {field_text} markdown description.")) + ); + } + } + +} \ No newline at end of file diff --git a/lib/dsc-lib-jsonschema/tests/integration_locales/schemas.yaml b/lib/dsc-lib-jsonschema/tests/integration_locales/schemas.yaml index 1dcc16d6c..b0de51990 100644 --- a/lib/dsc-lib-jsonschema/tests/integration_locales/schemas.yaml +++ b/lib/dsc-lib-jsonschema/tests/integration_locales/schemas.yaml @@ -2,6 +2,222 @@ _version: 2 schemas: example: + root: + document: + title: + en-US: Example document title + description: + en-US: Example document description + markdownDescription: + en-US: |- + Example document markdown description. + fields: + string_enum: + title: + en-US: Example document string enum field + description: + en-US: >- + Example document string enum description. + markdownDescription: + en-US: |- + Example document string enum markdown description. + tagged_variant_enum: + title: + en-US: Example document tagged variant enum field + description: + en-US: >- + Example document tagged variant enum description. + markdownDescription: + en-US: |- + Example document tagged variant enum markdown description. + untagged_variant_enum: + title: + en-US: Example document untagged variant enum field + description: + en-US: >- + Example document untagged variant enum description. + markdownDescription: + en-US: |- + Example document untagged variant enum markdown description. + boolean_struct: + title: + en-US: Example document boolean struct field + description: + en-US: >- + Example document boolean struct description. + markdownDescription: + en-US: |- + Example document boolean struct markdown description. + string_struct: + title: + en-US: Example document string struct field + description: + en-US: >- + Example document string struct description. + markdownDescription: + en-US: |- + Example document string struct markdown description. + object_struct: + title: + en-US: Example document object struct field + description: + en-US: >- + Example document object struct description. + markdownDescription: + en-US: |- + Example document object struct markdown description. + schema_version: + title: + en-US: Schema version + description: + en-US: >- + Defines the schema version with the DscRepoSchema trait. + markdownDescription: + en-US: |- + Defines the schema version with the `DscRepoSchema` trait. + fields: + string_enum: + title: + en-US: Example string enum + description: + en-US: >- + Defines a string enum with the DscRepoSchema trait. + markdownDescription: + en-US: |- + Defines a string enum with the `DscRepoSchema` trait. + variants: + foo: + title: + en-US: String enum foo variant + description: + en-US: >- + Defines the foo variant. + markdownDescription: + en-US: |- + Defines the `foo` variant. + bar: + title: + en-US: String enum bar variant + description: + en-US: >- + Defines the bar variant. + markdownDescription: + en-US: |- + Defines the `bar` variant. + baz: + title: + en-US: String enum baz variant + description: + en-US: >- + Defines the baz variant. + markdownDescription: + en-US: |- + Defines the `baz` variant. + tagged_variant_enum: + title: + en-US: Example tagged variant enum + description: + en-US: >- + Defines a tagged variant enum with the DscRepoSchema trait. + markdownDescription: + en-US: |- + Defines a tagged variant enum with the `DscRepoSchema` trait. + variants: + string: + title: + en-US: String variant + description: + en-US: >- + Defines the string variant. + markdownDescription: + en-US: |- + Defines the `string` variant. + boolean: + title: + en-US: Boolean variant + description: + en-US: >- + Defines the boolean variant. + markdownDescription: + en-US: |- + Defines the `boolean` variant. + untagged_variant_enum: + title: + en-US: Example untagged variant enum + description: + en-US: >- + Defines an untagged variant enum with the DscRepoSchema trait. + markdownDescription: + en-US: |- + Defines an untagged variant enum with the `DscRepoSchema` trait. + variants: + string: + title: + en-US: String variant + description: + en-US: >- + Defines the string variant. + markdownDescription: + en-US: |- + Defines the `string` variant. + boolean: + title: + en-US: Boolean variant + description: + en-US: >- + Defines the boolean variant. + markdownDescription: + en-US: |- + Defines the `boolean` variant. + boolean_struct: + title: + en-US: Example boolean struct + description: + en-US: >- + Defines a boolean struct with the DscRepoSchema trait. + markdownDescription: + en-US: |- + Defines a boolean struct with the `DscRepoSchema` trait. + string_struct: + title: + en-US: Example string struct + description: + en-US: >- + Defines a string struct with the DscRepoSchema trait. + markdownDescription: + en-US: |- + Defines a string struct with the `DscRepoSchema` trait. + object_struct: + title: + en-US: Example object struct + description: + en-US: >- + Defines an object struct with the DscRepoSchema trait. + markdownDescription: + en-US: |- + Defines an object struct with the `DscRepoSchema` trait. + fields: + nested_string_enum: + title: + en-US: Nested string enum field + description: + en-US: >- + Defines the nested string enum field. + markdownDescription: + en-US: |- + Defines the `nested_string_enum` field. + nested_boolean_struct: + title: + en-US: Nested boolean struct field + description: + en-US: >- + Defines the nested boolean struct field. + markdownDescription: + en-US: |- + Defines the `nested_boolean_struct` field. + + + valid: enum: title: From 86c35ca8d9a80d9eb6f27692a2babf6ea67f7d9e Mon Sep 17 00:00:00 2001 From: Mikey Lombardi <mlombardi@microsoft.com> Date: Fri, 9 Oct 2026 11:05:33 -0500 Subject: [PATCH 6/8] (MAINT) Fix clippy and doctest errors --- .../src/dsc_repo/dsc_repo_schema.rs | 69 ++++++++++--------- .../src/schema_utility_extensions.rs | 4 +- .../src/types/fully_qualified_type_name.rs | 7 +- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/dsc_repo_schema.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/dsc_repo_schema.rs index ed0b96ef5..ba6a439d5 100644 --- a/lib/dsc-lib-jsonschema/src/dsc_repo/dsc_repo_schema.rs +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/dsc_repo_schema.rs @@ -47,7 +47,7 @@ pub trait DscRepoSchema : JsonSchema { const SCHEMA_SHOULD_BUNDLE: bool; /// Defines the translation root key to use when resolving translations for JSON Schema keywords. - /// + /// /// All bundled schemas must include documentation keywords like `title` and `description`. const SCHEMA_I18N_ROOT_KEY: &'static str; @@ -84,7 +84,7 @@ pub trait DscRepoSchema : JsonSchema { /// function overrides this default when exporting schemas for various versions and forms. /// /// [`generate_exportable_schema()`]: DscRepoSchema::generate_exportable_schema - #[must_use] + #[must_use] fn default_export_schema_id_uri() -> String { Self::get_schema_id_uri( RecognizedSchemaVersion::VNext, @@ -97,14 +97,14 @@ pub trait DscRepoSchema : JsonSchema { /// /// Use this to define the `$schema` keyword when deriving or manually implementing the /// [`schemars::JsonSchema`] trait. - #[must_use] + #[must_use] fn default_export_meta_schema_uri() -> String { "https://json-schema.org/draft/2020-12/schema".to_string() } /// Generates the JSON schema for a given version and form. This function is /// useful for exporting the JSON Schema to disk. - #[must_use] + #[must_use] fn generate_exportable_schema( schema_version: RecognizedSchemaVersion, schema_form: SchemaForm @@ -113,7 +113,7 @@ pub trait DscRepoSchema : JsonSchema { } /// Generates the JSON Schema for a given version, form, and URI prefix. - #[must_use] + #[must_use] fn generate_schema( schema_version: RecognizedSchemaVersion, schema_form: SchemaForm, @@ -176,7 +176,7 @@ pub trait DscRepoSchema : JsonSchema { } /// Returns the path for a schema relative to the `schemas` folder. - #[must_use] + #[must_use] fn get_schema_relative_path( schema_version: RecognizedSchemaVersion, schema_form: SchemaForm @@ -338,7 +338,7 @@ pub trait DscRepoSchema : JsonSchema { /// /// - If the value is `true`, all schema forms are valid for the type. /// - If the value is `false`, only [`SchemaForm::Canonical`] is valid for the type. - #[must_use] + #[must_use] fn get_valid_schema_forms() -> Vec<SchemaForm> { if Self::SCHEMA_SHOULD_BUNDLE { vec![SchemaForm::VSCode, SchemaForm::Bundled, SchemaForm::Canonical] @@ -347,31 +347,36 @@ pub trait DscRepoSchema : JsonSchema { } } - /// Retrieves a translation string using a dot-path relative to the [`SCHEMA_I18N_ROOT_KEY`]. - /// + /// Retrieves a translation string using a dot-path relative to the + /// [`SCHEMA_I18N_ROOT_KEY`]. + /// /// # Arguments - /// - /// - `suffix` - The string to append to the root key, like `items.description`. - /// + /// + /// - `suffix` - The string to append to the root key, like + /// `items.description`. + /// /// # Errors - /// - /// Returns a [`DscRepoSchemaMissingTranslationError`] error if the translation key doesn't exist. + /// + /// Returns a [`DscRepoSchemaMissingTranslationError`] error if the + /// translation key doesn't exist. + /// + /// [`SCHEMA_I18N_ROOT_KEY`]: Self::SCHEMA_I18N_ROOT_KEY fn schema_i18n(suffix: &str) -> Result<String, DscRepoSchemaMissingTranslationError>; /// Transforms the `$schema` and `$id` fields of the given schema to use the default export URIs. - /// + /// /// This associated function modifies the given schema in-place to use the default export URIs for the `$schema` /// and `$id` fields. - /// + /// /// # Arguments - /// + /// /// - `schema` - The mutable reference to the schema to be transformed. - /// + /// /// # Example /// /// The first definition shows how you must manually define the `$schema` and `$id` fields /// without this transformer. - /// + /// /// ```ignore /// # // Ignore the example because derive doesn't work in the crate doc tests /// #[derive(Debug, Clone, JsonSchema, DscRepoSchema)] @@ -386,9 +391,9 @@ pub trait DscRepoSchema : JsonSchema { /// pub field: String, /// } /// ``` - /// + /// /// With this transform, the definition is much shorter: - /// + /// /// ```ignore /// # // Ignore the example because derive doesn't work in the crate doc tests /// #[derive(Debug, Clone, JsonSchema, DscRepoSchema)] @@ -405,17 +410,17 @@ pub trait DscRepoSchema : JsonSchema { /// Inserts the localized documentation (title, description, and markdown description) into the /// schema if available. - /// + /// /// This transform overrides the following keywords in the schema if the matching localized /// documentation is available: - /// + /// /// - `title` /// - `description` /// - `markdownDescription` - /// - /// To panic on missing translations, use the [`transform_schema_docs_strict`] associated + /// + /// To panic on missing translations, use the [`transform_schema_docs_strict`] associated /// function instead. - /// + /// /// [`transform_schema_docs_strict`]: Self::transform_schema_docs_strict fn transform_schema_docs(schema: &mut schemars::Schema) { use super::super::vscode::VSCodeSchemaExtensions; @@ -433,22 +438,22 @@ pub trait DscRepoSchema : JsonSchema { /// Inserts the localized documentation (title, description, and markdown description) into the /// schema and panics if any translations are missing. - /// + /// /// This transform overrides the following keywords in the schema if the matching localized /// documentation is available: - /// + /// /// - `title` /// - `description` /// - `markdownDescription` - /// - /// To silently ignore missing translations, use the [`transform_schema_docs`] associated + /// + /// To silently ignore missing translations, use the [`transform_schema_docs`] associated /// function instead. - /// + /// /// # Panics /// /// This function will panic if any of the localized documentation translations are missing for /// any of the keywords. - /// + /// /// [`transform_schema_docs`]: Self::transform_schema_docs fn transform_schema_docs_strict(schema: &mut schemars::Schema) { use super::super::vscode::VSCodeSchemaExtensions; diff --git a/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs index a1df90dfb..cef04ed0c 100644 --- a/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs +++ b/lib/dsc-lib-jsonschema/src/schema_utility_extensions.rs @@ -2451,7 +2451,9 @@ impl SchemaUtilityExtensions for Schema { if let Some(schema_id_url) = self.get_id_as_url() { let resource_id_url = url::Url::parse(resource_id) - .expect("$id keyword values should always parse as URLs"); + .unwrap_or_else(|_| panic!( + "$id keyword values should always parse as URLs - invalid ID '{resource_id}'" + )); if schema_id_url.host() == resource_id_url.host() { matching_references.push(resource_id_url[Position::BeforePath..].to_string()); } 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 10231266b..b9780c644 100644 --- a/lib/dsc-lib/src/types/fully_qualified_type_name.rs +++ b/lib/dsc-lib/src/types/fully_qualified_type_name.rs @@ -271,9 +271,8 @@ pub enum FullyQualifiedTypeNameError { /// This static lazily defines the validating regex for [`FullyQualifiedTypeName`]. It enables the /// [`Regex`] instance to be constructed once, the first time it's used, and then reused on all -/// subsequent validation calls. It's kept private, since the API usage is to invoke the -/// [`FullyQualifiedTypeName::validate()`] method for direct validation or to leverage this static -/// from within the constructor for [`FullyQualifiedTypeName`]. +/// subsequent validation calls. It's kept private, since the API usage is to leverage this static +/// from within [`FullyQualifiedTypeName::parse()`] to construct a new instance. static VALIDATING_SEGMENT_REGEX: OnceLock<Regex> = OnceLock::new(); impl FullyQualifiedTypeName { @@ -460,6 +459,8 @@ impl FullyQualifiedTypeName { /// The method returns the parsed segments (`owner`, `namespaces`, and `name`) as a tuple. Any /// validation errors encountered during parsing are added to the provided `errors` vector for /// the caller to handle. + /// + /// [`WildcardTypeName`]: crate::types::WildcardTypeName pub(crate) fn parse_segments( text: &str, validating_segment_regex: &Regex, From 1f35645a217cc98489453182471327b73ab68448 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi <mlombardi@microsoft.com> Date: Fri, 9 Oct 2026 11:07:21 -0500 Subject: [PATCH 7/8] (MAINT) Ensure i18n tests skip comment lines Prior to this change, the i18n tests would find any apparent usage of the `t!` macro in a file. This unfortunately also included documentation comments, which raised errors for undefined translation strings that are not expected to be defined. This change strips lines that begin with a comment before searching for the expected patterns. --- dsc/tests/dsc_i18n.tests.ps1 | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/dsc/tests/dsc_i18n.tests.ps1 b/dsc/tests/dsc_i18n.tests.ps1 index 049780c72..a70c7c8d8 100644 --- a/dsc/tests/dsc_i18n.tests.ps1 +++ b/dsc/tests/dsc_i18n.tests.ps1 @@ -135,6 +135,16 @@ BeforeDiscovery { } } + function Get-ContentWithoutComments { + param( + [Parameter(Mandatory)] + [string[]]$Path + ) + + $lines = Get-Content -Path $Path | Where-Object { $_ -notmatch '^\s*(//|///|//!)\s*' } + $lines -join "`n" + } + function Get-TranslationKey { [cmdletbinding()] [OutputType([string[]])] @@ -159,7 +169,7 @@ BeforeDiscovery { Get-ChildItem -Recurse -Path $ProjectDirectory -Include *.rs -File | ForEach-Object { $file = $_ - $content = Get-Content -Path $file -Raw + $content = Get-ContentWithoutComments -Path $file.FullName foreach ($pattern in $patterns.keys) { ($content | Select-String -Pattern $patterns[$pattern] -AllMatches).Matches | ForEach-Object { if ($null -ne $_) { From df7663e73747bd1826e37ae28d26ad17e7c0776c Mon Sep 17 00:00:00 2001 From: Mikey Lombardi <mlombardi@microsoft.com> Date: Fri, 9 Oct 2026 11:07:47 -0500 Subject: [PATCH 8/8] (MAINT) Add convenience snippets for defining settings errors --- .vscode/dsc-lib.settings.code-snippets | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .vscode/dsc-lib.settings.code-snippets diff --git a/.vscode/dsc-lib.settings.code-snippets b/.vscode/dsc-lib.settings.code-snippets new file mode 100644 index 000000000..5c62e3fc2 --- /dev/null +++ b/.vscode/dsc-lib.settings.code-snippets @@ -0,0 +1,38 @@ +{ + "string_enum_error": { + "prefix": "string_enum_error", + "scope": "rust", + "include": "**/dsc-lib/src/settings/errors.rs", + "body": [ + "/// Indicates that an input string failed to parse case-insensitively into a valid variant", + "/// of [`${2:StringEnumFieldType}`].", + "#[error(\"{t}\", t = t!(", + " concat!(\"settings.errors.\", \"${3:i18n_key}\"),", + " text = text,", + " variant_list = <${2:StringEnumFieldType}>::VARIANT_LIST,", + " variant_last = <${2:StringEnumFieldType}>::VARIANT_LAST,", + "))]", + "${1:Variant} {", + " text: String,", + "},", + ], + "description": "Insert a new error variant for an invalid string enum value" + }, + "collected_errors": { + "prefix": "collected_errors", + "scope": "rust", + "include": "**/dsc-lib/src/settings/errors.rs", + "body": [ + "#[error(\"{t}\", t = t!(", + " concat!(\"settings.errors.\", \"${2:i18n_key}\"),", + " err = DscSettingsError::collect_errors_for_message(&errors),", + "))]", + "${1:VariantPrefix}MultipleErrors {", + " /// Collected errors encountered while ${3:<describe context>}.", + " #[related]", + " errors: Vec<DscSettingsError>,", + "},", + ], + "description": "Insert a new collected errors variant" + } +} \ No newline at end of file