Skip to main content

mars_agents/config/
mod.rs

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