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