Skip to main content

wipe_core/
store.rs

1//! Deterministic, atomic persistence of a `.wipe` board and project discovery.
2//!
3//! [`Store`] is the *only* sanctioned way to read and write a `.wipe` directory.
4//! All writes are:
5//!
6//! * **Deterministic** - `serde_json::to_string_pretty` plus a trailing newline,
7//!   with a model whose field/collection order is stable, so re-serializing
8//!   unchanged data yields byte-identical output and git diffs stay minimal.
9//! * **Atomic** - written to a temporary file in the same directory and then
10//!   renamed over the target, so a crash never leaves a half-written file.
11
12use std::fs;
13use std::io::Write as _;
14use std::path::{Path, PathBuf};
15
16use serde::de::DeserializeOwned;
17use serde::Serialize;
18
19use crate::error::{Error, Result};
20use crate::model::{Board, Definitions, Identity, Settings, Thread, Ticket};
21
22/// Name of the per-project board directory.
23pub const WIPE_DIR: &str = ".wipe";
24
25/// A handle to a `.wipe` board rooted at a project directory.
26#[derive(Debug, Clone)]
27pub struct Store {
28    /// The project root (the directory that contains `.wipe`).
29    root: PathBuf,
30}
31
32impl Store {
33    /// The project root directory (the parent of `.wipe`).
34    pub fn root(&self) -> &Path {
35        &self.root
36    }
37
38    /// Path to the `.wipe` directory.
39    pub fn wipe_dir(&self) -> PathBuf {
40        self.root.join(WIPE_DIR)
41    }
42
43    fn board_path(&self) -> PathBuf {
44        self.wipe_dir().join("board.json")
45    }
46
47    fn definitions_path(&self) -> PathBuf {
48        self.wipe_dir().join("definitions.json")
49    }
50
51    fn settings_path(&self) -> PathBuf {
52        self.wipe_dir().join("settings.json")
53    }
54
55    fn tickets_dir(&self) -> PathBuf {
56        self.wipe_dir().join("tickets")
57    }
58
59    /// Path to the media directory (version-controlled attachments).
60    pub fn media_dir(&self) -> PathBuf {
61        self.wipe_dir().join("media")
62    }
63
64    /// Path to the (gitignored) cache directory.
65    pub fn cache_dir(&self) -> PathBuf {
66        self.wipe_dir().join(".cache")
67    }
68
69    fn ticket_path(&self, id: &str) -> PathBuf {
70        self.tickets_dir().join(format!("{id}.json"))
71    }
72
73    /// Open an existing board rooted exactly at `root` (which must contain `.wipe`).
74    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
75        let root = root.as_ref();
76        if root.join(WIPE_DIR).is_dir() {
77            Ok(Store {
78                root: root.to_path_buf(),
79            })
80        } else {
81            Err(Error::not_initialized(root))
82        }
83    }
84
85    /// Discover the board by walking up from `start` until a `.wipe` directory is
86    /// found, mirroring how git locates its repository root.
87    pub fn discover(start: impl AsRef<Path>) -> Result<Self> {
88        let start = start.as_ref();
89        let abs = fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
90        let mut cur: Option<PathBuf> = Some(abs);
91        while let Some(dir) = cur {
92            if dir.join(WIPE_DIR).is_dir() {
93                return Ok(Store { root: dir });
94            }
95            cur = dir.parent().map(Path::to_path_buf);
96        }
97        Err(Error::not_initialized(start))
98    }
99
100    /// Initialize a brand-new board under `root` with the default (standard)
101    /// starter content. Fails with [`Error::AlreadyInitialized`] if a `.wipe`
102    /// directory already exists.
103    pub fn init(
104        root: impl AsRef<Path>,
105        name: &str,
106        now: chrono::DateTime<chrono::Utc>,
107    ) -> Result<Self> {
108        Self::init_with(root, name, now, crate::model::Starter::Standard)
109    }
110
111    /// Initialize a brand-new board, choosing how much starter content to seed:
112    /// standard lists+labels, lists only, or a blank board.
113    pub fn init_with(
114        root: impl AsRef<Path>,
115        name: &str,
116        now: chrono::DateTime<chrono::Utc>,
117        starter: crate::model::Starter,
118    ) -> Result<Self> {
119        use crate::model::Starter;
120
121        let abs = fs::canonicalize(root.as_ref())?;
122        let wipe = abs.join(WIPE_DIR);
123        if wipe.exists() {
124            return Err(Error::AlreadyInitialized(wipe.display().to_string()));
125        }
126        fs::create_dir_all(wipe.join("tickets"))?;
127        fs::create_dir_all(wipe.join("media"))?;
128        fs::create_dir_all(wipe.join("forum"))?;
129        fs::create_dir_all(wipe.join(".cache"))?;
130        // Keep the local cache out of version control.
131        write_bytes_atomic(&wipe.join(".gitignore"), b"/.cache/\n")?;
132        // Keep the media and forum directories in git even when empty.
133        write_bytes_atomic(&wipe.join("media").join(".gitkeep"), b"")?;
134        write_bytes_atomic(&wipe.join("forum").join(".gitkeep"), b"")?;
135
136        let board = match starter {
137            Starter::Standard | Starter::ListsOnly => Board::new(name, now),
138            Starter::Empty => Board::empty(name, now),
139        };
140        // Priorities are a harmless shared vocabulary, kept for every starter;
141        // labels are only seeded for the standard starter.
142        let mut defs = Definitions::seed();
143        if starter != Starter::Standard {
144            defs.labels.clear();
145        }
146
147        let store = Store { root: abs };
148        store.save_board(&board)?;
149        store.save_definitions(&defs)?;
150        store.save_settings(&Settings::default())?;
151        Ok(store)
152    }
153
154    // --- board -------------------------------------------------------------
155
156    /// Load `board.json`.
157    pub fn load_board(&self) -> Result<Board> {
158        read_json(&self.board_path())
159    }
160
161    /// Write `board.json` deterministically and atomically.
162    pub fn save_board(&self, board: &Board) -> Result<()> {
163        write_json_atomic(&self.board_path(), board)
164    }
165
166    // --- definitions -------------------------------------------------------
167
168    /// Load `definitions.json`.
169    pub fn load_definitions(&self) -> Result<Definitions> {
170        read_json(&self.definitions_path())
171    }
172
173    /// Write `definitions.json`.
174    pub fn save_definitions(&self, defs: &Definitions) -> Result<()> {
175        write_json_atomic(&self.definitions_path(), defs)
176    }
177
178    // --- settings ----------------------------------------------------------
179
180    /// Load `settings.json`.
181    pub fn load_settings(&self) -> Result<Settings> {
182        read_json(&self.settings_path())
183    }
184
185    /// Write `settings.json`.
186    pub fn save_settings(&self, settings: &Settings) -> Result<()> {
187        write_json_atomic(&self.settings_path(), settings)
188    }
189
190    // --- identities --------------------------------------------------------
191
192    fn identities_path(&self) -> PathBuf {
193        self.wipe_dir().join("identities.json")
194    }
195
196    /// Load `identities.json` (empty if the file doesn't exist yet).
197    pub fn load_identities(&self) -> Result<Vec<Identity>> {
198        let path = self.identities_path();
199        if !path.exists() {
200            return Ok(Vec::new());
201        }
202        read_json(&path)
203    }
204
205    /// Write `identities.json`.
206    pub fn save_identities(&self, identities: &[Identity]) -> Result<()> {
207        write_json_atomic(&self.identities_path(), identities)
208    }
209
210    // --- tickets -----------------------------------------------------------
211
212    /// Load a single ticket by ID.
213    pub fn load_ticket(&self, id: &str) -> Result<Ticket> {
214        let path = self.ticket_path(id);
215        if !path.exists() {
216            return Err(Error::TicketNotFound(id.to_string()));
217        }
218        read_json(&path)
219    }
220
221    /// Write a ticket file.
222    pub fn save_ticket(&self, ticket: &Ticket) -> Result<()> {
223        write_json_atomic(&self.ticket_path(&ticket.id), ticket)
224    }
225
226    /// Delete a ticket file. Errors if it does not exist.
227    pub fn delete_ticket(&self, id: &str) -> Result<()> {
228        let path = self.ticket_path(id);
229        if !path.exists() {
230            return Err(Error::TicketNotFound(id.to_string()));
231        }
232        fs::remove_file(path)?;
233        Ok(())
234    }
235
236    /// Return all ticket IDs currently on disk, sorted numerically by counter.
237    pub fn ticket_ids(&self) -> Result<Vec<String>> {
238        let dir = self.tickets_dir();
239        let mut ids: Vec<String> = Vec::new();
240        if !dir.exists() {
241            return Ok(ids);
242        }
243        for entry in fs::read_dir(&dir)? {
244            let entry = entry?;
245            let path = entry.path();
246            if path.extension().and_then(|e| e.to_str()) == Some("json") {
247                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
248                    ids.push(stem.to_string());
249                }
250            }
251        }
252        ids.sort_by_key(|id| ticket_counter(id).unwrap_or(u64::MAX));
253        Ok(ids)
254    }
255
256    /// Load every ticket on disk, ordered by ID counter.
257    pub fn load_all_tickets(&self) -> Result<Vec<Ticket>> {
258        self.ticket_ids()?
259            .iter()
260            .map(|id| self.load_ticket(id))
261            .collect()
262    }
263
264    // --- forum -------------------------------------------------------------
265
266    /// Path to the forum directory (`.wipe/forum`).
267    pub fn forum_dir(&self) -> PathBuf {
268        self.wipe_dir().join("forum")
269    }
270
271    fn thread_path(&self, thread_id: &str) -> PathBuf {
272        self.forum_dir().join(format!("{thread_id}.json"))
273    }
274
275    /// Whether a thread file already exists on disk for `thread_id`.
276    pub fn thread_exists(&self, thread_id: &str) -> bool {
277        valid_thread_id(thread_id) && self.thread_path(thread_id).exists()
278    }
279
280    /// Load a forum thread by its thread ID (e.g. `F-1`).
281    pub fn load_thread(&self, thread_id: &str) -> Result<Thread> {
282        if !valid_thread_id(thread_id) {
283            return Err(Error::ThreadNotFound(thread_id.to_string()));
284        }
285        let path = self.thread_path(thread_id);
286        if !path.exists() {
287            return Err(Error::ThreadNotFound(thread_id.to_string()));
288        }
289        read_json(&path)
290    }
291
292    /// Write a forum thread file.
293    pub fn save_thread(&self, thread: &Thread) -> Result<()> {
294        if !valid_thread_id(&thread.id) {
295            return Err(Error::msg(format!("invalid thread id `{}`", thread.id)));
296        }
297        write_json_atomic(&self.thread_path(&thread.id), thread)
298    }
299
300    /// Delete a forum thread file. Errors if it does not exist.
301    pub fn delete_thread(&self, thread_id: &str) -> Result<()> {
302        if !valid_thread_id(thread_id) {
303            return Err(Error::ThreadNotFound(thread_id.to_string()));
304        }
305        let path = self.thread_path(thread_id);
306        if !path.exists() {
307            return Err(Error::ThreadNotFound(thread_id.to_string()));
308        }
309        fs::remove_file(path)?;
310        Ok(())
311    }
312
313    /// All forum thread IDs on disk, sorted numerically by counter.
314    pub fn thread_ids(&self) -> Result<Vec<String>> {
315        let dir = self.forum_dir();
316        let mut ids: Vec<String> = Vec::new();
317        if !dir.exists() {
318            return Ok(ids);
319        }
320        for entry in fs::read_dir(&dir)? {
321            let entry = entry?;
322            let path = entry.path();
323            if path.extension().and_then(|e| e.to_str()) == Some("json") {
324                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
325                    ids.push(stem.to_string());
326                }
327            }
328        }
329        ids.sort_by_key(|id| thread_counter(id).unwrap_or(u64::MAX));
330        Ok(ids)
331    }
332
333    /// Load every forum thread on disk, ordered by ID counter.
334    pub fn load_all_threads(&self) -> Result<Vec<Thread>> {
335        self.thread_ids()?
336            .iter()
337            .map(|id| self.load_thread(id))
338            .collect()
339    }
340}
341
342/// A thread ID must be `F-` followed by digits only. Rejecting anything else
343/// (path separators, `..`, dots) guarantees a caller-supplied ID can never be
344/// turned into a path that escapes the forum directory.
345fn valid_thread_id(id: &str) -> bool {
346    matches!(id.strip_prefix("F-"), Some(n) if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
347}
348
349/// Parse the numeric counter out of an `F-<n>` thread ID (ignoring any `.x` tail).
350fn thread_counter(id: &str) -> Option<u64> {
351    id.strip_prefix("F-")
352        .map(|rest| rest.split('.').next().unwrap_or(rest))
353        .and_then(|n| n.parse().ok())
354}
355
356/// Parse the numeric counter out of a `T-<n>` ticket ID.
357fn ticket_counter(id: &str) -> Option<u64> {
358    id.strip_prefix("T-").and_then(|n| n.parse().ok())
359}
360
361// --- low-level IO ----------------------------------------------------------
362
363fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
364    let dir = path
365        .parent()
366        .ok_or_else(|| Error::msg(format!("path `{}` has no parent", path.display())))?;
367    fs::create_dir_all(dir)?;
368    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
369    tmp.write_all(bytes)?;
370    tmp.flush()?;
371    tmp.persist(path).map_err(|e| Error::Io(e.error))?;
372    Ok(())
373}
374
375fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<()> {
376    let mut s = serde_json::to_string_pretty(value)?;
377    s.push('\n');
378    write_bytes_atomic(path, s.as_bytes())
379}
380
381fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
382    let bytes = fs::read(path)?;
383    Ok(serde_json::from_slice(&bytes)?)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::model::Ticket;
390    use chrono::{TimeZone, Utc};
391
392    fn now() -> chrono::DateTime<Utc> {
393        Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
394    }
395
396    fn temp_project() -> (tempfile::TempDir, Store) {
397        let dir = tempfile::tempdir().unwrap();
398        let store = Store::init(dir.path(), "Test Board", now()).unwrap();
399        (dir, store)
400    }
401
402    #[test]
403    fn init_creates_layout() {
404        let (_dir, store) = temp_project();
405        assert!(store.wipe_dir().join("board.json").is_file());
406        assert!(store.wipe_dir().join("definitions.json").is_file());
407        assert!(store.wipe_dir().join("settings.json").is_file());
408        assert!(store.wipe_dir().join("tickets").is_dir());
409        assert!(store.wipe_dir().join("media").is_dir());
410        assert!(store.wipe_dir().join(".gitignore").is_file());
411    }
412
413    #[test]
414    fn init_twice_fails() {
415        let (dir, _store) = temp_project();
416        let err = Store::init(dir.path(), "Again", now()).unwrap_err();
417        assert!(matches!(err, Error::AlreadyInitialized(_)));
418    }
419
420    #[test]
421    fn discover_walks_up() {
422        let (dir, _store) = temp_project();
423        let nested = dir.path().join("a").join("b");
424        fs::create_dir_all(&nested).unwrap();
425        let found = Store::discover(&nested).unwrap();
426        assert_eq!(
427            fs::canonicalize(found.root()).unwrap(),
428            fs::canonicalize(dir.path()).unwrap()
429        );
430    }
431
432    #[test]
433    fn ticket_roundtrip_and_ordering() {
434        let (_dir, store) = temp_project();
435        for n in [1u64, 2, 10] {
436            let t = Ticket::new(format!("T-{n}"), format!("Ticket {n}"), now());
437            store.save_ticket(&t).unwrap();
438        }
439        // Numeric, not lexical, ordering: T-2 before T-10.
440        assert_eq!(store.ticket_ids().unwrap(), vec!["T-1", "T-2", "T-10"]);
441        let loaded = store.load_ticket("T-10").unwrap();
442        assert_eq!(loaded.title, "Ticket 10");
443    }
444
445    #[test]
446    fn missing_ticket_errors() {
447        let (_dir, store) = temp_project();
448        assert!(matches!(
449            store.load_ticket("T-99"),
450            Err(Error::TicketNotFound(_))
451        ));
452    }
453
454    #[test]
455    fn serialization_is_deterministic_and_newline_terminated() {
456        let (_dir, store) = temp_project();
457        let raw = fs::read_to_string(store.wipe_dir().join("board.json")).unwrap();
458        assert!(raw.ends_with('\n'));
459        // Round-trip: load and re-save yields byte-identical output.
460        let board = store.load_board().unwrap();
461        store.save_board(&board).unwrap();
462        let raw2 = fs::read_to_string(store.wipe_dir().join("board.json")).unwrap();
463        assert_eq!(raw, raw2);
464    }
465}