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