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)]
27pub(crate) struct CheckReport {
28    agents: usize,
29    skills: usize,
30    pub(crate) 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            "  source package validates for .mars/ canonical store and native harness targets"
61        );
62        println!();
63
64        if report.errors.is_empty() && report.warnings.is_empty() {
65            output::print_success("all checks passed");
66        } else {
67            for e in &report.errors {
68                output::print_error(e);
69            }
70            for w in &report.warnings {
71                output::print_warn(w);
72            }
73            if !report.errors.is_empty() {
74                println!();
75                println!("  {} error(s) found", report.errors.len());
76            }
77        }
78    }
79
80    if report.errors.is_empty() {
81        Ok(0)
82    } else {
83        Ok(1)
84    }
85}
86
87pub(crate) fn check_dir(base: &Path) -> Result<CheckReport, MarsError> {
88    let skills_dir = base.join("skills");
89
90    let mut errors: Vec<String> = Vec::new();
91    let mut warnings: Vec<String> = Vec::new();
92
93    let discovered = discover::discover_resolved_source(base, None)?;
94
95    // ── Validate discovered agents/skills ────────────────────────────
96    let mut agent_names: HashMap<String, PathBuf> = HashMap::new();
97    let mut agent_skill_refs: Vec<(String, Vec<String>)> = Vec::new();
98    let mut skill_names: HashMap<String, PathBuf> = HashMap::new();
99
100    for item in discovered {
101        let path = base.join(&item.source_path);
102        match item.id.kind {
103            crate::lock::ItemKind::Agent => {
104                if super::is_symlink(&path) {
105                    let name = path
106                        .file_stem()
107                        .and_then(|n| n.to_str())
108                        .unwrap_or_default();
109                    warnings.push(format!(
110                        "skipping symlinked agent `{name}` — source packages should not contain symlinks"
111                    ));
112                    continue;
113                }
114
115                let filename = path
116                    .file_stem()
117                    .and_then(|n| n.to_str())
118                    .unwrap_or_default()
119                    .to_string();
120
121                match std::fs::read_to_string(&path) {
122                    Ok(content) => match frontmatter::parse(&content) {
123                        Ok(fm) => {
124                            let name = fm
125                                .name()
126                                .map(str::to_string)
127                                .unwrap_or_else(|| filename.clone());
128
129                            let mut agent_diags = Vec::new();
130                            let _profile =
131                                crate::compiler::agents::parse_agent_profile(&fm, &mut agent_diags);
132                            for diagnostic in agent_diags {
133                                let message = format!("agent `{name}`: {}", diagnostic.message());
134                                if diagnostic.is_error() {
135                                    errors.push(message);
136                                } else {
137                                    warnings.push(message);
138                                }
139                            }
140
141                            if fm.name().is_none() {
142                                warnings.push(format!(
143                                    "agent `{filename}` has no `name` in frontmatter"
144                                ));
145                            }
146
147                            if fm.get("description").and_then(|v| v.as_str()).is_none() {
148                                warnings.push(format!("agent `{name}` has no `description`"));
149                            }
150
151                            if fm.name().is_some() && name != filename {
152                                warnings.push(format!(
153                                    "agent filename `{filename}.md` doesn't match name `{name}` in frontmatter"
154                                ));
155                            }
156
157                            if let Some(existing) = agent_names.get(&name) {
158                                errors.push(format!(
159                                    "duplicate agent name `{name}` in {} and {}",
160                                    existing.display(),
161                                    path.display()
162                                ));
163                            } else {
164                                agent_names.insert(name.clone(), path.clone());
165                            }
166
167                            let skills = fm.skills();
168                            if !skills.is_empty() {
169                                agent_skill_refs.push((name, skills));
170                            }
171                        }
172                        Err(e) => {
173                            errors.push(format!("agent `{filename}` has invalid frontmatter: {e}"));
174                        }
175                    },
176                    Err(e) => {
177                        errors.push(format!("cannot read {}: {e}", path.display()));
178                    }
179                }
180            }
181            crate::lock::ItemKind::Skill => {
182                let (dirname, skill_md, duplicate_path) = if item.source_path
183                    == std::path::Path::new(".")
184                {
185                    let dirname = item.id.name.to_string();
186                    (dirname, base.join("SKILL.md"), base.join("SKILL.md"))
187                } else {
188                    if super::is_symlink(&path) {
189                        let name = path
190                            .file_name()
191                            .and_then(|n| n.to_str())
192                            .unwrap_or_default();
193                        warnings.push(format!(
194                            "skipping symlinked skill `{name}` — source packages should not contain symlinks"
195                        ));
196                        continue;
197                    }
198                    let dirname = path
199                        .file_name()
200                        .and_then(|n| n.to_str())
201                        .unwrap_or_default()
202                        .to_string();
203                    (dirname, path.join("SKILL.md"), path.clone())
204                };
205
206                match std::fs::read_to_string(&skill_md) {
207                    Ok(content) => {
208                        let mut skill_diags = Vec::new();
209                        match crate::compiler::skills::parse_skill_content(
210                            &content,
211                            &mut skill_diags,
212                        ) {
213                            Ok((profile, fm)) => {
214                                let name = profile
215                                    .name
216                                    .clone()
217                                    .or_else(|| fm.name().map(str::to_string))
218                                    .unwrap_or_else(|| dirname.clone());
219
220                                let schema_missing_name = skill_diags.iter().any(|diagnostic| {
221                                    matches!(
222                                        diagnostic,
223                                        crate::compiler::skills::SkillDiagnostic::InvalidFieldValue { field, value, .. }
224                                            if field == "name" && value == "missing"
225                                    )
226                                });
227                                let schema_missing_description =
228                                    skill_diags.iter().any(|diagnostic| {
229                                        matches!(
230                                            diagnostic,
231                                            crate::compiler::skills::SkillDiagnostic::InvalidFieldValue { field, value, .. }
232                                                if field == "description" && value == "missing"
233                                        )
234                                    });
235
236                                for diagnostic in skill_diags {
237                                    let message =
238                                        format!("skill `{name}`: {}", diagnostic.message());
239                                    if diagnostic.is_error() {
240                                        errors.push(message);
241                                    } else {
242                                        warnings.push(message);
243                                    }
244                                }
245
246                                if fm.name().is_none() && !schema_missing_name {
247                                    warnings.push(format!(
248                                        "skill `{dirname}` has no `name` in frontmatter"
249                                    ));
250                                }
251
252                                if fm.get("description").and_then(|v| v.as_str()).is_none()
253                                    && !schema_missing_description
254                                {
255                                    warnings.push(format!("skill `{name}` has no `description`"));
256                                }
257
258                                if fm.name().is_some() && name != dirname {
259                                    warnings.push(format!(
260                                        "skill dirname `{dirname}` doesn't match name `{name}` in frontmatter"
261                                    ));
262                                }
263
264                                if let Some(existing) = skill_names.get(&name) {
265                                    errors.push(format!(
266                                        "duplicate skill name `{name}` in {} and {}",
267                                        existing.display(),
268                                        duplicate_path.display()
269                                    ));
270                                } else {
271                                    skill_names.insert(name, duplicate_path);
272                                }
273                            }
274                            Err(e) => {
275                                errors.push(format!(
276                                    "skill `{dirname}` has invalid frontmatter: {e}"
277                                ));
278                            }
279                        }
280                    }
281                    Err(e) => {
282                        errors.push(format!("cannot read {}: {e}", skill_md.display()));
283                    }
284                }
285            }
286            // New kinds not yet subject to source-package checks.
287            crate::lock::ItemKind::Hook
288            | crate::lock::ItemKind::McpServer
289            | crate::lock::ItemKind::BootstrapDoc => {}
290        }
291    }
292
293    // Structural validation for nested skill layout:
294    // if skills/* directories exist, each must contain SKILL.md.
295    if skills_dir.is_dir() {
296        let mut entries: Vec<_> = std::fs::read_dir(&skills_dir)?
297            .filter_map(|e| e.ok())
298            .filter(|e| e.path().is_dir())
299            .collect();
300        entries.sort_by_key(|e| e.file_name());
301        for entry in entries {
302            let path = entry.path();
303            let dirname = path
304                .file_name()
305                .and_then(|n| n.to_str())
306                .unwrap_or_default();
307            if !path.join("SKILL.md").exists() {
308                errors.push(format!("skill `{dirname}` is missing SKILL.md"));
309            }
310        }
311    }
312
313    let agent_count = agent_names.len();
314    let skill_count = skill_names.len();
315
316    // ── Empty package check ──────────────────────────────────────────
317    if agent_count == 0 && skill_count == 0 {
318        errors.push("no agents or skills found — is this a mars source package?".to_string());
319    }
320
321    // ── Skill dependency check ───────────────────────────────────────
322    let available: HashSet<&str> = skill_names.keys().map(|s| s.as_str()).collect();
323
324    match has_package_dependencies(base) {
325        Ok(true) => {
326            // Graph-backed validation: resolve deps fresh from constraints, check
327            // skill refs against local skills + all resolved dependency packages.
328            match resolve_available_skills(base) {
329                Ok(graph_skills) => {
330                    for (agent_name, skills) in &agent_skill_refs {
331                        for skill in skills {
332                            if !available.contains(skill.as_str())
333                                && !graph_skills.contains_key(skill)
334                            {
335                                errors.push(format!(
336                                    "agent `{agent_name}` references skill `{skill}` not found in local package or dependencies\n  searched: {}\n  hint: add the skill's source package as a dependency, or remove the skill reference",
337                                    format_searched_packages(&graph_skills)
338                                ));
339                            }
340                        }
341                    }
342                }
343                Err(resolve_err) => {
344                    errors.push(format!(
345                        "dependency graph resolution failed: {resolve_err}\n  hint: check network access, or use `mars version --force` to bypass the publish gate"
346                    ));
347                }
348            }
349        }
350        Ok(false) => {
351            // No [dependencies] — local-only validation, emit warnings for external refs.
352            for (agent_name, skills) in &agent_skill_refs {
353                for skill in skills {
354                    if !available.contains(skill.as_str()) {
355                        warnings.push(format!(
356                            "external dependency: `{skill}` (referenced by: {agent_name})"
357                        ));
358                    }
359                }
360            }
361        }
362        Err(config_err) => {
363            errors.push(format!(
364                "failed to load mars.toml for dependency checks: {config_err}\n  hint: fix mars.toml syntax (Windows paths in TOML must use `/` or escaped `\\\\`)"
365            ));
366        }
367    }
368
369    // ── Output ───────────────────────────────────────────────────────
370    Ok(CheckReport {
371        agents: agent_count,
372        skills: skill_count,
373        errors,
374        warnings,
375    })
376}
377
378/// Check if mars.toml has `[package]` and at least one `[dependencies]` entry.
379///
380/// Both are required to trigger graph-backed validation: `[package]` indicates
381/// this is a publishable source package, and `[dependencies]` means there are
382/// skills that could come from external packages.
383fn has_package_dependencies(base: &Path) -> Result<bool, MarsError> {
384    match crate::config::load(base) {
385        Ok(config) => Ok(config.package.is_some() && !config.dependencies.is_empty()),
386        Err(MarsError::Config(crate::error::ConfigError::NotFound { .. })) => Ok(false),
387        Err(err) => Err(err),
388    }
389}
390
391/// Resolve the dependency graph and collect available skills, respecting package filters.
392///
393/// Returns a map of `skill_name → (source_name, version_string)`.
394/// Fails closed — if resolution cannot complete, returns an error.
395///
396/// Uses only `[dependencies]` from mars.toml — excludes `[local-dependencies]` (dev-only)
397/// and ignores mars.local.toml overrides (local dev paths). This matches what consumers
398/// see when they depend on this package.
399fn resolve_available_skills(base: &Path) -> Result<HashMap<String, (String, String)>, MarsError> {
400    use crate::resolve::{ResolveOptions, resolve};
401    use crate::source::GlobalCache;
402    use crate::sync::provider::RealSourceProvider;
403
404    let config = crate::config::load(base)?;
405    // Publish gate: use only mars.toml [dependencies].
406    // Strip [local-dependencies] (dev-only, not exported to consumers) and skip
407    // mars.local.toml (local dev path overrides that don't exist on consumers).
408    let mut publish_config = config.clone();
409    publish_config.local_dependencies.clear();
410    let effective = crate::config::merge(publish_config, crate::config::LocalConfig::default())?;
411
412    let cache = GlobalCache::new()?;
413    let provider = RealSourceProvider::new(&cache, base);
414    let mut diag = crate::diagnostic::DiagnosticCollector::new();
415    let options = ResolveOptions::default(); // no lock, not frozen, not maximizing
416
417    let graph = resolve(&effective, &provider, None, &options, &mut diag)?;
418
419    let mut skills: HashMap<String, (String, String)> = HashMap::new();
420    for (source_name, node) in &graph.nodes {
421        let discovered =
422            crate::discover::discover_resolved_source(&node.rooted_ref.package_root, None)?;
423        let package_filters = graph.filters.get(source_name);
424        for item in &discovered {
425            if item.id.kind == crate::lock::ItemKind::Skill
426                && item_passes_filters(item, package_filters)
427            {
428                let version_str = node
429                    .resolved_ref
430                    .version
431                    .as_ref()
432                    .map(|v| v.to_string())
433                    .unwrap_or_else(|| "unknown".to_string());
434                skills.insert(
435                    item.id.name.to_string(),
436                    (source_name.to_string(), version_str),
437                );
438            }
439        }
440    }
441
442    Ok(skills)
443}
444
445/// Returns true if a skill item would be installed given the accumulated filter constraints.
446///
447/// Filters are accumulated with OR semantics: an item passes if ANY filter in the list
448/// would include it (multiple requests for the same package may each install different
449/// subsets, and a skill available from any of them is usable).
450///
451/// Matches real install semantics from `seed_items_for_request`: `Exclude` checks both
452/// skill name and source path so path-based excludes are honoured in the publish gate.
453fn item_passes_filters(
454    item: &crate::discover::DiscoveredItem,
455    filters: Option<&Vec<crate::config::FilterMode>>,
456) -> bool {
457    let Some(filters) = filters else {
458        return true; // no filter constraint → all items pass
459    };
460    filters.iter().any(|filter| match filter {
461        crate::config::FilterMode::All => true,
462        crate::config::FilterMode::Include { skills, .. } => skills.contains(&item.id.name),
463        crate::config::FilterMode::Exclude(excluded) => {
464            let source_path = item.source_path.to_string_lossy();
465            !excluded.iter().any(|e| {
466                *e == item.id.name || crate::target::paths_equivalent(e.as_ref(), &source_path)
467            })
468        }
469        crate::config::FilterMode::OnlySkills => true,
470        crate::config::FilterMode::OnlyAgents => false,
471    })
472}
473
474fn format_searched_packages(graph_skills: &HashMap<String, (String, String)>) -> String {
475    let mut packages: Vec<(&str, &str)> = graph_skills
476        .values()
477        .map(|(name, ver)| (name.as_str(), ver.as_str()))
478        .collect();
479    packages.sort();
480    packages.dedup();
481    if packages.is_empty() {
482        "no dependency packages resolved".to_string()
483    } else {
484        packages
485            .iter()
486            .map(|(name, ver)| format!("{name}@{ver}"))
487            .collect::<Vec<_>>()
488            .join(", ")
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use std::path::Path;
495
496    use tempfile::TempDir;
497
498    fn write_agent(path: &Path, filename: &str, skills: &[&str]) {
499        let agents = path.join("agents");
500        std::fs::create_dir_all(&agents).unwrap();
501        let skills_str = skills.join(", ");
502        std::fs::write(
503            agents.join(format!("{filename}.md")),
504            format!(
505                "---\nname: {filename}\ndescription: test agent\nskills: [{skills_str}]\n---\n# Agent"
506            ),
507        )
508        .unwrap();
509    }
510
511    fn write_agent_content(path: &Path, filename: &str, content: &str) {
512        let agents = path.join("agents");
513        std::fs::create_dir_all(&agents).unwrap();
514        std::fs::write(agents.join(format!("{filename}.md")), content).unwrap();
515    }
516
517    /// Create a minimal path-dep source package with the given skills.
518    fn write_dep_package(path: &Path, name: &str, version: &str, skills: &[&str]) {
519        std::fs::create_dir_all(path).unwrap();
520        std::fs::write(
521            path.join("mars.toml"),
522            format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n\n[dependencies]\n"),
523        )
524        .unwrap();
525        for skill_name in skills {
526            let skill_dir = path.join("skills").join(skill_name);
527            std::fs::create_dir_all(&skill_dir).unwrap();
528            std::fs::write(
529                skill_dir.join("SKILL.md"),
530                format!("---\nname: {skill_name}\ndescription: test skill\n---\n# Skill"),
531            )
532            .unwrap();
533        }
534    }
535
536    fn toml_path(path: &Path) -> String {
537        path.to_string_lossy().replace('\\', "/")
538    }
539
540    // ── Structural checks (unchanged) ─────────────────────────────────
541
542    #[cfg(unix)]
543    #[test]
544    fn check_skips_symlinked_agent() {
545        let dir = TempDir::new().unwrap();
546        let agents = dir.path().join("agents");
547        std::fs::create_dir_all(&agents).unwrap();
548
549        std::fs::write(
550            agents.join("real.md"),
551            "---\nname: real\ndescription: real agent\n---\n# Real",
552        )
553        .unwrap();
554        std::os::unix::fs::symlink(agents.join("real.md"), agents.join("linked.md")).unwrap();
555
556        let args = super::CheckArgs {
557            path: Some(dir.path().to_path_buf()),
558        };
559        let code = super::run(&args, true).unwrap();
560        assert_eq!(code, 0);
561    }
562
563    #[cfg(unix)]
564    #[test]
565    fn check_skips_symlinked_skill() {
566        let dir = TempDir::new().unwrap();
567        let skills = dir.path().join("skills");
568        let real_skill = skills.join("real-skill");
569        std::fs::create_dir_all(&real_skill).unwrap();
570        std::fs::write(
571            real_skill.join("SKILL.md"),
572            "---\nname: real-skill\ndescription: a skill\n---\n# Skill",
573        )
574        .unwrap();
575        std::os::unix::fs::symlink(&real_skill, skills.join("linked-skill")).unwrap();
576
577        let agents = dir.path().join("agents");
578        std::fs::create_dir_all(&agents).unwrap();
579        std::fs::write(
580            agents.join("coder.md"),
581            "---\nname: coder\ndescription: agent\n---\n# Coder",
582        )
583        .unwrap();
584
585        let args = super::CheckArgs {
586            path: Some(dir.path().to_path_buf()),
587        };
588        let code = super::run(&args, true).unwrap();
589        assert_eq!(code, 0);
590    }
591
592    #[test]
593    fn check_accepts_flat_skill_repo() {
594        let dir = TempDir::new().unwrap();
595        std::fs::write(
596            dir.path().join("SKILL.md"),
597            "---\nname: flat-skill\ndescription: flat layout\n---\n# Flat skill",
598        )
599        .unwrap();
600
601        let args = super::CheckArgs {
602            path: Some(dir.path().to_path_buf()),
603        };
604        let code = super::run(&args, true).unwrap();
605        assert_eq!(code, 0);
606    }
607
608    // ── P3: No [dependencies] → local-only path, external refs are warnings ──
609
610    #[test]
611    fn check_no_dependencies_warns_for_external_skill() {
612        // No mars.toml → has_package_dependencies returns false → warning path.
613        let dir = TempDir::new().unwrap();
614        write_agent(dir.path(), "coder", &["missing-skill"]);
615
616        let report = super::check_dir(dir.path()).unwrap();
617        assert!(
618            report.errors.is_empty(),
619            "expected no errors in local-only mode: {:?}",
620            report.errors
621        );
622        let has_warning = report
623            .warnings
624            .iter()
625            .any(|w| w.contains("external dependency: `missing-skill`"));
626        assert!(
627            has_warning,
628            "expected warning for missing-skill: {:?}",
629            report.warnings
630        );
631    }
632
633    #[test]
634    fn check_warns_for_truly_missing_external_skill() {
635        // No mars.toml → local-only path → skill ref that isn't local → warning.
636        let dir = TempDir::new().unwrap();
637        write_agent(dir.path(), "coder", &["missing-skill"]);
638
639        let report = super::check_dir(dir.path()).unwrap();
640        let has_missing_warning = report
641            .warnings
642            .iter()
643            .any(|w| w.contains("external dependency: `missing-skill`"));
644
645        assert!(
646            has_missing_warning,
647            "expected missing external dependency warning, got: {:?}",
648            report.warnings
649        );
650    }
651
652    #[test]
653    fn check_errors_for_malformed_agent_model_policy() {
654        let dir = TempDir::new().unwrap();
655        write_agent_content(
656            dir.path(),
657            "browser-tester",
658            "---\nname: browser-tester\ndescription: browser test\nmodel-policies:\n  - match:\n      alias: gpt55\n      model: gpt-5.5\n---\n# Browser Tester",
659        );
660
661        let report = super::check_dir(dir.path()).unwrap();
662
663        let joined = report.errors.join("\n");
664        assert!(
665            joined.contains("model-policies[1].match"),
666            "expected model-policies match error: {joined}"
667        );
668    }
669
670    #[test]
671    fn check_accepts_snake_case_skill_tool_alias() {
672        let dir = TempDir::new().unwrap();
673        let skill_dir = dir.path().join("skills").join("planning");
674        std::fs::create_dir_all(&skill_dir).unwrap();
675        std::fs::write(
676            skill_dir.join("SKILL.md"),
677            "---
678name: planning
679description: plan
680allowed-tools: [ask_user]
681---
682# Skill",
683        )
684        .unwrap();
685
686        let report = super::check_dir(dir.path()).unwrap();
687
688        assert!(
689            report.errors.is_empty(),
690            "unexpected errors: {:?}",
691            report.errors
692        );
693        assert!(
694            report.warnings.is_empty(),
695            "unexpected warnings: {:?}",
696            report.warnings
697        );
698    }
699
700    #[test]
701    fn check_passes_through_unseparated_skill_tool_name() {
702        let dir = TempDir::new().unwrap();
703        let skill_dir = dir.path().join("skills").join("planning");
704        std::fs::create_dir_all(&skill_dir).unwrap();
705        std::fs::write(
706            skill_dir.join("SKILL.md"),
707            "---
708name: planning
709description: plan
710allowed-tools: [askuser]
711---
712# Skill",
713        )
714        .unwrap();
715
716        let report = super::check_dir(dir.path()).unwrap();
717
718        assert!(
719            report.errors.is_empty(),
720            "unexpected errors: {:?}",
721            report.errors
722        );
723        assert!(
724            report.warnings.is_empty(),
725            "unexpected warnings: {:?}",
726            report.warnings
727        );
728    }
729
730    // ── P1 + P4 + P9: [dependencies] present, resolution fails → error with hint ─
731
732    #[test]
733    fn check_with_unresolvable_dep_fails_closed_with_remediation_hint() {
734        // P1: mars.toml with [dependencies] triggers graph resolution.
735        // P4: resolution fails (non-existent path) → fail-closed error.
736        // P9: error message includes remediation ("mars version --force").
737        let dir = TempDir::new().unwrap();
738        write_agent(dir.path(), "coder", &["some-skill"]);
739        std::fs::write(
740            dir.path().join("mars.toml"),
741            // [package] required to trigger graph-backed validation.
742            // Absolute path that does not exist — resolution must fail.
743            "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"/nonexistent-mars-dep-xyz-abc\" }\n",
744        )
745        .unwrap();
746
747        let report = super::check_dir(dir.path()).unwrap();
748        assert!(
749            !report.errors.is_empty(),
750            "expected errors when dep cannot be resolved"
751        );
752        let joined = report.errors.join("\n");
753        assert!(
754            joined.contains("mars version --force"),
755            "error must include remediation hint: {joined}"
756        );
757    }
758
759    // ── P2 + P8: [dependencies] resolve, skill missing from graph → error ────────
760
761    #[test]
762    fn check_missing_skill_in_resolved_graph_is_error() {
763        // P2: skill not in graph → error (not warning).
764        // P8: error message includes agent name, skill name, searched packages.
765        let dir = TempDir::new().unwrap();
766        let dep_dir = TempDir::new().unwrap();
767
768        // Path dep provides "provided-skill", NOT "missing-skill".
769        write_dep_package(dep_dir.path(), "dep-pkg", "0.1.0", &["provided-skill"]);
770
771        write_agent(dir.path(), "coder", &["missing-skill"]);
772        std::fs::write(
773            dir.path().join("mars.toml"),
774            format!(
775                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\" }}\n",
776                toml_path(dep_dir.path())
777            ),
778        )
779        .unwrap();
780
781        let report = super::check_dir(dir.path()).unwrap();
782        assert!(
783            !report.errors.is_empty(),
784            "expected error for missing skill, got: {:?}",
785            report.errors
786        );
787        let joined = report.errors.join("\n");
788        // P8: error includes agent name, skill name, searched packages, and remediation.
789        assert!(
790            joined.contains("coder"),
791            "error must name the agent: {joined}"
792        );
793        assert!(
794            joined.contains("missing-skill"),
795            "error must name the missing skill: {joined}"
796        );
797        assert!(
798            joined.contains("searched:"),
799            "error must list searched packages: {joined}"
800        );
801        assert!(
802            joined.contains("hint:"),
803            "error must include remediation guidance: {joined}"
804        );
805        // Warnings must NOT contain missing-skill (it is now an error).
806        let has_warning = report.warnings.iter().any(|w| w.contains("missing-skill"));
807        assert!(
808            !has_warning,
809            "missing skill must be error, not warning: {:?}",
810            report.warnings
811        );
812    }
813
814    // ── Skill provided by path dep passes (graph-backed success) ─────────────────
815
816    #[test]
817    fn check_skill_provided_by_path_dep_passes() {
818        // When the skill is found in a resolved path dependency, no error.
819        let dir = TempDir::new().unwrap();
820        let dep_dir = TempDir::new().unwrap();
821
822        write_dep_package(dep_dir.path(), "dep-pkg", "0.1.0", &["ext-skill"]);
823        write_agent(dir.path(), "coder", &["ext-skill"]);
824        std::fs::write(
825            dir.path().join("mars.toml"),
826            format!(
827                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\" }}\n",
828                toml_path(dep_dir.path())
829            ),
830        )
831        .unwrap();
832
833        let report = super::check_dir(dir.path()).unwrap();
834        assert!(
835            report.errors.is_empty(),
836            "expected no errors when skill is in dep: {:?}",
837            report.errors
838        );
839    }
840
841    // ── Fix 1: Filter bypass — excluded skill must not satisfy a ref ──────────────
842
843    #[test]
844    fn check_excluded_skill_in_dep_is_not_available() {
845        // A skill that exists in the dep package but is excluded via filter
846        // must not satisfy an agent skill reference — the filter bypass is the bug.
847        let dir = TempDir::new().unwrap();
848        let dep_dir = TempDir::new().unwrap();
849
850        // Dep provides "ext-skill" and "other-skill", but consumer excludes "ext-skill".
851        write_dep_package(
852            dep_dir.path(),
853            "dep-pkg",
854            "0.1.0",
855            &["ext-skill", "other-skill"],
856        );
857        write_agent(dir.path(), "coder", &["ext-skill"]);
858        std::fs::write(
859            dir.path().join("mars.toml"),
860            format!(
861                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\", exclude = [\"ext-skill\"] }}\n",
862                toml_path(dep_dir.path())
863            ),
864        )
865        .unwrap();
866
867        let report = super::check_dir(dir.path()).unwrap();
868        assert!(
869            !report.errors.is_empty(),
870            "excluded skill must not satisfy ref — expected error, got none: {:?}",
871            report.errors
872        );
873        let joined = report.errors.join("\n");
874        assert!(
875            joined.contains("ext-skill"),
876            "error must mention the missing skill: {joined}"
877        );
878    }
879
880    #[test]
881    fn check_only_agents_filter_makes_skills_unavailable() {
882        // only_agents = true means skills are NOT installed from the dep.
883        let dir = TempDir::new().unwrap();
884        let dep_dir = TempDir::new().unwrap();
885
886        write_dep_package(dep_dir.path(), "dep-pkg", "0.1.0", &["ext-skill"]);
887        write_agent(dir.path(), "coder", &["ext-skill"]);
888        std::fs::write(
889            dir.path().join("mars.toml"),
890            format!(
891                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = {{ path = \"{}\", only_agents = true }}\n",
892                toml_path(dep_dir.path())
893            ),
894        )
895        .unwrap();
896
897        let report = super::check_dir(dir.path()).unwrap();
898        assert!(
899            !report.errors.is_empty(),
900            "only_agents filter must make skills unavailable — expected error: {:?}",
901            report.errors
902        );
903    }
904
905    // ── Fix 2: Local config leakage — local-dependencies must not satisfy refs ────
906
907    #[test]
908    fn check_local_dependency_skill_does_not_satisfy_ref() {
909        // Skills from [local-dependencies] are dev-only and must not satisfy
910        // skill references in the publish gate check.
911        let dir = TempDir::new().unwrap();
912        let local_dep_dir = TempDir::new().unwrap();
913
914        write_dep_package(local_dep_dir.path(), "local-dep", "0.1.0", &["local-skill"]);
915        write_agent(dir.path(), "coder", &["local-skill"]);
916        // [package] + [local-dependencies] only, no [dependencies]
917        std::fs::write(
918            dir.path().join("mars.toml"),
919            format!(
920                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n\n[local-dependencies]\nlocal-dep = {{ path = \"{}\" }}\n",
921                toml_path(local_dep_dir.path())
922            ),
923        )
924        .unwrap();
925
926        // has_package_dependencies checks config.dependencies (not local_dependencies),
927        // so this will be false → falls through to local-only warning path.
928        // That's the correct behavior: local-only validation, external ref → warning.
929        let report = super::check_dir(dir.path()).unwrap();
930        // local-skill is not in the local package, so it should warn (not error)
931        // since we're in local-only mode (no [dependencies]).
932        let has_warning = report.warnings.iter().any(|w| w.contains("local-skill"));
933        assert!(
934            has_warning,
935            "local-skill from [local-dependencies] must not satisfy ref in publish gate — expected warning: {:?}",
936            report.warnings
937        );
938    }
939
940    #[test]
941    fn check_local_dep_skill_not_available_when_regular_dep_present() {
942        // Fix 2 code path: [dependencies] is non-empty (triggers resolve_available_skills),
943        // skill is only in [local-dependencies]. Before the fix, local-deps were included
944        // in the resolved graph and could silently satisfy refs. After the fix, they are
945        // stripped and the missing skill is correctly flagged as an error.
946        let dir = TempDir::new().unwrap();
947        let regular_dep_dir = TempDir::new().unwrap();
948        let local_dep_dir = TempDir::new().unwrap();
949
950        // Regular dep provides an unrelated skill — exists only to satisfy has_package_dependencies.
951        write_dep_package(
952            regular_dep_dir.path(),
953            "regular-dep",
954            "0.1.0",
955            &["unrelated-skill"],
956        );
957        // Local dep has the skill the agent references.
958        write_dep_package(
959            local_dep_dir.path(),
960            "local-dep",
961            "0.1.0",
962            &["local-only-skill"],
963        );
964        write_agent(dir.path(), "coder", &["local-only-skill"]);
965        std::fs::write(
966            dir.path().join("mars.toml"),
967            format!(
968                "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\nregular = {{ path = \"{}\" }}\n\n[local-dependencies]\nlocal = {{ path = \"{}\" }}\n",
969                toml_path(regular_dep_dir.path()),
970                toml_path(local_dep_dir.path())
971            ),
972        )
973        .unwrap();
974
975        let report = super::check_dir(dir.path()).unwrap();
976        assert!(
977            !report.errors.is_empty(),
978            "skill from [local-dependencies] must not satisfy ref in publish gate — expected error: {:?}",
979            report.errors
980        );
981        let joined = report.errors.join("\n");
982        assert!(
983            joined.contains("local-only-skill"),
984            "error must name the missing skill: {joined}"
985        );
986    }
987
988    #[test]
989    fn check_invalid_config_reports_error_instead_of_falling_back_to_local_only() {
990        let dir = TempDir::new().unwrap();
991        write_agent(dir.path(), "coder", &["missing-skill"]);
992        // Intentionally invalid TOML (Windows-style path escapes in basic string).
993        std::fs::write(
994            dir.path().join("mars.toml"),
995            "[package]\nname = \"test-pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"C:\\Users\\dev\\dep\" }\n",
996        )
997        .unwrap();
998
999        let report = super::check_dir(dir.path()).unwrap();
1000        let joined = report.errors.join("\n");
1001        assert!(
1002            joined.contains("failed to load mars.toml for dependency checks"),
1003            "expected config parse/load error to surface: {joined}"
1004        );
1005        let has_local_warning = report
1006            .warnings
1007            .iter()
1008            .any(|w| w.contains("external dependency: `missing-skill`"));
1009        assert!(
1010            !has_local_warning,
1011            "must not silently fall back to local-only warnings on invalid config: {:?}",
1012            report.warnings
1013        );
1014    }
1015}