v0.1.3-pre.007

This commit is contained in:
2026-08-15 20:57:13 +02:00
parent b7323fe961
commit 96753e4ba1
17 changed files with 1647 additions and 44 deletions

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-config-lib/Cargo.toml
# version: 1
# version: 2
[package]
name = "ksp-config-lib"
@@ -9,6 +9,9 @@ repository.workspace = true
[dependencies]
ksp-core-lib = { path = "../ksp-core-lib" }
serde.workspace = true
serde_json.workspace = true
jsonschema.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,451 @@
// file: crates/ksp-config-lib/src/document.rs
// version: 1
/// A Config-managed JSON document that has passed syntax, schema and current semantic validation.
#[derive(Clone, Debug, PartialEq)]
pub struct ConfigJsonDocument {
file_id: crate::ConfigFileId,
path: std::path::PathBuf,
value: serde_json::Value,
}
impl ConfigJsonDocument {
/// Returns the logical Config file identifier used to load this document.
#[must_use]
pub fn file_id(&self) -> &crate::ConfigFileId {
return &self.file_id;
}
/// Returns the resolved physical path from which this document was loaded.
#[must_use]
pub fn path(&self) -> &std::path::Path {
return self.path.as_path();
}
/// Returns the validated JSON value without transferring Config ownership of file I/O or validation.
#[must_use]
pub fn value(&self) -> &serde_json::Value {
return &self.value;
}
}
/// Generic JSON/JSON Schema engine owned by `ksp-config-lib`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigDocumentEngine {
bootstrap: crate::ConfigBootstrapOptions,
registry: crate::ConfigFileRegistry,
}
impl ConfigDocumentEngine {
/// Creates a document engine from already validated bootstrap options and a logical file registry.
#[must_use]
pub fn new(bootstrap: crate::ConfigBootstrapOptions, registry: crate::ConfigFileRegistry) -> Self {
return Self { bootstrap, registry };
}
/// Returns the bootstrap roots used by this engine.
#[must_use]
pub const fn bootstrap(&self) -> &crate::ConfigBootstrapOptions {
return &self.bootstrap;
}
/// Returns the logical file registry used by this engine.
#[must_use]
pub const fn registry(&self) -> &crate::ConfigFileRegistry {
return &self.registry;
}
/// Loads one registered Config document and validates it against its registered JSON Schema and current KSP semantic invariants.
pub fn load_validated_document(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
let descriptor = self.registry.descriptor(file_id);
let descriptor = match descriptor {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if descriptor.kind() != crate::ConfigFileKind::Config {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document")
.with_context("file_id", file_id.as_str()),
);
}
let schema_file_id = match descriptor.schema_file_id() {
std::option::Option::Some(value) => value.clone(),
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema")
.with_context("file_id", file_id.as_str()),
);
},
};
let document = self.load_json(file_id);
let document = match document {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let schema = self.load_json(&schema_file_id);
let schema = match schema {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let schema_validation = validate_schema_document(&schema);
match schema_validation {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let instance_validation = validate_instance(&document, &schema);
match instance_validation {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
let semantic_validation = validate_document_semantics(&document);
return match semantic_validation {
std::result::Result::Ok(()) => std::result::Result::Ok(document),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn load_json(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result<ConfigJsonDocument> {
let path = self.registry.resolve_path(&self.bootstrap, file_id);
let path = match path {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let content = std::fs::read_to_string(path.as_path());
let content = match content {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(json_read_error(file_id, &path, error)),
};
let value = serde_json::from_str::<serde_json::Value>(content.as_str());
return match value {
std::result::Result::Ok(value) => std::result::Result::Ok(ConfigJsonDocument { file_id: file_id.clone(), path, value }),
std::result::Result::Err(error) => std::result::Result::Err(json_syntax_error(file_id, &path, error)),
};
}
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingDocumentSource {
format_version: u32,
logs_directory: String,
default_profile: String,
profiles: std::vec::Vec<LoggingProfileSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingProfileSource {
profile_id: String,
default_filter: String,
span_events: String,
console: LoggingConsoleSource,
files: std::vec::Vec<LoggingFileSource>,
target_filters: std::vec::Vec<LoggingTargetFilterSource>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingConsoleSource {
enabled: bool,
output: String,
ansi: bool,
format: String,
filter: LoggingOutputFilterSource,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingFileSource {
output_id: String,
enabled: bool,
path: String,
rotation: String,
format: String,
ansi: bool,
filter: LoggingOutputFilterSource,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingOutputFilterSource {
level: String,
targets: std::vec::Vec<String>,
domains: std::vec::Vec<String>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct LoggingTargetFilterSource {
target_prefix: String,
level: String,
}
fn validate_schema_document(schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
let validation = jsonschema::meta::validate(schema.value());
return match validation {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_INVALID, "Config JSON Schema document is invalid")
.with_context("file_id", schema.file_id().as_str())
.with_context("path", schema.path().to_string_lossy().into_owned())
.with_context("detail", error.to_string()),
),
};
}
fn validate_instance(document: &ConfigJsonDocument, schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
let validation = jsonschema::draft202012::validate(schema.value(), document.value());
return match validation {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "Config document does not satisfy its registered JSON Schema")
.with_context("file_id", document.file_id().as_str())
.with_context("schema_file_id", schema.file_id().as_str())
.with_context("path", document.path().to_string_lossy().into_owned())
.with_context("detail", error.to_string()),
),
};
}
fn validate_document_semantics(document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> {
if document.file_id().as_str() != crate::FILE_ID_STD_LOGGING {
return std::result::Result::Ok(());
}
let parsed = serde_json::from_value::<LoggingDocumentSource>(document.value().clone());
let parsed = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
semantic_error(document, "schema-valid Logging document cannot be decoded into the KSP source contract").with_source(error),
);
},
};
return validate_logging_document(document, &parsed);
}
fn validate_logging_document(document: &ConfigJsonDocument, source: &LoggingDocumentSource) -> ksp_core_lib::Result<()> {
if source.format_version != 1 {
return std::result::Result::Err(semantic_error(document, "unsupported Logging document format_version"));
}
if source.logs_directory.trim().is_empty() {
return std::result::Result::Err(semantic_error(document, "logs_directory must not be empty"));
}
if source.default_profile.trim().is_empty() {
return std::result::Result::Err(semantic_error(document, "default_profile must not be empty"));
}
for (profile_index, profile) in source.profiles.iter().enumerate() {
let validation = validate_logging_profile(document, profile, profile_index);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
}
return std::result::Result::Ok(());
}
fn validate_logging_profile(document: &ConfigJsonDocument, profile: &LoggingProfileSource, profile_index: usize) -> ksp_core_lib::Result<()> {
if profile.profile_id.trim().is_empty() || profile.default_filter.trim().is_empty() || profile.span_events.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging profile identity and base settings must not be empty").with_context("profile_index", profile_index.to_string()),
);
}
let console_validation = validate_logging_console(document, &profile.console, profile_index);
if let std::result::Result::Err(error) = console_validation {
return std::result::Result::Err(error);
}
for (file_index, file) in profile.files.iter().enumerate() {
let file_validation = validate_logging_file(document, file, profile_index, file_index);
if let std::result::Result::Err(error) = file_validation {
return std::result::Result::Err(error);
}
for previous in &profile.files[..file_index] {
if previous.output_id == file.output_id {
return std::result::Result::Err(
semantic_error(document, "Logging file output identifiers must be unique within a profile")
.with_context("profile_index", profile_index.to_string())
.with_context("output_id", file.output_id.as_str()),
);
}
}
}
for (target_index, target_filter) in profile.target_filters.iter().enumerate() {
if target_filter.target_prefix.trim().is_empty() || !target_filter.target_prefix.starts_with("ksp-") || target_filter.level.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging global target filter must identify a KSP-owned target")
.with_context("profile_index", profile_index.to_string())
.with_context("target_filter_index", target_index.to_string()),
);
}
}
return std::result::Result::Ok(());
}
fn validate_logging_console(document: &ConfigJsonDocument, console: &LoggingConsoleSource, profile_index: usize) -> ksp_core_lib::Result<()> {
let _enabled = console.enabled;
if console.output.trim().is_empty() || console.format.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging console output and format must not be empty").with_context("profile_index", profile_index.to_string()),
);
}
if console.ansi && console.format == "json" {
return std::result::Result::Err(
semantic_error(document, "ANSI formatting is not compatible with JSON console output").with_context("profile_index", profile_index.to_string()),
);
}
return validate_logging_output_filter(document, &console.filter, profile_index, "console");
}
fn validate_logging_file(document: &ConfigJsonDocument, file: &LoggingFileSource, profile_index: usize, file_index: usize) -> ksp_core_lib::Result<()> {
let _enabled = file.enabled;
if !valid_output_id(file.output_id.as_str()) {
return std::result::Result::Err(
semantic_error(document, "Logging file output_id is invalid")
.with_context("profile_index", profile_index.to_string())
.with_context("file_index", file_index.to_string())
.with_context("output_id", file.output_id.as_str()),
);
}
if file.path.trim().is_empty() || !relative_log_path_is_valid(file.path.as_str()) {
return std::result::Result::Err(
semantic_error(document, "Logging file path must stay relative to logs_directory without traversal")
.with_context("profile_index", profile_index.to_string())
.with_context("file_index", file_index.to_string()),
);
}
if file.rotation.trim().is_empty() || file.format.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging file rotation and format must not be empty").with_context("profile_index", profile_index.to_string()),
);
}
if file.ansi {
return std::result::Result::Err(
semantic_error(document, "ANSI sequences are not allowed in persistent Logging outputs").with_context("profile_index", profile_index.to_string()),
);
}
return validate_logging_output_filter(document, &file.filter, profile_index, file.output_id.as_str());
}
fn validate_logging_output_filter(
document: &ConfigJsonDocument,
filter: &LoggingOutputFilterSource,
profile_index: usize,
output: &str,
) -> ksp_core_lib::Result<()> {
if filter.level.trim().is_empty() {
return std::result::Result::Err(
semantic_error(document, "Logging output filter level must not be empty").with_context("profile_index", profile_index.to_string()),
);
}
let targets = validate_selectors(document, &filter.targets, true, profile_index, output, "targets");
if let std::result::Result::Err(error) = targets {
return std::result::Result::Err(error);
}
return validate_selectors(document, &filter.domains, false, profile_index, output, "domains");
}
fn validate_selectors(
document: &ConfigJsonDocument,
selectors: &[String],
target_dimension: bool,
profile_index: usize,
output: &str,
dimension: &'static str,
) -> ksp_core_lib::Result<()> {
if selectors.is_empty() {
return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "selector list must not be empty"));
}
if selectors.len() > 1
&& selectors.iter().any(|selector| -> bool {
return selector == "*";
})
{
return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "wildcard selector must be used alone"));
}
for (index, selector) in selectors.iter().enumerate() {
if selector.trim().is_empty() {
return std::result::Result::Err(
selector_error(document, profile_index, output, dimension, "selector must not be empty").with_context("selector_index", index.to_string()),
);
}
if target_dimension && selector != "*" && !selector.starts_with("ksp-") {
return std::result::Result::Err(
selector_error(document, profile_index, output, dimension, "target selector must identify a KSP-owned target")
.with_context("selector_index", index.to_string()),
);
}
for previous in &selectors[..index] {
if previous == selector {
return std::result::Result::Err(
selector_error(document, profile_index, output, dimension, "selectors must be unique").with_context("selector_index", index.to_string()),
);
}
}
}
return std::result::Result::Ok(());
}
fn valid_output_id(output_id: &str) -> bool {
let mut previous_was_separator = true;
if output_id.is_empty() {
return false;
}
for byte in output_id.bytes() {
if byte == b'.' {
if previous_was_separator {
return false;
}
previous_was_separator = true;
} else if byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' {
previous_was_separator = false;
} else {
return false;
}
}
return !previous_was_separator;
}
fn relative_log_path_is_valid(value: &str) -> bool {
let path = std::path::Path::new(value);
if path.is_absolute() {
return false;
}
let mut has_normal_component = false;
for component in path.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 false,
}
}
return has_normal_component;
}
fn selector_error(document: &ConfigJsonDocument, profile_index: usize, output: &str, dimension: &'static str, reason: &'static str) -> ksp_core_lib::Error {
return semantic_error(document, reason)
.with_context("profile_index", profile_index.to_string())
.with_context("output", output)
.with_context("dimension", dimension);
}
fn semantic_error(document: &ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document 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 json_read_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "Config-managed JSON file cannot be read")
.with_context("file_id", file_id.as_str())
.with_context("path", path.to_string_lossy().into_owned())
.with_source(source);
}
fn json_syntax_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: serde_json::Error) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "Config-managed file contains invalid JSON syntax")
.with_context("file_id", file_id.as_str())
.with_context("path", path.to_string_lossy().into_owned())
.with_source(source);
}
#[cfg(test)]
#[path = "../unit_tests/document.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/error.rs
// version: 2
// version: 3
/// 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");
@@ -18,3 +18,18 @@ pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib::
/// 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");
/// Error code used when a Config-managed JSON document or schema cannot be read from its resolved path.
pub const ERROR_CODE_JSON_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_file_read_failed");
/// Error code used when a Config-managed file contains invalid JSON syntax.
pub const ERROR_CODE_JSON_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_syntax_invalid");
/// Error code used when a JSON Schema document is itself invalid for the selected JSON Schema draft.
pub const ERROR_CODE_SCHEMA_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_invalid");
/// Error code used when a Config document does not satisfy its registered JSON Schema.
pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_validation_failed");
/// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type.
pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid");

