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