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