use crate::types::Receipt;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Fixture {
pub id: String,
pub name: String,
pub tags: Vec<String>,
pub event_count: usize,
pub event_types: Vec<String>,
pub chain_hash: String,
pub inserted_at: String,
pub receipt: Receipt,
}
#[derive(Debug, Default)]
pub struct FixtureQuery {
pub name_contains: Option<String>,
pub tag: Option<String>,
pub min_events: Option<usize>,
pub max_events: Option<usize>,
pub event_type: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct JsonDb {
schema_version: String,
fixtures: Vec<Fixture>,
}
pub struct FixtureDatabase {
path: PathBuf,
db: JsonDb,
index_by_name: BTreeMap<String, usize>,
index_by_event_count: BTreeMap<usize, Vec<usize>>,
index_by_chain_hash: BTreeMap<String, usize>,
}
impl FixtureDatabase {
pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
let db = if path.exists() {
let content = fs::read_to_string(&path)?;
serde_json::from_str(&content).context("failed to parse fixture database")?
} else {
JsonDb {
schema_version: "1".to_string(),
fixtures: Vec::new(),
}
};
let mut s = FixtureDatabase {
path,
db,
index_by_name: BTreeMap::new(),
index_by_event_count: BTreeMap::new(),
index_by_chain_hash: BTreeMap::new(),
};
s.reindex()?;
Ok(s)
}
pub fn insert(&mut self, name: &str, tags: &[&str], receipt: Receipt) -> Result<Fixture> {
let chain_hash = receipt.chain_hash.as_hex().to_string();
if self.index_by_chain_hash.contains_key(&chain_hash) {
anyhow::bail!("fixture with chain_hash {} already exists", chain_hash);
}
if self.index_by_name.contains_key(name) {
anyhow::bail!("fixture with name '{}' already exists", name);
}
let event_count = receipt.events.len();
let mut event_types: Vec<String> = receipt
.events
.iter()
.map(|e| e.event_type.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
event_types.sort();
let inserted_at = iso8601_now();
let id_input = format!("{}:{}:{}", name, chain_hash, inserted_at);
let id = blake3::hash(id_input.as_bytes()).to_hex().to_string();
let fixture = Fixture {
id,
name: name.to_string(),
tags: tags.iter().map(|s| s.to_string()).collect(),
event_count,
event_types,
chain_hash: chain_hash.clone(),
inserted_at,
receipt,
};
let index = self.db.fixtures.len();
self.db.fixtures.push(fixture.clone());
self.index_by_name.insert(name.to_string(), index);
self.index_by_chain_hash.insert(chain_hash, index);
self.index_by_event_count
.entry(event_count)
.or_default()
.push(index);
Ok(fixture)
}
pub fn search(&self, query: &FixtureQuery) -> Vec<Fixture> {
let mut indices: HashSet<usize> = (0..self.db.fixtures.len()).collect();
if query.min_events.is_some() || query.max_events.is_some() {
let min = query.min_events.unwrap_or(0);
let max = query.max_events.unwrap_or(usize::MAX);
let mut count_matches = HashSet::new();
for (_count, idxs) in self.index_by_event_count.range(min..=max) {
for &idx in idxs {
count_matches.insert(idx);
}
}
indices.retain(|i| count_matches.contains(i));
}
let mut results: Vec<usize> = indices.into_iter().collect();
results.sort();
let mut filtered = Vec::new();
for idx in results {
let f = &self.db.fixtures[idx];
if let Some(ref name_sub) = query.name_contains {
if !f.name.to_lowercase().contains(&name_sub.to_lowercase()) {
continue;
}
}
if let Some(ref tag) = query.tag {
if !f.tags.contains(tag) {
continue;
}
}
if let Some(ref etype) = query.event_type {
if !f.event_types.contains(etype) {
continue;
}
}
filtered.push(f.clone());
if let Some(limit) = query.limit {
if filtered.len() >= limit {
break;
}
}
}
filtered
}
pub fn get_by_name(&self, name: &str) -> Option<Fixture> {
self.index_by_name
.get(name)
.map(|&idx| self.db.fixtures[idx].clone())
}
pub fn get_by_chain_hash(&self, chain_hash: &str) -> Option<Fixture> {
self.index_by_chain_hash
.get(chain_hash)
.map(|&idx| self.db.fixtures[idx].clone())
}
pub fn all(&self) -> Vec<Fixture> {
self.db.fixtures.clone()
}
pub fn len(&self) -> usize {
self.db.fixtures.len()
}
pub fn is_empty(&self) -> bool {
self.db.fixtures.is_empty()
}
pub fn save(&self) -> Result<()> {
let content = serde_json::to_string_pretty(&self.db)?;
atomic_write(&self.path, content.as_bytes())
}
pub fn delete_by_name(&mut self, name: &str) -> Result<bool> {
if let Some(idx) = self.index_by_name.remove(name) {
self.db.fixtures.remove(idx);
self.reindex()?;
Ok(true)
} else {
Ok(false)
}
}
pub fn reindex(&mut self) -> Result<()> {
self.index_by_name.clear();
self.index_by_event_count.clear();
self.index_by_chain_hash.clear();
for (idx, f) in self.db.fixtures.iter().enumerate() {
self.index_by_name.insert(f.name.clone(), idx);
self.index_by_chain_hash.insert(f.chain_hash.clone(), idx);
self.index_by_event_count
.entry(f.event_count)
.or_default()
.push(idx);
}
Ok(())
}
}
fn iso8601_now() -> String {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
format!("{}-01-01T00:00:00Z", 1970 + (secs / 31536000))
}
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp_path = parent.join(path.file_name().unwrap());
tmp_path.set_extension("tmp");
fs::write(&tmp_path, data).context("failed to write temporary file")?;
fs::rename(&tmp_path, path).context("failed to rename temporary file to destination")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chain::ChainAssembler;
use crate::ocel::{build_event, object_ref, SeqCounter};
fn create_test_receipt(event_count: usize) -> Receipt {
let mut asm = ChainAssembler::new();
let mut counter = SeqCounter::new();
for i in 0..event_count {
let event = build_event(
format!("type-{}", i % 3),
vec![object_ref(format!("obj-{}", i), "artifact")],
format!("payload-{}", i).as_bytes(),
&mut counter,
)
.unwrap();
asm.append(event).unwrap();
}
asm.finalize()
}
#[test]
fn test_fixture_db_basic_ops() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("fixtures.json");
let mut db = FixtureDatabase::open(&db_path).unwrap();
let r1 = create_test_receipt(3);
let r2 = create_test_receipt(5);
db.insert("fix-1", &["tag1"], r1.clone()).unwrap();
db.insert("fix-2", &["tag2", "shared"], r2.clone()).unwrap();
assert_eq!(db.len(), 2);
assert_eq!(db.get_by_name("fix-1").unwrap().receipt, r1);
let search_res = db.search(&FixtureQuery {
min_events: Some(4),
..Default::default()
});
assert_eq!(search_res.len(), 1);
assert_eq!(search_res[0].name, "fix-2");
db.save().unwrap();
let db2 = FixtureDatabase::open(&db_path).unwrap();
assert_eq!(db2.len(), 2);
assert_eq!(db2.get_by_name("fix-1").unwrap().receipt, r1);
}
}
#[allow(dead_code)]
fn main() -> Result<()> {
println!("FixtureDatabase Maximalist Implementation Demo");
let db_path = PathBuf::from("fixtures_demo.json");
let mut db = FixtureDatabase::open(&db_path)?;
let mut asm = crate::chain::ChainAssembler::new();
let mut counter = crate::ocel::SeqCounter::new();
let e = crate::ocel::build_event("demo", vec![], b"data", &mut counter)?;
asm.append(e)?;
let receipt = asm.finalize();
match db.insert("demo-fixture", &["demo", "maximalist"], receipt) {
Ok(f) => println!("Inserted fixture: {} (ID: {})", f.name, f.id),
Err(e) => println!("Note: {}", e),
}
db.save()?;
println!("Database saved to {:?}", db_path);
let query = FixtureQuery {
tag: Some("maximalist".to_string()),
..Default::default()
};
let results = db.search(&query);
println!("Search found {} matches", results.len());
Ok(())
}