v0.1.3-pre.003

This commit is contained in:
2026-08-15 19:17:09 +02:00
parent 9207919d47
commit 0629a48e97
8 changed files with 898 additions and 11 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 42
# version: 43
[workspace]
resolver = "3"
members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
[workspace.package]
version = "0.1.3-pre.2.fix.1"
version = "0.1.3-pre.3"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,8 +1,20 @@
// file: crates/ksp-config-lib/src/error.rs
// version: 1
// version: 2
/// 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");
/// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path.
pub const ERROR_CODE_BOOTSTRAP_INVALID_PATH: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_invalid_path");
/// Error code used when a logical Config file identifier is malformed.
pub const ERROR_CODE_FILE_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_invalid");
/// Error code used when a requested logical Config file identifier is not registered.
pub const ERROR_CODE_FILE_ID_UNKNOWN: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_unknown");
/// Error code used when the same logical Config file identifier is registered more than once.
pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_id_duplicate");
/// Error code used when a Config filename mapping or descriptor relation is invalid.
pub const ERROR_CODE_FILE_MAPPING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_mapping_invalid");

View File

@@ -1,17 +1,18 @@
// file: crates/ksp-config-lib/src/lib.rs
// version: 1
// version: 2
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! KSP-owned application configuration facade.
//!
//! `0.1.3-pre.002` establishes the non-recursive bootstrap boundary used before any managed configuration document can be read. Configuration and schema
//! roots have hardcoded KSP defaults and can only be replaced through explicit bootstrap arguments or the programmatic [`ConfigBootstrapOptions`] API.
//! Document registries, JSON/schema loading, profiles, environment resolution and persistence are introduced by later bounded prereleases.
//! `0.1.3-pre.003` provides the non-recursive bootstrap roots plus a stable logical file registry. Consumers select Config-managed files by `file_id`;
//! physical filenames can be replaced at bootstrap without changing those logical identities. JSON/schema loading, profiles, environment resolution and
//! persistence are introduced by later bounded prereleases.
mod bootstrap;
mod error;
mod registry;
/// Bootstrap argument used to replace the configuration document root.
pub use self::bootstrap::ARG_CFG_PATH;
@@ -27,3 +28,29 @@ pub use self::bootstrap::DEFAULT_SCHEMA_PATH;
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 the same logical Config file identifier is registered more than once.
pub use self::error::ERROR_CODE_FILE_ID_DUPLICATE;
/// Error code used when a logical Config file identifier is malformed.
pub use self::error::ERROR_CODE_FILE_ID_INVALID;
/// Error code used when a requested logical Config file identifier is not registered.
pub use self::error::ERROR_CODE_FILE_ID_UNKNOWN;
/// Error code used when a Config filename mapping or descriptor relation is invalid.
pub use self::error::ERROR_CODE_FILE_MAPPING_INVALID;
/// 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 root category.
pub use self::registry::ConfigFileDescriptor;
/// Stable logical identifier for a Config-managed file.
pub use self::registry::ConfigFileId;
/// Physical root category used to resolve a Config-managed file.
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 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 standard Logging JSON Schema document.
pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING;
/// Logical file identifier for the standard Logging configuration document.
pub use self::registry::FILE_ID_STD_LOGGING;

View File

