use amberfork_ingest::{IngestError, from_json_str, load_file};
use amberfork_model::{Outcome, Payload, StepKind, WarningCode};
const MINIMAL_TRACE: &str = r#"{
"schema_version": "0.1",
"id": "minimal",
"outcome": "fail",
"steps": [
{ "idx": 0, "kind": "llm", "name": "planner", "outputs": "hi", "attrs": {} }
]
}"#;
#[test]
fn clean_trace_loads_with_no_warnings() {
let ingested = from_json_str(MINIMAL_TRACE).unwrap();
assert!(ingested.warnings.is_empty());
assert_eq!(ingested.run.id, "minimal");
assert_eq!(ingested.run.outcome, Some(Outcome::Fail));
let step = &ingested.run.steps[0];
assert_eq!(step.kind, StepKind::Llm);
assert_eq!(step.outputs, Some(Payload::Text("hi".to_string())));
}
#[test]
fn unmapped_step_fields_move_to_attrs_and_warn() {
let json = r#"{
"schema_version": "0.1",
"id": "extra",
"steps": [{
"idx": 0, "kind": "tool", "name": "web.search", "outputs": "ok",
"attrs": { "model": "x" },
"retries": 2, "latency_ms": 40
}]
}"#;
let ingested = from_json_str(json).unwrap();
let step = &ingested.run.steps[0];
assert_eq!(step.attrs.get("retries").and_then(|v| v.as_i64()), Some(2));
assert_eq!(
step.attrs.get("latency_ms").and_then(|v| v.as_i64()),
Some(40)
);
assert_eq!(step.attrs.get("model").and_then(|v| v.as_str()), Some("x"));
assert_eq!(ingested.warnings.len(), 1);
let warning = &ingested.warnings[0];
assert_eq!(warning.code, WarningCode::UnmappedAttributes);
assert!(
warning.msg.contains("latency_ms, retries"),
"got: {}",
warning.msg
);
}
#[test]
fn content_absent_step_warns_but_still_loads() {
let json = r#"{
"schema_version": "0.1",
"id": "meta-only",
"steps": [{ "idx": 0, "kind": "agent", "name": "orchestrator" }]
}"#;
let ingested = from_json_str(json).unwrap();
assert_eq!(ingested.run.steps.len(), 1);
assert_eq!(ingested.warnings.len(), 1);
assert_eq!(ingested.warnings[0].code, WarningCode::ContentAbsent);
}
#[test]
fn run_level_unmapped_field_warns() {
let json = r#"{
"schema_version": "0.1",
"id": "runextra",
"provenance": "otel-collector-1",
"steps": [{ "idx": 0, "kind": "llm", "name": "a", "outputs": "x" }]
}"#;
let ingested = from_json_str(json).unwrap();
assert_eq!(ingested.warnings.len(), 1);
assert_eq!(ingested.warnings[0].code, WarningCode::UnmappedAttributes);
assert!(ingested.warnings[0].msg.contains("provenance"));
}
#[test]
fn foreign_schema_version_warns_but_still_loads() {
let json = r#"{
"schema_version": "9.9",
"id": "future",
"steps": [{ "idx": 0, "kind": "llm", "name": "a", "outputs": "x" }]
}"#;
let ingested = from_json_str(json).unwrap();
assert_eq!(ingested.run.steps.len(), 1, "permissive: still loads");
let mismatch = ingested
.warnings
.iter()
.find(|w| w.code == WarningCode::SchemaVersionMismatch)
.expect("a foreign schema_version must warn");
assert!(mismatch.msg.contains("9.9"), "got: {}", mismatch.msg);
}
#[test]
fn malformed_json_is_a_parse_error() {
let err = from_json_str("{ not json").unwrap_err();
assert!(matches!(err, IngestError::Parse { .. }));
assert!(std::error::Error::source(&err).is_some());
}
#[test]
fn valid_json_that_is_not_a_trace_says_what_a_trace_needs() {
let err = from_json_str(r#"{"hello": "world"}"#).unwrap_err();
assert!(matches!(err, IngestError::NotATrace { .. }));
let msg = err.to_string();
assert!(
msg.contains("missing field `schema_version`"),
"keeps serde's detail: {msg}"
);
assert!(
msg.contains("docs/trace-format.md"),
"points to the format reference: {msg}"
);
assert!(
msg.contains("docs/run-on-your-own-agent.md"),
"points to the conversion guide: {msg}"
);
}
#[test]
fn json_lines_input_is_named_and_points_to_the_conversion_guide() {
let jsonl = "{\"role\": \"assistant\", \"content\": \"hi\"}\n{\"role\": \"tool\", \"name\": \"web.search\"}\n";
let err = from_json_str(jsonl).unwrap_err();
assert!(matches!(err, IngestError::JsonLines { .. }));
let msg = err.to_string();
assert!(msg.contains("JSON-Lines"), "names the shape: {msg}");
assert!(
msg.contains("docs/run-on-your-own-agent.md"),
"points to the conversion guide: {msg}"
);
}
#[test]
fn pretty_printed_wrong_shape_is_not_mistaken_for_json_lines() {
let err = from_json_str("{\n \"hello\": \"world\"\n}").unwrap_err();
assert!(matches!(err, IngestError::NotATrace { .. }));
}
#[test]
fn non_canonical_kind_is_not_a_trace() {
let json = r#"{
"schema_version": "0.1", "id": "k",
"steps": [{ "idx": 0, "kind": "chain", "name": "a", "outputs": "x" }]
}"#;
let err = from_json_str(json).unwrap_err();
assert!(matches!(err, IngestError::NotATrace { .. }));
assert!(err.to_string().contains("chain"), "got: {err}");
}
#[test]
fn load_file_reads_a_trace_from_disk() {
let path = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("load_file_reads.json");
std::fs::write(&path, MINIMAL_TRACE).unwrap();
let ingested = load_file(&path).unwrap();
assert_eq!(ingested.run.id, "minimal");
assert_eq!(ingested.run.steps.len(), 1);
}
#[test]
fn load_file_parse_errors_name_the_offending_file() {
let path = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("raw_transcript.jsonl");
std::fs::write(&path, "{\"a\": 1}\n{\"a\": 2}\n").unwrap();
let err = load_file(&path).unwrap_err();
assert!(matches!(err, IngestError::JsonLines { .. }));
assert!(
err.to_string().contains("raw_transcript.jsonl"),
"names which input failed: {err}"
);
}
#[test]
fn missing_file_is_an_io_error() {
let path = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("does-not-exist.json");
let err = load_file(&path).unwrap_err();
match err {
IngestError::Io { path: p, .. } => assert!(p.ends_with("does-not-exist.json")),
other => panic!("expected Io error, got {other:?}"),
}
}