Skip to main content

mars_agents/config/
mod.rs

1// qa-validated: harness-order-settings-audit
2
3use std::path::{Path, PathBuf};
4
5use indexmap::IndexMap;
6use serde::ser::SerializeMap;
7use serde::{Deserialize, Serialize};
8
9use crate::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticLevel};
10use crate::dialect::Dialect;
11use crate::error::{ConfigError, MarsError};
12use crate::types::managed_cmd;
13use crate::types::{
14    ItemName, RenameMap, SourceId, SourceName, SourceOrigin, SourceSubpath, SourceUrl,
15};
16
17pub mod layering;
18pub mod migrations;
19pub mod routing_settings;
20pub mod targets;
21
22pub use layering::{
23    merged_settings, overlay_agent_overlays_replace_by_key, overlay_models_replace_by_key,
24    overlay_skills_replace_by_key,
25};
26
27/// Top-level mars.toml configuration.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
29pub struct Config {
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub package: Option<PackageInfo>,
32    #[serde(default)]
33    pub dependencies: IndexMap<SourceName, InstallDep>,
34    /// Local-only dependencies — installed when syncing this repo but NOT
35    /// exported to consumers via manifest. Use for dev tooling, prompt
36    /// authoring helpers, etc.
37    #[serde(
38        default,
39        skip_serializing_if = "IndexMap::is_empty",
40        rename = "local-dependencies"
41    )]
42    pub local_dependencies: IndexMap<SourceName, InstallDep>,
43    #[serde(default)]
44    pub settings: Settings,
45    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
46    pub models: IndexMap<String, crate::models::ModelAlias>,
47    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
48    pub agents: IndexMap<String, AgentOverlay>,
49    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
50    pub skills: IndexMap<String, SkillOverlay>,
51}
52
53/// Package metadata.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub struct PackageInfo {
56    pub name: String,
57    pub version: String,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub description: Option<String>,
60    #[serde(
61        default,
62        rename = "requires-mars",
63        skip_serializing_if = "Option::is_none"
64    )]
65    pub requires_mars: Option<String>,
66    #[serde(
67        default,
68        rename = "requires-meridian",
69        skip_serializing_if = "Option::is_none"
70    )]
71    pub requires_meridian: Option<String>,
72}
73
74mod toml_path_serde {
75    use serde::{Deserialize, Deserializer, Serializer};
76    use std::path::{Path, PathBuf};
77
78    pub fn serialize<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
79    where
80        S: Serializer,
81    {
82        let s = path.to_string_lossy().replace('\\', "/");
83        serializer.serialize_str(&s)
84    }
85
86    pub fn deserialize<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
87    where
88        D: Deserializer<'de>,
89    {
90        let s = String::deserialize(deserializer)?;
91        Ok(PathBuf::from(s))
92    }
93}
94
95mod toml_path_serde_opt {
96    use serde::{Deserialize, Deserializer, Serializer};
97    use std::path::PathBuf;
98
99    pub fn serialize<S>(path: &Option<PathBuf>, serializer: S) -> Result<S::Ok, S::Error>
100    where
101        S: Serializer,
102    {
103        match path {
104            Some(path) => {
105                let s = path.to_string_lossy().replace('\\', "/");
106                serializer.serialize_some(&s)
107            }
108            None => serializer.serialize_none(),
109        }
110    }
111
112    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<PathBuf>, D::Error>
113    where
114        D: Deserializer<'de>,
115    {
116        let s = Option::<String>::deserialize(deserializer)?;
117        Ok(s.map(PathBuf::from))
118    }
119}
120
121/// Consumer install intent — what goes in [dependencies] of a consumer mars.toml.
122/// Has optional URL or path source plus filters for selecting items.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct InstallDep {
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub url: Option<SourceUrl>,
127    #[serde(
128        default,
129        skip_serializing_if = "Option::is_none",
130        with = "toml_path_serde_opt"
131    )]
132    pub path: Option<PathBuf>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub subpath: Option<SourceSubpath>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub version: Option<String>,
137    /// Inbound lift dialect for this dependency (see `crate::dialect::Dialect`).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub dialect: Option<Dialect>,
140    #[serde(flatten)]
141    pub filter: FilterConfig,
142}
143
144/// Backwards-compatible alias during migration.
145pub type DependencyEntry = InstallDep;
146
147/// Package manifest dependency — what a package declares its consumers need.
148/// Supports both URL (for remote consumers) and path (for local development).
149#[derive(Debug, Clone, PartialEq)]
150pub struct ManifestDep {
151    pub url: Option<SourceUrl>,
152    pub path: Option<PathBuf>,
153    pub subpath: Option<SourceSubpath>,
154    pub version: Option<String>,
155    pub filter: FilterConfig,
156}
157
158/// Source-manifest view extracted from mars.toml.
159///
160/// In source repositories, `mars.toml` may include `[package]` +
161/// `[dependencies]` only, or coexist with consumer sections.
162/// Dependencies are ManifestDep (URL or path, matching the source config).
163#[derive(Debug, Clone, PartialEq)]
164pub struct Manifest {
165    pub package: PackageInfo,
166    pub dependencies: IndexMap<String, ManifestDep>,
167    pub models: IndexMap<String, crate::models::ModelAlias>,
168}
169
170/// Shared include/exclude/rename filter configuration for a source.
171#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
172pub struct FilterConfig {
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub agents: Option<Vec<ItemName>>,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub skills: Option<Vec<ItemName>>,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub exclude: Option<Vec<ItemName>>,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub rename: Option<RenameMap>,
181    #[serde(default, skip_serializing_if = "is_false")]
182    pub only_skills: bool,
183    #[serde(default, skip_serializing_if = "is_false")]
184    pub only_agents: bool,
185}
186
187/// Display visibility filter for `mars models list`.
188/// Consumer-only — lives under [settings], not [models].
189#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
190pub struct ModelVisibility {
191    /// Show only aliases matching these glob patterns.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub include: Option<Vec<String>>,
194    /// Hide aliases matching these glob patterns.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub exclude: Option<Vec<String>>,
197}
198
199impl ModelVisibility {
200    pub fn validate(&self) -> Result<(), MarsError> {
201        Ok(())
202    }
203
204    pub fn is_empty(&self) -> bool {
205        self.include.is_none() && self.exclude.is_none()
206    }
207}
208
209/// Structured tool-policy overrides in `[agents.<name>]` (`tools.allowed` / `disallowed`).
210#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
211pub struct AgentOverlayTools {
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub allowed: Vec<String>,
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub disallowed: Vec<String>,
216}
217
218impl AgentOverlayTools {
219    pub fn is_empty(&self) -> bool {
220        self.allowed.is_empty() && self.disallowed.is_empty()
221    }
222}
223
224/// Per-agent launch-bundle overlay policy in mars.toml `[agents.<name>]`.
225#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
226pub struct AgentOverlay {
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub description: Option<String>,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub model: Option<String>,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub harness: Option<String>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub effort: Option<String>,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub approval: Option<String>,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub sandbox: Option<String>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub autocompact: Option<i64>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub autocompact_pct: Option<i64>,
243    #[serde(
244        default,
245        alias = "model-invocable",
246        skip_serializing_if = "Option::is_none"
247    )]
248    pub model_invocable: Option<bool>,
249    #[serde(
250        default,
251        alias = "user-invocable",
252        skip_serializing_if = "Option::is_none"
253    )]
254    pub user_invocable: Option<bool>,
255    #[serde(default, skip_serializing_if = "AgentOverlayTools::is_empty")]
256    pub tools: AgentOverlayTools,
257    #[serde(
258        default,
259        rename = "model-policies",
260        skip_serializing_if = "Vec::is_empty"
261    )]
262    pub model_policies: Vec<ModelPolicyRule>,
263}
264
265/// Per-skill overlay policy in mars.toml `[skills.<name>]`.
266#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
267pub struct SkillOverlay {
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub description: Option<String>,
270    #[serde(
271        default,
272        alias = "model-invocable",
273        skip_serializing_if = "Option::is_none"
274    )]
275    pub model_invocable: Option<bool>,
276    #[serde(
277        default,
278        alias = "user-invocable",
279        skip_serializing_if = "Option::is_none"
280    )]
281    pub user_invocable: Option<bool>,
282    #[serde(default, skip_serializing_if = "AgentOverlayTools::is_empty")]
283    pub tools: AgentOverlayTools,
284}
285
286impl SkillOverlay {
287    pub fn is_empty(&self) -> bool {
288        self.description.is_none()
289            && self.model_invocable.is_none()
290            && self.user_invocable.is_none()
291            && self.tools.is_empty()
292    }
293}
294
295#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
296#[serde(rename_all = "kebab-case")]
297pub enum ModelPolicyMatchType {
298    Model,
299    Alias,
300    ModelGlob,
301}
302
303/// Shared model-policy rule type used by profile frontmatter, agent overlays,
304/// and settings-level model policies.
305#[derive(Debug, Clone, PartialEq)]
306pub struct ModelPolicyRule {
307    pub match_type: ModelPolicyMatchType,
308    pub match_value: String,
309    pub no_fallback: bool,
310    pub overrides: serde_yaml::Mapping,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub enum ModelPolicyRuleParseError {
315    RuleMustBeMapping { found: String },
316    MatchMissing,
317    MatchMustBeMapping { found: String },
318    MatchMustContainExactlyOne { found: String },
319    MatchKeyMustBeString { found: String },
320    UnknownMatchKey { key: String },
321    MatchValueMustBeString { key: String, found: String },
322    MatchValueEmpty { key: String },
323    OverrideMustBeMapping { found: String },
324    NoFallbackMustBeBoolean { found: String },
325}
326
327impl ModelPolicyRuleParseError {
328    fn deserialize_message(&self) -> String {
329        match self {
330            Self::MatchMustContainExactlyOne { .. }
331            | Self::MatchMissing
332            | Self::MatchMustBeMapping { .. } => {
333                "model policy `match` must contain exactly one of model, alias, model-glob"
334                    .to_string()
335            }
336            Self::MatchKeyMustBeString { .. } => {
337                "model policy `match` key must be a string".to_string()
338            }
339            Self::MatchValueMustBeString { .. } => {
340                "model policy `match` value must be a string".to_string()
341            }
342            Self::MatchValueEmpty { .. } => {
343                "model policy `match` value must be a non-empty string".to_string()
344            }
345            Self::UnknownMatchKey { key } => {
346                format!(
347                    "unknown model policy match key `{key}`; expected model, alias, or model-glob"
348                )
349            }
350            Self::OverrideMustBeMapping { .. } => {
351                "model policy `override` must be a mapping".to_string()
352            }
353            Self::NoFallbackMustBeBoolean { .. } => {
354                "model policy `no-fallback` must be a boolean".to_string()
355            }
356            Self::RuleMustBeMapping { .. } => "model policy rule must be a mapping".to_string(),
357        }
358    }
359}
360
361pub fn parse_model_policy_rule_value(
362    value: &serde_yaml::Value,
363) -> Result<ModelPolicyRule, ModelPolicyRuleParseError> {
364    let rule = value
365        .as_mapping()
366        .ok_or_else(|| ModelPolicyRuleParseError::RuleMustBeMapping {
367            found: format!("{value:?}"),
368        })?;
369
370    let match_value = rule.get(serde_yaml::Value::String("match".to_string()));
371    let match_mapping = match match_value {
372        Some(value) => {
373            value
374                .as_mapping()
375                .ok_or_else(|| ModelPolicyRuleParseError::MatchMustBeMapping {
376                    found: format!("{value:?}"),
377                })?
378        }
379        None => return Err(ModelPolicyRuleParseError::MatchMissing),
380    };
381
382    let mut entries = match_mapping.iter();
383    let Some((match_key, match_value)) = entries.next() else {
384        return Err(ModelPolicyRuleParseError::MatchMustContainExactlyOne {
385            found: format!("{match_mapping:?}"),
386        });
387    };
388    if entries.next().is_some() {
389        return Err(ModelPolicyRuleParseError::MatchMustContainExactlyOne {
390            found: format!("{match_mapping:?}"),
391        });
392    }
393
394    let key =
395        match_key
396            .as_str()
397            .ok_or_else(|| ModelPolicyRuleParseError::MatchKeyMustBeString {
398                found: format!("{match_key:?}"),
399            })?;
400    let value =
401        match_value
402            .as_str()
403            .ok_or_else(|| ModelPolicyRuleParseError::MatchValueMustBeString {
404                key: key.to_string(),
405                found: format!("{match_value:?}"),
406            })?;
407    let match_value = value.trim().to_string();
408    if match_value.is_empty() {
409        return Err(ModelPolicyRuleParseError::MatchValueEmpty {
410            key: key.to_string(),
411        });
412    }
413
414    let match_type = match key {
415        "model" => ModelPolicyMatchType::Model,
416        "alias" => ModelPolicyMatchType::Alias,
417        "model-glob" => ModelPolicyMatchType::ModelGlob,
418        _ => {
419            return Err(ModelPolicyRuleParseError::UnknownMatchKey {
420                key: key.to_string(),
421            });
422        }
423    };
424
425    let overrides = match rule.get(serde_yaml::Value::String("override".to_string())) {
426        None | Some(serde_yaml::Value::Null) => serde_yaml::Mapping::new(),
427        Some(value) => value.as_mapping().cloned().ok_or_else(|| {
428            ModelPolicyRuleParseError::OverrideMustBeMapping {
429                found: format!("{value:?}"),
430            }
431        })?,
432    };
433
434    let no_fallback = match rule.get(serde_yaml::Value::String("no-fallback".to_string())) {
435        None | Some(serde_yaml::Value::Null) => false,
436        Some(serde_yaml::Value::Bool(value)) => *value,
437        Some(value) => {
438            return Err(ModelPolicyRuleParseError::NoFallbackMustBeBoolean {
439                found: format!("{value:?}"),
440            });
441        }
442    };
443
444    Ok(ModelPolicyRule {
445        match_type,
446        match_value,
447        no_fallback,
448        overrides,
449    })
450}
451
452impl<'de> Deserialize<'de> for ModelPolicyRule {
453    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
454    where
455        D: serde::Deserializer<'de>,
456    {
457        let value = serde_yaml::Value::deserialize(deserializer)?;
458        parse_model_policy_rule_value(&value)
459            .map_err(|err| serde::de::Error::custom(err.deserialize_message()))
460    }
461}
462
463impl Serialize for ModelPolicyRule {
464    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
465    where
466        S: serde::Serializer,
467    {
468        let mut map = serializer.serialize_map(None)?;
469        let match_key = match self.match_type {
470            ModelPolicyMatchType::Model => "model",
471            ModelPolicyMatchType::Alias => "alias",
472            ModelPolicyMatchType::ModelGlob => "model-glob",
473        };
474
475        let mut match_clause = serde_yaml::Mapping::new();
476        match_clause.insert(
477            serde_yaml::Value::String(match_key.to_string()),
478            serde_yaml::Value::String(self.match_value.clone()),
479        );
480        map.serialize_entry("match", &match_clause)?;
481        if !self.overrides.is_empty() {
482            map.serialize_entry("override", &self.overrides)?;
483        }
484        if self.no_fallback {
485            map.serialize_entry("no-fallback", &self.no_fallback)?;
486        }
487        map.end()
488    }
489}
490
491fn is_false(v: &bool) -> bool {
492    !v
493}
494
495/// Dev override config (mars.local.toml).
496///
497/// Gitignored — each developer can work with local checkouts while
498/// production config points at git.
499#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
500pub struct LocalConfig {
501    #[serde(default)]
502    pub overrides: IndexMap<SourceName, OverrideEntry>,
503    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
504    pub models: IndexMap<String, crate::models::ModelAlias>,
505    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
506    pub agents: IndexMap<String, AgentOverlay>,
507    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
508    pub skills: IndexMap<String, SkillOverlay>,
509    #[serde(default, skip_serializing_if = "LocalSettings::is_empty")]
510    pub settings: LocalSettings,
511}
512
513#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
514#[serde(deny_unknown_fields)]
515pub struct LocalSettings {
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub managed_root: Option<String>,
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub targets: Option<Vec<String>>,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub model_visibility: Option<LocalModelVisibility>,
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub models_cache_ttl_hours: Option<u32>,
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub min_mars_version: Option<String>,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub default_harness: Option<String>,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub default_model: Option<String>,
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub harness_order: Option<Vec<String>>,
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub provider_order: Option<Vec<String>>,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub agent_emission: Option<AgentEmission>,
536    #[serde(default, skip_serializing_if = "MeridianSettings::is_empty")]
537    pub meridian: MeridianSettings,
538    #[serde(default, rename = "model-policies")]
539    pub model_policies: Option<Vec<ModelPolicyRule>>,
540}
541
542#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
543#[serde(deny_unknown_fields)]
544pub struct LocalModelVisibility {
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub include: Option<Vec<String>>,
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub exclude: Option<Vec<String>>,
549}
550
551/// Dev override — local path swap for a git source.
552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
553pub struct OverrideEntry {
554    #[serde(with = "toml_path_serde")]
555    pub path: PathBuf,
556}
557
558/// Global settings — extensible via additional fields.
559#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
560pub struct Settings {
561    /// Custom managed output directory (e.g. ".claude").
562    ///
563    /// When unset, mars no longer creates a generic `.agents` target by default;
564    /// `.mars/` is the canonical compiled store and native emission is handled
565    /// by target-specific compiler paths.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub managed_root: Option<String>,
568    /// Managed target directories materialized from .mars/ canonical store.
569    /// When set, only listed targets are populated. When unset, `managed_root`
570    /// is used for backwards compatibility; otherwise no target-sync targets
571    /// are enabled by default.
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub targets: Option<Vec<String>>,
574    #[serde(default, skip_serializing_if = "ModelVisibility::is_empty")]
575    pub model_visibility: ModelVisibility,
576    #[serde(default = "default_models_cache_ttl_hours")]
577    pub models_cache_ttl_hours: u32,
578    /// Minimum mars binary version required to use this project.
579    /// Old binary + new package with this set → compatibility error.
580    /// New binary + old package without this set → succeeds with defaults.
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub min_mars_version: Option<String>,
583    /// Default harness for launch routing when profile/alias/provider cannot resolve one.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub default_harness: Option<String>,
586    /// Project-wide default model token when no CLI override or profile model is set.
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub default_model: Option<String>,
589    /// Ordered harness preference for launch-bundle candidate selection.
590    ///
591    /// When set, replaces built-in provider preference ordering for candidate
592    /// selection. First installed candidate wins. When unset, Mars uses the
593    /// built-in default (`claude`, `pi`, `codex`, `opencode`, `cursor`).
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub harness_order: Option<Vec<String>>,
596    /// Ordered provider preference for model-first routing tie-breaks.
597    ///
598    /// Optional soft preference used only after model-name matching. Empty means
599    /// preserve harness-reported model order.
600    #[serde(default, skip_serializing_if = "Option::is_none")]
601    pub provider_order: Option<Vec<String>>,
602    /// Controls whether harness-bound agents are emitted to native harness dirs.
603    ///
604    /// `auto` (the default when unset) emits for standalone mars syncs and
605    /// suppresses native agent artifacts when Meridian invokes mars with
606    /// `MERIDIAN_MANAGED=1`.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub agent_emission: Option<AgentEmission>,
609    #[serde(default, skip_serializing_if = "MeridianSettings::is_empty")]
610    pub meridian: MeridianSettings,
611    #[serde(
612        default,
613        rename = "model-policies",
614        skip_serializing_if = "Vec::is_empty"
615    )]
616    pub model_policies: Vec<ModelPolicyRule>,
617}
618
619/// Selective native agent emission under managed mode or `agent_emission = "never"`.
620#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
621#[serde(deny_unknown_fields)]
622pub struct AgentCopyConfig {
623    #[serde(default)]
624    pub harnesses: Vec<String>,
625    #[serde(default)]
626    pub include_fanout: bool,
627    /// Deprecated: use `[settings.meridian.fanout].agents` instead. Parsed only to emit a migration warning.
628    #[serde(
629        default,
630        rename = "fanout_agents",
631        skip_serializing_if = "Vec::is_empty"
632    )]
633    pub deprecated_fanout_agents: Vec<String>,
634}
635
636/// Fanout agent list for native emission qualification and dual inventory listing.
637#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
638#[serde(deny_unknown_fields)]
639pub struct FanoutConfig {
640    #[serde(default)]
641    pub agents: Vec<String>,
642}
643
644/// Meridian-managed settings nested under `[settings.meridian]`.
645#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
646#[serde(deny_unknown_fields)]
647pub struct MeridianSettings {
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub agent_copy: Option<AgentCopyConfig>,
650    #[serde(default, skip_serializing_if = "Option::is_none")]
651    pub fanout: Option<FanoutConfig>,
652}
653
654impl MeridianSettings {
655    pub fn is_empty(&self) -> bool {
656        self.agent_copy.is_none() && self.fanout.is_none()
657    }
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
661#[serde(rename_all = "lowercase")]
662pub enum AgentEmission {
663    Auto,
664    Always,
665    Never,
666}
667
668impl Default for Settings {
669    fn default() -> Self {
670        Self {
671            managed_root: None,
672            targets: None,
673            model_visibility: ModelVisibility::default(),
674            models_cache_ttl_hours: default_models_cache_ttl_hours(),
675            min_mars_version: None,
676            default_harness: None,
677            default_model: None,
678            harness_order: None,
679            provider_order: None,
680            agent_emission: None,
681            meridian: MeridianSettings::default(),
682            model_policies: Vec::new(),
683        }
684    }
685}
686
687fn default_models_cache_ttl_hours() -> u32 {
688    24
689}
690
691impl Settings {
692    pub fn effective_links(&self) -> targets::EffectiveLinks {
693        targets::effective_links(self.targets.as_deref(), self.managed_root.as_ref())
694    }
695
696    /// Returns the effective list of managed target directories.
697    ///
698    /// - If `targets` is explicitly set, returns those targets normalized through
699    ///   the link migration boundary.
700    /// - If `targets` is unset, uses normalized `managed_root` for backwards compatibility.
701    /// - If neither is set, returns no target-sync targets; `.mars/` remains
702    ///   the canonical compiled store.
703    pub fn managed_targets(&self) -> Vec<String> {
704        self.effective_links().managed_targets()
705    }
706
707    /// Returns known harness intents from configured links. Generic targets are ignored.
708    pub fn linked_harnesses(&self) -> Vec<String> {
709        self.effective_links()
710            .linked_harnesses()
711            .into_iter()
712            .map(|harness| harness.to_string())
713            .collect()
714    }
715
716    /// Selective native agent copy config from `[settings.meridian.agent_copy]`.
717    pub fn meridian_agent_copy(&self) -> Option<&AgentCopyConfig> {
718        self.meridian.agent_copy.as_ref()
719    }
720
721    /// Fanout agent list from `[settings.meridian.fanout]`.
722    pub fn meridian_fanout_agents(&self) -> &[String] {
723        self.meridian
724            .fanout
725            .as_ref()
726            .map(|f| f.agents.as_slice())
727            .unwrap_or(&[])
728    }
729}
730
731/// Resolved source specification after merging config and overrides.
732#[derive(Debug, Clone)]
733pub enum SourceSpec {
734    Git(GitSpec),
735    Path(PathBuf),
736}
737
738/// Git source specification preserved when overrides are active.
739#[derive(Debug, Clone)]
740pub struct GitSpec {
741    pub url: SourceUrl,
742    pub version: Option<String>,
743}
744
745/// How items are filtered from a source.
746#[derive(Debug, Clone, PartialEq, Eq)]
747pub enum FilterMode {
748    /// Install everything from the source.
749    All,
750    /// Only install specific agents and/or skills.
751    Include {
752        agents: Vec<ItemName>,
753        skills: Vec<ItemName>,
754    },
755    /// Install everything except these items.
756    Exclude(Vec<ItemName>),
757    /// Install only skills, no agents.
758    OnlySkills,
759    /// Install only agents plus their transitive skill dependencies.
760    OnlyAgents,
761}
762
763/// Effective configuration after merging mars.toml and mars.local.toml.
764///
765/// This is what the rest of the pipeline operates on.
766#[derive(Debug, Clone)]
767pub struct EffectiveConfig {
768    pub dependencies: IndexMap<SourceName, EffectiveDependency>,
769    pub settings: Settings,
770    /// Skill overlays from mars.toml / mars.local.toml (carried for staging).
771    pub skills: IndexMap<String, SkillOverlay>,
772}
773
774/// Layered project-level config used by routing and policy consumers.
775///
776/// Precedence: project config < project-local overrides.
777#[derive(Debug, Clone, Default)]
778pub struct EffectiveProjectConfig {
779    pub settings: Settings,
780    pub models: IndexMap<String, crate::models::ModelAlias>,
781    pub agents: IndexMap<String, AgentOverlay>,
782}
783
784#[derive(Debug, Clone)]
785pub(crate) struct LoadedProjectConfig {
786    pub(crate) config: Config,
787    pub(crate) local: LocalConfig,
788    pub(crate) effective: EffectiveProjectConfig,
789}
790
791/// A dependency's declared configuration and effective source after local overrides.
792#[derive(Debug, Clone)]
793pub struct EffectiveDependency {
794    /// Identity from the dependency entry in `mars.toml`, before local overrides.
795    pub declared_source_id: SourceId,
796    /// Identity used to fetch and resolve the dependency.
797    pub source_id: SourceId,
798    pub spec: SourceSpec,
799    pub subpath: Option<SourceSubpath>,
800    pub filter: FilterMode,
801    pub rename: RenameMap,
802    pub dialect: Option<Dialect>,
803}
804
805const CONFIG_FILE: &str = "mars.toml";
806const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
807
808/// Load mars.toml from the given root directory.
809pub fn load(root: &Path) -> Result<Config, MarsError> {
810    let path = root.join(CONFIG_FILE);
811    let content = std::fs::read_to_string(&path).map_err(|e| {
812        if e.kind() == std::io::ErrorKind::NotFound {
813            ConfigError::NotFound { path: path.clone() }
814        } else {
815            ConfigError::Io(e)
816        }
817    })?;
818    let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
819    migrate_legacy_source_urls(&mut config);
820    Ok(config)
821}
822
823/// Load source manifest data from mars.toml in a source tree root.
824///
825/// Returns `None` when mars.toml is absent or when it has no `[package]`
826/// section (consumer config only).
827///
828/// Converts `InstallDep` entries to `ManifestDep`, preserving both URL and
829/// path dependencies.
830pub fn load_manifest(source_root: &Path) -> Result<(Option<Manifest>, Vec<Diagnostic>), MarsError> {
831    let path = source_root.join(CONFIG_FILE);
832    let diagnostics = Vec::new();
833    match std::fs::read_to_string(&path) {
834        Ok(content) => {
835            let parsed: Config =
836                toml::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
837                    message: format!("failed to parse {}: {e}", path.display()),
838                })?;
839            let Some(package) = parsed.package else {
840                return Ok((None, diagnostics));
841            };
842            // Convert InstallDep → ManifestDep, preserving both URL and path deps
843            let deps: IndexMap<String, ManifestDep> = parsed
844                .dependencies
845                .into_iter()
846                .map(|(name, entry)| {
847                    (
848                        name.to_string(),
849                        ManifestDep {
850                            url: entry.url,
851                            path: entry.path,
852                            subpath: entry.subpath,
853                            version: entry.version,
854                            filter: entry.filter,
855                        },
856                    )
857                })
858                .collect();
859            Ok((
860                Some(Manifest {
861                    package,
862                    dependencies: deps,
863                    models: parsed.models,
864                }),
865                diagnostics,
866            ))
867        }
868        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((None, diagnostics)),
869        Err(source) => Err(MarsError::Io {
870            operation: "read manifest config".to_string(),
871            path,
872            source,
873        }),
874    }
875}
876
877/// Load mars.local.toml (returns Default if absent).
878pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
879    let path = root.join(LOCAL_CONFIG_FILE);
880    match std::fs::read_to_string(&path) {
881        Ok(content) => {
882            let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
883            Ok(local)
884        }
885        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
886        Err(e) => Err(ConfigError::Io(e).into()),
887    }
888}
889
890/// Apply mars.local.toml model overlays as replace-by-key entries.
891pub fn merged_models(
892    base: &IndexMap<String, crate::models::ModelAlias>,
893    local: &LocalConfig,
894) -> IndexMap<String, crate::models::ModelAlias> {
895    overlay_models_replace_by_key(base, local)
896}
897
898/// Apply mars.local.toml agent overlays as replace-by-key entries.
899pub fn merged_agent_overlays(
900    base: &IndexMap<String, AgentOverlay>,
901    local: &LocalConfig,
902) -> IndexMap<String, AgentOverlay> {
903    overlay_agent_overlays_replace_by_key(base, local)
904}
905
906/// Base model precedence for the overlay+profile layer shared by native emission
907/// and launch-bundle: `overlay.model` wins over `profile.model`. Settings-level
908/// defaults (e.g. `settings.default_model`) are layered on by launch-bundle only.
909pub fn overlay_then_profile_model<'a>(
910    overlay: Option<&'a AgentOverlay>,
911    profile_model: Option<&'a str>,
912) -> Option<&'a str> {
913    overlay
914        .and_then(|entry| entry.model.as_deref())
915        .or(profile_model)
916}
917
918/// Model-policy precedence for the overlay+profile layer: overlay policies first,
919/// then profile policies. Each item carries `is_overlay` (its source layer) and the
920/// index within that layer's own list, so callers can index back into the originating
921/// slice. Launch-bundle appends `settings.model_policies` after this iterator; native
922/// emission consumes it directly (settings policies stay launch-bundle/spawn-only).
923pub fn overlay_then_profile_policies<'a>(
924    overlay: Option<&'a AgentOverlay>,
925    profile_policies: &'a [ModelPolicyRule],
926) -> impl Iterator<Item = (bool, usize, &'a ModelPolicyRule)> + 'a {
927    overlay
928        .into_iter()
929        .flat_map(|entry| {
930            entry
931                .model_policies
932                .iter()
933                .enumerate()
934                .map(|(index, rule)| (true, index, rule))
935        })
936        .chain(
937            profile_policies
938                .iter()
939                .enumerate()
940                .map(|(index, rule)| (false, index, rule)),
941        )
942}
943
944pub fn load_config_with_local(root: &Path) -> Result<(Config, LocalConfig), MarsError> {
945    let config = load(root)?;
946    let local = load_local(root)?;
947    Ok((config, local))
948}
949
950fn effective_project_config(config: &Config, local: &LocalConfig) -> EffectiveProjectConfig {
951    EffectiveProjectConfig {
952        settings: merged_settings(&config.settings, local),
953        models: overlay_models_replace_by_key(&config.models, local),
954        agents: overlay_agent_overlays_replace_by_key(&config.agents, local),
955    }
956}
957
958pub fn load_effective_project_config(root: &Path) -> Result<EffectiveProjectConfig, MarsError> {
959    Ok(load_project_config_layers(root)?.effective)
960}
961
962pub(crate) fn load_project_config_layers(root: &Path) -> Result<LoadedProjectConfig, MarsError> {
963    let (config, local) = load_config_with_local(root)?;
964    let effective = effective_project_config(&config, &local);
965    Ok(LoadedProjectConfig {
966        config,
967        local,
968        effective,
969    })
970}
971
972/// Merge config + local overrides into EffectiveConfig.
973///
974/// Validates:
975/// - Each source has `url` XOR `path` (not both, not neither)
976/// - Each source uses either include filters (`agents`/`skills`) or `exclude`, not both
977/// - Collects diagnostics if an override references a source name not in config
978pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
979    let (effective, _diagnostics) = merge_with_root(config, local, Path::new("."))?;
980    Ok(effective)
981}
982
983/// Same as `merge`, but uses an explicit root for path-based SourceId canonicalization.
984pub fn merge_with_root(
985    config: Config,
986    local: LocalConfig,
987    root: &Path,
988) -> Result<(EffectiveConfig, Vec<Diagnostic>), MarsError> {
989    let merged_settings = merged_settings(&config.settings, &local);
990    merged_settings.model_visibility.validate()?;
991    let mut dependencies = IndexMap::new();
992    let mut diagnostics = Vec::new();
993    let local_source_name = SourceOrigin::LocalPackage.to_string();
994
995    diagnostics.extend(deprecated_agents_target_diagnostics(&merged_settings));
996
997    // Process both regular and local dependencies into the same effective map.
998    // Local deps are installed locally but not exported to consumers via manifest.
999    let all_deps = config
1000        .dependencies
1001        .iter()
1002        .chain(config.local_dependencies.iter());
1003
1004    for (name, entry) in all_deps {
1005        // Reject reserved name
1006        if name.as_ref() == local_source_name.as_str() {
1007            return Err(ConfigError::Invalid {
1008                message: "dependency name `_self` is reserved for local package items".into(),
1009            }
1010            .into());
1011        }
1012
1013        // Reject duplicate names across sections
1014        if dependencies.contains_key(name) {
1015            return Err(ConfigError::Invalid {
1016                message: format!(
1017                    "dependency `{name}` appears in both [dependencies] and [local-dependencies]"
1018                ),
1019            }
1020            .into());
1021        }
1022
1023        // Validate url XOR path
1024        let base_spec = match (&entry.url, &entry.path) {
1025            (Some(url), None) => SourceSpec::Git(GitSpec {
1026                url: url.clone(),
1027                version: entry.version.clone(),
1028            }),
1029            (None, Some(path)) => SourceSpec::Path(path.clone()),
1030            (Some(_), Some(_)) => {
1031                return Err(ConfigError::Invalid {
1032                    message: format!("source `{name}` has both `url` and `path` — pick one"),
1033                }
1034                .into());
1035            }
1036            (None, None) => {
1037                return Err(ConfigError::Invalid {
1038                    message: format!(
1039                        "source `{name}` has neither `url` nor `path` — one is required"
1040                    ),
1041                }
1042                .into());
1043            }
1044        };
1045
1046        // Validate filter combinations
1047        validate_filter(&entry.filter, name.as_ref())?;
1048
1049        let filter = entry.filter.to_mode();
1050
1051        let rename = entry.filter.rename.clone().unwrap_or_default();
1052
1053        let subpath = entry.subpath.clone();
1054        let declared_source_id = source_id_for_spec(root, &base_spec, subpath.clone());
1055
1056        // Check if this source has a local override
1057        let spec = if let Some(ov) = local.overrides.get(name) {
1058            SourceSpec::Path(ov.path.clone())
1059        } else {
1060            base_spec
1061        };
1062        let dialect = entry.dialect;
1063        let effective_source_id = source_id_for_spec(root, &spec, subpath.clone());
1064
1065        dependencies.insert(
1066            name.clone(),
1067            EffectiveDependency {
1068                declared_source_id,
1069                source_id: effective_source_id,
1070                spec,
1071                subpath,
1072                filter,
1073                rename,
1074                dialect,
1075            },
1076        );
1077    }
1078
1079    let skills = overlay_skills_replace_by_key(&config.skills, &local.skills);
1080
1081    Ok((
1082        EffectiveConfig {
1083            dependencies,
1084            settings: merged_settings,
1085            skills,
1086        },
1087        diagnostics,
1088    ))
1089}
1090
1091fn deprecated_agents_target_diagnostics(settings: &Settings) -> Vec<Diagnostic> {
1092    let mut diagnostics = Vec::new();
1093
1094    if settings.managed_root.as_deref() == Some(".agents") {
1095        diagnostics.push(deprecated_agents_target_diagnostic("settings.managed_root"));
1096    }
1097
1098    if settings
1099        .targets
1100        .as_ref()
1101        .is_some_and(|targets| targets.iter().any(|target| target == ".agents"))
1102    {
1103        diagnostics.push(deprecated_agents_target_diagnostic("settings.targets"));
1104    }
1105
1106    diagnostics
1107}
1108
1109fn deprecated_agents_target_diagnostic(context: &str) -> Diagnostic {
1110    Diagnostic {
1111        level: DiagnosticLevel::Warning,
1112        code: "deprecated-agents-target",
1113        message: format!(
1114            "`.agents` is a deprecated link target. Run `{}` to remove it. Skills are now emitted to native harness dirs automatically.",
1115            managed_cmd("mars unlink .agents"),
1116        ),
1117        context: Some(context.to_string()),
1118        category: Some(DiagnosticCategory::Compatibility),
1119    }
1120}
1121
1122/// Validate filter configuration for consistency.
1123///
1124/// Rejects invalid combinations:
1125/// - `only_skills` and `only_agents` together
1126/// - category-only flags with include lists
1127/// - category-only flags with exclude
1128/// - include lists with exclude
1129pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
1130    let has_include = filter.agents.is_some() || filter.skills.is_some();
1131    let has_exclude = filter.exclude.is_some();
1132    let has_category = filter.only_skills || filter.only_agents;
1133
1134    if filter.only_skills && filter.only_agents {
1135        return Err(ConfigError::Invalid {
1136            message: format!(
1137                "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
1138            ),
1139        }
1140        .into());
1141    }
1142    if has_category && has_include {
1143        return Err(ConfigError::Invalid {
1144            message: format!(
1145                "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
1146            ),
1147        }
1148        .into());
1149    }
1150    if has_category && has_exclude {
1151        return Err(ConfigError::Invalid {
1152            message: format!(
1153                "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
1154            ),
1155        }
1156        .into());
1157    }
1158    if has_include && has_exclude {
1159        return Err(ConfigError::ConflictingFilters {
1160            name: dep_name.to_string(),
1161        }
1162        .into());
1163    }
1164    Ok(())
1165}
1166
1167impl FilterConfig {
1168    /// Convert to the resolved FilterMode enum.
1169    pub fn to_mode(&self) -> FilterMode {
1170        if self.only_skills {
1171            FilterMode::OnlySkills
1172        } else if self.only_agents {
1173            FilterMode::OnlyAgents
1174        } else if self.agents.is_some() || self.skills.is_some() {
1175            FilterMode::Include {
1176                agents: self.agents.clone().unwrap_or_default(),
1177                skills: self.skills.clone().unwrap_or_default(),
1178            }
1179        } else if self.exclude.is_some() {
1180            FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
1181        } else {
1182            FilterMode::All
1183        }
1184    }
1185
1186    /// Returns true if any filter field is set (not default).
1187    pub fn has_any_filter(&self) -> bool {
1188        self.agents.is_some()
1189            || self.skills.is_some()
1190            || self.exclude.is_some()
1191            || self.only_skills
1192            || self.only_agents
1193    }
1194}
1195
1196fn source_id_for_spec(root: &Path, spec: &SourceSpec, subpath: Option<SourceSubpath>) -> SourceId {
1197    match spec {
1198        SourceSpec::Git(git) => {
1199            let canonical_url = SourceUrl::from(crate::source::canonical::canonicalize_git_url(
1200                git.url.as_ref(),
1201            ));
1202            SourceId::git_with_subpath(canonical_url, subpath.clone())
1203        }
1204        SourceSpec::Path(path) => match SourceId::path_with_subpath(root, path, subpath.clone()) {
1205            Ok(id) => id,
1206            Err(_) => {
1207                let canonical = if path.is_absolute() {
1208                    path.clone()
1209                } else {
1210                    root.join(path)
1211                };
1212                SourceId::Path { canonical, subpath }
1213            }
1214        },
1215    }
1216}
1217
1218fn migrate_legacy_source_urls(config: &mut Config) {
1219    for dep in config
1220        .dependencies
1221        .values_mut()
1222        .chain(config.local_dependencies.values_mut())
1223    {
1224        if let Some(url) = dep.url.as_mut() {
1225            let raw = url.as_str();
1226            if should_upgrade_legacy_git_url(raw) {
1227                *url = SourceUrl::from(format!("https://{raw}"));
1228            }
1229        }
1230    }
1231}
1232
1233fn should_upgrade_legacy_git_url(url: &str) -> bool {
1234    !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
1235}
1236
1237/// Write mars.toml atomically.
1238pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
1239    let path = root.join(CONFIG_FILE);
1240    let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
1241        message: format!("failed to serialize config: {e}"),
1242    })?;
1243    let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
1244        message: format!("refusing to save config: serialized output failed to parse: {e}"),
1245    })?;
1246    validate_save_roundtrip(config, &reparsed)?;
1247    crate::fs::atomic_write(&path, content.as_bytes())
1248}
1249
1250fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
1251    if reparsed.dependencies.len() != original.dependencies.len() {
1252        return Err(ConfigError::Invalid {
1253            message: format!(
1254                "refusing to save config: dependency count changed during roundtrip ({} -> {})",
1255                original.dependencies.len(),
1256                reparsed.dependencies.len()
1257            ),
1258        }
1259        .into());
1260    }
1261
1262    if reparsed.local_dependencies.len() != original.local_dependencies.len() {
1263        return Err(ConfigError::Invalid {
1264            message: format!(
1265                "refusing to save config: local-dependencies count changed during roundtrip ({} -> {})",
1266                original.local_dependencies.len(),
1267                reparsed.local_dependencies.len()
1268            ),
1269        }
1270        .into());
1271    }
1272
1273    if reparsed.settings.managed_root != original.settings.managed_root {
1274        return Err(ConfigError::Invalid {
1275            message: format!(
1276                "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
1277                original.settings.managed_root, reparsed.settings.managed_root
1278            ),
1279        }
1280        .into());
1281    }
1282    if reparsed.settings.model_visibility != original.settings.model_visibility {
1283        return Err(ConfigError::Invalid {
1284            message: format!(
1285                "refusing to save config: settings.model_visibility changed during roundtrip ({:?} -> {:?})",
1286                original.settings.model_visibility, reparsed.settings.model_visibility
1287            ),
1288        }
1289        .into());
1290    }
1291    if reparsed.settings.default_harness != original.settings.default_harness {
1292        return Err(ConfigError::Invalid {
1293            message: format!(
1294                "refusing to save config: settings.default_harness changed during roundtrip ({:?} -> {:?})",
1295                original.settings.default_harness, reparsed.settings.default_harness
1296            ),
1297        }
1298        .into());
1299    }
1300    if reparsed.settings.default_model != original.settings.default_model {
1301        return Err(ConfigError::Invalid {
1302            message: format!(
1303                "refusing to save config: settings.default_model changed during roundtrip ({:?} -> {:?})",
1304                original.settings.default_model, reparsed.settings.default_model
1305            ),
1306        }
1307        .into());
1308    }
1309    if reparsed.settings.harness_order != original.settings.harness_order {
1310        return Err(ConfigError::Invalid {
1311            message: format!(
1312                "refusing to save config: settings.harness_order changed during roundtrip ({:?} -> {:?})",
1313                original.settings.harness_order, reparsed.settings.harness_order
1314            ),
1315        }
1316        .into());
1317    }
1318    if reparsed.settings.provider_order != original.settings.provider_order {
1319        return Err(ConfigError::Invalid {
1320            message: format!(
1321                "refusing to save config: settings.provider_order changed during roundtrip ({:?} -> {:?})",
1322                original.settings.provider_order, reparsed.settings.provider_order
1323            ),
1324        }
1325        .into());
1326    }
1327    if reparsed.settings.agent_emission != original.settings.agent_emission {
1328        return Err(ConfigError::Invalid {
1329            message: format!(
1330                "refusing to save config: settings.agent_emission changed during roundtrip ({:?} -> {:?})",
1331                original.settings.agent_emission, reparsed.settings.agent_emission
1332            ),
1333        }
1334        .into());
1335    }
1336    if reparsed.settings.meridian.agent_copy != original.settings.meridian.agent_copy {
1337        return Err(ConfigError::Invalid {
1338            message: format!(
1339                "refusing to save config: settings.meridian.agent_copy changed during roundtrip ({:?} -> {:?})",
1340                original.settings.meridian.agent_copy, reparsed.settings.meridian.agent_copy
1341            ),
1342        }
1343        .into());
1344    }
1345    if reparsed.settings.meridian.fanout != original.settings.meridian.fanout {
1346        return Err(ConfigError::Invalid {
1347            message: format!(
1348                "refusing to save config: settings.meridian.fanout changed during roundtrip ({:?} -> {:?})",
1349                original.settings.meridian.fanout, reparsed.settings.meridian.fanout
1350            ),
1351        }
1352        .into());
1353    }
1354    if reparsed.settings.model_policies != original.settings.model_policies {
1355        return Err(ConfigError::Invalid {
1356            message: "refusing to save config: settings.model_policies changed during roundtrip"
1357                .to_string(),
1358        }
1359        .into());
1360    }
1361    if reparsed.agents != original.agents {
1362        return Err(ConfigError::Invalid {
1363            message: "refusing to save config: agents changed during roundtrip".to_string(),
1364        }
1365        .into());
1366    }
1367
1368    for (name, dep) in &original.dependencies {
1369        let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
1370            return Err(ConfigError::Invalid {
1371                message: format!(
1372                    "refusing to save config: dependency `{name}` missing after roundtrip"
1373                ),
1374            }
1375            .into());
1376        };
1377
1378        if reparsed_dep != dep {
1379            return Err(ConfigError::Invalid {
1380                message: format!(
1381                    "refusing to save config: dependency `{name}` changed during roundtrip"
1382                ),
1383            }
1384            .into());
1385        }
1386    }
1387
1388    for (name, dep) in &original.local_dependencies {
1389        let Some(reparsed_dep) = reparsed.local_dependencies.get(name) else {
1390            return Err(ConfigError::Invalid {
1391                message: format!(
1392                    "refusing to save config: local-dependency `{name}` missing after roundtrip"
1393                ),
1394            }
1395            .into());
1396        };
1397
1398        if reparsed_dep != dep {
1399            return Err(ConfigError::Invalid {
1400                message: format!(
1401                    "refusing to save config: local-dependency `{name}` changed during roundtrip"
1402                ),
1403            }
1404            .into());
1405        }
1406    }
1407
1408    Ok(())
1409}
1410
1411/// Write mars.local.toml atomically.
1412pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
1413    let path = root.join(LOCAL_CONFIG_FILE);
1414    let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
1415        message: format!("failed to serialize local config: {e}"),
1416    })?;
1417    crate::fs::atomic_write(&path, content.as_bytes())
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422    use super::*;
1423    use crate::models::{ModelAlias, ModelSpec};
1424    use tempfile::TempDir;
1425
1426    #[test]
1427    fn parse_git_dependency() {
1428        let toml_str = r#"
1429[dependencies.base]
1430url = "https://github.com/org/base.git"
1431version = "v1.0"
1432"#;
1433        let config: Config = toml::from_str(toml_str).unwrap();
1434        assert_eq!(config.dependencies.len(), 1);
1435        let entry = &config.dependencies["base"];
1436        assert_eq!(
1437            entry.url.as_deref(),
1438            Some("https://github.com/org/base.git")
1439        );
1440        assert!(entry.path.is_none());
1441        assert_eq!(entry.version.as_deref(), Some("v1.0"));
1442    }
1443
1444    #[test]
1445    fn parse_path_dependency() {
1446        let toml_str = r#"
1447[dependencies.local]
1448path = "../my-agents"
1449"#;
1450        let config: Config = toml::from_str(toml_str).unwrap();
1451        let entry = &config.dependencies["local"];
1452        assert!(entry.url.is_none());
1453        assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
1454    }
1455
1456    #[test]
1457    fn parse_mixed_dependencies() {
1458        let toml_str = r#"
1459[dependencies.remote]
1460url = "https://github.com/org/remote.git"
1461version = "v2.0"
1462agents = ["coder", "reviewer"]
1463
1464[dependencies.local]
1465path = "/home/dev/agents"
1466exclude = ["experimental"]
1467"#;
1468        let config: Config = toml::from_str(toml_str).unwrap();
1469        assert_eq!(config.dependencies.len(), 2);
1470        assert!(config.dependencies.contains_key("remote"));
1471        assert!(config.dependencies.contains_key("local"));
1472    }
1473
1474    #[test]
1475    fn parse_package_and_dependencies_coexist() {
1476        let toml_str = r#"
1477[package]
1478name = "my-agents"
1479version = "0.1.0"
1480
1481[dependencies.base]
1482url = "https://github.com/org/base.git"
1483version = ">=1.0.0"
1484
1485[dependencies.local]
1486path = "../local-agents"
1487"#;
1488        let config: Config = toml::from_str(toml_str).unwrap();
1489        assert!(config.package.is_some());
1490        assert!(config.dependencies.contains_key("base"));
1491        assert!(config.dependencies.contains_key("local"));
1492    }
1493
1494    #[test]
1495    fn parse_include_filter() {
1496        let toml_str = r#"
1497[dependencies.base]
1498url = "https://github.com/org/base.git"
1499agents = ["coder"]
1500skills = ["review"]
1501"#;
1502        let config: Config = toml::from_str(toml_str).unwrap();
1503        let local = LocalConfig::default();
1504        let effective = merge(config, local).unwrap();
1505        let source = &effective.dependencies["base"];
1506        match &source.filter {
1507            FilterMode::Include { agents, skills } => {
1508                assert_eq!(agents, &["coder"]);
1509                assert_eq!(skills, &["review"]);
1510            }
1511            other => panic!("expected Include, got {other:?}"),
1512        }
1513    }
1514
1515    #[test]
1516    fn parse_exclude_filter() {
1517        let toml_str = r#"
1518[dependencies.base]
1519url = "https://github.com/org/base.git"
1520exclude = ["experimental", "deprecated"]
1521"#;
1522        let config: Config = toml::from_str(toml_str).unwrap();
1523        let local = LocalConfig::default();
1524        let effective = merge(config, local).unwrap();
1525        let source = &effective.dependencies["base"];
1526        match &source.filter {
1527            FilterMode::Exclude(items) => {
1528                assert_eq!(items, &["experimental", "deprecated"]);
1529            }
1530            other => panic!("expected Exclude, got {other:?}"),
1531        }
1532    }
1533
1534    #[test]
1535    fn error_on_both_include_and_exclude() {
1536        let toml_str = r#"
1537[dependencies.bad]
1538url = "https://github.com/org/bad.git"
1539agents = ["coder"]
1540exclude = ["reviewer"]
1541"#;
1542        let config: Config = toml::from_str(toml_str).unwrap();
1543        let local = LocalConfig::default();
1544        let result = merge(config, local);
1545        assert!(result.is_err());
1546        let err = result.unwrap_err().to_string();
1547        assert!(
1548            err.contains("bad"),
1549            "error should mention dependency name: {err}"
1550        );
1551    }
1552
1553    #[test]
1554    fn error_on_neither_url_nor_path() {
1555        let toml_str = r#"
1556[dependencies.empty]
1557version = "v1.0"
1558"#;
1559        let config: Config = toml::from_str(toml_str).unwrap();
1560        let local = LocalConfig::default();
1561        let result = merge(config, local);
1562        assert!(result.is_err());
1563        let err = result.unwrap_err().to_string();
1564        assert!(
1565            err.contains("neither"),
1566            "error should mention 'neither': {err}"
1567        );
1568    }
1569
1570    #[test]
1571    fn error_on_both_url_and_path() {
1572        let toml_str = r#"
1573[dependencies.both]
1574url = "https://github.com/org/repo.git"
1575path = "/local/path"
1576"#;
1577        let config: Config = toml::from_str(toml_str).unwrap();
1578        let local = LocalConfig::default();
1579        let result = merge(config, local);
1580        assert!(result.is_err());
1581        let err = result.unwrap_err().to_string();
1582        assert!(err.contains("both"), "error should mention 'both': {err}");
1583    }
1584
1585    #[test]
1586    fn roundtrip_full_config_shape_survives_save() {
1587        let dir = TempDir::new().unwrap();
1588        let original = r#"
1589[package]
1590name = "sample"
1591version = "0.1.0"
1592description = "sample package"
1593
1594[dependencies.base]
1595url = "https://github.com/org/base.git"
1596version = "v1.0"
1597agents = ["coder", "reviewer"]
1598
1599[dependencies.local]
1600path = "../local-agents"
1601exclude = ["experimental"]
1602
1603[settings]
1604managed_root = ".custom-agents"
1605targets = [".claude", ".cursor"]
1606harness_order = ["pi", "opencode", "codex"]
1607"#;
1608        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1609
1610        let config = load(dir.path()).unwrap();
1611        save(dir.path(), &config).unwrap();
1612        let reloaded = load(dir.path()).unwrap();
1613
1614        assert_eq!(
1615            reloaded.package.as_ref().map(|p| p.name.as_str()),
1616            Some("sample")
1617        );
1618        assert_eq!(reloaded.dependencies.len(), 2);
1619        assert_eq!(
1620            reloaded.dependencies["base"].url.as_deref(),
1621            Some("https://github.com/org/base.git")
1622        );
1623        assert_eq!(
1624            reloaded.dependencies["local"].path.as_deref(),
1625            Some(Path::new("../local-agents"))
1626        );
1627        assert_eq!(
1628            reloaded.settings.managed_root.as_deref(),
1629            Some(".custom-agents")
1630        );
1631        assert_eq!(
1632            reloaded.settings.targets,
1633            Some(vec![".claude".to_string(), ".cursor".to_string()])
1634        );
1635        assert_eq!(
1636            reloaded.settings.harness_order,
1637            Some(vec![
1638                "pi".to_string(),
1639                "opencode".to_string(),
1640                "codex".to_string()
1641            ])
1642        );
1643    }
1644
1645    #[test]
1646    fn load_from_disk() {
1647        let dir = TempDir::new().unwrap();
1648        let toml_str = r#"
1649[dependencies.base]
1650url = "https://github.com/org/base.git"
1651version = "v1.0"
1652"#;
1653        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1654        let config = load(dir.path()).unwrap();
1655        assert_eq!(config.dependencies.len(), 1);
1656    }
1657
1658    #[test]
1659    fn load_migrates_legacy_bare_domain_url() {
1660        let dir = TempDir::new().unwrap();
1661        let toml_str = r#"
1662[dependencies.base]
1663url = "github.com/org/base"
1664"#;
1665        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1666
1667        let config = load(dir.path()).unwrap();
1668        assert_eq!(
1669            config.dependencies["base"].url.as_deref(),
1670            Some("https://github.com/org/base")
1671        );
1672    }
1673
1674    #[test]
1675    fn load_does_not_migrate_ssh_url() {
1676        let dir = TempDir::new().unwrap();
1677        let toml_str = r#"
1678[dependencies.base]
1679url = "git@github.com:org/base.git"
1680"#;
1681        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1682
1683        let config = load(dir.path()).unwrap();
1684        assert_eq!(
1685            config.dependencies["base"].url.as_deref(),
1686            Some("git@github.com:org/base.git")
1687        );
1688    }
1689
1690    #[test]
1691    fn load_missing_file_returns_not_found() {
1692        let dir = TempDir::new().unwrap();
1693        let result = load(dir.path());
1694        assert!(result.is_err());
1695        let err = result.unwrap_err().to_string();
1696        assert!(err.contains("not found"), "should be NotFound: {err}");
1697    }
1698
1699    #[test]
1700    fn load_manifest_returns_none_without_package() {
1701        let dir = TempDir::new().unwrap();
1702        std::fs::write(
1703            dir.path().join("mars.toml"),
1704            r#"
1705[dependencies.base]
1706url = "https://github.com/org/base.git"
1707"#,
1708        )
1709        .unwrap();
1710
1711        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1712        assert!(diagnostics.is_empty());
1713        assert!(manifest.is_none());
1714    }
1715
1716    #[test]
1717    fn load_manifest_returns_package_and_dependencies() {
1718        let dir = TempDir::new().unwrap();
1719        std::fs::write(
1720            dir.path().join("mars.toml"),
1721            r#"
1722[package]
1723name = "pkg"
1724version = "1.2.3"
1725
1726[dependencies.base]
1727url = "https://github.com/org/base.git"
1728version = ">=1.0.0"
1729skills = ["frontend-design"]
1730"#,
1731        )
1732        .unwrap();
1733
1734        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1735        assert!(diagnostics.is_empty());
1736        let manifest = manifest.unwrap();
1737        assert_eq!(manifest.package.name, "pkg");
1738        assert_eq!(manifest.package.version, "1.2.3");
1739        assert!(manifest.dependencies.contains_key("base"));
1740        assert_eq!(
1741            manifest.dependencies["base"].filter.skills.as_deref(),
1742            Some(&[ItemName::from("frontend-design")][..])
1743        );
1744    }
1745
1746    #[test]
1747    fn load_manifest_io_error_includes_operation_and_path() {
1748        let dir = TempDir::new().unwrap();
1749        let config_path = dir.path().join("mars.toml");
1750        std::fs::create_dir(&config_path).unwrap();
1751
1752        let err = load_manifest(dir.path()).unwrap_err();
1753        let msg = err.to_string();
1754
1755        assert!(
1756            msg.contains("read manifest config"),
1757            "error should include operation context: {msg}"
1758        );
1759        assert!(
1760            msg.contains("mars.toml"),
1761            "error should include config path: {msg}"
1762        );
1763    }
1764
1765    #[test]
1766    fn load_local_missing_returns_default() {
1767        let dir = TempDir::new().unwrap();
1768        let local = load_local(dir.path()).unwrap();
1769        assert!(local.overrides.is_empty());
1770    }
1771
1772    #[test]
1773    fn load_local_from_disk() {
1774        let dir = TempDir::new().unwrap();
1775        let toml_str = r#"
1776[overrides.base]
1777path = "/home/dev/local-base"
1778"#;
1779        std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
1780        let local = load_local(dir.path()).unwrap();
1781        assert_eq!(local.overrides.len(), 1);
1782        assert_eq!(
1783            local.overrides["base"].path,
1784            PathBuf::from("/home/dev/local-base")
1785        );
1786    }
1787
1788    #[test]
1789    fn parse_agent_overlay_and_settings_model_policies() {
1790        let config: Config = toml::from_str(
1791            r#"
1792[agents.tech-lead]
1793description = "Tech lead overlay"
1794model = "gpt55"
1795harness = "codex"
1796effort = "medium"
1797approval = "default"
1798sandbox = "default"
1799autocompact = 1200
1800autocompact_pct = 80
1801user_invocable = false
1802model-invocable = true
1803tools.disallowed = ["Bash(rm *)", "Write"]
1804
1805[[agents.tech-lead.model-policies]]
1806match = { alias = "gpt55" }
1807override = { harness = "opencode", effort = "low" }
1808no-fallback = true
1809
1810[settings]
1811
1812[[settings.model-policies]]
1813match = { model-glob = "gpt-*" }
1814override = { effort = "high" }
1815"#,
1816        )
1817        .unwrap();
1818
1819        let overlay = config.agents.get("tech-lead").expect("tech-lead overlay");
1820        assert_eq!(overlay.description.as_deref(), Some("Tech lead overlay"));
1821        assert_eq!(overlay.model.as_deref(), Some("gpt55"));
1822        assert_eq!(overlay.harness.as_deref(), Some("codex"));
1823        assert_eq!(overlay.autocompact, Some(1200));
1824        assert_eq!(overlay.autocompact_pct, Some(80));
1825        assert_eq!(overlay.user_invocable, Some(false));
1826        assert_eq!(overlay.model_invocable, Some(true));
1827        assert_eq!(
1828            overlay.tools.disallowed,
1829            vec!["Bash(rm *)".to_string(), "Write".to_string()]
1830        );
1831        assert_eq!(overlay.model_policies.len(), 1);
1832        assert_eq!(
1833            overlay.model_policies[0].match_type,
1834            ModelPolicyMatchType::Alias
1835        );
1836        assert_eq!(overlay.model_policies[0].match_value, "gpt55");
1837        assert!(overlay.model_policies[0].no_fallback);
1838
1839        assert_eq!(config.settings.model_policies.len(), 1);
1840        assert_eq!(
1841            config.settings.model_policies[0].match_type,
1842            ModelPolicyMatchType::ModelGlob
1843        );
1844        assert_eq!(config.settings.model_policies[0].match_value, "gpt-*");
1845    }
1846
1847    #[test]
1848    fn parse_skill_overlay_tools_and_invocability() {
1849        let config: Config = toml::from_str(
1850            r#"
1851[skills.planning]
1852description = "Planning overlay"
1853user_invocable = false
1854model-invocable = true
1855tools.disallowed = ["Agent", "Write"]
1856tools.allowed = ["Bash(git *)", "mcp(plugin:demo)"]
1857"#,
1858        )
1859        .unwrap();
1860
1861        let overlay = config.skills.get("planning").expect("planning overlay");
1862        assert_eq!(overlay.description.as_deref(), Some("Planning overlay"));
1863        assert_eq!(overlay.user_invocable, Some(false));
1864        assert_eq!(overlay.model_invocable, Some(true));
1865        assert_eq!(
1866            overlay.tools.disallowed,
1867            vec!["Agent".to_string(), "Write".to_string()]
1868        );
1869        assert_eq!(
1870            overlay.tools.allowed,
1871            vec!["Bash(git *)".to_string(), "mcp(plugin:demo)".to_string()]
1872        );
1873    }
1874
1875    #[test]
1876    fn merged_skill_overlays_local_replaces_fields() {
1877        let mut base_skills = IndexMap::new();
1878        base_skills.insert(
1879            "planning".to_string(),
1880            SkillOverlay {
1881                description: Some("Base description".to_string()),
1882                user_invocable: Some(true),
1883                ..SkillOverlay::default()
1884            },
1885        );
1886
1887        let mut local_skills = IndexMap::new();
1888        local_skills.insert(
1889            "planning".to_string(),
1890            SkillOverlay {
1891                description: Some("Local description".to_string()),
1892                user_invocable: Some(false),
1893                ..SkillOverlay::default()
1894            },
1895        );
1896        let local = LocalConfig {
1897            skills: local_skills,
1898            ..LocalConfig::default()
1899        };
1900
1901        let merged = overlay_skills_replace_by_key(&base_skills, &local.skills);
1902        let replaced = merged.get("planning").expect("planning overlay");
1903        assert_eq!(replaced.description.as_deref(), Some("Local description"));
1904        assert_eq!(replaced.user_invocable, Some(false));
1905    }
1906
1907    #[test]
1908    fn merged_agent_overlays_local_replaces_widened_fields() {
1909        let mut base_agents = IndexMap::new();
1910        base_agents.insert(
1911            "worker".to_string(),
1912            AgentOverlay {
1913                description: Some("Base description".to_string()),
1914                user_invocable: Some(true),
1915                ..AgentOverlay::default()
1916            },
1917        );
1918
1919        let mut local_agents = IndexMap::new();
1920        local_agents.insert(
1921            "worker".to_string(),
1922            AgentOverlay {
1923                description: Some("Local description".to_string()),
1924                user_invocable: Some(false),
1925                ..AgentOverlay::default()
1926            },
1927        );
1928        let local = LocalConfig {
1929            agents: local_agents,
1930            ..LocalConfig::default()
1931        };
1932
1933        let merged = merged_agent_overlays(&base_agents, &local);
1934        let replaced = merged.get("worker").expect("worker overlay");
1935        assert_eq!(replaced.description.as_deref(), Some("Local description"));
1936        assert_eq!(replaced.user_invocable, Some(false));
1937    }
1938
1939    #[test]
1940    fn merged_agent_overlays_replace_by_agent_name() {
1941        let mut base_agents = IndexMap::new();
1942        base_agents.insert(
1943            "tech-lead".to_string(),
1944            AgentOverlay {
1945                model: Some("gpt55".to_string()),
1946                harness: Some("codex".to_string()),
1947                effort: Some("high".to_string()),
1948                ..AgentOverlay::default()
1949            },
1950        );
1951        base_agents.insert(
1952            "reviewer".to_string(),
1953            AgentOverlay {
1954                model: Some("gpt-5.4-mini".to_string()),
1955                ..AgentOverlay::default()
1956            },
1957        );
1958
1959        let mut local_agents = IndexMap::new();
1960        local_agents.insert(
1961            "tech-lead".to_string(),
1962            AgentOverlay {
1963                model: Some("gptmini".to_string()),
1964                ..AgentOverlay::default()
1965            },
1966        );
1967        let local = LocalConfig {
1968            agents: local_agents,
1969            ..LocalConfig::default()
1970        };
1971
1972        let merged = merged_agent_overlays(&base_agents, &local);
1973        let replaced = merged.get("tech-lead").expect("tech-lead should exist");
1974        assert_eq!(replaced.model.as_deref(), Some("gptmini"));
1975        assert!(
1976            replaced.harness.is_none(),
1977            "local overlay must replace the base overlay block"
1978        );
1979        assert!(
1980            replaced.effort.is_none(),
1981            "local overlay replacement must not deep-merge base fields"
1982        );
1983        assert_eq!(
1984            merged
1985                .get("reviewer")
1986                .and_then(|overlay| overlay.model.as_deref()),
1987            Some("gpt-5.4-mini")
1988        );
1989    }
1990
1991    #[test]
1992    fn merged_settings_applies_local_routing_overrides() {
1993        let settings = Settings {
1994            default_harness: Some("claude".to_string()),
1995            harness_order: Some(vec!["claude".to_string(), "pi".to_string()]),
1996            provider_order: Some(vec!["anthropic".to_string()]),
1997            ..Settings::default()
1998        };
1999        let local = LocalConfig {
2000            settings: LocalSettings {
2001                default_harness: Some("cursor".to_string()),
2002                default_model: Some("gpt-5".to_string()),
2003                harness_order: Some(vec!["cursor".to_string(), "pi".to_string()]),
2004                provider_order: Some(vec!["openai".to_string()]),
2005                model_policies: None,
2006                ..LocalSettings::default()
2007            },
2008            ..LocalConfig::default()
2009        };
2010
2011        let merged = merged_settings(&settings, &local);
2012        assert_eq!(merged.default_harness.as_deref(), Some("cursor"));
2013        assert_eq!(merged.default_model.as_deref(), Some("gpt-5"));
2014        assert_eq!(
2015            merged.harness_order,
2016            Some(vec!["cursor".to_string(), "pi".to_string()])
2017        );
2018        assert_eq!(merged.provider_order, Some(vec!["openai".to_string()]));
2019    }
2020
2021    #[test]
2022    fn merged_settings_overlays_model_visibility_keys_independently() {
2023        let settings = Settings {
2024            model_visibility: ModelVisibility {
2025                include: Some(vec!["openai/*".to_string()]),
2026                exclude: Some(vec!["*-preview".to_string()]),
2027            },
2028            ..Settings::default()
2029        };
2030        let local = LocalConfig {
2031            settings: LocalSettings {
2032                model_visibility: Some(LocalModelVisibility {
2033                    include: Some(vec!["anthropic/*".to_string()]),
2034                    exclude: None,
2035                }),
2036                ..LocalSettings::default()
2037            },
2038            ..LocalConfig::default()
2039        };
2040
2041        let merged = merged_settings(&settings, &local);
2042        assert_eq!(
2043            merged.model_visibility.include,
2044            Some(vec!["anthropic/*".to_string()])
2045        );
2046        assert_eq!(
2047            merged.model_visibility.exclude,
2048            Some(vec!["*-preview".to_string()])
2049        );
2050    }
2051
2052    #[test]
2053    fn merged_settings_replaces_scalar_table_and_array_fields() {
2054        let base_rule = ModelPolicyRule {
2055            match_type: ModelPolicyMatchType::Alias,
2056            match_value: "gpt55".to_string(),
2057            no_fallback: false,
2058            overrides: serde_yaml::Mapping::new(),
2059        };
2060        let local_rule = ModelPolicyRule {
2061            match_type: ModelPolicyMatchType::Alias,
2062            match_value: "gptmini".to_string(),
2063            no_fallback: false,
2064            overrides: serde_yaml::Mapping::new(),
2065        };
2066        let settings = Settings {
2067            targets: Some(vec![".claude".to_string(), ".codex".to_string()]),
2068            model_visibility: ModelVisibility {
2069                include: Some(vec!["anthropic/*".to_string()]),
2070                exclude: None,
2071            },
2072            models_cache_ttl_hours: 24,
2073            min_mars_version: Some("0.1.0".to_string()),
2074            model_policies: vec![base_rule],
2075            ..Settings::default()
2076        };
2077        let local = LocalConfig {
2078            settings: LocalSettings {
2079                targets: Some(vec![".cursor".to_string()]),
2080                model_visibility: Some(LocalModelVisibility {
2081                    include: None,
2082                    exclude: Some(vec!["*-preview*".to_string()]),
2083                }),
2084                models_cache_ttl_hours: Some(48),
2085                min_mars_version: Some("0.2.0".to_string()),
2086                model_policies: Some(vec![local_rule.clone()]),
2087                ..LocalSettings::default()
2088            },
2089            ..LocalConfig::default()
2090        };
2091
2092        let merged = merged_settings(&settings, &local);
2093        assert_eq!(merged.targets, Some(vec![".cursor".to_string()]));
2094        assert_eq!(merged.models_cache_ttl_hours, 48);
2095        assert_eq!(merged.min_mars_version.as_deref(), Some("0.2.0"));
2096        assert_eq!(
2097            merged.model_visibility.exclude,
2098            Some(vec!["*-preview*".to_string()])
2099        );
2100        assert_eq!(
2101            merged.model_visibility.include,
2102            Some(vec!["anthropic/*".to_string()])
2103        );
2104        assert_eq!(merged.model_policies, vec![local_rule]);
2105    }
2106
2107    #[test]
2108    fn merged_models_local_overlay_replaces_by_key_and_inherits_others() {
2109        let mut base = IndexMap::new();
2110        base.insert(
2111            "fast".to_string(),
2112            ModelAlias {
2113                harness: Some("codex".to_string()),
2114                description: Some("base".to_string()),
2115                prompting: None,
2116                default_effort: None,
2117                autocompact: None,
2118                autocompact_pct: None,
2119                spec: ModelSpec::Pinned {
2120                    model: "gpt-5".to_string(),
2121                    provider: None,
2122                },
2123            },
2124        );
2125        base.insert(
2126            "legacy".to_string(),
2127            ModelAlias {
2128                harness: Some("claude".to_string()),
2129                description: None,
2130                prompting: None,
2131                default_effort: None,
2132                autocompact: None,
2133                autocompact_pct: None,
2134                spec: ModelSpec::Pinned {
2135                    model: "claude-opus-4-6".to_string(),
2136                    provider: None,
2137                },
2138            },
2139        );
2140
2141        let local = LocalConfig {
2142            models: {
2143                let mut m = IndexMap::new();
2144                m.insert(
2145                    "fast".to_string(),
2146                    ModelAlias {
2147                        harness: Some("cursor".to_string()),
2148                        description: Some("local".to_string()),
2149                        prompting: None,
2150                        default_effort: None,
2151                        autocompact: None,
2152                        autocompact_pct: None,
2153                        spec: ModelSpec::Pinned {
2154                            model: "gpt-5.4-mini".to_string(),
2155                            provider: None,
2156                        },
2157                    },
2158                );
2159                m.insert(
2160                    "local-only".to_string(),
2161                    ModelAlias {
2162                        harness: Some("pi".to_string()),
2163                        description: None,
2164                        prompting: None,
2165                        default_effort: None,
2166                        autocompact: None,
2167                        autocompact_pct: None,
2168                        spec: ModelSpec::Pinned {
2169                            model: "gpt-5.5".to_string(),
2170                            provider: None,
2171                        },
2172                    },
2173                );
2174                m
2175            },
2176            ..LocalConfig::default()
2177        };
2178
2179        let merged = merged_models(&base, &local);
2180        assert_eq!(merged.len(), 3);
2181        assert_eq!(merged["fast"].harness.as_deref(), Some("cursor"));
2182        assert_eq!(
2183            merged["fast"].description.as_deref(),
2184            Some("local"),
2185            "local value should replace by key"
2186        );
2187        assert_eq!(merged["legacy"].harness.as_deref(), Some("claude"));
2188        assert_eq!(merged["local-only"].harness.as_deref(), Some("pi"));
2189    }
2190
2191    #[test]
2192    fn load_local_rejects_unknown_local_settings_fields() {
2193        let dir = TempDir::new().unwrap();
2194        std::fs::write(
2195            dir.path().join("mars.local.toml"),
2196            "[settings]\nnot_supported = true\n",
2197        )
2198        .unwrap();
2199
2200        let err = load_local(dir.path()).unwrap_err().to_string();
2201        assert!(err.contains("unknown field"), "unexpected error: {err}");
2202        assert!(err.contains("not_supported"), "unexpected error: {err}");
2203    }
2204
2205    #[test]
2206    fn load_local_rejects_unknown_local_model_visibility_fields() {
2207        let dir = TempDir::new().unwrap();
2208        std::fs::write(
2209            dir.path().join("mars.local.toml"),
2210            "[settings.model_visibility]\nfuture_nested_key = true\n",
2211        )
2212        .unwrap();
2213
2214        let err = load_local(dir.path()).unwrap_err().to_string();
2215        assert!(err.contains("unknown field"), "unexpected error: {err}");
2216        assert!(err.contains("future_nested_key"), "unexpected error: {err}");
2217    }
2218
2219    #[test]
2220    fn load_effective_project_config_accepts_unknown_project_settings_keys() {
2221        let dir = TempDir::new().unwrap();
2222        std::fs::write(
2223            dir.path().join("mars.toml"),
2224            r#"[settings]
2225harness_order = ["codex", "pi"]
2226future_key = "allowed"
2227
2228[settings.model_visibility]
2229include = ["openai/*"]
2230future_nested_key = true
2231"#,
2232        )
2233        .unwrap();
2234
2235        let effective = load_effective_project_config(dir.path())
2236            .expect("project settings overlay should ignore unknown keys");
2237        assert_eq!(
2238            effective.settings.harness_order,
2239            Some(vec!["codex".to_string(), "pi".to_string()])
2240        );
2241        assert_eq!(
2242            effective.settings.model_visibility.include,
2243            Some(vec!["openai/*".to_string()])
2244        );
2245    }
2246
2247    #[test]
2248    fn merge_with_empty_local() {
2249        let config = Config {
2250            dependencies: {
2251                let mut m = IndexMap::new();
2252                m.insert(
2253                    "base".into(),
2254                    DependencyEntry {
2255                        url: Some("https://github.com/org/base.git".into()),
2256                        path: None,
2257                        subpath: None,
2258                        version: Some("v1.0".into()),
2259                        dialect: None,
2260                        filter: FilterConfig::default(),
2261                    },
2262                );
2263                m
2264            },
2265            settings: Settings::default(),
2266            ..Config::default()
2267        };
2268        let local = LocalConfig::default();
2269        let effective = merge(config, local).unwrap();
2270        assert_eq!(effective.dependencies.len(), 1);
2271        let source = &effective.dependencies["base"];
2272        match &source.spec {
2273            SourceSpec::Git(git) => {
2274                assert_eq!(git.url, "https://github.com/org/base.git");
2275                assert_eq!(git.version.as_deref(), Some("v1.0"));
2276            }
2277            SourceSpec::Path(_) => panic!("expected Git"),
2278        }
2279    }
2280
2281    #[test]
2282    fn merge_override_replaces_with_path() {
2283        let config = Config {
2284            dependencies: {
2285                let mut m = IndexMap::new();
2286                m.insert(
2287                    "base".into(),
2288                    DependencyEntry {
2289                        url: Some("https://github.com/org/base.git".into()),
2290                        path: None,
2291                        subpath: None,
2292                        version: Some("v1.0".into()),
2293                        dialect: None,
2294                        filter: FilterConfig::default(),
2295                    },
2296                );
2297                m
2298            },
2299            settings: Settings::default(),
2300            ..Config::default()
2301        };
2302        let local = LocalConfig {
2303            overrides: {
2304                let mut m = IndexMap::new();
2305                m.insert(
2306                    "base".into(),
2307                    OverrideEntry {
2308                        path: PathBuf::from("/home/dev/local-base"),
2309                    },
2310                );
2311                m
2312            },
2313            ..LocalConfig::default()
2314        };
2315        let effective = merge(config, local).unwrap();
2316        let source = &effective.dependencies["base"];
2317        match &source.spec {
2318            SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
2319            SourceSpec::Git(_) => panic!("expected Path override"),
2320        }
2321    }
2322
2323    #[test]
2324    fn merge_override_retains_subpath_coordinate() {
2325        let temp = TempDir::new().unwrap();
2326        // Canonicalize temp root once to avoid Windows 8.3 short-name mismatches
2327        let temp_root = dunce::canonicalize(temp.path()).unwrap();
2328        let override_path = temp_root.join("local-base");
2329        std::fs::create_dir_all(&override_path).unwrap();
2330        let canonical_override = dunce::canonicalize(&override_path).unwrap();
2331
2332        let config = Config {
2333            dependencies: {
2334                let mut m = IndexMap::new();
2335                m.insert(
2336                    "base".into(),
2337                    DependencyEntry {
2338                        url: Some("https://github.com/org/base.git".into()),
2339                        path: None,
2340                        subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
2341                        version: Some("v1.0".into()),
2342                        dialect: None,
2343                        filter: FilterConfig::default(),
2344                    },
2345                );
2346                m
2347            },
2348            settings: Settings::default(),
2349            ..Config::default()
2350        };
2351        let local = LocalConfig {
2352            overrides: {
2353                let mut m = IndexMap::new();
2354                m.insert(
2355                    "base".into(),
2356                    OverrideEntry {
2357                        path: canonical_override.clone(),
2358                    },
2359                );
2360                m
2361            },
2362            ..LocalConfig::default()
2363        };
2364
2365        let (effective, _) = merge_with_root(config, local, &temp_root).unwrap();
2366        let source = &effective.dependencies["base"];
2367        assert_eq!(
2368            source.subpath.as_ref().map(SourceSubpath::as_str),
2369            Some("plugins/foo")
2370        );
2371        assert!(matches!(&source.spec, SourceSpec::Path(p) if p == &canonical_override));
2372        assert!(matches!(
2373            &source.source_id,
2374            SourceId::Path {
2375                canonical,
2376                subpath: Some(sp)
2377            } if canonical == &canonical_override && sp.as_str() == "plugins/foo"
2378        ));
2379    }
2380
2381    #[test]
2382    fn merge_all_filter_mode() {
2383        let config = Config {
2384            dependencies: {
2385                let mut m = IndexMap::new();
2386                m.insert(
2387                    "base".into(),
2388                    DependencyEntry {
2389                        url: Some("https://github.com/org/base.git".into()),
2390                        path: None,
2391                        subpath: None,
2392                        version: None,
2393                        dialect: None,
2394                        filter: FilterConfig::default(),
2395                    },
2396                );
2397                m
2398            },
2399            settings: Settings::default(),
2400            ..Config::default()
2401        };
2402        let effective = merge(config, LocalConfig::default()).unwrap();
2403        assert!(matches!(
2404            effective.dependencies["base"].filter,
2405            FilterMode::All
2406        ));
2407    }
2408
2409    #[test]
2410    fn save_and_reload() {
2411        let dir = TempDir::new().unwrap();
2412        let config = Config {
2413            dependencies: {
2414                let mut m = IndexMap::new();
2415                m.insert(
2416                    "base".into(),
2417                    DependencyEntry {
2418                        url: Some("https://github.com/org/base.git".into()),
2419                        path: None,
2420                        subpath: None,
2421                        version: Some("v2.0".into()),
2422                        dialect: None,
2423                        filter: FilterConfig::default(),
2424                    },
2425                );
2426                m
2427            },
2428            settings: Settings::default(),
2429            ..Config::default()
2430        };
2431        save(dir.path(), &config).unwrap();
2432        let reloaded = load(dir.path()).unwrap();
2433        assert_eq!(config, reloaded);
2434    }
2435
2436    #[test]
2437    fn rename_map_preserved() {
2438        let toml_str = r#"
2439[dependencies.base]
2440url = "https://github.com/org/base.git"
2441
2442[dependencies.base.rename]
2443old-name = "new-name"
2444"#;
2445        let config: Config = toml::from_str(toml_str).unwrap();
2446        let effective = merge(config, LocalConfig::default()).unwrap();
2447        let source = &effective.dependencies["base"];
2448        assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
2449    }
2450
2451    #[test]
2452    fn self_dependency_name_rejected() {
2453        let toml_str = r#"
2454[dependencies._self]
2455url = "https://github.com/org/base.git"
2456"#;
2457        let config: Config = toml::from_str(toml_str).unwrap();
2458        let local = LocalConfig::default();
2459        let result = merge(config, local);
2460        assert!(result.is_err());
2461        let err = result.unwrap_err().to_string();
2462        assert!(
2463            err.contains("_self") && err.contains("reserved"),
2464            "should reject _self: {err}"
2465        );
2466    }
2467
2468    #[test]
2469    fn managed_root_setting_roundtrip() {
2470        let config = Config {
2471            settings: Settings {
2472                managed_root: Some(".claude".into()),
2473                targets: None,
2474                ..Settings::default()
2475            },
2476            ..Config::default()
2477        };
2478        let serialized = toml::to_string_pretty(&config).unwrap();
2479        let deserialized: Config = toml::from_str(&serialized).unwrap();
2480        assert_eq!(
2481            deserialized.settings.managed_root.as_deref(),
2482            Some(".claude")
2483        );
2484    }
2485
2486    #[test]
2487    fn save_preserves_dependencies_when_clearing_last_target() {
2488        let dir = TempDir::new().unwrap();
2489        let original = r#"
2490[package]
2491name = "sample"
2492version = "0.1.0"
2493
2494[dependencies.base]
2495url = "https://github.com/org/base.git"
2496version = "v1.0"
2497agents = ["coder"]
2498
2499[settings]
2500managed_root = ".agents"
2501targets = [".claude"]
2502"#;
2503        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2504
2505        let mut config = load(dir.path()).unwrap();
2506        if let Some(targets) = config.settings.targets.as_mut() {
2507            targets.retain(|target| target != ".claude");
2508            if targets.is_empty() {
2509                config.settings.targets = None;
2510            }
2511        }
2512        save(dir.path(), &config).unwrap();
2513
2514        let reloaded = load(dir.path()).unwrap();
2515        assert_eq!(
2516            reloaded.package.as_ref().map(|p| p.name.as_str()),
2517            Some("sample")
2518        );
2519        assert_eq!(
2520            reloaded.dependencies["base"].url.as_deref(),
2521            Some("https://github.com/org/base.git")
2522        );
2523        assert_eq!(
2524            reloaded.dependencies["base"].version.as_deref(),
2525            Some("v1.0")
2526        );
2527        assert_eq!(
2528            reloaded.dependencies["base"].filter.agents.as_deref(),
2529            Some(&["coder".into()][..])
2530        );
2531        assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
2532        assert!(reloaded.settings.targets.is_none());
2533    }
2534
2535    #[test]
2536    fn roundtrip_preserves_all_filter_fields() {
2537        let dir = TempDir::new().unwrap();
2538        let original = r#"
2539[dependencies.include]
2540url = "https://github.com/org/include.git"
2541agents = ["coder", "reviewer"]
2542skills = ["review", "plan"]
2543
2544[dependencies.include.rename]
2545coder = "core-coder"
2546
2547[dependencies.exclude]
2548url = "https://github.com/org/exclude.git"
2549exclude = ["experimental", "deprecated"]
2550
2551[dependencies.only_skills]
2552url = "https://github.com/org/skills.git"
2553only_skills = true
2554
2555[dependencies.only_agents]
2556url = "https://github.com/org/agents.git"
2557only_agents = true
2558"#;
2559        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2560
2561        let config = load(dir.path()).unwrap();
2562        save(dir.path(), &config).unwrap();
2563        let reloaded = load(dir.path()).unwrap();
2564
2565        let include = &reloaded.dependencies["include"].filter;
2566        assert_eq!(
2567            include.agents.as_deref(),
2568            Some(&["coder".into(), "reviewer".into()][..])
2569        );
2570        assert_eq!(
2571            include.skills.as_deref(),
2572            Some(&["review".into(), "plan".into()][..])
2573        );
2574        assert_eq!(
2575            include.rename.as_ref().and_then(|r| r.get("coder")),
2576            Some(&"core-coder".into())
2577        );
2578
2579        let exclude = &reloaded.dependencies["exclude"].filter;
2580        assert_eq!(
2581            exclude.exclude.as_deref(),
2582            Some(&["experimental".into(), "deprecated".into()][..])
2583        );
2584
2585        let only_skills = &reloaded.dependencies["only_skills"].filter;
2586        assert!(only_skills.only_skills);
2587        assert!(!only_skills.only_agents);
2588
2589        let only_agents = &reloaded.dependencies["only_agents"].filter;
2590        assert!(only_agents.only_agents);
2591        assert!(!only_agents.only_skills);
2592    }
2593
2594    #[test]
2595    fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
2596        let dir = TempDir::new().unwrap();
2597        let original = r#"
2598[dependencies.git-include]
2599url = "https://github.com/org/git-include.git"
2600agents = ["coder"]
2601
2602[dependencies.path-exclude]
2603path = "../local-source"
2604exclude = ["draft"]
2605
2606[dependencies.git-only-skills]
2607url = "https://github.com/org/git-skills.git"
2608only_skills = true
2609
2610[dependencies.git-only-agents]
2611url = "https://github.com/org/git-agents.git"
2612only_agents = true
2613"#;
2614        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2615
2616        let config = load(dir.path()).unwrap();
2617        save(dir.path(), &config).unwrap();
2618        let reloaded = load(dir.path()).unwrap();
2619
2620        assert_eq!(reloaded.dependencies.len(), 4);
2621        assert_eq!(
2622            reloaded.dependencies["git-include"]
2623                .filter
2624                .agents
2625                .as_deref(),
2626            Some(&["coder".into()][..])
2627        );
2628        assert_eq!(
2629            reloaded.dependencies["path-exclude"].path.as_deref(),
2630            Some(Path::new("../local-source"))
2631        );
2632        assert_eq!(
2633            reloaded.dependencies["path-exclude"]
2634                .filter
2635                .exclude
2636                .as_deref(),
2637            Some(&["draft".into()][..])
2638        );
2639        assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
2640        assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
2641    }
2642
2643    #[test]
2644    fn save_roundtrip_guard_rejects_dependency_count_loss() {
2645        let mut original = Config::default();
2646        original.dependencies.insert(
2647            "base".into(),
2648            DependencyEntry {
2649                url: Some("https://github.com/org/base.git".into()),
2650                path: None,
2651                subpath: None,
2652                version: Some("v1.0".into()),
2653                dialect: None,
2654                filter: FilterConfig::default(),
2655            },
2656        );
2657
2658        let reparsed = Config::default();
2659        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2660        let msg = err.to_string();
2661        assert!(
2662            msg.contains("dependency count changed"),
2663            "unexpected error: {msg}"
2664        );
2665    }
2666
2667    #[test]
2668    fn save_roundtrip_guard_rejects_managed_root_loss() {
2669        let original = Config {
2670            settings: Settings {
2671                managed_root: Some(".agents".into()),
2672                targets: None,
2673                ..Settings::default()
2674            },
2675            ..Config::default()
2676        };
2677        let reparsed = Config::default();
2678        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2679        let msg = err.to_string();
2680        assert!(
2681            msg.contains("settings.managed_root changed"),
2682            "unexpected error: {msg}"
2683        );
2684    }
2685
2686    #[test]
2687    fn save_roundtrip_guard_rejects_harness_order_loss() {
2688        let original = Config {
2689            settings: Settings {
2690                harness_order: Some(vec!["pi".into(), "codex".into()]),
2691                ..Settings::default()
2692            },
2693            ..Config::default()
2694        };
2695        let reparsed = Config::default();
2696        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2697        let msg = err.to_string();
2698        assert!(
2699            msg.contains("settings.harness_order changed"),
2700            "unexpected error: {msg}"
2701        );
2702    }
2703
2704    #[test]
2705    fn parse_only_skills_filter() {
2706        let toml_str = r#"
2707[dependencies.base]
2708url = "https://github.com/org/base.git"
2709only_skills = true
2710"#;
2711        let config: Config = toml::from_str(toml_str).unwrap();
2712        let local = LocalConfig::default();
2713        let effective = merge(config, local).unwrap();
2714        let source = &effective.dependencies["base"];
2715        assert!(matches!(source.filter, FilterMode::OnlySkills));
2716    }
2717
2718    #[test]
2719    fn parse_only_agents_filter() {
2720        let toml_str = r#"
2721[dependencies.base]
2722url = "https://github.com/org/base.git"
2723only_agents = true
2724"#;
2725        let config: Config = toml::from_str(toml_str).unwrap();
2726        let local = LocalConfig::default();
2727        let effective = merge(config, local).unwrap();
2728        let source = &effective.dependencies["base"];
2729        assert!(matches!(source.filter, FilterMode::OnlyAgents));
2730    }
2731
2732    #[test]
2733    fn error_on_only_skills_and_only_agents() {
2734        let toml_str = r#"
2735[dependencies.bad]
2736url = "https://github.com/org/bad.git"
2737only_skills = true
2738only_agents = true
2739"#;
2740        let config: Config = toml::from_str(toml_str).unwrap();
2741        let local = LocalConfig::default();
2742        let result = merge(config, local);
2743        assert!(result.is_err());
2744        let err = result.unwrap_err().to_string();
2745        assert!(
2746            err.contains("mutually exclusive"),
2747            "should mention mutually exclusive: {err}"
2748        );
2749    }
2750
2751    #[test]
2752    fn error_on_only_skills_with_agents_list() {
2753        let toml_str = r#"
2754[dependencies.bad]
2755url = "https://github.com/org/bad.git"
2756only_skills = true
2757agents = ["coder"]
2758"#;
2759        let config: Config = toml::from_str(toml_str).unwrap();
2760        let local = LocalConfig::default();
2761        let result = merge(config, local);
2762        assert!(result.is_err());
2763        let err = result.unwrap_err().to_string();
2764        assert!(
2765            err.contains("cannot combine"),
2766            "should mention cannot combine: {err}"
2767        );
2768    }
2769
2770    #[test]
2771    fn error_on_only_agents_with_skills_list() {
2772        let toml_str = r#"
2773[dependencies.bad]
2774url = "https://github.com/org/bad.git"
2775only_agents = true
2776skills = ["planning"]
2777"#;
2778        let config: Config = toml::from_str(toml_str).unwrap();
2779        let local = LocalConfig::default();
2780        let result = merge(config, local);
2781        assert!(result.is_err());
2782    }
2783
2784    #[test]
2785    fn error_on_only_skills_with_exclude() {
2786        let toml_str = r#"
2787[dependencies.bad]
2788url = "https://github.com/org/bad.git"
2789only_skills = true
2790exclude = ["deprecated"]
2791"#;
2792        let config: Config = toml::from_str(toml_str).unwrap();
2793        let local = LocalConfig::default();
2794        let result = merge(config, local);
2795        assert!(result.is_err());
2796    }
2797
2798    #[test]
2799    fn only_skills_false_not_serialized() {
2800        let config = Config {
2801            dependencies: {
2802                let mut m = IndexMap::new();
2803                m.insert(
2804                    "base".into(),
2805                    DependencyEntry {
2806                        url: Some("https://github.com/org/base.git".into()),
2807                        path: None,
2808                        subpath: None,
2809                        version: None,
2810                        dialect: None,
2811                        filter: FilterConfig::default(),
2812                    },
2813                );
2814                m
2815            },
2816            settings: Settings::default(),
2817            ..Config::default()
2818        };
2819        let serialized = toml::to_string_pretty(&config).unwrap();
2820        assert!(
2821            !serialized.contains("only_skills"),
2822            "false booleans should not be serialized: {serialized}"
2823        );
2824        assert!(
2825            !serialized.contains("only_agents"),
2826            "false booleans should not be serialized: {serialized}"
2827        );
2828    }
2829
2830    #[test]
2831    fn only_skills_true_roundtrips() {
2832        let toml_str = r#"
2833[dependencies.base]
2834url = "https://github.com/org/base.git"
2835only_skills = true
2836"#;
2837        let config: Config = toml::from_str(toml_str).unwrap();
2838        assert!(config.dependencies["base"].filter.only_skills);
2839        assert!(!config.dependencies["base"].filter.only_agents);
2840
2841        let serialized = toml::to_string_pretty(&config).unwrap();
2842        let reloaded: Config = toml::from_str(&serialized).unwrap();
2843        assert!(reloaded.dependencies["base"].filter.only_skills);
2844    }
2845
2846    #[test]
2847    fn filter_config_has_any_filter() {
2848        assert!(!FilterConfig::default().has_any_filter());
2849        assert!(
2850            FilterConfig {
2851                only_skills: true,
2852                ..FilterConfig::default()
2853            }
2854            .has_any_filter()
2855        );
2856        assert!(
2857            FilterConfig {
2858                agents: Some(vec!["coder".into()]),
2859                ..FilterConfig::default()
2860            }
2861            .has_any_filter()
2862        );
2863    }
2864
2865    #[test]
2866    fn filter_config_to_mode() {
2867        assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
2868        assert!(matches!(
2869            FilterConfig {
2870                only_skills: true,
2871                ..FilterConfig::default()
2872            }
2873            .to_mode(),
2874            FilterMode::OnlySkills
2875        ));
2876        assert!(matches!(
2877            FilterConfig {
2878                only_agents: true,
2879                ..FilterConfig::default()
2880            }
2881            .to_mode(),
2882            FilterMode::OnlyAgents
2883        ));
2884        assert!(matches!(
2885            FilterConfig {
2886                agents: Some(vec!["coder".into()]),
2887                ..FilterConfig::default()
2888            }
2889            .to_mode(),
2890            FilterMode::Include { .. }
2891        ));
2892        assert!(matches!(
2893            FilterConfig {
2894                exclude: Some(vec!["old".into()]),
2895                ..FilterConfig::default()
2896            }
2897            .to_mode(),
2898            FilterMode::Exclude(_)
2899        ));
2900    }
2901
2902    // === managed_targets tests ===
2903
2904    #[test]
2905    fn managed_targets_defaults_to_no_target_sync_targets() {
2906        let settings = Settings::default();
2907        assert!(settings.managed_targets().is_empty());
2908    }
2909
2910    #[test]
2911    fn managed_targets_uses_explicit_targets() {
2912        let settings = Settings {
2913            targets: Some(vec![".claude".to_string()]),
2914            ..Settings::default()
2915        };
2916        assert_eq!(settings.managed_targets(), vec![".claude"]);
2917    }
2918
2919    #[test]
2920    fn managed_targets_uses_managed_root_as_primary() {
2921        let settings = Settings {
2922            managed_root: Some(".claude".to_string()),
2923            ..Settings::default()
2924        };
2925        assert_eq!(settings.managed_targets(), vec![".claude"]);
2926    }
2927
2928    #[test]
2929    fn managed_targets_explicit_overrides_links_and_managed_root() {
2930        let settings = Settings {
2931            managed_root: Some(".cursor".to_string()),
2932            targets: Some(vec![".codex".to_string()]),
2933            ..Settings::default()
2934        };
2935        // targets takes precedence over managed_root
2936        assert_eq!(settings.managed_targets(), vec![".codex"]);
2937    }
2938
2939    #[test]
2940    fn managed_targets_normalizes_bare_harness_and_generic_links() {
2941        let settings = Settings {
2942            targets: Some(vec![
2943                "codex".to_string(),
2944                "agents".to_string(),
2945                "foo".to_string(),
2946            ]),
2947            ..Settings::default()
2948        };
2949        assert_eq!(
2950            settings.managed_targets(),
2951            vec![
2952                ".codex".to_string(),
2953                ".agents".to_string(),
2954                ".foo".to_string()
2955            ]
2956        );
2957    }
2958
2959    #[test]
2960    fn linked_harnesses_extracts_legacy_path_form_harness_links() {
2961        let settings = Settings {
2962            targets: Some(vec![
2963                ".codex".to_string(),
2964                ".claude".to_string(),
2965                ".agents".to_string(),
2966            ]),
2967            ..Settings::default()
2968        };
2969        assert_eq!(
2970            settings.linked_harnesses(),
2971            vec!["codex".to_string(), "claude".to_string()]
2972        );
2973    }
2974
2975    #[test]
2976    fn merge_warns_when_managed_root_is_agents() {
2977        let config = Config {
2978            settings: Settings {
2979                managed_root: Some(".agents".into()),
2980                ..Settings::default()
2981            },
2982            ..Config::default()
2983        };
2984
2985        let (_, diagnostics) =
2986            merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
2987
2988        assert!(diagnostics.iter().any(|diag| {
2989            diag.code == "deprecated-agents-target"
2990                && diag.context.as_deref() == Some("settings.managed_root")
2991        }));
2992    }
2993
2994    #[test]
2995    fn merge_warns_when_targets_include_agents() {
2996        let config = Config {
2997            settings: Settings {
2998                targets: Some(vec![".agents".into(), ".claude".into()]),
2999                ..Settings::default()
3000            },
3001            ..Config::default()
3002        };
3003
3004        let (_, diagnostics) =
3005            merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
3006
3007        assert!(diagnostics.iter().any(|diag| {
3008            diag.code == "deprecated-agents-target"
3009                && diag.context.as_deref() == Some("settings.targets")
3010        }));
3011    }
3012
3013    #[test]
3014    fn settings_models_cache_ttl_defaults_to_24_when_omitted() {
3015        let config: Config = toml::from_str(
3016            r#"
3017[dependencies.base]
3018url = "https://github.com/org/base.git"
3019"#,
3020        )
3021        .unwrap();
3022        assert_eq!(config.settings.models_cache_ttl_hours, 24);
3023    }
3024
3025    #[test]
3026    fn settings_models_cache_ttl_defaults_to_24_when_settings_present_without_ttl() {
3027        let config: Config = toml::from_str(
3028            r#"
3029[settings]
3030managed_root = ".agents"
3031"#,
3032        )
3033        .unwrap();
3034        assert_eq!(config.settings.models_cache_ttl_hours, 24);
3035    }
3036
3037    #[test]
3038    fn settings_models_cache_ttl_parses_zero() {
3039        let config: Config = toml::from_str(
3040            r#"
3041[settings]
3042models_cache_ttl_hours = 0
3043"#,
3044        )
3045        .unwrap();
3046        assert_eq!(config.settings.models_cache_ttl_hours, 0);
3047    }
3048
3049    #[test]
3050    fn settings_models_cache_ttl_parses_custom_value() {
3051        let config: Config = toml::from_str(
3052            r#"
3053[settings]
3054models_cache_ttl_hours = 48
3055"#,
3056        )
3057        .unwrap();
3058        assert_eq!(config.settings.models_cache_ttl_hours, 48);
3059    }
3060
3061    #[test]
3062    fn settings_models_cache_ttl_roundtrip_preserves_value() {
3063        let original = Config {
3064            settings: Settings {
3065                models_cache_ttl_hours: 48,
3066                ..Settings::default()
3067            },
3068            ..Config::default()
3069        };
3070        let serialized = toml::to_string_pretty(&original).unwrap();
3071        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3072        assert_eq!(
3073            roundtripped.settings.models_cache_ttl_hours,
3074            original.settings.models_cache_ttl_hours
3075        );
3076    }
3077
3078    #[test]
3079    fn settings_agent_emission_parses_auto() {
3080        let config: Config = toml::from_str(
3081            r#"
3082[settings]
3083agent_emission = "auto"
3084"#,
3085        )
3086        .unwrap();
3087        assert_eq!(config.settings.agent_emission, Some(AgentEmission::Auto));
3088    }
3089
3090    #[test]
3091    fn settings_agent_emission_parses_always_and_never() {
3092        let always: Config = toml::from_str(
3093            r#"
3094[settings]
3095agent_emission = "always"
3096"#,
3097        )
3098        .unwrap();
3099        assert_eq!(always.settings.agent_emission, Some(AgentEmission::Always));
3100
3101        let never: Config = toml::from_str(
3102            r#"
3103[settings]
3104agent_emission = "never"
3105"#,
3106        )
3107        .unwrap();
3108        assert_eq!(never.settings.agent_emission, Some(AgentEmission::Never));
3109    }
3110
3111    #[test]
3112    fn settings_agent_emission_defaults_to_auto_when_omitted() {
3113        let config: Config = toml::from_str(
3114            r#"
3115[settings]
3116models_cache_ttl_hours = 48
3117"#,
3118        )
3119        .unwrap();
3120        assert!(config.settings.agent_emission.is_none());
3121    }
3122
3123    #[test]
3124    fn settings_agent_copy_parses() {
3125        let config: Config = toml::from_str(
3126            r#"
3127[settings]
3128targets = [".claude"]
3129
3130[settings.meridian.agent_copy]
3131harnesses = ["claude"]
3132include_fanout = true
3133"#,
3134        )
3135        .unwrap();
3136        let agent_copy = config
3137            .settings
3138            .meridian_agent_copy()
3139            .expect("agent_copy should parse")
3140            .clone();
3141        assert_eq!(agent_copy.harnesses, vec!["claude".to_string()]);
3142        assert!(agent_copy.include_fanout);
3143    }
3144
3145    #[test]
3146    fn settings_default_harness_parses_and_roundtrips() {
3147        let config: Config = toml::from_str(
3148            r#"
3149[settings]
3150default_harness = "codex"
3151"#,
3152        )
3153        .unwrap();
3154        assert_eq!(config.settings.default_harness.as_deref(), Some("codex"));
3155
3156        let serialized = toml::to_string_pretty(&config).unwrap();
3157        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3158        assert_eq!(
3159            roundtripped.settings.default_harness,
3160            config.settings.default_harness
3161        );
3162    }
3163
3164    #[test]
3165    fn settings_default_model_parses_and_roundtrips() {
3166        let config: Config = toml::from_str(
3167            r#"
3168[settings]
3169default_model = "gpt-5.4-mini"
3170"#,
3171        )
3172        .unwrap();
3173        assert_eq!(
3174            config.settings.default_model.as_deref(),
3175            Some("gpt-5.4-mini")
3176        );
3177
3178        let serialized = toml::to_string_pretty(&config).unwrap();
3179        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3180        assert_eq!(
3181            roundtripped.settings.default_model,
3182            config.settings.default_model
3183        );
3184    }
3185
3186    #[test]
3187    fn settings_harness_order_parses_and_roundtrips() {
3188        let config: Config = toml::from_str(
3189            r#"
3190[settings]
3191harness_order = ["pi", "opencode", "codex", "claude"]
3192"#,
3193        )
3194        .unwrap();
3195        assert_eq!(
3196            config.settings.harness_order,
3197            Some(vec![
3198                "pi".to_string(),
3199                "opencode".to_string(),
3200                "codex".to_string(),
3201                "claude".to_string()
3202            ])
3203        );
3204
3205        let serialized = toml::to_string_pretty(&config).unwrap();
3206        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3207        assert_eq!(
3208            roundtripped.settings.harness_order,
3209            config.settings.harness_order
3210        );
3211    }
3212
3213    #[test]
3214    fn settings_agent_emission_roundtrip_preserves_value() {
3215        let original = Config {
3216            settings: Settings {
3217                agent_emission: Some(AgentEmission::Always),
3218                ..Settings::default()
3219            },
3220            ..Config::default()
3221        };
3222        let serialized = toml::to_string_pretty(&original).unwrap();
3223        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3224        assert_eq!(
3225            roundtripped.settings.agent_emission,
3226            original.settings.agent_emission
3227        );
3228    }
3229
3230    #[test]
3231    fn model_visibility_validate_allows_include_and_exclude() {
3232        let visibility = ModelVisibility {
3233            include: Some(vec!["opus*".into()]),
3234            exclude: Some(vec!["test*".into()]),
3235        };
3236        visibility.validate().unwrap();
3237    }
3238
3239    #[test]
3240    fn model_visibility_validate_allows_include_only_exclude_only_and_empty() {
3241        ModelVisibility {
3242            include: Some(vec!["opus*".into()]),
3243            exclude: None,
3244        }
3245        .validate()
3246        .unwrap();
3247        ModelVisibility {
3248            include: None,
3249            exclude: Some(vec!["test*".into()]),
3250        }
3251        .validate()
3252        .unwrap();
3253        ModelVisibility::default().validate().unwrap();
3254    }
3255
3256    #[test]
3257    fn model_visibility_is_empty_reports_state() {
3258        assert!(ModelVisibility::default().is_empty());
3259        assert!(
3260            !ModelVisibility {
3261                include: Some(vec!["opus*".into()]),
3262                exclude: None,
3263            }
3264            .is_empty()
3265        );
3266        assert!(
3267            !ModelVisibility {
3268                include: None,
3269                exclude: Some(vec!["test*".into()]),
3270            }
3271            .is_empty()
3272        );
3273    }
3274
3275    #[test]
3276    fn load_accepts_model_visibility_with_include_and_exclude() {
3277        let dir = TempDir::new().unwrap();
3278        std::fs::write(
3279            dir.path().join("mars.toml"),
3280            r#"
3281[settings.model_visibility]
3282include = ["opus*"]
3283exclude = ["test*"]
3284"#,
3285        )
3286        .unwrap();
3287
3288        let config = load(dir.path()).unwrap();
3289        assert_eq!(
3290            config.settings.model_visibility.include,
3291            Some(vec!["opus*".into()])
3292        );
3293        assert_eq!(
3294            config.settings.model_visibility.exclude,
3295            Some(vec!["test*".into()])
3296        );
3297    }
3298
3299    #[test]
3300    fn load_accepts_model_visibility_include_only() {
3301        let dir = TempDir::new().unwrap();
3302        std::fs::write(
3303            dir.path().join("mars.toml"),
3304            r#"
3305[settings.model_visibility]
3306include = ["opus*", "gpt-*"]
3307"#,
3308        )
3309        .unwrap();
3310
3311        let config = load(dir.path()).unwrap();
3312        assert_eq!(
3313            config.settings.model_visibility.include,
3314            Some(vec!["opus*".into(), "gpt-*".into()])
3315        );
3316        assert!(config.settings.model_visibility.exclude.is_none());
3317    }
3318
3319    #[test]
3320    fn load_accepts_model_visibility_exclude_only() {
3321        let dir = TempDir::new().unwrap();
3322        std::fs::write(
3323            dir.path().join("mars.toml"),
3324            r#"
3325[settings.model_visibility]
3326exclude = ["test-*", "deprecated-*"]
3327"#,
3328        )
3329        .unwrap();
3330
3331        let config = load(dir.path()).unwrap();
3332        assert_eq!(
3333            config.settings.model_visibility.exclude,
3334            Some(vec!["test-*".into(), "deprecated-*".into()])
3335        );
3336        assert!(config.settings.model_visibility.include.is_none());
3337    }
3338
3339    // === local-dependencies tests ===
3340
3341    #[test]
3342    fn parse_local_dependencies() {
3343        let toml_str = r#"
3344[dependencies.base]
3345url = "https://github.com/org/base.git"
3346
3347[local-dependencies.prompter]
3348url = "https://github.com/org/prompter.git"
3349skills = ["prompt-helper"]
3350"#;
3351        let config: Config = toml::from_str(toml_str).unwrap();
3352        assert_eq!(config.dependencies.len(), 1);
3353        assert_eq!(config.local_dependencies.len(), 1);
3354        assert!(config.local_dependencies.contains_key("prompter"));
3355        assert_eq!(
3356            config.local_dependencies["prompter"].url.as_deref(),
3357            Some("https://github.com/org/prompter.git")
3358        );
3359    }
3360
3361    #[test]
3362    fn local_dependencies_merged_into_effective_config() {
3363        let toml_str = r#"
3364[dependencies.base]
3365url = "https://github.com/org/base.git"
3366
3367[local-dependencies.prompter]
3368url = "https://github.com/org/prompter.git"
3369"#;
3370        let config: Config = toml::from_str(toml_str).unwrap();
3371        let local = LocalConfig::default();
3372        let effective = merge(config, local).unwrap();
3373
3374        // Both deps should be in effective config
3375        assert_eq!(effective.dependencies.len(), 2);
3376        assert!(effective.dependencies.contains_key("base"));
3377        assert!(effective.dependencies.contains_key("prompter"));
3378    }
3379
3380    #[test]
3381    fn local_dependencies_not_exported_to_manifest() {
3382        let dir = TempDir::new().unwrap();
3383        std::fs::write(
3384            dir.path().join("mars.toml"),
3385            r#"
3386[package]
3387name = "my-package"
3388version = "1.0.0"
3389
3390[dependencies.base]
3391url = "https://github.com/org/base.git"
3392
3393[local-dependencies.prompter]
3394url = "https://github.com/org/prompter.git"
3395"#,
3396        )
3397        .unwrap();
3398
3399        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
3400        assert!(diagnostics.is_empty());
3401        let manifest = manifest.unwrap();
3402
3403        // Only base should be in manifest, not prompter
3404        assert_eq!(manifest.dependencies.len(), 1);
3405        assert!(manifest.dependencies.contains_key("base"));
3406        assert!(!manifest.dependencies.contains_key("prompter"));
3407    }
3408
3409    #[test]
3410    fn error_on_duplicate_name_across_sections() {
3411        let toml_str = r#"
3412[dependencies.base]
3413url = "https://github.com/org/base.git"
3414
3415[local-dependencies.base]
3416url = "https://github.com/org/base-local.git"
3417"#;
3418        let config: Config = toml::from_str(toml_str).unwrap();
3419        let local = LocalConfig::default();
3420        let result = merge(config, local);
3421        assert!(result.is_err());
3422        let err = result.unwrap_err().to_string();
3423        assert!(
3424            err.contains("base") && err.contains("both"),
3425            "should reject duplicate name: {err}"
3426        );
3427    }
3428
3429    #[test]
3430    fn local_dependencies_roundtrip() {
3431        let dir = TempDir::new().unwrap();
3432        let original = r#"
3433[dependencies.base]
3434url = "https://github.com/org/base.git"
3435
3436[local-dependencies.prompter]
3437url = "https://github.com/org/prompter.git"
3438skills = ["prompt-helper"]
3439"#;
3440        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
3441
3442        let config = load(dir.path()).unwrap();
3443        save(dir.path(), &config).unwrap();
3444        let reloaded = load(dir.path()).unwrap();
3445
3446        assert_eq!(reloaded.dependencies.len(), 1);
3447        assert_eq!(reloaded.local_dependencies.len(), 1);
3448        assert!(reloaded.local_dependencies.contains_key("prompter"));
3449        assert_eq!(
3450            reloaded.local_dependencies["prompter"]
3451                .filter
3452                .skills
3453                .as_deref(),
3454            Some(&["prompt-helper".into()][..])
3455        );
3456    }
3457
3458    #[test]
3459    fn path_with_backslashes_serializes_as_forward_slashes() {
3460        let mut deps = IndexMap::new();
3461        deps.insert(
3462            SourceName::from("test-src"),
3463            InstallDep {
3464                url: None,
3465                path: Some(PathBuf::from("C:\\Users\\dev\\src")),
3466                subpath: None,
3467                version: None,
3468                dialect: None,
3469                filter: FilterConfig::default(),
3470            },
3471        );
3472        let config = Config {
3473            dependencies: deps,
3474            ..Config::default()
3475        };
3476        let toml_str = toml::to_string_pretty(&config).unwrap();
3477        assert!(
3478            !toml_str.contains('\\'),
3479            "TOML output must not contain backslashes: {toml_str}"
3480        );
3481        assert!(
3482            toml_str.contains("C:/Users/dev/src"),
3483            "expected forward-slash path in TOML: {toml_str}"
3484        );
3485        let reparsed: Config = toml::from_str(&toml_str).unwrap();
3486        assert_eq!(
3487            reparsed.dependencies["test-src"].path.as_ref().unwrap(),
3488            &PathBuf::from("C:/Users/dev/src"),
3489        );
3490    }
3491
3492    #[test]
3493    fn override_path_serializes_forward_slashes() {
3494        let mut overrides = IndexMap::new();
3495        overrides.insert(
3496            SourceName::from("my-dep"),
3497            OverrideEntry {
3498                path: PathBuf::from("C:\\Users\\dev\\local-pkg"),
3499            },
3500        );
3501        let local = LocalConfig {
3502            overrides,
3503            ..LocalConfig::default()
3504        };
3505        let toml_str = toml::to_string_pretty(&local).unwrap();
3506        assert!(
3507            !toml_str.contains('\\'),
3508            "local config TOML must not contain backslashes: {toml_str}"
3509        );
3510        assert!(
3511            toml_str.contains("C:/Users/dev/local-pkg"),
3512            "expected forward-slash override path: {toml_str}"
3513        );
3514    }
3515}