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