use super::*;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
fn xdg_env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
struct XdgScope<'a> {
_guard: std::sync::MutexGuard<'a, ()>,
prev: Option<std::ffi::OsString>,
}
impl<'a> XdgScope<'a> {
fn cleared() -> Self {
let guard = xdg_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("XDG_CONFIG_HOME");
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
Self {
_guard: guard,
prev,
}
}
}
impl<'a> Drop for XdgScope<'a> {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
}
struct CwdScope<'a> {
_guard: std::sync::MutexGuard<'a, ()>,
prev: PathBuf,
}
impl<'a> CwdScope<'a> {
fn enter(path: &std::path::Path) -> Self {
let guard = xdg_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(path).unwrap();
Self {
_guard: guard,
prev,
}
}
}
impl<'a> Drop for CwdScope<'a> {
fn drop(&mut self) {
std::env::set_current_dir(&self.prev).unwrap();
}
}
#[cfg(unix)]
#[test]
fn detect_installed_agents_finds_binaries_on_path() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
for exec in ["claude", "cursor-agent"] {
let path = temp.path().join(exec);
std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let detected = detect_installed_agents_in(Some(temp.path().as_os_str()));
assert!(detected.contains(&CodingAgent::ClaudeCode));
assert!(detected.contains(&CodingAgent::Cursor));
assert!(!detected.contains(&CodingAgent::Codex));
assert!(!detected.contains(&CodingAgent::Hermes));
}
#[test]
fn build_config_does_not_emit_observability_exporters() {
let answers = SetupAnswers {
scope: ConfigScope::Project,
agents: vec![],
hermes_hooks_path: None,
};
let rendered = build_config(&answers).to_string();
assert!(!rendered.contains("[exporters]"));
assert!(!rendered.contains("[export."));
assert!(!rendered.contains("[observability]"));
assert!(!rendered.contains("[exporters.atif]"));
assert!(!rendered.contains("[exporters.openinference]"));
}
#[test]
fn build_config_skips_empty_sections_when_no_backends_selected() {
let answers = SetupAnswers {
scope: ConfigScope::Project,
agents: vec![],
hermes_hooks_path: None,
};
let doc = build_config(&answers);
let rendered = doc.to_string();
assert!(!rendered.contains("[exporters]"));
assert!(!rendered.contains("[observability]"));
assert!(!rendered.contains("[export"));
assert!(!rendered.contains("[agents]"));
}
#[test]
fn build_config_emits_agents_block_with_user_facing_keys() {
let answers = SetupAnswers {
scope: ConfigScope::Project,
agents: vec![CodingAgent::ClaudeCode, CodingAgent::Codex],
hermes_hooks_path: None,
};
let doc = build_config(&answers);
let rendered = doc.to_string();
assert!(rendered.contains("[agents.claude]"));
assert!(rendered.contains(r#"command = "claude""#));
assert!(rendered.contains("[agents.codex]"));
assert!(rendered.contains(r#"command = "codex""#));
}
#[test]
fn save_config_writes_project_scope_to_workspace_dir() {
let answers = SetupAnswers {
scope: ConfigScope::Project,
agents: vec![CodingAgent::ClaudeCode],
hermes_hooks_path: None,
};
let doc = build_config(&answers);
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let written = save_config(&doc, ConfigScope::Project, temp.path(), home.path(), None).unwrap();
assert_eq!(written.len(), 1);
assert_eq!(written[0], temp.path().join(".nemo-relay/config.toml"));
let contents = std::fs::read_to_string(&written[0]).unwrap();
assert!(!contents.contains("[exporters]"));
assert!(contents.contains("[agents.claude]"));
}
#[test]
fn save_config_scoped_merge_preserves_other_agents() {
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let project_dir = temp.path().join(".nemo-relay");
std::fs::create_dir_all(&project_dir).unwrap();
let existing_path = project_dir.join("config.toml");
std::fs::write(
&existing_path,
r#"[upstream]
openai_base_url = "http://old-openai"
[agents.claude]
command = "old-claude-binary"
[agents.codex]
command = "codex --full-auto"
"#,
)
.unwrap();
let answers = SetupAnswers {
scope: ConfigScope::Project,
agents: vec![CodingAgent::ClaudeCode],
hermes_hooks_path: None,
};
let doc = build_config(&answers);
save_config(
&doc,
ConfigScope::Project,
temp.path(),
home.path(),
Some(CodingAgent::ClaudeCode),
)
.unwrap();
let merged = std::fs::read_to_string(&existing_path).unwrap();
assert!(!merged.contains("[exporters]"));
assert!(merged.contains("[agents.claude]"));
assert!(merged.contains(r#"command = "claude""#));
assert!(
merged.contains("[agents.codex]"),
"expected scoped merge to preserve [agents.codex], got:\n{merged}"
);
assert!(
merged.contains("codex --full-auto"),
"expected scoped merge to preserve codex command, got:\n{merged}"
);
assert!(
merged.contains("http://old-openai"),
"expected scoped merge to preserve [upstream], got:\n{merged}"
);
assert!(
!merged.contains("old-claude-binary"),
"expected scoped merge to overwrite [agents.claude].command, got:\n{merged}"
);
}
#[test]
fn save_config_writes_both_scopes_when_both_selected() {
let _xdg = XdgScope::cleared();
let answers = SetupAnswers {
scope: ConfigScope::Both,
agents: vec![],
hermes_hooks_path: None,
};
let doc = build_config(&answers);
let cwd = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let written = save_config(&doc, ConfigScope::Both, cwd.path(), home.path(), None).unwrap();
assert_eq!(written.len(), 2);
assert!(written.iter().any(|p| p.starts_with(cwd.path())));
assert!(written.iter().any(|p| p.starts_with(home.path())));
}
#[test]
fn build_config_emits_hooks_path_for_hermes_when_set() {
let answers = SetupAnswers {
scope: ConfigScope::Project,
agents: vec![CodingAgent::Hermes],
hermes_hooks_path: Some(std::path::PathBuf::from("/tmp/proj/.hermes/config.yaml")),
};
let rendered = build_config(&answers).to_string();
assert!(rendered.contains("[agents.hermes]"));
assert!(rendered.contains(r#"hooks_path = "/tmp/proj/.hermes/config.yaml""#));
}
#[test]
fn config_scope_labels_are_user_facing_and_stable() {
assert!(
ConfigScope::Project
.label()
.contains(".nemo-relay/config.toml")
);
assert!(
ConfigScope::Global
.label()
.contains(".config/nemo-relay/config.toml")
);
assert!(
ConfigScope::Both
.label()
.contains("project overrides global")
);
}
#[test]
fn hermes_hook_paths_follow_selected_scope() {
let cwd = PathBuf::from("/workspace");
let home = PathBuf::from("/home/user");
let agents = [CodingAgent::Hermes];
assert_eq!(
hermes_hooks_path_for_scope(&agents, ConfigScope::Project, &cwd, &home),
Some(PathBuf::from("/workspace/.hermes/config.yaml"))
);
assert_eq!(
hermes_hooks_path_for_scope(&agents, ConfigScope::Both, &cwd, &home),
Some(PathBuf::from("/workspace/.hermes/config.yaml"))
);
assert_eq!(
hermes_hooks_path_for_scope(&agents, ConfigScope::Global, &cwd, &home),
Some(PathBuf::from("/home/user/.hermes/config.yaml"))
);
assert_eq!(
hermes_hooks_path_for_scope(&[], ConfigScope::Project, &cwd, &home),
None
);
assert_eq!(
hermes_hook_targets(ConfigScope::Both, &cwd, &home),
vec![
PathBuf::from("/workspace/.hermes/config.yaml"),
PathBuf::from("/home/user/.hermes/config.yaml")
]
);
}
#[test]
fn existing_defaults_detects_scope_and_agents_from_docs() {
let empty = Defaults::default();
assert!(!empty.has_any());
assert!(
Defaults {
scope: Some(ConfigScope::Project),
agents: vec![]
}
.has_any()
);
assert!(
Defaults {
scope: None,
agents: vec![CodingAgent::Codex]
}
.has_any()
);
let doc: DocumentMut = r#"
[agents.claude]
command = "claude"
[agents.codex]
command = "codex"
[agents.unknown]
command = "custom"
"#
.parse()
.unwrap();
let agents = read_agents_from_doc(&doc);
assert_eq!(agents, vec![CodingAgent::ClaudeCode, CodingAgent::Codex]);
}
#[test]
fn install_hermes_hooks_writes_yaml_and_merges_existing() {
let cwd = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let project_hermes = cwd.path().join(".hermes");
std::fs::create_dir_all(&project_hermes).unwrap();
std::fs::write(
project_hermes.join("config.yaml"),
"model:\n provider: auto\n",
)
.unwrap();
let written = install_hermes_hooks(ConfigScope::Both, cwd.path(), home.path()).unwrap();
assert_eq!(written.len(), 2);
let project_yaml = std::fs::read_to_string(cwd.path().join(".hermes/config.yaml")).unwrap();
assert!(project_yaml.contains("nemo-relay hook-forward hermes"));
assert!(project_yaml.contains("api_request_error"));
assert!(
project_yaml.contains("provider: auto"),
"existing model block must survive merge"
);
let home_yaml = std::fs::read_to_string(home.path().join(".hermes/config.yaml")).unwrap();
assert!(home_yaml.contains("nemo-relay hook-forward hermes"));
}
#[test]
fn write_or_merge_recovers_from_non_table_agents_value() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("config.toml");
std::fs::write(
&path,
r#"
agents = "not-a-table"
[plugins]
enabled = true
"#,
)
.unwrap();
let doc = build_config(&SetupAnswers {
scope: ConfigScope::Project,
agents: vec![CodingAgent::Codex],
hermes_hooks_path: None,
});
write_or_merge(&path, &doc, Some(CodingAgent::Codex)).unwrap();
let merged = std::fs::read_to_string(path).unwrap();
assert!(merged.contains("[agents.codex]"));
assert!(merged.contains(r#"command = "codex""#));
assert!(merged.contains("[plugins]"));
}
#[test]
fn reset_removes_whole_project_config_or_one_agent() {
let temp = tempfile::tempdir().unwrap();
let _cwd = CwdScope::enter(temp.path());
let config_dir = temp.path().join(".nemo-relay");
std::fs::create_dir_all(&config_dir).unwrap();
let path = config_dir.join("config.toml");
std::fs::write(
&path,
r#"
[agents.claude]
command = "claude"
[agents.codex]
command = "codex"
"#,
)
.unwrap();
reset(Some(CodingAgent::ClaudeCode)).unwrap();
let scoped = std::fs::read_to_string(&path).unwrap();
assert!(!scoped.contains("[agents.claude]"));
assert!(scoped.contains("[agents.codex]"));
reset(None).unwrap();
assert!(!path.exists());
}
#[test]
fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() {
let temp = tempfile::tempdir().unwrap();
let _cwd = CwdScope::enter(temp.path());
let config_dir = temp.path().join(".nemo-relay");
std::fs::create_dir_all(&config_dir).unwrap();
let path = config_dir.join("config.toml");
std::fs::write(&path, "agents = \"not-a-table\"\n").unwrap();
reset(Some(CodingAgent::Hermes)).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"agents = \"not-a-table\"\n"
);
std::fs::write(&path, "not valid toml = [\n").unwrap();
let error = reset(Some(CodingAgent::Hermes)).unwrap_err().to_string();
assert!(
error.contains("could not parse existing config"),
"error was: {error}"
);
}