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 fully-resolved source with override tracking.
780#[derive(Debug, Clone)]
781pub struct EffectiveDependency {
782    pub name: SourceName,
783    pub id: SourceId,
784    pub spec: SourceSpec,
785    pub subpath: Option<SourceSubpath>,
786    pub filter: FilterMode,
787    pub rename: RenameMap,
788    pub dialect: Option<Dialect>,
789    pub is_overridden: bool,
790    pub original_git: Option<GitSpec>,
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        // Check if this source has a local override
1042        let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
1043            let original = match &base_spec {
1044                SourceSpec::Git(git) => Some(git.clone()),
1045                SourceSpec::Path(_) => None,
1046            };
1047            (SourceSpec::Path(ov.path.clone()), true, original)
1048        } else {
1049            (base_spec, false, None)
1050        };
1051        let subpath = entry.subpath.clone();
1052        let dialect = entry.dialect;
1053        let id = source_id_for_spec(root, &spec, subpath.clone());
1054
1055        dependencies.insert(
1056            name.clone(),
1057            EffectiveDependency {
1058                name: name.clone(),
1059                id,
1060                spec,
1061                subpath,
1062                filter,
1063                rename,
1064                dialect,
1065                is_overridden,
1066                original_git,
1067            },
1068        );
1069    }
1070
1071    let skills = overlay_skills_replace_by_key(&config.skills, &local.skills);
1072
1073    // Warn if override references a dependency not in config
1074    for override_name in local.overrides.keys() {
1075        if !config.dependencies.contains_key(override_name) {
1076            diagnostics.push(Diagnostic {
1077                level: DiagnosticLevel::Warning,
1078                code: "override-missing-dep",
1079                message: format!(
1080                    "override `{override_name}` references a dependency not in mars.toml"
1081                ),
1082                context: None,
1083                category: None,
1084            });
1085        }
1086    }
1087
1088    Ok((
1089        EffectiveConfig {
1090            dependencies,
1091            settings: merged_settings,
1092            skills,
1093        },
1094        diagnostics,
1095    ))
1096}
1097
1098fn deprecated_agents_target_diagnostics(settings: &Settings) -> Vec<Diagnostic> {
1099    let mut diagnostics = Vec::new();
1100
1101    if settings.managed_root.as_deref() == Some(".agents") {
1102        diagnostics.push(deprecated_agents_target_diagnostic("settings.managed_root"));
1103    }
1104
1105    if settings
1106        .targets
1107        .as_ref()
1108        .is_some_and(|targets| targets.iter().any(|target| target == ".agents"))
1109    {
1110        diagnostics.push(deprecated_agents_target_diagnostic("settings.targets"));
1111    }
1112
1113    diagnostics
1114}
1115
1116fn deprecated_agents_target_diagnostic(context: &str) -> Diagnostic {
1117    Diagnostic {
1118        level: DiagnosticLevel::Warning,
1119        code: "deprecated-agents-target",
1120        message: format!(
1121            "`.agents` is a deprecated link target. Run `{}` to remove it. Skills are now emitted to native harness dirs automatically.",
1122            managed_cmd("mars unlink .agents"),
1123        ),
1124        context: Some(context.to_string()),
1125        category: Some(DiagnosticCategory::Compatibility),
1126    }
1127}
1128
1129/// Validate filter configuration for consistency.
1130///
1131/// Rejects invalid combinations:
1132/// - `only_skills` and `only_agents` together
1133/// - category-only flags with include lists
1134/// - category-only flags with exclude
1135/// - include lists with exclude
1136pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
1137    let has_include = filter.agents.is_some() || filter.skills.is_some();
1138    let has_exclude = filter.exclude.is_some();
1139    let has_category = filter.only_skills || filter.only_agents;
1140
1141    if filter.only_skills && filter.only_agents {
1142        return Err(ConfigError::Invalid {
1143            message: format!(
1144                "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
1145            ),
1146        }
1147        .into());
1148    }
1149    if has_category && has_include {
1150        return Err(ConfigError::Invalid {
1151            message: format!(
1152                "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
1153            ),
1154        }
1155        .into());
1156    }
1157    if has_category && has_exclude {
1158        return Err(ConfigError::Invalid {
1159            message: format!(
1160                "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
1161            ),
1162        }
1163        .into());
1164    }
1165    if has_include && has_exclude {
1166        return Err(ConfigError::ConflictingFilters {
1167            name: dep_name.to_string(),
1168        }
1169        .into());
1170    }
1171    Ok(())
1172}
1173
1174impl FilterConfig {
1175    /// Convert to the resolved FilterMode enum.
1176    pub fn to_mode(&self) -> FilterMode {
1177        if self.only_skills {
1178            FilterMode::OnlySkills
1179        } else if self.only_agents {
1180            FilterMode::OnlyAgents
1181        } else if self.agents.is_some() || self.skills.is_some() {
1182            FilterMode::Include {
1183                agents: self.agents.clone().unwrap_or_default(),
1184                skills: self.skills.clone().unwrap_or_default(),
1185            }
1186        } else if self.exclude.is_some() {
1187            FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
1188        } else {
1189            FilterMode::All
1190        }
1191    }
1192
1193    /// Returns true if any filter field is set (not default).
1194    pub fn has_any_filter(&self) -> bool {
1195        self.agents.is_some()
1196            || self.skills.is_some()
1197            || self.exclude.is_some()
1198            || self.only_skills
1199            || self.only_agents
1200    }
1201}
1202
1203fn source_id_for_spec(root: &Path, spec: &SourceSpec, subpath: Option<SourceSubpath>) -> SourceId {
1204    match spec {
1205        SourceSpec::Git(git) => {
1206            let canonical_url = SourceUrl::from(crate::source::canonical::canonicalize_git_url(
1207                git.url.as_ref(),
1208            ));
1209            SourceId::git_with_subpath(canonical_url, subpath.clone())
1210        }
1211        SourceSpec::Path(path) => match SourceId::path_with_subpath(root, path, subpath.clone()) {
1212            Ok(id) => id,
1213            Err(_) => {
1214                let canonical = if path.is_absolute() {
1215                    path.clone()
1216                } else {
1217                    root.join(path)
1218                };
1219                SourceId::Path { canonical, subpath }
1220            }
1221        },
1222    }
1223}
1224
1225fn migrate_legacy_source_urls(config: &mut Config) {
1226    for dep in config
1227        .dependencies
1228        .values_mut()
1229        .chain(config.local_dependencies.values_mut())
1230    {
1231        if let Some(url) = dep.url.as_mut() {
1232            let raw = url.as_str();
1233            if should_upgrade_legacy_git_url(raw) {
1234                *url = SourceUrl::from(format!("https://{raw}"));
1235            }
1236        }
1237    }
1238}
1239
1240fn should_upgrade_legacy_git_url(url: &str) -> bool {
1241    !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
1242}
1243
1244/// Write mars.toml atomically.
1245pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
1246    let path = root.join(CONFIG_FILE);
1247    let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
1248        message: format!("failed to serialize config: {e}"),
1249    })?;
1250    let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
1251        message: format!("refusing to save config: serialized output failed to parse: {e}"),
1252    })?;
1253    validate_save_roundtrip(config, &reparsed)?;
1254    crate::fs::atomic_write(&path, content.as_bytes())
1255}
1256
1257fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
1258    if reparsed.dependencies.len() != original.dependencies.len() {
1259        return Err(ConfigError::Invalid {
1260            message: format!(
1261                "refusing to save config: dependency count changed during roundtrip ({} -> {})",
1262                original.dependencies.len(),
1263                reparsed.dependencies.len()
1264            ),
1265        }
1266        .into());
1267    }
1268
1269    if reparsed.local_dependencies.len() != original.local_dependencies.len() {
1270        return Err(ConfigError::Invalid {
1271            message: format!(
1272                "refusing to save config: local-dependencies count changed during roundtrip ({} -> {})",
1273                original.local_dependencies.len(),
1274                reparsed.local_dependencies.len()
1275            ),
1276        }
1277        .into());
1278    }
1279
1280    if reparsed.settings.managed_root != original.settings.managed_root {
1281        return Err(ConfigError::Invalid {
1282            message: format!(
1283                "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
1284                original.settings.managed_root, reparsed.settings.managed_root
1285            ),
1286        }
1287        .into());
1288    }
1289    if reparsed.settings.model_visibility != original.settings.model_visibility {
1290        return Err(ConfigError::Invalid {
1291            message: format!(
1292                "refusing to save config: settings.model_visibility changed during roundtrip ({:?} -> {:?})",
1293                original.settings.model_visibility, reparsed.settings.model_visibility
1294            ),
1295        }
1296        .into());
1297    }
1298    if reparsed.settings.default_harness != original.settings.default_harness {
1299        return Err(ConfigError::Invalid {
1300            message: format!(
1301                "refusing to save config: settings.default_harness changed during roundtrip ({:?} -> {:?})",
1302                original.settings.default_harness, reparsed.settings.default_harness
1303            ),
1304        }
1305        .into());
1306    }
1307    if reparsed.settings.default_model != original.settings.default_model {
1308        return Err(ConfigError::Invalid {
1309            message: format!(
1310                "refusing to save config: settings.default_model changed during roundtrip ({:?} -> {:?})",
1311                original.settings.default_model, reparsed.settings.default_model
1312            ),
1313        }
1314        .into());
1315    }
1316    if reparsed.settings.harness_order != original.settings.harness_order {
1317        return Err(ConfigError::Invalid {
1318            message: format!(
1319                "refusing to save config: settings.harness_order changed during roundtrip ({:?} -> {:?})",
1320                original.settings.harness_order, reparsed.settings.harness_order
1321            ),
1322        }
1323        .into());
1324    }
1325    if reparsed.settings.provider_order != original.settings.provider_order {
1326        return Err(ConfigError::Invalid {
1327            message: format!(
1328                "refusing to save config: settings.provider_order changed during roundtrip ({:?} -> {:?})",
1329                original.settings.provider_order, reparsed.settings.provider_order
1330            ),
1331        }
1332        .into());
1333    }
1334    if reparsed.settings.agent_emission != original.settings.agent_emission {
1335        return Err(ConfigError::Invalid {
1336            message: format!(
1337                "refusing to save config: settings.agent_emission changed during roundtrip ({:?} -> {:?})",
1338                original.settings.agent_emission, reparsed.settings.agent_emission
1339            ),
1340        }
1341        .into());
1342    }
1343    if reparsed.settings.meridian.agent_copy != original.settings.meridian.agent_copy {
1344        return Err(ConfigError::Invalid {
1345            message: format!(
1346                "refusing to save config: settings.meridian.agent_copy changed during roundtrip ({:?} -> {:?})",
1347                original.settings.meridian.agent_copy, reparsed.settings.meridian.agent_copy
1348            ),
1349        }
1350        .into());
1351    }
1352    if reparsed.settings.meridian.fanout != original.settings.meridian.fanout {
1353        return Err(ConfigError::Invalid {
1354            message: format!(
1355                "refusing to save config: settings.meridian.fanout changed during roundtrip ({:?} -> {:?})",
1356                original.settings.meridian.fanout, reparsed.settings.meridian.fanout
1357            ),
1358        }
1359        .into());
1360    }
1361    if reparsed.settings.model_policies != original.settings.model_policies {
1362        return Err(ConfigError::Invalid {
1363            message: "refusing to save config: settings.model_policies changed during roundtrip"
1364                .to_string(),
1365        }
1366        .into());
1367    }
1368    if reparsed.agents != original.agents {
1369        return Err(ConfigError::Invalid {
1370            message: "refusing to save config: agents changed during roundtrip".to_string(),
1371        }
1372        .into());
1373    }
1374
1375    for (name, dep) in &original.dependencies {
1376        let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
1377            return Err(ConfigError::Invalid {
1378                message: format!(
1379                    "refusing to save config: dependency `{name}` missing after roundtrip"
1380                ),
1381            }
1382            .into());
1383        };
1384
1385        if reparsed_dep != dep {
1386            return Err(ConfigError::Invalid {
1387                message: format!(
1388                    "refusing to save config: dependency `{name}` changed during roundtrip"
1389                ),
1390            }
1391            .into());
1392        }
1393    }
1394
1395    for (name, dep) in &original.local_dependencies {
1396        let Some(reparsed_dep) = reparsed.local_dependencies.get(name) else {
1397            return Err(ConfigError::Invalid {
1398                message: format!(
1399                    "refusing to save config: local-dependency `{name}` missing after roundtrip"
1400                ),
1401            }
1402            .into());
1403        };
1404
1405        if reparsed_dep != dep {
1406            return Err(ConfigError::Invalid {
1407                message: format!(
1408                    "refusing to save config: local-dependency `{name}` changed during roundtrip"
1409                ),
1410            }
1411            .into());
1412        }
1413    }
1414
1415    Ok(())
1416}
1417
1418/// Write mars.local.toml atomically.
1419pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
1420    let path = root.join(LOCAL_CONFIG_FILE);
1421    let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
1422        message: format!("failed to serialize local config: {e}"),
1423    })?;
1424    crate::fs::atomic_write(&path, content.as_bytes())
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429    use super::*;
1430    use crate::models::{ModelAlias, ModelSpec};
1431    use tempfile::TempDir;
1432
1433    #[test]
1434    fn parse_git_dependency() {
1435        let toml_str = r#"
1436[dependencies.base]
1437url = "https://github.com/org/base.git"
1438version = "v1.0"
1439"#;
1440        let config: Config = toml::from_str(toml_str).unwrap();
1441        assert_eq!(config.dependencies.len(), 1);
1442        let entry = &config.dependencies["base"];
1443        assert_eq!(
1444            entry.url.as_deref(),
1445            Some("https://github.com/org/base.git")
1446        );
1447        assert!(entry.path.is_none());
1448        assert_eq!(entry.version.as_deref(), Some("v1.0"));
1449    }
1450
1451    #[test]
1452    fn parse_path_dependency() {
1453        let toml_str = r#"
1454[dependencies.local]
1455path = "../my-agents"
1456"#;
1457        let config: Config = toml::from_str(toml_str).unwrap();
1458        let entry = &config.dependencies["local"];
1459        assert!(entry.url.is_none());
1460        assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
1461    }
1462
1463    #[test]
1464    fn parse_mixed_dependencies() {
1465        let toml_str = r#"
1466[dependencies.remote]
1467url = "https://github.com/org/remote.git"
1468version = "v2.0"
1469agents = ["coder", "reviewer"]
1470
1471[dependencies.local]
1472path = "/home/dev/agents"
1473exclude = ["experimental"]
1474"#;
1475        let config: Config = toml::from_str(toml_str).unwrap();
1476        assert_eq!(config.dependencies.len(), 2);
1477        assert!(config.dependencies.contains_key("remote"));
1478        assert!(config.dependencies.contains_key("local"));
1479    }
1480
1481    #[test]
1482    fn parse_package_and_dependencies_coexist() {
1483        let toml_str = r#"
1484[package]
1485name = "my-agents"
1486version = "0.1.0"
1487
1488[dependencies.base]
1489url = "https://github.com/org/base.git"
1490version = ">=1.0.0"
1491
1492[dependencies.local]
1493path = "../local-agents"
1494"#;
1495        let config: Config = toml::from_str(toml_str).unwrap();
1496        assert!(config.package.is_some());
1497        assert!(config.dependencies.contains_key("base"));
1498        assert!(config.dependencies.contains_key("local"));
1499    }
1500
1501    #[test]
1502    fn parse_include_filter() {
1503        let toml_str = r#"
1504[dependencies.base]
1505url = "https://github.com/org/base.git"
1506agents = ["coder"]
1507skills = ["review"]
1508"#;
1509        let config: Config = toml::from_str(toml_str).unwrap();
1510        let local = LocalConfig::default();
1511        let effective = merge(config, local).unwrap();
1512        let source = &effective.dependencies["base"];
1513        match &source.filter {
1514            FilterMode::Include { agents, skills } => {
1515                assert_eq!(agents, &["coder"]);
1516                assert_eq!(skills, &["review"]);
1517            }
1518            other => panic!("expected Include, got {other:?}"),
1519        }
1520    }
1521
1522    #[test]
1523    fn parse_exclude_filter() {
1524        let toml_str = r#"
1525[dependencies.base]
1526url = "https://github.com/org/base.git"
1527exclude = ["experimental", "deprecated"]
1528"#;
1529        let config: Config = toml::from_str(toml_str).unwrap();
1530        let local = LocalConfig::default();
1531        let effective = merge(config, local).unwrap();
1532        let source = &effective.dependencies["base"];
1533        match &source.filter {
1534            FilterMode::Exclude(items) => {
1535                assert_eq!(items, &["experimental", "deprecated"]);
1536            }
1537            other => panic!("expected Exclude, got {other:?}"),
1538        }
1539    }
1540
1541    #[test]
1542    fn error_on_both_include_and_exclude() {
1543        let toml_str = r#"
1544[dependencies.bad]
1545url = "https://github.com/org/bad.git"
1546agents = ["coder"]
1547exclude = ["reviewer"]
1548"#;
1549        let config: Config = toml::from_str(toml_str).unwrap();
1550        let local = LocalConfig::default();
1551        let result = merge(config, local);
1552        assert!(result.is_err());
1553        let err = result.unwrap_err().to_string();
1554        assert!(
1555            err.contains("bad"),
1556            "error should mention dependency name: {err}"
1557        );
1558    }
1559
1560    #[test]
1561    fn error_on_neither_url_nor_path() {
1562        let toml_str = r#"
1563[dependencies.empty]
1564version = "v1.0"
1565"#;
1566        let config: Config = toml::from_str(toml_str).unwrap();
1567        let local = LocalConfig::default();
1568        let result = merge(config, local);
1569        assert!(result.is_err());
1570        let err = result.unwrap_err().to_string();
1571        assert!(
1572            err.contains("neither"),
1573            "error should mention 'neither': {err}"
1574        );
1575    }
1576
1577    #[test]
1578    fn error_on_both_url_and_path() {
1579        let toml_str = r#"
1580[dependencies.both]
1581url = "https://github.com/org/repo.git"
1582path = "/local/path"
1583"#;
1584        let config: Config = toml::from_str(toml_str).unwrap();
1585        let local = LocalConfig::default();
1586        let result = merge(config, local);
1587        assert!(result.is_err());
1588        let err = result.unwrap_err().to_string();
1589        assert!(err.contains("both"), "error should mention 'both': {err}");
1590    }
1591
1592    #[test]
1593    fn roundtrip_full_config_shape_survives_save() {
1594        let dir = TempDir::new().unwrap();
1595        let original = r#"
1596[package]
1597name = "sample"
1598version = "0.1.0"
1599description = "sample package"
1600
1601[dependencies.base]
1602url = "https://github.com/org/base.git"
1603version = "v1.0"
1604agents = ["coder", "reviewer"]
1605
1606[dependencies.local]
1607path = "../local-agents"
1608exclude = ["experimental"]
1609
1610[settings]
1611managed_root = ".custom-agents"
1612targets = [".claude", ".cursor"]
1613harness_order = ["pi", "opencode", "codex"]
1614"#;
1615        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1616
1617        let config = load(dir.path()).unwrap();
1618        save(dir.path(), &config).unwrap();
1619        let reloaded = load(dir.path()).unwrap();
1620
1621        assert_eq!(
1622            reloaded.package.as_ref().map(|p| p.name.as_str()),
1623            Some("sample")
1624        );
1625        assert_eq!(reloaded.dependencies.len(), 2);
1626        assert_eq!(
1627            reloaded.dependencies["base"].url.as_deref(),
1628            Some("https://github.com/org/base.git")
1629        );
1630        assert_eq!(
1631            reloaded.dependencies["local"].path.as_deref(),
1632            Some(Path::new("../local-agents"))
1633        );
1634        assert_eq!(
1635            reloaded.settings.managed_root.as_deref(),
1636            Some(".custom-agents")
1637        );
1638        assert_eq!(
1639            reloaded.settings.targets,
1640            Some(vec![".claude".to_string(), ".cursor".to_string()])
1641        );
1642        assert_eq!(
1643            reloaded.settings.harness_order,
1644            Some(vec![
1645                "pi".to_string(),
1646                "opencode".to_string(),
1647                "codex".to_string()
1648            ])
1649        );
1650    }
1651
1652    #[test]
1653    fn load_from_disk() {
1654        let dir = TempDir::new().unwrap();
1655        let toml_str = r#"
1656[dependencies.base]
1657url = "https://github.com/org/base.git"
1658version = "v1.0"
1659"#;
1660        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1661        let config = load(dir.path()).unwrap();
1662        assert_eq!(config.dependencies.len(), 1);
1663    }
1664
1665    #[test]
1666    fn load_migrates_legacy_bare_domain_url() {
1667        let dir = TempDir::new().unwrap();
1668        let toml_str = r#"
1669[dependencies.base]
1670url = "github.com/org/base"
1671"#;
1672        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1673
1674        let config = load(dir.path()).unwrap();
1675        assert_eq!(
1676            config.dependencies["base"].url.as_deref(),
1677            Some("https://github.com/org/base")
1678        );
1679    }
1680
1681    #[test]
1682    fn load_does_not_migrate_ssh_url() {
1683        let dir = TempDir::new().unwrap();
1684        let toml_str = r#"
1685[dependencies.base]
1686url = "git@github.com:org/base.git"
1687"#;
1688        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
1689
1690        let config = load(dir.path()).unwrap();
1691        assert_eq!(
1692            config.dependencies["base"].url.as_deref(),
1693            Some("git@github.com:org/base.git")
1694        );
1695    }
1696
1697    #[test]
1698    fn load_missing_file_returns_not_found() {
1699        let dir = TempDir::new().unwrap();
1700        let result = load(dir.path());
1701        assert!(result.is_err());
1702        let err = result.unwrap_err().to_string();
1703        assert!(err.contains("not found"), "should be NotFound: {err}");
1704    }
1705
1706    #[test]
1707    fn load_manifest_returns_none_without_package() {
1708        let dir = TempDir::new().unwrap();
1709        std::fs::write(
1710            dir.path().join("mars.toml"),
1711            r#"
1712[dependencies.base]
1713url = "https://github.com/org/base.git"
1714"#,
1715        )
1716        .unwrap();
1717
1718        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1719        assert!(diagnostics.is_empty());
1720        assert!(manifest.is_none());
1721    }
1722
1723    #[test]
1724    fn load_manifest_returns_package_and_dependencies() {
1725        let dir = TempDir::new().unwrap();
1726        std::fs::write(
1727            dir.path().join("mars.toml"),
1728            r#"
1729[package]
1730name = "pkg"
1731version = "1.2.3"
1732
1733[dependencies.base]
1734url = "https://github.com/org/base.git"
1735version = ">=1.0.0"
1736skills = ["frontend-design"]
1737"#,
1738        )
1739        .unwrap();
1740
1741        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1742        assert!(diagnostics.is_empty());
1743        let manifest = manifest.unwrap();
1744        assert_eq!(manifest.package.name, "pkg");
1745        assert_eq!(manifest.package.version, "1.2.3");
1746        assert!(manifest.dependencies.contains_key("base"));
1747        assert_eq!(
1748            manifest.dependencies["base"].filter.skills.as_deref(),
1749            Some(&[ItemName::from("frontend-design")][..])
1750        );
1751    }
1752
1753    #[test]
1754    fn load_manifest_io_error_includes_operation_and_path() {
1755        let dir = TempDir::new().unwrap();
1756        let config_path = dir.path().join("mars.toml");
1757        std::fs::create_dir(&config_path).unwrap();
1758
1759        let err = load_manifest(dir.path()).unwrap_err();
1760        let msg = err.to_string();
1761
1762        assert!(
1763            msg.contains("read manifest config"),
1764            "error should include operation context: {msg}"
1765        );
1766        assert!(
1767            msg.contains("mars.toml"),
1768            "error should include config path: {msg}"
1769        );
1770    }
1771
1772    #[test]
1773    fn load_local_missing_returns_default() {
1774        let dir = TempDir::new().unwrap();
1775        let local = load_local(dir.path()).unwrap();
1776        assert!(local.overrides.is_empty());
1777    }
1778
1779    #[test]
1780    fn load_local_from_disk() {
1781        let dir = TempDir::new().unwrap();
1782        let toml_str = r#"
1783[overrides.base]
1784path = "/home/dev/local-base"
1785"#;
1786        std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
1787        let local = load_local(dir.path()).unwrap();
1788        assert_eq!(local.overrides.len(), 1);
1789        assert_eq!(
1790            local.overrides["base"].path,
1791            PathBuf::from("/home/dev/local-base")
1792        );
1793    }
1794
1795    #[test]
1796    fn parse_agent_overlay_and_settings_model_policies() {
1797        let config: Config = toml::from_str(
1798            r#"
1799[agents.tech-lead]
1800description = "Tech lead overlay"
1801model = "gpt55"
1802harness = "codex"
1803effort = "medium"
1804approval = "default"
1805sandbox = "default"
1806autocompact = 1200
1807autocompact_pct = 80
1808user_invocable = false
1809model-invocable = true
1810tools.disallowed = ["Bash(rm *)", "Write"]
1811
1812[[agents.tech-lead.model-policies]]
1813match = { alias = "gpt55" }
1814override = { harness = "opencode", effort = "low" }
1815no-fallback = true
1816
1817[settings]
1818
1819[[settings.model-policies]]
1820match = { model-glob = "gpt-*" }
1821override = { effort = "high" }
1822"#,
1823        )
1824        .unwrap();
1825
1826        let overlay = config.agents.get("tech-lead").expect("tech-lead overlay");
1827        assert_eq!(overlay.description.as_deref(), Some("Tech lead overlay"));
1828        assert_eq!(overlay.model.as_deref(), Some("gpt55"));
1829        assert_eq!(overlay.harness.as_deref(), Some("codex"));
1830        assert_eq!(overlay.autocompact, Some(1200));
1831        assert_eq!(overlay.autocompact_pct, Some(80));
1832        assert_eq!(overlay.user_invocable, Some(false));
1833        assert_eq!(overlay.model_invocable, Some(true));
1834        assert_eq!(
1835            overlay.tools.disallowed,
1836            vec!["Bash(rm *)".to_string(), "Write".to_string()]
1837        );
1838        assert_eq!(overlay.model_policies.len(), 1);
1839        assert_eq!(
1840            overlay.model_policies[0].match_type,
1841            ModelPolicyMatchType::Alias
1842        );
1843        assert_eq!(overlay.model_policies[0].match_value, "gpt55");
1844        assert!(overlay.model_policies[0].no_fallback);
1845
1846        assert_eq!(config.settings.model_policies.len(), 1);
1847        assert_eq!(
1848            config.settings.model_policies[0].match_type,
1849            ModelPolicyMatchType::ModelGlob
1850        );
1851        assert_eq!(config.settings.model_policies[0].match_value, "gpt-*");
1852    }
1853
1854    #[test]
1855    fn parse_skill_overlay_tools_and_invocability() {
1856        let config: Config = toml::from_str(
1857            r#"
1858[skills.planning]
1859description = "Planning overlay"
1860user_invocable = false
1861model-invocable = true
1862tools.disallowed = ["Agent", "Write"]
1863tools.allowed = ["Bash(git *)", "mcp(plugin:demo)"]
1864"#,
1865        )
1866        .unwrap();
1867
1868        let overlay = config.skills.get("planning").expect("planning overlay");
1869        assert_eq!(overlay.description.as_deref(), Some("Planning overlay"));
1870        assert_eq!(overlay.user_invocable, Some(false));
1871        assert_eq!(overlay.model_invocable, Some(true));
1872        assert_eq!(
1873            overlay.tools.disallowed,
1874            vec!["Agent".to_string(), "Write".to_string()]
1875        );
1876        assert_eq!(
1877            overlay.tools.allowed,
1878            vec!["Bash(git *)".to_string(), "mcp(plugin:demo)".to_string()]
1879        );
1880    }
1881
1882    #[test]
1883    fn merged_skill_overlays_local_replaces_fields() {
1884        let mut base_skills = IndexMap::new();
1885        base_skills.insert(
1886            "planning".to_string(),
1887            SkillOverlay {
1888                description: Some("Base description".to_string()),
1889                user_invocable: Some(true),
1890                ..SkillOverlay::default()
1891            },
1892        );
1893
1894        let mut local_skills = IndexMap::new();
1895        local_skills.insert(
1896            "planning".to_string(),
1897            SkillOverlay {
1898                description: Some("Local description".to_string()),
1899                user_invocable: Some(false),
1900                ..SkillOverlay::default()
1901            },
1902        );
1903        let local = LocalConfig {
1904            skills: local_skills,
1905            ..LocalConfig::default()
1906        };
1907
1908        let merged = overlay_skills_replace_by_key(&base_skills, &local.skills);
1909        let replaced = merged.get("planning").expect("planning overlay");
1910        assert_eq!(replaced.description.as_deref(), Some("Local description"));
1911        assert_eq!(replaced.user_invocable, Some(false));
1912    }
1913
1914    #[test]
1915    fn merged_agent_overlays_local_replaces_widened_fields() {
1916        let mut base_agents = IndexMap::new();
1917        base_agents.insert(
1918            "worker".to_string(),
1919            AgentOverlay {
1920                description: Some("Base description".to_string()),
1921                user_invocable: Some(true),
1922                ..AgentOverlay::default()
1923            },
1924        );
1925
1926        let mut local_agents = IndexMap::new();
1927        local_agents.insert(
1928            "worker".to_string(),
1929            AgentOverlay {
1930                description: Some("Local description".to_string()),
1931                user_invocable: Some(false),
1932                ..AgentOverlay::default()
1933            },
1934        );
1935        let local = LocalConfig {
1936            agents: local_agents,
1937            ..LocalConfig::default()
1938        };
1939
1940        let merged = merged_agent_overlays(&base_agents, &local);
1941        let replaced = merged.get("worker").expect("worker overlay");
1942        assert_eq!(replaced.description.as_deref(), Some("Local description"));
1943        assert_eq!(replaced.user_invocable, Some(false));
1944    }
1945
1946    #[test]
1947    fn merged_agent_overlays_replace_by_agent_name() {
1948        let mut base_agents = IndexMap::new();
1949        base_agents.insert(
1950            "tech-lead".to_string(),
1951            AgentOverlay {
1952                model: Some("gpt55".to_string()),
1953                harness: Some("codex".to_string()),
1954                effort: Some("high".to_string()),
1955                ..AgentOverlay::default()
1956            },
1957        );
1958        base_agents.insert(
1959            "reviewer".to_string(),
1960            AgentOverlay {
1961                model: Some("gpt-5.4-mini".to_string()),
1962                ..AgentOverlay::default()
1963            },
1964        );
1965
1966        let mut local_agents = IndexMap::new();
1967        local_agents.insert(
1968            "tech-lead".to_string(),
1969            AgentOverlay {
1970                model: Some("gptmini".to_string()),
1971                ..AgentOverlay::default()
1972            },
1973        );
1974        let local = LocalConfig {
1975            agents: local_agents,
1976            ..LocalConfig::default()
1977        };
1978
1979        let merged = merged_agent_overlays(&base_agents, &local);
1980        let replaced = merged.get("tech-lead").expect("tech-lead should exist");
1981        assert_eq!(replaced.model.as_deref(), Some("gptmini"));
1982        assert!(
1983            replaced.harness.is_none(),
1984            "local overlay must replace the base overlay block"
1985        );
1986        assert!(
1987            replaced.effort.is_none(),
1988            "local overlay replacement must not deep-merge base fields"
1989        );
1990        assert_eq!(
1991            merged
1992                .get("reviewer")
1993                .and_then(|overlay| overlay.model.as_deref()),
1994            Some("gpt-5.4-mini")
1995        );
1996    }
1997
1998    #[test]
1999    fn merged_settings_applies_local_routing_overrides() {
2000        let settings = Settings {
2001            default_harness: Some("claude".to_string()),
2002            harness_order: Some(vec!["claude".to_string(), "pi".to_string()]),
2003            provider_order: Some(vec!["anthropic".to_string()]),
2004            ..Settings::default()
2005        };
2006        let local = LocalConfig {
2007            settings: LocalSettings {
2008                default_harness: Some("cursor".to_string()),
2009                default_model: Some("gpt-5".to_string()),
2010                harness_order: Some(vec!["cursor".to_string(), "pi".to_string()]),
2011                provider_order: Some(vec!["openai".to_string()]),
2012                model_policies: None,
2013                ..LocalSettings::default()
2014            },
2015            ..LocalConfig::default()
2016        };
2017
2018        let merged = merged_settings(&settings, &local);
2019        assert_eq!(merged.default_harness.as_deref(), Some("cursor"));
2020        assert_eq!(merged.default_model.as_deref(), Some("gpt-5"));
2021        assert_eq!(
2022            merged.harness_order,
2023            Some(vec!["cursor".to_string(), "pi".to_string()])
2024        );
2025        assert_eq!(merged.provider_order, Some(vec!["openai".to_string()]));
2026    }
2027
2028    #[test]
2029    fn merged_settings_overlays_model_visibility_keys_independently() {
2030        let settings = Settings {
2031            model_visibility: ModelVisibility {
2032                include: Some(vec!["openai/*".to_string()]),
2033                exclude: Some(vec!["*-preview".to_string()]),
2034            },
2035            ..Settings::default()
2036        };
2037        let local = LocalConfig {
2038            settings: LocalSettings {
2039                model_visibility: Some(LocalModelVisibility {
2040                    include: Some(vec!["anthropic/*".to_string()]),
2041                    exclude: None,
2042                }),
2043                ..LocalSettings::default()
2044            },
2045            ..LocalConfig::default()
2046        };
2047
2048        let merged = merged_settings(&settings, &local);
2049        assert_eq!(
2050            merged.model_visibility.include,
2051            Some(vec!["anthropic/*".to_string()])
2052        );
2053        assert_eq!(
2054            merged.model_visibility.exclude,
2055            Some(vec!["*-preview".to_string()])
2056        );
2057    }
2058
2059    #[test]
2060    fn merged_settings_replaces_scalar_table_and_array_fields() {
2061        let base_rule = ModelPolicyRule {
2062            match_type: ModelPolicyMatchType::Alias,
2063            match_value: "gpt55".to_string(),
2064            no_fallback: false,
2065            overrides: serde_yaml::Mapping::new(),
2066        };
2067        let local_rule = ModelPolicyRule {
2068            match_type: ModelPolicyMatchType::Alias,
2069            match_value: "gptmini".to_string(),
2070            no_fallback: false,
2071            overrides: serde_yaml::Mapping::new(),
2072        };
2073        let settings = Settings {
2074            targets: Some(vec![".claude".to_string(), ".codex".to_string()]),
2075            model_visibility: ModelVisibility {
2076                include: Some(vec!["anthropic/*".to_string()]),
2077                exclude: None,
2078            },
2079            models_cache_ttl_hours: 24,
2080            min_mars_version: Some("0.1.0".to_string()),
2081            model_policies: vec![base_rule],
2082            ..Settings::default()
2083        };
2084        let local = LocalConfig {
2085            settings: LocalSettings {
2086                targets: Some(vec![".cursor".to_string()]),
2087                model_visibility: Some(LocalModelVisibility {
2088                    include: None,
2089                    exclude: Some(vec!["*-preview*".to_string()]),
2090                }),
2091                models_cache_ttl_hours: Some(48),
2092                min_mars_version: Some("0.2.0".to_string()),
2093                model_policies: Some(vec![local_rule.clone()]),
2094                ..LocalSettings::default()
2095            },
2096            ..LocalConfig::default()
2097        };
2098
2099        let merged = merged_settings(&settings, &local);
2100        assert_eq!(merged.targets, Some(vec![".cursor".to_string()]));
2101        assert_eq!(merged.models_cache_ttl_hours, 48);
2102        assert_eq!(merged.min_mars_version.as_deref(), Some("0.2.0"));
2103        assert_eq!(
2104            merged.model_visibility.exclude,
2105            Some(vec!["*-preview*".to_string()])
2106        );
2107        assert_eq!(
2108            merged.model_visibility.include,
2109            Some(vec!["anthropic/*".to_string()])
2110        );
2111        assert_eq!(merged.model_policies, vec![local_rule]);
2112    }
2113
2114    #[test]
2115    fn merged_models_local_overlay_replaces_by_key_and_inherits_others() {
2116        let mut base = IndexMap::new();
2117        base.insert(
2118            "fast".to_string(),
2119            ModelAlias {
2120                harness: Some("codex".to_string()),
2121                description: Some("base".to_string()),
2122                prompting: None,
2123                default_effort: None,
2124                autocompact: None,
2125                autocompact_pct: None,
2126                spec: ModelSpec::Pinned {
2127                    model: "gpt-5".to_string(),
2128                    provider: None,
2129                },
2130            },
2131        );
2132        base.insert(
2133            "legacy".to_string(),
2134            ModelAlias {
2135                harness: Some("claude".to_string()),
2136                description: None,
2137                prompting: None,
2138                default_effort: None,
2139                autocompact: None,
2140                autocompact_pct: None,
2141                spec: ModelSpec::Pinned {
2142                    model: "claude-opus-4-6".to_string(),
2143                    provider: None,
2144                },
2145            },
2146        );
2147
2148        let local = LocalConfig {
2149            models: {
2150                let mut m = IndexMap::new();
2151                m.insert(
2152                    "fast".to_string(),
2153                    ModelAlias {
2154                        harness: Some("cursor".to_string()),
2155                        description: Some("local".to_string()),
2156                        prompting: None,
2157                        default_effort: None,
2158                        autocompact: None,
2159                        autocompact_pct: None,
2160                        spec: ModelSpec::Pinned {
2161                            model: "gpt-5.4-mini".to_string(),
2162                            provider: None,
2163                        },
2164                    },
2165                );
2166                m.insert(
2167                    "local-only".to_string(),
2168                    ModelAlias {
2169                        harness: Some("pi".to_string()),
2170                        description: None,
2171                        prompting: None,
2172                        default_effort: None,
2173                        autocompact: None,
2174                        autocompact_pct: None,
2175                        spec: ModelSpec::Pinned {
2176                            model: "gpt-5.5".to_string(),
2177                            provider: None,
2178                        },
2179                    },
2180                );
2181                m
2182            },
2183            ..LocalConfig::default()
2184        };
2185
2186        let merged = merged_models(&base, &local);
2187        assert_eq!(merged.len(), 3);
2188        assert_eq!(merged["fast"].harness.as_deref(), Some("cursor"));
2189        assert_eq!(
2190            merged["fast"].description.as_deref(),
2191            Some("local"),
2192            "local value should replace by key"
2193        );
2194        assert_eq!(merged["legacy"].harness.as_deref(), Some("claude"));
2195        assert_eq!(merged["local-only"].harness.as_deref(), Some("pi"));
2196    }
2197
2198    #[test]
2199    fn load_local_rejects_unknown_local_settings_fields() {
2200        let dir = TempDir::new().unwrap();
2201        std::fs::write(
2202            dir.path().join("mars.local.toml"),
2203            "[settings]\nnot_supported = true\n",
2204        )
2205        .unwrap();
2206
2207        let err = load_local(dir.path()).unwrap_err().to_string();
2208        assert!(err.contains("unknown field"), "unexpected error: {err}");
2209        assert!(err.contains("not_supported"), "unexpected error: {err}");
2210    }
2211
2212    #[test]
2213    fn load_local_rejects_unknown_local_model_visibility_fields() {
2214        let dir = TempDir::new().unwrap();
2215        std::fs::write(
2216            dir.path().join("mars.local.toml"),
2217            "[settings.model_visibility]\nfuture_nested_key = true\n",
2218        )
2219        .unwrap();
2220
2221        let err = load_local(dir.path()).unwrap_err().to_string();
2222        assert!(err.contains("unknown field"), "unexpected error: {err}");
2223        assert!(err.contains("future_nested_key"), "unexpected error: {err}");
2224    }
2225
2226    #[test]
2227    fn load_effective_project_config_accepts_unknown_project_settings_keys() {
2228        let dir = TempDir::new().unwrap();
2229        std::fs::write(
2230            dir.path().join("mars.toml"),
2231            r#"[settings]
2232harness_order = ["codex", "pi"]
2233future_key = "allowed"
2234
2235[settings.model_visibility]
2236include = ["openai/*"]
2237future_nested_key = true
2238"#,
2239        )
2240        .unwrap();
2241
2242        let effective = load_effective_project_config(dir.path())
2243            .expect("project settings overlay should ignore unknown keys");
2244        assert_eq!(
2245            effective.settings.harness_order,
2246            Some(vec!["codex".to_string(), "pi".to_string()])
2247        );
2248        assert_eq!(
2249            effective.settings.model_visibility.include,
2250            Some(vec!["openai/*".to_string()])
2251        );
2252    }
2253
2254    #[test]
2255    fn merge_with_empty_local() {
2256        let config = Config {
2257            dependencies: {
2258                let mut m = IndexMap::new();
2259                m.insert(
2260                    "base".into(),
2261                    DependencyEntry {
2262                        url: Some("https://github.com/org/base.git".into()),
2263                        path: None,
2264                        subpath: None,
2265                        version: Some("v1.0".into()),
2266                        dialect: None,
2267                        filter: FilterConfig::default(),
2268                    },
2269                );
2270                m
2271            },
2272            settings: Settings::default(),
2273            ..Config::default()
2274        };
2275        let local = LocalConfig::default();
2276        let effective = merge(config, local).unwrap();
2277        assert_eq!(effective.dependencies.len(), 1);
2278        let source = &effective.dependencies["base"];
2279        assert!(!source.is_overridden);
2280        assert!(source.original_git.is_none());
2281        match &source.spec {
2282            SourceSpec::Git(git) => {
2283                assert_eq!(git.url, "https://github.com/org/base.git");
2284                assert_eq!(git.version.as_deref(), Some("v1.0"));
2285            }
2286            SourceSpec::Path(_) => panic!("expected Git"),
2287        }
2288    }
2289
2290    #[test]
2291    fn merge_override_replaces_with_path() {
2292        let config = Config {
2293            dependencies: {
2294                let mut m = IndexMap::new();
2295                m.insert(
2296                    "base".into(),
2297                    DependencyEntry {
2298                        url: Some("https://github.com/org/base.git".into()),
2299                        path: None,
2300                        subpath: None,
2301                        version: Some("v1.0".into()),
2302                        dialect: None,
2303                        filter: FilterConfig::default(),
2304                    },
2305                );
2306                m
2307            },
2308            settings: Settings::default(),
2309            ..Config::default()
2310        };
2311        let local = LocalConfig {
2312            overrides: {
2313                let mut m = IndexMap::new();
2314                m.insert(
2315                    "base".into(),
2316                    OverrideEntry {
2317                        path: PathBuf::from("/home/dev/local-base"),
2318                    },
2319                );
2320                m
2321            },
2322            ..LocalConfig::default()
2323        };
2324        let effective = merge(config, local).unwrap();
2325        let source = &effective.dependencies["base"];
2326        assert!(source.is_overridden);
2327
2328        match &source.spec {
2329            SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
2330            SourceSpec::Git(_) => panic!("expected Path override"),
2331        }
2332
2333        let orig = source.original_git.as_ref().unwrap();
2334        assert_eq!(orig.url, "https://github.com/org/base.git");
2335        assert_eq!(orig.version.as_deref(), Some("v1.0"));
2336    }
2337
2338    #[test]
2339    fn merge_override_retains_subpath_coordinate() {
2340        let temp = TempDir::new().unwrap();
2341        // Canonicalize temp root once to avoid Windows 8.3 short-name mismatches
2342        let temp_root = dunce::canonicalize(temp.path()).unwrap();
2343        let override_path = temp_root.join("local-base");
2344        std::fs::create_dir_all(&override_path).unwrap();
2345        let canonical_override = dunce::canonicalize(&override_path).unwrap();
2346
2347        let config = Config {
2348            dependencies: {
2349                let mut m = IndexMap::new();
2350                m.insert(
2351                    "base".into(),
2352                    DependencyEntry {
2353                        url: Some("https://github.com/org/base.git".into()),
2354                        path: None,
2355                        subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
2356                        version: Some("v1.0".into()),
2357                        dialect: None,
2358                        filter: FilterConfig::default(),
2359                    },
2360                );
2361                m
2362            },
2363            settings: Settings::default(),
2364            ..Config::default()
2365        };
2366        let local = LocalConfig {
2367            overrides: {
2368                let mut m = IndexMap::new();
2369                m.insert(
2370                    "base".into(),
2371                    OverrideEntry {
2372                        path: canonical_override.clone(),
2373                    },
2374                );
2375                m
2376            },
2377            ..LocalConfig::default()
2378        };
2379
2380        let (effective, _) = merge_with_root(config, local, &temp_root).unwrap();
2381        let source = &effective.dependencies["base"];
2382        assert!(source.is_overridden);
2383        assert_eq!(
2384            source.subpath.as_ref().map(SourceSubpath::as_str),
2385            Some("plugins/foo")
2386        );
2387        assert!(matches!(&source.spec, SourceSpec::Path(p) if p == &canonical_override));
2388        assert!(matches!(
2389            &source.id,
2390            SourceId::Path {
2391                canonical,
2392                subpath: Some(sp)
2393            } if canonical == &canonical_override && sp.as_str() == "plugins/foo"
2394        ));
2395    }
2396
2397    #[test]
2398    fn merge_all_filter_mode() {
2399        let config = Config {
2400            dependencies: {
2401                let mut m = IndexMap::new();
2402                m.insert(
2403                    "base".into(),
2404                    DependencyEntry {
2405                        url: Some("https://github.com/org/base.git".into()),
2406                        path: None,
2407                        subpath: None,
2408                        version: None,
2409                        dialect: None,
2410                        filter: FilterConfig::default(),
2411                    },
2412                );
2413                m
2414            },
2415            settings: Settings::default(),
2416            ..Config::default()
2417        };
2418        let effective = merge(config, LocalConfig::default()).unwrap();
2419        assert!(matches!(
2420            effective.dependencies["base"].filter,
2421            FilterMode::All
2422        ));
2423    }
2424
2425    #[test]
2426    fn save_and_reload() {
2427        let dir = TempDir::new().unwrap();
2428        let config = Config {
2429            dependencies: {
2430                let mut m = IndexMap::new();
2431                m.insert(
2432                    "base".into(),
2433                    DependencyEntry {
2434                        url: Some("https://github.com/org/base.git".into()),
2435                        path: None,
2436                        subpath: None,
2437                        version: Some("v2.0".into()),
2438                        dialect: None,
2439                        filter: FilterConfig::default(),
2440                    },
2441                );
2442                m
2443            },
2444            settings: Settings::default(),
2445            ..Config::default()
2446        };
2447        save(dir.path(), &config).unwrap();
2448        let reloaded = load(dir.path()).unwrap();
2449        assert_eq!(config, reloaded);
2450    }
2451
2452    #[test]
2453    fn rename_map_preserved() {
2454        let toml_str = r#"
2455[dependencies.base]
2456url = "https://github.com/org/base.git"
2457
2458[dependencies.base.rename]
2459old-name = "new-name"
2460"#;
2461        let config: Config = toml::from_str(toml_str).unwrap();
2462        let effective = merge(config, LocalConfig::default()).unwrap();
2463        let source = &effective.dependencies["base"];
2464        assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
2465    }
2466
2467    #[test]
2468    fn self_dependency_name_rejected() {
2469        let toml_str = r#"
2470[dependencies._self]
2471url = "https://github.com/org/base.git"
2472"#;
2473        let config: Config = toml::from_str(toml_str).unwrap();
2474        let local = LocalConfig::default();
2475        let result = merge(config, local);
2476        assert!(result.is_err());
2477        let err = result.unwrap_err().to_string();
2478        assert!(
2479            err.contains("_self") && err.contains("reserved"),
2480            "should reject _self: {err}"
2481        );
2482    }
2483
2484    #[test]
2485    fn managed_root_setting_roundtrip() {
2486        let config = Config {
2487            settings: Settings {
2488                managed_root: Some(".claude".into()),
2489                targets: None,
2490                ..Settings::default()
2491            },
2492            ..Config::default()
2493        };
2494        let serialized = toml::to_string_pretty(&config).unwrap();
2495        let deserialized: Config = toml::from_str(&serialized).unwrap();
2496        assert_eq!(
2497            deserialized.settings.managed_root.as_deref(),
2498            Some(".claude")
2499        );
2500    }
2501
2502    #[test]
2503    fn save_preserves_dependencies_when_clearing_last_target() {
2504        let dir = TempDir::new().unwrap();
2505        let original = r#"
2506[package]
2507name = "sample"
2508version = "0.1.0"
2509
2510[dependencies.base]
2511url = "https://github.com/org/base.git"
2512version = "v1.0"
2513agents = ["coder"]
2514
2515[settings]
2516managed_root = ".agents"
2517targets = [".claude"]
2518"#;
2519        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2520
2521        let mut config = load(dir.path()).unwrap();
2522        if let Some(targets) = config.settings.targets.as_mut() {
2523            targets.retain(|target| target != ".claude");
2524            if targets.is_empty() {
2525                config.settings.targets = None;
2526            }
2527        }
2528        save(dir.path(), &config).unwrap();
2529
2530        let reloaded = load(dir.path()).unwrap();
2531        assert_eq!(
2532            reloaded.package.as_ref().map(|p| p.name.as_str()),
2533            Some("sample")
2534        );
2535        assert_eq!(
2536            reloaded.dependencies["base"].url.as_deref(),
2537            Some("https://github.com/org/base.git")
2538        );
2539        assert_eq!(
2540            reloaded.dependencies["base"].version.as_deref(),
2541            Some("v1.0")
2542        );
2543        assert_eq!(
2544            reloaded.dependencies["base"].filter.agents.as_deref(),
2545            Some(&["coder".into()][..])
2546        );
2547        assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
2548        assert!(reloaded.settings.targets.is_none());
2549    }
2550
2551    #[test]
2552    fn roundtrip_preserves_all_filter_fields() {
2553        let dir = TempDir::new().unwrap();
2554        let original = r#"
2555[dependencies.include]
2556url = "https://github.com/org/include.git"
2557agents = ["coder", "reviewer"]
2558skills = ["review", "plan"]
2559
2560[dependencies.include.rename]
2561coder = "core-coder"
2562
2563[dependencies.exclude]
2564url = "https://github.com/org/exclude.git"
2565exclude = ["experimental", "deprecated"]
2566
2567[dependencies.only_skills]
2568url = "https://github.com/org/skills.git"
2569only_skills = true
2570
2571[dependencies.only_agents]
2572url = "https://github.com/org/agents.git"
2573only_agents = true
2574"#;
2575        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2576
2577        let config = load(dir.path()).unwrap();
2578        save(dir.path(), &config).unwrap();
2579        let reloaded = load(dir.path()).unwrap();
2580
2581        let include = &reloaded.dependencies["include"].filter;
2582        assert_eq!(
2583            include.agents.as_deref(),
2584            Some(&["coder".into(), "reviewer".into()][..])
2585        );
2586        assert_eq!(
2587            include.skills.as_deref(),
2588            Some(&["review".into(), "plan".into()][..])
2589        );
2590        assert_eq!(
2591            include.rename.as_ref().and_then(|r| r.get("coder")),
2592            Some(&"core-coder".into())
2593        );
2594
2595        let exclude = &reloaded.dependencies["exclude"].filter;
2596        assert_eq!(
2597            exclude.exclude.as_deref(),
2598            Some(&["experimental".into(), "deprecated".into()][..])
2599        );
2600
2601        let only_skills = &reloaded.dependencies["only_skills"].filter;
2602        assert!(only_skills.only_skills);
2603        assert!(!only_skills.only_agents);
2604
2605        let only_agents = &reloaded.dependencies["only_agents"].filter;
2606        assert!(only_agents.only_agents);
2607        assert!(!only_agents.only_skills);
2608    }
2609
2610    #[test]
2611    fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
2612        let dir = TempDir::new().unwrap();
2613        let original = r#"
2614[dependencies.git-include]
2615url = "https://github.com/org/git-include.git"
2616agents = ["coder"]
2617
2618[dependencies.path-exclude]
2619path = "../local-source"
2620exclude = ["draft"]
2621
2622[dependencies.git-only-skills]
2623url = "https://github.com/org/git-skills.git"
2624only_skills = true
2625
2626[dependencies.git-only-agents]
2627url = "https://github.com/org/git-agents.git"
2628only_agents = true
2629"#;
2630        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2631
2632        let config = load(dir.path()).unwrap();
2633        save(dir.path(), &config).unwrap();
2634        let reloaded = load(dir.path()).unwrap();
2635
2636        assert_eq!(reloaded.dependencies.len(), 4);
2637        assert_eq!(
2638            reloaded.dependencies["git-include"]
2639                .filter
2640                .agents
2641                .as_deref(),
2642            Some(&["coder".into()][..])
2643        );
2644        assert_eq!(
2645            reloaded.dependencies["path-exclude"].path.as_deref(),
2646            Some(Path::new("../local-source"))
2647        );
2648        assert_eq!(
2649            reloaded.dependencies["path-exclude"]
2650                .filter
2651                .exclude
2652                .as_deref(),
2653            Some(&["draft".into()][..])
2654        );
2655        assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
2656        assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
2657    }
2658
2659    #[test]
2660    fn save_roundtrip_guard_rejects_dependency_count_loss() {
2661        let mut original = Config::default();
2662        original.dependencies.insert(
2663            "base".into(),
2664            DependencyEntry {
2665                url: Some("https://github.com/org/base.git".into()),
2666                path: None,
2667                subpath: None,
2668                version: Some("v1.0".into()),
2669                dialect: None,
2670                filter: FilterConfig::default(),
2671            },
2672        );
2673
2674        let reparsed = Config::default();
2675        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2676        let msg = err.to_string();
2677        assert!(
2678            msg.contains("dependency count changed"),
2679            "unexpected error: {msg}"
2680        );
2681    }
2682
2683    #[test]
2684    fn save_roundtrip_guard_rejects_managed_root_loss() {
2685        let original = Config {
2686            settings: Settings {
2687                managed_root: Some(".agents".into()),
2688                targets: None,
2689                ..Settings::default()
2690            },
2691            ..Config::default()
2692        };
2693        let reparsed = Config::default();
2694        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2695        let msg = err.to_string();
2696        assert!(
2697            msg.contains("settings.managed_root changed"),
2698            "unexpected error: {msg}"
2699        );
2700    }
2701
2702    #[test]
2703    fn save_roundtrip_guard_rejects_harness_order_loss() {
2704        let original = Config {
2705            settings: Settings {
2706                harness_order: Some(vec!["pi".into(), "codex".into()]),
2707                ..Settings::default()
2708            },
2709            ..Config::default()
2710        };
2711        let reparsed = Config::default();
2712        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
2713        let msg = err.to_string();
2714        assert!(
2715            msg.contains("settings.harness_order changed"),
2716            "unexpected error: {msg}"
2717        );
2718    }
2719
2720    #[test]
2721    fn parse_only_skills_filter() {
2722        let toml_str = r#"
2723[dependencies.base]
2724url = "https://github.com/org/base.git"
2725only_skills = true
2726"#;
2727        let config: Config = toml::from_str(toml_str).unwrap();
2728        let local = LocalConfig::default();
2729        let effective = merge(config, local).unwrap();
2730        let source = &effective.dependencies["base"];
2731        assert!(matches!(source.filter, FilterMode::OnlySkills));
2732    }
2733
2734    #[test]
2735    fn parse_only_agents_filter() {
2736        let toml_str = r#"
2737[dependencies.base]
2738url = "https://github.com/org/base.git"
2739only_agents = true
2740"#;
2741        let config: Config = toml::from_str(toml_str).unwrap();
2742        let local = LocalConfig::default();
2743        let effective = merge(config, local).unwrap();
2744        let source = &effective.dependencies["base"];
2745        assert!(matches!(source.filter, FilterMode::OnlyAgents));
2746    }
2747
2748    #[test]
2749    fn error_on_only_skills_and_only_agents() {
2750        let toml_str = r#"
2751[dependencies.bad]
2752url = "https://github.com/org/bad.git"
2753only_skills = true
2754only_agents = true
2755"#;
2756        let config: Config = toml::from_str(toml_str).unwrap();
2757        let local = LocalConfig::default();
2758        let result = merge(config, local);
2759        assert!(result.is_err());
2760        let err = result.unwrap_err().to_string();
2761        assert!(
2762            err.contains("mutually exclusive"),
2763            "should mention mutually exclusive: {err}"
2764        );
2765    }
2766
2767    #[test]
2768    fn error_on_only_skills_with_agents_list() {
2769        let toml_str = r#"
2770[dependencies.bad]
2771url = "https://github.com/org/bad.git"
2772only_skills = true
2773agents = ["coder"]
2774"#;
2775        let config: Config = toml::from_str(toml_str).unwrap();
2776        let local = LocalConfig::default();
2777        let result = merge(config, local);
2778        assert!(result.is_err());
2779        let err = result.unwrap_err().to_string();
2780        assert!(
2781            err.contains("cannot combine"),
2782            "should mention cannot combine: {err}"
2783        );
2784    }
2785
2786    #[test]
2787    fn error_on_only_agents_with_skills_list() {
2788        let toml_str = r#"
2789[dependencies.bad]
2790url = "https://github.com/org/bad.git"
2791only_agents = true
2792skills = ["planning"]
2793"#;
2794        let config: Config = toml::from_str(toml_str).unwrap();
2795        let local = LocalConfig::default();
2796        let result = merge(config, local);
2797        assert!(result.is_err());
2798    }
2799
2800    #[test]
2801    fn error_on_only_skills_with_exclude() {
2802        let toml_str = r#"
2803[dependencies.bad]
2804url = "https://github.com/org/bad.git"
2805only_skills = true
2806exclude = ["deprecated"]
2807"#;
2808        let config: Config = toml::from_str(toml_str).unwrap();
2809        let local = LocalConfig::default();
2810        let result = merge(config, local);
2811        assert!(result.is_err());
2812    }
2813
2814    #[test]
2815    fn only_skills_false_not_serialized() {
2816        let config = Config {
2817            dependencies: {
2818                let mut m = IndexMap::new();
2819                m.insert(
2820                    "base".into(),
2821                    DependencyEntry {
2822                        url: Some("https://github.com/org/base.git".into()),
2823                        path: None,
2824                        subpath: None,
2825                        version: None,
2826                        dialect: None,
2827                        filter: FilterConfig::default(),
2828                    },
2829                );
2830                m
2831            },
2832            settings: Settings::default(),
2833            ..Config::default()
2834        };
2835        let serialized = toml::to_string_pretty(&config).unwrap();
2836        assert!(
2837            !serialized.contains("only_skills"),
2838            "false booleans should not be serialized: {serialized}"
2839        );
2840        assert!(
2841            !serialized.contains("only_agents"),
2842            "false booleans should not be serialized: {serialized}"
2843        );
2844    }
2845
2846    #[test]
2847    fn only_skills_true_roundtrips() {
2848        let toml_str = r#"
2849[dependencies.base]
2850url = "https://github.com/org/base.git"
2851only_skills = true
2852"#;
2853        let config: Config = toml::from_str(toml_str).unwrap();
2854        assert!(config.dependencies["base"].filter.only_skills);
2855        assert!(!config.dependencies["base"].filter.only_agents);
2856
2857        let serialized = toml::to_string_pretty(&config).unwrap();
2858        let reloaded: Config = toml::from_str(&serialized).unwrap();
2859        assert!(reloaded.dependencies["base"].filter.only_skills);
2860    }
2861
2862    #[test]
2863    fn filter_config_has_any_filter() {
2864        assert!(!FilterConfig::default().has_any_filter());
2865        assert!(
2866            FilterConfig {
2867                only_skills: true,
2868                ..FilterConfig::default()
2869            }
2870            .has_any_filter()
2871        );
2872        assert!(
2873            FilterConfig {
2874                agents: Some(vec!["coder".into()]),
2875                ..FilterConfig::default()
2876            }
2877            .has_any_filter()
2878        );
2879    }
2880
2881    #[test]
2882    fn filter_config_to_mode() {
2883        assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
2884        assert!(matches!(
2885            FilterConfig {
2886                only_skills: true,
2887                ..FilterConfig::default()
2888            }
2889            .to_mode(),
2890            FilterMode::OnlySkills
2891        ));
2892        assert!(matches!(
2893            FilterConfig {
2894                only_agents: true,
2895                ..FilterConfig::default()
2896            }
2897            .to_mode(),
2898            FilterMode::OnlyAgents
2899        ));
2900        assert!(matches!(
2901            FilterConfig {
2902                agents: Some(vec!["coder".into()]),
2903                ..FilterConfig::default()
2904            }
2905            .to_mode(),
2906            FilterMode::Include { .. }
2907        ));
2908        assert!(matches!(
2909            FilterConfig {
2910                exclude: Some(vec!["old".into()]),
2911                ..FilterConfig::default()
2912            }
2913            .to_mode(),
2914            FilterMode::Exclude(_)
2915        ));
2916    }
2917
2918    // === managed_targets tests ===
2919
2920    #[test]
2921    fn managed_targets_defaults_to_no_target_sync_targets() {
2922        let settings = Settings::default();
2923        assert!(settings.managed_targets().is_empty());
2924    }
2925
2926    #[test]
2927    fn managed_targets_uses_explicit_targets() {
2928        let settings = Settings {
2929            targets: Some(vec![".claude".to_string()]),
2930            ..Settings::default()
2931        };
2932        assert_eq!(settings.managed_targets(), vec![".claude"]);
2933    }
2934
2935    #[test]
2936    fn managed_targets_uses_managed_root_as_primary() {
2937        let settings = Settings {
2938            managed_root: Some(".claude".to_string()),
2939            ..Settings::default()
2940        };
2941        assert_eq!(settings.managed_targets(), vec![".claude"]);
2942    }
2943
2944    #[test]
2945    fn managed_targets_explicit_overrides_links_and_managed_root() {
2946        let settings = Settings {
2947            managed_root: Some(".cursor".to_string()),
2948            targets: Some(vec![".codex".to_string()]),
2949            ..Settings::default()
2950        };
2951        // targets takes precedence over managed_root
2952        assert_eq!(settings.managed_targets(), vec![".codex"]);
2953    }
2954
2955    #[test]
2956    fn managed_targets_normalizes_bare_harness_and_generic_links() {
2957        let settings = Settings {
2958            targets: Some(vec![
2959                "codex".to_string(),
2960                "agents".to_string(),
2961                "foo".to_string(),
2962            ]),
2963            ..Settings::default()
2964        };
2965        assert_eq!(
2966            settings.managed_targets(),
2967            vec![
2968                ".codex".to_string(),
2969                ".agents".to_string(),
2970                ".foo".to_string()
2971            ]
2972        );
2973    }
2974
2975    #[test]
2976    fn linked_harnesses_extracts_legacy_path_form_harness_links() {
2977        let settings = Settings {
2978            targets: Some(vec![
2979                ".codex".to_string(),
2980                ".claude".to_string(),
2981                ".agents".to_string(),
2982            ]),
2983            ..Settings::default()
2984        };
2985        assert_eq!(
2986            settings.linked_harnesses(),
2987            vec!["codex".to_string(), "claude".to_string()]
2988        );
2989    }
2990
2991    #[test]
2992    fn merge_warns_when_managed_root_is_agents() {
2993        let config = Config {
2994            settings: Settings {
2995                managed_root: Some(".agents".into()),
2996                ..Settings::default()
2997            },
2998            ..Config::default()
2999        };
3000
3001        let (_, diagnostics) =
3002            merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
3003
3004        assert!(diagnostics.iter().any(|diag| {
3005            diag.code == "deprecated-agents-target"
3006                && diag.context.as_deref() == Some("settings.managed_root")
3007        }));
3008    }
3009
3010    #[test]
3011    fn merge_warns_when_targets_include_agents() {
3012        let config = Config {
3013            settings: Settings {
3014                targets: Some(vec![".agents".into(), ".claude".into()]),
3015                ..Settings::default()
3016            },
3017            ..Config::default()
3018        };
3019
3020        let (_, diagnostics) =
3021            merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
3022
3023        assert!(diagnostics.iter().any(|diag| {
3024            diag.code == "deprecated-agents-target"
3025                && diag.context.as_deref() == Some("settings.targets")
3026        }));
3027    }
3028
3029    #[test]
3030    fn settings_models_cache_ttl_defaults_to_24_when_omitted() {
3031        let config: Config = toml::from_str(
3032            r#"
3033[dependencies.base]
3034url = "https://github.com/org/base.git"
3035"#,
3036        )
3037        .unwrap();
3038        assert_eq!(config.settings.models_cache_ttl_hours, 24);
3039    }
3040
3041    #[test]
3042    fn settings_models_cache_ttl_defaults_to_24_when_settings_present_without_ttl() {
3043        let config: Config = toml::from_str(
3044            r#"
3045[settings]
3046managed_root = ".agents"
3047"#,
3048        )
3049        .unwrap();
3050        assert_eq!(config.settings.models_cache_ttl_hours, 24);
3051    }
3052
3053    #[test]
3054    fn settings_models_cache_ttl_parses_zero() {
3055        let config: Config = toml::from_str(
3056            r#"
3057[settings]
3058models_cache_ttl_hours = 0
3059"#,
3060        )
3061        .unwrap();
3062        assert_eq!(config.settings.models_cache_ttl_hours, 0);
3063    }
3064
3065    #[test]
3066    fn settings_models_cache_ttl_parses_custom_value() {
3067        let config: Config = toml::from_str(
3068            r#"
3069[settings]
3070models_cache_ttl_hours = 48
3071"#,
3072        )
3073        .unwrap();
3074        assert_eq!(config.settings.models_cache_ttl_hours, 48);
3075    }
3076
3077    #[test]
3078    fn settings_models_cache_ttl_roundtrip_preserves_value() {
3079        let original = Config {
3080            settings: Settings {
3081                models_cache_ttl_hours: 48,
3082                ..Settings::default()
3083            },
3084            ..Config::default()
3085        };
3086        let serialized = toml::to_string_pretty(&original).unwrap();
3087        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3088        assert_eq!(
3089            roundtripped.settings.models_cache_ttl_hours,
3090            original.settings.models_cache_ttl_hours
3091        );
3092    }
3093
3094    #[test]
3095    fn settings_agent_emission_parses_auto() {
3096        let config: Config = toml::from_str(
3097            r#"
3098[settings]
3099agent_emission = "auto"
3100"#,
3101        )
3102        .unwrap();
3103        assert_eq!(config.settings.agent_emission, Some(AgentEmission::Auto));
3104    }
3105
3106    #[test]
3107    fn settings_agent_emission_parses_always_and_never() {
3108        let always: Config = toml::from_str(
3109            r#"
3110[settings]
3111agent_emission = "always"
3112"#,
3113        )
3114        .unwrap();
3115        assert_eq!(always.settings.agent_emission, Some(AgentEmission::Always));
3116
3117        let never: Config = toml::from_str(
3118            r#"
3119[settings]
3120agent_emission = "never"
3121"#,
3122        )
3123        .unwrap();
3124        assert_eq!(never.settings.agent_emission, Some(AgentEmission::Never));
3125    }
3126
3127    #[test]
3128    fn settings_agent_emission_defaults_to_auto_when_omitted() {
3129        let config: Config = toml::from_str(
3130            r#"
3131[settings]
3132models_cache_ttl_hours = 48
3133"#,
3134        )
3135        .unwrap();
3136        assert!(config.settings.agent_emission.is_none());
3137    }
3138
3139    #[test]
3140    fn settings_agent_copy_parses() {
3141        let config: Config = toml::from_str(
3142            r#"
3143[settings]
3144targets = [".claude"]
3145
3146[settings.meridian.agent_copy]
3147harnesses = ["claude"]
3148include_fanout = true
3149"#,
3150        )
3151        .unwrap();
3152        let agent_copy = config
3153            .settings
3154            .meridian_agent_copy()
3155            .expect("agent_copy should parse")
3156            .clone();
3157        assert_eq!(agent_copy.harnesses, vec!["claude".to_string()]);
3158        assert!(agent_copy.include_fanout);
3159    }
3160
3161    #[test]
3162    fn settings_default_harness_parses_and_roundtrips() {
3163        let config: Config = toml::from_str(
3164            r#"
3165[settings]
3166default_harness = "codex"
3167"#,
3168        )
3169        .unwrap();
3170        assert_eq!(config.settings.default_harness.as_deref(), Some("codex"));
3171
3172        let serialized = toml::to_string_pretty(&config).unwrap();
3173        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3174        assert_eq!(
3175            roundtripped.settings.default_harness,
3176            config.settings.default_harness
3177        );
3178    }
3179
3180    #[test]
3181    fn settings_default_model_parses_and_roundtrips() {
3182        let config: Config = toml::from_str(
3183            r#"
3184[settings]
3185default_model = "gpt-5.4-mini"
3186"#,
3187        )
3188        .unwrap();
3189        assert_eq!(
3190            config.settings.default_model.as_deref(),
3191            Some("gpt-5.4-mini")
3192        );
3193
3194        let serialized = toml::to_string_pretty(&config).unwrap();
3195        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3196        assert_eq!(
3197            roundtripped.settings.default_model,
3198            config.settings.default_model
3199        );
3200    }
3201
3202    #[test]
3203    fn settings_harness_order_parses_and_roundtrips() {
3204        let config: Config = toml::from_str(
3205            r#"
3206[settings]
3207harness_order = ["pi", "opencode", "codex", "claude"]
3208"#,
3209        )
3210        .unwrap();
3211        assert_eq!(
3212            config.settings.harness_order,
3213            Some(vec![
3214                "pi".to_string(),
3215                "opencode".to_string(),
3216                "codex".to_string(),
3217                "claude".to_string()
3218            ])
3219        );
3220
3221        let serialized = toml::to_string_pretty(&config).unwrap();
3222        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3223        assert_eq!(
3224            roundtripped.settings.harness_order,
3225            config.settings.harness_order
3226        );
3227    }
3228
3229    #[test]
3230    fn settings_agent_emission_roundtrip_preserves_value() {
3231        let original = Config {
3232            settings: Settings {
3233                agent_emission: Some(AgentEmission::Always),
3234                ..Settings::default()
3235            },
3236            ..Config::default()
3237        };
3238        let serialized = toml::to_string_pretty(&original).unwrap();
3239        let roundtripped: Config = toml::from_str(&serialized).unwrap();
3240        assert_eq!(
3241            roundtripped.settings.agent_emission,
3242            original.settings.agent_emission
3243        );
3244    }
3245
3246    #[test]
3247    fn model_visibility_validate_allows_include_and_exclude() {
3248        let visibility = ModelVisibility {
3249            include: Some(vec!["opus*".into()]),
3250            exclude: Some(vec!["test*".into()]),
3251        };
3252        visibility.validate().unwrap();
3253    }
3254
3255    #[test]
3256    fn model_visibility_validate_allows_include_only_exclude_only_and_empty() {
3257        ModelVisibility {
3258            include: Some(vec!["opus*".into()]),
3259            exclude: None,
3260        }
3261        .validate()
3262        .unwrap();
3263        ModelVisibility {
3264            include: None,
3265            exclude: Some(vec!["test*".into()]),
3266        }
3267        .validate()
3268        .unwrap();
3269        ModelVisibility::default().validate().unwrap();
3270    }
3271
3272    #[test]
3273    fn model_visibility_is_empty_reports_state() {
3274        assert!(ModelVisibility::default().is_empty());
3275        assert!(
3276            !ModelVisibility {
3277                include: Some(vec!["opus*".into()]),
3278                exclude: None,
3279            }
3280            .is_empty()
3281        );
3282        assert!(
3283            !ModelVisibility {
3284                include: None,
3285                exclude: Some(vec!["test*".into()]),
3286            }
3287            .is_empty()
3288        );
3289    }
3290
3291    #[test]
3292    fn load_accepts_model_visibility_with_include_and_exclude() {
3293        let dir = TempDir::new().unwrap();
3294        std::fs::write(
3295            dir.path().join("mars.toml"),
3296            r#"
3297[settings.model_visibility]
3298include = ["opus*"]
3299exclude = ["test*"]
3300"#,
3301        )
3302        .unwrap();
3303
3304        let config = load(dir.path()).unwrap();
3305        assert_eq!(
3306            config.settings.model_visibility.include,
3307            Some(vec!["opus*".into()])
3308        );
3309        assert_eq!(
3310            config.settings.model_visibility.exclude,
3311            Some(vec!["test*".into()])
3312        );
3313    }
3314
3315    #[test]
3316    fn load_accepts_model_visibility_include_only() {
3317        let dir = TempDir::new().unwrap();
3318        std::fs::write(
3319            dir.path().join("mars.toml"),
3320            r#"
3321[settings.model_visibility]
3322include = ["opus*", "gpt-*"]
3323"#,
3324        )
3325        .unwrap();
3326
3327        let config = load(dir.path()).unwrap();
3328        assert_eq!(
3329            config.settings.model_visibility.include,
3330            Some(vec!["opus*".into(), "gpt-*".into()])
3331        );
3332        assert!(config.settings.model_visibility.exclude.is_none());
3333    }
3334
3335    #[test]
3336    fn load_accepts_model_visibility_exclude_only() {
3337        let dir = TempDir::new().unwrap();
3338        std::fs::write(
3339            dir.path().join("mars.toml"),
3340            r#"
3341[settings.model_visibility]
3342exclude = ["test-*", "deprecated-*"]
3343"#,
3344        )
3345        .unwrap();
3346
3347        let config = load(dir.path()).unwrap();
3348        assert_eq!(
3349            config.settings.model_visibility.exclude,
3350            Some(vec!["test-*".into(), "deprecated-*".into()])
3351        );
3352        assert!(config.settings.model_visibility.include.is_none());
3353    }
3354
3355    // === local-dependencies tests ===
3356
3357    #[test]
3358    fn parse_local_dependencies() {
3359        let toml_str = r#"
3360[dependencies.base]
3361url = "https://github.com/org/base.git"
3362
3363[local-dependencies.prompter]
3364url = "https://github.com/org/prompter.git"
3365skills = ["prompt-helper"]
3366"#;
3367        let config: Config = toml::from_str(toml_str).unwrap();
3368        assert_eq!(config.dependencies.len(), 1);
3369        assert_eq!(config.local_dependencies.len(), 1);
3370        assert!(config.local_dependencies.contains_key("prompter"));
3371        assert_eq!(
3372            config.local_dependencies["prompter"].url.as_deref(),
3373            Some("https://github.com/org/prompter.git")
3374        );
3375    }
3376
3377    #[test]
3378    fn local_dependencies_merged_into_effective_config() {
3379        let toml_str = r#"
3380[dependencies.base]
3381url = "https://github.com/org/base.git"
3382
3383[local-dependencies.prompter]
3384url = "https://github.com/org/prompter.git"
3385"#;
3386        let config: Config = toml::from_str(toml_str).unwrap();
3387        let local = LocalConfig::default();
3388        let effective = merge(config, local).unwrap();
3389
3390        // Both deps should be in effective config
3391        assert_eq!(effective.dependencies.len(), 2);
3392        assert!(effective.dependencies.contains_key("base"));
3393        assert!(effective.dependencies.contains_key("prompter"));
3394    }
3395
3396    #[test]
3397    fn local_dependencies_not_exported_to_manifest() {
3398        let dir = TempDir::new().unwrap();
3399        std::fs::write(
3400            dir.path().join("mars.toml"),
3401            r#"
3402[package]
3403name = "my-package"
3404version = "1.0.0"
3405
3406[dependencies.base]
3407url = "https://github.com/org/base.git"
3408
3409[local-dependencies.prompter]
3410url = "https://github.com/org/prompter.git"
3411"#,
3412        )
3413        .unwrap();
3414
3415        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
3416        assert!(diagnostics.is_empty());
3417        let manifest = manifest.unwrap();
3418
3419        // Only base should be in manifest, not prompter
3420        assert_eq!(manifest.dependencies.len(), 1);
3421        assert!(manifest.dependencies.contains_key("base"));
3422        assert!(!manifest.dependencies.contains_key("prompter"));
3423    }
3424
3425    #[test]
3426    fn error_on_duplicate_name_across_sections() {
3427        let toml_str = r#"
3428[dependencies.base]
3429url = "https://github.com/org/base.git"
3430
3431[local-dependencies.base]
3432url = "https://github.com/org/base-local.git"
3433"#;
3434        let config: Config = toml::from_str(toml_str).unwrap();
3435        let local = LocalConfig::default();
3436        let result = merge(config, local);
3437        assert!(result.is_err());
3438        let err = result.unwrap_err().to_string();
3439        assert!(
3440            err.contains("base") && err.contains("both"),
3441            "should reject duplicate name: {err}"
3442        );
3443    }
3444
3445    #[test]
3446    fn local_dependencies_roundtrip() {
3447        let dir = TempDir::new().unwrap();
3448        let original = r#"
3449[dependencies.base]
3450url = "https://github.com/org/base.git"
3451
3452[local-dependencies.prompter]
3453url = "https://github.com/org/prompter.git"
3454skills = ["prompt-helper"]
3455"#;
3456        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
3457
3458        let config = load(dir.path()).unwrap();
3459        save(dir.path(), &config).unwrap();
3460        let reloaded = load(dir.path()).unwrap();
3461
3462        assert_eq!(reloaded.dependencies.len(), 1);
3463        assert_eq!(reloaded.local_dependencies.len(), 1);
3464        assert!(reloaded.local_dependencies.contains_key("prompter"));
3465        assert_eq!(
3466            reloaded.local_dependencies["prompter"]
3467                .filter
3468                .skills
3469                .as_deref(),
3470            Some(&["prompt-helper".into()][..])
3471        );
3472    }
3473
3474    #[test]
3475    fn path_with_backslashes_serializes_as_forward_slashes() {
3476        let mut deps = IndexMap::new();
3477        deps.insert(
3478            SourceName::from("test-src"),
3479            InstallDep {
3480                url: None,
3481                path: Some(PathBuf::from("C:\\Users\\dev\\src")),
3482                subpath: None,
3483                version: None,
3484                dialect: None,
3485                filter: FilterConfig::default(),
3486            },
3487        );
3488        let config = Config {
3489            dependencies: deps,
3490            ..Config::default()
3491        };
3492        let toml_str = toml::to_string_pretty(&config).unwrap();
3493        assert!(
3494            !toml_str.contains('\\'),
3495            "TOML output must not contain backslashes: {toml_str}"
3496        );
3497        assert!(
3498            toml_str.contains("C:/Users/dev/src"),
3499            "expected forward-slash path in TOML: {toml_str}"
3500        );
3501        let reparsed: Config = toml::from_str(&toml_str).unwrap();
3502        assert_eq!(
3503            reparsed.dependencies["test-src"].path.as_ref().unwrap(),
3504            &PathBuf::from("C:/Users/dev/src"),
3505        );
3506    }
3507
3508    #[test]
3509    fn override_path_serializes_forward_slashes() {
3510        let mut overrides = IndexMap::new();
3511        overrides.insert(
3512            SourceName::from("my-dep"),
3513            OverrideEntry {
3514                path: PathBuf::from("C:\\Users\\dev\\local-pkg"),
3515            },
3516        );
3517        let local = LocalConfig {
3518            overrides,
3519            ..LocalConfig::default()
3520        };
3521        let toml_str = toml::to_string_pretty(&local).unwrap();
3522        assert!(
3523            !toml_str.contains('\\'),
3524            "local config TOML must not contain backslashes: {toml_str}"
3525        );
3526        assert!(
3527            toml_str.contains("C:/Users/dev/local-pkg"),
3528            "expected forward-slash override path: {toml_str}"
3529        );
3530    }
3531}