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