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