v0.1.3-pre.010
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# file: .env.example
|
||||
# version: 1
|
||||
|
||||
# KSP Logging root directory. Used by config/std.logging.json for relative log output paths.
|
||||
# The current Config document fallback is "logs" when neither the process environment nor .env defines this variable.
|
||||
KSP_LOGS_DIRECTORY=logs
|
||||
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 51
|
||||
# version: 52
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.3-pre.9.fix.1"
|
||||
version = "0.1.3-pre.10"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
|
||||
@@ -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
|
||||
|
||||
451
crates/ksp-config-lib/src/environment.rs
Normal file
451
crates/ksp-config-lib/src/environment.rs
Normal 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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
231
crates/ksp-config-lib/unit_tests/environment.rs
Normal file
231
crates/ksp-config-lib/unit_tests/environment.rs
Normal 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");
|
||||
}
|
||||
298
deltas/0.1.3/pre.010.md
Normal file
298
deltas/0.1.3/pre.010.md
Normal file
@@ -0,0 +1,298 @@
|
||||
<!-- file: deltas/0.1.3/pre.010.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.1.3-pre.010
|
||||
|
||||
## Base requise
|
||||
|
||||
Livraison précédente validée :
|
||||
|
||||
```text
|
||||
0.1.3-pre.009-fix.001
|
||||
```
|
||||
|
||||
Version technique de cette base :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.3-pre.9.fix.1"
|
||||
Cargo.toml header version = 51
|
||||
```
|
||||
|
||||
Validations utilisateur exécutées le 2026-08-15 :
|
||||
|
||||
```text
|
||||
cargo fmt --all OK
|
||||
cargo check --workspace OK
|
||||
cargo clippy --workspace --all-targets OK
|
||||
cargo test --workspace OK
|
||||
cargo tree -p ksp-config-lib OK
|
||||
cargo tree -p ksp-config-lib -d OK, doublon transitif syn 2/3 déjà connu
|
||||
cargo tree -p ksp-config-lib -e features OK
|
||||
```
|
||||
|
||||
`cargo test --workspace` confirme notamment 39 tests unitaires + 7 tests publics pour `ksp-config-lib`.
|
||||
|
||||
## Objet de pre.010
|
||||
|
||||
Introduire la première résolution environnementale Config réellement consommable :
|
||||
|
||||
```text
|
||||
process environment
|
||||
>
|
||||
./.env
|
||||
>
|
||||
placeholder/API fallback
|
||||
->
|
||||
ConfigEnvironmentValue
|
||||
->
|
||||
${NAME} / ${NAME:-fallback}
|
||||
->
|
||||
JSON effective values
|
||||
```
|
||||
|
||||
La sensibilité et les représentations sûres restent volontairement réservées à `pre.011`.
|
||||
|
||||
## Propriété et sources
|
||||
|
||||
`ConfigEnvironment` est le snapshot possédé par `ksp-config-lib`.
|
||||
|
||||
`ConfigEnvironment::load()` :
|
||||
|
||||
1. capture les variables supportées héritées par le processus ;
|
||||
2. lit `./.env` s'il existe ;
|
||||
3. ne modifie jamais l'environnement du processus, du shell, de systemd ou du parent ;
|
||||
4. considère l'absence de `.env` comme une source locale vide.
|
||||
|
||||
Une erreur de lecture réelle de `.env` reste distincte de son absence.
|
||||
|
||||
Les seuls namespaces applicatifs acceptés sont :
|
||||
|
||||
```text
|
||||
KSP_*
|
||||
KSPB_*
|
||||
```
|
||||
|
||||
ce qui couvre naturellement :
|
||||
|
||||
```text
|
||||
KSP_PUBLIC_*
|
||||
KSP_SECRET_*
|
||||
KSPB_PUBLIC_*
|
||||
KSPB_SECRET_*
|
||||
```
|
||||
|
||||
## Priorité et chaîne vide
|
||||
|
||||
`ConfigEnvironment::resolve_variable(name, fallback)` applique :
|
||||
|
||||
```text
|
||||
process > .env > fallback > missing
|
||||
```
|
||||
|
||||
Une chaîne vide explicitement présente dans le processus ou `.env` reste une valeur définie. Elle ne déclenche pas le fallback.
|
||||
|
||||
`ConfigEnvironmentValue` expose :
|
||||
|
||||
```text
|
||||
variable_name
|
||||
value réelle
|
||||
source = Process | DotEnv | Fallback
|
||||
```
|
||||
|
||||
Le type n'implémente volontairement pas `Debug` afin de ne pas créer une voie de fuite accidentelle avant l'introduction de la sensibilité/redaction en `pre.011`.
|
||||
|
||||
## Resolver `${...}`
|
||||
|
||||
Config supporte :
|
||||
|
||||
```text
|
||||
${KSP_VAR}
|
||||
${KSP_VAR:-fallback}
|
||||
```
|
||||
|
||||
Règles :
|
||||
|
||||
- plusieurs placeholders peuvent apparaître dans une même string ;
|
||||
- le fallback n'est utilisé que si la variable est absente ;
|
||||
- le fallback est littéral dans cette tranche et n'est pas récursivement interprété ;
|
||||
- les placeholders imbriqués/malformés sont refusés ;
|
||||
- les clés JSON ne sont pas interpolées ; seules les valeurs string le sont ;
|
||||
- objets et tableaux JSON sont parcourus récursivement.
|
||||
|
||||
APIs principales :
|
||||
|
||||
```text
|
||||
ConfigEnvironment::resolve_variable(...)
|
||||
ConfigEnvironment::resolve_text(...)
|
||||
ConfigEnvironment::resolve_json(...)
|
||||
ConfigEnvironment::resolve_map(...)
|
||||
ResolvedConfigProfile::resolve_effective_environment(...)
|
||||
```
|
||||
|
||||
La résolution d'environnement d'un profil produit une nouvelle map effective et ne modifie pas la source, le `profile_id`, la provenance `Global/Profile` ni la sélection du profil déjà résolu.
|
||||
|
||||
## Variable manquante
|
||||
|
||||
Une référence sans valeur process, sans valeur `.env` et sans fallback retourne :
|
||||
|
||||
```text
|
||||
config.environment_variable_missing
|
||||
```
|
||||
|
||||
Config émet également un warning via la façade KSP :
|
||||
|
||||
```text
|
||||
target = ksp-config-lib
|
||||
domain = config.environment
|
||||
```
|
||||
|
||||
Le warning et l'erreur contiennent le nom de la variable mais jamais sa valeur.
|
||||
|
||||
`ksp-config-lib` dépend donc maintenant réellement de `ksp-logging-lib`. La dépendance inverse Logging -> Config reste interdite.
|
||||
|
||||
## `.env`
|
||||
|
||||
Le parser Config de cette tranche accepte un sous-ensemble déterministe adapté au fichier KSP géré :
|
||||
|
||||
- lignes vides et commentaires `#` ;
|
||||
- préfixe optionnel `export ` ;
|
||||
- `NAME=value` ;
|
||||
- valeur non quotée ;
|
||||
- valeur entre quotes simples ;
|
||||
- valeur entre quotes doubles avec escapes bornés `\\`, `\"`, `\n`, `\r`, `\t` ;
|
||||
- commentaire inline d'une valeur non quotée lorsqu'il commence par ` #` ;
|
||||
- chaîne vide ;
|
||||
- clés étrangères valides ignorées par Config ;
|
||||
- doublon d'une clé KSP/KSPB refusé pour éviter une résolution locale ambiguë.
|
||||
|
||||
Aucune interpolation interne du fichier `.env` n'est ajoutée dans cette tranche.
|
||||
|
||||
## `.env.example` — nouvelle règle durable
|
||||
|
||||
Le dépôt possède désormais à la racine :
|
||||
|
||||
```text
|
||||
.env.example
|
||||
```
|
||||
|
||||
Ce fichier est versionné et sert d'inventaire canonique des variables runtime KSP/KSPB utilisées par les fichiers Config ou le code opérationnel.
|
||||
|
||||
Règles enregistrées :
|
||||
|
||||
- toute nouvelle variable runtime KSP/KSPB est ajoutée à `.env.example` dans le même delta que sa première utilisation ;
|
||||
- chaque variable est précédée d'un commentaire expliquant son usage/utilité ;
|
||||
- une entrée peut être active avec une valeur par défaut sûre, porter une valeur générique non secrète ou être commentée ;
|
||||
- aucun vrai secret n'y est stocké ;
|
||||
- le fichier local `.env` reste non versionné et non échangé ;
|
||||
- l'objectif est de pouvoir créer `.env` depuis `.env.example` et repérer localement les nouvelles clés par diff.
|
||||
|
||||
La première entrée est :
|
||||
|
||||
```text
|
||||
KSP_LOGS_DIRECTORY=logs
|
||||
```
|
||||
|
||||
car `config/std.logging.json` est actuellement le seul fichier runtime utilisant une variable d'environnement.
|
||||
|
||||
`.gitignore` possédait déjà :
|
||||
|
||||
```text
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
```
|
||||
|
||||
et n'a donc pas besoin d'être modifié.
|
||||
|
||||
## Tests
|
||||
|
||||
Les nouveaux tests couvrent notamment :
|
||||
|
||||
- priorité process > `.env` > fallback ;
|
||||
- chaîne vide process et `.env` considérée comme définie ;
|
||||
- variable manquante sans fallback ;
|
||||
- namespaces KSP/KSPB ;
|
||||
- résolution de plusieurs placeholders ;
|
||||
- placeholder malformé/imbriqué ;
|
||||
- résolution récursive object/array JSON ;
|
||||
- parsing `.env` avec commentaires, `export` et quotes ;
|
||||
- refus des doublons KSP dans `.env` ;
|
||||
- collecte process synthétique sans mutation du vrai environnement ;
|
||||
- résolution du fallback `${KSP_LOGS_DIRECTORY:-logs}` du document Logging commité ;
|
||||
- présence commentée de `KSP_LOGS_DIRECTORY` dans `.env.example` ;
|
||||
- disponibilité de la surface environment depuis le crate-root.
|
||||
|
||||
Les tests n'appellent pas `std::env::set_var` / `remove_var`; aucune mutation unsafe de l'environnement n'est nécessaire en Rust 2024.
|
||||
|
||||
## Dépendances
|
||||
|
||||
Aucune nouvelle dépendance externe n'est ajoutée.
|
||||
|
||||
Dépendance KSP désormais utilisée :
|
||||
|
||||
```text
|
||||
ksp-config-lib -> ksp-logging-lib
|
||||
```
|
||||
|
||||
pour les warnings/diagnostics runtime Config.
|
||||
|
||||
Les dépendances externes `serde`, `serde_json` et `jsonschema` restent inchangées.
|
||||
|
||||
## Version technique
|
||||
|
||||
La prerelease devient :
|
||||
|
||||
```text
|
||||
workspace.package.version = "0.1.3-pre.10"
|
||||
Cargo.toml header version = 52
|
||||
```
|
||||
|
||||
## Fichiers ajoutés
|
||||
|
||||
```text
|
||||
.env.example
|
||||
crates/ksp-config-lib/src/environment.rs
|
||||
crates/ksp-config-lib/unit_tests/environment.rs
|
||||
deltas/0.1.3/pre.010.md
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-config-lib/Cargo.toml
|
||||
crates/ksp-config-lib/src/error.rs
|
||||
crates/ksp-config-lib/src/lib.rs
|
||||
crates/ksp-config-lib/src/profile.rs
|
||||
crates/ksp-config-lib/tests/public_api.rs
|
||||
docs/architecture/005-DEPENDENCY_GRAPH.md
|
||||
docs/plans/000-README.md
|
||||
docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md
|
||||
docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md
|
||||
docs/rules/FILE_CONTRACTS.md
|
||||
docs/rules/RULES_KSP.md
|
||||
```
|
||||
|
||||
## Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
## Contrôles exécutés dans l'environnement de génération
|
||||
|
||||
- parsing TOML du manifest racine et de `ksp-config-lib` ;
|
||||
- audit statique des accès `std::env::*` : seul `crates/ksp-config-lib/src/environment.rs` lit l'environnement applicatif ;
|
||||
- audit statique des variables runtime exactes dans `crates/*/src` + `config/` : `KSP_LOGS_DIRECTORY` est la seule clé actuelle et elle est présente dans `.env.example` ;
|
||||
- absence de `unsafe`, `unwrap`, `expect`, `panic!` et `?` dans le nouveau code de production Config ;
|
||||
- contrôle des headers/version et des lignes Rust ajoutées/modifiées ;
|
||||
- comparaison du delta avec la base `pre.009-fix.001` ;
|
||||
- aucun `Cargo.lock` ajouté.
|
||||
|
||||
Le toolchain Rust n'est pas disponible dans l'environnement de génération. `cargo fmt/check/clippy/test` doivent donc être exécutés par l'utilisateur.
|
||||
|
||||
## Étape suivante
|
||||
|
||||
Après validation utilisateur :
|
||||
|
||||
```text
|
||||
0.1.3-pre.011 — sensibilité Public/Internal/Secret + real/safe/provenance + redaction par segment
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/architecture/005-DEPENDENCY_GRAPH.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# Graphe de dépendances KSP
|
||||
|
||||
@@ -76,7 +76,7 @@ ksp-core-lib
|
||||
|
||||
ksp-logging-lib -> ksp-core-lib
|
||||
ksp-config-lib -> ksp-core-lib
|
||||
ksp-config-lib -> ksp-logging-lib # lorsque du runtime logging est nécessaire
|
||||
ksp-config-lib -> ksp-logging-lib # warnings/diagnostics Config runtime
|
||||
ksp-interface-lib -> ksp-core-lib
|
||||
ksp-interface-lib -> ksp-logging-lib # lorsque du runtime logging est nécessaire
|
||||
```
|
||||
@@ -85,7 +85,7 @@ ksp-interface-lib -> ksp-logging-lib # lorsque du runtime logging est né
|
||||
|
||||
`ksp-interface-lib` est propriétaire de la façade wire et peut dépendre des crates externes Solana/interface explicitement autorisées par `RULES_DEPENDENCIES.md`.
|
||||
|
||||
`ksp-logging-lib` est la façade logging/tracing KSP et peut dépendre de `ksp-core-lib` pour `Error` / `Result`. `ksp-core-lib` n'a pas de dépendance inverse vers le logging. Les composants comportant du runtime peuvent dépendre directement de `ksp-logging-lib`; cette permission n'oblige pas les crates purement déclaratives à le faire.
|
||||
`ksp-logging-lib` est la façade logging/tracing KSP et peut dépendre de `ksp-core-lib` pour `Error` / `Result`. `ksp-core-lib` n'a pas de dépendance inverse vers le logging. Les composants comportant du runtime peuvent dépendre directement de `ksp-logging-lib`; cette permission n'oblige pas les crates purement déclaratives à le faire. Depuis `0.1.3-pre.010`, `ksp-config-lib` utilise cette dépendance pour ses warnings/diagnostics runtime, sans dépendance inverse Logging -> Config.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/000-README.md -->
|
||||
<!-- version: 16 -->
|
||||
<!-- version: 17 -->
|
||||
|
||||
# Plans KSP
|
||||
|
||||
@@ -13,7 +13,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou
|
||||
- [`002-FUNCTIONAL_RELEASE_SEQUENCE.md`](002-FUNCTIONAL_RELEASE_SEQUENCE.md) — séquence active de référence des premières releases fonctionnelles ;
|
||||
- [`003-V0_1_1_CORE_FOUNDATION_PLAN.md`](003-V0_1_1_CORE_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.1`, établi par `0.1.1-pre.001` puis consolidé jusqu'à `0.1.1-rel.001`.
|
||||
- [`004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](004-V0_1_2_LOGGING_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.2`, établi par `0.1.2-pre.001` puis consolidé jusqu'à `0.1.2-rel.001`.
|
||||
- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan actif de `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, corrigé par `pre.001-fix.001`, complété par `pre.001-fix.002` pour le registre `file_id`/bootstrap/non-régression Logging, regranularisé par `pre.001-fix.003`, puis rescindé pendant `pre.005`; `pre.006` a fermé le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema et le premier `std.logging.json`, `pre.008` la résolution globals/profils/`default_profile`, puis `pre.009` les compositions génériques par `file_id`; `pre.010` poursuivra avec `.env`, process env et le resolver `${...}`.
|
||||
- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan actif de `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, corrigé par `pre.001-fix.001`, complété par `pre.001-fix.002` pour le registre `file_id`/bootstrap/non-régression Logging, regranularisé par `pre.001-fix.003`, puis rescindé pendant `pre.005`; `pre.006` a fermé le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema et le premier `std.logging.json`, `pre.008` la résolution globals/profils/`default_profile`, puis `pre.009` les compositions génériques par `file_id`; `pre.010` a livré `.env`, process env, `.env.example` et le resolver `${...}`; `pre.011` poursuivra avec sensibilité, valeur réelle/sûre et provenance.
|
||||
|
||||
Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md -->
|
||||
<!-- version: 14 -->
|
||||
<!-- version: 15 -->
|
||||
|
||||
# Séquence des releases fonctionnelles KSP
|
||||
|
||||
@@ -219,7 +219,7 @@ pre.015 clôture
|
||||
|
||||
Cette prévision n'est pas un plafond : chaque prerelease doit rester une petite tranche, avec scission explicite si l'objectif dépasse environ 15–20 minutes de travail effectif.
|
||||
|
||||
`pre.006` a fermé le routing Logging structuré `domain`; `pre.007` a livré le moteur JSON/JSON Schema et `std.logging.json`; `pre.008` a ajouté la résolution générique globals/profils/`default_profile`; `pre.009` ajoute les compositions génériques par `file_id`, avec `schema.composite` mais sans composite runtime fictif. Après validation utilisateur, `pre.010` introduira `.env`, process env et le resolver `${...}`.
|
||||
`pre.006` a fermé le routing Logging structuré `domain`; `pre.007` a livré le moteur JSON/JSON Schema et `std.logging.json`; `pre.008` a ajouté la résolution générique globals/profils/`default_profile`; `pre.009` a ajouté les compositions génériques par `file_id`, avec `schema.composite` mais sans composite runtime fictif; `pre.010` ajoute le snapshot process + `.env`, `.env.example` et le resolver `${...}`. Après validation utilisateur, `pre.011` ajoutera sensibilité, valeur réelle/sûre et provenance enrichie.
|
||||
|
||||
## `0.1.4` — Config desktop par défaut
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- file: docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md -->
|
||||
<!-- version: 12 -->
|
||||
<!-- version: 13 -->
|
||||
|
||||
# Plan `0.1.3` — Configuration foundation
|
||||
|
||||
## 1. Statut et objectif
|
||||
|
||||
Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, `pre.004` les contrats publics multi-output de Logging, `pre.005` le runtime multi-sink sur niveau/target/formats, `pre.006` le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema, `pre.008` la résolution des globals/profils/`default_profile` et `pre.009` les compositions génériques par `file_id`. La prochaine tranche est `pre.010` pour `.env`, process env et le resolver `${...}`.
|
||||
Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, `pre.004` les contrats publics multi-output de Logging, `pre.005` le runtime multi-sink sur niveau/target/formats, `pre.006` le routing structuré `domain`, `pre.007` le moteur JSON/JSON Schema, `pre.008` la résolution des globals/profils/`default_profile`, `pre.009` les compositions génériques par `file_id` et `pre.010` le snapshot process + `.env` et le resolver `${...}`. La prochaine tranche est `pre.011` pour sensibilité, valeur réelle/sûre et provenance enrichie.
|
||||
|
||||
La base auditée reste la release stable `v0.1.2`.
|
||||
|
||||
@@ -163,6 +163,7 @@ KSP conserve :
|
||||
- composition propre à un exécutable/application ;
|
||||
- sélection d'un profil spécialisé depuis la composition ;
|
||||
- `.env` séparé des documents JSON ;
|
||||
- `.env.example` versionné et maintenu au fil de l'apparition des variables runtime ;
|
||||
- priorité de l'environnement réel du processus sur `.env` ;
|
||||
- fallback déclaré au point d'usage via `${NAME:-fallback}` ;
|
||||
- classification nominale des variables ;
|
||||
@@ -1779,16 +1780,27 @@ Tranche livrée :
|
||||
- `ConfigDocumentEngine::load_resolved_composite(file_id, requested_profile)` est prêt pour les futurs descriptors `cfg.composite.<consumer>` ;
|
||||
- les tests utilisent un descriptor composite privé à la crate pointant vers l’exemple versionné afin de valider la résolution complète sans créer un composite runtime fictif.
|
||||
|
||||
La validation utilisateur de `pre.009-fix.001` est acquise : `fmt/check/clippy/test` passent, les 39 tests unitaires Config et 7 tests publics passent, et le graphe de dépendances reste conforme avec le seul doublon transitif `syn 2`/`syn 3` déjà connu via `jsonschema`.
|
||||
|
||||
### `0.1.3-pre.010` — `.env` + process env + resolver `${...}`
|
||||
|
||||
- lecture process env ;
|
||||
- lecture `.env` sans écrasement process ;
|
||||
- priorité process > `.env` > fallback ;
|
||||
- `${NAME}` / `${NAME:-fallback}` ;
|
||||
- fallback appliqué seulement si la variable est absente ;
|
||||
- missing diagnostics + warning ;
|
||||
- namespaces KSP/KSPB ;
|
||||
- tests d'isolation process env.
|
||||
Tranche livrée :
|
||||
|
||||
- `ConfigEnvironment::load()` capture les variables KSP/KSPB du processus puis lit `./.env` sans modifier l'environnement externe ;
|
||||
- l'absence de `.env` est valide et équivaut à une source locale vide ; les autres erreurs de lecture sont distinctes ;
|
||||
- priorité effective process > `.env` > fallback, y compris le cas d'une chaîne vide explicitement définie ;
|
||||
- `ConfigEnvironmentValue` expose la valeur réelle et sa source `Process` / `DotEnv` / `Fallback` sans implémentation `Debug` afin de ne pas créer une fuite accidentelle avant le contrat de redaction de `pre.011` ;
|
||||
- `${NAME}` et `${NAME:-fallback}` sont résolus dans les strings, maps et valeurs JSON récursives ; plusieurs placeholders sont supportés ;
|
||||
- le fallback reste littéral et n'est pas récursivement interprété dans cette tranche ;
|
||||
- une variable manquante sans fallback retourne `config.environment_variable_missing` et émet un warning via `ksp-logging-lib` avec target `ksp-config-lib`, sans valeur dans le diagnostic ;
|
||||
- seuls les namespaces `KSP_*` et `KSPB_*` sont acceptés par l'API Config ;
|
||||
- le parser `.env` supporte commentaires, `export`, valeurs non quotées/simplement/doublement quotées et refuse les doublons KSP ambigus ;
|
||||
- les tests de priorité process utilisent des sources injectées/itérateurs synthétiques : ils ne mutent jamais le vrai environnement du processus, ce qui évite `unsafe` en Rust 2024 ;
|
||||
- `ResolvedConfigProfile::resolve_effective_environment()` résout la vue effective sans modifier le profil source ni sa provenance Global/Profile ;
|
||||
- `ksp-config-lib` dépend désormais réellement de `ksp-logging-lib` pour ses warnings Config ; la dépendance inverse reste interdite ;
|
||||
- `.env.example` est créé à la racine avec `KSP_LOGS_DIRECTORY`, seule variable runtime actuellement utilisée ;
|
||||
- la règle durable impose désormais d'ajouter toute nouvelle variable runtime KSP/KSPB à `.env.example`, avec commentaire d'usage, dans le même delta que sa première utilisation ;
|
||||
- `.gitignore` possédait déjà la règle correcte `.env`, `.env.*`, `!.env.example`; aucun changement n'est nécessaire.
|
||||
|
||||
### `0.1.3-pre.011` — sensibilité + valeurs real/safe/provenance
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/rules/FILE_CONTRACTS.md -->
|
||||
<!-- version: 11 -->
|
||||
<!-- version: 12 -->
|
||||
|
||||
# Contrats des fichiers
|
||||
|
||||
@@ -9,17 +9,19 @@ Les règles `FILE-*` définissent la responsabilité et le mode de modification
|
||||
|
||||
## Fichiers racine et configuration Cargo
|
||||
|
||||
| Fichier | Responsabilité | Règle de modification |
|
||||
|----------------------|-----------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `.gitignore` | Exclure uniquement les artefacts non versionnés décidés par le projet. | Ajouter une exclusion lorsqu'un besoin réel apparaît ; éviter les exclusions spéculatives. |
|
||||
| `README.md` | Présenter KSP, sa finalité, son périmètre général, ses principes et les points d'entrée. | Mettre à jour lorsqu'une définition structurante du projet change ; ne pas y consigner l'historique des versions. |
|
||||
| `RULES.md` | Indexer les règles normatives. | Modifier uniquement lorsque la structure normative ou ses points d'entrée changent. |
|
||||
| `Cargo.toml` | Définir le workspace, sa version Cargo, les métadonnées héritées et les lints communs. | Modifier lors de toute prerelease/release non-fix, lors d'un correctif touchant le code/build/runtime/configuration/migrations, lorsqu'une crate entre/sort du workspace ou lorsqu'un contrat Cargo commun change. Un correctif purement documentaire ou de référence non consommée par le runtime ne force pas un changement de version Cargo. |
|
||||
| `.cargo/config.toml` | Définir les réglages Cargo propres au workspace qui ne relèvent pas du manifeste, notamment l'emplacement des artefacts de build. | Modifier lorsqu'un réglage Cargo commun change ; ne pas y placer de secret ni de configuration spécifique à une machine particulière. |
|
||||
| `rustfmt.toml` | Définir le formatage Rust commun. | Modifier comme changement normatif, avec justification dans le delta. |
|
||||
| `clippy.toml` | Définir les paramètres Clippy communs. | Modifier comme changement normatif, avec justification dans le delta. |
|
||||
| `ROADMAP.md` | Décrire les objectifs globaux et les grandes étapes prévues par phase/version, avec leur état synthétique. | Modifier lorsqu'un objectif, une grande étape, un report, une annulation ou un état global change ; ne pas y recopier le détail des prereleases prévu dans les plans de version. |
|
||||
| `CHANGELOG.md` | Résumer les releases stables dans un ordre chronologique décroissant, sous forme d'un ou plusieurs paragraphes par release. | Synchroniser lors de la phase documentaire finale ; ne pas dupliquer les deltas ni créer de changelog par crate/module. |
|
||||
| Fichier | Responsabilité | Règle de modification |
|
||||
|----------------------|-----------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `.gitignore` | Exclure uniquement les artefacts non versionnés décidés par le projet. | Ajouter une exclusion lorsqu'un besoin réel apparaît ; éviter les exclusions spéculatives. |
|
||||
| `.env` | Fournir les valeurs d'environnement locales KSP/KSPB du runtime lorsqu'elles ne viennent pas du processus externe. | Fichier local non versionné et non échangé ; lu puis, à terme, modifié uniquement par `ksp-config-lib`. L'environnement du processus garde priorité sur cette source. |
|
||||
| `.env.example` | Inventorier le contrat versionné de toutes les variables d'environnement runtime KSP/KSPB utilisées par les fichiers Config ou le code. | Ajouter la variable dans le même delta que sa première utilisation. Chaque entrée est précédée d'un commentaire décrivant son usage ; elle peut être active avec une valeur par défaut/générique non secrète ou rester commentée. Ce fichier ne contient jamais de vrai secret. |
|
||||
| `README.md` | Présenter KSP, sa finalité, son périmètre général, ses principes et les points d'entrée. | Mettre à jour lorsqu'une définition structurante du projet change ; ne pas y consigner l'historique des versions. |
|
||||
| `RULES.md` | Indexer les règles normatives. | Modifier uniquement lorsque la structure normative ou ses points d'entrée changent. |
|
||||
| `Cargo.toml` | Définir le workspace, sa version Cargo, les métadonnées héritées et les lints communs. | Modifier lors de toute prerelease/release non-fix, lors d'un correctif touchant le code/build/runtime/configuration/migrations, lorsqu'une crate entre/sort du workspace ou lorsqu'un contrat Cargo commun change. Un correctif purement documentaire ou de référence non consommée par le runtime ne force pas un changement de version Cargo. |
|
||||
| `.cargo/config.toml` | Définir les réglages Cargo propres au workspace qui ne relèvent pas du manifeste, notamment l'emplacement des artefacts de build. | Modifier lorsqu'un réglage Cargo commun change ; ne pas y placer de secret ni de configuration spécifique à une machine particulière. |
|
||||
| `rustfmt.toml` | Définir le formatage Rust commun. | Modifier comme changement normatif, avec justification dans le delta. |
|
||||
| `clippy.toml` | Définir les paramètres Clippy communs. | Modifier comme changement normatif, avec justification dans le delta. |
|
||||
| `ROADMAP.md` | Décrire les objectifs globaux et les grandes étapes prévues par phase/version, avec leur état synthétique. | Modifier lorsqu'un objectif, une grande étape, un report, une annulation ou un état global change ; ne pas y recopier le détail des prereleases prévu dans les plans de version. |
|
||||
| `CHANGELOG.md` | Résumer les releases stables dans un ordre chronologique décroissant, sous forme d'un ou plusieurs paragraphes par release. | Synchroniser lors de la phase documentaire finale ; ne pas dupliquer les deltas ni créer de changelog par crate/module. |
|
||||
|
||||
## Répertoire `docs/`
|
||||
|
||||
@@ -47,6 +49,8 @@ Les règles `FILE-*` définissent la responsabilité et le mode de modification
|
||||
|
||||
Les noms physiques sont remplaçables via le registre Config lorsque le contrat le permet ; les consumers référencent les documents par `file_id`, pas par filename.
|
||||
|
||||
Le fichier runtime d'environnement est toujours `./.env` pour `0.1.3`. Il n'est ni un document `config/` ni une source de bootstrap de `cfgpath`/`schemapath`. Le template `.env.example` est la référence versionnée permettant de créer localement `.env` et d'identifier par diff les nouvelles clés attendues.
|
||||
|
||||
Pour un document standard profilé, `default_profile` et `profiles` sont des clés structurelles réservées. Les autres propriétés top-level sont des valeurs globales. Chaque entrée de `profiles` possède un `profile_id` unique ; `default_profile` référence obligatoirement l'un de ces identifiants. La résolution Config peut sélectionner le profil par défaut ou un profil explicite et conserve séparément la provenance `Global` / `Profile` de la vue effective. Les consumers ne reconstituent jamais eux-mêmes cette fusion.
|
||||
|
||||
## Répertoire `prompts/`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/rules/RULES_KSP.md -->
|
||||
<!-- version: 14 -->
|
||||
<!-- version: 15 -->
|
||||
|
||||
# Règles spécifiques à KSP
|
||||
|
||||
@@ -26,6 +26,17 @@
|
||||
- **KSP-API-006** — `ksp-store-lib` contient PostgreSQL comme implémentation officielle de référence derrière `ksp-store-api`.
|
||||
- **KSP-API-007** — Une crate `*-api` n'est créée que lorsqu'un vrai besoin d'extension, backend ou lifecycle le justifie ; la symétrie de nommage n'est jamais une justification suffisante.
|
||||
|
||||
|
||||
## Configuration et environnement
|
||||
|
||||
- **KSP-CONFIG-001** — `ksp-config-lib` est l'unique propriétaire KSP de la lecture des documents Config, du `.env`, des variables applicatives `KSP_*` / `KSPB_*` et de leur résolution ; les autres crates ne lisent pas directement ces sources.
|
||||
- **KSP-CONFIG-002** — Les namespaces d'environnement sont `KSP_*`, `KSP_PUBLIC_*`, `KSP_SECRET_*` pour les composants génériques et `KSPB_*`, `KSPB_PUBLIC_*`, `KSPB_SECRET_*` pour la branche bot ; les anciens préfixes KS/KB ne sont pas utilisés dans KSP.
|
||||
- **KSP-CONFIG-003** — La priorité d'une variable est environnement du processus > `./.env` > fallback déclaré au point d'usage. Une chaîne vide explicitement définie est une valeur présente et n'active pas le fallback.
|
||||
- **KSP-CONFIG-004** — Le `.env` runtime est local, non versionné et non échangé. Config le lit sans prétendre modifier l'environnement du shell/systemd/parent qui a lancé le processus.
|
||||
- **KSP-CONFIG-005** — `.env.example` est versionné à la racine et inventorie toutes les variables d'environnement runtime KSP/KSPB utilisées par les fichiers Config ou le code ; toute nouvelle variable y est ajoutée dans le même delta que sa première utilisation.
|
||||
- **KSP-CONFIG-006** — Chaque entrée de `.env.example` est précédée d'un commentaire décrivant son usage/utilité. Sa valeur peut être un défaut sûr, une valeur générique non secrète ou une entrée commentée ; aucun vrai secret n'y est enregistré.
|
||||
- **KSP-CONFIG-007** — Les placeholders Config utilisent `${NAME}` ou `${NAME:-fallback}`. Le fallback s'applique uniquement si la variable est absente ; l'interpolation appartient à `ksp-config-lib` et non aux consumers.
|
||||
|
||||
## Programmes et exécution
|
||||
|
||||
- **KSP-PROGRAM-001** — Les contrats de décodage et de préparation d'exécution appartiennent à `ksp-program-api`; les implémentations officielles intégrées appartiennent à `ksp-program-lib`.
|
||||
|
||||
Reference in New Issue
Block a user