use std::io::BufRead;
use serde_json::{Map, Value};
use crate::InputFormat;
use crate::error::{FaceError, SkipReason, SkipReport};
use crate::input::items::{ItemsOptions, detect_items_with_options};
use crate::path;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ParsedInput {
pub items: Vec<Value>,
pub meta: Option<Map<String, Value>>,
pub items_path: String,
pub skips: Vec<SkipReport>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct ParseOptions {
pub items: ItemsOptions,
}
pub fn parse(
reader: &mut impl BufRead,
format: InputFormat,
items_path: Option<&str>,
) -> Result<ParsedInput, FaceError> {
parse_with_columns(reader, format, items_path, None)
}
pub fn parse_with_columns(
reader: &mut impl BufRead,
format: InputFormat,
items_path: Option<&str>,
columns: Option<&[String]>,
) -> Result<ParsedInput, FaceError> {
parse_with_columns_and_options(
reader,
format,
items_path,
columns,
&ParseOptions::default(),
)
}
pub fn parse_with_columns_and_options(
reader: &mut impl BufRead,
format: InputFormat,
items_path: Option<&str>,
columns: Option<&[String]>,
options: &ParseOptions,
) -> Result<ParsedInput, FaceError> {
match format {
InputFormat::Json => parse_json(reader, items_path, options),
InputFormat::Jsonl => parse_jsonl(reader, items_path),
InputFormat::Csv | InputFormat::Tsv => parse_delimited(reader, format, items_path, columns),
InputFormat::FaceEnvelope => parse_face_envelope(reader),
}
}
fn parse_json(
reader: &mut impl BufRead,
items_path: Option<&str>,
options: &ParseOptions,
) -> Result<ParsedInput, FaceError> {
let value: Value = serde_json::from_reader(reader).map_err(|e| FaceError::InputParse {
format: InputFormat::Json,
message: e.to_string(),
})?;
if let Some(path) = items_path {
let resolved = path::resolve(&value, path)?.clone();
let items = match resolved {
Value::Array(a) => a,
_ => {
return Err(FaceError::InputParse {
format: InputFormat::Json,
message: format!("items path `{path}` did not resolve to an array"),
});
}
};
return Ok(ParsedInput {
items,
meta: None,
items_path: path.to_string(),
skips: Vec::new(),
});
}
let detection = detect_items_with_options(value, &options.items)?;
Ok(ParsedInput {
items: detection.items,
meta: detection.meta,
items_path: detection.items_path,
skips: Vec::new(),
})
}
fn parse_jsonl(
reader: &mut impl BufRead,
items_path: Option<&str>,
) -> Result<ParsedInput, FaceError> {
let mut items = Vec::new();
let mut skips = Vec::new();
let mut record_index = 0usize;
let resolved_items_path = items_path
.map(str::to_string)
.unwrap_or_else(|| ".".to_string());
let mut line = String::new();
loop {
line.clear();
let read = reader.read_line(&mut line)?;
if read == 0 {
break;
}
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.trim().is_empty() {
continue;
}
match serde_json::from_str::<Value>(trimmed) {
Ok(value) => {
if let Some(path) = items_path {
match path::resolve(&value, path) {
Ok(v) => items.push(v.clone()),
Err(_) => skips.push(SkipReport {
record_index,
reason: SkipReason::MissingField {
field: path.to_string(),
},
}),
}
} else {
items.push(value);
}
}
Err(e) => skips.push(SkipReport {
record_index,
reason: SkipReason::InvalidJson {
column: e.column(),
message: e.to_string(),
},
}),
}
record_index += 1;
}
Ok(ParsedInput {
items,
meta: None,
items_path: resolved_items_path,
skips,
})
}
fn parse_delimited(
reader: &mut impl BufRead,
format: InputFormat,
items_path: Option<&str>,
columns: Option<&[String]>,
) -> Result<ParsedInput, FaceError> {
if let Some(path) = items_path {
return Err(FaceError::InputParse {
format,
message: format!("--items={path} is not supported for CSV/TSV input"),
});
}
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
if bytes.is_empty() {
return Err(FaceError::InputParse {
format,
message: "input is empty (no rows to parse)".to_string(),
});
}
let delimiter = match format {
InputFormat::Csv => detect_csv_delimiter(&bytes).unwrap_or(b','),
InputFormat::Tsv => b'\t',
_ => unreachable!("parse_delimited called only for CSV/TSV"),
};
let mut csv_reader = csv::ReaderBuilder::new()
.has_headers(false)
.delimiter(delimiter)
.flexible(false)
.from_reader(bytes.as_slice());
let mut rows = Vec::new();
for record in csv_reader.byte_records() {
let record = record.map_err(|e| FaceError::InputParse {
format,
message: e.to_string(),
})?;
rows.push(record.iter().map(decode_field).collect::<Vec<_>>());
}
if rows.is_empty() {
return Err(FaceError::InputParse {
format,
message: "input is empty (no rows to parse)".to_string(),
});
}
let (headers, data_start) = resolve_delimited_headers(&rows, columns, format)?;
let expected_width = headers.len();
let mut items = Vec::new();
for (row_index, row) in rows.iter().enumerate().skip(data_start) {
if row.len() != expected_width {
return Err(FaceError::InputParse {
format,
message: format!(
"row {} has {} columns but header has {expected_width}",
row_index + 1,
row.len()
),
});
}
let mut object = Map::new();
for (name, value) in headers.iter().zip(row) {
object.insert(name.clone(), parse_delimited_scalar(value));
}
items.push(Value::Object(object));
}
Ok(ParsedInput {
items,
meta: None,
items_path: ".".to_string(),
skips: Vec::new(),
})
}
fn resolve_delimited_headers(
rows: &[Vec<String>],
columns: Option<&[String]>,
format: InputFormat,
) -> Result<(Vec<String>, usize), FaceError> {
if let Some(columns) = columns {
if columns.is_empty() {
return Err(FaceError::InputParse {
format,
message: "--columns must include at least one column name".to_string(),
});
}
return Ok((normalize_headers(columns.iter().map(String::as_str)), 0));
}
if looks_like_header(rows) {
Ok((normalize_headers(rows[0].iter().map(String::as_str)), 1))
} else {
let width = rows.first().map(Vec::len).unwrap_or(0);
let generated = (1..=width).map(|idx| format!("column{idx}"));
Ok((generated.collect(), 0))
}
}
fn looks_like_header(rows: &[Vec<String>]) -> bool {
let Some(first) = rows.first() else {
return false;
};
if first.is_empty() || !first.iter().all(|field| !field.trim().is_empty()) {
return false;
}
let first_text = first.iter().filter(|field| !is_scalarish(field)).count();
if first_text == 0 {
return false;
}
let Some(second) = rows.get(1) else {
return true;
};
let second_text = second.iter().filter(|field| !is_scalarish(field)).count();
first_text > second_text || first.iter().any(|field| is_common_header_name(field))
}
fn is_common_header_name(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"id" | "kind"
| "type"
| "status"
| "severity"
| "score"
| "rank"
| "path"
| "file"
| "module"
| "repo"
| "name"
| "title"
| "category"
| "value"
| "count"
)
}
fn normalize_headers<'a>(headers: impl IntoIterator<Item = &'a str>) -> Vec<String> {
use std::collections::BTreeMap;
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
headers
.into_iter()
.enumerate()
.map(|(idx, raw)| {
let base = if raw.trim().is_empty() {
format!("column{}", idx + 1)
} else {
raw.trim().to_string()
};
let count = seen.entry(base.clone()).or_default();
*count += 1;
if *count == 1 {
base
} else {
format!("{base}_{}", *count)
}
})
.collect()
}
fn parse_delimited_scalar(value: &str) -> Value {
let trimmed = value.trim();
if trimmed.is_empty() {
return Value::Null;
}
if trimmed.eq_ignore_ascii_case("true") {
return Value::Bool(true);
}
if trimmed.eq_ignore_ascii_case("false") {
return Value::Bool(false);
}
if is_integer_literal(trimmed)
&& let Ok(n) = trimmed.parse::<i64>()
{
return Value::Number(n.into());
}
if let Ok(n) = trimmed.parse::<f64>()
&& n.is_finite()
&& let Some(number) = serde_json::Number::from_f64(n)
{
return Value::Number(number);
}
Value::String(value.to_string())
}
fn is_scalarish(value: &str) -> bool {
let trimmed = value.trim();
trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("false")
|| trimmed.parse::<f64>().is_ok()
}
fn is_integer_literal(value: &str) -> bool {
let rest = value.strip_prefix(['+', '-']).unwrap_or(value);
!rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
}
fn decode_field(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
}
}
fn detect_csv_delimiter(bytes: &[u8]) -> Option<u8> {
const CANDIDATES: [u8; 3] = [b',', b';', b'|'];
let lines = sample_nonempty_lines(bytes);
let mut best = None;
for delimiter in CANDIDATES {
let counts = lines
.iter()
.map(|line| count_delimiter_outside_quotes(line, delimiter))
.collect::<Vec<_>>();
let Some((&first, rest)) = counts.split_first() else {
continue;
};
if first == 0 || rest.iter().any(|count| *count != first) {
continue;
}
if best.is_none_or(|(_, best_count)| first > best_count) {
best = Some((delimiter, first));
}
}
best.map(|(delimiter, _)| delimiter)
}
fn sample_nonempty_lines(bytes: &[u8]) -> Vec<&[u8]> {
bytes
.split(|b| *b == b'\n')
.map(|line| line.strip_suffix(b"\r").unwrap_or(line))
.filter(|line| line.iter().any(|b| !b.is_ascii_whitespace()))
.take(8)
.collect()
}
fn count_delimiter_outside_quotes(line: &[u8], delimiter: u8) -> usize {
let mut count = 0usize;
let mut in_quotes = false;
let mut i = 0usize;
while let Some(&byte) = line.get(i) {
if byte == b'"' {
if in_quotes && line.get(i + 1) == Some(&b'"') {
i += 2;
continue;
}
in_quotes = !in_quotes;
} else if byte == delimiter && !in_quotes {
count += 1;
}
i += 1;
}
count
}
fn parse_face_envelope(_reader: &mut impl BufRead) -> Result<ParsedInput, FaceError> {
Err(FaceError::InputParse {
format: InputFormat::FaceEnvelope,
message: "face envelope input is handled by the CLI re-processing path".to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::BufReader;
#[test]
fn parses_json_array() {
let mut r = BufReader::new(b"[1, 2, 3]" as &[u8]);
let p = parse(&mut r, InputFormat::Json, None).unwrap();
assert_eq!(p.items, vec![json!(1), json!(2), json!(3)]);
assert!(p.meta.is_none());
assert!(p.skips.is_empty());
}
#[test]
fn parses_json_object_with_items_field() {
let mut r = BufReader::new(br#"{"items": [10, 20], "took_ms": 5}"# as &[u8]);
let p = parse(&mut r, InputFormat::Json, None).unwrap();
assert_eq!(p.items, vec![json!(10), json!(20)]);
let meta = p.meta.unwrap();
assert_eq!(meta.get("took_ms"), Some(&json!(5)));
}
#[test]
fn parses_jsonl_skips_malformed() {
let input = b"{\"a\":1}\n{this is not json}\n{\"a\":3}\n";
let mut r = BufReader::new(&input[..]);
let p = parse(&mut r, InputFormat::Jsonl, None).unwrap();
assert_eq!(p.items, vec![json!({"a": 1}), json!({"a": 3})]);
assert_eq!(p.skips.len(), 1);
assert_eq!(p.skips[0].record_index, 1);
match &p.skips[0].reason {
SkipReason::InvalidJson { .. } => {}
other => panic!("unexpected reason: {other:?}"),
}
}
#[test]
fn jsonl_skips_empty_lines_silently() {
let input = b"{\"a\":1}\n\n{\"a\":2}\n\n";
let mut r = BufReader::new(&input[..]);
let p = parse(&mut r, InputFormat::Jsonl, None).unwrap();
assert_eq!(p.items, vec![json!({"a": 1}), json!({"a": 2})]);
assert!(p.skips.is_empty());
}
#[test]
fn json_with_items_path_override() {
let mut r = BufReader::new(br#"{"hits": {"records": [1, 2, 3]}, "extra": 9}"# as &[u8]);
let p = parse(&mut r, InputFormat::Json, Some(".hits.records")).unwrap();
assert_eq!(p.items, vec![json!(1), json!(2), json!(3)]);
assert!(p.meta.is_none());
}
#[test]
fn json_unknown_items_path_errors() {
let mut r = BufReader::new(br#"{"hits": [1]}"# as &[u8]);
let err = parse(&mut r, InputFormat::Json, Some(".missing")).unwrap_err();
match err {
FaceError::UnknownItemsPath { path } => assert_eq!(path, ".missing"),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn parses_csv_with_header() {
let mut r = BufReader::new(b"a,b\n1,2\n" as &[u8]);
let parsed = parse(&mut r, InputFormat::Csv, None).unwrap();
assert_eq!(parsed.items, vec![json!({"a": 1, "b": 2})]);
assert_eq!(parsed.items_path, ".");
}
#[test]
fn face_envelope_path_returns_input_parse() {
let mut r =
BufReader::new(br#"{"result":{"input_total":0,"skipped":0,"axes":[],"detection":{"format":"json","items_path":".","score_path":null,"preset":null,"fallback_reason":null}},"meta":{},"clusters":[],"page":{"cluster_id":null,"page":0,"per_page":0,"total_items":0,"items":[]}}"# as &[u8]);
let err = parse(&mut r, InputFormat::FaceEnvelope, None).unwrap_err();
match err {
FaceError::InputParse {
format: InputFormat::FaceEnvelope,
..
} => {}
other => panic!("unexpected: {other:?}"),
}
}
}