1use std::path::{Path, PathBuf};
2
3use crate::error::MarsError;
4use crate::lock::{ItemId, ItemKind};
5use crate::types::ItemName;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct DiscoveredItem {
13 pub id: ItemId,
14 pub source_path: PathBuf,
16}
17
18pub fn discover_source(
29 tree_path: &Path,
30 fallback_name: Option<&str>,
31) -> Result<Vec<DiscoveredItem>, MarsError> {
32 let mut items = Vec::new();
33
34 let agents_dir = tree_path.join("agents");
36 if agents_dir.is_dir() {
37 for entry in std::fs::read_dir(&agents_dir)? {
38 let entry = entry?;
39 let file_name = entry.file_name();
40 let name_str = file_name.to_string_lossy();
41
42 if name_str.starts_with('.') {
44 continue;
45 }
46
47 let path = entry.path();
48 if path.is_file()
49 && let (Some(ext), Some(stem)) = (path.extension(), path.file_stem())
50 && ext == "md"
51 {
52 items.push(DiscoveredItem {
53 id: ItemId {
54 kind: ItemKind::Agent,
55 name: ItemName::from(stem.to_string_lossy().into_owned()),
56 },
57 source_path: PathBuf::from("agents").join(&file_name),
58 });
59 }
60 }
61 }
62
63 let skills_dir = tree_path.join("skills");
65 if skills_dir.is_dir() {
66 for entry in std::fs::read_dir(&skills_dir)? {
67 let entry = entry?;
68 let dir_name = entry.file_name();
69 let name_str = dir_name.to_string_lossy();
70
71 if name_str.starts_with('.') {
73 continue;
74 }
75
76 let path = entry.path();
77 if path.is_dir() && path.join("SKILL.md").is_file() {
78 items.push(DiscoveredItem {
79 id: ItemId {
80 kind: ItemKind::Skill,
81 name: ItemName::from(name_str.into_owned()),
82 },
83 source_path: PathBuf::from("skills").join(&dir_name),
84 });
85 }
86 }
87 }
88
89 if items.is_empty() && tree_path.join("SKILL.md").is_file() {
92 let name = fallback_name.map(String::from).unwrap_or_else(|| {
93 tree_path
94 .file_name()
95 .map(|n| n.to_string_lossy().into_owned())
96 .unwrap_or_else(|| "unknown-skill".to_string())
97 });
98 items.push(DiscoveredItem {
99 id: ItemId {
100 kind: ItemKind::Skill,
101 name: ItemName::from(name),
102 },
103 source_path: PathBuf::from("."),
104 });
105 }
106
107 items.sort_by(|a, b| a.id.cmp(&b.id));
110
111 Ok(items)
112}
113
114#[derive(Debug, Clone)]
116pub struct InstalledItem {
117 pub id: ItemId,
118 pub path: PathBuf,
120 pub frontmatter_name: Option<String>,
122 pub description: Option<String>,
124 pub skill_refs: Vec<String>,
126 pub is_symlink: bool,
128}
129
130#[derive(Debug, Clone)]
132pub struct InstalledState {
133 pub agents: Vec<InstalledItem>,
134 pub skills: Vec<InstalledItem>,
135}
136
137pub fn discover_installed(root: &Path) -> Result<InstalledState, MarsError> {
143 let mut agents = Vec::new();
144 let mut skills = Vec::new();
145
146 let agents_dir = root.join("agents");
148 if agents_dir.is_dir() {
149 for entry in std::fs::read_dir(&agents_dir)? {
150 let entry = entry?;
151 let path = entry.path();
152 let file_name = entry.file_name();
153 let name_str = file_name.to_string_lossy();
154
155 if name_str.starts_with('.') {
157 continue;
158 }
159
160 let is_symlink = path
161 .symlink_metadata()
162 .map(|m| m.file_type().is_symlink())
163 .unwrap_or(false);
164
165 if !path.is_file() {
167 continue;
168 }
169 let ext = path.extension().and_then(|e| e.to_str());
170 if ext != Some("md") {
171 continue;
172 }
173
174 let stem = path
175 .file_stem()
176 .map(|s| s.to_string_lossy().into_owned())
177 .unwrap_or_default();
178
179 let (frontmatter_name, description, skill_refs) = parse_installed_frontmatter(&path);
180
181 agents.push(InstalledItem {
182 id: ItemId {
183 kind: ItemKind::Agent,
184 name: ItemName::from(stem),
185 },
186 path,
187 frontmatter_name,
188 description,
189 skill_refs,
190 is_symlink,
191 });
192 }
193 }
194
195 let skills_dir = root.join("skills");
197 if skills_dir.is_dir() {
198 for entry in std::fs::read_dir(&skills_dir)? {
199 let entry = entry?;
200 let path = entry.path();
201 let dir_name = entry.file_name();
202 let name_str = dir_name.to_string_lossy();
203
204 if name_str.starts_with('.') {
206 continue;
207 }
208
209 let is_symlink = path
210 .symlink_metadata()
211 .map(|m| m.file_type().is_symlink())
212 .unwrap_or(false);
213
214 if !path.is_dir() {
215 continue;
216 }
217
218 let skill_md = path.join("SKILL.md");
219 if !skill_md.is_file() {
220 continue;
221 }
222
223 let (frontmatter_name, description, _) = parse_installed_frontmatter(&skill_md);
224
225 skills.push(InstalledItem {
226 id: ItemId {
227 kind: ItemKind::Skill,
228 name: ItemName::from(name_str.into_owned()),
229 },
230 path,
231 frontmatter_name,
232 description,
233 skill_refs: Vec::new(),
234 is_symlink,
235 });
236 }
237 }
238
239 agents.sort_by(|a, b| a.id.cmp(&b.id));
241 skills.sort_by(|a, b| a.id.cmp(&b.id));
242
243 Ok(InstalledState { agents, skills })
244}
245
246fn parse_installed_frontmatter(path: &Path) -> (Option<String>, Option<String>, Vec<String>) {
249 let content = match std::fs::read_to_string(path) {
250 Ok(c) => c,
251 Err(_) => return (None, None, Vec::new()),
252 };
253 match crate::frontmatter::parse(&content) {
254 Ok(fm) => {
255 let name = fm.name().map(str::to_owned);
256 let description = fm
257 .get("description")
258 .and_then(|v| v.as_str())
259 .map(str::to_owned);
260 let skill_refs = fm.skills();
261 (name, description, skill_refs)
262 }
263 Err(_) => (None, None, Vec::new()),
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use std::fs;
271 use tempfile::TempDir;
272
273 fn make_tree(agents: &[&str], skills: &[&str]) -> TempDir {
275 let dir = TempDir::new().unwrap();
276
277 if !agents.is_empty() {
278 let agents_dir = dir.path().join("agents");
279 fs::create_dir_all(&agents_dir).unwrap();
280 for name in agents {
281 fs::write(agents_dir.join(name), "# agent content").unwrap();
282 }
283 }
284
285 if !skills.is_empty() {
286 let skills_dir = dir.path().join("skills");
287 fs::create_dir_all(&skills_dir).unwrap();
288 for name in skills {
289 let skill_dir = skills_dir.join(name);
290 fs::create_dir_all(&skill_dir).unwrap();
291 fs::write(skill_dir.join("SKILL.md"), "# skill content").unwrap();
292 }
293 }
294
295 dir
296 }
297
298 #[test]
299 fn discover_agents_only() {
300 let tree = make_tree(&["coder.md", "reviewer.md"], &[]);
301 let items = discover_source(tree.path(), None).unwrap();
302
303 assert_eq!(items.len(), 2);
304 assert_eq!(items[0].id.kind, ItemKind::Agent);
305 assert_eq!(items[0].id.name, "coder");
306 assert_eq!(items[0].source_path, PathBuf::from("agents/coder.md"));
307 assert_eq!(items[1].id.kind, ItemKind::Agent);
308 assert_eq!(items[1].id.name, "reviewer");
309 assert_eq!(items[1].source_path, PathBuf::from("agents/reviewer.md"));
310 }
311
312 #[test]
313 fn discover_skills_only() {
314 let tree = make_tree(&[], &["planning"]);
315 let items = discover_source(tree.path(), None).unwrap();
316
317 assert_eq!(items.len(), 1);
318 assert_eq!(items[0].id.kind, ItemKind::Skill);
319 assert_eq!(items[0].id.name, "planning");
320 assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
321 }
322
323 #[test]
324 fn discover_agents_and_skills() {
325 let tree = make_tree(&["coder.md", "reviewer.md"], &["planning", "review"]);
326 let items = discover_source(tree.path(), None).unwrap();
327
328 assert_eq!(items.len(), 4);
329 assert_eq!(items[0].id.name, "coder");
331 assert_eq!(items[0].id.kind, ItemKind::Agent);
332 assert_eq!(items[1].id.name, "reviewer");
333 assert_eq!(items[1].id.kind, ItemKind::Agent);
334 assert_eq!(items[2].id.name, "planning");
336 assert_eq!(items[2].id.kind, ItemKind::Skill);
337 assert_eq!(items[3].id.name, "review");
338 assert_eq!(items[3].id.kind, ItemKind::Skill);
339 }
340
341 #[test]
342 fn empty_tree_no_agents_or_skills_dir() {
343 let tree = TempDir::new().unwrap();
344 let items = discover_source(tree.path(), None).unwrap();
345 assert!(items.is_empty());
346 }
347
348 #[test]
349 fn empty_agents_dir() {
350 let tree = TempDir::new().unwrap();
351 fs::create_dir_all(tree.path().join("agents")).unwrap();
352 let items = discover_source(tree.path(), None).unwrap();
353 assert!(items.is_empty());
354 }
355
356 #[test]
357 fn non_md_files_in_agents_skipped() {
358 let tree = TempDir::new().unwrap();
359 let agents_dir = tree.path().join("agents");
360 fs::create_dir_all(&agents_dir).unwrap();
361 fs::write(agents_dir.join("coder.md"), "# agent").unwrap();
362 fs::write(agents_dir.join("notes.txt"), "not an agent").unwrap();
363 fs::write(agents_dir.join("config.yaml"), "not an agent").unwrap();
364 fs::write(agents_dir.join("README"), "not an agent").unwrap();
365
366 let items = discover_source(tree.path(), None).unwrap();
367 assert_eq!(items.len(), 1);
368 assert_eq!(items[0].id.name, "coder");
369 }
370
371 #[test]
372 fn skill_dir_without_skill_md_skipped() {
373 let tree = TempDir::new().unwrap();
374 let skills_dir = tree.path().join("skills");
375 let valid = skills_dir.join("planning");
376 let invalid = skills_dir.join("incomplete");
377 fs::create_dir_all(&valid).unwrap();
378 fs::create_dir_all(&invalid).unwrap();
379 fs::write(valid.join("SKILL.md"), "# skill").unwrap();
380 let items = discover_source(tree.path(), None).unwrap();
383 assert_eq!(items.len(), 1);
384 assert_eq!(items[0].id.name, "planning");
385 }
386
387 #[test]
388 fn hidden_files_skipped() {
389 let tree = TempDir::new().unwrap();
390 let agents_dir = tree.path().join("agents");
391 let skills_dir = tree.path().join("skills");
392 fs::create_dir_all(&agents_dir).unwrap();
393 fs::create_dir_all(&skills_dir).unwrap();
394
395 fs::write(agents_dir.join(".hidden.md"), "# hidden").unwrap();
397 fs::write(agents_dir.join("visible.md"), "# visible").unwrap();
399
400 let hidden_skill = skills_dir.join(".secret");
402 fs::create_dir_all(&hidden_skill).unwrap();
403 fs::write(hidden_skill.join("SKILL.md"), "# secret").unwrap();
404
405 let visible_skill = skills_dir.join("planning");
407 fs::create_dir_all(&visible_skill).unwrap();
408 fs::write(visible_skill.join("SKILL.md"), "# planning").unwrap();
409
410 let items = discover_source(tree.path(), None).unwrap();
411 assert_eq!(items.len(), 2);
412 assert_eq!(items[0].id.name, "visible");
413 assert_eq!(items[1].id.name, "planning");
414 }
415
416 #[test]
417 fn deterministic_ordering() {
418 let tree = make_tree(
419 &["zebra.md", "alpha.md", "middle.md"],
420 &["z-skill", "a-skill"],
421 );
422
423 let items1 = discover_source(tree.path(), None).unwrap();
424 let items2 = discover_source(tree.path(), None).unwrap();
425
426 assert_eq!(items1, items2);
428
429 let names: Vec<&str> = items1.iter().map(|i| i.id.name.as_str()).collect();
431 assert_eq!(
432 names,
433 vec!["alpha", "middle", "zebra", "a-skill", "z-skill"]
434 );
435 }
436
437 #[test]
438 fn subdirectories_in_agents_ignored() {
439 let tree = TempDir::new().unwrap();
440 let agents_dir = tree.path().join("agents");
441 let sub = agents_dir.join("subdir");
442 fs::create_dir_all(&sub).unwrap();
443 fs::write(sub.join("nested.md"), "# nested").unwrap();
444 fs::write(agents_dir.join("top.md"), "# top").unwrap();
445
446 let items = discover_source(tree.path(), None).unwrap();
447 assert_eq!(items.len(), 1);
448 assert_eq!(items[0].id.name, "top");
449 }
450
451 #[test]
452 fn skill_file_not_dir_ignored() {
453 let tree = TempDir::new().unwrap();
455 let skills_dir = tree.path().join("skills");
456 fs::create_dir_all(&skills_dir).unwrap();
457 fs::write(skills_dir.join("not-a-dir"), "# not a skill dir").unwrap();
458
459 let items = discover_source(tree.path(), None).unwrap();
460 assert!(items.is_empty());
461 }
462
463 #[test]
464 fn dunder_prefix_skills_discovered() {
465 let tree = make_tree(&[], &["__meridian-spawn", "planning"]);
467 let items = discover_source(tree.path(), None).unwrap();
468
469 assert_eq!(items.len(), 2);
470 assert_eq!(items[0].id.name, "__meridian-spawn");
471 assert_eq!(items[1].id.name, "planning");
472 }
473
474 #[test]
475 fn only_agents_dir_exists() {
476 let tree = TempDir::new().unwrap();
477 let agents_dir = tree.path().join("agents");
478 fs::create_dir_all(&agents_dir).unwrap();
479 fs::write(agents_dir.join("coder.md"), "# coder").unwrap();
480 let items = discover_source(tree.path(), None).unwrap();
483 assert_eq!(items.len(), 1);
484 assert_eq!(items[0].id.name, "coder");
485 }
486
487 #[test]
488 fn only_skills_dir_exists() {
489 let tree = TempDir::new().unwrap();
490 let skills_dir = tree.path().join("skills");
491 let planning = skills_dir.join("planning");
492 fs::create_dir_all(&planning).unwrap();
493 fs::write(planning.join("SKILL.md"), "# planning").unwrap();
494 let items = discover_source(tree.path(), None).unwrap();
497 assert_eq!(items.len(), 1);
498 assert_eq!(items[0].id.name, "planning");
499 }
500
501 #[test]
502 fn flat_skill_repo_discovered() {
503 let tree = TempDir::new().unwrap();
504 fs::write(tree.path().join("SKILL.md"), "# flat skill").unwrap();
505
506 let items = discover_source(tree.path(), None).unwrap();
507 assert_eq!(items.len(), 1);
508 assert_eq!(items[0].id.kind, ItemKind::Skill);
509 assert_eq!(items[0].source_path, PathBuf::from("."));
510 }
511
512 #[test]
513 fn flat_skill_with_resources() {
514 let tree = TempDir::new().unwrap();
515 fs::write(tree.path().join("SKILL.md"), "# flat skill").unwrap();
516 fs::create_dir_all(tree.path().join("resources")).unwrap();
517 fs::write(tree.path().join("resources/guide.md"), "# guide").unwrap();
518
519 let items = discover_source(tree.path(), None).unwrap();
520 assert_eq!(items.len(), 1);
521 assert_eq!(items[0].id.kind, ItemKind::Skill);
522 assert_eq!(items[0].source_path, PathBuf::from("."));
523 }
524
525 #[test]
526 fn flat_skill_uses_fallback_name() {
527 let tree = TempDir::new().unwrap();
528 fs::write(tree.path().join("SKILL.md"), "# flat skill").unwrap();
529
530 let items = discover_source(tree.path(), Some("my-skill")).unwrap();
531 assert_eq!(items.len(), 1);
532 assert_eq!(items[0].id.name, "my-skill");
533 }
534
535 #[test]
536 fn flat_skill_uses_dirname_when_no_fallback() {
537 let parent = TempDir::new().unwrap();
538 let tree = parent.path().join("demo-skill");
539 fs::create_dir_all(&tree).unwrap();
540 fs::write(tree.join("SKILL.md"), "# flat skill").unwrap();
541
542 let items = discover_source(&tree, None).unwrap();
543 assert_eq!(items.len(), 1);
544 assert_eq!(items[0].id.name, "demo-skill");
545 }
546
547 #[test]
548 fn nested_structure_ignores_root_skill_md() {
549 let tree = TempDir::new().unwrap();
550 fs::write(tree.path().join("SKILL.md"), "# root skill").unwrap();
551 let planning = tree.path().join("skills/planning");
552 fs::create_dir_all(&planning).unwrap();
553 fs::write(planning.join("SKILL.md"), "# nested skill").unwrap();
554
555 let items = discover_source(tree.path(), None).unwrap();
556 assert_eq!(items.len(), 1);
557 assert_eq!(items[0].id.kind, ItemKind::Skill);
558 assert_eq!(items[0].id.name, "planning");
559 assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
560 }
561
562 #[test]
565 fn discover_installed_finds_agents_and_skills() {
566 let root = TempDir::new().unwrap();
567 let agents_dir = root.path().join("agents");
568 let skills_dir = root.path().join("skills");
569 fs::create_dir_all(&agents_dir).unwrap();
570 fs::create_dir_all(skills_dir.join("planning")).unwrap();
571 fs::write(
572 agents_dir.join("coder.md"),
573 "---\nname: coder\n---\n# Agent",
574 )
575 .unwrap();
576 fs::write(
577 skills_dir.join("planning").join("SKILL.md"),
578 "---\nname: planning\n---\n# Skill",
579 )
580 .unwrap();
581
582 let state = discover_installed(root.path()).unwrap();
583 assert_eq!(state.agents.len(), 1);
584 assert_eq!(state.agents[0].id.name, "coder");
585 assert_eq!(state.skills.len(), 1);
586 assert_eq!(state.skills[0].id.name, "planning");
587 }
588
589 #[test]
590 fn discover_installed_parses_frontmatter() {
591 let root = TempDir::new().unwrap();
592 let agents_dir = root.path().join("agents");
593 fs::create_dir_all(&agents_dir).unwrap();
594 fs::write(
595 agents_dir.join("coder.md"),
596 "---\nname: my-coder\ndescription: A coding agent\nskills:\n - planning\n - review\n---\n# Agent",
597 )
598 .unwrap();
599
600 let state = discover_installed(root.path()).unwrap();
601 assert_eq!(state.agents.len(), 1);
602 let agent = &state.agents[0];
603 assert_eq!(agent.frontmatter_name.as_deref(), Some("my-coder"));
604 assert_eq!(agent.description.as_deref(), Some("A coding agent"));
605 assert_eq!(agent.skill_refs, vec!["planning", "review"]);
606 }
607
608 #[test]
609 fn discover_installed_handles_missing_frontmatter() {
610 let root = TempDir::new().unwrap();
611 let agents_dir = root.path().join("agents");
612 fs::create_dir_all(&agents_dir).unwrap();
613 fs::write(agents_dir.join("bare.md"), "# No frontmatter").unwrap();
614
615 let state = discover_installed(root.path()).unwrap();
616 assert_eq!(state.agents.len(), 1);
617 assert_eq!(state.agents[0].id.name, "bare");
618 assert!(state.agents[0].frontmatter_name.is_none());
619 assert!(state.agents[0].skill_refs.is_empty());
620 }
621
622 #[test]
623 fn discover_installed_handles_symlinks() {
624 let root = TempDir::new().unwrap();
625 let agents_dir = root.path().join("agents");
626 fs::create_dir_all(&agents_dir).unwrap();
627
628 let real = agents_dir.join("real.md");
630 fs::write(&real, "# Real agent").unwrap();
631
632 let link = agents_dir.join("linked.md");
634 std::os::unix::fs::symlink(&real, &link).unwrap();
635
636 let state = discover_installed(root.path()).unwrap();
637 assert_eq!(state.agents.len(), 2);
638
639 let linked = state
640 .agents
641 .iter()
642 .find(|a| a.id.name.as_str() == "linked")
643 .unwrap();
644 assert!(linked.is_symlink);
645
646 let real_agent = state
647 .agents
648 .iter()
649 .find(|a| a.id.name.as_str() == "real")
650 .unwrap();
651 assert!(!real_agent.is_symlink);
652 }
653}