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