use std::fs;
use opi_coding_agent::config::{ConfigSource, OpiConfig, load_config_file, resolve_config};
fn write_config(dir: &std::path::Path, subpath: &str, contents: &str) -> std::path::PathBuf {
if let Some(parent) = std::path::Path::new(subpath).parent() {
let parent_dir = dir.join(parent);
fs::create_dir_all(&parent_dir).unwrap();
}
let path = dir.join(subpath);
fs::write(&path, contents).unwrap();
path
}
fn user_config_path(temp: &std::path::Path) -> std::path::PathBuf {
temp.join("user_config").join("config.toml")
}
fn project_dir(temp: &std::path::Path) -> std::path::PathBuf {
temp.join("project")
}
#[test]
fn cli_model_overrides_user_config() {
let temp = tempfile::tempdir().unwrap();
write_config(
temp.path(),
"user_config/config.toml",
r#"
[defaults]
model = "anthropic:claude-opus-4"
"#,
);
write_config(
temp.path(),
"project/.opi/config.toml",
r#"
[defaults]
model = "anthropic:claude-haiku-4"
"#,
);
let config = resolve_config(ConfigSource {
cli_model: Some("anthropic:claude-sonnet-4".into()),
config_path: None,
env_model: None,
project_dir: Some(project_dir(temp.path())),
user_config_path: Some(user_config_path(temp.path())),
})
.unwrap();
assert_eq!(config.defaults.model, "anthropic:claude-sonnet-4");
}
#[test]
fn env_model_overrides_user_config() {
let temp = tempfile::tempdir().unwrap();
write_config(
temp.path(),
"user_config/config.toml",
r#"
[defaults]
model = "anthropic:claude-opus-4"
"#,
);
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: None,
env_model: Some("anthropic:claude-haiku-4".into()),
project_dir: None,
user_config_path: Some(user_config_path(temp.path())),
})
.unwrap();
assert_eq!(config.defaults.model, "anthropic:claude-haiku-4");
}
#[test]
fn project_config_overrides_user_config() {
let temp = tempfile::tempdir().unwrap();
write_config(
temp.path(),
"user_config/config.toml",
r#"
[defaults]
model = "anthropic:claude-opus-4"
max_iterations = 200
"#,
);
write_config(
temp.path(),
"project/.opi/config.toml",
r#"
[defaults]
model = "anthropic:claude-sonnet-4"
"#,
);
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: None,
env_model: None,
project_dir: Some(project_dir(temp.path())),
user_config_path: Some(user_config_path(temp.path())),
})
.unwrap();
assert_eq!(config.defaults.model, "anthropic:claude-sonnet-4");
assert_eq!(config.defaults.max_iterations, 200);
}
#[test]
fn user_config_overrides_defaults() {
let temp = tempfile::tempdir().unwrap();
write_config(
temp.path(),
"user_config/config.toml",
r#"
[defaults]
model = "anthropic:claude-opus-4"
max_iterations = 100
"#,
);
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: None,
env_model: None,
project_dir: None,
user_config_path: Some(user_config_path(temp.path())),
})
.unwrap();
assert_eq!(config.defaults.model, "anthropic:claude-opus-4");
assert_eq!(config.defaults.max_iterations, 100);
}
#[test]
fn defaults_when_nothing_configured() {
let temp = tempfile::tempdir().unwrap();
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: None,
env_model: None,
project_dir: Some(temp.path().join("no_project")),
user_config_path: Some(temp.path().join("no_user").join("config.toml")),
})
.unwrap();
let defaults = OpiConfig::default();
assert_eq!(config.defaults.model, defaults.defaults.model);
assert_eq!(
config.defaults.max_iterations,
defaults.defaults.max_iterations
);
assert_eq!(
config.defaults.tool_timeout_ms,
defaults.defaults.tool_timeout_ms
);
}
#[test]
fn full_precedence_chain() {
let temp = tempfile::tempdir().unwrap();
write_config(
temp.path(),
"user_config/config.toml",
r#"
[defaults]
model = "user-model"
"#,
);
write_config(
temp.path(),
"project/.opi/config.toml",
r#"
[defaults]
model = "project-model"
"#,
);
let config = resolve_config(ConfigSource {
cli_model: Some("cli-model".into()),
config_path: None,
env_model: Some("env-model".into()),
project_dir: Some(project_dir(temp.path())),
user_config_path: Some(user_config_path(temp.path())),
})
.unwrap();
assert_eq!(config.defaults.model, "cli-model");
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: None,
env_model: Some("env-model".into()),
project_dir: Some(project_dir(temp.path())),
user_config_path: Some(user_config_path(temp.path())),
})
.unwrap();
assert_eq!(config.defaults.model, "env-model");
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: None,
env_model: None,
project_dir: Some(project_dir(temp.path())),
user_config_path: Some(user_config_path(temp.path())),
})
.unwrap();
assert_eq!(config.defaults.model, "project-model");
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: None,
env_model: None,
project_dir: None,
user_config_path: Some(user_config_path(temp.path())),
})
.unwrap();
assert_eq!(config.defaults.model, "user-model");
}
#[test]
fn malformed_user_config_is_error() {
let temp = tempfile::tempdir().unwrap();
write_config(
temp.path(),
"user_config/config.toml",
r#"
[invalid toml !!!
"#,
);
let result = resolve_config(ConfigSource {
cli_model: None,
config_path: None,
env_model: None,
project_dir: None,
user_config_path: Some(user_config_path(temp.path())),
});
assert!(result.is_err(), "malformed user config should be an error");
}
#[test]
fn malformed_project_config_is_error() {
let temp = tempfile::tempdir().unwrap();
write_config(
temp.path(),
"project/.opi/config.toml",
r#"
[broken [[[
"#,
);
let result = load_config_file(&temp.path().join("project").join(".opi").join("config.toml"));
assert!(
result.is_err(),
"malformed project config should be an error"
);
}
#[test]
fn explicit_config_path_nonexistent_is_error() {
let temp = tempfile::tempdir().unwrap();
let result = resolve_config(ConfigSource {
cli_model: None,
config_path: Some(temp.path().join("does_not_exist.toml")),
env_model: None,
project_dir: None,
user_config_path: None,
});
assert!(
result.is_err(),
"explicit --config with non-existent file should be an error"
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("not found") || err.to_string().contains("config"),
"error message should indicate file not found, got: {err}"
);
}
#[test]
fn explicit_config_path_overrides_project() {
let temp = tempfile::tempdir().unwrap();
write_config(
temp.path(),
"project/.opi/config.toml",
r#"
[defaults]
model = "project-model"
"#,
);
let config_path = write_config(
temp.path(),
"cli_config.toml",
r#"
[defaults]
model = "cli-config-model"
"#,
);
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: Some(config_path),
env_model: None,
project_dir: Some(project_dir(temp.path())),
user_config_path: None,
})
.unwrap();
assert_eq!(config.defaults.model, "cli-config-model");
}
#[test]
fn explicit_config_model_not_overridden_by_env() {
let temp = tempfile::tempdir().unwrap();
let config_path = write_config(
temp.path(),
"cli_config.toml",
r#"
[defaults]
model = "config-model"
"#,
);
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: Some(config_path),
env_model: Some("env-model".into()),
project_dir: None,
user_config_path: None,
})
.unwrap();
assert_eq!(
config.defaults.model, "config-model",
"--config model should not be overridden by OPI_MODEL"
);
}
#[test]
fn cli_model_overrides_explicit_config() {
let temp = tempfile::tempdir().unwrap();
let config_path = write_config(
temp.path(),
"cli_config.toml",
r#"
[defaults]
model = "config-model"
"#,
);
let config = resolve_config(ConfigSource {
cli_model: Some("cli-model".into()),
config_path: Some(config_path),
env_model: Some("env-model".into()),
project_dir: None,
user_config_path: None,
})
.unwrap();
assert_eq!(
config.defaults.model, "cli-model",
"--model should override --config and env"
);
}