View File

@@ -1,16 +1,17 @@
// file: crates/ksp-config-lib/src/lib.rs
// version: 2
// version: 3
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! KSP-owned application configuration facade.
//!
//! `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.
//! `0.1.3-pre.007` owns the non-recursive bootstrap roots, stable logical file registry and first generic JSON/JSON Schema loading pipeline. The standard
//! Logging document is the first registered runtime document. Profile resolution, environment substitution, sensitivity and persistence remain in later bounded
//! prereleases.
mod bootstrap;
mod document;
mod error;
mod registry;
@@ -24,10 +25,16 @@ 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;
/// 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.
pub use self::document::ConfigJsonDocument;
/// Error code used when a Config bootstrap argument is missing its value.
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 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.
pub use self::error::ERROR_CODE_FILE_ID_DUPLICATE;
/// Error code used when a logical Config file identifier is malformed.
@@ -36,9 +43,17 @@ pub use self::error::ERROR_CODE_FILE_ID_INVALID;
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;
/// Error code used when a Config-managed JSON document or schema cannot be read.
pub use self::error::ERROR_CODE_JSON_FILE_READ_FAILED;
/// Error code used when a Config-managed file contains invalid JSON syntax.
pub use self::error::ERROR_CODE_JSON_SYNTAX_INVALID;
/// Error code used when a JSON Schema document is itself invalid.
pub use self::error::ERROR_CODE_SCHEMA_INVALID;
/// Error code used when a Config document fails its registered JSON Schema validation.
pub use self::error::ERROR_CODE_SCHEMA_VALIDATION_FAILED;
/// 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.
/// Logical descriptor associating a stable file identifier with its physical filename and validation schema.
pub use self::registry::ConfigFileDescriptor;
/// Stable logical identifier for a Config-managed file.
pub use self::registry::ConfigFileId;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/registry.rs
// version: 1
// version: 2
/// Bootstrap argument used to replace a known Config filename mapping.
pub const ARG_FILE_MAP: &str = "--filemap";
@@ -43,12 +43,13 @@ pub enum ConfigFileKind {
Schema,
}
/// Logical descriptor associating a stable file identifier with its physical filename and root category.
/// Logical descriptor associating a stable file identifier with its physical filename, root category and optional validation schema.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfigFileDescriptor {
file_id: ConfigFileId,
kind: ConfigFileKind,
filename: std::path::PathBuf,
schema_file_id: std::option::Option<ConfigFileId>,
}
impl ConfigFileDescriptor {
@@ -70,7 +71,18 @@ impl ConfigFileDescriptor {
return self.filename.as_path();
}
fn new(file_id: &'static str, kind: ConfigFileKind, filename: &'static str) -> ksp_core_lib::Result<Self> {
/// Returns the logical schema identifier associated with this Config document when one is declared.
#[must_use]
pub fn schema_file_id(&self) -> std::option::Option<&ConfigFileId> {
return self.schema_file_id.as_ref();
}
fn new(
file_id: &'static str,
kind: ConfigFileKind,
filename: &'static str,
schema_file_id: std::option::Option<&'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,
@@ -82,8 +94,13 @@ impl ConfigFileDescriptor {
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 }),
let filename = match filename {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let schema_file_id = parse_schema_file_id(&file_id, kind, schema_file_id);
return match schema_file_id {
std::result::Result::Ok(value) => std::result::Result::Ok(Self { file_id, kind, filename, schema_file_id: value }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
@@ -98,12 +115,18 @@ pub struct ConfigFileRegistry {
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 = ConfigFileDescriptor::new(
FILE_ID_STD_LOGGING,
ConfigFileKind::Config,
DEFAULT_STD_LOGGING_FILENAME,
std::option::Option::Some(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 = ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_LOGGING, ConfigFileKind::Schema, DEFAULT_STD_LOGGING_SCHEMA_FILENAME);
let logging_schema =
ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_LOGGING, ConfigFileKind::Schema, 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),
@@ -154,7 +177,7 @@ impl ConfigFileRegistry {
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.
/// Replaces the physical filename of one known logical identifier while preserving its kind, schema association 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 {
@@ -174,7 +197,12 @@ impl ConfigFileRegistry {
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 };
let updated = ConfigFileDescriptor {
file_id: descriptor.file_id.clone(),
kind: descriptor.kind,
filename,
schema_file_id: descriptor.schema_file_id.clone(),
};
self.descriptors.insert(file_id.clone(), updated);
return std::result::Result::Ok(());
}
@@ -190,7 +218,68 @@ fn build_registry<const N: usize>(descriptors: [ConfigFileDescriptor; N]) -> ksp
return std::result::Result::Err(duplicate_file_id_error(duplicate_id.as_str()));
}
}
return std::result::Result::Ok(registry);
let associations = validate_schema_associations(&registry);
return match associations {
std::result::Result::Ok(()) => std::result::Result::Ok(registry),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn parse_schema_file_id(
file_id: &ConfigFileId,
kind: ConfigFileKind,
schema_file_id: std::option::Option<&'static str>,
) -> ksp_core_lib::Result<std::option::Option<ConfigFileId>> {
return match (kind, schema_file_id) {
(ConfigFileKind::Schema, std::option::Option::Some(_)) => {
std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "schema descriptors cannot declare another validation schema"))
},
(ConfigFileKind::Schema, std::option::Option::None) | (ConfigFileKind::Config, std::option::Option::None) => {
std::result::Result::Ok(std::option::Option::None)
},
(ConfigFileKind::Config, std::option::Option::Some(value)) => {
let schema_id = ConfigFileId::new(value);
match schema_id {
std::result::Result::Ok(schema_id) => {
if schema_id.as_str().starts_with("schema.") {
std::result::Result::Ok(std::option::Option::Some(schema_id))
} else {
std::result::Result::Err(invalid_file_mapping_with_id_error(
file_id.as_str(),
"validation schema file_id must use the schema namespace",
))
}
},
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
};
}
fn validate_schema_associations(registry: &ConfigFileRegistry) -> ksp_core_lib::Result<()> {
for descriptor in registry.descriptors.values() {
let schema_file_id = match descriptor.schema_file_id() {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
let schema = registry.descriptors.get(schema_file_id);
let schema = match schema {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(invalid_file_mapping_with_id_error(
descriptor.file_id().as_str(),
"validation schema file_id is not registered",
));
},
};
if schema.kind() != ConfigFileKind::Schema {
return std::result::Result::Err(invalid_file_mapping_with_id_error(
descriptor.file_id().as_str(),
"validation schema descriptor must have schema kind",
));
}
}
return std::result::Result::Ok(());
}
fn apply_file_map_argument(registry: &mut ConfigFileRegistry, argument: &std::ffi::OsStr) -> ksp_core_lib::Result<()> {

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-config-lib/tests/public_api.rs
// version: 3
// version: 4
//! Integration tests for the public `ksp-config-lib` bootstrap and logical file registry contracts.
//! Integration tests for the public `ksp-config-lib` bootstrap, logical file registry and validated JSON document contracts.
#[test]
fn bootstrap_contract_is_available_from_crate_root() {
@@ -72,3 +72,23 @@ fn logical_file_registry_is_available_from_crate_root() {
assert_eq!(ksp_config_lib::ARG_FILE_MAP, "--filemap");
}
}
#[test]
fn validated_json_document_engine_is_available_from_crate_root() {
let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
let registry = ksp_config_lib::ConfigFileRegistry::defaults();
let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING);
assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}");
assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}");
assert!(file_id.is_ok(), "public Logging file_id should remain constructible: {file_id:?}");
if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) {
let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry);
let document = engine.load_validated_document(&file_id);
assert!(document.is_ok(), "public document engine should validate committed Logging configuration: {document:?}");
if let std::result::Result::Ok(document) = document {
assert_eq!(document.file_id(), &file_id);
assert_eq!(document.value().get("format_version"), std::option::Option::Some(&serde_json::Value::from(1)));
}
}
}

View File

@@ -0,0 +1,224 @@
// file: crates/ksp-config-lib/unit_tests/document.rs
// version: 1
#[test]
fn committed_logging_document_passes_registered_schema_and_semantic_validation() {
let workspace = workspace_root();
let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas"));
let registry = crate::ConfigFileRegistry::defaults();
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
assert!(bootstrap.is_ok(), "workspace Config paths should be valid: {bootstrap:?}");
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(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) {
let engine = super::ConfigDocumentEngine::new(bootstrap, registry);
let document = engine.load_validated_document(&file_id);
assert!(document.is_ok(), "committed std.logging.json should validate: {document:?}");
if let std::result::Result::Ok(document) = document {
assert_eq!(document.file_id(), &file_id);
assert_eq!(document.path(), workspace.join("config/std.logging.json").as_path());
let default_profile = document.value().get("default_profile");
assert!(default_profile.is_some(), "validated Logging document should retain default_profile");
if let std::option::Option::Some(default_profile) = default_profile {
assert_eq!(default_profile.as_str(), std::option::Option::Some("local_dev"));
}
}
}
}
#[test]
fn missing_document_is_reported_with_file_read_error() {
let fixture = fixture_roots("missing-document");
let prepared = prepare_fixture(&fixture, std::option::Option::None, valid_minimal_logging_schema());
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
if prepared.is_ok() {
let result = load_fixture(&fixture);
assert_error_code(result, crate::ERROR_CODE_JSON_FILE_READ_FAILED);
}
cleanup_fixture(&fixture);
}
#[test]
fn malformed_json_is_reported_before_schema_validation() {
let fixture = fixture_roots("malformed-json");
let prepared = prepare_fixture(&fixture, std::option::Option::Some("{ invalid-json"), valid_minimal_logging_schema());
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
if prepared.is_ok() {
let result = load_fixture(&fixture);
assert_error_code(result, crate::ERROR_CODE_JSON_SYNTAX_INVALID);
}
cleanup_fixture(&fixture);
}
#[test]
fn invalid_schema_document_is_reported_before_instance_validation() {
let fixture = fixture_roots("invalid-schema");
let schema = r#"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "definitely-not-a-json-schema-type"
}"#;
let prepared = prepare_fixture(&fixture, std::option::Option::Some("{}"), schema);
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
if prepared.is_ok() {
let result = load_fixture(&fixture);
assert_error_code(result, crate::ERROR_CODE_SCHEMA_INVALID);
}
cleanup_fixture(&fixture);
}
#[test]
fn schema_violation_is_distinct_from_json_syntax_failure() {
let fixture = fixture_roots("schema-violation");
let schema = r#"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["required_field"],
"properties": {
"required_field": {"type": "string"}
}
}"#;
let prepared = prepare_fixture(&fixture, std::option::Option::Some("{}"), schema);
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
if prepared.is_ok() {
let result = load_fixture(&fixture);
assert_error_code(result, crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED);
}
cleanup_fixture(&fixture);
}
#[test]
fn schema_valid_logging_document_can_still_fail_ksp_semantics() {
let fixture = fixture_roots("semantic-invalid");
let workspace = workspace_root();
let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json"));
assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}");
if let std::result::Result::Ok(schema_source) = schema_source {
let document = r#"{
"format_version": 1,
"logs_directory": "logs",
"default_profile": "duplicate-output",
"profiles": [
{
"profile_id": "duplicate-output",
"default_filter": "info",
"span_events": "off",
"console": {
"enabled": false,
"output": "stderr",
"ansi": false,
"format": "human",
"filter": {"level": "trace", "targets": ["*"], "domains": ["*"]}
},
"files": [
{
"output_id": "file.same",
"enabled": true,
"path": "first.log",
"rotation": "daily",
"format": "human",
"ansi": false,
"filter": {"level": "info", "targets": ["*"], "domains": ["*"]}
},
{
"output_id": "file.same",
"enabled": true,
"path": "second.log",
"rotation": "daily",
"format": "human",
"ansi": false,
"filter": {"level": "info", "targets": ["*"], "domains": ["*"]}
}
],
"target_filters": []
}
]
}"#;
let prepared = prepare_fixture(&fixture, std::option::Option::Some(document), schema_source.as_str());
assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}");
if prepared.is_ok() {
let result = load_fixture(&fixture);
assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID);
}
}
cleanup_fixture(&fixture);
}
fn load_fixture(fixture: &FixtureRoots) -> ksp_core_lib::Result<super::ConfigJsonDocument> {
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 = crate::ConfigFileRegistry::defaults();
let registry = match registry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING);
let file_id = match file_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let engine = super::ConfigDocumentEngine::new(bootstrap, registry);
return engine.load_validated_document(&file_id);
}
fn prepare_fixture(fixture: &FixtureRoots, document: std::option::Option<&str>, schema: &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 schema_write = std::fs::write(fixture.schemas.join(crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME), schema);
if let std::result::Result::Err(error) = schema_write {
return std::result::Result::Err(error);
}
if let std::option::Option::Some(document) = document {
let document_write = std::fs::write(fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME), document);
if let std::result::Result::Err(error) = document_write {
return std::result::Result::Err(error);
}
}
return std::result::Result::Ok(());
}
fn valid_minimal_logging_schema() -> &'static str {
return r#"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object"
}"#;
}
fn assert_error_code(result: ksp_core_lib::Result<super::ConfigJsonDocument>, expected: ksp_core_lib::ErrorCode) {
assert!(result.is_err(), "fixture should fail with {expected:?}: {result:?}");
if let std::result::Result::Err(error) = result {
assert_eq!(error.code(), expected);
}
}
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 mut root = std::env::temp_dir();
root.push(format!("ksp-config-lib-pre007-{name}-{}", std::process::id()));
return FixtureRoots { config: root.join("config"), schemas: root.join("schemas"), root };
}
fn cleanup_fixture(fixture: &FixtureRoots) {
let result = std::fs::remove_dir_all(fixture.root.as_path());
if let std::result::Result::Err(error) = result {
assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "fixture cleanup should only ignore missing directories: {error}");
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/unit_tests/registry.rs
// version: 1
// version: 2
#[test]
fn defaults_register_logging_document_and_schema_with_distinct_roots() {
@@ -18,6 +18,11 @@ fn defaults_register_logging_document_and_schema_with_distinct_roots() {
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));
let logging_schema = logging.schema_file_id();
assert!(logging_schema.is_some(), "logging document should declare its validation schema");
if let std::option::Option::Some(logging_schema) = logging_schema {
assert_eq!(logging_schema, &schema_id);
}
assert_eq!(schema.kind(), super::ConfigFileKind::Schema);
assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_SCHEMA_FILENAME));
}
@@ -143,8 +148,8 @@ fn malformed_filemap_arguments_are_rejected() {
#[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");
let first = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "first.json", std::option::Option::None);
let second = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "second.json", std::option::Option::None);
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) {
@@ -158,13 +163,26 @@ fn duplicate_registry_ids_are_rejected() {
#[test]
fn descriptor_kind_must_match_file_id_namespace() {
let result = super::ConfigFileDescriptor::new("schema.invalid-kind", super::ConfigFileKind::Config, "invalid.json");
let result = super::ConfigFileDescriptor::new("schema.invalid-kind", super::ConfigFileKind::Config, "invalid.json", std::option::Option::None);
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);
}
}
#[test]
fn config_schema_association_must_reference_registered_schema_descriptor() {
let config = super::ConfigFileDescriptor::new("cfg.test", super::ConfigFileKind::Config, "test.json", std::option::Option::Some("schema.test"));
assert!(config.is_ok(), "config descriptor should be valid before registry association validation: {config:?}");
if let std::result::Result::Ok(config) = config {
let result = super::build_registry([config]);
assert!(result.is_err(), "registry must reject a missing schema association");
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 {