1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticLevel};
7use crate::error::{ConfigError, MarsError};
8use crate::types::managed_cmd;
9use crate::types::{
10 ItemName, RenameMap, SourceId, SourceName, SourceOrigin, SourceSubpath, SourceUrl,
11};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
15pub struct Config {
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub package: Option<PackageInfo>,
18 #[serde(default)]
19 pub dependencies: IndexMap<SourceName, InstallDep>,
20 #[serde(
24 default,
25 skip_serializing_if = "IndexMap::is_empty",
26 rename = "local-dependencies"
27 )]
28 pub local_dependencies: IndexMap<SourceName, InstallDep>,
29 #[serde(default)]
30 pub settings: Settings,
31 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
32 pub models: IndexMap<String, crate::models::ModelAlias>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct PackageInfo {
38 pub name: String,
39 pub version: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub description: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub primary_agent: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub targets: Option<PackageTargets>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
50pub struct PackageTargets {
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub required: Vec<String>,
53}
54
55mod toml_path_serde {
56 use serde::{Deserialize, Deserializer, Serializer};
57 use std::path::{Path, PathBuf};
58
59 pub fn serialize<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
60 where
61 S: Serializer,
62 {
63 let s = path.to_string_lossy().replace('\\', "/");
64 serializer.serialize_str(&s)
65 }
66
67 pub fn deserialize<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
68 where
69 D: Deserializer<'de>,
70 {
71 let s = String::deserialize(deserializer)?;
72 Ok(PathBuf::from(s))
73 }
74}
75
76mod toml_path_serde_opt {
77 use serde::{Deserialize, Deserializer, Serializer};
78 use std::path::PathBuf;
79
80 pub fn serialize<S>(path: &Option<PathBuf>, serializer: S) -> Result<S::Ok, S::Error>
81 where
82 S: Serializer,
83 {
84 match path {
85 Some(path) => {
86 let s = path.to_string_lossy().replace('\\', "/");
87 serializer.serialize_some(&s)
88 }
89 None => serializer.serialize_none(),
90 }
91 }
92
93 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<PathBuf>, D::Error>
94 where
95 D: Deserializer<'de>,
96 {
97 let s = Option::<String>::deserialize(deserializer)?;
98 Ok(s.map(PathBuf::from))
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
105pub struct InstallDep {
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub url: Option<SourceUrl>,
108 #[serde(
109 default,
110 skip_serializing_if = "Option::is_none",
111 with = "toml_path_serde_opt"
112 )]
113 pub path: Option<PathBuf>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub subpath: Option<SourceSubpath>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub version: Option<String>,
118 #[serde(flatten)]
119 pub filter: FilterConfig,
120}
121
122pub type DependencyEntry = InstallDep;
124
125#[derive(Debug, Clone, PartialEq)]
128pub struct ManifestDep {
129 pub url: Option<SourceUrl>,
130 pub path: Option<PathBuf>,
131 pub subpath: Option<SourceSubpath>,
132 pub version: Option<String>,
133 pub filter: FilterConfig,
134}
135
136#[derive(Debug, Clone, PartialEq)]
142pub struct Manifest {
143 pub package: PackageInfo,
144 pub dependencies: IndexMap<String, ManifestDep>,
145 pub models: IndexMap<String, crate::models::ModelAlias>,
146}
147
148#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
150pub struct FilterConfig {
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub agents: Option<Vec<ItemName>>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub skills: Option<Vec<ItemName>>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub exclude: Option<Vec<ItemName>>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub rename: Option<RenameMap>,
159 #[serde(default, skip_serializing_if = "is_false")]
160 pub only_skills: bool,
161 #[serde(default, skip_serializing_if = "is_false")]
162 pub only_agents: bool,
163}
164
165#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
168pub struct ModelVisibility {
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub include: Option<Vec<String>>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub exclude: Option<Vec<String>>,
175}
176
177impl ModelVisibility {
178 pub fn validate(&self) -> Result<(), MarsError> {
179 Ok(())
180 }
181
182 pub fn is_empty(&self) -> bool {
183 self.include.is_none() && self.exclude.is_none()
184 }
185}
186
187fn is_false(v: &bool) -> bool {
188 !v
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
196pub struct LocalConfig {
197 #[serde(default)]
198 pub overrides: IndexMap<SourceName, OverrideEntry>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203pub struct OverrideEntry {
204 #[serde(with = "toml_path_serde")]
205 pub path: PathBuf,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
210pub struct Settings {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub managed_root: Option<String>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub targets: Option<Vec<String>>,
224 #[serde(default, skip_serializing_if = "ModelVisibility::is_empty")]
225 pub model_visibility: ModelVisibility,
226 #[serde(default = "default_models_cache_ttl_hours")]
227 pub models_cache_ttl_hours: u32,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub min_mars_version: Option<String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub agent_emission: Option<AgentEmission>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243#[serde(rename_all = "lowercase")]
244pub enum AgentEmission {
245 Auto,
246 Always,
247 Never,
248}
249
250impl Default for Settings {
251 fn default() -> Self {
252 Self {
253 managed_root: None,
254 targets: None,
255 model_visibility: ModelVisibility::default(),
256 models_cache_ttl_hours: default_models_cache_ttl_hours(),
257 min_mars_version: None,
258 agent_emission: None,
259 }
260 }
261}
262
263fn default_models_cache_ttl_hours() -> u32 {
264 24
265}
266
267impl Settings {
268 pub fn managed_targets(&self) -> Vec<String> {
275 if let Some(targets) = &self.targets {
276 return targets.clone();
277 }
278 self.managed_root.clone().into_iter().collect()
279 }
280}
281
282#[derive(Debug, Clone)]
284pub enum SourceSpec {
285 Git(GitSpec),
286 Path(PathBuf),
287}
288
289#[derive(Debug, Clone)]
291pub struct GitSpec {
292 pub url: SourceUrl,
293 pub version: Option<String>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum FilterMode {
299 All,
301 Include {
303 agents: Vec<ItemName>,
304 skills: Vec<ItemName>,
305 },
306 Exclude(Vec<ItemName>),
308 OnlySkills,
310 OnlyAgents,
312}
313
314#[derive(Debug, Clone)]
318pub struct EffectiveConfig {
319 pub dependencies: IndexMap<SourceName, EffectiveDependency>,
320 pub settings: Settings,
321}
322
323#[derive(Debug, Clone)]
325pub struct EffectiveDependency {
326 pub name: SourceName,
327 pub id: SourceId,
328 pub spec: SourceSpec,
329 pub subpath: Option<SourceSubpath>,
330 pub filter: FilterMode,
331 pub rename: RenameMap,
332 pub is_overridden: bool,
333 pub original_git: Option<GitSpec>,
334}
335
336const CONFIG_FILE: &str = "mars.toml";
337const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
338
339pub fn load(root: &Path) -> Result<Config, MarsError> {
341 let path = root.join(CONFIG_FILE);
342 let content = std::fs::read_to_string(&path).map_err(|e| {
343 if e.kind() == std::io::ErrorKind::NotFound {
344 ConfigError::NotFound { path: path.clone() }
345 } else {
346 ConfigError::Io(e)
347 }
348 })?;
349 let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
350 migrate_legacy_source_urls(&mut config);
351 Ok(config)
352}
353
354pub fn load_manifest(source_root: &Path) -> Result<(Option<Manifest>, Vec<Diagnostic>), MarsError> {
362 let path = source_root.join(CONFIG_FILE);
363 let mut diagnostics = Vec::new();
364 match std::fs::read_to_string(&path) {
365 Ok(content) => {
366 let parsed: Config =
367 toml::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
368 message: format!("failed to parse {}: {e}", path.display()),
369 })?;
370 let Some(package) = parsed.package else {
371 return Ok((None, diagnostics));
372 };
373 if let Some(ref targets) = package.targets {
376 for target_name in &targets.required {
377 if crate::cli::target::validate_target(target_name).is_err() {
378 diagnostics.push(Diagnostic {
379 level: DiagnosticLevel::Warning,
380 code: "invalid-package-target",
381 message: format!(
382 "package target `{}` is not a valid target name",
383 target_name
384 ),
385 context: Some("package.targets.required".to_string()),
386 category: Some(DiagnosticCategory::Compatibility),
387 });
388 }
389 }
390 }
391 let deps: IndexMap<String, ManifestDep> = parsed
393 .dependencies
394 .into_iter()
395 .map(|(name, entry)| {
396 (
397 name.to_string(),
398 ManifestDep {
399 url: entry.url,
400 path: entry.path,
401 subpath: entry.subpath,
402 version: entry.version,
403 filter: entry.filter,
404 },
405 )
406 })
407 .collect();
408 Ok((
409 Some(Manifest {
410 package,
411 dependencies: deps,
412 models: parsed.models,
413 }),
414 diagnostics,
415 ))
416 }
417 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((None, diagnostics)),
418 Err(source) => Err(MarsError::Io {
419 operation: "read manifest config".to_string(),
420 path,
421 source,
422 }),
423 }
424}
425
426pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
428 let path = root.join(LOCAL_CONFIG_FILE);
429 match std::fs::read_to_string(&path) {
430 Ok(content) => {
431 let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
432 Ok(local)
433 }
434 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
435 Err(e) => Err(ConfigError::Io(e).into()),
436 }
437}
438
439pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
446 let (effective, _diagnostics) = merge_with_root(config, local, Path::new("."))?;
447 Ok(effective)
448}
449
450pub fn merge_with_root(
452 config: Config,
453 local: LocalConfig,
454 root: &Path,
455) -> Result<(EffectiveConfig, Vec<Diagnostic>), MarsError> {
456 config.settings.model_visibility.validate()?;
457 let mut dependencies = IndexMap::new();
458 let mut diagnostics = Vec::new();
459 let local_source_name = SourceOrigin::LocalPackage.to_string();
460
461 diagnostics.extend(deprecated_agents_target_diagnostics(&config.settings));
462
463 let all_deps = config
466 .dependencies
467 .iter()
468 .chain(config.local_dependencies.iter());
469
470 for (name, entry) in all_deps {
471 if name.as_ref() == local_source_name.as_str() {
473 return Err(ConfigError::Invalid {
474 message: "dependency name `_self` is reserved for local package items".into(),
475 }
476 .into());
477 }
478
479 if dependencies.contains_key(name) {
481 return Err(ConfigError::Invalid {
482 message: format!(
483 "dependency `{name}` appears in both [dependencies] and [local-dependencies]"
484 ),
485 }
486 .into());
487 }
488
489 let base_spec = match (&entry.url, &entry.path) {
491 (Some(url), None) => SourceSpec::Git(GitSpec {
492 url: url.clone(),
493 version: entry.version.clone(),
494 }),
495 (None, Some(path)) => SourceSpec::Path(path.clone()),
496 (Some(_), Some(_)) => {
497 return Err(ConfigError::Invalid {
498 message: format!("source `{name}` has both `url` and `path` — pick one"),
499 }
500 .into());
501 }
502 (None, None) => {
503 return Err(ConfigError::Invalid {
504 message: format!(
505 "source `{name}` has neither `url` nor `path` — one is required"
506 ),
507 }
508 .into());
509 }
510 };
511
512 validate_filter(&entry.filter, name.as_ref())?;
514
515 let filter = entry.filter.to_mode();
516
517 let rename = entry.filter.rename.clone().unwrap_or_default();
518
519 let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
521 let original = match &base_spec {
522 SourceSpec::Git(git) => Some(git.clone()),
523 SourceSpec::Path(_) => None,
524 };
525 (SourceSpec::Path(ov.path.clone()), true, original)
526 } else {
527 (base_spec, false, None)
528 };
529 let subpath = entry.subpath.clone();
530 let id = source_id_for_spec(root, &spec, subpath.clone());
531
532 dependencies.insert(
533 name.clone(),
534 EffectiveDependency {
535 name: name.clone(),
536 id,
537 spec,
538 subpath,
539 filter,
540 rename,
541 is_overridden,
542 original_git,
543 },
544 );
545 }
546
547 for override_name in local.overrides.keys() {
549 if !config.dependencies.contains_key(override_name) {
550 diagnostics.push(Diagnostic {
551 level: DiagnosticLevel::Warning,
552 code: "override-missing-dep",
553 message: format!(
554 "override `{override_name}` references a dependency not in mars.toml"
555 ),
556 context: None,
557 category: None,
558 });
559 }
560 }
561
562 Ok((
563 EffectiveConfig {
564 dependencies,
565 settings: config.settings,
566 },
567 diagnostics,
568 ))
569}
570
571fn deprecated_agents_target_diagnostics(settings: &Settings) -> Vec<Diagnostic> {
572 let mut diagnostics = Vec::new();
573
574 if settings.managed_root.as_deref() == Some(".agents") {
575 diagnostics.push(deprecated_agents_target_diagnostic("settings.managed_root"));
576 }
577
578 if settings
579 .targets
580 .as_ref()
581 .is_some_and(|targets| targets.iter().any(|target| target == ".agents"))
582 {
583 diagnostics.push(deprecated_agents_target_diagnostic("settings.targets"));
584 }
585
586 diagnostics
587}
588
589fn deprecated_agents_target_diagnostic(context: &str) -> Diagnostic {
590 Diagnostic {
591 level: DiagnosticLevel::Warning,
592 code: "deprecated-agents-target",
593 message: format!(
594 "`.agents` is a deprecated link target. Run `{}` to remove it. Skills are now emitted to native harness dirs automatically.",
595 managed_cmd("mars unlink .agents"),
596 ),
597 context: Some(context.to_string()),
598 category: Some(DiagnosticCategory::Compatibility),
599 }
600}
601
602pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
610 let has_include = filter.agents.is_some() || filter.skills.is_some();
611 let has_exclude = filter.exclude.is_some();
612 let has_category = filter.only_skills || filter.only_agents;
613
614 if filter.only_skills && filter.only_agents {
615 return Err(ConfigError::Invalid {
616 message: format!(
617 "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
618 ),
619 }
620 .into());
621 }
622 if has_category && has_include {
623 return Err(ConfigError::Invalid {
624 message: format!(
625 "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
626 ),
627 }
628 .into());
629 }
630 if has_category && has_exclude {
631 return Err(ConfigError::Invalid {
632 message: format!(
633 "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
634 ),
635 }
636 .into());
637 }
638 if has_include && has_exclude {
639 return Err(ConfigError::ConflictingFilters {
640 name: dep_name.to_string(),
641 }
642 .into());
643 }
644 Ok(())
645}
646
647impl FilterConfig {
648 pub fn to_mode(&self) -> FilterMode {
650 if self.only_skills {
651 FilterMode::OnlySkills
652 } else if self.only_agents {
653 FilterMode::OnlyAgents
654 } else if self.agents.is_some() || self.skills.is_some() {
655 FilterMode::Include {
656 agents: self.agents.clone().unwrap_or_default(),
657 skills: self.skills.clone().unwrap_or_default(),
658 }
659 } else if self.exclude.is_some() {
660 FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
661 } else {
662 FilterMode::All
663 }
664 }
665
666 pub fn has_any_filter(&self) -> bool {
668 self.agents.is_some()
669 || self.skills.is_some()
670 || self.exclude.is_some()
671 || self.only_skills
672 || self.only_agents
673 }
674}
675
676fn source_id_for_spec(root: &Path, spec: &SourceSpec, subpath: Option<SourceSubpath>) -> SourceId {
677 match spec {
678 SourceSpec::Git(git) => {
679 let canonical_url = SourceUrl::from(crate::source::canonical::canonicalize_git_url(
680 git.url.as_ref(),
681 ));
682 SourceId::git_with_subpath(canonical_url, subpath.clone())
683 }
684 SourceSpec::Path(path) => match SourceId::path_with_subpath(root, path, subpath.clone()) {
685 Ok(id) => id,
686 Err(_) => {
687 let canonical = if path.is_absolute() {
688 path.clone()
689 } else {
690 root.join(path)
691 };
692 SourceId::Path { canonical, subpath }
693 }
694 },
695 }
696}
697
698fn migrate_legacy_source_urls(config: &mut Config) {
699 for dep in config
700 .dependencies
701 .values_mut()
702 .chain(config.local_dependencies.values_mut())
703 {
704 if let Some(url) = dep.url.as_mut() {
705 let raw = url.as_str();
706 if should_upgrade_legacy_git_url(raw) {
707 *url = SourceUrl::from(format!("https://{raw}"));
708 }
709 }
710 }
711}
712
713fn should_upgrade_legacy_git_url(url: &str) -> bool {
714 !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
715}
716
717pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
719 let path = root.join(CONFIG_FILE);
720 let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
721 message: format!("failed to serialize config: {e}"),
722 })?;
723 let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
724 message: format!("refusing to save config: serialized output failed to parse: {e}"),
725 })?;
726 validate_save_roundtrip(config, &reparsed)?;
727 crate::fs::atomic_write(&path, content.as_bytes())
728}
729
730fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
731 if reparsed.dependencies.len() != original.dependencies.len() {
732 return Err(ConfigError::Invalid {
733 message: format!(
734 "refusing to save config: dependency count changed during roundtrip ({} -> {})",
735 original.dependencies.len(),
736 reparsed.dependencies.len()
737 ),
738 }
739 .into());
740 }
741
742 if reparsed.local_dependencies.len() != original.local_dependencies.len() {
743 return Err(ConfigError::Invalid {
744 message: format!(
745 "refusing to save config: local-dependencies count changed during roundtrip ({} -> {})",
746 original.local_dependencies.len(),
747 reparsed.local_dependencies.len()
748 ),
749 }
750 .into());
751 }
752
753 if reparsed.settings.managed_root != original.settings.managed_root {
754 return Err(ConfigError::Invalid {
755 message: format!(
756 "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
757 original.settings.managed_root, reparsed.settings.managed_root
758 ),
759 }
760 .into());
761 }
762 if reparsed.settings.model_visibility != original.settings.model_visibility {
763 return Err(ConfigError::Invalid {
764 message: format!(
765 "refusing to save config: settings.model_visibility changed during roundtrip ({:?} -> {:?})",
766 original.settings.model_visibility, reparsed.settings.model_visibility
767 ),
768 }
769 .into());
770 }
771 if reparsed.settings.agent_emission != original.settings.agent_emission {
772 return Err(ConfigError::Invalid {
773 message: format!(
774 "refusing to save config: settings.agent_emission changed during roundtrip ({:?} -> {:?})",
775 original.settings.agent_emission, reparsed.settings.agent_emission
776 ),
777 }
778 .into());
779 }
780
781 for (name, dep) in &original.dependencies {
782 let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
783 return Err(ConfigError::Invalid {
784 message: format!(
785 "refusing to save config: dependency `{name}` missing after roundtrip"
786 ),
787 }
788 .into());
789 };
790
791 if reparsed_dep != dep {
792 return Err(ConfigError::Invalid {
793 message: format!(
794 "refusing to save config: dependency `{name}` changed during roundtrip"
795 ),
796 }
797 .into());
798 }
799 }
800
801 for (name, dep) in &original.local_dependencies {
802 let Some(reparsed_dep) = reparsed.local_dependencies.get(name) else {
803 return Err(ConfigError::Invalid {
804 message: format!(
805 "refusing to save config: local-dependency `{name}` missing after roundtrip"
806 ),
807 }
808 .into());
809 };
810
811 if reparsed_dep != dep {
812 return Err(ConfigError::Invalid {
813 message: format!(
814 "refusing to save config: local-dependency `{name}` changed during roundtrip"
815 ),
816 }
817 .into());
818 }
819 }
820
821 Ok(())
822}
823
824pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
826 let path = root.join(LOCAL_CONFIG_FILE);
827 let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
828 message: format!("failed to serialize local config: {e}"),
829 })?;
830 crate::fs::atomic_write(&path, content.as_bytes())
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836 use tempfile::TempDir;
837
838 #[test]
839 fn parse_git_dependency() {
840 let toml_str = r#"
841[dependencies.base]
842url = "https://github.com/org/base.git"
843version = "v1.0"
844"#;
845 let config: Config = toml::from_str(toml_str).unwrap();
846 assert_eq!(config.dependencies.len(), 1);
847 let entry = &config.dependencies["base"];
848 assert_eq!(
849 entry.url.as_deref(),
850 Some("https://github.com/org/base.git")
851 );
852 assert!(entry.path.is_none());
853 assert_eq!(entry.version.as_deref(), Some("v1.0"));
854 }
855
856 #[test]
857 fn parse_path_dependency() {
858 let toml_str = r#"
859[dependencies.local]
860path = "../my-agents"
861"#;
862 let config: Config = toml::from_str(toml_str).unwrap();
863 let entry = &config.dependencies["local"];
864 assert!(entry.url.is_none());
865 assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
866 }
867
868 #[test]
869 fn parse_mixed_dependencies() {
870 let toml_str = r#"
871[dependencies.remote]
872url = "https://github.com/org/remote.git"
873version = "v2.0"
874agents = ["coder", "reviewer"]
875
876[dependencies.local]
877path = "/home/dev/agents"
878exclude = ["experimental"]
879"#;
880 let config: Config = toml::from_str(toml_str).unwrap();
881 assert_eq!(config.dependencies.len(), 2);
882 assert!(config.dependencies.contains_key("remote"));
883 assert!(config.dependencies.contains_key("local"));
884 }
885
886 #[test]
887 fn parse_package_and_dependencies_coexist() {
888 let toml_str = r#"
889[package]
890name = "my-agents"
891version = "0.1.0"
892
893[dependencies.base]
894url = "https://github.com/org/base.git"
895version = ">=1.0.0"
896
897[dependencies.local]
898path = "../local-agents"
899"#;
900 let config: Config = toml::from_str(toml_str).unwrap();
901 assert!(config.package.is_some());
902 assert!(config.dependencies.contains_key("base"));
903 assert!(config.dependencies.contains_key("local"));
904 }
905
906 #[test]
907 fn parse_include_filter() {
908 let toml_str = r#"
909[dependencies.base]
910url = "https://github.com/org/base.git"
911agents = ["coder"]
912skills = ["review"]
913"#;
914 let config: Config = toml::from_str(toml_str).unwrap();
915 let local = LocalConfig::default();
916 let effective = merge(config, local).unwrap();
917 let source = &effective.dependencies["base"];
918 match &source.filter {
919 FilterMode::Include { agents, skills } => {
920 assert_eq!(agents, &["coder"]);
921 assert_eq!(skills, &["review"]);
922 }
923 other => panic!("expected Include, got {other:?}"),
924 }
925 }
926
927 #[test]
928 fn parse_exclude_filter() {
929 let toml_str = r#"
930[dependencies.base]
931url = "https://github.com/org/base.git"
932exclude = ["experimental", "deprecated"]
933"#;
934 let config: Config = toml::from_str(toml_str).unwrap();
935 let local = LocalConfig::default();
936 let effective = merge(config, local).unwrap();
937 let source = &effective.dependencies["base"];
938 match &source.filter {
939 FilterMode::Exclude(items) => {
940 assert_eq!(items, &["experimental", "deprecated"]);
941 }
942 other => panic!("expected Exclude, got {other:?}"),
943 }
944 }
945
946 #[test]
947 fn error_on_both_include_and_exclude() {
948 let toml_str = r#"
949[dependencies.bad]
950url = "https://github.com/org/bad.git"
951agents = ["coder"]
952exclude = ["reviewer"]
953"#;
954 let config: Config = toml::from_str(toml_str).unwrap();
955 let local = LocalConfig::default();
956 let result = merge(config, local);
957 assert!(result.is_err());
958 let err = result.unwrap_err().to_string();
959 assert!(
960 err.contains("bad"),
961 "error should mention dependency name: {err}"
962 );
963 }
964
965 #[test]
966 fn error_on_neither_url_nor_path() {
967 let toml_str = r#"
968[dependencies.empty]
969version = "v1.0"
970"#;
971 let config: Config = toml::from_str(toml_str).unwrap();
972 let local = LocalConfig::default();
973 let result = merge(config, local);
974 assert!(result.is_err());
975 let err = result.unwrap_err().to_string();
976 assert!(
977 err.contains("neither"),
978 "error should mention 'neither': {err}"
979 );
980 }
981
982 #[test]
983 fn error_on_both_url_and_path() {
984 let toml_str = r#"
985[dependencies.both]
986url = "https://github.com/org/repo.git"
987path = "/local/path"
988"#;
989 let config: Config = toml::from_str(toml_str).unwrap();
990 let local = LocalConfig::default();
991 let result = merge(config, local);
992 assert!(result.is_err());
993 let err = result.unwrap_err().to_string();
994 assert!(err.contains("both"), "error should mention 'both': {err}");
995 }
996
997 #[test]
998 fn roundtrip_full_config_shape_survives_save() {
999 let dir = TempDir::new().unwrap();
1000 let original = r#"
1001[package]
1002name = "sample"
1003version = "0.1.0"
1004description = "sample package"
1005primary_agent = "coder"
1006
1007[package.targets]
1008required = [".claude"]
1009
1010[dependencies.base]
1011url = "https://github.com/org/base.git"
1012version = "v1.0"
1013agents = ["coder", "reviewer"]
1014
1015[dependencies.local]
1016path = "../local-agents"
1017exclude = ["experimental"]
1018
1019[settings]
1020managed_root = ".custom-agents"
1021targets = [".claude", ".cursor"]
1022"#;
1023 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1024
1025 let config = load(dir.path()).unwrap();
1026 save(dir.path(), &config).unwrap();
1027 let reloaded = load(dir.path()).unwrap();
1028
1029 assert_eq!(
1030 reloaded.package.as_ref().map(|p| p.name.as_str()),
1031 Some("sample")
1032 );
1033 assert_eq!(
1034 reloaded
1035 .package
1036 .as_ref()
1037 .and_then(|p| p.primary_agent.as_deref()),
1038 Some("coder")
1039 );
1040 assert_eq!(
1041 reloaded
1042 .package
1043 .as_ref()
1044 .and_then(|p| p.targets.as_ref())
1045 .map(|t| t.required.as_slice()),
1046 Some(&[".claude".to_string()][..])
1047 );
1048 assert_eq!(reloaded.dependencies.len(), 2);
1049 assert_eq!(
1050 reloaded.dependencies["base"].url.as_deref(),
1051 Some("https://github.com/org/base.git")
1052 );
1053 assert_eq!(
1054 reloaded.dependencies["local"].path.as_deref(),
1055 Some(Path::new("../local-agents"))
1056 );
1057 assert_eq!(
1058 reloaded.settings.managed_root.as_deref(),
1059 Some(".custom-agents")
1060 );
1061 assert_eq!(
1062 reloaded.settings.targets,
1063 Some(vec![".claude".to_string(), ".cursor".to_string()])
1064 );
1065 }
1066
1067 #[test]
1068 fn load_from_disk() {
1069 let dir = TempDir::new().unwrap();
1070 let toml_str = r#"
1071[dependencies.base]
1072url = "https://github.com/org/base.git"
1073version = "v1.0"
1074"#;
1075 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1076 let config = load(dir.path()).unwrap();
1077 assert_eq!(config.dependencies.len(), 1);
1078 }
1079
1080 #[test]
1081 fn load_migrates_legacy_bare_domain_url() {
1082 let dir = TempDir::new().unwrap();
1083 let toml_str = r#"
1084[dependencies.base]
1085url = "github.com/org/base"
1086"#;
1087 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1088
1089 let config = load(dir.path()).unwrap();
1090 assert_eq!(
1091 config.dependencies["base"].url.as_deref(),
1092 Some("https://github.com/org/base")
1093 );
1094 }
1095
1096 #[test]
1097 fn load_does_not_migrate_ssh_url() {
1098 let dir = TempDir::new().unwrap();
1099 let toml_str = r#"
1100[dependencies.base]
1101url = "git@github.com:org/base.git"
1102"#;
1103 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1104
1105 let config = load(dir.path()).unwrap();
1106 assert_eq!(
1107 config.dependencies["base"].url.as_deref(),
1108 Some("git@github.com:org/base.git")
1109 );
1110 }
1111
1112 #[test]
1113 fn load_missing_file_returns_not_found() {
1114 let dir = TempDir::new().unwrap();
1115 let result = load(dir.path());
1116 assert!(result.is_err());
1117 let err = result.unwrap_err().to_string();
1118 assert!(err.contains("not found"), "should be NotFound: {err}");
1119 }
1120
1121 #[test]
1122 fn load_manifest_returns_none_without_package() {
1123 let dir = TempDir::new().unwrap();
1124 std::fs::write(
1125 dir.path().join("mars.toml"),
1126 r#"
1127[dependencies.base]
1128url = "https://github.com/org/base.git"
1129"#,
1130 )
1131 .unwrap();
1132
1133 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1134 assert!(diagnostics.is_empty());
1135 assert!(manifest.is_none());
1136 }
1137
1138 #[test]
1139 fn load_manifest_returns_package_and_dependencies() {
1140 let dir = TempDir::new().unwrap();
1141 std::fs::write(
1142 dir.path().join("mars.toml"),
1143 r#"
1144[package]
1145name = "pkg"
1146version = "1.2.3"
1147
1148[dependencies.base]
1149url = "https://github.com/org/base.git"
1150version = ">=1.0.0"
1151skills = ["frontend-design"]
1152"#,
1153 )
1154 .unwrap();
1155
1156 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1157 assert!(diagnostics.is_empty());
1158 let manifest = manifest.unwrap();
1159 assert_eq!(manifest.package.name, "pkg");
1160 assert_eq!(manifest.package.version, "1.2.3");
1161 assert!(manifest.dependencies.contains_key("base"));
1162 assert_eq!(
1163 manifest.dependencies["base"].filter.skills.as_deref(),
1164 Some(&[ItemName::from("frontend-design")][..])
1165 );
1166 }
1167
1168 #[test]
1169 fn load_manifest_io_error_includes_operation_and_path() {
1170 let dir = TempDir::new().unwrap();
1171 let config_path = dir.path().join("mars.toml");
1172 std::fs::create_dir(&config_path).unwrap();
1173
1174 let err = load_manifest(dir.path()).unwrap_err();
1175 let msg = err.to_string();
1176
1177 assert!(
1178 msg.contains("read manifest config"),
1179 "error should include operation context: {msg}"
1180 );
1181 assert!(
1182 msg.contains("mars.toml"),
1183 "error should include config path: {msg}"
1184 );
1185 }
1186
1187 #[test]
1188 fn load_local_missing_returns_default() {
1189 let dir = TempDir::new().unwrap();
1190 let local = load_local(dir.path()).unwrap();
1191 assert!(local.overrides.is_empty());
1192 }
1193
1194 #[test]
1195 fn load_local_from_disk() {
1196 let dir = TempDir::new().unwrap();
1197 let toml_str = r#"
1198[overrides.base]
1199path = "/home/dev/local-base"
1200"#;
1201 std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
1202 let local = load_local(dir.path()).unwrap();
1203 assert_eq!(local.overrides.len(), 1);
1204 assert_eq!(
1205 local.overrides["base"].path,
1206 PathBuf::from("/home/dev/local-base")
1207 );
1208 }
1209
1210 #[test]
1211 fn merge_with_empty_local() {
1212 let config = Config {
1213 dependencies: {
1214 let mut m = IndexMap::new();
1215 m.insert(
1216 "base".into(),
1217 DependencyEntry {
1218 url: Some("https://github.com/org/base.git".into()),
1219 path: None,
1220 subpath: None,
1221 version: Some("v1.0".into()),
1222 filter: FilterConfig::default(),
1223 },
1224 );
1225 m
1226 },
1227 settings: Settings::default(),
1228 ..Config::default()
1229 };
1230 let local = LocalConfig::default();
1231 let effective = merge(config, local).unwrap();
1232 assert_eq!(effective.dependencies.len(), 1);
1233 let source = &effective.dependencies["base"];
1234 assert!(!source.is_overridden);
1235 assert!(source.original_git.is_none());
1236 match &source.spec {
1237 SourceSpec::Git(git) => {
1238 assert_eq!(git.url, "https://github.com/org/base.git");
1239 assert_eq!(git.version.as_deref(), Some("v1.0"));
1240 }
1241 SourceSpec::Path(_) => panic!("expected Git"),
1242 }
1243 }
1244
1245 #[test]
1246 fn merge_override_replaces_with_path() {
1247 let config = Config {
1248 dependencies: {
1249 let mut m = IndexMap::new();
1250 m.insert(
1251 "base".into(),
1252 DependencyEntry {
1253 url: Some("https://github.com/org/base.git".into()),
1254 path: None,
1255 subpath: None,
1256 version: Some("v1.0".into()),
1257 filter: FilterConfig::default(),
1258 },
1259 );
1260 m
1261 },
1262 settings: Settings::default(),
1263 ..Config::default()
1264 };
1265 let local = LocalConfig {
1266 overrides: {
1267 let mut m = IndexMap::new();
1268 m.insert(
1269 "base".into(),
1270 OverrideEntry {
1271 path: PathBuf::from("/home/dev/local-base"),
1272 },
1273 );
1274 m
1275 },
1276 };
1277 let effective = merge(config, local).unwrap();
1278 let source = &effective.dependencies["base"];
1279 assert!(source.is_overridden);
1280
1281 match &source.spec {
1282 SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
1283 SourceSpec::Git(_) => panic!("expected Path override"),
1284 }
1285
1286 let orig = source.original_git.as_ref().unwrap();
1287 assert_eq!(orig.url, "https://github.com/org/base.git");
1288 assert_eq!(orig.version.as_deref(), Some("v1.0"));
1289 }
1290
1291 #[test]
1292 fn merge_override_retains_subpath_coordinate() {
1293 let temp = TempDir::new().unwrap();
1294 let temp_root = dunce::canonicalize(temp.path()).unwrap();
1296 let override_path = temp_root.join("local-base");
1297 std::fs::create_dir_all(&override_path).unwrap();
1298 let canonical_override = dunce::canonicalize(&override_path).unwrap();
1299
1300 let config = Config {
1301 dependencies: {
1302 let mut m = IndexMap::new();
1303 m.insert(
1304 "base".into(),
1305 DependencyEntry {
1306 url: Some("https://github.com/org/base.git".into()),
1307 path: None,
1308 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1309 version: Some("v1.0".into()),
1310 filter: FilterConfig::default(),
1311 },
1312 );
1313 m
1314 },
1315 settings: Settings::default(),
1316 ..Config::default()
1317 };
1318 let local = LocalConfig {
1319 overrides: {
1320 let mut m = IndexMap::new();
1321 m.insert(
1322 "base".into(),
1323 OverrideEntry {
1324 path: canonical_override.clone(),
1325 },
1326 );
1327 m
1328 },
1329 };
1330
1331 let (effective, _) = merge_with_root(config, local, &temp_root).unwrap();
1332 let source = &effective.dependencies["base"];
1333 assert!(source.is_overridden);
1334 assert_eq!(
1335 source.subpath.as_ref().map(SourceSubpath::as_str),
1336 Some("plugins/foo")
1337 );
1338 assert!(matches!(&source.spec, SourceSpec::Path(p) if p == &canonical_override));
1339 assert!(matches!(
1340 &source.id,
1341 SourceId::Path {
1342 canonical,
1343 subpath: Some(sp)
1344 } if canonical == &canonical_override && sp.as_str() == "plugins/foo"
1345 ));
1346 }
1347
1348 #[test]
1349 fn merge_all_filter_mode() {
1350 let config = Config {
1351 dependencies: {
1352 let mut m = IndexMap::new();
1353 m.insert(
1354 "base".into(),
1355 DependencyEntry {
1356 url: Some("https://github.com/org/base.git".into()),
1357 path: None,
1358 subpath: None,
1359 version: None,
1360 filter: FilterConfig::default(),
1361 },
1362 );
1363 m
1364 },
1365 settings: Settings::default(),
1366 ..Config::default()
1367 };
1368 let effective = merge(config, LocalConfig::default()).unwrap();
1369 assert!(matches!(
1370 effective.dependencies["base"].filter,
1371 FilterMode::All
1372 ));
1373 }
1374
1375 #[test]
1376 fn save_and_reload() {
1377 let dir = TempDir::new().unwrap();
1378 let config = Config {
1379 dependencies: {
1380 let mut m = IndexMap::new();
1381 m.insert(
1382 "base".into(),
1383 DependencyEntry {
1384 url: Some("https://github.com/org/base.git".into()),
1385 path: None,
1386 subpath: None,
1387 version: Some("v2.0".into()),
1388 filter: FilterConfig::default(),
1389 },
1390 );
1391 m
1392 },
1393 settings: Settings::default(),
1394 ..Config::default()
1395 };
1396 save(dir.path(), &config).unwrap();
1397 let reloaded = load(dir.path()).unwrap();
1398 assert_eq!(config, reloaded);
1399 }
1400
1401 #[test]
1402 fn rename_map_preserved() {
1403 let toml_str = r#"
1404[dependencies.base]
1405url = "https://github.com/org/base.git"
1406
1407[dependencies.base.rename]
1408old-name = "new-name"
1409"#;
1410 let config: Config = toml::from_str(toml_str).unwrap();
1411 let effective = merge(config, LocalConfig::default()).unwrap();
1412 let source = &effective.dependencies["base"];
1413 assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
1414 }
1415
1416 #[test]
1417 fn self_dependency_name_rejected() {
1418 let toml_str = r#"
1419[dependencies._self]
1420url = "https://github.com/org/base.git"
1421"#;
1422 let config: Config = toml::from_str(toml_str).unwrap();
1423 let local = LocalConfig::default();
1424 let result = merge(config, local);
1425 assert!(result.is_err());
1426 let err = result.unwrap_err().to_string();
1427 assert!(
1428 err.contains("_self") && err.contains("reserved"),
1429 "should reject _self: {err}"
1430 );
1431 }
1432
1433 #[test]
1434 fn managed_root_setting_roundtrip() {
1435 let config = Config {
1436 settings: Settings {
1437 managed_root: Some(".claude".into()),
1438 targets: None,
1439 ..Settings::default()
1440 },
1441 ..Config::default()
1442 };
1443 let serialized = toml::to_string_pretty(&config).unwrap();
1444 let deserialized: Config = toml::from_str(&serialized).unwrap();
1445 assert_eq!(
1446 deserialized.settings.managed_root.as_deref(),
1447 Some(".claude")
1448 );
1449 }
1450
1451 #[test]
1452 fn save_preserves_dependencies_when_clearing_last_target() {
1453 let dir = TempDir::new().unwrap();
1454 let original = r#"
1455[package]
1456name = "sample"
1457version = "0.1.0"
1458
1459[dependencies.base]
1460url = "https://github.com/org/base.git"
1461version = "v1.0"
1462agents = ["coder"]
1463
1464[settings]
1465managed_root = ".agents"
1466targets = [".claude"]
1467"#;
1468 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1469
1470 let mut config = load(dir.path()).unwrap();
1471 if let Some(targets) = config.settings.targets.as_mut() {
1472 targets.retain(|target| target != ".claude");
1473 if targets.is_empty() {
1474 config.settings.targets = None;
1475 }
1476 }
1477 save(dir.path(), &config).unwrap();
1478
1479 let reloaded = load(dir.path()).unwrap();
1480 assert_eq!(
1481 reloaded.package.as_ref().map(|p| p.name.as_str()),
1482 Some("sample")
1483 );
1484 assert_eq!(
1485 reloaded.dependencies["base"].url.as_deref(),
1486 Some("https://github.com/org/base.git")
1487 );
1488 assert_eq!(
1489 reloaded.dependencies["base"].version.as_deref(),
1490 Some("v1.0")
1491 );
1492 assert_eq!(
1493 reloaded.dependencies["base"].filter.agents.as_deref(),
1494 Some(&["coder".into()][..])
1495 );
1496 assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
1497 assert!(reloaded.settings.targets.is_none());
1498 }
1499
1500 #[test]
1501 fn roundtrip_preserves_all_filter_fields() {
1502 let dir = TempDir::new().unwrap();
1503 let original = r#"
1504[dependencies.include]
1505url = "https://github.com/org/include.git"
1506agents = ["coder", "reviewer"]
1507skills = ["review", "plan"]
1508
1509[dependencies.include.rename]
1510coder = "core-coder"
1511
1512[dependencies.exclude]
1513url = "https://github.com/org/exclude.git"
1514exclude = ["experimental", "deprecated"]
1515
1516[dependencies.only_skills]
1517url = "https://github.com/org/skills.git"
1518only_skills = true
1519
1520[dependencies.only_agents]
1521url = "https://github.com/org/agents.git"
1522only_agents = true
1523"#;
1524 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1525
1526 let config = load(dir.path()).unwrap();
1527 save(dir.path(), &config).unwrap();
1528 let reloaded = load(dir.path()).unwrap();
1529
1530 let include = &reloaded.dependencies["include"].filter;
1531 assert_eq!(
1532 include.agents.as_deref(),
1533 Some(&["coder".into(), "reviewer".into()][..])
1534 );
1535 assert_eq!(
1536 include.skills.as_deref(),
1537 Some(&["review".into(), "plan".into()][..])
1538 );
1539 assert_eq!(
1540 include.rename.as_ref().and_then(|r| r.get("coder")),
1541 Some(&"core-coder".into())
1542 );
1543
1544 let exclude = &reloaded.dependencies["exclude"].filter;
1545 assert_eq!(
1546 exclude.exclude.as_deref(),
1547 Some(&["experimental".into(), "deprecated".into()][..])
1548 );
1549
1550 let only_skills = &reloaded.dependencies["only_skills"].filter;
1551 assert!(only_skills.only_skills);
1552 assert!(!only_skills.only_agents);
1553
1554 let only_agents = &reloaded.dependencies["only_agents"].filter;
1555 assert!(only_agents.only_agents);
1556 assert!(!only_agents.only_skills);
1557 }
1558
1559 #[test]
1560 fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
1561 let dir = TempDir::new().unwrap();
1562 let original = r#"
1563[dependencies.git-include]
1564url = "https://github.com/org/git-include.git"
1565agents = ["coder"]
1566
1567[dependencies.path-exclude]
1568path = "../local-source"
1569exclude = ["draft"]
1570
1571[dependencies.git-only-skills]
1572url = "https://github.com/org/git-skills.git"
1573only_skills = true
1574
1575[dependencies.git-only-agents]
1576url = "https://github.com/org/git-agents.git"
1577only_agents = true
1578"#;
1579 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1580
1581 let config = load(dir.path()).unwrap();
1582 save(dir.path(), &config).unwrap();
1583 let reloaded = load(dir.path()).unwrap();
1584
1585 assert_eq!(reloaded.dependencies.len(), 4);
1586 assert_eq!(
1587 reloaded.dependencies["git-include"]
1588 .filter
1589 .agents
1590 .as_deref(),
1591 Some(&["coder".into()][..])
1592 );
1593 assert_eq!(
1594 reloaded.dependencies["path-exclude"].path.as_deref(),
1595 Some(Path::new("../local-source"))
1596 );
1597 assert_eq!(
1598 reloaded.dependencies["path-exclude"]
1599 .filter
1600 .exclude
1601 .as_deref(),
1602 Some(&["draft".into()][..])
1603 );
1604 assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
1605 assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
1606 }
1607
1608 #[test]
1609 fn save_roundtrip_guard_rejects_dependency_count_loss() {
1610 let mut original = Config::default();
1611 original.dependencies.insert(
1612 "base".into(),
1613 DependencyEntry {
1614 url: Some("https://github.com/org/base.git".into()),
1615 path: None,
1616 subpath: None,
1617 version: Some("v1.0".into()),
1618 filter: FilterConfig::default(),
1619 },
1620 );
1621
1622 let reparsed = Config::default();
1623 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1624 let msg = err.to_string();
1625 assert!(
1626 msg.contains("dependency count changed"),
1627 "unexpected error: {msg}"
1628 );
1629 }
1630
1631 #[test]
1632 fn save_roundtrip_guard_rejects_managed_root_loss() {
1633 let original = Config {
1634 settings: Settings {
1635 managed_root: Some(".agents".into()),
1636 targets: None,
1637 ..Settings::default()
1638 },
1639 ..Config::default()
1640 };
1641 let reparsed = Config::default();
1642 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1643 let msg = err.to_string();
1644 assert!(
1645 msg.contains("settings.managed_root changed"),
1646 "unexpected error: {msg}"
1647 );
1648 }
1649
1650 #[test]
1651 fn parse_only_skills_filter() {
1652 let toml_str = r#"
1653[dependencies.base]
1654url = "https://github.com/org/base.git"
1655only_skills = true
1656"#;
1657 let config: Config = toml::from_str(toml_str).unwrap();
1658 let local = LocalConfig::default();
1659 let effective = merge(config, local).unwrap();
1660 let source = &effective.dependencies["base"];
1661 assert!(matches!(source.filter, FilterMode::OnlySkills));
1662 }
1663
1664 #[test]
1665 fn parse_only_agents_filter() {
1666 let toml_str = r#"
1667[dependencies.base]
1668url = "https://github.com/org/base.git"
1669only_agents = true
1670"#;
1671 let config: Config = toml::from_str(toml_str).unwrap();
1672 let local = LocalConfig::default();
1673 let effective = merge(config, local).unwrap();
1674 let source = &effective.dependencies["base"];
1675 assert!(matches!(source.filter, FilterMode::OnlyAgents));
1676 }
1677
1678 #[test]
1679 fn error_on_only_skills_and_only_agents() {
1680 let toml_str = r#"
1681[dependencies.bad]
1682url = "https://github.com/org/bad.git"
1683only_skills = true
1684only_agents = true
1685"#;
1686 let config: Config = toml::from_str(toml_str).unwrap();
1687 let local = LocalConfig::default();
1688 let result = merge(config, local);
1689 assert!(result.is_err());
1690 let err = result.unwrap_err().to_string();
1691 assert!(
1692 err.contains("mutually exclusive"),
1693 "should mention mutually exclusive: {err}"
1694 );
1695 }
1696
1697 #[test]
1698 fn error_on_only_skills_with_agents_list() {
1699 let toml_str = r#"
1700[dependencies.bad]
1701url = "https://github.com/org/bad.git"
1702only_skills = true
1703agents = ["coder"]
1704"#;
1705 let config: Config = toml::from_str(toml_str).unwrap();
1706 let local = LocalConfig::default();
1707 let result = merge(config, local);
1708 assert!(result.is_err());
1709 let err = result.unwrap_err().to_string();
1710 assert!(
1711 err.contains("cannot combine"),
1712 "should mention cannot combine: {err}"
1713 );
1714 }
1715
1716 #[test]
1717 fn error_on_only_agents_with_skills_list() {
1718 let toml_str = r#"
1719[dependencies.bad]
1720url = "https://github.com/org/bad.git"
1721only_agents = true
1722skills = ["planning"]
1723"#;
1724 let config: Config = toml::from_str(toml_str).unwrap();
1725 let local = LocalConfig::default();
1726 let result = merge(config, local);
1727 assert!(result.is_err());
1728 }
1729
1730 #[test]
1731 fn error_on_only_skills_with_exclude() {
1732 let toml_str = r#"
1733[dependencies.bad]
1734url = "https://github.com/org/bad.git"
1735only_skills = true
1736exclude = ["deprecated"]
1737"#;
1738 let config: Config = toml::from_str(toml_str).unwrap();
1739 let local = LocalConfig::default();
1740 let result = merge(config, local);
1741 assert!(result.is_err());
1742 }
1743
1744 #[test]
1745 fn only_skills_false_not_serialized() {
1746 let config = Config {
1747 dependencies: {
1748 let mut m = IndexMap::new();
1749 m.insert(
1750 "base".into(),
1751 DependencyEntry {
1752 url: Some("https://github.com/org/base.git".into()),
1753 path: None,
1754 subpath: None,
1755 version: None,
1756 filter: FilterConfig::default(),
1757 },
1758 );
1759 m
1760 },
1761 settings: Settings::default(),
1762 ..Config::default()
1763 };
1764 let serialized = toml::to_string_pretty(&config).unwrap();
1765 assert!(
1766 !serialized.contains("only_skills"),
1767 "false booleans should not be serialized: {serialized}"
1768 );
1769 assert!(
1770 !serialized.contains("only_agents"),
1771 "false booleans should not be serialized: {serialized}"
1772 );
1773 }
1774
1775 #[test]
1776 fn only_skills_true_roundtrips() {
1777 let toml_str = r#"
1778[dependencies.base]
1779url = "https://github.com/org/base.git"
1780only_skills = true
1781"#;
1782 let config: Config = toml::from_str(toml_str).unwrap();
1783 assert!(config.dependencies["base"].filter.only_skills);
1784 assert!(!config.dependencies["base"].filter.only_agents);
1785
1786 let serialized = toml::to_string_pretty(&config).unwrap();
1787 let reloaded: Config = toml::from_str(&serialized).unwrap();
1788 assert!(reloaded.dependencies["base"].filter.only_skills);
1789 }
1790
1791 #[test]
1792 fn filter_config_has_any_filter() {
1793 assert!(!FilterConfig::default().has_any_filter());
1794 assert!(
1795 FilterConfig {
1796 only_skills: true,
1797 ..FilterConfig::default()
1798 }
1799 .has_any_filter()
1800 );
1801 assert!(
1802 FilterConfig {
1803 agents: Some(vec!["coder".into()]),
1804 ..FilterConfig::default()
1805 }
1806 .has_any_filter()
1807 );
1808 }
1809
1810 #[test]
1811 fn filter_config_to_mode() {
1812 assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
1813 assert!(matches!(
1814 FilterConfig {
1815 only_skills: true,
1816 ..FilterConfig::default()
1817 }
1818 .to_mode(),
1819 FilterMode::OnlySkills
1820 ));
1821 assert!(matches!(
1822 FilterConfig {
1823 only_agents: true,
1824 ..FilterConfig::default()
1825 }
1826 .to_mode(),
1827 FilterMode::OnlyAgents
1828 ));
1829 assert!(matches!(
1830 FilterConfig {
1831 agents: Some(vec!["coder".into()]),
1832 ..FilterConfig::default()
1833 }
1834 .to_mode(),
1835 FilterMode::Include { .. }
1836 ));
1837 assert!(matches!(
1838 FilterConfig {
1839 exclude: Some(vec!["old".into()]),
1840 ..FilterConfig::default()
1841 }
1842 .to_mode(),
1843 FilterMode::Exclude(_)
1844 ));
1845 }
1846
1847 #[test]
1850 fn managed_targets_defaults_to_no_target_sync_targets() {
1851 let settings = Settings::default();
1852 assert!(settings.managed_targets().is_empty());
1853 }
1854
1855 #[test]
1856 fn managed_targets_uses_explicit_targets() {
1857 let settings = Settings {
1858 targets: Some(vec![".claude".to_string()]),
1859 ..Settings::default()
1860 };
1861 assert_eq!(settings.managed_targets(), vec![".claude"]);
1862 }
1863
1864 #[test]
1865 fn managed_targets_uses_managed_root_as_primary() {
1866 let settings = Settings {
1867 managed_root: Some(".claude".to_string()),
1868 ..Settings::default()
1869 };
1870 assert_eq!(settings.managed_targets(), vec![".claude"]);
1871 }
1872
1873 #[test]
1874 fn managed_targets_explicit_overrides_links_and_managed_root() {
1875 let settings = Settings {
1876 managed_root: Some(".cursor".to_string()),
1877 targets: Some(vec![".codex".to_string()]),
1878 ..Settings::default()
1879 };
1880 assert_eq!(settings.managed_targets(), vec![".codex"]);
1882 }
1883
1884 #[test]
1885 fn merge_warns_when_managed_root_is_agents() {
1886 let config = Config {
1887 settings: Settings {
1888 managed_root: Some(".agents".into()),
1889 ..Settings::default()
1890 },
1891 ..Config::default()
1892 };
1893
1894 let (_, diagnostics) =
1895 merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
1896
1897 assert!(diagnostics.iter().any(|diag| {
1898 diag.code == "deprecated-agents-target"
1899 && diag.context.as_deref() == Some("settings.managed_root")
1900 }));
1901 }
1902
1903 #[test]
1904 fn merge_warns_when_targets_include_agents() {
1905 let config = Config {
1906 settings: Settings {
1907 targets: Some(vec![".agents".into(), ".claude".into()]),
1908 ..Settings::default()
1909 },
1910 ..Config::default()
1911 };
1912
1913 let (_, diagnostics) =
1914 merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
1915
1916 assert!(diagnostics.iter().any(|diag| {
1917 diag.code == "deprecated-agents-target"
1918 && diag.context.as_deref() == Some("settings.targets")
1919 }));
1920 }
1921
1922 #[test]
1923 fn settings_models_cache_ttl_defaults_to_24_when_omitted() {
1924 let config: Config = toml::from_str(
1925 r#"
1926[dependencies.base]
1927url = "https://github.com/org/base.git"
1928"#,
1929 )
1930 .unwrap();
1931 assert_eq!(config.settings.models_cache_ttl_hours, 24);
1932 }
1933
1934 #[test]
1935 fn settings_models_cache_ttl_defaults_to_24_when_settings_present_without_ttl() {
1936 let config: Config = toml::from_str(
1937 r#"
1938[settings]
1939managed_root = ".agents"
1940"#,
1941 )
1942 .unwrap();
1943 assert_eq!(config.settings.models_cache_ttl_hours, 24);
1944 }
1945
1946 #[test]
1947 fn settings_models_cache_ttl_parses_zero() {
1948 let config: Config = toml::from_str(
1949 r#"
1950[settings]
1951models_cache_ttl_hours = 0
1952"#,
1953 )
1954 .unwrap();
1955 assert_eq!(config.settings.models_cache_ttl_hours, 0);
1956 }
1957
1958 #[test]
1959 fn settings_models_cache_ttl_parses_custom_value() {
1960 let config: Config = toml::from_str(
1961 r#"
1962[settings]
1963models_cache_ttl_hours = 48
1964"#,
1965 )
1966 .unwrap();
1967 assert_eq!(config.settings.models_cache_ttl_hours, 48);
1968 }
1969
1970 #[test]
1971 fn settings_models_cache_ttl_roundtrip_preserves_value() {
1972 let original = Config {
1973 settings: Settings {
1974 models_cache_ttl_hours: 48,
1975 ..Settings::default()
1976 },
1977 ..Config::default()
1978 };
1979 let serialized = toml::to_string_pretty(&original).unwrap();
1980 let roundtripped: Config = toml::from_str(&serialized).unwrap();
1981 assert_eq!(
1982 roundtripped.settings.models_cache_ttl_hours,
1983 original.settings.models_cache_ttl_hours
1984 );
1985 }
1986
1987 #[test]
1988 fn settings_agent_emission_parses_auto() {
1989 let config: Config = toml::from_str(
1990 r#"
1991[settings]
1992agent_emission = "auto"
1993"#,
1994 )
1995 .unwrap();
1996 assert_eq!(config.settings.agent_emission, Some(AgentEmission::Auto));
1997 }
1998
1999 #[test]
2000 fn settings_agent_emission_parses_always_and_never() {
2001 let always: Config = toml::from_str(
2002 r#"
2003[settings]
2004agent_emission = "always"
2005"#,
2006 )
2007 .unwrap();
2008 assert_eq!(always.settings.agent_emission, Some(AgentEmission::Always));
2009
2010 let never: Config = toml::from_str(
2011 r#"
2012[settings]
2013agent_emission = "never"
2014"#,
2015 )
2016 .unwrap();
2017 assert_eq!(never.settings.agent_emission, Some(AgentEmission::Never));
2018 }
2019
2020 #[test]
2021 fn settings_agent_emission_defaults_to_auto_when_omitted() {
2022 let config: Config = toml::from_str(
2023 r#"
2024[settings]
2025models_cache_ttl_hours = 48
2026"#,
2027 )
2028 .unwrap();
2029 assert!(config.settings.agent_emission.is_none());
2030 }
2031
2032 #[test]
2033 fn settings_agent_emission_roundtrip_preserves_value() {
2034 let original = Config {
2035 settings: Settings {
2036 agent_emission: Some(AgentEmission::Always),
2037 ..Settings::default()
2038 },
2039 ..Config::default()
2040 };
2041 let serialized = toml::to_string_pretty(&original).unwrap();
2042 let roundtripped: Config = toml::from_str(&serialized).unwrap();
2043 assert_eq!(
2044 roundtripped.settings.agent_emission,
2045 original.settings.agent_emission
2046 );
2047 }
2048
2049 #[test]
2050 fn model_visibility_validate_allows_include_and_exclude() {
2051 let visibility = ModelVisibility {
2052 include: Some(vec!["opus*".into()]),
2053 exclude: Some(vec!["test*".into()]),
2054 };
2055 visibility.validate().unwrap();
2056 }
2057
2058 #[test]
2059 fn model_visibility_validate_allows_include_only_exclude_only_and_empty() {
2060 ModelVisibility {
2061 include: Some(vec!["opus*".into()]),
2062 exclude: None,
2063 }
2064 .validate()
2065 .unwrap();
2066 ModelVisibility {
2067 include: None,
2068 exclude: Some(vec!["test*".into()]),
2069 }
2070 .validate()
2071 .unwrap();
2072 ModelVisibility::default().validate().unwrap();
2073 }
2074
2075 #[test]
2076 fn model_visibility_is_empty_reports_state() {
2077 assert!(ModelVisibility::default().is_empty());
2078 assert!(
2079 !ModelVisibility {
2080 include: Some(vec!["opus*".into()]),
2081 exclude: None,
2082 }
2083 .is_empty()
2084 );
2085 assert!(
2086 !ModelVisibility {
2087 include: None,
2088 exclude: Some(vec!["test*".into()]),
2089 }
2090 .is_empty()
2091 );
2092 }
2093
2094 #[test]
2095 fn load_accepts_model_visibility_with_include_and_exclude() {
2096 let dir = TempDir::new().unwrap();
2097 std::fs::write(
2098 dir.path().join("mars.toml"),
2099 r#"
2100[settings.model_visibility]
2101include = ["opus*"]
2102exclude = ["test*"]
2103"#,
2104 )
2105 .unwrap();
2106
2107 let config = load(dir.path()).unwrap();
2108 assert_eq!(
2109 config.settings.model_visibility.include,
2110 Some(vec!["opus*".into()])
2111 );
2112 assert_eq!(
2113 config.settings.model_visibility.exclude,
2114 Some(vec!["test*".into()])
2115 );
2116 }
2117
2118 #[test]
2119 fn load_accepts_model_visibility_include_only() {
2120 let dir = TempDir::new().unwrap();
2121 std::fs::write(
2122 dir.path().join("mars.toml"),
2123 r#"
2124[settings.model_visibility]
2125include = ["opus*", "gpt-*"]
2126"#,
2127 )
2128 .unwrap();
2129
2130 let config = load(dir.path()).unwrap();
2131 assert_eq!(
2132 config.settings.model_visibility.include,
2133 Some(vec!["opus*".into(), "gpt-*".into()])
2134 );
2135 assert!(config.settings.model_visibility.exclude.is_none());
2136 }
2137
2138 #[test]
2139 fn load_accepts_model_visibility_exclude_only() {
2140 let dir = TempDir::new().unwrap();
2141 std::fs::write(
2142 dir.path().join("mars.toml"),
2143 r#"
2144[settings.model_visibility]
2145exclude = ["test-*", "deprecated-*"]
2146"#,
2147 )
2148 .unwrap();
2149
2150 let config = load(dir.path()).unwrap();
2151 assert_eq!(
2152 config.settings.model_visibility.exclude,
2153 Some(vec!["test-*".into(), "deprecated-*".into()])
2154 );
2155 assert!(config.settings.model_visibility.include.is_none());
2156 }
2157
2158 #[test]
2161 fn parse_local_dependencies() {
2162 let toml_str = r#"
2163[dependencies.base]
2164url = "https://github.com/org/base.git"
2165
2166[local-dependencies.prompter]
2167url = "https://github.com/org/prompter.git"
2168skills = ["prompt-helper"]
2169"#;
2170 let config: Config = toml::from_str(toml_str).unwrap();
2171 assert_eq!(config.dependencies.len(), 1);
2172 assert_eq!(config.local_dependencies.len(), 1);
2173 assert!(config.local_dependencies.contains_key("prompter"));
2174 assert_eq!(
2175 config.local_dependencies["prompter"].url.as_deref(),
2176 Some("https://github.com/org/prompter.git")
2177 );
2178 }
2179
2180 #[test]
2181 fn local_dependencies_merged_into_effective_config() {
2182 let toml_str = r#"
2183[dependencies.base]
2184url = "https://github.com/org/base.git"
2185
2186[local-dependencies.prompter]
2187url = "https://github.com/org/prompter.git"
2188"#;
2189 let config: Config = toml::from_str(toml_str).unwrap();
2190 let local = LocalConfig::default();
2191 let effective = merge(config, local).unwrap();
2192
2193 assert_eq!(effective.dependencies.len(), 2);
2195 assert!(effective.dependencies.contains_key("base"));
2196 assert!(effective.dependencies.contains_key("prompter"));
2197 }
2198
2199 #[test]
2200 fn local_dependencies_not_exported_to_manifest() {
2201 let dir = TempDir::new().unwrap();
2202 std::fs::write(
2203 dir.path().join("mars.toml"),
2204 r#"
2205[package]
2206name = "my-package"
2207version = "1.0.0"
2208
2209[dependencies.base]
2210url = "https://github.com/org/base.git"
2211
2212[local-dependencies.prompter]
2213url = "https://github.com/org/prompter.git"
2214"#,
2215 )
2216 .unwrap();
2217
2218 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
2219 assert!(diagnostics.is_empty());
2220 let manifest = manifest.unwrap();
2221
2222 assert_eq!(manifest.dependencies.len(), 1);
2224 assert!(manifest.dependencies.contains_key("base"));
2225 assert!(!manifest.dependencies.contains_key("prompter"));
2226 }
2227
2228 #[test]
2229 fn error_on_duplicate_name_across_sections() {
2230 let toml_str = r#"
2231[dependencies.base]
2232url = "https://github.com/org/base.git"
2233
2234[local-dependencies.base]
2235url = "https://github.com/org/base-local.git"
2236"#;
2237 let config: Config = toml::from_str(toml_str).unwrap();
2238 let local = LocalConfig::default();
2239 let result = merge(config, local);
2240 assert!(result.is_err());
2241 let err = result.unwrap_err().to_string();
2242 assert!(
2243 err.contains("base") && err.contains("both"),
2244 "should reject duplicate name: {err}"
2245 );
2246 }
2247
2248 #[test]
2249 fn local_dependencies_roundtrip() {
2250 let dir = TempDir::new().unwrap();
2251 let original = r#"
2252[dependencies.base]
2253url = "https://github.com/org/base.git"
2254
2255[local-dependencies.prompter]
2256url = "https://github.com/org/prompter.git"
2257skills = ["prompt-helper"]
2258"#;
2259 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2260
2261 let config = load(dir.path()).unwrap();
2262 save(dir.path(), &config).unwrap();
2263 let reloaded = load(dir.path()).unwrap();
2264
2265 assert_eq!(reloaded.dependencies.len(), 1);
2266 assert_eq!(reloaded.local_dependencies.len(), 1);
2267 assert!(reloaded.local_dependencies.contains_key("prompter"));
2268 assert_eq!(
2269 reloaded.local_dependencies["prompter"]
2270 .filter
2271 .skills
2272 .as_deref(),
2273 Some(&["prompt-helper".into()][..])
2274 );
2275 }
2276
2277 #[test]
2278 fn path_with_backslashes_serializes_as_forward_slashes() {
2279 let mut deps = IndexMap::new();
2280 deps.insert(
2281 SourceName::from("test-src"),
2282 InstallDep {
2283 url: None,
2284 path: Some(PathBuf::from("C:\\Users\\dev\\src")),
2285 subpath: None,
2286 version: None,
2287 filter: FilterConfig::default(),
2288 },
2289 );
2290 let config = Config {
2291 dependencies: deps,
2292 ..Config::default()
2293 };
2294 let toml_str = toml::to_string_pretty(&config).unwrap();
2295 assert!(
2296 !toml_str.contains('\\'),
2297 "TOML output must not contain backslashes: {toml_str}"
2298 );
2299 assert!(
2300 toml_str.contains("C:/Users/dev/src"),
2301 "expected forward-slash path in TOML: {toml_str}"
2302 );
2303 let reparsed: Config = toml::from_str(&toml_str).unwrap();
2304 assert_eq!(
2305 reparsed.dependencies["test-src"].path.as_ref().unwrap(),
2306 &PathBuf::from("C:/Users/dev/src"),
2307 );
2308 }
2309
2310 #[test]
2311 fn override_path_serializes_forward_slashes() {
2312 let mut overrides = IndexMap::new();
2313 overrides.insert(
2314 SourceName::from("my-dep"),
2315 OverrideEntry {
2316 path: PathBuf::from("C:\\Users\\dev\\local-pkg"),
2317 },
2318 );
2319 let local = LocalConfig { overrides };
2320 let toml_str = toml::to_string_pretty(&local).unwrap();
2321 assert!(
2322 !toml_str.contains('\\'),
2323 "local config TOML must not contain backslashes: {toml_str}"
2324 );
2325 assert!(
2326 toml_str.contains("C:/Users/dev/local-pkg"),
2327 "expected forward-slash override path: {toml_str}"
2328 );
2329 }
2330 #[test]
2331 fn load_manifest_warns_on_invalid_package_target() {
2332 let dir = TempDir::new().unwrap();
2333 std::fs::write(
2334 dir.path().join("mars.toml"),
2335 r#"
2336[package]
2337name = "bad-targets-pkg"
2338version = "0.1.0"
2339
2340[package.targets]
2341required = ["not/a/valid/target"]
2342"#,
2343 )
2344 .unwrap();
2345
2346 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
2347 assert!(manifest.is_some());
2348 assert!(
2349 diagnostics
2350 .iter()
2351 .any(|d| d.code == "invalid-package-target"),
2352 "expected invalid-package-target warning, got: {diagnostics:?}"
2353 );
2354 }
2355}