1use 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
22pub const WIPE_DIR: &str = ".wipe";
24
25#[derive(Debug, Clone)]
27pub struct Store {
28 root: PathBuf,
30}
31
32impl Store {
33 pub fn root(&self) -> &Path {
35 &self.root
36 }
37
38 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 pub fn media_dir(&self) -> PathBuf {
61 self.wipe_dir().join("media")
62 }
63
64 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 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 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 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 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 write_bytes_atomic(&wipe.join(".gitignore"), b"/.cache/\n")?;
131 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 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 pub fn load_board(&self) -> Result<Board> {
156 read_json(&self.board_path())
157 }
158
159 pub fn save_board(&self, board: &Board) -> Result<()> {
161 write_json_atomic(&self.board_path(), board)
162 }
163
164 pub fn load_definitions(&self) -> Result<Definitions> {
168 read_json(&self.definitions_path())
169 }
170
171 pub fn save_definitions(&self, defs: &Definitions) -> Result<()> {
173 write_json_atomic(&self.definitions_path(), defs)
174 }
175
176 pub fn load_settings(&self) -> Result<Settings> {
180 read_json(&self.settings_path())
181 }
182
183 pub fn save_settings(&self, settings: &Settings) -> Result<()> {
185 write_json_atomic(&self.settings_path(), settings)
186 }
187
188 fn identities_path(&self) -> PathBuf {
191 self.wipe_dir().join("identities.json")
192 }
193
194 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 pub fn save_identities(&self, identities: &[Identity]) -> Result<()> {
205 write_json_atomic(&self.identities_path(), identities)
206 }
207
208 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 pub fn save_ticket(&self, ticket: &Ticket) -> Result<()> {
221 write_json_atomic(&self.ticket_path(&ticket.id), ticket)
222 }
223
224 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 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 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
263fn ticket_counter(id: &str) -> Option<u64> {
265 id.strip_prefix("T-").and_then(|n| n.parse().ok())
266}
267
268fn 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 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 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}