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 = dunce::canonicalize(&candidate).map_err(|_| {
503            MarsError::ManifestDeclaredPathMissing {
504                source_name: source_name.to_string(),
505                manifest_path: raw.raw_path.display().to_string(),
506                package_root: package_root.to_path_buf(),
507            }
508        })?;
509        let canonical_root = dunce::canonicalize(package_root).map_err(|e| MarsError::Source {
510            source_name: source_name.to_string(),
511            message: format!(
512                "failed to canonicalize package root `{}`: {e}",
513                package_root.display()
514            ),
515        })?;
516        if !canonical.starts_with(&canonical_root) {
517            return Err(MarsError::ManifestDeclaredPathEscape {
518                source_name: source_name.to_string(),
519                manifest_path: raw.raw_path.display().to_string(),
520                package_root: package_root.to_path_buf(),
521            });
522        }
523        let rel = relative_to(package_root, &candidate)?;
524        if seen.insert((raw.kind, rel.clone())) {
525            resolved.push(DeclaredPath {
526                kind: raw.kind,
527                relative_path: rel,
528            });
529        }
530    }
531    Ok(resolved)
532}
533
534fn ensure_unique_names(source_name: &str, items: &[DiscoveredItem]) -> Result<(), MarsError> {
535    let mut seen: HashMap<(ItemKind, String), PathBuf> = HashMap::new();
536    for item in items {
537        let key = (item.id.kind, item.id.name.to_string());
538        if let Some(existing) = seen.insert(key.clone(), item.source_path.clone()) {
539            return Err(MarsError::DiscoveryCollision {
540                source_name: source_name.to_string(),
541                kind: item.id.kind.to_string(),
542                item_name: item.id.name.to_string(),
543                path_a: existing,
544                path_b: item.source_path.clone(),
545            });
546        }
547    }
548    Ok(())
549}
550
551fn relative_to(base: &Path, child: &Path) -> Result<PathBuf, MarsError> {
552    child
553        .strip_prefix(base)
554        .map(|path| path.to_path_buf())
555        .map_err(|_| MarsError::Source {
556            source_name: "discover".to_string(),
557            message: format!(
558                "path `{}` is not under package root `{}`",
559                child.display(),
560                base.display()
561            ),
562        })
563}
564
565fn normalize_relative_path(path: &Path) -> PathBuf {
566    let mut normalized = PathBuf::new();
567    for component in path.components() {
568        normalized.push(component.as_os_str());
569    }
570    normalized
571}
572
573fn normalize_manifest_declared_path(path: &Path) -> Option<PathBuf> {
574    let mut normalized = PathBuf::new();
575    for component in path.components() {
576        match component {
577            Component::CurDir => {}
578            Component::Normal(seg) => normalized.push(seg),
579            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
580        }
581    }
582    if normalized.as_os_str().is_empty() {
583        None
584    } else {
585        Some(normalized)
586    }
587}
588
589fn package_basename(path: &Path) -> String {
590    path.file_name()
591        .and_then(|name| name.to_str())
592        .filter(|name| !name.is_empty())
593        .unwrap_or("unknown-skill")
594        .to_string()
595}
596
597fn read_dir_paths_sorted(dir: &Path) -> Result<Vec<PathBuf>, MarsError> {
598    let mut paths = Vec::new();
599    for entry in std::fs::read_dir(dir)? {
600        paths.push(entry?.path());
601    }
602    paths.sort();
603    Ok(paths)
604}
605
606fn join_relative(base: &Path, suffix: &Path) -> PathBuf {
607    if base.as_os_str().is_empty() {
608        suffix.to_path_buf()
609    } else {
610        base.join(suffix)
611    }
612}
613
614fn matches_container_root(path: &Path, roots: &[&str]) -> bool {
615    roots.iter().any(|root| path == Path::new(root))
616}
617
618fn parse_declared_paths(json: &Value) -> Vec<RawDeclaredPath> {
619    let Some(map) = json.as_object() else {
620        return Vec::new();
621    };
622
623    let mut declared = Vec::new();
624    for key in MANIFEST_SKILL_KEYS {
625        if let Some(value) = map.get(*key) {
626            collect_declared_paths_from_value(ItemKind::Skill, value, &mut declared);
627        }
628    }
629    for key in MANIFEST_AGENT_KEYS {
630        if let Some(value) = map.get(*key) {
631            collect_declared_paths_from_value(ItemKind::Agent, value, &mut declared);
632        }
633    }
634    declared
635}
636
637fn collect_declared_paths_from_value(
638    kind: ItemKind,
639    value: &Value,
640    declared: &mut Vec<RawDeclaredPath>,
641) {
642    match value {
643        Value::String(path) => declared.push(RawDeclaredPath {
644            kind,
645            raw_path: PathBuf::from(path),
646        }),
647        Value::Array(values) => {
648            for child in values {
649                collect_declared_paths_from_value(kind, child, declared);
650            }
651        }
652        Value::Object(map) => {
653            if let Some(path) = map.get("path").and_then(|value| value.as_str()) {
654                declared.push(RawDeclaredPath {
655                    kind,
656                    raw_path: PathBuf::from(path),
657                });
658            }
659        }
660        _ => {}
661    }
662}
663
664fn split_segments(path: &Path) -> Vec<String> {
665    path.components()
666        .filter_map(|component| match component {
667            Component::Normal(segment) => Some(segment.to_string_lossy().into_owned()),
668            _ => None,
669        })
670        .collect()
671}
672
673fn logical_layer(kind: ItemKind, relative_path: &Path) -> Result<usize, MarsError> {
674    let segments = split_segments(relative_path);
675    let default_layer = match kind {
676        ItemKind::Skill => segments.len(),
677        ItemKind::Agent => usize::MAX,
678    };
679    let container_roots = match kind {
680        ItemKind::Skill => SKILL_CONTAINER_ROOTS,
681        ItemKind::Agent => AGENT_CONTAINER_ROOTS,
682    };
683
684    let mut layer = default_layer;
685    for root in container_roots {
686        let root_segments: Vec<&str> = root.split('/').collect();
687        if segments.len() < root_segments.len() + 1 {
688            continue;
689        }
690        let start = segments.len() - 1 - root_segments.len();
691        if segments[start..start + root_segments.len()]
692            .iter()
693            .map(String::as_str)
694            .eq(root_segments.iter().copied())
695        {
696            layer = layer.min(start + 1);
697        }
698    }
699
700    if layer == usize::MAX || layer == 0 || layer > MAX_FALLBACK_DEPTH {
701        return Err(MarsError::Source {
702            source_name: "discover".to_string(),
703            message: format!(
704                "invalid logical discovery layer for `{}`",
705                relative_path.display()
706            ),
707        });
708    }
709
710    Ok(layer)
711}
712
713#[derive(Debug, Clone)]
714struct RawDeclaredPath {
715    kind: ItemKind,
716    raw_path: PathBuf,
717}
718
719#[derive(Debug, Clone)]
720struct DeclaredPath {
721    kind: ItemKind,
722    relative_path: PathBuf,
723}
724
725#[derive(Debug, Clone)]
726struct LayeredCandidate {
727    item: DiscoveredItem,
728    layer: usize,
729}
730
731impl LayeredCandidate {
732    fn new(kind: ItemKind, source_path: PathBuf) -> Result<Self, MarsError> {
733        let item = match kind {
734            ItemKind::Skill => DiscoveredItem {
735                id: ItemId {
736                    kind,
737                    name: ItemName::from(
738                        source_path
739                            .file_name()
740                            .and_then(|name| name.to_str())
741                            .unwrap_or_default()
742                            .to_string(),
743                    ),
744                },
745                source_path: normalize_relative_path(&source_path),
746            },
747            ItemKind::Agent => DiscoveredItem {
748                id: ItemId {
749                    kind,
750                    name: ItemName::from(
751                        source_path
752                            .file_stem()
753                            .and_then(|name| name.to_str())
754                            .unwrap_or_default()
755                            .to_string(),
756                    ),
757                },
758                source_path: normalize_relative_path(&source_path),
759            },
760        };
761
762        Ok(Self {
763            layer: logical_layer(kind, &item.source_path)?,
764            item,
765        })
766    }
767}
768
769fn sort_items(items: &mut [DiscoveredItem]) {
770    items.sort_by(|a, b| {
771        a.id.cmp(&b.id)
772            .then_with(|| a.source_path.cmp(&b.source_path))
773    });
774}
775
776/// An installed item with parsed frontmatter metadata.
777#[derive(Debug, Clone)]
778pub struct InstalledItem {
779    pub id: ItemId,
780    /// Disk path (absolute) to the installed file/dir.
781    pub path: PathBuf,
782    /// Parsed frontmatter name (may differ from filename).
783    pub frontmatter_name: Option<String>,
784    /// Parsed frontmatter description.
785    pub description: Option<String>,
786    /// Skills referenced in frontmatter (agents only).
787    pub skill_refs: Vec<String>,
788}
789
790/// Result of scanning an installed managed root.
791#[derive(Debug, Clone)]
792pub struct InstalledState {
793    pub agents: Vec<InstalledItem>,
794    pub skills: Vec<InstalledItem>,
795}
796
797/// Discover all installed agents and skills in a managed root.
798pub fn discover_installed(root: &Path) -> Result<InstalledState, MarsError> {
799    let mut agents = Vec::new();
800    let mut skills = Vec::new();
801
802    let mut scratch = Vec::new();
803    let mut visited = HashSet::new();
804    scan_agent_dir(root, Path::new("agents"), &mut scratch, &mut visited)?;
805    for item in scratch.drain(..) {
806        let path = root.join(&item.source_path);
807        let (frontmatter_name, description, skill_refs) = parse_installed_frontmatter(&path);
808        agents.push(InstalledItem {
809            id: item.id,
810            path,
811            frontmatter_name,
812            description,
813            skill_refs,
814        });
815    }
816
817    scan_skill_dir(root, Path::new("skills"), &mut scratch, &mut HashSet::new())?;
818    for item in scratch.drain(..) {
819        let path = root.join(&item.source_path);
820        let skill_md = if item.source_path == Path::new(".") {
821            root.join("SKILL.md")
822        } else {
823            path.join("SKILL.md")
824        };
825        let (frontmatter_name, description, _) = parse_installed_frontmatter(&skill_md);
826        skills.push(InstalledItem {
827            id: item.id,
828            path,
829            frontmatter_name,
830            description,
831            skill_refs: Vec::new(),
832        });
833    }
834
835    sort_installed(&mut agents);
836    sort_installed(&mut skills);
837    Ok(InstalledState { agents, skills })
838}
839
840fn parse_installed_frontmatter(path: &Path) -> (Option<String>, Option<String>, Vec<String>) {
841    let content = match std::fs::read_to_string(path) {
842        Ok(c) => c,
843        Err(_) => return (None, None, Vec::new()),
844    };
845    match crate::frontmatter::parse(&content) {
846        Ok(fm) => {
847            let name = fm.name().map(str::to_owned);
848            let description = fm
849                .get("description")
850                .and_then(|value| value.as_str())
851                .map(str::to_owned);
852            (name, description, fm.skills())
853        }
854        Err(_) => (None, None, Vec::new()),
855    }
856}
857
858fn sort_installed(items: &mut [InstalledItem]) {
859    items.sort_by(|a, b| a.id.cmp(&b.id).then_with(|| a.path.cmp(&b.path)));
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use std::fs;
866    use tempfile::TempDir;
867
868    #[test]
869    fn conventional_discovery_finds_agents_and_skills() {
870        let dir = TempDir::new().unwrap();
871        fs::create_dir_all(dir.path().join("agents")).unwrap();
872        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
873        fs::write(dir.path().join("agents/coder.md"), "# coder").unwrap();
874        fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
875
876        let items = discover_source(dir.path(), None).unwrap();
877        assert_eq!(items.len(), 2);
878        assert!(
879            items
880                .iter()
881                .any(|item| item.source_path == Path::new("agents/coder.md"))
882        );
883        assert!(
884            items
885                .iter()
886                .any(|item| item.source_path == Path::new("skills/planning"))
887        );
888    }
889
890    #[test]
891    fn dispatcher_prefers_conventional_when_manifest_exists() {
892        let dir = TempDir::new().unwrap();
893        fs::write(
894            dir.path().join("mars.toml"),
895            "[package]\nname='demo'\nversion='0.1.0'\n",
896        )
897        .unwrap();
898        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
899        fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
900        fs::create_dir_all(dir.path().join("nested")).unwrap();
901        fs::write(dir.path().join("nested/SKILL.md"), "# nested").unwrap();
902
903        let items = discover_resolved_source(dir.path(), Some("demo")).unwrap();
904        assert_eq!(items.len(), 1);
905        assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
906    }
907
908    #[test]
909    fn fallback_short_circuits_root_skill() {
910        let dir = TempDir::new().unwrap();
911        fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
912        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
913        fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
914
915        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
916        assert_eq!(items.len(), 1);
917        assert_eq!(
918            items[0].id.name.as_str(),
919            dir.path().file_name().unwrap().to_string_lossy().as_ref()
920        );
921        assert_eq!(items[0].source_path, PathBuf::from("."));
922    }
923
924    #[test]
925    fn fallback_priority_scan_finds_skill_dirs_and_agents() {
926        let dir = TempDir::new().unwrap();
927        fs::create_dir_all(dir.path().join("skills/.experimental/find-skills")).unwrap();
928        fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
929        fs::write(
930            dir.path().join("skills/.experimental/find-skills/SKILL.md"),
931            "# skill",
932        )
933        .unwrap();
934        fs::write(dir.path().join(".claude/agents/reviewer.md"), "# agent").unwrap();
935
936        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
937        assert_eq!(items.len(), 2);
938        assert!(
939            items
940                .iter()
941                .any(|item| item.source_path == Path::new("skills/.experimental/find-skills"))
942        );
943        assert!(
944            items
945                .iter()
946                .any(|item| item.source_path == Path::new(".claude/agents/reviewer.md"))
947        );
948    }
949
950    #[test]
951    fn conventional_root_skill_does_not_override_conventional_items() {
952        let dir = TempDir::new().unwrap();
953        fs::write(
954            dir.path().join("mars.toml"),
955            "[package]\nname='demo'\nversion='0.1.0'\n",
956        )
957        .unwrap();
958        fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
959        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
960        fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
961
962        let items = discover_resolved_source(dir.path(), Some("demo")).unwrap();
963        assert_eq!(items.len(), 1);
964        assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
965    }
966
967    #[test]
968    fn fallback_manifest_paths_precede_heuristic_layers() {
969        let dir = TempDir::new().unwrap();
970        fs::create_dir_all(dir.path().join("top-level")).unwrap();
971        fs::create_dir_all(dir.path().join("plugins/deep-skill")).unwrap();
972        fs::write(dir.path().join("top-level/SKILL.md"), "# top").unwrap();
973        fs::write(dir.path().join("plugins/deep-skill/SKILL.md"), "# deep").unwrap();
974        fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
975        fs::write(
976            dir.path().join(".claude-plugin/plugin.json"),
977            r#"{"skills":[{"path":"./plugins/deep-skill"}]}"#,
978        )
979        .unwrap();
980
981        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
982        assert_eq!(items.len(), 1);
983        assert_eq!(items[0].source_path, PathBuf::from("plugins/deep-skill"));
984    }
985
986    #[test]
987    fn fallback_dedupes_overlapping_manifest_and_container_paths() {
988        let dir = TempDir::new().unwrap();
989        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
990        fs::write(dir.path().join("skills/planning/SKILL.md"), "# skill").unwrap();
991        fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
992        fs::write(
993            dir.path().join(".claude-plugin/plugin.json"),
994            r#"{"skills":[{"path":"./skills/planning"}]}"#,
995        )
996        .unwrap();
997
998        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
999        assert_eq!(items.len(), 1);
1000        assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
1001    }
1002
1003    #[test]
1004    fn fallback_manifest_declares_agent_paths_without_heuristic_json_walk() {
1005        let dir = TempDir::new().unwrap();
1006        fs::create_dir_all(dir.path().join("agents")).unwrap();
1007        fs::write(dir.path().join("agents/reviewer.md"), "# reviewer").unwrap();
1008        fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1009        fs::write(
1010            dir.path().join(".claude-plugin/plugin.json"),
1011            r#"{"agents":[{"path":"./agents/reviewer.md"}],"metadata":{"agents":[{"path":"./ignore.md"}]}}"#,
1012        )
1013        .unwrap();
1014
1015        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1016        assert_eq!(items.len(), 1);
1017        assert_eq!(items[0].source_path, PathBuf::from("agents/reviewer.md"));
1018    }
1019
1020    #[test]
1021    fn fallback_prefers_first_match_after_visit_dedupe() {
1022        let dir = TempDir::new().unwrap();
1023        fs::create_dir_all(dir.path().join("skills/plan")).unwrap();
1024        fs::create_dir_all(dir.path().join("plan")).unwrap();
1025        fs::write(dir.path().join("skills/plan/SKILL.md"), "# skill a").unwrap();
1026        fs::write(dir.path().join("plan/SKILL.md"), "# skill b").unwrap();
1027
1028        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1029        assert_eq!(items.len(), 1);
1030        assert_eq!(items[0].source_path, PathBuf::from("plan"));
1031    }
1032
1033    #[test]
1034    fn fallback_prefers_first_mirrored_skill_match() {
1035        let dir = TempDir::new().unwrap();
1036        fs::create_dir_all(dir.path().join("skills/caveman")).unwrap();
1037        fs::create_dir_all(dir.path().join("caveman")).unwrap();
1038        fs::write(dir.path().join("skills/caveman/SKILL.md"), "# same").unwrap();
1039        fs::write(dir.path().join("caveman/SKILL.md"), "# same").unwrap();
1040
1041        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1042        assert_eq!(items.len(), 1);
1043        assert_eq!(items[0].source_path, PathBuf::from("caveman"));
1044    }
1045
1046    #[test]
1047    fn fallback_manifest_declared_escape_is_rejected() {
1048        let dir = TempDir::new().unwrap();
1049        fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1050        fs::write(
1051            dir.path().join(".claude-plugin/plugin.json"),
1052            r#"{"skills":[{"path":"./../escape"}]}"#,
1053        )
1054        .unwrap();
1055
1056        let err = discover_fallback(dir.path(), Some("demo")).unwrap_err();
1057        assert!(matches!(err, MarsError::ManifestDeclaredPathEscape { .. }));
1058    }
1059
1060    #[test]
1061    fn fallback_selects_first_non_empty_logical_layer() {
1062        let dir = TempDir::new().unwrap();
1063        fs::create_dir_all(dir.path().join("top")).unwrap();
1064        fs::create_dir_all(dir.path().join("nested/deeper/skill")).unwrap();
1065        fs::write(dir.path().join("top/SKILL.md"), "# top").unwrap();
1066        fs::write(dir.path().join("nested/deeper/skill/SKILL.md"), "# skill").unwrap();
1067
1068        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1069        assert_eq!(items.len(), 1);
1070        assert_eq!(items[0].source_path, PathBuf::from("top"));
1071    }
1072
1073    #[test]
1074    fn fallback_groups_layout_variants_into_same_logical_layer() {
1075        let dir = TempDir::new().unwrap();
1076        fs::create_dir_all(dir.path().join("caveman")).unwrap();
1077        fs::create_dir_all(dir.path().join("skills/caveman")).unwrap();
1078        fs::write(dir.path().join("caveman/SKILL.md"), "# direct").unwrap();
1079        fs::write(dir.path().join("skills/caveman/SKILL.md"), "# container").unwrap();
1080
1081        let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1082        assert_eq!(items.len(), 1);
1083        assert_eq!(items[0].source_path, PathBuf::from("caveman"));
1084    }
1085
1086    #[test]
1087    fn discover_installed_reads_frontmatter() {
1088        let dir = TempDir::new().unwrap();
1089        fs::create_dir_all(dir.path().join("agents")).unwrap();
1090        fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
1091        fs::write(
1092            dir.path().join("agents/coder.md"),
1093            "---\nname: coder\ndescription: test\nskills: [planning]\n---\n# Coder\n",
1094        )
1095        .unwrap();
1096        fs::write(
1097            dir.path().join("skills/planning/SKILL.md"),
1098            "---\nname: planning\ndescription: test\n---\n# Planning\n",
1099        )
1100        .unwrap();
1101
1102        let state = discover_installed(dir.path()).unwrap();
1103        assert_eq!(state.agents.len(), 1);
1104        assert_eq!(state.skills.len(), 1);
1105        assert_eq!(state.agents[0].skill_refs, vec!["planning"]);
1106        assert_eq!(
1107            state.skills[0].frontmatter_name.as_deref(),
1108            Some("planning")
1109        );
1110    }
1111}