use std::collections::{BTreeMap, BTreeSet};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde_json::Value;
pub const MAX_ARRAY_OBJECTS: usize = 5;
#[derive(Debug)]
pub struct Record {
pub original: Value,
pub flat: BTreeMap<String, Value>,
}
#[derive(Debug, Default, Clone)]
pub struct FieldInfo {
pub count: usize,
pub types: BTreeSet<&'static str>,
}
#[derive(Debug)]
pub struct Dataset {
pub records: Vec<Record>,
pub schema: BTreeMap<String, FieldInfo>,
pub parse_errors: usize,
pub total_lines: usize,
}
pub fn type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "num",
Value::String(_) => "str",
Value::Array(_) => "arr",
Value::Object(_) => "obj",
}
}
pub fn flatten(value: &Value) -> BTreeMap<String, Value> {
let mut out = BTreeMap::new();
match value {
Value::Object(_) => flatten_into(value, String::new(), &mut out),
other => {
out.insert("$".to_string(), other.clone());
}
}
out
}
fn flatten_into(value: &Value, prefix: String, out: &mut BTreeMap<String, Value>) {
match value {
Value::Object(map) => {
if map.is_empty() {
if !prefix.is_empty() {
out.insert(prefix, value.clone());
}
return;
}
for (k, v) in map {
let path = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
flatten_into(v, path, out);
}
}
Value::Array(items) => {
let has_objects = items.iter().any(Value::is_object);
if has_objects {
for (i, item) in items.iter().take(MAX_ARRAY_OBJECTS).enumerate() {
flatten_into(item, format!("{prefix}[{i}]"), out);
}
} else {
out.insert(prefix, value.clone());
}
}
scalar => {
out.insert(prefix, scalar.clone());
}
}
}
pub fn load_jsonl(path: &Path, max_lines: Option<usize>) -> Result<Dataset> {
let file =
File::open(path).with_context(|| format!("cannot open file '{}'", path.display()))?;
load_from_reader(BufReader::new(file), max_lines)
.with_context(|| format!("while loading '{}'", path.display()))
}
pub fn load_from_reader<R: BufRead>(reader: R, max_lines: Option<usize>) -> Result<Dataset> {
let mut records = Vec::new();
let mut schema: BTreeMap<String, FieldInfo> = BTreeMap::new();
let mut parse_errors = 0usize;
let mut total_lines = 0usize;
for line in reader.lines() {
if let Some(max) = max_lines {
if records.len() >= max {
break;
}
}
let line = line.context("failed to read line")?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
total_lines += 1;
match serde_json::from_str::<Value>(trimmed) {
Ok(v) => {
let flat = flatten(&v);
for (path, val) in &flat {
let info = schema.entry(path.clone()).or_default();
info.count += 1;
info.types.insert(type_name(val));
}
records.push(Record { original: v, flat });
}
Err(_) => parse_errors += 1,
}
}
if records.is_empty() {
if total_lines == 0 {
bail!("the file contains no JSON lines (empty file)");
}
bail!(
"no valid JSON records found ({parse_errors} malformed line(s) out of {total_lines})"
);
}
Ok(Dataset {
records,
schema,
parse_errors,
total_lines,
})
}
pub fn shape_hash<'a, I: Iterator<Item = &'a String>>(paths: I) -> String {
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET;
for p in paths {
for b in p.as_bytes() {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash ^= 0x1e;
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("{hash:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Cursor;
#[test]
fn flatten_nested_objects() {
let v = json!({"user": {"id": 1, "name": "ada", "address": {"city": "x"}}, "type": "t"});
let flat = flatten(&v);
assert_eq!(flat.get("user.id"), Some(&json!(1)));
assert_eq!(flat.get("user.name"), Some(&json!("ada")));
assert_eq!(flat.get("user.address.city"), Some(&json!("x")));
assert_eq!(flat.get("type"), Some(&json!("t")));
assert!(!flat.contains_key("user"));
}
#[test]
fn flatten_scalar_array_kept_whole() {
let v = json!({"tags": ["a", "b", 3]});
let flat = flatten(&v);
assert_eq!(flat.get("tags"), Some(&json!(["a", "b", 3])));
assert!(!flat.contains_key("tags[0]"));
}
#[test]
fn flatten_object_array_expands_capped() {
let items: Vec<Value> = (0..8).map(|i| json!({"id": i})).collect();
let v = json!({"items": items});
let flat = flatten(&v);
assert_eq!(flat.get("items[0].id"), Some(&json!(0)));
assert_eq!(flat.get("items[4].id"), Some(&json!(4)));
assert!(!flat.contains_key("items[5].id"), "cap at {MAX_ARRAY_OBJECTS}");
}
#[test]
fn flatten_empty_and_null() {
let v = json!({"a": {}, "b": null});
let flat = flatten(&v);
assert_eq!(flat.get("a"), Some(&json!({})));
assert_eq!(flat.get("b"), Some(&Value::Null));
}
#[test]
fn flatten_non_object_root() {
let flat = flatten(&json!([1, 2]));
assert_eq!(flat.get("$"), Some(&json!([1, 2])));
}
#[test]
fn load_accumulates_schema_and_skips_malformed() {
let input = "\
{\"a\": 1, \"b\": {\"c\": true}}\n\
not json at all\n\
{\"a\": \"x\"}\n\
\n\
{\"b\": {\"c\": null}}\n";
let ds = load_from_reader(Cursor::new(input), None).unwrap();
assert_eq!(ds.records.len(), 3);
assert_eq!(ds.parse_errors, 1);
assert_eq!(ds.total_lines, 4);
let a = &ds.schema["a"];
assert_eq!(a.count, 2);
assert!(a.types.contains("num") && a.types.contains("str"));
let bc = &ds.schema["b.c"];
assert_eq!(bc.count, 2);
assert!(bc.types.contains("bool") && bc.types.contains("null"));
}
#[test]
fn load_respects_max_lines() {
let input = "{\"a\":1}\n{\"a\":2}\n{\"a\":3}\n";
let ds = load_from_reader(Cursor::new(input), Some(2)).unwrap();
assert_eq!(ds.records.len(), 2);
}
#[test]
fn load_all_malformed_is_error() {
let err = load_from_reader(Cursor::new("nope\nstill nope\n"), None).unwrap_err();
assert!(err.to_string().contains("no valid JSON records"));
}
#[test]
fn load_empty_is_error() {
let err = load_from_reader(Cursor::new(""), None).unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn shape_hash_is_deterministic_and_order_sensitive() {
let a = ["a".to_string(), "b".to_string()];
let h1 = shape_hash(a.iter());
let h2 = shape_hash(a.iter());
assert_eq!(h1, h2);
let b = ["ab".to_string()];
assert_ne!(h1, shape_hash(b.iter()));
}
}