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 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),
}
#[derive(Debug, Default, 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 save(&mut self) -> Result<(), ProjectStoreError> {
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| ProjectStoreError::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(), "project store saved");
Ok(())
}
pub async fn upsert(&mut self, project: Project) -> Result<(), ProjectStoreError> {
self.reload_if_changed().await?;
let key = project.name.clone();
self.data.projects.insert(key, project);
self.save().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)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_project(name: &str) -> Project {
Project {
name: name.to_string(),
repo_url: format!("https://github.com/owner/{name}"),
default_branch: "main".to_string(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
github: None,
commit_name: None,
commit_email: None,
}
}
#[tokio::test]
async fn store_load_save_round_trip() {
let dir = TempDir::new().expect("tempdir");
let mut store = ProjectStore::load(dir.path()).await.expect("load empty");
assert!(store.all().await.expect("all").is_empty());
store.upsert(make_project("alpha")).await.expect("upsert");
let mut store2 = ProjectStore::load(dir.path()).await.expect("reload");
let p = store2.get("alpha").await.expect("get after reload");
assert_eq!(p.name, "alpha");
}
#[tokio::test]
async fn store_upsert_idempotent() {
let dir = TempDir::new().expect("tempdir");
let mut store = ProjectStore::load(dir.path()).await.expect("load");
let p1 = make_project("beta");
store.upsert(p1).await.expect("first upsert");
let p2 = Project {
description: Some("updated".into()),
..make_project("beta")
};
store.upsert(p2).await.expect("second upsert");
let all = store.all().await.expect("all");
assert_eq!(all.len(), 1, "idempotent upsert must not duplicate");
assert_eq!(all[0].description.as_deref(), Some("updated"));
}
#[tokio::test]
async fn store_list_and_get() {
let dir = TempDir::new().expect("tempdir");
let mut store = ProjectStore::load(dir.path()).await.expect("load");
store
.upsert(make_project("gamma"))
.await
.expect("upsert gamma");
store
.upsert(make_project("delta"))
.await
.expect("upsert delta");
let all = store.all().await.expect("all");
assert_eq!(all.len(), 2);
let p = store.get("gamma").await.expect("get gamma");
assert_eq!(p.name, "gamma");
let err = store.get("missing").await;
assert!(
matches!(err, Err(ProjectStoreError::NotFound(_))),
"expected NotFound"
);
}
#[tokio::test]
async fn store_reload_picks_up_external_write() {
let dir = TempDir::new().expect("tempdir");
let mut store_a = ProjectStore::load(dir.path()).await.expect("load A");
store_a
.upsert(make_project("epsilon"))
.await
.expect("seed A");
let mut store_b = ProjectStore::load(dir.path()).await.expect("load B");
store_b.upsert(make_project("zeta")).await.expect("write B");
let all = store_a.all().await.expect("all A after external write");
let names: Vec<&str> = all.iter().map(|p| p.name.as_str()).collect();
assert!(
names.contains(&"zeta"),
"store A must reload zeta: {names:?}"
);
}
#[tokio::test]
async fn store_reload_noop_when_unchanged() {
let dir = TempDir::new().expect("tempdir");
let mut store = ProjectStore::load(dir.path()).await.expect("load");
store.upsert(make_project("eta")).await.expect("upsert");
store.reload_if_changed().await.expect("reload no-op");
let p = store.get("eta").await.expect("get");
assert_eq!(p.name, "eta");
}
#[tokio::test]
async fn json_round_trip_preserves_all_fields() {
let dir = TempDir::new().expect("tempdir");
let mut store = ProjectStore::load(dir.path()).await.expect("load");
let full = Project {
name: "full-project".into(),
repo_url: "https://github.com/owner/full-project.git".into(),
default_branch: "develop".into(),
stack_hint: Some("typescript".into()),
tags: vec!["frontend".into(), "oss".into()],
description: Some("a fully-populated project".into()),
gh_user: Some("bobmatnyc".into()),
github: Some(crate::core::trusty_tools_config::GithubConfig {
config_dir: Some("/home/bob/.config/gh-full".into()),
token_env: None,
account: None,
host: Some("github.example.com".into()),
}),
commit_name: Some("Full Bot".into()),
commit_email: Some("full-bot@example.com".into()),
};
store.upsert(full.clone()).await.expect("upsert full");
let mut store2 = ProjectStore::load(dir.path()).await.expect("reload");
let back = store2.get("full-project").await.expect("get");
assert_eq!(back, full);
}
#[tokio::test]
async fn store_not_found_starts_fresh() {
let dir = TempDir::new().expect("tempdir");
let mut store = ProjectStore::load(dir.path())
.await
.expect("not-found should start fresh");
assert!(
store.all().await.expect("all").is_empty(),
"fresh store must be empty"
);
store
.upsert(make_project("theta"))
.await
.expect("upsert after fresh load");
let mut store2 = ProjectStore::load(dir.path()).await.expect("reload");
assert_eq!(store2.get("theta").await.expect("get theta").name, "theta");
}
#[tokio::test]
async fn store_other_io_error_propagates() {
let dir = TempDir::new().expect("tempdir");
let blocking_dir = dir.path().join("projects.json");
tokio::fs::create_dir_all(&blocking_dir)
.await
.expect("create blocking dir");
let result = ProjectStore::load(dir.path()).await;
assert!(
matches!(result, Err(ProjectStoreError::Io(_))),
"expected Io error when path is a directory, got: {result:?}"
);
}
}