1use std::path::{Component, Path, PathBuf};
55
56use serde::Deserialize;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
59#[serde(rename_all = "lowercase")]
60pub enum Share {
61 #[default]
63 Team,
64 Local,
66}
67
68#[derive(Debug, Clone, Deserialize)]
69pub struct Entry {
70 pub name: String,
71 pub paths: Vec<String>,
72 #[serde(default)]
73 pub share: Share,
74 #[serde(default)]
77 pub experts: Vec<ExpertSource>,
78 #[serde(default)]
82 pub relay: Option<String>,
83 #[serde(default)]
86 pub mode: Option<String>,
87 #[serde(default)]
92 pub budget: Option<serde_json::Value>,
93 #[serde(default)]
100 pub risk: Option<serde_json::Value>,
101}
102
103impl Entry {
104 #[must_use]
106 pub fn budget(&self) -> Option<f64> {
107 self.budget.as_ref()?.as_f64().filter(|b| *b > 0.0)
108 }
109
110 #[must_use]
112 pub fn risk(&self) -> Option<f64> {
113 self.risk.as_ref()?.as_f64().filter(|r| (0.0..=1.0).contains(r))
114 }
115}
116
117#[derive(Debug, Clone, Deserialize)]
121pub struct ExpertSource {
122 pub name: String,
123 pub repo: String,
124 #[serde(rename = "match", default)]
125 pub matches: Vec<String>,
126 #[serde(default)]
129 pub drone: bool,
130}
131
132impl ExpertSource {
133 pub fn path(&self) -> PathBuf {
135 expand(&self.repo)
136 }
137}
138
139impl Config {
140 pub fn experts_for(&self, cwd: &str) -> Vec<&ExpertSource> {
143 let Route::Collective { name, .. } = route(Some(self), cwd, Path::new("/")) else { return Vec::new() };
144 let Some(c) = self.collectives.iter().find(|c| c.name == name) else { return Vec::new() };
145 c.experts.iter().filter(|e| self.may_consult(&c.name, e)).collect()
146 }
147
148 pub fn may_consult(&self, collective: &str, e: &ExpertSource) -> bool {
154 let p = e.path();
155 let real = p.canonicalize().ok();
156 std::iter::once(p).chain(real).all(|p| {
157 if self.off.iter().any(|o| inside(&normal(&p), o).is_some()) {
158 return false;
159 }
160 !matches!(route(Some(self), &p.to_string_lossy(), Path::new("/")),
161 Route::Collective { name, share: Share::Local, .. } if name != collective)
162 })
163 }
164}
165
166#[derive(Debug, Clone, Deserialize)]
167pub struct Config {
168 #[serde(default = "off")]
170 pub default: String,
171 #[serde(default)]
173 pub off: Vec<String>,
174 #[serde(default)]
175 pub collectives: Vec<Entry>,
176 #[serde(default, deserialize_with = "engineers")]
178 pub engineers: Vec<Engineer>,
179 #[serde(default)]
183 pub personal: Option<String>,
184}
185
186fn engineers<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Engineer>, D::Error> {
188 Vec::<Engineer>::deserialize(d).map_err(|e| serde::de::Error::custom(format!("engineers: {e}")))
189}
190
191#[derive(Debug, Clone, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct Engineer {
195 pub name: String,
196 pub emails: Vec<String>,
197}
198
199#[derive(Debug, Clone, Default, PartialEq, Eq)]
202pub struct Aliases {
203 by_email: std::collections::HashMap<String, String>,
204}
205
206impl Aliases {
207 pub fn new(engineers: &[Engineer]) -> Aliases {
208 let by_email = engineers.iter().flat_map(|e| e.emails.iter().map(move |m| (m.trim().to_lowercase(), e.name.trim().to_string()))).collect();
209 Aliases { by_email }
210 }
211
212 pub fn canon(&self, email: &str) -> String {
215 let key = email.trim().to_lowercase();
216 self.by_email.get(&key).cloned().unwrap_or(key)
217 }
218
219 pub fn display(&self, email: &str) -> String {
221 self.by_email.get(&email.trim().to_lowercase()).cloned().unwrap_or_else(|| email.to_string())
222 }
223
224 pub fn same(&self, a: &str, b: &str) -> bool {
226 self.canon(a) == self.canon(b)
227 }
228}
229
230impl Config {
231 pub fn aliases(&self) -> Aliases {
232 Aliases::new(&self.engineers)
233 }
234}
235
236pub fn aliases() -> &'static Aliases {
240 static A: std::sync::OnceLock<Aliases> = std::sync::OnceLock::new();
241 A.get_or_init(|| {
242 if cfg!(test) {
243 return Aliases::default();
244 }
245 load().ok().flatten().map(|c| c.aliases()).unwrap_or_default()
246 })
247}
248
249fn off() -> String {
250 "off".into()
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum Route {
255 Off,
257 Collective {
258 name: String,
259 home: PathBuf,
260 share: Share,
261 },
262}
263
264pub fn config_path() -> PathBuf {
265 std::env::var("BORG_CONFIG")
266 .map(PathBuf::from)
267 .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config/borg/collectives.json"))
268}
269
270pub fn load() -> Result<Option<Config>, String> {
273 load_from(&config_path())
274}
275
276pub fn load_from(p: &Path) -> Result<Option<Config>, String> {
278 match std::fs::read_to_string(p) {
279 Ok(t) => {
280 let cfg: Config = serde_json::from_str(&t).map_err(|e| format!("{}: {e}", p.display()))?;
281 cfg.check().map_err(|e| format!("{}: {e}", p.display()))?;
282 Ok(Some(cfg))
283 }
284 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
285 Err(e) => Err(format!("{}: {e}", p.display())),
286 }
287}
288
289impl Config {
290 pub fn check(&self) -> Result<(), String> {
292 if let Some(p) = &self.personal
293 && !self.collectives.iter().any(|c| &c.name == p && c.share == Share::Local)
294 {
295 return Err(format!("personal: {p:?} is not a collective with \"share\": \"local\""));
296 }
297 let mut seen: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
298 for c in &self.collectives {
299 if let Some(other) = seen.insert(store_name(&c.name).to_lowercase(), &c.name) {
300 return Err(format!("collectives {other:?} and {:?} would share one store; rename one", c.name));
301 }
302 }
303 let mut names = std::collections::HashSet::new();
304 let mut owner: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
305 for e in &self.engineers {
306 if e.name.trim().is_empty() {
307 return Err("engineers: an entry has no name".into());
308 }
309 if !names.insert(e.name.trim().to_lowercase()) {
310 return Err(format!("engineers: {:?} is listed twice; merge the entries", e.name));
311 }
312 if e.emails.is_empty() {
313 return Err(format!("engineers: {:?} lists no emails", e.name));
314 }
315 for m in e.emails.iter().map(|m| m.trim().to_lowercase()) {
316 if m.is_empty() {
317 return Err(format!("engineers: {:?} lists an empty email", e.name));
318 }
319 if let Some(other) = owner.insert(m.clone(), &e.name).filter(|o| *o != e.name) {
320 return Err(format!("engineers: {m} is listed under both {other:?} and {:?}", e.name));
321 }
322 }
323 }
324 Ok(())
325 }
326}
327
328pub(crate) fn expand(p: &str) -> PathBuf {
329 match p.strip_prefix("~/") {
330 Some(rest) => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(rest),
331 None if p == "~" => PathBuf::from(std::env::var("HOME").unwrap_or_default()),
332 None => PathBuf::from(p),
333 }
334}
335
336pub(crate) fn normal(p: &Path) -> Vec<String> {
338 let mut out: Vec<String> = Vec::new();
339 for c in p.components() {
340 match c {
341 Component::ParentDir => {
342 out.pop();
343 }
344 Component::Normal(s) => out.push(s.to_string_lossy().into_owned()),
345 _ => {}
346 }
347 }
348 out
349}
350
351pub(crate) fn inside(path: &[String], prefix: &str) -> Option<usize> {
353 let pre = normal(&expand(prefix));
354 (!pre.is_empty() && path.len() >= pre.len() && path[..pre.len()] == pre[..]).then_some(pre.len())
355}
356
357fn store_name(name: &str) -> String {
360 let safe: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
361 if safe == name && !safe.is_empty() {
362 return safe;
363 }
364 let h = name.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3));
367 format!("{safe}-{:08x}", h as u32 ^ (h >> 32) as u32)
368}
369
370pub fn store(base: &Path, name: &str) -> PathBuf {
372 base.join("collectives").join(store_name(name))
373}
374
375pub fn root_of(home: &Path) -> PathBuf {
377 match home.parent() {
378 Some(p) if p.file_name().is_some_and(|n| n == "collectives") => p.parent().map_or_else(|| home.to_path_buf(), Path::to_path_buf),
379 _ => home.to_path_buf(),
380 }
381}
382
383pub const REPO_DEPTH: usize = 2;
388
389pub fn places_of(cfg: &Config, repo: &str) -> Vec<PathBuf> {
393 if repo.is_empty() || repo == "." || repo == ".." || repo.contains('/') {
394 return Vec::new();
395 }
396 fn inside(p: &Path, repo: &str, depth: usize, out: &mut Vec<PathBuf>) {
397 let here = p.join(repo);
398 if here.is_dir() {
399 out.push(here);
400 }
401 if depth <= 1 {
402 return;
403 }
404 for d in std::fs::read_dir(p).into_iter().flatten().flatten() {
405 let name = d.file_name();
406 if name.to_string_lossy().starts_with('.') || name == repo || !d.file_type().is_ok_and(|t| t.is_dir()) {
407 continue;
408 }
409 inside(&d.path(), repo, depth - 1, out);
410 }
411 }
412 let mut out = Vec::new();
413 for p in cfg.collectives.iter().flat_map(|c| c.paths.iter()).chain(cfg.off.iter()).map(|p| expand(p)) {
414 if p.file_name().is_some_and(|n| n == repo) {
415 out.push(p.clone());
416 }
417 inside(&p, repo, REPO_DEPTH, &mut out);
418 }
419 out
420}
421
422pub fn route_repo(cfg: &Config, repo: &str, base: &Path) -> Option<Route> {
427 let mut routes = places_of(cfg, repo).into_iter().map(|p| route(Some(cfg), &p.to_string_lossy(), base));
428 let first = routes.next()?;
429 routes.all(|r| r == first).then_some(first)
430}
431
432pub fn is_store(cfg: &Config, home: &Path) -> bool {
434 let base = root_of(home);
435 cfg.collectives.iter().any(|c| store(&base, &c.name) == home)
436}
437
438pub fn local_owner_of<'a>(cfg: Option<&'a Config>, home: &Path, base: &Path) -> Option<&'a str> {
441 cfg?.collectives.iter().find(|c| c.share == Share::Local && store(base, &c.name) == home).map(|c| c.name.as_str())
442}
443
444fn within(cwd: &str) -> impl Fn(&str) -> Option<usize> {
448 let lexical = expand(cwd);
449 let paths: Vec<Vec<String>> = std::iter::once(normal(&lexical)).chain(lexical.canonicalize().ok().map(|p| normal(&p))).collect();
450 move |prefix: &str| {
451 let real = expand(prefix).canonicalize().ok().map(|p| p.to_string_lossy().into_owned());
452 let prefixes = std::iter::once(prefix.to_string()).chain(real);
453 prefixes.filter_map(|pre| paths.iter().filter_map(|p| inside(p, &pre)).max()).max()
454 }
455}
456
457pub fn route(cfg: Option<&Config>, cwd: &str, base: &Path) -> Route {
459 let Some(cfg) = cfg else {
460 return Route::Collective { name: "default".into(), home: base.to_path_buf(), share: Share::Team };
461 };
462 if cwd.trim().is_empty() {
463 return Route::Off; }
465 let within = within(cwd);
466 if cfg.off.iter().any(|p| within(p).is_some()) {
467 return Route::Off;
468 }
469 let best = cfg.collectives.iter().filter_map(|c| c.paths.iter().filter_map(|p| within(p)).max().map(|n| (n, c))).max_by_key(|(n, _)| *n).map(|(_, c)| c);
470 let chosen = best.or_else(|| cfg.collectives.iter().find(|c| c.name == cfg.default && cfg.default != "off"));
471 match chosen {
472 Some(c) => Route::Collective { name: c.name.clone(), home: store(base, &c.name), share: c.share },
473 None => Route::Off,
474 }
475}
476
477pub fn may_sync(r: &Route) -> bool {
480 matches!(r, Route::Collective { share: Share::Team, .. })
481}
482
483pub fn mode_gone(cfg: Option<&Config>, env: Option<&str>) -> Option<String> {
487 let mut set: Vec<String> = env.filter(|v| !v.trim().is_empty()).map(|_| "BORG_MODE".to_string()).into_iter().collect();
488 let named: Vec<&str> = cfg.into_iter().flat_map(|c| &c.collectives).filter(|e| e.mode.is_some()).map(|e| e.name.as_str()).collect();
489 if !named.is_empty() {
490 set.push(format!("\"mode\" in collectives.json ({})", named.join(", ")));
491 }
492 (!set.is_empty()).then(|| set.join(" and "))
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
501 fn a_mode_left_set_is_named_once_and_ignored() {
502 let cfg: Config = serde_json::from_value(serde_json::json!({
503 "default": "a",
504 "collectives": [{"name": "a", "paths": ["/w/a"], "mode": "live"}, {"name": "b", "paths": ["/w/b"]}, {"name": "c", "paths": ["/w/c"], "mode": "hook"}]
505 }))
506 .expect("an old config with mode still loads");
507 assert_eq!(mode_gone(Some(&cfg), None).as_deref(), Some("\"mode\" in collectives.json (a, c)"));
508 assert_eq!(mode_gone(Some(&cfg), Some("live")).as_deref(), Some("BORG_MODE and \"mode\" in collectives.json (a, c)"));
509 assert_eq!(mode_gone(None, Some("hook")).as_deref(), Some("BORG_MODE"));
510 assert_eq!(mode_gone(None, Some(" ")), None, "an empty BORG_MODE is unset");
511 assert_eq!(mode_gone(None, None), None);
512 }
513
514 fn cfg(json: &str) -> Config {
515 serde_json::from_str(json).unwrap()
516 }
517
518 const CFG: &str = r#"{ "default": "off",
519 "off": ["/home/u/private/diary"],
520 "collectives": [
521 { "name": "work", "paths": ["/home/u/work-repos"], "share": "team" },
522 { "name": "private", "paths": ["/home/u/private"], "share": "local" },
523 { "name": "libs", "paths": ["/home/u/work-repos/shared-libs"] } ] }"#;
524
525 fn name(r: &Route) -> &str {
526 match r {
527 Route::Off => "off",
528 Route::Collective { name, .. } => name,
529 }
530 }
531
532 #[test]
533 fn routes_by_longest_whole_component_prefix() {
534 let c = cfg(CFG);
535 let base = Path::new("/b");
536 assert_eq!(name(&route(Some(&c), "/home/u/work-repos/harbor_orion/src", base)), "work");
537 assert_eq!(name(&route(Some(&c), "/home/u/work-repos/shared-libs/x", base)), "libs", "longest prefix wins");
538 assert_eq!(name(&route(Some(&c), "/home/u/work", base)), "off", "no partial component match");
539 assert_eq!(name(&route(Some(&c), "/home/u/work-repos-old/x", base)), "off", "work-repos-old is not work-repos");
540 assert_eq!(name(&route(Some(&c), "/home/u/private/finances", base)), "private");
541 assert_eq!(name(&route(Some(&c), "/home/u/work-repos/../private/x", base)), "private", "normalised before matching");
542 }
543
544 #[test]
545 fn a_symlink_and_its_target_route_alike() {
546 let root = std::env::temp_dir().join(format!("borg-route-link-{}", std::process::id()));
549 let _ = std::fs::remove_dir_all(&root);
550 std::fs::create_dir_all(root.join("real/work/secret/x")).unwrap();
551 std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
552 let (real, link) = (root.join("real").display().to_string(), root.join("link").display().to_string());
553 let c = cfg(&format!(
554 r#"{{ "default": "off", "off": ["{link}/work/secret"], "collectives": [ {{ "name": "work", "paths": ["{real}/work"], "share": "team" }} ] }}"#
555 ));
556 let base = Path::new("/b");
557 assert_eq!(route(Some(&c), &format!("{real}/work/secret/x"), base), Route::Off, "an off path written through the link");
558 assert_eq!(route(Some(&c), &format!("{link}/work/secret/x"), base), Route::Off);
559 assert_eq!(name(&route(Some(&c), &format!("{link}/work"), base)), "work", "a cwd reported through the link");
560 let _ = std::fs::remove_dir_all(root);
561 }
562
563 #[test]
564 fn off_wins_and_unknown_is_off() {
565 let c = cfg(CFG);
566 let base = Path::new("/b");
567 assert_eq!(route(Some(&c), "/home/u/private/diary/2026", base), Route::Off, "off beats the private collective above it");
568 assert_eq!(route(Some(&c), "/somewhere/else", base), Route::Off, "default off");
569 assert_eq!(route(Some(&c), "", base), Route::Off, "no cwd: never guess");
570 let d = cfg(r#"{ "default": "work", "collectives": [ { "name": "work", "paths": ["/w"] } ] }"#);
571 assert_eq!(name(&route(Some(&d), "/somewhere/else", base)), "work");
572 }
573
574 #[test]
575 fn stores_are_separate_and_local_never_syncs() {
576 let c = cfg(CFG);
577 let base = Path::new("/b");
578 let corp = route(Some(&c), "/home/u/work-repos/a", base);
579 let private = route(Some(&c), "/home/u/private/a", base);
580 let (Route::Collective { home: h1, .. }, Route::Collective { home: h2, .. }) = (&corp, &private) else { panic!() };
581 assert_ne!(h1, h2);
582 assert_eq!(h2, &PathBuf::from("/b/collectives/private"));
583 assert!(may_sync(&corp));
584 assert!(!may_sync(&private), "a local collective never leaves the machine");
585 assert!(!may_sync(&Route::Off));
586 }
587
588 #[test]
589 fn similar_names_get_separate_stores() {
590 let c = cfg(r#"{ "collectives": [
591 { "name": "my team", "paths": ["/l"], "share": "local" },
592 { "name": "my_team", "paths": ["/t"], "share": "team" } ] }"#);
593 let base = Path::new("/b");
594 let (Route::Collective { home: local, .. }, Route::Collective { home: team, .. }) = (route(Some(&c), "/l/x", base), route(Some(&c), "/t/x", base))
595 else {
596 panic!()
597 };
598 assert_ne!(local, team, "a local collective never shares a store with a team one");
599 assert_eq!(team, PathBuf::from("/b/collectives/my_team"), "safe names keep their store");
600 assert!(local.to_string_lossy().starts_with("/b/collectives/my_team-"));
601 assert_ne!(store_name("a.b"), store_name("a_b"));
602 assert_ne!(store_name("équipe"), store_name("_quipe"));
603 assert_eq!(store_name("a.b"), store_name("a.b"), "stable");
604 assert!(c.check().is_ok());
605 assert_eq!(local_owner_of(Some(&c), &team, base), None);
606 assert_eq!(local_owner_of(Some(&c), &local, base), Some("my team"));
607 }
608
609 #[test]
610 fn a_config_whose_collectives_share_a_store_is_rejected() {
611 for json in [
612 r#"{ "collectives": [ { "name": "x", "paths": ["/a"], "share": "local" }, { "name": "x", "paths": ["/b"] } ] }"#,
613 r#"{ "collectives": [ { "name": "Team", "paths": ["/a"], "share": "local" }, { "name": "team", "paths": ["/b"] } ] }"#,
614 ] {
615 assert!(cfg(json).check().unwrap_err().contains("share one store"), "{json}");
616 let p = std::env::temp_dir().join(format!("borg-collective-dup-{}.json", std::process::id()));
617 std::fs::write(&p, json).unwrap();
618 assert!(load_from(&p).is_err(), "load rejects it, so everything routes off");
619 let _ = std::fs::remove_file(&p);
620 }
621 }
622
623 #[test]
624 fn expert_repos_behind_the_boundary_are_never_consulted() {
625 let c = cfg(r#"{ "default": "off", "off": ["/home/u/secret"],
626 "collectives": [
627 { "name": "work", "paths": ["/home/u/work"], "share": "team", "experts": [
628 { "name": "open", "repo": "/home/u/src/flowlib" },
629 { "name": "mine", "repo": "/home/u/work/sdk" },
630 { "name": "offrepo", "repo": "/home/u/secret/sdk" },
631 { "name": "privaterepo", "repo": "/home/u/private/flowlib" },
632 { "name": "sneaky", "repo": "/home/u/work/../private/x" } ] },
633 { "name": "private", "paths": ["/home/u/private"], "share": "local", "experts": [
634 { "name": "own", "repo": "/home/u/private/flowlib" },
635 { "name": "team", "repo": "/home/u/work/sdk" },
636 { "name": "off", "repo": "/home/u/secret/sdk" } ] } ] }"#);
637 let names = |cwd: &str| c.experts_for(cwd).iter().map(|e| e.name.clone()).collect::<Vec<_>>();
638 assert_eq!(names("/home/u/work/app"), ["open", "mine"], "off and local-private expert repos are dropped");
639 assert_eq!(names("/home/u/private/app"), ["own", "team"], "a local collective may read its own and team repos, never off ones");
640 assert!(names("/home/u/secret/app").is_empty());
641 let d = std::env::temp_dir().canonicalize().unwrap().join(format!("borg-collective-link-{}", std::process::id()));
643 let _ = std::fs::remove_dir_all(&d);
644 std::fs::create_dir_all(d.join("secret/sdk")).unwrap();
645 std::os::unix::fs::symlink(d.join("secret/sdk"), d.join("link")).unwrap();
646 let c = cfg(&format!(
647 r#"{{ "off": ["{0}/secret"], "collectives": [ {{ "name": "t", "paths": ["/w"], "experts": [ {{ "name": "x", "repo": "{0}/link" }} ] }} ] }}"#,
648 d.display()
649 ));
650 assert!(c.experts_for("/w/app").is_empty(), "a symlink into an off path");
651 let _ = std::fs::remove_dir_all(&d);
652 }
653
654 #[test]
655 fn engineers_map_aliases_case_insensitively() {
656 let c = cfg(r#"{ "engineers": [ { "name": "dana", "emails": ["Dana.Example@corp.example", "da@home.example"] } ] }"#);
657 assert!(c.check().is_ok());
658 let a = c.aliases();
659 assert_eq!(a.canon("dana.example@CORP.example"), "dana");
660 assert_eq!(a.canon(" da@home.example "), "dana");
661 assert!(a.same("Dana.Example@corp.example", "da@home.example"));
662 assert_eq!(a.canon("Ann@X"), "ann@x", "unlisted emails still compare case-insensitively");
663 assert_eq!(a.display("Ann@X"), "Ann@X", "and display as written");
664 assert_eq!(a.display("DA@home.example"), "dana");
665 assert_eq!(a.canon(""), "");
666 assert_eq!(cfg(CFG).aliases(), Aliases::default(), "absent section: no aliases");
667 }
668
669 #[test]
670 fn an_invalid_engineers_section_is_rejected_at_load() {
671 for (json, why) in [
672 (r#"{ "engineers": [ { "name": "", "emails": ["a@x"] } ] }"#, "has no name"),
673 (r#"{ "engineers": [ { "name": "a", "emails": [] } ] }"#, "lists no emails"),
674 (r#"{ "engineers": [ { "name": "a", "emails": ["a@x", " "] } ] }"#, "empty email"),
675 (r#"{ "engineers": [ { "name": "a", "emails": ["a@x"] }, { "name": "A", "emails": ["b@x"] } ] }"#, "listed twice"),
676 (r#"{ "engineers": [ { "name": "a", "emails": ["a@x"] }, { "name": "b", "emails": ["A@X"] } ] }"#, "listed under both"),
677 (r#"{ "engineers": [ { "name": "a", "email": ["a@x"] } ] }"#, "unknown field"),
678 (r#"{ "engineers": { "name": "a" } }"#, "invalid type"),
679 ] {
680 let p = std::env::temp_dir().join(format!("borg-collective-eng-{}.json", std::process::id()));
681 std::fs::write(&p, json).unwrap();
682 let e = load_from(&p).unwrap_err();
683 assert!(e.contains(why) && e.contains(&p.display().to_string()), "{json}: {e}");
684 let _ = std::fs::remove_file(&p);
685 }
686 }
687
688 #[test]
689 fn a_repo_name_routes_only_where_no_other_place_of_it_could_be() {
690 let root = std::env::temp_dir().join(format!("borg-collective-repo-{}", std::process::id()));
691 let _ = std::fs::remove_dir_all(&root);
692 for d in ["work/group/api", "work/app", "oss/api", "oss/lib", "work/a/b/deep"] {
693 std::fs::create_dir_all(root.join(d)).unwrap();
694 }
695 let cfg: Config = serde_json::from_value(serde_json::json!({"collectives": [
696 {"name": "work", "paths": [root.join("work")]}, {"name": "oss", "paths": [root.join("oss")]}]}))
697 .unwrap();
698 let base = root.join("base");
699 let to = |name: &str| Some(Route::Collective { name: name.into(), home: store(&base, name), share: Share::Team });
700 assert_eq!(route_repo(&cfg, "lib", &base), to("oss"));
701 assert_eq!(route_repo(&cfg, "app", &base), to("work"));
702 assert_eq!(route_repo(&cfg, "api", &base), None);
704 assert_eq!(places_of(&cfg, "api").len(), 2);
705 assert_eq!(route_repo(&cfg, "deep", &base), None, "deeper than REPO_DEPTH: unknown");
706 let _ = std::fs::remove_dir_all(root);
707 }
708
709 #[test]
710 fn no_config_keeps_one_default_collective() {
711 let r = route(None, "/anything", Path::new("/b"));
712 assert_eq!(r, Route::Collective { name: "default".into(), home: PathBuf::from("/b"), share: Share::Team });
713 }
714}