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 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 write_bytes_atomic(&wipe.join(".gitignore"), b"/.cache/\n")?;
118 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 pub fn load_board(&self) -> Result<Board> {
132 read_json(&self.board_path())
133 }
134
135 pub fn save_board(&self, board: &Board) -> Result<()> {
137 write_json_atomic(&self.board_path(), board)
138 }
139
140 pub fn load_definitions(&self) -> Result<Definitions> {
144 read_json(&self.definitions_path())
145 }
146
147 pub fn save_definitions(&self, defs: &Definitions) -> Result<()> {
149 write_json_atomic(&self.definitions_path(), defs)
150 }
151
152 pub fn load_settings(&self) -> Result<Settings> {
156 read_json(&self.settings_path())
157 }
158
159 pub fn save_settings(&self, settings: &Settings) -> Result<()> {
161 write_json_atomic(&self.settings_path(), settings)
162 }
163
164 fn identities_path(&self) -> PathBuf {
167 self.wipe_dir().join("identities.json")
168 }
169
170 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 pub fn save_identities(&self, identities: &[Identity]) -> Result<()> {
181 write_json_atomic(&self.identities_path(), identities)
182 }
183
184 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 pub fn save_ticket(&self, ticket: &Ticket) -> Result<()> {
197 write_json_atomic(&self.ticket_path(&ticket.id), ticket)
198 }
199
200 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 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 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
239fn ticket_counter(id: &str) -> Option<u64> {
241 id.strip_prefix("T-").and_then(|n| n.parse().ok())
242}
243
244fn 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 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 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}