Skip to main content

mars_agents/config/
mod.rs

1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::diagnostic::{Diagnostic, DiagnosticLevel};
7use crate::error::{ConfigError, MarsError};
8use crate::types::{
9    ItemName, RenameMap, SourceId, SourceName, SourceOrigin, SourceSubpath, SourceUrl,
10};
11
12/// Top-level mars.toml configuration.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
14pub struct Config {
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub package: Option<PackageInfo>,
17    #[serde(default)]
18    pub dependencies: IndexMap<SourceName, InstallDep>,
19    /// Local-only dependencies — installed when syncing this repo but NOT
20    /// exported to consumers via manifest. Use for dev tooling, prompt
21    /// authoring helpers, etc.
22    #[serde(
23        default,
24        skip_serializing_if = "IndexMap::is_empty",
25        rename = "local-dependencies"
26    )]
27    pub local_dependencies: IndexMap<SourceName, InstallDep>,
28    #[serde(default)]
29    pub settings: Settings,
30    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
31    pub models: IndexMap<String, crate::models::ModelAlias>,
32}
33
34/// Package metadata.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct PackageInfo {
37    pub name: String,
38    pub version: String,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub description: Option<String>,
41}
42
43/// Consumer install intent — what goes in [dependencies] of a consumer mars.toml.
44/// Has optional URL or path source plus filters for selecting items.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct InstallDep {
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub url: Option<SourceUrl>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub path: Option<PathBuf>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub subpath: Option<SourceSubpath>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub version: Option<String>,
55    #[serde(flatten)]
56    pub filter: FilterConfig,
57}
58
59/// Backwards-compatible alias during migration.
60pub type DependencyEntry = InstallDep;
61
62/// Package manifest dependency — what a package declares its consumers need.
63/// Supports both URL (for remote consumers) and path (for local development).
64#[derive(Debug, Clone, PartialEq)]
65pub struct ManifestDep {
66    pub url: Option<SourceUrl>,
67    pub path: Option<PathBuf>,
68    pub subpath: Option<SourceSubpath>,
69    pub version: Option<String>,
70    pub filter: FilterConfig,
71}
72
73/// Source-manifest view extracted from mars.toml.
74///
75/// In source repositories, `mars.toml` may include `[package]` +
76/// `[dependencies]` only, or coexist with consumer sections.
77/// Dependencies are ManifestDep (URL or path, matching the source config).
78#[derive(Debug, Clone, PartialEq)]
79pub struct Manifest {
80    pub package: PackageInfo,
81    pub dependencies: IndexMap<String, ManifestDep>,
82    pub models: IndexMap<String, crate::models::ModelAlias>,
83}
84
85/// Shared include/exclude/rename filter configuration for a source.
86#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
87pub struct FilterConfig {
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub agents: Option<Vec<ItemName>>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub skills: Option<Vec<ItemName>>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub exclude: Option<Vec<ItemName>>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub rename: Option<RenameMap>,
96    #[serde(default, skip_serializing_if = "is_false")]
97    pub only_skills: bool,
98    #[serde(default, skip_serializing_if = "is_false")]
99    pub only_agents: bool,
100}
101
102/// Display visibility filter for `mars models list`.
103/// Consumer-only — lives under [settings], not [models].
104#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
105pub struct ModelVisibility {
106    /// Show only aliases matching these glob patterns.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub include: Option<Vec<String>>,
109    /// Hide aliases matching these glob patterns.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub exclude: Option<Vec<String>>,
112}
113
114impl ModelVisibility {
115    pub fn validate(&self) -> Result<(), MarsError> {
116        Ok(())
117    }
118
119    pub fn is_empty(&self) -> bool {
120        self.include.is_none() && self.exclude.is_none()
121    }
122}
123
124fn is_false(v: &bool) -> bool {
125    !v
126}
127
128/// Dev override config (mars.local.toml).
129///
130/// Gitignored — each developer can work with local checkouts while
131/// production config points at git.
132#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
133pub struct LocalConfig {
134    #[serde(default)]
135    pub overrides: IndexMap<SourceName, OverrideEntry>,
136}
137
138/// Dev override — local path swap for a git source.
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140pub struct OverrideEntry {
141    pub path: PathBuf,
142}
143
144/// Global settings — extensible via additional fields.
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub struct Settings {
147    /// Custom managed output directory (e.g. ".claude"). Default: ".agents".
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub managed_root: Option<String>,
150    /// Managed target directories materialized from .mars/ canonical store.
151    /// When set, only listed targets are populated. When unset, defaults to [".agents"].
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub targets: Option<Vec<String>>,
154    #[serde(default, skip_serializing_if = "ModelVisibility::is_empty")]
155    pub model_visibility: ModelVisibility,
156    #[serde(default = "default_models_cache_ttl_hours")]
157    pub models_cache_ttl_hours: u32,
158}
159
160impl Default for Settings {
161    fn default() -> Self {
162        Self {
163            managed_root: None,
164            targets: None,
165            model_visibility: ModelVisibility::default(),
166            models_cache_ttl_hours: default_models_cache_ttl_hours(),
167        }
168    }
169}
170
171fn default_models_cache_ttl_hours() -> u32 {
172    24
173}
174
175impl Settings {
176    /// Returns the effective list of managed target directories.
177    ///
178    /// - If `targets` is explicitly set, returns exactly those targets.
179    /// - If `targets` is unset, uses `managed_root` (or ".agents" default).
180    pub fn managed_targets(&self) -> Vec<String> {
181        if let Some(targets) = &self.targets {
182            return targets.clone();
183        }
184        vec![
185            self.managed_root
186                .clone()
187                .unwrap_or_else(|| ".agents".to_string()),
188        ]
189    }
190}
191
192/// Resolved source specification after merging config and overrides.
193#[derive(Debug, Clone)]
194pub enum SourceSpec {
195    Git(GitSpec),
196    Path(PathBuf),
197}
198
199/// Git source specification preserved when overrides are active.
200#[derive(Debug, Clone)]
201pub struct GitSpec {
202    pub url: SourceUrl,
203    pub version: Option<String>,
204}
205
206/// How items are filtered from a source.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum FilterMode {
209    /// Install everything from the source.
210    All,
211    /// Only install specific agents and/or skills.
212    Include {
213        agents: Vec<ItemName>,
214        skills: Vec<ItemName>,
215    },
216    /// Install everything except these items.
217    Exclude(Vec<ItemName>),
218    /// Install only skills, no agents.
219    OnlySkills,
220    /// Install only agents plus their transitive skill dependencies.
221    OnlyAgents,
222}
223
224/// Effective configuration after merging mars.toml and mars.local.toml.
225///
226/// This is what the rest of the pipeline operates on.
227#[derive(Debug, Clone)]
228pub struct EffectiveConfig {
229    pub dependencies: IndexMap<SourceName, EffectiveDependency>,
230    pub settings: Settings,
231}
232
233/// A fully-resolved source with override tracking.
234#[derive(Debug, Clone)]
235pub struct EffectiveDependency {
236    pub name: SourceName,
237    pub id: SourceId,
238    pub spec: SourceSpec,
239    pub subpath: Option<SourceSubpath>,
240    pub filter: FilterMode,
241    pub rename: RenameMap,
242    pub is_overridden: bool,
243    pub original_git: Option<GitSpec>,
244}
245
246const CONFIG_FILE: &str = "mars.toml";
247const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
248
249/// Load mars.toml from the given root directory.
250pub fn load(root: &Path) -> Result<Config, MarsError> {
251    let path = root.join(CONFIG_FILE);
252    let content = std::fs::read_to_string(&path).map_err(|e| {
253        if e.kind() == std::io::ErrorKind::NotFound {
254            ConfigError::NotFound { path: path.clone() }
255        } else {
256            ConfigError::Io(e)
257        }
258    })?;
259    let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
260    migrate_legacy_source_urls(&mut config);
261    config.settings.model_visibility.validate()?;
262    Ok(config)
263}
264
265/// Load source manifest data from mars.toml in a source tree root.
266///
267/// Returns `None` when mars.toml is absent or when it has no `[package]`
268/// section (consumer config only).
269///
270/// Converts `InstallDep` entries to `ManifestDep`, preserving both URL and
271/// path dependencies.
272pub fn load_manifest(source_root: &Path) -> Result<(Option<Manifest>, Vec<Diagnostic>), MarsError> {
273    let path = source_root.join(CONFIG_FILE);
274    let diagnostics = Vec::new();
275    match std::fs::read_to_string(&path) {
276        Ok(content) => {
277            let parsed: Config =
278                toml::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
279                    message: format!("failed to parse {}: {e}", path.display()),
280                })?;
281            let Some(package) = parsed.package else {
282                return Ok((None, diagnostics));
283            };
284            // Convert InstallDep → ManifestDep, preserving both URL and path deps
285            let deps: IndexMap<String, ManifestDep> = parsed
286                .dependencies
287                .into_iter()
288                .map(|(name, entry)| {
289                    (
290                        name.to_string(),
291                        ManifestDep {
292                            url: entry.url,
293                            path: entry.path,
294                            subpath: entry.subpath,
295                            version: entry.version,
296                            filter: entry.filter,
297                        },
298                    )
299                })
300                .collect();
301            Ok((
302                Some(Manifest {
303                    package,
304                    dependencies: deps,
305                    models: parsed.models,
306                }),
307                diagnostics,
308            ))
309        }
310        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((None, diagnostics)),
311        Err(source) => Err(MarsError::Io {
312            operation: "read manifest config".to_string(),
313            path,
314            source,
315        }),
316    }
317}
318
319/// Load mars.local.toml (returns Default if absent).
320pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
321    let path = root.join(LOCAL_CONFIG_FILE);
322    match std::fs::read_to_string(&path) {
323        Ok(content) => {
324            let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
325            Ok(local)
326        }
327        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
328        Err(e) => Err(ConfigError::Io(e).into()),
329    }
330}
331
332/// Merge config + local overrides into EffectiveConfig.
333///
334/// Validates:
335/// - Each source has `url` XOR `path` (not both, not neither)
336/// - Each source uses either include filters (`agents`/`skills`) or `exclude`, not both
337/// - Collects diagnostics if an override references a source name not in config
338pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
339    let (effective, _diagnostics) = merge_with_root(config, local, Path::new("."))?;
340    Ok(effective)
341}
342
343/// Same as `merge`, but uses an explicit root for path-based SourceId canonicalization.
344pub fn merge_with_root(
345    config: Config,
346    local: LocalConfig,
347    root: &Path,
348) -> Result<(EffectiveConfig, Vec<Diagnostic>), MarsError> {
349    config.settings.model_visibility.validate()?;
350    let mut dependencies = IndexMap::new();
351    let mut diagnostics = Vec::new();
352    let local_source_name = SourceOrigin::LocalPackage.to_string();
353
354    // Process both regular and local dependencies into the same effective map.
355    // Local deps are installed locally but not exported to consumers via manifest.
356    let all_deps = config
357        .dependencies
358        .iter()
359        .chain(config.local_dependencies.iter());
360
361    for (name, entry) in all_deps {
362        // Reject reserved name
363        if name.as_ref() == local_source_name.as_str() {
364            return Err(ConfigError::Invalid {
365                message: "dependency name `_self` is reserved for local package items".into(),
366            }
367            .into());
368        }
369
370        // Reject duplicate names across sections
371        if dependencies.contains_key(name) {
372            return Err(ConfigError::Invalid {
373                message: format!(
374                    "dependency `{name}` appears in both [dependencies] and [local-dependencies]"
375                ),
376            }
377            .into());
378        }
379
380        // Validate url XOR path
381        let base_spec = match (&entry.url, &entry.path) {
382            (Some(url), None) => SourceSpec::Git(GitSpec {
383                url: url.clone(),
384                version: entry.version.clone(),
385            }),
386            (None, Some(path)) => SourceSpec::Path(path.clone()),
387            (Some(_), Some(_)) => {
388                return Err(ConfigError::Invalid {
389                    message: format!("source `{name}` has both `url` and `path` — pick one"),
390                }
391                .into());
392            }
393            (None, None) => {
394                return Err(ConfigError::Invalid {
395                    message: format!(
396                        "source `{name}` has neither `url` nor `path` — one is required"
397                    ),
398                }
399                .into());
400            }
401        };
402
403        // Validate filter combinations
404        validate_filter(&entry.filter, name.as_ref())?;
405
406        let filter = entry.filter.to_mode();
407
408        let rename = entry.filter.rename.clone().unwrap_or_default();
409
410        // Check if this source has a local override
411        let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
412            let original = match &base_spec {
413                SourceSpec::Git(git) => Some(git.clone()),
414                SourceSpec::Path(_) => None,
415            };
416            (SourceSpec::Path(ov.path.clone()), true, original)
417        } else {
418            (base_spec, false, None)
419        };
420        let subpath = entry.subpath.clone();
421        let id = source_id_for_spec(root, &spec, subpath.clone());
422
423        dependencies.insert(
424            name.clone(),
425            EffectiveDependency {
426                name: name.clone(),
427                id,
428                spec,
429                subpath,
430                filter,
431                rename,
432                is_overridden,
433                original_git,
434            },
435        );
436    }
437
438    // Warn if override references a dependency not in config
439    for override_name in local.overrides.keys() {
440        if !config.dependencies.contains_key(override_name) {
441            diagnostics.push(Diagnostic {
442                level: DiagnosticLevel::Warning,
443                code: "override-missing-dep",
444                message: format!(
445                    "override `{override_name}` references a dependency not in mars.toml"
446                ),
447                context: None,
448            });
449        }
450    }
451
452    Ok((
453        EffectiveConfig {
454            dependencies,
455            settings: config.settings,
456        },
457        diagnostics,
458    ))
459}
460
461/// Validate filter configuration for consistency.
462///
463/// Rejects invalid combinations:
464/// - `only_skills` and `only_agents` together
465/// - category-only flags with include lists
466/// - category-only flags with exclude
467/// - include lists with exclude
468pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
469    let has_include = filter.agents.is_some() || filter.skills.is_some();
470    let has_exclude = filter.exclude.is_some();
471    let has_category = filter.only_skills || filter.only_agents;
472
473    if filter.only_skills && filter.only_agents {
474        return Err(ConfigError::Invalid {
475            message: format!(
476                "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
477            ),
478        }
479        .into());
480    }
481    if has_category && has_include {
482        return Err(ConfigError::Invalid {
483            message: format!(
484                "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
485            ),
486        }
487        .into());
488    }
489    if has_category && has_exclude {
490        return Err(ConfigError::Invalid {
491            message: format!(
492                "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
493            ),
494        }
495        .into());
496    }
497    if has_include && has_exclude {
498        return Err(ConfigError::ConflictingFilters {
499            name: dep_name.to_string(),
500        }
501        .into());
502    }
503    Ok(())
504}
505
506impl FilterConfig {
507    /// Convert to the resolved FilterMode enum.
508    pub fn to_mode(&self) -> FilterMode {
509        if self.only_skills {
510            FilterMode::OnlySkills
511        } else if self.only_agents {
512            FilterMode::OnlyAgents
513        } else if self.agents.is_some() || self.skills.is_some() {
514            FilterMode::Include {
515                agents: self.agents.clone().unwrap_or_default(),
516                skills: self.skills.clone().unwrap_or_default(),
517            }
518        } else if self.exclude.is_some() {
519            FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
520        } else {
521            FilterMode::All
522        }
523    }
524
525    /// Returns true if any filter field is set (not default).
526    pub fn has_any_filter(&self) -> bool {
527        self.agents.is_some()
528            || self.skills.is_some()
529            || self.exclude.is_some()
530            || self.only_skills
531            || self.only_agents
532    }
533}
534
535fn source_id_for_spec(root: &Path, spec: &SourceSpec, subpath: Option<SourceSubpath>) -> SourceId {
536    match spec {
537        SourceSpec::Git(git) => SourceId::git_with_subpath(git.url.clone(), subpath.clone()),
538        SourceSpec::Path(path) => match SourceId::path_with_subpath(root, path, subpath.clone()) {
539            Ok(id) => id,
540            Err(_) => {
541                let canonical = if path.is_absolute() {
542                    path.clone()
543                } else {
544                    root.join(path)
545                };
546                SourceId::Path { canonical, subpath }
547            }
548        },
549    }
550}
551
552fn migrate_legacy_source_urls(config: &mut Config) {
553    for dep in config
554        .dependencies
555        .values_mut()
556        .chain(config.local_dependencies.values_mut())
557    {
558        if let Some(url) = dep.url.as_mut() {
559            let raw = url.as_str();
560            if should_upgrade_legacy_git_url(raw) {
561                *url = SourceUrl::from(format!("https://{raw}"));
562            }
563        }
564    }
565}
566
567fn should_upgrade_legacy_git_url(url: &str) -> bool {
568    !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
569}
570
571/// Write mars.toml atomically.
572pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
573    let path = root.join(CONFIG_FILE);
574    let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
575        message: format!("failed to serialize config: {e}"),
576    })?;
577    let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
578        message: format!("refusing to save config: serialized output failed to parse: {e}"),
579    })?;
580    validate_save_roundtrip(config, &reparsed)?;
581    crate::fs::atomic_write(&path, content.as_bytes())
582}
583
584fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
585    if reparsed.dependencies.len() != original.dependencies.len() {
586        return Err(ConfigError::Invalid {
587            message: format!(
588                "refusing to save config: dependency count changed during roundtrip ({} -> {})",
589                original.dependencies.len(),
590                reparsed.dependencies.len()
591            ),
592        }
593        .into());
594    }
595
596    if reparsed.local_dependencies.len() != original.local_dependencies.len() {
597        return Err(ConfigError::Invalid {
598            message: format!(
599                "refusing to save config: local-dependencies count changed during roundtrip ({} -> {})",
600                original.local_dependencies.len(),
601                reparsed.local_dependencies.len()
602            ),
603        }
604        .into());
605    }
606
607    if reparsed.settings.managed_root != original.settings.managed_root {
608        return Err(ConfigError::Invalid {
609            message: format!(
610                "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
611                original.settings.managed_root, reparsed.settings.managed_root
612            ),
613        }
614        .into());
615    }
616    if reparsed.settings.model_visibility != original.settings.model_visibility {
617        return Err(ConfigError::Invalid {
618            message: format!(
619                "refusing to save config: settings.model_visibility changed during roundtrip ({:?} -> {:?})",
620                original.settings.model_visibility, reparsed.settings.model_visibility
621            ),
622        }
623        .into());
624    }
625
626    for (name, dep) in &original.dependencies {
627        let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
628            return Err(ConfigError::Invalid {
629                message: format!(
630                    "refusing to save config: dependency `{name}` missing after roundtrip"
631                ),
632            }
633            .into());
634        };
635
636        if reparsed_dep != dep {
637            return Err(ConfigError::Invalid {
638                message: format!(
639                    "refusing to save config: dependency `{name}` changed during roundtrip"
640                ),
641            }
642            .into());
643        }
644    }
645
646    for (name, dep) in &original.local_dependencies {
647        let Some(reparsed_dep) = reparsed.local_dependencies.get(name) else {
648            return Err(ConfigError::Invalid {
649                message: format!(
650                    "refusing to save config: local-dependency `{name}` missing after roundtrip"
651                ),
652            }
653            .into());
654        };
655
656        if reparsed_dep != dep {
657            return Err(ConfigError::Invalid {
658                message: format!(
659                    "refusing to save config: local-dependency `{name}` changed during roundtrip"
660                ),
661            }
662            .into());
663        }
664    }
665
666    Ok(())
667}
668
669/// Write mars.local.toml atomically.
670pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
671    let path = root.join(LOCAL_CONFIG_FILE);
672    let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
673        message: format!("failed to serialize local config: {e}"),
674    })?;
675    crate::fs::atomic_write(&path, content.as_bytes())
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use tempfile::TempDir;
682
683    #[test]
684    fn parse_git_dependency() {
685        let toml_str = r#"
686[dependencies.base]
687url = "https://github.com/org/base.git"
688version = "v1.0"
689"#;
690        let config: Config = toml::from_str(toml_str).unwrap();
691        assert_eq!(config.dependencies.len(), 1);
692        let entry = &config.dependencies["base"];
693        assert_eq!(
694            entry.url.as_deref(),
695            Some("https://github.com/org/base.git")
696        );
697        assert!(entry.path.is_none());
698        assert_eq!(entry.version.as_deref(), Some("v1.0"));
699    }
700
701    #[test]
702    fn parse_path_dependency() {
703        let toml_str = r#"
704[dependencies.local]
705path = "../my-agents"
706"#;
707        let config: Config = toml::from_str(toml_str).unwrap();
708        let entry = &config.dependencies["local"];
709        assert!(entry.url.is_none());
710        assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
711    }
712
713    #[test]
714    fn parse_mixed_dependencies() {
715        let toml_str = r#"
716[dependencies.remote]
717url = "https://github.com/org/remote.git"
718version = "v2.0"
719agents = ["coder", "reviewer"]
720
721[dependencies.local]
722path = "/home/dev/agents"
723exclude = ["experimental"]
724"#;
725        let config: Config = toml::from_str(toml_str).unwrap();
726        assert_eq!(config.dependencies.len(), 2);
727        assert!(config.dependencies.contains_key("remote"));
728        assert!(config.dependencies.contains_key("local"));
729    }
730
731    #[test]
732    fn parse_package_and_dependencies_coexist() {
733        let toml_str = r#"
734[package]
735name = "my-agents"
736version = "0.1.0"
737
738[dependencies.base]
739url = "https://github.com/org/base.git"
740version = ">=1.0.0"
741
742[dependencies.local]
743path = "../local-agents"
744"#;
745        let config: Config = toml::from_str(toml_str).unwrap();
746        assert!(config.package.is_some());
747        assert!(config.dependencies.contains_key("base"));
748        assert!(config.dependencies.contains_key("local"));
749    }
750
751    #[test]
752    fn parse_include_filter() {
753        let toml_str = r#"
754[dependencies.base]
755url = "https://github.com/org/base.git"
756agents = ["coder"]
757skills = ["review"]
758"#;
759        let config: Config = toml::from_str(toml_str).unwrap();
760        let local = LocalConfig::default();
761        let effective = merge(config, local).unwrap();
762        let source = &effective.dependencies["base"];
763        match &source.filter {
764            FilterMode::Include { agents, skills } => {
765                assert_eq!(agents, &["coder"]);
766                assert_eq!(skills, &["review"]);
767            }
768            other => panic!("expected Include, got {other:?}"),
769        }
770    }
771
772    #[test]
773    fn parse_exclude_filter() {
774        let toml_str = r#"
775[dependencies.base]
776url = "https://github.com/org/base.git"
777exclude = ["experimental", "deprecated"]
778"#;
779        let config: Config = toml::from_str(toml_str).unwrap();
780        let local = LocalConfig::default();
781        let effective = merge(config, local).unwrap();
782        let source = &effective.dependencies["base"];
783        match &source.filter {
784            FilterMode::Exclude(items) => {
785                assert_eq!(items, &["experimental", "deprecated"]);
786            }
787            other => panic!("expected Exclude, got {other:?}"),
788        }
789    }
790
791    #[test]
792    fn error_on_both_include_and_exclude() {
793        let toml_str = r#"
794[dependencies.bad]
795url = "https://github.com/org/bad.git"
796agents = ["coder"]
797exclude = ["reviewer"]
798"#;
799        let config: Config = toml::from_str(toml_str).unwrap();
800        let local = LocalConfig::default();
801        let result = merge(config, local);
802        assert!(result.is_err());
803        let err = result.unwrap_err().to_string();
804        assert!(
805            err.contains("bad"),
806            "error should mention dependency name: {err}"
807        );
808    }
809
810    #[test]
811    fn error_on_neither_url_nor_path() {
812        let toml_str = r#"
813[dependencies.empty]
814version = "v1.0"
815"#;
816        let config: Config = toml::from_str(toml_str).unwrap();
817        let local = LocalConfig::default();
818        let result = merge(config, local);
819        assert!(result.is_err());
820        let err = result.unwrap_err().to_string();
821        assert!(
822            err.contains("neither"),
823            "error should mention 'neither': {err}"
824        );
825    }
826
827    #[test]
828    fn error_on_both_url_and_path() {
829        let toml_str = r#"
830[dependencies.both]
831url = "https://github.com/org/repo.git"
832path = "/local/path"
833"#;
834        let config: Config = toml::from_str(toml_str).unwrap();
835        let local = LocalConfig::default();
836        let result = merge(config, local);
837        assert!(result.is_err());
838        let err = result.unwrap_err().to_string();
839        assert!(err.contains("both"), "error should mention 'both': {err}");
840    }
841
842    #[test]
843    fn roundtrip_full_config_shape_survives_save() {
844        let dir = TempDir::new().unwrap();
845        let original = r#"
846[package]
847name = "sample"
848version = "0.1.0"
849description = "sample package"
850
851[dependencies.base]
852url = "https://github.com/org/base.git"
853version = "v1.0"
854agents = ["coder", "reviewer"]
855
856[dependencies.local]
857path = "../local-agents"
858exclude = ["experimental"]
859
860[settings]
861managed_root = ".custom-agents"
862targets = [".claude", ".cursor"]
863"#;
864        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
865
866        let config = load(dir.path()).unwrap();
867        save(dir.path(), &config).unwrap();
868        let reloaded = load(dir.path()).unwrap();
869
870        assert_eq!(
871            reloaded.package.as_ref().map(|p| p.name.as_str()),
872            Some("sample")
873        );
874        assert_eq!(reloaded.dependencies.len(), 2);
875        assert_eq!(
876            reloaded.dependencies["base"].url.as_deref(),
877            Some("https://github.com/org/base.git")
878        );
879        assert_eq!(
880            reloaded.dependencies["local"].path.as_deref(),
881            Some(Path::new("../local-agents"))
882        );
883        assert_eq!(
884            reloaded.settings.managed_root.as_deref(),
885            Some(".custom-agents")
886        );
887        assert_eq!(
888            reloaded.settings.targets,
889            Some(vec![".claude".to_string(), ".cursor".to_string()])
890        );
891    }
892
893    #[test]
894    fn load_from_disk() {
895        let dir = TempDir::new().unwrap();
896        let toml_str = r#"
897[dependencies.base]
898url = "https://github.com/org/base.git"
899version = "v1.0"
900"#;
901        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
902        let config = load(dir.path()).unwrap();
903        assert_eq!(config.dependencies.len(), 1);
904    }
905
906    #[test]
907    fn load_migrates_legacy_bare_domain_url() {
908        let dir = TempDir::new().unwrap();
909        let toml_str = r#"
910[dependencies.base]
911url = "github.com/org/base"
912"#;
913        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
914
915        let config = load(dir.path()).unwrap();
916        assert_eq!(
917            config.dependencies["base"].url.as_deref(),
918            Some("https://github.com/org/base")
919        );
920    }
921
922    #[test]
923    fn load_does_not_migrate_ssh_url() {
924        let dir = TempDir::new().unwrap();
925        let toml_str = r#"
926[dependencies.base]
927url = "git@github.com:org/base.git"
928"#;
929        std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
930
931        let config = load(dir.path()).unwrap();
932        assert_eq!(
933            config.dependencies["base"].url.as_deref(),
934            Some("git@github.com:org/base.git")
935        );
936    }
937
938    #[test]
939    fn load_missing_file_returns_not_found() {
940        let dir = TempDir::new().unwrap();
941        let result = load(dir.path());
942        assert!(result.is_err());
943        let err = result.unwrap_err().to_string();
944        assert!(err.contains("not found"), "should be NotFound: {err}");
945    }
946
947    #[test]
948    fn load_manifest_returns_none_without_package() {
949        let dir = TempDir::new().unwrap();
950        std::fs::write(
951            dir.path().join("mars.toml"),
952            r#"
953[dependencies.base]
954url = "https://github.com/org/base.git"
955"#,
956        )
957        .unwrap();
958
959        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
960        assert!(diagnostics.is_empty());
961        assert!(manifest.is_none());
962    }
963
964    #[test]
965    fn load_manifest_returns_package_and_dependencies() {
966        let dir = TempDir::new().unwrap();
967        std::fs::write(
968            dir.path().join("mars.toml"),
969            r#"
970[package]
971name = "pkg"
972version = "1.2.3"
973
974[dependencies.base]
975url = "https://github.com/org/base.git"
976version = ">=1.0.0"
977skills = ["frontend-design"]
978"#,
979        )
980        .unwrap();
981
982        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
983        assert!(diagnostics.is_empty());
984        let manifest = manifest.unwrap();
985        assert_eq!(manifest.package.name, "pkg");
986        assert_eq!(manifest.package.version, "1.2.3");
987        assert!(manifest.dependencies.contains_key("base"));
988        assert_eq!(
989            manifest.dependencies["base"].filter.skills.as_deref(),
990            Some(&[ItemName::from("frontend-design")][..])
991        );
992    }
993
994    #[test]
995    fn load_manifest_io_error_includes_operation_and_path() {
996        let dir = TempDir::new().unwrap();
997        let config_path = dir.path().join("mars.toml");
998        std::fs::create_dir(&config_path).unwrap();
999
1000        let err = load_manifest(dir.path()).unwrap_err();
1001        let msg = err.to_string();
1002
1003        assert!(
1004            msg.contains("read manifest config"),
1005            "error should include operation context: {msg}"
1006        );
1007        assert!(
1008            msg.contains("mars.toml"),
1009            "error should include config path: {msg}"
1010        );
1011    }
1012
1013    #[test]
1014    fn load_local_missing_returns_default() {
1015        let dir = TempDir::new().unwrap();
1016        let local = load_local(dir.path()).unwrap();
1017        assert!(local.overrides.is_empty());
1018    }
1019
1020    #[test]
1021    fn load_local_from_disk() {
1022        let dir = TempDir::new().unwrap();
1023        let toml_str = r#"
1024[overrides.base]
1025path = "/home/dev/local-base"
1026"#;
1027        std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
1028        let local = load_local(dir.path()).unwrap();
1029        assert_eq!(local.overrides.len(), 1);
1030        assert_eq!(
1031            local.overrides["base"].path,
1032            PathBuf::from("/home/dev/local-base")
1033        );
1034    }
1035
1036    #[test]
1037    fn merge_with_empty_local() {
1038        let config = Config {
1039            dependencies: {
1040                let mut m = IndexMap::new();
1041                m.insert(
1042                    "base".into(),
1043                    DependencyEntry {
1044                        url: Some("https://github.com/org/base.git".into()),
1045                        path: None,
1046                        subpath: None,
1047                        version: Some("v1.0".into()),
1048                        filter: FilterConfig::default(),
1049                    },
1050                );
1051                m
1052            },
1053            settings: Settings::default(),
1054            ..Config::default()
1055        };
1056        let local = LocalConfig::default();
1057        let effective = merge(config, local).unwrap();
1058        assert_eq!(effective.dependencies.len(), 1);
1059        let source = &effective.dependencies["base"];
1060        assert!(!source.is_overridden);
1061        assert!(source.original_git.is_none());
1062        match &source.spec {
1063            SourceSpec::Git(git) => {
1064                assert_eq!(git.url, "https://github.com/org/base.git");
1065                assert_eq!(git.version.as_deref(), Some("v1.0"));
1066            }
1067            SourceSpec::Path(_) => panic!("expected Git"),
1068        }
1069    }
1070
1071    #[test]
1072    fn merge_override_replaces_with_path() {
1073        let config = Config {
1074            dependencies: {
1075                let mut m = IndexMap::new();
1076                m.insert(
1077                    "base".into(),
1078                    DependencyEntry {
1079                        url: Some("https://github.com/org/base.git".into()),
1080                        path: None,
1081                        subpath: None,
1082                        version: Some("v1.0".into()),
1083                        filter: FilterConfig::default(),
1084                    },
1085                );
1086                m
1087            },
1088            settings: Settings::default(),
1089            ..Config::default()
1090        };
1091        let local = LocalConfig {
1092            overrides: {
1093                let mut m = IndexMap::new();
1094                m.insert(
1095                    "base".into(),
1096                    OverrideEntry {
1097                        path: PathBuf::from("/home/dev/local-base"),
1098                    },
1099                );
1100                m
1101            },
1102        };
1103        let effective = merge(config, local).unwrap();
1104        let source = &effective.dependencies["base"];
1105        assert!(source.is_overridden);
1106
1107        match &source.spec {
1108            SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
1109            SourceSpec::Git(_) => panic!("expected Path override"),
1110        }
1111
1112        let orig = source.original_git.as_ref().unwrap();
1113        assert_eq!(orig.url, "https://github.com/org/base.git");
1114        assert_eq!(orig.version.as_deref(), Some("v1.0"));
1115    }
1116
1117    #[test]
1118    fn merge_override_retains_subpath_coordinate() {
1119        let temp = TempDir::new().unwrap();
1120        // Canonicalize temp root once to avoid Windows 8.3 short-name mismatches
1121        let temp_root = dunce::canonicalize(temp.path()).unwrap();
1122        let override_path = temp_root.join("local-base");
1123        std::fs::create_dir_all(&override_path).unwrap();
1124        let canonical_override = dunce::canonicalize(&override_path).unwrap();
1125
1126        let config = Config {
1127            dependencies: {
1128                let mut m = IndexMap::new();
1129                m.insert(
1130                    "base".into(),
1131                    DependencyEntry {
1132                        url: Some("https://github.com/org/base.git".into()),
1133                        path: None,
1134                        subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1135                        version: Some("v1.0".into()),
1136                        filter: FilterConfig::default(),
1137                    },
1138                );
1139                m
1140            },
1141            settings: Settings::default(),
1142            ..Config::default()
1143        };
1144        let local = LocalConfig {
1145            overrides: {
1146                let mut m = IndexMap::new();
1147                m.insert(
1148                    "base".into(),
1149                    OverrideEntry {
1150                        path: canonical_override.clone(),
1151                    },
1152                );
1153                m
1154            },
1155        };
1156
1157        let (effective, _) = merge_with_root(config, local, &temp_root).unwrap();
1158        let source = &effective.dependencies["base"];
1159        assert!(source.is_overridden);
1160        assert_eq!(
1161            source.subpath.as_ref().map(SourceSubpath::as_str),
1162            Some("plugins/foo")
1163        );
1164        assert!(matches!(&source.spec, SourceSpec::Path(p) if p == &canonical_override));
1165        assert!(matches!(
1166            &source.id,
1167            SourceId::Path {
1168                canonical,
1169                subpath: Some(sp)
1170            } if canonical == &canonical_override && sp.as_str() == "plugins/foo"
1171        ));
1172    }
1173
1174    #[test]
1175    fn merge_all_filter_mode() {
1176        let config = Config {
1177            dependencies: {
1178                let mut m = IndexMap::new();
1179                m.insert(
1180                    "base".into(),
1181                    DependencyEntry {
1182                        url: Some("https://github.com/org/base.git".into()),
1183                        path: None,
1184                        subpath: None,
1185                        version: None,
1186                        filter: FilterConfig::default(),
1187                    },
1188                );
1189                m
1190            },
1191            settings: Settings::default(),
1192            ..Config::default()
1193        };
1194        let effective = merge(config, LocalConfig::default()).unwrap();
1195        assert!(matches!(
1196            effective.dependencies["base"].filter,
1197            FilterMode::All
1198        ));
1199    }
1200
1201    #[test]
1202    fn save_and_reload() {
1203        let dir = TempDir::new().unwrap();
1204        let config = Config {
1205            dependencies: {
1206                let mut m = IndexMap::new();
1207                m.insert(
1208                    "base".into(),
1209                    DependencyEntry {
1210                        url: Some("https://github.com/org/base.git".into()),
1211                        path: None,
1212                        subpath: None,
1213                        version: Some("v2.0".into()),
1214                        filter: FilterConfig::default(),
1215                    },
1216                );
1217                m
1218            },
1219            settings: Settings::default(),
1220            ..Config::default()
1221        };
1222        save(dir.path(), &config).unwrap();
1223        let reloaded = load(dir.path()).unwrap();
1224        assert_eq!(config, reloaded);
1225    }
1226
1227    #[test]
1228    fn rename_map_preserved() {
1229        let toml_str = r#"
1230[dependencies.base]
1231url = "https://github.com/org/base.git"
1232
1233[dependencies.base.rename]
1234old-name = "new-name"
1235"#;
1236        let config: Config = toml::from_str(toml_str).unwrap();
1237        let effective = merge(config, LocalConfig::default()).unwrap();
1238        let source = &effective.dependencies["base"];
1239        assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
1240    }
1241
1242    #[test]
1243    fn self_dependency_name_rejected() {
1244        let toml_str = r#"
1245[dependencies._self]
1246url = "https://github.com/org/base.git"
1247"#;
1248        let config: Config = toml::from_str(toml_str).unwrap();
1249        let local = LocalConfig::default();
1250        let result = merge(config, local);
1251        assert!(result.is_err());
1252        let err = result.unwrap_err().to_string();
1253        assert!(
1254            err.contains("_self") && err.contains("reserved"),
1255            "should reject _self: {err}"
1256        );
1257    }
1258
1259    #[test]
1260    fn managed_root_setting_roundtrip() {
1261        let config = Config {
1262            settings: Settings {
1263                managed_root: Some(".claude".into()),
1264                targets: None,
1265                ..Settings::default()
1266            },
1267            ..Config::default()
1268        };
1269        let serialized = toml::to_string_pretty(&config).unwrap();
1270        let deserialized: Config = toml::from_str(&serialized).unwrap();
1271        assert_eq!(
1272            deserialized.settings.managed_root.as_deref(),
1273            Some(".claude")
1274        );
1275    }
1276
1277    #[test]
1278    fn save_preserves_dependencies_when_clearing_last_target() {
1279        let dir = TempDir::new().unwrap();
1280        let original = r#"
1281[package]
1282name = "sample"
1283version = "0.1.0"
1284
1285[dependencies.base]
1286url = "https://github.com/org/base.git"
1287version = "v1.0"
1288agents = ["coder"]
1289
1290[settings]
1291managed_root = ".agents"
1292targets = [".claude"]
1293"#;
1294        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1295
1296        let mut config = load(dir.path()).unwrap();
1297        if let Some(targets) = config.settings.targets.as_mut() {
1298            targets.retain(|target| target != ".claude");
1299            if targets.is_empty() {
1300                config.settings.targets = None;
1301            }
1302        }
1303        save(dir.path(), &config).unwrap();
1304
1305        let reloaded = load(dir.path()).unwrap();
1306        assert_eq!(
1307            reloaded.package.as_ref().map(|p| p.name.as_str()),
1308            Some("sample")
1309        );
1310        assert_eq!(
1311            reloaded.dependencies["base"].url.as_deref(),
1312            Some("https://github.com/org/base.git")
1313        );
1314        assert_eq!(
1315            reloaded.dependencies["base"].version.as_deref(),
1316            Some("v1.0")
1317        );
1318        assert_eq!(
1319            reloaded.dependencies["base"].filter.agents.as_deref(),
1320            Some(&["coder".into()][..])
1321        );
1322        assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
1323        assert!(reloaded.settings.targets.is_none());
1324    }
1325
1326    #[test]
1327    fn roundtrip_preserves_all_filter_fields() {
1328        let dir = TempDir::new().unwrap();
1329        let original = r#"
1330[dependencies.include]
1331url = "https://github.com/org/include.git"
1332agents = ["coder", "reviewer"]
1333skills = ["review", "plan"]
1334
1335[dependencies.include.rename]
1336coder = "core-coder"
1337
1338[dependencies.exclude]
1339url = "https://github.com/org/exclude.git"
1340exclude = ["experimental", "deprecated"]
1341
1342[dependencies.only_skills]
1343url = "https://github.com/org/skills.git"
1344only_skills = true
1345
1346[dependencies.only_agents]
1347url = "https://github.com/org/agents.git"
1348only_agents = true
1349"#;
1350        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1351
1352        let config = load(dir.path()).unwrap();
1353        save(dir.path(), &config).unwrap();
1354        let reloaded = load(dir.path()).unwrap();
1355
1356        let include = &reloaded.dependencies["include"].filter;
1357        assert_eq!(
1358            include.agents.as_deref(),
1359            Some(&["coder".into(), "reviewer".into()][..])
1360        );
1361        assert_eq!(
1362            include.skills.as_deref(),
1363            Some(&["review".into(), "plan".into()][..])
1364        );
1365        assert_eq!(
1366            include.rename.as_ref().and_then(|r| r.get("coder")),
1367            Some(&"core-coder".into())
1368        );
1369
1370        let exclude = &reloaded.dependencies["exclude"].filter;
1371        assert_eq!(
1372            exclude.exclude.as_deref(),
1373            Some(&["experimental".into(), "deprecated".into()][..])
1374        );
1375
1376        let only_skills = &reloaded.dependencies["only_skills"].filter;
1377        assert!(only_skills.only_skills);
1378        assert!(!only_skills.only_agents);
1379
1380        let only_agents = &reloaded.dependencies["only_agents"].filter;
1381        assert!(only_agents.only_agents);
1382        assert!(!only_agents.only_skills);
1383    }
1384
1385    #[test]
1386    fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
1387        let dir = TempDir::new().unwrap();
1388        let original = r#"
1389[dependencies.git-include]
1390url = "https://github.com/org/git-include.git"
1391agents = ["coder"]
1392
1393[dependencies.path-exclude]
1394path = "../local-source"
1395exclude = ["draft"]
1396
1397[dependencies.git-only-skills]
1398url = "https://github.com/org/git-skills.git"
1399only_skills = true
1400
1401[dependencies.git-only-agents]
1402url = "https://github.com/org/git-agents.git"
1403only_agents = true
1404"#;
1405        std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1406
1407        let config = load(dir.path()).unwrap();
1408        save(dir.path(), &config).unwrap();
1409        let reloaded = load(dir.path()).unwrap();
1410
1411        assert_eq!(reloaded.dependencies.len(), 4);
1412        assert_eq!(
1413            reloaded.dependencies["git-include"]
1414                .filter
1415                .agents
1416                .as_deref(),
1417            Some(&["coder".into()][..])
1418        );
1419        assert_eq!(
1420            reloaded.dependencies["path-exclude"].path.as_deref(),
1421            Some(Path::new("../local-source"))
1422        );
1423        assert_eq!(
1424            reloaded.dependencies["path-exclude"]
1425                .filter
1426                .exclude
1427                .as_deref(),
1428            Some(&["draft".into()][..])
1429        );
1430        assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
1431        assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
1432    }
1433
1434    #[test]
1435    fn save_roundtrip_guard_rejects_dependency_count_loss() {
1436        let mut original = Config::default();
1437        original.dependencies.insert(
1438            "base".into(),
1439            DependencyEntry {
1440                url: Some("https://github.com/org/base.git".into()),
1441                path: None,
1442                subpath: None,
1443                version: Some("v1.0".into()),
1444                filter: FilterConfig::default(),
1445            },
1446        );
1447
1448        let reparsed = Config::default();
1449        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1450        let msg = err.to_string();
1451        assert!(
1452            msg.contains("dependency count changed"),
1453            "unexpected error: {msg}"
1454        );
1455    }
1456
1457    #[test]
1458    fn save_roundtrip_guard_rejects_managed_root_loss() {
1459        let original = Config {
1460            settings: Settings {
1461                managed_root: Some(".agents".into()),
1462                targets: None,
1463                ..Settings::default()
1464            },
1465            ..Config::default()
1466        };
1467        let reparsed = Config::default();
1468        let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1469        let msg = err.to_string();
1470        assert!(
1471            msg.contains("settings.managed_root changed"),
1472            "unexpected error: {msg}"
1473        );
1474    }
1475
1476    #[test]
1477    fn parse_only_skills_filter() {
1478        let toml_str = r#"
1479[dependencies.base]
1480url = "https://github.com/org/base.git"
1481only_skills = true
1482"#;
1483        let config: Config = toml::from_str(toml_str).unwrap();
1484        let local = LocalConfig::default();
1485        let effective = merge(config, local).unwrap();
1486        let source = &effective.dependencies["base"];
1487        assert!(matches!(source.filter, FilterMode::OnlySkills));
1488    }
1489
1490    #[test]
1491    fn parse_only_agents_filter() {
1492        let toml_str = r#"
1493[dependencies.base]
1494url = "https://github.com/org/base.git"
1495only_agents = true
1496"#;
1497        let config: Config = toml::from_str(toml_str).unwrap();
1498        let local = LocalConfig::default();
1499        let effective = merge(config, local).unwrap();
1500        let source = &effective.dependencies["base"];
1501        assert!(matches!(source.filter, FilterMode::OnlyAgents));
1502    }
1503
1504    #[test]
1505    fn error_on_only_skills_and_only_agents() {
1506        let toml_str = r#"
1507[dependencies.bad]
1508url = "https://github.com/org/bad.git"
1509only_skills = true
1510only_agents = true
1511"#;
1512        let config: Config = toml::from_str(toml_str).unwrap();
1513        let local = LocalConfig::default();
1514        let result = merge(config, local);
1515        assert!(result.is_err());
1516        let err = result.unwrap_err().to_string();
1517        assert!(
1518            err.contains("mutually exclusive"),
1519            "should mention mutually exclusive: {err}"
1520        );
1521    }
1522
1523    #[test]
1524    fn error_on_only_skills_with_agents_list() {
1525        let toml_str = r#"
1526[dependencies.bad]
1527url = "https://github.com/org/bad.git"
1528only_skills = true
1529agents = ["coder"]
1530"#;
1531        let config: Config = toml::from_str(toml_str).unwrap();
1532        let local = LocalConfig::default();
1533        let result = merge(config, local);
1534        assert!(result.is_err());
1535        let err = result.unwrap_err().to_string();
1536        assert!(
1537            err.contains("cannot combine"),
1538            "should mention cannot combine: {err}"
1539        );
1540    }
1541
1542    #[test]
1543    fn error_on_only_agents_with_skills_list() {
1544        let toml_str = r#"
1545[dependencies.bad]
1546url = "https://github.com/org/bad.git"
1547only_agents = true
1548skills = ["planning"]
1549"#;
1550        let config: Config = toml::from_str(toml_str).unwrap();
1551        let local = LocalConfig::default();
1552        let result = merge(config, local);
1553        assert!(result.is_err());
1554    }
1555
1556    #[test]
1557    fn error_on_only_skills_with_exclude() {
1558        let toml_str = r#"
1559[dependencies.bad]
1560url = "https://github.com/org/bad.git"
1561only_skills = true
1562exclude = ["deprecated"]
1563"#;
1564        let config: Config = toml::from_str(toml_str).unwrap();
1565        let local = LocalConfig::default();
1566        let result = merge(config, local);
1567        assert!(result.is_err());
1568    }
1569
1570    #[test]
1571    fn only_skills_false_not_serialized() {
1572        let config = Config {
1573            dependencies: {
1574                let mut m = IndexMap::new();
1575                m.insert(
1576                    "base".into(),
1577                    DependencyEntry {
1578                        url: Some("https://github.com/org/base.git".into()),
1579                        path: None,
1580                        subpath: None,
1581                        version: None,
1582                        filter: FilterConfig::default(),
1583                    },
1584                );
1585                m
1586            },
1587            settings: Settings::default(),
1588            ..Config::default()
1589        };
1590        let serialized = toml::to_string_pretty(&config).unwrap();
1591        assert!(
1592            !serialized.contains("only_skills"),
1593            "false booleans should not be serialized: {serialized}"
1594        );
1595        assert!(
1596            !serialized.contains("only_agents"),
1597            "false booleans should not be serialized: {serialized}"
1598        );
1599    }
1600
1601    #[test]
1602    fn only_skills_true_roundtrips() {
1603        let toml_str = r#"
1604[dependencies.base]
1605url = "https://github.com/org/base.git"
1606only_skills = true
1607"#;
1608        let config: Config = toml::from_str(toml_str).unwrap();
1609        assert!(config.dependencies["base"].filter.only_skills);
1610        assert!(!config.dependencies["base"].filter.only_agents);
1611
1612        let serialized = toml::to_string_pretty(&config).unwrap();
1613        let reloaded: Config = toml::from_str(&serialized).unwrap();
1614        assert!(reloaded.dependencies["base"].filter.only_skills);
1615    }
1616
1617    #[test]
1618    fn filter_config_has_any_filter() {
1619        assert!(!FilterConfig::default().has_any_filter());
1620        assert!(
1621            FilterConfig {
1622                only_skills: true,
1623                ..FilterConfig::default()
1624            }
1625            .has_any_filter()
1626        );
1627        assert!(
1628            FilterConfig {
1629                agents: Some(vec!["coder".into()]),
1630                ..FilterConfig::default()
1631            }
1632            .has_any_filter()
1633        );
1634    }
1635
1636    #[test]
1637    fn filter_config_to_mode() {
1638        assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
1639        assert!(matches!(
1640            FilterConfig {
1641                only_skills: true,
1642                ..FilterConfig::default()
1643            }
1644            .to_mode(),
1645            FilterMode::OnlySkills
1646        ));
1647        assert!(matches!(
1648            FilterConfig {
1649                only_agents: true,
1650                ..FilterConfig::default()
1651            }
1652            .to_mode(),
1653            FilterMode::OnlyAgents
1654        ));
1655        assert!(matches!(
1656            FilterConfig {
1657                agents: Some(vec!["coder".into()]),
1658                ..FilterConfig::default()
1659            }
1660            .to_mode(),
1661            FilterMode::Include { .. }
1662        ));
1663        assert!(matches!(
1664            FilterConfig {
1665                exclude: Some(vec!["old".into()]),
1666                ..FilterConfig::default()
1667            }
1668            .to_mode(),
1669            FilterMode::Exclude(_)
1670        ));
1671    }
1672
1673    // === managed_targets tests ===
1674
1675    #[test]
1676    fn managed_targets_defaults_to_agents() {
1677        let settings = Settings::default();
1678        assert_eq!(settings.managed_targets(), vec![".agents"]);
1679    }
1680
1681    #[test]
1682    fn managed_targets_uses_explicit_targets() {
1683        let settings = Settings {
1684            targets: Some(vec![".claude".to_string()]),
1685            ..Settings::default()
1686        };
1687        assert_eq!(settings.managed_targets(), vec![".claude"]);
1688    }
1689
1690    #[test]
1691    fn managed_targets_uses_managed_root_as_primary() {
1692        let settings = Settings {
1693            managed_root: Some(".claude".to_string()),
1694            ..Settings::default()
1695        };
1696        assert_eq!(settings.managed_targets(), vec![".claude"]);
1697    }
1698
1699    #[test]
1700    fn managed_targets_explicit_overrides_links_and_managed_root() {
1701        let settings = Settings {
1702            managed_root: Some(".cursor".to_string()),
1703            targets: Some(vec![".codex".to_string()]),
1704            ..Settings::default()
1705        };
1706        // targets takes precedence over managed_root
1707        assert_eq!(settings.managed_targets(), vec![".codex"]);
1708    }
1709
1710    #[test]
1711    fn settings_models_cache_ttl_defaults_to_24_when_omitted() {
1712        let config: Config = toml::from_str(
1713            r#"
1714[dependencies.base]
1715url = "https://github.com/org/base.git"
1716"#,
1717        )
1718        .unwrap();
1719        assert_eq!(config.settings.models_cache_ttl_hours, 24);
1720    }
1721
1722    #[test]
1723    fn settings_models_cache_ttl_defaults_to_24_when_settings_present_without_ttl() {
1724        let config: Config = toml::from_str(
1725            r#"
1726[settings]
1727managed_root = ".agents"
1728"#,
1729        )
1730        .unwrap();
1731        assert_eq!(config.settings.models_cache_ttl_hours, 24);
1732    }
1733
1734    #[test]
1735    fn settings_models_cache_ttl_parses_zero() {
1736        let config: Config = toml::from_str(
1737            r#"
1738[settings]
1739models_cache_ttl_hours = 0
1740"#,
1741        )
1742        .unwrap();
1743        assert_eq!(config.settings.models_cache_ttl_hours, 0);
1744    }
1745
1746    #[test]
1747    fn settings_models_cache_ttl_parses_custom_value() {
1748        let config: Config = toml::from_str(
1749            r#"
1750[settings]
1751models_cache_ttl_hours = 48
1752"#,
1753        )
1754        .unwrap();
1755        assert_eq!(config.settings.models_cache_ttl_hours, 48);
1756    }
1757
1758    #[test]
1759    fn settings_models_cache_ttl_roundtrip_preserves_value() {
1760        let original = Config {
1761            settings: Settings {
1762                models_cache_ttl_hours: 48,
1763                ..Settings::default()
1764            },
1765            ..Config::default()
1766        };
1767        let serialized = toml::to_string_pretty(&original).unwrap();
1768        let roundtripped: Config = toml::from_str(&serialized).unwrap();
1769        assert_eq!(
1770            roundtripped.settings.models_cache_ttl_hours,
1771            original.settings.models_cache_ttl_hours
1772        );
1773    }
1774
1775    #[test]
1776    fn model_visibility_validate_allows_include_and_exclude() {
1777        let visibility = ModelVisibility {
1778            include: Some(vec!["opus*".into()]),
1779            exclude: Some(vec!["test*".into()]),
1780        };
1781        visibility.validate().unwrap();
1782    }
1783
1784    #[test]
1785    fn model_visibility_validate_allows_include_only_exclude_only_and_empty() {
1786        ModelVisibility {
1787            include: Some(vec!["opus*".into()]),
1788            exclude: None,
1789        }
1790        .validate()
1791        .unwrap();
1792        ModelVisibility {
1793            include: None,
1794            exclude: Some(vec!["test*".into()]),
1795        }
1796        .validate()
1797        .unwrap();
1798        ModelVisibility::default().validate().unwrap();
1799    }
1800
1801    #[test]
1802    fn model_visibility_is_empty_reports_state() {
1803        assert!(ModelVisibility::default().is_empty());
1804        assert!(
1805            !ModelVisibility {
1806                include: Some(vec!["opus*".into()]),
1807                exclude: None,
1808            }
1809            .is_empty()
1810        );
1811        assert!(
1812            !ModelVisibility {
1813                include: None,
1814                exclude: Some(vec!["test*".into()]),
1815            }
1816            .is_empty()
1817        );
1818    }
1819
1820    #[test]
1821    fn load_accepts_model_visibility_with_include_and_exclude() {
1822        let dir = TempDir::new().unwrap();
1823        std::fs::write(
1824            dir.path().join("mars.toml"),
1825            r#"
1826[settings.model_visibility]
1827include = ["opus*"]
1828exclude = ["test*"]
1829"#,
1830        )
1831        .unwrap();
1832
1833        let config = load(dir.path()).unwrap();
1834        assert_eq!(
1835            config.settings.model_visibility.include,
1836            Some(vec!["opus*".into()])
1837        );
1838        assert_eq!(
1839            config.settings.model_visibility.exclude,
1840            Some(vec!["test*".into()])
1841        );
1842    }
1843
1844    #[test]
1845    fn load_accepts_model_visibility_include_only() {
1846        let dir = TempDir::new().unwrap();
1847        std::fs::write(
1848            dir.path().join("mars.toml"),
1849            r#"
1850[settings.model_visibility]
1851include = ["opus*", "gpt-*"]
1852"#,
1853        )
1854        .unwrap();
1855
1856        let config = load(dir.path()).unwrap();
1857        assert_eq!(
1858            config.settings.model_visibility.include,
1859            Some(vec!["opus*".into(), "gpt-*".into()])
1860        );
1861        assert!(config.settings.model_visibility.exclude.is_none());
1862    }
1863
1864    #[test]
1865    fn load_accepts_model_visibility_exclude_only() {
1866        let dir = TempDir::new().unwrap();
1867        std::fs::write(
1868            dir.path().join("mars.toml"),
1869            r#"
1870[settings.model_visibility]
1871exclude = ["test-*", "deprecated-*"]
1872"#,
1873        )
1874        .unwrap();
1875
1876        let config = load(dir.path()).unwrap();
1877        assert_eq!(
1878            config.settings.model_visibility.exclude,
1879            Some(vec!["test-*".into(), "deprecated-*".into()])
1880        );
1881        assert!(config.settings.model_visibility.include.is_none());
1882    }
1883
1884    // === local-dependencies tests ===
1885
1886    #[test]
1887    fn parse_local_dependencies() {
1888        let toml_str = r#"
1889[dependencies.base]
1890url = "https://github.com/org/base.git"
1891
1892[local-dependencies.prompter]
1893url = "https://github.com/org/prompter.git"
1894skills = ["prompt-helper"]
1895"#;
1896        let config: Config = toml::from_str(toml_str).unwrap();
1897        assert_eq!(config.dependencies.len(), 1);
1898        assert_eq!(config.local_dependencies.len(), 1);
1899        assert!(config.local_dependencies.contains_key("prompter"));
1900        assert_eq!(
1901            config.local_dependencies["prompter"].url.as_deref(),
1902            Some("https://github.com/org/prompter.git")
1903        );
1904    }
1905
1906    #[test]
1907    fn local_dependencies_merged_into_effective_config() {
1908        let toml_str = r#"
1909[dependencies.base]
1910url = "https://github.com/org/base.git"
1911
1912[local-dependencies.prompter]
1913url = "https://github.com/org/prompter.git"
1914"#;
1915        let config: Config = toml::from_str(toml_str).unwrap();
1916        let local = LocalConfig::default();
1917        let effective = merge(config, local).unwrap();
1918
1919        // Both deps should be in effective config
1920        assert_eq!(effective.dependencies.len(), 2);
1921        assert!(effective.dependencies.contains_key("base"));
1922        assert!(effective.dependencies.contains_key("prompter"));
1923    }
1924
1925    #[test]
1926    fn local_dependencies_not_exported_to_manifest() {
1927        let dir = TempDir::new().unwrap();
1928        std::fs::write(
1929            dir.path().join("mars.toml"),
1930            r#"
1931[package]
1932name = "my-package"
1933version = "1.0.0"
1934
1935[dependencies.base]
1936url = "https://github.com/org/base.git"
1937
1938[local-dependencies.prompter]
1939url = "https://github.com/org/prompter.git"
1940"#,
1941        )
1942        .unwrap();
1943
1944        let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1945        assert!(diagnostics.is_empty());
1946        let manifest = manifest.unwrap();
1947
1948        // Only base should be in manifest, not prompter
1949        assert_eq!(manifest.dependencies.len(), 1);
1950        assert!(manifest.dependencies.contains_key("base"));
1951        assert!(!manifest.dependencies.contains_key("prompter"));
1952    }
1953
1954    #[test]
1955    fn error_on_duplicate_name_across_sections() {
1956        let toml_str = r#"
1957[dependencies.base]
1958url = "https://github.com/org/base.git"
1959
1960[local-dependencies.base]
1961url = "https://github.com/org/base-local.git"
1962"#;
1963        let config: Config = toml::from_str(toml_str).unwrap();
1964        let local = LocalConfig::default();
1965        let result = merge(config, local);
1966        assert!(result.is_err());
1967        let err = result.unwrap_err().to_string();
1968        assert!(
1969            err.contains("base") && err.contains("both"),
1970            "should reject duplicate name: {err}"
1971        );
1972    }
1973
1974    #[test]
1975    fn local_dependencies_roundtrip() {
1976        let dir = TempDir::new().unwrap();
1977        let original = r#"
1978[dependencies.base]
1979url = "https://github.com/org/base.git"
1980
1981[local-dependencies.prompter]
1982url = "https://github.com/org/prompter.git"
1983skills = ["prompt-helper"]
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        assert_eq!(reloaded.dependencies.len(), 1);
1992        assert_eq!(reloaded.local_dependencies.len(), 1);
1993        assert!(reloaded.local_dependencies.contains_key("prompter"));
1994        assert_eq!(
1995            reloaded.local_dependencies["prompter"]
1996                .filter
1997                .skills
1998                .as_deref(),
1999            Some(&["prompt-helper".into()][..])
2000        );
2001    }
2002}