use std::path::Path;
use crate::error::{AppError, Result};
use crate::paths::agent_dir;
use crate::settings::{CliOverrides, Settings};
pub fn load(cli: &CliOverrides) -> Result<Settings> {
load_with(&agent_dir(), cli, |name| std::env::var(name).ok())
}
pub fn load_with<F>(agent_dir: &Path, cli: &CliOverrides, env_lookup: F) -> Result<Settings>
where
F: Fn(&str) -> Option<String>,
{
let mut settings = Settings::default();
let file_path = agent_dir.join("settings.json");
if file_path.exists() {
let raw = std::fs::read_to_string(&file_path).map_err(|err| {
AppError::Config(format!("failed to read {}: {err}", file_path.display()))
})?;
let mut merged = serde_json::to_value(Settings::default())
.map_err(|err| AppError::Config(format!("failed to encode defaults: {err}")))?;
let file_value: serde_json::Value = serde_json::from_str(&raw).map_err(|err| {
AppError::Config(format!("failed to parse {}: {err}", file_path.display()))
})?;
merge_json(&mut merged, file_value);
settings = serde_json::from_value(merged).map_err(|err| {
AppError::Config(format!("failed to parse {}: {err}", file_path.display()))
})?;
}
if let Some(name) = env_lookup("CAPO_MODEL_NAME") {
settings.model.name = name.trim().to_string();
}
if let Some(provider) = env_lookup("CAPO_MODEL_PROVIDER") {
settings.model.provider = provider.trim().to_string();
}
if let Some(max_tokens) = env_lookup("CAPO_MODEL_MAX_TOKENS") {
let parsed: u32 = max_tokens.trim().parse().map_err(|_| {
AppError::Config(format!("CAPO_MODEL_MAX_TOKENS not a u32: {max_tokens}"))
})?;
settings.model.max_tokens = parsed;
}
if let Some(base_url) = env_lookup("CAPO_ANTHROPIC_BASE_URL") {
settings.anthropic.base_url = base_url.trim().to_string();
}
if let Some(model) = &cli.model {
settings.model.name = model.clone();
}
Ok(settings)
}
fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
match (base, overlay) {
(serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
for (key, value) in overlay {
match base.get_mut(&key) {
Some(existing) => merge_json(existing, value),
None => {
base.insert(key, value);
}
}
}
}
(slot, value) => *slot = value,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
fn temp_dir() -> TempDir {
match tempfile::tempdir() {
Ok(dir) => dir,
Err(err) => panic!("tempdir failed: {err}"),
}
}
fn lookup(map: HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
move |name| map.get(name).map(|v| (*v).to_string())
}
#[test]
fn missing_file_returns_defaults_with_env_overlay() {
let dir = temp_dir();
let env = HashMap::from([("CAPO_MODEL_NAME", "claude-opus-4-7")]);
let s = match Settings::load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
Ok(settings) => settings,
Err(err) => panic!("load failed: {err}"),
};
assert_eq!(s.model.name, "claude-opus-4-7");
assert_eq!(s.model.provider, "anthropic"); }
#[test]
fn partial_nested_file_values_overlay_defaults() {
let dir = temp_dir();
let path = dir.path().join("settings.json");
if let Err(err) = std::fs::write(&path, r#"{ "model": { "name": "from-file" } }"#) {
panic!("write failed: {err}");
}
let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
Ok(settings) => settings,
Err(err) => panic!("load failed: {err}"),
};
assert_eq!(s.model.name, "from-file");
assert_eq!(s.model.provider, "anthropic");
assert_eq!(s.model.max_tokens, 8192);
}
#[test]
fn env_overlays_file_overlays_default() {
let dir = temp_dir();
let path = dir.path().join("settings.json");
if let Err(err) = std::fs::write(
&path,
r#"{ "model": { "provider": "anthropic", "name": "from-file", "max_tokens": 4096 } }"#,
) {
panic!("write failed: {err}");
}
let env = HashMap::from([("CAPO_MODEL_NAME", "from-env")]);
let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
Ok(settings) => settings,
Err(err) => panic!("load failed: {err}"),
};
assert_eq!(s.model.name, "from-env");
assert_eq!(s.model.max_tokens, 4096);
}
#[test]
fn cli_overlays_env_overlays_file() {
let dir = temp_dir();
let path = dir.path().join("settings.json");
if let Err(err) = std::fs::write(
&path,
r#"{ "model": { "provider": "anthropic", "name": "from-file", "max_tokens": 4096 } }"#,
) {
panic!("write failed: {err}");
}
let env = HashMap::from([("CAPO_MODEL_NAME", "from-env")]);
let cli = CliOverrides {
model: Some("from-cli".into()),
};
let s = match load_with(dir.path(), &cli, lookup(env)) {
Ok(settings) => settings,
Err(err) => panic!("load failed: {err}"),
};
assert_eq!(s.model.name, "from-cli");
}
#[test]
fn anthropic_base_url_loads_from_settings_json() {
let dir = temp_dir();
let path = dir.path().join("settings.json");
if let Err(err) = std::fs::write(
&path,
r#"{ "anthropic": { "base_url": "https://from-file.example.com/anthropic" } }"#,
) {
panic!("write failed: {err}");
}
let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
Ok(settings) => settings,
Err(err) => panic!("load failed: {err}"),
};
assert_eq!(
s.anthropic.base_url,
"https://from-file.example.com/anthropic"
);
assert_eq!(s.model.provider, "anthropic");
}
#[test]
fn anthropic_base_url_env_overlays_settings_json() {
let dir = temp_dir();
let path = dir.path().join("settings.json");
if let Err(err) = std::fs::write(
&path,
r#"{ "anthropic": { "base_url": "https://from-file.example.com/anthropic" } }"#,
) {
panic!("write failed: {err}");
}
let env = HashMap::from([(
"CAPO_ANTHROPIC_BASE_URL",
"https://from-env.example.com/anthropic",
)]);
let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
Ok(settings) => settings,
Err(err) => panic!("load failed: {err}"),
};
assert_eq!(
s.anthropic.base_url,
"https://from-env.example.com/anthropic"
);
}
#[test]
fn capo_anthropic_base_url_env_overlays_default() {
let dir = temp_dir();
let env = HashMap::from([(
"CAPO_ANTHROPIC_BASE_URL",
"https://proxy.example.com/anthropic",
)]);
let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
Ok(settings) => settings,
Err(err) => panic!("load failed: {err}"),
};
assert_eq!(s.anthropic.base_url, "https://proxy.example.com/anthropic");
}
#[test]
fn malformed_json_returns_config_error_with_path() {
let dir = temp_dir();
let path = dir.path().join("settings.json");
if let Err(err) = std::fs::write(&path, "{not json}") {
panic!("write failed: {err}");
}
let err = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
Ok(_) => panic!("must fail on malformed json"),
Err(err) => err,
};
let msg = format!("{err}");
assert!(msg.contains("settings.json"), "{msg}");
}
}