use std::fs;
use std::path::{Path, PathBuf};
use opi_coding_agent::config::{ConfigSource, OpiConfig, load_config_file, resolve_config};
fn write_temp_config(dir: &Path, contents: &str) -> std::path::PathBuf {
let path = dir.join("config.toml");
fs::write(&path, contents).unwrap();
path
}
#[test]
fn missing_file_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nonexistent.toml");
let config = load_config_file(&missing).unwrap();
let defaults = OpiConfig::default();
assert_eq!(
config.defaults.model, defaults.defaults.model,
"missing file should fall back to default model"
);
}
#[test]
fn missing_file_does_not_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nonexistent.toml");
let result = load_config_file(&missing);
assert!(
result.is_ok(),
"missing optional config file should not error, got {:?}",
result
);
}
#[test]
fn valid_config_parses_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp_config(
dir.path(),
r#"
[defaults]
model = "anthropic:claude-sonnet-4"
max_iterations = 100
tool_timeout_ms = 60000
theme = "dark"
"#,
);
let config = load_config_file(&path).unwrap();
assert_eq!(config.defaults.model, "anthropic:claude-sonnet-4");
assert_eq!(config.defaults.max_iterations, 100);
assert_eq!(config.defaults.tool_timeout_ms, 60000);
assert_eq!(config.defaults.theme, "dark");
}
#[test]
fn valid_config_parses_thinking() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp_config(
dir.path(),
r#"
[thinking]
enabled = true
budget_tokens = 20000
"#,
);
let config = load_config_file(&path).unwrap();
assert!(config.thinking.enabled);
assert_eq!(config.thinking.budget_tokens, 20000);
}
#[test]
fn valid_config_parses_providers() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp_config(
dir.path(),
r#"
[providers.anthropic]
api_key_env = "MY_ANTHROPIC_KEY"
"#,
);
let config = load_config_file(&path).unwrap();
assert_eq!(config.providers.anthropic.api_key_env, "MY_ANTHROPIC_KEY");
}
#[test]
fn valid_config_parses_extension_and_package_paths() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp_config(
dir.path(),
r#"
[extensions]
paths = ["vendor/ext-a", "vendor/ext-b"]
[packages]
paths = ["vendor/pkg-a"]
"#,
);
let config = load_config_file(&path).unwrap();
assert_eq!(
config.extensions.paths,
vec![PathBuf::from("vendor/ext-a"), PathBuf::from("vendor/ext-b")]
);
assert_eq!(config.packages.paths, vec![PathBuf::from("vendor/pkg-a")]);
}
#[test]
fn resolve_config_appends_resource_paths_in_layer_order() {
let dir = tempfile::tempdir().unwrap();
let user_config = write_temp_config(
dir.path(),
r#"
[extensions]
paths = ["user-ext"]
[packages]
paths = ["user-pkg"]
"#,
);
let project_dir = dir.path().join("project");
let project_opi = project_dir.join(".opi");
fs::create_dir_all(&project_opi).unwrap();
fs::write(
project_opi.join("config.toml"),
r#"
[extensions]
paths = ["project-ext"]
[packages]
paths = ["project-pkg"]
"#,
)
.unwrap();
let cli_config = dir.path().join("explicit.toml");
fs::write(
&cli_config,
r#"
[extensions]
paths = ["cli-ext"]
[packages]
paths = ["cli-pkg"]
"#,
)
.unwrap();
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: Some(cli_config),
env_model: None,
project_dir: Some(project_dir),
user_config_path: Some(user_config),
})
.unwrap();
assert_eq!(
config.extensions.paths,
vec![
PathBuf::from("user-ext"),
PathBuf::from("project-ext"),
PathBuf::from("cli-ext")
]
);
assert_eq!(
config.packages.paths,
vec![
PathBuf::from("user-pkg"),
PathBuf::from("project-pkg"),
PathBuf::from("cli-pkg")
]
);
}
#[test]
fn malformed_toml_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp_config(
dir.path(),
r#"
this is not valid toml [[[
[defaults
model = broken
"#,
);
let result = load_config_file(&path);
assert!(result.is_err(), "malformed TOML should return error");
}
#[test]
fn malformed_error_message_is_clear() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp_config(
dir.path(),
r#"
[invalid toml !!
"#,
);
let result = load_config_file(&path);
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("config") || msg.contains("parse") || msg.contains("TOML"),
"error message should mention config/parse/TOML, got: {msg}"
);
}
#[test]
fn partial_config_fills_missing_with_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp_config(
dir.path(),
r#"
[defaults]
model = "anthropic:claude-sonnet-4"
"#,
);
let config = load_config_file(&path).unwrap();
assert_eq!(config.defaults.model, "anthropic:claude-sonnet-4");
let defaults = OpiConfig::default();
assert_eq!(
config.defaults.max_iterations, defaults.defaults.max_iterations,
"missing field should use default"
);
assert_eq!(
config.defaults.tool_timeout_ms, defaults.defaults.tool_timeout_ms,
"missing field should use default"
);
}
#[test]
fn empty_config_uses_all_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp_config(dir.path(), "");
let config = load_config_file(&path).unwrap();
let defaults = OpiConfig::default();
assert_eq!(config.defaults.model, defaults.defaults.model);
assert_eq!(
config.defaults.max_iterations,
defaults.defaults.max_iterations
);
}
#[test]
fn resolve_with_no_sources_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let config = resolve_config(ConfigSource {
cli_model: None,
config_path: None,
env_model: None,
project_dir: Some(dir.path().to_path_buf()),
user_config_path: None,
})
.unwrap();
let defaults = OpiConfig::default();
assert_eq!(config.defaults.model, defaults.defaults.model);
}
#[test]
fn unknown_fields_are_ignored() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp_config(
dir.path(),
r#"
[defaults]
model = "anthropic:claude-sonnet-4"
[future_feature]
some_new_option = true
"#,
);
let result = load_config_file(&path);
assert!(
result.is_ok(),
"unknown fields should be ignored, got {:?}",
result
);
}