Skip to main content

borg/
top.rs

1//! `borg top [--dir DIR] [--once] [--json]`: the drones of each collective, one screen.
2//!
3//! Local drones come from presence records (presence.rs), remote ones from what the mesh daemon
4//! last heard from members (only for a collective that may sync). Drones are designated by start
5//! order across both, Borg style: "Two of Five". `--dir` shows only the collective DIR routes to;
6//! otherwise every collective on this machine that has drones. On a terminal it is a live screen until
7//! `q` (top_live.rs); piped, or with `--once`, it prints the text once; `--json` prints the same data
8//! for scripts.
9//!
10//! Messages (msg.rs): each local drone's unread count (what its next prompt or tool call shows), and
11//! the collective's last few messages, one line each. A remote drone's reads are claimed on its node.
12//! A sender is named by what its line claims, with the node the mesh checked it against (`sender`).
13
14use std::collections::BTreeMap;
15use std::path::Path;
16
17use serde::Serialize;
18use serde_json::{Value, json};
19
20use crate::collective::{self, Route, Share};
21use crate::msg::{self, Address, Message};
22use crate::presence::{self, Presence, State};
23use crate::{install, learn, steer};
24
25#[derive(Debug, Clone, Serialize)]
26pub struct Drone {
27    /// "Two of Five".
28    pub designation: String,
29    /// 1-based start order.
30    pub number: usize,
31    /// The member key of the node it runs on; empty for this machine.
32    pub node: String,
33    pub state: State,
34    /// Paused by a steering directive (local drones only).
35    pub paused: bool,
36    #[serde(flatten)]
37    pub p: Presence,
38}
39
40/// The messages side of a collective's screen.
41#[derive(Debug, Clone, Default, Serialize)]
42pub struct Inbox {
43    /// Per local drone's session, the messages not shown to it yet.
44    pub unread: BTreeMap<String, usize>,
45    /// The newest `RECENT` messages, newest first.
46    pub recent: Vec<Message>,
47    /// The messages sent in the last day.
48    pub day: usize,
49    /// This node as messages name it (msg.rs `node`).
50    #[serde(skip)]
51    pub here: String,
52    /// Per local drone's session that listens for wakes (listen.rs), whether its waiter runs.
53    pub listen: BTreeMap<String, crate::listen::Status>,
54}
55
56/// How many messages the screen lists.
57pub const RECENT: usize = 5;
58
59const DAY_MS: i64 = 24 * 60 * 60 * 1000;
60
61const NUMBERS: [&str; 20] = [
62    "One",
63    "Two",
64    "Three",
65    "Four",
66    "Five",
67    "Six",
68    "Seven",
69    "Eight",
70    "Nine",
71    "Ten",
72    "Eleven",
73    "Twelve",
74    "Thirteen",
75    "Fourteen",
76    "Fifteen",
77    "Sixteen",
78    "Seventeen",
79    "Eighteen",
80    "Nineteen",
81    "Twenty",
82];
83
84pub fn word(n: usize) -> String {
85    NUMBERS.get(n.wrapping_sub(1)).map(|s| s.to_string()).unwrap_or_else(|| n.to_string())
86}
87
88/// The drones of the collective stored at `home` (pure over its inputs, for tests).
89pub fn assemble(local: Vec<Presence>, remote: Vec<presence::Remote>, now: i64, alive: impl Fn(u32) -> bool, paused: impl Fn(&Presence) -> bool) -> Vec<Drone> {
90    let mut all: Vec<(String, Presence, State, bool)> = local
91        .into_iter()
92        .map(|p| {
93            let s = presence::state(&p, now, &alive);
94            let z = s != State::Ended && paused(&p);
95            (String::new(), p, s, z)
96        })
97        .collect();
98    // A remote pid means nothing here: its state comes from its events and age only.
99    all.extend(remote.into_iter().map(|r| {
100        let s = presence::state(&r.p, now, |_| true);
101        (r.key, r.p, s, false)
102    }));
103    all.sort_by(|a, b| (a.1.started, &a.0, &a.1.session).cmp(&(b.1.started, &b.0, &b.1.session)));
104    let n = all.len();
105    all.into_iter()
106        .enumerate()
107        .map(|(i, (node, p, state, paused))| Drone { designation: format!("{} of {}", word(i + 1), word(n)), number: i + 1, node, state, paused, p })
108        .collect()
109}
110
111/// The drones of the collective stored at `home` as of `now`; remote ones only if it may sync.
112pub fn drones(home: &Path, may_sync: bool, now: i64) -> Vec<Drone> {
113    let remote = if may_sync { presence::remote(home, now) } else { Vec::new() };
114    assemble(presence::local(home, now), remote, now, presence::alive, |p| steer::paused(home, &p.session, Some(p.started), now))
115}
116
117/// The messages of the store at `home` for its `drones` as of `now` (one read of the log, one
118/// directory listing per local drone that has a message).
119pub fn inbox(home: &Path, drones: &[Drone], now: i64) -> Inbox {
120    let listen = listen_of(home, drones);
121    if std::fs::metadata(msg::path(home)).is_err() {
122        return Inbox { listen, ..Inbox::default() };
123    }
124    let all: Vec<Message> = msg::read(home).into_iter().filter(|m| now - m.ms <= msg::TTL_MS).collect();
125    let here = msg::node(home);
126    let unread = unread_of(home, drones, &here, &all, now);
127    let day = all.iter().filter(|m| now - m.ms <= DAY_MS).count();
128    let mut recent = all;
129    recent.sort_by(|a, b| (b.ms, &b.id).cmp(&(a.ms, &a.id)));
130    recent.truncate(RECENT);
131    Inbox { unread, recent, day, here, listen }
132}
133
134/// Per local drone's session that listens for wakes, whether its waiter runs (one stat for any other).
135fn listen_of(home: &Path, drones: &[Drone]) -> BTreeMap<String, crate::listen::Status> {
136    drones
137        .iter()
138        .filter(|d| d.node.is_empty() && d.state != State::Ended)
139        .filter_map(|d| crate::listen::status(home, &d.p.session).map(|s| (d.p.session.clone(), s)))
140        .collect()
141}
142
143/// A drone's wake as the screen shows it: `on` (it listens, the next stop starts its waiter), `waiter`
144/// (its waiter runs), `-` (it is never woken, or this node cannot tell).
145pub fn wake_cell(inbox: &Inbox, d: &Drone) -> &'static str {
146    inbox.listen.get(&d.p.session).map_or("-", |s| s.name())
147}
148
149/// Per live local drone's session, how many of the already-read `all` messages were not shown to it
150/// (`here`: this node, msg.rs `node`).
151pub fn unread_of(home: &Path, drones: &[Drone], here: &str, all: &[Message], now: i64) -> BTreeMap<String, usize> {
152    drones
153        .iter()
154        .filter(|d| d.node.is_empty() && d.state != State::Ended)
155        .map(|d| (d.p.session.clone(), msg::unread(home, all, here, &d.p.session, d.p.started, now)))
156        .collect()
157}
158
159/// The drone of `drones` that is `session` on `node` (`here`: this node).
160fn drone_at<'a>(drones: &'a [Drone], here: &str, node: &str, session: &str) -> Option<&'a Drone> {
161    drones.iter().find(|d| d.p.session == session && if d.node.is_empty() { node == here } else { d.node == node })
162}
163
164/// Who sent `m`, as the screen says it: the drone its (node, `by`) pair is, else the name it was sent
165/// under, else the start of `by`; the engineer by email. Every part but the node is the sender's claim,
166/// so a line from another node than `here` carries that node's short key (as msg.rs `line`).
167pub fn sender(drones: &[Drone], here: &str, m: &Message) -> String {
168    let name = match Address::parse(&m.by) {
169        Some(Address::Engineer { email }) if email.is_empty() => msg::ENGINEER.into(),
170        Some(Address::Engineer { email }) => collective::aliases().display(&email),
171        _ => match drone_at(drones, here, &m.node, &m.by) {
172            Some(d) => d.designation.clone(),
173            None if !m.name.is_empty() => m.name.clone(),
174            None => m.by.chars().take(8).collect(),
175        },
176    };
177    if m.node == here { name } else { format!("{name} @{}", m.node.get(..12).unwrap_or(&m.node)) }
178}
179
180/// Whom `to` names, as the screen says it: `local` (this machine's sessions), `collective` (every
181/// member's), the engineer, the drone it is, else the start of the session id. Another node's `local`
182/// (it never travels, msg.rs `stays_here`) is named with that node's short key.
183pub fn recipient(drones: &[Drone], here: &str, to: &str) -> String {
184    match Address::parse(to) {
185        Some(Address::Engineer { email }) if !email.is_empty() => collective::aliases().display(&email),
186        Some(Address::Local { node }) if node == here => msg::LOCAL.into(),
187        Some(Address::Local { node }) => format!("{} @{}", msg::LOCAL, node.get(..12).unwrap_or(&node)),
188        Some(Address::All) => msg::COLLECTIVE.into(),
189        Some(Address::Session { id, node }) => {
190            let d = match node {
191                Some(n) => drone_at(drones, here, &n, &id),
192                None => drones.iter().find(|d| d.p.session == id),
193            };
194            d.map_or_else(|| id.chars().take(8).collect(), |d| d.designation.clone())
195        }
196        Some(a) => a.to_string(),
197        None => to.chars().take(8).collect(),
198    }
199}
200
201/// One message as one screen line (without its age): `sender -> recipient: text` (msg.rs keeps the
202/// text on one line, its whitespace collapsed).
203pub fn message_line(drones: &[Drone], here: &str, m: &Message) -> String {
204    let ask = if m.asks() { " asks" } else { "" };
205    format!("{}{ask} -> {}: {}", sender(drones, here, m), recipient(drones, here, &m.to), m.text)
206}
207
208/// A drone's unread count as the screen shows it: `-` where this node cannot tell (a remote or ended one).
209pub fn unread_cell(inbox: &Inbox, d: &Drone) -> String {
210    inbox.unread.get(&d.p.session).map_or_else(|| "-".into(), usize::to_string)
211}
212
213/// Which drone `target` names: "local", "collective" or "all", a designation ("Two", "Two of Five", "2") or a session
214/// id (or a unique prefix of at least 4 characters). `Ok(None)` is every drone. Live first, then local:
215/// an id or prefix that matches a running drone names it over an ended one (which never reads a message),
216/// and among those one on this machine over one on another node; a remote drone is named only when
217/// none here matches as well (or by its number, which names one drone of all).
218pub fn resolve<'a>(drones: &'a [Drone], target: &str) -> Result<Option<&'a Drone>, String> {
219    let t = target.trim();
220    if [msg::ALL, msg::COLLECTIVE, msg::LOCAL].iter().any(|w| t.eq_ignore_ascii_case(w)) {
221        return Ok(None);
222    }
223    let first = t.split_whitespace().next().unwrap_or_default();
224    let number = first.parse::<usize>().ok().or_else(|| NUMBERS.iter().position(|w| w.eq_ignore_ascii_case(first)).map(|i| i + 1));
225    if let Some(n) = number {
226        return drones.iter().find(|d| d.number == n).map(Some).ok_or_else(|| format!("no drone {target}: there are {} (`borg top`)", drones.len()));
227    }
228    let mut hits: Vec<&Drone> = drones.iter().filter(|d| d.p.session == t || (t.len() >= 4 && d.p.session.starts_with(t))).collect();
229    if hits.iter().any(|d| d.state != State::Ended) {
230        hits.retain(|d| d.state != State::Ended);
231    }
232    if hits.iter().any(|d| d.node.is_empty()) {
233        hits.retain(|d| d.node.is_empty());
234    }
235    match hits.as_slice() {
236        [d] => Ok(Some(d)),
237        [] => Err(format!("no drone or session {target:?} (`borg top`)")),
238        _ => Err(format!("{target:?} names {} sessions: give more of the id", hits.len())),
239    }
240}
241
242fn ago(ms: i64) -> String {
243    let s = (ms / 1000).max(0);
244    match s {
245        0..60 => format!("{s}s"),
246        60..3600 => format!("{}m", s / 60),
247        _ => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
248    }
249}
250
251fn cut(s: &str, n: usize) -> String {
252    if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::<String>()) }
253}
254
255/// One collective's screen.
256pub fn render(name: &str, share: Share, drones: &[Drone], inbox: &Inbox, now: i64) -> String {
257    let working = drones.iter().filter(|d| matches!(d.state, State::Working | State::Waiting)).count();
258    let remote = drones.iter().filter(|d| !d.node.is_empty()).count();
259    let share = match share {
260        Share::Team => "team",
261        Share::Local => "local, never leaves this machine",
262    };
263    let mut s = format!("{name} ({share}): {} drone(s), {working} active, {remote} remote\n", drones.len());
264    if !drones.is_empty() {
265        s.push_str(&drone_table(drones, inbox, now));
266    }
267    if !inbox.recent.is_empty() {
268        s.push_str(&format!("  messages: {} in the last day, {} unread\n", inbox.day, inbox.unread.values().sum::<usize>()));
269        for m in &inbox.recent {
270            s.push_str(&format!("  {:>6}  {}\n", ago(now - m.ms), cut(&message_line(drones, &inbox.here, m), 120)));
271        }
272    }
273    s
274}
275
276fn drone_table(drones: &[Drone], inbox: &Inbox, now: i64) -> String {
277    let mut s = format!(
278        "  {:<18} {:<8} {:<24} {:<14} {:<8} {:<6} {:>6} {:>6}  {:<40} {}\n",
279        "DRONE", "SESSION", "ENGINEER", "REPO", "STATE", "WAKE", "IDLE", "UNREAD", "DOING", "FILES"
280    );
281    for d in drones {
282        let state = if d.paused { "paused" } else { d.state.name() };
283        let engineer = if d.node.is_empty() { d.p.engineer.clone() } else { format!("{} @{}", d.p.engineer, d.node.get(..6).unwrap_or(&d.node)) };
284        let line = format!(
285            "  {:<18} {:<8} {:<24} {:<14} {:<8} {:<6} {:>6} {:>6}  {:<40} {}",
286            d.designation,
287            cut(&d.p.session, 8),
288            cut(if engineer.is_empty() { "?" } else { &engineer }, 24),
289            cut(&d.p.repo, 14),
290            state,
291            wake_cell(inbox, d),
292            ago(now - d.p.ms),
293            unread_cell(inbox, d),
294            cut(&d.p.prompt, 40),
295            d.p.files.join(", ")
296        );
297        s.push_str(line.trim_end());
298        s.push('\n');
299    }
300    s
301}
302
303pub fn to_json(name: &str, share: Share, drones: &[Drone]) -> Value {
304    json!({"collective": name, "share": if share == Share::Team { "team" } else { "local" }, "drones": drones})
305}
306
307/// `to_json` with the messages side: each local drone's `unread`, and the collective's `messages`.
308pub fn to_json_with(name: &str, share: Share, drones: &[Drone], inbox: &Inbox) -> Value {
309    let mut v = to_json(name, share, drones);
310    if let Some(ds) = v["drones"].as_array_mut() {
311        for (o, d) in ds.iter_mut().zip(drones) {
312            if let Some(n) = inbox.unread.get(&d.p.session) {
313                o["unread"] = json!(n);
314            }
315            if let Some(l) = inbox.listen.get(&d.p.session) {
316                o["listen"] = json!(l);
317            }
318        }
319    }
320    v["messages"] = json!({"day": inbox.day, "recent": inbox.recent});
321    v
322}
323
324/// The collectives to show: the one `dir` routes to, or every one on this machine.
325fn routes(dir: Option<&str>) -> Result<Vec<Route>, String> {
326    let cfg = collective::load()?;
327    let base = learn::home();
328    Ok(match dir {
329        Some(d) => vec![collective::route(cfg.as_ref(), d, &base)].into_iter().filter(|r| *r != Route::Off).collect(),
330        None => install::collectives(cfg.as_ref(), &base),
331    })
332}
333
334/// Every collective to show with its drones (top_live.rs).
335pub fn gather(dir: Option<&str>, now: i64) -> Result<Vec<crate::top_live::Coll>, String> {
336    let mut out = Vec::new();
337    for r in routes(dir)? {
338        let Route::Collective { name, home, share } = &r else { continue };
339        presence::prune(home, now);
340        let mut ds = drones(home, collective::may_sync(&r), now);
341        for d in &mut ds {
342            d.p.engineer = collective::aliases().display(&d.p.engineer);
343        }
344        let inbox = inbox(home, &ds, now);
345        if ds.is_empty() && inbox.recent.is_empty() && dir.is_none() {
346            continue;
347        }
348        out.push(crate::top_live::Coll { name: name.clone(), share: *share, drones: ds, inbox });
349    }
350    Ok(out)
351}
352
353fn screen(dir: Option<&str>, json_out: bool, now: i64) -> Result<String, String> {
354    let mut text = String::new();
355    let mut all = Vec::new();
356    for r in routes(dir)? {
357        let Route::Collective { name, home, share } = &r else { continue };
358        presence::prune(home, now);
359        let mut ds = drones(home, collective::may_sync(&r), now);
360        // An engineer's several emails show as their name (collective.rs "Engineers"), peers' too.
361        for d in &mut ds {
362            d.p.engineer = collective::aliases().display(&d.p.engineer);
363        }
364        let inbox = inbox(home, &ds, now);
365        if json_out {
366            all.push(to_json_with(name, *share, &ds, &inbox));
367        } else if !ds.is_empty() || !inbox.recent.is_empty() || dir.is_some() {
368            text.push_str(&render(name, *share, &ds, &inbox, now));
369            text.push('\n');
370        }
371    }
372    if json_out {
373        return Ok(serde_json::to_string_pretty(&all).unwrap_or_default() + "\n");
374    }
375    if text.is_empty() {
376        text = "no drones (borg top lists sessions whose hooks ran in the last day)\n".into();
377    }
378    Ok(text)
379}
380
381pub fn main(args: &[&str]) -> i32 {
382    let (mut dir, mut once, mut json_out) = (None, false, false);
383    let mut it = args.iter();
384    while let Some(a) = it.next() {
385        match *a {
386            "--dir" if dir.is_none() => match it.next() {
387                Some(d) => dir = Some(*d),
388                None => return usage("--dir needs a value"),
389            },
390            "--once" => once = true,
391            "--json" => json_out = true,
392            a => return usage(&format!("unknown flag {a}")),
393        }
394    }
395    // SAFETY: isatty only inspects the descriptor.
396    if !json_out && !once && unsafe { libc::isatty(1) } == 1 {
397        return crate::top_live::run(dir);
398    }
399    match screen(dir, json_out, learn::now_ms()) {
400        Ok(s) => {
401            print!("{s}");
402            0
403        }
404        Err(e) => {
405            eprintln!("borg top: {e}");
406            1
407        }
408    }
409}
410
411fn usage(why: &str) -> i32 {
412    eprintln!("borg top: {why}\nusage: borg top [--once] [--json] [--dir DIR]");
413    2
414}
415
416#[cfg(test)]
417pub(crate) fn tests_fixture() -> Vec<Drone> {
418    tests::fixture()
419}
420
421#[cfg(test)]
422pub(crate) fn tests_inbox() -> Inbox {
423    tests::inbox_fixture()
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn p(session: &str, engineer: &str, started: i64, ms: i64, event: &str, prompt: &str, files: &[&str]) -> Presence {
431        Presence {
432            session: session.into(),
433            harness: "claude".into(),
434            engineer: engineer.into(),
435            repo: "borg-ai".into(),
436            collective: "corp".into(),
437            prompt: prompt.into(),
438            files: files.iter().map(|f| f.to_string()).collect(),
439            event: event.into(),
440            ms,
441            started,
442            pid: None,
443        }
444    }
445
446    const NOW: i64 = 10_000_000;
447
448    pub(crate) fn fixture() -> Vec<Drone> {
449        let local = vec![
450            p("aaaa1111-x", "alice@x", 100, NOW - 5_000, "post_tool", "fix the flaky sync test", &["crates/borg-mesh/src/sync.rs", "src/recon.rs"]),
451            p("cccc3333-x", "alice@x", 300, NOW - presence::IDLE_MS - 60_000, "stop", "write the release notes for the mesh daemon and more words", &[]),
452        ];
453        let remote = vec![presence::Remote {
454            p: p("bbbb2222-y", "bob@y", 200, NOW - 120_000, "stop", "review PR 42", &["README.md"]),
455            key: "beefcafe".repeat(8),
456            sig: String::new(),
457        }];
458        assemble(local, remote, NOW, |_| true, |p| p.session.starts_with("aaaa"))
459    }
460
461    fn message(by: &str, name: &str, to: &str, kind: msg::Kind, ms: i64, text: &str) -> Message {
462        message_on("n1", by, name, to, kind, ms, text)
463    }
464
465    fn message_on(node: &str, by: &str, name: &str, to: &str, kind: msg::Kind, ms: i64, text: &str) -> Message {
466        let from = msg::Sender { by: by.into(), name: name.into(), harness: "claude".into(), node: node.into() };
467        Message::new(kind, &from, to, None, ms, text)
468    }
469
470    pub(crate) fn inbox_fixture() -> Inbox {
471        Inbox {
472            unread: [("aaaa1111-x".to_string(), 2), ("cccc3333-x".to_string(), 0)].into_iter().collect(),
473            recent: vec![
474                message("cccc3333-x", "Three of Three", "aaaa1111-x", msg::ASK, NOW - 60_000, "which   test\nflakes?"),
475                message("engineer:alice@x", "alice@x", msg::ALL, msg::TELL, NOW - 120_000, "release at five"),
476                message_on(&"feed".repeat(16), "gone-session", "Six of Six", "zzzz9999-q", msg::TELL, NOW - 180_000, "done"),
477            ],
478            day: 3,
479            here: "n1".into(),
480            listen: [("aaaa1111-x".to_string(), crate::listen::Status::Waits), ("cccc3333-x".to_string(), crate::listen::Status::Listens)]
481                .into_iter()
482                .collect(),
483        }
484    }
485
486    #[test]
487    fn renders_one_screen_per_collective() {
488        let out = render("corp", Share::Team, &fixture(), &inbox_fixture(), NOW);
489        let expected = "\
490corp (team): 3 drone(s), 2 active, 1 remote
491  DRONE              SESSION  ENGINEER                 REPO           STATE    WAKE     IDLE UNREAD  DOING                                    FILES
492  One of Three       aaaa111… alice@x                  borg-ai        paused   waiter     5s      2  fix the flaky sync test                  crates/borg-mesh/src/sync.rs, src/recon.rs
493  Two of Three       bbbb222… bob@y @beefca            borg-ai        waiting  -          2m      -  review PR 42                             README.md
494  Three of Three     cccc333… alice@x                  borg-ai        idle     on        31m      0  write the release notes for the mesh da…
495  messages: 3 in the last day, 2 unread
496      1m  Three of Three asks -> One of Three: which test flakes?
497      2m  alice@x -> collective: release at five
498      3m  Six of Six @feedfeedfeed -> zzzz9999: done
499";
500        assert_eq!(out, expected, "\n{out}");
501        let none = Inbox::default();
502        assert_eq!(render("private", Share::Local, &[], &none, NOW), "private (local, never leaves this machine): 0 drone(s), 0 active, 0 remote\n");
503    }
504
505    /// The unread count is what the next prompt would show: a message shown once no longer counts,
506    /// broadcasts only to drones running when sent, never its own; no log costs no read.
507    #[test]
508    fn unread_counts_follow_the_shown_once_claims() {
509        let home = std::env::temp_dir().join(format!("borg-top-inbox-{}", std::process::id()));
510        let _ = std::fs::remove_dir_all(&home);
511        std::fs::create_dir_all(&home).unwrap();
512        let ds = fixture();
513        assert!(inbox(&home, &ds, NOW).recent.is_empty());
514        let here = msg::node(&home);
515        msg::send(&home, message_on(&here, "cccc3333-x", "Three of Three", "aaaa1111-x", msg::TELL, NOW - 3_000, "look at recon"), NOW).unwrap();
516        msg::send(&home, message_on(&here, "cccc3333-x", "Three of Three", msg::ALL, msg::TELL, NOW - 2_000, "lunch"), NOW).unwrap();
517        let i = inbox(&home, &ds, NOW);
518        assert_eq!(i.unread.get("aaaa1111-x"), Some(&2), "{i:?}");
519        assert_eq!(i.unread.get("cccc3333-x"), Some(&0), "its own broadcast");
520        assert!(!i.unread.contains_key(&ds[1].p.session), "remote: claimed on its node");
521        assert_eq!((i.day, i.recent[0].text.as_str()), (2, "lunch"));
522        let caps = crate::harness::by_name("claude").expect("a harness").caps();
523        let ev = json!({"session_id": "aaaa1111-x"});
524        assert!(msg::deliver(&home, caps, "prompt", &ev, Some(100), NOW, None).is_some());
525        assert_eq!(inbox(&home, &ds, NOW).unread.get("aaaa1111-x"), Some(&0));
526        let _ = std::fs::remove_dir_all(&home);
527    }
528
529    /// A line's `by` and `name` are its sender's claims: one from another node that names a local drone's
530    /// session is not shown as that drone, and carries its node's key.
531    #[test]
532    fn a_sender_is_tied_to_the_node_its_line_names() {
533        let ds = fixture();
534        let own = message("aaaa1111-x", "anything", msg::ALL, msg::TELL, NOW, "hi");
535        assert_eq!(sender(&ds, "n1", &own), "One of Three", "this node's drone");
536        let forged = message_on("evil0123456789", "aaaa1111-x", "One of Three", msg::ALL, msg::TELL, NOW, "hi");
537        assert_eq!(sender(&ds, "n1", &forged), "One of Three @evil01234567", "the claim, with the node it came from");
538        let remote = message_on(&"beefcafe".repeat(8), &ds[1].p.session, "x", msg::ALL, msg::TELL, NOW, "hi");
539        assert_eq!(sender(&ds, "n1", &remote), "Two of Three @beefcafebeef", "the remote drone on its own node");
540        assert_eq!(recipient(&ds, "n1", "aaaa1111-x@n1"), "One of Three");
541        assert_eq!(recipient(&ds, "n1", "aaaa1111-x@evil"), "aaaa1111", "not the local drone of that id");
542        assert_eq!((recipient(&ds, "n1", "engineer"), recipient(&ds, "n1", msg::ALL)), ("engineer".into(), "collective".into()), "an `all` line");
543        assert_eq!(recipient(&ds, "n1", "local@n1"), "local");
544        assert_eq!(recipient(&ds, "n1", "local@beefcafebeefcafe00"), "local @beefcafebeef");
545    }
546
547    #[test]
548    fn json_for_scripts() {
549        let v = to_json_with("corp", Share::Team, &fixture(), &inbox_fixture());
550        assert_eq!((v["drones"][0]["unread"].as_u64(), v["drones"][1].get("unread")), (Some(2), None));
551        assert_eq!((v["messages"]["day"].as_u64(), v["messages"]["recent"][0]["kind"].as_str()), (Some(3), Some("ask")));
552        assert_eq!(v["collective"], "corp");
553        let d = &v["drones"];
554        assert_eq!(d.as_array().unwrap().len(), 3);
555        assert_eq!(d[0]["designation"], "One of Three");
556        assert_eq!(d[0]["session"], "aaaa1111-x");
557        assert_eq!(d[0]["state"], "working");
558        assert_eq!(d[0]["paused"], true);
559        assert_eq!(d[1]["node"], "beefcafe".repeat(8));
560        assert_eq!(d[1]["state"], "waiting");
561        assert_eq!(d[2]["state"], "idle");
562        assert_eq!(d[2]["files"], json!([]));
563    }
564
565    /// Local first: a prefix or id that matches a drone here and one of a member names the one here.
566    #[test]
567    fn a_name_matching_here_and_on_a_member_names_the_drone_here() {
568        let local = vec![p("aaaa1111-x", "alice@x", 100, NOW - 5_000, "post_tool", "a", &[])];
569        let remote = ["aaaa1111-x", "aaaa2222-z"].map(|s| presence::Remote {
570            p: p(s, "bob@y", 50, NOW - 5_000, "post_tool", "b", &[]),
571            key: "beefcafe".repeat(8),
572            sig: String::new(),
573        });
574        let ds = assemble(local, remote.into(), NOW, |_| true, |_| false);
575        for t in ["aaaa", "aaaa1111", "aaaa1111-x"] {
576            let d = resolve(&ds, t).unwrap().unwrap();
577            assert!(d.node.is_empty() && d.p.session == "aaaa1111-x", "{t}");
578        }
579        assert_eq!(resolve(&ds, "aaaa2").unwrap().unwrap().node, "beefcafe".repeat(8), "only a member's matches");
580        assert!(!resolve(&ds, "One").unwrap().unwrap().node.is_empty(), "a number names one drone of all, wherever it runs");
581    }
582
583    /// An ended session here does not shadow a live one on a member's machine: it would never read the message.
584    #[test]
585    fn a_name_matching_an_ended_drone_here_and_a_live_member_names_the_live_one() {
586        let local = vec![p("aaaa1111-x", "alice@x", 100, NOW - 5_000, "end", "a", &[])];
587        let remote =
588            vec![presence::Remote { p: p("aaaa2222-z", "bob@y", 50, NOW - 5_000, "post_tool", "b", &[]), key: "beefcafe".repeat(8), sig: String::new() }];
589        let ds = assemble(local, remote, NOW, |_| true, |_| false);
590        let d = resolve(&ds, "aaaa").unwrap().unwrap();
591        assert_eq!((d.p.session.as_str(), d.node.as_str()), ("aaaa2222-z", "beefcafe".repeat(8).as_str()), "the live one, though remote");
592        assert!(resolve(&ds, "aaaa1111").unwrap().unwrap().node.is_empty(), "an ended drone is still named by its own id");
593    }
594
595    #[test]
596    fn resolves_drones_by_designation_or_session() {
597        let ds = fixture();
598        assert_eq!(resolve(&ds, "Two").unwrap().unwrap().p.session, "bbbb2222-y");
599        assert_eq!(resolve(&ds, "two of three").unwrap().unwrap().p.session, "bbbb2222-y");
600        assert_eq!(resolve(&ds, "3").unwrap().unwrap().p.session, "cccc3333-x");
601        assert_eq!(resolve(&ds, "aaaa").unwrap().unwrap().p.session, "aaaa1111-x");
602        assert!(resolve(&ds, "all").unwrap().is_none());
603        assert!(resolve(&ds, "Four").is_err());
604        assert!(resolve(&ds, "zzzz").is_err());
605        assert!(resolve(&ds, "aa").is_err(), "too short a prefix");
606        assert!(["collective", "local"].iter().all(|w| resolve(&ds, w).unwrap().is_none()));
607        assert_eq!(resolve(&ds, "bbbb").unwrap().unwrap().p.session, "bbbb2222-y", "nothing here matches: the member's");
608        assert_eq!(word(21), "21");
609    }
610}