use amberfork_ingest::{IngestError, Ingested};
use amberfork_model::Run;
use amberfork_record::Cassette;
use std::fmt;
use std::path::{Path, PathBuf};
const CASSETTE_FORMAT_URL: &str = concat!(
env!("CARGO_PKG_REPOSITORY"),
"/blob/main/docs/cassette-format.md"
);
#[derive(Debug)]
pub enum LoadError {
Io {
path: PathBuf,
source: std::io::Error,
},
Ingest(IngestError),
Cassette {
path: PathBuf,
source: serde_json::Error,
},
NotACassette { path: PathBuf },
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => {
write!(f, "failed to read trace file {}: {source}", path.display())
}
Self::Ingest(err) => write!(f, "{err}"),
Self::Cassette { path, source } => write!(
f,
"{}has a `cassette_version` but is not a valid cassette: {source}\n \
a cassette is a single JSON object with `cassette_version`, `id`, and \
`exchanges`\n \
format reference: {CASSETTE_FORMAT_URL}",
format_args!("{}: ", path.display())
),
Self::NotACassette { path } => write!(
f,
"{}: diff --verify re-executes recorded exchanges, so both runs must be cassettes \
— this is a canonical trace (no `cassette_version`)\n \
record one with `amberfork record -- <cmd>`\n \
format reference: {CASSETTE_FORMAT_URL}",
path.display()
),
}
}
}
impl std::error::Error for LoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Ingest(err) => Some(err),
Self::Cassette { source, .. } => Some(source),
Self::NotACassette { .. } => None,
}
}
}
fn is_cassette(text: &str) -> bool {
serde_json::from_str::<serde_json::Value>(text)
.is_ok_and(|value| value.get("cassette_version").is_some())
}
pub fn load_run(path: &Path) -> Result<Ingested, LoadError> {
let text = std::fs::read_to_string(path).map_err(|source| LoadError::Io {
path: path.to_path_buf(),
source,
})?;
if is_cassette(&text) {
let run: Run =
amberfork_record::normalize_str(&text).map_err(|source| LoadError::Cassette {
path: path.to_path_buf(),
source,
})?;
return Ok(Ingested {
run,
warnings: Vec::new(),
});
}
amberfork_ingest::from_json_str(&text).map_err(|err| LoadError::Ingest(err.with_path(path)))
}
pub fn load_cassette(path: &Path) -> Result<Cassette, LoadError> {
let text = std::fs::read_to_string(path).map_err(|source| LoadError::Io {
path: path.to_path_buf(),
source,
})?;
if !is_cassette(&text) {
return Err(LoadError::NotACassette {
path: path.to_path_buf(),
});
}
serde_json::from_str(&text).map_err(|source| LoadError::Cassette {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cassette_is_detected_by_its_version_key() {
assert!(is_cassette(
r#"{"cassette_version": "0.1", "id": "x", "exchanges": []}"#
));
}
#[test]
fn a_canonical_trace_is_not_a_cassette() {
assert!(!is_cassette(
r#"{"schema_version": "0.1", "id": "x", "steps": []}"#
));
}
#[test]
fn a_cassette_with_a_wrong_typed_version_still_routes_to_the_cassette_path() {
assert!(is_cassette(r#"{"cassette_version": 5, "id": "x"}"#));
}
#[test]
fn malformed_json_is_not_claimed_as_a_cassette() {
assert!(!is_cassette("{not json"));
assert!(!is_cassette("{\"a\":1}\n{\"b\":2}"));
}
fn write_temp(contents: &str) -> tempfile::NamedTempFile {
let file = tempfile::NamedTempFile::new().expect("temp file");
std::fs::write(file.path(), contents).expect("write temp");
file
}
#[test]
fn load_cassette_reads_a_recorded_cassette() {
let file = write_temp(r#"{"cassette_version": "0.1", "id": "run-7", "exchanges": []}"#);
let cassette = load_cassette(file.path()).expect("a well-formed cassette loads");
assert_eq!(cassette.id, "run-7");
}
#[test]
fn load_cassette_rejects_a_canonical_trace_as_not_a_cassette() {
let file = write_temp(r#"{"schema_version": "0.1", "id": "x", "steps": []}"#);
let err = load_cassette(file.path()).expect_err("a canonical trace is not a cassette");
assert!(matches!(err, LoadError::NotACassette { .. }));
}
#[test]
fn load_cassette_reports_a_cassette_shaped_but_broken_file_as_a_cassette_error() {
let file = write_temp(r#"{"cassette_version": "0.1", "id": 5}"#);
let err = load_cassette(file.path()).expect_err("a broken cassette body is an error");
assert!(matches!(err, LoadError::Cassette { .. }));
}
}