use super::types::{Config, OutputFormat};
use crate::schema::SettingsJson;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::warn;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginConfigLocation {
pub source: String,
pub path: PathBuf,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parsing error: {0}")]
TomlParse(#[from] toml::de::Error),
#[error("JSON parsing error: {0}")]
JsonParse(#[from] serde_json::Error),
#[error("Configuration not found")]
NotFound,
}
#[derive(Debug, Default, Clone)]
pub struct ConfigOverrides {
pub team: Option<String>,
pub identity: Option<String>,
pub format: Option<OutputFormat>,
pub color: Option<bool>,
pub config_path: Option<PathBuf>,
}
pub fn resolve_config(
overrides: &ConfigOverrides,
current_dir: &Path,
home_dir: &Path,
) -> Result<Config, ConfigError> {
let mut config = Config::default();
let config_path_override = resolve_config_path_override(overrides);
let global_config_path = home_dir.join(".config/atm/config.toml");
if global_config_path.exists() {
if let Ok(file_config) = load_config_file(&global_config_path) {
merge_config(&mut config, file_config);
} else {
warn!("Failed to parse global config at {global_config_path:?}");
}
}
if let Some(repo_config) = find_repo_local_config(current_dir) {
if let Ok(file_config) = load_config_file(&repo_config) {
merge_config(&mut config, file_config);
} else {
warn!("Failed to parse repo config at {repo_config:?}");
}
}
if let Some(path) = config_path_override {
let file_config = load_config_file(&path)?;
merge_config(&mut config, file_config);
}
apply_env_overrides(&mut config);
apply_cli_overrides(&mut config, overrides);
Ok(config)
}
pub fn resolve_plugin_config_location(
plugin_name: &str,
current_dir: &Path,
home_dir: &Path,
) -> Option<PluginConfigLocation> {
if plugin_name.trim().is_empty() {
return None;
}
if let Some(repo_config_path) = find_repo_local_config(current_dir)
&& config_file_declares_plugin(&repo_config_path, plugin_name)
{
return Some(PluginConfigLocation {
source: "repo".to_string(),
path: repo_config_path,
});
}
let global_config_path = home_dir.join(".config/atm/config.toml");
if config_file_declares_plugin(&global_config_path, plugin_name) {
return Some(PluginConfigLocation {
source: "global".to_string(),
path: global_config_path,
});
}
None
}
fn find_repo_local_config(current_dir: &Path) -> Option<PathBuf> {
let mut dir = current_dir;
loop {
let config_path = dir.join(".atm.toml");
if config_path.exists() {
return Some(config_path);
}
if dir.join(".git").exists() {
break;
}
dir = dir.parent()?;
}
None
}
fn load_config_file(path: &Path) -> Result<Config, ConfigError> {
let contents = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}
fn config_file_declares_plugin(path: &Path, plugin_name: &str) -> bool {
let Ok(contents) = std::fs::read_to_string(path) else {
return false;
};
let Ok(value) = toml::from_str::<toml::Value>(&contents) else {
return false;
};
value
.get("plugins")
.and_then(toml::Value::as_table)
.is_some_and(|plugins| plugins.contains_key(plugin_name))
}
fn merge_config(base: &mut Config, file: Config) {
base.core.default_team = file.core.default_team;
base.core.identity = file.core.identity;
base.display.format = file.display.format;
base.display.color = file.display.color;
base.display.timestamps = file.display.timestamps;
if file.messaging.offline_action.is_some() {
base.messaging.offline_action = file.messaging.offline_action;
}
base.retention = file.retention;
for (alias, identity) in file.aliases {
base.aliases.insert(alias, identity);
}
for (role, identity) in file.roles {
base.roles.insert(role, identity);
}
for (name, table) in file.plugins {
base.plugins.insert(name, table);
}
}
fn apply_env_overrides(config: &mut Config) {
if let Some(team) = env_var_nonempty("ATM_TEAM") {
config.core.default_team = team;
}
if let Some(identity) = env_var_nonempty("ATM_IDENTITY") {
config.core.identity = identity;
}
if std::env::var("ATM_NO_COLOR").is_ok() {
config.display.color = false;
}
}
fn resolve_config_path_override(overrides: &ConfigOverrides) -> Option<PathBuf> {
overrides
.config_path
.clone()
.or_else(|| env_var_nonempty("ATM_CONFIG").map(PathBuf::from))
}
fn env_var_nonempty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
fn apply_cli_overrides(config: &mut Config, overrides: &ConfigOverrides) {
if let Some(ref team) = overrides.team {
config.core.default_team = team.clone();
}
if let Some(ref identity) = overrides.identity {
config.core.identity = identity.clone();
}
if let Some(format) = overrides.format {
config.display.format = format;
}
if let Some(color) = overrides.color {
config.display.color = color;
}
}
pub fn resolve_settings(
settings_path_override: Option<&Path>,
current_dir: &Path,
home_dir: &Path,
) -> Option<SettingsJson> {
if let Some(path) = settings_path_override
&& let Some(settings) = try_load_settings(path)
{
return Some(settings);
}
if let Some(settings_path) = find_repo_local_settings(current_dir)
&& let Some(settings) = try_load_settings(&settings_path)
{
return Some(settings);
}
let global_path = home_dir.join(".claude/settings.json");
if let Some(settings) = try_load_settings(&global_path) {
return Some(settings);
}
None
}
fn find_repo_local_settings(current_dir: &Path) -> Option<PathBuf> {
let mut dir = current_dir;
loop {
let local_path = dir.join(".claude/settings.local.json");
if local_path.exists() {
return Some(local_path);
}
let settings_path = dir.join(".claude/settings.json");
if settings_path.exists() {
return Some(settings_path);
}
if dir.join(".git").exists() {
break;
}
dir = dir.parent()?;
}
None
}
fn try_load_settings(path: &Path) -> Option<SettingsJson> {
if !path.exists() {
return None;
}
match std::fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(settings) => Some(settings),
Err(e) => {
warn!("Failed to parse settings at {path:?}: {e}");
None
}
},
Err(e) => {
warn!("Failed to read settings at {path:?}: {e}");
None
}
}
}
#[cfg(test)]
mod tests {
use super::super::types::TimestampFormat;
use super::*;
use serial_test::serial;
use std::env;
struct EnvGuard {
vars: Vec<(&'static str, Option<String>)>,
}
impl EnvGuard {
fn isolate(keys: &'static [&'static str]) -> Self {
let mut vars = Vec::with_capacity(keys.len());
unsafe {
for key in keys {
vars.push((*key, env::var(key).ok()));
env::remove_var(key);
}
}
Self { vars }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
for (key, original) in &self.vars {
match original {
Some(v) => env::set_var(key, v),
None => env::remove_var(key),
}
}
}
}
}
const RESOLVE_ENV_KEYS: &[&str] = &["ATM_TEAM", "ATM_IDENTITY", "ATM_NO_COLOR", "ATM_CONFIG"];
#[test]
#[serial]
fn test_config_defaults() {
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = std::env::temp_dir();
let overrides = ConfigOverrides::default();
let config = resolve_config(&overrides, &temp_dir, &temp_dir).unwrap();
assert_eq!(config.core.default_team, "default");
assert_eq!(config.core.identity, "human");
assert_eq!(config.display.format, OutputFormat::Text);
assert!(config.display.color);
}
#[test]
#[serial]
fn test_env_overrides() {
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = std::env::temp_dir();
let overrides = ConfigOverrides::default();
unsafe {
env::set_var("ATM_TEAM", "test-team");
env::set_var("ATM_IDENTITY", "test-user");
}
let config = resolve_config(&overrides, &temp_dir, &temp_dir).unwrap();
assert_eq!(config.core.default_team, "test-team");
assert_eq!(config.core.identity, "test-user");
}
#[test]
#[serial]
fn test_empty_env_values_do_not_override_config() {
use tempfile::TempDir;
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = TempDir::new().unwrap();
std::fs::write(
temp_dir.path().join(".atm.toml"),
"[core]\ndefault_team = \"repo-team\"\nidentity = \"repo-user\"\n",
)
.unwrap();
unsafe {
env::set_var("ATM_TEAM", " ");
env::set_var("ATM_IDENTITY", "");
}
let overrides = ConfigOverrides::default();
let config = resolve_config(&overrides, temp_dir.path(), temp_dir.path()).unwrap();
assert_eq!(config.core.default_team, "repo-team");
assert_eq!(config.core.identity, "repo-user");
}
#[test]
#[serial]
fn test_cli_overrides() {
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = std::env::temp_dir();
let overrides = ConfigOverrides {
team: Some("cli-team".to_string()),
identity: Some("cli-user".to_string()),
format: Some(OutputFormat::Json),
color: Some(false),
config_path: None,
};
let config = resolve_config(&overrides, &temp_dir, &temp_dir).unwrap();
assert_eq!(config.core.default_team, "cli-team");
assert_eq!(config.core.identity, "cli-user");
assert_eq!(config.display.format, OutputFormat::Json);
assert!(!config.display.color);
}
#[test]
#[serial]
fn test_no_color_env() {
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = std::env::temp_dir();
let overrides = ConfigOverrides::default();
unsafe {
env::set_var("ATM_NO_COLOR", "1");
}
let config = resolve_config(&overrides, &temp_dir, &temp_dir).unwrap();
assert!(!config.display.color);
}
#[test]
fn test_settings_resolution_none() {
let temp_dir = std::env::temp_dir();
let nonexistent = temp_dir.join("nonexistent");
let settings = resolve_settings(None, &nonexistent, &nonexistent);
assert!(settings.is_none());
}
#[test]
fn test_config_file_parse() {
let temp_dir = std::env::temp_dir();
let config_path = temp_dir.join("test-config.toml");
let toml_content = r#"
[core]
default_team = "file-team"
identity = "file-user"
[display]
format = "json"
color = false
timestamps = "iso8601"
"#;
std::fs::write(&config_path, toml_content).unwrap();
let config = load_config_file(&config_path).unwrap();
assert_eq!(config.core.default_team, "file-team");
assert_eq!(config.core.identity, "file-user");
assert_eq!(config.display.format, OutputFormat::Json);
assert!(!config.display.color);
assert_eq!(config.display.timestamps, TimestampFormat::Iso8601);
std::fs::remove_file(&config_path).ok();
}
#[test]
fn test_malformed_config_handled_gracefully() {
let temp_dir = std::env::temp_dir();
let config_path = temp_dir.join("malformed-config.toml");
std::fs::write(&config_path, "invalid toml [[[").unwrap();
let result = load_config_file(&config_path);
assert!(result.is_err());
std::fs::remove_file(&config_path).ok();
}
#[test]
fn test_settings_resolution_from_subdirectory() {
use tempfile::TempDir;
let temp_root = TempDir::new().unwrap();
let git_dir = temp_root.path().join(".git");
let claude_dir = temp_root.path().join(".claude");
let settings_path = claude_dir.join("settings.json");
let sub_dir = temp_root.path().join("subdir");
std::fs::create_dir(&git_dir).unwrap();
std::fs::create_dir(&claude_dir).unwrap();
std::fs::create_dir(&sub_dir).unwrap();
let settings_json = r#"{"env": {"TEST_VAR": "from_root"}}"#;
std::fs::write(&settings_path, settings_json).unwrap();
let home_dir = temp_root.path(); let settings = resolve_settings(None, &sub_dir, home_dir);
assert!(settings.is_some());
let settings = settings.unwrap();
assert_eq!(
settings.env.get("TEST_VAR").map(String::as_str),
Some("from_root")
);
}
#[test]
fn test_settings_local_takes_precedence() {
use tempfile::TempDir;
let temp_root = TempDir::new().unwrap();
let git_dir = temp_root.path().join(".git");
let claude_dir = temp_root.path().join(".claude");
let settings_path = claude_dir.join("settings.json");
let settings_local_path = claude_dir.join("settings.local.json");
std::fs::create_dir(&git_dir).unwrap();
std::fs::create_dir(&claude_dir).unwrap();
let settings_json = r#"{"env": {"SOURCE": "settings"}}"#;
std::fs::write(&settings_path, settings_json).unwrap();
let settings_local_json = r#"{"env": {"SOURCE": "settings_local"}}"#;
std::fs::write(&settings_local_path, settings_local_json).unwrap();
let home_dir = temp_root.path();
let settings = resolve_settings(None, temp_root.path(), home_dir);
assert!(settings.is_some());
let settings = settings.unwrap();
assert_eq!(
settings.env.get("SOURCE").map(String::as_str),
Some("settings_local")
);
}
#[test]
#[serial]
fn test_plugin_config_merge_via_resolve() {
use tempfile::TempDir;
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".atm.toml");
let toml_content = r#"
[core]
default_team = "test-team"
identity = "test-user"
[plugins.issues]
enabled = true
poll_interval = 60
[plugins.ci-monitor]
enabled = false
"#;
std::fs::write(&config_path, toml_content).unwrap();
let overrides = ConfigOverrides::default();
let config = resolve_config(&overrides, temp_dir.path(), temp_dir.path()).unwrap();
assert!(config.plugin_config("issues").is_some());
assert!(config.plugin_config("ci-monitor").is_some());
let issues = config.plugin_config("issues").unwrap();
assert_eq!(issues.get("enabled").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
issues.get("poll_interval").and_then(|v| v.as_integer()),
Some(60)
);
}
#[test]
#[serial]
fn test_aliases_merge_via_resolve_with_repo_override() {
use tempfile::TempDir;
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path();
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_dir).unwrap();
let global_cfg_dir = home_dir.join(".config/atm");
std::fs::create_dir_all(&global_cfg_dir).unwrap();
std::fs::write(
global_cfg_dir.join("config.toml"),
"[aliases]\narch-atm = \"team-lead\"\nqa = \"qa-bot\"\n",
)
.unwrap();
std::fs::write(
repo_dir.join(".atm.toml"),
"[aliases]\narch-atm = \"lead-override\"\n",
)
.unwrap();
let overrides = ConfigOverrides::default();
let config = resolve_config(&overrides, &repo_dir, home_dir).unwrap();
assert_eq!(
config.aliases.get("arch-atm").map(String::as_str),
Some("lead-override")
);
assert_eq!(config.aliases.get("qa").map(String::as_str), Some("qa-bot"));
}
#[test]
#[serial]
fn test_roles_merge_via_resolve_with_repo_override() {
use tempfile::TempDir;
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path();
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_dir).unwrap();
let global_cfg_dir = home_dir.join(".config/atm");
std::fs::create_dir_all(&global_cfg_dir).unwrap();
std::fs::write(
global_cfg_dir.join("config.toml"),
"[roles]\nteam-lead = \"arch-atm\"\nreviewer = \"qa-bot\"\n",
)
.unwrap();
std::fs::write(
repo_dir.join(".atm.toml"),
"[roles]\nteam-lead = \"lead-override\"\n",
)
.unwrap();
let overrides = ConfigOverrides::default();
let config = resolve_config(&overrides, &repo_dir, home_dir).unwrap();
assert_eq!(
config.roles.get("team-lead").map(String::as_str),
Some("lead-override")
);
assert_eq!(
config.roles.get("reviewer").map(String::as_str),
Some("qa-bot")
);
}
#[test]
#[serial]
fn test_config_path_override_merges_last() {
use tempfile::TempDir;
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path();
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_dir).unwrap();
let global_cfg_dir = home_dir.join(".config/atm");
std::fs::create_dir_all(&global_cfg_dir).unwrap();
std::fs::write(
global_cfg_dir.join("config.toml"),
"[core]\ndefault_team = \"global-team\"\nidentity = \"global-user\"\n",
)
.unwrap();
std::fs::write(
repo_dir.join(".atm.toml"),
"[core]\ndefault_team = \"repo-team\"\nidentity = \"repo-user\"\n",
)
.unwrap();
let override_path = temp_dir.path().join("override.toml");
std::fs::write(
&override_path,
"[core]\ndefault_team = \"override-team\"\nidentity = \"override-user\"\n",
)
.unwrap();
let overrides = ConfigOverrides {
config_path: Some(override_path),
..Default::default()
};
let config = resolve_config(&overrides, &repo_dir, home_dir).unwrap();
assert_eq!(config.core.default_team, "override-team");
assert_eq!(config.core.identity, "override-user");
}
#[test]
#[serial]
fn test_atm_config_env_override_merges_last() {
use tempfile::TempDir;
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path();
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_dir).unwrap();
std::fs::write(
repo_dir.join(".atm.toml"),
"[core]\ndefault_team = \"repo-team\"\nidentity = \"repo-user\"\n",
)
.unwrap();
let override_path = temp_dir.path().join("env-override.toml");
std::fs::write(
&override_path,
"[core]\ndefault_team = \"env-team\"\nidentity = \"env-user\"\n",
)
.unwrap();
unsafe { env::set_var("ATM_CONFIG", &override_path) };
let config = resolve_config(&ConfigOverrides::default(), &repo_dir, home_dir).unwrap();
assert_eq!(config.core.default_team, "env-team");
assert_eq!(config.core.identity, "env-user");
}
#[test]
#[serial]
fn test_resolve_plugin_config_location_prefers_repo_over_global() {
use tempfile::TempDir;
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path();
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_dir).unwrap();
let global_cfg_dir = home_dir.join(".config/atm");
std::fs::create_dir_all(&global_cfg_dir).unwrap();
std::fs::write(
global_cfg_dir.join("config.toml"),
"[plugins.gh_monitor]\nenabled = false\n",
)
.unwrap();
std::fs::write(
repo_dir.join(".atm.toml"),
"[plugins.gh_monitor]\nenabled = true\n",
)
.unwrap();
let location =
resolve_plugin_config_location("gh_monitor", &repo_dir, home_dir).expect("location");
assert_eq!(location.source, "repo");
assert_eq!(location.path, repo_dir.join(".atm.toml"));
}
#[test]
#[serial]
fn test_resolve_plugin_config_location_falls_back_to_global() {
use tempfile::TempDir;
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path();
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_dir).unwrap();
let global_cfg_dir = home_dir.join(".config/atm");
std::fs::create_dir_all(&global_cfg_dir).unwrap();
std::fs::write(
global_cfg_dir.join("config.toml"),
"[plugins.gh_monitor]\nenabled = true\n",
)
.unwrap();
let location =
resolve_plugin_config_location("gh_monitor", &repo_dir, home_dir).expect("location");
assert_eq!(location.source, "global");
assert_eq!(location.path, global_cfg_dir.join("config.toml"));
}
#[test]
#[serial]
fn test_resolve_plugin_config_location_none_when_not_declared() {
use tempfile::TempDir;
let _env_guard = EnvGuard::isolate(RESOLVE_ENV_KEYS);
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path();
let repo_dir = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_dir).unwrap();
assert!(resolve_plugin_config_location("gh_monitor", &repo_dir, home_dir).is_none());
}
}