use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::fs;
use tracing::debug;
use trusty_common::json_rmw::{self, JsonRmwError};
use super::record::Project;
#[derive(Debug, Error)]
pub enum ProjectStoreError {
#[error("project store I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("project store serialization error: {0}")]
Serialize(String),
#[error("project not found: {0}")]
NotFound(String),
#[error("project store lock error: {0}")]
Lock(String),
}
impl From<JsonRmwError> for ProjectStoreError {
fn from(e: JsonRmwError) -> Self {
match e {
JsonRmwError::Io { source, .. } => Self::Io(source),
JsonRmwError::Serialize { message, .. } => Self::Serialize(message),
lock @ JsonRmwError::Lock { .. } => Self::Lock(lock.to_string()),
}
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct StoredData {
projects: HashMap<String, Project>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct FileSig {
mtime: Option<SystemTime>,
len: u64,
}
#[derive(Debug)]
pub struct ProjectStore {
data: StoredData,
path: PathBuf,
last_sig: Option<FileSig>,
}
impl ProjectStore {
pub async fn load(data_dir: &Path) -> Result<Self, ProjectStoreError> {
let path = data_dir.join("projects.json");
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<(StoredData, Option<FileSig>), ProjectStoreError> {
match fs::read_to_string(path).await {
Ok(raw) => {
let data = serde_json::from_str::<StoredData>(&raw)
.map_err(|e| ProjectStoreError::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 project store file found; starting fresh");
Ok((StoredData::default(), None))
}
Err(e) => Err(ProjectStoreError::Io(e)),
}
}
pub async fn reload_if_changed(&mut self) -> Result<(), ProjectStoreError> {
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(), "project store reloaded after external change");
Ok(())
}
pub async fn mutate<R, F>(&mut self, f: F) -> Result<R, ProjectStoreError>
where
F: FnOnce(&mut HashMap<String, Project>) -> Result<R, ProjectStoreError> + Send + 'static,
R: Send + 'static,
{
let path = self.path.clone();
let joined = tokio::task::spawn_blocking(move || {
json_rmw::update::<StoredData, _, ProjectStoreError, _>(&path, move |data| {
let result = f(&mut data.projects)?;
Ok((result, data.clone()))
})
})
.await;
let (result, snapshot) = match joined {
Ok(inner) => inner?,
Err(e) => {
return Err(ProjectStoreError::Io(std::io::Error::other(format!(
"project store update task failed: {e}"
))));
}
};
self.data = snapshot;
self.last_sig = Self::sig_of(&self.path).await;
debug!(path = %self.path.display(), "project store saved under write lock");
Ok(result)
}
pub async fn upsert(&mut self, project: Project) -> Result<(), ProjectStoreError> {
self.mutate(move |projects| {
projects.insert(project.name.clone(), project);
Ok(())
})
.await
}
pub async fn update_with<F>(&mut self, name: &str, f: F) -> Result<Project, ProjectStoreError>
where
F: FnOnce(Project) -> Project + Send + 'static,
{
let name = name.to_string();
self.mutate(move |projects| {
let current = projects
.get(&name)
.cloned()
.ok_or(ProjectStoreError::NotFound(name))?;
let updated = f(current);
projects.insert(updated.name.clone(), updated.clone());
Ok(updated)
})
.await
}
pub async fn get(&mut self, name: &str) -> Result<Project, ProjectStoreError> {
self.reload_if_changed().await?;
self.data
.projects
.get(name)
.cloned()
.ok_or_else(|| ProjectStoreError::NotFound(name.to_string()))
}
pub async fn all(&mut self) -> Result<Vec<Project>, ProjectStoreError> {
self.reload_if_changed().await?;
Ok(self.data.projects.values().cloned().collect())
}
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;