use std::{
collections::{HashMap, HashSet},
hash::{Hash, Hasher},
io::{BufRead, BufReader},
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(file) = fs_util::open_optional(path)? else {
return Ok(HashMap::new());
};
let mut entries = HashMap::new();
let mut reader = BufReader::new(file);
let mut line = Vec::new();
for line_number in 1.. {
line.clear();
let read = reader
.read_until(b'\n', &mut line)
.map_err(|error| store_error(&format!("read '{}'", path.display()), error))?;
if read == 0 {
break;
}
let contents = line.strip_suffix(b"\n").unwrap_or(&line);
if contents.is_empty() {
continue;
}
match parse_line(contents) {
Ok(item) => {
entries.insert(item.id.as_str().to_string(), item);
}
Err(_) if contents.len() == read => break,
Err(error) => {
return Err(store_error(
&format!("parse '{}' line {line_number}", path.display()),
error,
));
}
}
}
Ok(entries)
}
fn parse_line(line: &[u8]) -> Result<TranscriptItem, RuntimeError> {
let line = std::str::from_utf8(line).map_err(|error| RuntimeError::Store(error.to_string()))?;
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)
}
}