Skip to main content

ljos_cli/
note_queue.rs

1//! Memory copies the pack did not take because it was down or did not know
2//! this seat.
3//!
4//! `ljos note` writes the logbook line first, then a `note` atom to the
5//! pack. When the pack is off, not answering, or answers 401, the fitted
6//! copy waits here, one JSON line per copy. The next command that may
7//! send it tries under the remember budget, and tries once more if the
8//! pack is still busy. `ljos search` and `ljos sitting` say when a lesson
9//! is still waiting. `ljos doctor` only counts it. A copy the pack would refuse is kept in the
10//! memory slot with its label and leaves the queue. A line left from
11//! before that rule is cut to what the pack takes on the next send, and
12//! the original stays in the slot. A note that has to wait stays queued.
13//! Later notes are still tried. The queue holds one entry per issue, text,
14//! workspace, kind
15//! and, for a lesson, the atoms it closes. The seat that queued the copy
16//! stays on the row, so a later seat does not take it. packsetd answers an
17//! identical issue and text with the claim it already holds, so a resend
18//! after a lost answer stores nothing new.
19
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result};
23use serde::{Deserialize, Serialize};
24
25fn default_kind() -> String {
26    "note".into()
27}
28
29/// One memory waiting for the pack.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Queued {
32    pub issue: String,
33    pub text: String,
34    /// The pack workspace the note was meant for.
35    pub workspace: String,
36    /// Unix seconds when the pack did not take it.
37    pub at: u64,
38    /// `note` or `lesson`. A line written before this field reads as `note`.
39    #[serde(default = "default_kind")]
40    pub kind: String,
41    /// Atoms a queued lesson closes. Empty for a note, and for a lesson
42    /// that closes nothing. A line written before this field reads as empty.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub supersedes: Vec<String>,
45    /// The seat that queued the copy. Empty on a line written before this
46    /// field. The seat that drains the queue does not replace it.
47    #[serde(default, skip_serializing_if = "String::is_empty")]
48    pub seat: String,
49    /// The memory slot (`<tracker>/.ljos/memory`) the copy belongs to.
50    /// Empty on a line written before this field.
51    #[serde(default, skip_serializing_if = "String::is_empty")]
52    pub home: String,
53}
54
55impl Queued {
56    pub(crate) fn same(&self, other: &Self) -> bool {
57        self.issue.trim() == other.issue.trim()
58            && self.text.trim() == other.text.trim()
59            && self.workspace == other.workspace
60            && self.kind == other.kind
61            && self.supersedes == other.supersedes
62            && self.seat == other.seat
63            && self.home == other.home
64    }
65}
66
67/// What one send says about a queued note.
68pub enum Sent {
69    /// The pack holds it, under this id.
70    Kept(String),
71    /// The pack refused it; the reason. The note leaves the queue.
72    /// The caller keeps the text in the memory slot.
73    Refused(String),
74    /// The pack did not answer. The note stays queued. Later notes are
75    /// still tried, so one waiting row does not stall the rest.
76    Wait(String),
77}
78
79/// What a drain did.
80#[derive(Debug, Default, Clone, PartialEq, Eq)]
81pub struct Drained {
82    /// Issue and pack id of each note the pack now holds.
83    pub kept: Vec<(String, String)>,
84    /// Issue and reason of each note the pack refused.
85    pub refused: Vec<(String, String)>,
86    /// Notes still waiting.
87    pub left: usize,
88    /// Why the first waiting note did not go, when one did not.
89    pub waiting_on: Option<String>,
90    /// Queue bytes that were not a note, moved out before a rewrite.
91    pub set_aside: Vec<SetAside>,
92}
93
94/// Bytes of the queue a rewrite moved to a file beside it rather than
95/// drop: complete lines that did not parse and a last line without its
96/// newline.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct SetAside {
99    pub path: PathBuf,
100    pub bytes: usize,
101}
102
103/// The state dir that holds the queue: `$XDG_STATE_HOME/ljos`, else
104/// `~/.local/state/ljos`.
105#[must_use]
106pub fn state_dir() -> PathBuf {
107    std::env::var_os("XDG_STATE_HOME")
108        .map(PathBuf::from)
109        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
110        .unwrap_or_else(|| PathBuf::from(".local/state"))
111        .join("ljos")
112}
113
114fn queue_file(dir: &Path) -> PathBuf {
115    dir.join("note-queue.jsonl")
116}
117
118/// Whether `dir` already holds the queue file. A missing directory or a
119/// missing file is false, and neither this nor a later read creates them.
120pub(crate) fn queue_exists(dir: &Path) -> bool {
121    let path = queue_file(dir);
122    let Ok((opened, name)) = crate::open_state_dir(&path) else {
123        return false;
124    };
125    crate::open_regular_at(&opened, &name, &path, libc::O_RDONLY, 0).is_ok()
126}
127
128fn lock_file(dir: &Path) -> PathBuf {
129    dir.join("note-queue.lock")
130}
131
132fn lock(dir: &Path) -> crate::CommitLock {
133    let _ = std::fs::create_dir_all(dir);
134    crate::CommitLock::acquire_private(&lock_file(dir))
135}
136
137/// The queue lock in `dir` when another ljos holds it now, queueing or
138/// sending; tried without waiting for it.
139#[must_use]
140pub fn busy_in(dir: &Path) -> Option<PathBuf> {
141    let lock = lock_file(dir);
142    crate::CommitLock::is_held_private(&lock).then_some(lock)
143}
144
145fn read(dir: &Path) -> Vec<Queued> {
146    read_split(dir).0
147}
148
149/// The queue's notes, and its bytes that are not one: each complete line
150/// that does not parse, and a last line without its newline. Under the
151/// lock no writer is part way through that last line; every append holds
152/// it.
153fn read_split(dir: &Path) -> (Vec<Queued>, Vec<u8>) {
154    let bytes = crate::read_state_file(&queue_file(dir)).unwrap_or_default();
155    let mut notes = Vec::new();
156    let mut unread = Vec::new();
157    for line in bytes.split_inclusive(|&b| b == b'\n') {
158        match line.strip_suffix(b"\n").map(serde_json::from_slice) {
159            Some(Ok(note)) => notes.push(note),
160            _ if line == b"\n" => {}
161            _ => unread.extend_from_slice(line),
162        }
163    }
164    (notes, unread)
165}
166
167/// Where a rewrite on this day puts the queue bytes it could not read.
168fn damaged_file(dir: &Path) -> PathBuf {
169    dir.join(format!(
170        "note-queue.jsonl.{}.damaged",
171        chrono::Utc::now().format("%Y-%m-%d")
172    ))
173}
174
175/// [`set_aside_at`] for `dir`, opened the way every state-log open is: a
176/// link in place of `dir` is refused and lists nothing.
177#[must_use]
178pub fn set_aside_in(dir: &Path) -> Vec<(PathBuf, u64)> {
179    match crate::open_state_dir(&queue_file(dir)) {
180        Ok((fd, _)) => set_aside_at(&fd, dir),
181        Err(_) => Vec::new(),
182    }
183}
184
185/// The damaged files a rewrite left in the state dir open as `fd`, named
186/// below `dir`, with their sizes, oldest day first. Each name is listed
187/// and stated through `fd`, without following a link and without opening
188/// one, so name and size come from the same directory; a link or
189/// anything else that is not a regular file is left out, since a send
190/// never writes through one.
191#[must_use]
192pub fn set_aside_at(fd: &std::fs::File, dir: &Path) -> Vec<(PathBuf, u64)> {
193    let Ok(names) = crate::deedbag::list_names(fd) else {
194        return Vec::new();
195    };
196    let mut found: Vec<(PathBuf, u64)> = names
197        .iter()
198        .filter_map(|name| {
199            let text = name.to_str().ok()?;
200            text.strip_prefix("note-queue.jsonl.")?
201                .strip_suffix(".damaged")?;
202            let st = crate::deedbag::stat_at(fd, name).ok()??;
203            let size = u64::try_from(st.st_size).ok()?;
204            ((st.st_mode & libc::S_IFMT) == libc::S_IFREG).then(|| (dir.join(text), size))
205        })
206        .collect();
207    found.sort();
208    found
209}
210
211/// Add `unread` to the day's damaged file and put it on disk, before the
212/// rewrite that leaves those bytes out of the queue.
213fn set_aside(dir: &Path, unread: &[u8]) -> Result<Option<SetAside>> {
214    if unread.is_empty() {
215        return Ok(None);
216    }
217    let path = damaged_file(dir);
218    crate::append_locked_row(&path, unread, crate::Durability::Durable)
219        .and_then(|()| crate::sync_state_file(&path))
220        .with_context(|| format!("set aside unreadable queue bytes in {}", path.display()))?;
221    Ok(Some(SetAside {
222        path,
223        bytes: unread.len(),
224    }))
225}
226
227/// Rewrite the queue to `notes`. `unread`, what [`read_split`] could not
228/// read, is set aside first, so a rewrite drops no byte it could not
229/// parse.
230fn write(dir: &Path, notes: &[Queued], unread: &[u8]) -> Result<Option<SetAside>> {
231    let _durable = crate::bookkeep::durable_write();
232    let aside = set_aside(dir, unread)?;
233    let path = queue_file(dir);
234    if notes.is_empty() {
235        // Opened, not stat'ed through the path: a dangling link is refused
236        // instead of read as no queue.
237        let present = crate::open_state_dir(&path)
238            .and_then(|(fd, name)| crate::open_regular_at(&fd, &name, &path, libc::O_RDONLY, 0));
239        match present {
240            Ok(_) => crate::rewrite_state_file(&path, b"")
241                .with_context(|| format!("empty {}", path.display()))?,
242            Err(err) if err.raw_os_error() == Some(libc::ENOENT) => {}
243            Err(err) => return Err(err).with_context(|| format!("empty {}", path.display())),
244        }
245        return Ok(aside);
246    }
247    let mut body = String::new();
248    for note in notes {
249        body.push_str(&serde_json::to_string(note)?);
250        body.push('\n');
251    }
252    crate::rewrite_state_file(&path, body.as_bytes())
253        .with_context(|| format!("replace {}", path.display()))?;
254    Ok(aside)
255}
256
257/// The notes waiting in `dir`, oldest first.
258///
259/// A read does not create `dir` or `note-queue.lock`. The directory and
260/// the lock appear when a lesson is queued, after the pack stays busy.
261#[must_use]
262pub fn pending_in(dir: &Path) -> Vec<Queued> {
263    if !queue_exists(dir) {
264        return Vec::new();
265    }
266    read(dir)
267}
268
269/// Replace the waiting memories in `dir`.
270///
271/// # Errors
272///
273/// The queue file cannot be rewritten.
274pub fn replace_in(dir: &Path, notes: Vec<Queued>) -> Result<()> {
275    let _held = lock(dir);
276    let (_, unread) = read_split(dir);
277    write(dir, &notes, &unread).map(drop)
278}
279
280/// Fit the waiting memories in `dir` with `fit` and set aside the bytes
281/// that are not a memory, rewriting the queue only when either changes
282/// it. Reading and rewriting under one lock keeps a note queued between
283/// them.
284///
285/// # Errors
286///
287/// The queue file cannot be rewritten, or the bytes cannot be set aside.
288pub fn tidy_in(
289    dir: &Path,
290    fit: impl FnOnce(Vec<Queued>) -> Vec<Queued>,
291) -> Result<Option<SetAside>> {
292    let _held = lock(dir);
293    let (notes, unread) = read_split(dir);
294    let fitted = fit(notes.clone());
295    if fitted == notes && unread.is_empty() {
296        return Ok(None);
297    }
298    write(dir, &fitted, &unread)
299}
300
301/// Add `note` to the queue in `dir`. `false` when the same copy already
302/// waits there, including the atoms a lesson closes.
303///
304/// # Errors
305///
306/// The queue file cannot be written.
307pub fn push_in(dir: &Path, note: Queued) -> Result<bool> {
308    let _held = lock(dir);
309    if read(dir).iter().any(|seen| seen.same(&note)) {
310        return Ok(false);
311    }
312    let path = queue_file(dir);
313    let mut line = serde_json::to_string(&note)?;
314    line.push('\n');
315    crate::append_locked_row(&path, line.as_bytes(), crate::Durability::Durable)
316        .with_context(|| format!("append {}", path.display()))?;
317    Ok(true)
318}
319
320/// Send each waiting note in `dir` through `send`, oldest first. A kept
321/// or refused note leaves the queue. A note that has to wait stays, and
322/// the drain still tries every later note.
323///
324/// # Errors
325///
326/// The queue file cannot be rewritten.
327pub fn drain_in(dir: &Path, mut send: impl FnMut(&Queued) -> Sent) -> Result<Drained> {
328    // The queue lock is the file's. Holding it across the pack post is
329    // what made `ljos finish` wait out another seat's lesson. The post
330    // runs outside the lock. The rewrite takes the lock again and drops
331    // only the notes this drain finished, so a note queued during the
332    // post stays.
333    let held = lock(dir);
334    let (notes, unread) = read_split(dir);
335    let mut done = Drained::default();
336    if notes.is_empty() {
337        if !unread.is_empty() {
338            done.set_aside.extend(write(dir, &[], &unread)?);
339        }
340        return Ok(done);
341    }
342    drop(held);
343    let mut finished = Vec::with_capacity(notes.len());
344    for note in &notes {
345        match send(note) {
346            Sent::Kept(id) => {
347                done.kept.push((note.issue.clone(), id));
348                finished.push(note);
349            }
350            Sent::Refused(why) => {
351                done.refused.push((note.issue.clone(), why));
352                finished.push(note);
353            }
354            Sent::Wait(why) => {
355                if done.waiting_on.is_none() {
356                    done.waiting_on = Some(why);
357                }
358            }
359        }
360    }
361    let _held = lock(dir);
362    let (mut rest, unread) = read_split(dir);
363    for note in finished {
364        if let Some(at) = rest.iter().position(|row| row.same(note)) {
365            rest.remove(at);
366        }
367    }
368    done.left = rest.len();
369    done.set_aside.extend(write(dir, &rest, &unread)?);
370    Ok(done)
371}
372
373/// The lines a drain prints: what went, what the pack refused, and what
374/// still waits. Empty when nothing was queued.
375#[must_use]
376pub fn drained_lines(done: &Drained) -> String {
377    let mut out = String::new();
378    if !done.kept.is_empty() {
379        let ids: Vec<String> = done
380            .kept
381            .iter()
382            .map(|(issue, id)| format!("{issue} {}", id.chars().take(8).collect::<String>()))
383            .collect();
384        out.push_str(&format!(
385            "memory: sent {} queued note(s): {}\n",
386            done.kept.len(),
387            ids.join(", ")
388        ));
389    }
390    for (issue, why) in &done.refused {
391        out.push_str(&format!(
392            "memory: the pack refused the queued note on {issue} ({why}); it stays on the logbook\n"
393        ));
394    }
395    for aside in &done.set_aside {
396        out.push_str(&format!(
397            "memory: moved {} byte(s) of the note queue that are not a note to {}; they were not sent\n",
398            aside.bytes,
399            aside.path.display()
400        ));
401    }
402    if done.left > 0 {
403        out.push_str(&format!(
404            "memory: {} note(s) still queued (the lesson is safe and will be sent automatically; {})\n",
405            done.left,
406            done.waiting_on
407                .as_deref()
408                .unwrap_or("the pack did not answer")
409        ));
410    }
411    out
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    fn note(issue: &str, text: &str) -> Queued {
419        Queued {
420            issue: issue.into(),
421            text: text.into(),
422            workspace: "ws".into(),
423            at: 1,
424            kind: "note".into(),
425            supersedes: Vec::new(),
426            seat: String::new(),
427            home: String::new(),
428        }
429    }
430
431    fn dir(name: &str) -> PathBuf {
432        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
433            .join("../../target/note-queue")
434            .join(name);
435        let _ = std::fs::remove_dir_all(&dir);
436        std::fs::create_dir_all(&dir).unwrap();
437        dir
438    }
439
440    #[test]
441    fn a_read_does_not_create_the_queue_dir_or_the_lock() {
442        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
443            .join("../../target/note-queue")
444            .join("absent-read");
445        let _ = std::fs::remove_dir_all(&root);
446        let missing = root.join("ljos");
447        assert!(pending_in(&missing).is_empty());
448        assert!(!missing.exists(), "a read created {}", missing.display());
449        assert!(!lock_file(&missing).exists());
450        let present = dir("empty-read");
451        assert!(pending_in(&present).is_empty());
452        assert!(!lock_file(&present).exists());
453    }
454
455    #[test]
456    fn an_old_line_without_kind_is_a_note() {
457        let dir = dir("old");
458        std::fs::write(
459            dir.join("note-queue.jsonl"),
460            "{\"issue\":\"p-1\",\"text\":\"one line\",\"workspace\":\"ws\",\"at\":1}\n",
461        )
462        .unwrap();
463        let pending = pending_in(&dir);
464        assert_eq!(pending.len(), 1);
465        assert_eq!(pending[0].kind, "note");
466        assert_eq!(pending[0].text, "one line");
467        assert!(pending[0].supersedes.is_empty());
468        assert!(pending[0].seat.is_empty());
469        assert!(pending[0].home.is_empty());
470    }
471
472    #[test]
473    fn a_lesson_that_closes_another_atom_is_a_different_copy() {
474        let dir = dir("closes");
475        let mut lesson = note("p-1", "one line");
476        lesson.kind = "lesson".into();
477        lesson.supersedes = vec!["atom-old".into()];
478        assert!(push_in(&dir, lesson.clone()).unwrap());
479        assert!(!push_in(&dir, lesson.clone()).unwrap());
480        let mut other = lesson;
481        other.supersedes = vec!["atom-new".into()];
482        assert!(push_in(&dir, other).unwrap());
483        assert_eq!(pending_in(&dir).len(), 2);
484    }
485
486    /// A copy still being appended is not yet on the queue; the ones
487    /// before it are.
488    #[test]
489    fn a_torn_last_copy_leaves_the_queue() {
490        let dir = dir("torn");
491        let mut bytes =
492            format!("{}\n", serde_json::to_string(&note("p-1", "kept")).unwrap()).into_bytes();
493        bytes.extend_from_slice(b"{\"issue\":\"p-2\",\"text\":\"caf\xc3");
494        std::fs::write(dir.join("note-queue.jsonl"), bytes).unwrap();
495        let pending = pending_in(&dir);
496        assert_eq!(pending.len(), 1, "{pending:?}");
497        assert_eq!(pending[0].text, "kept");
498    }
499
500    /// The queue is one JSON line per copy. writeln! of that line is two
501    /// write calls, and the sidecar lock is skipped when it cannot be
502    /// taken, so two copies can still land on one line.
503    #[test]
504    fn concurrent_queue_rows_each_parse() {
505        use std::os::unix::fs::PermissionsExt;
506        let dir = dir("concurrent");
507        const THREADS: usize = 32;
508        const EACH: usize = 4;
509        let mut handles = Vec::with_capacity(THREADS);
510        for thread in 0..THREADS {
511            let dir = dir.clone();
512            handles.push(std::thread::spawn(move || {
513                for n in 0..EACH {
514                    let mut queued = note(&format!("p-{thread}"), &format!("text {thread}-{n}"));
515                    queued.at = (thread * EACH + n) as u64;
516                    push_in(&dir, queued).unwrap();
517                }
518            }));
519        }
520        for handle in handles {
521            handle.join().expect("queue writer");
522        }
523        let path = dir.join("note-queue.jsonl");
524        let text = std::fs::read_to_string(&path).unwrap();
525        let rows: Vec<Queued> = text
526            .lines()
527            .map(|line| {
528                serde_json::from_str(line).unwrap_or_else(|err| {
529                    panic!("queue row does not parse ({err}): {line}");
530                })
531            })
532            .collect();
533        assert_eq!(rows.len(), THREADS * EACH, "{text}");
534        let mode = std::fs::symlink_metadata(&path)
535            .unwrap()
536            .permissions()
537            .mode()
538            & 0o777;
539        assert_eq!(mode, 0o600, "queue mode {mode:o}");
540    }
541
542    /// A drain rewrites the queue. The file and the lock are ljos's own, so
543    /// the rewrite lands mode 0600, not the 0644 a plain `tmp + rename`
544    /// leaves, and a symlink swapped in for the queue file is refused rather
545    /// than written through to its target.
546    #[test]
547    fn a_rewrite_stays_private_and_refuses_a_symlink() {
548        use std::os::unix::fs::PermissionsExt;
549        let dir = dir("rewrite");
550        push_in(&dir, note("p-1", "a")).unwrap();
551        push_in(&dir, note("p-2", "b")).unwrap();
552        let done = drain_in(&dir, |n| match n.issue.as_str() {
553            "p-1" => Sent::Kept("id".into()),
554            _ => Sent::Wait("down".into()),
555        })
556        .unwrap();
557        assert_eq!(done.left, 1);
558        let path = queue_file(&dir);
559        let mode = std::fs::symlink_metadata(&path)
560            .unwrap()
561            .permissions()
562            .mode()
563            & 0o777;
564        assert_eq!(mode, 0o600, "queue mode after rewrite {mode:o}");
565        let lock_mode = std::fs::symlink_metadata(dir.join("note-queue.lock"))
566            .unwrap()
567            .permissions()
568            .mode()
569            & 0o777;
570        assert_eq!(lock_mode, 0o600, "lock mode {lock_mode:o}");
571
572        let outside = dir.join("outside");
573        std::fs::write(&outside, "kept\n").unwrap();
574        std::fs::remove_file(&path).unwrap();
575        std::os::unix::fs::symlink(&outside, &path).unwrap();
576        assert!(replace_in(&dir, vec![note("p-9", "forged")]).is_err());
577        assert_eq!(std::fs::read_to_string(&outside).unwrap(), "kept\n");
578    }
579
580    /// Emptying a queue that is a dangling link is refused, and the link
581    /// stays for doctor to name.
582    #[test]
583    fn emptying_a_dangling_linked_queue_is_refused() {
584        let dir = dir("empty-dangling");
585        let path = queue_file(&dir);
586        std::os::unix::fs::symlink(dir.join("gone"), &path).unwrap();
587        assert!(replace_in(&dir, Vec::new()).is_err());
588        assert!(std::fs::symlink_metadata(&path).unwrap().is_symlink());
589    }
590
591    /// A drain rewrites the queue with the notes left. A line that does
592    /// not parse and a last line without its newline, even one that
593    /// would, go to the day's damaged file byte for byte, not out with
594    /// the rewrite.
595    #[test]
596    fn a_drain_sets_aside_what_it_cannot_read() {
597        use std::os::unix::fs::PermissionsExt;
598        let dir = dir("aside");
599        let sent = format!("{}\n", serde_json::to_string(&note("p-1", "sent")).unwrap());
600        let waits = format!(
601            "{}\n",
602            serde_json::to_string(&note("p-2", "waits")).unwrap()
603        );
604        let cut: &[u8] = b"{\"issue\":\"p-3\",\"te\n";
605        let tail: &[u8] =
606            br#"{"issue":"p-4","text":"no newline","workspace":"ws","at":1,"kind":"note"}"#;
607        let mut bytes = sent.into_bytes();
608        bytes.extend_from_slice(cut);
609        bytes.extend_from_slice(waits.as_bytes());
610        bytes.extend_from_slice(tail);
611        std::fs::write(queue_file(&dir), &bytes).unwrap();
612        let done = drain_in(&dir, |n| match n.issue.as_str() {
613            "p-1" => Sent::Kept("id".into()),
614            _ => Sent::Wait("down".into()),
615        })
616        .unwrap();
617        assert_eq!(done.left, 1, "{done:?}");
618        assert_eq!(std::fs::read_to_string(queue_file(&dir)).unwrap(), waits);
619        let unread = [cut, tail].concat();
620        let [aside] = done.set_aside.as_slice() else {
621            panic!("{done:?}");
622        };
623        assert_eq!(aside.bytes, unread.len());
624        assert_eq!(std::fs::read(&aside.path).unwrap(), unread);
625        let name = aside.path.file_name().unwrap().to_str().unwrap();
626        assert!(
627            name.starts_with("note-queue.jsonl.") && name.ends_with(".damaged"),
628            "{name}"
629        );
630        let mode = std::fs::metadata(&aside.path).unwrap().permissions().mode() & 0o777;
631        assert_eq!(mode, 0o600, "damaged file mode {mode:o}");
632        let lines = drained_lines(&done);
633        assert!(lines.contains(&aside.path.display().to_string()), "{lines}");
634    }
635
636    /// A queue of nothing but bytes that are not a note is emptied into
637    /// the damaged file, and the next tidy finds nothing to move.
638    #[test]
639    fn a_tidy_sets_aside_a_queue_with_no_note_in_it() {
640        let dir = dir("tidy");
641        std::fs::write(queue_file(&dir), b"{\"issue\":\"p-1\",\"te").unwrap();
642        let aside = tidy_in(&dir, |notes| notes).unwrap().expect("set aside");
643        assert_eq!(
644            std::fs::read(&aside.path).unwrap(),
645            b"{\"issue\":\"p-1\",\"te"
646        );
647        assert_eq!(std::fs::read(queue_file(&dir)).unwrap(), b"");
648        assert_eq!(tidy_in(&dir, |notes| notes).unwrap(), None);
649    }
650
651    /// A damaged name that is a link, to a file outside the state dir or
652    /// to nothing, is not listed, and its target is neither read nor
653    /// changed.
654    #[test]
655    fn a_linked_damaged_name_is_not_followed() {
656        let dir = dir("linked");
657        let real = dir.join("note-queue.jsonl.2026-01-01.damaged");
658        std::fs::write(&real, b"cut").unwrap();
659        let outside = dir.parent().unwrap().join("linked-outside");
660        std::fs::write(&outside, b"not ljos's own").unwrap();
661        std::os::unix::fs::symlink(&outside, dir.join("note-queue.jsonl.2026-01-02.damaged"))
662            .unwrap();
663        std::os::unix::fs::symlink(
664            dir.join("gone"),
665            dir.join("note-queue.jsonl.2026-01-03.damaged"),
666        )
667        .unwrap();
668        assert_eq!(set_aside_in(&dir), [(real, 3)]);
669        assert_eq!(std::fs::read(&outside).unwrap(), b"not ljos's own");
670        assert!(!dir.join("gone").exists());
671    }
672
673    /// A state dir swapped for a link to a dir that holds a damaged file
674    /// lists nothing from that dir.
675    #[test]
676    fn a_linked_state_dir_lists_nothing_from_outside() {
677        let root = dir("linked-dir");
678        let outside = root.join("outside");
679        std::fs::create_dir(&outside).unwrap();
680        std::fs::write(outside.join("note-queue.jsonl.2026-01-01.damaged"), b"x").unwrap();
681        let state = root.join("ljos");
682        std::os::unix::fs::symlink(&outside, &state).unwrap();
683        assert_eq!(set_aside_in(&state), []);
684    }
685
686    #[test]
687    fn the_same_note_waits_once() {
688        let dir = dir("once");
689        assert!(push_in(&dir, note("p-1", "one line")).unwrap());
690        assert!(!push_in(&dir, note("p-1", " one line ")).unwrap());
691        assert!(push_in(&dir, note("p-2", "one line")).unwrap());
692        assert_eq!(pending_in(&dir).len(), 2);
693    }
694
695    #[test]
696    fn a_drain_keeps_waiting_rows_and_tries_later_ones() {
697        let dir = dir("order");
698        for (issue, text) in [("p-1", "a"), ("p-2", "b"), ("p-3", "c"), ("p-4", "d")] {
699            push_in(&dir, note(issue, text)).unwrap();
700        }
701        let mut seen = Vec::new();
702        let done = drain_in(&dir, |n| {
703            seen.push(n.issue.clone());
704            match n.issue.as_str() {
705                "p-1" => Sent::Kept("id-1".into()),
706                "p-2" => Sent::Refused("too long".into()),
707                "p-3" => Sent::Wait("connection refused".into()),
708                _ => Sent::Kept("id-4".into()),
709            }
710        })
711        .unwrap();
712        assert_eq!(seen, ["p-1", "p-2", "p-3", "p-4"]);
713        assert_eq!(
714            done.kept,
715            [
716                ("p-1".to_string(), "id-1".to_string()),
717                ("p-4".to_string(), "id-4".to_string())
718            ]
719        );
720        assert_eq!(done.refused.len(), 1);
721        assert_eq!(done.left, 1);
722        let left: Vec<String> = pending_in(&dir).into_iter().map(|n| n.issue).collect();
723        assert_eq!(left, ["p-3"]);
724        let lines = drained_lines(&done);
725        assert!(
726            lines.contains("sent 2 queued note(s): p-1 id-1, p-4 id-4"),
727            "{lines}"
728        );
729        assert!(
730            lines.contains(
731                "1 note(s) still queued (the lesson is safe and will be sent automatically; connection refused)"
732            ),
733            "{lines}"
734        );
735        let done = drain_in(&dir, |_| Sent::Kept("id".into())).unwrap();
736        assert_eq!(done.kept.len(), 1);
737        assert!(pending_in(&dir).is_empty());
738        assert_eq!(
739            drained_lines(&drain_in(&dir, |_| unreachable!()).unwrap()),
740            ""
741        );
742    }
743
744    /// The queue file stays locked only around its own read and rewrite.
745    /// Another seat can queue a note while this one is asking the pack.
746    #[test]
747    fn a_drain_does_not_hold_the_queue_lock_while_the_pack_is_asked() {
748        let dir = dir("lock-during-send");
749        push_in(&dir, note("p-1", "held")).unwrap();
750        let done = drain_in(&dir, |_| {
751            assert!(
752                busy_in(&dir).is_none(),
753                "the queue lock was held while the pack was asked"
754            );
755            Sent::Kept("id".into())
756        })
757        .unwrap();
758        assert_eq!(done.kept, [("p-1".to_string(), "id".to_string())]);
759        assert!(pending_in(&dir).is_empty());
760    }
761}