1use 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
11pub(crate) const RECURSIVE_SKIP_DIRS: &[&str] =
13 &["node_modules", ".git", "dist", "build", "__pycache__"];
14const 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 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 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 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#[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 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 pub(crate) fn generated_root() -> Self {
96 let mut discovery = Self::empty();
97 report_generated(&mut discovery.diagnostics, [""]);
98 discovery
99 }
100}
101
102#[derive(Debug, Clone, Default)]
106pub(crate) struct Boundary {
107 plugin_dir: Option<String>,
110}
111
112impl Boundary {
113 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum Excluded {
193 PluginDir,
195 Generated(String),
197}
198
199pub 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
217pub(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
242pub(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
270pub 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
281pub(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
358pub(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
406pub(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
426fn 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
439pub(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
449pub(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}