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)]
11pub struct Config {
12    #[serde(default)]
13    pub sources: IndexMap<SourceName, SourceEntry>,
14    #[serde(default)]
15    pub settings: Settings,
16}
17
18/// User-declared source entry in mars.toml.
19///
20/// Sources are either git URLs (versioned, fetched via source adapters) or local paths
21/// (unversioned, always syncs current state). Uses `url` XOR `path` to
22/// determine type — validation happens in `merge()`.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct SourceEntry {
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub url: Option<SourceUrl>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub path: Option<PathBuf>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub version: Option<String>,
31    #[serde(flatten)]
32    pub filter: FilterConfig,
33}
34
35/// Shared include/exclude/rename filter configuration for a source.
36#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
37pub struct FilterConfig {
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub agents: Option<Vec<ItemName>>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub skills: Option<Vec<ItemName>>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub exclude: Option<Vec<ItemName>>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub rename: Option<RenameMap>,
46}
47
48/// Dev override config (mars.local.toml).
49///
50/// Gitignored — each developer can work with local checkouts while
51/// production config points at git.
52#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
53pub struct LocalConfig {
54    #[serde(default)]
55    pub overrides: IndexMap<SourceName, OverrideEntry>,
56}
57
58/// Dev override — local path swap for a git source.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct OverrideEntry {
61    pub path: PathBuf,
62}
63
64/// Global settings — extensible via additional fields.
65#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
66pub struct Settings {
67    /// Directories to symlink agents/ and skills/ into (e.g. [".claude"]).
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub links: Vec<String>,
70}
71
72/// Resolved source specification after merging config and overrides.
73#[derive(Debug, Clone)]
74pub enum SourceSpec {
75    Git(GitSpec),
76    Path(PathBuf),
77}
78
79/// Git source specification preserved when overrides are active.
80#[derive(Debug, Clone)]
81pub struct GitSpec {
82    pub url: SourceUrl,
83    pub version: Option<String>,
84}
85
86/// How items are filtered from a source.
87#[derive(Debug, Clone)]
88pub enum FilterMode {
89    /// Install everything from the source.
90    All,
91    /// Only install specific agents and/or skills.
92    Include {
93        agents: Vec<ItemName>,
94        skills: Vec<ItemName>,
95    },
96    /// Install everything except these items.
97    Exclude(Vec<ItemName>),
98}
99
100/// Effective configuration after merging mars.toml and mars.local.toml.
101///
102/// This is what the rest of the pipeline operates on.
103#[derive(Debug, Clone)]
104pub struct EffectiveConfig {
105    pub sources: IndexMap<SourceName, EffectiveSource>,
106    pub settings: Settings,
107}
108
109/// A fully-resolved source with override tracking.
110#[derive(Debug, Clone)]
111pub struct EffectiveSource {
112    pub name: SourceName,
113    pub id: SourceId,
114    pub spec: SourceSpec,
115    pub filter: FilterMode,
116    pub rename: RenameMap,
117    pub is_overridden: bool,
118    pub original_git: Option<GitSpec>,
119}
120
121const CONFIG_FILE: &str = "mars.toml";
122const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
123
124/// Load mars.toml from the given root directory.
125pub fn load(root: &Path) -> Result<Config, MarsError> {
126    let path = root.join(CONFIG_FILE);
127    let content = std::fs::read_to_string(&path).map_err(|e| {
128        if e.kind() == std::io::ErrorKind::NotFound {
129            ConfigError::NotFound { path: path.clone() }
130        } else {
131            ConfigError::Io(e)
132        }
133    })?;
134    let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
135    migrate_legacy_source_urls(&mut config);
136    Ok(config)
137}
138
139/// Load mars.local.toml (returns Default if absent).
140pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
141    let path = root.join(LOCAL_CONFIG_FILE);
142    match std::fs::read_to_string(&path) {
143        Ok(content) => {
144            let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
145            Ok(local)
146        }
147        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
148        Err(e) => Err(ConfigError::Io(e).into()),
149    }
150}
151
152/// Merge config + local overrides into EffectiveConfig.
153///
154/// Validates:
155/// - Each source has `url` XOR `path` (not both, not neither)
156/// - Each source uses either include filters (`agents`/`skills`) or `exclude`, not both
157/// - Warns (via eprintln) if an override references a source name not in config
158pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
159    merge_with_root(config, local, Path::new("."))
160}
161
162/// Same as `merge`, but uses an explicit root for path-based SourceId canonicalization.
163pub fn merge_with_root(
164    config: Config,
165    local: LocalConfig,
166    root: &Path,
167) -> Result<EffectiveConfig, MarsError> {
168    let mut sources = IndexMap::new();
169
170    for (name, entry) in &config.sources {
171        // Validate url XOR path
172        let base_spec = match (&entry.url, &entry.path) {
173            (Some(url), None) => SourceSpec::Git(GitSpec {
174                url: url.clone(),
175                version: entry.version.clone(),
176            }),
177            (None, Some(path)) => SourceSpec::Path(path.clone()),
178            (Some(_), Some(_)) => {
179                return Err(ConfigError::Invalid {
180                    message: format!("source `{name}` has both `url` and `path` — pick one"),
181                }
182                .into());
183            }
184            (None, None) => {
185                return Err(ConfigError::Invalid {
186                    message: format!(
187                        "source `{name}` has neither `url` nor `path` — one is required"
188                    ),
189                }
190                .into());
191            }
192        };
193
194        // Validate filter mode: agents/skills XOR exclude
195        let has_include = entry.filter.agents.is_some() || entry.filter.skills.is_some();
196        let has_exclude = entry.filter.exclude.is_some();
197        if has_include && has_exclude {
198            return Err(ConfigError::ConflictingFilters {
199                name: name.to_string(),
200            }
201            .into());
202        }
203
204        let filter = if has_include {
205            FilterMode::Include {
206                agents: entry.filter.agents.clone().unwrap_or_default(),
207                skills: entry.filter.skills.clone().unwrap_or_default(),
208            }
209        } else if has_exclude {
210            FilterMode::Exclude(entry.filter.exclude.clone().unwrap_or_default())
211        } else {
212            FilterMode::All
213        };
214
215        let rename = entry.filter.rename.clone().unwrap_or_default();
216
217        // Check if this source has a local override
218        let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
219            let original = match &base_spec {
220                SourceSpec::Git(git) => Some(git.clone()),
221                SourceSpec::Path(_) => None,
222            };
223            (SourceSpec::Path(ov.path.clone()), true, original)
224        } else {
225            (base_spec, false, None)
226        };
227        let id = source_id_for_spec(root, &spec);
228
229        sources.insert(
230            name.clone(),
231            EffectiveSource {
232                name: name.clone(),
233                id,
234                spec,
235                filter,
236                rename,
237                is_overridden,
238                original_git,
239            },
240        );
241    }
242
243    // Warn if override references a source not in config
244    for override_name in local.overrides.keys() {
245        if !config.sources.contains_key(override_name) {
246            eprintln!("warning: override `{override_name}` references a source not in mars.toml");
247        }
248    }
249
250    Ok(EffectiveConfig {
251        sources,
252        settings: config.settings,
253    })
254}
255
256fn source_id_for_spec(root: &Path, spec: &SourceSpec) -> SourceId {
257    match spec {
258        SourceSpec::Git(git) => SourceId::git(git.url.clone()),
259        SourceSpec::Path(path) => match SourceId::path(root, path) {
260            Ok(id) => id,
261            Err(_) => {
262                let canonical = if path.is_absolute() {
263                    path.clone()
264                } else {
265                    root.join(path)
266                };
267                SourceId::Path { canonical }
268            }
269        },
270    }
271}
272
273fn migrate_legacy_source_urls(config: &mut Config) {
274    for source in config.sources.values_mut() {
275        if let Some(url) = source.url.as_mut() {
276            let raw = url.as_str();
277            if should_upgrade_legacy_git_url(raw) {
278                *url = SourceUrl::from(format!("https://{raw}"));
279            }
280        }
281    }
282}
283
284fn should_upgrade_legacy_git_url(url: &str) -> bool {
285    !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
286}
287
288/// Write mars.toml atomically.
289pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
290    let path = root.join(CONFIG_FILE);
291    let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
292        message: format!("failed to serialize config: {e}"),
293    })?;
294    crate::fs::atomic_write(&path, content.as_bytes())
295}
296
297/// Write mars.local.toml atomically.
298pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
299    let path = root.join(LOCAL_CONFIG_FILE);
300    let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
301        message: format!("failed to serialize local config: {e}"),
302    })?;
303    crate::fs::atomic_write(&path, content.as_bytes())
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use tempfile::TempDir;
310
311    #[test]
312    fn parse_git_source() {
313        let toml_str = r#"
314[sources.base]
315url = "https://github.com/org/base.git"
316version = "v1.0"
317"#;
318        let config: Config = toml::from_str(toml_str).unwrap();
319        assert_eq!(config.sources.len(), 1);
320        let entry = &config.sources["base"];
321        assert_eq!(
322            entry.url.as_deref(),
323            Some("https://github.com/org/base.git")
324        );
325        assert!(entry.path.is_none());
326        assert_eq!(entry.version.as_deref(), Some("v1.0"));
327    }
328
329    #[test]
330    fn parse_path_source() {
331        let toml_str = r#"
332[sources.local]
333path = "../my-agents"
334"#;
335        let config: Config = toml::from_str(toml_str).unwrap();
336        let entry = &config.sources["local"];
337        assert!(entry.url.is_none());
338        assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
339    }
340
341    #[test]
342    fn parse_mixed_sources() {
343        let toml_str = r#"
344[sources.remote]
345url = "https://github.com/org/remote.git"
346version = "v2.0"
347agents = ["coder", "reviewer"]
348
349[sources.local]
350path = "/home/dev/agents"
351exclude = ["experimental"]
352"#;
353        let config: Config = toml::from_str(toml_str).unwrap();
354        assert_eq!(config.sources.len(), 2);
355        assert!(config.sources.contains_key("remote"));
356        assert!(config.sources.contains_key("local"));
357    }
358
359    #[test]
360    fn parse_include_filter() {
361        let toml_str = r#"
362[sources.base]
363url = "https://github.com/org/base.git"
364agents = ["coder"]
365skills = ["review"]
366"#;
367        let config: Config = toml::from_str(toml_str).unwrap();
368        let local = LocalConfig::default();
369        let effective = merge(config, local).unwrap();
370        let source = &effective.sources["base"];
371        match &source.filter {
372            FilterMode::Include { agents, skills } => {
373                assert_eq!(agents, &["coder"]);
374                assert_eq!(skills, &["review"]);
375            }
376            other => panic!("expected Include, got {other:?}"),
377        }
378    }
379
380    #[test]
381    fn parse_exclude_filter() {
382        let toml_str = r#"
383[sources.base]
384url = "https://github.com/org/base.git"
385exclude = ["experimental", "deprecated"]
386"#;
387        let config: Config = toml::from_str(toml_str).unwrap();
388        let local = LocalConfig::default();
389        let effective = merge(config, local).unwrap();
390        let source = &effective.sources["base"];
391        match &source.filter {
392            FilterMode::Exclude(items) => {
393                assert_eq!(items, &["experimental", "deprecated"]);
394            }
395            other => panic!("expected Exclude, got {other:?}"),
396        }
397    }
398
399    #[test]
400    fn error_on_both_include_and_exclude() {
401        let toml_str = r#"
402[sources.bad]
403url = "https://github.com/org/bad.git"
404agents = ["coder"]
405exclude = ["reviewer"]
406"#;
407        let config: Config = toml::from_str(toml_str).unwrap();
408        let local = LocalConfig::default();
409        let result = merge(config, local);
410        assert!(result.is_err());
411        let err = result.unwrap_err().to_string();
412        assert!(
413            err.contains("bad"),
414            "error should mention source name: {err}"
415        );
416    }
417
418    #[test]
419    fn error_on_neither_url_nor_path() {
420        let toml_str = r#"
421[sources.empty]
422version = "v1.0"
423"#;
424        let config: Config = toml::from_str(toml_str).unwrap();
425        let local = LocalConfig::default();
426        let result = merge(config, local);
427        assert!(result.is_err());
428        let err = result.unwrap_err().to_string();
429        assert!(
430            err.contains("neither"),
431            "error should mention 'neither': {err}"
432        );
433    }
434
435    #[test]
436    fn error_on_both_url_and_path() {
437        let toml_str = r#"
438[sources.both]
439url = "https://github.com/org/repo.git"
440path = "/local/path"
441"#;
442        let config: Config = toml::from_str(toml_str).unwrap();
443        let local = LocalConfig::default();
444        let result = merge(config, local);
445        assert!(result.is_err());
446        let err = result.unwrap_err().to_string();
447        assert!(err.contains("both"), "error should mention 'both': {err}");
448    }
449
450    #[test]
451    fn roundtrip_config() {
452        let config = Config {
453            sources: {
454                let mut m = IndexMap::new();
455                m.insert(
456                    "base".into(),
457                    SourceEntry {
458                        url: Some("https://github.com/org/base.git".into()),
459                        path: None,
460                        version: Some("v1.0".into()),
461                        filter: FilterConfig {
462                            agents: Some(vec!["coder".into()]),
463                            skills: None,
464                            exclude: None,
465                            rename: None,
466                        },
467                    },
468                );
469                m.insert(
470                    "local".into(),
471                    SourceEntry {
472                        url: None,
473                        path: Some(PathBuf::from("../my-agents")),
474                        version: None,
475                        filter: FilterConfig::default(),
476                    },
477                );
478                m
479            },
480            settings: Settings::default(),
481        };
482        let serialized = toml::to_string_pretty(&config).unwrap();
483        let deserialized: Config = toml::from_str(&serialized).unwrap();
484        assert_eq!(config, deserialized);
485    }
486
487    #[test]
488    fn load_from_disk() {
489        let dir = TempDir::new().unwrap();
490        let toml_str = r#"
491[sources.base]
492url = "https://github.com/org/base.git"
493version = "v1.0"
494"#;
495        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
496        let config = load(dir.path()).unwrap();
497        assert_eq!(config.sources.len(), 1);
498    }
499
500    #[test]
501    fn load_migrates_legacy_bare_domain_url() {
502        let dir = TempDir::new().unwrap();
503        let toml_str = r#"
504[sources.base]
505url = "github.com/org/base"
506"#;
507        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
508
509        let config = load(dir.path()).unwrap();
510        assert_eq!(
511            config.sources["base"].url.as_deref(),
512            Some("https://github.com/org/base")
513        );
514    }
515
516    #[test]
517    fn load_does_not_migrate_ssh_url() {
518        let dir = TempDir::new().unwrap();
519        let toml_str = r#"
520[sources.base]
521url = "git@github.com:org/base.git"
522"#;
523        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
524
525        let config = load(dir.path()).unwrap();
526        assert_eq!(
527            config.sources["base"].url.as_deref(),
528            Some("git@github.com:org/base.git")
529        );
530    }
531
532    #[test]
533    fn load_missing_file_returns_not_found() {
534        let dir = TempDir::new().unwrap();
535        let result = load(dir.path());
536        assert!(result.is_err());
537        let err = result.unwrap_err().to_string();
538        assert!(err.contains("not found"), "should be NotFound: {err}");
539    }
540
541    #[test]
542    fn load_local_missing_returns_default() {
543        let dir = TempDir::new().unwrap();
544        let local = load_local(dir.path()).unwrap();
545        assert!(local.overrides.is_empty());
546    }
547
548    #[test]
549    fn load_local_from_disk() {
550        let dir = TempDir::new().unwrap();
551        let toml_str = r#"
552[overrides.base]
553path = "/home/dev/local-base"
554"#;
555        std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
556        let local = load_local(dir.path()).unwrap();
557        assert_eq!(local.overrides.len(), 1);
558        assert_eq!(
559            local.overrides["base"].path,
560            PathBuf::from("/home/dev/local-base")
561        );
562    }
563
564    #[test]
565    fn merge_with_empty_local() {
566        let config = Config {
567            sources: {
568                let mut m = IndexMap::new();
569                m.insert(
570                    "base".into(),
571                    SourceEntry {
572                        url: Some("https://github.com/org/base.git".into()),
573                        path: None,
574                        version: Some("v1.0".into()),
575                        filter: FilterConfig::default(),
576                    },
577                );
578                m
579            },
580            settings: Settings::default(),
581        };
582        let local = LocalConfig::default();
583        let effective = merge(config, local).unwrap();
584        assert_eq!(effective.sources.len(), 1);
585        let source = &effective.sources["base"];
586        assert!(!source.is_overridden);
587        assert!(source.original_git.is_none());
588        match &source.spec {
589            SourceSpec::Git(git) => {
590                assert_eq!(git.url, "https://github.com/org/base.git");
591                assert_eq!(git.version.as_deref(), Some("v1.0"));
592            }
593            SourceSpec::Path(_) => panic!("expected Git"),
594        }
595    }
596
597    #[test]
598    fn merge_override_replaces_with_path() {
599        let config = Config {
600            sources: {
601                let mut m = IndexMap::new();
602                m.insert(
603                    "base".into(),
604                    SourceEntry {
605                        url: Some("https://github.com/org/base.git".into()),
606                        path: None,
607                        version: Some("v1.0".into()),
608                        filter: FilterConfig::default(),
609                    },
610                );
611                m
612            },
613            settings: Settings::default(),
614        };
615        let local = LocalConfig {
616            overrides: {
617                let mut m = IndexMap::new();
618                m.insert(
619                    "base".into(),
620                    OverrideEntry {
621                        path: PathBuf::from("/home/dev/local-base"),
622                    },
623                );
624                m
625            },
626        };
627        let effective = merge(config, local).unwrap();
628        let source = &effective.sources["base"];
629        assert!(source.is_overridden);
630
631        // Spec should be the override path
632        match &source.spec {
633            SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
634            SourceSpec::Git(_) => panic!("expected Path override"),
635        }
636
637        // Original git should be preserved
638        let orig = source.original_git.as_ref().unwrap();
639        assert_eq!(orig.url, "https://github.com/org/base.git");
640        assert_eq!(orig.version.as_deref(), Some("v1.0"));
641    }
642
643    #[test]
644    fn merge_all_filter_mode() {
645        let config = Config {
646            sources: {
647                let mut m = IndexMap::new();
648                m.insert(
649                    "base".into(),
650                    SourceEntry {
651                        url: Some("https://github.com/org/base.git".into()),
652                        path: None,
653                        version: None,
654                        filter: FilterConfig::default(),
655                    },
656                );
657                m
658            },
659            settings: Settings::default(),
660        };
661        let effective = merge(config, LocalConfig::default()).unwrap();
662        assert!(matches!(effective.sources["base"].filter, FilterMode::All));
663    }
664
665    #[test]
666    fn save_and_reload() {
667        let dir = TempDir::new().unwrap();
668        let config = Config {
669            sources: {
670                let mut m = IndexMap::new();
671                m.insert(
672                    "base".into(),
673                    SourceEntry {
674                        url: Some("https://github.com/org/base.git".into()),
675                        path: None,
676                        version: Some("v2.0".into()),
677                        filter: FilterConfig::default(),
678                    },
679                );
680                m
681            },
682            settings: Settings::default(),
683        };
684        save(dir.path(), &config).unwrap();
685        let reloaded = load(dir.path()).unwrap();
686        assert_eq!(config, reloaded);
687    }
688
689    #[test]
690    fn rename_map_preserved() {
691        let toml_str = r#"
692[sources.base]
693url = "https://github.com/org/base.git"
694
695[sources.base.rename]
696old-name = "new-name"
697"#;
698        let config: Config = toml::from_str(toml_str).unwrap();
699        let effective = merge(config, LocalConfig::default()).unwrap();
700        let source = &effective.sources["base"];
701        assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
702    }
703}