1use std::io::Write;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Queued {
20 pub issue: String,
21 pub text: String,
22 pub workspace: String,
24 pub at: u64,
26}
27
28impl Queued {
29 fn same(&self, other: &Self) -> bool {
30 self.issue.trim() == other.issue.trim()
31 && self.text.trim() == other.text.trim()
32 && self.workspace == other.workspace
33 }
34}
35
36pub enum Sent {
38 Kept(String),
40 Refused(String),
42 Wait(String),
44}
45
46#[derive(Debug, Default, Clone, PartialEq, Eq)]
48pub struct Drained {
49 pub kept: Vec<(String, String)>,
51 pub refused: Vec<(String, String)>,
53 pub left: usize,
55 pub waiting_on: Option<String>,
57}
58
59#[must_use]
62pub fn state_dir() -> PathBuf {
63 std::env::var_os("XDG_STATE_HOME")
64 .map(PathBuf::from)
65 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
66 .unwrap_or_else(|| PathBuf::from(".local/state"))
67 .join("ljos")
68}
69
70fn queue_file(dir: &Path) -> PathBuf {
71 dir.join("note-queue.jsonl")
72}
73
74fn lock(dir: &Path) -> crate::CommitLock {
75 let _ = std::fs::create_dir_all(dir);
76 crate::CommitLock::acquire(&dir.join("note-queue.lock"))
77}
78
79fn read(dir: &Path) -> Vec<Queued> {
80 std::fs::read_to_string(queue_file(dir))
81 .unwrap_or_default()
82 .lines()
83 .filter_map(|line| serde_json::from_str(line).ok())
84 .collect()
85}
86
87fn write(dir: &Path, notes: &[Queued]) -> Result<()> {
88 let path = queue_file(dir);
89 if notes.is_empty() {
90 if path.exists() {
91 std::fs::write(&path, "").with_context(|| format!("empty {}", path.display()))?;
92 }
93 return Ok(());
94 }
95 let tmp = dir.join("note-queue.jsonl.tmp");
96 let mut body = String::new();
97 for note in notes {
98 body.push_str(&serde_json::to_string(note)?);
99 body.push('\n');
100 }
101 std::fs::write(&tmp, body).with_context(|| format!("write {}", tmp.display()))?;
102 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))
103}
104
105#[must_use]
107pub fn pending_in(dir: &Path) -> Vec<Queued> {
108 let _held = lock(dir);
109 read(dir)
110}
111
112pub fn push_in(dir: &Path, note: Queued) -> Result<bool> {
119 let _held = lock(dir);
120 if read(dir).iter().any(|seen| seen.same(¬e)) {
121 return Ok(false);
122 }
123 let path = queue_file(dir);
124 let mut file = std::fs::OpenOptions::new()
125 .create(true)
126 .append(true)
127 .open(&path)
128 .with_context(|| format!("open {}", path.display()))?;
129 writeln!(file, "{}", serde_json::to_string(¬e)?)
130 .with_context(|| format!("append {}", path.display()))?;
131 Ok(true)
132}
133
134pub fn drain_in(dir: &Path, mut send: impl FnMut(&Queued) -> Sent) -> Result<Drained> {
142 let _held = lock(dir);
143 let notes = read(dir);
144 let mut done = Drained::default();
145 if notes.is_empty() {
146 return Ok(done);
147 }
148 let mut rest = Vec::new();
149 for (at, note) in notes.iter().enumerate() {
150 match send(note) {
151 Sent::Kept(id) => done.kept.push((note.issue.clone(), id)),
152 Sent::Refused(why) => done.refused.push((note.issue.clone(), why)),
153 Sent::Wait(why) => {
154 done.waiting_on = Some(why);
155 rest.extend_from_slice(¬es[at..]);
156 break;
157 }
158 }
159 }
160 done.left = rest.len();
161 write(dir, &rest)?;
162 Ok(done)
163}
164
165#[must_use]
168pub fn drained_lines(done: &Drained) -> String {
169 let mut out = String::new();
170 if !done.kept.is_empty() {
171 let ids: Vec<String> = done
172 .kept
173 .iter()
174 .map(|(issue, id)| format!("{issue} {}", id.chars().take(8).collect::<String>()))
175 .collect();
176 out.push_str(&format!(
177 "memory: sent {} queued note(s): {}\n",
178 done.kept.len(),
179 ids.join(", ")
180 ));
181 }
182 for (issue, why) in &done.refused {
183 out.push_str(&format!(
184 "memory: the pack refused the queued note on {issue} ({why}); it stays on the logbook\n"
185 ));
186 }
187 if done.left > 0 {
188 out.push_str(&format!(
189 "memory: {} note(s) still queued ({})\n",
190 done.left,
191 done.waiting_on
192 .as_deref()
193 .unwrap_or("the pack did not answer")
194 ));
195 }
196 out
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn note(issue: &str, text: &str) -> Queued {
204 Queued {
205 issue: issue.into(),
206 text: text.into(),
207 workspace: "ws".into(),
208 at: 1,
209 }
210 }
211
212 fn dir(name: &str) -> PathBuf {
213 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
214 .join("../../target/note-queue")
215 .join(name);
216 let _ = std::fs::remove_dir_all(&dir);
217 std::fs::create_dir_all(&dir).unwrap();
218 dir
219 }
220
221 #[test]
222 fn the_same_note_waits_once() {
223 let dir = dir("once");
224 assert!(push_in(&dir, note("p-1", "one line")).unwrap());
225 assert!(!push_in(&dir, note("p-1", " one line ")).unwrap());
226 assert!(push_in(&dir, note("p-2", "one line")).unwrap());
227 assert_eq!(pending_in(&dir).len(), 2);
228 }
229
230 #[test]
231 fn a_drain_keeps_order_and_stops_at_the_first_wait() {
232 let dir = dir("order");
233 for (issue, text) in [("p-1", "a"), ("p-2", "b"), ("p-3", "c"), ("p-4", "d")] {
234 push_in(&dir, note(issue, text)).unwrap();
235 }
236 let mut seen = Vec::new();
237 let done = drain_in(&dir, |n| {
238 seen.push(n.issue.clone());
239 match n.issue.as_str() {
240 "p-1" => Sent::Kept("id-1".into()),
241 "p-2" => Sent::Refused("too long".into()),
242 _ => Sent::Wait("connection refused".into()),
243 }
244 })
245 .unwrap();
246 assert_eq!(seen, ["p-1", "p-2", "p-3"]);
247 assert_eq!(done.kept, [("p-1".to_string(), "id-1".to_string())]);
248 assert_eq!(done.refused.len(), 1);
249 assert_eq!(done.left, 2);
250 let left: Vec<String> = pending_in(&dir).into_iter().map(|n| n.issue).collect();
251 assert_eq!(left, ["p-3", "p-4"]);
252 let lines = drained_lines(&done);
253 assert!(lines.contains("sent 1 queued note(s): p-1 id-1"), "{lines}");
254 assert!(
255 lines.contains("2 note(s) still queued (connection refused)"),
256 "{lines}"
257 );
258 let done = drain_in(&dir, |_| Sent::Kept("id".into())).unwrap();
259 assert_eq!(done.kept.len(), 2);
260 assert!(pending_in(&dir).is_empty());
261 assert_eq!(
262 drained_lines(&drain_in(&dir, |_| unreachable!()).unwrap()),
263 ""
264 );
265 }
266}