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