use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::{Arc, Mutex};
use std::time::Instant;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub(crate) struct RecordEntry {
pub action: String,
pub timestamp: u64,
#[serde(flatten)]
pub params: serde_json::Map<String, Value>,
}
struct RecorderState {
active: bool,
start_time: Option<Instant>,
entries: Vec<RecordEntry>,
}
#[derive(Clone)]
pub(crate) struct Recorder {
state: Arc<Mutex<RecorderState>>,
}
impl Recorder {
pub fn new() -> Self {
Self { state: Arc::new(Mutex::new(RecorderState { active: false, start_time: None, entries: Vec::new() })) }
}
pub fn start(&self) {
let mut s = self.state.lock().expect("recorder lock poisoned");
s.active = true;
s.start_time = Some(Instant::now());
s.entries.clear();
}
pub fn stop(&self) -> Vec<RecordEntry> {
let mut s = self.state.lock().expect("recorder lock poisoned");
s.active = false;
s.start_time = None;
std::mem::take(&mut s.entries)
}
pub fn is_active(&self) -> bool {
self.state.lock().expect("recorder lock poisoned").active
}
pub fn record(&self, method: &str, params: Option<&Value>) {
if !is_recordable(method) {
return;
}
let mut s = self.state.lock().expect("recorder lock poisoned");
if !s.active {
return;
}
let timestamp = u64::try_from(s.start_time.expect("start_time must be set when active").elapsed().as_millis())
.unwrap_or(u64::MAX);
let mut map = params.and_then(|v| v.as_object().cloned()).unwrap_or_default();
map.remove("window");
s.entries.push(RecordEntry { action: method.to_string(), timestamp, params: map });
}
pub fn add_entry(&self, entry: RecordEntry) {
let mut s = self.state.lock().expect("recorder lock poisoned");
if s.active {
s.entries.push(entry);
}
}
pub fn status(&self) -> Value {
let s = self.state.lock().expect("recorder lock poisoned");
let elapsed_ms: u64 = s.start_time.map_or(0, |t| u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX));
serde_json::json!({
"active": s.active,
"count": s.entries.len(),
"elapsed_ms": elapsed_ms,
})
}
}
fn is_recordable(method: &str) -> bool {
matches!(method, "click" | "fill" | "type" | "press" | "select" | "check" | "scroll" | "drag" | "drop" | "navigate")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_start_activates_recording() {
let rec = Recorder::new();
assert!(!rec.is_active());
rec.start();
assert!(rec.is_active());
}
#[test]
fn test_stop_returns_entries_and_deactivates() {
let rec = Recorder::new();
rec.start();
rec.record("click", Some(&json!({"ref": "e1"})));
let entries = rec.stop();
assert_eq!(entries.len(), 1);
assert!(!rec.is_active());
}
#[test]
fn test_record_adds_entry_when_active() {
let rec = Recorder::new();
rec.start();
rec.record("click", Some(&json!({"ref": "e1"})));
let entries = rec.stop();
assert_eq!(entries[0].action, "click");
assert_eq!(entries[0].params.get("ref").expect("ref recorded"), "e1");
}
#[test]
fn test_record_ignores_when_inactive() {
let rec = Recorder::new();
rec.record("click", Some(&json!({"ref": "e1"})));
rec.start();
let entries = rec.stop();
assert!(entries.is_empty());
}
#[test]
fn test_record_strips_window_param() {
let rec = Recorder::new();
rec.start();
rec.record("fill", Some(&json!({"ref": "e1", "value": "hello", "window": "main"})));
let entries = rec.stop();
assert_eq!(entries.len(), 1);
assert!(!entries[0].params.contains_key("window"));
assert_eq!(entries[0].params.get("value").expect("value recorded"), "hello");
}
#[test]
fn test_timestamp_relative_to_start() {
let rec = Recorder::new();
rec.start();
std::thread::sleep(std::time::Duration::from_millis(10));
rec.record("click", Some(&json!({"ref": "e1"})));
let entries = rec.stop();
assert!(entries[0].timestamp >= 10, "timestamp should be at least 10ms");
}
#[test]
fn test_add_entry_explicit() {
let rec = Recorder::new();
rec.start();
let entry = RecordEntry {
action: "navigate".to_string(),
timestamp: 100,
params: {
let mut m = serde_json::Map::new();
m.insert("url".to_string(), json!("/home"));
m
},
};
rec.add_entry(entry);
let entries = rec.stop();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].action, "navigate");
assert_eq!(entries[0].timestamp, 100);
}
#[test]
fn test_status_reports_correctly() {
let rec = Recorder::new();
let status = rec.status();
assert_eq!(status["active"], false);
assert_eq!(status["count"], 0);
rec.start();
rec.record("click", Some(&json!({"ref": "e1"})));
let status = rec.status();
assert_eq!(status["active"], true);
assert_eq!(status["count"], 1);
rec.stop();
let status = rec.status();
assert_eq!(status["active"], false);
assert_eq!(status["count"], 0);
assert_eq!(status["elapsed_ms"], 0);
}
#[test]
fn test_add_entry_ignores_when_inactive() {
let rec = Recorder::new();
let entry = RecordEntry { action: "click".to_string(), timestamp: 0, params: serde_json::Map::new() };
rec.add_entry(entry);
rec.start();
let entries = rec.stop();
assert!(entries.is_empty());
}
#[test]
fn test_record_ignores_non_recordable_method() {
let rec = Recorder::new();
rec.start();
rec.record("snapshot", None);
rec.record("ping", None);
rec.record("eval", None);
let entries = rec.stop();
assert!(entries.is_empty());
}
}