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