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