use std::{
collections::{HashMap, HashSet},
hash::{Hash, Hasher},
path::Path,
};
use serde::{Deserialize, Serialize};
use crate::{runtime::error::RuntimeError, transcript::TranscriptItem};
use super::{SCHEMA_VERSION, fs_util, store_error};
#[derive(Deserialize)]
struct LogLine {
schema: u32,
#[serde(flatten)]
item: TranscriptItem,
}
#[derive(Serialize)]
struct LogLineRef<'a> {
schema: u32,
#[serde(flatten)]
item: &'a TranscriptItem,
}
#[derive(Default)]
pub(super) struct TranscriptLogIndex {
loaded: bool,
entries: HashMap<String, u64>,
}
impl TranscriptLogIndex {
fn ensure_loaded(&mut self, path: &Path) -> Result<(), RuntimeError> {
if self.loaded {
return Ok(());
}
self.entries = read_log(path)?
.into_iter()
.map(|(id, item)| Ok((id, verification_hash(&item)?)))
.collect::<Result<_, RuntimeError>>()?;
self.loaded = true;
Ok(())
}
pub(super) fn append_missing<'a>(
&mut self,
path: &Path,
entries: impl Iterator<Item = &'a TranscriptItem>,
) -> Result<(), RuntimeError> {
self.ensure_loaded(path)?;
let mut lines = Vec::new();
let mut pending: Vec<(String, u64)> = Vec::new();
let mut pending_ids: HashSet<&str> = HashSet::new();
for item in entries {
let id = item.id.as_str();
if pending_ids.contains(id) {
continue;
}
if let Some(&logged) = self.entries.get(id) {
#[cfg(debug_assertions)]
{
let current = verification_hash(item)?;
debug_assert_eq!(
logged, current,
"transcript entry '{id}' changed after being logged"
);
}
#[cfg(not(debug_assertions))]
let _ = logged;
continue;
}
lines.push(log_line(item)?);
pending.push((id.to_string(), verification_hash(item)?));
pending_ids.insert(id);
}
drop(pending_ids);
fs_util::append_lines(path, &lines)?;
self.entries.extend(pending);
Ok(())
}
}
pub(super) fn read_log(path: &Path) -> Result<HashMap<String, TranscriptItem>, RuntimeError> {
let Some(contents) = fs_util::read_optional(path)? else {
return Ok(HashMap::new());
};
let mut entries = HashMap::new();
let lines: Vec<&str> = contents.split('\n').collect();
let last_index = lines.len().saturating_sub(1);
for (index, line) in lines.iter().enumerate() {
if line.is_empty() {
continue;
}
match parse_line(line) {
Ok(item) => {
entries.insert(item.id.as_str().to_string(), item);
}
Err(error) => {
let final_line = index == last_index && !contents.ends_with('\n');
if final_line {
continue;
}
return Err(store_error(
&format!("parse '{}' line {}", path.display(), index + 1),
error,
));
}
}
}
Ok(entries)
}
fn parse_line(line: &str) -> Result<TranscriptItem, RuntimeError> {
let parsed: LogLine =
serde_json::from_str(line).map_err(|error| RuntimeError::Store(error.to_string()))?;
if parsed.schema > SCHEMA_VERSION {
return Err(RuntimeError::Store(format!(
"transcript line schema {} is newer than this build understands ({SCHEMA_VERSION})",
parsed.schema
)));
}
Ok(parsed.item)
}
fn log_line(item: &TranscriptItem) -> Result<String, RuntimeError> {
serde_json::to_string(&LogLineRef {
schema: SCHEMA_VERSION,
item,
})
.map_err(|error| RuntimeError::Store(error.to_string()))
}
fn verification_hash(item: &TranscriptItem) -> Result<u64, RuntimeError> {
if cfg!(debug_assertions) {
let canonical =
serde_json::to_string(item).map_err(|error| RuntimeError::Store(error.to_string()))?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
canonical.hash(&mut hasher);
Ok(hasher.finish())
} else {
Ok(0)
}
}