use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{IdentityError, Result};
use crate::receipt::{ActionReceipt, ReceiptId};
const RECEIPT_FILE_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
struct ReceiptFile {
version: u32,
receipt: ActionReceipt,
}
pub struct ReceiptStore {
base_dir: PathBuf,
}
impl ReceiptStore {
pub fn new(base_dir: impl Into<PathBuf>) -> Result<Self> {
let base_dir = base_dir.into();
std::fs::create_dir_all(&base_dir)?;
Ok(Self { base_dir })
}
pub fn save(&self, receipt: &ActionReceipt) -> Result<()> {
let file = ReceiptFile {
version: RECEIPT_FILE_VERSION,
receipt: receipt.clone(),
};
let json = serde_json::to_string_pretty(&file)
.map_err(|e| IdentityError::SerializationError(e.to_string()))?;
let path = self.receipt_path(&receipt.id);
std::fs::write(&path, json.as_bytes())?;
Ok(())
}
pub fn load(&self, id: &ReceiptId) -> Result<ActionReceipt> {
let path = self.receipt_path(id);
if !path.exists() {
return Err(IdentityError::NotFound(format!(
"receipt not found: {}",
id
)));
}
let bytes = std::fs::read(&path)?;
let file: ReceiptFile = serde_json::from_slice(&bytes).map_err(|e| {
IdentityError::InvalidFileFormat(format!(
"failed to parse receipt file {}: {e}",
path.display()
))
})?;
Ok(file.receipt)
}
pub fn list(&self) -> Result<Vec<ReceiptId>> {
let mut ids = Vec::new();
for entry in std::fs::read_dir(&self.base_dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if let Some(stem) = name_str.strip_suffix(".json") {
ids.push(ReceiptId(stem.to_string()));
}
}
Ok(ids)
}
pub fn delete(&self, id: &ReceiptId) -> Result<()> {
let path = self.receipt_path(id);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(IdentityError::Io(e)),
}
}
fn receipt_path(&self, id: &ReceiptId) -> PathBuf {
self.base_dir.join(format!("{}.json", id.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::IdentityAnchor;
use crate::receipt::receipt::ReceiptBuilder;
use crate::receipt::{ActionContent, ActionType};
fn make_receipt(anchor: &IdentityAnchor, description: &str) -> ActionReceipt {
ReceiptBuilder::new(
anchor.id(),
ActionType::Decision,
ActionContent::new(description),
)
.sign(anchor.signing_key())
.expect("signing receipt failed")
}
#[test]
fn test_receipt_store_save_load() {
let dir = tempfile::tempdir().unwrap();
let store = ReceiptStore::new(dir.path()).unwrap();
let anchor = IdentityAnchor::new(Some("store-test".to_string()));
let receipt = make_receipt(&anchor, "deployed v1.0");
let id = receipt.id.clone();
store.save(&receipt).expect("save failed");
let loaded = store.load(&id).expect("load failed");
assert_eq!(loaded.id, receipt.id);
assert_eq!(loaded.receipt_hash, receipt.receipt_hash);
assert_eq!(loaded.signature, receipt.signature);
assert_eq!(loaded.actor, receipt.actor);
}
#[test]
fn test_receipt_store_save_load_100() {
let dir = tempfile::tempdir().unwrap();
let store = ReceiptStore::new(dir.path()).unwrap();
let anchor = IdentityAnchor::new(None);
let mut receipts = Vec::with_capacity(100);
for i in 0..100 {
let r = make_receipt(&anchor, &format!("action-{i}"));
store.save(&r).unwrap();
receipts.push(r);
}
for original in &receipts {
let loaded = store.load(&original.id).expect("load failed");
assert_eq!(loaded.id, original.id);
assert_eq!(loaded.receipt_hash, original.receipt_hash);
}
}
#[test]
fn test_receipt_store_list() {
let dir = tempfile::tempdir().unwrap();
let store = ReceiptStore::new(dir.path()).unwrap();
let anchor = IdentityAnchor::new(None);
let r1 = make_receipt(&anchor, "action 1");
let r2 = make_receipt(&anchor, "action 2");
let r3 = make_receipt(&anchor, "action 3");
store.save(&r1).unwrap();
store.save(&r2).unwrap();
store.save(&r3).unwrap();
let mut ids = store.list().unwrap();
ids.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(ids.len(), 3);
assert!(ids.contains(&r1.id));
assert!(ids.contains(&r2.id));
assert!(ids.contains(&r3.id));
}
#[test]
fn test_receipt_store_delete() {
let dir = tempfile::tempdir().unwrap();
let store = ReceiptStore::new(dir.path()).unwrap();
let anchor = IdentityAnchor::new(None);
let receipt = make_receipt(&anchor, "to delete");
let id = receipt.id.clone();
store.save(&receipt).unwrap();
assert!(store.load(&id).is_ok());
store.delete(&id).unwrap();
assert!(store.load(&id).is_err());
}
#[test]
fn test_receipt_store_delete_nonexistent_is_ok() {
let dir = tempfile::tempdir().unwrap();
let store = ReceiptStore::new(dir.path()).unwrap();
let phantom = ReceiptId("arec_doesnotexist".to_string());
assert!(store.delete(&phantom).is_ok());
}
#[test]
fn test_receipt_store_load_not_found() {
let dir = tempfile::tempdir().unwrap();
let store = ReceiptStore::new(dir.path()).unwrap();
let missing = ReceiptId("arec_missing".to_string());
let result = store.load(&missing);
assert!(matches!(result, Err(IdentityError::NotFound(_))));
}
#[test]
fn test_receipt_store_overwrite() {
let dir = tempfile::tempdir().unwrap();
let store = ReceiptStore::new(dir.path()).unwrap();
let anchor = IdentityAnchor::new(None);
let receipt = make_receipt(&anchor, "original");
let id = receipt.id.clone();
store.save(&receipt).unwrap();
let mut modified = receipt.clone();
modified.witnesses.clear(); store.save(&modified).unwrap();
let loaded = store.load(&id).unwrap();
assert_eq!(loaded.id, id);
}
#[test]
fn test_receipt_store_creates_directory() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("receipts").join("v1");
assert!(!nested.exists());
let _store = ReceiptStore::new(&nested).unwrap();
assert!(nested.exists());
}
#[test]
fn test_receipt_file_format() {
let dir = tempfile::tempdir().unwrap();
let store = ReceiptStore::new(dir.path()).unwrap();
let anchor = IdentityAnchor::new(None);
let receipt = make_receipt(&anchor, "format check");
store.save(&receipt).unwrap();
let path = dir.path().join(format!("{}.json", receipt.id.0));
let bytes = std::fs::read(&path).unwrap();
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(value["version"], RECEIPT_FILE_VERSION);
assert!(value["receipt"].is_object());
assert_eq!(value["receipt"]["id"].as_str().unwrap(), receipt.id.0);
}
}