use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub struct CastInfo {
pub version: u8,
pub duration: Option<f64>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum CastError {
#[error("not an asciicast: the file starts with neither an asciicast header nor a v1 JSON document")]
NotAsciicast,
#[error("asciicast v{0} is not supported (v1, v2, and v3 are)")]
UnsupportedVersion(u64),
}
pub fn inspect(content: &str) -> Result<CastInfo, CastError> {
let first_line = content.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
let header: Value =
serde_json::from_str(first_line).or_else(|_| serde_json::from_str(content)).map_err(|_| CastError::NotAsciicast)?;
let version = header.get("version").and_then(Value::as_u64).ok_or(CastError::NotAsciicast)?;
match version {
1 => Ok(CastInfo { version: 1, duration: header.get("duration").and_then(Value::as_f64) }),
2 | 3 => Ok(CastInfo { version: version as u8, duration: ndjson_duration(content, version == 3) }),
other => Err(CastError::UnsupportedVersion(other)),
}
}
fn ndjson_duration(content: &str, relative: bool) -> Option<f64> {
let mut last = 0.0f64;
let mut sum = 0.0f64;
for line in content.lines().skip(1) {
let Ok(Value::Array(items)) = serde_json::from_str(line.trim()) else { continue };
let Some(t) = items.first().and_then(Value::as_f64) else { continue };
last = t;
sum += t;
}
Some(if relative { sum } else { last })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inspects_v2_with_absolute_times() {
let cast = "{\"version\":2,\"width\":80,\"height\":24}\n[0.5,\"o\",\"a\"]\n[2.0,\"o\",\"b\"]\n";
assert_eq!(inspect(cast).unwrap(), CastInfo { version: 2, duration: Some(2.0) });
}
#[test]
fn inspects_v3_with_relative_intervals() {
let cast =
"{\"version\":3,\"term\":{\"cols\":80,\"rows\":24}}\n[0.5,\"o\",\"a\"]\n[1.0,\"o\",\"b\"]\n[1.5,\"o\",\"c\"]\n";
assert_eq!(inspect(cast).unwrap(), CastInfo { version: 3, duration: Some(3.0) });
}
#[test]
fn inspects_v1_whole_document() {
let cast = "{\n \"version\": 1,\n \"duration\": 7.5,\n \"width\": 80,\n \"height\": 24,\n \"stdout\": []\n}";
assert_eq!(inspect(cast).unwrap(), CastInfo { version: 1, duration: Some(7.5) });
}
#[test]
fn tolerates_a_truncated_trailing_line() {
let cast = "{\"version\":3,\"term\":{\"cols\":80,\"rows\":24}}\n[0.5,\"o\",\"a\"]\n[1.0,\"o\",\"tru";
assert_eq!(inspect(cast).unwrap(), CastInfo { version: 3, duration: Some(0.5) });
}
#[test]
fn rejects_junk_and_unknown_versions() {
assert_eq!(inspect("hello world"), Err(CastError::NotAsciicast));
assert_eq!(inspect("{\"no_version\":true}"), Err(CastError::NotAsciicast));
assert_eq!(inspect("{\"version\":9}"), Err(CastError::UnsupportedVersion(9)));
}
}