1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6use crate::error::{ConfigError, MarsError};
7use crate::types::{ItemName, RenameMap, SourceId, SourceName, SourceUrl};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct Config {
12 #[serde(default)]
13 pub sources: IndexMap<SourceName, SourceEntry>,
14 #[serde(default)]
15 pub settings: Settings,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct SourceEntry {
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub url: Option<SourceUrl>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub path: Option<PathBuf>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub version: Option<String>,
31 #[serde(flatten)]
32 pub filter: FilterConfig,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
37pub struct FilterConfig {
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub agents: Option<Vec<ItemName>>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub skills: Option<Vec<ItemName>>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub exclude: Option<Vec<ItemName>>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub rename: Option<RenameMap>,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
53pub struct LocalConfig {
54 #[serde(default)]
55 pub overrides: IndexMap<SourceName, OverrideEntry>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct OverrideEntry {
61 pub path: PathBuf,
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
66pub struct Settings {
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub links: Vec<String>,
70}
71
72#[derive(Debug, Clone)]
74pub enum SourceSpec {
75 Git(GitSpec),
76 Path(PathBuf),
77}
78
79#[derive(Debug, Clone)]
81pub struct GitSpec {
82 pub url: SourceUrl,
83 pub version: Option<String>,
84}
85
86#[derive(Debug, Clone)]
88pub enum FilterMode {
89 All,
91 Include {
93 agents: Vec<ItemName>,
94 skills: Vec<ItemName>,
95 },
96 Exclude(Vec<ItemName>),
98}
99
100#[derive(Debug, Clone)]
104pub struct EffectiveConfig {
105 pub sources: IndexMap<SourceName, EffectiveSource>,
106 pub settings: Settings,
107}
108
109#[derive(Debug, Clone)]
111pub struct EffectiveSource {
112 pub name: SourceName,
113 pub id: SourceId,
114 pub spec: SourceSpec,
115 pub filter: FilterMode,
116 pub rename: RenameMap,
117 pub is_overridden: bool,
118 pub original_git: Option<GitSpec>,
119}
120
121const CONFIG_FILE: &str = "mars.toml";
122const LOCAL_CONFIG_FILE: &str = "mars.local.toml";
123
124pub fn load(root: &Path) -> Result<Config, MarsError> {
126 let path = root.join(CONFIG_FILE);
127 let content = std::fs::read_to_string(&path).map_err(|e| {
128 if e.kind() == std::io::ErrorKind::NotFound {
129 ConfigError::NotFound { path: path.clone() }
130 } else {
131 ConfigError::Io(e)
132 }
133 })?;
134 let mut config: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
135 migrate_legacy_source_urls(&mut config);
136 Ok(config)
137}
138
139pub fn load_local(root: &Path) -> Result<LocalConfig, MarsError> {
141 let path = root.join(LOCAL_CONFIG_FILE);
142 match std::fs::read_to_string(&path) {
143 Ok(content) => {
144 let local: LocalConfig = toml::from_str(&content).map_err(ConfigError::Parse)?;
145 Ok(local)
146 }
147 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LocalConfig::default()),
148 Err(e) => Err(ConfigError::Io(e).into()),
149 }
150}
151
152pub fn merge(config: Config, local: LocalConfig) -> Result<EffectiveConfig, MarsError> {
159 merge_with_root(config, local, Path::new("."))
160}
161
162pub fn merge_with_root(
164 config: Config,
165 local: LocalConfig,
166 root: &Path,
167) -> Result<EffectiveConfig, MarsError> {
168 let mut sources = IndexMap::new();
169
170 for (name, entry) in &config.sources {
171 let base_spec = match (&entry.url, &entry.path) {
173 (Some(url), None) => SourceSpec::Git(GitSpec {
174 url: url.clone(),
175 version: entry.version.clone(),
176 }),
177 (None, Some(path)) => SourceSpec::Path(path.clone()),
178 (Some(_), Some(_)) => {
179 return Err(ConfigError::Invalid {
180 message: format!("source `{name}` has both `url` and `path` — pick one"),
181 }
182 .into());
183 }
184 (None, None) => {
185 return Err(ConfigError::Invalid {
186 message: format!(
187 "source `{name}` has neither `url` nor `path` — one is required"
188 ),
189 }
190 .into());
191 }
192 };
193
194 let has_include = entry.filter.agents.is_some() || entry.filter.skills.is_some();
196 let has_exclude = entry.filter.exclude.is_some();
197 if has_include && has_exclude {
198 return Err(ConfigError::ConflictingFilters {
199 name: name.to_string(),
200 }
201 .into());
202 }
203
204 let filter = if has_include {
205 FilterMode::Include {
206 agents: entry.filter.agents.clone().unwrap_or_default(),
207 skills: entry.filter.skills.clone().unwrap_or_default(),
208 }
209 } else if has_exclude {
210 FilterMode::Exclude(entry.filter.exclude.clone().unwrap_or_default())
211 } else {
212 FilterMode::All
213 };
214
215 let rename = entry.filter.rename.clone().unwrap_or_default();
216
217 let (spec, is_overridden, original_git) = if let Some(ov) = local.overrides.get(name) {
219 let original = match &base_spec {
220 SourceSpec::Git(git) => Some(git.clone()),
221 SourceSpec::Path(_) => None,
222 };
223 (SourceSpec::Path(ov.path.clone()), true, original)
224 } else {
225 (base_spec, false, None)
226 };
227 let id = source_id_for_spec(root, &spec);
228
229 sources.insert(
230 name.clone(),
231 EffectiveSource {
232 name: name.clone(),
233 id,
234 spec,
235 filter,
236 rename,
237 is_overridden,
238 original_git,
239 },
240 );
241 }
242
243 for override_name in local.overrides.keys() {
245 if !config.sources.contains_key(override_name) {
246 eprintln!("warning: override `{override_name}` references a source not in mars.toml");
247 }
248 }
249
250 Ok(EffectiveConfig {
251 sources,
252 settings: config.settings,
253 })
254}
255
256fn source_id_for_spec(root: &Path, spec: &SourceSpec) -> SourceId {
257 match spec {
258 SourceSpec::Git(git) => SourceId::git(git.url.clone()),
259 SourceSpec::Path(path) => match SourceId::path(root, path) {
260 Ok(id) => id,
261 Err(_) => {
262 let canonical = if path.is_absolute() {
263 path.clone()
264 } else {
265 root.join(path)
266 };
267 SourceId::Path { canonical }
268 }
269 },
270 }
271}
272
273fn migrate_legacy_source_urls(config: &mut Config) {
274 for source in config.sources.values_mut() {
275 if let Some(url) = source.url.as_mut() {
276 let raw = url.as_str();
277 if should_upgrade_legacy_git_url(raw) {
278 *url = SourceUrl::from(format!("https://{raw}"));
279 }
280 }
281 }
282}
283
284fn should_upgrade_legacy_git_url(url: &str) -> bool {
285 !url.contains("://") && !url.starts_with("git@") && url.contains('/') && url.contains('.')
286}
287
288pub fn save(root: &Path, config: &Config) -> Result<(), MarsError> {
290 let path = root.join(CONFIG_FILE);
291 let content = toml::to_string_pretty(config).map_err(|e| ConfigError::Invalid {
292 message: format!("failed to serialize config: {e}"),
293 })?;
294 crate::fs::atomic_write(&path, content.as_bytes())
295}
296
297pub fn save_local(root: &Path, local: &LocalConfig) -> Result<(), MarsError> {
299 let path = root.join(LOCAL_CONFIG_FILE);
300 let content = toml::to_string_pretty(local).map_err(|e| ConfigError::Invalid {
301 message: format!("failed to serialize local config: {e}"),
302 })?;
303 crate::fs::atomic_write(&path, content.as_bytes())
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use tempfile::TempDir;
310
311 #[test]
312 fn parse_git_source() {
313 let toml_str = r#"
314[sources.base]
315url = "https://github.com/org/base.git"
316version = "v1.0"
317"#;
318 let config: Config = toml::from_str(toml_str).unwrap();
319 assert_eq!(config.sources.len(), 1);
320 let entry = &config.sources["base"];
321 assert_eq!(
322 entry.url.as_deref(),
323 Some("https://github.com/org/base.git")
324 );
325 assert!(entry.path.is_none());
326 assert_eq!(entry.version.as_deref(), Some("v1.0"));
327 }
328
329 #[test]
330 fn parse_path_source() {
331 let toml_str = r#"
332[sources.local]
333path = "../my-agents"
334"#;
335 let config: Config = toml::from_str(toml_str).unwrap();
336 let entry = &config.sources["local"];
337 assert!(entry.url.is_none());
338 assert_eq!(entry.path.as_deref(), Some(Path::new("../my-agents")));
339 }
340
341 #[test]
342 fn parse_mixed_sources() {
343 let toml_str = r#"
344[sources.remote]
345url = "https://github.com/org/remote.git"
346version = "v2.0"
347agents = ["coder", "reviewer"]
348
349[sources.local]
350path = "/home/dev/agents"
351exclude = ["experimental"]
352"#;
353 let config: Config = toml::from_str(toml_str).unwrap();
354 assert_eq!(config.sources.len(), 2);
355 assert!(config.sources.contains_key("remote"));
356 assert!(config.sources.contains_key("local"));
357 }
358
359 #[test]
360 fn parse_include_filter() {
361 let toml_str = r#"
362[sources.base]
363url = "https://github.com/org/base.git"
364agents = ["coder"]
365skills = ["review"]
366"#;
367 let config: Config = toml::from_str(toml_str).unwrap();
368 let local = LocalConfig::default();
369 let effective = merge(config, local).unwrap();
370 let source = &effective.sources["base"];
371 match &source.filter {
372 FilterMode::Include { agents, skills } => {
373 assert_eq!(agents, &["coder"]);
374 assert_eq!(skills, &["review"]);
375 }
376 other => panic!("expected Include, got {other:?}"),
377 }
378 }
379
380 #[test]
381 fn parse_exclude_filter() {
382 let toml_str = r#"
383[sources.base]
384url = "https://github.com/org/base.git"
385exclude = ["experimental", "deprecated"]
386"#;
387 let config: Config = toml::from_str(toml_str).unwrap();
388 let local = LocalConfig::default();
389 let effective = merge(config, local).unwrap();
390 let source = &effective.sources["base"];
391 match &source.filter {
392 FilterMode::Exclude(items) => {
393 assert_eq!(items, &["experimental", "deprecated"]);
394 }
395 other => panic!("expected Exclude, got {other:?}"),
396 }
397 }
398
399 #[test]
400 fn error_on_both_include_and_exclude() {
401 let toml_str = r#"
402[sources.bad]
403url = "https://github.com/org/bad.git"
404agents = ["coder"]
405exclude = ["reviewer"]
406"#;
407 let config: Config = toml::from_str(toml_str).unwrap();
408 let local = LocalConfig::default();
409 let result = merge(config, local);
410 assert!(result.is_err());
411 let err = result.unwrap_err().to_string();
412 assert!(
413 err.contains("bad"),
414 "error should mention source name: {err}"
415 );
416 }
417
418 #[test]
419 fn error_on_neither_url_nor_path() {
420 let toml_str = r#"
421[sources.empty]
422version = "v1.0"
423"#;
424 let config: Config = toml::from_str(toml_str).unwrap();
425 let local = LocalConfig::default();
426 let result = merge(config, local);
427 assert!(result.is_err());
428 let err = result.unwrap_err().to_string();
429 assert!(
430 err.contains("neither"),
431 "error should mention 'neither': {err}"
432 );
433 }
434
435 #[test]
436 fn error_on_both_url_and_path() {
437 let toml_str = r#"
438[sources.both]
439url = "https://github.com/org/repo.git"
440path = "/local/path"
441"#;
442 let config: Config = toml::from_str(toml_str).unwrap();
443 let local = LocalConfig::default();
444 let result = merge(config, local);
445 assert!(result.is_err());
446 let err = result.unwrap_err().to_string();
447 assert!(err.contains("both"), "error should mention 'both': {err}");
448 }
449
450 #[test]
451 fn roundtrip_config() {
452 let config = Config {
453 sources: {
454 let mut m = IndexMap::new();
455 m.insert(
456 "base".into(),
457 SourceEntry {
458 url: Some("https://github.com/org/base.git".into()),
459 path: None,
460 version: Some("v1.0".into()),
461 filter: FilterConfig {
462 agents: Some(vec!["coder".into()]),
463 skills: None,
464 exclude: None,
465 rename: None,
466 },
467 },
468 );
469 m.insert(
470 "local".into(),
471 SourceEntry {
472 url: None,
473 path: Some(PathBuf::from("../my-agents")),
474 version: None,
475 filter: FilterConfig::default(),
476 },
477 );
478 m
479 },
480 settings: Settings::default(),
481 };
482 let serialized = toml::to_string_pretty(&config).unwrap();
483 let deserialized: Config = toml::from_str(&serialized).unwrap();
484 assert_eq!(config, deserialized);
485 }
486
487 #[test]
488 fn load_from_disk() {
489 let dir = TempDir::new().unwrap();
490 let toml_str = r#"
491[sources.base]
492url = "https://github.com/org/base.git"
493version = "v1.0"
494"#;
495 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
496 let config = load(dir.path()).unwrap();
497 assert_eq!(config.sources.len(), 1);
498 }
499
500 #[test]
501 fn load_migrates_legacy_bare_domain_url() {
502 let dir = TempDir::new().unwrap();
503 let toml_str = r#"
504[sources.base]
505url = "github.com/org/base"
506"#;
507 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
508
509 let config = load(dir.path()).unwrap();
510 assert_eq!(
511 config.sources["base"].url.as_deref(),
512 Some("https://github.com/org/base")
513 );
514 }
515
516 #[test]
517 fn load_does_not_migrate_ssh_url() {
518 let dir = TempDir::new().unwrap();
519 let toml_str = r#"
520[sources.base]
521url = "git@github.com:org/base.git"
522"#;
523 std::fs::write(dir.path().join("mars.toml"), toml_str).unwrap();
524
525 let config = load(dir.path()).unwrap();
526 assert_eq!(
527 config.sources["base"].url.as_deref(),
528 Some("git@github.com:org/base.git")
529 );
530 }
531
532 #[test]
533 fn load_missing_file_returns_not_found() {
534 let dir = TempDir::new().unwrap();
535 let result = load(dir.path());
536 assert!(result.is_err());
537 let err = result.unwrap_err().to_string();
538 assert!(err.contains("not found"), "should be NotFound: {err}");
539 }
540
541 #[test]
542 fn load_local_missing_returns_default() {
543 let dir = TempDir::new().unwrap();
544 let local = load_local(dir.path()).unwrap();
545 assert!(local.overrides.is_empty());
546 }
547
548 #[test]
549 fn load_local_from_disk() {
550 let dir = TempDir::new().unwrap();
551 let toml_str = r#"
552[overrides.base]
553path = "/home/dev/local-base"
554"#;
555 std::fs::write(dir.path().join("mars.local.toml"), toml_str).unwrap();
556 let local = load_local(dir.path()).unwrap();
557 assert_eq!(local.overrides.len(), 1);
558 assert_eq!(
559 local.overrides["base"].path,
560 PathBuf::from("/home/dev/local-base")
561 );
562 }
563
564 #[test]
565 fn merge_with_empty_local() {
566 let config = Config {
567 sources: {
568 let mut m = IndexMap::new();
569 m.insert(
570 "base".into(),
571 SourceEntry {
572 url: Some("https://github.com/org/base.git".into()),
573 path: None,
574 version: Some("v1.0".into()),
575 filter: FilterConfig::default(),
576 },
577 );
578 m
579 },
580 settings: Settings::default(),
581 };
582 let local = LocalConfig::default();
583 let effective = merge(config, local).unwrap();
584 assert_eq!(effective.sources.len(), 1);
585 let source = &effective.sources["base"];
586 assert!(!source.is_overridden);
587 assert!(source.original_git.is_none());
588 match &source.spec {
589 SourceSpec::Git(git) => {
590 assert_eq!(git.url, "https://github.com/org/base.git");
591 assert_eq!(git.version.as_deref(), Some("v1.0"));
592 }
593 SourceSpec::Path(_) => panic!("expected Git"),
594 }
595 }
596
597 #[test]
598 fn merge_override_replaces_with_path() {
599 let config = Config {
600 sources: {
601 let mut m = IndexMap::new();
602 m.insert(
603 "base".into(),
604 SourceEntry {
605 url: Some("https://github.com/org/base.git".into()),
606 path: None,
607 version: Some("v1.0".into()),
608 filter: FilterConfig::default(),
609 },
610 );
611 m
612 },
613 settings: Settings::default(),
614 };
615 let local = LocalConfig {
616 overrides: {
617 let mut m = IndexMap::new();
618 m.insert(
619 "base".into(),
620 OverrideEntry {
621 path: PathBuf::from("/home/dev/local-base"),
622 },
623 );
624 m
625 },
626 };
627 let effective = merge(config, local).unwrap();
628 let source = &effective.sources["base"];
629 assert!(source.is_overridden);
630
631 match &source.spec {
633 SourceSpec::Path(p) => assert_eq!(p, &PathBuf::from("/home/dev/local-base")),
634 SourceSpec::Git(_) => panic!("expected Path override"),
635 }
636
637 let orig = source.original_git.as_ref().unwrap();
639 assert_eq!(orig.url, "https://github.com/org/base.git");
640 assert_eq!(orig.version.as_deref(), Some("v1.0"));
641 }
642
643 #[test]
644 fn merge_all_filter_mode() {
645 let config = Config {
646 sources: {
647 let mut m = IndexMap::new();
648 m.insert(
649 "base".into(),
650 SourceEntry {
651 url: Some("https://github.com/org/base.git".into()),
652 path: None,
653 version: None,
654 filter: FilterConfig::default(),
655 },
656 );
657 m
658 },
659 settings: Settings::default(),
660 };
661 let effective = merge(config, LocalConfig::default()).unwrap();
662 assert!(matches!(effective.sources["base"].filter, FilterMode::All));
663 }
664
665 #[test]
666 fn save_and_reload() {
667 let dir = TempDir::new().unwrap();
668 let config = Config {
669 sources: {
670 let mut m = IndexMap::new();
671 m.insert(
672 "base".into(),
673 SourceEntry {
674 url: Some("https://github.com/org/base.git".into()),
675 path: None,
676 version: Some("v2.0".into()),
677 filter: FilterConfig::default(),
678 },
679 );
680 m
681 },
682 settings: Settings::default(),
683 };
684 save(dir.path(), &config).unwrap();
685 let reloaded = load(dir.path()).unwrap();
686 assert_eq!(config, reloaded);
687 }
688
689 #[test]
690 fn rename_map_preserved() {
691 let toml_str = r#"
692[sources.base]
693url = "https://github.com/org/base.git"
694
695[sources.base.rename]
696old-name = "new-name"
697"#;
698 let config: Config = toml::from_str(toml_str).unwrap();
699 let effective = merge(config, LocalConfig::default()).unwrap();
700 let source = &effective.sources["base"];
701 assert_eq!(source.rename.get("old-name").unwrap(), "new-name");
702 }
703}