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