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` (Software-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    let list = if known.is_empty() {
558        "none yet".to_string()
559    } else {
560        known.iter().cloned().collect::<Vec<_>>().join(", ")
561    };
562    let guess = closest_seat(seat, known)
563        .map(|s| format!("Did you mean {s}? "))
564        .unwrap_or_default();
565    bail!(
566        "send: no seat named {seat} has written to this pack, so the message would wait unread. \
567         {guess}Seats this pack knows: {list}. Pass --new-seat to write to a seat that has not started yet"
568    )
569}
570
571/// The known seat a mistyped name most likely meant: one that starts with
572/// it or holds it (`ljos` for `ljos-bot`), else the nearest by edits, at
573/// most two away. `None` when nothing is that close.
574#[must_use]
575pub fn closest_seat<'a>(seat: &str, known: &'a BTreeSet<String>) -> Option<&'a str> {
576    let lower = seat.to_lowercase();
577    if lower.is_empty() {
578        return None;
579    }
580    let holds = known
581        .iter()
582        .filter(|k| k.to_lowercase().contains(&lower) || lower.contains(&k.to_lowercase()))
583        .min_by_key(|k| (k.len().abs_diff(seat.len()), k.as_str()));
584    if let Some(k) = holds {
585        return Some(k.as_str());
586    }
587    known
588        .iter()
589        .map(|k| (edits(&lower, &k.to_lowercase()), k))
590        .filter(|(d, _)| *d <= 2)
591        .min()
592        .map(|(_, k)| k.as_str())
593}
594
595/// Levenshtein distance, in characters.
596fn edits(a: &str, b: &str) -> usize {
597    let b: Vec<char> = b.chars().collect();
598    let mut row: Vec<usize> = (0..=b.len()).collect();
599    for (i, ca) in a.chars().enumerate() {
600        let mut prev = row[0];
601        row[0] = i + 1;
602        for (j, cb) in b.iter().enumerate() {
603            let cur = row[j + 1];
604            row[j + 1] = if ca == *cb {
605                prev
606            } else {
607                1 + prev.min(cur).min(row[j])
608            };
609            prev = cur;
610        }
611    }
612    row[b.len()]
613}
614
615/// Who a send addresses. A group send leaves the sender off the list.
616pub fn recipients(
617    from: &str,
618    seat: Option<&str>,
619    group: Option<&str>,
620    members: &[String],
621) -> Result<Vec<String>> {
622    if let Some(group) = group {
623        if seat.is_some() {
624            bail!("send: name a seat or a group");
625        }
626        let to: Vec<String> = members
627            .iter()
628            .filter(|m| m.as_str() != from)
629            .cloned()
630            .collect();
631        if to.is_empty() {
632            bail!("send: {group} has no other member");
633        }
634        return Ok(to);
635    }
636    let Some(seat) = seat.map(str::trim).filter(|s| !s.is_empty()) else {
637        bail!("send: name a seat or a group");
638    };
639    let seat = check_name("send", seat)?;
640    if seat == from {
641        bail!("send: {seat} is this seat");
642    }
643    Ok(vec![seat])
644}
645
646fn is_mail_atom(atom: &Value) -> bool {
647    matches!(atom["kind"].as_str(), Some("message" | "receipt" | "group"))
648}
649
650fn writer_version(client: &packset_client::PacksetClient) -> Option<String> {
651    client
652        .status(Some(&client.workspace()))
653        .ok()
654        .and_then(|status| {
655            status
656                .get("version")
657                .and_then(Value::as_str)
658                .filter(|version| !version.is_empty())
659                .map(str::to_string)
660        })
661}
662
663/// Fail when this writer does not keep mail. An empty message is not
664/// stored: the kind is refused, or the text is.
665fn ensure_mail(client: &packset_client::PacksetClient, verb: &str) -> Result<()> {
666    // The writer's version decides when it gives one. packset 0.12.1 checks
667    // the text before the kind, so an empty probe reads the same from a
668    // writer that keeps mail and from one that refuses it.
669    if let Some(version) = writer_version(client) {
670        match super::cmp_semver(&version, MAIL_PACKSET) {
671            Some(std::cmp::Ordering::Less) => {
672                bail!("{verb}: {}", unsupported_mail(Some(&version)));
673            }
674            Some(_) => return Ok(()),
675            None => {}
676        }
677    }
678    let probe = json!({
679        "schema": "inside.atom/v1",
680        "kind": "message",
681        "level": "explicit",
682        "text": "",
683        "workspace": workspace_of(Some(&client.workspace())),
684    });
685    let posted = super::with_writer(|| client.post_atom(&probe).map_err(anyhow::Error::from));
686    match posted {
687        Ok(_) => Ok(()),
688        Err(err) => match probe_of(&format!("{err:#}")) {
689            Probe::Kept => Ok(()),
690            Probe::Refused => {
691                let version = writer_version(client);
692                bail!("{verb}: {}", unsupported_mail(version.as_deref()));
693            }
694            Probe::Store => Err(err).context("mail: the pack did not answer"),
695        },
696    }
697}
698
699fn listed() -> Result<Vec<Value>> {
700    let client = super::pack()?;
701    let workspace = client.workspace();
702    super::atoms_lean(&client, &workspace).context("mail: the pack did not answer")
703}
704
705fn load(verb: &str) -> Result<Vec<Value>> {
706    let client = super::pack()?;
707    let atoms = listed()?;
708    if !atoms.iter().any(is_mail_atom) {
709        ensure_mail(&client, verb)?;
710    }
711    Ok(atoms)
712}
713
714fn load_quiet() -> Option<Vec<Value>> {
715    let client = super::pack().ok()?;
716    let workspace = client.workspace();
717    super::atoms_lean(&client, &workspace).ok()
718}
719
720fn post(atom: &Value, verb: &str) -> Result<Value> {
721    let client = super::pack()?;
722    super::with_writer(|| {
723        client
724            .post_atom(atom)
725            .with_context(|| format!("{verb}: POST /v1/atoms failed"))
726    })
727}
728
729fn workspace_of(client_workspace: Option<&str>) -> String {
730    client_workspace.unwrap_or("seat").to_string()
731}
732
733fn stamp_source(atom: &mut Value) {
734    atom["source"] = super::atom_source();
735}
736
737/// Write one message and print its id.
738pub fn send(args: &Send<'_>) -> Result<String> {
739    let text = args.text.trim();
740    if text.is_empty() {
741        bail!("send: empty text");
742    }
743    check_length("send", text)?;
744    let from = super::seat_name();
745    let group = args
746        .group
747        .map(|name| check_name("send", name))
748        .transpose()?;
749    let client = super::pack()?;
750    // A named seat is checked before the pack, so "this seat" stays that
751    // error. A group has no roster until the pack answers.
752    let atoms = if group.is_some() {
753        ensure_mail(&client, "send")?;
754        listed()?
755    } else {
756        Vec::new()
757    };
758    let roster = group
759        .as_deref()
760        .map(|name| members(&atoms, name))
761        .unwrap_or_default();
762    let to = recipients(from.as_str(), args.seat, group.as_deref(), &roster)?;
763    if group.is_none() {
764        ensure_mail(&client, "send")?;
765        if !args.new_seat {
766            let known = known_seats(&listed()?);
767            for seat in &to {
768                check_known(seat, &known, false)?;
769            }
770        }
771    }
772    let issue = args
773        .issue
774        .map(str::trim)
775        .filter(|s| !s.is_empty())
776        .map(|s| check_name("send", s))
777        .transpose()?;
778    let scope = issue.as_deref().and_then(super::sync::scope_for_issue);
779    let outgoing = Outgoing {
780        id: mint_id("msg", &[&from, text]),
781        from,
782        to: to.clone(),
783        body: text.to_string(),
784        interrupt: args.interrupt,
785        issue,
786        group,
787        reply: None,
788        scope,
789    };
790    let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
791    stamp_source(&mut atom);
792    post(&atom, "send")?;
793    let mut said = format!("sent {} to {}\n", outgoing.id, to.join(", "));
794    said.push_str(&wake_after(&outgoing));
795    Ok(said)
796}
797
798/// Start the wake of each recipient that set one, and say which. The
799/// message is already written, so a wake that fails changes nothing here.
800fn wake_after(m: &Outgoing) -> String {
801    let events: Vec<super::wake::Event> =
802        m.to.iter()
803            .map(|seat| super::wake::Event {
804                seat: seat.clone(),
805                from: m.from.clone(),
806                msg: m.id.clone(),
807                issue: m.issue.clone(),
808                interrupt: m.interrupt,
809            })
810            .collect();
811    let woken = super::wake::spawn(&events);
812    if woken.is_empty() {
813        String::new()
814    } else {
815        format!("waking {}\n", woken.join(", "))
816    }
817}
818
819/// Unread mail for this seat, then receipts on what it sent that it has not
820/// been shown yet.
821///
822/// Listing does not write a receipt. `ljos hook --prompt` does, for each
823/// message it shows. A pack that does not answer, or that refuses mail, is
824/// an error. `ljos_inbox` calls this.
825pub fn inbox(all: bool) -> Result<String> {
826    let atoms = load("inbox")?;
827    let seat = super::seat_name();
828    let seen = receipt_seen();
829    let (text, keys) = render_inbox(&atoms, &seat, all, &seen);
830    remember_receipts(&keys);
831    Ok(text)
832}
833
834fn already_read(atoms: &[Value], msg: &str, seat: &str) -> bool {
835    readers_of(atoms).contains(&(msg.to_string(), seat.to_string()))
836}
837
838fn receipt_atom(msg: &Letter, reader: &str, at: &str, workspace: &str) -> Value {
839    let id = mint_id("receipt", &[msg.id.as_str(), reader, at]);
840    let text = format!("read {}\nby {reader}\nat {at}\nmsg {id}", msg.id);
841    let mut entities = vec![
842        format!("seat:{reader}"),
843        format!("from:{reader}"),
844        format!("to:{}", msg.from),
845        format!("msg:{id}"),
846        format!("receipt:{}", msg.id),
847    ];
848    if let Some(scope) = &msg.scope {
849        entities.push(format!("scope:{scope}"));
850    }
851    json!({
852        "schema": "inside.atom/v1",
853        "kind": "receipt",
854        "level": "explicit",
855        "text": text,
856        "workspace": workspace,
857        "entities": entities,
858    })
859}
860
861/// Write a receipt for one message. A second read of the same message
862/// writes nothing.
863pub fn read(id: &str) -> Result<String> {
864    let id = id.trim();
865    if id.is_empty() {
866        bail!("read: name a message");
867    }
868    let atoms = load("read")?;
869    let seat = super::seat_name();
870    let letter = find_letter(&atoms, id).context("read")?;
871    if !letter.to.iter().any(|to| to == &seat) {
872        bail!("read: {id} is not for {seat}");
873    }
874    if already_read(&atoms, id, &seat) {
875        return Ok(format!("read {id} already\n"));
876    }
877    let client = super::pack()?;
878    let mut atom = receipt_atom(
879        &letter,
880        &seat,
881        &super::now_utc(),
882        &workspace_of(Some(&client.workspace())),
883    );
884    stamp_source(&mut atom);
885    post(&atom, "read")?;
886    Ok(format!("read {id}\n"))
887}
888
889fn read_ids(ids: &[String]) {
890    let Some(atoms) = load_quiet() else {
891        return;
892    };
893    let seat = super::seat_name();
894    let Ok(client) = super::pack() else {
895        return;
896    };
897    let workspace = client.workspace();
898    let at = super::now_utc();
899    for id in ids {
900        let Ok(letter) = find_letter(&atoms, id) else {
901            continue;
902        };
903        if !letter.to.iter().any(|to| to == &seat) || already_read(&atoms, id, &seat) {
904            continue;
905        }
906        let mut atom = receipt_atom(&letter, &seat, &at, &workspace_of(Some(&workspace)));
907        stamp_source(&mut atom);
908        let _ = post(&atom, "read");
909    }
910}
911
912/// Answer a message. The reply goes to its sender and keeps its issue.
913pub fn reply(id: &str, text: &str, interrupt: bool) -> Result<String> {
914    let id = id.trim();
915    let text = text.trim();
916    if id.is_empty() {
917        bail!("reply: name a message");
918    }
919    if text.is_empty() {
920        bail!("reply: empty text");
921    }
922    check_length("reply", text)?;
923    let atoms = load("reply")?;
924    let parent = find_letter(&atoms, id).context("reply")?;
925    let from = super::seat_name();
926    if parent.from == from {
927        bail!("reply: {id} is from this seat");
928    }
929    let issue = parent.issue.clone();
930    let scope = issue
931        .as_deref()
932        .and_then(super::sync::scope_for_issue)
933        .or(parent.scope);
934    let outgoing = Outgoing {
935        id: mint_id("msg", &[&from, id, text]),
936        from,
937        to: vec![parent.from.clone()],
938        body: text.to_string(),
939        interrupt,
940        issue,
941        group: None,
942        reply: Some(parent.id.clone()),
943        scope,
944    };
945    let client = super::pack()?;
946    let mut atom = message_atom(&outgoing, &workspace_of(Some(&client.workspace())));
947    stamp_source(&mut atom);
948    post(&atom, "reply")?;
949    let mut line = format!(
950        "sent {} to {}\nreply {}\n",
951        outgoing.id, parent.from, parent.id
952    );
953    if let Some(issue) = &outgoing.issue {
954        line.push_str(&format!("issue {issue}\n"));
955    }
956    line.push_str(&wake_after(&outgoing));
957    Ok(line)
958}
959
960fn group_atom(name: &str, seat: &str, drop: bool, by: &str, workspace: &str) -> Value {
961    let at = super::now_utc();
962    let id = mint_id("group", &[name, seat, by, &at]);
963    let verb = if drop { "drop" } else { "member" };
964    let text = format!("group {id}\nname {name}\n{verb} {seat}\nby {by}\nat {at}");
965    let mut entities = vec![
966        format!("seat:{by}"),
967        format!("group:{name}"),
968        format!("msg:{id}"),
969    ];
970    if drop {
971        entities.push(format!("drop:{seat}"));
972    } else {
973        entities.push(format!("member:{seat}"));
974    }
975    json!({
976        "schema": "inside.atom/v1",
977        "kind": "group",
978        "level": "explicit",
979        "text": text,
980        "workspace": workspace,
981        "entities": entities,
982    })
983}
984
985/// List a group, or add and remove members. A drop is its own atom.
986pub fn group(name: &str, add: &[String], remove: &[String]) -> Result<String> {
987    let name = check_name("group", name)?;
988    if add.is_empty() && remove.is_empty() {
989        let atoms = load("group")?;
990        let seats = members(&atoms, &name);
991        if seats.is_empty() {
992            return Ok(format!("{name}\t(no members)\n"));
993        }
994        let mut out = String::new();
995        for seat in seats {
996            out.push_str(&format!("{name}\t{seat}\n"));
997        }
998        return Ok(out);
999    }
1000    let atoms = load("group")?;
1001    let by = super::seat_name();
1002    let client = super::pack()?;
1003    let workspace = client.workspace();
1004    let mut roster = members(&atoms, &name);
1005    let mut out = String::new();
1006    for seat in add {
1007        let seat = check_name("group", seat)?;
1008        if roster.iter().any(|s| s == &seat) {
1009            out.push_str(&format!("{seat} is already in {name}\n"));
1010            continue;
1011        }
1012        let mut atom = group_atom(&name, &seat, false, &by, &workspace_of(Some(&workspace)));
1013        stamp_source(&mut atom);
1014        post(&atom, "group")?;
1015        roster.push(seat.clone());
1016        out.push_str(&format!("added {seat} to {name}\n"));
1017    }
1018    for seat in remove {
1019        let seat = check_name("group", seat)?;
1020        if !roster.iter().any(|s| s == &seat) {
1021            out.push_str(&format!("{seat} is not in {name}\n"));
1022            continue;
1023        }
1024        let mut atom = group_atom(&name, &seat, true, &by, &workspace_of(Some(&workspace)));
1025        stamp_source(&mut atom);
1026        post(&atom, "group")?;
1027        roster.retain(|s| s != &seat);
1028        out.push_str(&format!("removed {seat} from {name}\n"));
1029    }
1030    Ok(out)
1031}
1032
1033fn state_dir() -> PathBuf {
1034    std::env::var_os("XDG_STATE_HOME")
1035        .map(PathBuf::from)
1036        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
1037        .unwrap_or_else(|| PathBuf::from(".local/state"))
1038        .join("ljos")
1039        .join("mail")
1040}
1041
1042fn receipt_seen_path() -> PathBuf {
1043    state_dir().join("receipts-seen")
1044}
1045
1046fn receipt_seen() -> BTreeSet<String> {
1047    std::fs::read_to_string(receipt_seen_path())
1048        .map(|text| {
1049            text.lines()
1050                .map(str::trim)
1051                .filter(|l| !l.is_empty())
1052                .map(str::to_string)
1053                .collect()
1054        })
1055        .unwrap_or_default()
1056}
1057
1058fn remember_receipts(keys: &[String]) {
1059    if keys.is_empty() {
1060        return;
1061    }
1062    let path = receipt_seen_path();
1063    if let Some(dir) = path.parent() {
1064        let _ = std::fs::create_dir_all(dir);
1065    }
1066    let mut text = std::fs::read_to_string(&path).unwrap_or_default();
1067    for key in keys {
1068        if text.lines().any(|l| l == key) {
1069            continue;
1070        }
1071        text.push_str(key);
1072        text.push('\n');
1073    }
1074    let _ = std::fs::write(path, text);
1075}
1076
1077/// What the prompt hook prints when the pack does not answer.
1078///
1079/// One line. The hook still exits 0, so the prompt is not blocked.
1080pub const MAIL_UNCHECKED: &str = "mail could not be checked";
1081
1082/// Unread mail and unseen receipts for the prompt hook. A pack that does
1083/// not answer leaves one line, so unread mail is not silent.
1084#[must_use]
1085pub fn prompt_note() -> (String, Vec<String>) {
1086    let Some(atoms) = load_quiet() else {
1087        return (MAIL_UNCHECKED.to_string(), Vec::new());
1088    };
1089    let seat = super::seat_name();
1090    let letters = inbox_letters(&atoms, &seat, false);
1091    let seen = receipt_seen();
1092    let receipts: Vec<Receipt> = receipts_for(&atoms, &seat)
1093        .into_iter()
1094        .filter(|r| !seen.contains(&r.key))
1095        .collect();
1096    prompt_lines(&letters, &receipts)
1097}
1098
1099/// The line a held stop opens with, before the mail it carries.
1100pub const STOP_MAIL_HEAD: &str =
1101    "Mail marked interrupt reached this seat while you worked. Read it before you stop:";
1102
1103/// Unread interrupt mail for a stop hook, and the ids to receipt once the
1104/// runner has it. A running session takes it as one more round before it
1105/// goes idle, so a sender's interrupt does not wait for a prompt that may
1106/// never come. Plain mail waits for the next prompt or the seat's wake. A
1107/// pack that does not answer says nothing here: holding a stop on an
1108/// error would keep every turn open.
1109#[must_use]
1110pub fn stop_note() -> (String, Vec<String>) {
1111    let Some(atoms) = load_quiet() else {
1112        return (String::new(), Vec::new());
1113    };
1114    stop_lines(&atoms, &super::seat_name())
1115}
1116
1117/// [`stop_note`] over atoms already read.
1118#[must_use]
1119pub fn stop_lines(atoms: &[Value], seat: &str) -> (String, Vec<String>) {
1120    let hot: Vec<Letter> = inbox_letters(atoms, seat, false)
1121        .into_iter()
1122        .filter(|l| l.interrupt)
1123        .collect();
1124    let (lines, ids) = prompt_lines(&hot, &[]);
1125    if lines.is_empty() {
1126        return (String::new(), Vec::new());
1127    }
1128    let reply = "Answer with `ljos reply ID TEXT`.";
1129    (format!("{STOP_MAIL_HEAD}\n{lines}\n{reply}"), ids)
1130}
1131
1132/// Receipts for messages a runner was just shown, and a local mark for
1133/// receipts this seat was told about. A pack that does not answer leaves
1134/// the message unread, so the next prompt shows it again.
1135pub fn ack_delivered(ids: &[String]) {
1136    let mut reads = Vec::new();
1137    let mut seen = Vec::new();
1138    for id in ids {
1139        if let Some(msg) = id.strip_prefix("mail:") {
1140            reads.push(msg.to_string());
1141        } else if let Some(key) = id.strip_prefix("rcpt:") {
1142            seen.push(key.to_string());
1143        }
1144    }
1145    if !reads.is_empty() {
1146        read_ids(&reads);
1147    }
1148    remember_receipts(&seen);
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153    use super::*;
1154
1155    fn outgoing(id: &str, from: &str, to: &[&str], body: &str) -> Outgoing {
1156        Outgoing {
1157            id: id.to_string(),
1158            from: from.to_string(),
1159            to: to.iter().map(|s| (*s).to_string()).collect(),
1160            body: body.to_string(),
1161            interrupt: false,
1162            issue: None,
1163            group: None,
1164            reply: None,
1165            scope: None,
1166        }
1167    }
1168
1169    #[test]
1170    fn a_long_body_fits_the_pack_and_reads_back_whole() {
1171        let long = "the aarch64 log says the Highway test times out. ".repeat(40);
1172        let m = outgoing("msg-1", "saddle", &["inky"], &long);
1173        let atom = message_atom(&m, "seat");
1174        let text = atom["text"].as_str().unwrap();
1175        assert!(
1176            text.chars().count() <= ATOM_TEXT_CAP,
1177            "{}",
1178            text.chars().count()
1179        );
1180        assert!(text.ends_with('\u{2026}'), "{text}");
1181        assert!(
1182            text.starts_with("msg msg-1\nfrom saddle\nto inky\n\n"),
1183            "{text}"
1184        );
1185        assert_eq!(atom["body"].as_str(), Some(long.trim()));
1186        let letter = letter_of(&atom, 0).expect("a letter");
1187        assert_eq!(letter.body, long.trim());
1188        // A short body has no `body` field and reads back from the text.
1189        let short = message_atom(&outgoing("msg-2", "saddle", &["inky"], "ok"), "seat");
1190        assert!(short.get("body").is_none());
1191        assert_eq!(letter_of(&short, 0).unwrap().body, "ok");
1192        // Past the mail cap, a refusal that names the deed route.
1193        let err = check_length("send", &"x".repeat(MAIL_BODY_CAP + 1))
1194            .unwrap_err()
1195            .to_string();
1196        assert!(err.contains("deedar create file"), "{err}");
1197        assert!(check_length("send", &"x".repeat(MAIL_BODY_CAP)).is_ok());
1198        let shown = prompt_body(&long);
1199        assert!(shown.contains("`ljos inbox --all`"), "{shown}");
1200        assert_eq!(prompt_body("ok"), "ok");
1201    }
1202
1203    fn atom(m: &Outgoing, ts: &str) -> Value {
1204        let mut v = message_atom(m, "seat");
1205        v["ts"] = json!(ts);
1206        v
1207    }
1208
1209    #[test]
1210    fn the_same_words_stay_two_messages() {
1211        let a = outgoing("a", "alice", &["bob"], "ok");
1212        let b = outgoing("b", "carol", &["bob"], "ok");
1213        let left = message_text(&a);
1214        let right = message_text(&b);
1215        assert_ne!(left, right);
1216        assert_ne!(super::super::work_id(&left), super::super::work_id(&right));
1217        assert_eq!(body_of(&left), "ok");
1218        let atoms = [
1219            atom(&a, "2026-10-09T00:00:00Z"),
1220            atom(&b, "2026-10-09T00:00:01Z"),
1221        ];
1222        let plain = super::super::sync::log_plaintext(&atoms, "seat", "seat");
1223        assert_eq!(plain.lines().count(), 2, "{plain}");
1224        let got = super::super::sync::incoming(&plain, "other", &BTreeSet::new(), &BTreeSet::new());
1225        assert_eq!(got.post.len(), 2);
1226    }
1227
1228    #[test]
1229    fn inbox_is_the_seat_named_and_an_interrupt_leads() {
1230        let mut late = outgoing("late", "alice", &["bob"], "when you can");
1231        late.issue = Some("acme-4".into());
1232        let mut hot = outgoing("hot", "carol", &["bob"], "the build is red");
1233        hot.interrupt = true;
1234        hot.issue = Some("acme-4".into());
1235        let other = outgoing("other", "alice", &["dave"], "not for bob");
1236        let atoms = [
1237            atom(&late, "2026-10-09T00:00:00Z"),
1238            atom(&hot, "2026-10-09T00:00:02Z"),
1239            atom(&other, "2026-10-09T00:00:01Z"),
1240        ];
1241        let letters = inbox_letters(&atoms, "bob", false);
1242        assert_eq!(
1243            letters.iter().map(|l| l.id.as_str()).collect::<Vec<_>>(),
1244            ["hot", "late"]
1245        );
1246        assert!(letters[0].interrupt);
1247        assert_eq!(letters[1].issue.as_deref(), Some("acme-4"));
1248        let (text, ids) = prompt_lines(&letters, &[]);
1249        assert!(
1250            text.starts_with("mail:\n! hot from carol on acme-4:"),
1251            "{text}"
1252        );
1253        assert!(ids.contains(&"mail:hot".to_string()), "{ids:?}");
1254        assert!(text.contains("late from alice on acme-4:"));
1255    }
1256
1257    #[test]
1258    fn a_receipt_takes_the_message_out_of_the_unread_list() {
1259        let msg = outgoing("m1", "alice", &["bob"], "look at the log");
1260        let message = atom(&msg, "2026-10-09T00:00:00Z");
1261        let receipt = json!({
1262            "kind": "receipt",
1263            "text": "read m1\nby bob\nat 2026-10-09T00:01:00Z\nmsg r1",
1264            "ts": "2026-10-09T00:01:00Z",
1265            "entities": ["seat:bob", "from:bob", "to:alice", "receipt:m1", "msg:r1"],
1266        });
1267        let held = [message, receipt];
1268        let unread = inbox_letters(&held, "bob", false);
1269        assert!(unread.is_empty());
1270        let all = inbox_letters(&held, "bob", true);
1271        assert!(all[0].read);
1272        let (text, keys) = render_inbox(&held, "alice", false, &BTreeSet::new());
1273        assert!(
1274            text.contains("receipt\tm1\tbob\t2026-10-09T00:01:00Z"),
1275            "{text}"
1276        );
1277        assert_eq!(keys.len(), 1);
1278        let (again, _) = render_inbox(&held, "alice", false, &keys.into_iter().collect());
1279        assert_eq!(again, "inbox: nothing unread\n");
1280    }
1281
1282    #[test]
1283    fn a_reply_keeps_the_issue_and_names_the_sender() {
1284        let mut parent = outgoing("p", "alice", &["bob"], "the build is red");
1285        parent.issue = Some("acme-4".into());
1286        parent.scope = Some("tools".into());
1287        let letter = letter_of(&atom(&parent, "2026-10-09T00:00:00Z"), 0).unwrap();
1288        let reply = Outgoing {
1289            id: "r".into(),
1290            from: "bob".into(),
1291            to: vec![letter.from.clone()],
1292            body: "looking".into(),
1293            interrupt: false,
1294            issue: letter.issue.clone(),
1295            group: None,
1296            reply: Some(letter.id.clone()),
1297            scope: letter.scope.clone(),
1298        };
1299        let atom = message_atom(&reply, "seat");
1300        let back = letter_of(&atom, 1).unwrap();
1301        assert_eq!(back.to, ["alice"]);
1302        assert_eq!(back.reply.as_deref(), Some("p"));
1303        assert_eq!(back.issue.as_deref(), Some("acme-4"));
1304        assert_eq!(back.scope.as_deref(), Some("tools"));
1305        assert!(super::super::sync::is_own(&atom));
1306    }
1307
1308    #[test]
1309    fn a_group_send_reaches_the_other_members() {
1310        let atoms = [
1311            json!({
1312                "kind": "group",
1313                "ts": "2026-10-09T00:00:00Z",
1314                "entities": ["group:ops", "member:alice"],
1315            }),
1316            json!({
1317                "kind": "group",
1318                "ts": "2026-10-09T00:00:01Z",
1319                "entities": ["group:ops", "member:bob"],
1320            }),
1321            json!({
1322                "kind": "group",
1323                "ts": "2026-10-09T00:00:02Z",
1324                "entities": ["group:ops", "member:carol"],
1325            }),
1326            json!({
1327                "kind": "group",
1328                "ts": "2026-10-09T00:00:03Z",
1329                "entities": ["group:ops", "drop:carol"],
1330            }),
1331        ];
1332        assert_eq!(members(&atoms, "ops"), ["alice", "bob"]);
1333        let to = recipients("alice", None, Some("ops"), &members(&atoms, "ops")).unwrap();
1334        assert_eq!(to, ["bob"]);
1335        let err = recipients("alice", None, Some("ops"), &["alice".into()]).unwrap_err();
1336        assert!(err.to_string().contains("no other member"), "{err}");
1337        let err = recipients("alice", Some("alice"), None, &[]).unwrap_err();
1338        assert!(err.to_string().contains("this seat"), "{err}");
1339    }
1340
1341    #[test]
1342    fn a_prompt_caps_the_note_and_keeps_the_rest_unread() {
1343        let letters: Vec<Letter> = (0..10)
1344            .map(|n| Letter {
1345                id: format!("m{n}"),
1346                from: "alice".into(),
1347                to: vec!["bob".into()],
1348                body: format!("line {n}"),
1349                interrupt: n == 9,
1350                issue: None,
1351                group: None,
1352                reply: None,
1353                scope: None,
1354                read: false,
1355                ts: format!("2026-10-09T00:00:{n:02}Z"),
1356                seq: n,
1357            })
1358            .collect();
1359        let mut ordered = letters.clone();
1360        ordered.sort_by(|a, b| b.interrupt.cmp(&a.interrupt).then(a.ts.cmp(&b.ts)));
1361        let (text, ids) = prompt_lines(&ordered, &[]);
1362        assert!(text.lines().next().unwrap() == "mail:");
1363        assert!(text.contains("! m9 from alice:"));
1364        assert!(text.contains("2 more in ljos inbox"), "{text}");
1365        assert_eq!(ids.iter().filter(|id| id.starts_with("mail:")).count(), 8);
1366        assert!(!ids.iter().any(|id| id == "mail:m7" || id == "mail:m8"));
1367    }
1368
1369    #[test]
1370    fn a_misspelt_seat_is_answered_with_the_seat_it_meant() {
1371        let known: BTreeSet<String> = ["ljos-bot", "saddle", "inky"]
1372            .iter()
1373            .map(|s| (*s).to_string())
1374            .collect();
1375        assert_eq!(closest_seat("ljos", &known), Some("ljos-bot"));
1376        assert_eq!(closest_seat("sadle", &known), Some("saddle"));
1377        assert_eq!(closest_seat("eessi", &known), None);
1378        let err = check_known("ljos", &known, false).unwrap_err().to_string();
1379        assert!(err.contains("Did you mean ljos-bot?"), "{err}");
1380        let err = check_known("eessi", &known, false).unwrap_err().to_string();
1381        assert!(!err.contains("Did you mean"), "{err}");
1382        assert!(check_known("eessi", &known, true).is_ok());
1383    }
1384
1385    #[test]
1386    fn a_stop_carries_unread_interrupt_mail_only() {
1387        let plain = outgoing("plain", "grok", &["claude"], "when you can");
1388        let mut hot = outgoing("hot", "grok", &["claude"], "take Software-d9n6 now");
1389        hot.interrupt = true;
1390        hot.issue = Some("Software-d9n6".into());
1391        let atoms = [
1392            atom(&plain, "2026-10-11T00:00:00Z"),
1393            atom(&hot, "2026-10-11T00:00:01Z"),
1394        ];
1395        let (text, ids) = stop_lines(&atoms, "claude");
1396        assert!(text.starts_with(STOP_MAIL_HEAD), "{text}");
1397        assert!(
1398            text.contains("! hot from grok on Software-d9n6: take Software-d9n6 now"),
1399            "{text}"
1400        );
1401        assert!(!text.contains("when you can"), "{text}");
1402        assert_eq!(ids, ["mail:hot"]);
1403        let read = json!({
1404            "kind": "receipt",
1405            "text": "read hot\nby claude\nat 2026-10-11T00:00:02Z\nmsg r1",
1406            "entities": ["seat:claude", "from:claude", "to:grok", "receipt:hot", "msg:r1"],
1407        });
1408        let after = [atoms[0].clone(), atoms[1].clone(), read];
1409        assert_eq!(stop_lines(&after, "claude"), (String::new(), Vec::new()));
1410        assert_eq!(stop_lines(&atoms, "grok"), (String::new(), Vec::new()));
1411    }
1412
1413    #[test]
1414    fn an_unknown_kind_is_a_pack_without_mail() {
1415        let refused =
1416            "bad response: http://127.0.0.1:8761/v1/atoms: 400: unknown atom kind: message";
1417        assert_eq!(probe_of(refused), Probe::Refused);
1418        let kept = "bad response: http://127.0.0.1:8761/v1/atoms: 400: atom text is required";
1419        assert_eq!(probe_of(kept), Probe::Kept);
1420        let down = "http://127.0.0.1:8761/v1/atoms: http: connection refused";
1421        assert_eq!(probe_of(down), Probe::Store);
1422        let named = unsupported_mail(Some("0.12.1"));
1423        assert!(named.contains("packset 0.12.1"), "{named}");
1424        assert!(named.contains(MAIL_PACKSET), "{named}");
1425        assert!(named.contains("kind message"), "{named}");
1426        let quiet = unsupported_mail(None);
1427        assert!(quiet.contains(MAIL_PACKSET), "{quiet}");
1428        assert!(quiet.contains("refused kind message"), "{quiet}");
1429    }
1430}