use std::collections::HashSet;
use std::path::PathBuf;
pub fn resolve_agent_paths(
cli_agents: &[PathBuf],
config_user_dir: Option<&PathBuf>,
extra_dirs: &[PathBuf],
) -> Result<Vec<PathBuf>, String> {
for p in cli_agents {
if !p.exists() {
return Err(format!("--agents path does not exist: {}", p.display()));
}
}
let mut paths: Vec<PathBuf> = Vec::new();
paths.extend(cli_agents.iter().cloned());
paths.push(PathBuf::from(".zeph/agents"));
if let Some(dir) = config_user_dir {
if !dir.as_os_str().is_empty() {
paths.push(dir.clone());
}
} else {
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("zeph").join("agents"));
} else {
tracing::debug!("user config dir unavailable; user-level agents directory skipped");
}
}
paths.extend(extra_dirs.iter().cloned());
Ok(dedup_by_canonical(paths))
}
fn dedup_by_canonical(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut result = Vec::with_capacity(paths.len());
for path in paths {
let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if seen.insert(key) {
result.push(path);
} else {
tracing::debug!(
path = %path.display(),
"deduplicating agent path (canonical path already in list)"
);
}
}
result
}
#[must_use]
pub fn scope_label(
def_path: &std::path::Path,
cli_agents: &[PathBuf],
config_user_dir: Option<&PathBuf>,
extra_dirs: &[PathBuf],
) -> &'static str {
for cli_path in cli_agents {
if def_path.starts_with(cli_path) || def_path == cli_path {
return "cli";
}
}
if def_path.starts_with(".zeph/agents") {
return "project";
}
let user_dir = if let Some(dir) = config_user_dir {
if dir.as_os_str().is_empty() {
None
} else {
Some(dir.clone())
}
} else {
dirs::config_dir().map(|d| d.join("zeph").join("agents"))
};
if user_dir
.as_ref()
.is_some_and(|udir| def_path.starts_with(udir))
{
return "user";
}
for extra in extra_dirs {
if def_path.starts_with(extra) {
return "extra";
}
}
"unknown"
}
#[cfg(test)]
mod tests {
#![allow(clippy::cloned_ref_to_slice_refs)]
use super::*;
#[test]
fn resolve_empty_inputs_returns_project_and_user() {
let paths = resolve_agent_paths(&[], None, &[]).unwrap();
assert!(paths.iter().any(|p| p == &PathBuf::from(".zeph/agents")));
}
#[test]
fn resolve_cli_paths_come_first() {
let tmp = tempfile::tempdir().unwrap();
let cli_path = tmp.path().to_path_buf();
let paths = resolve_agent_paths(std::slice::from_ref(&cli_path), None, &[]).unwrap();
assert_eq!(paths[0], cli_path);
}
#[test]
fn resolve_nonexistent_cli_path_returns_error() {
let bad = PathBuf::from("/tmp/zeph-test-does-not-exist-12345");
let err = resolve_agent_paths(&[bad], None, &[]).unwrap_err();
assert!(err.contains("--agents path does not exist"));
}
#[test]
fn resolve_empty_user_dir_disables_user_level() {
let paths = resolve_agent_paths(&[], Some(&PathBuf::from("")), &[]).unwrap();
let has_config_dir = paths.iter().any(|p| {
p.to_str()
.is_some_and(|s| s.contains(".config") || s.contains("AppData"))
});
assert!(!has_config_dir);
}
#[test]
fn resolve_explicit_user_dir_added() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().to_path_buf();
let paths = resolve_agent_paths(&[], Some(&user_dir), &[]).unwrap();
assert!(paths.contains(&user_dir));
}
#[test]
fn resolve_extra_dirs_come_last() {
let tmp = tempfile::tempdir().unwrap();
let extra = tmp.path().to_path_buf();
let paths =
resolve_agent_paths(&[], Some(&PathBuf::from("")), std::slice::from_ref(&extra))
.unwrap();
assert_eq!(paths.last().unwrap(), &extra);
}
#[test]
fn resolve_deduplicates_same_canonical_path() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
let paths = resolve_agent_paths(&[], Some(&dir), std::slice::from_ref(&dir)).unwrap();
let count = paths.iter().filter(|p| *p == &dir).count();
assert_eq!(count, 1, "duplicate paths should be removed");
}
#[test]
fn scope_label_cli() {
let tmp = tempfile::tempdir().unwrap();
let cli_dir = tmp.path().to_path_buf();
let def_path = cli_dir.join("my-agent.md");
let label = scope_label(&def_path, &[cli_dir], None, &[]);
assert_eq!(label, "cli");
}
#[test]
fn scope_label_project() {
let def_path = PathBuf::from(".zeph/agents/my-agent.md");
let label = scope_label(&def_path, &[], None, &[]);
assert_eq!(label, "project");
}
#[test]
fn scope_label_extra() {
let tmp = tempfile::tempdir().unwrap();
let extra_dir = tmp.path().to_path_buf();
let def_path = extra_dir.join("my-agent.md");
let label = scope_label(&def_path, &[], Some(&PathBuf::from("")), &[extra_dir]);
assert_eq!(label, "extra");
}
#[test]
fn scope_label_user() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().to_path_buf();
let def_path = user_dir.join("my-agent.md");
let label = scope_label(&def_path, &[], Some(&user_dir), &[]);
assert_eq!(label, "user");
}
#[test]
fn scope_label_unknown_when_no_match() {
let tmp = tempfile::tempdir().unwrap();
let def_path = tmp.path().join("my-agent.md");
let label = scope_label(&def_path, &[], Some(&PathBuf::from("")), &[]);
assert_eq!(label, "unknown");
}
#[test]
fn resolve_user_dir_none_falls_back_to_platform_default() {
let paths = resolve_agent_paths(&[], None, &[]).unwrap();
assert!(paths.iter().any(|p| p == &PathBuf::from(".zeph/agents")));
}
#[test]
fn resolve_priority_order_cli_first_then_project() {
let tmp = tempfile::tempdir().unwrap();
let cli_dir = tmp.path().to_path_buf();
let paths = resolve_agent_paths(
std::slice::from_ref(&cli_dir),
Some(&PathBuf::from("")),
&[],
)
.unwrap();
assert_eq!(paths[0], cli_dir);
assert_eq!(paths[1], PathBuf::from(".zeph/agents"));
}
#[test]
fn resolve_extra_dirs_after_user_dir() {
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
let user_dir = tmp1.path().to_path_buf();
let extra_dir = tmp2.path().to_path_buf();
let paths =
resolve_agent_paths(&[], Some(&user_dir), std::slice::from_ref(&extra_dir)).unwrap();
let user_pos = paths.iter().position(|p| p == &user_dir).unwrap();
let extra_pos = paths.iter().position(|p| p == &extra_dir).unwrap();
assert!(user_pos < extra_pos, "user dir must come before extra dirs");
}
}