use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::Serialize;
use serde::de::DeserializeOwned;
use thiserror::Error;
use tokio::fs;
use tracing::debug;
use super::milestone::Milestone;
use super::record::Deliverable;
pub trait Keyed {
fn store_key(&self) -> String;
fn project_name(&self) -> &str;
}
impl Keyed for Deliverable {
fn store_key(&self) -> String {
self.id.to_string()
}
fn project_name(&self) -> &str {
&self.project_name
}
}
impl Keyed for Milestone {
fn store_key(&self) -> String {
self.id.to_string()
}
fn project_name(&self) -> &str {
&self.project_name
}
}
#[derive(Debug, Error)]
pub enum StoreError {
#[error("store I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("store serialization error: {0}")]
Serialize(String),
#[error("record not found: {0}")]
NotFound(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct FileSig {
mtime: Option<SystemTime>,
len: u64,
}
#[derive(Debug)]
pub struct JsonStore<T> {
data: HashMap<String, T>,
path: PathBuf,
last_sig: Option<FileSig>,
}
impl<T> JsonStore<T>
where
T: Keyed + Clone + Serialize + DeserializeOwned,
{
pub async fn load(path: PathBuf) -> Result<Self, StoreError> {
let (data, last_sig) = Self::read_file(&path).await?;
Ok(Self {
data,
path,
last_sig,
})
}
async fn sig_of(path: &Path) -> Option<FileSig> {
let meta = fs::metadata(path).await.ok()?;
Some(FileSig {
mtime: meta.modified().ok(),
len: meta.len(),
})
}
async fn read_file(path: &Path) -> Result<(HashMap<String, T>, Option<FileSig>), StoreError> {
match fs::read_to_string(path).await {
Ok(raw) => {
let data = serde_json::from_str::<HashMap<String, T>>(&raw)
.map_err(|e| StoreError::Serialize(e.to_string()))?;
let sig = Self::sig_of(path).await.unwrap_or_default();
Ok((data, Some(sig)))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!(path = %path.display(), "no store file found; starting fresh");
Ok((HashMap::new(), None))
}
Err(e) => Err(StoreError::Io(e)),
}
}
pub async fn reload_if_changed(&mut self) -> Result<(), StoreError> {
let current = Self::sig_of(&self.path).await;
let unchanged = matches!((current, self.last_sig), (Some(a), Some(b)) if a == b);
if unchanged {
return Ok(());
}
let (data, sig) = Self::read_file(&self.path).await?;
self.data = data;
self.last_sig = sig;
debug!(path = %self.path.display(), "store reloaded after external change");
Ok(())
}
pub async fn save(&mut self) -> Result<(), StoreError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).await?;
}
let json = serde_json::to_string_pretty(&self.data)
.map_err(|e| StoreError::Serialize(e.to_string()))?;
let tmp = self.path.with_extension("json.tmp");
fs::write(&tmp, json).await?;
fs::rename(&tmp, &self.path).await?;
self.last_sig = Self::sig_of(&self.path).await;
debug!(path = %self.path.display(), "store saved");
Ok(())
}
pub async fn upsert(&mut self, record: T) -> Result<(), StoreError> {
self.reload_if_changed().await?;
self.data.insert(record.store_key(), record);
self.save().await
}
pub async fn get(&mut self, id: &str) -> Result<T, StoreError> {
self.reload_if_changed().await?;
self.data
.get(id)
.cloned()
.ok_or_else(|| StoreError::NotFound(id.to_string()))
}
pub async fn all(&mut self) -> Result<Vec<T>, StoreError> {
self.reload_if_changed().await?;
Ok(self.data.values().cloned().collect())
}
pub async fn by_project(&mut self, project_name: &str) -> Result<Vec<T>, StoreError> {
self.reload_if_changed().await?;
Ok(self
.data
.values()
.filter(|r| r.project_name() == project_name)
.cloned()
.collect())
}
pub async fn remove(&mut self, id: &str) -> Result<T, StoreError> {
self.reload_if_changed().await?;
let removed = self
.data
.remove(id)
.ok_or_else(|| StoreError::NotFound(id.to_string()))?;
self.save().await?;
Ok(removed)
}
}
pub type DeliverableStore = JsonStore<Deliverable>;
pub type MilestoneStore = JsonStore<Milestone>;
#[cfg(test)]
mod tests {
use super::*;
use crate::deliverable::record::{DeliverableId, DeliverableKind, EstimationTier};
use crate::deliverable::status::DeliverableStatus;
use chrono::Utc;
use tempfile::TempDir;
fn deliverable(project: &str, name: &str) -> Deliverable {
Deliverable {
id: DeliverableId::new(),
project_name: project.into(),
name: name.into(),
description: String::new(),
kind: DeliverableKind::Feature,
ticket_ref: None,
spec_ref: None,
status: DeliverableStatus::Proposed,
estimated_effort: EstimationTier::M,
created_at: Utc::now(),
target_date: None,
}
}
fn path(dir: &TempDir) -> PathBuf {
dir.path().join("deliverables.json")
}
#[tokio::test]
async fn store_round_trip() {
let dir = TempDir::new().unwrap();
let mut store = DeliverableStore::load(path(&dir)).await.unwrap();
assert!(store.all().await.unwrap().is_empty());
let d = deliverable("alpha", "one");
let key = d.store_key();
store.upsert(d).await.unwrap();
let mut store2 = DeliverableStore::load(path(&dir)).await.unwrap();
let got = store2.get(&key).await.unwrap();
assert_eq!(got.name, "one");
}
#[tokio::test]
async fn store_upsert_idempotent() {
let dir = TempDir::new().unwrap();
let mut store = DeliverableStore::load(path(&dir)).await.unwrap();
let mut d = deliverable("beta", "orig");
let key = d.store_key();
store.upsert(d.clone()).await.unwrap();
d.name = "updated".into();
store.upsert(d).await.unwrap();
let all = store.all().await.unwrap();
assert_eq!(all.len(), 1, "upsert on same key must not duplicate");
assert_eq!(store.get(&key).await.unwrap().name, "updated");
}
#[tokio::test]
async fn store_by_project_filters() {
let dir = TempDir::new().unwrap();
let mut store = DeliverableStore::load(path(&dir)).await.unwrap();
store.upsert(deliverable("p1", "a")).await.unwrap();
store.upsert(deliverable("p1", "b")).await.unwrap();
store.upsert(deliverable("p2", "c")).await.unwrap();
let p1 = store.by_project("p1").await.unwrap();
assert_eq!(p1.len(), 2);
assert!(p1.iter().all(|d| d.project_name == "p1"));
assert_eq!(store.by_project("p2").await.unwrap().len(), 1);
assert!(store.by_project("missing").await.unwrap().is_empty());
}
#[tokio::test]
async fn store_get_missing_is_not_found() {
let dir = TempDir::new().unwrap();
let mut store = DeliverableStore::load(path(&dir)).await.unwrap();
let err = store.get("nope").await;
assert!(matches!(err, Err(StoreError::NotFound(_))));
}
#[tokio::test]
async fn store_remove() {
let dir = TempDir::new().unwrap();
let mut store = DeliverableStore::load(path(&dir)).await.unwrap();
let d = deliverable("g", "x");
let key = d.store_key();
store.upsert(d).await.unwrap();
store.remove(&key).await.unwrap();
assert!(matches!(
store.get(&key).await,
Err(StoreError::NotFound(_))
));
assert!(matches!(
store.remove(&key).await,
Err(StoreError::NotFound(_))
));
}
#[tokio::test]
async fn store_reload_picks_up_external_write() {
let dir = TempDir::new().unwrap();
let mut a = DeliverableStore::load(path(&dir)).await.unwrap();
a.upsert(deliverable("p", "first")).await.unwrap();
let mut b = DeliverableStore::load(path(&dir)).await.unwrap();
let d = deliverable("p", "second");
let key = d.store_key();
b.upsert(d).await.unwrap();
let got = a.get(&key).await.unwrap();
assert_eq!(got.name, "second");
}
#[tokio::test]
async fn store_not_found_starts_fresh() {
let dir = TempDir::new().unwrap();
let mut store = DeliverableStore::load(path(&dir)).await.unwrap();
assert!(store.all().await.unwrap().is_empty());
}
#[tokio::test]
async fn store_other_io_error_propagates() {
let dir = TempDir::new().unwrap();
let p = path(&dir);
tokio::fs::create_dir_all(&p).await.unwrap();
let result = DeliverableStore::load(p).await;
assert!(matches!(result, Err(StoreError::Io(_))));
}
#[tokio::test]
async fn milestone_store_round_trips() {
use crate::deliverable::milestone::{Milestone, MilestoneId, MilestoneStatus};
let dir = TempDir::new().unwrap();
let p = dir.path().join("milestones.json");
let mut store = MilestoneStore::load(p.clone()).await.unwrap();
let m = Milestone {
id: MilestoneId::new(),
project_name: "trusty-tools".into(),
name: "v1".into(),
description: String::new(),
target_date: Utc::now(),
status: MilestoneStatus::Proposed,
deliverables: vec![],
created_at: Utc::now(),
};
let key = m.store_key();
store.upsert(m).await.unwrap();
let mut store2 = MilestoneStore::load(p).await.unwrap();
assert_eq!(store2.get(&key).await.unwrap().name, "v1");
}
}