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)]
87 pub mode: Option<String>,
88}
89
90#[derive(Debug, Clone, Deserialize)]
94pub struct ExpertSource {
95 pub name: String,
96 pub repo: String,
97 #[serde(rename = "match", default)]
98 pub matches: Vec<String>,
99 #[serde(default)]
101 pub drone: bool,
102}
103
104impl ExpertSource {
105 pub fn path(&self) -> PathBuf {
107 expand(&self.repo)
108 }
109}
110
111impl Config {
112 pub fn experts_for(&self, cwd: &str) -> Vec<&ExpertSource> {
115 let Route::Collective { name, .. } = route(Some(self), cwd, Path::new("/")) else { return Vec::new() };
116 let Some(c) = self.collectives.iter().find(|c| c.name == name) else { return Vec::new() };
117 c.experts.iter().filter(|e| self.may_consult(&c.name, e)).collect()
118 }
119
120 pub fn may_consult(&self, collective: &str, e: &ExpertSource) -> bool {
126 let p = e.path();
127 let real = p.canonicalize().ok();
128 std::iter::once(p).chain(real).all(|p| {
129 if self.off.iter().any(|o| inside(&normal(&p), o).is_some()) {
130 return false;
131 }
132 !matches!(route(Some(self), &p.to_string_lossy(), Path::new("/")),
133 Route::Collective { name, share: Share::Local, .. } if name != collective)
134 })
135 }
136}
137
138#[derive(Debug, Clone, Deserialize)]
139pub struct Config {
140 #[serde(default = "off")]
142 pub default: String,
143 #[serde(default)]
145 pub off: Vec<String>,
146 #[serde(default)]
147 pub collectives: Vec<Entry>,
148 #[serde(default, deserialize_with = "engineers")]
150 pub engineers: Vec<Engineer>,
151 #[serde(default)]
153 pub personal: Option<String>,
154}
155
156fn engineers<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Engineer>, D::Error> {
158 Vec::<Engineer>::deserialize(d).map_err(|e| serde::de::Error::custom(format!("engineers: {e}")))
159}
160
161#[derive(Debug, Clone, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct Engineer {
165 pub name: String,
166 pub emails: Vec<String>,
167}
168
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
172pub struct Aliases {
173 by_email: std::collections::HashMap<String, String>,
174}
175
176impl Aliases {
177 pub fn new(engineers: &[Engineer]) -> Aliases {
178 let by_email = engineers.iter().flat_map(|e| e.emails.iter().map(move |m| (m.trim().to_lowercase(), e.name.trim().to_string()))).collect();
179 Aliases { by_email }
180 }
181
182 pub fn canon(&self, email: &str) -> String {
185 let key = email.trim().to_lowercase();
186 self.by_email.get(&key).cloned().unwrap_or(key)
187 }
188
189 pub fn display(&self, email: &str) -> String {
191 self.by_email.get(&email.trim().to_lowercase()).cloned().unwrap_or_else(|| email.to_string())
192 }
193
194 pub fn same(&self, a: &str, b: &str) -> bool {
196 self.canon(a) == self.canon(b)
197 }
198}
199
200impl Config {
201 pub fn aliases(&self) -> Aliases {
202 Aliases::new(&self.engineers)
203 }
204}
205
206pub fn aliases() -> &'static Aliases {
210 static A: std::sync::OnceLock<Aliases> = std::sync::OnceLock::new();
211 A.get_or_init(|| {
212 if cfg!(test) {
213 return Aliases::default();
214 }
215 load().ok().flatten().map(|c| c.aliases()).unwrap_or_default()
216 })
217}
218
219fn off() -> String {
220 "off".into()
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub enum Route {
225 Off,
227 Collective {
228 name: String,
229 home: PathBuf,
230 share: Share,
231 },
232}
233
234pub fn config_path() -> PathBuf {
235 std::env::var("BORG_CONFIG")
236 .map(PathBuf::from)
237 .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config/borg/collectives.json"))
238}
239
240pub fn load() -> Result<Option<Config>, String> {
243 load_from(&config_path())
244}
245
246pub fn load_from(p: &Path) -> Result<Option<Config>, String> {
248 match std::fs::read_to_string(p) {
249 Ok(t) => {
250 let cfg: Config = serde_json::from_str(&t).map_err(|e| format!("{}: {e}", p.display()))?;
251 cfg.check().map_err(|e| format!("{}: {e}", p.display()))?;
252 Ok(Some(cfg))
253 }
254 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
255 Err(e) => Err(format!("{}: {e}", p.display())),
256 }
257}
258
259impl Config {
260 pub fn check(&self) -> Result<(), String> {
262 if let Some(p) = &self.personal
263 && !self.collectives.iter().any(|c| &c.name == p && c.share == Share::Local)
264 {
265 return Err(format!("personal: {p:?} is not a collective with \"share\": \"local\""));
266 }
267 let mut seen: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
268 for c in &self.collectives {
269 if let Some(other) = seen.insert(store_name(&c.name).to_lowercase(), &c.name) {
270 return Err(format!("collectives {other:?} and {:?} would share one store; rename one", c.name));
271 }
272 }
273 let mut names = std::collections::HashSet::new();
274 let mut owner: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
275 for e in &self.engineers {
276 if e.name.trim().is_empty() {
277 return Err("engineers: an entry has no name".into());
278 }
279 if !names.insert(e.name.trim().to_lowercase()) {
280 return Err(format!("engineers: {:?} is listed twice; merge the entries", e.name));
281 }
282 if e.emails.is_empty() {
283 return Err(format!("engineers: {:?} lists no emails", e.name));
284 }
285 for m in e.emails.iter().map(|m| m.trim().to_lowercase()) {
286 if m.is_empty() {
287 return Err(format!("engineers: {:?} lists an empty email", e.name));
288 }
289 if let Some(other) = owner.insert(m.clone(), &e.name).filter(|o| *o != e.name) {
290 return Err(format!("engineers: {m} is listed under both {other:?} and {:?}", e.name));
291 }
292 }
293 }
294 Ok(())
295 }
296}
297
298pub(crate) fn expand(p: &str) -> PathBuf {
299 match p.strip_prefix("~/") {
300 Some(rest) => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(rest),
301 None if p == "~" => PathBuf::from(std::env::var("HOME").unwrap_or_default()),
302 None => PathBuf::from(p),
303 }
304}
305
306pub(crate) fn normal(p: &Path) -> Vec<String> {
308 let mut out: Vec<String> = Vec::new();
309 for c in p.components() {
310 match c {
311 Component::ParentDir => {
312 out.pop();
313 }
314 Component::Normal(s) => out.push(s.to_string_lossy().into_owned()),
315 _ => {}
316 }
317 }
318 out
319}
320
321pub(crate) fn inside(path: &[String], prefix: &str) -> Option<usize> {
323 let pre = normal(&expand(prefix));
324 (!pre.is_empty() && path.len() >= pre.len() && path[..pre.len()] == pre[..]).then_some(pre.len())
325}
326
327fn store_name(name: &str) -> String {
330 let safe: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
331 if safe == name && !safe.is_empty() {
332 return safe;
333 }
334 let h = name.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ b as u64).wrapping_mul(0x0100_0000_01b3));
337 format!("{safe}-{:08x}", h as u32 ^ (h >> 32) as u32)
338}
339
340pub fn store(base: &Path, name: &str) -> PathBuf {
342 base.join("collectives").join(store_name(name))
343}
344
345pub fn root_of(home: &Path) -> PathBuf {
347 match home.parent() {
348 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),
349 _ => home.to_path_buf(),
350 }
351}
352
353pub fn personal_store(cfg: Option<&Config>, home: &Path) -> Option<PathBuf> {
357 let cfg = cfg?;
358 let base = root_of(home);
359 let local: Vec<&Entry> = cfg.collectives.iter().filter(|c| c.share == Share::Local).collect();
360 let pick = match &cfg.personal {
361 Some(name) => local.iter().find(|c| &c.name == name).copied(),
362 None if local.len() == 1 => local.first().copied(),
363 None => None,
364 }?;
365 Some(store(&base, &pick.name))
366}
367
368pub fn local_owner_of<'a>(cfg: Option<&'a Config>, home: &Path, base: &Path) -> Option<&'a str> {
371 cfg?.collectives.iter().find(|c| c.share == Share::Local && store(base, &c.name) == home).map(|c| c.name.as_str())
372}
373
374fn within(cwd: &str) -> impl Fn(&str) -> Option<usize> {
378 let lexical = expand(cwd);
379 let paths: Vec<Vec<String>> = std::iter::once(normal(&lexical)).chain(lexical.canonicalize().ok().map(|p| normal(&p))).collect();
380 move |prefix: &str| {
381 let real = expand(prefix).canonicalize().ok().map(|p| p.to_string_lossy().into_owned());
382 let prefixes = std::iter::once(prefix.to_string()).chain(real);
383 prefixes.filter_map(|pre| paths.iter().filter_map(|p| inside(p, &pre)).max()).max()
384 }
385}
386
387pub fn route(cfg: Option<&Config>, cwd: &str, base: &Path) -> Route {
389 let Some(cfg) = cfg else {
390 return Route::Collective { name: "default".into(), home: base.to_path_buf(), share: Share::Team };
391 };
392 if cwd.trim().is_empty() {
393 return Route::Off; }
395 let within = within(cwd);
396 if cfg.off.iter().any(|p| within(p).is_some()) {
397 return Route::Off;
398 }
399 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);
400 let chosen = best.or_else(|| cfg.collectives.iter().find(|c| c.name == cfg.default && cfg.default != "off"));
401 match chosen {
402 Some(c) => Route::Collective { name: c.name.clone(), home: store(base, &c.name), share: c.share },
403 None => Route::Off,
404 }
405}
406
407pub fn may_sync(r: &Route) -> bool {
410 matches!(r, Route::Collective { share: Share::Team, .. })
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 fn cfg(json: &str) -> Config {
418 serde_json::from_str(json).unwrap()
419 }
420
421 const CFG: &str = r#"{ "default": "off",
422 "off": ["/home/u/private/diary"],
423 "collectives": [
424 { "name": "work", "paths": ["/home/u/work-repos"], "share": "team" },
425 { "name": "private", "paths": ["/home/u/private"], "share": "local" },
426 { "name": "libs", "paths": ["/home/u/work-repos/shared-libs"] } ] }"#;
427
428 fn name(r: &Route) -> &str {
429 match r {
430 Route::Off => "off",
431 Route::Collective { name, .. } => name,
432 }
433 }
434
435 #[test]
436 fn routes_by_longest_whole_component_prefix() {
437 let c = cfg(CFG);
438 let base = Path::new("/b");
439 assert_eq!(name(&route(Some(&c), "/home/u/work-repos/harbor_orion/src", base)), "work");
440 assert_eq!(name(&route(Some(&c), "/home/u/work-repos/shared-libs/x", base)), "libs", "longest prefix wins");
441 assert_eq!(name(&route(Some(&c), "/home/u/work", base)), "off", "no partial component match");
442 assert_eq!(name(&route(Some(&c), "/home/u/work-repos-old/x", base)), "off", "work-repos-old is not work-repos");
443 assert_eq!(name(&route(Some(&c), "/home/u/private/finances", base)), "private");
444 assert_eq!(name(&route(Some(&c), "/home/u/work-repos/../private/x", base)), "private", "normalised before matching");
445 }
446
447 #[test]
448 fn a_symlink_and_its_target_route_alike() {
449 let root = std::env::temp_dir().join(format!("borg-route-link-{}", std::process::id()));
452 let _ = std::fs::remove_dir_all(&root);
453 std::fs::create_dir_all(root.join("real/work/secret/x")).unwrap();
454 std::os::unix::fs::symlink(root.join("real"), root.join("link")).unwrap();
455 let (real, link) = (root.join("real").display().to_string(), root.join("link").display().to_string());
456 let c = cfg(&format!(
457 r#"{{ "default": "off", "off": ["{link}/work/secret"], "collectives": [ {{ "name": "work", "paths": ["{real}/work"], "share": "team" }} ] }}"#
458 ));
459 let base = Path::new("/b");
460 assert_eq!(route(Some(&c), &format!("{real}/work/secret/x"), base), Route::Off, "an off path written through the link");
461 assert_eq!(route(Some(&c), &format!("{link}/work/secret/x"), base), Route::Off);
462 assert_eq!(name(&route(Some(&c), &format!("{link}/work"), base)), "work", "a cwd reported through the link");
463 let _ = std::fs::remove_dir_all(root);
464 }
465
466 #[test]
467 fn off_wins_and_unknown_is_off() {
468 let c = cfg(CFG);
469 let base = Path::new("/b");
470 assert_eq!(route(Some(&c), "/home/u/private/diary/2026", base), Route::Off, "off beats the private collective above it");
471 assert_eq!(route(Some(&c), "/somewhere/else", base), Route::Off, "default off");
472 assert_eq!(route(Some(&c), "", base), Route::Off, "no cwd: never guess");
473 let d = cfg(r#"{ "default": "work", "collectives": [ { "name": "work", "paths": ["/w"] } ] }"#);
474 assert_eq!(name(&route(Some(&d), "/somewhere/else", base)), "work");
475 }
476
477 #[test]
478 fn stores_are_separate_and_local_never_syncs() {
479 let c = cfg(CFG);
480 let base = Path::new("/b");
481 let corp = route(Some(&c), "/home/u/work-repos/a", base);
482 let private = route(Some(&c), "/home/u/private/a", base);
483 let (Route::Collective { home: h1, .. }, Route::Collective { home: h2, .. }) = (&corp, &private) else { panic!() };
484 assert_ne!(h1, h2);
485 assert_eq!(h2, &PathBuf::from("/b/collectives/private"));
486 assert!(may_sync(&corp));
487 assert!(!may_sync(&private), "a local collective never leaves the machine");
488 assert!(!may_sync(&Route::Off));
489 }
490
491 #[test]
492 fn similar_names_get_separate_stores() {
493 let c = cfg(r#"{ "collectives": [
494 { "name": "my team", "paths": ["/l"], "share": "local" },
495 { "name": "my_team", "paths": ["/t"], "share": "team" } ] }"#);
496 let base = Path::new("/b");
497 let (Route::Collective { home: local, .. }, Route::Collective { home: team, .. }) = (route(Some(&c), "/l/x", base), route(Some(&c), "/t/x", base))
498 else {
499 panic!()
500 };
501 assert_ne!(local, team, "a local collective never shares a store with a team one");
502 assert_eq!(team, PathBuf::from("/b/collectives/my_team"), "safe names keep their store");
503 assert!(local.to_string_lossy().starts_with("/b/collectives/my_team-"));
504 assert_ne!(store_name("a.b"), store_name("a_b"));
505 assert_ne!(store_name("équipe"), store_name("_quipe"));
506 assert_eq!(store_name("a.b"), store_name("a.b"), "stable");
507 assert!(c.check().is_ok());
508 assert_eq!(local_owner_of(Some(&c), &team, base), None);
509 assert_eq!(local_owner_of(Some(&c), &local, base), Some("my team"));
510 }
511
512 #[test]
513 fn a_config_whose_collectives_share_a_store_is_rejected() {
514 for json in [
515 r#"{ "collectives": [ { "name": "x", "paths": ["/a"], "share": "local" }, { "name": "x", "paths": ["/b"] } ] }"#,
516 r#"{ "collectives": [ { "name": "Team", "paths": ["/a"], "share": "local" }, { "name": "team", "paths": ["/b"] } ] }"#,
517 ] {
518 assert!(cfg(json).check().unwrap_err().contains("share one store"), "{json}");
519 let p = std::env::temp_dir().join(format!("borg-collective-dup-{}.json", std::process::id()));
520 std::fs::write(&p, json).unwrap();
521 assert!(load_from(&p).is_err(), "load rejects it, so everything routes off");
522 let _ = std::fs::remove_file(&p);
523 }
524 }
525
526 #[test]
527 fn expert_repos_behind_the_boundary_are_never_consulted() {
528 let c = cfg(r#"{ "default": "off", "off": ["/home/u/secret"],
529 "collectives": [
530 { "name": "work", "paths": ["/home/u/work"], "share": "team", "experts": [
531 { "name": "open", "repo": "/home/u/src/flowlib" },
532 { "name": "mine", "repo": "/home/u/work/sdk" },
533 { "name": "offrepo", "repo": "/home/u/secret/sdk" },
534 { "name": "privaterepo", "repo": "/home/u/private/flowlib" },
535 { "name": "sneaky", "repo": "/home/u/work/../private/x" } ] },
536 { "name": "private", "paths": ["/home/u/private"], "share": "local", "experts": [
537 { "name": "own", "repo": "/home/u/private/flowlib" },
538 { "name": "team", "repo": "/home/u/work/sdk" },
539 { "name": "off", "repo": "/home/u/secret/sdk" } ] } ] }"#);
540 let names = |cwd: &str| c.experts_for(cwd).iter().map(|e| e.name.clone()).collect::<Vec<_>>();
541 assert_eq!(names("/home/u/work/app"), ["open", "mine"], "off and local-private expert repos are dropped");
542 assert_eq!(names("/home/u/private/app"), ["own", "team"], "a local collective may read its own and team repos, never off ones");
543 assert!(names("/home/u/secret/app").is_empty());
544 let d = std::env::temp_dir().canonicalize().unwrap().join(format!("borg-collective-link-{}", std::process::id()));
546 let _ = std::fs::remove_dir_all(&d);
547 std::fs::create_dir_all(d.join("secret/sdk")).unwrap();
548 std::os::unix::fs::symlink(d.join("secret/sdk"), d.join("link")).unwrap();
549 let c = cfg(&format!(
550 r#"{{ "off": ["{0}/secret"], "collectives": [ {{ "name": "t", "paths": ["/w"], "experts": [ {{ "name": "x", "repo": "{0}/link" }} ] }} ] }}"#,
551 d.display()
552 ));
553 assert!(c.experts_for("/w/app").is_empty(), "a symlink into an off path");
554 let _ = std::fs::remove_dir_all(&d);
555 }
556
557 #[test]
558 fn engineers_map_aliases_case_insensitively() {
559 let c = cfg(r#"{ "engineers": [ { "name": "dana", "emails": ["Dana.Example@corp.example", "da@home.example"] } ] }"#);
560 assert!(c.check().is_ok());
561 let a = c.aliases();
562 assert_eq!(a.canon("dana.example@CORP.example"), "dana");
563 assert_eq!(a.canon(" da@home.example "), "dana");
564 assert!(a.same("Dana.Example@corp.example", "da@home.example"));
565 assert_eq!(a.canon("Ann@X"), "ann@x", "unlisted emails still compare case-insensitively");
566 assert_eq!(a.display("Ann@X"), "Ann@X", "and display as written");
567 assert_eq!(a.display("DA@home.example"), "dana");
568 assert_eq!(a.canon(""), "");
569 assert_eq!(cfg(CFG).aliases(), Aliases::default(), "absent section: no aliases");
570 }
571
572 #[test]
573 fn an_invalid_engineers_section_is_rejected_at_load() {
574 for (json, why) in [
575 (r#"{ "engineers": [ { "name": "", "emails": ["a@x"] } ] }"#, "has no name"),
576 (r#"{ "engineers": [ { "name": "a", "emails": [] } ] }"#, "lists no emails"),
577 (r#"{ "engineers": [ { "name": "a", "emails": ["a@x", " "] } ] }"#, "empty email"),
578 (r#"{ "engineers": [ { "name": "a", "emails": ["a@x"] }, { "name": "A", "emails": ["b@x"] } ] }"#, "listed twice"),
579 (r#"{ "engineers": [ { "name": "a", "emails": ["a@x"] }, { "name": "b", "emails": ["A@X"] } ] }"#, "listed under both"),
580 (r#"{ "engineers": [ { "name": "a", "email": ["a@x"] } ] }"#, "unknown field"),
581 (r#"{ "engineers": { "name": "a" } }"#, "invalid type"),
582 ] {
583 let p = std::env::temp_dir().join(format!("borg-collective-eng-{}.json", std::process::id()));
584 std::fs::write(&p, json).unwrap();
585 let e = load_from(&p).unwrap_err();
586 assert!(e.contains(why) && e.contains(&p.display().to_string()), "{json}: {e}");
587 let _ = std::fs::remove_file(&p);
588 }
589 }
590
591 #[test]
592 fn no_config_keeps_one_default_collective() {
593 let r = route(None, "/anything", Path::new("/b"));
594 assert_eq!(r, Route::Collective { name: "default".into(), home: PathBuf::from("/b"), share: Share::Team });
595 }
596}