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