use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use algocline_core::AppDir;
use serde::{Deserialize, Serialize};
#[derive(thiserror::Error, Debug)]
pub enum SettingError {
#[error("setting: failed to read {path}: {source}")]
IoRead {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("setting: failed to parse TOML at {path}: {source}")]
TomlParse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("setting: target '{target}' contains invalid characters (snake_case only)")]
InvalidTarget { target: String },
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct SettingResolved {
pub resolved: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
pub sources: BTreeMap<String, BTreeMap<String, String>>,
}
#[derive(Debug, Default, Deserialize)]
struct ConfigToml {
#[serde(default)]
setting: BTreeMap<String, BTreeMap<String, toml::Value>>,
}
pub fn validate_target(target: &str) -> Result<(), SettingError> {
if target.is_empty() {
return Err(SettingError::InvalidTarget {
target: target.to_string(),
});
}
let mut chars = target.chars();
let first = chars.next().ok_or_else(|| SettingError::InvalidTarget {
target: target.to_string(),
})?;
if !first.is_ascii_lowercase() {
return Err(SettingError::InvalidTarget {
target: target.to_string(),
});
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
return Err(SettingError::InvalidTarget {
target: target.to_string(),
});
}
}
Ok(())
}
fn toml_to_json(v: &toml::Value) -> serde_json::Value {
match v {
toml::Value::String(s) => serde_json::Value::String(s.clone()),
toml::Value::Boolean(b) => serde_json::Value::Bool(*b),
toml::Value::Integer(i) => serde_json::json!(*i),
toml::Value::Float(f) => {
serde_json::Number::from_f64(*f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
}
toml::Value::Array(arr) => serde_json::Value::Array(arr.iter().map(toml_to_json).collect()),
toml::Value::Table(tbl) => {
let map: serde_json::Map<String, serde_json::Value> = tbl
.iter()
.map(|(k, v)| (k.clone(), toml_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()),
}
}
fn load_setting_section(
path: &Path,
) -> Result<BTreeMap<String, BTreeMap<String, serde_json::Value>>, SettingError> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(source) => {
return Err(SettingError::IoRead {
path: path.to_path_buf(),
source,
})
}
};
let doc: ConfigToml = toml::from_str(&content).map_err(|source| SettingError::TomlParse {
path: path.to_path_buf(),
source,
})?;
let converted = doc
.setting
.into_iter()
.map(|(target, fields)| {
let json_fields: BTreeMap<String, serde_json::Value> = fields
.iter()
.map(|(k, v)| (k.clone(), toml_to_json(v)))
.collect();
(target, json_fields)
})
.collect();
Ok(converted)
}
fn load_project_layer(
project_root: &Path,
) -> Result<BTreeMap<String, BTreeMap<String, serde_json::Value>>, SettingError> {
let alc_path = project_root.join("alc.toml");
let local_path = project_root.join("alc.local.toml");
let base = load_setting_section(&alc_path)?;
let local = load_setting_section(&local_path)?;
let mut merged: BTreeMap<String, BTreeMap<String, serde_json::Value>> = base;
for (target, local_fields) in local {
let entry = merged.entry(target).or_default();
for (field, value) in local_fields {
entry.insert(field, value);
}
}
Ok(merged)
}
pub fn scan_env_overrides() -> BTreeMap<String, BTreeMap<String, serde_json::Value>> {
const PREFIX: &str = "ALC_SETTING_";
let mut result: BTreeMap<String, BTreeMap<String, serde_json::Value>> = BTreeMap::new();
for (key, raw_value) in std::env::vars() {
let Some(rest) = key.strip_prefix(PREFIX) else {
continue;
};
let segments: Vec<&str> = rest.split('_').collect();
if segments.len() < 2 {
continue;
}
let mut found = false;
for split_at in (1..segments.len()).rev() {
let target_part = segments[..split_at].join("_");
let field_part = segments[split_at..].join("_");
if target_part.is_empty() || field_part.is_empty() {
continue;
}
let target_lower = target_part.to_ascii_lowercase();
let field_lower = field_part.to_ascii_lowercase();
if validate_target(&target_lower).is_ok() {
let value = parse_env_value(&raw_value);
result
.entry(target_lower)
.or_default()
.insert(field_lower, value);
found = true;
break;
}
}
let _ = found; }
result
}
fn parse_env_value(raw: &str) -> serde_json::Value {
match raw.to_ascii_lowercase().as_str() {
"true" => return serde_json::Value::Bool(true),
"false" => return serde_json::Value::Bool(false),
_ => {}
}
if let Ok(i) = raw.parse::<i64>() {
return serde_json::json!(i);
}
if let Ok(f) = raw.parse::<f64>() {
if let Some(n) = serde_json::Number::from_f64(f) {
return serde_json::Value::Number(n);
}
}
serde_json::Value::String(raw.to_string())
}
pub fn resolve_setting(
app_dir: &AppDir,
project_root: Option<&Path>,
target: Option<&str>,
) -> Result<SettingResolved, SettingError> {
if let Some(t) = target {
validate_target(t)?;
}
let env_layer = scan_env_overrides();
let project_layer: BTreeMap<String, BTreeMap<String, serde_json::Value>> =
if let Some(root) = project_root {
load_project_layer(root)?
} else {
BTreeMap::new()
};
let global_layer = load_setting_section(&app_dir.config_toml())?;
let all_targets: std::collections::BTreeSet<String> = env_layer
.keys()
.chain(project_layer.keys())
.chain(global_layer.keys())
.cloned()
.collect();
let mut resolved: BTreeMap<String, BTreeMap<String, serde_json::Value>> = BTreeMap::new();
let mut sources: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for t in &all_targets {
if let Some(requested) = target {
if t != requested {
continue;
}
}
let env_fields = env_layer.get(t);
let project_fields = project_layer.get(t);
let global_fields = global_layer.get(t);
let all_fields: std::collections::BTreeSet<String> = env_fields
.iter()
.flat_map(|m| m.keys())
.chain(project_fields.iter().flat_map(|m| m.keys()))
.chain(global_fields.iter().flat_map(|m| m.keys()))
.cloned()
.collect();
if all_fields.is_empty() {
continue;
}
let mut t_resolved: BTreeMap<String, serde_json::Value> = BTreeMap::new();
let mut t_sources: BTreeMap<String, String> = BTreeMap::new();
for f in &all_fields {
if let Some(v) = env_fields.and_then(|m| m.get(f)) {
t_resolved.insert(f.clone(), v.clone());
t_sources.insert(f.clone(), "env".to_string());
} else if let Some(v) = project_fields.and_then(|m| m.get(f)) {
t_resolved.insert(f.clone(), v.clone());
t_sources.insert(f.clone(), "project".to_string());
} else if let Some(v) = global_fields.and_then(|m| m.get(f)) {
t_resolved.insert(f.clone(), v.clone());
t_sources.insert(f.clone(), "global".to_string());
}
}
if !t_resolved.is_empty() {
resolved.insert(t.clone(), t_resolved);
sources.insert(t.clone(), t_sources);
}
}
Ok(SettingResolved { resolved, sources })
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvGuard {
key: String,
old_value: Option<String>,
}
impl EnvGuard {
fn set(key: &str, value: &str) -> Self {
let old_value = std::env::var(key).ok();
#[allow(deprecated)]
unsafe {
std::env::set_var(key, value);
}
Self {
key: key.to_string(),
old_value,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_value {
Some(v) => {
#[allow(deprecated)]
unsafe {
std::env::set_var(&self.key, v);
}
}
None => {
#[allow(deprecated)]
unsafe {
std::env::remove_var(&self.key);
}
}
}
}
}
#[test]
fn validate_target_snake_case_ok() {
assert!(validate_target("journal").is_ok());
assert!(validate_target("my_advisor").is_ok());
assert!(validate_target("a").is_ok());
assert!(validate_target("a1").is_ok());
assert!(validate_target("a_1_b").is_ok());
}
#[test]
fn validate_target_rejects_uppercase() {
assert!(matches!(
validate_target("Journal"),
Err(SettingError::InvalidTarget { .. })
));
assert!(matches!(
validate_target("JOURNAL"),
Err(SettingError::InvalidTarget { .. })
));
assert!(matches!(
validate_target("my_Advisor"),
Err(SettingError::InvalidTarget { .. })
));
}
#[test]
fn validate_target_rejects_special() {
assert!(matches!(
validate_target("my-target"),
Err(SettingError::InvalidTarget { .. })
));
assert!(matches!(
validate_target("a.b"),
Err(SettingError::InvalidTarget { .. })
));
assert!(matches!(
validate_target(""),
Err(SettingError::InvalidTarget { .. })
));
assert!(matches!(
validate_target("1start"),
Err(SettingError::InvalidTarget { .. })
));
assert!(matches!(
validate_target("_start"),
Err(SettingError::InvalidTarget { .. })
));
}
#[test]
fn load_setting_section_returns_empty_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nonexistent.toml");
let result = load_setting_section(&path).unwrap();
assert!(result.is_empty(), "expected empty map for absent file");
}
#[test]
fn load_setting_section_parses_string_bool_number() {
let tmp = tempfile::tempdir().unwrap();
let content = "[setting.j]\npath = \"a\"\npkg = true\nn = 42\n";
let path = tmp.path().join("config.toml");
std::fs::write(&path, content).unwrap();
let result = load_setting_section(&path).unwrap();
let j = result.get("j").expect("expected target 'j'");
assert_eq!(j.get("path"), Some(&serde_json::json!("a")));
assert_eq!(j.get("pkg"), Some(&serde_json::json!(true)));
assert_eq!(j.get("n"), Some(&serde_json::json!(42)));
}
#[test]
fn load_setting_section_propagates_parse_error() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("bad.toml");
std::fs::write(&path, "[[not valid toml\n").unwrap();
let result = load_setting_section(&path);
assert!(
matches!(result, Err(SettingError::TomlParse { .. })),
"expected TomlParse error, got: {result:?}"
);
}
#[test]
fn scan_env_overrides_parses_screaming_snake() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::set("ALC_SETTING_JOURNAL_PATH", "/x");
let result = scan_env_overrides();
let journal = result.get("journal").expect("expected 'journal' target");
assert_eq!(
journal.get("path"),
Some(&serde_json::json!("/x")),
"expected path = /x"
);
}
#[test]
fn scan_env_overrides_parses_bool() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::set("ALC_SETTING_JOURNAL_PKG", "true");
let result = scan_env_overrides();
let journal = result.get("journal").expect("expected 'journal' target");
assert_eq!(
journal.get("pkg"),
Some(&serde_json::Value::Bool(true)),
"expected pkg = true (Bool)"
);
}
#[test]
fn scan_env_overrides_string_fallback() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::set("ALC_SETTING_JOURNAL_PATH", "/some/path");
let result = scan_env_overrides();
let journal = result.get("journal").expect("expected 'journal' target");
assert_eq!(
journal.get("path"),
Some(&serde_json::Value::String("/some/path".to_string())),
"expected string fallback"
);
}
fn make_app_dir(root: &Path) -> AppDir {
AppDir::new(root.to_path_buf())
}
fn write_config(dir: &Path, content: &str) {
std::fs::write(dir.join("config.toml"), content).unwrap();
}
fn write_alc_toml(dir: &Path, content: &str) {
std::fs::write(dir.join("alc.toml"), content).unwrap();
}
fn write_local_alc_toml(dir: &Path, content: &str) {
std::fs::write(dir.join("alc.local.toml"), content).unwrap();
}
#[test]
fn resolve_setting_field_level_env_wins() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::set("ALC_SETTING_JOURNAL_PATH", "B");
let global_tmp = tempfile::tempdir().unwrap();
let project_tmp = tempfile::tempdir().unwrap();
write_config(
global_tmp.path(),
"[setting.journal]\npath = \"A\"\npkg = true\n",
);
let app_dir = make_app_dir(global_tmp.path());
let result = resolve_setting(&app_dir, Some(project_tmp.path()), None).unwrap();
let resolved_journal = result
.resolved
.get("journal")
.expect("expected 'journal' in resolved");
let sources_journal = result
.sources
.get("journal")
.expect("expected 'journal' in sources");
assert_eq!(
resolved_journal.get("path"),
Some(&serde_json::json!("B")),
"env should win for 'path'"
);
assert_eq!(
resolved_journal.get("pkg"),
Some(&serde_json::json!(true)),
"global should provide 'pkg'"
);
assert_eq!(sources_journal.get("path"), Some(&"env".to_string()));
assert_eq!(sources_journal.get("pkg"), Some(&"global".to_string()));
}
#[test]
fn resolve_setting_project_wins_over_global() {
let _lock = ENV_MUTEX.lock().unwrap();
let global_tmp = tempfile::tempdir().unwrap();
let project_tmp = tempfile::tempdir().unwrap();
write_config(global_tmp.path(), "[setting.journal]\npath = \"A\"\n");
write_alc_toml(project_tmp.path(), "[setting.journal]\npath = \"B\"\n");
let app_dir = make_app_dir(global_tmp.path());
let result = resolve_setting(&app_dir, Some(project_tmp.path()), None).unwrap();
let resolved = result.resolved.get("journal").unwrap();
let sources = result.sources.get("journal").unwrap();
assert_eq!(resolved.get("path"), Some(&serde_json::json!("B")));
assert_eq!(sources.get("path"), Some(&"project".to_string()));
}
#[test]
fn resolve_setting_target_filter_all() {
let _lock = ENV_MUTEX.lock().unwrap();
let global_tmp = tempfile::tempdir().unwrap();
let project_tmp = tempfile::tempdir().unwrap();
write_config(
global_tmp.path(),
"[setting.journal]\npath = \"A\"\n[setting.advisor]\nmodel = \"gpt4\"\n",
);
let app_dir = make_app_dir(global_tmp.path());
let result = resolve_setting(&app_dir, Some(project_tmp.path()), None).unwrap();
assert!(
result.resolved.contains_key("journal"),
"should include 'journal'"
);
assert!(
result.resolved.contains_key("advisor"),
"should include 'advisor'"
);
}
#[test]
fn resolve_setting_target_filter_specific() {
let _lock = ENV_MUTEX.lock().unwrap();
let global_tmp = tempfile::tempdir().unwrap();
let project_tmp = tempfile::tempdir().unwrap();
write_config(
global_tmp.path(),
"[setting.journal]\npath = \"A\"\n[setting.advisor]\nmodel = \"gpt4\"\n",
);
let app_dir = make_app_dir(global_tmp.path());
let result = resolve_setting(&app_dir, Some(project_tmp.path()), Some("journal")).unwrap();
assert!(
result.resolved.contains_key("journal"),
"should include 'journal'"
);
assert!(
!result.resolved.contains_key("advisor"),
"should NOT include 'advisor' when filtering for 'journal'"
);
}
#[test]
fn resolve_setting_missing_field_absent() {
let _lock = ENV_MUTEX.lock().unwrap();
let global_tmp = tempfile::tempdir().unwrap();
let project_tmp = tempfile::tempdir().unwrap();
write_config(global_tmp.path(), "[setting.journal]\npath = \"A\"\n");
let app_dir = make_app_dir(global_tmp.path());
let result = resolve_setting(&app_dir, Some(project_tmp.path()), None).unwrap();
let resolved = result.resolved.get("journal").unwrap();
assert!(
!resolved.contains_key("port"),
"'port' should be absent when no layer defines it"
);
let sources = result.sources.get("journal").unwrap();
assert!(
!sources.contains_key("port"),
"'port' should be absent from sources too"
);
}
#[test]
fn resolve_setting_returns_resolved_and_sources_both() {
let _lock = ENV_MUTEX.lock().unwrap();
let global_tmp = tempfile::tempdir().unwrap();
let project_tmp = tempfile::tempdir().unwrap();
write_config(global_tmp.path(), "[setting.journal]\npath = \"A\"\n");
let app_dir = make_app_dir(global_tmp.path());
let result = resolve_setting(&app_dir, Some(project_tmp.path()), None).unwrap();
assert!(
!result.resolved.is_empty(),
"resolved must be populated in a single call"
);
assert!(
!result.sources.is_empty(),
"sources must be populated in the same call"
);
assert_eq!(
result.resolved.keys().collect::<Vec<_>>(),
result.sources.keys().collect::<Vec<_>>(),
"resolved and sources must have matching target keys"
);
}
#[test]
fn resolve_setting_invalid_target_returns_err() {
let _lock = ENV_MUTEX.lock().unwrap();
let global_tmp = tempfile::tempdir().unwrap();
let project_tmp = tempfile::tempdir().unwrap();
let app_dir = make_app_dir(global_tmp.path());
let result = resolve_setting(&app_dir, Some(project_tmp.path()), Some("Bad-Target"));
assert!(
matches!(result, Err(SettingError::InvalidTarget { .. })),
"expected InvalidTarget error"
);
}
#[test]
fn resolve_setting_local_toml_wins_over_base() {
let _lock = ENV_MUTEX.lock().unwrap();
let global_tmp = tempfile::tempdir().unwrap();
let project_tmp = tempfile::tempdir().unwrap();
write_config(global_tmp.path(), "");
write_alc_toml(project_tmp.path(), "[setting.journal]\npath = \"base\"\n");
write_local_alc_toml(project_tmp.path(), "[setting.journal]\npath = \"local\"\n");
let app_dir = make_app_dir(global_tmp.path());
let result = resolve_setting(&app_dir, Some(project_tmp.path()), None).unwrap();
let resolved = result.resolved.get("journal").unwrap();
assert_eq!(
resolved.get("path"),
Some(&serde_json::json!("local")),
"alc.local.toml should win over alc.toml"
);
let sources = result.sources.get("journal").unwrap();
assert_eq!(sources.get("path"), Some(&"project".to_string()));
}
#[test]
fn resolve_setting_no_project_root_uses_global_only() {
let _lock = ENV_MUTEX.lock().unwrap();
let global_tmp = tempfile::tempdir().unwrap();
write_config(global_tmp.path(), "[setting.journal]\npath = \"G\"\n");
let app_dir = make_app_dir(global_tmp.path());
let result = resolve_setting(&app_dir, None, None).unwrap();
let resolved = result.resolved.get("journal").unwrap();
assert_eq!(resolved.get("path"), Some(&serde_json::json!("G")));
let sources = result.sources.get("journal").unwrap();
assert_eq!(sources.get("path"), Some(&"global".to_string()));
}
}