2026-02-13 12:31:36 +00:00
|
|
|
use serde_json::Value;
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::sync::Mutex;
|
|
|
|
|
|
|
|
|
|
pub trait StoreOps: Send + Sync {
|
|
|
|
|
fn get(&self, key: &str) -> Option<Value>;
|
|
|
|
|
fn set(&self, key: &str, value: Value);
|
|
|
|
|
fn delete(&self, key: &str);
|
|
|
|
|
fn save(&self) -> Result<(), String>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct JsonFileStore {
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
data: Mutex<HashMap<String, Value>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl JsonFileStore {
|
|
|
|
|
pub fn new(path: PathBuf) -> Result<Self, String> {
|
|
|
|
|
let data = if path.exists() {
|
|
|
|
|
let content =
|
|
|
|
|
fs::read_to_string(&path).map_err(|e| format!("Failed to read store: {e}"))?;
|
|
|
|
|
if content.trim().is_empty() {
|
|
|
|
|
HashMap::new()
|
|
|
|
|
} else {
|
|
|
|
|
serde_json::from_str::<HashMap<String, Value>>(&content)
|
|
|
|
|
.map_err(|e| format!("Failed to parse store: {e}"))?
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
HashMap::new()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
path,
|
|
|
|
|
data: Mutex::new(data),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, String> {
|
|
|
|
|
Self::new(path.as_ref().to_path_buf())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub fn path(&self) -> &Path {
|
|
|
|
|
&self.path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_parent_dir(&self) -> Result<(), String> {
|
|
|
|
|
if let Some(parent) = self.path.parent() {
|
|
|
|
|
fs::create_dir_all(parent)
|
|
|
|
|
.map_err(|e| format!("Failed to create store directory: {e}"))?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl StoreOps for JsonFileStore {
|
|
|
|
|
fn get(&self, key: &str) -> Option<Value> {
|
|
|
|
|
self.data.lock().ok().and_then(|map| map.get(key).cloned())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set(&self, key: &str, value: Value) {
|
|
|
|
|
if let Ok(mut map) = self.data.lock() {
|
|
|
|
|
map.insert(key.to_string(), value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn delete(&self, key: &str) {
|
|
|
|
|
if let Ok(mut map) = self.data.lock() {
|
|
|
|
|
map.remove(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn save(&self) -> Result<(), String> {
|
|
|
|
|
self.ensure_parent_dir()?;
|
|
|
|
|
let map = self.data.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
let content =
|
|
|
|
|
serde_json::to_string_pretty(&*map).map_err(|e| format!("Serialize failed: {e}"))?;
|
|
|
|
|
fs::write(&self.path, content).map_err(|e| format!("Failed to write store: {e}"))
|
|
|
|
|
}
|
|
|
|
|
}
|
WIP: Batch 1 — backfill tests for store, search, and workflow
- store.rs: 8 tests (roundtrip, persistence, corrupt/empty file handling)
- io/search.rs: 5 tests (matching, nested dirs, gitignore, empty results)
- workflow.rs: 7 new tests (acceptance logic, summarize, can_start, record, refresh)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:49:33 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
fn store_in(dir: &TempDir, name: &str) -> JsonFileStore {
|
|
|
|
|
let path = dir.path().join(name);
|
|
|
|
|
JsonFileStore::new(path).expect("store creation should succeed")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn new_from_missing_file_creates_empty_store() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = store_in(&dir, "missing.json");
|
|
|
|
|
assert!(store.get("anything").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn new_from_empty_file_creates_empty_store() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("empty.json");
|
|
|
|
|
fs::write(&path, "").unwrap();
|
|
|
|
|
let store = JsonFileStore::new(path).expect("should handle empty file");
|
|
|
|
|
assert!(store.get("anything").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn new_from_corrupt_file_returns_error() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("corrupt.json");
|
|
|
|
|
fs::write(&path, "not valid json {{{").unwrap();
|
|
|
|
|
let result = JsonFileStore::new(path);
|
|
|
|
|
match result {
|
|
|
|
|
Err(e) => assert!(e.contains("Failed to parse store"), "unexpected error: {e}"),
|
|
|
|
|
Ok(_) => panic!("expected error for corrupt file"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn get_set_delete_roundtrip() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = store_in(&dir, "data.json");
|
|
|
|
|
|
|
|
|
|
assert!(store.get("key").is_none());
|
|
|
|
|
|
|
|
|
|
store.set("key", json!("value"));
|
|
|
|
|
assert_eq!(store.get("key"), Some(json!("value")));
|
|
|
|
|
|
|
|
|
|
store.set("key", json!(42));
|
|
|
|
|
assert_eq!(store.get("key"), Some(json!(42)));
|
|
|
|
|
|
|
|
|
|
store.delete("key");
|
|
|
|
|
assert!(store.get("key").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn save_persists_and_reload_restores() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("persist.json");
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
let store = JsonFileStore::new(path.clone()).unwrap();
|
|
|
|
|
store.set("name", json!("story-kit"));
|
|
|
|
|
store.set("version", json!(1));
|
|
|
|
|
store.save().expect("save should succeed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let store = JsonFileStore::new(path).unwrap();
|
|
|
|
|
assert_eq!(store.get("name"), Some(json!("story-kit")));
|
|
|
|
|
assert_eq!(store.get("version"), Some(json!(1)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn save_creates_parent_directories() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("nested").join("deep").join("store.json");
|
|
|
|
|
let store = JsonFileStore::new(path.clone()).unwrap();
|
|
|
|
|
store.set("key", json!("value"));
|
|
|
|
|
store.save().expect("save should create parent dirs");
|
|
|
|
|
assert!(path.exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn delete_nonexistent_key_is_noop() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let store = store_in(&dir, "data.json");
|
|
|
|
|
store.delete("nonexistent");
|
|
|
|
|
assert!(store.get("nonexistent").is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn from_path_works_like_new() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("via_from.json");
|
|
|
|
|
let store = JsonFileStore::from_path(&path).unwrap();
|
|
|
|
|
store.set("test", json!(true));
|
|
|
|
|
assert_eq!(store.get("test"), Some(json!(true)));
|
|
|
|
|
}
|
|
|
|
|
}
|