1use 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#[derive(Debug, clap::Args)]
22pub struct CheckArgs {
23 pub path: Option<PathBuf>,
25
26 #[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
39pub 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 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 crate::lock::ItemKind::Hook
328 | crate::lock::ItemKind::McpServer
329 | crate::lock::ItemKind::BootstrapDoc => {}
330 }
331 }
332
333 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 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 let available: HashSet<&str> = skill_names.keys().map(|s| s.as_str()).collect();
363
364 match has_package_dependencies(base) {
365 Ok(true) => {
366 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 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 Ok(CheckReport {
411 agents: agent_count,
412 skills: skill_count,
413 errors,
414 warnings,
415 })
416}
417
418fn 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
431fn 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 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(); 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
485fn 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; };
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 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 #[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 #[test]
654 fn check_no_dependencies_warns_for_external_skill() {
655 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 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 #[test]
776 fn check_with_unresolvable_dep_fails_closed_with_remediation_hint() {
777 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]\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 #[test]
805 fn check_missing_skill_in_resolved_graph_is_error() {
806 let dir = TempDir::new().unwrap();
809 let dep_dir = TempDir::new().unwrap();
810
811 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 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 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 #[test]
860 fn check_skill_provided_by_path_dep_passes() {
861 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 #[test]
887 fn check_excluded_skill_in_dep_is_not_available() {
888 let dir = TempDir::new().unwrap();
891 let dep_dir = TempDir::new().unwrap();
892
893 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 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 #[test]
951 fn check_local_dependency_skill_does_not_satisfy_ref() {
952 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 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 let report = super::check_dir(dir.path()).unwrap();
973 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 let dir = TempDir::new().unwrap();
990 let regular_dep_dir = TempDir::new().unwrap();
991 let local_dep_dir = TempDir::new().unwrap();
992
993 write_dep_package(
995 regular_dep_dir.path(),
996 "regular-dep",
997 "0.1.0",
998 &["unrelated-skill"],
999 );
1000 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 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}