1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::diagnostic::{Diagnostic, 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(default)]
20 pub settings: Settings,
21 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
22 pub models: IndexMap<String, crate::models::ModelAlias>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27pub struct PackageInfo {
28 pub name: String,
29 pub version: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub description: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct InstallDep {
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub url: Option<SourceUrl>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub path: Option<PathBuf>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub subpath: Option<SourceSubpath>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub version: Option<String>,
46 #[serde(flatten)]
47 pub filter: FilterConfig,
48}
49
50pub type DependencyEntry = InstallDep;
52
53#[derive(Debug, Clone, PartialEq)]
56pub struct ManifestDep {
57 pub url: SourceUrl,
58 pub subpath: Option<SourceSubpath>,
59 pub version: Option<String>,
60 pub filter: FilterConfig,
61}
62
63#[derive(Debug, Clone, PartialEq)]
69pub struct Manifest {
70 pub package: PackageInfo,
71 pub dependencies: IndexMap<String, ManifestDep>,
72 pub models: IndexMap<String, crate::models::ModelAlias>,
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
77pub struct FilterConfig {
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub agents: Option<Vec<ItemName>>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub skills: Option<Vec<ItemName>>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub exclude: Option<Vec<ItemName>>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub rename: Option<RenameMap>,
86 #[serde(default, skip_serializing_if = "is_false")]
87 pub only_skills: bool,
88 #[serde(default, skip_serializing_if = "is_false")]
89 pub only_agents: bool,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
95pub struct ModelVisibility {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub include: Option<Vec<String>>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub exclude: Option<Vec<String>>,
102}
103
104impl ModelVisibility {
105 pub fn validate(&self) -> Result<(), MarsError> {
106 if self.include.is_some() && self.exclude.is_some() {
107 return Err(ConfigError::Invalid {
108 message: "[settings.model_visibility] cannot have both 'include' and 'exclude'"
109 .into(),
110 }
111 .into());
112 }
113 Ok(())
114 }
115
116 pub fn is_empty(&self) -> bool {
117 self.include.is_none() && self.exclude.is_none()
118 }
119}
120
121fn is_false(v: &bool) -> bool {
122 !v
123}
124
125#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
130pub struct LocalConfig {
131 #[serde(default)]
132 pub overrides: IndexMap<SourceName, OverrideEntry>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137pub struct OverrideEntry {
138 pub path: PathBuf,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
143pub struct Settings {
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub managed_root: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub targets: Option<Vec<String>>,
151 #[serde(default, skip_serializing_if = "ModelVisibility::is_empty")]
152 pub model_visibility: ModelVisibility,
153 #[serde(default = "default_models_cache_ttl_hours")]
154 pub models_cache_ttl_hours: u32,
155}
156
157impl Default for Settings {
158 fn default() -> Self {
159 Self {
160 managed_root: None,
161 targets: None,
162 model_visibility: ModelVisibility::default(),
163 models_cache_ttl_hours: default_models_cache_ttl_hours(),
164 }
165 }
166}
167
168fn default_models_cache_ttl_hours() -> u32 {
169 24
170}
171
172impl Settings {
173 pub fn managed_targets(&self) -> Vec<String> {
178 if let Some(targets) = &self.targets {
179 return targets.clone();
180 }
181 vec![
182 self.managed_root
183 .clone()
184 .unwrap_or_else(|| ".agents".to_string()),
185 ]
186 }
187}
188
189#[derive(Debug, Clone)]
191pub enum SourceSpec {
192 Git(GitSpec),
193 Path(PathBuf),
194}
195
196#[derive(Debug, Clone)]
198pub struct GitSpec {
199 pub url: SourceUrl,
200 pub version: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum FilterMode {
206 All,
208 Include {
210 agents: Vec<ItemName>,
211 skills: Vec<ItemName>,
212 },
213 Exclude(Vec<ItemName>),
215 OnlySkills,
217 OnlyAgents,
219}
220
221#[derive(Debug, Clone)]
225pub struct EffectiveConfig {
226 pub dependencies: IndexMap<SourceName, EffectiveDependency>,
227 pub settings: Settings,
228}
229
230#[derive(Debug, Clone)]
232pub struct EffectiveDependency {
233 pub name: SourceName,
234 pub id: SourceId,
235 pub spec: SourceSpec,
236 pub subpath: Option<SourceSubpath>,
237 pub filter: FilterMode,
238 pub rename: RenameMap,
239 pub is_overridden: bool,
240 pub original_git: Option<GitSpec>,
241}
242
243const CONFIG_FILE: &str = "mars.toml";
244const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
245
246pub fn load(root: &Path) -> Result<Config, MarsError> {
248 let path = root.join(CONFIG_FILE);
249 let content = std::fs::read_to_string(&path).map_err(|e| {
250 if e.kind() == std::io::ErrorKind::NotFound {
251 ConfigError::NotFound { path: path.clone() }
252 } else {
253 ConfigError::Io(e)
254 }
255 })?;
256 let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
257 migrate_legacy_source_urls(&mut config);
258 config.settings.model_visibility.validate()?;
259 Ok(config)
260}
261
262pub fn load_manifest(source_root: &Path) -> Result<(Option<Manifest>, Vec<Diagnostic>), MarsError> {
270 let path = source_root.join(CONFIG_FILE);
271 let mut diagnostics = Vec::new();
272 match std::fs::read_to_string(&path) {
273 Ok(content) => {
274 let parsed: Config =
275 toml::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
276 message: format!("failed to parse {}: {e}", path.display()),
277 })?;
278 let Some(package) = parsed.package else {
279 return Ok((None, diagnostics));
280 };
281 let deps: IndexMap<String, ManifestDep> = parsed
283 .dependencies
284 .into_iter()
285 .filter_map(|(name, entry)| match entry.url {
286 Some(url) => Some((
287 name.to_string(),
288 ManifestDep {
289 url,
290 subpath: entry.subpath,
291 version: entry.version,
292 filter: entry.filter,
293 },
294 )),
295 None => {
296 diagnostics.push(Diagnostic {
298 level: DiagnosticLevel::Warning,
299 code: "manifest-path-dep",
300 message: format!(
301 "manifest dependency `{name}` has no URL and will not propagate to consumers"
302 ),
303 context: None,
304 });
305 None
306 }
307 })
308 .collect();
309 Ok((
310 Some(Manifest {
311 package,
312 dependencies: deps,
313 models: parsed.models,
314 }),
315 diagnostics,
316 ))
317 }
318 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((None, diagnostics)),
319 Err(e) => Err(MarsError::Io(e)),
320 }
321}
322
323pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
325 let path = root.join(LOCAL_CONFIG_FILE);
326 match std::fs::read_to_string(&path) {
327 Ok(content) => {
328 let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
329 Ok(local)
330 }
331 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
332 Err(e) => Err(ConfigError::Io(e).into()),
333 }
334}
335
336pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
343 let (effective, _diagnostics) = merge_with_root(config, local, Path::new("."))?;
344 Ok(effective)
345}
346
347pub fn merge_with_root(
349 config: Config,
350 local: LocalConfig,
351 root: &Path,
352) -> Result<(EffectiveConfig, Vec<Diagnostic>), MarsError> {
353 config.settings.model_visibility.validate()?;
354 let mut dependencies = IndexMap::new();
355 let mut diagnostics = Vec::new();
356 let local_source_name = SourceOrigin::LocalPackage.to_string();
357
358 for (name, entry) in &config.dependencies {
359 if name.as_ref() == local_source_name.as_str() {
361 return Err(ConfigError::Invalid {
362 message: "dependency name `_self` is reserved for local package items".into(),
363 }
364 .into());
365 }
366
367 let base_spec = match (&entry.url, &entry.path) {
369 (Some(url), None) => SourceSpec::Git(GitSpec {
370 url: url.clone(),
371 version: entry.version.clone(),
372 }),
373 (None, Some(path)) => SourceSpec::Path(path.clone()),
374 (Some(_), Some(_)) => {
375 return Err(ConfigError::Invalid {
376 message: format!("source `{name}` has both `url` and `path` — pick one"),
377 }
378 .into());
379 }
380 (None, None) => {
381 return Err(ConfigError::Invalid {
382 message: format!(
383 "source `{name}` has neither `url` nor `path` — one is required"
384 ),
385 }
386 .into());
387 }
388 };
389
390 validate_filter(&entry.filter, name.as_ref())?;
392
393 let filter = entry.filter.to_mode();
394
395 let rename = entry.filter.rename.clone().unwrap_or_default();
396
397 let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
399 let original = match &base_spec {
400 SourceSpec::Git(git) => Some(git.clone()),
401 SourceSpec::Path(_) => None,
402 };
403 (SourceSpec::Path(ov.path.clone()), true, original)
404 } else {
405 (base_spec, false, None)
406 };
407 let subpath = entry.subpath.clone();
408 let id = source_id_for_spec(root, &spec, subpath.clone());
409
410 dependencies.insert(
411 name.clone(),
412 EffectiveDependency {
413 name: name.clone(),
414 id,
415 spec,
416 subpath,
417 filter,
418 rename,
419 is_overridden,
420 original_git,
421 },
422 );
423 }
424
425 for override_name in local.overrides.keys() {
427 if !config.dependencies.contains_key(override_name) {
428 diagnostics.push(Diagnostic {
429 level: DiagnosticLevel::Warning,
430 code: "override-missing-dep",
431 message: format!(
432 "override `{override_name}` references a dependency not in mars.toml"
433 ),
434 context: None,
435 });
436 }
437 }
438
439 Ok((
440 EffectiveConfig {
441 dependencies,
442 settings: config.settings,
443 },
444 diagnostics,
445 ))
446}
447
448pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
456 let has_include = filter.agents.is_some() || filter.skills.is_some();
457 let has_exclude = filter.exclude.is_some();
458 let has_category = filter.only_skills || filter.only_agents;
459
460 if filter.only_skills && filter.only_agents {
461 return Err(ConfigError::Invalid {
462 message: format!(
463 "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
464 ),
465 }
466 .into());
467 }
468 if has_category && has_include {
469 return Err(ConfigError::Invalid {
470 message: format!(
471 "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
472 ),
473 }
474 .into());
475 }
476 if has_category && has_exclude {
477 return Err(ConfigError::Invalid {
478 message: format!(
479 "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
480 ),
481 }
482 .into());
483 }
484 if has_include && has_exclude {
485 return Err(ConfigError::ConflictingFilters {
486 name: dep_name.to_string(),
487 }
488 .into());
489 }
490 Ok(())
491}
492
493impl FilterConfig {
494 pub fn to_mode(&self) -> FilterMode {
496 if self.only_skills {
497 FilterMode::OnlySkills
498 } else if self.only_agents {
499 FilterMode::OnlyAgents
500 } else if self.agents.is_some() || self.skills.is_some() {
501 FilterMode::Include {
502 agents: self.agents.clone().unwrap_or_default(),
503 skills: self.skills.clone().unwrap_or_default(),
504 }
505 } else if self.exclude.is_some() {
506 FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
507 } else {
508 FilterMode::All
509 }
510 }
511
512 pub fn has_any_filter(&self) -> bool {
514 self.agents.is_some()
515 || self.skills.is_some()
516 || self.exclude.is_some()
517 || self.only_skills
518 || self.only_agents
519 }
520}
521
522fn source_id_for_spec(root: &Path, spec: &SourceSpec, subpath: Option<SourceSubpath>) -> SourceId {
523 match spec {
524 SourceSpec::Git(git) => SourceId::git_with_subpath(git.url.clone(), subpath.clone()),
525 SourceSpec::Path(path) => match SourceId::path_with_subpath(root, path, subpath.clone()) {
526 Ok(id) => id,
527 Err(_) => {
528 let canonical = if path.is_absolute() {
529 path.clone()
530 } else {
531 root.join(path)
532 };
533 SourceId::Path { canonical, subpath }
534 }
535 },
536 }
537}
538
539fn migrate_legacy_source_urls(config: &mut Config) {
540 for dep in config.dependencies.values_mut() {
541 if let Some(url) = dep.url.as_mut() {
542 let raw = url.as_str();
543 if should_upgrade_legacy_git_url(raw) {
544 *url = SourceUrl::from(format!("https://{raw}"));
545 }
546 }
547 }
548}
549
550fn should_upgrade_legacy_git_url(url: &str) -> bool {
551 !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
552}
553
554pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
556 let path = root.join(CONFIG_FILE);
557 let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
558 message: format!("failed to serialize config: {e}"),
559 })?;
560 let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
561 message: format!("refusing to save config: serialized output failed to parse: {e}"),
562 })?;
563 validate_save_roundtrip(config, &reparsed)?;
564 crate::fs::atomic_write(&path, content.as_bytes())
565}
566
567fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
568 if reparsed.dependencies.len() != original.dependencies.len() {
569 return Err(ConfigError::Invalid {
570 message: format!(
571 "refusing to save config: dependency count changed during roundtrip ({} -> {})",
572 original.dependencies.len(),
573 reparsed.dependencies.len()
574 ),
575 }
576 .into());
577 }
578
579 if reparsed.settings.managed_root != original.settings.managed_root {
580 return Err(ConfigError::Invalid {
581 message: format!(
582 "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
583 original.settings.managed_root, reparsed.settings.managed_root
584 ),
585 }
586 .into());
587 }
588 if reparsed.settings.model_visibility != original.settings.model_visibility {
589 return Err(ConfigError::Invalid {
590 message: format!(
591 "refusing to save config: settings.model_visibility changed during roundtrip ({:?} -> {:?})",
592 original.settings.model_visibility, reparsed.settings.model_visibility
593 ),
594 }
595 .into());
596 }
597
598 for (name, dep) in &original.dependencies {
599 let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
600 return Err(ConfigError::Invalid {
601 message: format!(
602 "refusing to save config: dependency `{name}` missing after roundtrip"
603 ),
604 }
605 .into());
606 };
607
608 if reparsed_dep != dep {
609 return Err(ConfigError::Invalid {
610 message: format!(
611 "refusing to save config: dependency `{name}` changed during roundtrip"
612 ),
613 }
614 .into());
615 }
616 }
617
618 Ok(())
619}
620
621pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
623 let path = root.join(LOCAL_CONFIG_FILE);
624 let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
625 message: format!("failed to serialize local config: {e}"),
626 })?;
627 crate::fs::atomic_write(&path, content.as_bytes())
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use tempfile::TempDir;
634
635 #[test]
636 fn parse_git_dependency() {
637 let toml_str = r#"
638[dependencies.base]
639url = "https://github.com/org/base.git"
640version = "v1.0"
641"#;
642 let config: Config = toml::from_str(toml_str).unwrap();
643 assert_eq!(config.dependencies.len(), 1);
644 let entry = &config.dependencies["base"];
645 assert_eq!(
646 entry.url.as_deref(),
647 Some("https://github.com/org/base.git")
648 );
649 assert!(entry.path.is_none());
650 assert_eq!(entry.version.as_deref(), Some("v1.0"));
651 }
652
653 #[test]
654 fn parse_path_dependency() {
655 let toml_str = r#"
656[dependencies.local]
657path = "../my-agents"
658"#;
659 let config: Config = toml::from_str(toml_str).unwrap();
660 let entry = &config.dependencies["local"];
661 assert!(entry.url.is_none());
662 assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
663 }
664
665 #[test]
666 fn parse_mixed_dependencies() {
667 let toml_str = r#"
668[dependencies.remote]
669url = "https://github.com/org/remote.git"
670version = "v2.0"
671agents = ["coder", "reviewer"]
672
673[dependencies.local]
674path = "/home/dev/agents"
675exclude = ["experimental"]
676"#;
677 let config: Config = toml::from_str(toml_str).unwrap();
678 assert_eq!(config.dependencies.len(), 2);
679 assert!(config.dependencies.contains_key("remote"));
680 assert!(config.dependencies.contains_key("local"));
681 }
682
683 #[test]
684 fn parse_package_and_dependencies_coexist() {
685 let toml_str = r#"
686[package]
687name = "my-agents"
688version = "0.1.0"
689
690[dependencies.base]
691url = "https://github.com/org/base.git"
692version = ">=1.0.0"
693
694[dependencies.local]
695path = "../local-agents"
696"#;
697 let config: Config = toml::from_str(toml_str).unwrap();
698 assert!(config.package.is_some());
699 assert!(config.dependencies.contains_key("base"));
700 assert!(config.dependencies.contains_key("local"));
701 }
702
703 #[test]
704 fn parse_include_filter() {
705 let toml_str = r#"
706[dependencies.base]
707url = "https://github.com/org/base.git"
708agents = ["coder"]
709skills = ["review"]
710"#;
711 let config: Config = toml::from_str(toml_str).unwrap();
712 let local = LocalConfig::default();
713 let effective = merge(config, local).unwrap();
714 let source = &effective.dependencies["base"];
715 match &source.filter {
716 FilterMode::Include { agents, skills } => {
717 assert_eq!(agents, &["coder"]);
718 assert_eq!(skills, &["review"]);
719 }
720 other => panic!("expected Include, got {other:?}"),
721 }
722 }
723
724 #[test]
725 fn parse_exclude_filter() {
726 let toml_str = r#"
727[dependencies.base]
728url = "https://github.com/org/base.git"
729exclude = ["experimental", "deprecated"]
730"#;
731 let config: Config = toml::from_str(toml_str).unwrap();
732 let local = LocalConfig::default();
733 let effective = merge(config, local).unwrap();
734 let source = &effective.dependencies["base"];
735 match &source.filter {
736 FilterMode::Exclude(items) => {
737 assert_eq!(items, &["experimental", "deprecated"]);
738 }
739 other => panic!("expected Exclude, got {other:?}"),
740 }
741 }
742
743 #[test]
744 fn error_on_both_include_and_exclude() {
745 let toml_str = r#"
746[dependencies.bad]
747url = "https://github.com/org/bad.git"
748agents = ["coder"]
749exclude = ["reviewer"]
750"#;
751 let config: Config = toml::from_str(toml_str).unwrap();
752 let local = LocalConfig::default();
753 let result = merge(config, local);
754 assert!(result.is_err());
755 let err = result.unwrap_err().to_string();
756 assert!(
757 err.contains("bad"),
758 "error should mention dependency name: {err}"
759 );
760 }
761
762 #[test]
763 fn error_on_neither_url_nor_path() {
764 let toml_str = r#"
765[dependencies.empty]
766version = "v1.0"
767"#;
768 let config: Config = toml::from_str(toml_str).unwrap();
769 let local = LocalConfig::default();
770 let result = merge(config, local);
771 assert!(result.is_err());
772 let err = result.unwrap_err().to_string();
773 assert!(
774 err.contains("neither"),
775 "error should mention 'neither': {err}"
776 );
777 }
778
779 #[test]
780 fn error_on_both_url_and_path() {
781 let toml_str = r#"
782[dependencies.both]
783url = "https://github.com/org/repo.git"
784path = "/local/path"
785"#;
786 let config: Config = toml::from_str(toml_str).unwrap();
787 let local = LocalConfig::default();
788 let result = merge(config, local);
789 assert!(result.is_err());
790 let err = result.unwrap_err().to_string();
791 assert!(err.contains("both"), "error should mention 'both': {err}");
792 }
793
794 #[test]
795 fn roundtrip_full_config_shape_survives_save() {
796 let dir = TempDir::new().unwrap();
797 let original = r#"
798[package]
799name = "sample"
800version = "0.1.0"
801description = "sample package"
802
803[dependencies.base]
804url = "https://github.com/org/base.git"
805version = "v1.0"
806agents = ["coder", "reviewer"]
807
808[dependencies.local]
809path = "../local-agents"
810exclude = ["experimental"]
811
812[settings]
813managed_root = ".custom-agents"
814targets = [".claude", ".cursor"]
815"#;
816 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
817
818 let config = load(dir.path()).unwrap();
819 save(dir.path(), &config).unwrap();
820 let reloaded = load(dir.path()).unwrap();
821
822 assert_eq!(
823 reloaded.package.as_ref().map(|p| p.name.as_str()),
824 Some("sample")
825 );
826 assert_eq!(reloaded.dependencies.len(), 2);
827 assert_eq!(
828 reloaded.dependencies["base"].url.as_deref(),
829 Some("https://github.com/org/base.git")
830 );
831 assert_eq!(
832 reloaded.dependencies["local"].path.as_deref(),
833 Some(Path::new("../local-agents"))
834 );
835 assert_eq!(
836 reloaded.settings.managed_root.as_deref(),
837 Some(".custom-agents")
838 );
839 assert_eq!(
840 reloaded.settings.targets,
841 Some(vec![".claude".to_string(), ".cursor".to_string()])
842 );
843 }
844
845 #[test]
846 fn load_from_disk() {
847 let dir = TempDir::new().unwrap();
848 let toml_str = r#"
849[dependencies.base]
850url = "https://github.com/org/base.git"
851version = "v1.0"
852"#;
853 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
854 let config = load(dir.path()).unwrap();
855 assert_eq!(config.dependencies.len(), 1);
856 }
857
858 #[test]
859 fn load_migrates_legacy_bare_domain_url() {
860 let dir = TempDir::new().unwrap();
861 let toml_str = r#"
862[dependencies.base]
863url = "github.com/org/base"
864"#;
865 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
866
867 let config = load(dir.path()).unwrap();
868 assert_eq!(
869 config.dependencies["base"].url.as_deref(),
870 Some("https://github.com/org/base")
871 );
872 }
873
874 #[test]
875 fn load_does_not_migrate_ssh_url() {
876 let dir = TempDir::new().unwrap();
877 let toml_str = r#"
878[dependencies.base]
879url = "git@github.com:org/base.git"
880"#;
881 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
882
883 let config = load(dir.path()).unwrap();
884 assert_eq!(
885 config.dependencies["base"].url.as_deref(),
886 Some("git@github.com:org/base.git")
887 );
888 }
889
890 #[test]
891 fn load_missing_file_returns_not_found() {
892 let dir = TempDir::new().unwrap();
893 let result = load(dir.path());
894 assert!(result.is_err());
895 let err = result.unwrap_err().to_string();
896 assert!(err.contains("not found"), "should be NotFound: {err}");
897 }
898
899 #[test]
900 fn load_manifest_returns_none_without_package() {
901 let dir = TempDir::new().unwrap();
902 std::fs::write(
903 dir.path().join("mars.toml"),
904 r#"
905[dependencies.base]
906url = "https://github.com/org/base.git"
907"#,
908 )
909 .unwrap();
910
911 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
912 assert!(diagnostics.is_empty());
913 assert!(manifest.is_none());
914 }
915
916 #[test]
917 fn load_manifest_returns_package_and_dependencies() {
918 let dir = TempDir::new().unwrap();
919 std::fs::write(
920 dir.path().join("mars.toml"),
921 r#"
922[package]
923name = "pkg"
924version = "1.2.3"
925
926[dependencies.base]
927url = "https://github.com/org/base.git"
928version = ">=1.0.0"
929skills = ["frontend-design"]
930"#,
931 )
932 .unwrap();
933
934 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
935 assert!(diagnostics.is_empty());
936 let manifest = manifest.unwrap();
937 assert_eq!(manifest.package.name, "pkg");
938 assert_eq!(manifest.package.version, "1.2.3");
939 assert!(manifest.dependencies.contains_key("base"));
940 assert_eq!(
941 manifest.dependencies["base"].filter.skills.as_deref(),
942 Some(&[ItemName::from("frontend-design")][..])
943 );
944 }
945
946 #[test]
947 fn load_local_missing_returns_default() {
948 let dir = TempDir::new().unwrap();
949 let local = load_local(dir.path()).unwrap();
950 assert!(local.overrides.is_empty());
951 }
952
953 #[test]
954 fn load_local_from_disk() {
955 let dir = TempDir::new().unwrap();
956 let toml_str = r#"
957[overrides.base]
958path = "/home/dev/local-base"
959"#;
960 std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
961 let local = load_local(dir.path()).unwrap();
962 assert_eq!(local.overrides.len(), 1);
963 assert_eq!(
964 local.overrides["base"].path,
965 PathBuf::from("/home/dev/local-base")
966 );
967 }
968
969 #[test]
970 fn merge_with_empty_local() {
971 let config = Config {
972 dependencies: {
973 let mut m = IndexMap::new();
974 m.insert(
975 "base".into(),
976 DependencyEntry {
977 url: Some("https://github.com/org/base.git".into()),
978 path: None,
979 subpath: None,
980 version: Some("v1.0".into()),
981 filter: FilterConfig::default(),
982 },
983 );
984 m
985 },
986 settings: Settings::default(),
987 ..Config::default()
988 };
989 let local = LocalConfig::default();
990 let effective = merge(config, local).unwrap();
991 assert_eq!(effective.dependencies.len(), 1);
992 let source = &effective.dependencies["base"];
993 assert!(!source.is_overridden);
994 assert!(source.original_git.is_none());
995 match &source.spec {
996 SourceSpec::Git(git) => {
997 assert_eq!(git.url, "https://github.com/org/base.git");
998 assert_eq!(git.version.as_deref(), Some("v1.0"));
999 }
1000 SourceSpec::Path(_) => panic!("expected Git"),
1001 }
1002 }
1003
1004 #[test]
1005 fn merge_override_replaces_with_path() {
1006 let config = Config {
1007 dependencies: {
1008 let mut m = IndexMap::new();
1009 m.insert(
1010 "base".into(),
1011 DependencyEntry {
1012 url: Some("https://github.com/org/base.git".into()),
1013 path: None,
1014 subpath: None,
1015 version: Some("v1.0".into()),
1016 filter: FilterConfig::default(),
1017 },
1018 );
1019 m
1020 },
1021 settings: Settings::default(),
1022 ..Config::default()
1023 };
1024 let local = LocalConfig {
1025 overrides: {
1026 let mut m = IndexMap::new();
1027 m.insert(
1028 "base".into(),
1029 OverrideEntry {
1030 path: PathBuf::from("/home/dev/local-base"),
1031 },
1032 );
1033 m
1034 },
1035 };
1036 let effective = merge(config, local).unwrap();
1037 let source = &effective.dependencies["base"];
1038 assert!(source.is_overridden);
1039
1040 match &source.spec {
1041 SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
1042 SourceSpec::Git(_) => panic!("expected Path override"),
1043 }
1044
1045 let orig = source.original_git.as_ref().unwrap();
1046 assert_eq!(orig.url, "https://github.com/org/base.git");
1047 assert_eq!(orig.version.as_deref(), Some("v1.0"));
1048 }
1049
1050 #[test]
1051 fn merge_override_retains_subpath_coordinate() {
1052 let temp = TempDir::new().unwrap();
1053 let override_path = temp.path().join("local-base");
1054 std::fs::create_dir_all(&override_path).unwrap();
1055
1056 let config = Config {
1057 dependencies: {
1058 let mut m = IndexMap::new();
1059 m.insert(
1060 "base".into(),
1061 DependencyEntry {
1062 url: Some("https://github.com/org/base.git".into()),
1063 path: None,
1064 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1065 version: Some("v1.0".into()),
1066 filter: FilterConfig::default(),
1067 },
1068 );
1069 m
1070 },
1071 settings: Settings::default(),
1072 ..Config::default()
1073 };
1074 let local = LocalConfig {
1075 overrides: {
1076 let mut m = IndexMap::new();
1077 m.insert(
1078 "base".into(),
1079 OverrideEntry {
1080 path: override_path.clone(),
1081 },
1082 );
1083 m
1084 },
1085 };
1086
1087 let (effective, _) = merge_with_root(config, local, temp.path()).unwrap();
1088 let source = &effective.dependencies["base"];
1089 assert!(source.is_overridden);
1090 assert_eq!(
1091 source.subpath.as_ref().map(SourceSubpath::as_str),
1092 Some("plugins/foo")
1093 );
1094 assert!(matches!(&source.spec, SourceSpec::Path(p) if p == &override_path));
1095 assert!(matches!(
1096 &source.id,
1097 SourceId::Path {
1098 canonical,
1099 subpath: Some(sp)
1100 } if canonical == &override_path && sp.as_str() == "plugins/foo"
1101 ));
1102 }
1103
1104 #[test]
1105 fn merge_all_filter_mode() {
1106 let config = Config {
1107 dependencies: {
1108 let mut m = IndexMap::new();
1109 m.insert(
1110 "base".into(),
1111 DependencyEntry {
1112 url: Some("https://github.com/org/base.git".into()),
1113 path: None,
1114 subpath: None,
1115 version: None,
1116 filter: FilterConfig::default(),
1117 },
1118 );
1119 m
1120 },
1121 settings: Settings::default(),
1122 ..Config::default()
1123 };
1124 let effective = merge(config, LocalConfig::default()).unwrap();
1125 assert!(matches!(
1126 effective.dependencies["base"].filter,
1127 FilterMode::All
1128 ));
1129 }
1130
1131 #[test]
1132 fn save_and_reload() {
1133 let dir = TempDir::new().unwrap();
1134 let config = Config {
1135 dependencies: {
1136 let mut m = IndexMap::new();
1137 m.insert(
1138 "base".into(),
1139 DependencyEntry {
1140 url: Some("https://github.com/org/base.git".into()),
1141 path: None,
1142 subpath: None,
1143 version: Some("v2.0".into()),
1144 filter: FilterConfig::default(),
1145 },
1146 );
1147 m
1148 },
1149 settings: Settings::default(),
1150 ..Config::default()
1151 };
1152 save(dir.path(), &config).unwrap();
1153 let reloaded = load(dir.path()).unwrap();
1154 assert_eq!(config, reloaded);
1155 }
1156
1157 #[test]
1158 fn rename_map_preserved() {
1159 let toml_str = r#"
1160[dependencies.base]
1161url = "https://github.com/org/base.git"
1162
1163[dependencies.base.rename]
1164old-name = "new-name"
1165"#;
1166 let config: Config = toml::from_str(toml_str).unwrap();
1167 let effective = merge(config, LocalConfig::default()).unwrap();
1168 let source = &effective.dependencies["base"];
1169 assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
1170 }
1171
1172 #[test]
1173 fn self_dependency_name_rejected() {
1174 let toml_str = r#"
1175[dependencies._self]
1176url = "https://github.com/org/base.git"
1177"#;
1178 let config: Config = toml::from_str(toml_str).unwrap();
1179 let local = LocalConfig::default();
1180 let result = merge(config, local);
1181 assert!(result.is_err());
1182 let err = result.unwrap_err().to_string();
1183 assert!(
1184 err.contains("_self") && err.contains("reserved"),
1185 "should reject _self: {err}"
1186 );
1187 }
1188
1189 #[test]
1190 fn managed_root_setting_roundtrip() {
1191 let config = Config {
1192 settings: Settings {
1193 managed_root: Some(".claude".into()),
1194 targets: None,
1195 ..Settings::default()
1196 },
1197 ..Config::default()
1198 };
1199 let serialized = toml::to_string_pretty(&config).unwrap();
1200 let deserialized: Config = toml::from_str(&serialized).unwrap();
1201 assert_eq!(
1202 deserialized.settings.managed_root.as_deref(),
1203 Some(".claude")
1204 );
1205 }
1206
1207 #[test]
1208 fn save_preserves_dependencies_when_clearing_last_target() {
1209 let dir = TempDir::new().unwrap();
1210 let original = r#"
1211[package]
1212name = "sample"
1213version = "0.1.0"
1214
1215[dependencies.base]
1216url = "https://github.com/org/base.git"
1217version = "v1.0"
1218agents = ["coder"]
1219
1220[settings]
1221managed_root = ".agents"
1222targets = [".claude"]
1223"#;
1224 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1225
1226 let mut config = load(dir.path()).unwrap();
1227 if let Some(targets) = config.settings.targets.as_mut() {
1228 targets.retain(|target| target != ".claude");
1229 if targets.is_empty() {
1230 config.settings.targets = None;
1231 }
1232 }
1233 save(dir.path(), &config).unwrap();
1234
1235 let reloaded = load(dir.path()).unwrap();
1236 assert_eq!(
1237 reloaded.package.as_ref().map(|p| p.name.as_str()),
1238 Some("sample")
1239 );
1240 assert_eq!(
1241 reloaded.dependencies["base"].url.as_deref(),
1242 Some("https://github.com/org/base.git")
1243 );
1244 assert_eq!(
1245 reloaded.dependencies["base"].version.as_deref(),
1246 Some("v1.0")
1247 );
1248 assert_eq!(
1249 reloaded.dependencies["base"].filter.agents.as_deref(),
1250 Some(&["coder".into()][..])
1251 );
1252 assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
1253 assert!(reloaded.settings.targets.is_none());
1254 }
1255
1256 #[test]
1257 fn roundtrip_preserves_all_filter_fields() {
1258 let dir = TempDir::new().unwrap();
1259 let original = r#"
1260[dependencies.include]
1261url = "https://github.com/org/include.git"
1262agents = ["coder", "reviewer"]
1263skills = ["review", "plan"]
1264
1265[dependencies.include.rename]
1266coder = "core-coder"
1267
1268[dependencies.exclude]
1269url = "https://github.com/org/exclude.git"
1270exclude = ["experimental", "deprecated"]
1271
1272[dependencies.only_skills]
1273url = "https://github.com/org/skills.git"
1274only_skills = true
1275
1276[dependencies.only_agents]
1277url = "https://github.com/org/agents.git"
1278only_agents = true
1279"#;
1280 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1281
1282 let config = load(dir.path()).unwrap();
1283 save(dir.path(), &config).unwrap();
1284 let reloaded = load(dir.path()).unwrap();
1285
1286 let include = &reloaded.dependencies["include"].filter;
1287 assert_eq!(
1288 include.agents.as_deref(),
1289 Some(&["coder".into(), "reviewer".into()][..])
1290 );
1291 assert_eq!(
1292 include.skills.as_deref(),
1293 Some(&["review".into(), "plan".into()][..])
1294 );
1295 assert_eq!(
1296 include.rename.as_ref().and_then(|r| r.get("coder")),
1297 Some(&"core-coder".into())
1298 );
1299
1300 let exclude = &reloaded.dependencies["exclude"].filter;
1301 assert_eq!(
1302 exclude.exclude.as_deref(),
1303 Some(&["experimental".into(), "deprecated".into()][..])
1304 );
1305
1306 let only_skills = &reloaded.dependencies["only_skills"].filter;
1307 assert!(only_skills.only_skills);
1308 assert!(!only_skills.only_agents);
1309
1310 let only_agents = &reloaded.dependencies["only_agents"].filter;
1311 assert!(only_agents.only_agents);
1312 assert!(!only_agents.only_skills);
1313 }
1314
1315 #[test]
1316 fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
1317 let dir = TempDir::new().unwrap();
1318 let original = r#"
1319[dependencies.git-include]
1320url = "https://github.com/org/git-include.git"
1321agents = ["coder"]
1322
1323[dependencies.path-exclude]
1324path = "../local-source"
1325exclude = ["draft"]
1326
1327[dependencies.git-only-skills]
1328url = "https://github.com/org/git-skills.git"
1329only_skills = true
1330
1331[dependencies.git-only-agents]
1332url = "https://github.com/org/git-agents.git"
1333only_agents = true
1334"#;
1335 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1336
1337 let config = load(dir.path()).unwrap();
1338 save(dir.path(), &config).unwrap();
1339 let reloaded = load(dir.path()).unwrap();
1340
1341 assert_eq!(reloaded.dependencies.len(), 4);
1342 assert_eq!(
1343 reloaded.dependencies["git-include"]
1344 .filter
1345 .agents
1346 .as_deref(),
1347 Some(&["coder".into()][..])
1348 );
1349 assert_eq!(
1350 reloaded.dependencies["path-exclude"].path.as_deref(),
1351 Some(Path::new("../local-source"))
1352 );
1353 assert_eq!(
1354 reloaded.dependencies["path-exclude"]
1355 .filter
1356 .exclude
1357 .as_deref(),
1358 Some(&["draft".into()][..])
1359 );
1360 assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
1361 assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
1362 }
1363
1364 #[test]
1365 fn save_roundtrip_guard_rejects_dependency_count_loss() {
1366 let mut original = Config::default();
1367 original.dependencies.insert(
1368 "base".into(),
1369 DependencyEntry {
1370 url: Some("https://github.com/org/base.git".into()),
1371 path: None,
1372 subpath: None,
1373 version: Some("v1.0".into()),
1374 filter: FilterConfig::default(),
1375 },
1376 );
1377
1378 let reparsed = Config::default();
1379 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1380 let msg = err.to_string();
1381 assert!(
1382 msg.contains("dependency count changed"),
1383 "unexpected error: {msg}"
1384 );
1385 }
1386
1387 #[test]
1388 fn save_roundtrip_guard_rejects_managed_root_loss() {
1389 let original = Config {
1390 settings: Settings {
1391 managed_root: Some(".agents".into()),
1392 targets: None,
1393 ..Settings::default()
1394 },
1395 ..Config::default()
1396 };
1397 let reparsed = Config::default();
1398 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1399 let msg = err.to_string();
1400 assert!(
1401 msg.contains("settings.managed_root changed"),
1402 "unexpected error: {msg}"
1403 );
1404 }
1405
1406 #[test]
1407 fn parse_only_skills_filter() {
1408 let toml_str = r#"
1409[dependencies.base]
1410url = "https://github.com/org/base.git"
1411only_skills = true
1412"#;
1413 let config: Config = toml::from_str(toml_str).unwrap();
1414 let local = LocalConfig::default();
1415 let effective = merge(config, local).unwrap();
1416 let source = &effective.dependencies["base"];
1417 assert!(matches!(source.filter, FilterMode::OnlySkills));
1418 }
1419
1420 #[test]
1421 fn parse_only_agents_filter() {
1422 let toml_str = r#"
1423[dependencies.base]
1424url = "https://github.com/org/base.git"
1425only_agents = true
1426"#;
1427 let config: Config = toml::from_str(toml_str).unwrap();
1428 let local = LocalConfig::default();
1429 let effective = merge(config, local).unwrap();
1430 let source = &effective.dependencies["base"];
1431 assert!(matches!(source.filter, FilterMode::OnlyAgents));
1432 }
1433
1434 #[test]
1435 fn error_on_only_skills_and_only_agents() {
1436 let toml_str = r#"
1437[dependencies.bad]
1438url = "https://github.com/org/bad.git"
1439only_skills = true
1440only_agents = true
1441"#;
1442 let config: Config = toml::from_str(toml_str).unwrap();
1443 let local = LocalConfig::default();
1444 let result = merge(config, local);
1445 assert!(result.is_err());
1446 let err = result.unwrap_err().to_string();
1447 assert!(
1448 err.contains("mutually exclusive"),
1449 "should mention mutually exclusive: {err}"
1450 );
1451 }
1452
1453 #[test]
1454 fn error_on_only_skills_with_agents_list() {
1455 let toml_str = r#"
1456[dependencies.bad]
1457url = "https://github.com/org/bad.git"
1458only_skills = true
1459agents = ["coder"]
1460"#;
1461 let config: Config = toml::from_str(toml_str).unwrap();
1462 let local = LocalConfig::default();
1463 let result = merge(config, local);
1464 assert!(result.is_err());
1465 let err = result.unwrap_err().to_string();
1466 assert!(
1467 err.contains("cannot combine"),
1468 "should mention cannot combine: {err}"
1469 );
1470 }
1471
1472 #[test]
1473 fn error_on_only_agents_with_skills_list() {
1474 let toml_str = r#"
1475[dependencies.bad]
1476url = "https://github.com/org/bad.git"
1477only_agents = true
1478skills = ["planning"]
1479"#;
1480 let config: Config = toml::from_str(toml_str).unwrap();
1481 let local = LocalConfig::default();
1482 let result = merge(config, local);
1483 assert!(result.is_err());
1484 }
1485
1486 #[test]
1487 fn error_on_only_skills_with_exclude() {
1488 let toml_str = r#"
1489[dependencies.bad]
1490url = "https://github.com/org/bad.git"
1491only_skills = true
1492exclude = ["deprecated"]
1493"#;
1494 let config: Config = toml::from_str(toml_str).unwrap();
1495 let local = LocalConfig::default();
1496 let result = merge(config, local);
1497 assert!(result.is_err());
1498 }
1499
1500 #[test]
1501 fn only_skills_false_not_serialized() {
1502 let config = Config {
1503 dependencies: {
1504 let mut m = IndexMap::new();
1505 m.insert(
1506 "base".into(),
1507 DependencyEntry {
1508 url: Some("https://github.com/org/base.git".into()),
1509 path: None,
1510 subpath: None,
1511 version: None,
1512 filter: FilterConfig::default(),
1513 },
1514 );
1515 m
1516 },
1517 settings: Settings::default(),
1518 ..Config::default()
1519 };
1520 let serialized = toml::to_string_pretty(&config).unwrap();
1521 assert!(
1522 !serialized.contains("only_skills"),
1523 "false booleans should not be serialized: {serialized}"
1524 );
1525 assert!(
1526 !serialized.contains("only_agents"),
1527 "false booleans should not be serialized: {serialized}"
1528 );
1529 }
1530
1531 #[test]
1532 fn only_skills_true_roundtrips() {
1533 let toml_str = r#"
1534[dependencies.base]
1535url = "https://github.com/org/base.git"
1536only_skills = true
1537"#;
1538 let config: Config = toml::from_str(toml_str).unwrap();
1539 assert!(config.dependencies["base"].filter.only_skills);
1540 assert!(!config.dependencies["base"].filter.only_agents);
1541
1542 let serialized = toml::to_string_pretty(&config).unwrap();
1543 let reloaded: Config = toml::from_str(&serialized).unwrap();
1544 assert!(reloaded.dependencies["base"].filter.only_skills);
1545 }
1546
1547 #[test]
1548 fn filter_config_has_any_filter() {
1549 assert!(!FilterConfig::default().has_any_filter());
1550 assert!(
1551 FilterConfig {
1552 only_skills: true,
1553 ..FilterConfig::default()
1554 }
1555 .has_any_filter()
1556 );
1557 assert!(
1558 FilterConfig {
1559 agents: Some(vec!["coder".into()]),
1560 ..FilterConfig::default()
1561 }
1562 .has_any_filter()
1563 );
1564 }
1565
1566 #[test]
1567 fn filter_config_to_mode() {
1568 assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
1569 assert!(matches!(
1570 FilterConfig {
1571 only_skills: true,
1572 ..FilterConfig::default()
1573 }
1574 .to_mode(),
1575 FilterMode::OnlySkills
1576 ));
1577 assert!(matches!(
1578 FilterConfig {
1579 only_agents: true,
1580 ..FilterConfig::default()
1581 }
1582 .to_mode(),
1583 FilterMode::OnlyAgents
1584 ));
1585 assert!(matches!(
1586 FilterConfig {
1587 agents: Some(vec!["coder".into()]),
1588 ..FilterConfig::default()
1589 }
1590 .to_mode(),
1591 FilterMode::Include { .. }
1592 ));
1593 assert!(matches!(
1594 FilterConfig {
1595 exclude: Some(vec!["old".into()]),
1596 ..FilterConfig::default()
1597 }
1598 .to_mode(),
1599 FilterMode::Exclude(_)
1600 ));
1601 }
1602
1603 #[test]
1606 fn managed_targets_defaults_to_agents() {
1607 let settings = Settings::default();
1608 assert_eq!(settings.managed_targets(), vec![".agents"]);
1609 }
1610
1611 #[test]
1612 fn managed_targets_uses_explicit_targets() {
1613 let settings = Settings {
1614 targets: Some(vec![".claude".to_string()]),
1615 ..Settings::default()
1616 };
1617 assert_eq!(settings.managed_targets(), vec![".claude"]);
1618 }
1619
1620 #[test]
1621 fn managed_targets_uses_managed_root_as_primary() {
1622 let settings = Settings {
1623 managed_root: Some(".claude".to_string()),
1624 ..Settings::default()
1625 };
1626 assert_eq!(settings.managed_targets(), vec![".claude"]);
1627 }
1628
1629 #[test]
1630 fn managed_targets_explicit_overrides_links_and_managed_root() {
1631 let settings = Settings {
1632 managed_root: Some(".cursor".to_string()),
1633 targets: Some(vec![".codex".to_string()]),
1634 ..Settings::default()
1635 };
1636 assert_eq!(settings.managed_targets(), vec![".codex"]);
1638 }
1639
1640 #[test]
1641 fn settings_models_cache_ttl_defaults_to_24_when_omitted() {
1642 let config: Config = toml::from_str(
1643 r#"
1644[dependencies.base]
1645url = "https://github.com/org/base.git"
1646"#,
1647 )
1648 .unwrap();
1649 assert_eq!(config.settings.models_cache_ttl_hours, 24);
1650 }
1651
1652 #[test]
1653 fn settings_models_cache_ttl_defaults_to_24_when_settings_present_without_ttl() {
1654 let config: Config = toml::from_str(
1655 r#"
1656[settings]
1657managed_root = ".agents"
1658"#,
1659 )
1660 .unwrap();
1661 assert_eq!(config.settings.models_cache_ttl_hours, 24);
1662 }
1663
1664 #[test]
1665 fn settings_models_cache_ttl_parses_zero() {
1666 let config: Config = toml::from_str(
1667 r#"
1668[settings]
1669models_cache_ttl_hours = 0
1670"#,
1671 )
1672 .unwrap();
1673 assert_eq!(config.settings.models_cache_ttl_hours, 0);
1674 }
1675
1676 #[test]
1677 fn settings_models_cache_ttl_parses_custom_value() {
1678 let config: Config = toml::from_str(
1679 r#"
1680[settings]
1681models_cache_ttl_hours = 48
1682"#,
1683 )
1684 .unwrap();
1685 assert_eq!(config.settings.models_cache_ttl_hours, 48);
1686 }
1687
1688 #[test]
1689 fn settings_models_cache_ttl_roundtrip_preserves_value() {
1690 let original = Config {
1691 settings: Settings {
1692 models_cache_ttl_hours: 48,
1693 ..Settings::default()
1694 },
1695 ..Config::default()
1696 };
1697 let serialized = toml::to_string_pretty(&original).unwrap();
1698 let roundtripped: Config = toml::from_str(&serialized).unwrap();
1699 assert_eq!(
1700 roundtripped.settings.models_cache_ttl_hours,
1701 original.settings.models_cache_ttl_hours
1702 );
1703 }
1704
1705 #[test]
1706 fn model_visibility_validate_rejects_include_and_exclude() {
1707 let visibility = ModelVisibility {
1708 include: Some(vec!["opus*".into()]),
1709 exclude: Some(vec!["test*".into()]),
1710 };
1711 let err = visibility.validate().unwrap_err();
1712 assert!(
1713 err.to_string().contains("[settings.model_visibility]"),
1714 "unexpected error: {err}"
1715 );
1716 }
1717
1718 #[test]
1719 fn model_visibility_validate_allows_include_only_exclude_only_and_empty() {
1720 ModelVisibility {
1721 include: Some(vec!["opus*".into()]),
1722 exclude: None,
1723 }
1724 .validate()
1725 .unwrap();
1726 ModelVisibility {
1727 include: None,
1728 exclude: Some(vec!["test*".into()]),
1729 }
1730 .validate()
1731 .unwrap();
1732 ModelVisibility::default().validate().unwrap();
1733 }
1734
1735 #[test]
1736 fn model_visibility_is_empty_reports_state() {
1737 assert!(ModelVisibility::default().is_empty());
1738 assert!(
1739 !ModelVisibility {
1740 include: Some(vec!["opus*".into()]),
1741 exclude: None,
1742 }
1743 .is_empty()
1744 );
1745 assert!(
1746 !ModelVisibility {
1747 include: None,
1748 exclude: Some(vec!["test*".into()]),
1749 }
1750 .is_empty()
1751 );
1752 }
1753
1754 #[test]
1755 fn load_rejects_model_visibility_with_include_and_exclude() {
1756 let dir = TempDir::new().unwrap();
1757 std::fs::write(
1758 dir.path().join("mars.toml"),
1759 r#"
1760[settings.model_visibility]
1761include = ["opus*"]
1762exclude = ["test*"]
1763"#,
1764 )
1765 .unwrap();
1766
1767 let err = load(dir.path()).unwrap_err();
1768 assert!(
1769 err.to_string().contains("[settings.model_visibility]"),
1770 "unexpected error: {err}"
1771 );
1772 }
1773
1774 #[test]
1775 fn load_accepts_model_visibility_include_only() {
1776 let dir = TempDir::new().unwrap();
1777 std::fs::write(
1778 dir.path().join("mars.toml"),
1779 r#"
1780[settings.model_visibility]
1781include = ["opus*", "gpt-*"]
1782"#,
1783 )
1784 .unwrap();
1785
1786 let config = load(dir.path()).unwrap();
1787 assert_eq!(
1788 config.settings.model_visibility.include,
1789 Some(vec!["opus*".into(), "gpt-*".into()])
1790 );
1791 assert!(config.settings.model_visibility.exclude.is_none());
1792 }
1793
1794 #[test]
1795 fn load_accepts_model_visibility_exclude_only() {
1796 let dir = TempDir::new().unwrap();
1797 std::fs::write(
1798 dir.path().join("mars.toml"),
1799 r#"
1800[settings.model_visibility]
1801exclude = ["test-*", "deprecated-*"]
1802"#,
1803 )
1804 .unwrap();
1805
1806 let config = load(dir.path()).unwrap();
1807 assert_eq!(
1808 config.settings.model_visibility.exclude,
1809 Some(vec!["test-*".into(), "deprecated-*".into()])
1810 );
1811 assert!(config.settings.model_visibility.include.is_none());
1812 }
1813}