sheeter-engine 1.5.0

Convert, inspect, and transform data files across eleven formats with streaming where it matters and auto-detection where it can.
Documentation
//! JSON-specific helpers: `json:concat`, `json:append`, and `json:frag`.
//!
//! [`concat`] merges many JSON files into one object keyed by filename stem.
//! Each input becomes `<stem>` (without `.json`); collisions get `_2`, `_3`.
//! Values are preserved verbatim: objects stay objects, arrays stay arrays,
//! scalars stay scalars.
//!
//! [`append`] inserts many JSON files into an existing document, in place (or
//! to `-o`). Each file is added under its filename stem. If a key already
//! exists, it is overwritten. If the target does not exist, a new object is
//! created.
//!
//! [`frag`] descends `level` levels from the root and writes one JSON file per
//! child found at that depth. Level 0 fragments the root's direct children.
//! When multiple paths reach the same depth, filenames are prefixed with the
//! full key path joined by `_` to avoid collisions. Keys are sanitized the
//! same way `xlsx:frag` does (NFD, lowercase, non-alphanumeric collapsed to
//! `_`).
//!
//! # Examples
//!
//! ```
//! use sheeter_engine::json_tools;
//! use std::fs;
//!
//! let dir = tempfile::tempdir().unwrap();
//! fs::write(dir.path().join("a.json"), r#"{"x":1}"#).unwrap();
//! fs::write(dir.path().join("b.json"), r#"{"y":2}"#).unwrap();
//! let out = dir.path().join("out.json");
//! json_tools::concat(
//!     &[
//!         dir.path().join("a.json").to_str().unwrap().to_string(),
//!         dir.path().join("b.json").to_str().unwrap().to_string(),
//!     ],
//!     out.to_str().unwrap(),
//! )
//! .unwrap();
//! let parsed: serde_json::Value =
//!     serde_json::from_str(&fs::read_to_string(out).unwrap()).unwrap();
//! assert_eq!(parsed["a"]["x"], 1);
//! assert_eq!(parsed["b"]["y"], 2);
//! ```

use std::collections::HashSet;
use std::error::Error;
use std::path::{Path, PathBuf};
use unicode_normalization::UnicodeNormalization;

/// Merges many JSON files into a single object keyed by filename stem.
///
/// # Errors
///
/// Returns an error if any input cannot be read or parsed as JSON, or if the
/// output cannot be written.
pub fn concat(inputs: &[String], output: &str) -> Result<(), Box<dyn Error>> {
    let mut map = serde_json::Map::new();
    let mut used: HashSet<String> = HashSet::new();
    for input in inputs {
        let bytes = std::fs::read(input)?;
        let value: serde_json::Value = serde_json::from_slice(&bytes)?;
        let stem = Path::new(input)
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or(input)
            .to_string();
        let key = unique_key(&stem, &mut used);
        map.insert(key, value);
    }
    let out = serde_json::to_string_pretty(&serde_json::Value::Object(map))?;
    std::fs::write(output, out)?;
    Ok(())
}

/// Inserts many JSON files into `target` under their filename stems.
///
/// # Errors
///
/// Returns an error if any input cannot be read or parsed, if `target` exists
/// but is not a JSON object, or if writing fails.
pub fn append(
    target: &str,
    additions: &[String],
    output: Option<&str>,
) -> Result<(), Box<dyn Error>> {
    let mut root: serde_json::Value = if Path::new(target).exists() {
        let bytes = std::fs::read(target)?;
        serde_json::from_slice(&bytes)?
    } else {
        serde_json::Value::Object(serde_json::Map::new())
    };
    let obj = root.as_object_mut().ok_or("target is not a JSON object")?;
    for input in additions {
        let bytes = std::fs::read(input)?;
        let value: serde_json::Value = serde_json::from_slice(&bytes)?;
        let stem = Path::new(input)
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or(input)
            .to_string();
        obj.insert(stem, value);
    }
    let out = serde_json::to_string_pretty(&root)?;
    std::fs::write(output.unwrap_or(target), out)?;
    Ok(())
}

/// Descends `level` levels and writes one JSON file per child found there.
///
/// # Errors
///
/// Returns an error if the input cannot be read or parsed, or if any write
/// fails.
pub fn frag(
    input: &str,
    level: usize,
    out_dir: Option<&str>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
    let bytes = std::fs::read(input)?;
    let root: serde_json::Value = serde_json::from_slice(&bytes)?;
    let targets = collect_at_level(&root, level)?;
    let out = match out_dir {
        Some(d) => Path::new(d),
        None => Path::new("."),
    };
    std::fs::create_dir_all(out)?;

    let mut used: HashSet<String> = HashSet::new();
    let mut written: Vec<PathBuf> = Vec::new();
    for (path, value) in targets {
        let base = path
            .last()
            .map(|s| sanitize(s))
            .unwrap_or_else(|| "item".to_string());
        let name = unique_file(&base, &mut used);
        let file = out.join(format!("{name}.json"));
        let out_bytes = serde_json::to_string_pretty(value)?;
        std::fs::write(&file, out_bytes)?;
        written.push(file);
    }
    Ok(written)
}

type FragItem<'a> = (Vec<String>, &'a serde_json::Value);

fn collect_at_level(
    value: &serde_json::Value,
    level: usize,
) -> Result<Vec<FragItem<'_>>, Box<dyn Error>> {
    let children: Vec<(String, &serde_json::Value)> = match value {
        serde_json::Value::Object(o) => o.iter().map(|(k, v)| (k.clone(), v)).collect(),
        serde_json::Value::Array(a) => a
            .iter()
            .enumerate()
            .map(|(i, v)| (i.to_string(), v))
            .collect(),
        _ => return Ok(Vec::new()),
    };
    let mut out = Vec::new();
    if level == 0 {
        for (k, v) in children {
            out.push((vec![k], v));
        }
    } else {
        for (k, v) in children {
            let subs = collect_at_level(v, level - 1)?;
            for (path, val) in subs {
                let mut full = vec![k.clone()];
                full.extend(path);
                out.push((full, val));
            }
        }
    }
    Ok(out)
}

fn unique_key(raw: &str, used: &mut HashSet<String>) -> String {
    if used.insert(raw.to_string()) {
        return raw.to_string();
    }
    let mut n = 2;
    loop {
        let candidate = format!("{raw}_{n}");
        if used.insert(candidate.clone()) {
            return candidate;
        }
        n += 1;
    }
}

fn unique_file(base: &str, used: &mut HashSet<String>) -> String {
    if used.insert(base.to_string()) {
        return base.to_string();
    }
    let mut n = 2;
    loop {
        let candidate = format!("{base}_{n}");
        if used.insert(candidate.clone()) {
            return candidate;
        }
        n += 1;
    }
}

fn sanitize(name: &str) -> String {
    let normalized: String = name
        .nfd()
        .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
        .collect::<String>()
        .to_lowercase();
    let mut out = String::new();
    let mut last = false;
    for c in normalized.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c);
            last = false;
        } else if !last && !out.is_empty() {
            out.push('_');
            last = true;
        }
    }
    while out.ends_with('_') {
        out.pop();
    }
    if out.is_empty() {
        "item".to_string()
    } else {
        out
    }
}