Skip to main content

borg/
collective.rs

1//! Separate minds: which collective a repo belongs to, or none.
2//!
3//! An engineer can be part of several collectives (a work one shared with the team, a private
4//! one that never leaves this machine) and can keep repos out of every collective. The mapping lives
5//! only on this machine, in `$BORG_CONFIG` (default `~/.config/borg/collectives.json`):
6//!
7//! ```json
8//! { "default": "off",
9//!   "off": ["~/secret"],
10//!   "collectives": [
11//!     { "name": "work", "paths": ["~/work-repos"], "share": "team" },
12//!     { "name": "private",   "paths": ["~/private"],    "share": "local" } ] }
13//! ```
14//!
15//! Rules, in order: an `off` path wins over everything (also over a collective path above it);
16//! otherwise the collective with the longest matching path; otherwise `default` (`"off"` or a
17//! collective name). Paths match whole components, so `~/work` never matches `~/work-repos`.
18//! Without a config file every repo belongs to one collective, "default", stored at `BORG_HOME`.
19//!
20//! Each collective has its own store (`<BORG_HOME>/collectives/<name>/`): lessons, episodes,
21//! outcomes and scratch state. A drone only ever reads its own collective, so nothing crosses
22//! between collectives in either direction. `share: "local"` means the collective must never be
23//! synced to any peer: `may_sync` is the one gate a mesh has to ask.
24//!
25//! A `"mode"` key (live mode, removed in 0.7.6) is still read and ignored: the conscience judges the work
26//! at the turn end only, and `borg doctor` says so once.
27//!
28//! A collective may also list `experts` (expert repos its drones consult at the Stop checkpoint,
29//! see expert_route.rs); only the routed collective's experts are ever read, and never one whose
30//! repo lies under an `off` path or in another collective with `share: "local"`.
31//!
32//! Store names must never collide: if "my team" (local) and "my_team" (team) shared a directory,
33//! syncing the team collective would ship the local one's lessons. So a name that is not already a
34//! safe directory name gets a hash of the raw name appended (`my_team-1a2b3c4d`), and a config in
35//! which two collectives still resolve to one store (also by case, for case-insensitive
36//! filesystems) is rejected at load, which routes everything off.
37//!
38//! **Engineers**: one engineer may commit under several git emails (work and personal). An optional
39//! `engineers` section names them, so trust, lesson authorship, the queen's budget, `borg status`
40//! and `borg top` count them as one:
41//!
42//! ```json
43//! { "engineers": [ { "name": "dana", "emails": ["Dana.Example@corp.example", "da@home.example"] } ] }
44//! ```
45//!
46//! Emails always compare case-insensitively (`Ann@X` is `ann@x`); a listed email reads as its
47//! engineer's name. Stored data is never rewritten: lessons, episodes and presence keep the raw
48//! email and the mapping is applied when they are read, so removing an entry restores the split.
49//! The section is local config like the rest of this file and is never synced: a team peer reads
50//! the same lessons with its own config (or none), so it may see the raw emails and count them as
51//! separate engineers until it lists them too. An entry without a name or emails, a name listed
52//! twice, or an email listed under two engineers makes the config invalid (everything routes off).
53
54use 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    /// May be synced with the collective's members.
62    #[default]
63    Team,
64    /// Never leaves this machine.
65    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    /// Repos whose expertise this collective's drones may consult at the Stop checkpoint
75    /// (expert_route.rs). Absent or empty: the feature is off for this collective.
76    #[serde(default)]
77    pub experts: Vec<ExpertSource>,
78    /// How the mesh reaches this collective's members across NAT (borg-mesh): absent or
79    /// `"default"` for n0's public relays and address lookup, `"disabled"` for direct addresses
80    /// only (LAN or VPN), or the URL of a self-hosted `iroh-relay`. `BORG_RELAY` overrides it.
81    #[serde(default)]
82    pub relay: Option<String>,
83    /// Live mode's switch, removed in 0.7.6: read and ignored (an older config keeps loading), so `borg
84    /// doctor` can say once that live mode is gone (`mode_gone`).
85    #[serde(default)]
86    pub mode: Option<String>,
87    /// The engineer's optional budget for lesson selection (select.rs `budget_of`): the pool tokens this
88    /// collective's checks may read per session on average, by which the policy prices a larger pool.
89    /// Absent (the default): spend is measured and shown, never priced. Kept as written: a value that
90    /// is not a positive number counts as none (`budget`) and cannot invalidate the whole config.
91    #[serde(default)]
92    pub budget: Option<serde_json::Value>,
93    /// The engineer's optional tolerance for learning in lesson selection (select.rs `risk_of`): the
94    /// share of the fixed rule's value, 0 to 1, the learned policy's gate may risk, for deviations on
95    /// evidence and the randomised question-count test alike; 0 turns the test off and admits an
96    /// evidence deviation only where the gate's past gains cover it. Absent (the default):
97    /// select/policy.rs `ALPHA`. Kept as written like `budget`: a value that is not a number from 0
98    /// to 1 counts as none (`risk`).
99    #[serde(default)]
100    pub risk: Option<serde_json::Value>,
101}
102
103impl Entry {
104    /// `budget` when it is a positive number.
105    #[must_use]
106    pub fn budget(&self) -> Option<f64> {
107        self.budget.as_ref()?.as_f64().filter(|b| *b > 0.0)
108    }
109
110    /// `risk` when it is a number from 0 to 1.
111    #[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/// An expert repo: the engineer's local checkout of a dependency, consulted when a drone's
118/// manifests depend on it (`match`: group, artifact, package or crate names) and its plan touches it.
119/// `{"name": "flowlib", "repo": "~/src/flowlib", "match": ["flowlib", "org.flowlib"], "drone": false}`
120#[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    /// Ask a read-only expert drone (time-bounded, expert_route.rs `DRONE_TIMEOUT`) when the index has
127    /// nothing. Off by default.
128    #[serde(default)]
129    pub drone: bool,
130}
131
132impl ExpertSource {
133    /// The repo path, `~` expanded.
134    pub fn path(&self) -> PathBuf {
135        expand(&self.repo)
136    }
137}
138
139impl Config {
140    /// The experts of the collective `cwd` routes to; none for an off repo or another collective's.
141    /// An expert repo behind the boundary is never consulted (see [`Config::may_consult`]).
142    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    /// Whether `collective`'s drones may read expert `e`: its repo must not lie under an `off` path
149    /// (no collective may ever see it) nor in another collective that never leaves this machine
150    /// (`share: local`): its claims would land in this collective's transcripts, and from there in
151    /// lessons that sync. Checked on the path as written and on the path symlinks resolve to. An
152    /// expert repo outside every collective is fine: listing it as an expert is the opt-in.
153    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    /// "off", or the name of the collective for repos no rule matches.
169    #[serde(default = "off")]
170    pub default: String,
171    /// Repos no collective may ever see: the hooks do nothing there.
172    #[serde(default)]
173    pub off: Vec<String>,
174    #[serde(default)]
175    pub collectives: Vec<Entry>,
176    /// Engineers known under several git emails (see the module doc).
177    #[serde(default, deserialize_with = "engineers")]
178    pub engineers: Vec<Engineer>,
179    /// Named a local collective that received personal lessons until 0.7.3. Unused since: a personal
180    /// lesson stays in the collective it was learned in (reach.rs `PERSONAL_FILE`). Still read and
181    /// checked, so a config that names it stays valid.
182    #[serde(default)]
183    pub personal: Option<String>,
184}
185
186/// The `engineers` section; its errors name it, so `borg status` and `borg doctor` point at it.
187fn 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/// One engineer and every git email they commit under.
192#[derive(Debug, Clone, Deserialize)]
193#[serde(deny_unknown_fields)]
194pub struct Engineer {
195    pub name: String,
196    pub emails: Vec<String>,
197}
198
199/// Who an engineer is, as every count and comparison of engineers sees them: a listed email reads
200/// as its engineer's name, any other email lowercased. Empty for no config or no `engineers`.
201#[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    /// The canonical engineer for a raw git email: the listed name, else the email lowercased.
213    /// Empty stays empty (unknown engineer).
214    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    /// For display: the listed name, else the email as written.
220    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    /// Whether two raw emails are the same engineer.
225    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
236/// This machine's aliases, read once per process from the collectives config. No config, an
237/// invalid one (which routes everything off anyway) or no `engineers` section: none. Unit tests
238/// of this crate never read the engineer's real config.
239pub 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    /// Not part of any collective: no capture, no recall, no state written.
256    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
270/// The config, if present. An unreadable or invalid config routes everything off: a broken file
271/// must never leak a private repo into a shared collective.
272pub fn load() -> Result<Option<Config>, String> {
273    load_from(&config_path())
274}
275
276/// `load` from an explicit path (the mesh re-reads it per session; tests use temp files).
277pub 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    /// Every collective must have a store of its own (see the module doc).
291    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
336/// Lexically normalised absolute path (no filesystem access: the hook must stay fast).
337pub(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
351/// Number of components of `prefix` if `path` lies inside it (whole components only).
352pub(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
357/// The store directory of collective `name`: the name itself when it is already a safe directory
358/// name (so existing stores stay put), otherwise the sanitised name plus a hash of the raw name.
359fn 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    // FNV-1a: stable across builds and platforms, no dependency in the hook binary. Collisions are
365    // caught by `Config::check`; the hash only has to keep distinct names apart in practice.
366    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
370/// A collective's own store under the root store `base`.
371pub fn store(base: &Path, name: &str) -> PathBuf {
372    base.join("collectives").join(store_name(name))
373}
374
375/// The root store of `home`, a collective's store (`<root>/collectives/<name>`) or the root itself.
376pub 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
383/// Guard: how many directory levels below a config path [`places_of`] looks for a repo known only by
384/// its name: a repo directly in a config path (`~/work/app`) or one group deeper (`~/work/group/app`).
385/// Deeper ones stay unknown, never guessed. Bounds the cost: one directory listing per config path and
386/// one stat per entry in it, per repo name.
387pub const REPO_DEPTH: usize = 2;
388
389/// The directories of the repo `repo` (a name, as episodes carry it) among `cfg`'s paths (collective or
390/// `off`): a path named like it, or a directory of that name up to [`REPO_DEPTH`] levels inside one
391/// (hidden directories skipped).
392pub 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
422/// Where a repo known only by its name (an episode's `repo`, hook.rs `repo_of`) routes under `cfg`: the
423/// route of every place of it ([`places_of`]). `None` when there is none or they route to different
424/// places (a team repo and an open-source one of one name): unknown, never guessed. Reads the config's
425/// own directories only, never another store.
426pub 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
432/// Whether `home` is the store of one of `cfg`'s collectives (under the root store it lies in).
433pub 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
438/// A `share: local` collective of `cfg` whose store is `home`, if any. Second guard for a sync: even
439/// a config that slipped past `Config::check` never ships a local collective's files.
440pub 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
444/// How deep `cwd` lies inside a config path (`None`: not inside it). A path and the one its symlinks
445/// resolve to are one place (macOS's /tmp is /private/tmp; a harness may report either), on both sides:
446/// an `off` path written through a link must still win.
447fn 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
457/// Which collective `cwd` belongs to. `base` is the root store (`BORG_HOME`).
458pub 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; // unknown location: never guess
464    }
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
477/// The one gate any sync (mesh, export, gossip) must ask before a collective's data leaves this
478/// machine. Local collectives and off repos never sync.
479pub fn may_sync(r: &Route) -> bool {
480    matches!(r, Route::Collective { share: Share::Team, .. })
481}
482
483/// What still sets live mode's switch, removed in 0.7.6 (docs/delivery.md): `BORG_MODE` (`env`) and the
484/// collectives whose config has a `"mode"`, for `borg doctor`'s one note; None when nothing does. Both
485/// are ignored: every check runs at the turn end.
486pub 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    /// Live mode is gone (0.7.6): its switch still parses, and doctor names what still sets it, once.
500    #[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        // The engineer writes paths through a link (macOS's /tmp, a symlinked home), the harness reports
547        // the resolved cwd, or the other way round: `off` still wins, and the collective still routes.
548        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        // A symlink into an off path is resolved.
642        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        // A team repo one group deeper and an open-source one of the same name: unknown, never the other's.
703        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}