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::diagnostic::{Diagnostic, DiagnosticLevel};
7use crate::error::{ConfigError, MarsError};
8use crate::types::{ItemName, RenameMap, SourceId, SourceName, SourceOrigin, SourceUrl};
9
10/// Top-level mars.toml configuration.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
12pub struct Config {
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub package: Option<PackageInfo>,
15    #[serde(default)]
16    pub dependencies: IndexMap<SourceName, InstallDep>,
17    #[serde(default)]
18    pub settings: Settings,
19    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
20    pub models: IndexMap<String, crate::models::ModelAlias>,
21}
22
23/// Package metadata.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct PackageInfo {
26    pub name: String,
27    pub version: String,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub description: Option<String>,
30}
31
32/// Consumer install intent — what goes in [dependencies] of a consumer mars.toml.
33/// Has optional URL or path source plus filters for selecting items.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct InstallDep {
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub url: Option<SourceUrl>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub path: Option<PathBuf>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub version: Option<String>,
42    #[serde(flatten)]
43    pub filter: FilterConfig,
44}
45
46/// Backwards-compatible alias during migration.
47pub type DependencyEntry = InstallDep;
48
49/// Package manifest dependency — what a package declares its consumers need.
50/// Always has a URL (packages can't declare path deps for consumers).
51#[derive(Debug, Clone, PartialEq)]
52pub struct ManifestDep {
53    pub url: SourceUrl,
54    pub version: Option<String>,
55}
56
57/// Source-manifest view extracted from mars.toml.
58///
59/// In source repositories, `mars.toml` may include `[package]` +
60/// `[dependencies]` only, or coexist with consumer sections.
61/// Dependencies are ManifestDep (URL required, path-only deps filtered out).
62#[derive(Debug, Clone, PartialEq)]
63pub struct Manifest {
64    pub package: PackageInfo,
65    pub dependencies: IndexMap<String, ManifestDep>,
66    pub models: IndexMap<String, crate::models::ModelAlias>,
67}
68
69/// Shared include/exclude/rename filter configuration for a source.
70#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
71pub struct FilterConfig {
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub agents: Option<Vec<ItemName>>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub skills: Option<Vec<ItemName>>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub exclude: Option<Vec<ItemName>>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub rename: Option<RenameMap>,
80    #[serde(default, skip_serializing_if = "is_false")]
81    pub only_skills: bool,
82    #[serde(default, skip_serializing_if = "is_false")]
83    pub only_agents: bool,
84}
85
86fn is_false(v: &bool) -> bool {
87    !v
88}
89
90/// Dev override config (mars.local.toml).
91///
92/// Gitignored — each developer can work with local checkouts while
93/// production config points at git.
94#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
95pub struct LocalConfig {
96    #[serde(default)]
97    pub overrides: IndexMap<SourceName, OverrideEntry>,
98}
99
100/// Dev override — local path swap for a git source.
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct OverrideEntry {
103    pub path: PathBuf,
104}
105
106/// Global settings — extensible via additional fields.
107#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
108pub struct Settings {
109    /// Custom managed output directory (e.g. ".claude"). Default: ".agents".
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub managed_root: Option<String>,
112    /// Managed target directories materialized from .mars/ canonical store.
113    /// When set, only listed targets are populated. When unset, defaults to [".agents"].
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub targets: Option<Vec<String>>,
116}
117
118impl Settings {
119    /// Returns the effective list of managed target directories.
120    ///
121    /// - If `targets` is explicitly set, returns exactly those targets.
122    /// - If `targets` is unset, uses `managed_root` (or ".agents" default).
123    pub fn managed_targets(&self) -> Vec<String> {
124        if let Some(targets) = &self.targets {
125            return targets.clone();
126        }
127        vec![
128            self.managed_root
129                .clone()
130                .unwrap_or_else(|| ".agents".to_string()),
131        ]
132    }
133}
134
135/// Resolved source specification after merging config and overrides.
136#[derive(Debug, Clone)]
137pub enum SourceSpec {
138    Git(GitSpec),
139    Path(PathBuf),
140}
141
142/// Git source specification preserved when overrides are active.
143#[derive(Debug, Clone)]
144pub struct GitSpec {
145    pub url: SourceUrl,
146    pub version: Option<String>,
147}
148
149/// How items are filtered from a source.
150#[derive(Debug, Clone)]
151pub enum FilterMode {
152    /// Install everything from the source.
153    All,
154    /// Only install specific agents and/or skills.
155    Include {
156        agents: Vec<ItemName>,
157        skills: Vec<ItemName>,
158    },
159    /// Install everything except these items.
160    Exclude(Vec<ItemName>),
161    /// Install only skills, no agents.
162    OnlySkills,
163    /// Install only agents plus their transitive skill dependencies.
164    OnlyAgents,
165}
166
167/// Effective configuration after merging mars.toml and mars.local.toml.
168///
169/// This is what the rest of the pipeline operates on.
170#[derive(Debug, Clone)]
171pub struct EffectiveConfig {
172    pub dependencies: IndexMap<SourceName, EffectiveDependency>,
173    pub settings: Settings,
174}
175
176/// A fully-resolved source with override tracking.
177#[derive(Debug, Clone)]
178pub struct EffectiveDependency {
179    pub name: SourceName,
180    pub id: SourceId,
181    pub spec: SourceSpec,
182    pub filter: FilterMode,
183    pub rename: RenameMap,
184    pub is_overridden: bool,
185    pub original_git: Option<GitSpec>,
186}
187
188const CONFIG_FILE: &str = "mars.toml";
189const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
190
191/// Load mars.toml from the given root directory.
192pub fn load(root: &Path) -> Result<Config, MarsError> {
193    let path = root.join(CONFIG_FILE);
194    let content = std::fs::read_to_string(&path).map_err(|e| {
195        if e.kind() == std::io::ErrorKind::NotFound {
196            ConfigError::NotFound { path: path.clone() }
197        } else {
198            ConfigError::Io(e)
199        }
200    })?;
201    let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
202    migrate_legacy_source_urls(&mut config);
203    Ok(config)
204}
205
206/// Load source manifest data from mars.toml in a source tree root.
207///
208/// Returns `None` when mars.toml is absent or when it has no `[package]`
209/// section (consumer config only).
210///
211/// Converts `InstallDep` entries to `ManifestDep` by filtering out path-only
212/// deps (which can't propagate to consumers) and requiring a URL.
213pub fn load_manifest(source_root: &Path) -> Result<(Option<Manifest>, Vec<Diagnostic>), MarsError> {
214    let path = source_root.join(CONFIG_FILE);
215    let mut diagnostics = Vec::new();
216    match std::fs::read_to_string(&path) {
217        Ok(content) => {
218            let parsed: Config =
219                toml::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
220                    message: format!("failed to parse {}: {e}", path.display()),
221                })?;
222            let Some(package) = parsed.package else {
223                return Ok((None, diagnostics));
224            };
225            // Convert InstallDep → ManifestDep, filtering out path-only deps
226            let deps: IndexMap<String, ManifestDep> = parsed
227                .dependencies
228                .into_iter()
229                .filter_map(|(name, entry)| match entry.url {
230                    Some(url) => Some((
231                        name.to_string(),
232                        ManifestDep {
233                            url,
234                            version: entry.version,
235                        },
236                    )),
237                    None => {
238                        // Path-only manifest deps can't propagate to consumers
239                        diagnostics.push(Diagnostic {
240                            level: DiagnosticLevel::Warning,
241                            code: "manifest-path-dep",
242                            message: format!(
243                                "manifest dependency `{name}` has no URL and will not propagate to consumers"
244                            ),
245                            context: None,
246                        });
247                        None
248                    }
249                })
250                .collect();
251            Ok((
252                Some(Manifest {
253                    package,
254                    dependencies: deps,
255                    models: parsed.models,
256                }),
257                diagnostics,
258            ))
259        }
260        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((None, diagnostics)),
261        Err(e) => Err(MarsError::Io(e)),
262    }
263}
264
265/// Load mars.local.toml (returns Default if absent).
266pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
267    let path = root.join(LOCAL_CONFIG_FILE);
268    match std::fs::read_to_string(&path) {
269        Ok(content) => {
270            let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
271            Ok(local)
272        }
273        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
274        Err(e) => Err(ConfigError::Io(e).into()),
275    }
276}
277
278/// Merge config + local overrides into EffectiveConfig.
279///
280/// Validates:
281/// - Each source has `url` XOR `path` (not both, not neither)
282/// - Each source uses either include filters (`agents`/`skills`) or `exclude`, not both
283/// - Collects diagnostics if an override references a source name not in config
284pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
285    let (effective, _diagnostics) = merge_with_root(config, local, Path::new("."))?;
286    Ok(effective)
287}
288
289/// Same as `merge`, but uses an explicit root for path-based SourceId canonicalization.
290pub fn merge_with_root(
291    config: Config,
292    local: LocalConfig,
293    root: &Path,
294) -> Result<(EffectiveConfig, Vec<Diagnostic>), MarsError> {
295    let mut dependencies = IndexMap::new();
296    let mut diagnostics = Vec::new();
297    let local_source_name = SourceOrigin::LocalPackage.to_string();
298
299    for (name, entry) in &config.dependencies {
300        // Reject reserved name
301        if name.as_ref() == local_source_name.as_str() {
302            return Err(ConfigError::Invalid {
303                message: "dependency name `_self` is reserved for local package items".into(),
304            }
305            .into());
306        }
307
308        // Validate url XOR path
309        let base_spec = match (&entry.url, &entry.path) {
310            (Some(url), None) => SourceSpec::Git(GitSpec {
311                url: url.clone(),
312                version: entry.version.clone(),
313            }),
314            (None, Some(path)) => SourceSpec::Path(path.clone()),
315            (Some(_), Some(_)) => {
316                return Err(ConfigError::Invalid {
317                    message: format!("source `{name}` has both `url` and `path` — pick one"),
318                }
319                .into());
320            }
321            (None, None) => {
322                return Err(ConfigError::Invalid {
323                    message: format!(
324                        "source `{name}` has neither `url` nor `path` — one is required"
325                    ),
326                }
327                .into());
328            }
329        };
330
331        // Validate filter combinations
332        validate_filter(&entry.filter, name.as_ref())?;
333
334        let filter = entry.filter.to_mode();
335
336        let rename = entry.filter.rename.clone().unwrap_or_default();
337
338        // Check if this source has a local override
339        let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
340            let original = match &base_spec {
341                SourceSpec::Git(git) => Some(git.clone()),
342                SourceSpec::Path(_) => None,
343            };
344            (SourceSpec::Path(ov.path.clone()), true, original)
345        } else {
346            (base_spec, false, None)
347        };
348        let id = source_id_for_spec(root, &spec);
349
350        dependencies.insert(
351            name.clone(),
352            EffectiveDependency {
353                name: name.clone(),
354                id,
355                spec,
356                filter,
357                rename,
358                is_overridden,
359                original_git,
360            },
361        );
362    }
363
364    // Warn if override references a dependency not in config
365    for override_name in local.overrides.keys() {
366        if !config.dependencies.contains_key(override_name) {
367            diagnostics.push(Diagnostic {
368                level: DiagnosticLevel::Warning,
369                code: "override-missing-dep",
370                message: format!(
371                    "override `{override_name}` references a dependency not in mars.toml"
372                ),
373                context: None,
374            });
375        }
376    }
377
378    Ok((
379        EffectiveConfig {
380            dependencies,
381            settings: config.settings,
382        },
383        diagnostics,
384    ))
385}
386
387/// Validate filter configuration for consistency.
388///
389/// Rejects invalid combinations:
390/// - `only_skills` and `only_agents` together
391/// - category-only flags with include lists
392/// - category-only flags with exclude
393/// - include lists with exclude
394pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
395    let has_include = filter.agents.is_some() || filter.skills.is_some();
396    let has_exclude = filter.exclude.is_some();
397    let has_category = filter.only_skills || filter.only_agents;
398
399    if filter.only_skills && filter.only_agents {
400        return Err(ConfigError::Invalid {
401            message: format!(
402                "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
403            ),
404        }
405        .into());
406    }
407    if has_category && has_include {
408        return Err(ConfigError::Invalid {
409            message: format!(
410                "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
411            ),
412        }
413        .into());
414    }
415    if has_category && has_exclude {
416        return Err(ConfigError::Invalid {
417            message: format!(
418                "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
419            ),
420        }
421        .into());
422    }
423    if has_include && has_exclude {
424        return Err(ConfigError::ConflictingFilters {
425            name: dep_name.to_string(),
426        }
427        .into());
428    }
429    Ok(())
430}
431
432impl FilterConfig {
433    /// Convert to the resolved FilterMode enum.
434    pub fn to_mode(&self) -> FilterMode {
435        if self.only_skills {
436            FilterMode::OnlySkills
437        } else if self.only_agents {
438            FilterMode::OnlyAgents
439        } else if self.agents.is_some() || self.skills.is_some() {
440            FilterMode::Include {
441                agents: self.agents.clone().unwrap_or_default(),
442                skills: self.skills.clone().unwrap_or_default(),
443            }
444        } else if self.exclude.is_some() {
445            FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
446        } else {
447            FilterMode::All
448        }
449    }
450
451    /// Returns true if any filter field is set (not default).
452    pub fn has_any_filter(&self) -> bool {
453        self.agents.is_some()
454            || self.skills.is_some()
455            || self.exclude.is_some()
456            || self.only_skills
457            || self.only_agents
458    }
459}
460
461fn source_id_for_spec(root: &Path, spec: &SourceSpec) -> SourceId {
462    match spec {
463        SourceSpec::Git(git) => SourceId::git(git.url.clone()),
464        SourceSpec::Path(path) => match SourceId::path(root, path) {
465            Ok(id) => id,
466            Err(_) => {
467                let canonical = if path.is_absolute() {
468                    path.clone()
469                } else {
470                    root.join(path)
471                };
472                SourceId::Path { canonical }
473            }
474        },
475    }
476}
477
478fn migrate_legacy_source_urls(config: &mut Config) {
479    for dep in config.dependencies.values_mut() {
480        if let Some(url) = dep.url.as_mut() {
481            let raw = url.as_str();
482            if should_upgrade_legacy_git_url(raw) {
483                *url = SourceUrl::from(format!("https://{raw}"));
484            }
485        }
486    }
487}
488
489fn should_upgrade_legacy_git_url(url: &str) -> bool {
490    !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
491}
492
493/// Write mars.toml atomically.
494pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
495    let path = root.join(CONFIG_FILE);
496    let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
497        message: format!("failed to serialize config: {e}"),
498    })?;
499    let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
500        message: format!("refusing to save config: serialized output failed to parse: {e}"),
501    })?;
502    validate_save_roundtrip(config, &reparsed)?;
503    crate::fs::atomic_write(&path, content.as_bytes())
504}
505
506fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
507    if reparsed.dependencies.len() != original.dependencies.len() {
508        return Err(ConfigError::Invalid {
509            message: format!(
510                "refusing to save config: dependency count changed during roundtrip ({} -> {})",
511                original.dependencies.len(),
512                reparsed.dependencies.len()
513            ),
514        }
515        .into());
516    }
517
518    if reparsed.settings.managed_root != original.settings.managed_root {
519        return Err(ConfigError::Invalid {
520            message: format!(
521                "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
522                original.settings.managed_root, reparsed.settings.managed_root
523            ),
524        }
525        .into());
526    }
527
528    for (name, dep) in &original.dependencies {
529        let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
530            return Err(ConfigError::Invalid {
531                message: format!(
532                    "refusing to save config: dependency `{name}` missing after roundtrip"
533                ),
534            }
535            .into());
536        };
537
538        if reparsed_dep != dep {
539            return Err(ConfigError::Invalid {
540                message: format!(
541                    "refusing to save config: dependency `{name}` changed during roundtrip"
542                ),
543            }
544            .into());
545        }
546    }
547
548    Ok(())
549}
550
551/// Write mars.local.toml atomically.
552pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
553    let path = root.join(LOCAL_CONFIG_FILE);
554    let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
555        message: format!("failed to serialize local config: {e}"),
556    })?;
557    crate::fs::atomic_write(&path, content.as_bytes())
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use tempfile::TempDir;
564
565    #[test]
566    fn parse_git_dependency() {
567        let toml_str = r#"
568[dependencies.base]
569url = "https://github.com/org/base.git"
570version = "v1.0"
571"#;
572        let config: Config = toml::from_str(toml_str).unwrap();
573        assert_eq!(config.dependencies.len(), 1);
574        let entry = &config.dependencies["base"];
575        assert_eq!(
576            entry.url.as_deref(),
577            Some("https://github.com/org/base.git")
578        );
579        assert!(entry.path.is_none());
580        assert_eq!(entry.version.as_deref(), Some("v1.0"));
581    }
582
583    #[test]
584    fn parse_path_dependency() {
585        let toml_str = r#"
586[dependencies.local]
587path = "../my-agents"
588"#;
589        let config: Config = toml::from_str(toml_str).unwrap();
590        let entry = &config.dependencies["local"];
591        assert!(entry.url.is_none());
592        assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
593    }
594
595    #[test]
596    fn parse_mixed_dependencies() {
597        let toml_str = r#"
598[dependencies.remote]
599url = "https://github.com/org/remote.git"
600version = "v2.0"
601agents = ["coder", "reviewer"]
602
603[dependencies.local]
604path = "/home/dev/agents"
605exclude = ["experimental"]
606"#;
607        let config: Config = toml::from_str(toml_str).unwrap();
608        assert_eq!(config.dependencies.len(), 2);
609        assert!(config.dependencies.contains_key("remote"));
610        assert!(config.dependencies.contains_key("local"));
611    }
612
613    #[test]
614    fn parse_package_and_dependencies_coexist() {
615        let toml_str = r#"
616[package]
617name = "my-agents"
618version = "0.1.0"
619
620[dependencies.base]
621url = "https://github.com/org/base.git"
622version = ">=1.0.0"
623
624[dependencies.local]
625path = "../local-agents"
626"#;
627        let config: Config = toml::from_str(toml_str).unwrap();
628        assert!(config.package.is_some());
629        assert!(config.dependencies.contains_key("base"));
630        assert!(config.dependencies.contains_key("local"));
631    }
632
633    #[test]
634    fn parse_include_filter() {
635        let toml_str = r#"
636[dependencies.base]
637url = "https://github.com/org/base.git"
638agents = ["coder"]
639skills = ["review"]
640"#;
641        let config: Config = toml::from_str(toml_str).unwrap();
642        let local = LocalConfig::default();
643        let effective = merge(config, local).unwrap();
644        let source = &effective.dependencies["base"];
645        match &source.filter {
646            FilterMode::Include { agents, skills } => {
647                assert_eq!(agents, &["coder"]);
648                assert_eq!(skills, &["review"]);
649            }
650            other => panic!("expected Include, got {other:?}"),
651        }
652    }
653
654    #[test]
655    fn parse_exclude_filter() {
656        let toml_str = r#"
657[dependencies.base]
658url = "https://github.com/org/base.git"
659exclude = ["experimental", "deprecated"]
660"#;
661        let config: Config = toml::from_str(toml_str).unwrap();
662        let local = LocalConfig::default();
663        let effective = merge(config, local).unwrap();
664        let source = &effective.dependencies["base"];
665        match &source.filter {
666            FilterMode::Exclude(items) => {
667                assert_eq!(items, &["experimental", "deprecated"]);
668            }
669            other => panic!("expected Exclude, got {other:?}"),
670        }
671    }
672
673    #[test]
674    fn error_on_both_include_and_exclude() {
675        let toml_str = r#"
676[dependencies.bad]
677url = "https://github.com/org/bad.git"
678agents = ["coder"]
679exclude = ["reviewer"]
680"#;
681        let config: Config = toml::from_str(toml_str).unwrap();
682        let local = LocalConfig::default();
683        let result = merge(config, local);
684        assert!(result.is_err());
685        let err = result.unwrap_err().to_string();
686        assert!(
687            err.contains("bad"),
688            "error should mention dependency name: {err}"
689        );
690    }
691
692    #[test]
693    fn error_on_neither_url_nor_path() {
694        let toml_str = r#"
695[dependencies.empty]
696version = "v1.0"
697"#;
698        let config: Config = toml::from_str(toml_str).unwrap();
699        let local = LocalConfig::default();
700        let result = merge(config, local);
701        assert!(result.is_err());
702        let err = result.unwrap_err().to_string();
703        assert!(
704            err.contains("neither"),
705            "error should mention 'neither': {err}"
706        );
707    }
708
709    #[test]
710    fn error_on_both_url_and_path() {
711        let toml_str = r#"
712[dependencies.both]
713url = "https://github.com/org/repo.git"
714path = "/local/path"
715"#;
716        let config: Config = toml::from_str(toml_str).unwrap();
717        let local = LocalConfig::default();
718        let result = merge(config, local);
719        assert!(result.is_err());
720        let err = result.unwrap_err().to_string();
721        assert!(err.contains("both"), "error should mention 'both': {err}");
722    }
723
724    #[test]
725    fn roundtrip_full_config_shape_survives_save() {
726        let dir = TempDir::new().unwrap();
727        let original = r#"
728[package]
729name = "sample"
730version = "0.1.0"
731description = "sample package"
732
733[dependencies.base]
734url = "https://github.com/org/base.git"
735version = "v1.0"
736agents = ["coder", "reviewer"]
737
738[dependencies.local]
739path = "../local-agents"
740exclude = ["experimental"]
741
742[settings]
743managed_root = ".custom-agents"
744targets = [".claude", ".cursor"]
745"#;
746        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
747
748        let config = load(dir.path()).unwrap();
749        save(dir.path(), &config).unwrap();
750        let reloaded = load(dir.path()).unwrap();
751
752        assert_eq!(
753            reloaded.package.as_ref().map(|p| p.name.as_str()),
754            Some("sample")
755        );
756        assert_eq!(reloaded.dependencies.len(), 2);
757        assert_eq!(
758            reloaded.dependencies["base"].url.as_deref(),
759            Some("https://github.com/org/base.git")
760        );
761        assert_eq!(
762            reloaded.dependencies["local"].path.as_deref(),
763            Some(Path::new("../local-agents"))
764        );
765        assert_eq!(
766            reloaded.settings.managed_root.as_deref(),
767            Some(".custom-agents")
768        );
769        assert_eq!(
770            reloaded.settings.targets,
771            Some(vec![".claude".to_string(), ".cursor".to_string()])
772        );
773    }
774
775    #[test]
776    fn load_from_disk() {
777        let dir = TempDir::new().unwrap();
778        let toml_str = r#"
779[dependencies.base]
780url = "https://github.com/org/base.git"
781version = "v1.0"
782"#;
783        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
784        let config = load(dir.path()).unwrap();
785        assert_eq!(config.dependencies.len(), 1);
786    }
787
788    #[test]
789    fn load_migrates_legacy_bare_domain_url() {
790        let dir = TempDir::new().unwrap();
791        let toml_str = r#"
792[dependencies.base]
793url = "github.com/org/base"
794"#;
795        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
796
797        let config = load(dir.path()).unwrap();
798        assert_eq!(
799            config.dependencies["base"].url.as_deref(),
800            Some("https://github.com/org/base")
801        );
802    }
803
804    #[test]
805    fn load_does_not_migrate_ssh_url() {
806        let dir = TempDir::new().unwrap();
807        let toml_str = r#"
808[dependencies.base]
809url = "git@github.com:org/base.git"
810"#;
811        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
812
813        let config = load(dir.path()).unwrap();
814        assert_eq!(
815            config.dependencies["base"].url.as_deref(),
816            Some("git@github.com:org/base.git")
817        );
818    }
819
820    #[test]
821    fn load_missing_file_returns_not_found() {
822        let dir = TempDir::new().unwrap();
823        let result = load(dir.path());
824        assert!(result.is_err());
825        let err = result.unwrap_err().to_string();
826        assert!(err.contains("not found"), "should be NotFound: {err}");
827    }
828
829    #[test]
830    fn load_manifest_returns_none_without_package() {
831        let dir = TempDir::new().unwrap();
832        std::fs::write(
833            dir.path().join("mars.toml"),
834            r#"
835[dependencies.base]
836url = "https://github.com/org/base.git"
837"#,
838        )
839        .unwrap();
840
841        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
842        assert!(diagnostics.is_empty());
843        assert!(manifest.is_none());
844    }
845
846    #[test]
847    fn load_manifest_returns_package_and_dependencies() {
848        let dir = TempDir::new().unwrap();
849        std::fs::write(
850            dir.path().join("mars.toml"),
851            r#"
852[package]
853name = "pkg"
854version = "1.2.3"
855
856[dependencies.base]
857url = "https://github.com/org/base.git"
858version = ">=1.0.0"
859"#,
860        )
861        .unwrap();
862
863        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
864        assert!(diagnostics.is_empty());
865        let manifest = manifest.unwrap();
866        assert_eq!(manifest.package.name, "pkg");
867        assert_eq!(manifest.package.version, "1.2.3");
868        assert!(manifest.dependencies.contains_key("base"));
869    }
870
871    #[test]
872    fn load_local_missing_returns_default() {
873        let dir = TempDir::new().unwrap();
874        let local = load_local(dir.path()).unwrap();
875        assert!(local.overrides.is_empty());
876    }
877
878    #[test]
879    fn load_local_from_disk() {
880        let dir = TempDir::new().unwrap();
881        let toml_str = r#"
882[overrides.base]
883path = "/home/dev/local-base"
884"#;
885        std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
886        let local = load_local(dir.path()).unwrap();
887        assert_eq!(local.overrides.len(), 1);
888        assert_eq!(
889            local.overrides["base"].path,
890            PathBuf::from("/home/dev/local-base")
891        );
892    }
893
894    #[test]
895    fn merge_with_empty_local() {
896        let config = Config {
897            dependencies: {
898                let mut m = IndexMap::new();
899                m.insert(
900                    "base".into(),
901                    DependencyEntry {
902                        url: Some("https://github.com/org/base.git".into()),
903                        path: None,
904                        version: Some("v1.0".into()),
905                        filter: FilterConfig::default(),
906                    },
907                );
908                m
909            },
910            settings: Settings::default(),
911            ..Config::default()
912        };
913        let local = LocalConfig::default();
914        let effective = merge(config, local).unwrap();
915        assert_eq!(effective.dependencies.len(), 1);
916        let source = &effective.dependencies["base"];
917        assert!(!source.is_overridden);
918        assert!(source.original_git.is_none());
919        match &source.spec {
920            SourceSpec::Git(git) => {
921                assert_eq!(git.url, "https://github.com/org/base.git");
922                assert_eq!(git.version.as_deref(), Some("v1.0"));
923            }
924            SourceSpec::Path(_) => panic!("expected Git"),
925        }
926    }
927
928    #[test]
929    fn merge_override_replaces_with_path() {
930        let config = Config {
931            dependencies: {
932                let mut m = IndexMap::new();
933                m.insert(
934                    "base".into(),
935                    DependencyEntry {
936                        url: Some("https://github.com/org/base.git".into()),
937                        path: None,
938                        version: Some("v1.0".into()),
939                        filter: FilterConfig::default(),
940                    },
941                );
942                m
943            },
944            settings: Settings::default(),
945            ..Config::default()
946        };
947        let local = LocalConfig {
948            overrides: {
949                let mut m = IndexMap::new();
950                m.insert(
951                    "base".into(),
952                    OverrideEntry {
953                        path: PathBuf::from("/home/dev/local-base"),
954                    },
955                );
956                m
957            },
958        };
959        let effective = merge(config, local).unwrap();
960        let source = &effective.dependencies["base"];
961        assert!(source.is_overridden);
962
963        match &source.spec {
964            SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
965            SourceSpec::Git(_) => panic!("expected Path override"),
966        }
967
968        let orig = source.original_git.as_ref().unwrap();
969        assert_eq!(orig.url, "https://github.com/org/base.git");
970        assert_eq!(orig.version.as_deref(), Some("v1.0"));
971    }
972
973    #[test]
974    fn merge_all_filter_mode() {
975        let config = Config {
976            dependencies: {
977                let mut m = IndexMap::new();
978                m.insert(
979                    "base".into(),
980                    DependencyEntry {
981                        url: Some("https://github.com/org/base.git".into()),
982                        path: None,
983                        version: None,
984                        filter: FilterConfig::default(),
985                    },
986                );
987                m
988            },
989            settings: Settings::default(),
990            ..Config::default()
991        };
992        let effective = merge(config, LocalConfig::default()).unwrap();
993        assert!(matches!(
994            effective.dependencies["base"].filter,
995            FilterMode::All
996        ));
997    }
998
999    #[test]
1000    fn save_and_reload() {
1001        let dir = TempDir::new().unwrap();
1002        let config = Config {
1003            dependencies: {
1004                let mut m = IndexMap::new();
1005                m.insert(
1006                    "base".into(),
1007                    DependencyEntry {
1008                        url: Some("https://github.com/org/base.git".into()),
1009                        path: None,
1010                        version: Some("v2.0".into()),
1011                        filter: FilterConfig::default(),
1012                    },
1013                );
1014                m
1015            },
1016            settings: Settings::default(),
1017            ..Config::default()
1018        };
1019        save(dir.path(), &config).unwrap();
1020        let reloaded = load(dir.path()).unwrap();
1021        assert_eq!(config, reloaded);
1022    }
1023
1024    #[test]
1025    fn rename_map_preserved() {
1026        let toml_str = r#"
1027[dependencies.base]
1028url = "https://github.com/org/base.git"
1029
1030[dependencies.base.rename]
1031old-name = "new-name"
1032"#;
1033        let config: Config = toml::from_str(toml_str).unwrap();
1034        let effective = merge(config, LocalConfig::default()).unwrap();
1035        let source = &effective.dependencies["base"];
1036        assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
1037    }
1038
1039    #[test]
1040    fn self_dependency_name_rejected() {
1041        let toml_str = r#"
1042[dependencies._self]
1043url = "https://github.com/org/base.git"
1044"#;
1045        let config: Config = toml::from_str(toml_str).unwrap();
1046        let local = LocalConfig::default();
1047        let result = merge(config, local);
1048        assert!(result.is_err());
1049        let err = result.unwrap_err().to_string();
1050        assert!(
1051            err.contains("_self") && err.contains("reserved"),
1052            "should reject _self: {err}"
1053        );
1054    }
1055
1056    #[test]
1057    fn managed_root_setting_roundtrip() {
1058        let config = Config {
1059            settings: Settings {
1060                managed_root: Some(".claude".into()),
1061                targets: None,
1062            },
1063            ..Config::default()
1064        };
1065        let serialized = toml::to_string_pretty(&config).unwrap();
1066        let deserialized: Config = toml::from_str(&serialized).unwrap();
1067        assert_eq!(
1068            deserialized.settings.managed_root.as_deref(),
1069            Some(".claude")
1070        );
1071    }
1072
1073    #[test]
1074    fn save_preserves_dependencies_when_clearing_last_target() {
1075        let dir = TempDir::new().unwrap();
1076        let original = r#"
1077[package]
1078name = "sample"
1079version = "0.1.0"
1080
1081[dependencies.base]
1082url = "https://github.com/org/base.git"
1083version = "v1.0"
1084agents = ["coder"]
1085
1086[settings]
1087managed_root = ".agents"
1088targets = [".claude"]
1089"#;
1090        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1091
1092        let mut config = load(dir.path()).unwrap();
1093        if let Some(targets) = config.settings.targets.as_mut() {
1094            targets.retain(|target| target != ".claude");
1095            if targets.is_empty() {
1096                config.settings.targets = None;
1097            }
1098        }
1099        save(dir.path(), &config).unwrap();
1100
1101        let reloaded = load(dir.path()).unwrap();
1102        assert_eq!(
1103            reloaded.package.as_ref().map(|p| p.name.as_str()),
1104            Some("sample")
1105        );
1106        assert_eq!(
1107            reloaded.dependencies["base"].url.as_deref(),
1108            Some("https://github.com/org/base.git")
1109        );
1110        assert_eq!(
1111            reloaded.dependencies["base"].version.as_deref(),
1112            Some("v1.0")
1113        );
1114        assert_eq!(
1115            reloaded.dependencies["base"].filter.agents.as_deref(),
1116            Some(&["coder".into()][..])
1117        );
1118        assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
1119        assert!(reloaded.settings.targets.is_none());
1120    }
1121
1122    #[test]
1123    fn roundtrip_preserves_all_filter_fields() {
1124        let dir = TempDir::new().unwrap();
1125        let original = r#"
1126[dependencies.include]
1127url = "https://github.com/org/include.git"
1128agents = ["coder", "reviewer"]
1129skills = ["review", "plan"]
1130
1131[dependencies.include.rename]
1132coder = "core-coder"
1133
1134[dependencies.exclude]
1135url = "https://github.com/org/exclude.git"
1136exclude = ["experimental", "deprecated"]
1137
1138[dependencies.only_skills]
1139url = "https://github.com/org/skills.git"
1140only_skills = true
1141
1142[dependencies.only_agents]
1143url = "https://github.com/org/agents.git"
1144only_agents = true
1145"#;
1146        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1147
1148        let config = load(dir.path()).unwrap();
1149        save(dir.path(), &config).unwrap();
1150        let reloaded = load(dir.path()).unwrap();
1151
1152        let include = &reloaded.dependencies["include"].filter;
1153        assert_eq!(
1154            include.agents.as_deref(),
1155            Some(&["coder".into(), "reviewer".into()][..])
1156        );
1157        assert_eq!(
1158            include.skills.as_deref(),
1159            Some(&["review".into(), "plan".into()][..])
1160        );
1161        assert_eq!(
1162            include.rename.as_ref().and_then(|r| r.get("coder")),
1163            Some(&"core-coder".into())
1164        );
1165
1166        let exclude = &reloaded.dependencies["exclude"].filter;
1167        assert_eq!(
1168            exclude.exclude.as_deref(),
1169            Some(&["experimental".into(), "deprecated".into()][..])
1170        );
1171
1172        let only_skills = &reloaded.dependencies["only_skills"].filter;
1173        assert!(only_skills.only_skills);
1174        assert!(!only_skills.only_agents);
1175
1176        let only_agents = &reloaded.dependencies["only_agents"].filter;
1177        assert!(only_agents.only_agents);
1178        assert!(!only_agents.only_skills);
1179    }
1180
1181    #[test]
1182    fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
1183        let dir = TempDir::new().unwrap();
1184        let original = r#"
1185[dependencies.git-include]
1186url = "https://github.com/org/git-include.git"
1187agents = ["coder"]
1188
1189[dependencies.path-exclude]
1190path = "../local-source"
1191exclude = ["draft"]
1192
1193[dependencies.git-only-skills]
1194url = "https://github.com/org/git-skills.git"
1195only_skills = true
1196
1197[dependencies.git-only-agents]
1198url = "https://github.com/org/git-agents.git"
1199only_agents = true
1200"#;
1201        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1202
1203        let config = load(dir.path()).unwrap();
1204        save(dir.path(), &config).unwrap();
1205        let reloaded = load(dir.path()).unwrap();
1206
1207        assert_eq!(reloaded.dependencies.len(), 4);
1208        assert_eq!(
1209            reloaded.dependencies["git-include"]
1210                .filter
1211                .agents
1212                .as_deref(),
1213            Some(&["coder".into()][..])
1214        );
1215        assert_eq!(
1216            reloaded.dependencies["path-exclude"].path.as_deref(),
1217            Some(Path::new("../local-source"))
1218        );
1219        assert_eq!(
1220            reloaded.dependencies["path-exclude"]
1221                .filter
1222                .exclude
1223                .as_deref(),
1224            Some(&["draft".into()][..])
1225        );
1226        assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
1227        assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
1228    }
1229
1230    #[test]
1231    fn save_roundtrip_guard_rejects_dependency_count_loss() {
1232        let mut original = Config::default();
1233        original.dependencies.insert(
1234            "base".into(),
1235            DependencyEntry {
1236                url: Some("https://github.com/org/base.git".into()),
1237                path: None,
1238                version: Some("v1.0".into()),
1239                filter: FilterConfig::default(),
1240            },
1241        );
1242
1243        let reparsed = Config::default();
1244        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1245        let msg = err.to_string();
1246        assert!(
1247            msg.contains("dependency count changed"),
1248            "unexpected error: {msg}"
1249        );
1250    }
1251
1252    #[test]
1253    fn save_roundtrip_guard_rejects_managed_root_loss() {
1254        let original = Config {
1255            settings: Settings {
1256                managed_root: Some(".agents".into()),
1257                targets: None,
1258            },
1259            ..Config::default()
1260        };
1261        let reparsed = Config::default();
1262        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1263        let msg = err.to_string();
1264        assert!(
1265            msg.contains("settings.managed_root changed"),
1266            "unexpected error: {msg}"
1267        );
1268    }
1269
1270    #[test]
1271    fn parse_only_skills_filter() {
1272        let toml_str = r#"
1273[dependencies.base]
1274url = "https://github.com/org/base.git"
1275only_skills = true
1276"#;
1277        let config: Config = toml::from_str(toml_str).unwrap();
1278        let local = LocalConfig::default();
1279        let effective = merge(config, local).unwrap();
1280        let source = &effective.dependencies["base"];
1281        assert!(matches!(source.filter, FilterMode::OnlySkills));
1282    }
1283
1284    #[test]
1285    fn parse_only_agents_filter() {
1286        let toml_str = r#"
1287[dependencies.base]
1288url = "https://github.com/org/base.git"
1289only_agents = true
1290"#;
1291        let config: Config = toml::from_str(toml_str).unwrap();
1292        let local = LocalConfig::default();
1293        let effective = merge(config, local).unwrap();
1294        let source = &effective.dependencies["base"];
1295        assert!(matches!(source.filter, FilterMode::OnlyAgents));
1296    }
1297
1298    #[test]
1299    fn error_on_only_skills_and_only_agents() {
1300        let toml_str = r#"
1301[dependencies.bad]
1302url = "https://github.com/org/bad.git"
1303only_skills = true
1304only_agents = true
1305"#;
1306        let config: Config = toml::from_str(toml_str).unwrap();
1307        let local = LocalConfig::default();
1308        let result = merge(config, local);
1309        assert!(result.is_err());
1310        let err = result.unwrap_err().to_string();
1311        assert!(
1312            err.contains("mutually exclusive"),
1313            "should mention mutually exclusive: {err}"
1314        );
1315    }
1316
1317    #[test]
1318    fn error_on_only_skills_with_agents_list() {
1319        let toml_str = r#"
1320[dependencies.bad]
1321url = "https://github.com/org/bad.git"
1322only_skills = true
1323agents = ["coder"]
1324"#;
1325        let config: Config = toml::from_str(toml_str).unwrap();
1326        let local = LocalConfig::default();
1327        let result = merge(config, local);
1328        assert!(result.is_err());
1329        let err = result.unwrap_err().to_string();
1330        assert!(
1331            err.contains("cannot combine"),
1332            "should mention cannot combine: {err}"
1333        );
1334    }
1335
1336    #[test]
1337    fn error_on_only_agents_with_skills_list() {
1338        let toml_str = r#"
1339[dependencies.bad]
1340url = "https://github.com/org/bad.git"
1341only_agents = true
1342skills = ["planning"]
1343"#;
1344        let config: Config = toml::from_str(toml_str).unwrap();
1345        let local = LocalConfig::default();
1346        let result = merge(config, local);
1347        assert!(result.is_err());
1348    }
1349
1350    #[test]
1351    fn error_on_only_skills_with_exclude() {
1352        let toml_str = r#"
1353[dependencies.bad]
1354url = "https://github.com/org/bad.git"
1355only_skills = true
1356exclude = ["deprecated"]
1357"#;
1358        let config: Config = toml::from_str(toml_str).unwrap();
1359        let local = LocalConfig::default();
1360        let result = merge(config, local);
1361        assert!(result.is_err());
1362    }
1363
1364    #[test]
1365    fn only_skills_false_not_serialized() {
1366        let config = Config {
1367            dependencies: {
1368                let mut m = IndexMap::new();
1369                m.insert(
1370                    "base".into(),
1371                    DependencyEntry {
1372                        url: Some("https://github.com/org/base.git".into()),
1373                        path: None,
1374                        version: None,
1375                        filter: FilterConfig::default(),
1376                    },
1377                );
1378                m
1379            },
1380            settings: Settings::default(),
1381            ..Config::default()
1382        };
1383        let serialized = toml::to_string_pretty(&config).unwrap();
1384        assert!(
1385            !serialized.contains("only_skills"),
1386            "false booleans should not be serialized: {serialized}"
1387        );
1388        assert!(
1389            !serialized.contains("only_agents"),
1390            "false booleans should not be serialized: {serialized}"
1391        );
1392    }
1393
1394    #[test]
1395    fn only_skills_true_roundtrips() {
1396        let toml_str = r#"
1397[dependencies.base]
1398url = "https://github.com/org/base.git"
1399only_skills = true
1400"#;
1401        let config: Config = toml::from_str(toml_str).unwrap();
1402        assert!(config.dependencies["base"].filter.only_skills);
1403        assert!(!config.dependencies["base"].filter.only_agents);
1404
1405        let serialized = toml::to_string_pretty(&config).unwrap();
1406        let reloaded: Config = toml::from_str(&serialized).unwrap();
1407        assert!(reloaded.dependencies["base"].filter.only_skills);
1408    }
1409
1410    #[test]
1411    fn filter_config_has_any_filter() {
1412        assert!(!FilterConfig::default().has_any_filter());
1413        assert!(
1414            FilterConfig {
1415                only_skills: true,
1416                ..FilterConfig::default()
1417            }
1418            .has_any_filter()
1419        );
1420        assert!(
1421            FilterConfig {
1422                agents: Some(vec!["coder".into()]),
1423                ..FilterConfig::default()
1424            }
1425            .has_any_filter()
1426        );
1427    }
1428
1429    #[test]
1430    fn filter_config_to_mode() {
1431        assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
1432        assert!(matches!(
1433            FilterConfig {
1434                only_skills: true,
1435                ..FilterConfig::default()
1436            }
1437            .to_mode(),
1438            FilterMode::OnlySkills
1439        ));
1440        assert!(matches!(
1441            FilterConfig {
1442                only_agents: true,
1443                ..FilterConfig::default()
1444            }
1445            .to_mode(),
1446            FilterMode::OnlyAgents
1447        ));
1448        assert!(matches!(
1449            FilterConfig {
1450                agents: Some(vec!["coder".into()]),
1451                ..FilterConfig::default()
1452            }
1453            .to_mode(),
1454            FilterMode::Include { .. }
1455        ));
1456        assert!(matches!(
1457            FilterConfig {
1458                exclude: Some(vec!["old".into()]),
1459                ..FilterConfig::default()
1460            }
1461            .to_mode(),
1462            FilterMode::Exclude(_)
1463        ));
1464    }
1465
1466    // === managed_targets tests ===
1467
1468    #[test]
1469    fn managed_targets_defaults_to_agents() {
1470        let settings = Settings::default();
1471        assert_eq!(settings.managed_targets(), vec![".agents"]);
1472    }
1473
1474    #[test]
1475    fn managed_targets_uses_explicit_targets() {
1476        let settings = Settings {
1477            targets: Some(vec![".claude".to_string()]),
1478            ..Settings::default()
1479        };
1480        assert_eq!(settings.managed_targets(), vec![".claude"]);
1481    }
1482
1483    #[test]
1484    fn managed_targets_uses_managed_root_as_primary() {
1485        let settings = Settings {
1486            managed_root: Some(".claude".to_string()),
1487            ..Settings::default()
1488        };
1489        assert_eq!(settings.managed_targets(), vec![".claude"]);
1490    }
1491
1492    #[test]
1493    fn managed_targets_explicit_overrides_links_and_managed_root() {
1494        let settings = Settings {
1495            managed_root: Some(".cursor".to_string()),
1496            targets: Some(vec![".codex".to_string()]),
1497        };
1498        // targets takes precedence over managed_root
1499        assert_eq!(settings.managed_targets(), vec![".codex"]);
1500    }
1501}