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