1use std::collections::BTreeSet;
10use std::path::PathBuf;
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use anyhow::{bail, Context, Result};
14use serde_json::{json, Value};
15
16const PROMPT_CAP: usize = 8;
18
19pub(crate) const MAIL_PACKSET: &str = "0.13.0";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum Probe {
28 Refused,
30 Kept,
32 Store,
34}
35
36fn probe_of(text: &str) -> Probe {
41 if text.contains("unknown atom kind") {
42 Probe::Refused
43 } else if text.contains(": 400:") {
44 Probe::Kept
45 } else {
46 Probe::Store
47 }
48}
49
50fn unsupported_mail(version: Option<&str>) -> String {
51 match version {
52 Some(version) => format!(
53 "this pack does not keep mail. packset {version} refused kind message. Mail needs packset {MAIL_PACKSET}."
54 ),
55 None => format!(
56 "this pack does not keep mail. The writer refused kind message. Mail needs packset {MAIL_PACKSET}."
57 ),
58 }
59}
60
61#[derive(Debug, Clone)]
63pub struct Outgoing {
64 pub id: String,
65 pub from: String,
66 pub to: Vec<String>,
67 pub body: String,
68 pub interrupt: bool,
69 pub issue: Option<String>,
70 pub group: Option<String>,
71 pub reply: Option<String>,
72 pub scope: Option<String>,
73}
74
75pub struct Send<'a> {
77 pub seat: Option<&'a str>,
78 pub group: Option<&'a str>,
79 pub text: &'a str,
80 pub interrupt: bool,
81 pub issue: Option<&'a str>,
82 pub new_seat: bool,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Letter {
89 pub id: String,
90 pub from: String,
91 pub to: Vec<String>,
92 pub body: String,
93 pub interrupt: bool,
94 pub issue: Option<String>,
95 pub group: Option<String>,
96 pub reply: Option<String>,
97 pub scope: Option<String>,
98 pub read: bool,
99 ts: String,
100 seq: usize,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Receipt {
106 pub msg: String,
107 pub by: String,
108 pub at: String,
109 pub key: String,
110}
111
112fn mint_id(kind: &str, parts: &[&str]) -> String {
113 static N: AtomicU64 = AtomicU64::new(1);
114 let n = N.fetch_add(1, Ordering::Relaxed);
115 let nanos = std::time::SystemTime::now()
116 .duration_since(std::time::UNIX_EPOCH)
117 .map(|d| d.as_nanos())
118 .unwrap_or(0);
119 let raw = format!(
120 "{kind}\n{n}\n{nanos}\n{}\n{}",
121 std::process::id(),
122 parts.join("\n")
123 );
124 super::work_id(&raw)
125}
126
127fn check_name(kind: &str, name: &str) -> Result<String> {
128 let name = name.trim();
129 if name.is_empty()
130 || name
131 .chars()
132 .any(|c| c.is_whitespace() || c == ',' || c == ':')
133 {
134 bail!("{kind}: {name:?} is not a name");
135 }
136 Ok(name.to_string())
137}
138
139fn tag(atom: &Value, prefix: &str) -> Option<String> {
140 atom["entities"]
141 .as_array()?
142 .iter()
143 .filter_map(Value::as_str)
144 .find_map(|e| e.strip_prefix(prefix).map(str::to_string))
145}
146
147fn tags(atom: &Value, prefix: &str) -> Vec<String> {
148 atom["entities"]
149 .as_array()
150 .into_iter()
151 .flatten()
152 .filter_map(Value::as_str)
153 .filter_map(|e| e.strip_prefix(prefix).map(str::to_string))
154 .collect()
155}
156
157fn has(atom: &Value, entity: &str) -> bool {
158 atom["entities"]
159 .as_array()
160 .into_iter()
161 .flatten()
162 .any(|e| e.as_str() == Some(entity))
163}
164
165#[must_use]
168pub fn body_of(text: &str) -> String {
169 text.split_once("\n\n")
170 .map(|(_, body)| body.trim().to_string())
171 .unwrap_or_else(|| text.trim().to_string())
172}
173
174pub const ATOM_TEXT_CAP: usize = 500;
176
177pub const MAIL_BODY_CAP: usize = 16_000;
180
181fn check_length(verb: &str, body: &str) -> Result<()> {
184 let n = body.chars().count();
185 if n > MAIL_BODY_CAP {
186 bail!(
187 "{verb}: the message is {n} characters, past {MAIL_BODY_CAP}; write it to a file, \
188 `deedar create file --path FILE --agent SEAT`, and send the accession it prints. Nothing was sent"
189 );
190 }
191 Ok(())
192}
193
194#[must_use]
199pub fn message_text(m: &Outgoing) -> String {
200 let mut head = format!("msg {}\nfrom {}\nto {}", m.id, m.from, m.to.join(", "));
201 if m.interrupt {
202 head.push_str("\ninterrupt");
203 }
204 if let Some(issue) = &m.issue {
205 head.push_str(&format!("\nissue {issue}"));
206 }
207 if let Some(group) = &m.group {
208 head.push_str(&format!("\ngroup {group}"));
209 }
210 if let Some(reply) = &m.reply {
211 head.push_str(&format!("\nreply {reply}"));
212 }
213 let body = m.body.trim();
214 let room = ATOM_TEXT_CAP.saturating_sub(head.chars().count() + 2);
215 if body.chars().count() <= room {
216 return format!("{head}\n\n{body}");
217 }
218 let cut: String = body.chars().take(room.saturating_sub(1)).collect();
219 format!("{head}\n\n{cut}\u{2026}")
220}
221
222#[must_use]
224pub fn message_atom(m: &Outgoing, workspace: &str) -> Value {
225 let mut entities = vec![
226 format!("seat:{}", m.from),
227 format!("from:{}", m.from),
228 format!("msg:{}", m.id),
229 ];
230 for to in &m.to {
231 entities.push(format!("to:{to}"));
232 }
233 if m.interrupt {
234 entities.push("priority:interrupt".to_string());
235 }
236 if let Some(issue) = &m.issue {
237 entities.push(format!("issue:{issue}"));
238 }
239 if let Some(group) = &m.group {
240 entities.push(format!("group:{group}"));
241 }
242 if let Some(reply) = &m.reply {
243 entities.push(format!("reply:{reply}"));
244 }
245 if let Some(scope) = &m.scope {
246 entities.push(format!("scope:{scope}"));
247 }
248 let text = message_text(m);
249 let mut atom = json!({
250 "schema": "inside.atom/v1",
251 "kind": "message",
252 "level": "explicit",
253 "text": text,
254 "workspace": workspace,
255 "entities": entities,
256 });
257 if body_of(&text) != m.body.trim() {
258 atom["body"] = Value::String(m.body.trim().to_string());
259 }
260 atom
261}
262
263fn letter_of(atom: &Value, seq: usize) -> Option<Letter> {
264 if atom["kind"].as_str() != Some("message") {
265 return None;
266 }
267 let id = tag(atom, "msg:")?;
268 let from = tag(atom, "from:").or_else(|| tag(atom, "seat:"))?;
269 let to = tags(atom, "to:");
270 if to.is_empty() {
271 return None;
272 }
273 Some(Letter {
274 id,
275 from,
276 to,
277 body: atom["body"]
279 .as_str()
280 .map(|b| b.trim().to_string())
281 .unwrap_or_else(|| body_of(atom["text"].as_str().unwrap_or(""))),
282 interrupt: has(atom, "priority:interrupt"),
283 issue: tag(atom, "issue:"),
284 group: tag(atom, "group:"),
285 reply: tag(atom, "reply:"),
286 scope: tag(atom, "scope:"),
287 read: false,
288 ts: atom["ts"].as_str().unwrap_or("").to_string(),
289 seq,
290 })
291}
292
293fn readers_of(atoms: &[Value]) -> BTreeSet<(String, String)> {
294 let mut out = BTreeSet::new();
295 for atom in atoms {
296 if atom["kind"].as_str() != Some("receipt") {
297 continue;
298 }
299 let Some(msg) = tag(atom, "receipt:") else {
300 continue;
301 };
302 let Some(by) = tag(atom, "from:").or_else(|| tag(atom, "seat:")) else {
303 continue;
304 };
305 out.insert((msg, by));
306 }
307 out
308}
309
310#[must_use]
312pub fn inbox_letters(atoms: &[Value], seat: &str, all: bool) -> Vec<Letter> {
313 let read = readers_of(atoms);
314 let mut letters: Vec<Letter> = atoms
315 .iter()
316 .enumerate()
317 .filter_map(|(seq, atom)| letter_of(atom, seq))
318 .filter(|letter| letter.to.iter().any(|to| to == seat))
319 .map(|mut letter| {
320 letter.read = read.contains(&(letter.id.clone(), seat.to_string()));
321 letter
322 })
323 .filter(|letter| all || !letter.read)
324 .collect();
325 letters.sort_by(|a, b| {
326 b.interrupt
327 .cmp(&a.interrupt)
328 .then(a.ts.cmp(&b.ts))
329 .then(a.seq.cmp(&b.seq))
330 });
331 letters
332}
333
334#[must_use]
336pub fn receipts_for(atoms: &[Value], seat: &str) -> Vec<Receipt> {
337 let mut out = Vec::new();
338 for atom in atoms {
339 if atom["kind"].as_str() != Some("receipt") {
340 continue;
341 }
342 if tag(atom, "to:").as_deref() != Some(seat) {
343 continue;
344 }
345 let Some(msg) = tag(atom, "receipt:") else {
346 continue;
347 };
348 let Some(by) = tag(atom, "from:") else {
349 continue;
350 };
351 let text = atom["text"].as_str().unwrap_or("");
352 let at = text
353 .lines()
354 .find_map(|l| l.strip_prefix("at "))
355 .unwrap_or("")
356 .to_string();
357 out.push(Receipt {
358 key: super::work_id(text),
359 msg,
360 by,
361 at,
362 });
363 }
364 out.sort_by(|a, b| a.at.cmp(&b.at).then(a.key.cmp(&b.key)));
365 out
366}
367
368#[must_use]
370pub fn members(atoms: &[Value], group: &str) -> Vec<String> {
371 let mut events: Vec<(String, usize, String, bool)> = Vec::new();
372 for (seq, atom) in atoms.iter().enumerate() {
373 if atom["kind"].as_str() != Some("group") {
374 continue;
375 }
376 if tag(atom, "group:").as_deref() != Some(group) {
377 continue;
378 }
379 let ts = atom["ts"].as_str().unwrap_or("").to_string();
380 if let Some(seat) = tag(atom, "member:") {
381 events.push((ts, seq, seat, false));
382 } else if let Some(seat) = tag(atom, "drop:") {
383 events.push((ts, seq, seat, true));
384 }
385 }
386 events.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
387 let mut out: Vec<String> = Vec::new();
388 for (_, _, seat, drop) in events {
389 if drop {
390 out.retain(|s| s != &seat);
391 } else if !out.iter().any(|s| s == &seat) {
392 out.push(seat);
393 }
394 }
395 out
396}
397
398fn prompt_body(body: &str) -> String {
402 let n = body.chars().count();
403 let line = one_line(body, Some(160));
404 if n > 160 {
405 format!("{line} (all {n} characters: `ljos inbox --all`)")
406 } else {
407 line
408 }
409}
410
411fn one_line(body: &str, limit: Option<usize>) -> String {
412 let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
413 match limit {
414 Some(n) if flat.chars().count() > n => {
415 let t: String = flat.chars().take(n.saturating_sub(3)).collect();
416 format!("{t}...")
417 }
418 _ => flat,
419 }
420}
421
422fn flags(letter: &Letter) -> String {
423 let mut parts = Vec::new();
424 if letter.interrupt {
425 parts.push("interrupt".to_string());
426 }
427 if let Some(issue) = &letter.issue {
428 parts.push(format!("issue:{issue}"));
429 }
430 if let Some(group) = &letter.group {
431 parts.push(format!("group:{group}"));
432 }
433 if let Some(reply) = &letter.reply {
434 parts.push(format!("reply:{reply}"));
435 }
436 if letter.read {
437 parts.push("read".to_string());
438 }
439 parts.join(" ")
440}
441
442#[must_use]
444pub fn render_inbox(
445 atoms: &[Value],
446 seat: &str,
447 all: bool,
448 seen_receipts: &BTreeSet<String>,
449) -> (String, Vec<String>) {
450 let letters = inbox_letters(atoms, seat, all);
451 let receipts: Vec<Receipt> = receipts_for(atoms, seat)
452 .into_iter()
453 .filter(|r| !seen_receipts.contains(&r.key))
454 .collect();
455 if letters.is_empty() && receipts.is_empty() {
456 let line = if all {
457 "inbox: nothing\n"
458 } else {
459 "inbox: nothing unread\n"
460 };
461 return (line.to_string(), Vec::new());
462 }
463 let mut out = String::new();
464 for letter in &letters {
465 out.push_str(&format!(
466 "{}\t{}\t{}\t{}\n",
467 letter.id,
468 letter.from,
469 flags(letter),
470 one_line(&letter.body, None)
471 ));
472 }
473 let mut keys = Vec::new();
474 for receipt in &receipts {
475 out.push_str(&format!(
476 "receipt\t{}\t{}\t{}\n",
477 receipt.msg, receipt.by, receipt.at
478 ));
479 keys.push(receipt.key.clone());
480 }
481 (out, keys)
482}
483
484#[must_use]
489pub fn prompt_lines(letters: &[Letter], receipts: &[Receipt]) -> (String, Vec<String>) {
490 if letters.is_empty() && receipts.is_empty() {
491 return (String::new(), Vec::new());
492 }
493 let mut lines = vec!["mail:".to_string()];
494 let mut ids = Vec::new();
495 for letter in letters.iter().take(PROMPT_CAP) {
496 let mark = if letter.interrupt { "! " } else { "" };
497 let on = letter
498 .issue
499 .as_ref()
500 .map(|issue| format!(" on {issue}"))
501 .unwrap_or_default();
502 lines.push(format!(
503 "{mark}{} from {}{on}: {}",
504 letter.id,
505 letter.from,
506 prompt_body(&letter.body)
507 ));
508 ids.push(format!("mail:{}", letter.id));
509 }
510 let rest = letters.len().saturating_sub(PROMPT_CAP);
511 if rest > 0 {
512 lines.push(format!("{rest} more in ljos inbox"));
513 }
514 for receipt in receipts.iter().take(PROMPT_CAP) {
515 lines.push(format!("read {} by {}", receipt.msg, receipt.by));
516 ids.push(format!("rcpt:{}", receipt.key));
517 }
518 (lines.join("\n"), ids)
519}
520
521fn find_letter(atoms: &[Value], id: &str) -> Result<Letter> {
522 atoms
523 .iter()
524 .enumerate()
525 .filter_map(|(seq, atom)| letter_of(atom, seq))
526 .find(|letter| letter.id == id)
527 .ok_or_else(|| anyhow::anyhow!("no message {id}"))
528}
529
530#[must_use]
533pub fn known_seats(atoms: &[Value]) -> BTreeSet<String> {
534 let mut out = BTreeSet::new();
535 for atom in atoms {
536 for prefix in ["seat:", "from:", "member:"] {
537 out.extend(tags(atom, prefix));
538 }
539 if let Some(seat) = atom["source"]["harness"].as_str() {
540 out.insert(seat.to_string());
541 }
542 }
543 out.retain(|s| !s.trim().is_empty());
544 out
545}
546
547pub fn check_known(seat: &str, known: &BTreeSet<String>, new_seat: bool) -> Result<()> {
555 if new_seat || known.contains(seat) {
556 return Ok(());
557 }
558 bail!(
559 "send: no seat named {seat} has written to this pack, so the message would wait unread. \
560 {} Pass --new-seat to write to a seat that has not started yet",
561 seat_hint(seat, known)
562 )
563}
564
565const SEAT_HINT_MAX: usize = 4;
567
568#[must_use]
572pub fn named_seat(name: &str) -> bool {
573 let name = name.trim();
574 if name.is_empty() {
575 return false;
576 }
577 let rest = name.strip_prefix("sess-").unwrap_or(name);
578 let hex: Vec<char> = rest.chars().filter(|c| *c != '-').collect();
579 let conversation_id = hex.len() >= 16 && hex.iter().all(char::is_ascii_hexdigit);
580 !conversation_id
581}
582
583#[must_use]
587pub fn seat_hint(seat: &str, known: &BTreeSet<String>) -> String {
588 let named: BTreeSet<String> = known.iter().filter(|s| named_seat(s)).cloned().collect();
589 let guess = closest_seat(seat, &named).map(str::to_string);
590 let others: Vec<&String> = named
591 .iter()
592 .filter(|s| guess.as_deref() != Some(s.as_str()))
593 .collect();
594 let mut shown = others
595 .iter()
596 .take(SEAT_HINT_MAX)
597 .map(|s| s.as_str())
598 .collect::<Vec<_>>()
599 .join(", ");
600 if others.len() > SEAT_HINT_MAX {
601 shown.push_str(&format!(" and {} more", others.len() - SEAT_HINT_MAX));
602 }
603 match (guess, shown.is_empty()) {
604 (Some(g), true) => format!("Did you mean {g}?"),
605 (Some(g), false) => format!("Did you mean {g}? Other seats: {shown}."),
606 (None, false) => format!("Seats here: {shown}."),
607 (None, true) => "No named seat has started here yet.".to_string(),
608 }
609}
610
611#[must_use]
615pub fn closest_seat<'a>(seat: &str, known: &'a BTreeSet<String>) -> Option<&'a str> {
616 let lower = seat.to_lowercase();
617 if lower.is_empty() {
618 return None;
619 }
620 let holds = known
621 .iter()
622 .filter(|k| k.to_lowercase().contains(&lower) || lower.contains(&k.to_lowercase()))
623 .min_by_key(|k| (k.len().abs_diff(seat.len()), k.as_str()));
624 if let Some(k) = holds {
625 return Some(k.as_str());
626 }
627 known
628 .iter()
629 .map(|k| (edits(&lower, &k.to_lowercase()), k))
630 .filter(|(d, _)| *d <= 2)
631 .min()
632 .map(|(_, k)| k.as_str())
633}
634
635fn edits(a: &str, b: &str) -> usize {
637 let b: Vec<char> = b.chars().collect();
638 let mut row: Vec<usize> = (0..=b.len()).collect();
639 for (i, ca) in a.chars().enumerate() {
640 let mut prev = row[0];
641 row[0] = i + 1;
642 for (j, cb) in b.iter().enumerate() {
643 let cur = row[j + 1];
644 row[j + 1] = if ca == *cb {
645 prev
646 } else {
647 1 + prev.min(cur).min(row[j])
648 };
649 prev = cur;
650 }
651 }
652 row[b.len()]
653}
654
655pub fn recipients(
657 from: &str,
658 seat: Option<&str>,
659 group: Option<&str>,
660 members: &[String],
661) -> Result<Vec<String>> {
662 if let Some(group) = group {
663 if seat.is_some() {
664 bail!("send: name a seat or a group");
665 }
666 let to: Vec<String> = members
667 .iter()
668 .filter(|m| m.as_str() != from)
669 .cloned()
670 .collect();
671 if to.is_empty() {
672 bail!("send: {group} has no other member");
673 }
674 return Ok(to);
675 }
676 let Some(seat) = seat.map(str::trim).filter(|s| !s.is_empty()) else {
677 bail!("send: name a seat or a group");
678 };
679 let seat = check_name("send", seat)?;
680 if seat == from {
681 bail!("send: {seat} is this seat");
682 }
683 Ok(vec![seat])
684}
685
686fn is_mail_atom(atom: &Value) -> bool {
687 matches!(atom["kind"].as_str(), Some("message" | "receipt" | "group"))
688}
689
690fn writer_version(client: &crate::PacksetClient) -> Option<String> {
691 client
692 .status(Some(&client.workspace()))
693 .ok()
694 .and_then(|status| {
695 status
696 .get("version")
697 .and_then(Value::as_str)
698 .filter(|version| !version.is_empty())
699 .map(str::to_string)
700 })
701}
702
703fn ensure_mail(client: &crate::PacksetClient, verb: &str) -> Result<()> {
710 if let Some(version) = writer_version(client) {
713 match super::cmp_semver(&version, MAIL_PACKSET) {
714 Some(std::cmp::Ordering::Less) => {
715 bail!("{verb}: {}", unsupported_mail(Some(&version)));
716 }
717 Some(_) => return Ok(()),
718 None => {}
719 }
720 }
721 let probe = json!({
722 "schema": "inside.atom/v1",
723 "kind": "message",
724 "level": "nope",
725 "text": "probe",
726 "workspace": workspace_of(Some(&client.workspace())),
727 });
728 let posted = super::with_writer(|| client.post_atom(&probe).map_err(anyhow::Error::from));
729 match posted {
730 Ok(_) => Ok(()),
731 Err(err) => match probe_of(&format!("{err:#}")) {
732 Probe::Kept => Ok(()),
733 Probe::Refused => {
734 let version = writer_version(client);
735 bail!("{verb}: {}", unsupported_mail(version.as_deref()));
736 }
737 Probe::Store => Err(err).context("mail: the pack did not answer"),
738 },
739 }
740}
741
742fn listed() -> Result<Vec<Value>> {
743 let _clock = super::arm_cmd_pack();
744 let client = super::pack()?;
745 let workspace = client.workspace();
746 match super::atoms_mail(&client, &workspace) {
747 Err(err) if super::is_pack_busy(&err) => Err(super::pack_busy_err()),
748 Err(err) => Err(err).context("mail: the pack did not answer"),
749 Ok(atoms) => Ok(atoms),
750 }
751}
752
753fn load(verb: &str) -> Result<Vec<Value>> {
754 let _clock = super::arm_cmd_pack();
755 let client = super::pack()?;
756 let atoms = listed()?;
757 if !atoms.iter().any(is_mail_atom) {
758 ensure_mail(&client, verb)?;
759 }
760 Ok(atoms)
761}
762
763fn load_quiet() -> Option<Vec<Value>> {
764 listed().ok()
765}
766
767fn post(atom: &Value, verb: &str) -> Result<Value> {
768 let _clock = super::arm_cmd_pack();
769 let client = super::pack()?;
770 super::with_writer(|| {
771 client
772 .post_atom(atom)
773 .with_context(|| format!("{verb}: POST /v1/atoms failed"))
774 })
775 .map_err(|err| {
776 if super::is_pack_busy(&err) {
777 super::pack_busy_err()
778 } else {
779 err
780 }
781 })
782}
783
784fn workspace_of(client_workspace: Option<&str>) -> String {
785 client_workspace.unwrap_or("seat").to_string()
786}
787
788fn stamp_source(atom: &mut Value) {
789 atom["source"] = super::atom_source();
790}
791
792pub fn send(args: &Send<'_>) -> Result<String> {
794 let _clock = super::arm_cmd_pack();
795 let text = args.text.trim();
796 if text.is_empty() {
797 bail!("send: empty text");
798 }
799 check_length("send", text)?;
800 let from = super::seat_name();
801 let group = args
802 .group
803 .map(|name| check_name("send", name))
804 .transpose()?;
805 let client = super::pack()?;
806 let atoms = if group.is_some() {
809 ensure_mail(&client, "send")?;
810 listed()?
811 } else {
812 Vec::new()
813 };
814 let roster = group
815 .as_deref()
816 .map(|name| members(&atoms, name))
817 .unwrap_or_default();
818 let to = recipients(from.as_str(), args.seat, group.as_deref(), &roster)?;
819 if group.is_none() {
820 ensure_mail(&client, "send")?;
821 if !args.new_seat {
822 let known = known_seats(&seats_heard()?);
823 for seat in &to {
824 check_known(seat, &known, false)?;
825 }
826 }
827 }
828 let issue = args
829 .issue
830 .map(str::trim)
831 .filter(|s| !s.is_empty())
832 .map(|s| check_name("send", s))
833 .transpose()?;
834 let scope = issue.as_deref().and_then(super::sync::scope_for_issue);
835 let outgoing = Outgoing {
836 id: mint_id("msg", &[&from, text]),
837 from,
838 to: to.clone(),
839 body: text.to_string(),
840 interrupt: args.interrupt,
841 issue,
842 group,
843 reply: None,
844 scope,
845 };
846 let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
847 stamp_source(&mut atom);
848 post(&atom, "send")?;
849 let mut said = format!("sent {} to {}\n", outgoing.id, to.join(", "));
850 said.push_str(&wake_after(&outgoing));
851 Ok(said)
852}
853
854fn wake_after(m: &Outgoing) -> String {
858 let events: Vec<super::wake::Event> =
859 m.to.iter()
860 .map(|seat| super::wake::Event {
861 seat: seat.clone(),
862 from: m.from.clone(),
863 msg: m.id.clone(),
864 issue: m.issue.clone(),
865 interrupt: m.interrupt,
866 commit: None,
868 })
869 .collect();
870 let woken = super::wake::spawn(&events);
871 if woken.is_empty() {
872 String::new()
873 } else {
874 format!("waking {}\n", woken.join(", "))
875 }
876}
877
878pub fn inbox(all: bool) -> Result<String> {
888 inbox_sync(all, None)
889}
890
891pub fn inbox_sync(all: bool, until: Option<&str>) -> Result<String> {
905 if let Some(until) = until {
906 let named = commit_named(until);
907 let note = super::sync::fetch_for_inbox(named.as_deref())?;
908 if !note.trim().is_empty() {
909 eprint!("{note}");
910 }
911 }
912 let atoms = {
917 let _clock = super::arm_cmd_pack();
918 load("inbox")?
919 };
920 let seat = super::seat_name();
921 let seen = receipt_seen();
922 let (mut text, keys) = render_inbox(&atoms, &seat, all, &seen);
923 text.push_str(&super::quiet_claims::beside_inbox(&seat));
924 remember_receipts(&keys);
925 if let Err(err) = mark_shown(&atoms, &seat) {
928 if !super::is_pack_busy(&err) {
929 return Err(err);
930 }
931 }
932 Ok(text)
933}
934
935fn mark_shown(atoms: &[Value], seat: &str) -> Result<()> {
938 let letters = inbox_letters(atoms, seat, false);
939 if letters.is_empty() {
940 return Ok(());
941 }
942 let _clock = super::arm_cmd_pack();
945 let client = super::pack()?;
946 let workspace = workspace_of(Some(&client.workspace()));
947 let at = super::now_utc();
948 for letter in &letters {
949 let mut atom = receipt_atom(letter, seat, &at, &workspace);
950 stamp_source(&mut atom);
951 post(&atom, "inbox")?;
952 }
953 Ok(())
954}
955
956fn seats_heard() -> Result<Vec<Value>> {
962 let client = super::pack()?;
963 let workspace = client.workspace();
964 match super::atoms_lean(&client, &workspace) {
965 Err(err) if super::is_pack_busy(&err) => Err(super::pack_busy_err()),
966 other => other.context("mail: the pack did not answer"),
967 }
968}
969
970fn commit_named(until: &str) -> Option<String> {
973 let named = until.trim();
974 if !named.is_empty() {
975 return Some(named.to_string());
976 }
977 std::env::var("LJOS_WAKE_COMMIT")
978 .ok()
979 .map(|value| value.trim().to_string())
980 .filter(|value| !value.is_empty())
981}
982
983fn already_read(atoms: &[Value], msg: &str, seat: &str) -> bool {
984 readers_of(atoms).contains(&(msg.to_string(), seat.to_string()))
985}
986
987fn receipt_atom(msg: &Letter, reader: &str, at: &str, workspace: &str) -> Value {
988 let id = mint_id("receipt", &[msg.id.as_str(), reader, at]);
989 let text = format!("read {}\nby {reader}\nat {at}\nmsg {id}", msg.id);
990 let mut entities = vec![
991 format!("seat:{reader}"),
992 format!("from:{reader}"),
993 format!("to:{}", msg.from),
994 format!("msg:{id}"),
995 format!("receipt:{}", msg.id),
996 ];
997 if let Some(scope) = &msg.scope {
998 entities.push(format!("scope:{scope}"));
999 }
1000 json!({
1001 "schema": "inside.atom/v1",
1002 "kind": "receipt",
1003 "level": "explicit",
1004 "text": text,
1005 "workspace": workspace,
1006 "entities": entities,
1007 })
1008}
1009
1010pub fn read(id: &str) -> Result<String> {
1013 let _clock = super::arm_cmd_pack();
1014 let id = id.trim();
1015 if id.is_empty() {
1016 bail!("read: name a message");
1017 }
1018 let atoms = load("read")?;
1019 let seat = super::seat_name();
1020 let letter = find_letter(&atoms, id).context("read")?;
1021 if !letter.to.iter().any(|to| to == &seat) {
1022 bail!("read: {id} is not for {seat}");
1023 }
1024 if already_read(&atoms, id, &seat) {
1025 return Ok(format!("read {id} already\n"));
1026 }
1027 let client = super::pack()?;
1028 let mut atom = receipt_atom(
1029 &letter,
1030 &seat,
1031 &super::now_utc(),
1032 &workspace_of(Some(&client.workspace())),
1033 );
1034 stamp_source(&mut atom);
1035 post(&atom, "read")?;
1036 Ok(format!("read {id}\n"))
1037}
1038
1039fn read_ids(ids: &[String]) {
1040 let Some(atoms) = load_quiet() else {
1041 return;
1042 };
1043 let seat = super::seat_name();
1044 let Ok(client) = super::pack() else {
1045 return;
1046 };
1047 let workspace = client.workspace();
1048 let at = super::now_utc();
1049 for id in ids {
1050 let Ok(letter) = find_letter(&atoms, id) else {
1051 continue;
1052 };
1053 if !letter.to.iter().any(|to| to == &seat) || already_read(&atoms, id, &seat) {
1054 continue;
1055 }
1056 let mut atom = receipt_atom(&letter, &seat, &at, &workspace_of(Some(&workspace)));
1057 stamp_source(&mut atom);
1058 let _ = post(&atom, "read");
1059 }
1060}
1061
1062pub fn reply(id: &str, text: &str, interrupt: bool) -> Result<String> {
1064 let _clock = super::arm_cmd_pack();
1065 let id = id.trim();
1066 let text = text.trim();
1067 if id.is_empty() {
1068 bail!("reply: name a message");
1069 }
1070 if text.is_empty() {
1071 bail!("reply: empty text");
1072 }
1073 check_length("reply", text)?;
1074 let atoms = load("reply")?;
1075 let parent = find_letter(&atoms, id).context("reply")?;
1076 let from = super::seat_name();
1077 if parent.from == from {
1078 bail!("reply: {id} is from this seat");
1079 }
1080 let issue = parent.issue.clone();
1081 let scope = issue
1082 .as_deref()
1083 .and_then(super::sync::scope_for_issue)
1084 .or(parent.scope);
1085 let outgoing = Outgoing {
1086 id: mint_id("msg", &[&from, id, text]),
1087 from,
1088 to: vec![parent.from.clone()],
1089 body: text.to_string(),
1090 interrupt,
1091 issue,
1092 group: None,
1093 reply: Some(parent.id.clone()),
1094 scope,
1095 };
1096 let client = super::pack()?;
1097 let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
1098 stamp_source(&mut atom);
1099 post(&atom, "reply")?;
1100 let mut line = format!(
1101 "sent {} to {}\nreply {}\n",
1102 outgoing.id, parent.from, parent.id
1103 );
1104 if let Some(issue) = &outgoing.issue {
1105 line.push_str(&format!("issue {issue}\n"));
1106 }
1107 line.push_str(&wake_after(&outgoing));
1108 Ok(line)
1109}
1110
1111fn group_atom(name: &str, seat: &str, drop: bool, by: &str, workspace: &str) -> Value {
1112 let at = super::now_utc();
1113 let id = mint_id("group", &[name, seat, by, &at]);
1114 let verb = if drop { "drop" } else { "member" };
1115 let text = format!("group {id}\nname {name}\n{verb} {seat}\nby {by}\nat {at}");
1116 let mut entities = vec![
1117 format!("seat:{by}"),
1118 format!("group:{name}"),
1119 format!("msg:{id}"),
1120 ];
1121 if drop {
1122 entities.push(format!("drop:{seat}"));
1123 } else {
1124 entities.push(format!("member:{seat}"));
1125 }
1126 json!({
1127 "schema": "inside.atom/v1",
1128 "kind": "group",
1129 "level": "explicit",
1130 "text": text,
1131 "workspace": workspace,
1132 "entities": entities,
1133 })
1134}
1135
1136pub fn group(name: &str, add: &[String], remove: &[String]) -> Result<String> {
1138 let _clock = super::arm_cmd_pack();
1139 let name = check_name("group", name)?;
1140 if add.is_empty() && remove.is_empty() {
1141 let atoms = load("group")?;
1142 let seats = members(&atoms, &name);
1143 if seats.is_empty() {
1144 return Ok(format!("{name}\t(no members)\n"));
1145 }
1146 let mut out = String::new();
1147 for seat in seats {
1148 out.push_str(&format!("{name}\t{seat}\n"));
1149 }
1150 return Ok(out);
1151 }
1152 let atoms = load("group")?;
1153 let by = super::seat_name();
1154 let client = super::pack()?;
1155 let workspace = client.workspace();
1156 let mut roster = members(&atoms, &name);
1157 let mut out = String::new();
1158 for seat in add {
1159 let seat = check_name("group", seat)?;
1160 if roster.iter().any(|s| s == &seat) {
1161 out.push_str(&format!("{seat} is already in {name}\n"));
1162 continue;
1163 }
1164 let mut atom = group_atom(&name, &seat, false, &by, &workspace_of(Some(&workspace)));
1165 stamp_source(&mut atom);
1166 post(&atom, "group")?;
1167 roster.push(seat.clone());
1168 out.push_str(&format!("added {seat} to {name}\n"));
1169 }
1170 for seat in remove {
1171 let seat = check_name("group", seat)?;
1172 if !roster.iter().any(|s| s == &seat) {
1173 out.push_str(&format!("{seat} is not in {name}\n"));
1174 continue;
1175 }
1176 let mut atom = group_atom(&name, &seat, true, &by, &workspace_of(Some(&workspace)));
1177 stamp_source(&mut atom);
1178 post(&atom, "group")?;
1179 roster.retain(|s| s != &seat);
1180 out.push_str(&format!("removed {seat} from {name}\n"));
1181 }
1182 Ok(out)
1183}
1184
1185fn state_dir() -> PathBuf {
1186 std::env::var_os("XDG_STATE_HOME")
1187 .map(PathBuf::from)
1188 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
1189 .unwrap_or_else(|| PathBuf::from(".local/state"))
1190 .join("ljos")
1191 .join("mail")
1192}
1193
1194fn receipt_seen_path() -> PathBuf {
1195 state_dir().join("receipts-seen")
1196}
1197
1198fn receipt_seen() -> BTreeSet<String> {
1199 std::fs::read_to_string(receipt_seen_path())
1200 .map(|text| {
1201 text.lines()
1202 .map(str::trim)
1203 .filter(|l| !l.is_empty())
1204 .map(str::to_string)
1205 .collect()
1206 })
1207 .unwrap_or_default()
1208}
1209
1210fn remember_receipts(keys: &[String]) {
1211 if keys.is_empty() {
1212 return;
1213 }
1214 let path = receipt_seen_path();
1215 if let Some(dir) = path.parent() {
1216 let _ = std::fs::create_dir_all(dir);
1217 }
1218 let mut text = std::fs::read_to_string(&path).unwrap_or_default();
1219 for key in keys {
1220 if text.lines().any(|l| l == key) {
1221 continue;
1222 }
1223 text.push_str(key);
1224 text.push('\n');
1225 }
1226 let _ = std::fs::write(path, text);
1227}
1228
1229pub const MAIL_UNCHECKED: &str = "mail could not be checked";
1233
1234#[must_use]
1238pub fn prompt_note() -> (String, Vec<String>) {
1239 let atoms = match listed() {
1242 Ok(atoms) => atoms,
1243 Err(err) if super::is_pack_busy(&err) => return (String::new(), Vec::new()),
1244 Err(_) => return (MAIL_UNCHECKED.to_string(), Vec::new()),
1245 };
1246 let seat = super::seat_name();
1247 let letters = inbox_letters(&atoms, &seat, false);
1248 let seen = receipt_seen();
1249 let receipts: Vec<Receipt> = receipts_for(&atoms, &seat)
1250 .into_iter()
1251 .filter(|r| !seen.contains(&r.key))
1252 .collect();
1253 prompt_lines(&letters, &receipts)
1254}
1255
1256pub const STOP_MAIL_HEAD: &str =
1258 "Mail marked interrupt reached this seat while you worked. Read it before you stop:";
1259
1260#[must_use]
1267pub fn stop_note() -> (String, Vec<String>) {
1268 let Ok(atoms) = listed() else {
1269 return (String::new(), Vec::new());
1270 };
1271 stop_lines(&atoms, &super::seat_name())
1272}
1273
1274#[must_use]
1276pub fn stop_lines(atoms: &[Value], seat: &str) -> (String, Vec<String>) {
1277 let hot: Vec<Letter> = inbox_letters(atoms, seat, false)
1278 .into_iter()
1279 .filter(|l| l.interrupt)
1280 .collect();
1281 let (lines, ids) = prompt_lines(&hot, &[]);
1282 if lines.is_empty() {
1283 return (String::new(), Vec::new());
1284 }
1285 let reply = "Answer with `ljos reply ID TEXT`.";
1286 (format!("{STOP_MAIL_HEAD}\n{lines}\n{reply}"), ids)
1287}
1288
1289pub fn ack_delivered(ids: &[String]) {
1293 let mut reads = Vec::new();
1294 let mut seen = Vec::new();
1295 for id in ids {
1296 if let Some(msg) = id.strip_prefix("mail:") {
1297 reads.push(msg.to_string());
1298 } else if let Some(key) = id.strip_prefix("rcpt:") {
1299 seen.push(key.to_string());
1300 }
1301 }
1302 if !reads.is_empty() {
1303 read_ids(&reads);
1304 }
1305 remember_receipts(&seen);
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use super::*;
1311
1312 fn outgoing(id: &str, from: &str, to: &[&str], body: &str) -> Outgoing {
1313 Outgoing {
1314 id: id.to_string(),
1315 from: from.to_string(),
1316 to: to.iter().map(|s| (*s).to_string()).collect(),
1317 body: body.to_string(),
1318 interrupt: false,
1319 issue: None,
1320 group: None,
1321 reply: None,
1322 scope: None,
1323 }
1324 }
1325
1326 #[test]
1327 fn a_long_body_fits_the_pack_and_reads_back_whole() {
1328 let long = "the aarch64 log says the Highway test times out. ".repeat(40);
1329 let m = outgoing("msg-1", "saddle", &["inky"], &long);
1330 let atom = message_atom(&m, "seat");
1331 let text = atom["text"].as_str().unwrap();
1332 assert!(
1333 text.chars().count() <= ATOM_TEXT_CAP,
1334 "{}",
1335 text.chars().count()
1336 );
1337 assert!(text.ends_with('\u{2026}'), "{text}");
1338 assert!(
1339 text.starts_with("msg msg-1\nfrom saddle\nto inky\n\n"),
1340 "{text}"
1341 );
1342 assert_eq!(atom["body"].as_str(), Some(long.trim()));
1343 let letter = letter_of(&atom, 0).expect("a letter");
1344 assert_eq!(letter.body, long.trim());
1345 let short = message_atom(&outgoing("msg-2", "saddle", &["inky"], "ok"), "seat");
1347 assert!(short.get("body").is_none());
1348 assert_eq!(letter_of(&short, 0).unwrap().body, "ok");
1349 let err = check_length("send", &"x".repeat(MAIL_BODY_CAP + 1))
1351 .unwrap_err()
1352 .to_string();
1353 assert!(err.contains("deedar create file"), "{err}");
1354 assert!(check_length("send", &"x".repeat(MAIL_BODY_CAP)).is_ok());
1355 let shown = prompt_body(&long);
1356 assert!(shown.contains("`ljos inbox --all`"), "{shown}");
1357 assert_eq!(prompt_body("ok"), "ok");
1358 }
1359
1360 fn atom(m: &Outgoing, ts: &str) -> Value {
1361 let mut v = message_atom(m, "seat");
1362 v["ts"] = json!(ts);
1363 v
1364 }
1365
1366 #[test]
1367 fn the_same_words_stay_two_messages() {
1368 let a = outgoing("a", "alice", &["bob"], "ok");
1369 let b = outgoing("b", "carol", &["bob"], "ok");
1370 let left = message_text(&a);
1371 let right = message_text(&b);
1372 assert_ne!(left, right);
1373 assert_ne!(super::super::work_id(&left), super::super::work_id(&right));
1374 assert_eq!(body_of(&left), "ok");
1375 let atoms = [
1376 atom(&a, "2026-10-09T00:00:00Z"),
1377 atom(&b, "2026-10-09T00:00:01Z"),
1378 ];
1379 let plain = super::super::sync::log_plaintext(&atoms, "seat", "seat");
1380 assert_eq!(plain.lines().count(), 2, "{plain}");
1381 let got = super::super::sync::incoming(&plain, "other", &BTreeSet::new(), &BTreeSet::new());
1382 assert_eq!(got.post.len(), 2);
1383 }
1384
1385 #[test]
1386 fn inbox_is_the_seat_named_and_an_interrupt_leads() {
1387 let mut late = outgoing("late", "alice", &["bob"], "when you can");
1388 late.issue = Some("acme-4".into());
1389 let mut hot = outgoing("hot", "carol", &["bob"], "the build is red");
1390 hot.interrupt = true;
1391 hot.issue = Some("acme-4".into());
1392 let other = outgoing("other", "alice", &["dave"], "not for bob");
1393 let atoms = [
1394 atom(&late, "2026-10-09T00:00:00Z"),
1395 atom(&hot, "2026-10-09T00:00:02Z"),
1396 atom(&other, "2026-10-09T00:00:01Z"),
1397 ];
1398 let letters = inbox_letters(&atoms, "bob", false);
1399 assert_eq!(
1400 letters.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(),
1401 ["hot", "late"]
1402 );
1403 assert!(letters[0].interrupt);
1404 assert_eq!(letters[1].issue.as_deref(), Some("acme-4"));
1405 let (text, ids) = prompt_lines(&letters, &[]);
1406 assert!(
1407 text.starts_with("mail:\n! hot from carol on acme-4:"),
1408 "{text}"
1409 );
1410 assert!(ids.contains(&"mail:hot".to_string()), "{ids:?}");
1411 assert!(text.contains("late from alice on acme-4:"));
1412 }
1413
1414 #[test]
1415 fn a_receipt_takes_the_message_out_of_the_unread_list() {
1416 let msg = outgoing("m1", "alice", &["bob"], "look at the log");
1417 let message = atom(&msg, "2026-10-09T00:00:00Z");
1418 let receipt = json!({
1419 "kind": "receipt",
1420 "text": "read m1\nby bob\nat 2026-10-09T00:01:00Z\nmsg r1",
1421 "ts": "2026-10-09T00:01:00Z",
1422 "entities": ["seat:bob", "from:bob", "to:alice", "receipt:m1", "msg:r1"],
1423 });
1424 let held = [message, receipt];
1425 let unread = inbox_letters(&held, "bob", false);
1426 assert!(unread.is_empty());
1427 let all = inbox_letters(&held, "bob", true);
1428 assert!(all[0].read);
1429 let (text, keys) = render_inbox(&held, "alice", false, &BTreeSet::new());
1430 assert!(
1431 text.contains("receipt\tm1\tbob\t2026-10-09T00:01:00Z"),
1432 "{text}"
1433 );
1434 assert_eq!(keys.len(), 1);
1435 let (again, _) = render_inbox(&held, "alice", false, &keys.into_iter().collect());
1436 assert_eq!(again, "inbox: nothing unread\n");
1437 }
1438
1439 #[test]
1440 fn a_reply_keeps_the_issue_and_names_the_sender() {
1441 let mut parent = outgoing("p", "alice", &["bob"], "the build is red");
1442 parent.issue = Some("acme-4".into());
1443 parent.scope = Some("tools".into());
1444 let letter = letter_of(&atom(&parent, "2026-10-09T00:00:00Z"), 0).unwrap();
1445 let reply = Outgoing {
1446 id: "r".into(),
1447 from: "bob".into(),
1448 to: vec![letter.from.clone()],
1449 body: "looking".into(),
1450 interrupt: false,
1451 issue: letter.issue.clone(),
1452 group: None,
1453 reply: Some(letter.id.clone()),
1454 scope: letter.scope.clone(),
1455 };
1456 let atom = message_atom(&reply, "seat");
1457 let back = letter_of(&atom, 1).unwrap();
1458 assert_eq!(back.to, ["alice"]);
1459 assert_eq!(back.reply.as_deref(), Some("p"));
1460 assert_eq!(back.issue.as_deref(), Some("acme-4"));
1461 assert_eq!(back.scope.as_deref(), Some("tools"));
1462 assert!(super::super::sync::is_own(&atom));
1463 }
1464
1465 #[test]
1466 fn a_group_send_reaches_the_other_members() {
1467 let atoms = [
1468 json!({
1469 "kind": "group",
1470 "ts": "2026-10-09T00:00:00Z",
1471 "entities": ["group:ops", "member:alice"],
1472 }),
1473 json!({
1474 "kind": "group",
1475 "ts": "2026-10-09T00:00:01Z",
1476 "entities": ["group:ops", "member:bob"],
1477 }),
1478 json!({
1479 "kind": "group",
1480 "ts": "2026-10-09T00:00:02Z",
1481 "entities": ["group:ops", "member:carol"],
1482 }),
1483 json!({
1484 "kind": "group",
1485 "ts": "2026-10-09T00:00:03Z",
1486 "entities": ["group:ops", "drop:carol"],
1487 }),
1488 ];
1489 assert_eq!(members(&atoms, "ops"), ["alice", "bob"]);
1490 let to = recipients("alice", None, Some("ops"), &members(&atoms, "ops")).unwrap();
1491 assert_eq!(to, ["bob"]);
1492 let err = recipients("alice", None, Some("ops"), &["alice".into()]).unwrap_err();
1493 assert!(err.to_string().contains("no other member"), "{err}");
1494 let err = recipients("alice", Some("alice"), None, &[]).unwrap_err();
1495 assert!(err.to_string().contains("this seat"), "{err}");
1496 }
1497
1498 #[test]
1499 fn a_prompt_caps_the_note_and_keeps_the_rest_unread() {
1500 let letters: Vec<Letter> = (0..10)
1501 .map(|n| Letter {
1502 id: format!("m{n}"),
1503 from: "alice".into(),
1504 to: vec!["bob".into()],
1505 body: format!("line {n}"),
1506 interrupt: n == 9,
1507 issue: None,
1508 group: None,
1509 reply: None,
1510 scope: None,
1511 read: false,
1512 ts: format!("2026-10-09T00:00:{n:02}Z"),
1513 seq: n,
1514 })
1515 .collect();
1516 let mut ordered = letters.clone();
1517 ordered.sort_by(|a, b| b.interrupt.cmp(&a.interrupt).then(a.ts.cmp(&b.ts)));
1518 let (text, ids) = prompt_lines(&ordered, &[]);
1519 assert!(text.lines().next().unwrap() == "mail:");
1520 assert!(text.contains("! m9 from alice:"));
1521 assert!(text.contains("2 more in ljos inbox"), "{text}");
1522 assert_eq!(ids.iter().filter(|id| id.starts_with("mail:")).count(), 8);
1523 assert!(!ids.iter().any(|id| id == "mail:m7" || id == "mail:m8"));
1524 }
1525
1526 #[test]
1527 fn a_seat_hint_names_a_few_seats_and_no_ids() {
1528 let known: BTreeSet<String> = [
1529 "alpha",
1530 "bravo",
1531 "charlie",
1532 "delta",
1533 "echo",
1534 "foxtrot",
1535 "saddle",
1536 "01a0dab3-895e-7533-93ca-c9807d5cb680",
1537 "sess-4f2a9c0d1e2b3a4f",
1538 ]
1539 .iter()
1540 .map(|s| (*s).to_string())
1541 .collect();
1542 assert!(named_seat("codex-vax0"));
1543 assert!(named_seat("disk-saver"));
1544 assert!(!named_seat("01a0dab3-895e-7533-93ca-c9807d5cb680"));
1545 assert!(!named_seat("sess-4f2a9c0d1e2b3a4f"));
1546 let hint = seat_hint("sadle", &known);
1547 assert_eq!(
1548 hint,
1549 "Did you mean saddle? Other seats: alpha, bravo, charlie, delta and 2 more."
1550 );
1551 let hint = seat_hint("zzz", &known);
1552 assert_eq!(hint, "Seats here: alpha, bravo, charlie, delta and 3 more.");
1553 let ids: BTreeSet<String> = ["01a0dab3-895e-7533-93ca-c9807d5cb680".to_string()].into();
1554 assert_eq!(
1555 seat_hint("codex", &ids),
1556 "No named seat has started here yet."
1557 );
1558 let err = check_known("sadle", &known, false).unwrap_err().to_string();
1559 assert!(!err.contains("01a0dab3"), "{err}");
1560 assert!(err.contains("Did you mean saddle?"), "{err}");
1561 }
1562
1563 #[test]
1564 fn a_misspelt_seat_is_answered_with_the_seat_it_meant() {
1565 let known: BTreeSet<String> = ["ljos-bot", "saddle", "inky"]
1566 .iter()
1567 .map(|s| (*s).to_string())
1568 .collect();
1569 assert_eq!(closest_seat("ljos", &known), Some("ljos-bot"));
1570 assert_eq!(closest_seat("sadle", &known), Some("saddle"));
1571 assert_eq!(closest_seat("eessi", &known), None);
1572 let err = check_known("ljos", &known, false).unwrap_err().to_string();
1573 assert!(err.contains("Did you mean ljos-bot?"), "{err}");
1574 let err = check_known("eessi", &known, false).unwrap_err().to_string();
1575 assert!(!err.contains("Did you mean"), "{err}");
1576 assert!(check_known("eessi", &known, true).is_ok());
1577 }
1578
1579 #[test]
1580 fn a_stop_carries_unread_interrupt_mail_only() {
1581 let plain = outgoing("plain", "grok", &["claude"], "when you can");
1582 let mut hot = outgoing("hot", "grok", &["claude"], "take ticket-d9n6 now");
1583 hot.interrupt = true;
1584 hot.issue = Some("ticket-d9n6".into());
1585 let atoms = [
1586 atom(&plain, "2026-10-11T00:00:00Z"),
1587 atom(&hot, "2026-10-11T00:00:01Z"),
1588 ];
1589 let (text, ids) = stop_lines(&atoms, "claude");
1590 assert!(text.starts_with(STOP_MAIL_HEAD), "{text}");
1591 assert!(
1592 text.contains("! hot from grok on ticket-d9n6: take ticket-d9n6 now"),
1593 "{text}"
1594 );
1595 assert!(!text.contains("when you can"), "{text}");
1596 assert_eq!(ids, ["mail:hot"]);
1597 let read = json!({
1598 "kind": "receipt",
1599 "text": "read hot\nby claude\nat 2026-10-11T00:00:02Z\nmsg r1",
1600 "entities": ["seat:claude", "from:claude", "to:grok", "receipt:hot", "msg:r1"],
1601 });
1602 let after = [atoms[0].clone(), atoms[1].clone(), read];
1603 assert_eq!(stop_lines(&after, "claude"), (String::new(), Vec::new()));
1604 assert_eq!(stop_lines(&atoms, "grok"), (String::new(), Vec::new()));
1605 }
1606
1607 #[test]
1608 fn an_unknown_kind_is_a_pack_without_mail() {
1609 let refused =
1610 "bad response: http://127.0.0.1:8761/v1/atoms: 400: unknown atom kind: message";
1611 assert_eq!(probe_of(refused), Probe::Refused);
1612 let kept = "bad response: http://127.0.0.1:8761/v1/atoms: 400: atom text is required";
1613 assert_eq!(probe_of(kept), Probe::Kept);
1614 let level = "bad response: http://127.0.0.1:8761/v1/atoms: 400: unknown atom level: nope";
1615 assert_eq!(probe_of(level), Probe::Kept);
1616 let dump =
1620 "bad response: http://127.0.0.1:8761/v1/atoms: 400: tool dump is attach, not an atom";
1621 assert_eq!(probe_of(dump), Probe::Kept);
1622 let down = "http://127.0.0.1:8761/v1/atoms: http: connection refused";
1623 assert_eq!(probe_of(down), Probe::Store);
1624 let named = unsupported_mail(Some("0.12.1"));
1625 assert!(named.contains("packset 0.12.1"), "{named}");
1626 assert!(named.contains(MAIL_PACKSET), "{named}");
1627 assert!(named.contains("kind message"), "{named}");
1628 let quiet = unsupported_mail(None);
1629 assert!(quiet.contains(MAIL_PACKSET), "{quiet}");
1630 assert!(quiet.contains("refused kind message"), "{quiet}");
1631 }
1632}