v0.1.3-pre.010

This commit is contained in:
2026-08-15 21:48:54 +02:00
parent 97a07ce683
commit 1fc002c978
16 changed files with 1113 additions and 42 deletions

View File

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

View File

@@ -0,0 +1,451 @@
// file: crates/ksp-config-lib/src/environment.rs
// version: 1
/// Default local environment file read by Config from the process launch directory.
pub const DEFAULT_DOTENV_PATH: &str = ".env";
/// Versioned environment contract template expected at the repository/runtime root.
pub const DEFAULT_DOTENV_EXAMPLE_PATH: &str = ".env.example";
const LOGGING_TARGET: &str = "ksp-config-lib";
const LOGGING_DOMAIN: &str = "config.environment";
/// Source that supplied one resolved Config environment variable.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigEnvironmentSource {
/// Value was present in the environment inherited by the current process.
Process,
/// Value was absent from the process environment and came from the local `.env` file.
DotEnv,
/// Value was absent from both external sources and came from the placeholder/API fallback.
Fallback,
}
/// One resolved Config environment variable and its winning source.
///
/// This type intentionally does not implement `Debug`: environment values may contain secrets. Sensitivity-aware safe representations are added by the next
/// bounded Config prerelease.
#[derive(Clone, Eq, PartialEq)]
pub struct ConfigEnvironmentValue {
variable_name: String,
value: String,
source: ConfigEnvironmentSource,
}
impl ConfigEnvironmentValue {
/// Returns the resolved variable name.
#[must_use]
pub fn variable_name(&self) -> &str {
return self.variable_name.as_str();
}
/// Returns the real resolved value.
#[must_use]
pub fn value(&self) -> &str {
return self.value.as_str();
}
/// Returns the source that won process > `.env` > fallback resolution.
#[must_use]
pub const fn source(&self) -> ConfigEnvironmentSource {
return self.source;
}
}
/// Config-owned snapshot of KSP/KSPB process environment values and the local `.env` file.
///
/// The process environment is captured first and always has priority over `.env`. An absent `.env` file is equivalent to an empty local environment source.
/// Config never mutates the parent/process environment through this type.
#[derive(Clone, Eq, PartialEq)]
pub struct ConfigEnvironment {
process: std::collections::BTreeMap<String, String>,
dotenv: std::collections::BTreeMap<String, String>,
dotenv_path: std::path::PathBuf,
}
impl ConfigEnvironment {
/// Captures supported variables from the current process and reads `./.env` when it exists.
pub fn load() -> ksp_core_lib::Result<Self> {
return Self::load_from_dotenv_path(std::path::Path::new(DEFAULT_DOTENV_PATH));
}
/// Returns the local `.env` path used by this environment snapshot.
#[must_use]
pub fn dotenv_path(&self) -> &std::path::Path {
return self.dotenv_path.as_path();
}
/// Resolves one KSP/KSPB variable using process > `.env` > fallback priority.
///
/// The fallback is used only when the variable is absent. An explicitly defined empty string is a real value and therefore wins over the fallback.
pub fn resolve_variable(&self, variable_name: &str, fallback: std::option::Option<&str>) -> ksp_core_lib::Result<ConfigEnvironmentValue> {
let validation = validate_supported_variable_name(variable_name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
if let std::option::Option::Some(value) = self.process.get(variable_name) {
return std::result::Result::Ok(ConfigEnvironmentValue {
variable_name: variable_name.to_owned(),
value: value.clone(),
source: ConfigEnvironmentSource::Process,
});
}
if let std::option::Option::Some(value) = self.dotenv.get(variable_name) {
return std::result::Result::Ok(ConfigEnvironmentValue {
variable_name: variable_name.to_owned(),
value: value.clone(),
source: ConfigEnvironmentSource::DotEnv,
});
}
if let std::option::Option::Some(value) = fallback {
return std::result::Result::Ok(ConfigEnvironmentValue {
variable_name: variable_name.to_owned(),
value: value.to_owned(),
source: ConfigEnvironmentSource::Fallback,
});
}
emit_missing_variable_warning(variable_name);
return std::result::Result::Err(missing_variable_error(variable_name));
}
/// Resolves `${NAME}` and `${NAME:-fallback}` placeholders embedded in one UTF-8 string.
///
/// Multiple placeholders are supported. Fallback text is literal in this prerelease and is not recursively interpreted as another placeholder expression.
pub fn resolve_text(&self, source: &str) -> ksp_core_lib::Result<String> {
let mut output = String::new();
let mut remaining = source;
loop {
let start = remaining.find("${");
let start = match start {
std::option::Option::Some(value) => value,
std::option::Option::None => {
output.push_str(remaining);
return std::result::Result::Ok(output);
},
};
output.push_str(&remaining[..start]);
let expression_and_tail = &remaining[start + 2..];
let end = expression_and_tail.find('}');
let end = match end {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(invalid_placeholder_error("placeholder is missing its closing '}'")),
};
let expression = &expression_and_tail[..end];
let parsed = parse_placeholder_expression(expression);
let (variable_name, fallback) = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let resolved = self.resolve_variable(variable_name, fallback);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
output.push_str(resolved.value());
remaining = &expression_and_tail[end + 1..];
}
}
/// Recursively resolves environment placeholders in JSON string values while preserving keys and non-string JSON values.
pub fn resolve_json(&self, source: &serde_json::Value) -> ksp_core_lib::Result<serde_json::Value> {
return match source {
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => std::result::Result::Ok(source.clone()),
serde_json::Value::String(value) => {
let resolved = self.resolve_text(value.as_str());
match resolved {
std::result::Result::Ok(value) => std::result::Result::Ok(serde_json::Value::String(value)),
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
serde_json::Value::Array(values) => resolve_array(self, values),
serde_json::Value::Object(values) => {
let resolved = self.resolve_map(values);
match resolved {
std::result::Result::Ok(value) => std::result::Result::Ok(serde_json::Value::Object(value)),
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
};
}
/// Recursively resolves environment placeholders in one JSON object map while leaving the map keys unchanged.
pub fn resolve_map(&self, source: &serde_json::Map<String, serde_json::Value>) -> ksp_core_lib::Result<serde_json::Map<String, serde_json::Value>> {
let mut output = serde_json::Map::<String, serde_json::Value>::new();
for (key, value) in source {
let resolved = self.resolve_json(value);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
output.insert(key.clone(), resolved);
}
return std::result::Result::Ok(output);
}
fn load_from_dotenv_path(dotenv_path: &std::path::Path) -> ksp_core_lib::Result<Self> {
let process = collect_process_environment(std::env::vars_os());
let process = match process {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let dotenv = load_dotenv_file(dotenv_path);
let dotenv = match dotenv {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { process, dotenv, dotenv_path: dotenv_path.to_path_buf() });
}
#[cfg(test)]
fn from_maps(process: std::collections::BTreeMap<String, String>, dotenv: std::collections::BTreeMap<String, String>) -> Self {
return Self { process, dotenv, dotenv_path: std::path::PathBuf::from(DEFAULT_DOTENV_PATH) };
}
}
fn collect_process_environment<I>(values: I) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>>
where
I: std::iter::IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
{
let mut output = std::collections::BTreeMap::<String, String>::new();
for (name, value) in values {
let name = match name.to_str() {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
if !has_supported_namespace(name) {
continue;
}
let validation = validate_supported_variable_name(name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
let value = value.into_string();
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::result::Result::Err(invalid_environment_value_error(name)),
};
output.insert(name.to_owned(), value);
}
return std::result::Result::Ok(output);
}
fn load_dotenv_file(path: &std::path::Path) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
let content = std::fs::read_to_string(path);
let content = match content {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::NotFound => return std::result::Result::Ok(std::collections::BTreeMap::new()),
std::result::Result::Err(error) => return std::result::Result::Err(dotenv_read_error(path, error)),
};
return parse_dotenv_content(path, content.as_str());
}
fn parse_dotenv_content(path: &std::path::Path, content: &str) -> ksp_core_lib::Result<std::collections::BTreeMap<String, String>> {
let mut output = std::collections::BTreeMap::<String, String>::new();
for (line_index, raw_line) in content.lines().enumerate() {
let raw_line = if line_index == 0 { raw_line.trim_start_matches('\u{feff}') } else { raw_line };
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let assignment = match line.strip_prefix("export ") {
std::option::Option::Some(value) => value.trim_start(),
std::option::Option::None => line,
};
let separator = assignment.find('=');
let separator = match separator {
std::option::Option::Some(value) => value,
std::option::Option::None => return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "assignment is missing '='")),
};
let variable_name = assignment[..separator].trim();
if !is_generic_dotenv_name(variable_name) {
return std::result::Result::Err(dotenv_syntax_error(path, line_index + 1, "variable name is invalid"));
}
let raw_value = assignment[separator + 1..].trim();
let value = parse_dotenv_value(path, line_index + 1, raw_value);
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if !has_supported_namespace(variable_name) {
continue;
}
let validation = validate_supported_variable_name(variable_name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
let entry = output.entry(variable_name.to_owned());
match entry {
std::collections::btree_map::Entry::Occupied(_) => {
return std::result::Result::Err(dotenv_duplicate_error(path, line_index + 1, variable_name));
},
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert(value);
},
}
}
return std::result::Result::Ok(output);
}
fn parse_dotenv_value(path: &std::path::Path, line_number: usize, raw_value: &str) -> ksp_core_lib::Result<String> {
if raw_value.starts_with('\'') && (raw_value.len() < 2 || !raw_value.ends_with('\'')) {
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "single-quoted value is not terminated"));
}
if raw_value.starts_with('\'') {
return std::result::Result::Ok(raw_value[1..raw_value.len() - 1].to_owned());
}
if raw_value.starts_with('"') && (raw_value.len() < 2 || !raw_value.ends_with('"')) {
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value is not terminated"));
}
if raw_value.starts_with('"') {
return parse_double_quoted_value(path, line_number, &raw_value[1..raw_value.len() - 1]);
}
let inline_comment = raw_value.find(" #");
let value = match inline_comment {
std::option::Option::Some(index) => raw_value[..index].trim_end(),
std::option::Option::None => raw_value,
};
return std::result::Result::Ok(value.to_owned());
}
fn parse_double_quoted_value(path: &std::path::Path, line_number: usize, source: &str) -> ksp_core_lib::Result<String> {
let mut output = String::new();
let mut escaped = false;
for character in source.chars() {
if escaped {
let mapped = match character {
'n' => '\n',
'r' => '\r',
't' => '\t',
'\\' => '\\',
'"' => '"',
_ => return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value contains an unsupported escape")),
};
output.push(mapped);
escaped = false;
} else if character == '\\' {
escaped = true;
} else {
output.push(character);
}
}
if escaped {
return std::result::Result::Err(dotenv_syntax_error(path, line_number, "double-quoted value ends with an incomplete escape"));
}
return std::result::Result::Ok(output);
}
fn parse_placeholder_expression(expression: &str) -> ksp_core_lib::Result<(&str, std::option::Option<&str>)> {
if expression.is_empty() || expression.contains("${") {
return std::result::Result::Err(invalid_placeholder_error("placeholder expression is empty or nested"));
}
let fallback_separator = expression.find(":-");
let (variable_name, fallback) = match fallback_separator {
std::option::Option::Some(index) => (&expression[..index], std::option::Option::Some(&expression[index + 2..])),
std::option::Option::None => (expression, std::option::Option::None),
};
let validation = validate_supported_variable_name(variable_name);
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
return std::result::Result::Ok((variable_name, fallback));
}
fn resolve_array(environment: &ConfigEnvironment, source: &[serde_json::Value]) -> ksp_core_lib::Result<serde_json::Value> {
let mut output = std::vec::Vec::<serde_json::Value>::with_capacity(source.len());
for value in source {
let resolved = environment.resolve_json(value);
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
output.push(resolved);
}
return std::result::Result::Ok(serde_json::Value::Array(output));
}
fn validate_supported_variable_name(variable_name: &str) -> ksp_core_lib::Result<()> {
if !has_supported_namespace(variable_name) {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable must use the KSP_ or KSPB_ namespace"));
}
let prefix_length = if variable_name.starts_with("KSPB_") { 5 } else { 4 };
if variable_name.len() <= prefix_length {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable namespace must be followed by a name"));
}
for byte in variable_name.bytes() {
let valid = byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_';
if !valid {
return std::result::Result::Err(invalid_variable_error(variable_name, "variable names use uppercase ASCII letters, digits and underscores"));
}
}
return std::result::Result::Ok(());
}
fn has_supported_namespace(variable_name: &str) -> bool {
return variable_name.starts_with("KSP_") || variable_name.starts_with("KSPB_");
}
fn is_generic_dotenv_name(variable_name: &str) -> bool {
let mut bytes = variable_name.bytes();
let first = match bytes.next() {
std::option::Option::Some(value) => value,
std::option::Option::None => return false,
};
if !(first.is_ascii_alphabetic() || first == b'_') {
return false;
}
for byte in bytes {
if !(byte.is_ascii_alphanumeric() || byte == b'_') {
return false;
}
}
return true;
}
fn emit_missing_variable_warning(variable_name: &str) {
ksp_logging_lib::warn!(target: LOGGING_TARGET, domain = LOGGING_DOMAIN, variable_name = variable_name, "Config environment variable is missing");
}
fn missing_variable_error(variable_name: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING, "required Config environment variable is missing")
.with_context("variable_name", variable_name);
}
fn invalid_variable_error(variable_name: &str, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID, "Config environment variable name is invalid")
.with_context("variable_name", variable_name)
.with_context("reason", reason);
}
fn invalid_environment_value_error(variable_name: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_VALUE_INVALID, "Config environment variable value is not valid UTF-8")
.with_context("variable_name", variable_name);
}
fn invalid_placeholder_error(reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID, "Config environment placeholder is invalid")
.with_context("reason", reason);
}
fn dotenv_read_error(path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_FILE_READ_FAILED, "Config cannot read the local .env file")
.with_context("path", path.to_string_lossy().into_owned())
.with_source(source);
}
fn dotenv_syntax_error(path: &std::path::Path, line_number: usize, reason: &'static str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env syntax is invalid")
.with_context("path", path.to_string_lossy().into_owned())
.with_context("line", line_number.to_string())
.with_context("reason", reason);
}
fn dotenv_duplicate_error(path: &std::path::Path, line_number: usize, variable_name: &str) -> ksp_core_lib::Error {
return ksp_core_lib::Error::new(crate::ERROR_CODE_DOTENV_SYNTAX_INVALID, "Config local .env contains a duplicate KSP variable")
.with_context("path", path.to_string_lossy().into_owned())
.with_context("line", line_number.to_string())
.with_context("variable_name", variable_name);
}
#[cfg(test)]
#[path = "../unit_tests/environment.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/error.rs
// version: 5
// version: 6
/// 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");
@@ -39,3 +39,21 @@ pub const ERROR_CODE_PROFILE_NOT_FOUND: ksp_core_lib::ErrorCode = ksp_core_lib::
/// 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");
/// Error code used when the local `.env` file cannot be read for a reason other than absence.
pub const ERROR_CODE_DOTENV_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_file_read_failed");
/// Error code used when the local `.env` file contains syntax Config cannot interpret safely.
pub const ERROR_CODE_DOTENV_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "dotenv_syntax_invalid");
/// Error code used when a Config environment variable name is malformed or outside the KSP/KSPB namespaces.
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_invalid");
/// Error code used when a referenced Config environment variable is absent and has no fallback.
pub const ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_variable_missing");
/// Error code used when a supported process environment variable has a value that cannot become a JSON UTF-8 string.
pub const ERROR_CODE_ENVIRONMENT_VALUE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_value_invalid");
/// Error code used when a `${NAME}` / `${NAME:-fallback}` expression is malformed.
pub const ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "environment_placeholder_invalid");

