Skip to main content

mars_schema/
discover.rs

1//! Definition selection over a host-independent package tree, and the one bounded directory
2//! walk every convention scan shares (the binary's bootstrap walk included).
3
4use std::collections::{BTreeMap, BTreeSet, VecDeque};
5
6use crate::diagnostic::{SchemaDiagnostic, Severity};
7use crate::item::DefinitionKind;
8use crate::plugin_metadata::{SENTINEL_FILE, is_valid_sentinel};
9use crate::tree::TreeSource;
10
11/// Never walked: high-volume output that often holds docs shaped like agents or skills.
12pub(crate) const RECURSIVE_SKIP_DIRS: &[&str] =
13    &["node_modules", ".git", "dist", "build", "__pycache__"];
14/// Real layouts like `vendor/pkg/.claude/skills/foo` and `packages/group/tooling/agents/foo.md`
15/// fit; deeper convention directories are skipped silently so a repository tree is not an
16/// unbounded discovery surface.
17const MAX_DISCOVERY_WALK_DEPTH: usize = 5;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub(crate) struct SelectedDefinition {
21    pub kind: DefinitionKind,
22    pub slug: String,
23    pub source_path: String,
24    /// Where the file lands in the schema v1 layout; equal to `source_path` unless the
25    /// dialect relocates.
26    pub output_path: String,
27    pub skill_root: Option<String>,
28}
29
30impl SelectedDefinition {
31    pub(crate) fn new(
32        kind: DefinitionKind,
33        slug: String,
34        source_path: String,
35        skill_root: Option<String>,
36    ) -> Self {
37        Self {
38            kind,
39            slug,
40            output_path: source_path.clone(),
41            source_path,
42            skill_root,
43        }
44    }
45
46    /// Relocate to `skills/<slug>/` or `agents/<slug>.md`.
47    pub(crate) fn relocated(mut self) -> Self {
48        self.output_path = match self.kind {
49            DefinitionKind::Agent => format!("agents/{}.md", self.slug),
50            DefinitionKind::Skill => format!("skills/{}/SKILL.md", self.slug),
51        };
52        self
53    }
54
55    /// The directory a skill's payload lands in; empty for a skill kept at the package root.
56    pub(crate) fn output_root(&self) -> &str {
57        self.output_path
58            .strip_suffix("SKILL.md")
59            .map_or("", |root| root.trim_end_matches('/'))
60    }
61}
62
63/// A file the dialect recognizes and does not import (Claude commands).
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub(crate) struct DroppedItem {
66    pub slug: String,
67    pub source_path: String,
68}
69
70#[derive(Debug, Clone)]
71struct LayeredDefinition {
72    definition: SelectedDefinition,
73    layer: usize,
74}
75
76pub(crate) struct Discovery {
77    pub definitions: Vec<SelectedDefinition>,
78    pub dropped: Vec<DroppedItem>,
79    /// What this root's walks never enter; payload enumeration applies it too.
80    pub boundary: Boundary,
81    pub diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
82}
83
84impl Discovery {
85    pub(crate) fn empty() -> Self {
86        Self {
87            definitions: Vec::new(),
88            dropped: Vec::new(),
89            boundary: Boundary::default(),
90            diagnostics: BTreeMap::new(),
91        }
92    }
93
94    /// A root that is itself a generated plugin tree holds nothing to read.
95    pub(crate) fn generated_root() -> Self {
96        let mut discovery = Self::empty();
97        report_generated(&mut discovery.diagnostics, [""]);
98        discovery
99    }
100}
101
102/// The directories no walk enters: hidden ones, the skip list, `[plugin].dir`, and any
103/// directory holding a valid generated-plugin marker. A walk checks each directory it is about
104/// to enter, so a marker is found only inside the bounded walk and never by a scan of the tree.
105#[derive(Debug, Clone, Default)]
106pub(crate) struct Boundary {
107    /// `[plugin].dir`, excluded by path even before any marker exists, so the package's own
108    /// committed output never adds noise.
109    plugin_dir: Option<String>,
110}
111
112impl Boundary {
113    /// Reads `[plugin].dir` (`plugin` when the table omits it) from the root's `mars.toml`. A
114    /// value that is not a package-relative path is ignored: config validation reports it.
115    pub(crate) fn of(source: &impl TreeSource) -> Self {
116        let plugin_dir = source
117            .read("mars.toml")
118            .and_then(|bytes| String::from_utf8(bytes).ok())
119            .and_then(|text| toml::from_str::<toml::Value>(&text).ok())
120            .and_then(|manifest| {
121                let plugin = manifest.get("plugin")?.as_table()?;
122                match plugin.get("dir") {
123                    None => Some("plugin".to_owned()),
124                    Some(dir) => dir.as_str().map(str::to_owned),
125                }
126            })
127            .map(|dir| dir.trim_end_matches('/').to_owned())
128            .filter(|dir| {
129                !dir.is_empty()
130                    && !dir.starts_with('/')
131                    && dir
132                        .split('/')
133                        .all(|part| !part.is_empty() && !matches!(part, "." | ".."))
134            });
135        Self { plugin_dir }
136    }
137
138    fn in_plugin_dir(&self, path: &str) -> bool {
139        self.plugin_dir
140            .as_deref()
141            .is_some_and(|dir| within(path, dir))
142    }
143
144    /// Whether `dir`, whose parents are already clear, may be entered. A directory holding a
145    /// marker is recorded in `generated`. Hidden directories are not searched for markers: a
146    /// dot-prefixed directory can be intentional authored content in a skill's payload.
147    pub(crate) fn admits(
148        &self,
149        source: &impl TreeSource,
150        dir: &str,
151        generated: &mut Vec<String>,
152    ) -> bool {
153        if self.in_plugin_dir(dir) {
154            return false;
155        }
156        if !is_hidden(dir) && has_marker(source, dir) {
157            generated.push(dir.to_owned());
158            return false;
159        }
160        true
161    }
162
163    /// Why `path` is not input, if it is not: for a path a manifest names, whose parents
164    /// nothing has checked.
165    fn exclusion(&self, source: &impl TreeSource, path: &str) -> Option<Excluded> {
166        if self.in_plugin_dir(path) {
167            return Some(Excluded::PluginDir);
168        }
169        enclosing_generated_dir(source, path).map(Excluded::Generated)
170    }
171
172    /// [`Boundary::exclusion`], recording a generated tree.
173    pub(crate) fn excludes(
174        &self,
175        source: &impl TreeSource,
176        path: &str,
177        generated: &mut Vec<String>,
178    ) -> bool {
179        match self.exclusion(source, path) {
180            Some(Excluded::Generated(dir)) => {
181                generated.push(dir);
182                true
183            }
184            Some(Excluded::PluginDir) => true,
185            None => false,
186        }
187    }
188}
189
190/// Why a path is not package input.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum Excluded {
193    /// It lies in the manifest's `[plugin].dir`, where Mars writes its own output.
194    PluginDir,
195    /// It lies in this generated plugin tree (the directory holding the marker).
196    Generated(String),
197}
198
199/// Why a package-relative path a manifest names is not input, if it is not.
200pub fn excluded(source: &impl TreeSource, path: &str) -> Option<Excluded> {
201    Boundary::of(source).exclusion(source, path)
202}
203
204fn within(path: &str, dir: &str) -> bool {
205    path == dir
206        || path
207            .strip_prefix(dir)
208            .is_some_and(|rest| rest.starts_with('/'))
209}
210
211fn has_marker(source: &impl TreeSource, dir: &str) -> bool {
212    source
213        .read(&join(dir, SENTINEL_FILE))
214        .is_some_and(|bytes| is_valid_sentinel(&bytes))
215}
216
217/// An info diagnostic per generated tree, once each, at its marker.
218pub(crate) fn report_generated<'a>(
219    diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
220    dirs: impl IntoIterator<Item = &'a str>,
221) {
222    for dir in dirs {
223        let found = diagnostics.entry(join(dir, SENTINEL_FILE)).or_default();
224        if found
225            .iter()
226            .any(|diagnostic| diagnostic.code == "generated-plugin-tree-skipped")
227        {
228            continue;
229        }
230        found.push(SchemaDiagnostic::new(
231            "generated-plugin-tree-skipped",
232            Severity::Info,
233            Vec::new(),
234            format!(
235                "skipping generated plugin tree at `{}` while reading authored package input",
236                if dir.is_empty() { "." } else { dir }
237            ),
238        ));
239    }
240}
241
242/// Breadth-first over the directories convention scans look in: the root, then each directory
243/// the boundary admits that is not hidden or on the skip list, up to five levels down.
244/// `visit` sees each directory and says whether to descend; it may record generated trees it
245/// meets in the list it is given. Returns every generated tree the walk skipped.
246pub(crate) fn walk(
247    source: &impl TreeSource,
248    boundary: &Boundary,
249    mut visit: impl FnMut(&str, &mut Vec<String>) -> bool,
250) -> Vec<String> {
251    let mut generated = Vec::new();
252    let mut queue = VecDeque::from([(String::new(), 0usize)]);
253    while let Some((dir, depth)) = queue.pop_front() {
254        if !visit(&dir, &mut generated) || depth == MAX_DISCOVERY_WALK_DEPTH {
255            continue;
256        }
257        for child in source.children(&dir) {
258            if !is_hidden(&child)
259                && !RECURSIVE_SKIP_DIRS.contains(&basename(&child))
260                && source.is_dir(&child)
261                && boundary.admits(source, &child, &mut generated)
262            {
263                queue.push_back((child, depth + 1));
264            }
265        }
266    }
267    generated
268}
269
270/// The directories of a root a convention scan visits, with the generated plugin trees the
271/// walk skipped (the caller reports them). This is the walk `mars-schema` selects Agents and
272/// skills with; a caller scanning for something else (the binary's bootstrap documents)
273/// reuses it rather than keeping its own skip list, depth, or marker rule.
274pub fn walk_dirs(source: &impl TreeSource, mut visit: impl FnMut(&str)) -> Vec<String> {
275    walk(source, &Boundary::of(source), |dir, _| {
276        visit(dir);
277        true
278    })
279}
280
281/// Mars's convention walk: `agents/` and `skills/` containers at the shallowest layer.
282/// Output paths equal source paths. The root is not a generated tree: `dispatch` checks first.
283pub(crate) fn discover(source: &impl TreeSource, fallback_name: Option<&str>) -> Discovery {
284    let boundary = Boundary::of(source);
285    let mut found = Vec::new();
286    let generated = walk(source, &boundary, |dir, generated| {
287        let layer = component_count(dir);
288        match basename(dir) {
289            "agents" => {
290                for path in source.children(dir) {
291                    if source.is_file(&path) && path.ends_with(".md") {
292                        found.push(LayeredDefinition {
293                            definition: SelectedDefinition::new(
294                                DefinitionKind::Agent,
295                                file_stem(&path).to_owned(),
296                                path,
297                                None,
298                            ),
299                            layer,
300                        });
301                    }
302                }
303            }
304            "skills" => {
305                for path in source.children(dir) {
306                    if !is_hidden(&path)
307                        && source.is_dir(&path)
308                        && boundary.admits(source, &path, generated)
309                    {
310                        let definition = join(&path, "SKILL.md");
311                        if source.is_file(&definition) {
312                            found.push(LayeredDefinition {
313                                definition: SelectedDefinition::new(
314                                    DefinitionKind::Skill,
315                                    basename(&path).to_owned(),
316                                    definition,
317                                    Some(path),
318                                ),
319                                layer,
320                            });
321                        }
322                    }
323                }
324            }
325            _ => {}
326        }
327        true
328    });
329
330    let had_convention_definition = !found.is_empty();
331    if let Some(shallowest) = found.iter().map(|definition| definition.layer).min() {
332        found.retain(|definition| definition.layer == shallowest);
333    }
334    if !had_convention_definition && source.is_file("SKILL.md") {
335        found.push(LayeredDefinition {
336            definition: SelectedDefinition::new(
337                DefinitionKind::Skill,
338                root_skill_slug(source, fallback_name),
339                "SKILL.md".to_string(),
340                Some(String::new()),
341            ),
342            layer: 0,
343        });
344    }
345
346    finish(
347        found
348            .into_iter()
349            .map(|definition| definition.definition)
350            .collect(),
351        Vec::new(),
352        boundary,
353        generated,
354        BTreeMap::new(),
355    )
356}
357
358/// Sort, drop duplicate files, and refuse two definitions of one kind sharing a slug.
359pub(crate) fn finish(
360    mut definitions: Vec<SelectedDefinition>,
361    dropped: Vec<DroppedItem>,
362    boundary: Boundary,
363    generated: Vec<String>,
364    mut diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
365) -> Discovery {
366    report_generated(&mut diagnostics, generated.iter().map(String::as_str));
367    definitions.sort_by(|left, right| left.source_path.cmp(&right.source_path));
368    definitions.dedup_by(|left, right| left.source_path == right.source_path);
369    definitions.sort_by(|left, right| {
370        left.kind
371            .cmp(&right.kind)
372            .then_with(|| left.slug.cmp(&right.slug))
373            .then_with(|| left.source_path.cmp(&right.source_path))
374    });
375    let mut names = BTreeMap::new();
376    for definition in &definitions {
377        let key = (definition.kind, definition.slug.clone());
378        if let Some(first) = names.insert(key, definition.source_path.clone()) {
379            diagnostics
380                .entry(definition.source_path.clone())
381                .or_default()
382                .push(SchemaDiagnostic::new(
383                    "import.duplicate-slug",
384                    Severity::Error,
385                    Vec::new(),
386                    format!(
387                        "{} slug `{}` is selected by both `{first}` and `{}`",
388                        match definition.kind {
389                            DefinitionKind::Agent => "agent",
390                            DefinitionKind::Skill => "skill",
391                        },
392                        definition.slug,
393                        definition.source_path
394                    ),
395                ));
396        }
397    }
398    Discovery {
399        definitions,
400        dropped,
401        boundary,
402        diagnostics,
403    }
404}
405
406/// A skill kept at the package root is named by its frontmatter `name` when that is a valid
407/// slug, else by the package, else `skill`.
408pub(crate) fn root_skill_slug(source: &impl TreeSource, fallback_name: Option<&str>) -> String {
409    source
410        .read("SKILL.md")
411        .and_then(|bytes| String::from_utf8(bytes).ok())
412        .and_then(|text| crate::frontmatter::Frontmatter::parse(&text).ok())
413        .and_then(|frontmatter| frontmatter.name().map(str::to_owned))
414        .filter(|name| is_slug(name))
415        .or_else(|| fallback_name.map(slugify).filter(|name| is_slug(name)))
416        .unwrap_or_else(|| "skill".to_owned())
417}
418
419fn is_slug(name: &str) -> bool {
420    !name.is_empty()
421        && name
422            .bytes()
423            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
424}
425
426/// A package name as a slug: `Foo Bar/baz` → `foo-bar-baz`.
427fn slugify(name: &str) -> String {
428    let mut slug = String::new();
429    for character in name.chars().flat_map(char::to_lowercase) {
430        if character.is_ascii_alphanumeric() {
431            slug.push(character);
432        } else if !slug.ends_with('-') {
433            slug.push('-');
434        }
435    }
436    slug.trim_matches('-').to_owned()
437}
438
439/// The generated plugin directory that holds `path` or any parent of it, if one does.
440pub(crate) fn enclosing_generated_dir(source: &impl TreeSource, path: &str) -> Option<String> {
441    let mut prefix = String::new();
442    let mut components = std::iter::once("").chain(path.split('/').filter(|part| !part.is_empty()));
443    components.find_map(|component| {
444        prefix = join(&prefix, component);
445        has_marker(source, &prefix).then(|| prefix.clone())
446    })
447}
448
449/// The files a skill carries, source path to output path. `discovery.definitions` is every
450/// discovered definition, so a nested skill is never its parent's payload whether or not it is
451/// selected; `selected` are the skills whose payload is wanted. A foreign repository's skill
452/// leaves out `.git` and `node_modules` (vendored trees, not the skill); a Mars package installs
453/// whatever its skill holds, as every Mars release has.
454pub(crate) fn payload_paths<'a>(
455    source: &impl TreeSource,
456    discovery: &Discovery,
457    foreign: bool,
458    selected: impl IntoIterator<Item = &'a SelectedDefinition>,
459    diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
460) -> Vec<(String, String)> {
461    let all = &discovery.definitions;
462    let mut generated = Vec::new();
463    let definition_paths = all
464        .iter()
465        .map(|definition| definition.source_path.as_str())
466        .collect::<BTreeSet<_>>();
467    let skill_roots = all
468        .iter()
469        .filter_map(|definition| definition.skill_root.as_deref())
470        .collect::<BTreeSet<_>>();
471    let mut payload = BTreeMap::new();
472    for definition in selected {
473        let Some(root) = definition.skill_root.as_deref() else {
474            continue;
475        };
476        let mut queue = VecDeque::from([root.to_owned()]);
477        while let Some(dir) = queue.pop_front() {
478            for child in source.children(&dir) {
479                if source.is_dir(&child) {
480                    if (child != root && skill_roots.contains(child.as_str()))
481                        || (foreign && matches!(basename(&child), ".git" | "node_modules"))
482                        || !discovery.boundary.admits(source, &child, &mut generated)
483                    {
484                        continue;
485                    }
486                    queue.push_back(child);
487                } else if !definition_paths.contains(child.as_str()) && child != "mars.toml" {
488                    if basename(&child) == "SKILL.md" {
489                        diagnostics
490                            .entry(child.clone())
491                            .or_default()
492                            .push(SchemaDiagnostic::new(
493                                "skill.nested-skill-md",
494                                Severity::Warning,
495                                Vec::new(),
496                                format!(
497                                    "`{child}` is inside skill `{}` and is kept as its payload, not imported as a skill",
498                                    definition.slug
499                                ),
500                            ));
501                    }
502                    let relative = if root.is_empty() {
503                        child.as_str()
504                    } else {
505                        &child[root.len() + 1..]
506                    };
507                    let output_root = definition.output_root();
508                    payload.insert(child.clone(), join(output_root, relative));
509                }
510            }
511        }
512    }
513    report_generated(diagnostics, generated.iter().map(String::as_str));
514    payload.into_iter().collect()
515}
516
517pub fn join(parent: &str, child: &str) -> String {
518    match (parent.is_empty(), child.is_empty()) {
519        (true, _) => child.to_owned(),
520        (_, true) => parent.to_owned(),
521        _ => format!("{parent}/{child}"),
522    }
523}
524
525pub(crate) fn basename(path: &str) -> &str {
526    path.rsplit('/').next().unwrap_or(path)
527}
528
529pub(crate) fn file_stem(path: &str) -> &str {
530    basename(path).strip_suffix(".md").unwrap_or(basename(path))
531}
532
533fn component_count(path: &str) -> usize {
534    if path.is_empty() {
535        0
536    } else {
537        path.split('/').count()
538    }
539}
540
541pub(crate) fn is_hidden(path: &str) -> bool {
542    basename(path).starts_with('.')
543}