use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::schema::{
assert_readable_manifest, sequence_name, TraceEvent, TraceFiles, TraceManifest, TraceMode,
TraceOutcome, TRACE_SCHEMA_VERSION,
};
#[derive(Debug, thiserror::Error)]
pub enum TraceError {
#[error("no trace bundle at {path}")]
NotFound {
path: PathBuf,
},
#[error("{manifest} in {path} is not readable JSON", manifest = TraceFiles::MANIFEST)]
ManifestUnreadable {
path: PathBuf,
},
#[error("not a Browser Commander trace bundle")]
NotATrace,
#[error("trace schema version {found} is newer than this reader ({TRACE_SCHEMA_VERSION})")]
UnsupportedVersion {
found: u64,
},
#[error("cannot read {path}: {source}")]
Io {
path: PathBuf,
source: io::Error,
},
#[error("{path} is not readable JSON")]
MemberUnreadable {
path: PathBuf,
},
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ParsedNdjson {
pub records: Vec<Value>,
pub truncated: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TraceCheckpoint {
pub index: Option<u32>,
pub name: Option<String>,
pub actor: Option<String>,
pub reason: Option<String>,
pub url: Option<String>,
pub at: Option<String>,
pub sequence: Option<u64>,
pub members: Map<String, Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ControlChangeKind {
Added,
Changed,
Removed,
}
impl ControlChangeKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Added => "added",
Self::Changed => "changed",
Self::Removed => "removed",
}
}
}
impl std::fmt::Display for ControlChangeKind {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ControlChange {
pub path: Option<String>,
pub change: ControlChangeKind,
pub before: Option<Value>,
pub after: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct Trace {
pub path: PathBuf,
pub manifest: TraceManifest,
pub events: Vec<Value>,
pub checkpoints: Vec<TraceCheckpoint>,
pub truncated: bool,
}
fn read_if_present(path: &Path) -> Result<Option<String>, TraceError> {
match fs::read_to_string(path) {
Ok(body) => Ok(Some(body)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(TraceError::Io {
path: path.to_path_buf(),
source,
}),
}
}
fn read_bytes_if_present(path: &Path) -> Result<Option<Vec<u8>>, TraceError> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(TraceError::Io {
path: path.to_path_buf(),
source,
}),
}
}
fn absolute(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
std::env::current_dir()
.map(|directory| directory.join(path))
.unwrap_or_else(|_| path.to_path_buf())
}
fn text(event: &Value, field: &str) -> Option<String> {
event.get(field)?.as_str().map(str::to_string)
}
pub fn parse_ndjson(body: Option<&str>) -> ParsedNdjson {
let mut records = Vec::new();
for line in body.unwrap_or("").split('\n') {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str(line) {
Ok(record) => records.push(record),
Err(_) => {
return ParsedNdjson {
records,
truncated: true,
}
}
}
}
ParsedNdjson {
records,
truncated: false,
}
}
fn is_kind(event: &Value, kind: &str) -> bool {
event.get("kind").and_then(Value::as_str) == Some(kind)
}
fn rebuild_manifest(events: &[Value]) -> TraceManifest {
let started = events
.iter()
.find(|event| is_kind(event, TraceEvent::TRACE_START));
TraceManifest {
mode: Some(
started
.and_then(|event| text(event, "mode"))
.unwrap_or_else(|| TraceMode::CHECKPOINTS.to_string()),
),
outcome: TraceOutcome::TRUNCATED.to_string(),
started_at: started.and_then(|event| text(event, "at")),
stopped_at: events.last().and_then(|event| text(event, "at")),
engine: started.and_then(|event| text(event, "engine")),
events: started
.and_then(|event| event.get("events"))
.and_then(Value::as_array)
.map(|sources| {
sources
.iter()
.filter_map(|source| source.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
dom: started
.and_then(|event| event.get("dom"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default(),
counts: super::schema::TraceCounts {
events: events.len() as u64,
checkpoints: events
.iter()
.filter(|event| is_kind(event, TraceEvent::CHECKPOINT))
.count() as u64,
mutation_batches: 0,
},
..TraceManifest::default()
}
}
pub fn read_trace(bundle_path: impl AsRef<Path>) -> Result<Trace, TraceError> {
let root = absolute(bundle_path.as_ref());
let manifest_body = read_if_present(&root.join(TraceFiles::MANIFEST))?;
let events_body = read_if_present(&root.join(TraceFiles::EVENTS))?;
if manifest_body.is_none() && events_body.is_none() {
return Err(TraceError::NotFound { path: root });
}
let parsed = parse_ndjson(events_body.as_deref());
let manifest = match manifest_body {
None => rebuild_manifest(&parsed.records),
Some(body) => {
let manifest: TraceManifest = serde_json::from_str(&body)
.map_err(|_| TraceError::ManifestUnreadable { path: root.clone() })?;
assert_readable_manifest(&manifest)?;
manifest
}
};
let checkpoints = parsed
.records
.iter()
.filter(|event| is_kind(event, TraceEvent::CHECKPOINT))
.map(|event| TraceCheckpoint {
index: event
.get("index")
.and_then(Value::as_u64)
.map(|index| index as u32),
name: text(event, "name"),
actor: text(event, "actor"),
reason: text(event, "reason"),
url: text(event, "url"),
at: text(event, "at"),
sequence: event.get("sequence").and_then(Value::as_u64),
members: event
.get("members")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default(),
})
.collect();
let truncated = parsed.truncated || !manifest.is_complete();
Ok(Trace {
path: root,
manifest,
events: parsed.records,
checkpoints,
truncated,
})
}
impl Trace {
fn checkpoint_member(&self, index: u32, suffix: &str) -> PathBuf {
self.path
.join(TraceFiles::CHECKPOINTS_DIR)
.join(format!("{}{suffix}", sequence_name(index)))
}
pub fn html(&self, index: u32) -> Result<Option<String>, TraceError> {
read_if_present(&self.checkpoint_member(index, ".html"))
}
pub fn state(&self, index: u32) -> Result<Option<Value>, TraceError> {
let member = self.checkpoint_member(index, ".state.json");
match read_if_present(&member)? {
None => Ok(None),
Some(body) => serde_json::from_str(&body)
.map(Some)
.map_err(|_| TraceError::MemberUnreadable { path: member }),
}
}
pub fn screenshot(&self, index: u32) -> Result<Option<Vec<u8>>, TraceError> {
read_bytes_if_present(&self.checkpoint_member(index, ".png"))
}
pub fn mutations(&self, index: u32) -> Result<Vec<Value>, TraceError> {
let member = self
.path
.join(TraceFiles::MUTATIONS_DIR)
.join(format!("{}.ndjson", sequence_name(index)));
Ok(parse_ndjson(read_if_present(&member)?.as_deref()).records)
}
}
fn controls(state: Option<&Value>) -> Vec<&Value> {
state
.and_then(|state| state.get("controls"))
.and_then(Value::as_array)
.map(|controls| controls.iter().collect())
.unwrap_or_default()
}
fn control_path(control: &Value) -> Option<String> {
control
.get("path")
.and_then(Value::as_str)
.map(str::to_string)
}
fn field(control: &Value, name: &str) -> Option<Value> {
match control.get(name) {
None | Some(Value::Null) => None,
Some(value) => Some(value.clone()),
}
}
fn control_value(control: &Value) -> Option<Value> {
match field(control, "checked") {
None => field(control, "value"),
checked => checked,
}
}
pub fn diff_control_state(before: Option<&Value>, after: Option<&Value>) -> Vec<ControlChange> {
let mut earlier: Vec<(Option<String>, &Value, bool)> = controls(before)
.into_iter()
.map(|control| (control_path(control), control, false))
.collect();
let mut changes = Vec::new();
for control in controls(after) {
let path = control_path(control);
let previous = earlier
.iter_mut()
.find(|(seen, _, taken)| !*taken && *seen == path);
let Some((_, previous, taken)) = previous else {
changes.push(ControlChange {
path,
change: ControlChangeKind::Added,
before: None,
after: field(control, "value"),
});
continue;
};
*taken = true;
if field(previous, "value") != field(control, "value")
|| field(previous, "checked") != field(control, "checked")
{
changes.push(ControlChange {
path,
change: ControlChangeKind::Changed,
before: control_value(previous),
after: control_value(control),
});
}
}
changes.extend(earlier.into_iter().filter(|(_, _, taken)| !*taken).map(
|(path, control, _)| ControlChange {
path,
change: ControlChangeKind::Removed,
before: field(control, "value"),
after: None,
},
));
changes
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is after the epoch")
.as_nanos();
let path = std::env::temp_dir()
.join(format!("bc-trace-{name}-{}-{nanos}", std::process::id()));
fs::create_dir_all(&path).expect("temp directory is writable");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn ndjson(records: &[Value]) -> String {
records
.iter()
.map(|record| format!("{record}\n"))
.collect::<String>()
}
fn write(path: &Path, body: &str) {
fs::create_dir_all(path.parent().expect("member has a parent"))
.expect("bundle directory is writable");
fs::write(path, body).expect("member is writable");
}
fn write_bundle(root: &Path, close: bool) -> PathBuf {
write(
&root.join(TraceFiles::CHECKPOINTS_DIR).join("0001.html"),
"<html><body>one</body></html>",
);
write(
&root
.join(TraceFiles::CHECKPOINTS_DIR)
.join("0001.state.json"),
&json!({
"url": "https://example.com/one",
"controls": [{"path": "input", "value": "before"}],
})
.to_string(),
);
write(
&root.join(TraceFiles::MUTATIONS_DIR).join("0001.ndjson"),
&ndjson(&[json!({"records": [{"type": "childList"}]})]),
);
write(
&root.join(TraceFiles::EVENTS),
&ndjson(&[
json!({
"kind": TraceEvent::TRACE_START,
"sequence": 1,
"at": "2026-01-01T00:00:00.000Z",
"mode": TraceMode::CHECKPOINTS,
"engine": "playwright",
"events": ["console"],
}),
json!({
"kind": TraceEvent::CHECKPOINT,
"sequence": 2,
"at": "2026-01-01T00:00:01.000Z",
"index": 1,
"name": "start",
"actor": "automation",
"reason": "checkpoint",
"url": "https://example.com/one",
"members": {
"html": "checkpoints/0001.html",
"state": "checkpoints/0001.state.json",
},
}),
]),
);
if close {
write(
&root.join(TraceFiles::MANIFEST),
&json!({
"schemaVersion": TRACE_SCHEMA_VERSION,
"format": super::super::schema::TRACE_FORMAT,
"mode": TraceMode::CHECKPOINTS,
"outcome": TraceOutcome::COMPLETE,
"engine": "playwright",
"counts": {"checkpoints": 1, "events": 2, "mutationBatches": 1},
})
.to_string(),
);
}
root.to_path_buf()
}
#[test]
fn keeps_everything_before_a_half_written_line() {
let parsed = parse_ndjson(Some("{\"a\":1}\n{\"b\":2}\n{\"c\":"));
assert_eq!(parsed.records, vec![json!({"a": 1}), json!({"b": 2})]);
assert!(parsed.truncated);
}
#[test]
fn reads_an_empty_body_as_an_empty_timeline() {
let parsed = parse_ndjson(None);
assert!(parsed.records.is_empty());
assert!(!parsed.truncated);
}
#[test]
fn reads_the_manifest_timeline_and_checkpoint_members() {
let temp = TempDir::new("read");
let root = write_bundle(&temp.path().join("run"), true);
let trace = read_trace(&root).expect("bundle is readable");
assert_eq!(trace.manifest.outcome, TraceOutcome::COMPLETE);
assert_eq!(trace.checkpoints.len(), 1);
assert_eq!(trace.checkpoints[0].name.as_deref(), Some("start"));
assert_eq!(
trace.checkpoints[0].members["html"],
json!("checkpoints/0001.html")
);
assert!(trace
.html(1)
.expect("html is readable")
.expect("html was written")
.contains("one"));
assert_eq!(
trace
.state(1)
.expect("state is readable")
.expect("state was written")["url"],
json!("https://example.com/one")
);
assert_eq!(trace.mutations(1).expect("mutations are readable").len(), 1);
assert!(!trace.truncated);
}
#[test]
fn returns_nothing_for_a_checkpoint_member_that_was_dropped() {
let temp = TempDir::new("dropped");
let root = write_bundle(&temp.path().join("run"), true);
let trace = read_trace(&root).expect("bundle is readable");
assert!(trace
.html(2)
.expect("missing html is not an error")
.is_none());
assert!(trace
.state(2)
.expect("missing state is not an error")
.is_none());
assert!(trace
.screenshot(1)
.expect("missing screenshot is not an error")
.is_none());
assert!(trace
.mutations(2)
.expect("missing mutations are not an error")
.is_empty());
}
#[test]
fn rebuilds_a_manifest_for_a_run_that_never_stopped() {
let temp = TempDir::new("nostop");
let root = write_bundle(&temp.path().join("run"), false);
let trace = read_trace(&root).expect("bundle is readable");
assert_eq!(trace.manifest.outcome, TraceOutcome::TRUNCATED);
assert_eq!(trace.manifest.engine.as_deref(), Some("playwright"));
assert_eq!(trace.manifest.mode.as_deref(), Some(TraceMode::CHECKPOINTS));
assert_eq!(trace.manifest.counts.checkpoints, 1);
assert_eq!(trace.manifest.events, vec!["console".to_string()]);
assert!(trace.truncated);
assert!(trace
.html(1)
.expect("html is readable")
.expect("html was written")
.contains("one"));
}
#[test]
fn reads_a_timeline_whose_last_line_was_cut_off() {
let temp = TempDir::new("cutoff");
let root = write_bundle(&temp.path().join("run"), false);
let events = root.join(TraceFiles::EVENTS);
let body = format!(
"{}{}",
fs::read_to_string(&events).expect("timeline is readable"),
"{\"kind\":\"console\",\"text\":\"half"
);
fs::write(&events, body).expect("timeline is writable");
let trace = read_trace(&root).expect("bundle is readable");
assert!(trace.truncated);
assert_eq!(trace.events.len(), 2);
}
#[test]
fn refuses_a_directory_that_holds_no_trace() {
let temp = TempDir::new("empty");
let error = read_trace(temp.path().join("nothing-here")).expect_err("no bundle");
assert!(error.to_string().starts_with("no trace bundle at"));
}
#[test]
fn refuses_a_manifest_that_is_not_readable_json() {
let temp = TempDir::new("badjson");
let root = write_bundle(&temp.path().join("run"), true);
fs::write(root.join(TraceFiles::MANIFEST), "not json").expect("manifest is writable");
let error = read_trace(&root).expect_err("manifest is unreadable");
assert!(error.to_string().contains("is not readable JSON"));
}
#[test]
fn refuses_a_bundle_written_by_a_newer_format() {
let temp = TempDir::new("newer");
let root = write_bundle(&temp.path().join("run"), true);
fs::write(
root.join(TraceFiles::MANIFEST),
json!({"format": "browser-commander-trace", "schemaVersion": 99}).to_string(),
)
.expect("manifest is writable");
let error = read_trace(&root).expect_err("format is too new");
assert_eq!(
error.to_string(),
format!("trace schema version 99 is newer than this reader ({TRACE_SCHEMA_VERSION})")
);
}
#[test]
fn refuses_a_directory_that_holds_something_else() {
let temp = TempDir::new("other");
let root = write_bundle(&temp.path().join("run"), true);
fs::write(
root.join(TraceFiles::MANIFEST),
json!({"format": "something-else"}).to_string(),
)
.expect("manifest is writable");
let error = read_trace(&root).expect_err("not a trace");
assert_eq!(error.to_string(), "not a Browser Commander trace bundle");
}
#[test]
fn reports_what_a_step_changed_added_and_removed() {
let before = json!({"controls": [
{"path": "input#name", "value": "before"},
{"path": "input#gone", "value": "x"},
{"path": "input#same", "value": "stable"},
]});
let after = json!({"controls": [
{"path": "input#name", "value": "after"},
{"path": "input#same", "value": "stable"},
{"path": "input#new", "value": "fresh"},
]});
let changes = diff_control_state(Some(&before), Some(&after));
assert_eq!(
changes,
vec![
ControlChange {
path: Some("input#name".to_string()),
change: ControlChangeKind::Changed,
before: Some(json!("before")),
after: Some(json!("after")),
},
ControlChange {
path: Some("input#new".to_string()),
change: ControlChangeKind::Added,
before: None,
after: Some(json!("fresh")),
},
ControlChange {
path: Some("input#gone".to_string()),
change: ControlChangeKind::Removed,
before: Some(json!("x")),
after: None,
},
]
);
}
#[test]
fn reports_a_checkbox_by_what_it_is_checked_to() {
let before = json!({"controls": [{"path": "input", "checked": false, "value": "on"}]});
let after = json!({"controls": [{"path": "input", "checked": true, "value": "on"}]});
let changes = diff_control_state(Some(&before), Some(&after));
assert_eq!(
changes,
vec![ControlChange {
path: Some("input".to_string()),
change: ControlChangeKind::Changed,
before: Some(json!(false)),
after: Some(json!(true)),
}]
);
}
#[test]
fn reads_a_missing_state_as_no_controls_at_all() {
assert!(diff_control_state(None, None).is_empty());
}
}