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