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