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