use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
pub const HEADER_PHASE: &str = "recording";
pub const TURN_PHASE: &str = "turn";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Provenance {
pub router_version: String,
pub provider: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Turn {
pub index: usize,
pub request: Value,
pub status: u16,
pub headers: Vec<(String, String)>,
pub events: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct Recording {
pub provenance: Provenance,
pub turns: Vec<Turn>,
}
#[derive(Debug)]
pub enum LoadError {
Unreadable(std::io::Error),
MissingHeader,
}
impl std::fmt::Display for LoadError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unreadable(error) => write!(formatter, "recording could not be read: {error}"),
Self::MissingHeader => formatter
.write_str("recording has no header line stating the router version and provider"),
}
}
}
impl std::error::Error for LoadError {}
#[must_use]
pub fn header_line(provenance: &Provenance) -> String {
encode(&json!({
"phase": HEADER_PHASE,
"router_version": provenance.router_version,
"provider": provenance.provider,
}))
}
#[must_use]
pub fn turn_line(turn: &Turn) -> String {
encode(&json!({
"phase": TURN_PHASE,
"turn": turn.index,
"request": turn.request,
"response": {
"status": turn.status,
"headers": turn
.headers
.iter()
.map(|(name, value)| json!([name, value]))
.collect::<Vec<_>>(),
"events": turn.events,
},
}))
}
fn encode(value: &Value) -> String {
crate::lino_json::encode_line(value).unwrap_or_default()
}
pub fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")
}
pub fn load(path: &PathBuf) -> Result<Recording, LoadError> {
let text = std::fs::read_to_string(path).map_err(LoadError::Unreadable)?;
let mut provenance = None;
let mut turns = Vec::new();
for line in text.lines() {
let Some(record) = crate::lino_json::decode_line(line) else {
continue;
};
match record.get("phase").and_then(Value::as_str) {
Some(HEADER_PHASE) => provenance = Some(read_provenance(&record)),
Some(TURN_PHASE) => {
if let Some(turn) = read_turn(&record, turns.len() + 1) {
turns.push(turn);
}
}
_ => {}
}
}
provenance
.map(|provenance| Recording { provenance, turns })
.ok_or(LoadError::MissingHeader)
}
fn read_provenance(record: &Value) -> Provenance {
Provenance {
router_version: string_field(record, "router_version"),
provider: string_field(record, "provider"),
}
}
fn string_field(record: &Value, name: &str) -> String {
record
.get(name)
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string()
}
fn read_turn(record: &Value, position: usize) -> Option<Turn> {
let request = record.get("request")?.clone();
let response = record.get("response")?;
let status = u16::try_from(response.get("status")?.as_u64()?).ok()?;
let headers = response
.get("headers")
.and_then(Value::as_array)
.map(|pairs| {
pairs
.iter()
.filter_map(|pair| {
let pair = pair.as_array()?;
Some((
pair.first()?.as_str()?.to_string(),
pair.get(1)?.as_str()?.to_string(),
))
})
.collect()
})
.unwrap_or_default();
let events = response
.get("events")
.and_then(Value::as_array)
.map(|events| {
events
.iter()
.filter_map(|event| event.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Some(Turn {
index: position,
request,
status,
headers,
events,
})
}