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