use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
fn default_kind() -> String {
"note".into()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Queued {
pub issue: String,
pub text: String,
pub workspace: String,
pub at: u64,
#[serde(default = "default_kind")]
pub kind: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supersedes: Vec<String>,
}
impl Queued {
pub(crate) fn same(&self, other: &Self) -> bool {
self.issue.trim() == other.issue.trim()
&& self.text.trim() == other.text.trim()
&& self.workspace == other.workspace
&& self.kind == other.kind
&& self.supersedes == other.supersedes
}
}
pub enum Sent {
Kept(String),
Refused(String),
Wait(String),
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Drained {
pub kept: Vec<(String, String)>,
pub refused: Vec<(String, String)>,
pub left: usize,
pub waiting_on: Option<String>,
pub set_aside: Vec<SetAside>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetAside {
pub path: PathBuf,
pub bytes: usize,
}
#[must_use]
pub fn state_dir() -> PathBuf {
std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
.unwrap_or_else(|| PathBuf::from(".local/state"))
.join("ljos")
}
fn queue_file(dir: &Path) -> PathBuf {
dir.join("note-queue.jsonl")
}
fn lock_file(dir: &Path) -> PathBuf {
dir.join("note-queue.lock")
}
fn lock(dir: &Path) -> crate::CommitLock {
let _ = std::fs::create_dir_all(dir);
crate::CommitLock::acquire_private(&lock_file(dir))
}
#[must_use]
pub fn busy_in(dir: &Path) -> Option<PathBuf> {
let lock = lock_file(dir);
crate::CommitLock::is_held_private(&lock).then_some(lock)
}
fn read(dir: &Path) -> Vec<Queued> {
read_split(dir).0
}
fn read_split(dir: &Path) -> (Vec<Queued>, Vec<u8>) {
let bytes = crate::read_state_file(&queue_file(dir)).unwrap_or_default();
let mut notes = Vec::new();
let mut unread = Vec::new();
for line in bytes.split_inclusive(|&b| b == b'\n') {
match line.strip_suffix(b"\n").map(serde_json::from_slice) {
Some(Ok(note)) => notes.push(note),
_ if line == b"\n" => {}
_ => unread.extend_from_slice(line),
}
}
(notes, unread)
}
fn damaged_file(dir: &Path) -> PathBuf {
dir.join(format!(
"note-queue.jsonl.{}.damaged",
chrono::Utc::now().format("%Y-%m-%d")
))
}
#[must_use]
pub fn set_aside_in(dir: &Path) -> Vec<(PathBuf, u64)> {
match crate::open_state_dir(&queue_file(dir)) {
Ok((fd, _)) => set_aside_at(&fd, dir),
Err(_) => Vec::new(),
}
}
#[must_use]
pub fn set_aside_at(fd: &std::fs::File, dir: &Path) -> Vec<(PathBuf, u64)> {
let Ok(names) = crate::deedbag::list_names(fd) else {
return Vec::new();
};
let mut found: Vec<(PathBuf, u64)> = names
.iter()
.filter_map(|name| {
let text = name.to_str().ok()?;
text.strip_prefix("note-queue.jsonl.")?
.strip_suffix(".damaged")?;
let st = crate::deedbag::stat_at(fd, name).ok()??;
let size = u64::try_from(st.st_size).ok()?;
((st.st_mode & libc::S_IFMT) == libc::S_IFREG).then(|| (dir.join(text), size))
})
.collect();
found.sort();
found
}
fn set_aside(dir: &Path, unread: &[u8]) -> Result<Option<SetAside>> {
if unread.is_empty() {
return Ok(None);
}
let path = damaged_file(dir);
crate::append_locked_row(&path, unread, crate::Durability::Durable)
.and_then(|()| crate::sync_state_file(&path))
.with_context(|| format!("set aside unreadable queue bytes in {}", path.display()))?;
Ok(Some(SetAside {
path,
bytes: unread.len(),
}))
}
fn write(dir: &Path, notes: &[Queued], unread: &[u8]) -> Result<Option<SetAside>> {
let aside = set_aside(dir, unread)?;
let path = queue_file(dir);
if notes.is_empty() {
let present = crate::open_state_dir(&path)
.and_then(|(fd, name)| crate::open_regular_at(&fd, &name, &path, libc::O_RDONLY, 0));
match present {
Ok(_) => crate::rewrite_state_file(&path, b"")
.with_context(|| format!("empty {}", path.display()))?,
Err(err) if err.raw_os_error() == Some(libc::ENOENT) => {}
Err(err) => return Err(err).with_context(|| format!("empty {}", path.display())),
}
return Ok(aside);
}
let mut body = String::new();
for note in notes {
body.push_str(&serde_json::to_string(note)?);
body.push('\n');
}
crate::rewrite_state_file(&path, body.as_bytes())
.with_context(|| format!("replace {}", path.display()))?;
Ok(aside)
}
#[must_use]
pub fn pending_in(dir: &Path) -> Vec<Queued> {
let _held = lock(dir);
read(dir)
}
pub fn replace_in(dir: &Path, notes: Vec<Queued>) -> Result<()> {
let _held = lock(dir);
let (_, unread) = read_split(dir);
write(dir, ¬es, &unread).map(drop)
}
pub fn tidy_in(
dir: &Path,
fit: impl FnOnce(Vec<Queued>) -> Vec<Queued>,
) -> Result<Option<SetAside>> {
let _held = lock(dir);
let (notes, unread) = read_split(dir);
let fitted = fit(notes.clone());
if fitted == notes && unread.is_empty() {
return Ok(None);
}
write(dir, &fitted, &unread)
}
pub fn push_in(dir: &Path, note: Queued) -> Result<bool> {
let _held = lock(dir);
if read(dir).iter().any(|seen| seen.same(¬e)) {
return Ok(false);
}
let path = queue_file(dir);
let mut line = serde_json::to_string(¬e)?;
line.push('\n');
crate::append_locked_row(&path, line.as_bytes(), crate::Durability::Durable)
.with_context(|| format!("append {}", path.display()))?;
Ok(true)
}
pub fn drain_in(dir: &Path, mut send: impl FnMut(&Queued) -> Sent) -> Result<Drained> {
let _held = lock(dir);
let (notes, unread) = read_split(dir);
let mut done = Drained::default();
if notes.is_empty() && unread.is_empty() {
return Ok(done);
}
let mut rest = Vec::new();
for (at, note) in notes.iter().enumerate() {
match send(note) {
Sent::Kept(id) => done.kept.push((note.issue.clone(), id)),
Sent::Refused(why) => done.refused.push((note.issue.clone(), why)),
Sent::Wait(why) => {
done.waiting_on = Some(why);
rest.extend_from_slice(¬es[at..]);
break;
}
}
}
done.left = rest.len();
done.set_aside.extend(write(dir, &rest, &unread)?);
Ok(done)
}
#[must_use]
pub fn drained_lines(done: &Drained) -> String {
let mut out = String::new();
if !done.kept.is_empty() {
let ids: Vec<String> = done
.kept
.iter()
.map(|(issue, id)| format!("{issue} {}", id.chars().take(8).collect::<String>()))
.collect();
out.push_str(&format!(
"memory: sent {} queued note(s): {}\n",
done.kept.len(),
ids.join(", ")
));
}
for (issue, why) in &done.refused {
out.push_str(&format!(
"memory: the pack refused the queued note on {issue} ({why}); it stays on the logbook\n"
));
}
for aside in &done.set_aside {
out.push_str(&format!(
"memory: moved {} byte(s) of the note queue that are not a note to {}; they were not sent\n",
aside.bytes,
aside.path.display()
));
}
if done.left > 0 {
out.push_str(&format!(
"memory: {} note(s) still queued ({})\n",
done.left,
done.waiting_on
.as_deref()
.unwrap_or("the pack did not answer")
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn note(issue: &str, text: &str) -> Queued {
Queued {
issue: issue.into(),
text: text.into(),
workspace: "ws".into(),
at: 1,
kind: "note".into(),
supersedes: Vec::new(),
}
}
fn dir(name: &str) -> PathBuf {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../target/note-queue")
.join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn an_old_line_without_kind_is_a_note() {
let dir = dir("old");
std::fs::write(
dir.join("note-queue.jsonl"),
"{\"issue\":\"p-1\",\"text\":\"one line\",\"workspace\":\"ws\",\"at\":1}\n",
)
.unwrap();
let pending = pending_in(&dir);
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].kind, "note");
assert_eq!(pending[0].text, "one line");
assert!(pending[0].supersedes.is_empty());
}
#[test]
fn a_lesson_that_closes_another_atom_is_a_different_copy() {
let dir = dir("closes");
let mut lesson = note("p-1", "one line");
lesson.kind = "lesson".into();
lesson.supersedes = vec!["atom-old".into()];
assert!(push_in(&dir, lesson.clone()).unwrap());
assert!(!push_in(&dir, lesson.clone()).unwrap());
let mut other = lesson;
other.supersedes = vec!["atom-new".into()];
assert!(push_in(&dir, other).unwrap());
assert_eq!(pending_in(&dir).len(), 2);
}
#[test]
fn a_torn_last_copy_leaves_the_queue() {
let dir = dir("torn");
let mut bytes =
format!("{}\n", serde_json::to_string(¬e("p-1", "kept")).unwrap()).into_bytes();
bytes.extend_from_slice(b"{\"issue\":\"p-2\",\"text\":\"caf\xc3");
std::fs::write(dir.join("note-queue.jsonl"), bytes).unwrap();
let pending = pending_in(&dir);
assert_eq!(pending.len(), 1, "{pending:?}");
assert_eq!(pending[0].text, "kept");
}
#[test]
fn concurrent_queue_rows_each_parse() {
use std::os::unix::fs::PermissionsExt;
let dir = dir("concurrent");
const THREADS: usize = 32;
const EACH: usize = 4;
let mut handles = Vec::with_capacity(THREADS);
for thread in 0..THREADS {
let dir = dir.clone();
handles.push(std::thread::spawn(move || {
for n in 0..EACH {
let mut queued = note(&format!("p-{thread}"), &format!("text {thread}-{n}"));
queued.at = (thread * EACH + n) as u64;
push_in(&dir, queued).unwrap();
}
}));
}
for handle in handles {
handle.join().expect("queue writer");
}
let path = dir.join("note-queue.jsonl");
let text = std::fs::read_to_string(&path).unwrap();
let rows: Vec<Queued> = text
.lines()
.map(|line| {
serde_json::from_str(line).unwrap_or_else(|err| {
panic!("queue row does not parse ({err}): {line}");
})
})
.collect();
assert_eq!(rows.len(), THREADS * EACH, "{text}");
let mode = std::fs::symlink_metadata(&path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "queue mode {mode:o}");
}
#[test]
fn a_rewrite_stays_private_and_refuses_a_symlink() {
use std::os::unix::fs::PermissionsExt;
let dir = dir("rewrite");
push_in(&dir, note("p-1", "a")).unwrap();
push_in(&dir, note("p-2", "b")).unwrap();
let done = drain_in(&dir, |n| match n.issue.as_str() {
"p-1" => Sent::Kept("id".into()),
_ => Sent::Wait("down".into()),
})
.unwrap();
assert_eq!(done.left, 1);
let path = queue_file(&dir);
let mode = std::fs::symlink_metadata(&path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "queue mode after rewrite {mode:o}");
let lock_mode = std::fs::symlink_metadata(dir.join("note-queue.lock"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(lock_mode, 0o600, "lock mode {lock_mode:o}");
let outside = dir.join("outside");
std::fs::write(&outside, "kept\n").unwrap();
std::fs::remove_file(&path).unwrap();
std::os::unix::fs::symlink(&outside, &path).unwrap();
assert!(replace_in(&dir, vec![note("p-9", "forged")]).is_err());
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "kept\n");
}
#[test]
fn emptying_a_dangling_linked_queue_is_refused() {
let dir = dir("empty-dangling");
let path = queue_file(&dir);
std::os::unix::fs::symlink(dir.join("gone"), &path).unwrap();
assert!(replace_in(&dir, Vec::new()).is_err());
assert!(std::fs::symlink_metadata(&path).unwrap().is_symlink());
}
#[test]
fn a_drain_sets_aside_what_it_cannot_read() {
use std::os::unix::fs::PermissionsExt;
let dir = dir("aside");
let sent = format!("{}\n", serde_json::to_string(¬e("p-1", "sent")).unwrap());
let waits = format!(
"{}\n",
serde_json::to_string(¬e("p-2", "waits")).unwrap()
);
let cut: &[u8] = b"{\"issue\":\"p-3\",\"te\n";
let tail: &[u8] =
br#"{"issue":"p-4","text":"no newline","workspace":"ws","at":1,"kind":"note"}"#;
let mut bytes = sent.into_bytes();
bytes.extend_from_slice(cut);
bytes.extend_from_slice(waits.as_bytes());
bytes.extend_from_slice(tail);
std::fs::write(queue_file(&dir), &bytes).unwrap();
let done = drain_in(&dir, |n| match n.issue.as_str() {
"p-1" => Sent::Kept("id".into()),
_ => Sent::Wait("down".into()),
})
.unwrap();
assert_eq!(done.left, 1, "{done:?}");
assert_eq!(std::fs::read_to_string(queue_file(&dir)).unwrap(), waits);
let unread = [cut, tail].concat();
let [aside] = done.set_aside.as_slice() else {
panic!("{done:?}");
};
assert_eq!(aside.bytes, unread.len());
assert_eq!(std::fs::read(&aside.path).unwrap(), unread);
let name = aside.path.file_name().unwrap().to_str().unwrap();
assert!(
name.starts_with("note-queue.jsonl.") && name.ends_with(".damaged"),
"{name}"
);
let mode = std::fs::metadata(&aside.path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "damaged file mode {mode:o}");
let lines = drained_lines(&done);
assert!(lines.contains(&aside.path.display().to_string()), "{lines}");
}
#[test]
fn a_tidy_sets_aside_a_queue_with_no_note_in_it() {
let dir = dir("tidy");
std::fs::write(queue_file(&dir), b"{\"issue\":\"p-1\",\"te").unwrap();
let aside = tidy_in(&dir, |notes| notes).unwrap().expect("set aside");
assert_eq!(
std::fs::read(&aside.path).unwrap(),
b"{\"issue\":\"p-1\",\"te"
);
assert_eq!(std::fs::read(queue_file(&dir)).unwrap(), b"");
assert_eq!(tidy_in(&dir, |notes| notes).unwrap(), None);
}
#[test]
fn a_linked_damaged_name_is_not_followed() {
let dir = dir("linked");
let real = dir.join("note-queue.jsonl.2026-01-01.damaged");
std::fs::write(&real, b"cut").unwrap();
let outside = dir.parent().unwrap().join("linked-outside");
std::fs::write(&outside, b"not ljos's own").unwrap();
std::os::unix::fs::symlink(&outside, dir.join("note-queue.jsonl.2026-01-02.damaged"))
.unwrap();
std::os::unix::fs::symlink(
dir.join("gone"),
dir.join("note-queue.jsonl.2026-01-03.damaged"),
)
.unwrap();
assert_eq!(set_aside_in(&dir), [(real, 3)]);
assert_eq!(std::fs::read(&outside).unwrap(), b"not ljos's own");
assert!(!dir.join("gone").exists());
}
#[test]
fn a_linked_state_dir_lists_nothing_from_outside() {
let root = dir("linked-dir");
let outside = root.join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("note-queue.jsonl.2026-01-01.damaged"), b"x").unwrap();
let state = root.join("ljos");
std::os::unix::fs::symlink(&outside, &state).unwrap();
assert_eq!(set_aside_in(&state), []);
}
#[test]
fn the_same_note_waits_once() {
let dir = dir("once");
assert!(push_in(&dir, note("p-1", "one line")).unwrap());
assert!(!push_in(&dir, note("p-1", " one line ")).unwrap());
assert!(push_in(&dir, note("p-2", "one line")).unwrap());
assert_eq!(pending_in(&dir).len(), 2);
}
#[test]
fn a_drain_keeps_order_and_stops_at_the_first_wait() {
let dir = dir("order");
for (issue, text) in [("p-1", "a"), ("p-2", "b"), ("p-3", "c"), ("p-4", "d")] {
push_in(&dir, note(issue, text)).unwrap();
}
let mut seen = Vec::new();
let done = drain_in(&dir, |n| {
seen.push(n.issue.clone());
match n.issue.as_str() {
"p-1" => Sent::Kept("id-1".into()),
"p-2" => Sent::Refused("too long".into()),
_ => Sent::Wait("connection refused".into()),
}
})
.unwrap();
assert_eq!(seen, ["p-1", "p-2", "p-3"]);
assert_eq!(done.kept, [("p-1".to_string(), "id-1".to_string())]);
assert_eq!(done.refused.len(), 1);
assert_eq!(done.left, 2);
let left: Vec<String> = pending_in(&dir).into_iter().map(|n| n.issue).collect();
assert_eq!(left, ["p-3", "p-4"]);
let lines = drained_lines(&done);
assert!(lines.contains("sent 1 queued note(s): p-1 id-1"), "{lines}");
assert!(
lines.contains("2 note(s) still queued (connection refused)"),
"{lines}"
);
let done = drain_in(&dir, |_| Sent::Kept("id".into())).unwrap();
assert_eq!(done.kept.len(), 2);
assert!(pending_in(&dir).is_empty());
assert_eq!(
drained_lines(&drain_in(&dir, |_| unreachable!()).unwrap()),
""
);
}
}