// file: ks-config/src/environment.rs // version: 8 //! Environment-file loading and configuration placeholder resolution. /// Result of loading one optional workspace environment file. #[derive(Clone, Debug, Eq, PartialEq)] pub struct EnvironmentLoadReport { /// Explicit or default environment file that was loaded. pub loaded_path: std::option::Option, } /// Loads the selected environment file without overriding process variables. /// /// Resolution order is: existing process environment, selected `.env`, then /// fallback text declared with `${NAME:-fallback}` placeholders. pub fn load_workspace_environment( workspace_root: &std::path::Path, ) -> ks_core::Result { let explicit_path = std::env::var("KS_ENV_FILE").ok(); let selected_path = match explicit_path { std::option::Option::Some(path) if !path.trim().is_empty() => { let candidate = std::path::PathBuf::from(path); if candidate.is_absolute() { candidate } else { workspace_root.join(candidate) } }, _ => workspace_root.join(".env"), }; if !selected_path.exists() { return std::result::Result::Ok(crate::EnvironmentLoadReport { loaded_path: None }); } return match dotenvy::from_path(&selected_path) { std::result::Result::Ok(()) => std::result::Result::Ok(crate::EnvironmentLoadReport { loaded_path: std::option::Option::Some(selected_path), }), std::result::Result::Err(_) => std::result::Result::Err(ks_core::Error::new( "config_env_file_load_failed", format!("failed to load environment file {}", selected_path.display()), )), }; } /// Resolves `${NAME}` and `${NAME:-fallback}` placeholders in arbitrary text. /// Missing variables without fallbacks are preserved for lazy consumers. pub fn resolve_environment_placeholders(raw: &str) -> std::string::String { let mut output = std::string::String::with_capacity(raw.len()); let bytes = raw.as_bytes(); let mut index = 0_usize; while index < bytes.len() { if bytes[index] == b'$' && index + 1 < bytes.len() && bytes[index + 1] == b'{' { let start = index; let mut end = index + 2; while end < bytes.len() && bytes[end] != b'}' { end += 1; } if end < bytes.len() { let expression = &raw[index + 2..end]; let (name, fallback) = match expression.split_once(":-") { std::option::Option::Some((name, fallback)) => { (name, std::option::Option::Some(fallback)) }, std::option::Option::None => (expression, std::option::Option::None), }; let resolved = std::env::var(name) .ok() .or_else(|| return fallback.map(|value| return value.to_string())); match resolved { std::option::Option::Some(value) => output.push_str(&value), std::option::Option::None => output.push_str(&raw[start..=end]), } index = end + 1; continue; } } output.push(bytes[index] as char); index += 1; } return output; } #[cfg(test)] mod tests { #[test] fn fallback_is_used_when_variable_is_absent() { let resolved = crate::resolve_environment_placeholders("${KS_CONFIG_TEST_MISSING:-fallback}"); assert_eq!(resolved, "fallback"); } #[test] fn unresolved_required_placeholder_is_preserved() { let resolved = crate::resolve_environment_placeholders("prefix-${KS_CONFIG_TEST_MISSING}-suffix"); assert_eq!(resolved, "prefix-${KS_CONFIG_TEST_MISSING}-suffix"); } }