use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapturedRow {
pub at: u64,
#[serde(alias = "requestId")]
pub request_id: String,
pub method: String,
pub url: String,
#[serde(default)]
pub headers: Vec<(String, String)>,
pub body: Option<String>,
pub paused: bool,
}
impl CapturedRow {
pub fn to_curl(&self) -> String {
let mut parts: Vec<String> = vec![format!("curl -X {} '{}'", self.method, self.url)];
for (k, v) in &self.headers {
if k.starts_with(':') {
continue;
}
parts.push(format!(" -H '{}: {}'", k, escape_single(v)));
}
if let Some(body) = &self.body {
parts.push(format!(" --data-raw '{}'", escape_single(body)));
}
parts.join(" \\\n")
}
}
fn escape_single(s: &str) -> String {
s.replace('\'', "'\\''")
}
pub fn load(path: &Path) -> Vec<CapturedRow> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(_) => return Vec::new(),
};
text.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<CapturedRow>(l).ok())
.collect()
}
pub fn load_tail(path: &Path, n: usize) -> Vec<CapturedRow> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(_) => return Vec::new(),
};
let mut out: Vec<CapturedRow> = text
.lines()
.rev()
.filter(|l| !l.trim().is_empty())
.take(n)
.filter_map(|l| serde_json::from_str::<CapturedRow>(l).ok())
.collect();
out.reverse();
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"rqst-captured-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn load_returns_empty_when_missing() {
assert!(load(Path::new("/nonexistent/rqst/captured.log")).is_empty());
}
#[test]
fn load_skips_garbage_lines() {
let p = tmp("garbage");
std::fs::write(
&p,
"{\"at\":1,\"request_id\":\"r1\",\"method\":\"GET\",\"url\":\"https://x\",\"paused\":false}\nthis is not json\n",
)
.unwrap();
let rows = load(&p);
let _ = std::fs::remove_file(&p);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].request_id, "r1");
}
#[test]
fn to_curl_round_trips_headers_and_body() {
let row = CapturedRow {
at: 0,
request_id: "r1".into(),
method: "POST".into(),
url: "https://api/x".into(),
headers: vec![
("content-type".into(), "application/json".into()),
("authorization".into(), "Bearer abc".into()),
(":authority".into(), "api".into()), ],
body: Some("{\"hello\":\"world\"}".into()),
paused: false,
};
let s = row.to_curl();
assert!(s.starts_with("curl -X POST 'https://api/x'"));
assert!(s.contains("-H 'content-type: application/json'"));
assert!(s.contains("-H 'authorization: Bearer abc'"));
assert!(!s.contains(":authority"));
assert!(s.contains("--data-raw '{\"hello\":\"world\"}'"));
}
#[test]
fn to_curl_escapes_single_quotes_in_body() {
let row = CapturedRow {
at: 0,
request_id: "r1".into(),
method: "POST".into(),
url: "https://api".into(),
headers: vec![],
body: Some("it's mine".into()),
paused: false,
};
let s = row.to_curl();
assert!(s.contains("it'\\''s mine"));
}
}