Skip to main content

ftui_web/
session_record.rs

1#![forbid(unsafe_code)]
2
3//! Deterministic session recording and replay for WASM (bd-lff4p.3.7).
4//!
5//! Provides [`SessionRecorder`] for recording input events, time steps, and
6//! resize events during a WASM session, and [`replay`] for replaying them
7//! through a fresh model to verify that frame checksums match exactly.
8//!
9//! # Design
10//!
11//! Follows the golden-trace-v1 schema defined in
12//! `docs/spec/frankenterm-golden-trace-format.md`:
13//!
14//! - **Header**: seed, initial dimensions, capability profile.
15//! - **Input**: timestamped terminal events (key, mouse, paste, etc.).
16//! - **Resize**: terminal resize events.
17//! - **Tick**: explicit time advancement events.
18//! - **Frame**: frame checkpoints with FNV-1a checksums and chaining.
19//! - **Summary**: total frames and final checksum chain.
20//!
21//! # Determinism contract
22//!
23//! Given identical recorded inputs and the same model implementation, replay
24//! **must** produce identical frame checksums on the same build. This is
25//! guaranteed by:
26//!
27//! 1. Host-driven clock (no `Instant::now()` — time only advances via explicit
28//!    tick records).
29//! 2. Host-driven events (no polling — events are replayed from the trace).
30//! 3. Deterministic rendering (same model state → same buffer → same checksum).
31//!
32//! # Example
33//!
34//! ```ignore
35//! let mut recorder = SessionRecorder::new(MyModel::default(), 80, 24, /*seed=*/0);
36//! recorder.init().unwrap();
37//!
38//! recorder.push_event(0, key_event('+'));
39//! recorder.advance_time(16_000_000, Duration::from_millis(16));
40//! recorder.step().unwrap();
41//!
42//! let trace = recorder.finish();
43//! let result = replay(MyModel::default(), &trace).unwrap();
44//! assert!(result.ok());
45//! ```
46
47use core::time::Duration;
48
49use ftui_core::event::{
50    ClipboardEvent, ClipboardSource, Event, KeyCode, KeyEvent, KeyEventKind, Modifiers,
51    MouseButton, MouseEvent, MouseEventKind, PasteEvent,
52};
53use ftui_runtime::render_trace::checksum_buffer;
54
55use crate::WebBackendError;
56use crate::step_program::{StepProgram, StepResult};
57#[cfg(feature = "tracing")]
58use tracing::{error, info_span};
59
60/// Schema version for session traces.
61pub const SCHEMA_VERSION: &str = "golden-trace-v1";
62
63// FNV-1a constants — identical to ftui-runtime/src/render_trace.rs.
64const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
65const FNV_PRIME: u64 = 0x100000001b3;
66
67fn fnv1a64_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
68    for &b in bytes {
69        hash ^= b as u64;
70        hash = hash.wrapping_mul(FNV_PRIME);
71    }
72    hash
73}
74
75fn fnv1a64_u64(hash: u64, v: u64) -> u64 {
76    fnv1a64_bytes(hash, &v.to_le_bytes())
77}
78
79fn fnv1a64_pair(prev: u64, next: u64) -> u64 {
80    let hash = FNV_OFFSET_BASIS;
81    let hash = fnv1a64_u64(hash, prev);
82    fnv1a64_u64(hash, next)
83}
84
85/// A single record in a session trace.
86#[derive(Debug, Clone, PartialEq)]
87pub enum TraceRecord {
88    /// Session header (must be first).
89    Header {
90        seed: u64,
91        cols: u16,
92        rows: u16,
93        profile: String,
94    },
95    /// An input event at a specific timestamp.
96    Input { ts_ns: u64, event: Event },
97    /// Terminal resize at a specific timestamp.
98    Resize { ts_ns: u64, cols: u16, rows: u16 },
99    /// Explicit time advancement.
100    Tick { ts_ns: u64 },
101    /// Frame checkpoint with checksum.
102    Frame {
103        frame_idx: u64,
104        ts_ns: u64,
105        checksum: u64,
106        checksum_chain: u64,
107    },
108    /// Trace summary (must be last).
109    Summary {
110        total_frames: u64,
111        final_checksum_chain: u64,
112    },
113}
114
115/// A complete recorded session trace.
116#[derive(Debug, Clone)]
117pub struct SessionTrace {
118    pub records: Vec<TraceRecord>,
119}
120
121impl SessionTrace {
122    /// Number of frame checkpoints in the trace.
123    pub fn frame_count(&self) -> u64 {
124        self.records
125            .iter()
126            .filter(|r| matches!(r, TraceRecord::Frame { .. }))
127            .count() as u64
128    }
129
130    /// Extract the final checksum chain from the summary record.
131    pub fn final_checksum_chain(&self) -> Option<u64> {
132        self.records.iter().rev().find_map(|r| match r {
133            TraceRecord::Summary {
134                final_checksum_chain,
135                ..
136            } => Some(*final_checksum_chain),
137            _ => None,
138        })
139    }
140
141    /// Validate structural invariants for a recorded trace.
142    ///
143    /// This checks:
144    /// - header exists and is the first record
145    /// - summary exists and is the last record
146    /// - frame indices are contiguous and start at zero
147    /// - summary totals/chains match frame records
148    pub fn validate(&self) -> Result<(), TraceValidationError> {
149        if self.records.is_empty() {
150            return Err(TraceValidationError::EmptyTrace);
151        }
152
153        let mut header_count: usize = 0;
154        let mut summary: Option<(usize, u64, u64)> = None;
155        let mut expected_frame_idx: u64 = 0;
156        let mut frame_count: u64 = 0;
157        let mut last_checksum_chain: u64 = 0;
158        let mut last_ts_ns: Option<u64> = None;
159
160        let mut validate_ts =
161            |ts_ns: u64, record_index: usize| -> Result<(), TraceValidationError> {
162                if let Some(previous) = last_ts_ns
163                    && ts_ns < previous
164                {
165                    return Err(TraceValidationError::TimestampRegression {
166                        previous,
167                        current: ts_ns,
168                        record_index,
169                    });
170                }
171                last_ts_ns = Some(ts_ns);
172                Ok(())
173            };
174
175        for (idx, record) in self.records.iter().enumerate() {
176            match record {
177                TraceRecord::Header { .. } => {
178                    if summary.is_some() {
179                        let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
180                        return Err(TraceValidationError::SummaryNotLast {
181                            summary_index: summary_idx,
182                        });
183                    }
184                    header_count += 1;
185                }
186                TraceRecord::Summary {
187                    total_frames,
188                    final_checksum_chain,
189                } => {
190                    if summary.is_some() {
191                        return Err(TraceValidationError::MultipleSummaries);
192                    }
193                    summary = Some((idx, *total_frames, *final_checksum_chain));
194                }
195                TraceRecord::Frame {
196                    frame_idx,
197                    ts_ns,
198                    checksum_chain,
199                    ..
200                } => {
201                    validate_ts(*ts_ns, idx)?;
202                    if summary.is_some() {
203                        let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
204                        return Err(TraceValidationError::SummaryNotLast {
205                            summary_index: summary_idx,
206                        });
207                    }
208                    if *frame_idx != expected_frame_idx {
209                        return Err(TraceValidationError::FrameIndexMismatch {
210                            expected: expected_frame_idx,
211                            actual: *frame_idx,
212                        });
213                    }
214                    expected_frame_idx = expected_frame_idx.saturating_add(1);
215                    frame_count = frame_count.saturating_add(1);
216                    last_checksum_chain = *checksum_chain;
217                }
218                TraceRecord::Input { ts_ns, .. } => {
219                    validate_ts(*ts_ns, idx)?;
220                    if summary.is_some() {
221                        let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
222                        return Err(TraceValidationError::SummaryNotLast {
223                            summary_index: summary_idx,
224                        });
225                    }
226                }
227                TraceRecord::Resize { ts_ns, .. } => {
228                    validate_ts(*ts_ns, idx)?;
229                    if summary.is_some() {
230                        let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
231                        return Err(TraceValidationError::SummaryNotLast {
232                            summary_index: summary_idx,
233                        });
234                    }
235                }
236                TraceRecord::Tick { ts_ns } => {
237                    validate_ts(*ts_ns, idx)?;
238                    if summary.is_some() {
239                        let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
240                        return Err(TraceValidationError::SummaryNotLast {
241                            summary_index: summary_idx,
242                        });
243                    }
244                }
245            }
246        }
247
248        if header_count == 0 {
249            return Err(TraceValidationError::MissingHeader);
250        }
251        if header_count > 1 {
252            return Err(TraceValidationError::MultipleHeaders);
253        }
254        if !matches!(self.records.first(), Some(TraceRecord::Header { .. })) {
255            return Err(TraceValidationError::HeaderNotFirst);
256        }
257
258        let Some((summary_idx, summary_frames, summary_chain)) = summary else {
259            return Err(TraceValidationError::MissingSummary);
260        };
261        if summary_idx != self.records.len().saturating_sub(1) {
262            return Err(TraceValidationError::SummaryNotLast {
263                summary_index: summary_idx,
264            });
265        }
266        if summary_frames != frame_count {
267            return Err(TraceValidationError::SummaryFrameCountMismatch {
268                expected: frame_count,
269                actual: summary_frames,
270            });
271        }
272        if summary_chain != last_checksum_chain {
273            return Err(TraceValidationError::SummaryChecksumChainMismatch {
274                expected: last_checksum_chain,
275                actual: summary_chain,
276            });
277        }
278
279        Ok(())
280    }
281}
282
283/// Records a WASM session for deterministic replay.
284///
285/// Wraps a [`StepProgram`] and intercepts all input operations, recording
286/// them as [`TraceRecord`]s. Frame checksums are computed after each render
287/// using the same FNV-1a algorithm as the render trace system.
288pub struct SessionRecorder<M: ftui_runtime::program::Model> {
289    program: StepProgram<M>,
290    records: Vec<TraceRecord>,
291    checksum_chain: u64,
292    current_ts_ns: u64,
293}
294
295impl<M: ftui_runtime::program::Model> SessionRecorder<M> {
296    /// Create a new recorder with the given model, initial size, and seed.
297    #[must_use]
298    pub fn new(model: M, width: u16, height: u16, seed: u64) -> Self {
299        let program = StepProgram::new(model, width, height);
300        let records = vec![TraceRecord::Header {
301            seed,
302            cols: width,
303            rows: height,
304            profile: "modern".to_string(),
305        }];
306        Self {
307            program,
308            records,
309            checksum_chain: 0,
310            current_ts_ns: 0,
311        }
312    }
313
314    /// Initialize the model and record the first frame checkpoint.
315    pub fn init(&mut self) -> Result<(), WebBackendError> {
316        self.program.init()?;
317        self.record_frame();
318        Ok(())
319    }
320
321    /// Record an input event at the given timestamp (nanoseconds since start).
322    pub fn push_event(&mut self, ts_ns: u64, event: Event) {
323        self.current_ts_ns = ts_ns;
324        self.records.push(TraceRecord::Input {
325            ts_ns,
326            event: event.clone(),
327        });
328        self.program.push_event(event);
329    }
330
331    /// Record a resize at the given timestamp.
332    pub fn resize(&mut self, ts_ns: u64, width: u16, height: u16) {
333        self.current_ts_ns = ts_ns;
334        self.records.push(TraceRecord::Resize {
335            ts_ns,
336            cols: width,
337            rows: height,
338        });
339        self.program.resize(width, height);
340    }
341
342    /// Record a time advancement (tick) at the given timestamp.
343    pub fn advance_time(&mut self, ts_ns: u64, dt: Duration) {
344        self.current_ts_ns = ts_ns;
345        self.records.push(TraceRecord::Tick { ts_ns });
346        self.program.advance_time(dt);
347    }
348
349    /// Process one step and record a frame checkpoint if rendered.
350    pub fn step(&mut self) -> Result<StepResult, WebBackendError> {
351        let result = self.program.step()?;
352        if result.rendered {
353            self.record_frame();
354        }
355        Ok(result)
356    }
357
358    /// Finish recording and return the completed trace.
359    pub fn finish(mut self) -> SessionTrace {
360        let total_frames = self
361            .records
362            .iter()
363            .filter(|r| matches!(r, TraceRecord::Frame { .. }))
364            .count() as u64;
365        self.records.push(TraceRecord::Summary {
366            total_frames,
367            final_checksum_chain: self.checksum_chain,
368        });
369        SessionTrace {
370            records: self.records,
371        }
372    }
373
374    /// Access the underlying program.
375    pub fn program(&self) -> &StepProgram<M> {
376        &self.program
377    }
378
379    /// Mutably access the underlying program.
380    pub fn program_mut(&mut self) -> &mut StepProgram<M> {
381        &mut self.program
382    }
383
384    fn record_frame(&mut self) {
385        let outputs = self.program.outputs();
386        if let Some(buf) = &outputs.last_buffer {
387            let checksum = checksum_buffer(buf, self.program.pool());
388            let chain = fnv1a64_pair(self.checksum_chain, checksum);
389            self.records.push(TraceRecord::Frame {
390                frame_idx: self.program.frame_idx().saturating_sub(1),
391                ts_ns: self.current_ts_ns,
392                checksum,
393                checksum_chain: chain,
394            });
395            self.checksum_chain = chain;
396        }
397    }
398}
399
400/// Result of replaying a session trace.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct ReplayResult {
403    /// Total frames replayed.
404    pub total_frames: u64,
405    /// Final checksum chain from replay.
406    pub final_checksum_chain: u64,
407    /// First frame where a checksum mismatch was detected, if any.
408    pub first_mismatch: Option<ReplayMismatch>,
409}
410
411impl ReplayResult {
412    /// Whether the replay produced identical checksums.
413    #[must_use]
414    pub fn ok(&self) -> bool {
415        self.first_mismatch.is_none()
416    }
417}
418
419/// Description of a checksum mismatch during replay.
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct ReplayMismatch {
422    /// Frame index where the mismatch occurred.
423    pub frame_idx: u64,
424    /// Expected checksum from the trace.
425    pub expected: u64,
426    /// Actual checksum from replay.
427    pub actual: u64,
428}
429
430/// Errors that can occur during replay.
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub enum ReplayError {
433    /// The trace is missing a header record.
434    MissingHeader,
435    /// The trace violates structural invariants.
436    InvalidTrace(TraceValidationError),
437    /// A backend error occurred during replay.
438    Backend(WebBackendError),
439}
440
441impl core::fmt::Display for ReplayError {
442    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
443        match self {
444            Self::MissingHeader => write!(f, "trace missing header record"),
445            Self::InvalidTrace(e) => write!(f, "invalid trace: {e}"),
446            Self::Backend(e) => write!(f, "backend error: {e}"),
447        }
448    }
449}
450
451impl std::error::Error for ReplayError {}
452
453impl From<WebBackendError> for ReplayError {
454    fn from(e: WebBackendError) -> Self {
455        Self::Backend(e)
456    }
457}
458
459/// Replay a recorded session trace through a fresh model.
460///
461/// Feeds all recorded events, resizes, and ticks through a new
462/// [`StepProgram`], stepping only at frame boundaries (matching the
463/// original recording cadence). Compares frame checksums against the
464/// recorded values.
465///
466/// Returns [`ReplayResult`] with match/mismatch information.
467pub fn replay<M: ftui_runtime::program::Model>(
468    model: M,
469    trace: &SessionTrace,
470) -> Result<ReplayResult, ReplayError> {
471    // Extract header.
472    let (cols, rows) = trace
473        .records
474        .first()
475        .and_then(|r| match r {
476            TraceRecord::Header { cols, rows, .. } => Some((*cols, *rows)),
477            _ => None,
478        })
479        .ok_or(ReplayError::MissingHeader)?;
480    trace.validate().map_err(ReplayError::InvalidTrace)?;
481
482    let mut program = StepProgram::new(model, cols, rows);
483    program.init()?;
484
485    let mut replay_frame_idx: u64 = 0;
486    let mut checksum_chain: u64 = 0;
487    let mut first_mismatch: Option<ReplayMismatch> = None;
488
489    // Replay by iterating through trace records. Input/Resize/Tick records
490    // feed data into the program; Frame records trigger a step and checksum
491    // verification. This ensures event batching matches the original session.
492    for record in &trace.records {
493        match record {
494            TraceRecord::Input { event, .. } => {
495                program.push_event(event.clone());
496            }
497            TraceRecord::Resize { cols, rows, .. } => {
498                program.resize(*cols, *rows);
499            }
500            TraceRecord::Tick { ts_ns } => {
501                program.set_time(Duration::from_nanos(*ts_ns));
502            }
503            TraceRecord::Frame {
504                frame_idx: expected_idx,
505                checksum: expected_checksum,
506                ..
507            } => {
508                // The init frame (frame_idx 0) was already rendered by init().
509                // Subsequent frames require a step() call.
510                if replay_frame_idx > 0 {
511                    program.step()?;
512                }
513
514                // Verify checksum.
515                let outputs = program.outputs();
516                if let Some(buf) = &outputs.last_buffer {
517                    let actual = checksum_buffer(buf, program.pool());
518                    checksum_chain = fnv1a64_pair(checksum_chain, actual);
519                    if actual != *expected_checksum && first_mismatch.is_none() {
520                        first_mismatch = Some(ReplayMismatch {
521                            frame_idx: *expected_idx,
522                            expected: *expected_checksum,
523                            actual,
524                        });
525                    }
526                }
527                replay_frame_idx += 1;
528            }
529            TraceRecord::Header { .. } | TraceRecord::Summary { .. } => {}
530        }
531    }
532
533    Ok(ReplayResult {
534        total_frames: replay_frame_idx,
535        final_checksum_chain: checksum_chain,
536        first_mismatch,
537    })
538}
539
540// ---- JSONL serialization / deserialization ----
541
542fn json_escape(input: &str) -> String {
543    let mut out = String::with_capacity(input.len() + 8);
544    for ch in input.chars() {
545        match ch {
546            '"' => out.push_str("\\\""),
547            '\\' => out.push_str("\\\\"),
548            '\n' => out.push_str("\\n"),
549            '\r' => out.push_str("\\r"),
550            '\t' => out.push_str("\\t"),
551            c if c.is_control() => {
552                use core::fmt::Write as _;
553                let _ = write!(out, "\\u{:04x}", c as u32);
554            }
555            c => out.push(c),
556        }
557    }
558    out
559}
560
561fn event_to_json(event: &Event) -> String {
562    match event {
563        Event::Key(k) => {
564            let code = key_code_to_str(k.code);
565            let mods = k.modifiers.bits();
566            let kind = key_event_kind_to_str(k.kind);
567            format!(
568                r#"{{"kind":"key","code":"{}","modifiers":{},"event_kind":"{}"}}"#,
569                json_escape(&code),
570                mods,
571                kind
572            )
573        }
574        Event::Mouse(m) => {
575            let kind = mouse_event_kind_to_str(m.kind);
576            let mods = m.modifiers.bits();
577            format!(
578                r#"{{"kind":"mouse","mouse_kind":"{}","x":{},"y":{},"modifiers":{}}}"#,
579                kind, m.x, m.y, mods
580            )
581        }
582        Event::Resize { width, height } => {
583            format!(
584                r#"{{"kind":"resize","width":{},"height":{}}}"#,
585                width, height
586            )
587        }
588        Event::Paste(p) => {
589            format!(
590                r#"{{"kind":"paste","text":"{}","bracketed":{}}}"#,
591                json_escape(&p.text),
592                p.bracketed
593            )
594        }
595        Event::Focus(gained) => {
596            format!(r#"{{"kind":"focus","gained":{}}}"#, gained)
597        }
598        Event::Clipboard(c) => {
599            let source = clipboard_source_to_str(c.source);
600            format!(
601                r#"{{"kind":"clipboard","content":"{}","source":"{}"}}"#,
602                json_escape(&c.content),
603                source
604            )
605        }
606        Event::Tick => r#"{"kind":"tick"}"#.to_string(),
607    }
608}
609
610fn key_code_to_str(code: KeyCode) -> String {
611    match code {
612        KeyCode::Char(c) => format!("char:{c}"),
613        KeyCode::Enter => "enter".to_string(),
614        KeyCode::Escape => "escape".to_string(),
615        KeyCode::Backspace => "backspace".to_string(),
616        KeyCode::Tab => "tab".to_string(),
617        KeyCode::BackTab => "backtab".to_string(),
618        KeyCode::Delete => "delete".to_string(),
619        KeyCode::Insert => "insert".to_string(),
620        KeyCode::Home => "home".to_string(),
621        KeyCode::End => "end".to_string(),
622        KeyCode::PageUp => "pageup".to_string(),
623        KeyCode::PageDown => "pagedown".to_string(),
624        KeyCode::Up => "up".to_string(),
625        KeyCode::Down => "down".to_string(),
626        KeyCode::Left => "left".to_string(),
627        KeyCode::Right => "right".to_string(),
628        KeyCode::F(n) => format!("f:{n}"),
629        KeyCode::Null => "null".to_string(),
630        KeyCode::MediaPlayPause => "media_play_pause".to_string(),
631        KeyCode::MediaStop => "media_stop".to_string(),
632        KeyCode::MediaNextTrack => "media_next".to_string(),
633        KeyCode::MediaPrevTrack => "media_prev".to_string(),
634    }
635}
636
637fn key_event_kind_to_str(kind: KeyEventKind) -> &'static str {
638    match kind {
639        KeyEventKind::Press => "press",
640        KeyEventKind::Repeat => "repeat",
641        KeyEventKind::Release => "release",
642    }
643}
644
645fn mouse_event_kind_to_str(kind: MouseEventKind) -> &'static str {
646    match kind {
647        MouseEventKind::Down(MouseButton::Left) => "down_left",
648        MouseEventKind::Down(MouseButton::Right) => "down_right",
649        MouseEventKind::Down(MouseButton::Middle) => "down_middle",
650        MouseEventKind::Up(MouseButton::Left) => "up_left",
651        MouseEventKind::Up(MouseButton::Right) => "up_right",
652        MouseEventKind::Up(MouseButton::Middle) => "up_middle",
653        MouseEventKind::Drag(MouseButton::Left) => "drag_left",
654        MouseEventKind::Drag(MouseButton::Right) => "drag_right",
655        MouseEventKind::Drag(MouseButton::Middle) => "drag_middle",
656        MouseEventKind::Moved => "moved",
657        MouseEventKind::ScrollUp => "scroll_up",
658        MouseEventKind::ScrollDown => "scroll_down",
659        MouseEventKind::ScrollLeft => "scroll_left",
660        MouseEventKind::ScrollRight => "scroll_right",
661    }
662}
663
664fn clipboard_source_to_str(source: ClipboardSource) -> &'static str {
665    match source {
666        ClipboardSource::Osc52 => "osc52",
667        ClipboardSource::Unknown => "unknown",
668    }
669}
670
671impl TraceRecord {
672    /// Serialize this record as a golden-trace-v1 JSONL line.
673    pub fn to_jsonl(&self) -> String {
674        match self {
675            TraceRecord::Header {
676                seed,
677                cols,
678                rows,
679                profile,
680            } => format!(
681                r#"{{"schema_version":"{}","event":"trace_header","seed":{},"cols":{},"rows":{},"env":{{"target":"web"}},"profile":"{}"}}"#,
682                SCHEMA_VERSION,
683                seed,
684                cols,
685                rows,
686                json_escape(profile)
687            ),
688            TraceRecord::Input { ts_ns, event } => format!(
689                r#"{{"schema_version":"{}","event":"input","ts_ns":{},"data":{}}}"#,
690                SCHEMA_VERSION,
691                ts_ns,
692                event_to_json(event)
693            ),
694            TraceRecord::Resize { ts_ns, cols, rows } => format!(
695                r#"{{"schema_version":"{}","event":"resize","ts_ns":{},"cols":{},"rows":{}}}"#,
696                SCHEMA_VERSION, ts_ns, cols, rows
697            ),
698            TraceRecord::Tick { ts_ns } => format!(
699                r#"{{"schema_version":"{}","event":"tick","ts_ns":{}}}"#,
700                SCHEMA_VERSION, ts_ns
701            ),
702            TraceRecord::Frame {
703                frame_idx,
704                ts_ns,
705                checksum,
706                checksum_chain,
707            } => format!(
708                r#"{{"schema_version":"{}","event":"frame","frame_idx":{},"ts_ns":{},"hash_algo":"fnv1a64","frame_hash":"{:016x}","checksum_chain":"{:016x}"}}"#,
709                SCHEMA_VERSION, frame_idx, ts_ns, checksum, checksum_chain
710            ),
711            TraceRecord::Summary {
712                total_frames,
713                final_checksum_chain,
714            } => format!(
715                r#"{{"schema_version":"{}","event":"trace_summary","total_frames":{},"final_checksum_chain":"{:016x}"}}"#,
716                SCHEMA_VERSION, total_frames, final_checksum_chain
717            ),
718        }
719    }
720}
721
722impl SessionTrace {
723    /// Serialize the entire trace as a golden-trace-v1 JSONL string.
724    pub fn to_jsonl(&self) -> String {
725        let mut out = String::new();
726        for record in &self.records {
727            out.push_str(&record.to_jsonl());
728            out.push('\n');
729        }
730        out
731    }
732
733    /// Parse a golden-trace-v1 JSONL string into a `SessionTrace`.
734    ///
735    /// Returns a parse error with the line number on failure.
736    pub fn from_jsonl(input: &str) -> Result<Self, TraceParseError> {
737        let mut records = Vec::new();
738        for (line_num, line) in input.lines().enumerate() {
739            let line = line.trim();
740            if line.is_empty() {
741                continue;
742            }
743            let record = parse_trace_line(line, line_num + 1)?;
744            records.push(record);
745        }
746        Ok(SessionTrace { records })
747    }
748
749    /// Parse and validate a golden-trace-v1 JSONL payload.
750    pub fn from_jsonl_validated(input: &str) -> Result<Self, TraceLoadError> {
751        let trace = Self::from_jsonl(input)?;
752        trace.validate()?;
753        Ok(trace)
754    }
755}
756
757/// Error parsing a JSONL trace.
758#[derive(Debug, Clone, PartialEq, Eq)]
759pub struct TraceParseError {
760    pub line: usize,
761    pub message: String,
762}
763
764impl core::fmt::Display for TraceParseError {
765    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
766        write!(f, "line {}: {}", self.line, self.message)
767    }
768}
769
770impl std::error::Error for TraceParseError {}
771
772/// Typed validation failures for `SessionTrace`.
773#[derive(Debug, Clone, PartialEq, Eq)]
774pub enum TraceValidationError {
775    EmptyTrace,
776    MissingHeader,
777    HeaderNotFirst,
778    MultipleHeaders,
779    MissingSummary,
780    MultipleSummaries,
781    SummaryNotLast {
782        summary_index: usize,
783    },
784    TimestampRegression {
785        previous: u64,
786        current: u64,
787        record_index: usize,
788    },
789    FrameIndexMismatch {
790        expected: u64,
791        actual: u64,
792    },
793    SummaryFrameCountMismatch {
794        expected: u64,
795        actual: u64,
796    },
797    SummaryChecksumChainMismatch {
798        expected: u64,
799        actual: u64,
800    },
801}
802
803impl core::fmt::Display for TraceValidationError {
804    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
805        match self {
806            Self::EmptyTrace => write!(f, "trace is empty"),
807            Self::MissingHeader => write!(f, "trace is missing header"),
808            Self::HeaderNotFirst => write!(f, "trace header is not the first record"),
809            Self::MultipleHeaders => write!(f, "trace contains multiple headers"),
810            Self::MissingSummary => write!(f, "trace is missing summary"),
811            Self::MultipleSummaries => write!(f, "trace contains multiple summaries"),
812            Self::SummaryNotLast { summary_index } => write!(
813                f,
814                "trace summary at index {} is not the final record",
815                summary_index
816            ),
817            Self::TimestampRegression {
818                previous,
819                current,
820                record_index,
821            } => write!(
822                f,
823                "timestamp regression at record {}: current ts_ns={} is less than previous ts_ns={}",
824                record_index, current, previous
825            ),
826            Self::FrameIndexMismatch { expected, actual } => {
827                write!(
828                    f,
829                    "frame index mismatch: expected {}, got {}",
830                    expected, actual
831                )
832            }
833            Self::SummaryFrameCountMismatch { expected, actual } => write!(
834                f,
835                "summary frame-count mismatch: expected {}, got {}",
836                expected, actual
837            ),
838            Self::SummaryChecksumChainMismatch { expected, actual } => write!(
839                f,
840                "summary checksum-chain mismatch: expected {:016x}, got {:016x}",
841                expected, actual
842            ),
843        }
844    }
845}
846
847impl std::error::Error for TraceValidationError {}
848
849/// Combined load error for parse + validation.
850#[derive(Debug, Clone, PartialEq, Eq)]
851pub enum TraceLoadError {
852    Parse(TraceParseError),
853    Validation(TraceValidationError),
854}
855
856impl core::fmt::Display for TraceLoadError {
857    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
858        match self {
859            Self::Parse(e) => write!(f, "{e}"),
860            Self::Validation(e) => write!(f, "{e}"),
861        }
862    }
863}
864
865impl std::error::Error for TraceLoadError {}
866
867impl From<TraceParseError> for TraceLoadError {
868    fn from(value: TraceParseError) -> Self {
869        Self::Parse(value)
870    }
871}
872
873impl From<TraceValidationError> for TraceLoadError {
874    fn from(value: TraceValidationError) -> Self {
875        Self::Validation(value)
876    }
877}
878
879// ---- Minimal JSON field extraction (no serde dependency) ----
880
881fn extract_str<'a>(json: &'a str, key: &str) -> Option<&'a str> {
882    let pattern = format!("\"{}\":\"", key);
883    let start = json.find(&pattern)? + pattern.len();
884    let rest = &json[start..];
885    // Find closing quote, handling escapes.
886    let mut i = 0;
887    let bytes = rest.as_bytes();
888    while i < bytes.len() {
889        if bytes[i] == b'\\' {
890            i += 2; // Skip escaped char.
891            continue;
892        }
893        if bytes[i] == b'"' {
894            return Some(&rest[..i]);
895        }
896        i += 1;
897    }
898    None
899}
900
901fn extract_u64(json: &str, key: &str) -> Option<u64> {
902    let pattern = format!("\"{}\":", key);
903    let start = json.find(&pattern)? + pattern.len();
904    let rest = json[start..].trim_start();
905    let end = rest
906        .find(|c: char| !c.is_ascii_digit())
907        .unwrap_or(rest.len());
908    rest[..end].parse().ok()
909}
910
911fn extract_i64(json: &str, key: &str) -> Option<i64> {
912    let pattern = format!("\"{}\":", key);
913    let start = json.find(&pattern)? + pattern.len();
914    let rest = json[start..].trim_start();
915    let signed = rest.strip_prefix('-').is_some();
916    let digits = if signed { &rest[1..] } else { rest };
917    let end = digits
918        .find(|c: char| !c.is_ascii_digit())
919        .unwrap_or(digits.len());
920    if end == 0 {
921        return None;
922    }
923    let parsed: i64 = digits[..end].parse().ok()?;
924    Some(if signed { -parsed } else { parsed })
925}
926
927fn extract_u16(json: &str, key: &str) -> Option<u16> {
928    extract_u64(json, key).and_then(|v| u16::try_from(v).ok())
929}
930
931fn extract_bool(json: &str, key: &str) -> Option<bool> {
932    let pattern = format!("\"{}\":", key);
933    let start = json.find(&pattern)? + pattern.len();
934    let rest = json[start..].trim_start();
935    if rest.starts_with("true") {
936        Some(true)
937    } else if rest.starts_with("false") {
938        Some(false)
939    } else {
940        None
941    }
942}
943
944fn extract_hex_u64(json: &str, key: &str) -> Option<u64> {
945    let s = extract_str(json, key)?;
946    u64::from_str_radix(s, 16).ok()
947}
948
949fn extract_object<'a>(json: &'a str, key: &str) -> Option<&'a str> {
950    let pattern = format!("\"{}\":", key);
951    let start = json.find(&pattern)? + pattern.len();
952    let rest = json[start..].trim_start();
953    if !rest.starts_with('{') {
954        return None;
955    }
956    let mut depth = 0;
957    for (i, ch) in rest.char_indices() {
958        match ch {
959            '{' => depth += 1,
960            '}' => {
961                depth -= 1;
962                if depth == 0 {
963                    return Some(&rest[..=i]);
964                }
965            }
966            _ => {}
967        }
968    }
969    None
970}
971
972fn json_unescape(input: &str) -> String {
973    let mut out = String::with_capacity(input.len());
974    let mut chars = input.chars();
975    while let Some(ch) = chars.next() {
976        if ch == '\\' {
977            match chars.next() {
978                Some('"') => out.push('"'),
979                Some('\\') => out.push('\\'),
980                Some('n') => out.push('\n'),
981                Some('r') => out.push('\r'),
982                Some('t') => out.push('\t'),
983                Some('u') => {
984                    let hex: String = chars.by_ref().take(4).collect();
985                    if let Ok(cp) = u32::from_str_radix(&hex, 16)
986                        && let Some(c) = char::from_u32(cp)
987                    {
988                        out.push(c);
989                    }
990                }
991                Some(c) => {
992                    out.push('\\');
993                    out.push(c);
994                }
995                None => out.push('\\'),
996            }
997        } else {
998            out.push(ch);
999        }
1000    }
1001    out
1002}
1003
1004fn check_trace_schema_compat(schema_version: &str, line_num: usize) -> Result<(), TraceParseError> {
1005    let incompatible = schema_version != SCHEMA_VERSION;
1006
1007    #[cfg(feature = "tracing")]
1008    {
1009        let span = info_span!(
1010            "trace.compat_check",
1011            reader_schema_version = SCHEMA_VERSION,
1012            writer_schema_version = schema_version,
1013            line = line_num,
1014            compatible = !incompatible,
1015        );
1016        let _guard = span.enter();
1017
1018        if incompatible {
1019            error!(
1020                reader_schema_version = SCHEMA_VERSION,
1021                writer_schema_version = schema_version,
1022                line = line_num,
1023                "trace schema version incompatible"
1024            );
1025        }
1026    }
1027
1028    if incompatible {
1029        return Err(TraceParseError {
1030            line: line_num,
1031            message: format!(
1032                "unsupported schema_version: {schema_version} (reader={SCHEMA_VERSION}, migration required)"
1033            ),
1034        });
1035    }
1036    Ok(())
1037}
1038
1039fn parse_trace_line(line: &str, line_num: usize) -> Result<TraceRecord, TraceParseError> {
1040    let err = |msg: &str| TraceParseError {
1041        line: line_num,
1042        message: msg.to_string(),
1043    };
1044
1045    let schema_version = extract_str(line, "schema_version")
1046        .ok_or_else(|| err("missing \"schema_version\" field"))?;
1047    check_trace_schema_compat(schema_version, line_num)?;
1048
1049    let event = extract_str(line, "event").ok_or_else(|| err("missing \"event\" field"))?;
1050
1051    match event {
1052        "trace_header" => {
1053            let seed = extract_u64(line, "seed").unwrap_or(0);
1054            let cols = extract_u16(line, "cols").ok_or_else(|| err("missing cols"))?;
1055            let rows = extract_u16(line, "rows").ok_or_else(|| err("missing rows"))?;
1056            let profile = extract_str(line, "profile")
1057                .map(|s| s.to_string())
1058                .unwrap_or_else(|| "modern".to_string());
1059            Ok(TraceRecord::Header {
1060                seed,
1061                cols,
1062                rows,
1063                profile,
1064            })
1065        }
1066        "input" => {
1067            let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1068            let data = extract_object(line, "data").ok_or_else(|| err("missing data object"))?;
1069            let event = parse_event_json(data).map_err(|e| err(&e))?;
1070            Ok(TraceRecord::Input { ts_ns, event })
1071        }
1072        "resize" => {
1073            let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1074            let cols = extract_u16(line, "cols").ok_or_else(|| err("missing cols"))?;
1075            let rows = extract_u16(line, "rows").ok_or_else(|| err("missing rows"))?;
1076            Ok(TraceRecord::Resize { ts_ns, cols, rows })
1077        }
1078        "tick" => {
1079            let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1080            Ok(TraceRecord::Tick { ts_ns })
1081        }
1082        "frame" => {
1083            let frame_idx =
1084                extract_u64(line, "frame_idx").ok_or_else(|| err("missing frame_idx"))?;
1085            let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
1086            let checksum =
1087                extract_hex_u64(line, "frame_hash").ok_or_else(|| err("missing frame_hash"))?;
1088            let checksum_chain = extract_hex_u64(line, "checksum_chain")
1089                .ok_or_else(|| err("missing checksum_chain"))?;
1090            Ok(TraceRecord::Frame {
1091                frame_idx,
1092                ts_ns,
1093                checksum,
1094                checksum_chain,
1095            })
1096        }
1097        "trace_summary" => {
1098            let total_frames =
1099                extract_u64(line, "total_frames").ok_or_else(|| err("missing total_frames"))?;
1100            let final_checksum_chain = extract_hex_u64(line, "final_checksum_chain")
1101                .ok_or_else(|| err("missing final_checksum_chain"))?;
1102            Ok(TraceRecord::Summary {
1103                total_frames,
1104                final_checksum_chain,
1105            })
1106        }
1107        other => Err(err(&format!("unknown event type: {other}"))),
1108    }
1109}
1110
1111fn parse_event_json(data: &str) -> Result<Event, String> {
1112    let kind = extract_str(data, "kind").ok_or("missing event kind")?;
1113    match kind {
1114        "key" => {
1115            let code_str = extract_str(data, "code").ok_or("missing key code")?;
1116            let code = parse_key_code(&json_unescape(code_str))?;
1117            let mods_bits = extract_u64(data, "modifiers")
1118                .or(extract_u64(data, "mods"))
1119                .unwrap_or(0) as u8;
1120            let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
1121            let event_kind = if let Some(event_kind_str) = extract_str(data, "event_kind") {
1122                match event_kind_str {
1123                    "press" => KeyEventKind::Press,
1124                    "repeat" => KeyEventKind::Repeat,
1125                    "release" => KeyEventKind::Release,
1126                    _ => KeyEventKind::Press,
1127                }
1128            } else {
1129                let phase = extract_str(data, "phase").unwrap_or("down");
1130                let repeat = extract_bool(data, "repeat").unwrap_or(false);
1131                parse_key_event_kind(phase, repeat)
1132            };
1133            Ok(Event::Key(KeyEvent {
1134                code,
1135                modifiers,
1136                kind: event_kind,
1137            }))
1138        }
1139        "mouse" => {
1140            let mouse_kind = if let Some(mouse_kind_str) = extract_str(data, "mouse_kind") {
1141                parse_mouse_event_kind(mouse_kind_str)?
1142            } else {
1143                let phase = extract_str(data, "phase").ok_or("missing phase for mouse event")?;
1144                let button = extract_u64(data, "button")
1145                    .map(|raw| {
1146                        u8::try_from(raw).map_err(|_| "mouse button out of range".to_string())
1147                    })
1148                    .transpose()?;
1149                parse_mouse_phase_and_button(phase, button)?
1150            };
1151            let x = extract_u16(data, "x").unwrap_or(0);
1152            let y = extract_u16(data, "y").unwrap_or(0);
1153            let mods_bits = extract_u64(data, "modifiers")
1154                .or(extract_u64(data, "mods"))
1155                .unwrap_or(0) as u8;
1156            let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
1157            Ok(Event::Mouse(MouseEvent {
1158                kind: mouse_kind,
1159                x,
1160                y,
1161                modifiers,
1162            }))
1163        }
1164        "wheel" => {
1165            let x = extract_u16(data, "x").unwrap_or(0);
1166            let y = extract_u16(data, "y").unwrap_or(0);
1167            let dx = extract_i64(data, "dx")
1168                .and_then(|value| i16::try_from(value).ok())
1169                .unwrap_or(0);
1170            let dy = extract_i64(data, "dy")
1171                .and_then(|value| i16::try_from(value).ok())
1172                .unwrap_or(0);
1173            let kind = if dy < 0 {
1174                MouseEventKind::ScrollUp
1175            } else if dy > 0 {
1176                MouseEventKind::ScrollDown
1177            } else if dx < 0 {
1178                MouseEventKind::ScrollLeft
1179            } else if dx > 0 {
1180                MouseEventKind::ScrollRight
1181            } else {
1182                return Err("wheel event must include non-zero dx or dy".to_string());
1183            };
1184            let mods_bits = extract_u64(data, "modifiers")
1185                .or(extract_u64(data, "mods"))
1186                .unwrap_or(0) as u8;
1187            let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
1188            Ok(Event::Mouse(MouseEvent {
1189                kind,
1190                x,
1191                y,
1192                modifiers,
1193            }))
1194        }
1195        "resize" => {
1196            let width = extract_u16(data, "width").ok_or("missing width")?;
1197            let height = extract_u16(data, "height").ok_or("missing height")?;
1198            Ok(Event::Resize { width, height })
1199        }
1200        "paste" => {
1201            let text = extract_str(data, "text")
1202                .or(extract_str(data, "data"))
1203                .map(json_unescape)
1204                .unwrap_or_default();
1205            let bracketed = extract_bool(data, "bracketed").unwrap_or(true);
1206            Ok(Event::Paste(PasteEvent::new(text, bracketed)))
1207        }
1208        "focus" => {
1209            let gained = extract_bool(data, "gained")
1210                .or(extract_bool(data, "focused"))
1211                .unwrap_or(true);
1212            Ok(Event::Focus(gained))
1213        }
1214        "clipboard" => {
1215            let content = extract_str(data, "content")
1216                .map(json_unescape)
1217                .unwrap_or_default();
1218            let source_str = extract_str(data, "source").unwrap_or("unknown");
1219            let source = match source_str {
1220                "osc52" => ClipboardSource::Osc52,
1221                _ => ClipboardSource::Unknown,
1222            };
1223            Ok(Event::Clipboard(ClipboardEvent::new(content, source)))
1224        }
1225        "tick" => Ok(Event::Tick),
1226        other => Err(format!("unknown event kind: {other}")),
1227    }
1228}
1229
1230fn parse_key_code(s: &str) -> Result<KeyCode, String> {
1231    if let Some(rest) = s.strip_prefix("char:") {
1232        let ch = rest.chars().next().ok_or("empty char code")?;
1233        return Ok(KeyCode::Char(ch));
1234    }
1235    if let Some(rest) = s.strip_prefix("f:") {
1236        let n: u8 = rest.parse().map_err(|_| "invalid F-key number")?;
1237        return Ok(KeyCode::F(n));
1238    }
1239    if let Some(n) = parse_function_key_token(s) {
1240        return Ok(KeyCode::F(n));
1241    }
1242
1243    let mut chars = s.chars();
1244    if let Some(ch) = chars.next()
1245        && chars.next().is_none()
1246    {
1247        return Ok(KeyCode::Char(ch));
1248    }
1249
1250    let normalized = s.to_ascii_lowercase();
1251    match normalized.as_str() {
1252        "enter" | "return" => Ok(KeyCode::Enter),
1253        "escape" | "esc" => Ok(KeyCode::Escape),
1254        "backspace" => Ok(KeyCode::Backspace),
1255        "tab" => Ok(KeyCode::Tab),
1256        "backtab" => Ok(KeyCode::BackTab),
1257        "delete" => Ok(KeyCode::Delete),
1258        "insert" => Ok(KeyCode::Insert),
1259        "home" => Ok(KeyCode::Home),
1260        "end" => Ok(KeyCode::End),
1261        "pageup" => Ok(KeyCode::PageUp),
1262        "pagedown" => Ok(KeyCode::PageDown),
1263        "up" | "arrowup" => Ok(KeyCode::Up),
1264        "down" | "arrowdown" => Ok(KeyCode::Down),
1265        "left" | "arrowleft" => Ok(KeyCode::Left),
1266        "right" | "arrowright" => Ok(KeyCode::Right),
1267        "null" | "unidentified" => Ok(KeyCode::Null),
1268        "media_play_pause" => Ok(KeyCode::MediaPlayPause),
1269        "media_stop" => Ok(KeyCode::MediaStop),
1270        "media_next" => Ok(KeyCode::MediaNextTrack),
1271        "media_prev" => Ok(KeyCode::MediaPrevTrack),
1272        other => Err(format!("unknown key code: {other}")),
1273    }
1274}
1275
1276fn parse_function_key_token(s: &str) -> Option<u8> {
1277    let rest = s.strip_prefix('F').or_else(|| s.strip_prefix('f'))?;
1278    if rest.is_empty() || !rest.chars().all(|ch| ch.is_ascii_digit()) {
1279        return None;
1280    }
1281    rest.parse().ok()
1282}
1283
1284fn parse_key_event_kind(phase: &str, repeat: bool) -> KeyEventKind {
1285    if phase.eq_ignore_ascii_case("up") || phase.eq_ignore_ascii_case("release") {
1286        KeyEventKind::Release
1287    } else if repeat {
1288        KeyEventKind::Repeat
1289    } else {
1290        KeyEventKind::Press
1291    }
1292}
1293
1294fn parse_mouse_event_kind(s: &str) -> Result<MouseEventKind, String> {
1295    match s {
1296        "down_left" => Ok(MouseEventKind::Down(MouseButton::Left)),
1297        "down_right" => Ok(MouseEventKind::Down(MouseButton::Right)),
1298        "down_middle" => Ok(MouseEventKind::Down(MouseButton::Middle)),
1299        "up_left" => Ok(MouseEventKind::Up(MouseButton::Left)),
1300        "up_right" => Ok(MouseEventKind::Up(MouseButton::Right)),
1301        "up_middle" => Ok(MouseEventKind::Up(MouseButton::Middle)),
1302        "drag_left" => Ok(MouseEventKind::Drag(MouseButton::Left)),
1303        "drag_right" => Ok(MouseEventKind::Drag(MouseButton::Right)),
1304        "drag_middle" => Ok(MouseEventKind::Drag(MouseButton::Middle)),
1305        "moved" => Ok(MouseEventKind::Moved),
1306        "scroll_up" => Ok(MouseEventKind::ScrollUp),
1307        "scroll_down" => Ok(MouseEventKind::ScrollDown),
1308        "scroll_left" => Ok(MouseEventKind::ScrollLeft),
1309        "scroll_right" => Ok(MouseEventKind::ScrollRight),
1310        other => Err(format!("unknown mouse event kind: {other}")),
1311    }
1312}
1313
1314fn parse_mouse_phase_and_button(phase: &str, button: Option<u8>) -> Result<MouseEventKind, String> {
1315    match phase {
1316        "down" => Ok(MouseEventKind::Down(parse_mouse_button(
1317            button.ok_or("mouse down requires button")?,
1318        )?)),
1319        "up" => Ok(MouseEventKind::Up(parse_mouse_button(
1320            button.ok_or("mouse up requires button")?,
1321        )?)),
1322        "drag" => Ok(MouseEventKind::Drag(parse_mouse_button(
1323            button.ok_or("mouse drag requires button")?,
1324        )?)),
1325        "move" => Ok(MouseEventKind::Moved),
1326        other => Err(format!("unknown mouse phase: {other}")),
1327    }
1328}
1329
1330fn parse_mouse_button(raw: u8) -> Result<MouseButton, String> {
1331    match raw {
1332        0 => Ok(MouseButton::Left),
1333        1 => Ok(MouseButton::Middle),
1334        2 => Ok(MouseButton::Right),
1335        other => Err(format!("unsupported mouse button: {other}")),
1336    }
1337}
1338
1339// ---- Golden Gate API ----
1340
1341/// Validate a trace against a fresh model, returning a detailed report.
1342///
1343/// This is the primary entry point for CI checksum gates. It replays the
1344/// trace and produces a [`GateReport`] with pass/fail status and actionable
1345/// diff information on any mismatch.
1346pub fn gate_trace<M: ftui_runtime::program::Model>(
1347    model: M,
1348    trace: &SessionTrace,
1349) -> Result<GateReport, ReplayError> {
1350    let result = replay(model, trace)?;
1351
1352    let frame_checksums: Vec<(u64, u64)> = trace
1353        .records
1354        .iter()
1355        .filter_map(|r| match r {
1356            TraceRecord::Frame {
1357                frame_idx,
1358                checksum,
1359                ..
1360            } => Some((*frame_idx, *checksum)),
1361            _ => None,
1362        })
1363        .collect();
1364
1365    let diff = result.first_mismatch.as_ref().map(|m| {
1366        // Find the event context: count Input/Resize/Tick records before the failing frame.
1367        let mut event_idx: u64 = 0;
1368        let mut last_event_desc = String::new();
1369        let mut frame_count: u64 = 0;
1370        for record in &trace.records {
1371            match record {
1372                TraceRecord::Frame { .. } => {
1373                    if frame_count == m.frame_idx {
1374                        break;
1375                    }
1376                    frame_count += 1;
1377                }
1378                TraceRecord::Input { event, .. } => {
1379                    last_event_desc = format!("{event:?}");
1380                    event_idx += 1;
1381                }
1382                TraceRecord::Resize { cols, rows, .. } => {
1383                    last_event_desc = format!("Resize({cols}x{rows})");
1384                    event_idx += 1;
1385                }
1386                TraceRecord::Tick { ts_ns } => {
1387                    last_event_desc = format!("Tick(ts_ns={ts_ns})");
1388                    event_idx += 1;
1389                }
1390                _ => {}
1391            }
1392        }
1393
1394        GateDiff {
1395            frame_idx: m.frame_idx,
1396            event_idx,
1397            last_event: last_event_desc,
1398            expected_checksum: m.expected,
1399            actual_checksum: m.actual,
1400        }
1401    });
1402
1403    Ok(GateReport {
1404        passed: result.ok(),
1405        total_frames: result.total_frames,
1406        expected_frames: frame_checksums.len() as u64,
1407        final_checksum_chain: result.final_checksum_chain,
1408        diff,
1409    })
1410}
1411
1412/// Report from a golden trace gate validation.
1413#[derive(Debug, Clone)]
1414pub struct GateReport {
1415    /// Whether all frame checksums matched.
1416    pub passed: bool,
1417    /// Number of frames replayed.
1418    pub total_frames: u64,
1419    /// Number of frame checkpoints in the trace.
1420    pub expected_frames: u64,
1421    /// Final checksum chain from replay.
1422    pub final_checksum_chain: u64,
1423    /// Detailed diff information if there was a mismatch.
1424    pub diff: Option<GateDiff>,
1425}
1426
1427impl GateReport {
1428    /// Format the report as a human-readable string.
1429    pub fn format(&self) -> String {
1430        if self.passed {
1431            format!(
1432                "PASS: {}/{} frames verified, final_chain={:016x}",
1433                self.total_frames, self.expected_frames, self.final_checksum_chain
1434            )
1435        } else if let Some(d) = &self.diff {
1436            format!(
1437                "FAIL at frame {} (after event #{}: {}): expected {:016x}, got {:016x}",
1438                d.frame_idx, d.event_idx, d.last_event, d.expected_checksum, d.actual_checksum
1439            )
1440        } else {
1441            format!(
1442                "FAIL: {}/{} frames, unknown mismatch",
1443                self.total_frames, self.expected_frames
1444            )
1445        }
1446    }
1447}
1448
1449/// Detailed diff information for a checksum mismatch.
1450#[derive(Debug, Clone)]
1451pub struct GateDiff {
1452    /// Frame index where the mismatch occurred.
1453    pub frame_idx: u64,
1454    /// Number of input events processed before the failing frame.
1455    pub event_idx: u64,
1456    /// Description of the last event before the failing frame.
1457    pub last_event: String,
1458    /// Expected checksum from the trace.
1459    pub expected_checksum: u64,
1460    /// Actual checksum from replay.
1461    pub actual_checksum: u64,
1462}
1463
1464#[cfg(test)]
1465mod tests {
1466    use super::*;
1467    use ftui_core::event::{
1468        KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
1469        PasteEvent,
1470    };
1471    use ftui_render::cell::Cell;
1472    use ftui_render::frame::Frame;
1473    use ftui_runtime::program::{Cmd, Model};
1474    use pretty_assertions::assert_eq;
1475    #[cfg(feature = "tracing")]
1476    use std::sync::{Arc, Mutex};
1477    #[cfg(feature = "tracing")]
1478    use tracing::Subscriber;
1479    #[cfg(feature = "tracing")]
1480    use tracing::field::{Field, Visit};
1481    #[cfg(feature = "tracing")]
1482    use tracing_subscriber::Layer;
1483    #[cfg(feature = "tracing")]
1484    use tracing_subscriber::filter::LevelFilter;
1485    #[cfg(feature = "tracing")]
1486    use tracing_subscriber::layer::{Context, SubscriberExt};
1487    #[cfg(feature = "tracing")]
1488    use tracing_subscriber::registry::LookupSpan;
1489
1490    #[cfg(feature = "tracing")]
1491    #[derive(Default, Clone)]
1492    struct TraceCaptureLayer {
1493        spans: Arc<Mutex<Vec<String>>>,
1494        events: Arc<Mutex<Vec<String>>>,
1495    }
1496
1497    #[cfg(feature = "tracing")]
1498    #[derive(Default)]
1499    struct EventMessageVisitor {
1500        message: Option<String>,
1501    }
1502
1503    #[cfg(feature = "tracing")]
1504    impl Visit for EventMessageVisitor {
1505        fn record_str(&mut self, field: &Field, value: &str) {
1506            if field.name() == "message" {
1507                self.message = Some(value.to_string());
1508            }
1509        }
1510
1511        fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
1512            if field.name() == "message" {
1513                self.message = Some(format!("{value:?}"));
1514            }
1515        }
1516    }
1517
1518    #[cfg(feature = "tracing")]
1519    impl<S> Layer<S> for TraceCaptureLayer
1520    where
1521        S: Subscriber + for<'lookup> LookupSpan<'lookup>,
1522    {
1523        fn on_new_span(
1524            &self,
1525            attrs: &tracing::span::Attributes<'_>,
1526            _id: &tracing::span::Id,
1527            _ctx: Context<'_, S>,
1528        ) {
1529            self.spans
1530                .lock()
1531                .expect("span capture lock")
1532                .push(attrs.metadata().name().to_string());
1533        }
1534
1535        fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
1536            let mut visitor = EventMessageVisitor::default();
1537            event.record(&mut visitor);
1538            let message = visitor.message.unwrap_or_default();
1539            self.events
1540                .lock()
1541                .expect("event capture lock")
1542                .push(format!("{}:{}", event.metadata().level(), message));
1543        }
1544    }
1545
1546    // ---- Test model (same as step_program tests) ----
1547
1548    struct Counter {
1549        value: i32,
1550    }
1551
1552    #[derive(Debug)]
1553    enum CounterMsg {
1554        Increment,
1555        Decrement,
1556        Reset,
1557        Quit,
1558    }
1559
1560    impl From<Event> for CounterMsg {
1561        fn from(event: Event) -> Self {
1562            match event {
1563                Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
1564                Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
1565                Event::Key(k) if k.code == KeyCode::Char('r') => CounterMsg::Reset,
1566                Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
1567                Event::Tick => CounterMsg::Increment,
1568                _ => CounterMsg::Increment,
1569            }
1570        }
1571    }
1572
1573    impl Model for Counter {
1574        type Message = CounterMsg;
1575
1576        fn init(&mut self) -> Cmd<Self::Message> {
1577            Cmd::none()
1578        }
1579
1580        fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
1581            match msg {
1582                CounterMsg::Increment => {
1583                    self.value += 1;
1584                    Cmd::none()
1585                }
1586                CounterMsg::Decrement => {
1587                    self.value -= 1;
1588                    Cmd::none()
1589                }
1590                CounterMsg::Reset => {
1591                    self.value = 0;
1592                    Cmd::none()
1593                }
1594                CounterMsg::Quit => Cmd::quit(),
1595            }
1596        }
1597
1598        fn view(&self, frame: &mut Frame) {
1599            let text = format!("Count: {}", self.value);
1600            for (i, c) in text.chars().enumerate() {
1601                if (i as u16) < frame.width() {
1602                    frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
1603                }
1604            }
1605        }
1606    }
1607
1608    fn key_event(c: char) -> Event {
1609        Event::Key(KeyEvent {
1610            code: KeyCode::Char(c),
1611            modifiers: Modifiers::empty(),
1612            kind: KeyEventKind::Press,
1613        })
1614    }
1615
1616    fn parse_single_input_event(data_json: &str) -> Event {
1617        let line = format!(
1618            r#"{{"schema_version":"{}","event":"input","ts_ns":0,"data":{}}}"#,
1619            SCHEMA_VERSION, data_json
1620        );
1621        let trace = SessionTrace::from_jsonl(&line).expect("input JSON should parse");
1622        trace
1623            .records
1624            .into_iter()
1625            .next()
1626            .and_then(|record| match record {
1627                TraceRecord::Input { event, .. } => Some(event),
1628                _ => None,
1629            })
1630            .expect("expected single input record")
1631    }
1632
1633    fn new_counter(value: i32) -> Counter {
1634        Counter { value }
1635    }
1636
1637    // ---- FNV-1a hash tests ----
1638
1639    #[test]
1640    fn fnv1a64_pair_is_deterministic() {
1641        let a = fnv1a64_pair(0, 1234);
1642        let b = fnv1a64_pair(0, 1234);
1643        assert_eq!(a, b);
1644    }
1645
1646    #[test]
1647    fn fnv1a64_pair_differs_for_different_input() {
1648        assert_ne!(fnv1a64_pair(0, 1), fnv1a64_pair(0, 2));
1649        assert_ne!(fnv1a64_pair(1, 0), fnv1a64_pair(2, 0));
1650    }
1651
1652    // ---- Recorder basic lifecycle ----
1653
1654    #[test]
1655    fn recorder_produces_header_and_summary() {
1656        let mut rec = SessionRecorder::new(new_counter(0), 80, 24, 42);
1657        rec.init().unwrap();
1658
1659        let trace = rec.finish();
1660        assert!(trace.records.len() >= 3); // header + frame + summary
1661
1662        // First record is header.
1663        assert!(matches!(
1664            &trace.records[0],
1665            TraceRecord::Header {
1666                seed: 42,
1667                cols: 80,
1668                rows: 24,
1669                ..
1670            }
1671        ));
1672
1673        // Last record is summary.
1674        assert!(matches!(
1675            trace.records.last().unwrap(),
1676            TraceRecord::Summary {
1677                total_frames: 1,
1678                ..
1679            }
1680        ));
1681    }
1682
1683    #[test]
1684    fn recorder_captures_init_frame() {
1685        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1686        rec.init().unwrap();
1687
1688        let trace = rec.finish();
1689        let frames: Vec<_> = trace
1690            .records
1691            .iter()
1692            .filter(|r| matches!(r, TraceRecord::Frame { .. }))
1693            .collect();
1694        assert_eq!(frames.len(), 1);
1695
1696        if let TraceRecord::Frame {
1697            frame_idx,
1698            checksum,
1699            ..
1700        } = &frames[0]
1701        {
1702            assert_eq!(*frame_idx, 0);
1703            assert_ne!(*checksum, 0); // Non-trivial checksum.
1704        }
1705    }
1706
1707    // ---- Record and replay ----
1708
1709    #[test]
1710    fn record_replay_identical_checksums() {
1711        // Record a session.
1712        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1713        rec.init().unwrap();
1714
1715        rec.push_event(1_000_000, key_event('+'));
1716        rec.push_event(2_000_000, key_event('+'));
1717        rec.push_event(3_000_000, key_event('-'));
1718        rec.step().unwrap();
1719
1720        rec.push_event(16_000_000, key_event('+'));
1721        rec.step().unwrap();
1722
1723        let trace = rec.finish();
1724        assert_eq!(trace.frame_count(), 3); // init + 2 steps
1725
1726        // Replay with a fresh model.
1727        let result = replay(new_counter(0), &trace).unwrap();
1728        assert!(result.ok(), "replay mismatch: {:?}", result.first_mismatch);
1729        assert_eq!(result.total_frames, 3);
1730        assert_eq!(
1731            result.final_checksum_chain,
1732            trace.final_checksum_chain().unwrap()
1733        );
1734    }
1735
1736    #[test]
1737    fn replay_detects_different_initial_state() {
1738        // Record with counter starting at 0.
1739        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1740        rec.init().unwrap();
1741        let trace = rec.finish();
1742
1743        // Replay with counter starting at 5 — different init state → different checksum.
1744        let result = replay(new_counter(5), &trace).unwrap();
1745        assert!(!result.ok());
1746        assert_eq!(result.first_mismatch.as_ref().unwrap().frame_idx, 0);
1747    }
1748
1749    #[test]
1750    fn replay_detects_divergence_after_events() {
1751        // Record with normal counter.
1752        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1753        rec.init().unwrap();
1754
1755        rec.push_event(1_000_000, key_event('+'));
1756        rec.push_event(2_000_000, key_event('+'));
1757        rec.step().unwrap();
1758
1759        let trace = rec.finish();
1760
1761        // Replay with a model that starts at 1 instead of 0.
1762        let result = replay(new_counter(1), &trace).unwrap();
1763        assert!(!result.ok());
1764    }
1765
1766    // ---- Resize recording ----
1767
1768    #[test]
1769    fn resize_is_recorded_and_replayed() {
1770        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1771        rec.init().unwrap();
1772
1773        rec.resize(5_000_000, 40, 2);
1774        rec.step().unwrap();
1775
1776        let trace = rec.finish();
1777
1778        // Verify resize record exists.
1779        assert!(trace.records.iter().any(|r| matches!(
1780            r,
1781            TraceRecord::Resize {
1782                cols: 40,
1783                rows: 2,
1784                ..
1785            }
1786        )));
1787
1788        // Replay should match.
1789        let result = replay(new_counter(0), &trace).unwrap();
1790        assert!(
1791            result.ok(),
1792            "resize replay mismatch: {:?}",
1793            result.first_mismatch
1794        );
1795    }
1796
1797    // ---- Multiple steps ----
1798
1799    #[test]
1800    fn multi_step_record_replay() {
1801        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1802        rec.init().unwrap();
1803
1804        for i in 0..5 {
1805            rec.push_event(i * 16_000_000, key_event('+'));
1806            rec.step().unwrap();
1807        }
1808
1809        let trace = rec.finish();
1810        assert_eq!(trace.frame_count(), 6); // init + 5 steps
1811
1812        let result = replay(new_counter(0), &trace).unwrap();
1813        assert!(
1814            result.ok(),
1815            "multi-step mismatch: {:?}",
1816            result.first_mismatch
1817        );
1818        assert_eq!(result.total_frames, 6);
1819    }
1820
1821    // ---- Quit during session ----
1822
1823    #[test]
1824    fn quit_stops_recording() {
1825        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1826        rec.init().unwrap();
1827
1828        rec.push_event(1_000_000, key_event('+'));
1829        rec.push_event(2_000_000, key_event('q'));
1830        let result = rec.step().unwrap();
1831        assert!(!result.running);
1832
1833        let trace = rec.finish();
1834        // init frame + no render after quit (quit stops before render).
1835        assert_eq!(trace.frame_count(), 1);
1836    }
1837
1838    // ---- Empty session ----
1839
1840    #[test]
1841    fn empty_session_replay() {
1842        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1843        rec.init().unwrap();
1844        let trace = rec.finish();
1845
1846        let result = replay(new_counter(0), &trace).unwrap();
1847        assert!(result.ok());
1848        assert_eq!(result.total_frames, 1); // Just the init frame.
1849    }
1850
1851    // ---- Trace accessors ----
1852
1853    #[test]
1854    fn session_trace_frame_count() {
1855        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1856        rec.init().unwrap();
1857        rec.push_event(1_000_000, key_event('+'));
1858        rec.step().unwrap();
1859        let trace = rec.finish();
1860        assert_eq!(trace.frame_count(), 2);
1861    }
1862
1863    #[test]
1864    fn session_trace_final_checksum_chain() {
1865        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
1866        rec.init().unwrap();
1867        let trace = rec.finish();
1868        assert!(trace.final_checksum_chain().is_some());
1869        assert_ne!(trace.final_checksum_chain().unwrap(), 0);
1870    }
1871
1872    // ---- Replay error cases ----
1873
1874    #[test]
1875    fn replay_missing_header_returns_error() {
1876        let trace = SessionTrace { records: vec![] };
1877        let result = replay(new_counter(0), &trace);
1878        assert!(matches!(result, Err(ReplayError::MissingHeader)));
1879    }
1880
1881    #[test]
1882    fn replay_non_header_first_returns_error() {
1883        let trace = SessionTrace {
1884            records: vec![TraceRecord::Tick { ts_ns: 0 }],
1885        };
1886        let result = replay(new_counter(0), &trace);
1887        assert!(matches!(result, Err(ReplayError::MissingHeader)));
1888    }
1889
1890    #[test]
1891    fn trace_validate_missing_summary_returns_typed_error() {
1892        let trace = SessionTrace {
1893            records: vec![TraceRecord::Header {
1894                seed: 0,
1895                cols: 80,
1896                rows: 24,
1897                profile: "modern".to_string(),
1898            }],
1899        };
1900        let result = trace.validate();
1901        assert_eq!(result, Err(TraceValidationError::MissingSummary));
1902    }
1903
1904    #[test]
1905    fn trace_validate_summary_frame_count_mismatch_returns_typed_error() {
1906        let trace = SessionTrace {
1907            records: vec![
1908                TraceRecord::Header {
1909                    seed: 0,
1910                    cols: 80,
1911                    rows: 24,
1912                    profile: "modern".to_string(),
1913                },
1914                TraceRecord::Frame {
1915                    frame_idx: 0,
1916                    ts_ns: 0,
1917                    checksum: 0x1,
1918                    checksum_chain: 0x10,
1919                },
1920                TraceRecord::Summary {
1921                    total_frames: 2,
1922                    final_checksum_chain: 0x10,
1923                },
1924            ],
1925        };
1926        let result = trace.validate();
1927        assert_eq!(
1928            result,
1929            Err(TraceValidationError::SummaryFrameCountMismatch {
1930                expected: 1,
1931                actual: 2,
1932            })
1933        );
1934    }
1935
1936    #[test]
1937    fn trace_validate_frame_index_gap_returns_typed_error() {
1938        let trace = SessionTrace {
1939            records: vec![
1940                TraceRecord::Header {
1941                    seed: 0,
1942                    cols: 80,
1943                    rows: 24,
1944                    profile: "modern".to_string(),
1945                },
1946                TraceRecord::Frame {
1947                    frame_idx: 1,
1948                    ts_ns: 0,
1949                    checksum: 0x1,
1950                    checksum_chain: 0x10,
1951                },
1952                TraceRecord::Summary {
1953                    total_frames: 1,
1954                    final_checksum_chain: 0x10,
1955                },
1956            ],
1957        };
1958        let result = trace.validate();
1959        assert_eq!(
1960            result,
1961            Err(TraceValidationError::FrameIndexMismatch {
1962                expected: 0,
1963                actual: 1,
1964            })
1965        );
1966    }
1967
1968    #[test]
1969    fn trace_validate_timestamp_regression_returns_typed_error() {
1970        let trace = SessionTrace {
1971            records: vec![
1972                TraceRecord::Header {
1973                    seed: 0,
1974                    cols: 80,
1975                    rows: 24,
1976                    profile: "modern".to_string(),
1977                },
1978                TraceRecord::Tick { ts_ns: 20 },
1979                TraceRecord::Tick { ts_ns: 10 },
1980                TraceRecord::Summary {
1981                    total_frames: 0,
1982                    final_checksum_chain: 0,
1983                },
1984            ],
1985        };
1986        let result = trace.validate();
1987        assert_eq!(
1988            result,
1989            Err(TraceValidationError::TimestampRegression {
1990                previous: 20,
1991                current: 10,
1992                record_index: 2,
1993            })
1994        );
1995    }
1996
1997    #[test]
1998    fn replay_validates_trace_before_execution() {
1999        let trace = SessionTrace {
2000            records: vec![TraceRecord::Header {
2001                seed: 0,
2002                cols: 80,
2003                rows: 24,
2004                profile: "modern".to_string(),
2005            }],
2006        };
2007        let result = replay(new_counter(0), &trace);
2008        assert_eq!(
2009            result,
2010            Err(ReplayError::InvalidTrace(
2011                TraceValidationError::MissingSummary
2012            ))
2013        );
2014    }
2015
2016    // ---- Determinism: same input → same trace ----
2017
2018    #[test]
2019    fn same_inputs_produce_same_trace_checksums() {
2020        fn record_session() -> SessionTrace {
2021            let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2022            rec.init().unwrap();
2023
2024            rec.push_event(1_000_000, key_event('+'));
2025            rec.push_event(2_000_000, key_event('+'));
2026            rec.push_event(3_000_000, key_event('-'));
2027            rec.step().unwrap();
2028
2029            rec.push_event(16_000_000, key_event('+'));
2030            rec.step().unwrap();
2031
2032            rec.finish()
2033        }
2034
2035        let t1 = record_session();
2036        let t2 = record_session();
2037        let t3 = record_session();
2038
2039        // All traces should have identical frame checksums.
2040        let checksums = |t: &SessionTrace| -> Vec<u64> {
2041            t.records
2042                .iter()
2043                .filter_map(|r| match r {
2044                    TraceRecord::Frame { checksum, .. } => Some(*checksum),
2045                    _ => None,
2046                })
2047                .collect()
2048        };
2049
2050        assert_eq!(checksums(&t1), checksums(&t2));
2051        assert_eq!(checksums(&t2), checksums(&t3));
2052        assert_eq!(t1.final_checksum_chain(), t2.final_checksum_chain());
2053    }
2054
2055    // ---- Mouse, paste, and focus events ----
2056
2057    #[test]
2058    fn mouse_event_record_replay() {
2059        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2060        rec.init().unwrap();
2061
2062        let mouse = Event::Mouse(MouseEvent {
2063            kind: MouseEventKind::Down(MouseButton::Left),
2064            x: 5,
2065            y: 0,
2066            modifiers: Modifiers::empty(),
2067        });
2068        rec.push_event(1_000_000, mouse);
2069        rec.step().unwrap();
2070
2071        let trace = rec.finish();
2072        let result = replay(new_counter(0), &trace).unwrap();
2073        assert!(result.ok());
2074    }
2075
2076    #[test]
2077    fn paste_event_record_replay() {
2078        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2079        rec.init().unwrap();
2080
2081        let paste = Event::Paste(PasteEvent::bracketed("hello"));
2082        rec.push_event(1_000_000, paste);
2083        rec.step().unwrap();
2084
2085        let trace = rec.finish();
2086        let result = replay(new_counter(0), &trace).unwrap();
2087        assert!(result.ok());
2088    }
2089
2090    #[test]
2091    fn focus_event_record_replay() {
2092        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2093        rec.init().unwrap();
2094
2095        rec.push_event(1_000_000, Event::Focus(true));
2096        rec.push_event(2_000_000, Event::Focus(false));
2097        rec.step().unwrap();
2098
2099        let trace = rec.finish();
2100        let result = replay(new_counter(0), &trace).unwrap();
2101        assert!(result.ok());
2102    }
2103
2104    // ---- Checksum chain integrity ----
2105
2106    #[test]
2107    fn checksum_chain_is_cumulative() {
2108        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2109        rec.init().unwrap();
2110
2111        rec.push_event(1_000_000, key_event('+'));
2112        rec.step().unwrap();
2113
2114        rec.push_event(2_000_000, key_event('+'));
2115        rec.step().unwrap();
2116
2117        let trace = rec.finish();
2118        let frame_records: Vec<_> = trace
2119            .records
2120            .iter()
2121            .filter_map(|r| match r {
2122                TraceRecord::Frame {
2123                    checksum,
2124                    checksum_chain,
2125                    ..
2126                } => Some((*checksum, *checksum_chain)),
2127                _ => None,
2128            })
2129            .collect();
2130
2131        assert_eq!(frame_records.len(), 3);
2132
2133        // Verify chain: each chain = fnv1a64_pair(prev_chain, checksum).
2134        let (c0, chain0) = frame_records[0];
2135        assert_eq!(chain0, fnv1a64_pair(0, c0));
2136
2137        let (c1, chain1) = frame_records[1];
2138        assert_eq!(chain1, fnv1a64_pair(chain0, c1));
2139
2140        let (c2, chain2) = frame_records[2];
2141        assert_eq!(chain2, fnv1a64_pair(chain1, c2));
2142
2143        // Final chain in summary matches last frame chain.
2144        assert_eq!(trace.final_checksum_chain(), Some(chain2));
2145    }
2146
2147    // ---- Recorder program accessors ----
2148
2149    #[test]
2150    fn recorder_exposes_program() {
2151        let mut rec = SessionRecorder::new(new_counter(42), 20, 1, 0);
2152        rec.init().unwrap();
2153        assert_eq!(rec.program().model().value, 42);
2154    }
2155
2156    // ---- ReplayResult and ReplayError ----
2157
2158    #[test]
2159    fn replay_result_ok_when_no_mismatch() {
2160        let r = ReplayResult {
2161            total_frames: 5,
2162            final_checksum_chain: 123,
2163            first_mismatch: None,
2164        };
2165        assert!(r.ok());
2166    }
2167
2168    #[test]
2169    fn replay_result_not_ok_when_mismatch() {
2170        let r = ReplayResult {
2171            total_frames: 5,
2172            final_checksum_chain: 123,
2173            first_mismatch: Some(ReplayMismatch {
2174                frame_idx: 2,
2175                expected: 100,
2176                actual: 200,
2177            }),
2178        };
2179        assert!(!r.ok());
2180    }
2181
2182    #[test]
2183    fn replay_error_display() {
2184        assert_eq!(
2185            ReplayError::MissingHeader.to_string(),
2186            "trace missing header record"
2187        );
2188        let invalid = ReplayError::InvalidTrace(TraceValidationError::MissingSummary);
2189        assert_eq!(
2190            invalid.to_string(),
2191            "invalid trace: trace is missing summary"
2192        );
2193        let be = ReplayError::Backend(WebBackendError::Unsupported("test"));
2194        assert!(be.to_string().contains("test"));
2195    }
2196
2197    // ---- JSONL serialization ----
2198
2199    #[test]
2200    fn trace_record_header_to_jsonl() {
2201        let r = TraceRecord::Header {
2202            seed: 42,
2203            cols: 80,
2204            rows: 24,
2205            profile: "modern".to_string(),
2206        };
2207        let line = r.to_jsonl();
2208        assert!(line.contains("\"event\":\"trace_header\""));
2209        assert!(line.contains("\"schema_version\":\"golden-trace-v1\""));
2210        assert!(line.contains("\"seed\":42"));
2211        assert!(line.contains("\"cols\":80"));
2212        assert!(line.contains("\"rows\":24"));
2213        assert!(line.contains("\"profile\":\"modern\""));
2214    }
2215
2216    #[test]
2217    fn trace_record_input_key_to_jsonl() {
2218        let r = TraceRecord::Input {
2219            ts_ns: 1_000_000,
2220            event: key_event('+'),
2221        };
2222        let line = r.to_jsonl();
2223        assert!(line.contains("\"event\":\"input\""));
2224        assert!(line.contains("\"ts_ns\":1000000"));
2225        assert!(line.contains("\"kind\":\"key\""));
2226        assert!(line.contains("\"code\":\"char:+\""));
2227    }
2228
2229    #[test]
2230    fn trace_record_resize_to_jsonl() {
2231        let r = TraceRecord::Resize {
2232            ts_ns: 5_000_000,
2233            cols: 120,
2234            rows: 40,
2235        };
2236        let line = r.to_jsonl();
2237        assert!(line.contains("\"event\":\"resize\""));
2238        assert!(line.contains("\"cols\":120"));
2239        assert!(line.contains("\"rows\":40"));
2240    }
2241
2242    #[test]
2243    fn trace_record_frame_to_jsonl() {
2244        let r = TraceRecord::Frame {
2245            frame_idx: 3,
2246            ts_ns: 48_000_000,
2247            checksum: 0xDEADBEEF,
2248            checksum_chain: 0xCAFEBABE,
2249        };
2250        let line = r.to_jsonl();
2251        assert!(line.contains("\"event\":\"frame\""));
2252        assert!(line.contains("\"frame_idx\":3"));
2253        assert!(line.contains("\"frame_hash\":\"00000000deadbeef\""));
2254        assert!(line.contains("\"checksum_chain\":\"00000000cafebabe\""));
2255    }
2256
2257    #[test]
2258    fn trace_record_summary_to_jsonl() {
2259        let r = TraceRecord::Summary {
2260            total_frames: 10,
2261            final_checksum_chain: 0x1234567890ABCDEF,
2262        };
2263        let line = r.to_jsonl();
2264        assert!(line.contains("\"event\":\"trace_summary\""));
2265        assert!(line.contains("\"total_frames\":10"));
2266        assert!(line.contains("\"final_checksum_chain\":\"1234567890abcdef\""));
2267    }
2268
2269    // ---- JSONL round-trip ----
2270
2271    #[test]
2272    fn jsonl_round_trip_full_session() {
2273        // Record a session.
2274        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 42);
2275        rec.init().unwrap();
2276        rec.push_event(1_000_000, key_event('+'));
2277        rec.push_event(2_000_000, key_event('+'));
2278        rec.step().unwrap();
2279        let trace = rec.finish();
2280
2281        // Serialize to JSONL.
2282        let jsonl = trace.to_jsonl();
2283        assert!(!jsonl.is_empty());
2284
2285        // Deserialize back.
2286        let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
2287        assert_eq!(parsed.records.len(), trace.records.len());
2288        assert_eq!(parsed.frame_count(), trace.frame_count());
2289        assert_eq!(parsed.final_checksum_chain(), trace.final_checksum_chain());
2290    }
2291
2292    #[test]
2293    fn jsonl_round_trip_preserves_events() {
2294        let events = vec![
2295            key_event('+'),
2296            key_event('-'),
2297            Event::Key(KeyEvent {
2298                code: KeyCode::Enter,
2299                modifiers: Modifiers::CTRL | Modifiers::SHIFT,
2300                kind: KeyEventKind::Press,
2301            }),
2302            Event::Key(KeyEvent {
2303                code: KeyCode::F(12),
2304                modifiers: Modifiers::ALT,
2305                kind: KeyEventKind::Repeat,
2306            }),
2307            Event::Mouse(MouseEvent {
2308                kind: MouseEventKind::Down(MouseButton::Left),
2309                x: 10,
2310                y: 5,
2311                modifiers: Modifiers::empty(),
2312            }),
2313            Event::Mouse(MouseEvent {
2314                kind: MouseEventKind::ScrollDown,
2315                x: 0,
2316                y: 0,
2317                modifiers: Modifiers::CTRL,
2318            }),
2319            Event::Paste(PasteEvent::bracketed("hello world")),
2320            Event::Focus(true),
2321            Event::Focus(false),
2322            Event::Tick,
2323        ];
2324
2325        for (i, event) in events.iter().enumerate() {
2326            let record = TraceRecord::Input {
2327                ts_ns: i as u64 * 1_000_000,
2328                event: event.clone(),
2329            };
2330            let jsonl = record.to_jsonl();
2331            let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
2332            let parsed_record = &parsed.records[0];
2333            let TraceRecord::Input {
2334                event: parsed_event,
2335                ..
2336            } = parsed_record
2337            else {
2338                unreachable!("expected Input record for event {i}");
2339            };
2340
2341            assert_eq!(parsed_event, event, "event {i} round-trip failed: {jsonl}");
2342        }
2343    }
2344
2345    #[test]
2346    fn jsonl_round_trip_with_resize() {
2347        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2348        rec.init().unwrap();
2349        rec.resize(5_000_000, 40, 2);
2350        rec.step().unwrap();
2351        let trace = rec.finish();
2352
2353        let jsonl = trace.to_jsonl();
2354        let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
2355
2356        // Replay parsed trace.
2357        let result = replay(new_counter(0), &parsed).unwrap();
2358        assert!(
2359            result.ok(),
2360            "parsed trace replay failed: {:?}",
2361            result.first_mismatch
2362        );
2363    }
2364
2365    // ---- JSONL parsing errors ----
2366
2367    #[test]
2368    fn from_jsonl_empty_is_ok() {
2369        let trace = SessionTrace::from_jsonl("").unwrap();
2370        assert!(trace.records.is_empty());
2371    }
2372
2373    #[test]
2374    fn from_jsonl_unknown_event_fails() {
2375        let line = r#"{"schema_version":"golden-trace-v1","event":"unknown_type","ts_ns":0}"#;
2376        let result = SessionTrace::from_jsonl(line);
2377        assert!(result.is_err());
2378        assert!(result.unwrap_err().message.contains("unknown event type"));
2379    }
2380
2381    #[test]
2382    fn from_jsonl_missing_event_field_fails() {
2383        let line = r#"{"schema_version":"golden-trace-v1","ts_ns":0}"#;
2384        let result = SessionTrace::from_jsonl(line);
2385        assert!(result.is_err());
2386    }
2387
2388    #[test]
2389    fn from_jsonl_missing_schema_version_fails() {
2390        let line = r#"{"event":"tick","ts_ns":0}"#;
2391        let result = SessionTrace::from_jsonl(line);
2392        assert!(result.is_err());
2393        assert!(result.unwrap_err().message.contains("schema_version"));
2394    }
2395
2396    #[test]
2397    fn from_jsonl_schema_matrix_current_writer_version_passes() {
2398        let line = format!(
2399            r#"{{"schema_version":"{}","event":"tick","ts_ns":0}}"#,
2400            SCHEMA_VERSION
2401        );
2402        let trace = SessionTrace::from_jsonl(&line).expect("matching schema should parse");
2403        assert_eq!(trace.records, vec![TraceRecord::Tick { ts_ns: 0 }]);
2404    }
2405
2406    #[test]
2407    fn from_jsonl_schema_matrix_newer_writer_version_fails_with_migration_error() {
2408        let line = r#"{"schema_version":"golden-trace-v2","event":"tick","ts_ns":0}"#;
2409        let result = SessionTrace::from_jsonl(line);
2410        assert!(result.is_err());
2411        let message = result.unwrap_err().message;
2412        assert!(message.contains("unsupported schema_version"));
2413        assert!(message.contains("migration required"));
2414    }
2415
2416    #[cfg(feature = "tracing")]
2417    #[test]
2418    fn schema_incompatibility_emits_compat_span_and_error_log() {
2419        let capture = TraceCaptureLayer::default();
2420        let subscriber =
2421            tracing_subscriber::registry().with(capture.clone().with_filter(LevelFilter::TRACE));
2422        let _guard = tracing::subscriber::set_default(subscriber);
2423
2424        let line = r#"{"schema_version":"golden-trace-v2","event":"tick","ts_ns":0}"#;
2425        let err = SessionTrace::from_jsonl(line).expect_err("newer schema should fail");
2426        assert!(err.message.contains("migration required"));
2427
2428        let spans = capture.spans.lock().expect("span capture lock");
2429        assert!(
2430            spans.iter().any(|name| name == "trace.compat_check"),
2431            "expected trace.compat_check span, got {spans:?}"
2432        );
2433        drop(spans);
2434
2435        let events = capture.events.lock().expect("event capture lock");
2436        assert!(
2437            events
2438                .iter()
2439                .any(|event| event.contains("ERROR:trace schema version incompatible")),
2440            "expected incompatible schema ERROR log, got {events:?}"
2441        );
2442    }
2443
2444    #[test]
2445    fn from_jsonl_validated_surfaces_validation_error_type() {
2446        let jsonl = TraceRecord::Header {
2447            seed: 0,
2448            cols: 80,
2449            rows: 24,
2450            profile: "modern".to_string(),
2451        }
2452        .to_jsonl();
2453        let result = SessionTrace::from_jsonl_validated(&jsonl);
2454        assert!(matches!(
2455            result,
2456            Err(TraceLoadError::Validation(
2457                TraceValidationError::MissingSummary
2458            ))
2459        ));
2460    }
2461
2462    // ---- JSON helpers ----
2463
2464    #[test]
2465    fn json_escape_round_trip() {
2466        let cases = [
2467            "hello",
2468            "with\"quotes",
2469            "back\\slash",
2470            "line\nbreak",
2471            "tab\there",
2472        ];
2473        for input in cases {
2474            let escaped = json_escape(input);
2475            let unescaped = json_unescape(&escaped);
2476            assert_eq!(unescaped, input, "round-trip failed for: {input:?}");
2477        }
2478    }
2479
2480    #[test]
2481    fn extract_str_basic() {
2482        let json = r#"{"name":"alice","age":30}"#;
2483        assert_eq!(extract_str(json, "name"), Some("alice"));
2484    }
2485
2486    #[test]
2487    fn extract_u64_basic() {
2488        let json = r#"{"count":42,"name":"test"}"#;
2489        assert_eq!(extract_u64(json, "count"), Some(42));
2490    }
2491
2492    #[test]
2493    fn extract_i64_basic() {
2494        let json = r#"{"dx":-12,"dy":7}"#;
2495        assert_eq!(extract_i64(json, "dx"), Some(-12));
2496        assert_eq!(extract_i64(json, "dy"), Some(7));
2497    }
2498
2499    #[test]
2500    fn extract_bool_basic() {
2501        let json = r#"{"enabled":true,"disabled":false}"#;
2502        assert_eq!(extract_bool(json, "enabled"), Some(true));
2503        assert_eq!(extract_bool(json, "disabled"), Some(false));
2504    }
2505
2506    #[test]
2507    fn extract_hex_u64_basic() {
2508        let json = r#"{"hash":"00000000deadbeef"}"#;
2509        assert_eq!(extract_hex_u64(json, "hash"), Some(0xDEADBEEF));
2510    }
2511
2512    #[test]
2513    fn from_jsonl_parses_frankenterm_key_schema() {
2514        let down = parse_single_input_event(
2515            r#"{"kind":"key","phase":"down","code":"F12","mods":5,"repeat":false}"#,
2516        );
2517        assert_eq!(
2518            down,
2519            Event::Key(KeyEvent {
2520                code: KeyCode::F(12),
2521                modifiers: Modifiers::SHIFT | Modifiers::CTRL,
2522                kind: KeyEventKind::Press,
2523            })
2524        );
2525
2526        let repeat = parse_single_input_event(
2527            r#"{"kind":"key","phase":"down","code":"a","mods":0,"repeat":true}"#,
2528        );
2529        assert_eq!(
2530            repeat,
2531            Event::Key(KeyEvent {
2532                code: KeyCode::Char('a'),
2533                modifiers: Modifiers::empty(),
2534                kind: KeyEventKind::Repeat,
2535            })
2536        );
2537
2538        let release = parse_single_input_event(
2539            r#"{"kind":"key","phase":"up","code":"Enter","mods":0,"repeat":false}"#,
2540        );
2541        assert_eq!(
2542            release,
2543            Event::Key(KeyEvent {
2544                code: KeyCode::Enter,
2545                modifiers: Modifiers::empty(),
2546                kind: KeyEventKind::Release,
2547            })
2548        );
2549    }
2550
2551    #[test]
2552    fn key_event_json_round_trip_unescapes_code() {
2553        let quote_key = Event::Key(KeyEvent {
2554            code: KeyCode::Char('"'),
2555            modifiers: Modifiers::empty(),
2556            kind: KeyEventKind::Press,
2557        });
2558        let quote_json = event_to_json(&quote_key);
2559        let parsed_quote = parse_event_json(&quote_json).expect("quote key should parse");
2560        assert_eq!(parsed_quote, quote_key);
2561
2562        let slash_key = Event::Key(KeyEvent {
2563            code: KeyCode::Char('\\'),
2564            modifiers: Modifiers::SHIFT,
2565            kind: KeyEventKind::Press,
2566        });
2567        let slash_json = event_to_json(&slash_key);
2568        let parsed_slash = parse_event_json(&slash_json).expect("slash key should parse");
2569        assert_eq!(parsed_slash, slash_key);
2570    }
2571
2572    #[test]
2573    fn from_jsonl_parses_frankenterm_mouse_and_wheel_schema() {
2574        let mouse = parse_single_input_event(
2575            r#"{"kind":"mouse","phase":"drag","button":2,"x":7,"y":9,"mods":3}"#,
2576        );
2577        assert_eq!(
2578            mouse,
2579            Event::Mouse(MouseEvent {
2580                kind: MouseEventKind::Drag(MouseButton::Right),
2581                x: 7,
2582                y: 9,
2583                modifiers: Modifiers::SHIFT | Modifiers::ALT,
2584            })
2585        );
2586
2587        let wheel =
2588            parse_single_input_event(r#"{"kind":"wheel","x":4,"y":6,"dx":0,"dy":-2,"mods":4}"#);
2589        assert_eq!(
2590            wheel,
2591            Event::Mouse(MouseEvent {
2592                kind: MouseEventKind::ScrollUp,
2593                x: 4,
2594                y: 6,
2595                modifiers: Modifiers::CTRL,
2596            })
2597        );
2598    }
2599
2600    #[test]
2601    fn from_jsonl_parses_frankenterm_paste_and_focus_aliases() {
2602        let paste = parse_single_input_event(r#"{"kind":"paste","data":"hello\nworld"}"#);
2603        assert_eq!(paste, Event::Paste(PasteEvent::new("hello\nworld", true)));
2604
2605        let focus = parse_single_input_event(r#"{"kind":"focus","focused":false}"#);
2606        assert_eq!(focus, Event::Focus(false));
2607    }
2608
2609    // ---- Golden Gate API ----
2610
2611    #[test]
2612    fn gate_trace_passes_on_correct_replay() {
2613        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2614        rec.init().unwrap();
2615        rec.push_event(1_000_000, key_event('+'));
2616        rec.step().unwrap();
2617        let trace = rec.finish();
2618
2619        let report = gate_trace(new_counter(0), &trace).unwrap();
2620        assert!(report.passed);
2621        assert_eq!(report.total_frames, 2);
2622        assert!(report.diff.is_none());
2623        assert!(report.format().starts_with("PASS"));
2624    }
2625
2626    #[test]
2627    fn gate_trace_fails_with_actionable_diff() {
2628        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2629        rec.init().unwrap();
2630        rec.push_event(1_000_000, key_event('+'));
2631        rec.push_event(2_000_000, key_event('+'));
2632        rec.step().unwrap();
2633        let trace = rec.finish();
2634
2635        // Replay with different initial state.
2636        let report = gate_trace(new_counter(5), &trace).unwrap();
2637        assert!(!report.passed);
2638        assert!(report.diff.is_some());
2639
2640        let diff = report.diff.as_ref().unwrap();
2641        assert_eq!(diff.frame_idx, 0); // First frame mismatch (init).
2642
2643        let formatted = report.format();
2644        assert!(formatted.starts_with("FAIL"));
2645        assert!(formatted.contains("frame 0"));
2646    }
2647
2648    #[test]
2649    fn gate_trace_diff_has_event_context() {
2650        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2651        rec.init().unwrap();
2652        rec.push_event(1_000_000, key_event('+'));
2653        rec.push_event(2_000_000, key_event('+'));
2654        rec.step().unwrap();
2655        rec.push_event(3_000_000, key_event('-'));
2656        rec.step().unwrap();
2657        let trace = rec.finish();
2658
2659        // Tamper with the trace: change a frame checksum.
2660        let mut tampered = trace.clone();
2661        for record in &mut tampered.records {
2662            if let TraceRecord::Frame {
2663                frame_idx,
2664                checksum,
2665                ..
2666            } = record
2667                && *frame_idx == 2
2668            {
2669                *checksum = 0xBAD;
2670            }
2671        }
2672
2673        let report = gate_trace(new_counter(0), &tampered).unwrap();
2674        assert!(!report.passed);
2675        let diff = report.diff.unwrap();
2676        assert_eq!(diff.frame_idx, 2);
2677        assert!(diff.event_idx > 0); // Events were processed before frame 2.
2678    }
2679
2680    // ---- JSONL → replay integration ----
2681
2682    #[test]
2683    fn jsonl_serialize_parse_replay_round_trip() {
2684        // Full pipeline: record → JSONL → parse → replay → verify.
2685        let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
2686        rec.init().unwrap();
2687
2688        for i in 0..3 {
2689            rec.push_event(i * 16_000_000, key_event('+'));
2690            rec.step().unwrap();
2691        }
2692        let original_trace = rec.finish();
2693
2694        // Serialize.
2695        let jsonl = original_trace.to_jsonl();
2696
2697        // Parse.
2698        let parsed_trace = SessionTrace::from_jsonl(&jsonl).unwrap();
2699
2700        // Replay parsed trace.
2701        let result = replay(new_counter(0), &parsed_trace).unwrap();
2702        assert!(
2703            result.ok(),
2704            "JSONL round-trip replay failed: {:?}",
2705            result.first_mismatch
2706        );
2707        assert_eq!(result.total_frames, original_trace.frame_count());
2708        assert_eq!(
2709            result.final_checksum_chain,
2710            original_trace.final_checksum_chain().unwrap()
2711        );
2712    }
2713
2714    // ---- TraceParseError ----
2715
2716    #[test]
2717    fn trace_parse_error_display() {
2718        let e = TraceParseError {
2719            line: 5,
2720            message: "bad field".to_string(),
2721        };
2722        assert_eq!(e.to_string(), "line 5: bad field");
2723    }
2724}