Skip to main content

zeph_subagent/
resolve.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Priority-ordered resolution of sub-agent definition directories.
5//!
6//! Sub-agent definitions are discovered from multiple sources in a fixed priority order:
7//! CLI `--agents` paths > project-level `.zeph/agents/` > user-level config dir > extra dirs.
8//! Duplicate directories (by canonical path) are silently deduplicated.
9
10use std::collections::HashSet;
11use std::path::PathBuf;
12
13/// Build the ordered list of agent definition directories with deduplication.
14///
15/// Priority (highest first):
16/// 1. `cli_agents` — paths from `--agents` CLI flag
17/// 2. `.zeph/agents/` — project-level (relative to CWD)
18/// 3. user-level dir — `config_user_dir` or platform default (`~/.config/zeph/agents/`)
19/// 4. `extra_dirs` — from `[agents]` config section
20///
21/// Directories are deduplicated by canonical path before returning to avoid
22/// redundant scans when the same directory appears in multiple sources.
23/// Non-existent directories are kept in the list and silently skipped by
24/// [`SubAgentDef::load_all`][crate::SubAgentDef].
25///
26/// # Errors
27///
28/// Returns `Err` if any path in `cli_agents` does not exist on disk, because
29/// CLI arguments represent explicit user intent and should fail loudly on typos.
30///
31/// # Examples
32///
33/// ```rust,no_run
34/// use std::path::PathBuf;
35/// use zeph_subagent::resolve::resolve_agent_paths;
36///
37/// let paths = resolve_agent_paths(&[], None, &[]).unwrap();
38/// // At minimum the project-level directory is always included.
39/// assert!(paths.iter().any(|p| p == &PathBuf::from(".zeph/agents")));
40/// ```
41pub fn resolve_agent_paths(
42    cli_agents: &[PathBuf],
43    config_user_dir: Option<&PathBuf>,
44    extra_dirs: &[PathBuf],
45) -> Result<Vec<PathBuf>, String> {
46    // Validate CLI paths eagerly — non-existent paths are user errors.
47    for p in cli_agents {
48        if !p.exists() {
49            return Err(format!("--agents path does not exist: {}", p.display()));
50        }
51    }
52
53    let mut paths: Vec<PathBuf> = Vec::new();
54
55    // 1. CLI --agents (highest priority)
56    paths.extend(cli_agents.iter().cloned());
57
58    // 2. Project-level
59    paths.push(PathBuf::from(".zeph/agents"));
60
61    // 3. User-level
62    if let Some(dir) = config_user_dir {
63        if !dir.as_os_str().is_empty() {
64            paths.push(dir.clone());
65        }
66        // explicit empty string = user disabled user-level dir
67    } else {
68        // Use dirs crate for cross-platform config dir resolution.
69        if let Some(config_dir) = dirs::config_dir() {
70            paths.push(config_dir.join("zeph").join("agents"));
71        } else {
72            tracing::debug!("user config dir unavailable; user-level agents directory skipped");
73        }
74    }
75
76    // 4. Extra dirs from config
77    paths.extend(extra_dirs.iter().cloned());
78
79    // Deduplicate directories by canonical path. Non-existent paths cannot be
80    // canonicalized — they are kept as-is and load_all will skip them silently.
81    Ok(dedup_by_canonical(paths))
82}
83
84fn dedup_by_canonical(paths: Vec<PathBuf>) -> Vec<PathBuf> {
85    let mut seen: HashSet<PathBuf> = HashSet::new();
86    let mut result = Vec::with_capacity(paths.len());
87
88    for path in paths {
89        // Only canonicalize existing paths; non-existent ones pass through.
90        let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
91        if seen.insert(key) {
92            result.push(path);
93        } else {
94            tracing::debug!(
95                path = %path.display(),
96                "deduplicating agent path (canonical path already in list)"
97            );
98        }
99    }
100
101    result
102}
103
104/// Compute the human-readable scope label for an agent definition file.
105///
106/// Returns one of:
107/// - `"cli"` — file lives under one of the `--agents` CLI paths
108/// - `"project"` — file lives under `.zeph/agents/`
109/// - `"user"` — file lives under the user-level config dir
110/// - `"extra"` — file lives under an `extra_dirs` entry
111/// - `"unknown"` — no source directory matches
112///
113/// # Examples
114///
115/// ```rust,no_run
116/// use std::path::PathBuf;
117/// use zeph_subagent::resolve::scope_label;
118///
119/// let def = PathBuf::from(".zeph/agents/my-agent.md");
120/// assert_eq!(scope_label(&def, &[], None, &[]), "project");
121/// ```
122#[must_use]
123pub fn scope_label(
124    def_path: &std::path::Path,
125    cli_agents: &[PathBuf],
126    config_user_dir: Option<&PathBuf>,
127    extra_dirs: &[PathBuf],
128) -> &'static str {
129    // Check CLI paths
130    for cli_path in cli_agents {
131        if def_path.starts_with(cli_path) || def_path == cli_path {
132            return "cli";
133        }
134    }
135
136    // Check project-level
137    if def_path.starts_with(".zeph/agents") {
138        return "project";
139    }
140
141    // Check user-level dir
142    let user_dir = if let Some(dir) = config_user_dir {
143        if dir.as_os_str().is_empty() {
144            None
145        } else {
146            Some(dir.clone())
147        }
148    } else {
149        dirs::config_dir().map(|d| d.join("zeph").join("agents"))
150    };
151
152    if user_dir
153        .as_ref()
154        .is_some_and(|udir| def_path.starts_with(udir))
155    {
156        return "user";
157    }
158
159    // Check extra dirs
160    for extra in extra_dirs {
161        if def_path.starts_with(extra) {
162            return "extra";
163        }
164    }
165
166    "unknown"
167}
168
169#[cfg(test)]
170mod tests {
171    #![allow(clippy::cloned_ref_to_slice_refs)]
172
173    use super::*;
174
175    #[test]
176    fn resolve_empty_inputs_returns_project_and_user() {
177        let paths = resolve_agent_paths(&[], None, &[]).unwrap();
178        // Must have at least the project-level path
179        assert!(paths.iter().any(|p| p == &PathBuf::from(".zeph/agents")));
180    }
181
182    #[test]
183    fn resolve_cli_paths_come_first() {
184        let tmp = tempfile::tempdir().unwrap();
185        let cli_path = tmp.path().to_path_buf();
186        let paths = resolve_agent_paths(std::slice::from_ref(&cli_path), None, &[]).unwrap();
187        assert_eq!(paths[0], cli_path);
188    }
189
190    #[test]
191    fn resolve_nonexistent_cli_path_returns_error() {
192        let bad = PathBuf::from("/tmp/zeph-test-does-not-exist-12345");
193        let err = resolve_agent_paths(&[bad], None, &[]).unwrap_err();
194        assert!(err.contains("--agents path does not exist"));
195    }
196
197    #[test]
198    fn resolve_empty_user_dir_disables_user_level() {
199        let paths = resolve_agent_paths(&[], Some(&PathBuf::from("")), &[]).unwrap();
200        // No user-level dir should be added
201        let has_config_dir = paths.iter().any(|p| {
202            p.to_str()
203                .is_some_and(|s| s.contains(".config") || s.contains("AppData"))
204        });
205        assert!(!has_config_dir);
206    }
207
208    #[test]
209    fn resolve_explicit_user_dir_added() {
210        let tmp = tempfile::tempdir().unwrap();
211        let user_dir = tmp.path().to_path_buf();
212        let paths = resolve_agent_paths(&[], Some(&user_dir), &[]).unwrap();
213        assert!(paths.contains(&user_dir));
214    }
215
216    #[test]
217    fn resolve_extra_dirs_come_last() {
218        let tmp = tempfile::tempdir().unwrap();
219        let extra = tmp.path().to_path_buf();
220        let paths =
221            resolve_agent_paths(&[], Some(&PathBuf::from("")), std::slice::from_ref(&extra))
222                .unwrap();
223        assert_eq!(paths.last().unwrap(), &extra);
224    }
225
226    #[test]
227    fn resolve_deduplicates_same_canonical_path() {
228        let tmp = tempfile::tempdir().unwrap();
229        let dir = tmp.path().to_path_buf();
230        // Same directory added twice: once as explicit user dir, once as extra
231        let paths = resolve_agent_paths(&[], Some(&dir), std::slice::from_ref(&dir)).unwrap();
232        let count = paths.iter().filter(|p| *p == &dir).count();
233        assert_eq!(count, 1, "duplicate paths should be removed");
234    }
235
236    #[test]
237    fn scope_label_cli() {
238        let tmp = tempfile::tempdir().unwrap();
239        let cli_dir = tmp.path().to_path_buf();
240        let def_path = cli_dir.join("my-agent.md");
241        let label = scope_label(&def_path, &[cli_dir], None, &[]);
242        assert_eq!(label, "cli");
243    }
244
245    #[test]
246    fn scope_label_project() {
247        let def_path = PathBuf::from(".zeph/agents/my-agent.md");
248        let label = scope_label(&def_path, &[], None, &[]);
249        assert_eq!(label, "project");
250    }
251
252    #[test]
253    fn scope_label_extra() {
254        let tmp = tempfile::tempdir().unwrap();
255        let extra_dir = tmp.path().to_path_buf();
256        let def_path = extra_dir.join("my-agent.md");
257        let label = scope_label(&def_path, &[], Some(&PathBuf::from("")), &[extra_dir]);
258        assert_eq!(label, "extra");
259    }
260
261    #[test]
262    fn scope_label_user() {
263        let tmp = tempfile::tempdir().unwrap();
264        let user_dir = tmp.path().to_path_buf();
265        let def_path = user_dir.join("my-agent.md");
266        let label = scope_label(&def_path, &[], Some(&user_dir), &[]);
267        assert_eq!(label, "user");
268    }
269
270    #[test]
271    fn scope_label_unknown_when_no_match() {
272        let tmp = tempfile::tempdir().unwrap();
273        let def_path = tmp.path().join("my-agent.md");
274        let label = scope_label(&def_path, &[], Some(&PathBuf::from("")), &[]);
275        assert_eq!(label, "unknown");
276    }
277
278    #[test]
279    fn resolve_user_dir_none_falls_back_to_platform_default() {
280        // When config_user_dir is None, platform default should be attempted.
281        // We cannot guarantee dirs::config_dir() returns Some on all CI machines,
282        // but we can verify that at minimum the project-level path is present.
283        let paths = resolve_agent_paths(&[], None, &[]).unwrap();
284        assert!(paths.iter().any(|p| p == &PathBuf::from(".zeph/agents")));
285    }
286
287    #[test]
288    fn resolve_priority_order_cli_first_then_project() {
289        let tmp = tempfile::tempdir().unwrap();
290        let cli_dir = tmp.path().to_path_buf();
291        let paths = resolve_agent_paths(
292            std::slice::from_ref(&cli_dir),
293            Some(&PathBuf::from("")),
294            &[],
295        )
296        .unwrap();
297        // CLI must be index 0, project-level must follow
298        assert_eq!(paths[0], cli_dir);
299        assert_eq!(paths[1], PathBuf::from(".zeph/agents"));
300    }
301
302    #[test]
303    fn resolve_extra_dirs_after_user_dir() {
304        let tmp1 = tempfile::tempdir().unwrap();
305        let tmp2 = tempfile::tempdir().unwrap();
306        let user_dir = tmp1.path().to_path_buf();
307        let extra_dir = tmp2.path().to_path_buf();
308        let paths =
309            resolve_agent_paths(&[], Some(&user_dir), std::slice::from_ref(&extra_dir)).unwrap();
310        let user_pos = paths.iter().position(|p| p == &user_dir).unwrap();
311        let extra_pos = paths.iter().position(|p| p == &extra_dir).unwrap();
312        assert!(user_pos < extra_pos, "user dir must come before extra dirs");
313    }
314}