View File

@@ -1,18 +1,19 @@
// file: crates/ksp-config-lib/src/lib.rs
// version: 5
// version: 6
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! KSP-owned application configuration facade.
//!
//! `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.
//! `0.1.3-pre.010` owns bootstrap roots, the logical file registry, generic JSON/JSON Schema loading, standard-document profile resolution, generic composite
//! resolution and KSP/KSPB environment resolution through process + `.env` + fallback precedence. The standard Logging document remains the first registered
//! runtime document. Sensitivity-aware safe values and persistence remain in later bounded prereleases.
mod bootstrap;
mod composite;
mod document;
mod environment;
mod error;
mod profile;
mod registry;
@@ -35,6 +36,16 @@ pub use self::composite::ResolvedConfigComposite;
pub use self::document::ConfigDocumentEngine;
/// A Config-managed JSON document after syntax, schema and current semantic validation.
pub use self::document::ConfigJsonDocument;
/// Config-owned snapshot of KSP/KSPB process environment values and the local `.env` file.
pub use self::environment::ConfigEnvironment;
/// Source that supplied one resolved Config environment variable.
pub use self::environment::ConfigEnvironmentSource;
/// One resolved Config environment variable and its winning source.
pub use self::environment::ConfigEnvironmentValue;
/// Versioned environment contract template expected at the repository/runtime root.
pub use self::environment::DEFAULT_DOTENV_EXAMPLE_PATH;
/// Default local environment file read by Config from the process launch directory.
pub use self::environment::DEFAULT_DOTENV_PATH;
/// 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.
@@ -43,6 +54,18 @@ pub use self::error::ERROR_CODE_BOOTSTRAP_INVALID_PATH;
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 local `.env` file cannot be read for a reason other than absence.
pub use self::error::ERROR_CODE_DOTENV_FILE_READ_FAILED;
/// Error code used when the local `.env` file contains invalid syntax.
pub use self::error::ERROR_CODE_DOTENV_SYNTAX_INVALID;
/// Error code used when a Config environment placeholder is malformed.
pub use self::error::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID;
/// Error code used when a supported Config environment variable has a non-UTF-8 process value.
pub use self::error::ERROR_CODE_ENVIRONMENT_VALUE_INVALID;
/// Error code used when a Config environment variable name is invalid or outside KSP/KSPB namespaces.
pub use self::error::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID;
/// Error code used when a referenced Config environment variable is absent and has no fallback.
pub use self::error::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING;
/// 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.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-config-lib/src/profile.rs
// version: 2
// version: 3
/// Origin of one top-level value in a resolved standard Config profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -82,13 +82,19 @@ impl ResolvedConfigProfile {
pub fn origin(&self, key: &str) -> std::option::Option<ConfigValueOrigin> {
return self.origins.get(key).copied();
}
/// Resolves environment placeholders in the effective view while preserving this source profile and its Global/Profile provenance unchanged.
pub fn resolve_effective_environment(&self, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result<serde_json::Map<String, serde_json::Value>> {
return environment.resolve_map(&self.effective);
}
}
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 is intentionally not applied by this prerelease.
/// Environment interpolation is intentionally not applied implicitly by profile selection. Call `ResolvedConfigProfile::resolve_effective_environment` with
/// a Config-owned environment snapshot when an effective runtime view is required.
pub fn load_resolved_profile(
&self,
file_id: &crate::ConfigFileId,

View File

@@ -1,7 +1,7 @@
// file: crates/ksp-config-lib/tests/public_api.rs
// version: 6
// version: 7
//! Integration tests for the public `ksp-config-lib` bootstrap, file registry, validated JSON document, profile-resolution and composite contracts.
//! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite and environment-resolution contracts.
#[test]
fn bootstrap_contract_is_available_from_crate_root() {
@@ -133,3 +133,13 @@ fn composite_schema_and_provenance_contracts_are_available_from_crate_root() {
}
assert_ne!(ksp_config_lib::ConfigProfileSelectionSource::Composite, ksp_config_lib::ConfigProfileSelectionSource::Explicit);
}
#[test]
fn environment_resolution_contract_is_available_from_crate_root() {
let loader: fn() -> ksp_core_lib::Result<ksp_config_lib::ConfigEnvironment> = ksp_config_lib::ConfigEnvironment::load;
let _ = loader;
assert_eq!(ksp_config_lib::DEFAULT_DOTENV_PATH, ".env");
assert_eq!(ksp_config_lib::DEFAULT_DOTENV_EXAMPLE_PATH, ".env.example");
assert_ne!(ksp_config_lib::ConfigEnvironmentSource::Process, ksp_config_lib::ConfigEnvironmentSource::DotEnv);
assert_ne!(ksp_config_lib::ConfigEnvironmentSource::DotEnv, ksp_config_lib::ConfigEnvironmentSource::Fallback);
}

View File

@@ -0,0 +1,231 @@
// file: crates/ksp-config-lib/unit_tests/environment.rs
// version: 1
#[test]
fn process_environment_wins_over_dotenv_and_fallback_even_when_empty() {
let mut process = std::collections::BTreeMap::<String, String>::new();
process.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
assert!(resolved.is_ok(), "process value should resolve");
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(resolved.value(), "");
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Process);
}
#[test]
fn dotenv_wins_over_fallback_when_process_value_is_absent() {
let process = std::collections::BTreeMap::<String, String>::new();
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "dotenv-logs".to_owned());
let environment = super::ConfigEnvironment::from_maps(process, dotenv);
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
assert!(resolved.is_ok(), "dotenv value should resolve");
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(resolved.value(), "dotenv-logs");
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
}
#[test]
fn empty_dotenv_value_is_defined_and_beats_fallback() {
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), String::new());
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
assert!(resolved.is_ok(), "empty dotenv value should resolve");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved.value(), "");
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::DotEnv);
}
}
#[test]
fn fallback_is_used_only_when_external_sources_are_absent() {
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::Some("fallback-logs"));
assert!(resolved.is_ok(), "fallback should resolve");
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(resolved.value(), "fallback-logs");
assert_eq!(resolved.source(), super::ConfigEnvironmentSource::Fallback);
}
#[test]
fn missing_variable_without_fallback_is_a_distinct_error() {
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None);
let error = match resolved {
std::result::Result::Ok(_) => return,
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING);
assert!(!error.to_string().contains("fallback-logs"));
}
#[test]
fn ksp_and_kspb_namespaces_are_supported_but_external_names_are_rejected() {
let mut process = std::collections::BTreeMap::<String, String>::new();
process.insert("KSP_LOGS_DIRECTORY".to_owned(), "logs".to_owned());
let bot_variable = ["KSPB_", "TEST_KEY"].concat();
process.insert(bot_variable.clone(), "hidden".to_owned());
let environment = super::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new());
assert!(environment.resolve_variable("KSP_LOGS_DIRECTORY", std::option::Option::None).is_ok());
assert!(environment.resolve_variable(bot_variable.as_str(), std::option::Option::None).is_ok());
let external = environment.resolve_variable("OTHER_NETWORK", std::option::Option::None);
let error = match external {
std::result::Result::Ok(_) => return,
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID);
}
#[test]
fn text_resolver_supports_multiple_placeholders_and_literal_fallbacks() {
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let resolved = environment.resolve_text("logs=${KSP_LOGS_DIRECTORY:-logs};second=${KSP_LOGS_DIRECTORY:-other}");
assert!(resolved.is_ok(), "multiple placeholders should resolve");
if let std::result::Result::Ok(resolved) = resolved {
assert_eq!(resolved, "logs=logs;second=other");
}
}
#[test]
fn malformed_or_nested_placeholders_are_rejected() {
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let unclosed = environment.resolve_text("${KSP_LOGS_DIRECTORY");
let unclosed = match unclosed {
std::result::Result::Ok(_) => return,
std::result::Result::Err(error) => error,
};
assert_eq!(unclosed.code(), crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID);
let nested = environment.resolve_text("${KSP_LOGS_DIRECTORY:-${KSP_LOGS_DIRECTORY}}}");
let nested = match nested {
std::result::Result::Ok(_) => return,
std::result::Result::Err(error) => error,
};
assert_eq!(nested.code(), crate::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID);
}
#[test]
fn json_resolver_walks_objects_and_arrays_without_changing_keys() {
let mut dotenv = std::collections::BTreeMap::<String, String>::new();
dotenv.insert("KSP_LOGS_DIRECTORY".to_owned(), "runtime-logs".to_owned());
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), dotenv);
let source = serde_json::json!({"path": "${KSP_LOGS_DIRECTORY}", "items": [1, "${KSP_LOGS_DIRECTORY}"], "enabled": true});
let resolved = environment.resolve_json(&source);
assert!(resolved.is_ok(), "recursive JSON resolution should succeed");
let resolved = match resolved {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(resolved["path"], serde_json::Value::String("runtime-logs".to_owned()));
assert_eq!(resolved["items"][1], serde_json::Value::String("runtime-logs".to_owned()));
assert_eq!(resolved["enabled"], serde_json::Value::Bool(true));
}
#[test]
fn dotenv_parser_supports_comments_export_quotes_empty_values_and_ignores_external_keys() {
let path = std::path::Path::new("fixture.env");
let content = "# comment\nexport KSP_LOGS_DIRECTORY = 'quoted logs'\nOTHER_TOOL=value\n";
let parsed = super::parse_dotenv_content(path, content);
assert!(parsed.is_ok(), "dotenv fixture should parse");
let parsed = match parsed {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(parsed.get("KSP_LOGS_DIRECTORY").map(String::as_str), std::option::Option::Some("quoted logs"));
assert!(!parsed.contains_key("OTHER_TOOL"));
}
#[test]
fn dotenv_duplicate_ksp_key_is_rejected() {
let parsed = super::parse_dotenv_content(std::path::Path::new("fixture.env"), "KSP_LOGS_DIRECTORY=one\nKSP_LOGS_DIRECTORY=two\n");
let error = match parsed {
std::result::Result::Ok(_) => return,
std::result::Result::Err(error) => error,
};
assert_eq!(error.code(), crate::ERROR_CODE_DOTENV_SYNTAX_INVALID);
}
#[test]
fn fake_process_collection_filters_unrelated_names_without_mutating_real_environment() {
let values = vec![
(std::ffi::OsString::from("KSP_LOGS_DIRECTORY"), std::ffi::OsString::from("process-logs")),
(std::ffi::OsString::from("OTHER_TOOL"), std::ffi::OsString::from("ignored")),
];
let collected = super::collect_process_environment(values);
assert!(collected.is_ok(), "fake process environment should collect");
let collected = match collected {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(collected.len(), 1);
assert_eq!(collected.get("KSP_LOGS_DIRECTORY").map(String::as_str), std::option::Option::Some("process-logs"));
}
#[test]
fn committed_logging_profile_resolves_environment_fallback_without_changing_source_profile() {
let bootstrap = crate::ConfigBootstrapOptions::from_paths(std::path::PathBuf::from("config"), std::path::PathBuf::from("config/schemas"));
assert!(bootstrap.is_ok(), "bootstrap should resolve committed roots");
let bootstrap = match bootstrap {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let registry = crate::ConfigFileRegistry::defaults();
assert!(registry.is_ok(), "default registry should build");
let registry = match registry {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let engine = crate::ConfigDocumentEngine::new(bootstrap, registry);
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(_) => return,
};
let profile = engine.load_resolved_profile(&file_id, std::option::Option::None);
assert!(profile.is_ok(), "committed Logging profile should resolve");
let profile = match profile {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let environment = super::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
let effective = profile.resolve_effective_environment(&environment);
assert!(effective.is_ok(), "committed Logging environment fallback should resolve");
let effective = match effective {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(profile.effective().get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("${KSP_LOGS_DIRECTORY:-logs}"));
assert_eq!(effective.get("logs_directory").and_then(serde_json::Value::as_str), std::option::Option::Some("logs"));
}
#[test]
fn env_example_inventory_contains_current_runtime_variable_with_preceding_comment() {
let content = std::fs::read_to_string(".env.example");
assert!(content.is_ok(), ".env.example must be committed at workspace root");
let content = match content {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let lines = content.lines().collect::<std::vec::Vec<&str>>();
let mut found = false;
for index in 0..lines.len() {
if lines[index].starts_with("KSP_LOGS_DIRECTORY=") {
found = true;
assert!(index > 0, "environment entry must have a preceding comment");
assert!(lines[index - 1].trim_start().starts_with('#'), "environment entry must be immediately preceded by a comment");
}
}
assert!(found, "current Config environment variable must appear in .env.example");
}