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