1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::diagnostic::{Diagnostic, DiagnosticLevel};
7use crate::error::{ConfigError, MarsError};
8use crate::types::{ItemName, RenameMap, SourceId, SourceName, SourceOrigin, SourceUrl};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
12pub struct Config {
13 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub package: Option<PackageInfo>,
15 #[serde(default)]
16 pub dependencies: IndexMap<SourceName, InstallDep>,
17 #[serde(default)]
18 pub settings: Settings,
19 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
20 pub models: IndexMap<String, crate::models::ModelAlias>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct PackageInfo {
26 pub name: String,
27 pub version: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub description: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct InstallDep {
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub url: Option<SourceUrl>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub path: Option<PathBuf>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub version: Option<String>,
42 #[serde(flatten)]
43 pub filter: FilterConfig,
44}
45
46pub type DependencyEntry = InstallDep;
48
49#[derive(Debug, Clone, PartialEq)]
52pub struct ManifestDep {
53 pub url: SourceUrl,
54 pub version: Option<String>,
55}
56
57#[derive(Debug, Clone, PartialEq)]
63pub struct Manifest {
64 pub package: PackageInfo,
65 pub dependencies: IndexMap<String, ManifestDep>,
66 pub models: IndexMap<String, crate::models::ModelAlias>,
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
71pub struct FilterConfig {
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub agents: Option<Vec<ItemName>>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub skills: Option<Vec<ItemName>>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub exclude: Option<Vec<ItemName>>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub rename: Option<RenameMap>,
80 #[serde(default, skip_serializing_if = "is_false")]
81 pub only_skills: bool,
82 #[serde(default, skip_serializing_if = "is_false")]
83 pub only_agents: bool,
84}
85
86fn is_false(v: &bool) -> bool {
87 !v
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
95pub struct LocalConfig {
96 #[serde(default)]
97 pub overrides: IndexMap<SourceName, OverrideEntry>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct OverrideEntry {
103 pub path: PathBuf,
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
108pub struct Settings {
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub managed_root: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub targets: Option<Vec<String>>,
116}
117
118impl Settings {
119 pub fn managed_targets(&self) -> Vec<String> {
124 if let Some(targets) = &self.targets {
125 return targets.clone();
126 }
127 vec![
128 self.managed_root
129 .clone()
130 .unwrap_or_else(|| ".agents".to_string()),
131 ]
132 }
133}
134
135#[derive(Debug, Clone)]
137pub enum SourceSpec {
138 Git(GitSpec),
139 Path(PathBuf),
140}
141
142#[derive(Debug, Clone)]
144pub struct GitSpec {
145 pub url: SourceUrl,
146 pub version: Option<String>,
147}
148
149#[derive(Debug, Clone)]
151pub enum FilterMode {
152 All,
154 Include {
156 agents: Vec<ItemName>,
157 skills: Vec<ItemName>,
158 },
159 Exclude(Vec<ItemName>),
161 OnlySkills,
163 OnlyAgents,
165}
166
167#[derive(Debug, Clone)]
171pub struct EffectiveConfig {
172 pub dependencies: IndexMap<SourceName, EffectiveDependency>,
173 pub settings: Settings,
174}
175
176#[derive(Debug, Clone)]
178pub struct EffectiveDependency {
179 pub name: SourceName,
180 pub id: SourceId,
181 pub spec: SourceSpec,
182 pub filter: FilterMode,
183 pub rename: RenameMap,
184 pub is_overridden: bool,
185 pub original_git: Option<GitSpec>,
186}
187
188const CONFIG_FILE: &str = "mars.toml";
189const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
190
191pub fn load(root: &Path) -> Result<Config, MarsError> {
193 let path = root.join(CONFIG_FILE);
194 let content = std::fs::read_to_string(&path).map_err(|e| {
195 if e.kind() == std::io::ErrorKind::NotFound {
196 ConfigError::NotFound { path: path.clone() }
197 } else {
198 ConfigError::Io(e)
199 }
200 })?;
201 let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
202 migrate_legacy_source_urls(&mut config);
203 Ok(config)
204}
205
206pub fn load_manifest(source_root: &Path) -> Result<(Option<Manifest>, Vec<Diagnostic>), MarsError> {
214 let path = source_root.join(CONFIG_FILE);
215 let mut diagnostics = Vec::new();
216 match std::fs::read_to_string(&path) {
217 Ok(content) => {
218 let parsed: Config =
219 toml::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
220 message: format!("failed to parse {}: {e}", path.display()),
221 })?;
222 let Some(package) = parsed.package else {
223 return Ok((None, diagnostics));
224 };
225 let deps: IndexMap<String, ManifestDep> = parsed
227 .dependencies
228 .into_iter()
229 .filter_map(|(name, entry)| match entry.url {
230 Some(url) => Some((
231 name.to_string(),
232 ManifestDep {
233 url,
234 version: entry.version,
235 },
236 )),
237 None => {
238 diagnostics.push(Diagnostic {
240 level: DiagnosticLevel::Warning,
241 code: "manifest-path-dep",
242 message: format!(
243 "manifest dependency `{name}` has no URL and will not propagate to consumers"
244 ),
245 context: None,
246 });
247 None
248 }
249 })
250 .collect();
251 Ok((
252 Some(Manifest {
253 package,
254 dependencies: deps,
255 models: parsed.models,
256 }),
257 diagnostics,
258 ))
259 }
260 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((None, diagnostics)),
261 Err(e) => Err(MarsError::Io(e)),
262 }
263}
264
265pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
267 let path = root.join(LOCAL_CONFIG_FILE);
268 match std::fs::read_to_string(&path) {
269 Ok(content) => {
270 let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
271 Ok(local)
272 }
273 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
274 Err(e) => Err(ConfigError::Io(e).into()),
275 }
276}
277
278pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
285 let (effective, _diagnostics) = merge_with_root(config, local, Path::new("."))?;
286 Ok(effective)
287}
288
289pub fn merge_with_root(
291 config: Config,
292 local: LocalConfig,
293 root: &Path,
294) -> Result<(EffectiveConfig, Vec<Diagnostic>), MarsError> {
295 let mut dependencies = IndexMap::new();
296 let mut diagnostics = Vec::new();
297 let local_source_name = SourceOrigin::LocalPackage.to_string();
298
299 for (name, entry) in &config.dependencies {
300 if name.as_ref() == local_source_name.as_str() {
302 return Err(ConfigError::Invalid {
303 message: "dependency name `_self` is reserved for local package items".into(),
304 }
305 .into());
306 }
307
308 let base_spec = match (&entry.url, &entry.path) {
310 (Some(url), None) => SourceSpec::Git(GitSpec {
311 url: url.clone(),
312 version: entry.version.clone(),
313 }),
314 (None, Some(path)) => SourceSpec::Path(path.clone()),
315 (Some(_), Some(_)) => {
316 return Err(ConfigError::Invalid {
317 message: format!("source `{name}` has both `url` and `path` — pick one"),
318 }
319 .into());
320 }
321 (None, None) => {
322 return Err(ConfigError::Invalid {
323 message: format!(
324 "source `{name}` has neither `url` nor `path` — one is required"
325 ),
326 }
327 .into());
328 }
329 };
330
331 validate_filter(&entry.filter, name.as_ref())?;
333
334 let filter = entry.filter.to_mode();
335
336 let rename = entry.filter.rename.clone().unwrap_or_default();
337
338 let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
340 let original = match &base_spec {
341 SourceSpec::Git(git) => Some(git.clone()),
342 SourceSpec::Path(_) => None,
343 };
344 (SourceSpec::Path(ov.path.clone()), true, original)
345 } else {
346 (base_spec, false, None)
347 };
348 let id = source_id_for_spec(root, &spec);
349
350 dependencies.insert(
351 name.clone(),
352 EffectiveDependency {
353 name: name.clone(),
354 id,
355 spec,
356 filter,
357 rename,
358 is_overridden,
359 original_git,
360 },
361 );
362 }
363
364 for override_name in local.overrides.keys() {
366 if !config.dependencies.contains_key(override_name) {
367 diagnostics.push(Diagnostic {
368 level: DiagnosticLevel::Warning,
369 code: "override-missing-dep",
370 message: format!(
371 "override `{override_name}` references a dependency not in mars.toml"
372 ),
373 context: None,
374 });
375 }
376 }
377
378 Ok((
379 EffectiveConfig {
380 dependencies,
381 settings: config.settings,
382 },
383 diagnostics,
384 ))
385}
386
387pub fn validate_filter(filter: &FilterConfig, dep_name: &str) -> Result<(), MarsError> {
395 let has_include = filter.agents.is_some() || filter.skills.is_some();
396 let has_exclude = filter.exclude.is_some();
397 let has_category = filter.only_skills || filter.only_agents;
398
399 if filter.only_skills && filter.only_agents {
400 return Err(ConfigError::Invalid {
401 message: format!(
402 "dependency `{dep_name}`: only_skills and only_agents are mutually exclusive"
403 ),
404 }
405 .into());
406 }
407 if has_category && has_include {
408 return Err(ConfigError::Invalid {
409 message: format!(
410 "dependency `{dep_name}`: only_skills/only_agents cannot combine with agents/skills lists"
411 ),
412 }
413 .into());
414 }
415 if has_category && has_exclude {
416 return Err(ConfigError::Invalid {
417 message: format!(
418 "dependency `{dep_name}`: only_skills/only_agents cannot combine with exclude"
419 ),
420 }
421 .into());
422 }
423 if has_include && has_exclude {
424 return Err(ConfigError::ConflictingFilters {
425 name: dep_name.to_string(),
426 }
427 .into());
428 }
429 Ok(())
430}
431
432impl FilterConfig {
433 pub fn to_mode(&self) -> FilterMode {
435 if self.only_skills {
436 FilterMode::OnlySkills
437 } else if self.only_agents {
438 FilterMode::OnlyAgents
439 } else if self.agents.is_some() || self.skills.is_some() {
440 FilterMode::Include {
441 agents: self.agents.clone().unwrap_or_default(),
442 skills: self.skills.clone().unwrap_or_default(),
443 }
444 } else if self.exclude.is_some() {
445 FilterMode::Exclude(self.exclude.clone().unwrap_or_default())
446 } else {
447 FilterMode::All
448 }
449 }
450
451 pub fn has_any_filter(&self) -> bool {
453 self.agents.is_some()
454 || self.skills.is_some()
455 || self.exclude.is_some()
456 || self.only_skills
457 || self.only_agents
458 }
459}
460
461fn source_id_for_spec(root: &Path, spec: &SourceSpec) -> SourceId {
462 match spec {
463 SourceSpec::Git(git) => SourceId::git(git.url.clone()),
464 SourceSpec::Path(path) => match SourceId::path(root, path) {
465 Ok(id) => id,
466 Err(_) => {
467 let canonical = if path.is_absolute() {
468 path.clone()
469 } else {
470 root.join(path)
471 };
472 SourceId::Path { canonical }
473 }
474 },
475 }
476}
477
478fn migrate_legacy_source_urls(config: &mut Config) {
479 for dep in config.dependencies.values_mut() {
480 if let Some(url) = dep.url.as_mut() {
481 let raw = url.as_str();
482 if should_upgrade_legacy_git_url(raw) {
483 *url = SourceUrl::from(format!("https://{raw}"));
484 }
485 }
486 }
487}
488
489fn should_upgrade_legacy_git_url(url: &str) -> bool {
490 !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
491}
492
493pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
495 let path = root.join(CONFIG_FILE);
496 let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
497 message: format!("failed to serialize config: {e}"),
498 })?;
499 let reparsed: Config = toml::from_str(&content).map_err(|e| ConfigError::Invalid {
500 message: format!("refusing to save config: serialized output failed to parse: {e}"),
501 })?;
502 validate_save_roundtrip(config, &reparsed)?;
503 crate::fs::atomic_write(&path, content.as_bytes())
504}
505
506fn validate_save_roundtrip(original: &Config, reparsed: &Config) -> Result<(), MarsError> {
507 if reparsed.dependencies.len() != original.dependencies.len() {
508 return Err(ConfigError::Invalid {
509 message: format!(
510 "refusing to save config: dependency count changed during roundtrip ({} -> {})",
511 original.dependencies.len(),
512 reparsed.dependencies.len()
513 ),
514 }
515 .into());
516 }
517
518 if reparsed.settings.managed_root != original.settings.managed_root {
519 return Err(ConfigError::Invalid {
520 message: format!(
521 "refusing to save config: settings.managed_root changed during roundtrip ({:?} -> {:?})",
522 original.settings.managed_root, reparsed.settings.managed_root
523 ),
524 }
525 .into());
526 }
527
528 for (name, dep) in &original.dependencies {
529 let Some(reparsed_dep) = reparsed.dependencies.get(name) else {
530 return Err(ConfigError::Invalid {
531 message: format!(
532 "refusing to save config: dependency `{name}` missing after roundtrip"
533 ),
534 }
535 .into());
536 };
537
538 if reparsed_dep != dep {
539 return Err(ConfigError::Invalid {
540 message: format!(
541 "refusing to save config: dependency `{name}` changed during roundtrip"
542 ),
543 }
544 .into());
545 }
546 }
547
548 Ok(())
549}
550
551pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
553 let path = root.join(LOCAL_CONFIG_FILE);
554 let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
555 message: format!("failed to serialize local config: {e}"),
556 })?;
557 crate::fs::atomic_write(&path, content.as_bytes())
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use tempfile::TempDir;
564
565 #[test]
566 fn parse_git_dependency() {
567 let toml_str = r#"
568[dependencies.base]
569url = "https://github.com/org/base.git"
570version = "v1.0"
571"#;
572 let config: Config = toml::from_str(toml_str).unwrap();
573 assert_eq!(config.dependencies.len(), 1);
574 let entry = &config.dependencies["base"];
575 assert_eq!(
576 entry.url.as_deref(),
577 Some("https://github.com/org/base.git")
578 );
579 assert!(entry.path.is_none());
580 assert_eq!(entry.version.as_deref(), Some("v1.0"));
581 }
582
583 #[test]
584 fn parse_path_dependency() {
585 let toml_str = r#"
586[dependencies.local]
587path = "../my-agents"
588"#;
589 let config: Config = toml::from_str(toml_str).unwrap();
590 let entry = &config.dependencies["local"];
591 assert!(entry.url.is_none());
592 assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
593 }
594
595 #[test]
596 fn parse_mixed_dependencies() {
597 let toml_str = r#"
598[dependencies.remote]
599url = "https://github.com/org/remote.git"
600version = "v2.0"
601agents = ["coder", "reviewer"]
602
603[dependencies.local]
604path = "/home/dev/agents"
605exclude = ["experimental"]
606"#;
607 let config: Config = toml::from_str(toml_str).unwrap();
608 assert_eq!(config.dependencies.len(), 2);
609 assert!(config.dependencies.contains_key("remote"));
610 assert!(config.dependencies.contains_key("local"));
611 }
612
613 #[test]
614 fn parse_package_and_dependencies_coexist() {
615 let toml_str = r#"
616[package]
617name = "my-agents"
618version = "0.1.0"
619
620[dependencies.base]
621url = "https://github.com/org/base.git"
622version = ">=1.0.0"
623
624[dependencies.local]
625path = "../local-agents"
626"#;
627 let config: Config = toml::from_str(toml_str).unwrap();
628 assert!(config.package.is_some());
629 assert!(config.dependencies.contains_key("base"));
630 assert!(config.dependencies.contains_key("local"));
631 }
632
633 #[test]
634 fn parse_include_filter() {
635 let toml_str = r#"
636[dependencies.base]
637url = "https://github.com/org/base.git"
638agents = ["coder"]
639skills = ["review"]
640"#;
641 let config: Config = toml::from_str(toml_str).unwrap();
642 let local = LocalConfig::default();
643 let effective = merge(config, local).unwrap();
644 let source = &effective.dependencies["base"];
645 match &source.filter {
646 FilterMode::Include { agents, skills } => {
647 assert_eq!(agents, &["coder"]);
648 assert_eq!(skills, &["review"]);
649 }
650 other => panic!("expected Include, got {other:?}"),
651 }
652 }
653
654 #[test]
655 fn parse_exclude_filter() {
656 let toml_str = r#"
657[dependencies.base]
658url = "https://github.com/org/base.git"
659exclude = ["experimental", "deprecated"]
660"#;
661 let config: Config = toml::from_str(toml_str).unwrap();
662 let local = LocalConfig::default();
663 let effective = merge(config, local).unwrap();
664 let source = &effective.dependencies["base"];
665 match &source.filter {
666 FilterMode::Exclude(items) => {
667 assert_eq!(items, &["experimental", "deprecated"]);
668 }
669 other => panic!("expected Exclude, got {other:?}"),
670 }
671 }
672
673 #[test]
674 fn error_on_both_include_and_exclude() {
675 let toml_str = r#"
676[dependencies.bad]
677url = "https://github.com/org/bad.git"
678agents = ["coder"]
679exclude = ["reviewer"]
680"#;
681 let config: Config = toml::from_str(toml_str).unwrap();
682 let local = LocalConfig::default();
683 let result = merge(config, local);
684 assert!(result.is_err());
685 let err = result.unwrap_err().to_string();
686 assert!(
687 err.contains("bad"),
688 "error should mention dependency name: {err}"
689 );
690 }
691
692 #[test]
693 fn error_on_neither_url_nor_path() {
694 let toml_str = r#"
695[dependencies.empty]
696version = "v1.0"
697"#;
698 let config: Config = toml::from_str(toml_str).unwrap();
699 let local = LocalConfig::default();
700 let result = merge(config, local);
701 assert!(result.is_err());
702 let err = result.unwrap_err().to_string();
703 assert!(
704 err.contains("neither"),
705 "error should mention 'neither': {err}"
706 );
707 }
708
709 #[test]
710 fn error_on_both_url_and_path() {
711 let toml_str = r#"
712[dependencies.both]
713url = "https://github.com/org/repo.git"
714path = "/local/path"
715"#;
716 let config: Config = toml::from_str(toml_str).unwrap();
717 let local = LocalConfig::default();
718 let result = merge(config, local);
719 assert!(result.is_err());
720 let err = result.unwrap_err().to_string();
721 assert!(err.contains("both"), "error should mention 'both': {err}");
722 }
723
724 #[test]
725 fn roundtrip_full_config_shape_survives_save() {
726 let dir = TempDir::new().unwrap();
727 let original = r#"
728[package]
729name = "sample"
730version = "0.1.0"
731description = "sample package"
732
733[dependencies.base]
734url = "https://github.com/org/base.git"
735version = "v1.0"
736agents = ["coder", "reviewer"]
737
738[dependencies.local]
739path = "../local-agents"
740exclude = ["experimental"]
741
742[settings]
743managed_root = ".custom-agents"
744targets = [".claude", ".cursor"]
745"#;
746 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
747
748 let config = load(dir.path()).unwrap();
749 save(dir.path(), &config).unwrap();
750 let reloaded = load(dir.path()).unwrap();
751
752 assert_eq!(
753 reloaded.package.as_ref().map(|p| p.name.as_str()),
754 Some("sample")
755 );
756 assert_eq!(reloaded.dependencies.len(), 2);
757 assert_eq!(
758 reloaded.dependencies["base"].url.as_deref(),
759 Some("https://github.com/org/base.git")
760 );
761 assert_eq!(
762 reloaded.dependencies["local"].path.as_deref(),
763 Some(Path::new("../local-agents"))
764 );
765 assert_eq!(
766 reloaded.settings.managed_root.as_deref(),
767 Some(".custom-agents")
768 );
769 assert_eq!(
770 reloaded.settings.targets,
771 Some(vec![".claude".to_string(), ".cursor".to_string()])
772 );
773 }
774
775 #[test]
776 fn load_from_disk() {
777 let dir = TempDir::new().unwrap();
778 let toml_str = r#"
779[dependencies.base]
780url = "https://github.com/org/base.git"
781version = "v1.0"
782"#;
783 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
784 let config = load(dir.path()).unwrap();
785 assert_eq!(config.dependencies.len(), 1);
786 }
787
788 #[test]
789 fn load_migrates_legacy_bare_domain_url() {
790 let dir = TempDir::new().unwrap();
791 let toml_str = r#"
792[dependencies.base]
793url = "github.com/org/base"
794"#;
795 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
796
797 let config = load(dir.path()).unwrap();
798 assert_eq!(
799 config.dependencies["base"].url.as_deref(),
800 Some("https://github.com/org/base")
801 );
802 }
803
804 #[test]
805 fn load_does_not_migrate_ssh_url() {
806 let dir = TempDir::new().unwrap();
807 let toml_str = r#"
808[dependencies.base]
809url = "git@github.com:org/base.git"
810"#;
811 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
812
813 let config = load(dir.path()).unwrap();
814 assert_eq!(
815 config.dependencies["base"].url.as_deref(),
816 Some("git@github.com:org/base.git")
817 );
818 }
819
820 #[test]
821 fn load_missing_file_returns_not_found() {
822 let dir = TempDir::new().unwrap();
823 let result = load(dir.path());
824 assert!(result.is_err());
825 let err = result.unwrap_err().to_string();
826 assert!(err.contains("not found"), "should be NotFound: {err}");
827 }
828
829 #[test]
830 fn load_manifest_returns_none_without_package() {
831 let dir = TempDir::new().unwrap();
832 std::fs::write(
833 dir.path().join("mars.toml"),
834 r#"
835[dependencies.base]
836url = "https://github.com/org/base.git"
837"#,
838 )
839 .unwrap();
840
841 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
842 assert!(diagnostics.is_empty());
843 assert!(manifest.is_none());
844 }
845
846 #[test]
847 fn load_manifest_returns_package_and_dependencies() {
848 let dir = TempDir::new().unwrap();
849 std::fs::write(
850 dir.path().join("mars.toml"),
851 r#"
852[package]
853name = "pkg"
854version = "1.2.3"
855
856[dependencies.base]
857url = "https://github.com/org/base.git"
858version = ">=1.0.0"
859"#,
860 )
861 .unwrap();
862
863 let (manifest, diagnostics) = load_manifest(dir.path()).unwrap();
864 assert!(diagnostics.is_empty());
865 let manifest = manifest.unwrap();
866 assert_eq!(manifest.package.name, "pkg");
867 assert_eq!(manifest.package.version, "1.2.3");
868 assert!(manifest.dependencies.contains_key("base"));
869 }
870
871 #[test]
872 fn load_local_missing_returns_default() {
873 let dir = TempDir::new().unwrap();
874 let local = load_local(dir.path()).unwrap();
875 assert!(local.overrides.is_empty());
876 }
877
878 #[test]
879 fn load_local_from_disk() {
880 let dir = TempDir::new().unwrap();
881 let toml_str = r#"
882[overrides.base]
883path = "/home/dev/local-base"
884"#;
885 std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
886 let local = load_local(dir.path()).unwrap();
887 assert_eq!(local.overrides.len(), 1);
888 assert_eq!(
889 local.overrides["base"].path,
890 PathBuf::from("/home/dev/local-base")
891 );
892 }
893
894 #[test]
895 fn merge_with_empty_local() {
896 let config = Config {
897 dependencies: {
898 let mut m = IndexMap::new();
899 m.insert(
900 "base".into(),
901 DependencyEntry {
902 url: Some("https://github.com/org/base.git".into()),
903 path: None,
904 version: Some("v1.0".into()),
905 filter: FilterConfig::default(),
906 },
907 );
908 m
909 },
910 settings: Settings::default(),
911 ..Config::default()
912 };
913 let local = LocalConfig::default();
914 let effective = merge(config, local).unwrap();
915 assert_eq!(effective.dependencies.len(), 1);
916 let source = &effective.dependencies["base"];
917 assert!(!source.is_overridden);
918 assert!(source.original_git.is_none());
919 match &source.spec {
920 SourceSpec::Git(git) => {
921 assert_eq!(git.url, "https://github.com/org/base.git");
922 assert_eq!(git.version.as_deref(), Some("v1.0"));
923 }
924 SourceSpec::Path(_) => panic!("expected Git"),
925 }
926 }
927
928 #[test]
929 fn merge_override_replaces_with_path() {
930 let config = Config {
931 dependencies: {
932 let mut m = IndexMap::new();
933 m.insert(
934 "base".into(),
935 DependencyEntry {
936 url: Some("https://github.com/org/base.git".into()),
937 path: None,
938 version: Some("v1.0".into()),
939 filter: FilterConfig::default(),
940 },
941 );
942 m
943 },
944 settings: Settings::default(),
945 ..Config::default()
946 };
947 let local = LocalConfig {
948 overrides: {
949 let mut m = IndexMap::new();
950 m.insert(
951 "base".into(),
952 OverrideEntry {
953 path: PathBuf::from("/home/dev/local-base"),
954 },
955 );
956 m
957 },
958 };
959 let effective = merge(config, local).unwrap();
960 let source = &effective.dependencies["base"];
961 assert!(source.is_overridden);
962
963 match &source.spec {
964 SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
965 SourceSpec::Git(_) => panic!("expected Path override"),
966 }
967
968 let orig = source.original_git.as_ref().unwrap();
969 assert_eq!(orig.url, "https://github.com/org/base.git");
970 assert_eq!(orig.version.as_deref(), Some("v1.0"));
971 }
972
973 #[test]
974 fn merge_all_filter_mode() {
975 let config = Config {
976 dependencies: {
977 let mut m = IndexMap::new();
978 m.insert(
979 "base".into(),
980 DependencyEntry {
981 url: Some("https://github.com/org/base.git".into()),
982 path: None,
983 version: None,
984 filter: FilterConfig::default(),
985 },
986 );
987 m
988 },
989 settings: Settings::default(),
990 ..Config::default()
991 };
992 let effective = merge(config, LocalConfig::default()).unwrap();
993 assert!(matches!(
994 effective.dependencies["base"].filter,
995 FilterMode::All
996 ));
997 }
998
999 #[test]
1000 fn save_and_reload() {
1001 let dir = TempDir::new().unwrap();
1002 let config = Config {
1003 dependencies: {
1004 let mut m = IndexMap::new();
1005 m.insert(
1006 "base".into(),
1007 DependencyEntry {
1008 url: Some("https://github.com/org/base.git".into()),
1009 path: None,
1010 version: Some("v2.0".into()),
1011 filter: FilterConfig::default(),
1012 },
1013 );
1014 m
1015 },
1016 settings: Settings::default(),
1017 ..Config::default()
1018 };
1019 save(dir.path(), &config).unwrap();
1020 let reloaded = load(dir.path()).unwrap();
1021 assert_eq!(config, reloaded);
1022 }
1023
1024 #[test]
1025 fn rename_map_preserved() {
1026 let toml_str = r#"
1027[dependencies.base]
1028url = "https://github.com/org/base.git"
1029
1030[dependencies.base.rename]
1031old-name = "new-name"
1032"#;
1033 let config: Config = toml::from_str(toml_str).unwrap();
1034 let effective = merge(config, LocalConfig::default()).unwrap();
1035 let source = &effective.dependencies["base"];
1036 assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
1037 }
1038
1039 #[test]
1040 fn self_dependency_name_rejected() {
1041 let toml_str = r#"
1042[dependencies._self]
1043url = "https://github.com/org/base.git"
1044"#;
1045 let config: Config = toml::from_str(toml_str).unwrap();
1046 let local = LocalConfig::default();
1047 let result = merge(config, local);
1048 assert!(result.is_err());
1049 let err = result.unwrap_err().to_string();
1050 assert!(
1051 err.contains("_self") && err.contains("reserved"),
1052 "should reject _self: {err}"
1053 );
1054 }
1055
1056 #[test]
1057 fn managed_root_setting_roundtrip() {
1058 let config = Config {
1059 settings: Settings {
1060 managed_root: Some(".claude".into()),
1061 targets: None,
1062 },
1063 ..Config::default()
1064 };
1065 let serialized = toml::to_string_pretty(&config).unwrap();
1066 let deserialized: Config = toml::from_str(&serialized).unwrap();
1067 assert_eq!(
1068 deserialized.settings.managed_root.as_deref(),
1069 Some(".claude")
1070 );
1071 }
1072
1073 #[test]
1074 fn save_preserves_dependencies_when_clearing_last_target() {
1075 let dir = TempDir::new().unwrap();
1076 let original = r#"
1077[package]
1078name = "sample"
1079version = "0.1.0"
1080
1081[dependencies.base]
1082url = "https://github.com/org/base.git"
1083version = "v1.0"
1084agents = ["coder"]
1085
1086[settings]
1087managed_root = ".agents"
1088targets = [".claude"]
1089"#;
1090 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1091
1092 let mut config = load(dir.path()).unwrap();
1093 if let Some(targets) = config.settings.targets.as_mut() {
1094 targets.retain(|target| target != ".claude");
1095 if targets.is_empty() {
1096 config.settings.targets = None;
1097 }
1098 }
1099 save(dir.path(), &config).unwrap();
1100
1101 let reloaded = load(dir.path()).unwrap();
1102 assert_eq!(
1103 reloaded.package.as_ref().map(|p| p.name.as_str()),
1104 Some("sample")
1105 );
1106 assert_eq!(
1107 reloaded.dependencies["base"].url.as_deref(),
1108 Some("https://github.com/org/base.git")
1109 );
1110 assert_eq!(
1111 reloaded.dependencies["base"].version.as_deref(),
1112 Some("v1.0")
1113 );
1114 assert_eq!(
1115 reloaded.dependencies["base"].filter.agents.as_deref(),
1116 Some(&["coder".into()][..])
1117 );
1118 assert_eq!(reloaded.settings.managed_root.as_deref(), Some(".agents"));
1119 assert!(reloaded.settings.targets.is_none());
1120 }
1121
1122 #[test]
1123 fn roundtrip_preserves_all_filter_fields() {
1124 let dir = TempDir::new().unwrap();
1125 let original = r#"
1126[dependencies.include]
1127url = "https://github.com/org/include.git"
1128agents = ["coder", "reviewer"]
1129skills = ["review", "plan"]
1130
1131[dependencies.include.rename]
1132coder = "core-coder"
1133
1134[dependencies.exclude]
1135url = "https://github.com/org/exclude.git"
1136exclude = ["experimental", "deprecated"]
1137
1138[dependencies.only_skills]
1139url = "https://github.com/org/skills.git"
1140only_skills = true
1141
1142[dependencies.only_agents]
1143url = "https://github.com/org/agents.git"
1144only_agents = true
1145"#;
1146 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1147
1148 let config = load(dir.path()).unwrap();
1149 save(dir.path(), &config).unwrap();
1150 let reloaded = load(dir.path()).unwrap();
1151
1152 let include = &reloaded.dependencies["include"].filter;
1153 assert_eq!(
1154 include.agents.as_deref(),
1155 Some(&["coder".into(), "reviewer".into()][..])
1156 );
1157 assert_eq!(
1158 include.skills.as_deref(),
1159 Some(&["review".into(), "plan".into()][..])
1160 );
1161 assert_eq!(
1162 include.rename.as_ref().and_then(|r| r.get("coder")),
1163 Some(&"core-coder".into())
1164 );
1165
1166 let exclude = &reloaded.dependencies["exclude"].filter;
1167 assert_eq!(
1168 exclude.exclude.as_deref(),
1169 Some(&["experimental".into(), "deprecated".into()][..])
1170 );
1171
1172 let only_skills = &reloaded.dependencies["only_skills"].filter;
1173 assert!(only_skills.only_skills);
1174 assert!(!only_skills.only_agents);
1175
1176 let only_agents = &reloaded.dependencies["only_agents"].filter;
1177 assert!(only_agents.only_agents);
1178 assert!(!only_agents.only_skills);
1179 }
1180
1181 #[test]
1182 fn roundtrip_multiple_dependencies_with_distinct_filter_combos() {
1183 let dir = TempDir::new().unwrap();
1184 let original = r#"
1185[dependencies.git-include]
1186url = "https://github.com/org/git-include.git"
1187agents = ["coder"]
1188
1189[dependencies.path-exclude]
1190path = "../local-source"
1191exclude = ["draft"]
1192
1193[dependencies.git-only-skills]
1194url = "https://github.com/org/git-skills.git"
1195only_skills = true
1196
1197[dependencies.git-only-agents]
1198url = "https://github.com/org/git-agents.git"
1199only_agents = true
1200"#;
1201 std::fs::write(dir.path().join("mars.toml"), original).unwrap();
1202
1203 let config = load(dir.path()).unwrap();
1204 save(dir.path(), &config).unwrap();
1205 let reloaded = load(dir.path()).unwrap();
1206
1207 assert_eq!(reloaded.dependencies.len(), 4);
1208 assert_eq!(
1209 reloaded.dependencies["git-include"]
1210 .filter
1211 .agents
1212 .as_deref(),
1213 Some(&["coder".into()][..])
1214 );
1215 assert_eq!(
1216 reloaded.dependencies["path-exclude"].path.as_deref(),
1217 Some(Path::new("../local-source"))
1218 );
1219 assert_eq!(
1220 reloaded.dependencies["path-exclude"]
1221 .filter
1222 .exclude
1223 .as_deref(),
1224 Some(&["draft".into()][..])
1225 );
1226 assert!(reloaded.dependencies["git-only-skills"].filter.only_skills);
1227 assert!(reloaded.dependencies["git-only-agents"].filter.only_agents);
1228 }
1229
1230 #[test]
1231 fn save_roundtrip_guard_rejects_dependency_count_loss() {
1232 let mut original = Config::default();
1233 original.dependencies.insert(
1234 "base".into(),
1235 DependencyEntry {
1236 url: Some("https://github.com/org/base.git".into()),
1237 path: None,
1238 version: Some("v1.0".into()),
1239 filter: FilterConfig::default(),
1240 },
1241 );
1242
1243 let reparsed = Config::default();
1244 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1245 let msg = err.to_string();
1246 assert!(
1247 msg.contains("dependency count changed"),
1248 "unexpected error: {msg}"
1249 );
1250 }
1251
1252 #[test]
1253 fn save_roundtrip_guard_rejects_managed_root_loss() {
1254 let original = Config {
1255 settings: Settings {
1256 managed_root: Some(".agents".into()),
1257 targets: None,
1258 },
1259 ..Config::default()
1260 };
1261 let reparsed = Config::default();
1262 let err = validate_save_roundtrip(&original, &reparsed).unwrap_err();
1263 let msg = err.to_string();
1264 assert!(
1265 msg.contains("settings.managed_root changed"),
1266 "unexpected error: {msg}"
1267 );
1268 }
1269
1270 #[test]
1271 fn parse_only_skills_filter() {
1272 let toml_str = r#"
1273[dependencies.base]
1274url = "https://github.com/org/base.git"
1275only_skills = true
1276"#;
1277 let config: Config = toml::from_str(toml_str).unwrap();
1278 let local = LocalConfig::default();
1279 let effective = merge(config, local).unwrap();
1280 let source = &effective.dependencies["base"];
1281 assert!(matches!(source.filter, FilterMode::OnlySkills));
1282 }
1283
1284 #[test]
1285 fn parse_only_agents_filter() {
1286 let toml_str = r#"
1287[dependencies.base]
1288url = "https://github.com/org/base.git"
1289only_agents = true
1290"#;
1291 let config: Config = toml::from_str(toml_str).unwrap();
1292 let local = LocalConfig::default();
1293 let effective = merge(config, local).unwrap();
1294 let source = &effective.dependencies["base"];
1295 assert!(matches!(source.filter, FilterMode::OnlyAgents));
1296 }
1297
1298 #[test]
1299 fn error_on_only_skills_and_only_agents() {
1300 let toml_str = r#"
1301[dependencies.bad]
1302url = "https://github.com/org/bad.git"
1303only_skills = true
1304only_agents = true
1305"#;
1306 let config: Config = toml::from_str(toml_str).unwrap();
1307 let local = LocalConfig::default();
1308 let result = merge(config, local);
1309 assert!(result.is_err());
1310 let err = result.unwrap_err().to_string();
1311 assert!(
1312 err.contains("mutually exclusive"),
1313 "should mention mutually exclusive: {err}"
1314 );
1315 }
1316
1317 #[test]
1318 fn error_on_only_skills_with_agents_list() {
1319 let toml_str = r#"
1320[dependencies.bad]
1321url = "https://github.com/org/bad.git"
1322only_skills = true
1323agents = ["coder"]
1324"#;
1325 let config: Config = toml::from_str(toml_str).unwrap();
1326 let local = LocalConfig::default();
1327 let result = merge(config, local);
1328 assert!(result.is_err());
1329 let err = result.unwrap_err().to_string();
1330 assert!(
1331 err.contains("cannot combine"),
1332 "should mention cannot combine: {err}"
1333 );
1334 }
1335
1336 #[test]
1337 fn error_on_only_agents_with_skills_list() {
1338 let toml_str = r#"
1339[dependencies.bad]
1340url = "https://github.com/org/bad.git"
1341only_agents = true
1342skills = ["planning"]
1343"#;
1344 let config: Config = toml::from_str(toml_str).unwrap();
1345 let local = LocalConfig::default();
1346 let result = merge(config, local);
1347 assert!(result.is_err());
1348 }
1349
1350 #[test]
1351 fn error_on_only_skills_with_exclude() {
1352 let toml_str = r#"
1353[dependencies.bad]
1354url = "https://github.com/org/bad.git"
1355only_skills = true
1356exclude = ["deprecated"]
1357"#;
1358 let config: Config = toml::from_str(toml_str).unwrap();
1359 let local = LocalConfig::default();
1360 let result = merge(config, local);
1361 assert!(result.is_err());
1362 }
1363
1364 #[test]
1365 fn only_skills_false_not_serialized() {
1366 let config = Config {
1367 dependencies: {
1368 let mut m = IndexMap::new();
1369 m.insert(
1370 "base".into(),
1371 DependencyEntry {
1372 url: Some("https://github.com/org/base.git".into()),
1373 path: None,
1374 version: None,
1375 filter: FilterConfig::default(),
1376 },
1377 );
1378 m
1379 },
1380 settings: Settings::default(),
1381 ..Config::default()
1382 };
1383 let serialized = toml::to_string_pretty(&config).unwrap();
1384 assert!(
1385 !serialized.contains("only_skills"),
1386 "false booleans should not be serialized: {serialized}"
1387 );
1388 assert!(
1389 !serialized.contains("only_agents"),
1390 "false booleans should not be serialized: {serialized}"
1391 );
1392 }
1393
1394 #[test]
1395 fn only_skills_true_roundtrips() {
1396 let toml_str = r#"
1397[dependencies.base]
1398url = "https://github.com/org/base.git"
1399only_skills = true
1400"#;
1401 let config: Config = toml::from_str(toml_str).unwrap();
1402 assert!(config.dependencies["base"].filter.only_skills);
1403 assert!(!config.dependencies["base"].filter.only_agents);
1404
1405 let serialized = toml::to_string_pretty(&config).unwrap();
1406 let reloaded: Config = toml::from_str(&serialized).unwrap();
1407 assert!(reloaded.dependencies["base"].filter.only_skills);
1408 }
1409
1410 #[test]
1411 fn filter_config_has_any_filter() {
1412 assert!(!FilterConfig::default().has_any_filter());
1413 assert!(
1414 FilterConfig {
1415 only_skills: true,
1416 ..FilterConfig::default()
1417 }
1418 .has_any_filter()
1419 );
1420 assert!(
1421 FilterConfig {
1422 agents: Some(vec!["coder".into()]),
1423 ..FilterConfig::default()
1424 }
1425 .has_any_filter()
1426 );
1427 }
1428
1429 #[test]
1430 fn filter_config_to_mode() {
1431 assert!(matches!(FilterConfig::default().to_mode(), FilterMode::All));
1432 assert!(matches!(
1433 FilterConfig {
1434 only_skills: true,
1435 ..FilterConfig::default()
1436 }
1437 .to_mode(),
1438 FilterMode::OnlySkills
1439 ));
1440 assert!(matches!(
1441 FilterConfig {
1442 only_agents: true,
1443 ..FilterConfig::default()
1444 }
1445 .to_mode(),
1446 FilterMode::OnlyAgents
1447 ));
1448 assert!(matches!(
1449 FilterConfig {
1450 agents: Some(vec!["coder".into()]),
1451 ..FilterConfig::default()
1452 }
1453 .to_mode(),
1454 FilterMode::Include { .. }
1455 ));
1456 assert!(matches!(
1457 FilterConfig {
1458 exclude: Some(vec!["old".into()]),
1459 ..FilterConfig::default()
1460 }
1461 .to_mode(),
1462 FilterMode::Exclude(_)
1463 ));
1464 }
1465
1466 #[test]
1469 fn managed_targets_defaults_to_agents() {
1470 let settings = Settings::default();
1471 assert_eq!(settings.managed_targets(), vec![".agents"]);
1472 }
1473
1474 #[test]
1475 fn managed_targets_uses_explicit_targets() {
1476 let settings = Settings {
1477 targets: Some(vec![".claude".to_string()]),
1478 ..Settings::default()
1479 };
1480 assert_eq!(settings.managed_targets(), vec![".claude"]);
1481 }
1482
1483 #[test]
1484 fn managed_targets_uses_managed_root_as_primary() {
1485 let settings = Settings {
1486 managed_root: Some(".claude".to_string()),
1487 ..Settings::default()
1488 };
1489 assert_eq!(settings.managed_targets(), vec![".claude"]);
1490 }
1491
1492 #[test]
1493 fn managed_targets_explicit_overrides_links_and_managed_root() {
1494 let settings = Settings {
1495 managed_root: Some(".cursor".to_string()),
1496 targets: Some(vec![".codex".to_string()]),
1497 };
1498 assert_eq!(settings.managed_targets(), vec![".codex"]);
1500 }
1501}