@@ -0,0 +1,309 @@
// file: crates/ksp-config-lib/src/registry.rs
// version: 1
/// Bootstrap argument used to replace a known Config filename mapping.
pub const ARG_FILE_MAP: &str = "--filemap";
/// Logical file identifier for the standard Logging configuration document.
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";
/// 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";
/// Stable logical identifier for a Config-managed file.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct ConfigFileId(String);
impl ConfigFileId {
/// Creates and validates a logical Config file identifier.
pub fn new(value: impl std::convert::Into<String>) -> ksp_core_lib::Result<Self> {
let value = value.into();
let validated = validate_file_id(value.as_str());
return match validated {
std::result::Result::Ok(()) => std::result::Result::Ok(Self(value)),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Returns the logical identifier as text.
#[must_use]
pub fn as_str(&self) -> &str {
return self.0.as_str();
}
}
/// Physical root category used to resolve a Config-managed file.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigFileKind {
/// Runtime configuration document resolved below `cfgpath`.
Config,
/// JSON Schema document resolved below `schemapath`.
Schema,
}
/// Logical descriptor associating a stable file identifier with its physical filename and root category.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigFileDescriptor {
file_id: ConfigFileId,
kind: ConfigFileKind,
filename: std::path::PathBuf,
}
impl ConfigFileDescriptor {
/// Returns the stable logical identifier.
#[must_use]
pub fn file_id(&self) -> &ConfigFileId {
return &self.file_id;
}
/// Returns the root category used when resolving the file.
#[must_use]
pub fn kind(&self) -> ConfigFileKind {
return self.kind;
}
/// Returns the relative physical filename currently mapped to the identifier.
#[must_use]
pub fn filename(&self) -> &std::path::Path {
return self.filename.as_path();
}
fn new(file_id: &'static str, kind: ConfigFileKind, filename: &'static str) -> ksp_core_lib::Result<Self> {
let file_id = ConfigFileId::new(file_id);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kind_validation = validate_kind_prefix(&file_id, kind);
match kind_validation {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let filename = validate_relative_filename(&file_id, std::path::PathBuf::from(filename));
return match filename {
std::result::Result::Ok(value) => std::result::Result::Ok(Self { file_id, kind, filename: value }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Registry of KSP-known logical Config files and their replaceable physical filenames.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigFileRegistry {
descriptors: std::collections::BTreeMap<ConfigFileId, ConfigFileDescriptor>,
}
impl ConfigFileRegistry {
/// Creates the registry containing the KSP default file mappings known in the current release.
pub fn defaults() -> ksp_core_lib::Result<Self> {
let logging = ConfigFileDescriptor::new(FILE_ID_STD_LOGGING, ConfigFileKind::Config, DEFAULT_STD_LOGGING_FILENAME);
let logging = match logging {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let logging_schema = ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_LOGGING, ConfigFileKind::Schema, DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
let logging_schema = match logging_schema {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return build_registry([logging, logging_schema]);
}
/// Creates the default registry and applies repeatable `--filemap=<file_id>=<filename>` overrides from raw process arguments.
///
/// Unrelated arguments are ignored. A repeated mapping for the same known `file_id` is accepted and the last mapping wins.
pub fn from_args(args: &[std::ffi::OsString]) -> ksp_core_lib::Result<Self> {
let registry = Self::defaults();
let mut registry = match registry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut index: usize = 0;
while index < args.len() {
let application = apply_file_map_argument(&mut registry, &args[index]);
match application {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
index += 1;
}
return std::result::Result::Ok(registry);
}
/// Returns the descriptor associated with a known logical file identifier.
pub fn descriptor(&self, file_id: &ConfigFileId) -> ksp_core_lib::Result<&ConfigFileDescriptor> {
return match self.descriptors.get(file_id) {
std::option::Option::Some(descriptor) => std::result::Result::Ok(descriptor),
std::option::Option::None => std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
};
}
/// Resolves a known logical file identifier below the bootstrap root selected by its descriptor kind.
pub fn resolve_path(&self, bootstrap: &crate::ConfigBootstrapOptions, file_id: &ConfigFileId) -> ksp_core_lib::Result<std::path::PathBuf> {
let descriptor = self.descriptor(file_id);
let descriptor = match descriptor {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let root = match descriptor.kind() {
ConfigFileKind::Config => bootstrap.cfg_path(),
ConfigFileKind::Schema => bootstrap.schema_path(),
};
return std::result::Result::Ok(root.join(descriptor.filename()));
}
/// Replaces the physical filename of one known logical identifier while preserving its kind and logical identity.
pub fn with_filename_override(mut self, file_id: &ConfigFileId, filename: impl std::convert::Into<std::path::PathBuf>) -> ksp_core_lib::Result<Self> {
let update = self.set_filename_override(file_id, filename.into());
return match update {
std::result::Result::Ok(()) => std::result::Result::Ok(self),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn set_filename_override(&mut self, file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<()> {
let descriptor = self.descriptors.get(file_id);
let descriptor = match descriptor {
std::option::Option::Some(value) => value.clone(),
std::option::Option::None => return std::result::Result::Err(unknown_file_id_error(file_id.as_str())),
};
let filename = validate_relative_filename(file_id, filename);
let filename = match filename {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let updated = ConfigFileDescriptor { file_id: descriptor.file_id.clone(), kind: descriptor.kind, filename };
self.descriptors.insert(file_id.clone(), updated);
return std::result::Result::Ok(());
}
}
fn build_registry<const N: usize>(descriptors: [ConfigFileDescriptor; N]) -> ksp_core_lib::Result<ConfigFileRegistry> {
let mut registry = ConfigFileRegistry { descriptors: std::collections::BTreeMap::new() };
for descriptor in descriptors {
let file_id = descriptor.file_id.clone();
let duplicate_id = file_id.clone();
let previous = registry.descriptors.insert(file_id, descriptor);
if previous.is_some() {
return std::result::Result::Err(duplicate_file_id_error(duplicate_id.as_str()));
}
}
return std::result::Result::Ok(registry);
}
fn apply_file_map_argument(registry: &mut ConfigFileRegistry, argument: &std::ffi::OsStr) -> ksp_core_lib::Result<()> {
let text = match argument.to_str() {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(()),
};
if text == ARG_FILE_MAP {
return std::result::Result::Err(invalid_file_mapping_error("--filemap requires the inline form --filemap=<file_id>=<filename>"));
}
let prefix = "--filemap=";
let mapping = match text.strip_prefix(prefix) {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Ok(()),
};
let separator = mapping.find('=');
let separator = match separator {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(invalid_file_mapping_error("file mapping must contain a file_id and filename separated by '='"));
},
};
let file_id_text = &mapping[..separator];
let filename_text = &mapping[separator + 1..];
if file_id_text.is_empty() || filename_text.is_empty() {
return std::result::Result::Err(invalid_file_mapping_error("file mapping requires non-empty file_id and filename values"));
}
let file_id = ConfigFileId::new(file_id_text);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return registry.set_filename_override(&file_id, std::path::PathBuf::from(filename_text));
}
fn validate_file_id(value: &str) -> ksp_core_lib::Result<()> {
if value.is_empty() || value.starts_with('.') || value.ends_with('.') || value.contains("..") {
return std::result::Result::Err(invalid_file_id_error(value));
}
let mut valid = true;
for byte in value.bytes() {
let allowed = byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'.' || byte == b'_' || byte == b'-';
if !allowed {
valid = false;
}
}
if !valid {
return std::result::Result::Err(invalid_file_id_error(value));
}
return std::result::Result::Ok(());
}
fn validate_kind_prefix(file_id: &ConfigFileId, kind: ConfigFileKind) -> ksp_core_lib::Result<()> {
let valid = match kind {
ConfigFileKind::Config => file_id.as_str().starts_with("cfg."),
ConfigFileKind::Schema => file_id.as_str().starts_with("schema."),
};
if !valid {
return std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "file_id prefix does not match descriptor kind"));
}
return std::result::Result::Ok(());
}
fn validate_relative_filename(file_id: &ConfigFileId, filename: std::path::PathBuf) -> ksp_core_lib::Result<std::path::PathBuf> {
if filename.as_os_str().is_empty() || filename.is_absolute() {
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
}
let mut has_normal_component = false;
for component in filename.components() {
match component {
std::path::Component::Normal(_) => has_normal_component = true,
std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
},
}
}
if !has_normal_component {
return std::result::Result::Err(invalid_filename_error(file_id.as_str(), &filename));
}
return std::result::Result::Ok(filename);
}
fn invalid_file_id_error(file_id: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_INVALID, "Config file_id is invalid")
.with_context("file_id", file_id)
.with_context("reason", "expected lowercase ASCII segments separated by single dots");
}
fn unknown_file_id_error(file_id: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_UNKNOWN, "Config file_id is not registered").with_context("file_id", file_id);
}
fn duplicate_file_id_error(file_id: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_ID_DUPLICATE, "Config file_id is registered more than once").with_context("file_id", file_id);
}
fn invalid_file_mapping_error(reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid").with_context("reason", reason);
}
fn invalid_file_mapping_with_id_error(file_id: &str, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config file mapping is invalid")
.with_context("file_id", file_id)
.with_context("reason", reason);
}
fn invalid_filename_error(file_id: &str, filename: &std::path::Path) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config mapped filename is invalid")
.with_context("file_id", file_id)
.with_context("filename", filename.to_string_lossy().into_owned())
.with_context("reason", "filename must stay relative to its Config-owned root without traversal components");
}
#[cfg(test)]
#[path = "../unit_tests/registry.rs"]
mod tests;

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-config-lib/tests/public_api.rs
// version: 2
// version: 3
//! Integration tests for the public `ksp-config-lib` bootstrap contract.
//! Integration tests for the public `ksp-config-lib` bootstrap and logical file registry contracts.
#[test]
fn bootstrap_contract_is_available_from_crate_root() {
@@ -40,3 +40,35 @@ fn cli_bootstrap_parser_is_available_from_crate_root() {
assert_eq!(options.schema_path(), std::path::Path::new("consumer-schemas"));
}
}
#[test]
fn logical_file_registry_is_available_from_crate_root() {
let args = [
std::ffi::OsString::from("consumer"),
std::ffi::OsString::from("--filemap=cfg.std.logging=consumer.logging.json"),
std::ffi::OsString::from("--filemap=schema.std.logging=consumer.logging.schema.json"),
];
let registry = ksp_config_lib::ConfigFileRegistry::from_args(&args);
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths("consumer-config", "consumer-schemas");
let logging_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
let schema_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING);
assert!(registry.is_ok(), "public registry should accept known filemap overrides: {registry:?}");
assert!(bootstrap.is_ok(), "bootstrap paths should remain available: {bootstrap:?}");
assert!(logging_id.is_ok(), "public logging file_id should be valid: {logging_id:?}");
assert!(schema_id.is_ok(), "public schema file_id should be valid: {schema_id:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(bootstrap), std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) =
(registry, bootstrap, logging_id, schema_id)
{
let logging = registry.resolve_path(&bootstrap, &logging_id);
let schema = registry.resolve_path(&bootstrap, &schema_id);
assert!(logging.is_ok(), "public logging path should resolve: {logging:?}");
assert!(schema.is_ok(), "public schema path should resolve: {schema:?}");
if let std::result::Result::Ok(logging) = logging {
assert_eq!(logging, std::path::PathBuf::from("consumer-config/consumer.logging.json"));
}
if let std::result::Result::Ok(schema) = schema {
assert_eq!(schema, std::path::PathBuf::from("consumer-schemas/consumer.logging.schema.json"));
}
assert_eq!(ksp_config_lib::ARG_FILE_MAP, "--filemap");
}
}

View File

@@ -0,0 +1,179 @@
// file: crates/ksp-config-lib/unit_tests/registry.rs
// version: 1
#[test]
fn defaults_register_logging_document_and_schema_with_distinct_roots() {
let registry = super::ConfigFileRegistry::defaults();
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let logging_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
assert!(logging_id.is_ok(), "logging file_id should be valid: {logging_id:?}");
assert!(schema_id.is_ok(), "logging schema file_id should be valid: {schema_id:?}");
if let (std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) = (logging_id, schema_id) {
let logging = registry.descriptor(&logging_id);
let schema = registry.descriptor(&schema_id);
assert!(logging.is_ok(), "logging descriptor should exist: {logging:?}");
assert!(schema.is_ok(), "logging schema descriptor should exist: {schema:?}");
if let (std::result::Result::Ok(logging), std::result::Result::Ok(schema)) = (logging, schema) {
assert_eq!(logging.kind(), super::ConfigFileKind::Config);
assert_eq!(logging.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_FILENAME));
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_SCHEMA_FILENAME));
}
}
}
}
#[test]
fn resolve_path_uses_descriptor_kind_to_select_bootstrap_root() {
let registry = super::ConfigFileRegistry::defaults();
let bootstrap = crate::ConfigBootstrapOptions::from_paths("runtime-config", "runtime-schemas");
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(bootstrap.is_ok(), "bootstrap paths should be valid: {bootstrap:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(bootstrap)) = (registry, bootstrap) {
let logging_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
let schema_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
if let (std::result::Result::Ok(logging_id), std::result::Result::Ok(schema_id)) = (logging_id, schema_id) {
let logging = registry.resolve_path(&bootstrap, &logging_id);
let schema = registry.resolve_path(&bootstrap, &schema_id);
assert!(logging.is_ok(), "logging path should resolve: {logging:?}");
assert!(schema.is_ok(), "schema path should resolve: {schema:?}");
if let std::result::Result::Ok(logging) = logging {
assert_eq!(logging, std::path::PathBuf::from("runtime-config/std.logging.json"));
}
if let std::result::Result::Ok(schema) = schema {
assert_eq!(schema, std::path::PathBuf::from("runtime-schemas/std.logging.schema.json"));
}
}
}
}
#[test]
fn cli_filemap_override_replaces_filename_and_last_value_wins() {
let args = [
std::ffi::OsString::from("ksp-app"),
std::ffi::OsString::from("--filemap=cfg.std.logging=first.logging.json"),
std::ffi::OsString::from("--other-option"),
std::ffi::OsString::from("--filemap=cfg.std.logging=profiles/custom.logging.json"),
];
let registry = super::ConfigFileRegistry::from_args(&args);
assert!(registry.is_ok(), "filemap overrides should parse: {registry:?}");
if let std::result::Result::Ok(registry) = registry {
let file_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
if let std::result::Result::Ok(file_id) = file_id {
let descriptor = registry.descriptor(&file_id);
assert!(descriptor.is_ok(), "logging descriptor should remain registered: {descriptor:?}");
if let std::result::Result::Ok(descriptor) = descriptor {
assert_eq!(descriptor.filename(), std::path::Path::new("profiles/custom.logging.json"));
assert_eq!(descriptor.kind(), super::ConfigFileKind::Config);
}
}
}
}
#[test]
fn programmatic_override_preserves_file_id_and_kind() {
let registry = super::ConfigFileRegistry::defaults();
let file_id = super::ConfigFileId::new(super::FILE_ID_SCHEMA_STD_LOGGING);
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(file_id.is_ok(), "schema file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (registry, file_id) {
let overridden = registry.with_filename_override(&file_id, "alternate/logging.schema.json");
assert!(overridden.is_ok(), "programmatic override should be valid: {overridden:?}");
if let std::result::Result::Ok(overridden) = overridden {
let descriptor = overridden.descriptor(&file_id);
if let std::result::Result::Ok(descriptor) = descriptor {
assert_eq!(descriptor.file_id(), &file_id);
assert_eq!(descriptor.kind(), super::ConfigFileKind::Schema);
assert_eq!(descriptor.filename(), std::path::Path::new("alternate/logging.schema.json"));
}
}
}
}
#[test]
fn unknown_file_id_override_is_rejected() {
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from("--filemap=cfg.unknown=unknown.json")];
let result = super::ConfigFileRegistry::from_args(&args);
assert!(result.is_err(), "unknown logical files must not be introduced by CLI override");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_UNKNOWN);
}
}
#[test]
fn invalid_file_ids_are_rejected() {
for value in ["", ".cfg", "cfg.", "cfg..logging", "CFG.logging", "cfg/logging"] {
let result = super::ConfigFileId::new(value);
assert!(result.is_err(), "invalid file_id must be rejected: {value}");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_INVALID);
}
}
}
#[test]
fn absolute_and_traversing_filenames_are_rejected() {
let registry = super::ConfigFileRegistry::defaults();
let file_id = super::ConfigFileId::new(super::FILE_ID_STD_LOGGING);
assert!(registry.is_ok(), "default registry should be valid: {registry:?}");
assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}");
if let (std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (registry, file_id) {
let traversal = registry.clone().with_filename_override(&file_id, "../outside.json");
let current = registry.clone().with_filename_override(&file_id, "./logging.json");
let absolute = registry.with_filename_override(&file_id, absolute_fixture_path());
assert_mapping_invalid(traversal);
assert_mapping_invalid(current);
assert_mapping_invalid(absolute);
}
}
#[test]
fn malformed_filemap_arguments_are_rejected() {
for argument in ["--filemap", "--filemap=cfg.std.logging", "--filemap==logging.json", "--filemap=cfg.std.logging="] {
let args = [std::ffi::OsString::from("ksp-app"), std::ffi::OsString::from(argument)];
let result = super::ConfigFileRegistry::from_args(&args);
assert!(result.is_err(), "malformed filemap argument must be rejected: {argument}");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
}
}
}
#[test]
fn duplicate_registry_ids_are_rejected() {
let first = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "first.json");
let second = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "second.json");
assert!(first.is_ok(), "first descriptor should be valid: {first:?}");
assert!(second.is_ok(), "second descriptor should be valid: {second:?}");
if let (std::result::Result::Ok(first), std::result::Result::Ok(second)) = (first, second) {
let result = super::build_registry([first, second]);
assert!(result.is_err(), "duplicate file_ids must be rejected");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_ID_DUPLICATE);
}
}
}
#[test]
fn descriptor_kind_must_match_file_id_namespace() {
let result = super::ConfigFileDescriptor::new("schema.invalid-kind", super::ConfigFileKind::Config, "invalid.json");
assert!(result.is_err(), "descriptor kind mismatch must be rejected");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
}
}
fn assert_mapping_invalid<T: std::fmt::Debug>(result: ksp_core_lib::Result<T>) {
assert!(result.is_err(), "invalid filename must be rejected: {result:?}");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID);
}
}
fn absolute_fixture_path() -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push("ksp-config-lib-absolute-mapping.json");
return path;
}

