1use std::{
18 io::Write,
19 path::{Path, PathBuf},
20};
21
22use anyhow::Context;
23use serde::{Deserialize, Serialize};
24
25#[derive(Clone, Debug, Deserialize, Serialize)]
27pub struct Entry {
28 pub delivery_id: String,
29 pub message_id: String,
30 pub conversation_id: String,
31 #[serde(default)]
32 pub seq: i64,
33 #[serde(default)]
34 pub from_address: String,
35 #[serde(default)]
36 pub created_at: String,
37 #[serde(default)]
39 pub confirmed: bool,
40 pub spooled_at: String,
41}
42
43pub const KEEP_HOURS: i64 = 48;
47
48pub fn spool_path(dir: &Path, session: &str) -> PathBuf {
49 dir.join("inbox")
50 .join(format!("{}.jsonl", crate::context::binding_key(session)))
51}
52
53fn ensure_dir(path: &Path) -> anyhow::Result<()> {
54 if let Some(dir) = path.parent() {
55 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
56 #[cfg(unix)]
57 {
58 use std::os::unix::fs::PermissionsExt;
59 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
60 }
61 }
62 Ok(())
63}
64
65pub fn append(path: &Path, entries: &[Entry]) -> anyhow::Result<Vec<Entry>> {
69 ensure_dir(path)?;
70 let existing = read(path);
71 let mut fresh = Vec::new();
72 for entry in entries {
73 if existing.iter().any(|e| e.delivery_id == entry.delivery_id)
74 || fresh
75 .iter()
76 .any(|e: &Entry| e.delivery_id == entry.delivery_id)
77 {
78 continue;
79 }
80 fresh.push(entry.clone());
81 }
82 if fresh.is_empty() {
83 return Ok(fresh);
84 }
85 repair_tail(path)?;
90 let mut file = std::fs::OpenOptions::new()
91 .create(true)
92 .append(true)
93 .open(path)
94 .with_context(|| format!("opening {}", path.display()))?;
95 #[cfg(unix)]
96 {
97 use std::os::unix::fs::PermissionsExt;
98 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
99 }
100 for entry in &fresh {
101 let line = serde_json::to_string(entry)?;
102 writeln!(file, "{line}")?;
103 }
104 file.sync_all().context("fsync of the inbox spool")?;
106 Ok(fresh)
107}
108
109fn repair_tail(path: &Path) -> anyhow::Result<()> {
111 let Ok(text) = std::fs::read(path) else {
112 return Ok(());
113 };
114 if text.is_empty() || text.ends_with(b"\n") {
115 return Ok(());
116 }
117 let mut file = std::fs::OpenOptions::new()
118 .append(true)
119 .open(path)
120 .with_context(|| format!("opening {}", path.display()))?;
121 file.write_all(b"\n")?;
122 file.sync_all()?;
123 Ok(())
124}
125
126pub fn read(path: &Path) -> Vec<Entry> {
130 let Ok(text) = std::fs::read_to_string(path) else {
131 return Vec::new();
132 };
133 text.lines()
134 .filter(|l| !l.trim().is_empty())
135 .filter_map(|l| serde_json::from_str::<Entry>(l).ok())
136 .collect()
137}
138
139pub fn rewrite(path: &Path, entries: &[Entry]) -> anyhow::Result<()> {
142 ensure_dir(path)?;
143 let cutoff = chrono::Utc::now() - chrono::Duration::hours(KEEP_HOURS);
144 let kept: Vec<String> = entries
145 .iter()
146 .filter(|e| {
147 if !e.confirmed {
148 return true;
149 }
150 match chrono::DateTime::parse_from_rfc3339(&e.spooled_at) {
151 Ok(at) => at.with_timezone(&chrono::Utc) > cutoff,
152 Err(_) => true,
153 }
154 })
155 .filter_map(|e| serde_json::to_string(e).ok())
156 .collect();
157 crate::context::write_private(path, &format!("{}\n", kept.join("\n")))
158}
159
160pub fn unconfirmed(entries: &[Entry]) -> Vec<String> {
163 entries
164 .iter()
165 .filter(|e| !e.confirmed)
166 .map(|e| e.delivery_id.clone())
167 .collect()
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn entry(id: &str) -> Entry {
175 Entry {
176 delivery_id: id.to_owned(),
177 message_id: "m".into(),
178 conversation_id: "c".into(),
179 seq: 1,
180 from_address: "dani/review".into(),
181 created_at: "2026-09-20T00:00:00Z".into(),
182 confirmed: false,
183 spooled_at: chrono::Utc::now().to_rfc3339(),
184 }
185 }
186
187 #[test]
188 fn a_redelivered_reference_is_not_spooled_twice() {
189 let dir = std::env::temp_dir().join(format!("acs-spool-{}", uuid::Uuid::new_v4()));
190 let path = dir.join("s.jsonl");
191 assert_eq!(append(&path, &[entry("a"), entry("b")]).unwrap().len(), 2);
192 assert_eq!(
193 append(&path, &[entry("b"), entry("c")]).unwrap().len(),
194 1,
195 "'b' was already held; only 'c' is new"
196 );
197 assert_eq!(read(&path).len(), 3);
198 let _ = std::fs::remove_dir_all(&dir);
199 }
200
201 #[test]
202 fn a_truncated_line_does_not_lose_the_rest() {
203 let dir = std::env::temp_dir().join(format!("acs-spool-{}", uuid::Uuid::new_v4()));
204 let path = dir.join("s.jsonl");
205 append(&path, &[entry("a")]).unwrap();
206 {
207 use std::io::Write as _;
209 let mut f = std::fs::OpenOptions::new()
210 .append(true)
211 .open(&path)
212 .unwrap();
213 write!(f, "{{\"delivery_id\": \"hal").unwrap();
214 }
215 append(&path, &[entry("b")]).unwrap();
216 let held = read(&path);
217 assert_eq!(held.len(), 2);
218 assert!(held.iter().any(|e| e.delivery_id == "b"));
219 let _ = std::fs::remove_dir_all(&dir);
220 }
221
222 #[test]
223 fn compaction_keeps_what_the_bus_has_not_been_told() {
224 let dir = std::env::temp_dir().join(format!("acs-spool-{}", uuid::Uuid::new_v4()));
225 let path = dir.join("s.jsonl");
226 let mut old = entry("old");
227 old.confirmed = true;
228 old.spooled_at =
229 (chrono::Utc::now() - chrono::Duration::hours(KEEP_HOURS + 1)).to_rfc3339();
230 let mut pending = entry("pending");
231 pending.spooled_at = old.spooled_at.clone();
232 rewrite(&path, &[old, pending]).unwrap();
233 let held = read(&path);
234 assert_eq!(held.len(), 1);
235 assert_eq!(
236 held[0].delivery_id, "pending",
237 "an unconfirmed entry is never dropped, however old"
238 );
239 let _ = std::fs::remove_dir_all(&dir);
240 }
241}