1use 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 pub designation: String,
29 pub number: usize,
31 pub node: String,
33 pub state: State,
34 pub paused: bool,
36 #[serde(flatten)]
37 pub p: Presence,
38}
39
40#[derive(Debug, Clone, Default, Serialize)]
42pub struct Inbox {
43 pub unread: BTreeMap<String, usize>,
45 pub recent: Vec<Message>,
47 pub day: usize,
49 #[serde(skip)]
51 pub here: String,
52 pub listen: BTreeMap<String, crate::listen::Status>,
54}
55
56pub 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
88pub 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 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
111pub 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
117pub 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
134fn 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
143pub fn wake_cell(inbox: &Inbox, d: &Drone) -> &'static str {
146 inbox.listen.get(&d.p.session).map_or("-", |s| s.name())
147}
148
149pub 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
159fn 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
164pub 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
180pub 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
201pub 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
208pub fn unread_cell(inbox: &Inbox, d: &Drone) -> String {
210 inbox.unread.get(&d.p.session).map_or_else(|| "-".into(), usize::to_string)
211}
212
213pub 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
255pub 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
307pub 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
324fn 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
334pub 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 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 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 #[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 #[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 #[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 #[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}