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