Skip to main content

mars_agents/cli/
check.rs

1//! `mars check [PATH]` — validate a source package before publishing.
2//!
3//! Scans a directory as a mars source package
4//! (`agents/*.md`, `skills/*/SKILL.md`, or a flat root `SKILL.md`)
5//! and validates structure, frontmatter, and internal skill dependencies.
6//! No config or lock file needed — works on raw source directories.
7
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10
11use serde::Serialize;
12
13use crate::discover;
14use crate::error::MarsError;
15use crate::frontmatter;
16
17use super::output;
18
19/// Arguments for `mars check`.
20#[derive(Debug, clap::Args)]
21pub struct CheckArgs {
22    /// Directory to validate as a source package (default: current directory).
23    pub path: Option<PathBuf>,
24}
25
26#[derive(Debug, Serialize)]
27struct CheckReport {
28    agents: usize,
29    skills: usize,
30    errors: Vec<String>,
31    warnings: Vec<String>,
32}
33
34/// Run `mars check`.
35pub fn run(args: &CheckArgs, json: bool) -> Result<i32, MarsError> {
36    let base = match &args.path {
37        Some(p) => {
38            if p.is_absolute() {
39                p.clone()
40            } else {
41                std::env::current_dir()?.join(p)
42            }
43        }
44        None => std::env::current_dir()?,
45    };
46
47    if !base.is_dir() {
48        return Err(MarsError::Config(crate::error::ConfigError::Invalid {
49            message: format!("{} is not a directory", base.display()),
50        }));
51    }
52
53    let report = check_dir(&base)?;
54
55    if json {
56        output::print_json(&report);
57    } else {
58        println!("  {} agents, {} skills", report.agents, report.skills);
59        println!();
60
61        if report.errors.is_empty() && report.warnings.is_empty() {
62            output::print_success("all checks passed");
63        } else {
64            for e in &report.errors {
65                output::print_error(e);
66            }
67            for w in &report.warnings {
68                output::print_warn(w);
69            }
70            if !report.errors.is_empty() {
71                println!();
72                println!("  {} error(s) found", report.errors.len());
73            }
74        }
75    }
76
77    if report.errors.is_empty() {
78        Ok(0)
79    } else {
80        Ok(1)
81    }
82}
83
84fn check_dir(base: &Path) -> Result<CheckReport, MarsError> {
85    let skills_dir = base.join("skills");
86
87    let mut errors: Vec<String> = Vec::new();
88    let mut warnings: Vec<String> = Vec::new();
89
90    let discovered = discover::discover_resolved_source(base, None)?;
91
92    // ── Validate discovered agents/skills ────────────────────────────
93    let mut agent_names: HashMap<String, PathBuf> = HashMap::new();
94    let mut agent_skill_refs: Vec<(String, Vec<String>)> = Vec::new();
95    let mut skill_names: HashMap<String, PathBuf> = HashMap::new();
96
97    for item in discovered {
98        let path = base.join(&item.source_path);
99        match item.id.kind {
100            crate::lock::ItemKind::Agent => {
101                if super::is_symlink(&path) {
102                    let name = path
103                        .file_stem()
104                        .and_then(|n| n.to_str())
105                        .unwrap_or_default();
106                    warnings.push(format!(
107                        "skipping symlinked agent `{name}` — source packages should not contain symlinks"
108                    ));
109                    continue;
110                }
111
112                let filename = path
113                    .file_stem()
114                    .and_then(|n| n.to_str())
115                    .unwrap_or_default()
116                    .to_string();
117
118                match std::fs::read_to_string(&path) {
119                    Ok(content) => match frontmatter::parse(&content) {
120                        Ok(fm) => {
121                            let name = fm
122                                .name()
123                                .map(str::to_string)
124                                .unwrap_or_else(|| filename.clone());
125
126                            if fm.name().is_none() {
127                                warnings.push(format!(
128                                    "agent `{filename}` has no `name` in frontmatter"
129                                ));
130                            }
131
132                            if fm.get("description").and_then(|v| v.as_str()).is_none() {
133                                warnings.push(format!("agent `{name}` has no `description`"));
134                            }
135
136                            if fm.name().is_some() && name != filename {
137                                warnings.push(format!(
138                                    "agent filename `{filename}.md` doesn't match name `{name}` in frontmatter"
139                                ));
140                            }
141
142                            if let Some(existing) = agent_names.get(&name) {
143                                errors.push(format!(
144                                    "duplicate agent name `{name}` in {} and {}",
145                                    existing.display(),
146                                    path.display()
147                                ));
148                            } else {
149                                agent_names.insert(name.clone(), path.clone());
150                            }
151
152                            let skills = fm.skills();
153                            if !skills.is_empty() {
154                                agent_skill_refs.push((name, skills));
155                            }
156                        }
157                        Err(e) => {
158                            errors.push(format!("agent `{filename}` has invalid frontmatter: {e}"));
159                        }
160                    },
161                    Err(e) => {
162                        errors.push(format!("cannot read {}: {e}", path.display()));
163                    }
164                }
165            }
166            crate::lock::ItemKind::Skill => {
167                let (dirname, skill_md, duplicate_path) = if item.source_path
168                    == std::path::Path::new(".")
169                {
170                    let dirname = item.id.name.to_string();
171                    (dirname, base.join("SKILL.md"), base.join("SKILL.md"))
172                } else {
173                    if super::is_symlink(&path) {
174                        let name = path
175                            .file_name()
176                            .and_then(|n| n.to_str())
177                            .unwrap_or_default();
178                        warnings.push(format!(
179                            "skipping symlinked skill `{name}` — source packages should not contain symlinks"
180                        ));
181                        continue;
182                    }
183                    let dirname = path
184                        .file_name()
185                        .and_then(|n| n.to_str())
186                        .unwrap_or_default()
187                        .to_string();
188                    (dirname, path.join("SKILL.md"), path.clone())
189                };
190
191                match std::fs::read_to_string(&skill_md) {
192                    Ok(content) => match frontmatter::parse(&content) {
193                        Ok(fm) => {
194                            let name = fm
195                                .name()
196                                .map(str::to_string)
197                                .unwrap_or_else(|| dirname.clone());
198
199                            if fm.name().is_none() {
200                                warnings.push(format!(
201                                    "skill `{dirname}` has no `name` in frontmatter"
202                                ));
203                            }
204
205                            if fm.get("description").and_then(|v| v.as_str()).is_none() {
206                                warnings.push(format!("skill `{name}` has no `description`"));
207                            }
208
209                            if fm.name().is_some() && name != dirname {
210                                warnings.push(format!(
211                                    "skill dirname `{dirname}` doesn't match name `{name}` in frontmatter"
212                                ));
213                            }
214
215                            if let Some(existing) = skill_names.get(&name) {
216                                errors.push(format!(
217                                    "duplicate skill name `{name}` in {} and {}",
218                                    existing.display(),
219                                    duplicate_path.display()
220                                ));
221                            } else {
222                                skill_names.insert(name, duplicate_path);
223                            }
224                        }
225                        Err(e) => {
226                            errors.push(format!("skill `{dirname}` has invalid frontmatter: {e}"));
227                        }
228                    },
229                    Err(e) => {
230                        errors.push(format!("cannot read {}: {e}", skill_md.display()));
231                    }
232                }
233            }
234        }
235    }
236
237    // Structural validation for nested skill layout:
238    // if skills/* directories exist, each must contain SKILL.md.
239    if skills_dir.is_dir() {
240        let mut entries: Vec<_> = std::fs::read_dir(&skills_dir)?
241            .filter_map(|e| e.ok())
242            .filter(|e| e.path().is_dir())
243            .collect();
244        entries.sort_by_key(|e| e.file_name());
245        for entry in entries {
246            let path = entry.path();
247            let dirname = path
248                .file_name()
249                .and_then(|n| n.to_str())
250                .unwrap_or_default();
251            if !path.join("SKILL.md").exists() {
252                errors.push(format!("skill `{dirname}` is missing SKILL.md"));
253            }
254        }
255    }
256
257    let agent_count = agent_names.len();
258    let skill_count = skill_names.len();
259
260    // ── Empty package check ──────────────────────────────────────────
261    if agent_count == 0 && skill_count == 0 {
262        errors.push("no agents or skills found — is this a mars source package?".to_string());
263    }
264
265    // ── Skill dependency check ───────────────────────────────────────
266    let available: HashSet<&str> = skill_names.keys().map(|s| s.as_str()).collect();
267    let dependency_skills = dependency_skills_from_lock(base);
268    let mut external_deps: HashMap<String, Vec<String>> = HashMap::new();
269
270    for (agent_name, skills) in &agent_skill_refs {
271        for skill in skills {
272            if !available.contains(skill.as_str()) && !dependency_skills.contains(skill.as_str()) {
273                external_deps
274                    .entry(skill.clone())
275                    .or_default()
276                    .push(agent_name.clone());
277            }
278        }
279    }
280
281    if !external_deps.is_empty() {
282        let mut sorted: Vec<_> = external_deps.iter().collect();
283        sorted.sort_by_key(|(name, _)| name.as_str());
284        for (skill, agents) in &sorted {
285            warnings.push(format!(
286                "external dependency: `{skill}` (referenced by: {})",
287                agents.join(", ")
288            ));
289        }
290    }
291
292    // ── Output ───────────────────────────────────────────────────────
293    Ok(CheckReport {
294        agents: agent_count,
295        skills: skill_count,
296        errors,
297        warnings,
298    })
299}
300
301fn dependency_skills_from_lock(base: &Path) -> HashSet<String> {
302    let Ok(lock) = crate::lock::load(base) else {
303        return HashSet::new();
304    };
305
306    lock.items
307        .values()
308        .filter(|item| item.kind == crate::lock::ItemKind::Skill)
309        .filter_map(|item| skill_name_from_dest_path(item.dest_path.as_path()))
310        .collect()
311}
312
313fn skill_name_from_dest_path(dest_path: &Path) -> Option<String> {
314    let mut components = dest_path.components();
315    let prefix = components.next()?.as_os_str().to_str()?;
316    if prefix != "skills" {
317        return None;
318    }
319
320    components
321        .next()
322        .and_then(|c| c.as_os_str().to_str())
323        .map(str::to_string)
324}
325
326#[cfg(test)]
327mod tests {
328    use std::path::Path;
329
330    use crate::lock::{ItemKind, LockFile, LockedItem};
331    use crate::types::{ContentHash, DestPath, SourceName};
332    use tempfile::TempDir;
333
334    fn write_agent(path: &Path, filename: &str, skills: &[&str]) {
335        let agents = path.join("agents");
336        std::fs::create_dir_all(&agents).unwrap();
337        let skills = skills.join(", ");
338        std::fs::write(
339            agents.join(format!("{filename}.md")),
340            format!(
341                "---\nname: {filename}\ndescription: test agent\nskills: [{skills}]\n---\n# Agent"
342            ),
343        )
344        .unwrap();
345    }
346
347    fn write_lock_skill(path: &Path, skill_name: &str) {
348        let mut lock = LockFile::empty();
349        let dest_path = DestPath::from(format!("skills/{skill_name}"));
350        lock.items.insert(
351            dest_path.clone(),
352            LockedItem {
353                source: SourceName::from("dep-source"),
354                kind: ItemKind::Skill,
355                version: None,
356                source_checksum: ContentHash::from("source-hash"),
357                installed_checksum: ContentHash::from("installed-hash"),
358                dest_path,
359            },
360        );
361        crate::lock::write(path, &lock).unwrap();
362    }
363
364    #[test]
365    fn check_skips_symlinked_agent() {
366        let dir = TempDir::new().unwrap();
367        let agents = dir.path().join("agents");
368        std::fs::create_dir_all(&agents).unwrap();
369
370        // Real agent
371        std::fs::write(
372            agents.join("real.md"),
373            "---\nname: real\ndescription: real agent\n---\n# Real",
374        )
375        .unwrap();
376
377        // Symlinked agent pointing to the real one
378        std::os::unix::fs::symlink(agents.join("real.md"), agents.join("linked.md")).unwrap();
379
380        let args = super::CheckArgs {
381            path: Some(dir.path().to_path_buf()),
382        };
383        // Should succeed (the symlink is warned, not errored)
384        let code = super::run(&args, true).unwrap();
385        // No structural errors — the real agent is valid
386        assert_eq!(code, 0);
387    }
388
389    #[test]
390    fn check_skips_symlinked_skill() {
391        let dir = TempDir::new().unwrap();
392        let skills = dir.path().join("skills");
393        let real_skill = skills.join("real-skill");
394        std::fs::create_dir_all(&real_skill).unwrap();
395        std::fs::write(
396            real_skill.join("SKILL.md"),
397            "---\nname: real-skill\ndescription: a skill\n---\n# Skill",
398        )
399        .unwrap();
400
401        // Symlinked skill dir
402        std::os::unix::fs::symlink(&real_skill, skills.join("linked-skill")).unwrap();
403
404        // Also add an agent so the package isn't empty
405        let agents = dir.path().join("agents");
406        std::fs::create_dir_all(&agents).unwrap();
407        std::fs::write(
408            agents.join("coder.md"),
409            "---\nname: coder\ndescription: agent\n---\n# Coder",
410        )
411        .unwrap();
412
413        let args = super::CheckArgs {
414            path: Some(dir.path().to_path_buf()),
415        };
416        let code = super::run(&args, true).unwrap();
417        assert_eq!(code, 0);
418    }
419
420    #[test]
421    fn check_accepts_flat_skill_repo() {
422        let dir = TempDir::new().unwrap();
423        std::fs::write(
424            dir.path().join("SKILL.md"),
425            "---\nname: flat-skill\ndescription: flat layout\n---\n# Flat skill",
426        )
427        .unwrap();
428
429        let args = super::CheckArgs {
430            path: Some(dir.path().to_path_buf()),
431        };
432        let code = super::run(&args, true).unwrap();
433        assert_eq!(code, 0);
434    }
435
436    #[test]
437    fn check_suppresses_warning_for_dependency_provided_skill() {
438        let dir = TempDir::new().unwrap();
439        write_agent(dir.path(), "coder", &["ext-skill"]);
440        write_lock_skill(dir.path(), "ext-skill");
441
442        let report = super::check_dir(dir.path()).unwrap();
443        let has_external_warning = report
444            .warnings
445            .iter()
446            .any(|w| w.contains("external dependency: `ext-skill`"));
447
448        assert!(
449            !has_external_warning,
450            "unexpected external dependency warning: {:?}",
451            report.warnings
452        );
453    }
454
455    #[test]
456    fn check_warns_for_truly_missing_external_skill() {
457        let dir = TempDir::new().unwrap();
458        write_agent(dir.path(), "coder", &["missing-skill"]);
459        write_lock_skill(dir.path(), "some-other-skill");
460
461        let report = super::check_dir(dir.path()).unwrap();
462        let has_missing_warning = report
463            .warnings
464            .iter()
465            .any(|w| w.contains("external dependency: `missing-skill`"));
466
467        assert!(
468            has_missing_warning,
469            "expected missing external dependency warning, got: {:?}",
470            report.warnings
471        );
472    }
473}