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, 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(".cache"))?;
129        // Keep the local cache out of version control.
130        write_bytes_atomic(&wipe.join(".gitignore"), b"/.cache/\n")?;
131        // Keep the media directory in git even when empty.
132        write_bytes_atomic(&wipe.join("media").join(".gitkeep"), b"")?;
133
134        let board = match starter {
135            Starter::Standard | Starter::ListsOnly => Board::new(name, now),
136            Starter::Empty => Board::empty(name, now),
137        };
138        // Priorities are a harmless shared vocabulary, kept for every starter;
139        // labels are only seeded for the standard starter.
140        let mut defs = Definitions::seed();
141        if starter != Starter::Standard {
142            defs.labels.clear();
143        }
144
145        let store = Store { root: abs };
146        store.save_board(&board)?;
147        store.save_definitions(&defs)?;
148        store.save_settings(&Settings::default())?;
149        Ok(store)
150    }
151
152    // --- board -------------------------------------------------------------
153
154    /// Load `board.json`.
155    pub fn load_board(&self) -> Result<Board> {
156        read_json(&self.board_path())
157    }
158
159    /// Write `board.json` deterministically and atomically.
160    pub fn save_board(&self, board: &Board) -> Result<()> {
161        write_json_atomic(&self.board_path(), board)
162    }
163
164    // --- definitions -------------------------------------------------------
165
166    /// Load `definitions.json`.
167    pub fn load_definitions(&self) -> Result<Definitions> {
168        read_json(&self.definitions_path())
169    }
170
171    /// Write `definitions.json`.
172    pub fn save_definitions(&self, defs: &Definitions) -> Result<()> {
173        write_json_atomic(&self.definitions_path(), defs)
174    }
175
176    // --- settings ----------------------------------------------------------
177
178    /// Load `settings.json`.
179    pub fn load_settings(&self) -> Result<Settings> {
180        read_json(&self.settings_path())
181    }
182
183    /// Write `settings.json`.
184    pub fn save_settings(&self, settings: &Settings) -> Result<()> {
185        write_json_atomic(&self.settings_path(), settings)
186    }
187
188    // --- identities --------------------------------------------------------
189
190    fn identities_path(&self) -> PathBuf {
191        self.wipe_dir().join("identities.json")
192    }
193
194    /// Load `identities.json` (empty if the file doesn't exist yet).
195    pub fn load_identities(&self) -> Result<Vec<Identity>> {
196        let path = self.identities_path();
197        if !path.exists() {
198            return Ok(Vec::new());
199        }
200        read_json(&path)
201    }
202
203    /// Write `identities.json`.
204    pub fn save_identities(&self, identities: &[Identity]) -> Result<()> {
205        write_json_atomic(&self.identities_path(), identities)
206    }
207
208    // --- tickets -----------------------------------------------------------
209
210    /// Load a single ticket by ID.
211    pub fn load_ticket(&self, id: &str) -> Result<Ticket> {
212        let path = self.ticket_path(id);
213        if !path.exists() {
214            return Err(Error::TicketNotFound(id.to_string()));
215        }
216        read_json(&path)
217    }
218
219    /// Write a ticket file.
220    pub fn save_ticket(&self, ticket: &Ticket) -> Result<()> {
221        write_json_atomic(&self.ticket_path(&ticket.id), ticket)
222    }
223
224    /// Delete a ticket file. Errors if it does not exist.
225    pub fn delete_ticket(&self, id: &str) -> Result<()> {
226        let path = self.ticket_path(id);
227        if !path.exists() {
228            return Err(Error::TicketNotFound(id.to_string()));
229        }
230        fs::remove_file(path)?;
231        Ok(())
232    }
233
234    /// Return all ticket IDs currently on disk, sorted numerically by counter.
235    pub fn ticket_ids(&self) -> Result<Vec<String>> {
236        let dir = self.tickets_dir();
237        let mut ids: Vec<String> = Vec::new();
238        if !dir.exists() {
239            return Ok(ids);
240        }
241        for entry in fs::read_dir(&dir)? {
242            let entry = entry?;
243            let path = entry.path();
244            if path.extension().and_then(|e| e.to_str()) == Some("json") {
245                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
246                    ids.push(stem.to_string());
247                }
248            }
249        }
250        ids.sort_by_key(|id| ticket_counter(id).unwrap_or(u64::MAX));
251        Ok(ids)
252    }
253
254    /// Load every ticket on disk, ordered by ID counter.
255    pub fn load_all_tickets(&self) -> Result<Vec<Ticket>> {
256        self.ticket_ids()?
257            .iter()
258            .map(|id| self.load_ticket(id))
259            .collect()
260    }
261}
262
263/// Parse the numeric counter out of a `T-<n>` ticket ID.
264fn ticket_counter(id: &str) -> Option<u64> {
265    id.strip_prefix("T-").and_then(|n| n.parse().ok())
266}
267
268// --- low-level IO ----------------------------------------------------------
269
270fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
271    let dir = path
272        .parent()
273        .ok_or_else(|| Error::msg(format!("path `{}` has no parent", path.display())))?;
274    fs::create_dir_all(dir)?;
275    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
276    tmp.write_all(bytes)?;
277    tmp.flush()?;
278    tmp.persist(path).map_err(|e| Error::Io(e.error))?;
279    Ok(())
280}
281
282fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<()> {
283    let mut s = serde_json::to_string_pretty(value)?;
284    s.push('\n');
285    write_bytes_atomic(path, s.as_bytes())
286}
287
288fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
289    let bytes = fs::read(path)?;
290    Ok(serde_json::from_slice(&bytes)?)
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::model::Ticket;
297    use chrono::{TimeZone, Utc};
298
299    fn now() -> chrono::DateTime<Utc> {
300        Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
301    }
302
303    fn temp_project() -> (tempfile::TempDir, Store) {
304        let dir = tempfile::tempdir().unwrap();
305        let store = Store::init(dir.path(), "Test Board", now()).unwrap();
306        (dir, store)
307    }
308
309    #[test]
310    fn init_creates_layout() {
311        let (_dir, store) = temp_project();
312        assert!(store.wipe_dir().join("board.json").is_file());
313        assert!(store.wipe_dir().join("definitions.json").is_file());
314        assert!(store.wipe_dir().join("settings.json").is_file());
315        assert!(store.wipe_dir().join("tickets").is_dir());
316        assert!(store.wipe_dir().join("media").is_dir());
317        assert!(store.wipe_dir().join(".gitignore").is_file());
318    }
319
320    #[test]
321    fn init_twice_fails() {
322        let (dir, _store) = temp_project();
323        let err = Store::init(dir.path(), "Again", now()).unwrap_err();
324        assert!(matches!(err, Error::AlreadyInitialized(_)));
325    }
326
327    #[test]
328    fn discover_walks_up() {
329        let (dir, _store) = temp_project();
330        let nested = dir.path().join("a").join("b");
331        fs::create_dir_all(&nested).unwrap();
332        let found = Store::discover(&nested).unwrap();
333        assert_eq!(
334            fs::canonicalize(found.root()).unwrap(),
335            fs::canonicalize(dir.path()).unwrap()
336        );
337    }
338
339    #[test]
340    fn ticket_roundtrip_and_ordering() {
341        let (_dir, store) = temp_project();
342        for n in [1u64, 2, 10] {
343            let t = Ticket::new(format!("T-{n}"), format!("Ticket {n}"), now());
344            store.save_ticket(&t).unwrap();
345        }
346        // Numeric, not lexical, ordering: T-2 before T-10.
347        assert_eq!(store.ticket_ids().unwrap(), vec!["T-1", "T-2", "T-10"]);
348        let loaded = store.load_ticket("T-10").unwrap();
349        assert_eq!(loaded.title, "Ticket 10");
350    }
351
352    #[test]
353    fn missing_ticket_errors() {
354        let (_dir, store) = temp_project();
355        assert!(matches!(
356            store.load_ticket("T-99"),
357            Err(Error::TicketNotFound(_))
358        ));
359    }
360
361    #[test]
362    fn serialization_is_deterministic_and_newline_terminated() {
363        let (_dir, store) = temp_project();
364        let raw = fs::read_to_string(store.wipe_dir().join("board.json")).unwrap();
365        assert!(raw.ends_with('\n'));
366        // Round-trip: load and re-save yields byte-identical output.
367        let board = store.load_board().unwrap();
368        store.save_board(&board).unwrap();
369        let raw2 = fs::read_to_string(store.wipe_dir().join("board.json")).unwrap();
370        assert_eq!(raw, raw2);
371    }
372}