Skip to main content

mars_agents/discover/
mod.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::{Component, Path, PathBuf};
3
4use serde_json::Value;
5
6use crate::error::MarsError;
7use crate::lock::{ItemId, ItemKind};
8use crate::types::ItemName;
9
10const RECURSIVE_SKIP_DIRS: &[&str] = &["node_modules", ".git", "dist", "build", "__pycache__"];
11const PLUGIN_MANIFESTS: &[&str] = &[
12    ".claude-plugin/plugin.json",
13    ".claude-plugin/marketplace.json",
14];
15const MAX_FALLBACK_DEPTH: usize = 5;
16const MAX_CONTAINER_ROOT_DEPTH: usize = 2;
17const MAX_HEURISTIC_FS_DEPTH: usize = MAX_FALLBACK_DEPTH + MAX_CONTAINER_ROOT_DEPTH;
18const SKILL_CONTAINER_ROOTS: &[&str] = &[
19    "skills",
20    "skills/.curated",
21    "skills/.experimental",
22    "skills/.system",
23    ".claude/skills",
24    ".codex/skills",
25    ".agents/skills",
26];
27const AGENT_CONTAINER_ROOTS: &[&str] = &[
28    "agents",
29    ".claude/agents",
30    ".codex/agents",
31    ".agents/agents",
32];
33const MANIFEST_SKILL_KEYS: &[&str] = &["skills", "skill_paths", "skillPaths"];
34const MANIFEST_AGENT_KEYS: &[&str] = &["agents", "agent_paths", "agentPaths"];
35
36/// An item discovered in a source tree by filesystem convention.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct DiscoveredItem {
39    pub id: ItemId,
40    /// Path within source tree (relative), e.g. "agents/coder.md" or "skills/planning".
41    pub source_path: PathBuf,
42}
43
44/// Discover items by conventional mars package layout.
45pub fn discover_source(
46    tree_path: &Path,
47    fallback_name: Option<&str>,
48) -> Result<Vec<DiscoveredItem>, MarsError> {
49    let mut items = Vec::new();
50
51    scan_agent_dir(
52        tree_path,
53        Path::new("agents"),
54        &mut items,
55        &mut HashSet::new(),
56    )?;
57    scan_skill_dir(
58        tree_path,
59        Path::new("skills"),
60        &mut items,
61        &mut HashSet::new(),
62    )?;
63
64    if items.is_empty() && tree_path.join("SKILL.md").is_file() {
65        let name = fallback_name
66            .map(String::from)
67            .unwrap_or_else(|| package_basename(tree_path));
68        items.push(DiscoveredItem {
69            id: ItemId {
70                kind: ItemKind::Skill,
71                name: ItemName::from(name),
72            },
73            source_path: PathBuf::from("."),
74        });
75    }
76
77    sort_items(&mut items);
78    Ok(items)
79}
80
81/// Discover items using the Vercel-compatible fallback walk.
82pub fn discover_fallback(
83    package_root: &Path,
84    source_name: Option<&str>,
85) -> Result<Vec<DiscoveredItem>, MarsError> {
86    if package_root.join("SKILL.md").is_file() {
87        return Ok(vec![DiscoveredItem {
88            id: ItemId {
89                kind: ItemKind::Skill,
90                name: ItemName::from(package_basename(package_root)),
91            },
92            source_path: PathBuf::from("."),
93        }]);
94    }
95
96    let source_name = source_name.unwrap_or("unknown-source");
97    let explicit_items = discover_manifest_declared_items(package_root, source_name)?;
98    if !explicit_items.is_empty() {
99        return finalize_items(source_name, explicit_items);
100    }
101
102    let heuristic_items = discover_heuristic_layer_items(package_root)?;
103    finalize_items(source_name, heuristic_items)
104}
105
106/// Shared dispatcher for rooted-source discovery.
107pub fn discover_resolved_source(
108    package_root: &Path,
109    source_name: Option<&str>,
110) -> Result<Vec<DiscoveredItem>, MarsError> {
111    if package_root.join("mars.toml").is_file() {
112        discover_source(package_root, source_name)
113    } else {
114        discover_fallback(package_root, source_name)
115    }
116}
117
118fn scan_skill_dir(
119    package_root: &Path,
120    relative_root: &Path,
121    items: &mut Vec<DiscoveredItem>,
122    visited: &mut HashSet<PathBuf>,
123) -> Result<(), MarsError> {
124    let dir = package_root.join(relative_root);
125    if !dir.is_dir() {
126        return Ok(());
127    }
128
129    for path in read_dir_paths_sorted(&dir)? {
130        if !path.is_dir() {
131            continue;
132        }
133        if let Some(name) = path.file_name().and_then(|name| name.to_str())
134            && name.starts_with('.')
135        {
136            continue;
137        }
138        let rel = relative_to(package_root, &path)?;
139        register_skill_dir(package_root, &rel, items, visited)?;
140    }
141
142    Ok(())
143}
144
145fn scan_agent_dir(
146    package_root: &Path,
147    relative_root: &Path,
148    items: &mut Vec<DiscoveredItem>,
149    visited: &mut HashSet<PathBuf>,
150) -> Result<(), MarsError> {
151    let dir = package_root.join(relative_root);
152    if !dir.is_dir() {
153        return Ok(());
154    }
155
156    for path in read_dir_paths_sorted(&dir)? {
157        if !path.is_file() {
158            continue;
159        }
160        if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
161            continue;
162        }
163        let rel = relative_to(package_root, &path)?;
164        register_agent_file(&rel, items, visited);
165    }
166
167    Ok(())
168}
169
170fn scan_manifest_declared_path(
171    package_root: &Path,
172    declared_path: &DeclaredPath,
173    items: &mut Vec<DiscoveredItem>,
174) -> Result<(), MarsError> {
175    let mut visited = HashSet::new();
176    let candidate = package_root.join(&declared_path.relative_path);
177    match declared_path.kind {
178        ItemKind::Skill => {
179            if candidate.join("SKILL.md").is_file() {
180                register_skill_dir(
181                    package_root,
182                    &declared_path.relative_path,
183                    items,
184                    &mut visited,
185                )?;
186            } else if matches_container_root(&declared_path.relative_path, SKILL_CONTAINER_ROOTS) {
187                scan_skill_dir(
188                    package_root,
189                    &declared_path.relative_path,
190                    items,
191                    &mut visited,
192                )?;
193            }
194        }
195        ItemKind::Agent => {
196            if candidate.is_file()
197                && candidate.extension().and_then(|ext| ext.to_str()) == Some("md")
198            {
199                register_agent_file(&declared_path.relative_path, items, &mut visited);
200            } else if matches_container_root(&declared_path.relative_path, AGENT_CONTAINER_ROOTS) {
201                scan_agent_dir(
202                    package_root,
203                    &declared_path.relative_path,
204                    items,
205                    &mut visited,
206                )?;
207            }
208        }
209    }
210
211    Ok(())
212}
213
214fn register_skill_dir(
215    package_root: &Path,
216    relative_path: &Path,
217    items: &mut Vec<DiscoveredItem>,
218    visited: &mut HashSet<PathBuf>,
219) -> Result<(), MarsError> {
220    let normalized = normalize_relative_path(relative_path);
221    if !visited.insert(normalized.clone()) {
222        return Ok(());
223    }
224    if !package_root.join(&normalized).join("SKILL.md").is_file() {
225        return Ok(());
226    }
227    let name = normalized
228        .file_name()
229        .and_then(|name| name.to_str())
230        .unwrap_or_default();
231    items.push(DiscoveredItem {
232        id: ItemId {
233            kind: ItemKind::Skill,
234            name: ItemName::from(name.to_string()),
235        },
236        source_path: normalized,
237    });
238    Ok(())
239}
240
241fn register_agent_file(
242    relative_path: &Path,
243    items: &mut Vec<DiscoveredItem>,
244    visited: &mut HashSet<PathBuf>,
245) {
246    let normalized = normalize_relative_path(relative_path);
247    if !visited.insert(normalized.clone()) {
248        return;
249    }
250    let name = normalized
251        .file_stem()
252        .and_then(|name| name.to_str())
253        .unwrap_or_default();
254    items.push(DiscoveredItem {
255        id: ItemId {
256            kind: ItemKind::Agent,
257            name: ItemName::from(name.to_string()),
258        },
259        source_path: normalized,
260    });
261}
262
263fn discover_manifest_declared_items(
264    package_root: &Path,
265    source_name: &str,
266) -> Result<Vec<DiscoveredItem>, MarsError> {
267    let mut items = Vec::new();
268    for declared_path in collect_manifest_declared_paths(package_root, source_name)? {
269        scan_manifest_declared_path(package_root, &declared_path, &mut items)?;
270    }
271    Ok(dedupe_items_by_path(items))
272}
273
274fn discover_heuristic_layer_items(package_root: &Path) -> Result<Vec<DiscoveredItem>, MarsError> {
275    let candidates = collect_heuristic_candidates(package_root)?;
276    let Some(min_layer) = candidates.iter().map(|candidate| candidate.layer).min() else {
277        return Ok(Vec::new());
278    };
279
280    let items = candidates
281        .into_iter()
282        .filter(|candidate| candidate.layer == min_layer)
283        .map(|candidate| candidate.item)
284        .collect();
285    let items = dedupe_items_by_path(items);
286    Ok(dedupe_items_by_name_first_seen(items))
287}
288
289fn collect_heuristic_candidates(package_root: &Path) -> Result<Vec<LayeredCandidate>, MarsError> {
290    let mut candidates = Vec::new();
291    let mut queue = VecDeque::from([(package_root.to_path_buf(), 0usize)]);
292
293    while let Some((base_dir, depth)) = queue.pop_front() {
294        if depth > MAX_HEURISTIC_FS_DEPTH {
295            continue;
296        }
297
298        let base_rel = if base_dir == package_root {
299            PathBuf::new()
300        } else {
301            relative_to(package_root, &base_dir)?
302        };
303        collect_heuristic_candidates_at_base(package_root, &base_rel, &mut candidates)?;
304
305        if depth == MAX_HEURISTIC_FS_DEPTH {
306            continue;
307        }
308
309        for path in read_dir_paths_sorted(&base_dir)? {
310            if !path.is_dir() {
311                continue;
312            }
313            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
314                continue;
315            };
316            if RECURSIVE_SKIP_DIRS.contains(&name) {
317                continue;
318            }
319            queue.push_back((path, depth + 1));
320        }
321    }
322
323    Ok(candidates)
324}
325
326fn collect_heuristic_candidates_at_base(
327    package_root: &Path,
328    base_rel: &Path,
329    candidates: &mut Vec<LayeredCandidate>,
330) -> Result<(), MarsError> {
331    collect_direct_skill_children(package_root, base_rel, candidates)?;
332    for root in SKILL_CONTAINER_ROOTS {
333        collect_skill_container_candidates(
334            package_root,
335            &join_relative(base_rel, Path::new(root)),
336            candidates,
337        )?;
338    }
339    for root in AGENT_CONTAINER_ROOTS {
340        collect_agent_container_candidates(
341            package_root,
342            &join_relative(base_rel, Path::new(root)),
343            candidates,
344        )?;
345    }
346    Ok(())
347}
348
349fn collect_direct_skill_children(
350    package_root: &Path,
351    base_rel: &Path,
352    candidates: &mut Vec<LayeredCandidate>,
353) -> Result<(), MarsError> {
354    let base_dir = package_root.join(base_rel);
355    if !base_dir.is_dir() {
356        return Ok(());
357    }
358
359    for path in read_dir_paths_sorted(&base_dir)? {
360        if !path.is_dir() {
361            continue;
362        }
363        if let Some(name) = path.file_name().and_then(|name| name.to_str())
364            && name.starts_with('.')
365        {
366            continue;
367        }
368        let rel = relative_to(package_root, &path)?;
369        if !path.join("SKILL.md").is_file() {
370            continue;
371        }
372        candidates.push(LayeredCandidate::new(ItemKind::Skill, rel)?);
373    }
374
375    Ok(())
376}
377
378fn collect_skill_container_candidates(
379    package_root: &Path,
380    container_rel: &Path,
381    candidates: &mut Vec<LayeredCandidate>,
382) -> Result<(), MarsError> {
383    let container_dir = package_root.join(container_rel);
384    if !container_dir.is_dir() {
385        return Ok(());
386    }
387
388    for path in read_dir_paths_sorted(&container_dir)? {
389        if !path.is_dir() {
390            continue;
391        }
392        if let Some(name) = path.file_name().and_then(|name| name.to_str())
393            && name.starts_with('.')
394        {
395            continue;
396        }
397        if !path.join("SKILL.md").is_file() {
398            continue;
399        }
400        let rel = relative_to(package_root, &path)?;
401        candidates.push(LayeredCandidate::new(ItemKind::Skill, rel)?);
402    }
403
404    Ok(())
405}
406
407fn collect_agent_container_candidates(
408    package_root: &Path,
409    container_rel: &Path,
410    candidates: &mut Vec<LayeredCandidate>,
411) -> Result<(), MarsError> {
412    let container_dir = package_root.join(container_rel);
413    if !container_dir.is_dir() {
414        return Ok(());
415    }
416
417    for path in read_dir_paths_sorted(&container_dir)? {
418        if !path.is_file() {
419            continue;
420        }
421        if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
422            continue;
423        }
424        let rel = relative_to(package_root, &path)?;
425        candidates.push(LayeredCandidate::new(ItemKind::Agent, rel)?);
426    }
427
428    Ok(())
429}
430
431fn finalize_items(
432    source_name: &str,
433    mut items: Vec<DiscoveredItem>,
434) -> Result<Vec<DiscoveredItem>, MarsError> {
435    ensure_unique_names(source_name, &items)?;
436    sort_items(&mut items);
437    Ok(items)
438}
439
440fn dedupe_items_by_path(items: Vec<DiscoveredItem>) -> Vec<DiscoveredItem> {
441    let mut seen = HashSet::new();
442    let mut deduped = Vec::with_capacity(items.len());
443    for item in items {
444        if seen.insert(item.source_path.clone()) {
445            deduped.push(item);
446        }
447    }
448    deduped
449}
450
451fn dedupe_items_by_name_first_seen(items: Vec<DiscoveredItem>) -> Vec<DiscoveredItem> {
452    let mut seen = HashSet::new();
453    let mut deduped = Vec::with_capacity(items.len());
454    for item in items {
455        let key = (item.id.kind, item.id.name.to_string());
456        if seen.insert(key) {
457            deduped.push(item);
458        }
459    }
460    deduped
461}
462
463fn collect_manifest_declared_paths(
464    package_root: &Path,
465    source_name: &str,
466) -> Result<Vec<DeclaredPath>, MarsError> {
467    let mut declared = Vec::new();
468    for manifest in PLUGIN_MANIFESTS {
469        let path = package_root.join(manifest);
470        if !path.is_file() {
471            continue;
472        }
473        let content = std::fs::read_to_string(&path)?;
474        let json: Value = serde_json::from_str(&content).map_err(|e| MarsError::Source {
475            source_name: source_name.to_string(),
476            message: format!("failed to parse plugin manifest `{}`: {e}", path.display()),
477        })?;
478        declared.extend(parse_declared_paths(&json));
479    }
480
481    let mut resolved = Vec::new();
482    let mut seen = HashSet::new();
483    for raw in declared {
484        if !raw.raw_path.starts_with("./") {
485            continue;
486        }
487        let normalized = normalize_manifest_declared_path(&raw.raw_path).ok_or_else(|| {
488            MarsError::ManifestDeclaredPathEscape {
489                source_name: source_name.to_string(),
490                manifest_path: raw.raw_path.display().to_string(),
491                package_root: package_root.to_path_buf(),
492            }
493        })?;
494        let candidate = package_root.join(&normalized);
495        if !candidate.exists() {
496            return Err(MarsError::ManifestDeclaredPathMissing {
497                source_name: source_name.to_string(),
498                manifest_path: raw.raw_path.display().to_string(),
499                package_root: package_root.to_path_buf(),
500            });
501        }
502        let canonical =
503            candidate
504                .canonicalize()
505                .map_err(|_| MarsError::ManifestDeclaredPathMissing {
506                    source_name: source_name.to_string(),
507                    manifest_path: raw.raw_path.display().to_string(),
508                    package_root: package_root.to_path_buf(),
509                })?;
510        let canonical_root = package_root.canonicalize().map_err(|e| MarsError::Source {
511            source_name: source_name.to_string(),
512            message: format!(
513                "failed to canonicalize package root `{}`: {e}",
514                package_root.display()
515            ),
516        })?;
517        if !canonical.starts_with(&canonical_root) {
518            return Err(MarsError::ManifestDeclaredPathEscape {
519                source_name: source_name.to_string(),
520                manifest_path: raw.raw_path.display().to_string(),
521                package_root: package_root.to_path_buf(),
522            });
523        }
524        let rel = relative_to(package_root, &candidate)?;
525        if seen.insert((raw.kind, rel.clone())) {
526            resolved.push(DeclaredPath {
527                kind: raw.kind,
528                relative_path: rel,
529            });
530        }
531    }
532    Ok(resolved)
533}
534
535fn ensure_unique_names(source_name: &str, items: &[DiscoveredItem]) -> Result<(), MarsError> {
536    let mut seen: HashMap<(ItemKind, String), PathBuf> = HashMap::new();
537    for item in items {
538        let key = (item.id.kind, item.id.name.to_string());
539        if let Some(existing) = seen.insert(key.clone(), item.source_path.clone()) {
540            return Err(MarsError::DiscoveryCollision {
541                source_name: source_name.to_string(),
542                kind: item.id.kind.to_string(),
543                item_name: item.id.name.to_string(),
544                path_a: existing,
545                path_b: item.source_path.clone(),
546            });
547        }
548    }
549    Ok(())
550}
551
552fn relative_to(base: &Path, child: &Path) -> Result<PathBuf, MarsError> {
553    child
554        .strip_prefix(base)
555        .map(|path| path.to_path_buf())
556        .map_err(|_| MarsError::Source {
557            source_name: "discover".to_string(),
558            message: format!(
559                "path `{}` is not under package root `{}`",
560                child.display(),
561                base.display()
562            ),
563        })
564}
565
566fn normalize_relative_path(path: &Path) -> PathBuf {
567    let mut normalized = PathBuf::new();
568    for component in path.components() {
569        normalized.push(component.as_os_str());
570    }
571    normalized
572}
573
574fn normalize_manifest_declared_path(path: &Path) -> Option<PathBuf> {
575    let mut normalized = PathBuf::new();
576    for component in path.components() {
577        match component {
578            Component::CurDir => {}
579            Component::Normal(seg) => normalized.push(seg),
580            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
581        }
582    }
583    if normalized.as_os_str().is_empty() {
584        None
585    } else {
586        Some(normalized)
587    }
588}
589
590fn package_basename(path: &Path) -> String {
591    path.file_name()
592        .and_then(|name| name.to_str())
593        .filter(|name| !name.is_empty())
594        .unwrap_or("unknown-skill")
595        .to_string()
596}
597
598fn read_dir_paths_sorted(dir: &Path) -> Result<Vec<PathBuf>, MarsError> {
599    let mut paths = Vec::new();
600    for entry in std::fs::read_dir(dir)? {
601        paths.push(entry?.path());
602    }
603    paths.sort();
604    Ok(paths)
605}
606
607fn join_relative(base: &Path, suffix: &Path) -> PathBuf {
608    if base.as_os_str().is_empty() {
609        suffix.to_path_buf()
610    } else {
611        base.join(suffix)
612    }
613}
614
615fn matches_container_root(path: &Path, roots: &[&str]) -> bool {
616    roots.iter().any(|root| path == Path::new(root))
617}
618
619fn parse_declared_paths(json: &Value) -> Vec<RawDeclaredPath> {
620    let Some(map) = json.as_object() else {
621        return Vec::new();
622    };
623
624    let mut declared = Vec::new();
625    for key in MANIFEST_SKILL_KEYS {
626        if let Some(value) = map.get(*key) {
627            collect_declared_paths_from_value(ItemKind::Skill, value, &mut declared);
628        }
629    }
630    for key in MANIFEST_AGENT_KEYS {
631        if let Some(value) = map.get(*key) {
632            collect_declared_paths_from_value(ItemKind::Agent, value, &mut declared);
633        }
634    }
635    declared
636}
637
638fn collect_declared_paths_from_value(
639    kind: ItemKind,
640    value: &Value,
641    declared: &mut Vec<RawDeclaredPath>,
642) {
643    match value {
644        Value::String(path) => declared.push(RawDeclaredPath {
645            kind,
646            raw_path: PathBuf::from(path),
647        }),
648        Value::Array(values) => {
649            for child in values {
650                collect_declared_paths_from_value(kind, child, declared);
651            }
652        }
653        Value::Object(map) => {
654            if let Some(path) = map.get("path").and_then(|value| value.as_str()) {
655                declared.push(RawDeclaredPath {
656                    kind,
657                    raw_path: PathBuf::from(path),
658                });
659            }
660        }
661        _ => {}
662    }
663}
664
665fn split_segments(path: &Path) -> Vec<String> {
666    path.components()
667        .filter_map(|component| match component {
668            Component::Normal(segment) => Some(segment.to_string_lossy().into_owned()),
669            _ => None,
670        })
671        .collect()
672}
673
674fn logical_layer(kind: ItemKind, relative_path: &Path) -> Result<usize, MarsError> {
675    let segments = split_segments(relative_path);
676    let default_layer = match kind {
677        ItemKind::Skill => segments.len(),
678        ItemKind::Agent => usize::MAX,
679    };
680    let container_roots = match kind {
681        ItemKind::Skill => SKILL_CONTAINER_ROOTS,
682        ItemKind::Agent => AGENT_CONTAINER_ROOTS,
683    };
684
685    let mut layer = default_layer;
686    for root in container_roots {
687        let root_segments: Vec<&str> = root.split('/').collect();
688        if segments.len() < root_segments.len() + 1 {
689            continue;
690        }
691        let start = segments.len() - 1 - root_segments.len();
692        if segments[start..start + root_segments.len()]
693            .iter()
694            .map(String::as_str)
695            .eq(root_segments.iter().copied())
696        {
697            layer = layer.min(start + 1);
698        }
699    }
700
701    if layer == usize::MAX || layer == 0 || layer > MAX_FALLBACK_DEPTH {
702        return Err(MarsError::Source {
703            source_name: "discover".to_string(),
704            message: format!(
705                "invalid logical discovery layer for `{}`",
706                relative_path.display()
707            ),
708        });
709    }
710
711    Ok(layer)
712}
713
714#[derive(Debug, Clone)]
715struct RawDeclaredPath {
716    kind: ItemKind,
717    raw_path: PathBuf,
718}
719
720#[derive(Debug, Clone)]
721struct DeclaredPath {
722    kind: ItemKind,
723    relative_path: PathBuf,
724}
725
726#[derive(Debug, Clone)]
727struct LayeredCandidate {
728    item: DiscoveredItem,
729    layer: usize,
730}
731
732impl LayeredCandidate {
733    fn new(kind: ItemKind, source_path: PathBuf) -> Result<Self, MarsError> {
734        let item = match kind {
735            ItemKind::Skill => DiscoveredItem {
736                id: ItemId {
737                    kind,
738                    name: ItemName::from(
739                        source_path
740                            .file_name()
741                            .and_then(|name| name.to_str())
742                            .unwrap_or_default()
743                            .to_string(),
744                    ),
745                },
746                source_path: normalize_relative_path(&source_path),
747            },
748            ItemKind::Agent => DiscoveredItem {
749                id: ItemId {
750                    kind,
751                    name: ItemName::from(
752                        source_path
753                            .file_stem()
754                            .and_then(|name| name.to_str())
755                            .unwrap_or_default()
756                            .to_string(),
757                    ),
758                },
759                source_path: normalize_relative_path(&source_path),
760            },
761        };
762
763        Ok(Self {
764            layer: logical_layer(kind, &item.source_path)?,
765            item,
766        })
767    }
768}
769
770fn sort_items(items: &mut [DiscoveredItem]) {
771    items.sort_by(|a, b| {
772        a.id.cmp(&b.id)
773            .then_with(|| a.source_path.cmp(&b.source_path))
774    });
775}
776
777/// An installed item with parsed frontmatter metadata.
778#[derive(Debug, Clone)]
779pub struct InstalledItem {
780    pub id: ItemId,
781    /// Disk path (absolute) to the installed file/dir.
782    pub path: PathBuf,
783    /// Parsed frontmatter name (may differ from filename).
784    pub frontmatter_name: Option<String>,
785    /// Parsed frontmatter description.
786    pub description: Option<String>,
787    /// Skills referenced in frontmatter (agents only).
788    pub skill_refs: Vec<String>,
789}
790
791/// Result of scanning an installed managed root.
792#[derive(Debug, Clone)]
793pub struct InstalledState {
794    pub agents: Vec<InstalledItem>,
795    pub skills: Vec<InstalledItem>,
796}
797
798/// Discover all installed agents and skills in a managed root.
799pub fn discover_installed(root: &Path) -> Result<InstalledState, MarsError> {
800    let mut agents = Vec::new();
801    let mut skills = Vec::new();
802
803    let mut scratch = Vec::new();
804    let mut visited = HashSet::new();
805    scan_agent_dir(root, Path::new("agents"), &mut scratch, &mut visited)?;
806    for item in scratch.drain(..) {
807        let path = root.join(&item.source_path);
808        let (frontmatter_name, description, skill_refs) = parse_installed_frontmatter(&path);
809        agents.push(InstalledItem {
810            id: item.id,
811            path,
812            frontmatter_name,
813            description,
814            skill_refs,
815        });
816    }
817
818    scan_skill_dir(root, Path::new("skills"), &mut scratch, &mut HashSet::new())?;
819    for item in scratch.drain(..) {
820        let path = root.join(&item.source_path);
821        let skill_md = if item.source_path == Path::new(".") {
822            root.join("SKILL.md")
823        } else {
824            path.join("SKILL.md")
825        };
826        let (frontmatter_name, description, _) = parse_installed_frontmatter(&skill_md);
827        skills.push(InstalledItem {
828            id: item.id,
829            path,
830            frontmatter_name,
831            description,
832            skill_refs: Vec::new(),
833        });
834    }
835
836    sort_installed(&mut agents);
837    sort_installed(&mut skills);
838    Ok(InstalledState { agents, skills })
839}
840
841fn parse_installed_frontmatter(path: &Path) -> (Option<String>, Option<String>, Vec<String>) {
842    let content = match std::fs::read_to_string(path) {
843        Ok(c) => c,
844        Err(_) => return (None, None, Vec::new()),
845    };
846    match crate::frontmatter::parse(&content) {
847        Ok(fm) => {
848            let name = fm.name().map(str::to_owned);
849            let description = fm
850                .get("description")
851                .and_then(|value| value.as_str())
852                .map(str::to_owned);
853            (name, description, fm.skills())
854        }
855        Err(_) => (None, None, Vec::new()),
856    }
857}
858
859fn sort_installed(items: &mut [InstalledItem]) {
860    items.sort_by(|a, b| a.id.cmp(&b.id).then_with(|| a.path.cmp(&b.path)));
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866    use std::fs;
867    use tempfile::TempDir;
868
869    #[test]
870    fn conventional_discovery_finds_agents_and_skills() {
871        let dir = TempDir::new().unwrap();
872        fs::create_dir_all(dir.path().join("agents")).unwrap();
873        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
874        fs::write(dir.path().join("agents/coder.md"), "# coder").unwrap();
875        fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
876
877        let items = discover_source(dir.path(), None).unwrap();
878        assert_eq!(items.len(), 2);
879        assert!(
880            items
881                .iter()
882                .any(|item| item.source_path == Path::new("agents/coder.md"))
883        );
884        assert!(
885            items
886                .iter()
887                .any(|item| item.source_path == Path::new("skills/planning"))
888        );
889    }
890
891    #[test]
892    fn dispatcher_prefers_conventional_when_manifest_exists() {
893        let dir = TempDir::new().unwrap();
894        fs::write(
895            dir.path().join("mars.toml"),
896            "[package]\nname='demo'\nversion='0.1.0'\n",
897        )
898        .unwrap();
899        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
900        fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
901        fs::create_dir_all(dir.path().join("nested")).unwrap();
902        fs::write(dir.path().join("nested/SKILL.md"), "# nested").unwrap();
903
904        let items = discover_resolved_source(dir.path(), Some("demo")).unwrap();
905        assert_eq!(items.len(), 1);
906        assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
907    }
908
909    #[test]
910    fn fallback_short_circuits_root_skill() {
911        let dir = TempDir::new().unwrap();
912        fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
913        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
914        fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
915
916        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
917        assert_eq!(items.len(), 1);
918        assert_eq!(
919            items[0].id.name.as_str(),
920            dir.path().file_name().unwrap().to_string_lossy().as_ref()
921        );
922        assert_eq!(items[0].source_path, PathBuf::from("."));
923    }
924
925    #[test]
926    fn fallback_priority_scan_finds_skill_dirs_and_agents() {
927        let dir = TempDir::new().unwrap();
928        fs::create_dir_all(dir.path().join("skills/.experimental/find-skills")).unwrap();
929        fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
930        fs::write(
931            dir.path().join("skills/.experimental/find-skills/SKILL.md"),
932            "# skill",
933        )
934        .unwrap();
935        fs::write(dir.path().join(".claude/agents/reviewer.md"), "# agent").unwrap();
936
937        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
938        assert_eq!(items.len(), 2);
939        assert!(
940            items
941                .iter()
942                .any(|item| item.source_path == Path::new("skills/.experimental/find-skills"))
943        );
944        assert!(
945            items
946                .iter()
947                .any(|item| item.source_path == Path::new(".claude/agents/reviewer.md"))
948        );
949    }
950
951    #[test]
952    fn conventional_root_skill_does_not_override_conventional_items() {
953        let dir = TempDir::new().unwrap();
954        fs::write(
955            dir.path().join("mars.toml"),
956            "[package]\nname='demo'\nversion='0.1.0'\n",
957        )
958        .unwrap();
959        fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
960        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
961        fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
962
963        let items = discover_resolved_source(dir.path(), Some("demo")).unwrap();
964        assert_eq!(items.len(), 1);
965        assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
966    }
967
968    #[test]
969    fn fallback_manifest_paths_precede_heuristic_layers() {
970        let dir = TempDir::new().unwrap();
971        fs::create_dir_all(dir.path().join("top-level")).unwrap();
972        fs::create_dir_all(dir.path().join("plugins/deep-skill")).unwrap();
973        fs::write(dir.path().join("top-level/SKILL.md"), "# top").unwrap();
974        fs::write(dir.path().join("plugins/deep-skill/SKILL.md"), "# deep").unwrap();
975        fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
976        fs::write(
977            dir.path().join(".claude-plugin/plugin.json"),
978            r#"{"skills":[{"path":"./plugins/deep-skill"}]}"#,
979        )
980        .unwrap();
981
982        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
983        assert_eq!(items.len(), 1);
984        assert_eq!(items[0].source_path, PathBuf::from("plugins/deep-skill"));
985    }
986
987    #[test]
988    fn fallback_dedupes_overlapping_manifest_and_container_paths() {
989        let dir = TempDir::new().unwrap();
990        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
991        fs::write(dir.path().join("skills/planning/SKILL.md"), "# skill").unwrap();
992        fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
993        fs::write(
994            dir.path().join(".claude-plugin/plugin.json"),
995            r#"{"skills":[{"path":"./skills/planning"}]}"#,
996        )
997        .unwrap();
998
999        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1000        assert_eq!(items.len(), 1);
1001        assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
1002    }
1003
1004    #[test]
1005    fn fallback_manifest_declares_agent_paths_without_heuristic_json_walk() {
1006        let dir = TempDir::new().unwrap();
1007        fs::create_dir_all(dir.path().join("agents")).unwrap();
1008        fs::write(dir.path().join("agents/reviewer.md"), "# reviewer").unwrap();
1009        fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1010        fs::write(
1011            dir.path().join(".claude-plugin/plugin.json"),
1012            r#"{"agents":[{"path":"./agents/reviewer.md"}],"metadata":{"agents":[{"path":"./ignore.md"}]}}"#,
1013        )
1014        .unwrap();
1015
1016        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1017        assert_eq!(items.len(), 1);
1018        assert_eq!(items[0].source_path, PathBuf::from("agents/reviewer.md"));
1019    }
1020
1021    #[test]
1022    fn fallback_prefers_first_match_after_visit_dedupe() {
1023        let dir = TempDir::new().unwrap();
1024        fs::create_dir_all(dir.path().join("skills/plan")).unwrap();
1025        fs::create_dir_all(dir.path().join("plan")).unwrap();
1026        fs::write(dir.path().join("skills/plan/SKILL.md"), "# skill a").unwrap();
1027        fs::write(dir.path().join("plan/SKILL.md"), "# skill b").unwrap();
1028
1029        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1030        assert_eq!(items.len(), 1);
1031        assert_eq!(items[0].source_path, PathBuf::from("plan"));
1032    }
1033
1034    #[test]
1035    fn fallback_prefers_first_mirrored_skill_match() {
1036        let dir = TempDir::new().unwrap();
1037        fs::create_dir_all(dir.path().join("skills/caveman")).unwrap();
1038        fs::create_dir_all(dir.path().join("caveman")).unwrap();
1039        fs::write(dir.path().join("skills/caveman/SKILL.md"), "# same").unwrap();
1040        fs::write(dir.path().join("caveman/SKILL.md"), "# same").unwrap();
1041
1042        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1043        assert_eq!(items.len(), 1);
1044        assert_eq!(items[0].source_path, PathBuf::from("caveman"));
1045    }
1046
1047    #[test]
1048    fn fallback_manifest_declared_escape_is_rejected() {
1049        let dir = TempDir::new().unwrap();
1050        fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1051        fs::write(
1052            dir.path().join(".claude-plugin/plugin.json"),
1053            r#"{"skills":[{"path":"./../escape"}]}"#,
1054        )
1055        .unwrap();
1056
1057        let err = discover_fallback(dir.path(), Some("demo")).unwrap_err();
1058        assert!(matches!(err, MarsError::ManifestDeclaredPathEscape { .. }));
1059    }
1060
1061    #[test]
1062    fn fallback_selects_first_non_empty_logical_layer() {
1063        let dir = TempDir::new().unwrap();
1064        fs::create_dir_all(dir.path().join("top")).unwrap();
1065        fs::create_dir_all(dir.path().join("nested/deeper/skill")).unwrap();
1066        fs::write(dir.path().join("top/SKILL.md"), "# top").unwrap();
1067        fs::write(dir.path().join("nested/deeper/skill/SKILL.md"), "# skill").unwrap();
1068
1069        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1070        assert_eq!(items.len(), 1);
1071        assert_eq!(items[0].source_path, PathBuf::from("top"));
1072    }
1073
1074    #[test]
1075    fn fallback_groups_layout_variants_into_same_logical_layer() {
1076        let dir = TempDir::new().unwrap();
1077        fs::create_dir_all(dir.path().join("caveman")).unwrap();
1078        fs::create_dir_all(dir.path().join("skills/caveman")).unwrap();
1079        fs::write(dir.path().join("caveman/SKILL.md"), "# direct").unwrap();
1080        fs::write(dir.path().join("skills/caveman/SKILL.md"), "# container").unwrap();
1081
1082        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1083        assert_eq!(items.len(), 1);
1084        assert_eq!(items[0].source_path, PathBuf::from("caveman"));
1085    }
1086
1087    #[test]
1088    fn discover_installed_reads_frontmatter() {
1089        let dir = TempDir::new().unwrap();
1090        fs::create_dir_all(dir.path().join("agents")).unwrap();
1091        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
1092        fs::write(
1093            dir.path().join("agents/coder.md"),
1094            "---\nname: coder\ndescription: test\nskills: [planning]\n---\n# Coder\n",
1095        )
1096        .unwrap();
1097        fs::write(
1098            dir.path().join("skills/planning/SKILL.md"),
1099            "---\nname: planning\ndescription: test\n---\n# Planning\n",
1100        )
1101        .unwrap();
1102
1103        let state = discover_installed(dir.path()).unwrap();
1104        assert_eq!(state.agents.len(), 1);
1105        assert_eq!(state.skills.len(), 1);
1106        assert_eq!(state.agents[0].skill_refs, vec!["planning"]);
1107        assert_eq!(
1108            state.skills[0].frontmatter_name.as_deref(),
1109            Some("planning")
1110        );
1111    }
1112}