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