1use std::io::BufRead;
9
10use serde_json::{Map, Value};
11
12use crate::InputFormat;
13use crate::error::{FaceError, SkipReason, SkipReport};
14use crate::input::items::{ItemsOptions, detect_items_with_options};
15use crate::path;
16
17#[derive(Debug, Clone)]
22#[non_exhaustive]
23pub struct ParsedInput {
24 pub items: Vec<Value>,
26 pub meta: Option<Map<String, Value>>,
30 pub items_path: String,
41 pub skips: Vec<SkipReport>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Default)]
47#[non_exhaustive]
48pub struct ParseOptions {
49 pub items: ItemsOptions,
51}
52
53pub fn parse(
84 reader: &mut impl BufRead,
85 format: InputFormat,
86 items_path: Option<&str>,
87) -> Result<ParsedInput, FaceError> {
88 parse_with_columns(reader, format, items_path, None)
89}
90
91pub fn parse_with_columns(
94 reader: &mut impl BufRead,
95 format: InputFormat,
96 items_path: Option<&str>,
97 columns: Option<&[String]>,
98) -> Result<ParsedInput, FaceError> {
99 parse_with_columns_and_options(
100 reader,
101 format,
102 items_path,
103 columns,
104 &ParseOptions::default(),
105 )
106}
107
108pub fn parse_with_columns_and_options(
110 reader: &mut impl BufRead,
111 format: InputFormat,
112 items_path: Option<&str>,
113 columns: Option<&[String]>,
114 options: &ParseOptions,
115) -> Result<ParsedInput, FaceError> {
116 match format {
117 InputFormat::Json => parse_json(reader, items_path, options),
118 InputFormat::Jsonl => parse_jsonl(reader, items_path),
119 InputFormat::Csv | InputFormat::Tsv => parse_delimited(reader, format, items_path, columns),
120 InputFormat::FaceEnvelope => parse_face_envelope(reader),
121 }
122}
123
124fn parse_json(
126 reader: &mut impl BufRead,
127 items_path: Option<&str>,
128 options: &ParseOptions,
129) -> Result<ParsedInput, FaceError> {
130 let value: Value = serde_json::from_reader(reader).map_err(|e| FaceError::InputParse {
131 format: InputFormat::Json,
132 message: e.to_string(),
133 })?;
134
135 if let Some(path) = items_path {
136 let resolved = path::resolve(&value, path)?.clone();
137 let items = match resolved {
138 Value::Array(a) => a,
139 _ => {
140 return Err(FaceError::InputParse {
141 format: InputFormat::Json,
142 message: format!("items path `{path}` did not resolve to an array"),
143 });
144 }
145 };
146 return Ok(ParsedInput {
150 items,
151 meta: None,
152 items_path: path.to_string(),
153 skips: Vec::new(),
154 });
155 }
156
157 let detection = detect_items_with_options(value, &options.items)?;
158 Ok(ParsedInput {
159 items: detection.items,
160 meta: detection.meta,
161 items_path: detection.items_path,
162 skips: Vec::new(),
163 })
164}
165
166fn parse_jsonl(
174 reader: &mut impl BufRead,
175 items_path: Option<&str>,
176) -> Result<ParsedInput, FaceError> {
177 let mut items = Vec::new();
178 let mut skips = Vec::new();
179 let mut record_index = 0usize;
180 let resolved_items_path = items_path
184 .map(str::to_string)
185 .unwrap_or_else(|| ".".to_string());
186
187 let mut line = String::new();
188 loop {
189 line.clear();
190 let read = reader.read_line(&mut line)?;
191 if read == 0 {
192 break;
193 }
194 let trimmed = line.trim_end_matches(['\n', '\r']);
197 if trimmed.trim().is_empty() {
198 continue;
199 }
200
201 match serde_json::from_str::<Value>(trimmed) {
202 Ok(value) => {
203 if let Some(path) = items_path {
204 match path::resolve(&value, path) {
205 Ok(v) => items.push(v.clone()),
206 Err(_) => skips.push(SkipReport {
207 record_index,
208 reason: SkipReason::MissingField {
209 field: path.to_string(),
210 },
211 }),
212 }
213 } else {
214 items.push(value);
215 }
216 }
217 Err(e) => skips.push(SkipReport {
218 record_index,
219 reason: SkipReason::InvalidJson {
220 column: e.column(),
221 message: e.to_string(),
222 },
223 }),
224 }
225
226 record_index += 1;
227 }
228
229 Ok(ParsedInput {
230 items,
231 meta: None,
232 items_path: resolved_items_path,
233 skips,
234 })
235}
236
237fn parse_delimited(
238 reader: &mut impl BufRead,
239 format: InputFormat,
240 items_path: Option<&str>,
241 columns: Option<&[String]>,
242) -> Result<ParsedInput, FaceError> {
243 if let Some(path) = items_path {
244 return Err(FaceError::InputParse {
245 format,
246 message: format!("--items={path} is not supported for CSV/TSV input"),
247 });
248 }
249
250 let mut bytes = Vec::new();
251 reader.read_to_end(&mut bytes)?;
252 if bytes.is_empty() {
253 return Err(FaceError::InputParse {
254 format,
255 message: "input is empty (no rows to parse)".to_string(),
256 });
257 }
258
259 let delimiter = match format {
260 InputFormat::Csv => detect_csv_delimiter(&bytes).unwrap_or(b','),
261 InputFormat::Tsv => b'\t',
262 _ => unreachable!("parse_delimited called only for CSV/TSV"),
263 };
264
265 let mut csv_reader = csv::ReaderBuilder::new()
266 .has_headers(false)
267 .delimiter(delimiter)
268 .flexible(false)
269 .from_reader(bytes.as_slice());
270
271 let mut rows = Vec::new();
272 for record in csv_reader.byte_records() {
273 let record = record.map_err(|e| FaceError::InputParse {
274 format,
275 message: e.to_string(),
276 })?;
277 rows.push(record.iter().map(decode_field).collect::<Vec<_>>());
278 }
279
280 if rows.is_empty() {
281 return Err(FaceError::InputParse {
282 format,
283 message: "input is empty (no rows to parse)".to_string(),
284 });
285 }
286
287 let (headers, data_start) = resolve_delimited_headers(&rows, columns, format)?;
288 let expected_width = headers.len();
289 let mut items = Vec::new();
290 for (row_index, row) in rows.iter().enumerate().skip(data_start) {
291 if row.len() != expected_width {
292 return Err(FaceError::InputParse {
293 format,
294 message: format!(
295 "row {} has {} columns but header has {expected_width}",
296 row_index + 1,
297 row.len()
298 ),
299 });
300 }
301 let mut object = Map::new();
302 for (name, value) in headers.iter().zip(row) {
303 object.insert(name.clone(), parse_delimited_scalar(value));
304 }
305 items.push(Value::Object(object));
306 }
307
308 Ok(ParsedInput {
309 items,
310 meta: None,
311 items_path: ".".to_string(),
312 skips: Vec::new(),
313 })
314}
315
316fn resolve_delimited_headers(
317 rows: &[Vec<String>],
318 columns: Option<&[String]>,
319 format: InputFormat,
320) -> Result<(Vec<String>, usize), FaceError> {
321 if let Some(columns) = columns {
322 if columns.is_empty() {
323 return Err(FaceError::InputParse {
324 format,
325 message: "--columns must include at least one column name".to_string(),
326 });
327 }
328 return Ok((normalize_headers(columns.iter().map(String::as_str)), 0));
329 }
330
331 if looks_like_header(rows) {
332 Ok((normalize_headers(rows[0].iter().map(String::as_str)), 1))
333 } else {
334 let width = rows.first().map(Vec::len).unwrap_or(0);
335 let generated = (1..=width).map(|idx| format!("column{idx}"));
336 Ok((generated.collect(), 0))
337 }
338}
339
340fn looks_like_header(rows: &[Vec<String>]) -> bool {
341 let Some(first) = rows.first() else {
342 return false;
343 };
344 if first.is_empty() || !first.iter().all(|field| !field.trim().is_empty()) {
345 return false;
346 }
347
348 let first_text = first.iter().filter(|field| !is_scalarish(field)).count();
349 if first_text == 0 {
350 return false;
351 }
352
353 let Some(second) = rows.get(1) else {
354 return true;
355 };
356 let second_text = second.iter().filter(|field| !is_scalarish(field)).count();
357 first_text > second_text || first.iter().any(|field| is_common_header_name(field))
358}
359
360fn is_common_header_name(value: &str) -> bool {
361 matches!(
362 value.trim().to_ascii_lowercase().as_str(),
363 "id" | "kind"
364 | "type"
365 | "status"
366 | "severity"
367 | "score"
368 | "rank"
369 | "path"
370 | "file"
371 | "module"
372 | "repo"
373 | "name"
374 | "title"
375 | "category"
376 | "value"
377 | "count"
378 )
379}
380
381fn normalize_headers<'a>(headers: impl IntoIterator<Item = &'a str>) -> Vec<String> {
382 use std::collections::BTreeMap;
383
384 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
385 headers
386 .into_iter()
387 .enumerate()
388 .map(|(idx, raw)| {
389 let base = if raw.trim().is_empty() {
390 format!("column{}", idx + 1)
391 } else {
392 raw.trim().to_string()
393 };
394 let count = seen.entry(base.clone()).or_default();
395 *count += 1;
396 if *count == 1 {
397 base
398 } else {
399 format!("{base}_{}", *count)
400 }
401 })
402 .collect()
403}
404
405fn parse_delimited_scalar(value: &str) -> Value {
406 let trimmed = value.trim();
407 if trimmed.is_empty() {
408 return Value::Null;
409 }
410 if trimmed.eq_ignore_ascii_case("true") {
411 return Value::Bool(true);
412 }
413 if trimmed.eq_ignore_ascii_case("false") {
414 return Value::Bool(false);
415 }
416 if is_integer_literal(trimmed)
417 && let Ok(n) = trimmed.parse::<i64>()
418 {
419 return Value::Number(n.into());
420 }
421 if let Ok(n) = trimmed.parse::<f64>()
422 && n.is_finite()
423 && let Some(number) = serde_json::Number::from_f64(n)
424 {
425 return Value::Number(number);
426 }
427 Value::String(value.to_string())
428}
429
430fn is_scalarish(value: &str) -> bool {
431 let trimmed = value.trim();
432 trimmed.is_empty()
433 || trimmed.eq_ignore_ascii_case("true")
434 || trimmed.eq_ignore_ascii_case("false")
435 || trimmed.parse::<f64>().is_ok()
436}
437
438fn is_integer_literal(value: &str) -> bool {
439 let rest = value.strip_prefix(['+', '-']).unwrap_or(value);
440 !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
441}
442
443fn decode_field(bytes: &[u8]) -> String {
444 match std::str::from_utf8(bytes) {
445 Ok(s) => s.to_string(),
446 Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
447 }
448}
449
450fn detect_csv_delimiter(bytes: &[u8]) -> Option<u8> {
451 const CANDIDATES: [u8; 3] = [b',', b';', b'|'];
452 let lines = sample_nonempty_lines(bytes);
453 let mut best = None;
454 for delimiter in CANDIDATES {
455 let counts = lines
456 .iter()
457 .map(|line| count_delimiter_outside_quotes(line, delimiter))
458 .collect::<Vec<_>>();
459 let Some((&first, rest)) = counts.split_first() else {
460 continue;
461 };
462 if first == 0 || rest.iter().any(|count| *count != first) {
463 continue;
464 }
465 if best.is_none_or(|(_, best_count)| first > best_count) {
466 best = Some((delimiter, first));
467 }
468 }
469 best.map(|(delimiter, _)| delimiter)
470}
471
472fn sample_nonempty_lines(bytes: &[u8]) -> Vec<&[u8]> {
473 bytes
474 .split(|b| *b == b'\n')
475 .map(|line| line.strip_suffix(b"\r").unwrap_or(line))
476 .filter(|line| line.iter().any(|b| !b.is_ascii_whitespace()))
477 .take(8)
478 .collect()
479}
480
481fn count_delimiter_outside_quotes(line: &[u8], delimiter: u8) -> usize {
482 let mut count = 0usize;
483 let mut in_quotes = false;
484 let mut i = 0usize;
485 while let Some(&byte) = line.get(i) {
486 if byte == b'"' {
487 if in_quotes && line.get(i + 1) == Some(&b'"') {
488 i += 2;
489 continue;
490 }
491 in_quotes = !in_quotes;
492 } else if byte == delimiter && !in_quotes {
493 count += 1;
494 }
495 i += 1;
496 }
497 count
498}
499
500fn parse_face_envelope(_reader: &mut impl BufRead) -> Result<ParsedInput, FaceError> {
507 Err(FaceError::InputParse {
508 format: InputFormat::FaceEnvelope,
509 message: "face envelope input is handled by the CLI re-processing path".to_string(),
510 })
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use serde_json::json;
517 use std::io::BufReader;
518
519 #[test]
520 fn parses_json_array() {
521 let mut r = BufReader::new(b"[1, 2, 3]" as &[u8]);
522 let p = parse(&mut r, InputFormat::Json, None).unwrap();
523 assert_eq!(p.items, vec![json!(1), json!(2), json!(3)]);
524 assert!(p.meta.is_none());
525 assert!(p.skips.is_empty());
526 }
527
528 #[test]
529 fn parses_json_object_with_items_field() {
530 let mut r = BufReader::new(br#"{"items": [10, 20], "took_ms": 5}"# as &[u8]);
531 let p = parse(&mut r, InputFormat::Json, None).unwrap();
532 assert_eq!(p.items, vec![json!(10), json!(20)]);
533 let meta = p.meta.unwrap();
534 assert_eq!(meta.get("took_ms"), Some(&json!(5)));
535 }
536
537 #[test]
538 fn parses_jsonl_skips_malformed() {
539 let input = b"{\"a\":1}\n{this is not json}\n{\"a\":3}\n";
540 let mut r = BufReader::new(&input[..]);
541 let p = parse(&mut r, InputFormat::Jsonl, None).unwrap();
542 assert_eq!(p.items, vec![json!({"a": 1}), json!({"a": 3})]);
543 assert_eq!(p.skips.len(), 1);
544 assert_eq!(p.skips[0].record_index, 1);
545 match &p.skips[0].reason {
546 SkipReason::InvalidJson { .. } => {}
547 other => panic!("unexpected reason: {other:?}"),
548 }
549 }
550
551 #[test]
552 fn jsonl_skips_empty_lines_silently() {
553 let input = b"{\"a\":1}\n\n{\"a\":2}\n\n";
554 let mut r = BufReader::new(&input[..]);
555 let p = parse(&mut r, InputFormat::Jsonl, None).unwrap();
556 assert_eq!(p.items, vec![json!({"a": 1}), json!({"a": 2})]);
557 assert!(p.skips.is_empty());
558 }
559
560 #[test]
561 fn json_with_items_path_override() {
562 let mut r = BufReader::new(br#"{"hits": {"records": [1, 2, 3]}, "extra": 9}"# as &[u8]);
563 let p = parse(&mut r, InputFormat::Json, Some(".hits.records")).unwrap();
564 assert_eq!(p.items, vec![json!(1), json!(2), json!(3)]);
565 assert!(p.meta.is_none());
566 }
567
568 #[test]
569 fn json_unknown_items_path_errors() {
570 let mut r = BufReader::new(br#"{"hits": [1]}"# as &[u8]);
571 let err = parse(&mut r, InputFormat::Json, Some(".missing")).unwrap_err();
572 match err {
573 FaceError::UnknownItemsPath { path } => assert_eq!(path, ".missing"),
574 other => panic!("unexpected: {other:?}"),
575 }
576 }
577
578 #[test]
579 fn parses_csv_with_header() {
580 let mut r = BufReader::new(b"a,b\n1,2\n" as &[u8]);
581 let parsed = parse(&mut r, InputFormat::Csv, None).unwrap();
582 assert_eq!(parsed.items, vec![json!({"a": 1, "b": 2})]);
583 assert_eq!(parsed.items_path, ".");
584 }
585
586 #[test]
587 fn face_envelope_path_returns_input_parse() {
588 let mut r =
589 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]);
590 let err = parse(&mut r, InputFormat::FaceEnvelope, None).unwrap_err();
591 match err {
592 FaceError::InputParse {
593 format: InputFormat::FaceEnvelope,
594 ..
595 } => {}
596 other => panic!("unexpected: {other:?}"),
597 }
598 }
599}