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