use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Clone, Deserialize, Serialize)]
#[serde(transparent)]
pub struct Document(serde_json::Value);
impl Document {
#[must_use]
pub fn as_json(&self) -> &serde_json::Value {
&self.0
}
#[must_use]
pub fn leaf_paths(&self) -> Vec<String> {
let mut paths = Vec::new();
if let serde_json::Value::Object(table) = &self.0 {
let mut prefix = String::new();
for (key, value) in table {
prefix.clear();
prefix.push_str(key);
collect(value, &mut prefix, &mut paths);
}
}
paths
}
}
fn collect(value: &serde_json::Value, prefix: &mut String, paths: &mut Vec<String>) {
match value {
serde_json::Value::Object(table) if !table.is_empty() => {
for (key, nested) in table {
let restore = prefix.len();
prefix.push('.');
prefix.push_str(key);
collect(nested, prefix, paths);
prefix.truncate(restore);
}
}
_ => paths.push(prefix.clone()),
}
}
impl fmt::Debug for Document {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Document")
.field("leaves", &self.leaf_paths().len())
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn document(json: serde_json::Value) -> Document {
serde_json::from_value(json).expect("any JSON is a document")
}
#[test]
fn leaf_paths_reach_into_nested_tables_and_stop_at_arrays() {
let document = document(serde_json::json!({
"host": "db",
"pool": { "max": 8, "min": 1 },
"tags": ["a", "b"],
"empty": {},
}));
assert_eq!(
document.leaf_paths(),
["empty", "host", "pool.max", "pool.min", "tags"]
);
}
#[test]
fn a_key_that_is_the_empty_string_still_has_a_path() {
let document = document(serde_json::json!({ "": 1, "pool": { "": 2 } }));
assert_eq!(document.leaf_paths(), ["", "pool."]);
}
#[test]
fn a_document_that_is_not_a_table_has_no_paths() {
assert!(document(serde_json::json!(7)).leaf_paths().is_empty());
assert!(document(serde_json::json!({})).leaf_paths().is_empty());
}
#[test]
fn debug_prints_shape_and_never_a_value() {
let document = document(serde_json::json!({ "password": "hunter2" }));
let rendered = format!("{document:?}");
assert!(!rendered.contains("hunter2"), "{rendered}");
assert!(!rendered.contains("password"), "{rendered}");
}
}