use std::io::Cursor;
use face_core::input::{ParsedInput, parse, parse_with_columns};
use face_core::{FaceError, InputFormat, SkipReason};
fn run(
bytes: &[u8],
format: InputFormat,
items_path: Option<&str>,
) -> Result<ParsedInput, FaceError> {
let mut reader = Cursor::new(bytes);
parse(&mut reader, format, items_path)
}
fn run_with_columns(
bytes: &[u8],
format: InputFormat,
columns: &[&str],
) -> Result<ParsedInput, FaceError> {
let mut reader = Cursor::new(bytes);
let columns = columns
.iter()
.map(|column| (*column).to_string())
.collect::<Vec<_>>();
parse_with_columns(&mut reader, format, None, Some(&columns))
}
mod json {
use super::*;
#[test]
fn top_level_array() {
let parsed =
run(br#"[{"x":1}, {"x":2}]"#, InputFormat::Json, None).expect("top-level array parses");
assert_eq!(parsed.items.len(), 2);
assert_eq!(parsed.items[0], serde_json::json!({"x": 1}));
assert_eq!(parsed.items[1], serde_json::json!({"x": 2}));
assert!(
parsed.meta.is_none(),
"top-level array carries no meta; got {:?}",
parsed.meta,
);
assert!(parsed.skips.is_empty());
}
#[test]
fn object_with_named_items() {
let parsed = run(
br#"{"items": [{"x":1}], "query": "TODO"}"#,
InputFormat::Json,
None,
)
.expect("object with named `items` parses");
assert_eq!(parsed.items.len(), 1);
assert_eq!(parsed.items[0], serde_json::json!({"x": 1}));
let meta = parsed
.meta
.as_ref()
.expect("scalar fields preserved as meta");
assert_eq!(meta.get("query"), Some(&serde_json::json!("TODO")));
}
#[test]
fn object_with_one_unnamed_array_field() {
let parsed = run(br#"{"foo": [1, 2]}"#, InputFormat::Json, None)
.expect("single unnamed array field is unambiguous");
assert_eq!(parsed.items.len(), 2);
assert_eq!(parsed.items[0], serde_json::json!(1));
assert_eq!(parsed.items[1], serde_json::json!(2));
assert!(parsed.meta.is_none());
}
#[test]
fn explicit_items_path() {
let parsed = run(
br#"{"weird": {"nested": [1, 2, 3]}}"#,
InputFormat::Json,
Some(".weird.nested"),
)
.expect("explicit items_path resolves");
assert_eq!(parsed.items.len(), 3);
assert_eq!(parsed.items[0], serde_json::json!(1));
assert_eq!(parsed.items[2], serde_json::json!(3));
}
}
mod jsonl {
use super::*;
#[test]
fn three_records_all_valid() {
let parsed = run(
b"{\"a\":1}\n{\"a\":2}\n{\"a\":3}\n",
InputFormat::Jsonl,
None,
)
.expect("three valid jsonl records parse");
assert_eq!(parsed.items.len(), 3);
assert_eq!(parsed.items[0], serde_json::json!({"a": 1}));
assert_eq!(parsed.items[2], serde_json::json!({"a": 3}));
assert!(parsed.skips.is_empty(), "no skips on all-valid input");
}
#[test]
fn skips_malformed_middle_record() {
let parsed = run(
b"{\"a\":1}\n{not json}\n{\"a\":3}\n",
InputFormat::Jsonl,
None,
)
.expect("malformed middle record is skipped, not fatal");
assert_eq!(parsed.items.len(), 2);
assert_eq!(parsed.items[0], serde_json::json!({"a": 1}));
assert_eq!(parsed.items[1], serde_json::json!({"a": 3}));
assert_eq!(parsed.skips.len(), 1, "exactly one skip recorded");
let skip = &parsed.skips[0];
match &skip.reason {
SkipReason::InvalidJson { .. } => {}
other => panic!("expected SkipReason::InvalidJson for malformed JSON, got {other:?}",),
}
assert!(
skip.record_index == 2 || skip.record_index == 1,
"record_index should reference the second record (1-based: 2, 0-based: 1); got {}",
skip.record_index,
);
}
#[test]
fn skips_blank_lines_silently() {
let parsed = run(b"{\"a\":1}\n\n{\"a\":2}\n", InputFormat::Jsonl, None)
.expect("blank lines do not break parse");
assert_eq!(parsed.items.len(), 2);
assert_eq!(
parsed.skips.len(),
0,
"blank lines should not generate skip reports; got {:?}",
parsed.skips,
);
}
}
mod delimited {
use super::*;
use serde_json::json;
#[test]
fn csv_with_header_parses_rows() {
let parsed = run(
b"kind,score,active\nbug,0.7,true\nfeat,0.2,false\n",
InputFormat::Csv,
None,
)
.expect("CSV with header parses");
assert_eq!(parsed.items_path, ".");
assert_eq!(parsed.items.len(), 2);
assert_eq!(parsed.items[0]["kind"], json!("bug"));
assert_eq!(parsed.items[0]["score"], json!(0.7));
assert_eq!(parsed.items[0]["active"], json!(true));
assert_eq!(parsed.items[1]["kind"], json!("feat"));
}
#[test]
fn tsv_with_header_parses_rows() {
let parsed = run(b"kind\tscore\nbug\t1\nfeat\t2\n", InputFormat::Tsv, None)
.expect("TSV with header parses");
assert_eq!(parsed.items.len(), 2);
assert_eq!(parsed.items[0]["kind"], json!("bug"));
assert_eq!(parsed.items[0]["score"], json!(1));
}
#[test]
fn csv_headerless_uses_generated_columns() {
let parsed =
run(b"bug,high\nfeat,low\n", InputFormat::Csv, None).expect("headerless CSV parses");
assert_eq!(parsed.items[0]["column1"], json!("bug"));
assert_eq!(parsed.items[0]["column2"], json!("high"));
}
#[test]
fn csv_headerless_uses_supplied_columns() {
let parsed = run_with_columns(b"bug,high\nfeat,low\n", InputFormat::Csv, &["kind", "sev"])
.expect("--columns names headerless CSV fields");
assert_eq!(parsed.items[0]["kind"], json!("bug"));
assert_eq!(parsed.items[0]["sev"], json!("high"));
}
#[test]
fn semicolon_csv_is_supported() {
let parsed = run(b"kind;score\nbug;3\nfeat;5\n", InputFormat::Csv, None)
.expect("semicolon CSV parses");
assert_eq!(parsed.items[1]["kind"], json!("feat"));
assert_eq!(parsed.items[1]["score"], json!(5));
}
#[test]
fn latin1_field_fallback() {
let parsed =
run(b"name\ncaf\xe9\n", InputFormat::Csv, None).expect("Latin-1 field fallback parses");
assert_eq!(parsed.items[0]["name"], json!("caf\u{e9}"));
}
}
mod face_envelope_routing {
use super::*;
#[test]
fn face_envelope_format_errors() {
let envelope = br#"{
"result": {"input_total": 0, "skipped": 0, "axes": []},
"clusters": [],
"page": {"page": 1, "per_page": 20, "total_items": 0, "items": [], "cluster_id": null}
}"#;
let err = run(envelope, InputFormat::FaceEnvelope, None)
.expect_err("FaceEnvelope format must be routed before raw parsing");
match &err {
FaceError::InputParse { .. } => {}
other => panic!(
"expected FaceError::InputParse for raw envelope routing, got {other:?} ({other})",
),
}
let msg = err.to_string().to_ascii_lowercase();
assert!(
msg.contains("envelope") || msg.contains("re-process") || msg.contains("reprocess"),
"routing error should mention envelope/re-processing; got: {err}",
);
}
}
mod items_path {
use super::*;
#[test]
fn populated_for_named_candidate() {
let parsed = run(
br#"{"items": [1, 2, 3], "took_ms": 5}"#,
InputFormat::Json,
None,
)
.expect("named items candidate parses");
assert_eq!(
parsed.items_path, ".items",
"items_path mirrors the §4.2 candidate that was selected",
);
}
#[test]
fn populated_for_top_level_array() {
let parsed = run(b"[1, 2, 3]", InputFormat::Json, None).expect("top-level array parses");
assert_eq!(
parsed.items_path, ".",
"top-level array uses `.` as the items path",
);
}
#[test]
fn populated_for_jsonl_stream() {
let parsed =
run(b"{\"a\":1}\n{\"a\":2}\n", InputFormat::Jsonl, None).expect("jsonl stream parses");
assert_eq!(
parsed.items_path, ".",
"JSONL stream uses `.` as the items path",
);
}
#[test]
fn echoes_user_supplied_path() {
let parsed = run(
br#"{"weird": {"nested": [1, 2, 3]}}"#,
InputFormat::Json,
Some(".weird.nested"),
)
.expect("explicit items path resolves");
assert_eq!(
parsed.items_path, ".weird.nested",
"user-supplied --items path is echoed in items_path",
);
}
}
mod meta_preservation {
use super::*;
#[test]
fn scalar_fields_preserved() {
let parsed = run(
br#"{"items":[1], "elapsed_ms": 42, "tool": "rg", "version": null}"#,
InputFormat::Json,
None,
)
.expect("scalar fields preserved");
assert_eq!(parsed.items, vec![serde_json::json!(1)]);
let meta = parsed.meta.as_ref().expect("scalar fields populate meta");
assert_eq!(meta.get("elapsed_ms"), Some(&serde_json::json!(42)));
assert_eq!(meta.get("tool"), Some(&serde_json::json!("rg")));
assert_eq!(meta.get("version"), Some(&serde_json::Value::Null));
assert!(
!meta.contains_key("items"),
"the items field itself should not be duplicated into meta",
);
}
#[test]
fn nested_object_in_meta() {
let parsed = run(
br#"{"items":[1], "stats": {"hits": 5}}"#,
InputFormat::Json,
None,
)
.expect("nested object preserved as meta entry");
assert_eq!(parsed.items, vec![serde_json::json!(1)]);
let meta = parsed.meta.as_ref().expect("nested object populates meta");
assert_eq!(meta.get("stats"), Some(&serde_json::json!({"hits": 5})));
}
#[test]
fn non_items_arrays_dropped() {
let parsed = run(
br#"{"items":[1], "errors": [{"code": "x"}]}"#,
InputFormat::Json,
None,
)
.expect("non-items arrays are dropped, not fatal");
assert_eq!(parsed.items, vec![serde_json::json!(1)]);
if let Some(meta) = &parsed.meta {
assert!(
!meta.contains_key("errors"),
"non-items arrays must not appear in meta; got {meta:?}",
);
}
}
}