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
117pub(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#[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
148fn 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
166fn 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#[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#[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
210fn 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
226fn 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 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#[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
268pub fn replace_in(dir: &Path, notes: Vec<Queued>) -> Result<()> {
274 let _held = lock(dir);
275 let (_, unread) = read_split(dir);
276 write(dir, ¬es, &unread).map(drop)
277}
278
279pub 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
300pub fn push_in(dir: &Path, note: Queued) -> Result<bool> {
307 let _held = lock(dir);
308 if read(dir).iter().any(|seen| seen.same(¬e)) {
309 return Ok(false);
310 }
311 let path = queue_file(dir);
312 let mut line = serde_json::to_string(¬e)?;
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
319pub fn drain_in(dir: &Path, mut send: impl FnMut(&Queued) -> Sent) -> Result<Drained> {
327 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 ¬es {
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#[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 #[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(¬e("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 #[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 #[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 #[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 #[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(¬e("p-1", "sent")).unwrap());
599 let waits = format!(
600 "{}\n",
601 serde_json::to_string(¬e("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 #[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 #[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 #[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 #[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}