diff --git a/Cargo.toml b/Cargo.toml index 22be1f1..9dbd827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 49 +# version: 50 [workspace] resolver = "3" members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"] [workspace.package] -version = "0.1.3-pre.8" +version = "0.1.3-pre.9" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/config/examples/composite.example.json b/config/examples/composite.example.json new file mode 100644 index 0000000..01ce9bd --- /dev/null +++ b/config/examples/composite.example.json @@ -0,0 +1,25 @@ +{ + "format_version": 1, + "default_profile": "local_default", + "profiles": [ + { + "profile_id": "local_default", + "documents": [ + { + "component_id": "logging", + "file_id": "cfg.std.logging" + } + ] + }, + { + "profile_id": "local_explicit", + "documents": [ + { + "component_id": "logging", + "file_id": "cfg.std.logging", + "profile_id": "local_dev" + } + ] + } + ] +} diff --git a/config/schemas/composite.schema.json b/config/schemas/composite.schema.json new file mode 100644 index 0000000..a90f468 --- /dev/null +++ b/config/schemas/composite.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "KSP composite configuration", + "type": "object", + "additionalProperties": false, + "required": ["format_version", "default_profile", "profiles"], + "properties": { + "format_version": { + "const": 1 + }, + "default_profile": { + "type": "string", + "minLength": 1 + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/composite_profile" + } + } + }, + "$defs": { + "composite_profile": { + "type": "object", + "additionalProperties": false, + "required": ["profile_id", "documents"], + "properties": { + "profile_id": { + "type": "string", + "minLength": 1 + }, + "documents": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/document_reference" + } + } + } + }, + "document_reference": { + "type": "object", + "additionalProperties": false, + "required": ["component_id", "file_id"], + "properties": { + "component_id": { + "type": "string", + "minLength": 1 + }, + "file_id": { + "type": "string", + "pattern": "^cfg\\.std\\.[a-z0-9][a-z0-9._-]*$" + }, + "profile_id": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/crates/ksp-config-lib/src/composite.rs b/crates/ksp-config-lib/src/composite.rs new file mode 100644 index 0000000..5759155 --- /dev/null +++ b/crates/ksp-config-lib/src/composite.rs @@ -0,0 +1,309 @@ +// file: crates/ksp-config-lib/src/composite.rs +// version: 1 + +/// One resolved document component selected by a composite profile. +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedCompositeComponent { + component_id: String, + resolved: crate::ResolvedConfigProfile, +} + +impl ResolvedCompositeComponent { + /// Returns the component identifier unique inside the selected composite profile. + #[must_use] + pub fn component_id(&self) -> &str { + return self.component_id.as_str(); + } + + /// Returns the referenced standard document after global/profile resolution. + #[must_use] + pub const fn resolved(&self) -> &crate::ResolvedConfigProfile { + return &self.resolved; + } +} + +/// Validated composite document resolved to one composite profile and all of its referenced standard documents. +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedConfigComposite { + file_id: crate::ConfigFileId, + path: std::path::PathBuf, + profile_id: String, + selection_source: crate::ConfigProfileSelectionSource, + components: std::collections::BTreeMap, +} + +impl ResolvedConfigComposite { + /// Returns the logical composite file identifier. + #[must_use] + pub fn file_id(&self) -> &crate::ConfigFileId { + return &self.file_id; + } + + /// Returns the physical path of the validated composite source. + #[must_use] + pub fn path(&self) -> &std::path::Path { + return self.path.as_path(); + } + + /// Returns the selected composite profile identifier. + #[must_use] + pub fn profile_id(&self) -> &str { + return self.profile_id.as_str(); + } + + /// Returns whether the composite profile came from `default_profile` or an explicit caller selection. + #[must_use] + pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource { + return self.selection_source; + } + + /// Returns all resolved components keyed by their composite-local `component_id`. + #[must_use] + pub const fn components(&self) -> &std::collections::BTreeMap { + return &self.components; + } + + /// Returns one resolved component by its composite-local identifier. + #[must_use] + pub fn component(&self, component_id: &str) -> std::option::Option<&ResolvedCompositeComponent> { + return self.components.get(component_id); + } +} + +impl crate::ConfigDocumentEngine { + /// Loads, validates and resolves one registered composite Config document. + /// + /// Composite descriptors are introduced only when a concrete consumer exists. The generic contract and schema are available from `0.1.3-pre.009` onward. + /// Passing `None` selects the composite's `default_profile`; passing `Some(profile_id)` selects a composite profile explicitly. + pub fn load_resolved_composite( + &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), + }; + if !document.file_id().as_str().starts_with("cfg.composite.") { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID, "requested Config document is not a composite") + .with_context("file_id", document.file_id().as_str()), + ); + } + return resolve_composite_document(self, &document, requested_profile); + } +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct CompositeDocumentSource { + format_version: u32, + default_profile: String, + profiles: std::vec::Vec, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct CompositeProfileSource { + profile_id: String, + documents: std::vec::Vec, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct CompositeDocumentReferenceSource { + component_id: String, + file_id: String, + profile_id: std::option::Option, +} + +pub(crate) fn validate_composite_document_contract(engine: &crate::ConfigDocumentEngine, document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result<()> { + if !document.file_id().as_str().starts_with("cfg.composite.") { + return std::result::Result::Ok(()); + } + let source = parse_composite_source(document); + let source = match source { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if source.format_version != 1 { + return std::result::Result::Err(composite_semantic_error(document, "unsupported composite format_version")); + } + if source.default_profile.trim().is_empty() { + return std::result::Result::Err(composite_semantic_error(document, "composite default_profile must not be empty")); + } + for (profile_index, profile) in source.profiles.iter().enumerate() { + if profile.profile_id.trim().is_empty() { + return std::result::Result::Err( + composite_semantic_error(document, "composite profile_id must not be empty").with_context("profile_index", profile_index.to_string()), + ); + } + let mut component_ids = std::collections::BTreeSet::::new(); + for (component_index, reference) in profile.documents.iter().enumerate() { + if component_ids.contains(reference.component_id.as_str()) { + return std::result::Result::Err( + composite_semantic_error(document, "component_id values must be unique inside one composite profile") + .with_context("profile_id", profile.profile_id.as_str()) + .with_context("component_index", component_index.to_string()) + .with_context("component_id", reference.component_id.as_str()), + ); + } + component_ids.insert(reference.component_id.clone()); + let validation = validate_reference(engine, document, profile, reference, component_index); + if let std::result::Result::Err(error) = validation { + return std::result::Result::Err(error); + } + } + } + return std::result::Result::Ok(()); +} + +fn resolve_composite_document( + engine: &crate::ConfigDocumentEngine, + document: &crate::ConfigJsonDocument, + requested_profile: std::option::Option<&str>, +) -> ksp_core_lib::Result { + let source = parse_composite_source(document); + let source = match source { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let (selected_profile_id, selection_source) = match requested_profile { + std::option::Option::Some(value) => (value, crate::ConfigProfileSelectionSource::Explicit), + std::option::Option::None => (source.default_profile.as_str(), crate::ConfigProfileSelectionSource::DefaultProfile), + }; + let mut selected: std::option::Option<&CompositeProfileSource> = std::option::Option::None; + for profile in &source.profiles { + if profile.profile_id == selected_profile_id { + selected = std::option::Option::Some(profile); + 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 composite 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_id), + ); + }, + }; + let mut components = std::collections::BTreeMap::::new(); + for reference in &selected.documents { + let file_id = crate::ConfigFileId::new(reference.file_id.as_str()); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let resolved = crate::profile::load_resolved_profile_with_source( + engine, + &file_id, + reference.profile_id.as_deref(), + crate::ConfigProfileSelectionSource::Composite, + ); + let resolved = match resolved { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let component = ResolvedCompositeComponent { component_id: reference.component_id.clone(), resolved }; + components.insert(reference.component_id.clone(), component); + } + return std::result::Result::Ok(ResolvedConfigComposite { + file_id: document.file_id().clone(), + path: document.path().to_path_buf(), + profile_id: selected_profile_id.to_owned(), + selection_source, + components, + }); +} + +fn validate_reference( + engine: &crate::ConfigDocumentEngine, + document: &crate::ConfigJsonDocument, + profile: &CompositeProfileSource, + reference: &CompositeDocumentReferenceSource, + component_index: usize, +) -> ksp_core_lib::Result<()> { + if reference.component_id.trim().is_empty() { + return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "component_id must not be empty")); + } + let file_id = crate::ConfigFileId::new(reference.file_id.as_str()); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "referenced file_id is invalid")); + }, + }; + if !file_id.as_str().starts_with("cfg.std.") { + return std::result::Result::Err(composite_reference_error( + document, + profile, + reference, + component_index, + "composite references must target standard Config document file_ids", + )); + } + let descriptor = engine.registry().descriptor(&file_id); + let descriptor = match descriptor { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(composite_reference_error(document, profile, reference, component_index, "referenced file_id is not registered")); + }, + }; + if descriptor.kind() != crate::ConfigFileKind::Config { + return std::result::Result::Err(composite_reference_error( + document, + profile, + reference, + component_index, + "referenced file_id is not a Config document", + )); + } + let resolved = + crate::profile::load_resolved_profile_with_source(engine, &file_id, reference.profile_id.as_deref(), crate::ConfigProfileSelectionSource::Composite); + if let std::result::Result::Err(error) = resolved { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(()); +} + +fn parse_composite_source(document: &crate::ConfigJsonDocument) -> ksp_core_lib::Result { + let source = serde_json::from_value::(document.value().clone()); + return match source { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err( + composite_semantic_error(document, "schema-valid composite document cannot be decoded into the KSP source contract").with_source(error), + ), + }; +} + +fn composite_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 composite violates KSP semantic invariants") + .with_context("file_id", document.file_id().as_str()) + .with_context("path", document.path().to_string_lossy().into_owned()) + .with_context("reason", reason); +} + +fn composite_reference_error( + document: &crate::ConfigJsonDocument, + profile: &CompositeProfileSource, + reference: &CompositeDocumentReferenceSource, + component_index: usize, + reason: &'static str, +) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID, "Config composite contains an invalid document reference") + .with_context("file_id", document.file_id().as_str()) + .with_context("path", document.path().to_string_lossy().into_owned()) + .with_context("profile_id", profile.profile_id.as_str()) + .with_context("component_index", component_index.to_string()) + .with_context("component_id", reference.component_id.as_str()) + .with_context("referenced_file_id", reference.file_id.as_str()) + .with_context("reason", reason); +} + +#[cfg(test)] +#[path = "../unit_tests/composite.rs"] +mod tests; diff --git a/crates/ksp-config-lib/src/document.rs b/crates/ksp-config-lib/src/document.rs index 88f893a..5543ccf 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: 2 +// version: 3 /// A Config-managed JSON document that has passed syntax, schema and current semantic validation. #[derive(Clone, Debug, PartialEq)] @@ -97,7 +97,7 @@ impl ConfigDocumentEngine { std::result::Result::Ok(()) => {}, std::result::Result::Err(error) => return std::result::Result::Err(error), } - let semantic_validation = validate_document_semantics(&document); + let semantic_validation = validate_document_semantics(self, &document); return match semantic_validation { std::result::Result::Ok(()) => std::result::Result::Ok(document), std::result::Result::Err(error) => std::result::Result::Err(error), @@ -207,11 +207,15 @@ fn validate_instance(document: &ConfigJsonDocument, schema: &ConfigJsonDocument) }; } -fn validate_document_semantics(document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> { +fn validate_document_semantics(engine: &ConfigDocumentEngine, 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); } + let composite_validation = crate::composite::validate_composite_document_contract(engine, document); + if let std::result::Result::Err(error) = composite_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 6778387..fce4b60 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: 4 +// version: 5 /// 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"); @@ -36,3 +36,6 @@ pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_co /// 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"); + +/// Error code used when a composite document references an invalid, unknown, or unsupported Config document. +pub const ERROR_CODE_COMPOSITE_REFERENCE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "composite_reference_invalid"); diff --git a/crates/ksp-config-lib/src/lib.rs b/crates/ksp-config-lib/src/lib.rs index ac9bdcb..1533fa0 100644 --- a/crates/ksp-config-lib/src/lib.rs +++ b/crates/ksp-config-lib/src/lib.rs @@ -1,16 +1,17 @@ // file: crates/ksp-config-lib/src/lib.rs -// version: 4 +// version: 5 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] //! KSP-owned application configuration facade. //! -//! `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. +//! `0.1.3-pre.009` owns bootstrap roots, the logical file registry, generic JSON/JSON Schema loading, standard-document profile resolution and generic composite +//! resolution by stable `file_id`. The standard Logging document remains the first registered runtime document. Environment substitution, sensitivity and persistence +//! remain in later bounded prereleases. mod bootstrap; +mod composite; mod document; mod error; mod profile; @@ -26,6 +27,10 @@ pub use self::bootstrap::ConfigBootstrapOptions; pub use self::bootstrap::DEFAULT_CFG_PATH; /// Default root containing KSP JSON schemas. pub use self::bootstrap::DEFAULT_SCHEMA_PATH; +/// One resolved document component selected by a composite profile. +pub use self::composite::ResolvedCompositeComponent; +/// Validated composite document resolved to one profile and its referenced standard documents. +pub use self::composite::ResolvedConfigComposite; /// Generic JSON/JSON Schema engine owned by Config. pub use self::document::ConfigDocumentEngine; /// A Config-managed JSON document after syntax, schema and current semantic validation. @@ -34,6 +39,8 @@ pub use self::document::ConfigJsonDocument; pub use self::error::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE; /// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path. pub use self::error::ERROR_CODE_BOOTSTRAP_INVALID_PATH; +/// Error code used when a composite document contains an invalid or unsupported document reference. +pub use self::error::ERROR_CODE_COMPOSITE_REFERENCE_INVALID; /// Error code used when a schema-valid Config document violates KSP semantic invariants. pub use self::error::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID; /// Error code used when the same logical Config file identifier is registered more than once. @@ -70,10 +77,14 @@ pub use self::registry::ConfigFileId; pub use self::registry::ConfigFileKind; /// Registry of KSP-known logical Config files and their replaceable physical filenames. pub use self::registry::ConfigFileRegistry; +/// Default physical filename for the generic composite JSON Schema document. +pub use self::registry::DEFAULT_COMPOSITE_SCHEMA_FILENAME; /// Default physical filename for the standard Logging configuration document. pub use self::registry::DEFAULT_STD_LOGGING_FILENAME; /// Default physical filename for the standard Logging JSON Schema document. pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME; +/// Logical file identifier for the generic composite JSON Schema document. +pub use self::registry::FILE_ID_SCHEMA_COMPOSITE; /// Logical file identifier for the standard Logging JSON Schema document. pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING; /// Logical file identifier for the standard Logging configuration document. diff --git a/crates/ksp-config-lib/src/profile.rs b/crates/ksp-config-lib/src/profile.rs index 91b98ff..f757d9f 100644 --- a/crates/ksp-config-lib/src/profile.rs +++ b/crates/ksp-config-lib/src/profile.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/profile.rs -// version: 1 +// version: 2 /// Origin of one top-level value in a resolved standard Config profile. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -17,6 +17,8 @@ pub enum ConfigProfileSelectionSource { DefaultProfile, /// A caller explicitly requested the profile by `profile_id`. Explicit, + /// A composite document selected the referenced standard document profile. + Composite, } /// Validated standard document resolved to one profile while retaining global/profile provenance. @@ -86,7 +88,7 @@ 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. + /// Environment interpolation is intentionally not applied by this prerelease. pub fn load_resolved_profile( &self, file_id: &crate::ConfigFileId, @@ -97,10 +99,32 @@ impl crate::ConfigDocumentEngine { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - return resolve_document_profile(&document, requested_profile); + let source = match requested_profile { + std::option::Option::Some(_) => ConfigProfileSelectionSource::Explicit, + std::option::Option::None => ConfigProfileSelectionSource::DefaultProfile, + }; + return resolve_document_profile(&document, requested_profile, source); } } +pub(crate) fn load_resolved_profile_with_source( + engine: &crate::ConfigDocumentEngine, + file_id: &crate::ConfigFileId, + requested_profile: std::option::Option<&str>, + explicit_source: ConfigProfileSelectionSource, +) -> ksp_core_lib::Result { + let document = engine.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), + }; + let source = match requested_profile { + std::option::Option::Some(_) => explicit_source, + std::option::Option::None => ConfigProfileSelectionSource::DefaultProfile, + }; + return resolve_document_profile(&document, requested_profile, source); +} + 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, @@ -158,7 +182,11 @@ pub(crate) fn validate_document_profile_contract(document: &crate::ConfigJsonDoc return std::result::Result::Ok(()); } -fn resolve_document_profile(document: &crate::ConfigJsonDocument, requested_profile: std::option::Option<&str>) -> ksp_core_lib::Result { +fn resolve_document_profile( + document: &crate::ConfigJsonDocument, + requested_profile: std::option::Option<&str>, + explicit_source: ConfigProfileSelectionSource, +) -> 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")), @@ -168,7 +196,7 @@ fn resolve_document_profile(document: &crate::ConfigJsonDocument, requested_prof 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::Some(value) => (value, explicit_source), std::option::Option::None => (default_profile, ConfigProfileSelectionSource::DefaultProfile), }; let profiles = match root.get("profiles").and_then(serde_json::Value::as_array) { diff --git a/crates/ksp-config-lib/src/registry.rs b/crates/ksp-config-lib/src/registry.rs index 566aa76..b133851 100644 --- a/crates/ksp-config-lib/src/registry.rs +++ b/crates/ksp-config-lib/src/registry.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/registry.rs -// version: 2 +// version: 3 /// Bootstrap argument used to replace a known Config filename mapping. pub const ARG_FILE_MAP: &str = "--filemap"; @@ -7,10 +7,14 @@ pub const ARG_FILE_MAP: &str = "--filemap"; pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging"; /// Logical file identifier for the standard Logging JSON Schema document. pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging"; +/// Logical file identifier for the generic composite JSON Schema document. +pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite"; /// Default physical filename for the standard Logging configuration document. pub const DEFAULT_STD_LOGGING_FILENAME: &str = "std.logging.json"; /// Default physical filename for the standard Logging JSON Schema document. pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json"; +/// Default physical filename for the generic composite JSON Schema document. +pub const DEFAULT_COMPOSITE_SCHEMA_FILENAME: &str = "composite.schema.json"; /// Stable logical identifier for a Config-managed file. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -77,7 +81,7 @@ impl ConfigFileDescriptor { return self.schema_file_id.as_ref(); } - fn new( + pub(crate) fn new( file_id: &'static str, kind: ConfigFileKind, filename: &'static str, @@ -131,7 +135,13 @@ impl ConfigFileRegistry { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - return build_registry([logging, logging_schema]); + let composite_schema = + ConfigFileDescriptor::new(FILE_ID_SCHEMA_COMPOSITE, ConfigFileKind::Schema, DEFAULT_COMPOSITE_SCHEMA_FILENAME, std::option::Option::None); + let composite_schema = match composite_schema { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return build_registry([logging, logging_schema, composite_schema]); } /// Creates the default registry and applies repeatable `--filemap==` overrides from raw process arguments. @@ -208,7 +218,7 @@ impl ConfigFileRegistry { } } -fn build_registry(descriptors: [ConfigFileDescriptor; N]) -> ksp_core_lib::Result { +pub(crate) fn build_registry(descriptors: [ConfigFileDescriptor; N]) -> ksp_core_lib::Result { let mut registry = ConfigFileRegistry { descriptors: std::collections::BTreeMap::new() }; for descriptor in descriptors { let file_id = descriptor.file_id.clone(); diff --git a/crates/ksp-config-lib/tests/public_api.rs b/crates/ksp-config-lib/tests/public_api.rs index b9deab7..6c3ad8d 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: 5 +// version: 6 -//! Integration tests for the public `ksp-config-lib` bootstrap, file registry, validated JSON document and profile-resolution contracts. +//! Integration tests for the public `ksp-config-lib` bootstrap, file registry, validated JSON document, profile-resolution and composite contracts. #[test] fn bootstrap_contract_is_available_from_crate_root() { @@ -116,3 +116,20 @@ fn resolved_profile_contract_is_available_from_crate_root() { } } } + +#[test] +fn composite_schema_and_provenance_contracts_are_available_from_crate_root() { + let registry = ksp_config_lib::ConfigFileRegistry::defaults(); + let schema_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE); + assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}"); + assert!(schema_id.is_ok(), "public composite schema file_id should be valid: {schema_id:?}"); + if let (std::result::Result::Ok(registry), std::result::Result::Ok(schema_id)) = (registry, schema_id) { + let descriptor = registry.descriptor(&schema_id); + assert!(descriptor.is_ok(), "public composite schema descriptor should exist: {descriptor:?}"); + if let std::result::Result::Ok(descriptor) = descriptor { + assert_eq!(descriptor.filename(), std::path::Path::new(ksp_config_lib::DEFAULT_COMPOSITE_SCHEMA_FILENAME)); + assert_eq!(descriptor.kind(), ksp_config_lib::ConfigFileKind::Schema); + } + } + assert_ne!(ksp_config_lib::ConfigProfileSelectionSource::Composite, ksp_config_lib::ConfigProfileSelectionSource::Explicit); +} diff --git a/crates/ksp-config-lib/unit_tests/composite.rs b/crates/ksp-config-lib/unit_tests/composite.rs new file mode 100644 index 0000000..7bf1d4a --- /dev/null +++ b/crates/ksp-config-lib/unit_tests/composite.rs @@ -0,0 +1,247 @@ +// file: crates/ksp-config-lib/unit_tests/composite.rs +// version: 1 + +const TEST_COMPOSITE_FILE_ID: &str = "cfg.composite.test"; +const TEST_COMPOSITE_FILENAME: &str = "examples/composite.example.json"; + +#[test] +fn committed_composite_example_resolves_default_document_profile() { + let engine = committed_engine(); + let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID); + assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}"); + assert!(file_id.is_ok(), "composite test 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_composite(&file_id, std::option::Option::None); + assert!(resolved.is_ok(), "committed composite example should resolve: {resolved:?}"); + if let std::result::Result::Ok(resolved) = resolved { + assert_eq!(resolved.profile_id(), "local_default"); + assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile); + let logging = resolved.component("logging"); + assert!(logging.is_some(), "logging component should be resolved"); + if let std::option::Option::Some(logging) = logging { + assert_eq!(logging.resolved().file_id().as_str(), crate::FILE_ID_STD_LOGGING); + assert_eq!(logging.resolved().profile_id(), "local_dev"); + assert_eq!(logging.resolved().selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile); + assert_eq!(logging.resolved().origin("logs_directory"), std::option::Option::Some(crate::ConfigValueOrigin::Global)); + } + } + } +} + +#[test] +fn composite_profile_override_marks_referenced_profile_selection_as_composite() { + let engine = committed_engine(); + let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID); + assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}"); + assert!(file_id.is_ok(), "composite test 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_composite(&file_id, std::option::Option::Some("local_explicit")); + assert!(resolved.is_ok(), "explicit composite profile should resolve: {resolved:?}"); + if let std::result::Result::Ok(resolved) = resolved { + assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::Explicit); + let logging = resolved.component("logging"); + assert!(logging.is_some(), "logging component should be resolved"); + if let std::option::Option::Some(logging) = logging { + assert_eq!(logging.resolved().profile_id(), "local_dev"); + assert_eq!(logging.resolved().selection_source(), crate::ConfigProfileSelectionSource::Composite); + } + } + } +} + +#[test] +fn unknown_composite_profile_has_profile_not_found_error() { + let engine = committed_engine(); + let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID); + assert!(engine.is_ok(), "composite test engine should be constructible: {engine:?}"); + assert!(file_id.is_ok(), "composite test 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_composite(&file_id, std::option::Option::Some("missing")); + assert!(result.is_err(), "unknown composite profile must be rejected: {result:?}"); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_PROFILE_NOT_FOUND); + } + } +} + +#[test] +fn unknown_referenced_file_id_is_rejected_during_composite_validation() { + let fixture = fixture_roots("unknown-reference"); + let composite = r#"{ + "format_version": 1, + "default_profile": "test", + "profiles": [{ + "profile_id": "test", + "documents": [{"component_id": "missing", "file_id": "cfg.std.missing"}] + }] +}"#; + let prepared = prepare_fixture(&fixture, composite); + assert!(prepared.is_ok(), "composite fixture should be writable: {prepared:?}"); + if prepared.is_ok() { + let engine = fixture_engine(&fixture); + let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID); + assert!(engine.is_ok(), "fixture engine should be constructible: {engine:?}"); + if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) { + let result = engine.load_validated_document(&file_id); + assert!(result.is_err(), "unknown composite reference must be rejected: {result:?}"); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_COMPOSITE_REFERENCE_INVALID); + } + } + } + cleanup_fixture(&fixture); +} + +#[test] +fn duplicate_component_ids_are_rejected_inside_one_composite_profile() { + let fixture = fixture_roots("duplicate-component"); + let composite = r#"{ + "format_version": 1, + "default_profile": "test", + "profiles": [{ + "profile_id": "test", + "documents": [ + {"component_id": "logging", "file_id": "cfg.std.logging"}, + {"component_id": "logging", "file_id": "cfg.std.logging"} + ] + }] +}"#; + let prepared = prepare_fixture(&fixture, composite); + assert!(prepared.is_ok(), "composite fixture should be writable: {prepared:?}"); + if prepared.is_ok() { + let engine = fixture_engine(&fixture); + let file_id = crate::ConfigFileId::new(TEST_COMPOSITE_FILE_ID); + assert!(engine.is_ok(), "fixture engine should be constructible: {engine:?}"); + if let (std::result::Result::Ok(engine), std::result::Result::Ok(file_id)) = (engine, file_id) { + let result = engine.load_validated_document(&file_id); + assert!(result.is_err(), "duplicate component_id must be rejected: {result:?}"); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID); + } + } + } + cleanup_fixture(&fixture); +} + +fn committed_engine() -> ksp_core_lib::Result { + let workspace = workspace_root(); + 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 = test_registry(TEST_COMPOSITE_FILENAME); + 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), + }; +} + +fn fixture_engine(fixture: &FixtureRoots) -> ksp_core_lib::Result { + let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture.config.as_path(), fixture.schemas.as_path()); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let registry = test_registry("composite.test.json"); + 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), + }; +} + +fn test_registry(composite_filename: &'static str) -> ksp_core_lib::Result { + let logging = crate::registry::ConfigFileDescriptor::new( + crate::FILE_ID_STD_LOGGING, + crate::ConfigFileKind::Config, + crate::DEFAULT_STD_LOGGING_FILENAME, + std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_LOGGING), + ); + let logging = match logging { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let logging_schema = crate::registry::ConfigFileDescriptor::new( + crate::FILE_ID_SCHEMA_STD_LOGGING, + crate::ConfigFileKind::Schema, + crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME, + std::option::Option::None, + ); + let logging_schema = match logging_schema { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let composite_schema = crate::registry::ConfigFileDescriptor::new( + crate::FILE_ID_SCHEMA_COMPOSITE, + crate::ConfigFileKind::Schema, + crate::DEFAULT_COMPOSITE_SCHEMA_FILENAME, + std::option::Option::None, + ); + let composite_schema = match composite_schema { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let composite = crate::registry::ConfigFileDescriptor::new( + TEST_COMPOSITE_FILE_ID, + crate::ConfigFileKind::Config, + composite_filename, + std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE), + ); + let composite = match composite { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::registry::build_registry([logging, logging_schema, composite_schema, composite]); +} + +fn prepare_fixture(fixture: &FixtureRoots, composite: &str) -> std::io::Result<()> { + cleanup_fixture(fixture); + let config = std::fs::create_dir_all(fixture.config.as_path()); + if let std::result::Result::Err(error) = config { + return std::result::Result::Err(error); + } + let schemas = std::fs::create_dir_all(fixture.schemas.as_path()); + if let std::result::Result::Err(error) = schemas { + return std::result::Result::Err(error); + } + let workspace = workspace_root(); + let logging = std::fs::copy(workspace.join("config/std.logging.json"), fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME)); + if let std::result::Result::Err(error) = logging { + return std::result::Result::Err(error); + } + let logging_schema = + std::fs::copy(workspace.join("config/schemas/std.logging.schema.json"), fixture.schemas.join(crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME)); + if let std::result::Result::Err(error) = logging_schema { + return std::result::Result::Err(error); + } + let composite_schema = + std::fs::copy(workspace.join("config/schemas/composite.schema.json"), fixture.schemas.join(crate::DEFAULT_COMPOSITE_SCHEMA_FILENAME)); + if let std::result::Result::Err(error) = composite_schema { + return std::result::Result::Err(error); + } + return std::fs::write(fixture.config.join("composite.test.json"), composite); +} + +fn workspace_root() -> std::path::PathBuf { + return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); +} + +struct FixtureRoots { + root: std::path::PathBuf, + config: std::path::PathBuf, + schemas: std::path::PathBuf, +} + +fn fixture_roots(name: &str) -> FixtureRoots { + let root = std::env::temp_dir().join(format!("ksp-config-composite-{name}-{}", std::process::id())); + return FixtureRoots { config: root.join("config"), schemas: root.join("schemas"), root }; +} + +fn cleanup_fixture(fixture: &FixtureRoots) { + let removal = std::fs::remove_dir_all(fixture.root.as_path()); + if let std::result::Result::Err(error) = removal { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("unable to cleanup Config composite fixture {}: {error}", fixture.root.display()); + } + } +} diff --git a/crates/ksp-config-lib/unit_tests/registry.rs b/crates/ksp-config-lib/unit_tests/registry.rs index 8bb5771..e1f9bec 100644 --- a/crates/ksp-config-lib/unit_tests/registry.rs +++ b/crates/ksp-config-lib/unit_tests/registry.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/unit_tests/registry.rs -// version: 2 +// version: 3 #[test] fn defaults_register_logging_document_and_schema_with_distinct_roots() { @@ -195,3 +195,26 @@ fn absolute_fixture_path() -> std::path::PathBuf { path.push("ksp-config-lib-absolute-mapping.json"); return path; } + +#[test] +fn defaults_register_generic_composite_schema_without_runtime_composite() { + let registry = super::ConfigFileRegistry::defaults(); + let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_COMPOSITE); + let runtime_id = super::ConfigFileId::new("cfg.composite.ksp-app-wallet-desk"); + assert!(registry.is_ok(), "default registry should be valid: {registry:?}"); + assert!(schema_id.is_ok(), "composite schema file_id should be valid: {schema_id:?}"); + assert!(runtime_id.is_ok(), "future composite runtime file_id syntax should be valid: {runtime_id:?}"); + if let (std::result::Result::Ok(registry), std::result::Result::Ok(schema_id), std::result::Result::Ok(runtime_id)) = (registry, schema_id, runtime_id) { + let schema = registry.descriptor(&schema_id); + let runtime = registry.descriptor(&runtime_id); + assert!(schema.is_ok(), "generic composite schema should be registered: {schema:?}"); + assert!(runtime.is_err(), "no fictitious runtime composite should be registered"); + if let std::result::Result::Ok(schema) = schema { + assert_eq!(schema.kind(), super::ConfigFileKind::Schema); + assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_COMPOSITE_SCHEMA_FILENAME)); + } + if let std::result::Result::Err(error) = runtime { + assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN); + } + } +} diff --git a/deltas/0.1.3/pre.009.md b/deltas/0.1.3/pre.009.md new file mode 100644 index 0000000..211c79d --- /dev/null +++ b/deltas/0.1.3/pre.009.md @@ -0,0 +1,307 @@ + + + +# Delta 0.1.3-pre.009 + +## Base requise + +Livraison précédente validée : + +```text +0.1.3-pre.008 +``` + +Version technique de cette base : + +```text +workspace.package.version = "0.1.3-pre.8" +Cargo.toml header version = 49 +``` + +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 test --workspace` confirme notamment 33 tests unitaires + 6 tests publics pour `ksp-config-lib`. `cargo tree -p ksp-config-lib -d` ne signale que la coexistence transitive déjà connue `syn 2.0.119` / `syn 3.0.3` portée par l'écosystème `jsonschema`; aucune correction de dépendance KSP n'est justifiée dans cette tranche. + +## Objet de pre.009 + +Introduire le contrat de composition générique avant la résolution environnement : + +```text +composite file_id + -> schema.composite + -> composite profile + -> document references by file_id + -> default or composite-selected document profile + -> ResolvedConfigProfile per component + -> preserve Global/Profile provenance +``` + +Aucun composite runtime d'application fictif n'est créé. + +## `schema.composite` + +Le registre Config connaît désormais : + +```text +schema.composite -> composite.schema.json +``` + +Le mapping est résolu sous `schemapath` et peut être remplacé comme les autres fichiers connus : + +```text +--filemap=schema.composite=my.composite.schema.json +``` + +En revanche, le registre par défaut n'introduit aucun : + +```text +cfg.composite. +``` + +Un tel descriptor sera ajouté seulement lorsqu'un consumer concret existera. + +## Contrat composite + +Le schema générique versionné est : + +```text +config/schemas/composite.schema.json +``` + +Un exemple non runtime est fourni sous : + +```text +config/examples/composite.example.json +``` + +Structure : + +```text +format_version +default_profile +profiles[] + profile_id + documents[] + component_id + file_id + profile_id? # optionnel +``` + +Règles : + +- le composite utilise le même modèle `default_profile` / `profiles` que les documents standards ; +- `component_id` est unique à l'intérieur d'un profil composite ; +- une référence cible exclusivement un `cfg.std.*` connu du registre ; +- une référence ne contient jamais de filename physique ; +- l'imbrication de composites n'est pas ouverte dans cette première surface ; +- `profile_id` absent conserve le `default_profile` autonome du document référencé ; +- `profile_id` présent impose ce profil depuis la composition. + +## Résolution publique + +`ConfigDocumentEngine` expose maintenant : + +```text +load_resolved_composite(file_id, requested_profile) +``` + +Le caller peut sélectionner : + +```text +None -> default_profile du composite +Some(profile_id) -> profil composite explicite +``` + +Le résultat `ResolvedConfigComposite` conserve : + +```text +file_id du composite +path source +profile_id composite sélectionné +selection_source +components par component_id +``` + +Chaque `ResolvedCompositeComponent` conserve le `ResolvedConfigProfile` du document standard référencé. + +Ainsi les informations déjà stabilisées en `pre.008` restent disponibles : + +```text +globals +profile +effective +origin(key) = Global | Profile +``` + +## Provenance de sélection + +`ConfigProfileSelectionSource` possède désormais : + +```text +DefaultProfile +Explicit +Composite +``` + +Lorsqu'un composite déclare explicitement : + +```json +{ + "file_id": "cfg.std.logging", + "profile_id": "local_dev" +} +``` + +le `ResolvedConfigProfile` correspondant porte : + +```text +selection_source = Composite +``` + +Si `profile_id` est absent, le document référencé conserve : + +```text +selection_source = DefaultProfile +``` + +La provenance `Global/Profile` de chaque valeur top-level n'est pas remplacée par cette provenance de sélection. + +## Validation sémantique + +Un composite enregistré est soumis à : + +1. JSON syntax ; +2. `schema.composite` ; +3. contrat générique `default_profile` / `profiles` ; +4. unicité des `component_id` par profil ; +5. validité et enregistrement des `file_id` référencés ; +6. restriction aux documents standards `cfg.std.*` ; +7. validation/résolution du profil référencé, y compris le `default_profile` lorsqu'aucun override n'est fourni. + +Un document référencé invalide ou inconnu utilise le nouveau diagnostic : + +```text +config.composite_reference_invalid +``` + +Un profil document ou composite demandé mais absent conserve : + +```text +config.profile_not_found +``` + +## Tests + +Les nouveaux tests couvrent notamment : + +- enregistrement de `schema.composite` sans composite runtime fictif ; +- validation du schema/example composite versionné ; +- résolution du profil composite par défaut ; +- utilisation du `default_profile` du document référencé ; +- sélection explicite d'un profil composite ; +- provenance `Composite` lorsqu'un profil documentaire est imposé par le composite ; +- profil composite inconnu ; +- référence vers un `file_id` standard inconnu ; +- duplication de `component_id` dans un profil composite ; +- disponibilité des constantes/schema/provenance depuis le crate-root. + +Les tests de résolution utilisent un descriptor composite privé à la crate pointant vers l'exemple versionné ; il ne fait pas partie de `ConfigFileRegistry::defaults()`. + +## Hors scope + +Cette tranche n'introduit pas encore : + +- composite runtime d'une application réelle ; +- `.env` ; +- lecture de l'environnement du processus ; +- interpolation `${NAME}` / `${NAME:-fallback}` ; +- `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.9" +Cargo.toml header version = 50 +``` + +## Fichiers ajoutés + +```text +config/examples/composite.example.json +config/schemas/composite.schema.json +crates/ksp-config-lib/src/composite.rs +crates/ksp-config-lib/unit_tests/composite.rs +deltas/0.1.3/pre.009.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/src/profile.rs +crates/ksp-config-lib/src/registry.rs +crates/ksp-config-lib/tests/public_api.rs +crates/ksp-config-lib/unit_tests/registry.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 + +- parsing JSON du schema et de l'exemple composite ; +- validation du schema Draft 2020-12 et de l'exemple avec l'implémentation JSON Schema disponible dans l'environnement ; +- comparaison du delta avec la base `pre.008` ; +- 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`, `panic`, opérateur `?` et accès environnement applicatif 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.008` 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.009`, la prochaine tranche planifiée est : + +```text +0.1.3-pre.010 — .env + process env + resolver ${...} +``` diff --git a/docs/plans/000-README.md b/docs/plans/000-README.md index e500999..f610ff3 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`, `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`. +- [`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`, `pre.008` la résolution globals/profils/`default_profile`, puis `pre.009` les compositions génériques par `file_id`; `pre.010` poursuivra avec `.env`, process env et le resolver `${...}`. 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 064a5c9..61744c1 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` 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`. +`pre.006` a fermé le routing Logging structuré `domain`; `pre.007` a livré le moteur JSON/JSON Schema et `std.logging.json`; `pre.008` a ajouté la résolution générique globals/profils/`default_profile`; `pre.009` ajoute les compositions génériques par `file_id`, avec `schema.composite` mais sans composite runtime fictif. Après validation utilisateur, `pre.010` introduira `.env`, process env et le resolver `${...}`. ## `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 fd2f1d9..2322340 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`, `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`. +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, `pre.008` la résolution des globals/profils/`default_profile` et `pre.009` les compositions génériques par `file_id`. La prochaine tranche est `pre.010` pour `.env`, process env et le resolver `${...}`. La base auditée reste la release stable `v0.1.2`. @@ -409,7 +409,30 @@ config/composite.ksp-app-wallet-desk.json Leur identité logique reste `cfg.composite.`; le filename peut être remplacé par `--filemap` sans modifier leur contenu logique. -Aucun composite runtime fictif n'est créé avant l'existence d'un consumer concret. Le moteur et le schéma génériques peuvent néanmoins être testés dans `0.1.3` par fixtures/examples. +Aucun composite runtime fictif n'est créé avant l'existence d'un consumer concret. Le moteur et le schéma génériques sont exercés par `config/examples/composite.example.json` et par un descriptor de test privé à `ksp-config-lib`. + +Contrat générique livré en `pre.009` : + +```json +{ + "format_version": 1, + "default_profile": "local", + "profiles": [ + { + "profile_id": "local", + "documents": [ + { + "component_id": "logging", + "file_id": "cfg.std.logging", + "profile_id": "local_dev" + } + ] + } + ] +} +``` + +`component_id` est local au profil composite et unique dans celui-ci. `file_id` référence exclusivement un document standard `cfg.std.*` connu du registre. `profile_id` est optionnel : absent, il conserve le `default_profile` autonome du document référencé ; présent, il sélectionne explicitement ce profil au nom du composite. Les composites ne référencent jamais les filenames physiques et ne s'imbriquent pas dans cette première surface. ### 6.6 Schémas @@ -419,7 +442,7 @@ Les schémas appartiennent par défaut sous : config/schemas/ ``` -Première surface candidate : +Surface actuelle : ```text config/schemas/std.logging.schema.json @@ -1740,12 +1763,21 @@ La validation utilisateur de `pre.007` est acquise : `fmt/check/clippy/test` pas ### `0.1.3-pre.009` — compositions génériques par `file_id` -- contrat composite générique ; -- schema/example composite ; -- références de documents exclusivement par `file_id` ; -- sélection/remplacement de profils documentaires ; -- résolution composition -> document -> profil/global ; -- aucun composite runtime fictif obligatoire. +Tranche livrée : + +- `schema.composite` est enregistré par défaut vers `config/schemas/composite.schema.json` et reste surchargeable par `--filemap` ; +- aucun `cfg.composite.` n’est enregistré tant qu’un consumer concret n’existe pas ; +- `config/examples/composite.example.json` exerce le contrat sans devenir une source runtime implicite ; +- un composite utilise `format_version`, `default_profile` et `profiles[]` comme les documents standards profilés ; +- chaque profil composite contient `documents[]` avec `component_id`, `file_id` et `profile_id` optionnel ; +- `component_id` est unique dans un profil composite ; +- les références ciblent exclusivement des `cfg.std.*` déjà enregistrés, jamais des filenames ni d’autres composites ; +- `profile_id` absent laisse le document référencé choisir son `default_profile` autonome ; +- `profile_id` présent impose ce profil et marque sa source de sélection `ConfigProfileSelectionSource::Composite` ; +- `ResolvedConfigComposite` conserve le composite sélectionné et ses `ResolvedCompositeComponent`, chacun portant le `ResolvedConfigProfile` complet du document référencé ; +- les provenances `Global` / `Profile` du document spécialisé sont donc conservées sous la composition ; +- `ConfigDocumentEngine::load_resolved_composite(file_id, requested_profile)` est prêt pour les futurs descriptors `cfg.composite.` ; +- les tests utilisent un descriptor composite privé à la crate pointant vers l’exemple versionné afin de valider la résolution complète sans créer un composite runtime fictif. ### `0.1.3-pre.010` — `.env` + process env + resolver `${...}` diff --git a/docs/rules/FILE_CONTRACTS.md b/docs/rules/FILE_CONTRACTS.md index e2d3f67..4253b6e 100644 --- a/docs/rules/FILE_CONTRACTS.md +++ b/docs/rules/FILE_CONTRACTS.md @@ -1,5 +1,5 @@ - + # Contrats des fichiers @@ -38,11 +38,12 @@ Les règles `FILE-*` définissent la responsabilité et le mode de modification ## Répertoire `config/` -| Fichier/famille | Responsabilité | Règle de modification | -|--------------------------------|------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `config/std..json` | Document runtime spécialisé Config possédé par `ksp-config-lib`. | Identifié par un `file_id` stable, validé par son schema enregistré et lu/modifié uniquement via Config. Comme JSON ne porte pas de commentaires de header, la version du format appartient au champ JSON `format_version`. | -| `config/schemas/*.schema.json` | JSON Schema des documents Config gérés. | Identifié par un `file_id` `schema.*`; le schema doit être valide pour le draft déclaré avant validation d'une instance. Aucun secret/runtime local ne doit y apparaître. | -| `config/examples/*.json` | Exemples versionnés séparés des vrais fichiers runtime. | Doivent rester schema-valides et illustratifs ; ils ne constituent jamais une source runtime implicite. | +| Fichier/famille | Responsabilité | Règle de modification | +|------------------------------------|------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `config/std..json` | Document runtime spécialisé Config possédé par `ksp-config-lib`. | Identifié par un `file_id` stable, validé par son schema enregistré et lu/modifié uniquement via Config. Comme JSON ne porte pas de commentaires de header, la version du format appartient au champ JSON `format_version`. | +| `config/composite..json` | Composition runtime propre à un consumer concret. | Référence uniquement des documents standards par `file_id`; son descriptor référence `schema.composite` tant qu’un schema spécialisé n’est pas requis. Aucun composite runtime fictif n’est créé avant l’existence du consumer. | +| `config/schemas/*.schema.json` | JSON Schema des documents Config gérés. | Identifié par un `file_id` `schema.*`; le schema doit être valide pour le draft déclaré avant validation d'une instance. Aucun secret/runtime local ne doit y apparaître. | +| `config/examples/*.json` | Exemples versionnés séparés des vrais fichiers runtime. | Doivent rester schema-valides et illustratifs ; ils ne constituent jamais une source runtime implicite. | 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.