use anyhow::Result;
use serial_test::serial;
use std::env;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
use xchecker::config::{CliArgs, Config, ConfigSource};
fn create_config_file(dir: &std::path::Path, content: &str) -> PathBuf {
let xchecker_dir = dir.join(".xchecker");
fs::create_dir_all(&xchecker_dir).unwrap();
let config_path = xchecker_dir.join("config.toml");
fs::write(&config_path, content).unwrap();
config_path
}
fn create_git_marker(dir: &std::path::Path) {
fs::create_dir_all(dir.join(".git")).unwrap();
}
#[test]
fn test_upward_discovery_stops_at_git() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
create_git_marker(root);
let config_path = create_config_file(
root,
r#"
[defaults]
model = "sonnet"
max_turns = 10
"#,
);
let subsubdir = root.join("subdir").join("subsubdir");
fs::create_dir_all(&subsubdir)?;
let cli_args = CliArgs {
config_path: Some(config_path),
..Default::default()
};
let config = Config::discover(&cli_args)?;
assert_eq!(config.defaults.model, Some("sonnet".to_string()));
assert_eq!(config.defaults.max_turns, Some(10));
Ok(())
}
#[test]
fn test_discovery_stops_at_git_boundary() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
create_config_file(
root,
r#"
[defaults]
model = "opus-above-boundary"
max_turns = 99
"#,
);
let repo = root.join("repo");
create_git_marker(&repo);
create_config_file(
&repo,
r#"
[defaults]
model = "sonnet-inside-boundary"
max_turns = 8
"#,
);
let subdir = repo.join("subdir");
fs::create_dir_all(&subdir)?;
let cli_args = CliArgs::default();
let config = Config::discover_from(&subdir, &cli_args)?;
assert_eq!(
config.defaults.model,
Some("sonnet-inside-boundary".to_string())
);
assert_eq!(config.defaults.max_turns, Some(8));
Ok(())
}
#[test]
fn test_precedence_cli_over_config_over_defaults() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(
root,
r#"
[defaults]
model = "sonnet"
max_turns = 10
packet_max_bytes = 32768
verbose = false
[runner]
mode = "native"
"#,
);
let cli_args_config_only = CliArgs {
config_path: Some(config_path.clone()),
..Default::default()
};
let config = Config::discover(&cli_args_config_only)?;
assert_eq!(config.defaults.model, Some("sonnet".to_string()));
assert_eq!(config.defaults.max_turns, Some(10));
assert_eq!(config.defaults.packet_max_bytes, Some(32768));
assert_eq!(config.defaults.packet_max_lines, Some(1200)); assert_eq!(config.runner.mode, Some("native".to_string()));
let cli_args_override = CliArgs {
config_path: Some(config_path),
model: Some("opus".to_string()),
verbose: Some(true),
packet_max_lines: Some(2000),
runner_mode: Some("wsl".to_string()),
..Default::default()
};
let config = Config::discover(&cli_args_override)?;
assert_eq!(config.defaults.model, Some("opus".to_string())); assert_eq!(config.defaults.max_turns, Some(10)); assert_eq!(config.defaults.packet_max_bytes, Some(32768)); assert_eq!(config.defaults.packet_max_lines, Some(2000)); assert_eq!(config.defaults.verbose, Some(true)); assert_eq!(config.runner.mode, Some("wsl".to_string()));
Ok(())
}
#[test]
fn test_source_attribution_accuracy() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(
root,
r#"
[defaults]
model = "sonnet"
max_turns = 10
packet_max_bytes = 32768
[runner]
mode = "native"
"#,
);
let cli_args = CliArgs {
config_path: Some(config_path.clone()),
model: Some("opus".to_string()),
verbose: Some(true),
..Default::default()
};
let config = Config::discover(&cli_args)?;
assert_eq!(
config.source_attribution.get("model"),
Some(&ConfigSource::Cli)
);
assert_eq!(
config.source_attribution.get("verbose"),
Some(&ConfigSource::Cli)
);
assert_eq!(
config.source_attribution.get("max_turns"),
Some(&ConfigSource::Config)
);
assert_eq!(
config.source_attribution.get("packet_max_bytes"),
Some(&ConfigSource::Config)
);
assert_eq!(
config.source_attribution.get("packet_max_lines"),
Some(&ConfigSource::Default)
);
assert_eq!(
config.source_attribution.get("runner_mode"),
Some(&ConfigSource::Config)
);
let effective = config.effective_config();
assert_eq!(effective.get("model").unwrap().1, "cli");
assert_eq!(effective.get("verbose").unwrap().1, "cli");
assert_eq!(effective.get("max_turns").unwrap().1, "config");
assert_eq!(effective.get("packet_max_bytes").unwrap().1, "config");
assert_eq!(effective.get("packet_max_lines").unwrap().1, "default");
Ok(())
}
#[test]
#[serial]
fn test_xchecker_home_override() -> Result<()> {
let temp_dir = TempDir::new()?;
let custom_home = temp_dir.path().join("custom_xchecker");
fs::create_dir_all(&custom_home)?;
unsafe {
env::set_var("XCHECKER_HOME", &custom_home);
}
let home = xchecker::paths::xchecker_home();
assert_eq!(home.as_str(), custom_home.to_str().unwrap());
unsafe {
env::remove_var("XCHECKER_HOME");
}
Ok(())
}
#[test]
fn test_explicit_config_path() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let custom_config_dir = root.join("custom");
fs::create_dir_all(&custom_config_dir)?;
let custom_config_path = custom_config_dir.join("my-config.toml");
fs::write(
&custom_config_path,
r#"
[defaults]
model = "opus"
max_turns = 15
"#,
)?;
create_config_file(
root,
r#"
[defaults]
model = "sonnet"
max_turns = 10
"#,
);
let cli_args = CliArgs {
config_path: Some(custom_config_path.clone()),
..Default::default()
};
let config = Config::discover_from(root, &cli_args)?;
assert_eq!(config.defaults.model, Some("opus".to_string()));
assert_eq!(config.defaults.max_turns, Some(15));
assert_eq!(
config.source_attribution.get("model"),
Some(&ConfigSource::Config)
);
Ok(())
}
#[test]
fn test_invalid_toml_config() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(root, "invalid toml content [[[");
let cli_args = CliArgs {
config_path: Some(config_path),
..Default::default()
};
let result = Config::discover(&cli_args);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Invalid configuration file")
|| error_msg.contains("Failed to parse TOML config file"),
"Error message: {}",
error_msg
);
Ok(())
}
#[test]
fn test_invalid_config_values() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(
root,
r#"
[defaults]
packet_max_bytes = 0
"#,
);
let cli_args = CliArgs {
config_path: Some(config_path),
..Default::default()
};
let result = Config::discover(&cli_args);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("packet_max_bytes"));
assert!(error_msg.contains("must be greater than 0"));
Ok(())
}
#[test]
fn test_invalid_runner_mode() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(
root,
r#"
[runner]
mode = "invalid_mode"
"#,
);
let cli_args = CliArgs {
config_path: Some(config_path),
..Default::default()
};
let result = Config::discover(&cli_args);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("runner_mode"));
assert!(
error_msg.contains("auto") || error_msg.contains("native") || error_msg.contains("wsl")
);
Ok(())
}
#[test]
fn test_invalid_glob_pattern() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(root, "[defaults]\nmodel = \"sonnet\"\n");
let cli_args = CliArgs {
config_path: Some(config_path),
..Default::default()
};
let config = Config::discover(&cli_args)?;
assert_eq!(config.defaults.model, Some("sonnet".to_string()));
Ok(())
}
#[test]
fn test_packet_max_bytes_too_large() -> Result<()> {
let cli_args = CliArgs {
packet_max_bytes: Some(20_000_000), ..Default::default()
};
let result = Config::discover(&cli_args);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("packet_max_bytes"));
assert!(error_msg.contains("exceeds maximum"));
Ok(())
}
#[test]
fn test_max_turns_too_large() -> Result<()> {
let cli_args = CliArgs {
max_turns: Some(100), ..Default::default()
};
let result = Config::discover(&cli_args);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("max_turns"));
assert!(error_msg.contains("exceeds maximum"));
Ok(())
}
#[test]
fn test_phase_timeout_too_small() -> Result<()> {
let cli_args = CliArgs {
phase_timeout: Some(2), ..Default::default()
};
let result = Config::discover(&cli_args);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("phase_timeout"));
assert!(error_msg.contains("must be at least 5 seconds"));
Ok(())
}
#[test]
fn test_phase_timeout_too_large() -> Result<()> {
let cli_args = CliArgs {
phase_timeout: Some(10000), ..Default::default()
};
let result = Config::discover(&cli_args);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("phase_timeout"));
assert!(error_msg.contains("exceeds maximum"));
Ok(())
}
#[test]
fn test_invalid_output_format() -> Result<()> {
let cli_args = CliArgs {
output_format: Some("invalid-format".to_string()),
..Default::default()
};
let result = Config::discover(&cli_args);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("output_format"));
assert!(error_msg.contains("stream-json") || error_msg.contains("text"));
Ok(())
}
#[test]
fn test_missing_config_file_uses_defaults() -> Result<()> {
let temp_dir = TempDir::new()?;
let non_existent_path = temp_dir.path().join("non_existent_config.toml");
let cli_args = CliArgs {
config_path: Some(non_existent_path),
..Default::default()
};
let config = Config::discover(&cli_args)?;
assert_eq!(config.defaults.model, None);
assert_eq!(config.defaults.max_turns, Some(6));
assert_eq!(config.defaults.packet_max_bytes, Some(65536));
assert_eq!(config.defaults.packet_max_lines, Some(1200));
assert_eq!(
config.defaults.output_format,
Some("stream-json".to_string())
);
assert_eq!(config.defaults.verbose, Some(false));
assert_eq!(config.runner.mode, Some("auto".to_string()));
Ok(())
}
#[test]
fn test_empty_config_file_uses_defaults() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(root, "");
let cli_args = CliArgs {
config_path: Some(config_path),
..Default::default()
};
let config = Config::discover(&cli_args)?;
assert_eq!(config.defaults.max_turns, Some(6));
assert_eq!(config.defaults.packet_max_bytes, Some(65536));
Ok(())
}
#[test]
fn test_partial_config_file() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(
root,
r#"
[defaults]
model = "sonnet"
# max_turns not specified
"#,
);
let cli_args = CliArgs {
config_path: Some(config_path),
..Default::default()
};
let config = Config::discover(&cli_args)?;
assert_eq!(config.defaults.model, Some("sonnet".to_string()));
assert_eq!(config.defaults.max_turns, Some(6));
Ok(())
}
#[test]
fn test_get_runner_mode_conversion() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(
root,
r#"
[runner]
mode = "auto"
"#,
);
let cli_args = CliArgs {
config_path: Some(config_path),
..Default::default()
};
let config = Config::discover(&cli_args)?;
let runner_mode = config.get_runner_mode()?;
assert_eq!(runner_mode, xchecker::types::RunnerMode::Auto);
let cli_args = CliArgs {
runner_mode: Some("native".to_string()),
..Default::default()
};
let config = Config::discover(&cli_args)?;
let runner_mode = config.get_runner_mode()?;
assert_eq!(runner_mode, xchecker::types::RunnerMode::Native);
let cli_args = CliArgs {
runner_mode: Some("wsl".to_string()),
..Default::default()
};
let config = Config::discover(&cli_args)?;
let runner_mode = config.get_runner_mode()?;
assert_eq!(runner_mode, xchecker::types::RunnerMode::Wsl);
Ok(())
}
#[test]
fn test_full_config_file() -> Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let config_path = create_config_file(
root,
r#"
[defaults]
model = "opus"
max_turns = 12
packet_max_bytes = 100000
packet_max_lines = 2000
output_format = "text"
verbose = true
phase_timeout = 900
[selectors]
include = ["**/*.rs", "**/*.toml"]
exclude = ["target/**", "**/.git/**"]
[runner]
mode = "native"
distro = "Ubuntu"
claude_path = "/usr/local/bin/claude"
"#,
);
let cli_args = CliArgs {
config_path: Some(config_path),
..Default::default()
};
let config = Config::discover(&cli_args)?;
assert_eq!(config.defaults.model, Some("opus".to_string()));
assert_eq!(config.defaults.max_turns, Some(12));
assert_eq!(config.defaults.packet_max_bytes, Some(100000));
assert_eq!(config.defaults.packet_max_lines, Some(2000));
assert_eq!(config.defaults.output_format, Some("text".to_string()));
assert_eq!(config.defaults.verbose, Some(true));
assert_eq!(config.defaults.phase_timeout, Some(900));
assert_eq!(config.selectors.include, vec!["**/*.rs", "**/*.toml"]);
assert_eq!(config.selectors.exclude, vec!["target/**", "**/.git/**"]);
assert_eq!(config.runner.mode, Some("native".to_string()));
assert_eq!(config.runner.distro, Some("Ubuntu".to_string()));
assert_eq!(
config.runner.claude_path,
Some("/usr/local/bin/claude".to_string())
);
Ok(())
}
#[cfg(test)]
mod integration {
use super::*;
#[test]
#[ignore = "meta-test - individual tests already run; can cause races in parallel execution"]
fn run_all_config_tests() {
println!("\n=== Running Configuration System Tests (FR-CFG) ===\n");
test_upward_discovery_stops_at_git().unwrap();
test_discovery_stops_at_git_boundary().unwrap();
test_precedence_cli_over_config_over_defaults().unwrap();
test_source_attribution_accuracy().unwrap();
test_xchecker_home_override().unwrap();
test_explicit_config_path().unwrap();
test_invalid_toml_config().unwrap();
test_invalid_config_values().unwrap();
test_invalid_runner_mode().unwrap();
test_invalid_glob_pattern().unwrap();
test_packet_max_bytes_too_large().unwrap();
test_max_turns_too_large().unwrap();
test_phase_timeout_too_small().unwrap();
test_phase_timeout_too_large().unwrap();
test_invalid_output_format().unwrap();
test_missing_config_file_uses_defaults().unwrap();
test_empty_config_file_uses_defaults().unwrap();
test_partial_config_file().unwrap();
test_get_runner_mode_conversion().unwrap();
test_full_config_file().unwrap();
println!("\n=== All Configuration System Tests Passed ===\n");
}
}