1use std::collections::HashSet;
11use std::path::PathBuf;
12
13pub fn resolve_agent_paths(
42 cli_agents: &[PathBuf],
43 config_user_dir: Option<&PathBuf>,
44 extra_dirs: &[PathBuf],
45) -> Result<Vec<PathBuf>, String> {
46 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 paths.extend(cli_agents.iter().cloned());
57
58 paths.push(PathBuf::from(".zeph/agents"));
60
61 if let Some(dir) = config_user_dir {
63 if !dir.as_os_str().is_empty() {
64 paths.push(dir.clone());
65 }
66 } else {
68 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 paths.extend(extra_dirs.iter().cloned());
78
79 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 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#[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 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 if def_path.starts_with(".zeph/agents") {
138 return "project";
139 }
140
141 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 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 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 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 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 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 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}