1use std::path::{Path, PathBuf};
20
21use anyhow::{Context, Result};
22use serde::{Deserialize, Serialize};
23
24fn default_kind() -> String {
25 "note".into()
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Queued {
31 pub issue: String,
32 pub text: String,
33 pub workspace: String,
35 pub at: u64,
37 #[serde(default = "default_kind")]
39 pub kind: String,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 pub supersedes: Vec<String>,
44 #[serde(default, skip_serializing_if = "String::is_empty")]
47 pub seat: String,
48 #[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
66pub enum Sent {
68 Kept(String),
70 Refused(String),
73 Wait(String),
76}
77
78#[derive(Debug, Default, Clone, PartialEq, Eq)]
80pub struct Drained {
81 pub kept: Vec<(String, String)>,
83 pub refused: Vec<(String, String)>,
85 pub left: usize,
87 pub waiting_on: Option<String>,
89 pub set_aside: Vec<SetAside>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct SetAside {
98 pub path: PathBuf,
99 pub bytes: usize,
100}
101
102#[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#[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
138fn 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
156fn 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#[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#[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
200fn 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
216fn 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 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#[must_use]
248pub fn pending_in(dir: &Path) -> Vec<Queued> {
249 let _held = lock(dir);
250 read(dir)
251}
252
253pub fn replace_in(dir: &Path, notes: Vec<Queued>) -> Result<()> {
259 let _held = lock(dir);
260 let (_, unread) = read_split(dir);
261 write(dir, ¬es, &unread).map(drop)
262}
263
264pub 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
285pub fn push_in(dir: &Path, note: Queued) -> Result<bool> {
292 let _held = lock(dir);
293 if read(dir).iter().any(|seen| seen.same(¬e)) {
294 return Ok(false);
295 }
296 let path = queue_file(dir);
297 let mut line = serde_json::to_string(¬e)?;
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
304pub fn drain_in(dir: &Path, mut send: impl FnMut(&Queued) -> Sent) -> Result<Drained> {
312 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 ¬es {
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#[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 #[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(¬e("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 #[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 #[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 #[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 #[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(¬e("p-1", "sent")).unwrap());
569 let waits = format!(
570 "{}\n",
571 serde_json::to_string(¬e("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 #[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 #[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 #[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 #[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}