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; fn set(&self, key: &str, value: Value); fn delete(&self, key: &str); fn save(&self) -> Result<(), String>; } pub struct JsonFileStore { path: PathBuf, data: Mutex>, } impl JsonFileStore { pub fn new(path: PathBuf) -> Result { 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::>(&content) .map_err(|e| format!("Failed to parse store: {e}"))? } } else { HashMap::new() }; Ok(Self { path, data: Mutex::new(data), }) } pub fn from_path>(path: P) -> Result { 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 { 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}")) } } #[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!("storkit")); store.set("version", json!(1)); store.save().expect("save should succeed"); } let store = JsonFileStore::new(path).unwrap(); assert_eq!(store.get("name"), Some(json!("storkit"))); 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))); } }