From d1196c03e554d8e95013d695cfa413be4634b61a Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 15 Aug 2026 21:20:56 +0200 Subject: [PATCH] v0.1.3-pre.008 --- Cargo.toml | 4 +- crates/ksp-config-lib/src/document.rs | 6 +- crates/ksp-config-lib/src/error.rs | 5 +- crates/ksp-config-lib/src/lib.rs | 15 +- crates/ksp-config-lib/src/profile.rs | 236 +++++++++++++++ crates/ksp-config-lib/tests/public_api.rs | 28 +- crates/ksp-config-lib/unit_tests/document.rs | 83 +++++- crates/ksp-config-lib/unit_tests/profile.rs | 70 +++++ deltas/0.1.3/pre.008.md | 268 ++++++++++++++++++ docs/plans/000-README.md | 4 +- docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md | 4 +- .../005-V0_1_3_CONFIG_FOUNDATION_PLAN.md | 26 +- docs/rules/FILE_CONTRACTS.md | 4 +- 13 files changed, 728 insertions(+), 25 deletions(-) create mode 100644 crates/ksp-config-lib/src/profile.rs create mode 100644 crates/ksp-config-lib/unit_tests/profile.rs create mode 100644 deltas/0.1.3/pre.008.md diff --git a/Cargo.toml b/Cargo.toml index 5a16cf3..22be1f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 48 +# version: 49 [workspace] resolver = "3" members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"] [workspace.package] -version = "0.1.3-pre.7" +version = "0.1.3-pre.8" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-config-lib/src/document.rs b/crates/ksp-config-lib/src/document.rs index 0bfb081..88f893a 100644 --- a/crates/ksp-config-lib/src/document.rs +++ b/crates/ksp-config-lib/src/document.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/document.rs -// version: 1 +// version: 2 /// A Config-managed JSON document that has passed syntax, schema and current semantic validation. #[derive(Clone, Debug, PartialEq)] @@ -208,6 +208,10 @@ fn validate_instance(document: &ConfigJsonDocument, schema: &ConfigJsonDocument) } fn validate_document_semantics(document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> { + let profile_validation = crate::profile::validate_document_profile_contract(document); + if let std::result::Result::Err(error) = profile_validation { + return std::result::Result::Err(error); + } if document.file_id().as_str() != crate::FILE_ID_STD_LOGGING { return std::result::Result::Ok(()); } diff --git a/crates/ksp-config-lib/src/error.rs b/crates/ksp-config-lib/src/error.rs index b544e0c..6778387 100644 --- a/crates/ksp-config-lib/src/error.rs +++ b/crates/ksp-config-lib/src/error.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/error.rs -// version: 3 +// version: 4 /// Error code used when a Config bootstrap argument is missing its value. pub const ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_argument_missing_value"); @@ -33,3 +33,6 @@ pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_cor /// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type. pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid"); + +/// Error code used when an explicitly requested Config profile does not exist in a validated document. +pub const ERROR_CODE_PROFILE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "profile_not_found"); diff --git a/crates/ksp-config-lib/src/lib.rs b/crates/ksp-config-lib/src/lib.rs index 939c540..ac9bdcb 100644 --- a/crates/ksp-config-lib/src/lib.rs +++ b/crates/ksp-config-lib/src/lib.rs @@ -1,18 +1,19 @@ // file: crates/ksp-config-lib/src/lib.rs -// version: 3 +// version: 4 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] //! KSP-owned application configuration facade. //! -//! `0.1.3-pre.007` owns the non-recursive bootstrap roots, stable logical file registry and first generic JSON/JSON Schema loading pipeline. The standard -//! Logging document is the first registered runtime document. Profile resolution, environment substitution, sensitivity and persistence remain in later bounded +//! `0.1.3-pre.008` owns bootstrap roots, the logical file registry, generic JSON/JSON Schema loading and standard-document profile resolution. The standard +//! Logging document is the first registered runtime document. Composite resolution, environment substitution, sensitivity and persistence remain in later bounded //! prereleases. mod bootstrap; mod document; mod error; +mod profile; mod registry; /// Bootstrap argument used to replace the configuration document root. @@ -47,10 +48,18 @@ pub use self::error::ERROR_CODE_FILE_MAPPING_INVALID; pub use self::error::ERROR_CODE_JSON_FILE_READ_FAILED; /// Error code used when a Config-managed file contains invalid JSON syntax. pub use self::error::ERROR_CODE_JSON_SYNTAX_INVALID; +/// Error code used when an explicitly requested Config profile does not exist. +pub use self::error::ERROR_CODE_PROFILE_NOT_FOUND; /// Error code used when a JSON Schema document is itself invalid. pub use self::error::ERROR_CODE_SCHEMA_INVALID; /// Error code used when a Config document fails its registered JSON Schema validation. pub use self::error::ERROR_CODE_SCHEMA_VALIDATION_FAILED; +/// Source that selected an effective standard Config profile. +pub use self::profile::ConfigProfileSelectionSource; +/// Origin of one top-level value in a resolved standard Config profile. +pub use self::profile::ConfigValueOrigin; +/// Validated standard Config document resolved to one profile with global/profile provenance. +pub use self::profile::ResolvedConfigProfile; /// Bootstrap argument used to replace a known Config filename mapping. pub use self::registry::ARG_FILE_MAP; /// Logical descriptor associating a stable file identifier with its physical filename and validation schema. diff --git a/crates/ksp-config-lib/src/profile.rs b/crates/ksp-config-lib/src/profile.rs new file mode 100644 index 0000000..91b98ff --- /dev/null +++ b/crates/ksp-config-lib/src/profile.rs @@ -0,0 +1,236 @@ +// file: crates/ksp-config-lib/src/profile.rs +// version: 1 + +/// Origin of one top-level value in a resolved standard Config profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConfigValueOrigin { + /// Value comes from the global section of the specialized document. + Global, + /// Value comes from the selected profile object. + Profile, +} + +/// Source that selected the effective profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConfigProfileSelectionSource { + /// The document's autonomous `default_profile` selected the profile. + DefaultProfile, + /// A caller explicitly requested the profile by `profile_id`. + Explicit, +} + +/// Validated standard document resolved to one profile while retaining global/profile provenance. +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedConfigProfile { + file_id: crate::ConfigFileId, + path: std::path::PathBuf, + profile_id: String, + selection_source: ConfigProfileSelectionSource, + globals: serde_json::Map, + profile: serde_json::Map, + effective: serde_json::Map, + origins: std::collections::BTreeMap, +} + +impl ResolvedConfigProfile { + /// Returns the logical document identifier from which this profile was resolved. + #[must_use] + pub fn file_id(&self) -> &crate::ConfigFileId { + return &self.file_id; + } + + /// Returns the physical path of the validated source document. + #[must_use] + pub fn path(&self) -> &std::path::Path { + return self.path.as_path(); + } + + /// Returns the selected unique profile identifier. + #[must_use] + pub fn profile_id(&self) -> &str { + return self.profile_id.as_str(); + } + + /// Returns whether selection came from `default_profile` or an explicit caller request. + #[must_use] + pub const fn selection_source(&self) -> ConfigProfileSelectionSource { + return self.selection_source; + } + + /// Returns document-global values, excluding the reserved `default_profile` and `profiles` keys. + #[must_use] + pub fn globals(&self) -> &serde_json::Map { + return &self.globals; + } + + /// Returns the selected profile object including its `profile_id`. + #[must_use] + pub fn profile(&self) -> &serde_json::Map { + return &self.profile; + } + + /// Returns a deterministic top-level effective view in which selected profile keys override same-named global keys. + #[must_use] + pub fn effective(&self) -> &serde_json::Map { + return &self.effective; + } + + /// Returns the top-level provenance for an effective key. + #[must_use] + pub fn origin(&self, key: &str) -> std::option::Option { + return self.origins.get(key).copied(); + } +} + +impl crate::ConfigDocumentEngine { + /// Loads, validates and resolves one standard Config document to its default or explicitly requested profile. + /// + /// Passing `None` selects the autonomous `default_profile` declared by the document. Passing `Some(profile_id)` selects that profile explicitly. + /// Environment interpolation and composite overrides are intentionally not applied by this prerelease. + pub fn load_resolved_profile( + &self, + file_id: &crate::ConfigFileId, + requested_profile: std::option::Option<&str>, + ) -> ksp_core_lib::Result { + let document = self.load_validated_document(file_id); + let document = match document { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return resolve_document_profile(&document, requested_profile); + } +} + +pub(crate) fn validate_document_profile_contract(document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> { + let root = match document.value().as_object() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Ok(()), + }; + let default_profile = root.get("default_profile"); + let profiles = root.get("profiles"); + if default_profile.is_none() && profiles.is_none() { + return std::result::Result::Ok(()); + } + let default_profile = match default_profile.and_then(serde_json::Value::as_str) { + std::option::Option::Some(value) if !value.trim().is_empty() => value, + _ => return std::result::Result::Err(profile_semantic_error(document, "default_profile must identify a non-empty profile_id")), + }; + let profiles = match profiles.and_then(serde_json::Value::as_array) { + std::option::Option::Some(value) if !value.is_empty() => value, + _ => return std::result::Result::Err(profile_semantic_error(document, "profiles must contain at least one profile object")), + }; + let mut ids = std::collections::BTreeSet::::new(); + let mut default_found = false; + for (index, profile) in profiles.iter().enumerate() { + let object = match profile.as_object() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + profile_semantic_error(document, "profile entry must be an object").with_context("profile_index", index.to_string()), + ); + }, + }; + let profile_id = match object.get("profile_id").and_then(serde_json::Value::as_str) { + std::option::Option::Some(value) if !value.trim().is_empty() => value, + _ => { + return std::result::Result::Err( + profile_semantic_error(document, "profile entry must declare a non-empty profile_id").with_context("profile_index", index.to_string()), + ); + }, + }; + if ids.contains(profile_id) { + return std::result::Result::Err( + profile_semantic_error(document, "profile_id values must be unique") + .with_context("profile_index", index.to_string()) + .with_context("profile_id", profile_id), + ); + } + ids.insert(profile_id.to_owned()); + if profile_id == default_profile { + default_found = true; + } + } + if !default_found { + return std::result::Result::Err( + profile_semantic_error(document, "default_profile must reference an existing profile_id").with_context("default_profile", default_profile), + ); + } + return std::result::Result::Ok(()); +} + +fn resolve_document_profile(document: &crate::ConfigJsonDocument, requested_profile: std::option::Option<&str>) -> ksp_core_lib::Result { + let root = match document.value().as_object() { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires an object document")), + }; + let default_profile = match root.get("default_profile").and_then(serde_json::Value::as_str) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires default_profile")), + }; + let (selected_profile, selection_source) = match requested_profile { + std::option::Option::Some(value) => (value, ConfigProfileSelectionSource::Explicit), + std::option::Option::None => (default_profile, ConfigProfileSelectionSource::DefaultProfile), + }; + let profiles = match root.get("profiles").and_then(serde_json::Value::as_array) { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(profile_semantic_error(document, "profile resolution requires profiles")), + }; + let mut selected: std::option::Option<&serde_json::Map> = std::option::Option::None; + for profile in profiles { + let object = match profile.as_object() { + std::option::Option::Some(value) => value, + std::option::Option::None => continue, + }; + if object.get("profile_id").and_then(serde_json::Value::as_str) == std::option::Option::Some(selected_profile) { + selected = std::option::Option::Some(object); + break; + } + } + let selected = match selected { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_PROFILE_NOT_FOUND, "requested Config profile does not exist") + .with_context("file_id", document.file_id().as_str()) + .with_context("path", document.path().to_string_lossy().into_owned()) + .with_context("profile_id", selected_profile), + ); + }, + }; + let mut globals = serde_json::Map::::new(); + let mut effective = serde_json::Map::::new(); + let mut origins = std::collections::BTreeMap::::new(); + for (key, value) in root { + if key != "default_profile" && key != "profiles" { + globals.insert(key.clone(), value.clone()); + effective.insert(key.clone(), value.clone()); + origins.insert(key.clone(), ConfigValueOrigin::Global); + } + } + let profile = (*selected).clone(); + for (key, value) in &profile { + effective.insert(key.clone(), value.clone()); + origins.insert(key.clone(), ConfigValueOrigin::Profile); + } + return std::result::Result::Ok(ResolvedConfigProfile { + file_id: document.file_id().clone(), + path: document.path().to_path_buf(), + profile_id: selected_profile.to_owned(), + selection_source, + globals, + profile, + effective, + origins, + }); +} + +fn profile_semantic_error(document: &crate::ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document violates KSP profile invariants") + .with_context("file_id", document.file_id().as_str()) + .with_context("path", document.path().to_string_lossy().into_owned()) + .with_context("reason", reason); +} + +#[cfg(test)] +#[path = "../unit_tests/profile.rs"] +mod tests; diff --git a/crates/ksp-config-lib/tests/public_api.rs b/crates/ksp-config-lib/tests/public_api.rs index d2e3983..b9deab7 100644 --- a/crates/ksp-config-lib/tests/public_api.rs +++ b/crates/ksp-config-lib/tests/public_api.rs @@ -1,7 +1,7 @@ // file: crates/ksp-config-lib/tests/public_api.rs -// version: 4 +// version: 5 -//! Integration tests for the public `ksp-config-lib` bootstrap, logical file registry and validated JSON document contracts. +//! Integration tests for the public `ksp-config-lib` bootstrap, file registry, validated JSON document and profile-resolution contracts. #[test] fn bootstrap_contract_is_available_from_crate_root() { @@ -92,3 +92,27 @@ fn validated_json_document_engine_is_available_from_crate_root() { } } } + +#[test] +fn resolved_profile_contract_is_available_from_crate_root() { + let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); + let registry = ksp_config_lib::ConfigFileRegistry::defaults(); + let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING); + assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}"); + assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}"); + assert!(file_id.is_ok(), "public Logging file_id should remain constructible: {file_id:?}"); + if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) { + let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry); + let resolved = engine.load_resolved_profile(&file_id, std::option::Option::None); + assert!(resolved.is_ok(), "public profile resolver should resolve committed default profile: {resolved:?}"); + if let std::result::Result::Ok(resolved) = resolved { + assert_eq!(resolved.profile_id(), "local_dev"); + assert_eq!(resolved.selection_source(), ksp_config_lib::ConfigProfileSelectionSource::DefaultProfile); + assert_eq!(resolved.origin("logs_directory"), std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Global)); + assert_eq!(resolved.origin("default_filter"), std::option::Option::Some(ksp_config_lib::ConfigValueOrigin::Profile)); + assert!(!resolved.effective().contains_key("default_profile")); + assert!(!resolved.effective().contains_key("profiles")); + } + } +} diff --git a/crates/ksp-config-lib/unit_tests/document.rs b/crates/ksp-config-lib/unit_tests/document.rs index 10cfc6e..522ee7a 100644 --- a/crates/ksp-config-lib/unit_tests/document.rs +++ b/crates/ksp-config-lib/unit_tests/document.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/unit_tests/document.rs -// version: 1 +// version: 2 #[test] fn committed_logging_document_passes_registered_schema_and_semantic_validation() { @@ -143,6 +143,85 @@ fn schema_valid_logging_document_can_still_fail_ksp_semantics() { cleanup_fixture(&fixture); } +#[test] +fn duplicate_profile_ids_are_semantically_invalid() { + let fixture = fixture_roots("duplicate-profile-id"); + let workspace = workspace_root(); + let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json")); + assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}"); + if let std::result::Result::Ok(schema_source) = schema_source { + let source = valid_logging_source_with_profiles("first", "first", "first"); + let prepared = prepare_fixture(&fixture, std::option::Option::Some(source.as_str()), schema_source.as_str()); + assert!(prepared.is_ok(), "duplicate profile fixture should be writable: {prepared:?}"); + if prepared.is_ok() { + let result = load_fixture(&fixture); + assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID); + } + } + cleanup_fixture(&fixture); +} + +#[test] +fn missing_default_profile_target_is_semantically_invalid() { + let fixture = fixture_roots("missing-default-profile"); + let workspace = workspace_root(); + let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json")); + assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}"); + if let std::result::Result::Ok(schema_source) = schema_source { + let source = valid_logging_source_with_profiles("missing", "first", "second"); + let prepared = prepare_fixture(&fixture, std::option::Option::Some(source.as_str()), schema_source.as_str()); + assert!(prepared.is_ok(), "missing default profile fixture should be writable: {prepared:?}"); + if prepared.is_ok() { + let result = load_fixture(&fixture); + assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID); + } + } + cleanup_fixture(&fixture); +} + +fn valid_logging_source_with_profiles(default_profile: &str, first_profile: &str, second_profile: &str) -> String { + return format!( + r#"{{ + "format_version": 1, + "logs_directory": "logs", + "default_profile": "{default_profile}", + "profiles": [ + {}, + {} + ] +}}"#, + valid_logging_profile(first_profile, "file.first"), + valid_logging_profile(second_profile, "file.second") + ); +} + +fn valid_logging_profile(profile_id: &str, output_id: &str) -> String { + return format!( + r#"{{ + "profile_id": "{profile_id}", + "default_filter": "info", + "span_events": "off", + "console": {{ + "enabled": false, + "output": "stderr", + "ansi": false, + "format": "human", + "filter": {{"level": "trace", "targets": ["*"], "domains": ["*"]}} + }}, + "files": [{{ + "output_id": "{output_id}", + "enabled": true, + "path": "output.log", + "rotation": "daily", + "format": "human", + "ansi": false, + "filter": {{"level": "info", "targets": ["*"], "domains": ["*"]}} + }}], + "target_filters": [] + }}"# + ); +} + fn load_fixture(fixture: &FixtureRoots) -> ksp_core_lib::Result { let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture.config.as_path(), fixture.schemas.as_path()); let bootstrap = match bootstrap { @@ -212,7 +291,7 @@ struct FixtureRoots { fn fixture_roots(name: &str) -> FixtureRoots { let mut root = std::env::temp_dir(); - root.push(format!("ksp-config-lib-pre007-{name}-{}", std::process::id())); + root.push(format!("ksp-config-lib-pre008-{name}-{}", std::process::id())); return FixtureRoots { config: root.join("config"), schemas: root.join("schemas"), root }; } diff --git a/crates/ksp-config-lib/unit_tests/profile.rs b/crates/ksp-config-lib/unit_tests/profile.rs new file mode 100644 index 0000000..0015edc --- /dev/null +++ b/crates/ksp-config-lib/unit_tests/profile.rs @@ -0,0 +1,70 @@ +// file: crates/ksp-config-lib/unit_tests/profile.rs +// version: 1 + +#[test] +fn committed_default_profile_resolves_globals_profile_and_provenance() { + let engine = committed_engine(); + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); + assert!(engine.is_ok(), "committed Config engine should be constructible: {engine:?}"); + assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}"); + if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) { + let resolved = engine.load_resolved_profile(&file_id, std::option::Option::None); + assert!(resolved.is_ok(), "default profile should resolve: {resolved:?}"); + if let std::result::Result::Ok(resolved) = resolved { + assert_eq!(resolved.profile_id(), "local_dev"); + assert_eq!(resolved.selection_source(), super::ConfigProfileSelectionSource::DefaultProfile); + assert_eq!(resolved.globals().get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("${KSP_LOGS_DIRECTORY:-logs}")); + assert_eq!(resolved.profile().get("default_filter").and_then(serde_json::Value::as_str), std::option::Option::Some("warn")); + assert_eq!(resolved.effective().get("default_filter").and_then(serde_json::Value::as_str), std::option::Option::Some("warn")); + assert_eq!(resolved.origin("logs_directory"), std::option::Option::Some(super::ConfigValueOrigin::Global)); + assert_eq!(resolved.origin("default_filter"), std::option::Option::Some(super::ConfigValueOrigin::Profile)); + assert!(!resolved.effective().contains_key("default_profile")); + assert!(!resolved.effective().contains_key("profiles")); + } + } +} + +#[test] +fn explicit_profile_selection_is_distinct_from_default_selection() { + let engine = committed_engine(); + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); + assert!(engine.is_ok(), "committed Config engine should be constructible: {engine:?}"); + assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}"); + if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) { + let resolved = engine.load_resolved_profile(&file_id, std::option::Option::Some("local_dev")); + assert!(resolved.is_ok(), "explicit committed profile should resolve: {resolved:?}"); + if let std::result::Result::Ok(resolved) = resolved { + assert_eq!(resolved.profile_id(), "local_dev"); + assert_eq!(resolved.selection_source(), super::ConfigProfileSelectionSource::Explicit); + } + } +} + +#[test] +fn unknown_explicit_profile_has_distinct_error_code() { + let engine = committed_engine(); + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); + assert!(engine.is_ok(), "committed Config engine should be constructible: {engine:?}"); + assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}"); + if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) { + let result = engine.load_resolved_profile(&file_id, std::option::Option::Some("does-not-exist")); + assert!(result.is_err(), "unknown explicit profile must be rejected: {result:?}"); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_PROFILE_NOT_FOUND); + } + } +} + +fn committed_engine() -> ksp_core_lib::Result { + let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let registry = crate::ConfigFileRegistry::defaults(); + return match registry { + std::result::Result::Ok(value) => std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, value)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; +} diff --git a/deltas/0.1.3/pre.008.md b/deltas/0.1.3/pre.008.md new file mode 100644 index 0000000..e218f99 --- /dev/null +++ b/deltas/0.1.3/pre.008.md @@ -0,0 +1,268 @@ + + + +# Delta 0.1.3-pre.008 + +## Base requise + +Livraison précédente validée : + +```text +0.1.3-pre.007 +``` + +Version technique de cette base : + +```text +workspace.package.version = "0.1.3-pre.7" +Cargo.toml header version = 48 +``` + +Validations utilisateur exécutées le 2026-08-15 : + +```text +cargo fmt --all OK +cargo check --workspace OK +cargo clippy --workspace --all-targets OK +cargo test --workspace OK +cargo tree -p ksp-config-lib OK +cargo tree -p ksp-config-lib -e features OK +``` + +`cargo tree -p ksp-config-lib -d` ne signale qu'une coexistence transitive `syn 2.0.119` / `syn 3.0.3`, portée par l'écosystème `jsonschema`/ICU/proc-macros. Le graphe observé ne contient pas de stack HTTP/TLS de résolution distante avec `jsonschema default-features = false`; aucun changement de dépendance n'est donc justifié dans cette tranche. + +## Objet de pre.008 + +Fermer la résolution des paramètres globaux, profils et `default_profile` avant d'introduire les composites : + +```text +validated standard document + -> validate profile identities/default_profile + -> select default or explicit profile + -> retain globals + -> retain selected profile + -> build effective top-level view + -> preserve Global/Profile provenance +``` + +Cette tranche reste générique : le moteur de profils n'est pas un type Logging. `std.logging.json` est uniquement le premier document standard qui l'exerce. + +## Contrat générique des profils + +Un document standard profilé possède deux clés structurelles réservées : + +```text +default_profile +profiles +``` + +Toutes les autres propriétés top-level sont considérées comme des paramètres globaux du document. + +Garanties désormais validées après JSON Schema : + +- `profiles` contient au moins un objet ; +- chaque profil possède un `profile_id` non vide ; +- les `profile_id` sont uniques dans le document ; +- `default_profile` est autonome au niveau global ; +- `default_profile` référence obligatoirement un `profile_id` existant. + +Un document qui viole ces invariants retourne : + +```text +config.document_semantic_invalid +``` + +## Résolution publique + +`ConfigDocumentEngine` expose maintenant : + +```text +load_resolved_profile(file_id, None) + -> sélectionne default_profile + +load_resolved_profile(file_id, Some(profile_id)) + -> sélection explicite +``` + +Un profil explicitement demandé mais absent retourne un diagnostic distinct : + +```text +config.profile_not_found +``` + +Cette erreur ne signifie pas que le document source est invalide : le document peut être parfaitement valide tout en ne contenant pas le profil demandé par le caller. + +## `ResolvedConfigProfile` + +La nouvelle surface publique conserve : + +```text +file_id +source path +profile_id sélectionné +selection_source = DefaultProfile | Explicit +globals +profile +effective +origin(key) = Global | Profile +``` + +`globals` exclut volontairement : + +```text +default_profile +profiles +``` + +`profile` conserve l'objet du profil sélectionné, y compris son `profile_id`. + +`effective` est une vue top-level déterministe : + +1. insertion des globals ; +2. insertion des propriétés du profil sélectionné. + +Si un futur schema permet une même clé aux deux niveaux, la valeur du profil est prioritaire et sa provenance devient `Profile`. Les schemas spécialisés restent libres d'interdire ce cas lorsqu'une propriété doit rester strictement globale. + +La provenance documentaire commune reste disponible par : + +```text +file_id +path +``` + +et la provenance de chaque valeur top-level par : + +```text +ConfigValueOrigin::Global +ConfigValueOrigin::Profile +``` + +Les futures couches composite/env/sensibilité devront enrichir cette provenance sans la perdre. + +## `std.logging.json` + +Le fichier runtime et son schema ne nécessitent aucun changement dans cette tranche. + +Le document commité : + +```text +default_profile = local_dev +``` + +résout maintenant réellement le profil `local_dev`. + +Exemples de provenance : + +```text +logs_directory -> Global +default_filter -> Profile +``` + +Le placeholder : + +```text +${KSP_LOGS_DIRECTORY:-logs} +``` + +reste non interprété en `pre.008`; il sera résolu par la tranche environnement prévue plus tard. + +## Tests + +Les tests ajoutés couvrent : + +- résolution du `default_profile` commité ; +- sélection explicite d'un profil existant ; +- distinction `DefaultProfile` / `Explicit` ; +- conservation séparée des globals et du profil ; +- construction de la vue effective ; +- provenance `Global` / `Profile` ; +- profil explicite inconnu -> `config.profile_not_found` ; +- `profile_id` dupliqués -> document sémantiquement invalide ; +- `default_profile` ne référençant aucun profil -> document sémantiquement invalide ; +- consommation de la nouvelle surface depuis le crate-root dans le test d'intégration public. + +## Hors scope + +Cette tranche n'introduit pas encore : + +- document composite ; +- `schema.composite` ; +- override de profil depuis un composite ; +- `.env` ; +- lecture `std::env` applicative ; +- interpolation `${...}` ; +- `Public/Internal/Secret` ; +- redaction ; +- adaptation vers `ksp_logging_lib::LoggingSettings` ; +- mutation/persistence. + +## Dépendances + +Aucune dépendance externe ou feature Cargo supplémentaire n'est ajoutée. + +`ksp-config-lib` ne dépend toujours pas de `ksp-logging-lib` dans cette tranche. + +## Version technique + +La prerelease devient : + +```text +workspace.package.version = "0.1.3-pre.8" +Cargo.toml header version = 49 +``` + +## Fichiers ajoutés + +```text +crates/ksp-config-lib/src/profile.rs +crates/ksp-config-lib/unit_tests/profile.rs +deltas/0.1.3/pre.008.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-config-lib/src/document.rs +crates/ksp-config-lib/src/error.rs +crates/ksp-config-lib/src/lib.rs +crates/ksp-config-lib/tests/public_api.rs +crates/ksp-config-lib/unit_tests/document.rs +docs/plans/000-README.md +docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md +docs/rules/FILE_CONTRACTS.md +``` + +## Fichiers supprimés + +Aucun. + +## Contrôles exécutés dans l'environnement de génération + +- comparaison du delta avec la base `pre.007` ; +- contrôle des headers/version modifiés ; +- contrôle de l'absence de nouvelle dépendance Cargo ; +- contrôle statique des interdictions `unsafe`, `unwrap`, `expect`, opérateur `?` dans les nouveaux sources de production ; +- contrôle de la composition exacte de l'archive delta ; +- réapplication du delta sur une copie de `pre.007` pour vérifier la reproduction de l'arbre livré. + +`cargo`, `rustc` et `rustfmt` ne sont pas disponibles dans l'environnement de génération. Aucune validation Rust n'est déclarée réussie ici. + +## Validations utilisateur à exécuter + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test --workspace +cargo tree -p ksp-config-lib +cargo tree -p ksp-config-lib -d +cargo tree -p ksp-config-lib -e features +``` + +Après validation de `pre.008`, la prochaine tranche planifiée est : + +```text +0.1.3-pre.009 — compositions génériques par file_id +``` diff --git a/docs/plans/000-README.md b/docs/plans/000-README.md index fadfbb4..e500999 100644 --- a/docs/plans/000-README.md +++ b/docs/plans/000-README.md @@ -1,5 +1,5 @@ - + # Plans KSP @@ -13,7 +13,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou - [`002-FUNCTIONAL_RELEASE_SEQUENCE.md`](002-FUNCTIONAL_RELEASE_SEQUENCE.md) — séquence active de référence des premières releases fonctionnelles ; - [`003-V0_1_1_CORE_FOUNDATION_PLAN.md`](003-V0_1_1_CORE_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.1`, établi par `0.1.1-pre.001` puis consolidé jusqu'à `0.1.1-rel.001`. - [`004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](004-V0_1_2_LOGGING_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.2`, établi par `0.1.2-pre.001` puis consolidé jusqu'à `0.1.2-rel.001`. -- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan actif de `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, corrigé par `pre.001-fix.001`, complété par `pre.001-fix.002` pour le registre `file_id`/bootstrap/non-régression Logging, regranularisé par `pre.001-fix.003`, puis rescindé pendant `pre.005`; `pre.006` a fermé le routing structuré `domain` et `pre.007` livre maintenant le moteur JSON/JSON Schema et le premier `std.logging.json`; `pre.008` poursuit avec globals/profils/`default_profile`. +- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan actif de `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, corrigé par `pre.001-fix.001`, complété par `pre.001-fix.002` pour le registre `file_id`/bootstrap/non-régression Logging, regranularisé par `pre.001-fix.003`, puis rescindé pendant `pre.005`; `pre.006` a fermé le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema et le premier `std.logging.json`, puis `pre.008` la résolution globals/profils/`default_profile`; `pre.009` poursuivra avec les compositions génériques par `file_id`. Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre. diff --git a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md index 82ddac4..064a5c9 100644 --- a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +++ b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md @@ -1,5 +1,5 @@ - + # Séquence des releases fonctionnelles KSP @@ -219,7 +219,7 @@ pre.015 clôture Cette prévision n'est pas un plafond : chaque prerelease doit rester une petite tranche, avec scission explicite si l'objectif dépasse environ 15–20 minutes de travail effectif. -`pre.006` a fermé le routing Logging structuré `domain`; `pre.007` livre le moteur JSON/JSON Schema et le premier document `std.logging.json`. Après validation utilisateur de `pre.007`, la prochaine tranche est `pre.008` pour globals, profils et `default_profile`. +`pre.006` a fermé le routing Logging structuré `domain`; `pre.007` a livré le moteur JSON/JSON Schema et `std.logging.json`; `pre.008` ajoute la résolution générique globals/profils/`default_profile` avec provenance top-level. Après validation utilisateur, `pre.009` introduira les compositions génériques par `file_id`. ## `0.1.4` — Config desktop par défaut diff --git a/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md b/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md index 354ceba..fd2f1d9 100644 --- a/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md +++ b/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md @@ -1,11 +1,11 @@ - + # Plan `0.1.3` — Configuration foundation ## 1. Statut et objectif -Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, `pre.004` les contrats publics multi-output de Logging, `pre.005` le runtime multi-sink sur niveau/target/formats, `pre.006` le routing structuré `domain` et `pre.007` introduit le moteur JSON/JSON Schema ainsi que le premier document runtime `std.logging.json`. La prochaine tranche est `pre.008` pour la résolution des globals/profils/`default_profile`. +Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, `pre.004` les contrats publics multi-output de Logging, `pre.005` le runtime multi-sink sur niveau/target/formats, `pre.006` le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema et `pre.008` la résolution des globals/profils/`default_profile`. La prochaine tranche est `pre.009` pour les compositions génériques par `file_id`. La base auditée reste la release stable `v0.1.2`. @@ -1722,13 +1722,21 @@ Aucune dépendance à `ksp-logging-lib` n'est encore nécessaire dans Config : l ### `0.1.3-pre.008` — globals + profils + `default_profile` -- paramètres globaux hors profils ; -- `profile_id` unique ; -- `default_profile` autonome ; -- sélection/résolution d'un profil spécialisé ; -- héritage/usage des paramètres globaux par les profils selon le contrat retenu ; -- provenance document/global/profile ; -- tests sur `std.logging.json`. +Tranche livrée : + +- tout document standard possédant `default_profile` + `profiles` est soumis au contrat générique de profils Config ; +- les `profile_id` doivent être uniques dans le document ; +- `default_profile` reste une propriété globale autonome et doit référencer exactement un `profile_id` existant ; +- `ConfigDocumentEngine::load_resolved_profile(file_id, requested_profile)` sélectionne soit le profil par défaut (`None`), soit un profil explicite (`Some(profile_id)`) ; +- une sélection explicite inconnue retourne `config.profile_not_found` sans rendre le document source invalide ; +- `ResolvedConfigProfile` conserve le `file_id`, le path source, le `profile_id` sélectionné, la source de sélection, les globals, le profil et une vue effective ; +- les globals sont toutes les propriétés top-level hors clés réservées `default_profile` et `profiles` ; +- la vue effective fusionne les globals puis les propriétés du profil sélectionné ; une clé du profil, si elle existe aussi globalement dans un futur schema, est déterministement prioritaire ; +- `ConfigValueOrigin::{Global, Profile}` conserve la provenance top-level de chaque clé effective ; +- aucune interpolation `${...}`, aucun override composite et aucune sensibilité ne sont encore appliqués à cette vue ; ces couches seront ajoutées sans faire perdre la provenance déjà portée ; +- les tests couvrent le profil par défaut commité, la sélection explicite, la provenance global/profile, un profil demandé absent, les `profile_id` dupliqués et un `default_profile` orphelin. + +La validation utilisateur de `pre.007` est acquise : `fmt/check/clippy/test` passent, le moteur Config compte 28 tests unitaires + 5 tests publics et `jsonschema` avec `default-features = false` n'introduit pas de stack HTTP/TLS. Le seul doublon signalé par `cargo tree -d` est `syn 2`/`syn 3`, transitif via l'écosystème `jsonschema` et sans duplication de crate runtime KSP à corriger dans cette tranche. ### `0.1.3-pre.009` — compositions génériques par `file_id` diff --git a/docs/rules/FILE_CONTRACTS.md b/docs/rules/FILE_CONTRACTS.md index 5143627..e2d3f67 100644 --- a/docs/rules/FILE_CONTRACTS.md +++ b/docs/rules/FILE_CONTRACTS.md @@ -1,5 +1,5 @@ - + # Contrats des fichiers @@ -46,6 +46,8 @@ Les règles `FILE-*` définissent la responsabilité et le mode de modification Les noms physiques sont remplaçables via le registre Config lorsque le contrat le permet ; les consumers référencent les documents par `file_id`, pas par filename. +Pour un document standard profilé, `default_profile` et `profiles` sont des clés structurelles réservées. Les autres propriétés top-level sont des valeurs globales. Chaque entrée de `profiles` possède un `profile_id` unique ; `default_profile` référence obligatoirement l'un de ces identifiants. La résolution Config peut sélectionner le profil par défaut ou un profil explicite et conserve séparément la provenance `Global` / `Profile` de la vue effective. Les consumers ne reconstituent jamais eux-mêmes cette fusion. + ## Répertoire `prompts/` | Fichier/famille | Responsabilité | Règle de modification |