1use std::collections::HashMap;
11use std::fs::{File, OpenOptions};
12use std::io::Write as _;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex, OnceLock, PoisonError};
15use std::time::Duration;
16
17use chrono::{DateTime, Utc};
18
19use crate::board::{BOARD_SCHEMA, Board, Transition};
20use crate::{Change, TaskError};
21
22const BOARD_FILE: &str = "tasks.json";
23const JOURNAL_FILE: &str = "journal.jsonl";
24const LOCK_FILE: &str = "tasks.lock";
25const NOTICES_FILE: &str = "notices.json";
27const LOCK_WAIT: Duration = Duration::from_secs(10);
29const MAX_JOURNAL_BYTES: u64 = 1024 * 1024;
31const JOURNAL_KEEP: usize = 2000;
32
33struct Due {
35 through: u64,
37 changes: Vec<Change>,
38}
39
40#[derive(Debug, Clone)]
42pub struct Store {
43 dir: PathBuf,
44}
45
46fn store_error(what: &str, error: impl std::fmt::Display) -> TaskError {
47 TaskError::Store(format!("local tasks: {what}: {error}"))
48}
49
50fn process_lock(dir: &Path) -> Arc<Mutex<()>> {
53 static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
54 let mut map = LOCKS
55 .get_or_init(|| Mutex::new(HashMap::new()))
56 .lock()
57 .unwrap_or_else(PoisonError::into_inner);
58 map.entry(dir.to_path_buf()).or_default().clone()
59}
60
61#[cfg(unix)]
62fn private() -> Option<std::fs::Permissions> {
63 use std::os::unix::fs::PermissionsExt as _;
64 Some(std::fs::Permissions::from_mode(0o600))
65}
66
67#[cfg(not(unix))]
68fn private() -> Option<std::fs::Permissions> {
69 None
70}
71
72impl Store {
73 pub fn new(dir: impl Into<PathBuf>) -> Self {
75 Self { dir: dir.into() }
76 }
77
78 pub fn dir(&self) -> &Path {
79 &self.dir
80 }
81
82 pub fn load(&self) -> Result<Board, TaskError> {
84 let path = self.dir.join(BOARD_FILE);
85 let text = match std::fs::read_to_string(&path) {
86 Ok(text) => text,
87 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
88 return Ok(Board::default());
89 }
90 Err(error) => return Err(store_error("read", error)),
91 };
92 let board: Board = serde_json::from_str(&text).map_err(|e| store_error("parse", e))?;
93 if board.schema != BOARD_SCHEMA {
94 return Err(store_error("schema", &board.schema));
95 }
96 Ok(board)
97 }
98
99 pub fn read(&self, now: DateTime<Utc>) -> Result<(Board, Vec<Change>), TaskError> {
102 let board = self.load()?;
103 if !board.has_expired(now) {
104 return Ok((board, Vec::new()));
105 }
106 let mut current = None;
107 let ((), expired) = self.write(now, |board| {
108 current = Some(board.clone());
109 Ok(())
110 })?;
111 Ok((current.unwrap_or_default(), expired))
112 }
113
114 pub fn write<T>(
118 &self,
119 now: DateTime<Utc>,
120 change: impl FnOnce(&mut Board) -> Result<T, TaskError>,
121 ) -> Result<(T, Vec<Change>), TaskError> {
122 self.locked(|| {
123 let (out, changes, _) = self.write_locked(now, change)?;
124 Ok((out, changes))
125 })
126 }
127
128 pub fn read_announced(
131 &self,
132 now: DateTime<Utc>,
133 post: impl FnOnce(&[Change]) -> bool,
134 ) -> Result<(Board, Vec<Change>), TaskError> {
135 let board = self.load()?;
136 if board.has_expired(now) {
137 let mut current = None;
138 let ((), expired) = self.write_announced(
139 now,
140 |board| {
141 current = Some(board.clone());
142 Ok(())
143 },
144 post,
145 )?;
146 return Ok((current.unwrap_or_default(), expired));
147 }
148 if self
149 .notice_cursor()
150 .is_some_and(|posted| posted >= board.seq)
151 {
152 return Ok((board, Vec::new()));
153 }
154 let (board, due) = self.locked(|| {
155 let board = self.load()?;
156 self.ensure_cursor(board.seq)?;
157 let due = self.due_locked(board.seq, &[]);
158 Ok((board, due))
159 })?;
160 self.post_due(due, post);
161 Ok((board, Vec::new()))
162 }
163
164 pub fn write_announced<T>(
173 &self,
174 now: DateTime<Utc>,
175 change: impl FnOnce(&mut Board) -> Result<T, TaskError>,
176 post: impl FnOnce(&[Change]) -> bool,
177 ) -> Result<(T, Vec<Change>), TaskError> {
178 let (out, changes, due) = self.locked(|| {
179 self.ensure_cursor(self.load()?.seq)?;
182 let (out, changes, seq) = self.write_locked(now, change)?;
183 let due = self.due_locked(seq, &changes);
184 Ok((out, changes, due))
185 })?;
186 self.post_due(due, post);
187 Ok((out, changes))
188 }
189
190 fn ensure_cursor(&self, seq: u64) -> Result<(), TaskError> {
194 if self.notice_cursor().is_some() {
195 return Ok(());
196 }
197 self.save_cursor(seq)
198 }
199
200 fn due_locked(&self, seq: u64, fresh: &[Change]) -> Option<Due> {
204 let from = self.notice_cursor()?;
205 if from >= seq {
206 return None;
207 }
208 let changes = if fresh.first().is_some_and(|c| c.seq == from + 1) {
209 fresh.to_vec()
210 } else {
211 self.journal()
212 .iter()
213 .filter(|t| t.seq > from && t.seq <= seq)
214 .map(Change::from)
215 .collect()
216 };
217 Some(Due {
218 through: seq,
219 changes,
220 })
221 }
222
223 fn post_due(&self, due: Option<Due>, post: impl FnOnce(&[Change]) -> bool) {
228 let Some(due) = due else { return };
229 if !due.changes.is_empty() && !post(&due.changes) {
230 return;
231 }
232 let _ = self.locked(|| {
233 if self
234 .notice_cursor()
235 .is_some_and(|posted| posted >= due.through)
236 {
237 return Ok(());
238 }
239 self.save_cursor(due.through)
240 });
241 }
242
243 fn save_cursor(&self, through: u64) -> Result<(), TaskError> {
244 let bytes = format!("{{\"posted\":{through}}}\n");
245 lean_ctx_fs::atomic_fs::try_atomic_write(
246 &self.dir.join(NOTICES_FILE),
247 bytes.as_bytes(),
248 private().as_ref(),
249 )
250 .map_err(|e| store_error("notices", e))
251 }
252
253 fn notice_cursor(&self) -> Option<u64> {
255 let text = std::fs::read_to_string(self.dir.join(NOTICES_FILE)).ok()?;
256 let value: serde_json::Value = serde_json::from_str(&text).ok()?;
257 value.get("posted")?.as_u64()
258 }
259
260 fn locked<R>(&self, f: impl FnOnce() -> Result<R, TaskError>) -> Result<R, TaskError> {
262 let lock = process_lock(&self.dir);
263 let _guard = lock.lock().unwrap_or_else(PoisonError::into_inner);
264 std::fs::create_dir_all(&self.dir).map_err(|e| store_error("create", e))?;
265 let lock_file = File::options()
266 .create(true)
267 .truncate(false)
268 .write(true)
269 .open(self.dir.join(LOCK_FILE))
270 .map_err(|e| store_error("lock", e))?;
271 lean_ctx_fs::file_lock::acquire_exclusive_timeout(&lock_file, LOCK_WAIT)
272 .map_err(|e| store_error("lock", e))?;
273 let result = f();
274 let _ = fs2::FileExt::unlock(&lock_file);
275 result
276 }
277
278 fn write_locked<T>(
281 &self,
282 now: DateTime<Utc>,
283 change: impl FnOnce(&mut Board) -> Result<T, TaskError>,
284 ) -> Result<(T, Vec<Change>, u64), TaskError> {
285 let mut board = self.load()?;
286 board.expire(now);
287 let out = change(&mut board)?;
288 let pending = std::mem::take(&mut board.pending);
289 let changes = std::mem::take(&mut board.changes);
290 self.append_journal(&pending)?;
292 let bytes = serde_json::to_vec(&board).map_err(|e| store_error("encode", e))?;
293 lean_ctx_fs::atomic_fs::try_atomic_write(
294 &self.dir.join(BOARD_FILE),
295 &bytes,
296 private().as_ref(),
297 )
298 .map_err(|e| store_error("write", e))?;
299 Ok((out, changes, board.seq))
300 }
301
302 fn append_journal(&self, transitions: &[Transition]) -> Result<(), TaskError> {
303 if transitions.is_empty() {
304 return Ok(());
305 }
306 let path = self.dir.join(JOURNAL_FILE);
307 let mut lines = String::new();
308 for transition in transitions {
309 let line = serde_json::to_string(transition).map_err(|e| store_error("encode", e))?;
310 lines.push_str(&line);
311 lines.push('\n');
312 }
313 let mut options = OpenOptions::new();
314 options.create(true).append(true);
315 #[cfg(unix)]
316 {
317 use std::os::unix::fs::OpenOptionsExt as _;
318 options.mode(0o600);
319 }
320 let mut file = options.open(&path).map_err(|e| store_error("journal", e))?;
321 file.write_all(lines.as_bytes())
322 .map_err(|e| store_error("journal", e))?;
323 drop(file);
324 let size = std::fs::metadata(&path).map_or(0, |m| m.len());
325 if size > MAX_JOURNAL_BYTES {
326 let text = std::fs::read_to_string(&path).map_err(|e| store_error("journal", e))?;
327 let all: Vec<&str> = text.lines().collect();
328 let kept = all[all.len().saturating_sub(JOURNAL_KEEP)..].join("\n") + "\n";
329 lean_ctx_fs::atomic_fs::try_atomic_write(&path, kept.as_bytes(), private().as_ref())
330 .map_err(|e| store_error("journal", e))?;
331 }
332 Ok(())
333 }
334
335 pub fn journal(&self) -> Vec<Transition> {
337 std::fs::read_to_string(self.dir.join(JOURNAL_FILE))
338 .unwrap_or_default()
339 .lines()
340 .filter_map(|line| serde_json::from_str(line).ok())
341 .collect()
342 }
343}