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) => SourceId::git_with_subpath(git.url.clone(), subpath.clone()),
650        SourceSpec::Path(path) => match SourceId::path_with_subpath(root, path, subpath.clone()) {
651            Ok(id) => id,
652            Err(_) => {
653                let canonical = if path.is_absolute() {
654                    path.clone()
655                } else {
656                    root.join(path)
657                };
658                SourceId::Path { canonical, subpath }
659            }
660        },
661    }
662}
663
664fn migrate_legacy_source_urls(config: &mut Config) {
665    for dep in config
666        .dependencies
667        .values_mut()
668        .chain(config.local_dependencies.values_mut())
669    {
670        if let Some(url) = dep.url.as_mut() {
671            let raw = url.as_str();
672            if should_upgrade_legacy_git_url(raw) {
673                *url = SourceUrl::from(format!("https://{raw}"));
674            }
675        }
676    }
677}
678
679fn should_upgrade_legacy_git_url(url: &str) -> bool {
680    !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
681}
682
683/// Write mars.toml atomically.
684pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
685    let path = root.join(CONFIG_FILE);
686    let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
687        message: format!("failed to serialize config: {e}"),
688    })?;
689    let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
690        message: format!("refusing to save config: serialized output failed to parse: {e}"),
691    })?;
692    validate_save_roundtrip(config, &reparsed)?;
693    crate::fs::atomic_write(&path, content.as_bytes())
694}
695
696fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
697    if reparsed.dependencies.len() != original.dependencies.len() {
698        return Err(ConfigError::Invalid {
699            message: format!(
700                "refusing to save config: dependency count changed during roundtrip ({} -> {})",
701                original.dependencies.len(),
702                reparsed.dependencies.len()
703            ),
704        }
705        .into());
706    }
707
708    if reparsed.local_dependencies.len() != original.local_dependencies.len() {
709        return Err(ConfigError::Invalid {
710            message: format!(
711                "refusing to save config: local-dependencies count changed during roundtrip ({} -> {})",
712                original.local_dependencies.len(),
713                reparsed.local_dependencies.len()
714            ),
715        }
716        .into());
717    }
718
719    if reparsed.settings.managed_root != original.settings.managed_root {
720        return Err(ConfigError::Invalid {
721            message: format!(
722                "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
723                original.settings.managed_root, reparsed.settings.managed_root
724            ),
725        }
726        .into());
727    }
728    if reparsed.settings.model_visibility != original.settings.model_visibility {
729        return Err(ConfigError::Invalid {
730            message: format!(
731                "refusing to save config: settings.model_visibility changed during roundtrip ({:?} -> {:?})",
732                original.settings.model_visibility, reparsed.settings.model_visibility
733            ),
734        }
735        .into());
736    }
737    if reparsed.settings.agent_emission != original.settings.agent_emission {
738        return Err(ConfigError::Invalid {
739            message: format!(
740                "refusing to save config: settings.agent_emission changed during roundtrip ({:?} -> {:?})",
741                original.settings.agent_emission, reparsed.settings.agent_emission
742            ),
743        }
744        .into());
745    }
746
747    for (name, dep) in &original.dependencies {
748        let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
749            return Err(ConfigError::Invalid {
750                message: format!(
751                    "refusing to save config: dependency `{name}` missing after roundtrip"
752                ),
753            }
754            .into());
755        };
756
757        if reparsed_dep != dep {
758            return Err(ConfigError::Invalid {
759                message: format!(
760                    "refusing to save config: dependency `{name}` changed during roundtrip"
761                ),
762            }
763            .into());
764        }
765    }
766
767    for (name, dep) in &original.local_dependencies {
768        let Some(reparsed_dep) = reparsed.local_dependencies.get(name) else {
769            return Err(ConfigError::Invalid {
770                message: format!(
771                    "refusing to save config: local-dependency `{name}` missing after roundtrip"
772                ),
773            }
774            .into());
775        };
776
777        if reparsed_dep != dep {
778            return Err(ConfigError::Invalid {
779                message: format!(
780                    "refusing to save config: local-dependency `{name}` changed during roundtrip"
781                ),
782            }
783            .into());
784        }
785    }
786
787    Ok(())
788}
789
790/// Write mars.local.toml atomically.
791pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
792    let path = root.join(LOCAL_CONFIG_FILE);
793    let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
794        message: format!("failed to serialize local config: {e}"),
795    })?;
796    crate::fs::atomic_write(&path, content.as_bytes())
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use tempfile::TempDir;
803
804    #[test]
805    fn parse_git_dependency() {
806        let toml_str = r#"
807[dependencies.base]
808url = "https://github.com/org/base.git"
809version = "v1.0"
810"#;
811        let config: Config = toml::from_str(toml_str).unwrap();
812        assert_eq!(config.dependencies.len(), 1);
813        let entry = &config.dependencies["base"];
814        assert_eq!(
815            entry.url.as_deref(),
816            Some("https://github.com/org/base.git")
817        );
818        assert!(entry.path.is_none());
819        assert_eq!(entry.version.as_deref(), Some("v1.0"));
820    }
821
822    #[test]
823    fn parse_path_dependency() {
824        let toml_str = r#"
825[dependencies.local]
826path = "../my-agents"
827"#;
828        let config: Config = toml::from_str(toml_str).unwrap();
829        let entry = &config.dependencies["local"];
830        assert!(entry.url.is_none());
831        assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
832    }
833
834    #[test]
835    fn parse_mixed_dependencies() {
836        let toml_str = r#"
837[dependencies.remote]
838url = "https://github.com/org/remote.git"
839version = "v2.0"
840agents = ["coder", "reviewer"]
841
842[dependencies.local]
843path = "/home/dev/agents"
844exclude = ["experimental"]
845"#;
846        let config: Config = toml::from_str(toml_str).unwrap();
847        assert_eq!(config.dependencies.len(), 2);
848        assert!(config.dependencies.contains_key("remote"));
849        assert!(config.dependencies.contains_key("local"));
850    }
851
852    #[test]
853    fn parse_package_and_dependencies_coexist() {
854        let toml_str = r#"
855[package]
856name = "my-agents"
857version = "0.1.0"
858
859[dependencies.base]
860url = "https://github.com/org/base.git"
861version = ">=1.0.0"
862
863[dependencies.local]
864path = "../local-agents"
865"#;
866        let config: Config = toml::from_str(toml_str).unwrap();
867        assert!(config.package.is_some());
868        assert!(config.dependencies.contains_key("base"));
869        assert!(config.dependencies.contains_key("local"));
870    }
871
872    #[test]
873    fn parse_include_filter() {
874        let toml_str = r#"
875[dependencies.base]
876url = "https://github.com/org/base.git"
877agents = ["coder"]
878skills = ["review"]
879"#;
880        let config: Config = toml::from_str(toml_str).unwrap();
881        let local = LocalConfig::default();
882        let effective = merge(config, local).unwrap();
883        let source = &effective.dependencies["base"];
884        match &source.filter {
885            FilterMode::Include { agents, skills } => {
886                assert_eq!(agents, &["coder"]);
887                assert_eq!(skills, &["review"]);
888            }
889            other => panic!("expected Include, got {other:?}"),
890        }
891    }
892
893    #[test]
894    fn parse_exclude_filter() {
895        let toml_str = r#"
896[dependencies.base]
897url = "https://github.com/org/base.git"
898exclude = ["experimental", "deprecated"]
899"#;
900        let config: Config = toml::from_str(toml_str).unwrap();
901        let local = LocalConfig::default();
902        let effective = merge(config, local).unwrap();
903        let source = &effective.dependencies["base"];
904        match &source.filter {
905            FilterMode::Exclude(items) => {
906                assert_eq!(items, &["experimental", "deprecated"]);
907            }
908            other => panic!("expected Exclude, got {other:?}"),
909        }
910    }
911
912    #[test]
913    fn error_on_both_include_and_exclude() {
914        let toml_str = r#"
915[dependencies.bad]
916url = "https://github.com/org/bad.git"
917agents = ["coder"]
918exclude = ["reviewer"]
919"#;
920        let config: Config = toml::from_str(toml_str).unwrap();
921        let local = LocalConfig::default();
922        let result = merge(config, local);
923        assert!(result.is_err());
924        let err = result.unwrap_err().to_string();
925        assert!(
926            err.contains("bad"),
927            "error should mention dependency name: {err}"
928        );
929    }
930
931    #[test]
932    fn error_on_neither_url_nor_path() {
933        let toml_str = r#"
934[dependencies.empty]
935version = "v1.0"
936"#;
937        let config: Config = toml::from_str(toml_str).unwrap();
938        let local = LocalConfig::default();
939        let result = merge(config, local);
940        assert!(result.is_err());
941        let err = result.unwrap_err().to_string();
942        assert!(
943            err.contains("neither"),
944            "error should mention 'neither': {err}"
945        );
946    }
947
948    #[test]
949    fn error_on_both_url_and_path() {
950        let toml_str = r#"
951[dependencies.both]
952url = "https://github.com/org/repo.git"
953path = "/local/path"
954"#;
955        let config: Config = toml::from_str(toml_str).unwrap();
956        let local = LocalConfig::default();
957        let result = merge(config, local);
958        assert!(result.is_err());
959        let err = result.unwrap_err().to_string();
960        assert!(err.contains("both"), "error should mention 'both': {err}");
961    }
962
963    #[test]
964    fn roundtrip_full_config_shape_survives_save() {
965        let dir = TempDir::new().unwrap();
966        let original = r#"
967[package]
968name = "sample"
969version = "0.1.0"
970description = "sample package"
971
972[dependencies.base]
973url = "https://github.com/org/base.git"
974version = "v1.0"
975agents = ["coder", "reviewer"]
976
977[dependencies.local]
978path = "../local-agents"
979exclude = ["experimental"]
980
981[settings]
982managed_root = ".custom-agents"
983targets = [".claude", ".cursor"]
984"#;
985        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
986
987        let config = load(dir.path()).unwrap();
988        save(dir.path(), &config).unwrap();
989        let reloaded = load(dir.path()).unwrap();
990
991        assert_eq!(
992            reloaded.package.as_ref().map(|p| p.name.as_str()),
993            Some("sample")
994        );
995        assert_eq!(reloaded.dependencies.len(), 2);
996        assert_eq!(
997            reloaded.dependencies["base"].url.as_deref(),
998            Some("https://github.com/org/base.git")
999        );
1000        assert_eq!(
1001            reloaded.dependencies["local"].path.as_deref(),
1002            Some(Path::new("../local-agents"))
1003        );
1004        assert_eq!(
1005            reloaded.settings.managed_root.as_deref(),
1006            Some(".custom-agents")
1007        );
1008        assert_eq!(
1009            reloaded.settings.targets,
1010            Some(vec![".claude".to_string(), ".cursor".to_string()])
1011        );
1012    }
1013
1014    #[test]
1015    fn load_from_disk() {
1016        let dir = TempDir::new().unwrap();
1017        let toml_str = r#"
1018[dependencies.base]
1019url = "https://github.com/org/base.git"
1020version = "v1.0"
1021"#;
1022        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1023        let config = load(dir.path()).unwrap();
1024        assert_eq!(config.dependencies.len(), 1);
1025    }
1026
1027    #[test]
1028    fn load_migrates_legacy_bare_domain_url() {
1029        let dir = TempDir::new().unwrap();
1030        let toml_str = r#"
1031[dependencies.base]
1032url = "github.com/org/base"
1033"#;
1034        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1035
1036        let config = load(dir.path()).unwrap();
1037        assert_eq!(
1038            config.dependencies["base"].url.as_deref(),
1039            Some("https://github.com/org/base")
1040        );
1041    }
1042
1043    #[test]
1044    fn load_does_not_migrate_ssh_url() {
1045        let dir = TempDir::new().unwrap();
1046        let toml_str = r#"
1047[dependencies.base]
1048url = "git@github.com:org/base.git"
1049"#;
1050        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1051
1052        let config = load(dir.path()).unwrap();
1053        assert_eq!(
1054            config.dependencies["base"].url.as_deref(),
1055            Some("git@github.com:org/base.git")
1056        );
1057    }
1058
1059    #[test]
1060    fn load_missing_file_returns_not_found() {
1061        let dir = TempDir::new().unwrap();
1062        let result = load(dir.path());
1063        assert!(result.is_err());
1064        let err = result.unwrap_err().to_string();
1065        assert!(err.contains("not found"), "should be NotFound: {err}");
1066    }
1067
1068    #[test]
1069    fn load_manifest_returns_none_without_package() {
1070        let dir = TempDir::new().unwrap();
1071        std::fs::write(
1072            dir.path().join("mars.toml"),
1073            r#"
1074[dependencies.base]
1075url = "https://github.com/org/base.git"
1076"#,
1077        )
1078        .unwrap();
1079
1080        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1081        assert!(diagnostics.is_empty());
1082        assert!(manifest.is_none());
1083    }
1084
1085    #[test]
1086    fn load_manifest_returns_package_and_dependencies() {
1087        let dir = TempDir::new().unwrap();
1088        std::fs::write(
1089            dir.path().join("mars.toml"),
1090            r#"
1091[package]
1092name = "pkg"
1093version = "1.2.3"
1094
1095[dependencies.base]
1096url = "https://github.com/org/base.git"
1097version = ">=1.0.0"
1098skills = ["frontend-design"]
1099"#,
1100        )
1101        .unwrap();
1102
1103        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1104        assert!(diagnostics.is_empty());
1105        let manifest = manifest.unwrap();
1106        assert_eq!(manifest.package.name, "pkg");
1107        assert_eq!(manifest.package.version, "1.2.3");
1108        assert!(manifest.dependencies.contains_key("base"));
1109        assert_eq!(
1110            manifest.dependencies["base"].filter.skills.as_deref(),
1111            Some(&[ItemName::from("frontend-design")][..])
1112        );
1113    }
1114
1115    #[test]
1116    fn load_manifest_io_error_includes_operation_and_path() {
1117        let dir = TempDir::new().unwrap();
1118        let config_path = dir.path().join("mars.toml");
1119        std::fs::create_dir(&config_path).unwrap();
1120
1121        let err = load_manifest(dir.path()).unwrap_err();
1122        let msg = err.to_string();
1123
1124        assert!(
1125            msg.contains("read manifest config"),
1126            "error should include operation context: {msg}"
1127        );
1128        assert!(
1129            msg.contains("mars.toml"),
1130            "error should include config path: {msg}"
1131        );
1132    }
1133
1134    #[test]
1135    fn load_local_missing_returns_default() {
1136        let dir = TempDir::new().unwrap();
1137        let local = load_local(dir.path()).unwrap();
1138        assert!(local.overrides.is_empty());
1139    }
1140
1141    #[test]
1142    fn load_local_from_disk() {
1143        let dir = TempDir::new().unwrap();
1144        let toml_str = r#"
1145[overrides.base]
1146path = "/home/dev/local-base"
1147"#;
1148        std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
1149        let local = load_local(dir.path()).unwrap();
1150        assert_eq!(local.overrides.len(), 1);
1151        assert_eq!(
1152            local.overrides["base"].path,
1153            PathBuf::from("/home/dev/local-base")
1154        );
1155    }
1156
1157    #[test]
1158    fn merge_with_empty_local() {
1159        let config = Config {
1160            dependencies: {
1161                let mut m = IndexMap::new();
1162                m.insert(
1163                    "base".into(),
1164                    DependencyEntry {
1165                        url: Some("https://github.com/org/base.git".into()),
1166                        path: None,
1167                        subpath: None,
1168                        version: Some("v1.0".into()),
1169                        filter: FilterConfig::default(),
1170                    },
1171                );
1172                m
1173            },
1174            settings: Settings::default(),
1175            ..Config::default()
1176        };
1177        let local = LocalConfig::default();
1178        let effective = merge(config, local).unwrap();
1179        assert_eq!(effective.dependencies.len(), 1);
1180        let source = &effective.dependencies["base"];
1181        assert!(!source.is_overridden);
1182        assert!(source.original_git.is_none());
1183        match &source.spec {
1184            SourceSpec::Git(git) => {
1185                assert_eq!(git.url, "https://github.com/org/base.git");
1186                assert_eq!(git.version.as_deref(), Some("v1.0"));
1187            }
1188            SourceSpec::Path(_) => panic!("expected Git"),
1189        }
1190    }
1191
1192    #[test]
1193    fn merge_override_replaces_with_path() {
1194        let config = Config {
1195            dependencies: {
1196                let mut m = IndexMap::new();
1197                m.insert(
1198                    "base".into(),
1199                    DependencyEntry {
1200                        url: Some("https://github.com/org/base.git".into()),
1201                        path: None,
1202                        subpath: None,
1203                        version: Some("v1.0".into()),
1204                        filter: FilterConfig::default(),
1205                    },
1206                );
1207                m
1208            },
1209            settings: Settings::default(),
1210            ..Config::default()
1211        };
1212        let local = LocalConfig {
1213            overrides: {
1214                let mut m = IndexMap::new();
1215                m.insert(
1216                    "base".into(),
1217                    OverrideEntry {
1218                        path: PathBuf::from("/home/dev/local-base"),
1219                    },
1220                );
1221                m
1222            },
1223        };
1224        let effective = merge(config, local).unwrap();
1225        let source = &effective.dependencies["base"];
1226        assert!(source.is_overridden);
1227
1228        match &source.spec {
1229            SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
1230            SourceSpec::Git(_) => panic!("expected Path override"),
1231        }
1232
1233        let orig = source.original_git.as_ref().unwrap();
1234        assert_eq!(orig.url, "https://github.com/org/base.git");
1235        assert_eq!(orig.version.as_deref(), Some("v1.0"));
1236    }
1237
1238    #[test]
1239    fn merge_override_retains_subpath_coordinate() {
1240        let temp = TempDir::new().unwrap();
1241        // Canonicalize temp root once to avoid Windows 8.3 short-name mismatches
1242        let temp_root = dunce::canonicalize(temp.path()).unwrap();
1243        let override_path = temp_root.join("local-base");
1244        std::fs::create_dir_all(&override_path).unwrap();
1245        let canonical_override = dunce::canonicalize(&override_path).unwrap();
1246
1247        let config = Config {
1248            dependencies: {
1249                let mut m = IndexMap::new();
1250                m.insert(
1251                    "base".into(),
1252                    DependencyEntry {
1253                        url: Some("https://github.com/org/base.git".into()),
1254                        path: None,
1255                        subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1256                        version: Some("v1.0".into()),
1257                        filter: FilterConfig::default(),
1258                    },
1259                );
1260                m
1261            },
1262            settings: Settings::default(),
1263            ..Config::default()
1264        };
1265        let local = LocalConfig {
1266            overrides: {
1267                let mut m = IndexMap::new();
1268                m.insert(
1269                    "base".into(),
1270                    OverrideEntry {
1271                        path: canonical_override.clone(),
1272                    },
1273                );
1274                m
1275            },
1276        };
1277
1278        let (effective, _) = merge_with_root(config, local, &temp_root).unwrap();
1279        let source = &effective.dependencies["base"];
1280        assert!(source.is_overridden);
1281        assert_eq!(
1282            source.subpath.as_ref().map(SourceSubpath::as_str),
1283            Some("plugins/foo")
1284        );
1285        assert!(matches!(&source.spec, SourceSpec::Path(p) if p == &canonical_override));
1286        assert!(matches!(
1287            &source.id,
1288            SourceId::Path {
1289                canonical,
1290                subpath: Some(sp)
1291            } if canonical == &canonical_override && sp.as_str() == "plugins/foo"
1292        ));
1293    }
1294
1295    #[test]
1296    fn merge_all_filter_mode() {
1297        let config = Config {
1298            dependencies: {
1299                let mut m = IndexMap::new();
1300                m.insert(
1301                    "base".into(),
1302                    DependencyEntry {
1303                        url: Some("https://github.com/org/base.git".into()),
1304                        path: None,
1305                        subpath: None,
1306                        version: None,
1307                        filter: FilterConfig::default(),
1308                    },
1309                );
1310                m
1311            },
1312            settings: Settings::default(),
1313            ..Config::default()
1314        };
1315        let effective = merge(config, LocalConfig::default()).unwrap();
1316        assert!(matches!(
1317            effective.dependencies["base"].filter,
1318            FilterMode::All
1319        ));
1320    }
1321
1322    #[test]
1323    fn save_and_reload() {
1324        let dir = TempDir::new().unwrap();
1325        let config = Config {
1326            dependencies: {
1327                let mut m = IndexMap::new();
1328                m.insert(
1329                    "base".into(),
1330                    DependencyEntry {
1331                        url: Some("https://github.com/org/base.git".into()),
1332                        path: None,
1333                        subpath: None,
1334                        version: Some("v2.0".into()),
1335                        filter: FilterConfig::default(),
1336                    },
1337                );
1338                m
1339            },
1340            settings: Settings::default(),
1341            ..Config::default()
1342        };
1343        save(dir.path(), &config).unwrap();
1344        let reloaded = load(dir.path()).unwrap();
1345        assert_eq!(config, reloaded);
1346    }
1347
1348    #[test]
1349    fn rename_map_preserved() {
1350        let toml_str = r#"
1351[dependencies.base]
1352url = "https://github.com/org/base.git"
1353
1354[dependencies.base.rename]
1355old-name = "new-name"
1356"#;
1357        let config: Config = toml::from_str(toml_str).unwrap();
1358        let effective = merge(config, LocalConfig::default()).unwrap();
1359        let source = &effective.dependencies["base"];
1360        assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
1361    }
1362
1363    #[test]
1364    fn self_dependency_name_rejected() {
1365        let toml_str = r#"
1366[dependencies._self]
1367url = "https://github.com/org/base.git"
1368"#;
1369        let config: Config = toml::from_str(toml_str).unwrap();
1370        let local = LocalConfig::default();
1371        let result = merge(config, local);
1372        assert!(result.is_err());
1373        let err = result.unwrap_err().to_string();
1374        assert!(
1375            err.contains("_self") && err.contains("reserved"),
1376            "should reject _self: {err}"
1377        );
1378    }
1379
1380    #[test]
1381    fn managed_root_setting_roundtrip() {
1382        let config = Config {
1383            settings: Settings {
1384                managed_root: Some(".claude".into()),
1385                targets: None,
1386                ..Settings::default()
1387            },
1388            ..Config::default()
1389        };
1390        let serialized = toml::to_string_pretty(&config).unwrap();
1391        let deserialized: Config = toml::from_str(&serialized).unwrap();
1392        assert_eq!(
1393            deserialized.settings.managed_root.as_deref(),
1394            Some(".claude")
1395        );
1396    }
1397
1398    #[test]
1399    fn save_preserves_dependencies_when_clearing_last_target() {
1400        let dir = TempDir::new().unwrap();
1401        let original = r#"
1402[package]
1403name = "sample"
1404version = "0.1.0"
1405
1406[dependencies.base]
1407url = "https://github.com/org/base.git"
1408version = "v1.0"
1409agents = ["coder"]
1410
1411[settings]
1412managed_root = ".agents"
1413targets = [".claude"]
1414"#;
1415        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1416
1417        let mut config = load(dir.path()).unwrap();
1418        if let Some(targets) = config.settings.targets.as_mut() {
1419            targets.retain(|target| target != ".claude");
1420            if targets.is_empty() {
1421                config.settings.targets = None;
1422            }
1423        }
1424        save(dir.path(), &config).unwrap();
1425
1426        let reloaded = load(dir.path()).unwrap();
1427        assert_eq!(
1428            reloaded.package.as_ref().map(|p| p.name.as_str()),
1429            Some("sample")
1430        );
1431        assert_eq!(
1432            reloaded.dependencies["base"].url.as_deref(),
1433            Some("https://github.com/org/base.git")
1434        );
1435        assert_eq!(
1436            reloaded.dependencies["base"].version.as_deref(),
1437            Some("v1.0")
1438        );
1439        assert_eq!(
1440            reloaded.dependencies["base"].filter.agents.as_deref(),
1441            Some(&["coder".into()][..])
1442        );
1443        assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
1444        assert!(reloaded.settings.targets.is_none());
1445    }
1446
1447    #[test]
1448    fn roundtrip_preserves_all_filter_fields() {
1449        let dir = TempDir::new().unwrap();
1450        let original = r#"
1451[dependencies.include]
1452url = "https://github.com/org/include.git"
1453agents = ["coder", "reviewer"]
1454skills = ["review", "plan"]
1455
1456[dependencies.include.rename]
1457coder = "core-coder"
1458
1459[dependencies.exclude]
1460url = "https://github.com/org/exclude.git"
1461exclude = ["experimental", "deprecated"]
1462
1463[dependencies.only_skills]
1464url = "https://github.com/org/skills.git"
1465only_skills = true
1466
1467[dependencies.only_agents]
1468url = "https://github.com/org/agents.git"
1469only_agents = true
1470"#;
1471        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1472
1473        let config = load(dir.path()).unwrap();
1474        save(dir.path(), &config).unwrap();
1475        let reloaded = load(dir.path()).unwrap();
1476
1477        let include = &reloaded.dependencies["include"].filter;
1478        assert_eq!(
1479            include.agents.as_deref(),
1480            Some(&["coder".into(), "reviewer".into()][..])
1481        );
1482        assert_eq!(
1483            include.skills.as_deref(),
1484            Some(&["review".into(), "plan".into()][..])
1485        );
1486        assert_eq!(
1487            include.rename.as_ref().and_then(|r| r.get("coder")),
1488            Some(&"core-coder".into())
1489        );
1490
1491        let exclude = &reloaded.dependencies["exclude"].filter;
1492        assert_eq!(
1493            exclude.exclude.as_deref(),
1494            Some(&["experimental".into(), "deprecated".into()][..])
1495        );
1496
1497        let only_skills = &reloaded.dependencies["only_skills"].filter;
1498        assert!(only_skills.only_skills);
1499        assert!(!only_skills.only_agents);
1500
1501        let only_agents = &reloaded.dependencies["only_agents"].filter;
1502        assert!(only_agents.only_agents);
1503        assert!(!only_agents.only_skills);
1504    }
1505
1506    #[test]
1507    fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
1508        let dir = TempDir::new().unwrap();
1509        let original = r#"
1510[dependencies.git-include]
1511url = "https://github.com/org/git-include.git"
1512agents = ["coder"]
1513
1514[dependencies.path-exclude]
1515path = "../local-source"
1516exclude = ["draft"]
1517
1518[dependencies.git-only-skills]
1519url = "https://github.com/org/git-skills.git"
1520only_skills = true
1521
1522[dependencies.git-only-agents]
1523url = "https://github.com/org/git-agents.git"
1524only_agents = true
1525"#;
1526        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1527
1528        let config = load(dir.path()).unwrap();
1529        save(dir.path(), &config).unwrap();
1530        let reloaded = load(dir.path()).unwrap();
1531
1532        assert_eq!(reloaded.dependencies.len(), 4);
1533        assert_eq!(
1534            reloaded.dependencies["git-include"]
1535                .filter
1536                .agents
1537                .as_deref(),
1538            Some(&["coder".into()][..])
1539        );
1540        assert_eq!(
1541            reloaded.dependencies["path-exclude"].path.as_deref(),
1542            Some(Path::new("../local-source"))
1543        );
1544        assert_eq!(
1545            reloaded.dependencies["path-exclude"]
1546                .filter
1547                .exclude
1548                .as_deref(),
1549            Some(&["draft".into()][..])
1550        );
1551        assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
1552        assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
1553    }
1554
1555    #[test]
1556    fn save_roundtrip_guard_rejects_dependency_count_loss() {
1557        let mut original = Config::default();
1558        original.dependencies.insert(
1559            "base".into(),
1560            DependencyEntry {
1561                url: Some("https://github.com/org/base.git".into()),
1562                path: None,
1563                subpath: None,
1564                version: Some("v1.0".into()),
1565                filter: FilterConfig::default(),
1566            },
1567        );
1568
1569        let reparsed = Config::default();
1570        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1571        let msg = err.to_string();
1572        assert!(
1573            msg.contains("dependency count changed"),
1574            "unexpected error: {msg}"
1575        );
1576    }
1577
1578    #[test]
1579    fn save_roundtrip_guard_rejects_managed_root_loss() {
1580        let original = Config {
1581            settings: Settings {
1582                managed_root: Some(".agents".into()),
1583                targets: None,
1584                ..Settings::default()
1585            },
1586            ..Config::default()
1587        };
1588        let reparsed = Config::default();
1589        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1590        let msg = err.to_string();
1591        assert!(
1592            msg.contains("settings.managed_root changed"),
1593            "unexpected error: {msg}"
1594        );
1595    }
1596
1597    #[test]
1598    fn parse_only_skills_filter() {
1599        let toml_str = r#"
1600[dependencies.base]
1601url = "https://github.com/org/base.git"
1602only_skills = true
1603"#;
1604        let config: Config = toml::from_str(toml_str).unwrap();
1605        let local = LocalConfig::default();
1606        let effective = merge(config, local).unwrap();
1607        let source = &effective.dependencies["base"];
1608        assert!(matches!(source.filter, FilterMode::OnlySkills));
1609    }
1610
1611    #[test]
1612    fn parse_only_agents_filter() {
1613        let toml_str = r#"
1614[dependencies.base]
1615url = "https://github.com/org/base.git"
1616only_agents = true
1617"#;
1618        let config: Config = toml::from_str(toml_str).unwrap();
1619        let local = LocalConfig::default();
1620        let effective = merge(config, local).unwrap();
1621        let source = &effective.dependencies["base"];
1622        assert!(matches!(source.filter, FilterMode::OnlyAgents));
1623    }
1624
1625    #[test]
1626    fn error_on_only_skills_and_only_agents() {
1627        let toml_str = r#"
1628[dependencies.bad]
1629url = "https://github.com/org/bad.git"
1630only_skills = true
1631only_agents = true
1632"#;
1633        let config: Config = toml::from_str(toml_str).unwrap();
1634        let local = LocalConfig::default();
1635        let result = merge(config, local);
1636        assert!(result.is_err());
1637        let err = result.unwrap_err().to_string();
1638        assert!(
1639            err.contains("mutually exclusive"),
1640            "should mention mutually exclusive: {err}"
1641        );
1642    }
1643
1644    #[test]
1645    fn error_on_only_skills_with_agents_list() {
1646        let toml_str = r#"
1647[dependencies.bad]
1648url = "https://github.com/org/bad.git"
1649only_skills = true
1650agents = ["coder"]
1651"#;
1652        let config: Config = toml::from_str(toml_str).unwrap();
1653        let local = LocalConfig::default();
1654        let result = merge(config, local);
1655        assert!(result.is_err());
1656        let err = result.unwrap_err().to_string();
1657        assert!(
1658            err.contains("cannot combine"),
1659            "should mention cannot combine: {err}"
1660        );
1661    }
1662
1663    #[test]
1664    fn error_on_only_agents_with_skills_list() {
1665        let toml_str = r#"
1666[dependencies.bad]
1667url = "https://github.com/org/bad.git"
1668only_agents = true
1669skills = ["planning"]
1670"#;
1671        let config: Config = toml::from_str(toml_str).unwrap();
1672        let local = LocalConfig::default();
1673        let result = merge(config, local);
1674        assert!(result.is_err());
1675    }
1676
1677    #[test]
1678    fn error_on_only_skills_with_exclude() {
1679        let toml_str = r#"
1680[dependencies.bad]
1681url = "https://github.com/org/bad.git"
1682only_skills = true
1683exclude = ["deprecated"]
1684"#;
1685        let config: Config = toml::from_str(toml_str).unwrap();
1686        let local = LocalConfig::default();
1687        let result = merge(config, local);
1688        assert!(result.is_err());
1689    }
1690
1691    #[test]
1692    fn only_skills_false_not_serialized() {
1693        let config = Config {
1694            dependencies: {
1695                let mut m = IndexMap::new();
1696                m.insert(
1697                    "base".into(),
1698                    DependencyEntry {
1699                        url: Some("https://github.com/org/base.git".into()),
1700                        path: None,
1701                        subpath: None,
1702                        version: None,
1703                        filter: FilterConfig::default(),
1704                    },
1705                );
1706                m
1707            },
1708            settings: Settings::default(),
1709            ..Config::default()
1710        };
1711        let serialized = toml::to_string_pretty(&config).unwrap();
1712        assert!(
1713            !serialized.contains("only_skills"),
1714            "false booleans should not be serialized: {serialized}"
1715        );
1716        assert!(
1717            !serialized.contains("only_agents"),
1718            "false booleans should not be serialized: {serialized}"
1719        );
1720    }
1721
1722    #[test]
1723    fn only_skills_true_roundtrips() {
1724        let toml_str = r#"
1725[dependencies.base]
1726url = "https://github.com/org/base.git"
1727only_skills = true
1728"#;
1729        let config: Config = toml::from_str(toml_str).unwrap();
1730        assert!(config.dependencies["base"].filter.only_skills);
1731        assert!(!config.dependencies["base"].filter.only_agents);
1732
1733        let serialized = toml::to_string_pretty(&config).unwrap();
1734        let reloaded: Config = toml::from_str(&serialized).unwrap();
1735        assert!(reloaded.dependencies["base"].filter.only_skills);
1736    }
1737
1738    #[test]
1739    fn filter_config_has_any_filter() {
1740        assert!(!FilterConfig::default().has_any_filter());
1741        assert!(
1742            FilterConfig {
1743                only_skills: true,
1744                ..FilterConfig::default()
1745            }
1746            .has_any_filter()
1747        );
1748        assert!(
1749            FilterConfig {
1750                agents: Some(vec!["coder".into()]),
1751                ..FilterConfig::default()
1752            }
1753            .has_any_filter()
1754        );
1755    }
1756
1757    #[test]
1758    fn filter_config_to_mode() {
1759        assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
1760        assert!(matches!(
1761            FilterConfig {
1762                only_skills: true,
1763                ..FilterConfig::default()
1764            }
1765            .to_mode(),
1766            FilterMode::OnlySkills
1767        ));
1768        assert!(matches!(
1769            FilterConfig {
1770                only_agents: true,
1771                ..FilterConfig::default()
1772            }
1773            .to_mode(),
1774            FilterMode::OnlyAgents
1775        ));
1776        assert!(matches!(
1777            FilterConfig {
1778                agents: Some(vec!["coder".into()]),
1779                ..FilterConfig::default()
1780            }
1781            .to_mode(),
1782            FilterMode::Include { .. }
1783        ));
1784        assert!(matches!(
1785            FilterConfig {
1786                exclude: Some(vec!["old".into()]),
1787                ..FilterConfig::default()
1788            }
1789            .to_mode(),
1790            FilterMode::Exclude(_)
1791        ));
1792    }
1793
1794    // === managed_targets tests ===
1795
1796    #[test]
1797    fn managed_targets_defaults_to_no_target_sync_targets() {
1798        let settings = Settings::default();
1799        assert!(settings.managed_targets().is_empty());
1800    }
1801
1802    #[test]
1803    fn managed_targets_uses_explicit_targets() {
1804        let settings = Settings {
1805            targets: Some(vec![".claude".to_string()]),
1806            ..Settings::default()
1807        };
1808        assert_eq!(settings.managed_targets(), vec![".claude"]);
1809    }
1810
1811    #[test]
1812    fn managed_targets_uses_managed_root_as_primary() {
1813        let settings = Settings {
1814            managed_root: Some(".claude".to_string()),
1815            ..Settings::default()
1816        };
1817        assert_eq!(settings.managed_targets(), vec![".claude"]);
1818    }
1819
1820    #[test]
1821    fn managed_targets_explicit_overrides_links_and_managed_root() {
1822        let settings = Settings {
1823            managed_root: Some(".cursor".to_string()),
1824            targets: Some(vec![".codex".to_string()]),
1825            ..Settings::default()
1826        };
1827        // targets takes precedence over managed_root
1828        assert_eq!(settings.managed_targets(), vec![".codex"]);
1829    }
1830
1831    #[test]
1832    fn merge_warns_when_managed_root_is_agents() {
1833        let config = Config {
1834            settings: Settings {
1835                managed_root: Some(".agents".into()),
1836                ..Settings::default()
1837            },
1838            ..Config::default()
1839        };
1840
1841        let (_, diagnostics) =
1842            merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
1843
1844        assert!(diagnostics.iter().any(|diag| {
1845            diag.code == "deprecated-agents-target"
1846                && diag.context.as_deref() == Some("settings.managed_root")
1847        }));
1848    }
1849
1850    #[test]
1851    fn merge_warns_when_targets_include_agents() {
1852        let config = Config {
1853            settings: Settings {
1854                targets: Some(vec![".agents".into(), ".claude".into()]),
1855                ..Settings::default()
1856            },
1857            ..Config::default()
1858        };
1859
1860        let (_, diagnostics) =
1861            merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
1862
1863        assert!(diagnostics.iter().any(|diag| {
1864            diag.code == "deprecated-agents-target"
1865                && diag.context.as_deref() == Some("settings.targets")
1866        }));
1867    }
1868
1869    #[test]
1870    fn settings_models_cache_ttl_defaults_to_24_when_omitted() {
1871        let config: Config = toml::from_str(
1872            r#"
1873[dependencies.base]
1874url = "https://github.com/org/base.git"
1875"#,
1876        )
1877        .unwrap();
1878        assert_eq!(config.settings.models_cache_ttl_hours, 24);
1879    }
1880
1881    #[test]
1882    fn settings_models_cache_ttl_defaults_to_24_when_settings_present_without_ttl() {
1883        let config: Config = toml::from_str(
1884            r#"
1885[settings]
1886managed_root = ".agents"
1887"#,
1888        )
1889        .unwrap();
1890        assert_eq!(config.settings.models_cache_ttl_hours, 24);
1891    }
1892
1893    #[test]
1894    fn settings_models_cache_ttl_parses_zero() {
1895        let config: Config = toml::from_str(
1896            r#"
1897[settings]
1898models_cache_ttl_hours = 0
1899"#,
1900        )
1901        .unwrap();
1902        assert_eq!(config.settings.models_cache_ttl_hours, 0);
1903    }
1904
1905    #[test]
1906    fn settings_models_cache_ttl_parses_custom_value() {
1907        let config: Config = toml::from_str(
1908            r#"
1909[settings]
1910models_cache_ttl_hours = 48
1911"#,
1912        )
1913        .unwrap();
1914        assert_eq!(config.settings.models_cache_ttl_hours, 48);
1915    }
1916
1917    #[test]
1918    fn settings_models_cache_ttl_roundtrip_preserves_value() {
1919        let original = Config {
1920            settings: Settings {
1921                models_cache_ttl_hours: 48,
1922                ..Settings::default()
1923            },
1924            ..Config::default()
1925        };
1926        let serialized = toml::to_string_pretty(&original).unwrap();
1927        let roundtripped: Config = toml::from_str(&serialized).unwrap();
1928        assert_eq!(
1929            roundtripped.settings.models_cache_ttl_hours,
1930            original.settings.models_cache_ttl_hours
1931        );
1932    }
1933
1934    #[test]
1935    fn settings_agent_emission_parses_auto() {
1936        let config: Config = toml::from_str(
1937            r#"
1938[settings]
1939agent_emission = "auto"
1940"#,
1941        )
1942        .unwrap();
1943        assert_eq!(config.settings.agent_emission, Some(AgentEmission::Auto));
1944    }
1945
1946    #[test]
1947    fn settings_agent_emission_parses_always_and_never() {
1948        let always: Config = toml::from_str(
1949            r#"
1950[settings]
1951agent_emission = "always"
1952"#,
1953        )
1954        .unwrap();
1955        assert_eq!(always.settings.agent_emission, Some(AgentEmission::Always));
1956
1957        let never: Config = toml::from_str(
1958            r#"
1959[settings]
1960agent_emission = "never"
1961"#,
1962        )
1963        .unwrap();
1964        assert_eq!(never.settings.agent_emission, Some(AgentEmission::Never));
1965    }
1966
1967    #[test]
1968    fn settings_agent_emission_defaults_to_auto_when_omitted() {
1969        let config: Config = toml::from_str(
1970            r#"
1971[settings]
1972models_cache_ttl_hours = 48
1973"#,
1974        )
1975        .unwrap();
1976        assert!(config.settings.agent_emission.is_none());
1977    }
1978
1979    #[test]
1980    fn settings_agent_emission_roundtrip_preserves_value() {
1981        let original = Config {
1982            settings: Settings {
1983                agent_emission: Some(AgentEmission::Always),
1984                ..Settings::default()
1985            },
1986            ..Config::default()
1987        };
1988        let serialized = toml::to_string_pretty(&original).unwrap();
1989        let roundtripped: Config = toml::from_str(&serialized).unwrap();
1990        assert_eq!(
1991            roundtripped.settings.agent_emission,
1992            original.settings.agent_emission
1993        );
1994    }
1995
1996    #[test]
1997    fn model_visibility_validate_allows_include_and_exclude() {
1998        let visibility = ModelVisibility {
1999            include: Some(vec!["opus*".into()]),
2000            exclude: Some(vec!["test*".into()]),
2001        };
2002        visibility.validate().unwrap();
2003    }
2004
2005    #[test]
2006    fn model_visibility_validate_allows_include_only_exclude_only_and_empty() {
2007        ModelVisibility {
2008            include: Some(vec!["opus*".into()]),
2009            exclude: None,
2010        }
2011        .validate()
2012        .unwrap();
2013        ModelVisibility {
2014            include: None,
2015            exclude: Some(vec!["test*".into()]),
2016        }
2017        .validate()
2018        .unwrap();
2019        ModelVisibility::default().validate().unwrap();
2020    }
2021
2022    #[test]
2023    fn model_visibility_is_empty_reports_state() {
2024        assert!(ModelVisibility::default().is_empty());
2025        assert!(
2026            !ModelVisibility {
2027                include: Some(vec!["opus*".into()]),
2028                exclude: None,
2029            }
2030            .is_empty()
2031        );
2032        assert!(
2033            !ModelVisibility {
2034                include: None,
2035                exclude: Some(vec!["test*".into()]),
2036            }
2037            .is_empty()
2038        );
2039    }
2040
2041    #[test]
2042    fn load_accepts_model_visibility_with_include_and_exclude() {
2043        let dir = TempDir::new().unwrap();
2044        std::fs::write(
2045            dir.path().join("mars.toml"),
2046            r#"
2047[settings.model_visibility]
2048include = ["opus*"]
2049exclude = ["test*"]
2050"#,
2051        )
2052        .unwrap();
2053
2054        let config = load(dir.path()).unwrap();
2055        assert_eq!(
2056            config.settings.model_visibility.include,
2057            Some(vec!["opus*".into()])
2058        );
2059        assert_eq!(
2060            config.settings.model_visibility.exclude,
2061            Some(vec!["test*".into()])
2062        );
2063    }
2064
2065    #[test]
2066    fn load_accepts_model_visibility_include_only() {
2067        let dir = TempDir::new().unwrap();
2068        std::fs::write(
2069            dir.path().join("mars.toml"),
2070            r#"
2071[settings.model_visibility]
2072include = ["opus*", "gpt-*"]
2073"#,
2074        )
2075        .unwrap();
2076
2077        let config = load(dir.path()).unwrap();
2078        assert_eq!(
2079            config.settings.model_visibility.include,
2080            Some(vec!["opus*".into(), "gpt-*".into()])
2081        );
2082        assert!(config.settings.model_visibility.exclude.is_none());
2083    }
2084
2085    #[test]
2086    fn load_accepts_model_visibility_exclude_only() {
2087        let dir = TempDir::new().unwrap();
2088        std::fs::write(
2089            dir.path().join("mars.toml"),
2090            r#"
2091[settings.model_visibility]
2092exclude = ["test-*", "deprecated-*"]
2093"#,
2094        )
2095        .unwrap();
2096
2097        let config = load(dir.path()).unwrap();
2098        assert_eq!(
2099            config.settings.model_visibility.exclude,
2100            Some(vec!["test-*".into(), "deprecated-*".into()])
2101        );
2102        assert!(config.settings.model_visibility.include.is_none());
2103    }
2104
2105    // === local-dependencies tests ===
2106
2107    #[test]
2108    fn parse_local_dependencies() {
2109        let toml_str = r#"
2110[dependencies.base]
2111url = "https://github.com/org/base.git"
2112
2113[local-dependencies.prompter]
2114url = "https://github.com/org/prompter.git"
2115skills = ["prompt-helper"]
2116"#;
2117        let config: Config = toml::from_str(toml_str).unwrap();
2118        assert_eq!(config.dependencies.len(), 1);
2119        assert_eq!(config.local_dependencies.len(), 1);
2120        assert!(config.local_dependencies.contains_key("prompter"));
2121        assert_eq!(
2122            config.local_dependencies["prompter"].url.as_deref(),
2123            Some("https://github.com/org/prompter.git")
2124        );
2125    }
2126
2127    #[test]
2128    fn local_dependencies_merged_into_effective_config() {
2129        let toml_str = r#"
2130[dependencies.base]
2131url = "https://github.com/org/base.git"
2132
2133[local-dependencies.prompter]
2134url = "https://github.com/org/prompter.git"
2135"#;
2136        let config: Config = toml::from_str(toml_str).unwrap();
2137        let local = LocalConfig::default();
2138        let effective = merge(config, local).unwrap();
2139
2140        // Both deps should be in effective config
2141        assert_eq!(effective.dependencies.len(), 2);
2142        assert!(effective.dependencies.contains_key("base"));
2143        assert!(effective.dependencies.contains_key("prompter"));
2144    }
2145
2146    #[test]
2147    fn local_dependencies_not_exported_to_manifest() {
2148        let dir = TempDir::new().unwrap();
2149        std::fs::write(
2150            dir.path().join("mars.toml"),
2151            r#"
2152[package]
2153name = "my-package"
2154version = "1.0.0"
2155
2156[dependencies.base]
2157url = "https://github.com/org/base.git"
2158
2159[local-dependencies.prompter]
2160url = "https://github.com/org/prompter.git"
2161"#,
2162        )
2163        .unwrap();
2164
2165        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
2166        assert!(diagnostics.is_empty());
2167        let manifest = manifest.unwrap();
2168
2169        // Only base should be in manifest, not prompter
2170        assert_eq!(manifest.dependencies.len(), 1);
2171        assert!(manifest.dependencies.contains_key("base"));
2172        assert!(!manifest.dependencies.contains_key("prompter"));
2173    }
2174
2175    #[test]
2176    fn error_on_duplicate_name_across_sections() {
2177        let toml_str = r#"
2178[dependencies.base]
2179url = "https://github.com/org/base.git"
2180
2181[local-dependencies.base]
2182url = "https://github.com/org/base-local.git"
2183"#;
2184        let config: Config = toml::from_str(toml_str).unwrap();
2185        let local = LocalConfig::default();
2186        let result = merge(config, local);
2187        assert!(result.is_err());
2188        let err = result.unwrap_err().to_string();
2189        assert!(
2190            err.contains("base") && err.contains("both"),
2191            "should reject duplicate name: {err}"
2192        );
2193    }
2194
2195    #[test]
2196    fn local_dependencies_roundtrip() {
2197        let dir = TempDir::new().unwrap();
2198        let original = r#"
2199[dependencies.base]
2200url = "https://github.com/org/base.git"
2201
2202[local-dependencies.prompter]
2203url = "https://github.com/org/prompter.git"
2204skills = ["prompt-helper"]
2205"#;
2206        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2207
2208        let config = load(dir.path()).unwrap();
2209        save(dir.path(), &config).unwrap();
2210        let reloaded = load(dir.path()).unwrap();
2211
2212        assert_eq!(reloaded.dependencies.len(), 1);
2213        assert_eq!(reloaded.local_dependencies.len(), 1);
2214        assert!(reloaded.local_dependencies.contains_key("prompter"));
2215        assert_eq!(
2216            reloaded.local_dependencies["prompter"]
2217                .filter
2218                .skills
2219                .as_deref(),
2220            Some(&["prompt-helper".into()][..])
2221        );
2222    }
2223
2224    #[test]
2225    fn path_with_backslashes_serializes_as_forward_slashes() {
2226        let mut deps = IndexMap::new();
2227        deps.insert(
2228            SourceName::from("test-src"),
2229            InstallDep {
2230                url: None,
2231                path: Some(PathBuf::from("C:\\Users\\dev\\src")),
2232                subpath: None,
2233                version: None,
2234                filter: FilterConfig::default(),
2235            },
2236        );
2237        let config = Config {
2238            dependencies: deps,
2239            ..Config::default()
2240        };
2241        let toml_str = toml::to_string_pretty(&config).unwrap();
2242        assert!(
2243            !toml_str.contains('\\'),
2244            "TOML output must not contain backslashes: {toml_str}"
2245        );
2246        assert!(
2247            toml_str.contains("C:/Users/dev/src"),
2248            "expected forward-slash path in TOML: {toml_str}"
2249        );
2250        let reparsed: Config = toml::from_str(&toml_str).unwrap();
2251        assert_eq!(
2252            reparsed.dependencies["test-src"].path.as_ref().unwrap(),
2253            &PathBuf::from("C:/Users/dev/src"),
2254        );
2255    }
2256
2257    #[test]
2258    fn override_path_serializes_forward_slashes() {
2259        let mut overrides = IndexMap::new();
2260        overrides.insert(
2261            SourceName::from("my-dep"),
2262            OverrideEntry {
2263                path: PathBuf::from("C:\\Users\\dev\\local-pkg"),
2264            },
2265        );
2266        let local = LocalConfig { overrides };
2267        let toml_str = toml::to_string_pretty(&local).unwrap();
2268        assert!(
2269            !toml_str.contains('\\'),
2270            "local config TOML must not contain backslashes: {toml_str}"
2271        );
2272        assert!(
2273            toml_str.contains("C:/Users/dev/local-pkg"),
2274            "expected forward-slash override path: {toml_str}"
2275        );
2276    }
2277}