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