Skip to main content

sheeter_engine/
json_tools.rs

1//! JSON-specific helpers: `json:concat`, `json:append`, and `json:frag`.
2//!
3//! [`concat`] merges many JSON files into one object keyed by filename stem.
4//! Each input becomes `<stem>` (without `.json`); collisions get `_2`, `_3`.
5//! Values are preserved verbatim: objects stay objects, arrays stay arrays,
6//! scalars stay scalars.
7//!
8//! [`append`] inserts many JSON files into an existing document, in place (or
9//! to `-o`). Each file is added under its filename stem. If a key already
10//! exists, it is overwritten. If the target does not exist, a new object is
11//! created.
12//!
13//! [`frag`] descends `level` levels from the root and writes one JSON file per
14//! child found at that depth. Level 0 fragments the root's direct children.
15//! When multiple paths reach the same depth, filenames are prefixed with the
16//! full key path joined by `_` to avoid collisions. Keys are sanitized the
17//! same way `xlsx:frag` does (NFD, lowercase, non-alphanumeric collapsed to
18//! `_`).
19//!
20//! # Examples
21//!
22//! ```
23//! use sheeter_engine::json_tools;
24//! use std::fs;
25//!
26//! let dir = tempfile::tempdir().unwrap();
27//! fs::write(dir.path().join("a.json"), r#"{"x":1}"#).unwrap();
28//! fs::write(dir.path().join("b.json"), r#"{"y":2}"#).unwrap();
29//! let out = dir.path().join("out.json");
30//! json_tools::concat(
31//!     &[
32//!         dir.path().join("a.json").to_str().unwrap().to_string(),
33//!         dir.path().join("b.json").to_str().unwrap().to_string(),
34//!     ],
35//!     out.to_str().unwrap(),
36//! )
37//! .unwrap();
38//! let parsed: serde_json::Value =
39//!     serde_json::from_str(&fs::read_to_string(out).unwrap()).unwrap();
40//! assert_eq!(parsed["a"]["x"], 1);
41//! assert_eq!(parsed["b"]["y"], 2);
42//! ```
43
44use std::collections::HashSet;
45use std::error::Error;
46use std::path::{Path, PathBuf};
47use unicode_normalization::UnicodeNormalization;
48
49/// Merges many JSON files into a single object keyed by filename stem.
50///
51/// # Errors
52///
53/// Returns an error if any input cannot be read or parsed as JSON, or if the
54/// output cannot be written.
55pub fn concat(inputs: &[String], output: &str) -> Result<(), Box<dyn Error>> {
56    let mut map = serde_json::Map::new();
57    let mut used: HashSet<String> = HashSet::new();
58    for input in inputs {
59        let bytes = std::fs::read(input)?;
60        let value: serde_json::Value = serde_json::from_slice(&bytes)?;
61        let stem = Path::new(input)
62            .file_stem()
63            .and_then(|s| s.to_str())
64            .unwrap_or(input)
65            .to_string();
66        let key = unique_key(&stem, &mut used);
67        map.insert(key, value);
68    }
69    let out = serde_json::to_string_pretty(&serde_json::Value::Object(map))?;
70    std::fs::write(output, out)?;
71    Ok(())
72}
73
74/// Inserts many JSON files into `target` under their filename stems.
75///
76/// # Errors
77///
78/// Returns an error if any input cannot be read or parsed, if `target` exists
79/// but is not a JSON object, or if writing fails.
80pub fn append(
81    target: &str,
82    additions: &[String],
83    output: Option<&str>,
84) -> Result<(), Box<dyn Error>> {
85    let mut root: serde_json::Value = if Path::new(target).exists() {
86        let bytes = std::fs::read(target)?;
87        serde_json::from_slice(&bytes)?
88    } else {
89        serde_json::Value::Object(serde_json::Map::new())
90    };
91    let obj = root.as_object_mut().ok_or("target is not a JSON object")?;
92    for input in additions {
93        let bytes = std::fs::read(input)?;
94        let value: serde_json::Value = serde_json::from_slice(&bytes)?;
95        let stem = Path::new(input)
96            .file_stem()
97            .and_then(|s| s.to_str())
98            .unwrap_or(input)
99            .to_string();
100        obj.insert(stem, value);
101    }
102    let out = serde_json::to_string_pretty(&root)?;
103    std::fs::write(output.unwrap_or(target), out)?;
104    Ok(())
105}
106
107/// Descends `level` levels and writes one JSON file per child found there.
108///
109/// # Errors
110///
111/// Returns an error if the input cannot be read or parsed, or if any write
112/// fails.
113pub fn frag(
114    input: &str,
115    level: usize,
116    out_dir: Option<&str>,
117) -> Result<Vec<PathBuf>, Box<dyn Error>> {
118    let bytes = std::fs::read(input)?;
119    let root: serde_json::Value = serde_json::from_slice(&bytes)?;
120    let targets = collect_at_level(&root, level)?;
121    let out = match out_dir {
122        Some(d) => Path::new(d),
123        None => Path::new("."),
124    };
125    std::fs::create_dir_all(out)?;
126
127    let mut used: HashSet<String> = HashSet::new();
128    let mut written: Vec<PathBuf> = Vec::new();
129    for (path, value) in targets {
130        let base = path
131            .last()
132            .map(|s| sanitize(s))
133            .unwrap_or_else(|| "item".to_string());
134        let name = unique_file(&base, &mut used);
135        let file = out.join(format!("{name}.json"));
136        let out_bytes = serde_json::to_string_pretty(value)?;
137        std::fs::write(&file, out_bytes)?;
138        written.push(file);
139    }
140    Ok(written)
141}
142
143type FragItem<'a> = (Vec<String>, &'a serde_json::Value);
144
145fn collect_at_level(
146    value: &serde_json::Value,
147    level: usize,
148) -> Result<Vec<FragItem<'_>>, Box<dyn Error>> {
149    let children: Vec<(String, &serde_json::Value)> = match value {
150        serde_json::Value::Object(o) => o.iter().map(|(k, v)| (k.clone(), v)).collect(),
151        serde_json::Value::Array(a) => a
152            .iter()
153            .enumerate()
154            .map(|(i, v)| (i.to_string(), v))
155            .collect(),
156        _ => return Ok(Vec::new()),
157    };
158    let mut out = Vec::new();
159    if level == 0 {
160        for (k, v) in children {
161            out.push((vec![k], v));
162        }
163    } else {
164        for (k, v) in children {
165            let subs = collect_at_level(v, level - 1)?;
166            for (path, val) in subs {
167                let mut full = vec![k.clone()];
168                full.extend(path);
169                out.push((full, val));
170            }
171        }
172    }
173    Ok(out)
174}
175
176fn unique_key(raw: &str, used: &mut HashSet<String>) -> String {
177    if used.insert(raw.to_string()) {
178        return raw.to_string();
179    }
180    let mut n = 2;
181    loop {
182        let candidate = format!("{raw}_{n}");
183        if used.insert(candidate.clone()) {
184            return candidate;
185        }
186        n += 1;
187    }
188}
189
190fn unique_file(base: &str, used: &mut HashSet<String>) -> String {
191    if used.insert(base.to_string()) {
192        return base.to_string();
193    }
194    let mut n = 2;
195    loop {
196        let candidate = format!("{base}_{n}");
197        if used.insert(candidate.clone()) {
198            return candidate;
199        }
200        n += 1;
201    }
202}
203
204fn sanitize(name: &str) -> String {
205    let normalized: String = name
206        .nfd()
207        .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
208        .collect::<String>()
209        .to_lowercase();
210    let mut out = String::new();
211    let mut last = false;
212    for c in normalized.chars() {
213        if c.is_ascii_alphanumeric() {
214            out.push(c);
215            last = false;
216        } else if !last && !out.is_empty() {
217            out.push('_');
218            last = true;
219        }
220    }
221    while out.ends_with('_') {
222        out.pop();
223    }
224    if out.is_empty() {
225        "item".to_string()
226    } else {
227        out
228    }
229}