1use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result};
23use serde::{Deserialize, Serialize};
24
25fn default_kind() -> String {
26 "note".into()
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Queued {
32 pub issue: String,
33 pub text: String,
34 pub workspace: String,
36 pub at: u64,
38 #[serde(default = "default_kind")]
40 pub kind: String,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub supersedes: Vec<String>,
45 #[serde(default, skip_serializing_if = "String::is_empty")]
48 pub seat: String,
49 #[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
67pub enum Sent {
69 Kept(String),
71 Refused(String),
74 Wait(String),
77}
78
79#[derive(Debug, Default, Clone, PartialEq, Eq)]
81pub struct Drained {
82 pub kept: Vec<(String, String)>,
84 pub refused: Vec<(String, String)>,
86 pub left: usize,
88 pub waiting_on: Option<String>,
90 pub set_aside: Vec<SetAside>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct SetAside {
99 pub path: PathBuf,
100 pub bytes: usize,
101}
102
103#[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
118pub(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#[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
149fn 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
167fn 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#[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#[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
211fn 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
227fn 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 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#[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
269pub fn replace_in(dir: &Path, notes: Vec<Queued>) -> Result<()> {
275 let _held = lock(dir);
276 let (_, unread) = read_split(dir);
277 write(dir, ¬es, &unread).map(drop)
278}
279
280pub 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
301pub fn push_in(dir: &Path, note: Queued) -> Result<bool> {
308 let _held = lock(dir);
309 if read(dir).iter().any(|seen| seen.same(¬e)) {
310 return Ok(false);
311 }
312 let path = queue_file(dir);
313 let mut line = serde_json::to_string(¬e)?;
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
320pub fn drain_in(dir: &Path, mut send: impl FnMut(&Queued) -> Sent) -> Result<Drained> {
328 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 ¬es {
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#[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 #[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(¬e("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 #[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 #[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 #[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 #[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(¬e("p-1", "sent")).unwrap());
600 let waits = format!(
601 "{}\n",
602 serde_json::to_string(¬e("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 #[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 #[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 #[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 #[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}