311
deltas/0.1.3/pre.003.md Normal file
View File

@@ -0,0 +1,311 @@
<!-- file: deltas/0.1.3/pre.003.md -->
<!-- version: 1 -->
# Delta 0.1.3-pre.003
## Base requise
Livraison précédente validée :
```text
0.1.3-pre.002-fix.001
```
Version technique de cette base :
```text
workspace.package.version = "0.1.3-pre.2.fix.1"
Cargo.toml header version = 42
```
Les validations utilisateur de cette base sont propres : formatage, check, Clippy sans warning, tests workspace et vues `cargo tree` de `ksp-config-lib`.
## Objet de pre.003
Cette tranche introduit uniquement le registre logique des fichiers possédés par Config et le remplacement bootstrap de leurs noms physiques.
Elle ajoute :
- `ConfigFileId` ;
- `ConfigFileKind` ;
- `ConfigFileDescriptor` ;
- `ConfigFileRegistry` ;
- les mappings par défaut actuellement connus ;
- `--filemap=<file_id>=<filename>` répétable ;
- l'override programmatique équivalent ;
- la résolution d'un `file_id` sous `cfgpath` ou `schemapath` ;
- les validations d'identité, d'unicité, de kind et de filename relatif ;
- les erreurs Config et tests strictement nécessaires à cette surface.
Elle n'ajoute pas encore :
- `serde`, `serde_json` ou `jsonschema` ;
- lecture ou écriture JSON ;
- fichiers runtime `config/` ;
- validation de schema ;
- profils ou composites ;
- `.env` ou environnement du processus ;
- interpolation `${...}` ;
- secrets ;
- persistence ;
- modification de `ksp-logging-lib`.
## Identité logique et mappings par défaut
Les deux seuls fichiers que Config connaît actuellement sont :
```text
cfg.std.logging -> std.logging.json
schema.std.logging -> std.logging.schema.json
```
Les fichiers physiques ne sont pas encore créés ni lus dans cette tranche. Leur identité logique est introduite maintenant afin que les futurs consumers et composites ne dépendent jamais directement de leur filename.
Les constantes publiques associées sont :
```text
FILE_ID_STD_LOGGING
FILE_ID_SCHEMA_STD_LOGGING
DEFAULT_STD_LOGGING_FILENAME
DEFAULT_STD_LOGGING_SCHEMA_FILENAME
```
## `ConfigFileId`
`ConfigFileId` est une valeur typée possédée par Config.
La syntaxe initiale accepte uniquement :
- ASCII minuscule ;
- chiffres ;
- `_` et `-` dans les segments ;
- `.` comme séparateur de segments ;
- aucun segment vide.
Exemples valides :
```text
cfg.std.logging
schema.std.logging
cfg.composite.ksp-app-wallet-desk
```
Exemples refusés :
```text
CFG.std.logging
cfg..logging
cfg/logging
.cfg.logging
```
## Kind et racines
`ConfigFileKind` distingue :
```text
Config -> ConfigBootstrapOptions::cfg_path()
Schema -> ConfigBootstrapOptions::schema_path()
```
Un descriptor `Config` doit appartenir au namespace `cfg.*` et un descriptor `Schema` au namespace `schema.*`.
Cette relation est fixée au registre ; un override de filename ne peut jamais changer le kind ni déplacer un schema sous `cfgpath` ou un document Config sous `schemapath`.
## Override CLI `--filemap`
Le contrat initial est volontairement atomique :
```text
--filemap=<file_id>=<filename>
```
Exemples :
```text
--filemap=cfg.std.logging=my.logging.json
--filemap=schema.std.logging=my.logging.schema.json
```
L'argument est répétable. Lorsque le même `file_id` est fourni plusieurs fois, le dernier filename gagne.
Les arguments étrangers sont ignorés, comme pour le bootstrap des roots.
La forme séparée suivante n'est pas supportée :
```text
--filemap cfg.std.logging=my.logging.json
```
Un `--filemap` sans valeur inline est donc une erreur de mapping plutôt qu'un argument ignoré.
Un override ne peut pas enregistrer un nouveau `file_id`. Le `file_id` doit déjà appartenir au registre KSP ; seul son filename physique est remplaçable.
## Filenames et confinement lexical
Un filename mappé :
- doit être non vide ;
- doit être relatif ;
- ne peut contenir ni `.` ni `..` comme composants ;
- ne peut contenir de root/prefix absolu ;
- peut contenir des sous-répertoires relatifs normaux.
Ainsi :
```text
std.logging.json
profiles/my.logging.json
```
sont acceptés, tandis que :
```text
../std.logging.json
./std.logging.json
/opt/ksp/std.logging.json
```
sont refusés.
Cette tranche garantit le confinement **lexical** sous `cfgpath`/`schemapath`. Les garanties filesystem/canonicalisation/symlink nécessaires à la lecture et à la persistence seront ajoutées avec les opérations I/O correspondantes, pas simulées avant leur existence.
## API publique
La surface publique ajoute notamment :
```text
ConfigFileId::new(...)
ConfigFileId::as_str()
ConfigFileRegistry::defaults()
ConfigFileRegistry::from_args(...)
ConfigFileRegistry::descriptor(...)
ConfigFileRegistry::resolve_path(...)
ConfigFileRegistry::with_filename_override(...)
```
`resolve_path(...)` ne lit rien : il sélectionne simplement la bonne root bootstrap selon le kind et y joint le filename validé.
## Erreurs Config ajoutées
Les codes restent possédés par `ksp-config-lib` :
```text
config.file_id_invalid
config.file_id_unknown
config.file_id_duplicate
config.file_mapping_invalid
```
Ils réutilisent toujours `ksp_core_lib::Error`, `ErrorCode` et `Result<T>` sans introduire de connaissance Config dans Core.
## Dépendances
Aucune dépendance ajoutée ou modifiée.
`ksp-config-lib` dépend toujours directement uniquement de :
```text
ksp-core-lib
```
## Tests ajoutés
Les tests unitaires couvrent notamment :
- les deux mappings par défaut ;
- la séparation `cfgpath` / `schemapath` par kind ;
- l'override CLI ;
- la règle du dernier override ;
- l'override programmatique ;
- la conservation du `file_id` et du kind ;
- le refus d'un ID inconnu ;
- la syntaxe des IDs ;
- le refus des paths absolus et traversants ;
- le refus des arguments `--filemap` mal formés ;
- le refus des IDs dupliqués ;
- la cohérence namespace/kind.
Le test d'intégration public vérifie également que le registre, les deux IDs Logging, les overrides de document/schema et leur résolution sont consommables depuis le crate-root.
## Version technique
La prerelease devient :
```text
workspace.package.version = "0.1.3-pre.3"
```
Le manifest racine devient :
```text
# version: 43
```
Le plan Config devient :
```text
<!-- version: 6 -->
```
## Fichiers ajoutés
```text
crates/ksp-config-lib/src/registry.rs
crates/ksp-config-lib/unit_tests/registry.rs
deltas/0.1.3/pre.003.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-config-lib/src/error.rs
crates/ksp-config-lib/src/lib.rs
crates/ksp-config-lib/tests/public_api.rs
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
```
## Fichiers supprimés
Aucun.
## Validations utilisateur attendues
```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
```
Exécuter également tout script d'audit réellement présent dans le dépôt utilisateur.
## Validations exécutées avant livraison
L'environnement de génération ne fournit pas `cargo`, `rustc` ou `rustfmt`; aucune validation Cargo n'est donc déclarée réussie sur `pre.003` avant les tests utilisateur.
Les contrôles statiques de préparation vérifient :
- headers `file:` / `version:` ;
- version Cargo `0.1.3-pre.3` ;
- aucune ligne Rust supérieure à 160 colonnes avant formatage ;
- aucun `unsafe`, `unwrap`, `expect`, `panic!` ou opérateur `?` dans le nouveau code ;
- aucune lecture de variable applicative ;
- aucune dépendance externe nouvelle ;
- aucune surface JSON/schema I/O, `.env` ou Logging ouverte ;
- delta limité aux fichiers ajoutés/modifiés de cette tranche.
## Suite après validation
Si `pre.003` est validée, la tranche suivante reste :
```text
0.1.3-pre.004 — ksp-logging-lib : modèle public multi-output
```
Elle modifiera les contrats/settings Logging avant que `std.logging.schema.json` ne soit figé en `pre.006`.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md -->
<!-- version: 5 -->
<!-- version: 6 -->
# Plan `0.1.3` — Configuration foundation
@@ -1580,7 +1580,7 @@ Implémentation candidate livrée par `pre.002` :
- un path inexistant est accepté à ce stade, un path vide ou un path existant non répertoire est refusé ;
- le registre `file_id`, `--filemap`, JSON, schemas, profils et environnement restent strictement hors de `pre.002`.
La validation utilisateur de cette implémentation reste requise avant l'ouverture de `pre.003`.
Validation utilisateur acquise après `pre.002-fix.001` : `cargo fmt --all`, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test --workspace` et les trois vues `cargo tree` demandées sont propres. La seule alerte initiale `missing_docs` du test d'intégration a été corrigée par `pre.002-fix.001` puis revalidée sans warning.
### `0.1.3-pre.003` — registre logique `file_id -> filename`
@@ -1597,6 +1597,23 @@ Objectif unique : rendre les noms physiques remplaçables sans modifier l'identi
- tests du registre et des overrides ;
- aucune lecture JSON/schema encore nécessaire.
Implémentation candidate livrée par `pre.003` :
- `ConfigFileId` possède l'identité logique stable et valide une nomenclature ASCII minuscule segmentée par `.` ;
- `ConfigFileKind::{Config, Schema}` détermine exclusivement la racine physique utilisée ;
- `ConfigFileDescriptor` conserve `file_id`, kind et filename relatif ;
- `ConfigFileRegistry::defaults()` enregistre uniquement les deux fichiers actuellement connus : `cfg.std.logging -> std.logging.json` et `schema.std.logging -> std.logging.schema.json` ;
- un descriptor Config doit utiliser un `file_id` `cfg.*`, un descriptor Schema un `file_id` `schema.*` ;
- `ConfigFileRegistry::from_args(...)` possède le parsing répétable `--filemap=<file_id>=<filename>`, ignore les arguments étrangers et applique le dernier override d'un même ID ;
- un override ne peut pas créer un ID inconnu : il remplace uniquement le filename d'un descriptor possédé par Config ;
- `ConfigFileRegistry::with_filename_override(...)` fournit le même mécanisme par API programmatique ;
- les filenames doivent rester relatifs à leur root, sans composant `.` / `..`, préfixe ou racine absolue ; les sous-répertoires relatifs normaux restent autorisés ;
- `ConfigFileRegistry::resolve_path(...)` joint le filename à `cfgpath` ou `schemapath` selon le kind, sans lire le fichier ;
- l'unicité des `file_id`, les erreurs d'ID/mapping et les overrides config/schema sont couverts par tests unitaires et API publique ;
- aucune dépendance externe, lecture JSON, validation schema ou `.env` n'est introduite par cette tranche.
La validation utilisateur de `pre.003` reste requise avant l'ouverture de `pre.004`.
### `0.1.3-pre.004` — `ksp-logging-lib` : modèle public multi-output
Objectif unique : compléter les **contrats/settings publics** de Logging sans encore implémenter tout le routing runtime.