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, Thread, 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("forum"))?;
129 fs::create_dir_all(wipe.join(".cache"))?;
130 write_bytes_atomic(&wipe.join(".gitignore"), b"/.cache/\n")?;
132 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 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 pub fn load_board(&self) -> Result<Board> {
158 read_json(&self.board_path())
159 }
160
161 pub fn save_board(&self, board: &Board) -> Result<()> {
163 write_json_atomic(&self.board_path(), board)
164 }
165
166 pub fn load_definitions(&self) -> Result<Definitions> {
170 read_json(&self.definitions_path())
171 }
172
173 pub fn save_definitions(&self, defs: &Definitions) -> Result<()> {
175 write_json_atomic(&self.definitions_path(), defs)
176 }
177
178 pub fn load_settings(&self) -> Result<Settings> {
182 read_json(&self.settings_path())
183 }
184
185 pub fn save_settings(&self, settings: &Settings) -> Result<()> {
187 write_json_atomic(&self.settings_path(), settings)
188 }
189
190 fn identities_path(&self) -> PathBuf {
193 self.wipe_dir().join("identities.json")
194 }
195
196 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 pub fn save_identities(&self, identities: &[Identity]) -> Result<()> {
207 write_json_atomic(&self.identities_path(), identities)
208 }
209
210 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 pub fn save_ticket(&self, ticket: &Ticket) -> Result<()> {
223 write_json_atomic(&self.ticket_path(&ticket.id), ticket)
224 }
225
226 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 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 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 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 pub fn thread_exists(&self, thread_id: &str) -> bool {
277 valid_thread_id(thread_id) && self.thread_path(thread_id).exists()
278 }
279
280 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 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 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 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 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
342fn 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
349fn 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
356fn ticket_counter(id: &str) -> Option<u64> {
358 id.strip_prefix("T-").and_then(|n| n.parse().ok())
359}
360
361fn 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 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 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}