use std::path::{Path, PathBuf};
use crate::{Error, Item, Result, harness::Scope};
pub const CONFIG_DIR: &str = ".agents";
pub const CONFIG_NAME: &str = "agent-harness-adapter";
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Found {
pub path: PathBuf,
pub root: PathBuf,
}
pub fn find_config(
scope: Scope,
root: Option<&Path>,
cwd: &Path,
home: &Path,
) -> Result<Option<Found>> {
if scope == Scope::User {
return at(home);
}
if let Some(root) = root {
return at(root);
}
for d in project_dirs(cwd, Some(home), CONFIG_DIR) {
if let Some(found) = at(&d)? {
return Ok(Some(found));
}
}
Ok(None)
}
pub fn project_dirs(start: &Path, home: Option<&Path>, marker: &str) -> Vec<PathBuf> {
let chain: Vec<&Path> = start.ancestors().take_while(|d| Some(*d) != home).collect();
let end = match chain.iter().position(|d| d.join(".git").exists()) {
Some(git) => git,
None => chain
.iter()
.position(|d| d.join(marker).exists())
.unwrap_or(chain.len().saturating_sub(1)),
};
chain
.iter()
.take(end + 1)
.map(|d| d.to_path_buf())
.collect()
}
fn at(dir: &Path) -> Result<Option<Found>> {
let base = dir.join(CONFIG_DIR);
let toml = base.join(format!("{CONFIG_NAME}.toml"));
let json = base.join(format!("{CONFIG_NAME}.json"));
let found = |path: PathBuf| {
Some(Found {
path,
root: dir.to_path_buf(),
})
};
match (toml.is_file(), json.is_file()) {
(true, true) => Err(Error::file(
toml.display().to_string(),
format!("two configs: use this one or {}, not both", json.display()),
)),
(true, false) => Ok(found(toml)),
(false, true) => Ok(found(json)),
(false, false) => Ok(None),
}
}
pub fn standard_sources(scope: Scope) -> [(Item, &'static str); 5] {
let (instructions, mcp) = match scope {
Scope::User => (".agents/AGENTS.md", ".agents/mcp.json"),
_ => ("AGENTS.md", ".mcp.json"),
};
[
(Item::Instructions, instructions),
(Item::Skills, ".agents/skills"),
(Item::Hooks, ".agents/hooks.json"),
(Item::Mcp, mcp),
(Item::Agents, ".agents/agents"),
]
}
pub fn config_text(scope: Scope, name: &str) -> String {
let [i, s, h, m, a] = standard_sources(scope).map(|(_, path)| path);
format!(
"# agent-harness-adapter: one agent setup for every coding agent.\n\
# Edit the files below; `agent-harness-adapter install` brings them to\n\
# each agent. https://six5536.github.io/agent-harness-adapter/\n\
version = 1\n\
name = \"{name}\"\n\
\n\
instructions = {{ from = \"{i}\" }}\n\
skills = {{ from = \"{s}\" }}\n\
hooks = {{ from = \"{h}\" }}\n\
mcp_servers = {{ from = \"{m}\" }}\n\
agents = {{ from = \"{a}\" }}\n"
)
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("aha-config-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn put(dir: &Path, ext: &str) -> PathBuf {
let p = dir.join(CONFIG_DIR).join(format!("{CONFIG_NAME}.{ext}"));
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, "").unwrap();
p
}
#[test]
fn found_upwards_until_the_repository() {
let home = tmp("find");
let repo = home.join("repo");
let deep = repo.join("src/a");
fs::create_dir_all(&deep).unwrap();
fs::create_dir_all(repo.join(".git")).unwrap();
assert_eq!(
find_config(Scope::Project, None, &deep, &home).unwrap(),
None
);
let p = put(&repo, "toml");
let f = find_config(Scope::Project, None, &deep, &home)
.unwrap()
.unwrap();
assert_eq!((f.path, f.root), (p.clone(), repo.clone()));
let sub = repo.join("src");
fs::create_dir_all(sub.join(".agents")).unwrap();
assert_eq!(
find_config(Scope::Project, None, &deep, &home)
.unwrap()
.unwrap()
.path,
p
);
fs::remove_dir_all(sub.join(".agents")).unwrap();
put(&home, "toml");
let elsewhere = home.join("elsewhere");
fs::create_dir_all(&elsewhere).unwrap();
assert_eq!(
find_config(Scope::Project, None, &elsewhere, &home).unwrap(),
None
);
let outer = tmp("outer");
put(&outer, "toml");
let inner = outer.join("x");
fs::create_dir_all(inner.join(".git")).unwrap();
assert_eq!(
find_config(Scope::Project, None, &inner, &home).unwrap(),
None
);
assert_eq!(
find_config(Scope::Local, Some(&deep), &deep, &home).unwrap(),
None
);
assert_eq!(
find_config(Scope::Local, Some(&repo), &home, &home)
.unwrap()
.unwrap()
.path,
p
);
}
#[test]
fn user_scope_and_two_configs() {
let home = tmp("user");
assert_eq!(find_config(Scope::User, None, &home, &home).unwrap(), None);
let j = put(&home, "json");
assert_eq!(
find_config(Scope::User, None, Path::new("/"), &home)
.unwrap()
.unwrap()
.path,
j
);
put(&home, "toml");
let e = find_config(Scope::User, None, &home, &home)
.unwrap_err()
.to_string();
assert!(e.contains("two configs"), "{e}");
}
#[test]
fn the_new_config_names_the_standard_sources() {
let t = config_text(Scope::Project, "agents");
assert!(t.contains("instructions = { from = \"AGENTS.md\" }"));
assert!(t.contains("mcp_servers = { from = \".mcp.json\" }"));
let u = config_text(Scope::User, "me");
assert!(
u.contains("name = \"me\"")
&& u.contains("from = \".agents/AGENTS.md\"")
&& u.contains(".agents/mcp.json")
);
}
#[test]
fn outside_git_the_first_agents_decides() {
let home = tmp("nogit");
let outer = home.join("tmp");
put(&outer, "toml");
let project = outer.join("x");
fs::create_dir_all(project.join(".agents")).unwrap();
let dirs = project_dirs(&project.join("src"), Some(&home), CONFIG_DIR);
assert_eq!(dirs, vec![project.join("src"), project.clone()]);
assert_eq!(
find_config(Scope::Project, None, &project, &home).unwrap(),
None
);
}
}