use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::error::{Error, Result};
use crate::model::{Board, Definitions, Identity, Settings, Ticket};
pub const WIPE_DIR: &str = ".wipe";
#[derive(Debug, Clone)]
pub struct Store {
root: PathBuf,
}
impl Store {
pub fn root(&self) -> &Path {
&self.root
}
pub fn wipe_dir(&self) -> PathBuf {
self.root.join(WIPE_DIR)
}
fn board_path(&self) -> PathBuf {
self.wipe_dir().join("board.json")
}
fn definitions_path(&self) -> PathBuf {
self.wipe_dir().join("definitions.json")
}
fn settings_path(&self) -> PathBuf {
self.wipe_dir().join("settings.json")
}
fn tickets_dir(&self) -> PathBuf {
self.wipe_dir().join("tickets")
}
pub fn media_dir(&self) -> PathBuf {
self.wipe_dir().join("media")
}
pub fn cache_dir(&self) -> PathBuf {
self.wipe_dir().join(".cache")
}
fn ticket_path(&self, id: &str) -> PathBuf {
self.tickets_dir().join(format!("{id}.json"))
}
pub fn open(root: impl AsRef<Path>) -> Result<Self> {
let root = root.as_ref();
if root.join(WIPE_DIR).is_dir() {
Ok(Store {
root: root.to_path_buf(),
})
} else {
Err(Error::not_initialized(root))
}
}
pub fn discover(start: impl AsRef<Path>) -> Result<Self> {
let start = start.as_ref();
let abs = fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf());
let mut cur: Option<PathBuf> = Some(abs);
while let Some(dir) = cur {
if dir.join(WIPE_DIR).is_dir() {
return Ok(Store { root: dir });
}
cur = dir.parent().map(Path::to_path_buf);
}
Err(Error::not_initialized(start))
}
pub fn init(
root: impl AsRef<Path>,
name: &str,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Self> {
let abs = fs::canonicalize(root.as_ref())?;
let wipe = abs.join(WIPE_DIR);
if wipe.exists() {
return Err(Error::AlreadyInitialized(wipe.display().to_string()));
}
fs::create_dir_all(wipe.join("tickets"))?;
fs::create_dir_all(wipe.join("media"))?;
fs::create_dir_all(wipe.join(".cache"))?;
write_bytes_atomic(&wipe.join(".gitignore"), b"/.cache/\n")?;
write_bytes_atomic(&wipe.join("media").join(".gitkeep"), b"")?;
let store = Store { root: abs };
store.save_board(&Board::new(name, now))?;
store.save_definitions(&Definitions::seed())?;
store.save_settings(&Settings::default())?;
Ok(store)
}
pub fn load_board(&self) -> Result<Board> {
read_json(&self.board_path())
}
pub fn save_board(&self, board: &Board) -> Result<()> {
write_json_atomic(&self.board_path(), board)
}
pub fn load_definitions(&self) -> Result<Definitions> {
read_json(&self.definitions_path())
}
pub fn save_definitions(&self, defs: &Definitions) -> Result<()> {
write_json_atomic(&self.definitions_path(), defs)
}
pub fn load_settings(&self) -> Result<Settings> {
read_json(&self.settings_path())
}
pub fn save_settings(&self, settings: &Settings) -> Result<()> {
write_json_atomic(&self.settings_path(), settings)
}
fn identities_path(&self) -> PathBuf {
self.wipe_dir().join("identities.json")
}
pub fn load_identities(&self) -> Result<Vec<Identity>> {
let path = self.identities_path();
if !path.exists() {
return Ok(Vec::new());
}
read_json(&path)
}
pub fn save_identities(&self, identities: &[Identity]) -> Result<()> {
write_json_atomic(&self.identities_path(), identities)
}
pub fn load_ticket(&self, id: &str) -> Result<Ticket> {
let path = self.ticket_path(id);
if !path.exists() {
return Err(Error::TicketNotFound(id.to_string()));
}
read_json(&path)
}
pub fn save_ticket(&self, ticket: &Ticket) -> Result<()> {
write_json_atomic(&self.ticket_path(&ticket.id), ticket)
}
pub fn delete_ticket(&self, id: &str) -> Result<()> {
let path = self.ticket_path(id);
if !path.exists() {
return Err(Error::TicketNotFound(id.to_string()));
}
fs::remove_file(path)?;
Ok(())
}
pub fn ticket_ids(&self) -> Result<Vec<String>> {
let dir = self.tickets_dir();
let mut ids: Vec<String> = Vec::new();
if !dir.exists() {
return Ok(ids);
}
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
ids.push(stem.to_string());
}
}
}
ids.sort_by_key(|id| ticket_counter(id).unwrap_or(u64::MAX));
Ok(ids)
}
pub fn load_all_tickets(&self) -> Result<Vec<Ticket>> {
self.ticket_ids()?
.iter()
.map(|id| self.load_ticket(id))
.collect()
}
}
fn ticket_counter(id: &str) -> Option<u64> {
id.strip_prefix("T-").and_then(|n| n.parse().ok())
}
fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path
.parent()
.ok_or_else(|| Error::msg(format!("path `{}` has no parent", path.display())))?;
fs::create_dir_all(dir)?;
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
tmp.write_all(bytes)?;
tmp.flush()?;
tmp.persist(path).map_err(|e| Error::Io(e.error))?;
Ok(())
}
fn write_json_atomic<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<()> {
let mut s = serde_json::to_string_pretty(value)?;
s.push('\n');
write_bytes_atomic(path, s.as_bytes())
}
fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
let bytes = fs::read(path)?;
Ok(serde_json::from_slice(&bytes)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Ticket;
use chrono::{TimeZone, Utc};
fn now() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 7, 2, 12, 0, 0).unwrap()
}
fn temp_project() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().unwrap();
let store = Store::init(dir.path(), "Test Board", now()).unwrap();
(dir, store)
}
#[test]
fn init_creates_layout() {
let (_dir, store) = temp_project();
assert!(store.wipe_dir().join("board.json").is_file());
assert!(store.wipe_dir().join("definitions.json").is_file());
assert!(store.wipe_dir().join("settings.json").is_file());
assert!(store.wipe_dir().join("tickets").is_dir());
assert!(store.wipe_dir().join("media").is_dir());
assert!(store.wipe_dir().join(".gitignore").is_file());
}
#[test]
fn init_twice_fails() {
let (dir, _store) = temp_project();
let err = Store::init(dir.path(), "Again", now()).unwrap_err();
assert!(matches!(err, Error::AlreadyInitialized(_)));
}
#[test]
fn discover_walks_up() {
let (dir, _store) = temp_project();
let nested = dir.path().join("a").join("b");
fs::create_dir_all(&nested).unwrap();
let found = Store::discover(&nested).unwrap();
assert_eq!(
fs::canonicalize(found.root()).unwrap(),
fs::canonicalize(dir.path()).unwrap()
);
}
#[test]
fn ticket_roundtrip_and_ordering() {
let (_dir, store) = temp_project();
for n in [1u64, 2, 10] {
let t = Ticket::new(format!("T-{n}"), format!("Ticket {n}"), now());
store.save_ticket(&t).unwrap();
}
assert_eq!(store.ticket_ids().unwrap(), vec!["T-1", "T-2", "T-10"]);
let loaded = store.load_ticket("T-10").unwrap();
assert_eq!(loaded.title, "Ticket 10");
}
#[test]
fn missing_ticket_errors() {
let (_dir, store) = temp_project();
assert!(matches!(
store.load_ticket("T-99"),
Err(Error::TicketNotFound(_))
));
}
#[test]
fn serialization_is_deterministic_and_newline_terminated() {
let (_dir, store) = temp_project();
let raw = fs::read_to_string(store.wipe_dir().join("board.json")).unwrap();
assert!(raw.ends_with('\n'));
let board = store.load_board().unwrap();
store.save_board(&board).unwrap();
let raw2 = fs::read_to_string(store.wipe_dir().join("board.json")).unwrap();
assert_eq!(raw, raw2);
}
}