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::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 skills_dir = base.join("skills");
54
55    let mut errors: Vec<String> = Vec::new();
56    let mut warnings: Vec<String> = Vec::new();
57
58    let discovered = discover::discover_source(&base, None)?;
59
60    // ── Validate discovered agents/skills ────────────────────────────
61    let mut agent_names: HashMap<String, PathBuf> = HashMap::new();
62    let mut agent_skill_refs: Vec<(String, Vec<String>)> = Vec::new();
63    let mut skill_names: HashMap<String, PathBuf> = HashMap::new();
64
65    for item in discovered {
66        let path = base.join(&item.source_path);
67        match item.id.kind {
68            crate::lock::ItemKind::Agent => {
69                if super::is_symlink(&path) {
70                    let name = path
71                        .file_stem()
72                        .and_then(|n| n.to_str())
73                        .unwrap_or_default();
74                    warnings.push(format!(
75                        "skipping symlinked agent `{name}` — source packages should not contain symlinks"
76                    ));
77                    continue;
78                }
79
80                let filename = path
81                    .file_stem()
82                    .and_then(|n| n.to_str())
83                    .unwrap_or_default()
84                    .to_string();
85
86                match std::fs::read_to_string(&path) {
87                    Ok(content) => match frontmatter::parse(&content) {
88                        Ok(fm) => {
89                            let name = fm
90                                .name()
91                                .map(str::to_string)
92                                .unwrap_or_else(|| filename.clone());
93
94                            if fm.name().is_none() {
95                                warnings.push(format!(
96                                    "agent `{filename}` has no `name` in frontmatter"
97                                ));
98                            }
99
100                            if fm.get("description").and_then(|v| v.as_str()).is_none() {
101                                warnings.push(format!("agent `{name}` has no `description`"));
102                            }
103
104                            if fm.name().is_some() && name != filename {
105                                warnings.push(format!(
106                                    "agent filename `{filename}.md` doesn't match name `{name}` in frontmatter"
107                                ));
108                            }
109
110                            if let Some(existing) = agent_names.get(&name) {
111                                errors.push(format!(
112                                    "duplicate agent name `{name}` in {} and {}",
113                                    existing.display(),
114                                    path.display()
115                                ));
116                            } else {
117                                agent_names.insert(name.clone(), path.clone());
118                            }
119
120                            let skills = fm.skills();
121                            if !skills.is_empty() {
122                                agent_skill_refs.push((name, skills));
123                            }
124                        }
125                        Err(e) => {
126                            errors.push(format!("agent `{filename}` has invalid frontmatter: {e}"));
127                        }
128                    },
129                    Err(e) => {
130                        errors.push(format!("cannot read {}: {e}", path.display()));
131                    }
132                }
133            }
134            crate::lock::ItemKind::Skill => {
135                let (dirname, skill_md, duplicate_path) = if item.source_path
136                    == std::path::Path::new(".")
137                {
138                    let dirname = item.id.name.to_string();
139                    (dirname, base.join("SKILL.md"), base.join("SKILL.md"))
140                } else {
141                    if super::is_symlink(&path) {
142                        let name = path
143                            .file_name()
144                            .and_then(|n| n.to_str())
145                            .unwrap_or_default();
146                        warnings.push(format!(
147                            "skipping symlinked skill `{name}` — source packages should not contain symlinks"
148                        ));
149                        continue;
150                    }
151                    let dirname = path
152                        .file_name()
153                        .and_then(|n| n.to_str())
154                        .unwrap_or_default()
155                        .to_string();
156                    (dirname, path.join("SKILL.md"), path.clone())
157                };
158
159                match std::fs::read_to_string(&skill_md) {
160                    Ok(content) => match frontmatter::parse(&content) {
161                        Ok(fm) => {
162                            let name = fm
163                                .name()
164                                .map(str::to_string)
165                                .unwrap_or_else(|| dirname.clone());
166
167                            if fm.name().is_none() {
168                                warnings.push(format!(
169                                    "skill `{dirname}` has no `name` in frontmatter"
170                                ));
171                            }
172
173                            if fm.get("description").and_then(|v| v.as_str()).is_none() {
174                                warnings.push(format!("skill `{name}` has no `description`"));
175                            }
176
177                            if fm.name().is_some() && name != dirname {
178                                warnings.push(format!(
179                                    "skill dirname `{dirname}` doesn't match name `{name}` in frontmatter"
180                                ));
181                            }
182
183                            if let Some(existing) = skill_names.get(&name) {
184                                errors.push(format!(
185                                    "duplicate skill name `{name}` in {} and {}",
186                                    existing.display(),
187                                    duplicate_path.display()
188                                ));
189                            } else {
190                                skill_names.insert(name, duplicate_path);
191                            }
192                        }
193                        Err(e) => {
194                            errors.push(format!("skill `{dirname}` has invalid frontmatter: {e}"));
195                        }
196                    },
197                    Err(e) => {
198                        errors.push(format!("cannot read {}: {e}", skill_md.display()));
199                    }
200                }
201            }
202        }
203    }
204
205    // Structural validation for nested skill layout:
206    // if skills/* directories exist, each must contain SKILL.md.
207    if skills_dir.is_dir() {
208        let mut entries: Vec<_> = std::fs::read_dir(&skills_dir)?
209            .filter_map(|e| e.ok())
210            .filter(|e| e.path().is_dir())
211            .collect();
212        entries.sort_by_key(|e| e.file_name());
213        for entry in entries {
214            let path = entry.path();
215            let dirname = path
216                .file_name()
217                .and_then(|n| n.to_str())
218                .unwrap_or_default();
219            if !path.join("SKILL.md").exists() {
220                errors.push(format!("skill `{dirname}` is missing SKILL.md"));
221            }
222        }
223    }
224
225    let agent_count = agent_names.len();
226    let skill_count = skill_names.len();
227
228    // ── Empty package check ──────────────────────────────────────────
229    if agent_count == 0 && skill_count == 0 {
230        errors.push("no agents or skills found — is this a mars source package?".to_string());
231    }
232
233    // ── Skill dependency check ───────────────────────────────────────
234    let available: HashSet<&str> = skill_names.keys().map(|s| s.as_str()).collect();
235    let mut external_deps: HashMap<String, Vec<String>> = HashMap::new();
236
237    for (agent_name, skills) in &agent_skill_refs {
238        for skill in skills {
239            if !available.contains(skill.as_str()) {
240                external_deps
241                    .entry(skill.clone())
242                    .or_default()
243                    .push(agent_name.clone());
244            }
245        }
246    }
247
248    if !external_deps.is_empty() {
249        let mut sorted: Vec<_> = external_deps.iter().collect();
250        sorted.sort_by_key(|(name, _)| name.as_str());
251        for (skill, agents) in &sorted {
252            warnings.push(format!(
253                "external dependency: `{skill}` (referenced by: {})",
254                agents.join(", ")
255            ));
256        }
257    }
258
259    // ── Output ───────────────────────────────────────────────────────
260    let report = CheckReport {
261        agents: agent_count,
262        skills: skill_count,
263        errors: errors.clone(),
264        warnings: warnings.clone(),
265    };
266
267    if json {
268        output::print_json(&report);
269    } else {
270        println!("  {} agents, {} skills", agent_count, skill_count);
271        println!();
272
273        if errors.is_empty() && warnings.is_empty() {
274            output::print_success("all checks passed");
275        } else {
276            for e in &errors {
277                output::print_error(e);
278            }
279            for w in &warnings {
280                output::print_warn(w);
281            }
282            if !errors.is_empty() {
283                println!();
284                println!("  {} error(s) found", errors.len());
285            }
286        }
287    }
288
289    if errors.is_empty() { Ok(0) } else { Ok(1) }
290}
291
292#[cfg(test)]
293mod tests {
294    use tempfile::TempDir;
295
296    #[test]
297    fn check_skips_symlinked_agent() {
298        let dir = TempDir::new().unwrap();
299        let agents = dir.path().join("agents");
300        std::fs::create_dir_all(&agents).unwrap();
301
302        // Real agent
303        std::fs::write(
304            agents.join("real.md"),
305            "---\nname: real\ndescription: real agent\n---\n# Real",
306        )
307        .unwrap();
308
309        // Symlinked agent pointing to the real one
310        std::os::unix::fs::symlink(agents.join("real.md"), agents.join("linked.md")).unwrap();
311
312        let args = super::CheckArgs {
313            path: Some(dir.path().to_path_buf()),
314        };
315        // Should succeed (the symlink is warned, not errored)
316        let code = super::run(&args, true).unwrap();
317        // No structural errors — the real agent is valid
318        assert_eq!(code, 0);
319    }
320
321    #[test]
322    fn check_skips_symlinked_skill() {
323        let dir = TempDir::new().unwrap();
324        let skills = dir.path().join("skills");
325        let real_skill = skills.join("real-skill");
326        std::fs::create_dir_all(&real_skill).unwrap();
327        std::fs::write(
328            real_skill.join("SKILL.md"),
329            "---\nname: real-skill\ndescription: a skill\n---\n# Skill",
330        )
331        .unwrap();
332
333        // Symlinked skill dir
334        std::os::unix::fs::symlink(&real_skill, skills.join("linked-skill")).unwrap();
335
336        // Also add an agent so the package isn't empty
337        let agents = dir.path().join("agents");
338        std::fs::create_dir_all(&agents).unwrap();
339        std::fs::write(
340            agents.join("coder.md"),
341            "---\nname: coder\ndescription: agent\n---\n# Coder",
342        )
343        .unwrap();
344
345        let args = super::CheckArgs {
346            path: Some(dir.path().to_path_buf()),
347        };
348        let code = super::run(&args, true).unwrap();
349        assert_eq!(code, 0);
350    }
351
352    #[test]
353    fn check_accepts_flat_skill_repo() {
354        let dir = TempDir::new().unwrap();
355        std::fs::write(
356            dir.path().join("SKILL.md"),
357            "---\nname: flat-skill\ndescription: flat layout\n---\n# Flat skill",
358        )
359        .unwrap();
360
361        let args = super::CheckArgs {
362            path: Some(dir.path().to_path_buf()),
363        };
364        let code = super::run(&args, true).unwrap();
365        assert_eq!(code, 0);
366    }
367}