1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::error::{ConfigError, MarsError};
7use crate::types::{ItemName, RenameMap, SourceId, SourceName, SourceUrl};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
11pub struct Config {
12 #[serde(default, skip_serializing_if = "Option::is_none")]
13 pub package: Option<PackageInfo>,
14 #[serde(default)]
15 pub dependencies: IndexMap<SourceName, DependencyEntry>,
16 #[serde(default)]
17 pub settings: Settings,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct PackageInfo {
23 pub name: String,
24 pub version: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub description: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct DependencyEntry {
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub url: Option<SourceUrl>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub path: Option<PathBuf>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub version: Option<String>,
40 #[serde(flatten)]
41 pub filter: FilterConfig,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct Manifest {
50 pub package: PackageInfo,
51 #[serde(default)]
52 pub dependencies: IndexMap<String, DependencyEntry>,
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
57pub struct FilterConfig {
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub agents: Option<Vec<ItemName>>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub skills: Option<Vec<ItemName>>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub exclude: Option<Vec<ItemName>>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub rename: Option<RenameMap>,
66 #[serde(default, skip_serializing_if = "is_false")]
67 pub only_skills: bool,
68 #[serde(default, skip_serializing_if = "is_false")]
69 pub only_agents: bool,
70}
71
72fn is_false(v: &bool) -> bool {
73 !v
74}
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
81pub struct LocalConfig {
82 #[serde(default)]
83 pub overrides: IndexMap<SourceName, OverrideEntry>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct OverrideEntry {
89 pub path: PathBuf,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
94pub struct Settings {
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub managed_root: Option<String>,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub links: Vec<String>,
101}
102
103#[derive(Debug, Clone)]
105pub enum SourceSpec {
106 Git(GitSpec),
107 Path(PathBuf),
108}
109
110#[derive(Debug, Clone)]
112pub struct GitSpec {
113 pub url: SourceUrl,
114 pub version: Option<String>,
115}
116
117#[derive(Debug, Clone)]
119pub enum FilterMode {
120 All,
122 Include {
124 agents: Vec<ItemName>,
125 skills: Vec<ItemName>,
126 },
127 Exclude(Vec<ItemName>),
129 OnlySkills,
131 OnlyAgents,
133}
134
135#[derive(Debug, Clone)]
139pub struct EffectiveConfig {
140 pub dependencies: IndexMap<SourceName, EffectiveDependency>,
141 pub settings: Settings,
142}
143
144#[derive(Debug, Clone)]
146pub struct EffectiveDependency {
147 pub name: SourceName,
148 pub id: SourceId,
149 pub spec: SourceSpec,
150 pub filter: FilterMode,
151 pub rename: RenameMap,
152 pub is_overridden: bool,
153 pub original_git: Option<GitSpec>,
154}
155
156const CONFIG_FILE: &str = "mars.toml";
157const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
158
159pub fn load(root: &Path) -> Result<Config, MarsError> {
161 let path = root.join(CONFIG_FILE);
162 let content = std::fs::read_to_string(&path).map_err(|e| {
163 if e.kind() == std::io::ErrorKind::NotFound {
164 ConfigError::NotFound { path: path.clone() }
165 } else {
166 ConfigError::Io(e)
167 }
168 })?;
169 let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
170 migrate_legacy_source_urls(&mut config);
171 Ok(config)
172}
173
174pub fn load_manifest(source_root: &Path) -> Result<Option<Manifest>, MarsError> {
179 let path = source_root.join(CONFIG_FILE);
180 match std::fs::read_to_string(&path) {
181 Ok(content) => {
182 let parsed: Config =
183 toml::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
184 message: format!("failed to parse {}: {e}", path.display()),
185 })?;
186 let Some(package) = parsed.package else {
187 return Ok(None);
188 };
189 let deps: IndexMap<String, DependencyEntry> = parsed
191 .dependencies
192 .into_iter()
193 .map(|(k, v)| (k.to_string(), v))
194 .collect();
195 Ok(Some(Manifest {
196 package,
197 dependencies: deps,
198 }))
199 }
200 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
201 Err(e) => Err(MarsError::Io(e)),
202 }
203}
204
205pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
207 let path = root.join(LOCAL_CONFIG_FILE);
208 match std::fs::read_to_string(&path) {
209 Ok(content) => {
210 let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
211 Ok(local)
212 }
213 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
214 Err(e) => Err(ConfigError::Io(e).into()),
215 }
216}
217
218pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
225 merge_with_root(config, local, Path::new("."))
226}
227
228pub fn merge_with_root(
230 config: Config,
231 local: LocalConfig,
232 root: &Path,
233) -> Result<EffectiveConfig, MarsError> {
234 let mut dependencies = IndexMap::new();
235
236 for (name, entry) in &config.dependencies {
237 if name.as_ref() == "_self" {
239 return Err(ConfigError::Invalid {
240 message: "dependency name `_self` is reserved for local package items".into(),
241 }
242 .into());
243 }
244
245 let base_spec = match (&entry.url, &entry.path) {
247 (Some(url), None) => SourceSpec::Git(GitSpec {
248 url: url.clone(),
249 version: entry.version.clone(),
250 }),
251 (None, Some(path)) => SourceSpec::Path(path.clone()),
252 (Some(_), Some(_)) => {
253 return Err(ConfigError::Invalid {
254 message: format!("source `{name}` has both `url` and `path` — pick one"),
255 }
256 .into());
257 }
258 (None, None) => {
259 return Err(ConfigError::Invalid {
260 message: format!(
261 "source `{name}` has neither `url` nor `path` — one is required"
262 ),
263 }
264 .into());
265 }
266 };
267
268 validate_filter(&entry.filter, name.as_ref())?;
270
271 let filter = entry.filter.to_mode();
272
273 let rename = entry.filter.rename.clone().unwrap_or_default();
274
275 let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
277 let original = match &base_spec {
278 SourceSpec::Git(git) => Some(git.clone()),
279 SourceSpec::Path(_) => None,
280 };
281 (SourceSpec::Path(ov.path.clone()), true, original)
282 } else {
283 (base_spec, false, None)
284 };
285 let id = source_id_for_spec(root, &spec);
286
287 dependencies.insert(
288 name.clone(),
289 EffectiveDependency {
290 name: name.clone(),
291 id,
292 spec,
293 filter,
294 rename,
295 is_overridden,
296 original_git,
297 },
298 );
299 }
300
301 for override_name in local.overrides.keys() {
303 if !config.dependencies.contains_key(override_name) {
304 eprintln!(
305 "warning: override `{override_name}` references a dependency not in mars.toml"
306 );
307 }
308 }
309
310 Ok(EffectiveConfig {
311 dependencies,
312 settings: config.settings,
313 })
314}
315
316pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
324 let has_include = filter.agents.is_some() || filter.skills.is_some();
325 let has_exclude = filter.exclude.is_some();
326 let has_category = filter.only_skills || filter.only_agents;
327
328 if filter.only_skills && filter.only_agents {
329 return Err(ConfigError::Invalid {
330 message: format!(
331 "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
332 ),
333 }
334 .into());
335 }
336 if has_category && has_include {
337 return Err(ConfigError::Invalid {
338 message: format!(
339 "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
340 ),
341 }
342 .into());
343 }
344 if has_category && has_exclude {
345 return Err(ConfigError::Invalid {
346 message: format!(
347 "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
348 ),
349 }
350 .into());
351 }
352 if has_include && has_exclude {
353 return Err(ConfigError::ConflictingFilters {
354 name: dep_name.to_string(),
355 }
356 .into());
357 }
358 Ok(())
359}
360
361impl FilterConfig {
362 pub fn to_mode(&self) -> FilterMode {
364 if self.only_skills {
365 FilterMode::OnlySkills
366 } else if self.only_agents {
367 FilterMode::OnlyAgents
368 } else if self.agents.is_some() || self.skills.is_some() {
369 FilterMode::Include {
370 agents: self.agents.clone().unwrap_or_default(),
371 skills: self.skills.clone().unwrap_or_default(),
372 }
373 } else if self.exclude.is_some() {
374 FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
375 } else {
376 FilterMode::All
377 }
378 }
379
380 pub fn has_any_filter(&self) -> bool {
382 self.agents.is_some()
383 || self.skills.is_some()
384 || self.exclude.is_some()
385 || self.only_skills
386 || self.only_agents
387 }
388}
389
390fn source_id_for_spec(root: &Path, spec: &SourceSpec) -> SourceId {
391 match spec {
392 SourceSpec::Git(git) => SourceId::git(git.url.clone()),
393 SourceSpec::Path(path) => match SourceId::path(root, path) {
394 Ok(id) => id,
395 Err(_) => {
396 let canonical = if path.is_absolute() {
397 path.clone()
398 } else {
399 root.join(path)
400 };
401 SourceId::Path { canonical }
402 }
403 },
404 }
405}
406
407fn migrate_legacy_source_urls(config: &mut Config) {
408 for dep in config.dependencies.values_mut() {
409 if let Some(url) = dep.url.as_mut() {
410 let raw = url.as_str();
411 if should_upgrade_legacy_git_url(raw) {
412 *url = SourceUrl::from(format!("https://{raw}"));
413 }
414 }
415 }
416}
417
418fn should_upgrade_legacy_git_url(url: &str) -> bool {
419 !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
420}
421
422pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
424 let path = root.join(CONFIG_FILE);
425 let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
426 message: format!("failed to serialize config: {e}"),
427 })?;
428 crate::fs::atomic_write(&path, content.as_bytes())
429}
430
431pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
433 let path = root.join(LOCAL_CONFIG_FILE);
434 let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
435 message: format!("failed to serialize local config: {e}"),
436 })?;
437 crate::fs::atomic_write(&path, content.as_bytes())
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use tempfile::TempDir;
444
445 #[test]
446 fn parse_git_dependency() {
447 let toml_str = r#"
448[dependencies.base]
449url = "https://github.com/org/base.git"
450version = "v1.0"
451"#;
452 let config: Config = toml::from_str(toml_str).unwrap();
453 assert_eq!(config.dependencies.len(), 1);
454 let entry = &config.dependencies["base"];
455 assert_eq!(
456 entry.url.as_deref(),
457 Some("https://github.com/org/base.git")
458 );
459 assert!(entry.path.is_none());
460 assert_eq!(entry.version.as_deref(), Some("v1.0"));
461 }
462
463 #[test]
464 fn parse_path_dependency() {
465 let toml_str = r#"
466[dependencies.local]
467path = "../my-agents"
468"#;
469 let config: Config = toml::from_str(toml_str).unwrap();
470 let entry = &config.dependencies["local"];
471 assert!(entry.url.is_none());
472 assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
473 }
474
475 #[test]
476 fn parse_mixed_dependencies() {
477 let toml_str = r#"
478[dependencies.remote]
479url = "https://github.com/org/remote.git"
480version = "v2.0"
481agents = ["coder", "reviewer"]
482
483[dependencies.local]
484path = "/home/dev/agents"
485exclude = ["experimental"]
486"#;
487 let config: Config = toml::from_str(toml_str).unwrap();
488 assert_eq!(config.dependencies.len(), 2);
489 assert!(config.dependencies.contains_key("remote"));
490 assert!(config.dependencies.contains_key("local"));
491 }
492
493 #[test]
494 fn parse_package_and_dependencies_coexist() {
495 let toml_str = r#"
496[package]
497name = "my-agents"
498version = "0.1.0"
499
500[dependencies.base]
501url = "https://github.com/org/base.git"
502version = ">=1.0.0"
503
504[dependencies.local]
505path = "../local-agents"
506"#;
507 let config: Config = toml::from_str(toml_str).unwrap();
508 assert!(config.package.is_some());
509 assert!(config.dependencies.contains_key("base"));
510 assert!(config.dependencies.contains_key("local"));
511 }
512
513 #[test]
514 fn parse_include_filter() {
515 let toml_str = r#"
516[dependencies.base]
517url = "https://github.com/org/base.git"
518agents = ["coder"]
519skills = ["review"]
520"#;
521 let config: Config = toml::from_str(toml_str).unwrap();
522 let local = LocalConfig::default();
523 let effective = merge(config, local).unwrap();
524 let source = &effective.dependencies["base"];
525 match &source.filter {
526 FilterMode::Include { agents, skills } => {
527 assert_eq!(agents, &["coder"]);
528 assert_eq!(skills, &["review"]);
529 }
530 other => panic!("expected Include, got {other:?}"),
531 }
532 }
533
534 #[test]
535 fn parse_exclude_filter() {
536 let toml_str = r#"
537[dependencies.base]
538url = "https://github.com/org/base.git"
539exclude = ["experimental", "deprecated"]
540"#;
541 let config: Config = toml::from_str(toml_str).unwrap();
542 let local = LocalConfig::default();
543 let effective = merge(config, local).unwrap();
544 let source = &effective.dependencies["base"];
545 match &source.filter {
546 FilterMode::Exclude(items) => {
547 assert_eq!(items, &["experimental", "deprecated"]);
548 }
549 other => panic!("expected Exclude, got {other:?}"),
550 }
551 }
552
553 #[test]
554 fn error_on_both_include_and_exclude() {
555 let toml_str = r#"
556[dependencies.bad]
557url = "https://github.com/org/bad.git"
558agents = ["coder"]
559exclude = ["reviewer"]
560"#;
561 let config: Config = toml::from_str(toml_str).unwrap();
562 let local = LocalConfig::default();
563 let result = merge(config, local);
564 assert!(result.is_err());
565 let err = result.unwrap_err().to_string();
566 assert!(
567 err.contains("bad"),
568 "error should mention dependency name: {err}"
569 );
570 }
571
572 #[test]
573 fn error_on_neither_url_nor_path() {
574 let toml_str = r#"
575[dependencies.empty]
576version = "v1.0"
577"#;
578 let config: Config = toml::from_str(toml_str).unwrap();
579 let local = LocalConfig::default();
580 let result = merge(config, local);
581 assert!(result.is_err());
582 let err = result.unwrap_err().to_string();
583 assert!(
584 err.contains("neither"),
585 "error should mention 'neither': {err}"
586 );
587 }
588
589 #[test]
590 fn error_on_both_url_and_path() {
591 let toml_str = r#"
592[dependencies.both]
593url = "https://github.com/org/repo.git"
594path = "/local/path"
595"#;
596 let config: Config = toml::from_str(toml_str).unwrap();
597 let local = LocalConfig::default();
598 let result = merge(config, local);
599 assert!(result.is_err());
600 let err = result.unwrap_err().to_string();
601 assert!(err.contains("both"), "error should mention 'both': {err}");
602 }
603
604 #[test]
605 fn roundtrip_config() {
606 let config = Config {
607 dependencies: {
608 let mut m = IndexMap::new();
609 m.insert(
610 "base".into(),
611 DependencyEntry {
612 url: Some("https://github.com/org/base.git".into()),
613 path: None,
614 version: Some("v1.0".into()),
615 filter: FilterConfig {
616 agents: Some(vec!["coder".into()]),
617 skills: None,
618 exclude: None,
619 rename: None,
620 only_skills: false,
621 only_agents: false,
622 },
623 },
624 );
625 m.insert(
626 "local".into(),
627 DependencyEntry {
628 url: None,
629 path: Some(PathBuf::from("../my-agents")),
630 version: None,
631 filter: FilterConfig::default(),
632 },
633 );
634 m
635 },
636 settings: Settings::default(),
637 ..Config::default()
638 };
639 let serialized = toml::to_string_pretty(&config).unwrap();
640 let deserialized: Config = toml::from_str(&serialized).unwrap();
641 assert_eq!(config, deserialized);
642 }
643
644 #[test]
645 fn load_from_disk() {
646 let dir = TempDir::new().unwrap();
647 let toml_str = r#"
648[dependencies.base]
649url = "https://github.com/org/base.git"
650version = "v1.0"
651"#;
652 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
653 let config = load(dir.path()).unwrap();
654 assert_eq!(config.dependencies.len(), 1);
655 }
656
657 #[test]
658 fn load_migrates_legacy_bare_domain_url() {
659 let dir = TempDir::new().unwrap();
660 let toml_str = r#"
661[dependencies.base]
662url = "github.com/org/base"
663"#;
664 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
665
666 let config = load(dir.path()).unwrap();
667 assert_eq!(
668 config.dependencies["base"].url.as_deref(),
669 Some("https://github.com/org/base")
670 );
671 }
672
673 #[test]
674 fn load_does_not_migrate_ssh_url() {
675 let dir = TempDir::new().unwrap();
676 let toml_str = r#"
677[dependencies.base]
678url = "git@github.com:org/base.git"
679"#;
680 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
681
682 let config = load(dir.path()).unwrap();
683 assert_eq!(
684 config.dependencies["base"].url.as_deref(),
685 Some("git@github.com:org/base.git")
686 );
687 }
688
689 #[test]
690 fn load_missing_file_returns_not_found() {
691 let dir = TempDir::new().unwrap();
692 let result = load(dir.path());
693 assert!(result.is_err());
694 let err = result.unwrap_err().to_string();
695 assert!(err.contains("not found"), "should be NotFound: {err}");
696 }
697
698 #[test]
699 fn load_manifest_returns_none_without_package() {
700 let dir = TempDir::new().unwrap();
701 std::fs::write(
702 dir.path().join("mars.toml"),
703 r#"
704[dependencies.base]
705url = "https://github.com/org/base.git"
706"#,
707 )
708 .unwrap();
709
710 let manifest = load_manifest(dir.path()).unwrap();
711 assert!(manifest.is_none());
712 }
713
714 #[test]
715 fn load_manifest_returns_package_and_dependencies() {
716 let dir = TempDir::new().unwrap();
717 std::fs::write(
718 dir.path().join("mars.toml"),
719 r#"
720[package]
721name = "pkg"
722version = "1.2.3"
723
724[dependencies.base]
725url = "https://github.com/org/base.git"
726version = ">=1.0.0"
727"#,
728 )
729 .unwrap();
730
731 let manifest = load_manifest(dir.path()).unwrap().unwrap();
732 assert_eq!(manifest.package.name, "pkg");
733 assert_eq!(manifest.package.version, "1.2.3");
734 assert!(manifest.dependencies.contains_key("base"));
735 }
736
737 #[test]
738 fn load_local_missing_returns_default() {
739 let dir = TempDir::new().unwrap();
740 let local = load_local(dir.path()).unwrap();
741 assert!(local.overrides.is_empty());
742 }
743
744 #[test]
745 fn load_local_from_disk() {
746 let dir = TempDir::new().unwrap();
747 let toml_str = r#"
748[overrides.base]
749path = "/home/dev/local-base"
750"#;
751 std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
752 let local = load_local(dir.path()).unwrap();
753 assert_eq!(local.overrides.len(), 1);
754 assert_eq!(
755 local.overrides["base"].path,
756 PathBuf::from("/home/dev/local-base")
757 );
758 }
759
760 #[test]
761 fn merge_with_empty_local() {
762 let config = Config {
763 dependencies: {
764 let mut m = IndexMap::new();
765 m.insert(
766 "base".into(),
767 DependencyEntry {
768 url: Some("https://github.com/org/base.git".into()),
769 path: None,
770 version: Some("v1.0".into()),
771 filter: FilterConfig::default(),
772 },
773 );
774 m
775 },
776 settings: Settings::default(),
777 ..Config::default()
778 };
779 let local = LocalConfig::default();
780 let effective = merge(config, local).unwrap();
781 assert_eq!(effective.dependencies.len(), 1);
782 let source = &effective.dependencies["base"];
783 assert!(!source.is_overridden);
784 assert!(source.original_git.is_none());
785 match &source.spec {
786 SourceSpec::Git(git) => {
787 assert_eq!(git.url, "https://github.com/org/base.git");
788 assert_eq!(git.version.as_deref(), Some("v1.0"));
789 }
790 SourceSpec::Path(_) => panic!("expected Git"),
791 }
792 }
793
794 #[test]
795 fn merge_override_replaces_with_path() {
796 let config = Config {
797 dependencies: {
798 let mut m = IndexMap::new();
799 m.insert(
800 "base".into(),
801 DependencyEntry {
802 url: Some("https://github.com/org/base.git".into()),
803 path: None,
804 version: Some("v1.0".into()),
805 filter: FilterConfig::default(),
806 },
807 );
808 m
809 },
810 settings: Settings::default(),
811 ..Config::default()
812 };
813 let local = LocalConfig {
814 overrides: {
815 let mut m = IndexMap::new();
816 m.insert(
817 "base".into(),
818 OverrideEntry {
819 path: PathBuf::from("/home/dev/local-base"),
820 },
821 );
822 m
823 },
824 };
825 let effective = merge(config, local).unwrap();
826 let source = &effective.dependencies["base"];
827 assert!(source.is_overridden);
828
829 match &source.spec {
830 SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
831 SourceSpec::Git(_) => panic!("expected Path override"),
832 }
833
834 let orig = source.original_git.as_ref().unwrap();
835 assert_eq!(orig.url, "https://github.com/org/base.git");
836 assert_eq!(orig.version.as_deref(), Some("v1.0"));
837 }
838
839 #[test]
840 fn merge_all_filter_mode() {
841 let config = Config {
842 dependencies: {
843 let mut m = IndexMap::new();
844 m.insert(
845 "base".into(),
846 DependencyEntry {
847 url: Some("https://github.com/org/base.git".into()),
848 path: None,
849 version: None,
850 filter: FilterConfig::default(),
851 },
852 );
853 m
854 },
855 settings: Settings::default(),
856 ..Config::default()
857 };
858 let effective = merge(config, LocalConfig::default()).unwrap();
859 assert!(matches!(
860 effective.dependencies["base"].filter,
861 FilterMode::All
862 ));
863 }
864
865 #[test]
866 fn save_and_reload() {
867 let dir = TempDir::new().unwrap();
868 let config = Config {
869 dependencies: {
870 let mut m = IndexMap::new();
871 m.insert(
872 "base".into(),
873 DependencyEntry {
874 url: Some("https://github.com/org/base.git".into()),
875 path: None,
876 version: Some("v2.0".into()),
877 filter: FilterConfig::default(),
878 },
879 );
880 m
881 },
882 settings: Settings::default(),
883 ..Config::default()
884 };
885 save(dir.path(), &config).unwrap();
886 let reloaded = load(dir.path()).unwrap();
887 assert_eq!(config, reloaded);
888 }
889
890 #[test]
891 fn rename_map_preserved() {
892 let toml_str = r#"
893[dependencies.base]
894url = "https://github.com/org/base.git"
895
896[dependencies.base.rename]
897old-name = "new-name"
898"#;
899 let config: Config = toml::from_str(toml_str).unwrap();
900 let effective = merge(config, LocalConfig::default()).unwrap();
901 let source = &effective.dependencies["base"];
902 assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
903 }
904
905 #[test]
906 fn self_dependency_name_rejected() {
907 let toml_str = r#"
908[dependencies._self]
909url = "https://github.com/org/base.git"
910"#;
911 let config: Config = toml::from_str(toml_str).unwrap();
912 let local = LocalConfig::default();
913 let result = merge(config, local);
914 assert!(result.is_err());
915 let err = result.unwrap_err().to_string();
916 assert!(
917 err.contains("_self") && err.contains("reserved"),
918 "should reject _self: {err}"
919 );
920 }
921
922 #[test]
923 fn managed_root_setting_roundtrip() {
924 let config = Config {
925 settings: Settings {
926 managed_root: Some(".claude".into()),
927 links: vec![],
928 },
929 ..Config::default()
930 };
931 let serialized = toml::to_string_pretty(&config).unwrap();
932 let deserialized: Config = toml::from_str(&serialized).unwrap();
933 assert_eq!(
934 deserialized.settings.managed_root.as_deref(),
935 Some(".claude")
936 );
937 }
938
939 #[test]
940 fn parse_only_skills_filter() {
941 let toml_str = r#"
942[dependencies.base]
943url = "https://github.com/org/base.git"
944only_skills = true
945"#;
946 let config: Config = toml::from_str(toml_str).unwrap();
947 let local = LocalConfig::default();
948 let effective = merge(config, local).unwrap();
949 let source = &effective.dependencies["base"];
950 assert!(matches!(source.filter, FilterMode::OnlySkills));
951 }
952
953 #[test]
954 fn parse_only_agents_filter() {
955 let toml_str = r#"
956[dependencies.base]
957url = "https://github.com/org/base.git"
958only_agents = true
959"#;
960 let config: Config = toml::from_str(toml_str).unwrap();
961 let local = LocalConfig::default();
962 let effective = merge(config, local).unwrap();
963 let source = &effective.dependencies["base"];
964 assert!(matches!(source.filter, FilterMode::OnlyAgents));
965 }
966
967 #[test]
968 fn error_on_only_skills_and_only_agents() {
969 let toml_str = r#"
970[dependencies.bad]
971url = "https://github.com/org/bad.git"
972only_skills = true
973only_agents = true
974"#;
975 let config: Config = toml::from_str(toml_str).unwrap();
976 let local = LocalConfig::default();
977 let result = merge(config, local);
978 assert!(result.is_err());
979 let err = result.unwrap_err().to_string();
980 assert!(
981 err.contains("mutually exclusive"),
982 "should mention mutually exclusive: {err}"
983 );
984 }
985
986 #[test]
987 fn error_on_only_skills_with_agents_list() {
988 let toml_str = r#"
989[dependencies.bad]
990url = "https://github.com/org/bad.git"
991only_skills = true
992agents = ["coder"]
993"#;
994 let config: Config = toml::from_str(toml_str).unwrap();
995 let local = LocalConfig::default();
996 let result = merge(config, local);
997 assert!(result.is_err());
998 let err = result.unwrap_err().to_string();
999 assert!(
1000 err.contains("cannot combine"),
1001 "should mention cannot combine: {err}"
1002 );
1003 }
1004
1005 #[test]
1006 fn error_on_only_agents_with_skills_list() {
1007 let toml_str = r#"
1008[dependencies.bad]
1009url = "https://github.com/org/bad.git"
1010only_agents = true
1011skills = ["planning"]
1012"#;
1013 let config: Config = toml::from_str(toml_str).unwrap();
1014 let local = LocalConfig::default();
1015 let result = merge(config, local);
1016 assert!(result.is_err());
1017 }
1018
1019 #[test]
1020 fn error_on_only_skills_with_exclude() {
1021 let toml_str = r#"
1022[dependencies.bad]
1023url = "https://github.com/org/bad.git"
1024only_skills = true
1025exclude = ["deprecated"]
1026"#;
1027 let config: Config = toml::from_str(toml_str).unwrap();
1028 let local = LocalConfig::default();
1029 let result = merge(config, local);
1030 assert!(result.is_err());
1031 }
1032
1033 #[test]
1034 fn only_skills_false_not_serialized() {
1035 let config = Config {
1036 dependencies: {
1037 let mut m = IndexMap::new();
1038 m.insert(
1039 "base".into(),
1040 DependencyEntry {
1041 url: Some("https://github.com/org/base.git".into()),
1042 path: None,
1043 version: None,
1044 filter: FilterConfig::default(),
1045 },
1046 );
1047 m
1048 },
1049 settings: Settings::default(),
1050 ..Config::default()
1051 };
1052 let serialized = toml::to_string_pretty(&config).unwrap();
1053 assert!(
1054 !serialized.contains("only_skills"),
1055 "false booleans should not be serialized: {serialized}"
1056 );
1057 assert!(
1058 !serialized.contains("only_agents"),
1059 "false booleans should not be serialized: {serialized}"
1060 );
1061 }
1062
1063 #[test]
1064 fn only_skills_true_roundtrips() {
1065 let toml_str = r#"
1066[dependencies.base]
1067url = "https://github.com/org/base.git"
1068only_skills = true
1069"#;
1070 let config: Config = toml::from_str(toml_str).unwrap();
1071 assert!(config.dependencies["base"].filter.only_skills);
1072 assert!(!config.dependencies["base"].filter.only_agents);
1073
1074 let serialized = toml::to_string_pretty(&config).unwrap();
1075 let reloaded: Config = toml::from_str(&serialized).unwrap();
1076 assert!(reloaded.dependencies["base"].filter.only_skills);
1077 }
1078
1079 #[test]
1080 fn filter_config_has_any_filter() {
1081 assert!(!FilterConfig::default().has_any_filter());
1082 assert!(
1083 FilterConfig {
1084 only_skills: true,
1085 ..FilterConfig::default()
1086 }
1087 .has_any_filter()
1088 );
1089 assert!(
1090 FilterConfig {
1091 agents: Some(vec!["coder".into()]),
1092 ..FilterConfig::default()
1093 }
1094 .has_any_filter()
1095 );
1096 }
1097
1098 #[test]
1099 fn filter_config_to_mode() {
1100 assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
1101 assert!(matches!(
1102 FilterConfig {
1103 only_skills: true,
1104 ..FilterConfig::default()
1105 }
1106 .to_mode(),
1107 FilterMode::OnlySkills
1108 ));
1109 assert!(matches!(
1110 FilterConfig {
1111 only_agents: true,
1112 ..FilterConfig::default()
1113 }
1114 .to_mode(),
1115 FilterMode::OnlyAgents
1116 ));
1117 assert!(matches!(
1118 FilterConfig {
1119 agents: Some(vec!["coder".into()]),
1120 ..FilterConfig::default()
1121 }
1122 .to_mode(),
1123 FilterMode::Include { .. }
1124 ));
1125 assert!(matches!(
1126 FilterConfig {
1127 exclude: Some(vec!["old".into()]),
1128 ..FilterConfig::default()
1129 }
1130 .to_mode(),
1131 FilterMode::Exclude(_)
1132 ));
1133 }
1134}