1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticLevel};
7use crate::error::{ConfigError, MarsError};
8use crate::types::{
9 ItemName, RenameMap, SourceId, SourceName, SourceOrigin, SourceSubpath, SourceUrl,
10};
11
12#[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 #[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#[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#[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
59pub type DependencyEntry = InstallDep;
61
62#[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#[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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
105pub struct ModelVisibility {
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub include: Option<Vec<String>>,
109 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
133pub struct LocalConfig {
134 #[serde(default)]
135 pub overrides: IndexMap<SourceName, OverrideEntry>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140pub struct OverrideEntry {
141 pub path: PathBuf,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub struct Settings {
147 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub managed_root: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub targets: Option<Vec<String>>,
160 #[serde(default, skip_serializing_if = "ModelVisibility::is_empty")]
161 pub model_visibility: ModelVisibility,
162 #[serde(default = "default_models_cache_ttl_hours")]
163 pub models_cache_ttl_hours: u32,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub min_mars_version: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub agent_emission: Option<AgentEmission>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179#[serde(rename_all = "lowercase")]
180pub enum AgentEmission {
181 Auto,
182 Always,
183 Never,
184}
185
186impl Default for Settings {
187 fn default() -> Self {
188 Self {
189 managed_root: None,
190 targets: None,
191 model_visibility: ModelVisibility::default(),
192 models_cache_ttl_hours: default_models_cache_ttl_hours(),
193 min_mars_version: None,
194 agent_emission: None,
195 }
196 }
197}
198
199fn default_models_cache_ttl_hours() -> u32 {
200 24
201}
202
203impl Settings {
204 pub fn managed_targets(&self) -> Vec<String> {
211 if let Some(targets) = &self.targets {
212 return targets.clone();
213 }
214 self.managed_root.clone().into_iter().collect()
215 }
216}
217
218#[derive(Debug, Clone)]
220pub enum SourceSpec {
221 Git(GitSpec),
222 Path(PathBuf),
223}
224
225#[derive(Debug, Clone)]
227pub struct GitSpec {
228 pub url: SourceUrl,
229 pub version: Option<String>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum FilterMode {
235 All,
237 Include {
239 agents: Vec<ItemName>,
240 skills: Vec<ItemName>,
241 },
242 Exclude(Vec<ItemName>),
244 OnlySkills,
246 OnlyAgents,
248}
249
250#[derive(Debug, Clone)]
254pub struct EffectiveConfig {
255 pub dependencies: IndexMap<SourceName, EffectiveDependency>,
256 pub settings: Settings,
257}
258
259#[derive(Debug, Clone)]
261pub struct EffectiveDependency {
262 pub name: SourceName,
263 pub id: SourceId,
264 pub spec: SourceSpec,
265 pub subpath: Option<SourceSubpath>,
266 pub filter: FilterMode,
267 pub rename: RenameMap,
268 pub is_overridden: bool,
269 pub original_git: Option<GitSpec>,
270}
271
272const CONFIG_FILE: &str = "mars.toml";
273const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
274
275pub fn load(root: &Path) -> Result<Config, MarsError> {
277 let path = root.join(CONFIG_FILE);
278 let content = std::fs::read_to_string(&path).map_err(|e| {
279 if e.kind() == std::io::ErrorKind::NotFound {
280 ConfigError::NotFound { path: path.clone() }
281 } else {
282 ConfigError::Io(e)
283 }
284 })?;
285 let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
286 migrate_legacy_source_urls(&mut config);
287 Ok(config)
288}
289
290pub fn load_manifest(source_root: &Path) -> Result<(Option<Manifest>, Vec<Diagnostic>), MarsError> {
298 let path = source_root.join(CONFIG_FILE);
299 let diagnostics = Vec::new();
300 match std::fs::read_to_string(&path) {
301 Ok(content) => {
302 let parsed: Config =
303 toml::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
304 message: format!("failed to parse {}: {e}", path.display()),
305 })?;
306 let Some(package) = parsed.package else {
307 return Ok((None, diagnostics));
308 };
309 let deps: IndexMap<String, ManifestDep> = parsed
311 .dependencies
312 .into_iter()
313 .map(|(name, entry)| {
314 (
315 name.to_string(),
316 ManifestDep {
317 url: entry.url,
318 path: entry.path,
319 subpath: entry.subpath,
320 version: entry.version,
321 filter: entry.filter,
322 },
323 )
324 })
325 .collect();
326 Ok((
327 Some(Manifest {
328 package,
329 dependencies: deps,
330 models: parsed.models,
331 }),
332 diagnostics,
333 ))
334 }
335 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((None, diagnostics)),
336 Err(source) => Err(MarsError::Io {
337 operation: "read manifest config".to_string(),
338 path,
339 source,
340 }),
341 }
342}
343
344pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
346 let path = root.join(LOCAL_CONFIG_FILE);
347 match std::fs::read_to_string(&path) {
348 Ok(content) => {
349 let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
350 Ok(local)
351 }
352 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
353 Err(e) => Err(ConfigError::Io(e).into()),
354 }
355}
356
357pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
364 let (effective, _diagnostics) = merge_with_root(config, local, Path::new("."))?;
365 Ok(effective)
366}
367
368pub fn merge_with_root(
370 config: Config,
371 local: LocalConfig,
372 root: &Path,
373) -> Result<(EffectiveConfig, Vec<Diagnostic>), MarsError> {
374 config.settings.model_visibility.validate()?;
375 let mut dependencies = IndexMap::new();
376 let mut diagnostics = Vec::new();
377 let local_source_name = SourceOrigin::LocalPackage.to_string();
378
379 diagnostics.extend(deprecated_agents_target_diagnostics(&config.settings));
380
381 let all_deps = config
384 .dependencies
385 .iter()
386 .chain(config.local_dependencies.iter());
387
388 for (name, entry) in all_deps {
389 if name.as_ref() == local_source_name.as_str() {
391 return Err(ConfigError::Invalid {
392 message: "dependency name `_self` is reserved for local package items".into(),
393 }
394 .into());
395 }
396
397 if dependencies.contains_key(name) {
399 return Err(ConfigError::Invalid {
400 message: format!(
401 "dependency `{name}` appears in both [dependencies] and [local-dependencies]"
402 ),
403 }
404 .into());
405 }
406
407 let base_spec = match (&entry.url, &entry.path) {
409 (Some(url), None) => SourceSpec::Git(GitSpec {
410 url: url.clone(),
411 version: entry.version.clone(),
412 }),
413 (None, Some(path)) => SourceSpec::Path(path.clone()),
414 (Some(_), Some(_)) => {
415 return Err(ConfigError::Invalid {
416 message: format!("source `{name}` has both `url` and `path` — pick one"),
417 }
418 .into());
419 }
420 (None, None) => {
421 return Err(ConfigError::Invalid {
422 message: format!(
423 "source `{name}` has neither `url` nor `path` — one is required"
424 ),
425 }
426 .into());
427 }
428 };
429
430 validate_filter(&entry.filter, name.as_ref())?;
432
433 let filter = entry.filter.to_mode();
434
435 let rename = entry.filter.rename.clone().unwrap_or_default();
436
437 let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
439 let original = match &base_spec {
440 SourceSpec::Git(git) => Some(git.clone()),
441 SourceSpec::Path(_) => None,
442 };
443 (SourceSpec::Path(ov.path.clone()), true, original)
444 } else {
445 (base_spec, false, None)
446 };
447 let subpath = entry.subpath.clone();
448 let id = source_id_for_spec(root, &spec, subpath.clone());
449
450 dependencies.insert(
451 name.clone(),
452 EffectiveDependency {
453 name: name.clone(),
454 id,
455 spec,
456 subpath,
457 filter,
458 rename,
459 is_overridden,
460 original_git,
461 },
462 );
463 }
464
465 for override_name in local.overrides.keys() {
467 if !config.dependencies.contains_key(override_name) {
468 diagnostics.push(Diagnostic {
469 level: DiagnosticLevel::Warning,
470 code: "override-missing-dep",
471 message: format!(
472 "override `{override_name}` references a dependency not in mars.toml"
473 ),
474 context: None,
475 category: None,
476 });
477 }
478 }
479
480 Ok((
481 EffectiveConfig {
482 dependencies,
483 settings: config.settings,
484 },
485 diagnostics,
486 ))
487}
488
489fn deprecated_agents_target_diagnostics(settings: &Settings) -> Vec<Diagnostic> {
490 let mut diagnostics = Vec::new();
491
492 if settings.managed_root.as_deref() == Some(".agents") {
493 diagnostics.push(deprecated_agents_target_diagnostic("settings.managed_root"));
494 }
495
496 if settings
497 .targets
498 .as_ref()
499 .is_some_and(|targets| targets.iter().any(|target| target == ".agents"))
500 {
501 diagnostics.push(deprecated_agents_target_diagnostic("settings.targets"));
502 }
503
504 diagnostics
505}
506
507fn deprecated_agents_target_diagnostic(context: &str) -> Diagnostic {
508 Diagnostic {
509 level: DiagnosticLevel::Warning,
510 code: "deprecated-agents-target",
511 message: "`.agents` is a deprecated link target. Run `mars unlink .agents` to remove it. Skills are now emitted to native harness dirs automatically.".to_string(),
512 context: Some(context.to_string()),
513 category: Some(DiagnosticCategory::Compatibility),
514 }
515}
516
517pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
525 let has_include = filter.agents.is_some() || filter.skills.is_some();
526 let has_exclude = filter.exclude.is_some();
527 let has_category = filter.only_skills || filter.only_agents;
528
529 if filter.only_skills && filter.only_agents {
530 return Err(ConfigError::Invalid {
531 message: format!(
532 "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
533 ),
534 }
535 .into());
536 }
537 if has_category && has_include {
538 return Err(ConfigError::Invalid {
539 message: format!(
540 "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
541 ),
542 }
543 .into());
544 }
545 if has_category && has_exclude {
546 return Err(ConfigError::Invalid {
547 message: format!(
548 "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
549 ),
550 }
551 .into());
552 }
553 if has_include && has_exclude {
554 return Err(ConfigError::ConflictingFilters {
555 name: dep_name.to_string(),
556 }
557 .into());
558 }
559 Ok(())
560}
561
562impl FilterConfig {
563 pub fn to_mode(&self) -> FilterMode {
565 if self.only_skills {
566 FilterMode::OnlySkills
567 } else if self.only_agents {
568 FilterMode::OnlyAgents
569 } else if self.agents.is_some() || self.skills.is_some() {
570 FilterMode::Include {
571 agents: self.agents.clone().unwrap_or_default(),
572 skills: self.skills.clone().unwrap_or_default(),
573 }
574 } else if self.exclude.is_some() {
575 FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
576 } else {
577 FilterMode::All
578 }
579 }
580
581 pub fn has_any_filter(&self) -> bool {
583 self.agents.is_some()
584 || self.skills.is_some()
585 || self.exclude.is_some()
586 || self.only_skills
587 || self.only_agents
588 }
589}
590
591fn source_id_for_spec(root: &Path, spec: &SourceSpec, subpath: Option<SourceSubpath>) -> SourceId {
592 match spec {
593 SourceSpec::Git(git) => SourceId::git_with_subpath(git.url.clone(), subpath.clone()),
594 SourceSpec::Path(path) => match SourceId::path_with_subpath(root, path, subpath.clone()) {
595 Ok(id) => id,
596 Err(_) => {
597 let canonical = if path.is_absolute() {
598 path.clone()
599 } else {
600 root.join(path)
601 };
602 SourceId::Path { canonical, subpath }
603 }
604 },
605 }
606}
607
608fn migrate_legacy_source_urls(config: &mut Config) {
609 for dep in config
610 .dependencies
611 .values_mut()
612 .chain(config.local_dependencies.values_mut())
613 {
614 if let Some(url) = dep.url.as_mut() {
615 let raw = url.as_str();
616 if should_upgrade_legacy_git_url(raw) {
617 *url = SourceUrl::from(format!("https://{raw}"));
618 }
619 }
620 }
621}
622
623fn should_upgrade_legacy_git_url(url: &str) -> bool {
624 !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
625}
626
627pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
629 let path = root.join(CONFIG_FILE);
630 let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
631 message: format!("failed to serialize config: {e}"),
632 })?;
633 let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
634 message: format!("refusing to save config: serialized output failed to parse: {e}"),
635 })?;
636 validate_save_roundtrip(config, &reparsed)?;
637 crate::fs::atomic_write(&path, content.as_bytes())
638}
639
640fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
641 if reparsed.dependencies.len() != original.dependencies.len() {
642 return Err(ConfigError::Invalid {
643 message: format!(
644 "refusing to save config: dependency count changed during roundtrip ({} -> {})",
645 original.dependencies.len(),
646 reparsed.dependencies.len()
647 ),
648 }
649 .into());
650 }
651
652 if reparsed.local_dependencies.len() != original.local_dependencies.len() {
653 return Err(ConfigError::Invalid {
654 message: format!(
655 "refusing to save config: local-dependencies count changed during roundtrip ({} -> {})",
656 original.local_dependencies.len(),
657 reparsed.local_dependencies.len()
658 ),
659 }
660 .into());
661 }
662
663 if reparsed.settings.managed_root != original.settings.managed_root {
664 return Err(ConfigError::Invalid {
665 message: format!(
666 "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
667 original.settings.managed_root, reparsed.settings.managed_root
668 ),
669 }
670 .into());
671 }
672 if reparsed.settings.model_visibility != original.settings.model_visibility {
673 return Err(ConfigError::Invalid {
674 message: format!(
675 "refusing to save config: settings.model_visibility changed during roundtrip ({:?} -> {:?})",
676 original.settings.model_visibility, reparsed.settings.model_visibility
677 ),
678 }
679 .into());
680 }
681 if reparsed.settings.agent_emission != original.settings.agent_emission {
682 return Err(ConfigError::Invalid {
683 message: format!(
684 "refusing to save config: settings.agent_emission changed during roundtrip ({:?} -> {:?})",
685 original.settings.agent_emission, reparsed.settings.agent_emission
686 ),
687 }
688 .into());
689 }
690
691 for (name, dep) in &original.dependencies {
692 let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
693 return Err(ConfigError::Invalid {
694 message: format!(
695 "refusing to save config: dependency `{name}` missing after roundtrip"
696 ),
697 }
698 .into());
699 };
700
701 if reparsed_dep != dep {
702 return Err(ConfigError::Invalid {
703 message: format!(
704 "refusing to save config: dependency `{name}` changed during roundtrip"
705 ),
706 }
707 .into());
708 }
709 }
710
711 for (name, dep) in &original.local_dependencies {
712 let Some(reparsed_dep) = reparsed.local_dependencies.get(name) else {
713 return Err(ConfigError::Invalid {
714 message: format!(
715 "refusing to save config: local-dependency `{name}` missing after roundtrip"
716 ),
717 }
718 .into());
719 };
720
721 if reparsed_dep != dep {
722 return Err(ConfigError::Invalid {
723 message: format!(
724 "refusing to save config: local-dependency `{name}` changed during roundtrip"
725 ),
726 }
727 .into());
728 }
729 }
730
731 Ok(())
732}
733
734pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
736 let path = root.join(LOCAL_CONFIG_FILE);
737 let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
738 message: format!("failed to serialize local config: {e}"),
739 })?;
740 crate::fs::atomic_write(&path, content.as_bytes())
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746 use tempfile::TempDir;
747
748 #[test]
749 fn parse_git_dependency() {
750 let toml_str = r#"
751[dependencies.base]
752url = "https://github.com/org/base.git"
753version = "v1.0"
754"#;
755 let config: Config = toml::from_str(toml_str).unwrap();
756 assert_eq!(config.dependencies.len(), 1);
757 let entry = &config.dependencies["base"];
758 assert_eq!(
759 entry.url.as_deref(),
760 Some("https://github.com/org/base.git")
761 );
762 assert!(entry.path.is_none());
763 assert_eq!(entry.version.as_deref(), Some("v1.0"));
764 }
765
766 #[test]
767 fn parse_path_dependency() {
768 let toml_str = r#"
769[dependencies.local]
770path = "../my-agents"
771"#;
772 let config: Config = toml::from_str(toml_str).unwrap();
773 let entry = &config.dependencies["local"];
774 assert!(entry.url.is_none());
775 assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
776 }
777
778 #[test]
779 fn parse_mixed_dependencies() {
780 let toml_str = r#"
781[dependencies.remote]
782url = "https://github.com/org/remote.git"
783version = "v2.0"
784agents = ["coder", "reviewer"]
785
786[dependencies.local]
787path = "/home/dev/agents"
788exclude = ["experimental"]
789"#;
790 let config: Config = toml::from_str(toml_str).unwrap();
791 assert_eq!(config.dependencies.len(), 2);
792 assert!(config.dependencies.contains_key("remote"));
793 assert!(config.dependencies.contains_key("local"));
794 }
795
796 #[test]
797 fn parse_package_and_dependencies_coexist() {
798 let toml_str = r#"
799[package]
800name = "my-agents"
801version = "0.1.0"
802
803[dependencies.base]
804url = "https://github.com/org/base.git"
805version = ">=1.0.0"
806
807[dependencies.local]
808path = "../local-agents"
809"#;
810 let config: Config = toml::from_str(toml_str).unwrap();
811 assert!(config.package.is_some());
812 assert!(config.dependencies.contains_key("base"));
813 assert!(config.dependencies.contains_key("local"));
814 }
815
816 #[test]
817 fn parse_include_filter() {
818 let toml_str = r#"
819[dependencies.base]
820url = "https://github.com/org/base.git"
821agents = ["coder"]
822skills = ["review"]
823"#;
824 let config: Config = toml::from_str(toml_str).unwrap();
825 let local = LocalConfig::default();
826 let effective = merge(config, local).unwrap();
827 let source = &effective.dependencies["base"];
828 match &source.filter {
829 FilterMode::Include { agents, skills } => {
830 assert_eq!(agents, &["coder"]);
831 assert_eq!(skills, &["review"]);
832 }
833 other => panic!("expected Include, got {other:?}"),
834 }
835 }
836
837 #[test]
838 fn parse_exclude_filter() {
839 let toml_str = r#"
840[dependencies.base]
841url = "https://github.com/org/base.git"
842exclude = ["experimental", "deprecated"]
843"#;
844 let config: Config = toml::from_str(toml_str).unwrap();
845 let local = LocalConfig::default();
846 let effective = merge(config, local).unwrap();
847 let source = &effective.dependencies["base"];
848 match &source.filter {
849 FilterMode::Exclude(items) => {
850 assert_eq!(items, &["experimental", "deprecated"]);
851 }
852 other => panic!("expected Exclude, got {other:?}"),
853 }
854 }
855
856 #[test]
857 fn error_on_both_include_and_exclude() {
858 let toml_str = r#"
859[dependencies.bad]
860url = "https://github.com/org/bad.git"
861agents = ["coder"]
862exclude = ["reviewer"]
863"#;
864 let config: Config = toml::from_str(toml_str).unwrap();
865 let local = LocalConfig::default();
866 let result = merge(config, local);
867 assert!(result.is_err());
868 let err = result.unwrap_err().to_string();
869 assert!(
870 err.contains("bad"),
871 "error should mention dependency name: {err}"
872 );
873 }
874
875 #[test]
876 fn error_on_neither_url_nor_path() {
877 let toml_str = r#"
878[dependencies.empty]
879version = "v1.0"
880"#;
881 let config: Config = toml::from_str(toml_str).unwrap();
882 let local = LocalConfig::default();
883 let result = merge(config, local);
884 assert!(result.is_err());
885 let err = result.unwrap_err().to_string();
886 assert!(
887 err.contains("neither"),
888 "error should mention 'neither': {err}"
889 );
890 }
891
892 #[test]
893 fn error_on_both_url_and_path() {
894 let toml_str = r#"
895[dependencies.both]
896url = "https://github.com/org/repo.git"
897path = "/local/path"
898"#;
899 let config: Config = toml::from_str(toml_str).unwrap();
900 let local = LocalConfig::default();
901 let result = merge(config, local);
902 assert!(result.is_err());
903 let err = result.unwrap_err().to_string();
904 assert!(err.contains("both"), "error should mention 'both': {err}");
905 }
906
907 #[test]
908 fn roundtrip_full_config_shape_survives_save() {
909 let dir = TempDir::new().unwrap();
910 let original = r#"
911[package]
912name = "sample"
913version = "0.1.0"
914description = "sample package"
915
916[dependencies.base]
917url = "https://github.com/org/base.git"
918version = "v1.0"
919agents = ["coder", "reviewer"]
920
921[dependencies.local]
922path = "../local-agents"
923exclude = ["experimental"]
924
925[settings]
926managed_root = ".custom-agents"
927targets = [".claude", ".cursor"]
928"#;
929 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
930
931 let config = load(dir.path()).unwrap();
932 save(dir.path(), &config).unwrap();
933 let reloaded = load(dir.path()).unwrap();
934
935 assert_eq!(
936 reloaded.package.as_ref().map(|p| p.name.as_str()),
937 Some("sample")
938 );
939 assert_eq!(reloaded.dependencies.len(), 2);
940 assert_eq!(
941 reloaded.dependencies["base"].url.as_deref(),
942 Some("https://github.com/org/base.git")
943 );
944 assert_eq!(
945 reloaded.dependencies["local"].path.as_deref(),
946 Some(Path::new("../local-agents"))
947 );
948 assert_eq!(
949 reloaded.settings.managed_root.as_deref(),
950 Some(".custom-agents")
951 );
952 assert_eq!(
953 reloaded.settings.targets,
954 Some(vec![".claude".to_string(), ".cursor".to_string()])
955 );
956 }
957
958 #[test]
959 fn load_from_disk() {
960 let dir = TempDir::new().unwrap();
961 let toml_str = r#"
962[dependencies.base]
963url = "https://github.com/org/base.git"
964version = "v1.0"
965"#;
966 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
967 let config = load(dir.path()).unwrap();
968 assert_eq!(config.dependencies.len(), 1);
969 }
970
971 #[test]
972 fn load_migrates_legacy_bare_domain_url() {
973 let dir = TempDir::new().unwrap();
974 let toml_str = r#"
975[dependencies.base]
976url = "github.com/org/base"
977"#;
978 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
979
980 let config = load(dir.path()).unwrap();
981 assert_eq!(
982 config.dependencies["base"].url.as_deref(),
983 Some("https://github.com/org/base")
984 );
985 }
986
987 #[test]
988 fn load_does_not_migrate_ssh_url() {
989 let dir = TempDir::new().unwrap();
990 let toml_str = r#"
991[dependencies.base]
992url = "git@github.com:org/base.git"
993"#;
994 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
995
996 let config = load(dir.path()).unwrap();
997 assert_eq!(
998 config.dependencies["base"].url.as_deref(),
999 Some("git@github.com:org/base.git")
1000 );
1001 }
1002
1003 #[test]
1004 fn load_missing_file_returns_not_found() {
1005 let dir = TempDir::new().unwrap();
1006 let result = load(dir.path());
1007 assert!(result.is_err());
1008 let err = result.unwrap_err().to_string();
1009 assert!(err.contains("not found"), "should be NotFound: {err}");
1010 }
1011
1012 #[test]
1013 fn load_manifest_returns_none_without_package() {
1014 let dir = TempDir::new().unwrap();
1015 std::fs::write(
1016 dir.path().join("mars.toml"),
1017 r#"
1018[dependencies.base]
1019url = "https://github.com/org/base.git"
1020"#,
1021 )
1022 .unwrap();
1023
1024 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1025 assert!(diagnostics.is_empty());
1026 assert!(manifest.is_none());
1027 }
1028
1029 #[test]
1030 fn load_manifest_returns_package_and_dependencies() {
1031 let dir = TempDir::new().unwrap();
1032 std::fs::write(
1033 dir.path().join("mars.toml"),
1034 r#"
1035[package]
1036name = "pkg"
1037version = "1.2.3"
1038
1039[dependencies.base]
1040url = "https://github.com/org/base.git"
1041version = ">=1.0.0"
1042skills = ["frontend-design"]
1043"#,
1044 )
1045 .unwrap();
1046
1047 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
1048 assert!(diagnostics.is_empty());
1049 let manifest = manifest.unwrap();
1050 assert_eq!(manifest.package.name, "pkg");
1051 assert_eq!(manifest.package.version, "1.2.3");
1052 assert!(manifest.dependencies.contains_key("base"));
1053 assert_eq!(
1054 manifest.dependencies["base"].filter.skills.as_deref(),
1055 Some(&[ItemName::from("frontend-design")][..])
1056 );
1057 }
1058
1059 #[test]
1060 fn load_manifest_io_error_includes_operation_and_path() {
1061 let dir = TempDir::new().unwrap();
1062 let config_path = dir.path().join("mars.toml");
1063 std::fs::create_dir(&config_path).unwrap();
1064
1065 let err = load_manifest(dir.path()).unwrap_err();
1066 let msg = err.to_string();
1067
1068 assert!(
1069 msg.contains("read manifest config"),
1070 "error should include operation context: {msg}"
1071 );
1072 assert!(
1073 msg.contains("mars.toml"),
1074 "error should include config path: {msg}"
1075 );
1076 }
1077
1078 #[test]
1079 fn load_local_missing_returns_default() {
1080 let dir = TempDir::new().unwrap();
1081 let local = load_local(dir.path()).unwrap();
1082 assert!(local.overrides.is_empty());
1083 }
1084
1085 #[test]
1086 fn load_local_from_disk() {
1087 let dir = TempDir::new().unwrap();
1088 let toml_str = r#"
1089[overrides.base]
1090path = "/home/dev/local-base"
1091"#;
1092 std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
1093 let local = load_local(dir.path()).unwrap();
1094 assert_eq!(local.overrides.len(), 1);
1095 assert_eq!(
1096 local.overrides["base"].path,
1097 PathBuf::from("/home/dev/local-base")
1098 );
1099 }
1100
1101 #[test]
1102 fn merge_with_empty_local() {
1103 let config = Config {
1104 dependencies: {
1105 let mut m = IndexMap::new();
1106 m.insert(
1107 "base".into(),
1108 DependencyEntry {
1109 url: Some("https://github.com/org/base.git".into()),
1110 path: None,
1111 subpath: None,
1112 version: Some("v1.0".into()),
1113 filter: FilterConfig::default(),
1114 },
1115 );
1116 m
1117 },
1118 settings: Settings::default(),
1119 ..Config::default()
1120 };
1121 let local = LocalConfig::default();
1122 let effective = merge(config, local).unwrap();
1123 assert_eq!(effective.dependencies.len(), 1);
1124 let source = &effective.dependencies["base"];
1125 assert!(!source.is_overridden);
1126 assert!(source.original_git.is_none());
1127 match &source.spec {
1128 SourceSpec::Git(git) => {
1129 assert_eq!(git.url, "https://github.com/org/base.git");
1130 assert_eq!(git.version.as_deref(), Some("v1.0"));
1131 }
1132 SourceSpec::Path(_) => panic!("expected Git"),
1133 }
1134 }
1135
1136 #[test]
1137 fn merge_override_replaces_with_path() {
1138 let config = Config {
1139 dependencies: {
1140 let mut m = IndexMap::new();
1141 m.insert(
1142 "base".into(),
1143 DependencyEntry {
1144 url: Some("https://github.com/org/base.git".into()),
1145 path: None,
1146 subpath: None,
1147 version: Some("v1.0".into()),
1148 filter: FilterConfig::default(),
1149 },
1150 );
1151 m
1152 },
1153 settings: Settings::default(),
1154 ..Config::default()
1155 };
1156 let local = LocalConfig {
1157 overrides: {
1158 let mut m = IndexMap::new();
1159 m.insert(
1160 "base".into(),
1161 OverrideEntry {
1162 path: PathBuf::from("/home/dev/local-base"),
1163 },
1164 );
1165 m
1166 },
1167 };
1168 let effective = merge(config, local).unwrap();
1169 let source = &effective.dependencies["base"];
1170 assert!(source.is_overridden);
1171
1172 match &source.spec {
1173 SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
1174 SourceSpec::Git(_) => panic!("expected Path override"),
1175 }
1176
1177 let orig = source.original_git.as_ref().unwrap();
1178 assert_eq!(orig.url, "https://github.com/org/base.git");
1179 assert_eq!(orig.version.as_deref(), Some("v1.0"));
1180 }
1181
1182 #[test]
1183 fn merge_override_retains_subpath_coordinate() {
1184 let temp = TempDir::new().unwrap();
1185 let temp_root = dunce::canonicalize(temp.path()).unwrap();
1187 let override_path = temp_root.join("local-base");
1188 std::fs::create_dir_all(&override_path).unwrap();
1189 let canonical_override = dunce::canonicalize(&override_path).unwrap();
1190
1191 let config = Config {
1192 dependencies: {
1193 let mut m = IndexMap::new();
1194 m.insert(
1195 "base".into(),
1196 DependencyEntry {
1197 url: Some("https://github.com/org/base.git".into()),
1198 path: None,
1199 subpath: Some(SourceSubpath::new("plugins/foo").unwrap()),
1200 version: Some("v1.0".into()),
1201 filter: FilterConfig::default(),
1202 },
1203 );
1204 m
1205 },
1206 settings: Settings::default(),
1207 ..Config::default()
1208 };
1209 let local = LocalConfig {
1210 overrides: {
1211 let mut m = IndexMap::new();
1212 m.insert(
1213 "base".into(),
1214 OverrideEntry {
1215 path: canonical_override.clone(),
1216 },
1217 );
1218 m
1219 },
1220 };
1221
1222 let (effective, _) = merge_with_root(config, local, &temp_root).unwrap();
1223 let source = &effective.dependencies["base"];
1224 assert!(source.is_overridden);
1225 assert_eq!(
1226 source.subpath.as_ref().map(SourceSubpath::as_str),
1227 Some("plugins/foo")
1228 );
1229 assert!(matches!(&source.spec, SourceSpec::Path(p) if p == &canonical_override));
1230 assert!(matches!(
1231 &source.id,
1232 SourceId::Path {
1233 canonical,
1234 subpath: Some(sp)
1235 } if canonical == &canonical_override && sp.as_str() == "plugins/foo"
1236 ));
1237 }
1238
1239 #[test]
1240 fn merge_all_filter_mode() {
1241 let config = Config {
1242 dependencies: {
1243 let mut m = IndexMap::new();
1244 m.insert(
1245 "base".into(),
1246 DependencyEntry {
1247 url: Some("https://github.com/org/base.git".into()),
1248 path: None,
1249 subpath: None,
1250 version: None,
1251 filter: FilterConfig::default(),
1252 },
1253 );
1254 m
1255 },
1256 settings: Settings::default(),
1257 ..Config::default()
1258 };
1259 let effective = merge(config, LocalConfig::default()).unwrap();
1260 assert!(matches!(
1261 effective.dependencies["base"].filter,
1262 FilterMode::All
1263 ));
1264 }
1265
1266 #[test]
1267 fn save_and_reload() {
1268 let dir = TempDir::new().unwrap();
1269 let config = Config {
1270 dependencies: {
1271 let mut m = IndexMap::new();
1272 m.insert(
1273 "base".into(),
1274 DependencyEntry {
1275 url: Some("https://github.com/org/base.git".into()),
1276 path: None,
1277 subpath: None,
1278 version: Some("v2.0".into()),
1279 filter: FilterConfig::default(),
1280 },
1281 );
1282 m
1283 },
1284 settings: Settings::default(),
1285 ..Config::default()
1286 };
1287 save(dir.path(), &config).unwrap();
1288 let reloaded = load(dir.path()).unwrap();
1289 assert_eq!(config, reloaded);
1290 }
1291
1292 #[test]
1293 fn rename_map_preserved() {
1294 let toml_str = r#"
1295[dependencies.base]
1296url = "https://github.com/org/base.git"
1297
1298[dependencies.base.rename]
1299old-name = "new-name"
1300"#;
1301 let config: Config = toml::from_str(toml_str).unwrap();
1302 let effective = merge(config, LocalConfig::default()).unwrap();
1303 let source = &effective.dependencies["base"];
1304 assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
1305 }
1306
1307 #[test]
1308 fn self_dependency_name_rejected() {
1309 let toml_str = r#"
1310[dependencies._self]
1311url = "https://github.com/org/base.git"
1312"#;
1313 let config: Config = toml::from_str(toml_str).unwrap();
1314 let local = LocalConfig::default();
1315 let result = merge(config, local);
1316 assert!(result.is_err());
1317 let err = result.unwrap_err().to_string();
1318 assert!(
1319 err.contains("_self") && err.contains("reserved"),
1320 "should reject _self: {err}"
1321 );
1322 }
1323
1324 #[test]
1325 fn managed_root_setting_roundtrip() {
1326 let config = Config {
1327 settings: Settings {
1328 managed_root: Some(".claude".into()),
1329 targets: None,
1330 ..Settings::default()
1331 },
1332 ..Config::default()
1333 };
1334 let serialized = toml::to_string_pretty(&config).unwrap();
1335 let deserialized: Config = toml::from_str(&serialized).unwrap();
1336 assert_eq!(
1337 deserialized.settings.managed_root.as_deref(),
1338 Some(".claude")
1339 );
1340 }
1341
1342 #[test]
1343 fn save_preserves_dependencies_when_clearing_last_target() {
1344 let dir = TempDir::new().unwrap();
1345 let original = r#"
1346[package]
1347name = "sample"
1348version = "0.1.0"
1349
1350[dependencies.base]
1351url = "https://github.com/org/base.git"
1352version = "v1.0"
1353agents = ["coder"]
1354
1355[settings]
1356managed_root = ".agents"
1357targets = [".claude"]
1358"#;
1359 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1360
1361 let mut config = load(dir.path()).unwrap();
1362 if let Some(targets) = config.settings.targets.as_mut() {
1363 targets.retain(|target| target != ".claude");
1364 if targets.is_empty() {
1365 config.settings.targets = None;
1366 }
1367 }
1368 save(dir.path(), &config).unwrap();
1369
1370 let reloaded = load(dir.path()).unwrap();
1371 assert_eq!(
1372 reloaded.package.as_ref().map(|p| p.name.as_str()),
1373 Some("sample")
1374 );
1375 assert_eq!(
1376 reloaded.dependencies["base"].url.as_deref(),
1377 Some("https://github.com/org/base.git")
1378 );
1379 assert_eq!(
1380 reloaded.dependencies["base"].version.as_deref(),
1381 Some("v1.0")
1382 );
1383 assert_eq!(
1384 reloaded.dependencies["base"].filter.agents.as_deref(),
1385 Some(&["coder".into()][..])
1386 );
1387 assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
1388 assert!(reloaded.settings.targets.is_none());
1389 }
1390
1391 #[test]
1392 fn roundtrip_preserves_all_filter_fields() {
1393 let dir = TempDir::new().unwrap();
1394 let original = r#"
1395[dependencies.include]
1396url = "https://github.com/org/include.git"
1397agents = ["coder", "reviewer"]
1398skills = ["review", "plan"]
1399
1400[dependencies.include.rename]
1401coder = "core-coder"
1402
1403[dependencies.exclude]
1404url = "https://github.com/org/exclude.git"
1405exclude = ["experimental", "deprecated"]
1406
1407[dependencies.only_skills]
1408url = "https://github.com/org/skills.git"
1409only_skills = true
1410
1411[dependencies.only_agents]
1412url = "https://github.com/org/agents.git"
1413only_agents = true
1414"#;
1415 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1416
1417 let config = load(dir.path()).unwrap();
1418 save(dir.path(), &config).unwrap();
1419 let reloaded = load(dir.path()).unwrap();
1420
1421 let include = &reloaded.dependencies["include"].filter;
1422 assert_eq!(
1423 include.agents.as_deref(),
1424 Some(&["coder".into(), "reviewer".into()][..])
1425 );
1426 assert_eq!(
1427 include.skills.as_deref(),
1428 Some(&["review".into(), "plan".into()][..])
1429 );
1430 assert_eq!(
1431 include.rename.as_ref().and_then(|r| r.get("coder")),
1432 Some(&"core-coder".into())
1433 );
1434
1435 let exclude = &reloaded.dependencies["exclude"].filter;
1436 assert_eq!(
1437 exclude.exclude.as_deref(),
1438 Some(&["experimental".into(), "deprecated".into()][..])
1439 );
1440
1441 let only_skills = &reloaded.dependencies["only_skills"].filter;
1442 assert!(only_skills.only_skills);
1443 assert!(!only_skills.only_agents);
1444
1445 let only_agents = &reloaded.dependencies["only_agents"].filter;
1446 assert!(only_agents.only_agents);
1447 assert!(!only_agents.only_skills);
1448 }
1449
1450 #[test]
1451 fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
1452 let dir = TempDir::new().unwrap();
1453 let original = r#"
1454[dependencies.git-include]
1455url = "https://github.com/org/git-include.git"
1456agents = ["coder"]
1457
1458[dependencies.path-exclude]
1459path = "../local-source"
1460exclude = ["draft"]
1461
1462[dependencies.git-only-skills]
1463url = "https://github.com/org/git-skills.git"
1464only_skills = true
1465
1466[dependencies.git-only-agents]
1467url = "https://github.com/org/git-agents.git"
1468only_agents = true
1469"#;
1470 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1471
1472 let config = load(dir.path()).unwrap();
1473 save(dir.path(), &config).unwrap();
1474 let reloaded = load(dir.path()).unwrap();
1475
1476 assert_eq!(reloaded.dependencies.len(), 4);
1477 assert_eq!(
1478 reloaded.dependencies["git-include"]
1479 .filter
1480 .agents
1481 .as_deref(),
1482 Some(&["coder".into()][..])
1483 );
1484 assert_eq!(
1485 reloaded.dependencies["path-exclude"].path.as_deref(),
1486 Some(Path::new("../local-source"))
1487 );
1488 assert_eq!(
1489 reloaded.dependencies["path-exclude"]
1490 .filter
1491 .exclude
1492 .as_deref(),
1493 Some(&["draft".into()][..])
1494 );
1495 assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
1496 assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
1497 }
1498
1499 #[test]
1500 fn save_roundtrip_guard_rejects_dependency_count_loss() {
1501 let mut original = Config::default();
1502 original.dependencies.insert(
1503 "base".into(),
1504 DependencyEntry {
1505 url: Some("https://github.com/org/base.git".into()),
1506 path: None,
1507 subpath: None,
1508 version: Some("v1.0".into()),
1509 filter: FilterConfig::default(),
1510 },
1511 );
1512
1513 let reparsed = Config::default();
1514 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1515 let msg = err.to_string();
1516 assert!(
1517 msg.contains("dependency count changed"),
1518 "unexpected error: {msg}"
1519 );
1520 }
1521
1522 #[test]
1523 fn save_roundtrip_guard_rejects_managed_root_loss() {
1524 let original = Config {
1525 settings: Settings {
1526 managed_root: Some(".agents".into()),
1527 targets: None,
1528 ..Settings::default()
1529 },
1530 ..Config::default()
1531 };
1532 let reparsed = Config::default();
1533 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1534 let msg = err.to_string();
1535 assert!(
1536 msg.contains("settings.managed_root changed"),
1537 "unexpected error: {msg}"
1538 );
1539 }
1540
1541 #[test]
1542 fn parse_only_skills_filter() {
1543 let toml_str = r#"
1544[dependencies.base]
1545url = "https://github.com/org/base.git"
1546only_skills = true
1547"#;
1548 let config: Config = toml::from_str(toml_str).unwrap();
1549 let local = LocalConfig::default();
1550 let effective = merge(config, local).unwrap();
1551 let source = &effective.dependencies["base"];
1552 assert!(matches!(source.filter, FilterMode::OnlySkills));
1553 }
1554
1555 #[test]
1556 fn parse_only_agents_filter() {
1557 let toml_str = r#"
1558[dependencies.base]
1559url = "https://github.com/org/base.git"
1560only_agents = true
1561"#;
1562 let config: Config = toml::from_str(toml_str).unwrap();
1563 let local = LocalConfig::default();
1564 let effective = merge(config, local).unwrap();
1565 let source = &effective.dependencies["base"];
1566 assert!(matches!(source.filter, FilterMode::OnlyAgents));
1567 }
1568
1569 #[test]
1570 fn error_on_only_skills_and_only_agents() {
1571 let toml_str = r#"
1572[dependencies.bad]
1573url = "https://github.com/org/bad.git"
1574only_skills = true
1575only_agents = true
1576"#;
1577 let config: Config = toml::from_str(toml_str).unwrap();
1578 let local = LocalConfig::default();
1579 let result = merge(config, local);
1580 assert!(result.is_err());
1581 let err = result.unwrap_err().to_string();
1582 assert!(
1583 err.contains("mutually exclusive"),
1584 "should mention mutually exclusive: {err}"
1585 );
1586 }
1587
1588 #[test]
1589 fn error_on_only_skills_with_agents_list() {
1590 let toml_str = r#"
1591[dependencies.bad]
1592url = "https://github.com/org/bad.git"
1593only_skills = true
1594agents = ["coder"]
1595"#;
1596 let config: Config = toml::from_str(toml_str).unwrap();
1597 let local = LocalConfig::default();
1598 let result = merge(config, local);
1599 assert!(result.is_err());
1600 let err = result.unwrap_err().to_string();
1601 assert!(
1602 err.contains("cannot combine"),
1603 "should mention cannot combine: {err}"
1604 );
1605 }
1606
1607 #[test]
1608 fn error_on_only_agents_with_skills_list() {
1609 let toml_str = r#"
1610[dependencies.bad]
1611url = "https://github.com/org/bad.git"
1612only_agents = true
1613skills = ["planning"]
1614"#;
1615 let config: Config = toml::from_str(toml_str).unwrap();
1616 let local = LocalConfig::default();
1617 let result = merge(config, local);
1618 assert!(result.is_err());
1619 }
1620
1621 #[test]
1622 fn error_on_only_skills_with_exclude() {
1623 let toml_str = r#"
1624[dependencies.bad]
1625url = "https://github.com/org/bad.git"
1626only_skills = true
1627exclude = ["deprecated"]
1628"#;
1629 let config: Config = toml::from_str(toml_str).unwrap();
1630 let local = LocalConfig::default();
1631 let result = merge(config, local);
1632 assert!(result.is_err());
1633 }
1634
1635 #[test]
1636 fn only_skills_false_not_serialized() {
1637 let config = Config {
1638 dependencies: {
1639 let mut m = IndexMap::new();
1640 m.insert(
1641 "base".into(),
1642 DependencyEntry {
1643 url: Some("https://github.com/org/base.git".into()),
1644 path: None,
1645 subpath: None,
1646 version: None,
1647 filter: FilterConfig::default(),
1648 },
1649 );
1650 m
1651 },
1652 settings: Settings::default(),
1653 ..Config::default()
1654 };
1655 let serialized = toml::to_string_pretty(&config).unwrap();
1656 assert!(
1657 !serialized.contains("only_skills"),
1658 "false booleans should not be serialized: {serialized}"
1659 );
1660 assert!(
1661 !serialized.contains("only_agents"),
1662 "false booleans should not be serialized: {serialized}"
1663 );
1664 }
1665
1666 #[test]
1667 fn only_skills_true_roundtrips() {
1668 let toml_str = r#"
1669[dependencies.base]
1670url = "https://github.com/org/base.git"
1671only_skills = true
1672"#;
1673 let config: Config = toml::from_str(toml_str).unwrap();
1674 assert!(config.dependencies["base"].filter.only_skills);
1675 assert!(!config.dependencies["base"].filter.only_agents);
1676
1677 let serialized = toml::to_string_pretty(&config).unwrap();
1678 let reloaded: Config = toml::from_str(&serialized).unwrap();
1679 assert!(reloaded.dependencies["base"].filter.only_skills);
1680 }
1681
1682 #[test]
1683 fn filter_config_has_any_filter() {
1684 assert!(!FilterConfig::default().has_any_filter());
1685 assert!(
1686 FilterConfig {
1687 only_skills: true,
1688 ..FilterConfig::default()
1689 }
1690 .has_any_filter()
1691 );
1692 assert!(
1693 FilterConfig {
1694 agents: Some(vec!["coder".into()]),
1695 ..FilterConfig::default()
1696 }
1697 .has_any_filter()
1698 );
1699 }
1700
1701 #[test]
1702 fn filter_config_to_mode() {
1703 assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
1704 assert!(matches!(
1705 FilterConfig {
1706 only_skills: true,
1707 ..FilterConfig::default()
1708 }
1709 .to_mode(),
1710 FilterMode::OnlySkills
1711 ));
1712 assert!(matches!(
1713 FilterConfig {
1714 only_agents: true,
1715 ..FilterConfig::default()
1716 }
1717 .to_mode(),
1718 FilterMode::OnlyAgents
1719 ));
1720 assert!(matches!(
1721 FilterConfig {
1722 agents: Some(vec!["coder".into()]),
1723 ..FilterConfig::default()
1724 }
1725 .to_mode(),
1726 FilterMode::Include { .. }
1727 ));
1728 assert!(matches!(
1729 FilterConfig {
1730 exclude: Some(vec!["old".into()]),
1731 ..FilterConfig::default()
1732 }
1733 .to_mode(),
1734 FilterMode::Exclude(_)
1735 ));
1736 }
1737
1738 #[test]
1741 fn managed_targets_defaults_to_no_target_sync_targets() {
1742 let settings = Settings::default();
1743 assert!(settings.managed_targets().is_empty());
1744 }
1745
1746 #[test]
1747 fn managed_targets_uses_explicit_targets() {
1748 let settings = Settings {
1749 targets: Some(vec![".claude".to_string()]),
1750 ..Settings::default()
1751 };
1752 assert_eq!(settings.managed_targets(), vec![".claude"]);
1753 }
1754
1755 #[test]
1756 fn managed_targets_uses_managed_root_as_primary() {
1757 let settings = Settings {
1758 managed_root: Some(".claude".to_string()),
1759 ..Settings::default()
1760 };
1761 assert_eq!(settings.managed_targets(), vec![".claude"]);
1762 }
1763
1764 #[test]
1765 fn managed_targets_explicit_overrides_links_and_managed_root() {
1766 let settings = Settings {
1767 managed_root: Some(".cursor".to_string()),
1768 targets: Some(vec![".codex".to_string()]),
1769 ..Settings::default()
1770 };
1771 assert_eq!(settings.managed_targets(), vec![".codex"]);
1773 }
1774
1775 #[test]
1776 fn merge_warns_when_managed_root_is_agents() {
1777 let config = Config {
1778 settings: Settings {
1779 managed_root: Some(".agents".into()),
1780 ..Settings::default()
1781 },
1782 ..Config::default()
1783 };
1784
1785 let (_, diagnostics) =
1786 merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
1787
1788 assert!(diagnostics.iter().any(|diag| {
1789 diag.code == "deprecated-agents-target"
1790 && diag.context.as_deref() == Some("settings.managed_root")
1791 }));
1792 }
1793
1794 #[test]
1795 fn merge_warns_when_targets_include_agents() {
1796 let config = Config {
1797 settings: Settings {
1798 targets: Some(vec![".agents".into(), ".claude".into()]),
1799 ..Settings::default()
1800 },
1801 ..Config::default()
1802 };
1803
1804 let (_, diagnostics) =
1805 merge_with_root(config, LocalConfig::default(), Path::new(".")).unwrap();
1806
1807 assert!(diagnostics.iter().any(|diag| {
1808 diag.code == "deprecated-agents-target"
1809 && diag.context.as_deref() == Some("settings.targets")
1810 }));
1811 }
1812
1813 #[test]
1814 fn settings_models_cache_ttl_defaults_to_24_when_omitted() {
1815 let config: Config = toml::from_str(
1816 r#"
1817[dependencies.base]
1818url = "https://github.com/org/base.git"
1819"#,
1820 )
1821 .unwrap();
1822 assert_eq!(config.settings.models_cache_ttl_hours, 24);
1823 }
1824
1825 #[test]
1826 fn settings_models_cache_ttl_defaults_to_24_when_settings_present_without_ttl() {
1827 let config: Config = toml::from_str(
1828 r#"
1829[settings]
1830managed_root = ".agents"
1831"#,
1832 )
1833 .unwrap();
1834 assert_eq!(config.settings.models_cache_ttl_hours, 24);
1835 }
1836
1837 #[test]
1838 fn settings_models_cache_ttl_parses_zero() {
1839 let config: Config = toml::from_str(
1840 r#"
1841[settings]
1842models_cache_ttl_hours = 0
1843"#,
1844 )
1845 .unwrap();
1846 assert_eq!(config.settings.models_cache_ttl_hours, 0);
1847 }
1848
1849 #[test]
1850 fn settings_models_cache_ttl_parses_custom_value() {
1851 let config: Config = toml::from_str(
1852 r#"
1853[settings]
1854models_cache_ttl_hours = 48
1855"#,
1856 )
1857 .unwrap();
1858 assert_eq!(config.settings.models_cache_ttl_hours, 48);
1859 }
1860
1861 #[test]
1862 fn settings_models_cache_ttl_roundtrip_preserves_value() {
1863 let original = Config {
1864 settings: Settings {
1865 models_cache_ttl_hours: 48,
1866 ..Settings::default()
1867 },
1868 ..Config::default()
1869 };
1870 let serialized = toml::to_string_pretty(&original).unwrap();
1871 let roundtripped: Config = toml::from_str(&serialized).unwrap();
1872 assert_eq!(
1873 roundtripped.settings.models_cache_ttl_hours,
1874 original.settings.models_cache_ttl_hours
1875 );
1876 }
1877
1878 #[test]
1879 fn settings_agent_emission_parses_auto() {
1880 let config: Config = toml::from_str(
1881 r#"
1882[settings]
1883agent_emission = "auto"
1884"#,
1885 )
1886 .unwrap();
1887 assert_eq!(config.settings.agent_emission, Some(AgentEmission::Auto));
1888 }
1889
1890 #[test]
1891 fn settings_agent_emission_parses_always_and_never() {
1892 let always: Config = toml::from_str(
1893 r#"
1894[settings]
1895agent_emission = "always"
1896"#,
1897 )
1898 .unwrap();
1899 assert_eq!(always.settings.agent_emission, Some(AgentEmission::Always));
1900
1901 let never: Config = toml::from_str(
1902 r#"
1903[settings]
1904agent_emission = "never"
1905"#,
1906 )
1907 .unwrap();
1908 assert_eq!(never.settings.agent_emission, Some(AgentEmission::Never));
1909 }
1910
1911 #[test]
1912 fn settings_agent_emission_defaults_to_auto_when_omitted() {
1913 let config: Config = toml::from_str(
1914 r#"
1915[settings]
1916models_cache_ttl_hours = 48
1917"#,
1918 )
1919 .unwrap();
1920 assert!(config.settings.agent_emission.is_none());
1921 }
1922
1923 #[test]
1924 fn settings_agent_emission_roundtrip_preserves_value() {
1925 let original = Config {
1926 settings: Settings {
1927 agent_emission: Some(AgentEmission::Always),
1928 ..Settings::default()
1929 },
1930 ..Config::default()
1931 };
1932 let serialized = toml::to_string_pretty(&original).unwrap();
1933 let roundtripped: Config = toml::from_str(&serialized).unwrap();
1934 assert_eq!(
1935 roundtripped.settings.agent_emission,
1936 original.settings.agent_emission
1937 );
1938 }
1939
1940 #[test]
1941 fn model_visibility_validate_allows_include_and_exclude() {
1942 let visibility = ModelVisibility {
1943 include: Some(vec!["opus*".into()]),
1944 exclude: Some(vec!["test*".into()]),
1945 };
1946 visibility.validate().unwrap();
1947 }
1948
1949 #[test]
1950 fn model_visibility_validate_allows_include_only_exclude_only_and_empty() {
1951 ModelVisibility {
1952 include: Some(vec!["opus*".into()]),
1953 exclude: None,
1954 }
1955 .validate()
1956 .unwrap();
1957 ModelVisibility {
1958 include: None,
1959 exclude: Some(vec!["test*".into()]),
1960 }
1961 .validate()
1962 .unwrap();
1963 ModelVisibility::default().validate().unwrap();
1964 }
1965
1966 #[test]
1967 fn model_visibility_is_empty_reports_state() {
1968 assert!(ModelVisibility::default().is_empty());
1969 assert!(
1970 !ModelVisibility {
1971 include: Some(vec!["opus*".into()]),
1972 exclude: None,
1973 }
1974 .is_empty()
1975 );
1976 assert!(
1977 !ModelVisibility {
1978 include: None,
1979 exclude: Some(vec!["test*".into()]),
1980 }
1981 .is_empty()
1982 );
1983 }
1984
1985 #[test]
1986 fn load_accepts_model_visibility_with_include_and_exclude() {
1987 let dir = TempDir::new().unwrap();
1988 std::fs::write(
1989 dir.path().join("mars.toml"),
1990 r#"
1991[settings.model_visibility]
1992include = ["opus*"]
1993exclude = ["test*"]
1994"#,
1995 )
1996 .unwrap();
1997
1998 let config = load(dir.path()).unwrap();
1999 assert_eq!(
2000 config.settings.model_visibility.include,
2001 Some(vec!["opus*".into()])
2002 );
2003 assert_eq!(
2004 config.settings.model_visibility.exclude,
2005 Some(vec!["test*".into()])
2006 );
2007 }
2008
2009 #[test]
2010 fn load_accepts_model_visibility_include_only() {
2011 let dir = TempDir::new().unwrap();
2012 std::fs::write(
2013 dir.path().join("mars.toml"),
2014 r#"
2015[settings.model_visibility]
2016include = ["opus*", "gpt-*"]
2017"#,
2018 )
2019 .unwrap();
2020
2021 let config = load(dir.path()).unwrap();
2022 assert_eq!(
2023 config.settings.model_visibility.include,
2024 Some(vec!["opus*".into(), "gpt-*".into()])
2025 );
2026 assert!(config.settings.model_visibility.exclude.is_none());
2027 }
2028
2029 #[test]
2030 fn load_accepts_model_visibility_exclude_only() {
2031 let dir = TempDir::new().unwrap();
2032 std::fs::write(
2033 dir.path().join("mars.toml"),
2034 r#"
2035[settings.model_visibility]
2036exclude = ["test-*", "deprecated-*"]
2037"#,
2038 )
2039 .unwrap();
2040
2041 let config = load(dir.path()).unwrap();
2042 assert_eq!(
2043 config.settings.model_visibility.exclude,
2044 Some(vec!["test-*".into(), "deprecated-*".into()])
2045 );
2046 assert!(config.settings.model_visibility.include.is_none());
2047 }
2048
2049 #[test]
2052 fn parse_local_dependencies() {
2053 let toml_str = r#"
2054[dependencies.base]
2055url = "https://github.com/org/base.git"
2056
2057[local-dependencies.prompter]
2058url = "https://github.com/org/prompter.git"
2059skills = ["prompt-helper"]
2060"#;
2061 let config: Config = toml::from_str(toml_str).unwrap();
2062 assert_eq!(config.dependencies.len(), 1);
2063 assert_eq!(config.local_dependencies.len(), 1);
2064 assert!(config.local_dependencies.contains_key("prompter"));
2065 assert_eq!(
2066 config.local_dependencies["prompter"].url.as_deref(),
2067 Some("https://github.com/org/prompter.git")
2068 );
2069 }
2070
2071 #[test]
2072 fn local_dependencies_merged_into_effective_config() {
2073 let toml_str = r#"
2074[dependencies.base]
2075url = "https://github.com/org/base.git"
2076
2077[local-dependencies.prompter]
2078url = "https://github.com/org/prompter.git"
2079"#;
2080 let config: Config = toml::from_str(toml_str).unwrap();
2081 let local = LocalConfig::default();
2082 let effective = merge(config, local).unwrap();
2083
2084 assert_eq!(effective.dependencies.len(), 2);
2086 assert!(effective.dependencies.contains_key("base"));
2087 assert!(effective.dependencies.contains_key("prompter"));
2088 }
2089
2090 #[test]
2091 fn local_dependencies_not_exported_to_manifest() {
2092 let dir = TempDir::new().unwrap();
2093 std::fs::write(
2094 dir.path().join("mars.toml"),
2095 r#"
2096[package]
2097name = "my-package"
2098version = "1.0.0"
2099
2100[dependencies.base]
2101url = "https://github.com/org/base.git"
2102
2103[local-dependencies.prompter]
2104url = "https://github.com/org/prompter.git"
2105"#,
2106 )
2107 .unwrap();
2108
2109 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
2110 assert!(diagnostics.is_empty());
2111 let manifest = manifest.unwrap();
2112
2113 assert_eq!(manifest.dependencies.len(), 1);
2115 assert!(manifest.dependencies.contains_key("base"));
2116 assert!(!manifest.dependencies.contains_key("prompter"));
2117 }
2118
2119 #[test]
2120 fn error_on_duplicate_name_across_sections() {
2121 let toml_str = r#"
2122[dependencies.base]
2123url = "https://github.com/org/base.git"
2124
2125[local-dependencies.base]
2126url = "https://github.com/org/base-local.git"
2127"#;
2128 let config: Config = toml::from_str(toml_str).unwrap();
2129 let local = LocalConfig::default();
2130 let result = merge(config, local);
2131 assert!(result.is_err());
2132 let err = result.unwrap_err().to_string();
2133 assert!(
2134 err.contains("base") && err.contains("both"),
2135 "should reject duplicate name: {err}"
2136 );
2137 }
2138
2139 #[test]
2140 fn local_dependencies_roundtrip() {
2141 let dir = TempDir::new().unwrap();
2142 let original = r#"
2143[dependencies.base]
2144url = "https://github.com/org/base.git"
2145
2146[local-dependencies.prompter]
2147url = "https://github.com/org/prompter.git"
2148skills = ["prompt-helper"]
2149"#;
2150 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
2151
2152 let config = load(dir.path()).unwrap();
2153 save(dir.path(), &config).unwrap();
2154 let reloaded = load(dir.path()).unwrap();
2155
2156 assert_eq!(reloaded.dependencies.len(), 1);
2157 assert_eq!(reloaded.local_dependencies.len(), 1);
2158 assert!(reloaded.local_dependencies.contains_key("prompter"));
2159 assert_eq!(
2160 reloaded.local_dependencies["prompter"]
2161 .filter
2162 .skills
2163 .as_deref(),
2164 Some(&["prompt-helper".into()][..])
2165 );
2166 }
2167}