Skip to main content

mars_agents/config/
mod.rs

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/// Top-level mars.toml configuration.
10#[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/// Package metadata.
21#[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/// Unified dependency specification — replaces both old DepSpec and SourceEntry.
30/// Used in [dependencies] for both "what to install locally" (consumer)
31/// and "what downstream consumers inherit" (package manifest).
32#[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/// Source-manifest view extracted from mars.toml.
45///
46/// In source repositories, `mars.toml` may include `[package]` +
47/// `[dependencies]` only, or coexist with consumer sections.
48#[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/// Shared include/exclude/rename filter configuration for a source.
56#[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/// Dev override config (mars.local.toml).
77///
78/// Gitignored — each developer can work with local checkouts while
79/// production config points at git.
80#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
81pub struct LocalConfig {
82    #[serde(default)]
83    pub overrides: IndexMap<SourceName, OverrideEntry>,
84}
85
86/// Dev override — local path swap for a git source.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct OverrideEntry {
89    pub path: PathBuf,
90}
91
92/// Global settings — extensible via additional fields.
93#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
94pub struct Settings {
95    /// Custom managed output directory (e.g. ".claude"). Default: ".agents".
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub managed_root: Option<String>,
98    /// Directories to symlink agents/ and skills/ into (e.g. [".claude"]).
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    pub links: Vec<String>,
101}
102
103/// Resolved source specification after merging config and overrides.
104#[derive(Debug, Clone)]
105pub enum SourceSpec {
106    Git(GitSpec),
107    Path(PathBuf),
108}
109
110/// Git source specification preserved when overrides are active.
111#[derive(Debug, Clone)]
112pub struct GitSpec {
113    pub url: SourceUrl,
114    pub version: Option<String>,
115}
116
117/// How items are filtered from a source.
118#[derive(Debug, Clone)]
119pub enum FilterMode {
120    /// Install everything from the source.
121    All,
122    /// Only install specific agents and/or skills.
123    Include {
124        agents: Vec<ItemName>,
125        skills: Vec<ItemName>,
126    },
127    /// Install everything except these items.
128    Exclude(Vec<ItemName>),
129    /// Install only skills, no agents.
130    OnlySkills,
131    /// Install only agents plus their transitive skill dependencies.
132    OnlyAgents,
133}
134
135/// Effective configuration after merging mars.toml and mars.local.toml.
136///
137/// This is what the rest of the pipeline operates on.
138#[derive(Debug, Clone)]
139pub struct EffectiveConfig {
140    pub dependencies: IndexMap<SourceName, EffectiveDependency>,
141    pub settings: Settings,
142}
143
144/// A fully-resolved source with override tracking.
145#[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
159/// Load mars.toml from the given root directory.
160pub 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
174/// Load source manifest data from mars.toml in a source tree root.
175///
176/// Returns `None` when mars.toml is absent or when it has no `[package]`
177/// section (consumer config only).
178pub 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            // For manifest purposes, filter to only deps with url+version (package deps)
190            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
205/// Load mars.local.toml (returns Default if absent).
206pub 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
218/// Merge config + local overrides into EffectiveConfig.
219///
220/// Validates:
221/// - Each source has `url` XOR `path` (not both, not neither)
222/// - Each source uses either include filters (`agents`/`skills`) or `exclude`, not both
223/// - Warns (via eprintln) if an override references a source name not in config
224pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
225    merge_with_root(config, local, Path::new("."))
226}
227
228/// Same as `merge`, but uses an explicit root for path-based SourceId canonicalization.
229pub 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        // Reject reserved name
238        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        // Validate url XOR path
246        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 combinations
269        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        // Check if this source has a local override
276        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    // Warn if override references a dependency not in config
302    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
316/// Validate filter configuration for consistency.
317///
318/// Rejects invalid combinations:
319/// - `only_skills` and `only_agents` together
320/// - category-only flags with include lists
321/// - category-only flags with exclude
322/// - include lists with exclude
323pub 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    /// Convert to the resolved FilterMode enum.
363    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    /// Returns true if any filter field is set (not default).
381    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
422/// Write mars.toml atomically.
423pub 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
431/// Write mars.local.toml atomically.
432pub 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}