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