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