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