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