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, Thread, 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> {
Self::init_with(root, name, now, crate::model::Starter::Standard)
}
pub fn init_with(
root: impl AsRef<Path>,
name: &str,
now: chrono::DateTime<chrono::Utc>,
starter: crate::model::Starter,
) -> Result<Self> {
use crate::model::Starter;
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("forum"))?;
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"")?;
write_bytes_atomic(&wipe.join("forum").join(".gitkeep"), b"")?;
let board = match starter {
Starter::Standard | Starter::ListsOnly => Board::new(name, now),
Starter::Empty => Board::empty(name, now),
};
let mut defs = Definitions::seed();
if starter != Starter::Standard {
defs.labels.clear();
}
let store = Store { root: abs };
store.save_board(&board)?;
store.save_definitions(&defs)?;
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()
}
pub fn forum_dir(&self) -> PathBuf {
self.wipe_dir().join("forum")
}
fn thread_path(&self, thread_id: &str) -> PathBuf {
self.forum_dir().join(format!("{thread_id}.json"))
}
pub fn thread_exists(&self, thread_id: &str) -> bool {
valid_thread_id(thread_id) && self.thread_path(thread_id).exists()
}
pub fn load_thread(&self, thread_id: &str) -> Result<Thread> {
if !valid_thread_id(thread_id) {
return Err(Error::ThreadNotFound(thread_id.to_string()));
}
let path = self.thread_path(thread_id);
if !path.exists() {
return Err(Error::ThreadNotFound(thread_id.to_string()));
}
read_json(&path)
}
pub fn save_thread(&self, thread: &Thread) -> Result<()> {
if !valid_thread_id(&thread.id) {
return Err(Error::msg(format!("invalid thread id `{}`", thread.id)));
}
write_json_atomic(&self.thread_path(&thread.id), thread)
}
pub fn delete_thread(&self, thread_id: &str) -> Result<()> {
if !valid_thread_id(thread_id) {
return Err(Error::ThreadNotFound(thread_id.to_string()));
}
let path = self.thread_path(thread_id);
if !path.exists() {
return Err(Error::ThreadNotFound(thread_id.to_string()));
}
fs::remove_file(path)?;
Ok(())
}
pub fn thread_ids(&self) -> Result<Vec<String>> {
let dir = self.forum_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| thread_counter(id).unwrap_or(u64::MAX));
Ok(ids)
}
pub fn load_all_threads(&self) -> Result<Vec<Thread>> {
self.thread_ids()?
.iter()
.map(|id| self.load_thread(id))
.collect()
}
}
fn valid_thread_id(id: &str) -> bool {
matches!(id.strip_prefix("F-"), Some(n) if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
fn thread_counter(id: &str) -> Option<u64> {
id.strip_prefix("F-")
.map(|rest| rest.split('.').next().unwrap_or(rest))
.and_then(|n| n.parse().ok())
}
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);
}
}