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