Skip to main content

ljos_cli/
mail.rs

1//! Mail from one seat to another.
2//!
3//! A message is a pack atom. Seats on one box share the pack and see it at
4//! once. `ljos sync` exports the machine's own atoms, so the same atom
5//! crosses machines in the sealed log. A receipt is its own atom: the pack
6//! and the log are append-only, and an import tells two atoms apart by the
7//! hash of their text, so the text of a message carries its id.
8
9use 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
16/// How many messages a prompt is shown. The rest stay unread.
17const PROMPT_CAP: usize = 8;
18
19/// The first packset that keeps kinds `message`, `receipt` and `group`.
20///
21/// 0.12.1 refuses them. A new kind has been a minor release: `outcome`
22/// arrived in 0.12.0, and `prediction` and `rule` in 0.6.0.
23pub(crate) const MAIL_PACKSET: &str = "0.13.0";
24
25/// What a probe post of an empty `message` means.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum Probe {
28    /// The writer refused the kind.
29    Refused,
30    /// The kind passed and a later check refused the empty probe.
31    Kept,
32    /// The writer did not answer.
33    Store,
34}
35
36/// Classify a probe error. Kind is checked before the text, so an empty
37/// message is refused as unknown when the pack cannot keep mail, and as
38/// missing text when it can.
39fn probe_of(text: &str) -> Probe {
40    if text.contains("unknown atom kind") {
41        Probe::Refused
42    } else if text.contains(": 400:") {
43        Probe::Kept
44    } else {
45        Probe::Store
46    }
47}
48
49fn unsupported_mail(version: Option<&str>) -> String {
50    match version {
51        Some(version) => format!(
52            "this pack does not keep mail. packset {version} refused kind message. Mail needs packset {MAIL_PACKSET}."
53        ),
54        None => format!(
55            "this pack does not keep mail. The writer refused kind message. Mail needs packset {MAIL_PACKSET}."
56        ),
57    }
58}
59
60/// One message as it is written.
61#[derive(Debug, Clone)]
62pub struct Outgoing {
63    pub id: String,
64    pub from: String,
65    pub to: Vec<String>,
66    pub body: String,
67    pub interrupt: bool,
68    pub issue: Option<String>,
69    pub group: Option<String>,
70    pub reply: Option<String>,
71    pub scope: Option<String>,
72}
73
74/// What `ljos send` was asked to write.
75pub struct Send<'a> {
76    pub seat: Option<&'a str>,
77    pub group: Option<&'a str>,
78    pub text: &'a str,
79    pub interrupt: bool,
80    pub issue: Option<&'a str>,
81    /// Write to a seat this pack has not heard from yet.
82    pub new_seat: bool,
83}
84
85/// One message as a reader sees it.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Letter {
88    pub id: String,
89    pub from: String,
90    pub to: Vec<String>,
91    pub body: String,
92    pub interrupt: bool,
93    pub issue: Option<String>,
94    pub group: Option<String>,
95    pub reply: Option<String>,
96    pub scope: Option<String>,
97    pub read: bool,
98    ts: String,
99    seq: usize,
100}
101
102/// A read receipt addressed to the seat that sent the message.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Receipt {
105    pub msg: String,
106    pub by: String,
107    pub at: String,
108    pub key: String,
109}
110
111fn mint_id(kind: &str, parts: &[&str]) -> String {
112    static N: AtomicU64 = AtomicU64::new(1);
113    let n = N.fetch_add(1, Ordering::Relaxed);
114    let nanos = std::time::SystemTime::now()
115        .duration_since(std::time::UNIX_EPOCH)
116        .map(|d| d.as_nanos())
117        .unwrap_or(0);
118    let raw = format!(
119        "{kind}\n{n}\n{nanos}\n{}\n{}",
120        std::process::id(),
121        parts.join("\n")
122    );
123    super::work_id(&raw)
124}
125
126fn check_name(kind: &str, name: &str) -> Result<String> {
127    let name = name.trim();
128    if name.is_empty()
129        || name
130            .chars()
131            .any(|c| c.is_whitespace() || c == ',' || c == ':')
132    {
133        bail!("{kind}: {name:?} is not a name");
134    }
135    Ok(name.to_string())
136}
137
138fn tag(atom: &Value, prefix: &str) -> Option<String> {
139    atom["entities"]
140        .as_array()?
141        .iter()
142        .filter_map(Value::as_str)
143        .find_map(|e| e.strip_prefix(prefix).map(str::to_string))
144}
145
146fn tags(atom: &Value, prefix: &str) -> Vec<String> {
147    atom["entities"]
148        .as_array()
149        .into_iter()
150        .flatten()
151        .filter_map(Value::as_str)
152        .filter_map(|e| e.strip_prefix(prefix).map(str::to_string))
153        .collect()
154}
155
156fn has(atom: &Value, entity: &str) -> bool {
157    atom["entities"]
158        .as_array()
159        .into_iter()
160        .flatten()
161        .any(|e| e.as_str() == Some(entity))
162}
163
164/// The body a person wrote. The lines above the blank line are the id and
165/// the envelope, which keep two messages with the same words distinct.
166#[must_use]
167pub fn body_of(text: &str) -> String {
168    text.split_once("\n\n")
169        .map(|(_, body)| body.trim().to_string())
170        .unwrap_or_else(|| text.trim().to_string())
171}
172
173/// The pack refuses an atom whose text is longer than this, in characters.
174pub const ATOM_TEXT_CAP: usize = 500;
175
176/// The longest body a message carries. Past it, a file and a deed
177/// (`deedar create file`, then the accession in the message) say it better.
178pub const MAIL_BODY_CAP: usize = 16_000;
179
180/// A body past [`MAIL_BODY_CAP`] is refused before anything is written,
181/// with the way to send it as a deed.
182fn check_length(verb: &str, body: &str) -> Result<()> {
183    let n = body.chars().count();
184    if n > MAIL_BODY_CAP {
185        bail!(
186            "{verb}: the message is {n} characters, past {MAIL_BODY_CAP}; write it to a file, \
187             `deedar create file --path FILE --agent SEAT`, and send the accession it prints. Nothing was sent"
188        );
189    }
190    Ok(())
191}
192
193/// The text that travels. The id is in it, so an import that keys on the
194/// text keeps two copies of the same words. A body that would push the
195/// text past [`ATOM_TEXT_CAP`] is cut there with an ellipsis; the whole
196/// body rides in the atom's `body` field, which [`message_atom`] sets.
197#[must_use]
198pub fn message_text(m: &Outgoing) -> String {
199    let mut head = format!("msg {}\nfrom {}\nto {}", m.id, m.from, m.to.join(", "));
200    if m.interrupt {
201        head.push_str("\ninterrupt");
202    }
203    if let Some(issue) = &m.issue {
204        head.push_str(&format!("\nissue {issue}"));
205    }
206    if let Some(group) = &m.group {
207        head.push_str(&format!("\ngroup {group}"));
208    }
209    if let Some(reply) = &m.reply {
210        head.push_str(&format!("\nreply {reply}"));
211    }
212    let body = m.body.trim();
213    let room = ATOM_TEXT_CAP.saturating_sub(head.chars().count() + 2);
214    if body.chars().count() <= room {
215        return format!("{head}\n\n{body}");
216    }
217    let cut: String = body.chars().take(room.saturating_sub(1)).collect();
218    format!("{head}\n\n{cut}\u{2026}")
219}
220
221/// The atom a message posts. The entities are what an inbox filters on.
222#[must_use]
223pub fn message_atom(m: &Outgoing, workspace: &str) -> Value {
224    let mut entities = vec![
225        format!("seat:{}", m.from),
226        format!("from:{}", m.from),
227        format!("msg:{}", m.id),
228    ];
229    for to in &m.to {
230        entities.push(format!("to:{to}"));
231    }
232    if m.interrupt {
233        entities.push("priority:interrupt".to_string());
234    }
235    if let Some(issue) = &m.issue {
236        entities.push(format!("issue:{issue}"));
237    }
238    if let Some(group) = &m.group {
239        entities.push(format!("group:{group}"));
240    }
241    if let Some(reply) = &m.reply {
242        entities.push(format!("reply:{reply}"));
243    }
244    if let Some(scope) = &m.scope {
245        entities.push(format!("scope:{scope}"));
246    }
247    let text = message_text(m);
248    let mut atom = json!({
249        "schema": "inside.atom/v1",
250        "kind": "message",
251        "level": "explicit",
252        "text": text,
253        "workspace": workspace,
254        "entities": entities,
255    });
256    if body_of(&text) != m.body.trim() {
257        atom["body"] = Value::String(m.body.trim().to_string());
258    }
259    atom
260}
261
262fn letter_of(atom: &Value, seq: usize) -> Option<Letter> {
263    if atom["kind"].as_str() != Some("message") {
264        return None;
265    }
266    let id = tag(atom, "msg:")?;
267    let from = tag(atom, "from:").or_else(|| tag(atom, "seat:"))?;
268    let to = tags(atom, "to:");
269    if to.is_empty() {
270        return None;
271    }
272    Some(Letter {
273        id,
274        from,
275        to,
276        // A long body rides whole in `body`; the text holds its start.
277        body: atom["body"]
278            .as_str()
279            .map(|b| b.trim().to_string())
280            .unwrap_or_else(|| body_of(atom["text"].as_str().unwrap_or(""))),
281        interrupt: has(atom, "priority:interrupt"),
282        issue: tag(atom, "issue:"),
283        group: tag(atom, "group:"),
284        reply: tag(atom, "reply:"),
285        scope: tag(atom, "scope:"),
286        read: false,
287        ts: atom["ts"].as_str().unwrap_or("").to_string(),
288        seq,
289    })
290}
291
292fn readers_of(atoms: &[Value]) -> BTreeSet<(String, String)> {
293    let mut out = BTreeSet::new();
294    for atom in atoms {
295        if atom["kind"].as_str() != Some("receipt") {
296            continue;
297        }
298        let Some(msg) = tag(atom, "receipt:") else {
299            continue;
300        };
301        let Some(by) = tag(atom, "from:").or_else(|| tag(atom, "seat:")) else {
302            continue;
303        };
304        out.insert((msg, by));
305    }
306    out
307}
308
309/// Messages addressed to `seat`, interrupts first, then oldest first.
310#[must_use]
311pub fn inbox_letters(atoms: &[Value], seat: &str, all: bool) -> Vec<Letter> {
312    let read = readers_of(atoms);
313    let mut letters: Vec<Letter> = atoms
314        .iter()
315        .enumerate()
316        .filter_map(|(seq, atom)| letter_of(atom, seq))
317        .filter(|letter| letter.to.iter().any(|to| to == seat))
318        .map(|mut letter| {
319            letter.read = read.contains(&(letter.id.clone(), seat.to_string()));
320            letter
321        })
322        .filter(|letter| all || !letter.read)
323        .collect();
324    letters.sort_by(|a, b| {
325        b.interrupt
326            .cmp(&a.interrupt)
327            .then(a.ts.cmp(&b.ts))
328            .then(a.seq.cmp(&b.seq))
329    });
330    letters
331}
332
333/// Receipts on messages this seat sent, newest stamp last.
334#[must_use]
335pub fn receipts_for(atoms: &[Value], seat: &str) -> Vec<Receipt> {
336    let mut out = Vec::new();
337    for atom in atoms {
338        if atom["kind"].as_str() != Some("receipt") {
339            continue;
340        }
341        if tag(atom, "to:").as_deref() != Some(seat) {
342            continue;
343        }
344        let Some(msg) = tag(atom, "receipt:") else {
345            continue;
346        };
347        let Some(by) = tag(atom, "from:") else {
348            continue;
349        };
350        let text = atom["text"].as_str().unwrap_or("");
351        let at = text
352            .lines()
353            .find_map(|l| l.strip_prefix("at "))
354            .unwrap_or("")
355            .to_string();
356        out.push(Receipt {
357            key: super::work_id(text),
358            msg,
359            by,
360            at,
361        });
362    }
363    out.sort_by(|a, b| a.at.cmp(&b.at).then(a.key.cmp(&b.key)));
364    out
365}
366
367/// Seats in a group, in the order they were added. A later drop removes one.
368#[must_use]
369pub fn members(atoms: &[Value], group: &str) -> Vec<String> {
370    let mut events: Vec<(String, usize, String, bool)> = Vec::new();
371    for (seq, atom) in atoms.iter().enumerate() {
372        if atom["kind"].as_str() != Some("group") {
373            continue;
374        }
375        if tag(atom, "group:").as_deref() != Some(group) {
376            continue;
377        }
378        let ts = atom["ts"].as_str().unwrap_or("").to_string();
379        if let Some(seat) = tag(atom, "member:") {
380            events.push((ts, seq, seat, false));
381        } else if let Some(seat) = tag(atom, "drop:") {
382            events.push((ts, seq, seat, true));
383        }
384    }
385    events.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
386    let mut out: Vec<String> = Vec::new();
387    for (_, _, seat, drop) in events {
388        if drop {
389            out.retain(|s| s != &seat);
390        } else if !out.iter().any(|s| s == &seat) {
391            out.push(seat);
392        }
393    }
394    out
395}
396
397/// A body as the prompt shows it: one line of 160 characters, and when it
398/// is cut, where the whole of it is. The prompt marks the letter read, so
399/// a long body would otherwise be seen only by its start.
400fn prompt_body(body: &str) -> String {
401    let n = body.chars().count();
402    let line = one_line(body, Some(160));
403    if n > 160 {
404        format!("{line} (all {n} characters: `ljos inbox --all`)")
405    } else {
406        line
407    }
408}
409
410fn one_line(body: &str, limit: Option<usize>) -> String {
411    let flat = body.split_whitespace().collect::<Vec<_>>().join(" ");
412    match limit {
413        Some(n) if flat.chars().count() > n => {
414            let t: String = flat.chars().take(n.saturating_sub(3)).collect();
415            format!("{t}...")
416        }
417        _ => flat,
418    }
419}
420
421fn flags(letter: &Letter) -> String {
422    let mut parts = Vec::new();
423    if letter.interrupt {
424        parts.push("interrupt".to_string());
425    }
426    if let Some(issue) = &letter.issue {
427        parts.push(format!("issue:{issue}"));
428    }
429    if let Some(group) = &letter.group {
430        parts.push(format!("group:{group}"));
431    }
432    if let Some(reply) = &letter.reply {
433        parts.push(format!("reply:{reply}"));
434    }
435    if letter.read {
436        parts.push("read".to_string());
437    }
438    parts.join(" ")
439}
440
441/// The inbox a seat prints. Receipts already shown are left out.
442#[must_use]
443pub fn render_inbox(
444    atoms: &[Value],
445    seat: &str,
446    all: bool,
447    seen_receipts: &BTreeSet<String>,
448) -> (String, Vec<String>) {
449    let letters = inbox_letters(atoms, seat, all);
450    let receipts: Vec<Receipt> = receipts_for(atoms, seat)
451        .into_iter()
452        .filter(|r| !seen_receipts.contains(&r.key))
453        .collect();
454    if letters.is_empty() && receipts.is_empty() {
455        let line = if all {
456            "inbox: nothing\n"
457        } else {
458            "inbox: nothing unread\n"
459        };
460        return (line.to_string(), Vec::new());
461    }
462    let mut out = String::new();
463    for letter in &letters {
464        out.push_str(&format!(
465            "{}\t{}\t{}\t{}\n",
466            letter.id,
467            letter.from,
468            flags(letter),
469            one_line(&letter.body, None)
470        ));
471    }
472    let mut keys = Vec::new();
473    for receipt in &receipts {
474        out.push_str(&format!(
475            "receipt\t{}\t{}\t{}\n",
476            receipt.msg, receipt.by, receipt.at
477        ));
478        keys.push(receipt.key.clone());
479    }
480    (out, keys)
481}
482
483/// The note a prompt hook prints, and the ids to receipt when it is delivered.
484///
485/// Message ids are prefixed `mail:`. Receipt ids the sender is being told
486/// about are prefixed `rcpt:` and are recorded locally, not written again.
487#[must_use]
488pub fn prompt_lines(letters: &[Letter], receipts: &[Receipt]) -> (String, Vec<String>) {
489    if letters.is_empty() && receipts.is_empty() {
490        return (String::new(), Vec::new());
491    }
492    let mut lines = vec!["mail:".to_string()];
493    let mut ids = Vec::new();
494    for letter in letters.iter().take(PROMPT_CAP) {
495        let mark = if letter.interrupt { "! " } else { "" };
496        let on = letter
497            .issue
498            .as_ref()
499            .map(|issue| format!(" on {issue}"))
500            .unwrap_or_default();
501        lines.push(format!(
502            "{mark}{} from {}{on}: {}",
503            letter.id,
504            letter.from,
505            prompt_body(&letter.body)
506        ));
507        ids.push(format!("mail:{}", letter.id));
508    }
509    let rest = letters.len().saturating_sub(PROMPT_CAP);
510    if rest > 0 {
511        lines.push(format!("{rest} more in ljos inbox"));
512    }
513    for receipt in receipts.iter().take(PROMPT_CAP) {
514        lines.push(format!("read {} by {}", receipt.msg, receipt.by));
515        ids.push(format!("rcpt:{}", receipt.key));
516    }
517    (lines.join("\n"), ids)
518}
519
520fn find_letter(atoms: &[Value], id: &str) -> Result<Letter> {
521    atoms
522        .iter()
523        .enumerate()
524        .filter_map(|(seq, atom)| letter_of(atom, seq))
525        .find(|letter| letter.id == id)
526        .ok_or_else(|| anyhow::anyhow!("no message {id}"))
527}
528
529/// Every seat this pack has heard from: a sender, a reader, a group
530/// member, and the seat any atom was written under.
531#[must_use]
532pub fn known_seats(atoms: &[Value]) -> BTreeSet<String> {
533    let mut out = BTreeSet::new();
534    for atom in atoms {
535        for prefix in ["seat:", "from:", "member:"] {
536            out.extend(tags(atom, prefix));
537        }
538        if let Some(seat) = atom["source"]["harness"].as_str() {
539            out.insert(seat.to_string());
540        }
541    }
542    out.retain(|s| !s.trim().is_empty());
543    out
544}
545
546/// Refuse a seat the pack has never heard from. Mail waits under the name
547/// it was sent to, so a message to `ljos` sat unread while the seat that
548/// meant to read it was `ljos-bot` (ticket-mwy5).
549///
550/// # Errors
551///
552/// `seat` is not in `known` and `new_seat` was not passed.
553pub fn check_known(seat: &str, known: &BTreeSet<String>, new_seat: bool) -> Result<()> {
554    if new_seat || known.contains(seat) {
555        return Ok(());
556    }
557    bail!(
558        "send: no seat named {seat} has written to this pack, so the message would wait unread. \
559         {} Pass --new-seat to write to a seat that has not started yet",
560        seat_hint(seat, known)
561    )
562}
563
564/// The most seat names an error lists after its guess.
565const SEAT_HINT_MAX: usize = 4;
566
567/// Whether `name` reads as a seat name. A conversation id that an older
568/// ljos wrote as a holder does not: a UUID, a long hex run, or `sess-`
569/// and a hex run. Such a name helps nobody pick a seat.
570#[must_use]
571pub fn named_seat(name: &str) -> bool {
572    let name = name.trim();
573    if name.is_empty() {
574        return false;
575    }
576    let rest = name.strip_prefix("sess-").unwrap_or(name);
577    let hex: Vec<char> = rest.chars().filter(|c| *c != '-').collect();
578    let conversation_id = hex.len() >= 16 && hex.iter().all(char::is_ascii_hexdigit);
579    !conversation_id
580}
581
582/// The hint an unknown seat gets: the seat it most likely meant, then a
583/// few other named seats. Conversation ids are left out
584/// ([`named_seat`]), and past [`SEAT_HINT_MAX`] the rest are counted.
585#[must_use]
586pub fn seat_hint(seat: &str, known: &BTreeSet<String>) -> String {
587    let named: BTreeSet<String> = known.iter().filter(|s| named_seat(s)).cloned().collect();
588    let guess = closest_seat(seat, &named).map(str::to_string);
589    let others: Vec<&String> = named
590        .iter()
591        .filter(|s| guess.as_deref() != Some(s.as_str()))
592        .collect();
593    let mut shown = others
594        .iter()
595        .take(SEAT_HINT_MAX)
596        .map(|s| s.as_str())
597        .collect::<Vec<_>>()
598        .join(", ");
599    if others.len() > SEAT_HINT_MAX {
600        shown.push_str(&format!(" and {} more", others.len() - SEAT_HINT_MAX));
601    }
602    match (guess, shown.is_empty()) {
603        (Some(g), true) => format!("Did you mean {g}?"),
604        (Some(g), false) => format!("Did you mean {g}? Other seats: {shown}."),
605        (None, false) => format!("Seats here: {shown}."),
606        (None, true) => "No named seat has started here yet.".to_string(),
607    }
608}
609
610/// The known seat a mistyped name most likely meant: one that starts with
611/// it or holds it (`ljos` for `ljos-bot`), else the nearest by edits, at
612/// most two away. `None` when nothing is that close.
613#[must_use]
614pub fn closest_seat<'a>(seat: &str, known: &'a BTreeSet<String>) -> Option<&'a str> {
615    let lower = seat.to_lowercase();
616    if lower.is_empty() {
617        return None;
618    }
619    let holds = known
620        .iter()
621        .filter(|k| k.to_lowercase().contains(&lower) || lower.contains(&k.to_lowercase()))
622        .min_by_key(|k| (k.len().abs_diff(seat.len()), k.as_str()));
623    if let Some(k) = holds {
624        return Some(k.as_str());
625    }
626    known
627        .iter()
628        .map(|k| (edits(&lower, &k.to_lowercase()), k))
629        .filter(|(d, _)| *d <= 2)
630        .min()
631        .map(|(_, k)| k.as_str())
632}
633
634/// Levenshtein distance, in characters.
635fn edits(a: &str, b: &str) -> usize {
636    let b: Vec<char> = b.chars().collect();
637    let mut row: Vec<usize> = (0..=b.len()).collect();
638    for (i, ca) in a.chars().enumerate() {
639        let mut prev = row[0];
640        row[0] = i + 1;
641        for (j, cb) in b.iter().enumerate() {
642            let cur = row[j + 1];
643            row[j + 1] = if ca == *cb {
644                prev
645            } else {
646                1 + prev.min(cur).min(row[j])
647            };
648            prev = cur;
649        }
650    }
651    row[b.len()]
652}
653
654/// Who a send addresses. A group send leaves the sender off the list.
655pub fn recipients(
656    from: &str,
657    seat: Option<&str>,
658    group: Option<&str>,
659    members: &[String],
660) -> Result<Vec<String>> {
661    if let Some(group) = group {
662        if seat.is_some() {
663            bail!("send: name a seat or a group");
664        }
665        let to: Vec<String> = members
666            .iter()
667            .filter(|m| m.as_str() != from)
668            .cloned()
669            .collect();
670        if to.is_empty() {
671            bail!("send: {group} has no other member");
672        }
673        return Ok(to);
674    }
675    let Some(seat) = seat.map(str::trim).filter(|s| !s.is_empty()) else {
676        bail!("send: name a seat or a group");
677    };
678    let seat = check_name("send", seat)?;
679    if seat == from {
680        bail!("send: {seat} is this seat");
681    }
682    Ok(vec![seat])
683}
684
685fn is_mail_atom(atom: &Value) -> bool {
686    matches!(atom["kind"].as_str(), Some("message" | "receipt" | "group"))
687}
688
689fn writer_version(client: &packset_client::PacksetClient) -> Option<String> {
690    client
691        .status(Some(&client.workspace()))
692        .ok()
693        .and_then(|status| {
694            status
695                .get("version")
696                .and_then(Value::as_str)
697                .filter(|version| !version.is_empty())
698                .map(str::to_string)
699        })
700}
701
702/// Fail when this writer does not keep mail. An empty message is not
703/// stored: the kind is refused, or the text is.
704fn ensure_mail(client: &packset_client::PacksetClient, verb: &str) -> Result<()> {
705    // The writer's version decides when it gives one. packset 0.12.1 checks
706    // the text before the kind, so an empty probe reads the same from a
707    // writer that keeps mail and from one that refuses it.
708    if let Some(version) = writer_version(client) {
709        match super::cmp_semver(&version, MAIL_PACKSET) {
710            Some(std::cmp::Ordering::Less) => {
711                bail!("{verb}: {}", unsupported_mail(Some(&version)));
712            }
713            Some(_) => return Ok(()),
714            None => {}
715        }
716    }
717    let probe = json!({
718        "schema": "inside.atom/v1",
719        "kind": "message",
720        "level": "explicit",
721        "text": "",
722        "workspace": workspace_of(Some(&client.workspace())),
723    });
724    let posted = super::with_writer(|| client.post_atom(&probe).map_err(anyhow::Error::from));
725    match posted {
726        Ok(_) => Ok(()),
727        Err(err) => match probe_of(&format!("{err:#}")) {
728            Probe::Kept => Ok(()),
729            Probe::Refused => {
730                let version = writer_version(client);
731                bail!("{verb}: {}", unsupported_mail(version.as_deref()));
732            }
733            Probe::Store => Err(err).context("mail: the pack did not answer"),
734        },
735    }
736}
737
738fn listed() -> Result<Vec<Value>> {
739    let client = super::pack()?;
740    let workspace = client.workspace();
741    super::atoms_lean(&client, &workspace).context("mail: the pack did not answer")
742}
743
744fn load(verb: &str) -> Result<Vec<Value>> {
745    let client = super::pack()?;
746    let atoms = listed()?;
747    if !atoms.iter().any(is_mail_atom) {
748        ensure_mail(&client, verb)?;
749    }
750    Ok(atoms)
751}
752
753fn load_quiet() -> Option<Vec<Value>> {
754    let client = super::pack().ok()?;
755    let workspace = client.workspace();
756    super::atoms_lean_shared(&client, &workspace).ok()
757}
758
759fn post(atom: &Value, verb: &str) -> Result<Value> {
760    let client = super::pack()?;
761    super::with_writer(|| {
762        client
763            .post_atom(atom)
764            .with_context(|| format!("{verb}: POST /v1/atoms failed"))
765    })
766}
767
768fn workspace_of(client_workspace: Option<&str>) -> String {
769    client_workspace.unwrap_or("seat").to_string()
770}
771
772fn stamp_source(atom: &mut Value) {
773    atom["source"] = super::atom_source();
774}
775
776/// Write one message and print its id.
777pub fn send(args: &Send<'_>) -> Result<String> {
778    let text = args.text.trim();
779    if text.is_empty() {
780        bail!("send: empty text");
781    }
782    check_length("send", text)?;
783    let from = super::seat_name();
784    let group = args
785        .group
786        .map(|name| check_name("send", name))
787        .transpose()?;
788    let client = super::pack()?;
789    // A named seat is checked before the pack, so "this seat" stays that
790    // error. A group has no roster until the pack answers.
791    let atoms = if group.is_some() {
792        ensure_mail(&client, "send")?;
793        listed()?
794    } else {
795        Vec::new()
796    };
797    let roster = group
798        .as_deref()
799        .map(|name| members(&atoms, name))
800        .unwrap_or_default();
801    let to = recipients(from.as_str(), args.seat, group.as_deref(), &roster)?;
802    if group.is_none() {
803        ensure_mail(&client, "send")?;
804        if !args.new_seat {
805            let known = known_seats(&listed()?);
806            for seat in &to {
807                check_known(seat, &known, false)?;
808            }
809        }
810    }
811    let issue = args
812        .issue
813        .map(str::trim)
814        .filter(|s| !s.is_empty())
815        .map(|s| check_name("send", s))
816        .transpose()?;
817    let scope = issue.as_deref().and_then(super::sync::scope_for_issue);
818    let outgoing = Outgoing {
819        id: mint_id("msg", &[&from, text]),
820        from,
821        to: to.clone(),
822        body: text.to_string(),
823        interrupt: args.interrupt,
824        issue,
825        group,
826        reply: None,
827        scope,
828    };
829    let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
830    stamp_source(&mut atom);
831    post(&atom, "send")?;
832    let mut said = format!("sent {} to {}\n", outgoing.id, to.join(", "));
833    said.push_str(&wake_after(&outgoing));
834    Ok(said)
835}
836
837/// Start the wake of each recipient that set one, and say which. The
838/// message is already written, so a wake that fails changes nothing here.
839fn wake_after(m: &Outgoing) -> String {
840    let events: Vec<super::wake::Event> =
841        m.to.iter()
842            .map(|seat| super::wake::Event {
843                seat: seat.clone(),
844                from: m.from.clone(),
845                msg: m.id.clone(),
846                issue: m.issue.clone(),
847                interrupt: m.interrupt,
848            })
849            .collect();
850    let woken = super::wake::spawn(&events);
851    if woken.is_empty() {
852        String::new()
853    } else {
854        format!("waking {}\n", woken.join(", "))
855    }
856}
857
858/// Unread mail for this seat, then receipts on what it sent that it has not
859/// been shown yet.
860///
861/// Listing does not write a receipt. `ljos hook --prompt` does, for each
862/// message it shows. A pack that does not answer, or that refuses mail, is
863/// an error. `ljos_inbox` calls this.
864pub fn inbox(all: bool) -> Result<String> {
865    let atoms = load("inbox")?;
866    let seat = super::seat_name();
867    let seen = receipt_seen();
868    let (text, keys) = render_inbox(&atoms, &seat, all, &seen);
869    remember_receipts(&keys);
870    Ok(text)
871}
872
873fn already_read(atoms: &[Value], msg: &str, seat: &str) -> bool {
874    readers_of(atoms).contains(&(msg.to_string(), seat.to_string()))
875}
876
877fn receipt_atom(msg: &Letter, reader: &str, at: &str, workspace: &str) -> Value {
878    let id = mint_id("receipt", &[msg.id.as_str(), reader, at]);
879    let text = format!("read {}\nby {reader}\nat {at}\nmsg {id}", msg.id);
880    let mut entities = vec![
881        format!("seat:{reader}"),
882        format!("from:{reader}"),
883        format!("to:{}", msg.from),
884        format!("msg:{id}"),
885        format!("receipt:{}", msg.id),
886    ];
887    if let Some(scope) = &msg.scope {
888        entities.push(format!("scope:{scope}"));
889    }
890    json!({
891        "schema": "inside.atom/v1",
892        "kind": "receipt",
893        "level": "explicit",
894        "text": text,
895        "workspace": workspace,
896        "entities": entities,
897    })
898}
899
900/// Write a receipt for one message. A second read of the same message
901/// writes nothing.
902pub fn read(id: &str) -> Result<String> {
903    let id = id.trim();
904    if id.is_empty() {
905        bail!("read: name a message");
906    }
907    let atoms = load("read")?;
908    let seat = super::seat_name();
909    let letter = find_letter(&atoms, id).context("read")?;
910    if !letter.to.iter().any(|to| to == &seat) {
911        bail!("read: {id} is not for {seat}");
912    }
913    if already_read(&atoms, id, &seat) {
914        return Ok(format!("read {id} already\n"));
915    }
916    let client = super::pack()?;
917    let mut atom = receipt_atom(
918        &letter,
919        &seat,
920        &super::now_utc(),
921        &workspace_of(Some(&client.workspace())),
922    );
923    stamp_source(&mut atom);
924    post(&atom, "read")?;
925    Ok(format!("read {id}\n"))
926}
927
928fn read_ids(ids: &[String]) {
929    let Some(atoms) = load_quiet() else {
930        return;
931    };
932    let seat = super::seat_name();
933    let Ok(client) = super::pack() else {
934        return;
935    };
936    let workspace = client.workspace();
937    let at = super::now_utc();
938    for id in ids {
939        let Ok(letter) = find_letter(&atoms, id) else {
940            continue;
941        };
942        if !letter.to.iter().any(|to| to == &seat) || already_read(&atoms, id, &seat) {
943            continue;
944        }
945        let mut atom = receipt_atom(&letter, &seat, &at, &workspace_of(Some(&workspace)));
946        stamp_source(&mut atom);
947        let _ = post(&atom, "read");
948    }
949}
950
951/// Answer a message. The reply goes to its sender and keeps its issue.
952pub fn reply(id: &str, text: &str, interrupt: bool) -> Result<String> {
953    let id = id.trim();
954    let text = text.trim();
955    if id.is_empty() {
956        bail!("reply: name a message");
957    }
958    if text.is_empty() {
959        bail!("reply: empty text");
960    }
961    check_length("reply", text)?;
962    let atoms = load("reply")?;
963    let parent = find_letter(&atoms, id).context("reply")?;
964    let from = super::seat_name();
965    if parent.from == from {
966        bail!("reply: {id} is from this seat");
967    }
968    let issue = parent.issue.clone();
969    let scope = issue
970        .as_deref()
971        .and_then(super::sync::scope_for_issue)
972        .or(parent.scope);
973    let outgoing = Outgoing {
974        id: mint_id("msg", &[&from, id, text]),
975        from,
976        to: vec![parent.from.clone()],
977        body: text.to_string(),
978        interrupt,
979        issue,
980        group: None,
981        reply: Some(parent.id.clone()),
982        scope,
983    };
984    let client = super::pack()?;
985    let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
986    stamp_source(&mut atom);
987    post(&atom, "reply")?;
988    let mut line = format!(
989        "sent {} to {}\nreply {}\n",
990        outgoing.id, parent.from, parent.id
991    );
992    if let Some(issue) = &outgoing.issue {
993        line.push_str(&format!("issue {issue}\n"));
994    }
995    line.push_str(&wake_after(&outgoing));
996    Ok(line)
997}
998
999fn group_atom(name: &str, seat: &str, drop: bool, by: &str, workspace: &str) -> Value {
1000    let at = super::now_utc();
1001    let id = mint_id("group", &[name, seat, by, &at]);
1002    let verb = if drop { "drop" } else { "member" };
1003    let text = format!("group {id}\nname {name}\n{verb} {seat}\nby {by}\nat {at}");
1004    let mut entities = vec![
1005        format!("seat:{by}"),
1006        format!("group:{name}"),
1007        format!("msg:{id}"),
1008    ];
1009    if drop {
1010        entities.push(format!("drop:{seat}"));
1011    } else {
1012        entities.push(format!("member:{seat}"));
1013    }
1014    json!({
1015        "schema": "inside.atom/v1",
1016        "kind": "group",
1017        "level": "explicit",
1018        "text": text,
1019        "workspace": workspace,
1020        "entities": entities,
1021    })
1022}
1023
1024/// List a group, or add and remove members. A drop is its own atom.
1025pub fn group(name: &str, add: &[String], remove: &[String]) -> Result<String> {
1026    let name = check_name("group", name)?;
1027    if add.is_empty() && remove.is_empty() {
1028        let atoms = load("group")?;
1029        let seats = members(&atoms, &name);
1030        if seats.is_empty() {
1031            return Ok(format!("{name}\t(no members)\n"));
1032        }
1033        let mut out = String::new();
1034        for seat in seats {
1035            out.push_str(&format!("{name}\t{seat}\n"));
1036        }
1037        return Ok(out);
1038    }
1039    let atoms = load("group")?;
1040    let by = super::seat_name();
1041    let client = super::pack()?;
1042    let workspace = client.workspace();
1043    let mut roster = members(&atoms, &name);
1044    let mut out = String::new();
1045    for seat in add {
1046        let seat = check_name("group", seat)?;
1047        if roster.iter().any(|s| s == &seat) {
1048            out.push_str(&format!("{seat} is already in {name}\n"));
1049            continue;
1050        }
1051        let mut atom = group_atom(&name, &seat, false, &by, &workspace_of(Some(&workspace)));
1052        stamp_source(&mut atom);
1053        post(&atom, "group")?;
1054        roster.push(seat.clone());
1055        out.push_str(&format!("added {seat} to {name}\n"));
1056    }
1057    for seat in remove {
1058        let seat = check_name("group", seat)?;
1059        if !roster.iter().any(|s| s == &seat) {
1060            out.push_str(&format!("{seat} is not in {name}\n"));
1061            continue;
1062        }
1063        let mut atom = group_atom(&name, &seat, true, &by, &workspace_of(Some(&workspace)));
1064        stamp_source(&mut atom);
1065        post(&atom, "group")?;
1066        roster.retain(|s| s != &seat);
1067        out.push_str(&format!("removed {seat} from {name}\n"));
1068    }
1069    Ok(out)
1070}
1071
1072fn state_dir() -> PathBuf {
1073    std::env::var_os("XDG_STATE_HOME")
1074        .map(PathBuf::from)
1075        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
1076        .unwrap_or_else(|| PathBuf::from(".local/state"))
1077        .join("ljos")
1078        .join("mail")
1079}
1080
1081fn receipt_seen_path() -> PathBuf {
1082    state_dir().join("receipts-seen")
1083}
1084
1085fn receipt_seen() -> BTreeSet<String> {
1086    std::fs::read_to_string(receipt_seen_path())
1087        .map(|text| {
1088            text.lines()
1089                .map(str::trim)
1090                .filter(|l| !l.is_empty())
1091                .map(str::to_string)
1092                .collect()
1093        })
1094        .unwrap_or_default()
1095}
1096
1097fn remember_receipts(keys: &[String]) {
1098    if keys.is_empty() {
1099        return;
1100    }
1101    let path = receipt_seen_path();
1102    if let Some(dir) = path.parent() {
1103        let _ = std::fs::create_dir_all(dir);
1104    }
1105    let mut text = std::fs::read_to_string(&path).unwrap_or_default();
1106    for key in keys {
1107        if text.lines().any(|l| l == key) {
1108            continue;
1109        }
1110        text.push_str(key);
1111        text.push('\n');
1112    }
1113    let _ = std::fs::write(path, text);
1114}
1115
1116/// What the prompt hook prints when the pack does not answer.
1117///
1118/// One line. The hook still exits 0, so the prompt is not blocked.
1119pub const MAIL_UNCHECKED: &str = "mail could not be checked";
1120
1121/// Unread mail and unseen receipts for the prompt hook. A pack that does
1122/// not answer leaves one line, so unread mail is not silent.
1123#[must_use]
1124pub fn prompt_note() -> (String, Vec<String>) {
1125    let Some(atoms) = load_quiet() else {
1126        return (MAIL_UNCHECKED.to_string(), Vec::new());
1127    };
1128    let seat = super::seat_name();
1129    let letters = inbox_letters(&atoms, &seat, false);
1130    let seen = receipt_seen();
1131    let receipts: Vec<Receipt> = receipts_for(&atoms, &seat)
1132        .into_iter()
1133        .filter(|r| !seen.contains(&r.key))
1134        .collect();
1135    prompt_lines(&letters, &receipts)
1136}
1137
1138/// The line a held stop opens with, before the mail it carries.
1139pub const STOP_MAIL_HEAD: &str =
1140    "Mail marked interrupt reached this seat while you worked. Read it before you stop:";
1141
1142/// Unread interrupt mail for a stop hook, and the ids to receipt once the
1143/// runner has it. A running session takes it as one more round before it
1144/// goes idle, so a sender's interrupt does not wait for a prompt that may
1145/// never come. Plain mail waits for the next prompt or the seat's wake. A
1146/// pack that does not answer says nothing here: holding a stop on an
1147/// error would keep every turn open.
1148#[must_use]
1149pub fn stop_note() -> (String, Vec<String>) {
1150    let Some(atoms) = load_quiet() else {
1151        return (String::new(), Vec::new());
1152    };
1153    stop_lines(&atoms, &super::seat_name())
1154}
1155
1156/// [`stop_note`] over atoms already read.
1157#[must_use]
1158pub fn stop_lines(atoms: &[Value], seat: &str) -> (String, Vec<String>) {
1159    let hot: Vec<Letter> = inbox_letters(atoms, seat, false)
1160        .into_iter()
1161        .filter(|l| l.interrupt)
1162        .collect();
1163    let (lines, ids) = prompt_lines(&hot, &[]);
1164    if lines.is_empty() {
1165        return (String::new(), Vec::new());
1166    }
1167    let reply = "Answer with `ljos reply ID TEXT`.";
1168    (format!("{STOP_MAIL_HEAD}\n{lines}\n{reply}"), ids)
1169}
1170
1171/// Receipts for messages a runner was just shown, and a local mark for
1172/// receipts this seat was told about. A pack that does not answer leaves
1173/// the message unread, so the next prompt shows it again.
1174pub fn ack_delivered(ids: &[String]) {
1175    let mut reads = Vec::new();
1176    let mut seen = Vec::new();
1177    for id in ids {
1178        if let Some(msg) = id.strip_prefix("mail:") {
1179            reads.push(msg.to_string());
1180        } else if let Some(key) = id.strip_prefix("rcpt:") {
1181            seen.push(key.to_string());
1182        }
1183    }
1184    if !reads.is_empty() {
1185        read_ids(&reads);
1186    }
1187    remember_receipts(&seen);
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193
1194    fn outgoing(id: &str, from: &str, to: &[&str], body: &str) -> Outgoing {
1195        Outgoing {
1196            id: id.to_string(),
1197            from: from.to_string(),
1198            to: to.iter().map(|s| (*s).to_string()).collect(),
1199            body: body.to_string(),
1200            interrupt: false,
1201            issue: None,
1202            group: None,
1203            reply: None,
1204            scope: None,
1205        }
1206    }
1207
1208    #[test]
1209    fn a_long_body_fits_the_pack_and_reads_back_whole() {
1210        let long = "the aarch64 log says the Highway test times out. ".repeat(40);
1211        let m = outgoing("msg-1", "saddle", &["inky"], &long);
1212        let atom = message_atom(&m, "seat");
1213        let text = atom["text"].as_str().unwrap();
1214        assert!(
1215            text.chars().count() <= ATOM_TEXT_CAP,
1216            "{}",
1217            text.chars().count()
1218        );
1219        assert!(text.ends_with('\u{2026}'), "{text}");
1220        assert!(
1221            text.starts_with("msg msg-1\nfrom saddle\nto inky\n\n"),
1222            "{text}"
1223        );
1224        assert_eq!(atom["body"].as_str(), Some(long.trim()));
1225        let letter = letter_of(&atom, 0).expect("a letter");
1226        assert_eq!(letter.body, long.trim());
1227        // A short body has no `body` field and reads back from the text.
1228        let short = message_atom(&outgoing("msg-2", "saddle", &["inky"], "ok"), "seat");
1229        assert!(short.get("body").is_none());
1230        assert_eq!(letter_of(&short, 0).unwrap().body, "ok");
1231        // Past the mail cap, a refusal that names the deed route.
1232        let err = check_length("send", &"x".repeat(MAIL_BODY_CAP + 1))
1233            .unwrap_err()
1234            .to_string();
1235        assert!(err.contains("deedar create file"), "{err}");
1236        assert!(check_length("send", &"x".repeat(MAIL_BODY_CAP)).is_ok());
1237        let shown = prompt_body(&long);
1238        assert!(shown.contains("`ljos inbox --all`"), "{shown}");
1239        assert_eq!(prompt_body("ok"), "ok");
1240    }
1241
1242    fn atom(m: &Outgoing, ts: &str) -> Value {
1243        let mut v = message_atom(m, "seat");
1244        v["ts"] = json!(ts);
1245        v
1246    }
1247
1248    #[test]
1249    fn the_same_words_stay_two_messages() {
1250        let a = outgoing("a", "alice", &["bob"], "ok");
1251        let b = outgoing("b", "carol", &["bob"], "ok");
1252        let left = message_text(&a);
1253        let right = message_text(&b);
1254        assert_ne!(left, right);
1255        assert_ne!(super::super::work_id(&left), super::super::work_id(&right));
1256        assert_eq!(body_of(&left), "ok");
1257        let atoms = [
1258            atom(&a, "2026-10-09T00:00:00Z"),
1259            atom(&b, "2026-10-09T00:00:01Z"),
1260        ];
1261        let plain = super::super::sync::log_plaintext(&atoms, "seat", "seat");
1262        assert_eq!(plain.lines().count(), 2, "{plain}");
1263        let got = super::super::sync::incoming(&plain, "other", &BTreeSet::new(), &BTreeSet::new());
1264        assert_eq!(got.post.len(), 2);
1265    }
1266
1267    #[test]
1268    fn inbox_is_the_seat_named_and_an_interrupt_leads() {
1269        let mut late = outgoing("late", "alice", &["bob"], "when you can");
1270        late.issue = Some("acme-4".into());
1271        let mut hot = outgoing("hot", "carol", &["bob"], "the build is red");
1272        hot.interrupt = true;
1273        hot.issue = Some("acme-4".into());
1274        let other = outgoing("other", "alice", &["dave"], "not for bob");
1275        let atoms = [
1276            atom(&late, "2026-10-09T00:00:00Z"),
1277            atom(&hot, "2026-10-09T00:00:02Z"),
1278            atom(&other, "2026-10-09T00:00:01Z"),
1279        ];
1280        let letters = inbox_letters(&atoms, "bob", false);
1281        assert_eq!(
1282            letters.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(),
1283            ["hot", "late"]
1284        );
1285        assert!(letters[0].interrupt);
1286        assert_eq!(letters[1].issue.as_deref(), Some("acme-4"));
1287        let (text, ids) = prompt_lines(&letters, &[]);
1288        assert!(
1289            text.starts_with("mail:\n! hot from carol on acme-4:"),
1290            "{text}"
1291        );
1292        assert!(ids.contains(&"mail:hot".to_string()), "{ids:?}");
1293        assert!(text.contains("late from alice on acme-4:"));
1294    }
1295
1296    #[test]
1297    fn a_receipt_takes_the_message_out_of_the_unread_list() {
1298        let msg = outgoing("m1", "alice", &["bob"], "look at the log");
1299        let message = atom(&msg, "2026-10-09T00:00:00Z");
1300        let receipt = json!({
1301            "kind": "receipt",
1302            "text": "read m1\nby bob\nat 2026-10-09T00:01:00Z\nmsg r1",
1303            "ts": "2026-10-09T00:01:00Z",
1304            "entities": ["seat:bob", "from:bob", "to:alice", "receipt:m1", "msg:r1"],
1305        });
1306        let held = [message, receipt];
1307        let unread = inbox_letters(&held, "bob", false);
1308        assert!(unread.is_empty());
1309        let all = inbox_letters(&held, "bob", true);
1310        assert!(all[0].read);
1311        let (text, keys) = render_inbox(&held, "alice", false, &BTreeSet::new());
1312        assert!(
1313            text.contains("receipt\tm1\tbob\t2026-10-09T00:01:00Z"),
1314            "{text}"
1315        );
1316        assert_eq!(keys.len(), 1);
1317        let (again, _) = render_inbox(&held, "alice", false, &keys.into_iter().collect());
1318        assert_eq!(again, "inbox: nothing unread\n");
1319    }
1320
1321    #[test]
1322    fn a_reply_keeps_the_issue_and_names_the_sender() {
1323        let mut parent = outgoing("p", "alice", &["bob"], "the build is red");
1324        parent.issue = Some("acme-4".into());
1325        parent.scope = Some("tools".into());
1326        let letter = letter_of(&atom(&parent, "2026-10-09T00:00:00Z"), 0).unwrap();
1327        let reply = Outgoing {
1328            id: "r".into(),
1329            from: "bob".into(),
1330            to: vec![letter.from.clone()],
1331            body: "looking".into(),
1332            interrupt: false,
1333            issue: letter.issue.clone(),
1334            group: None,
1335            reply: Some(letter.id.clone()),
1336            scope: letter.scope.clone(),
1337        };
1338        let atom = message_atom(&reply, "seat");
1339        let back = letter_of(&atom, 1).unwrap();
1340        assert_eq!(back.to, ["alice"]);
1341        assert_eq!(back.reply.as_deref(), Some("p"));
1342        assert_eq!(back.issue.as_deref(), Some("acme-4"));
1343        assert_eq!(back.scope.as_deref(), Some("tools"));
1344        assert!(super::super::sync::is_own(&atom));
1345    }
1346
1347    #[test]
1348    fn a_group_send_reaches_the_other_members() {
1349        let atoms = [
1350            json!({
1351                "kind": "group",
1352                "ts": "2026-10-09T00:00:00Z",
1353                "entities": ["group:ops", "member:alice"],
1354            }),
1355            json!({
1356                "kind": "group",
1357                "ts": "2026-10-09T00:00:01Z",
1358                "entities": ["group:ops", "member:bob"],
1359            }),
1360            json!({
1361                "kind": "group",
1362                "ts": "2026-10-09T00:00:02Z",
1363                "entities": ["group:ops", "member:carol"],
1364            }),
1365            json!({
1366                "kind": "group",
1367                "ts": "2026-10-09T00:00:03Z",
1368                "entities": ["group:ops", "drop:carol"],
1369            }),
1370        ];
1371        assert_eq!(members(&atoms, "ops"), ["alice", "bob"]);
1372        let to = recipients("alice", None, Some("ops"), &members(&atoms, "ops")).unwrap();
1373        assert_eq!(to, ["bob"]);
1374        let err = recipients("alice", None, Some("ops"), &["alice".into()]).unwrap_err();
1375        assert!(err.to_string().contains("no other member"), "{err}");
1376        let err = recipients("alice", Some("alice"), None, &[]).unwrap_err();
1377        assert!(err.to_string().contains("this seat"), "{err}");
1378    }
1379
1380    #[test]
1381    fn a_prompt_caps_the_note_and_keeps_the_rest_unread() {
1382        let letters: Vec<Letter> = (0..10)
1383            .map(|n| Letter {
1384                id: format!("m{n}"),
1385                from: "alice".into(),
1386                to: vec!["bob".into()],
1387                body: format!("line {n}"),
1388                interrupt: n == 9,
1389                issue: None,
1390                group: None,
1391                reply: None,
1392                scope: None,
1393                read: false,
1394                ts: format!("2026-10-09T00:00:{n:02}Z"),
1395                seq: n,
1396            })
1397            .collect();
1398        let mut ordered = letters.clone();
1399        ordered.sort_by(|a, b| b.interrupt.cmp(&a.interrupt).then(a.ts.cmp(&b.ts)));
1400        let (text, ids) = prompt_lines(&ordered, &[]);
1401        assert!(text.lines().next().unwrap() == "mail:");
1402        assert!(text.contains("! m9 from alice:"));
1403        assert!(text.contains("2 more in ljos inbox"), "{text}");
1404        assert_eq!(ids.iter().filter(|id| id.starts_with("mail:")).count(), 8);
1405        assert!(!ids.iter().any(|id| id == "mail:m7" || id == "mail:m8"));
1406    }
1407
1408    #[test]
1409    fn a_seat_hint_names_a_few_seats_and_no_ids() {
1410        let known: BTreeSet<String> = [
1411            "alpha",
1412            "bravo",
1413            "charlie",
1414            "delta",
1415            "echo",
1416            "foxtrot",
1417            "saddle",
1418            "01a0dab3-895e-7533-93ca-c9807d5cb680",
1419            "sess-4f2a9c0d1e2b3a4f",
1420        ]
1421        .iter()
1422        .map(|s| (*s).to_string())
1423        .collect();
1424        assert!(named_seat("codex-vax0"));
1425        assert!(named_seat("disk-saver"));
1426        assert!(!named_seat("01a0dab3-895e-7533-93ca-c9807d5cb680"));
1427        assert!(!named_seat("sess-4f2a9c0d1e2b3a4f"));
1428        let hint = seat_hint("sadle", &known);
1429        assert_eq!(
1430            hint,
1431            "Did you mean saddle? Other seats: alpha, bravo, charlie, delta and 2 more."
1432        );
1433        let hint = seat_hint("zzz", &known);
1434        assert_eq!(hint, "Seats here: alpha, bravo, charlie, delta and 3 more.");
1435        let ids: BTreeSet<String> = ["01a0dab3-895e-7533-93ca-c9807d5cb680".to_string()].into();
1436        assert_eq!(
1437            seat_hint("codex", &ids),
1438            "No named seat has started here yet."
1439        );
1440        let err = check_known("sadle", &known, false).unwrap_err().to_string();
1441        assert!(!err.contains("01a0dab3"), "{err}");
1442        assert!(err.contains("Did you mean saddle?"), "{err}");
1443    }
1444
1445    #[test]
1446    fn a_misspelt_seat_is_answered_with_the_seat_it_meant() {
1447        let known: BTreeSet<String> = ["ljos-bot", "saddle", "inky"]
1448            .iter()
1449            .map(|s| (*s).to_string())
1450            .collect();
1451        assert_eq!(closest_seat("ljos", &known), Some("ljos-bot"));
1452        assert_eq!(closest_seat("sadle", &known), Some("saddle"));
1453        assert_eq!(closest_seat("eessi", &known), None);
1454        let err = check_known("ljos", &known, false).unwrap_err().to_string();
1455        assert!(err.contains("Did you mean ljos-bot?"), "{err}");
1456        let err = check_known("eessi", &known, false).unwrap_err().to_string();
1457        assert!(!err.contains("Did you mean"), "{err}");
1458        assert!(check_known("eessi", &known, true).is_ok());
1459    }
1460
1461    #[test]
1462    fn a_stop_carries_unread_interrupt_mail_only() {
1463        let plain = outgoing("plain", "grok", &["claude"], "when you can");
1464        let mut hot = outgoing("hot", "grok", &["claude"], "take ticket-d9n6 now");
1465        hot.interrupt = true;
1466        hot.issue = Some("ticket-d9n6".into());
1467        let atoms = [
1468            atom(&plain, "2026-10-11T00:00:00Z"),
1469            atom(&hot, "2026-10-11T00:00:01Z"),
1470        ];
1471        let (text, ids) = stop_lines(&atoms, "claude");
1472        assert!(text.starts_with(STOP_MAIL_HEAD), "{text}");
1473        assert!(
1474            text.contains("! hot from grok on ticket-d9n6: take ticket-d9n6 now"),
1475            "{text}"
1476        );
1477        assert!(!text.contains("when you can"), "{text}");
1478        assert_eq!(ids, ["mail:hot"]);
1479        let read = json!({
1480            "kind": "receipt",
1481            "text": "read hot\nby claude\nat 2026-10-11T00:00:02Z\nmsg r1",
1482            "entities": ["seat:claude", "from:claude", "to:grok", "receipt:hot", "msg:r1"],
1483        });
1484        let after = [atoms[0].clone(), atoms[1].clone(), read];
1485        assert_eq!(stop_lines(&after, "claude"), (String::new(), Vec::new()));
1486        assert_eq!(stop_lines(&atoms, "grok"), (String::new(), Vec::new()));
1487    }
1488
1489    #[test]
1490    fn an_unknown_kind_is_a_pack_without_mail() {
1491        let refused =
1492            "bad response: http://127.0.0.1:8761/v1/atoms: 400: unknown atom kind: message";
1493        assert_eq!(probe_of(refused), Probe::Refused);
1494        let kept = "bad response: http://127.0.0.1:8761/v1/atoms: 400: atom text is required";
1495        assert_eq!(probe_of(kept), Probe::Kept);
1496        let down = "http://127.0.0.1:8761/v1/atoms: http: connection refused";
1497        assert_eq!(probe_of(down), Probe::Store);
1498        let named = unsupported_mail(Some("0.12.1"));
1499        assert!(named.contains("packset 0.12.1"), "{named}");
1500        assert!(named.contains(MAIL_PACKSET), "{named}");
1501        assert!(named.contains("kind message"), "{named}");
1502        let quiet = unsupported_mail(None);
1503        assert!(quiet.contains(MAIL_PACKSET), "{quiet}");
1504        assert!(quiet.contains("refused kind message"), "{quiet}");
1505    }
1506}