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