use std::collections::BTreeSet;
use serde_json::{Map, Value};
const TYPE_KEY: &str = "$type";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Budget {
pub max_depth: usize,
pub max_list: usize,
pub max_bytes: usize,
}
impl Budget {
pub fn new(max_depth: usize, max_list: usize, max_bytes: usize) -> Self {
Self {
max_depth,
max_list,
max_bytes,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Truncation {
pub cut: Vec<Cut>,
pub exceeds: bool,
}
impl Truncation {
pub fn is_empty(&self) -> bool {
self.cut.is_empty() && !self.exceeds
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Cut {
pub pointer: String,
pub reason: CutReason,
pub removed: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CutReason {
Depth,
ListLength,
Bytes,
}
pub fn truncate(value: &Value, budget: &Budget) -> (Value, Truncation) {
let mut truncation = Truncation::default();
let mut markers = BTreeSet::new();
let mut pointer = String::new();
let mut out = shrink(value, 0, &mut pointer, budget, &mut truncation, &mut markers);
let mut size = size_of(&out);
while size > budget.max_bytes {
match cut_largest(&mut out, &mut pointer, &mut truncation, &mut markers) {
Some(saved) => size -= saved,
None => {
truncation.exceeds = true;
break;
}
}
}
(out, truncation)
}
fn shrink(
value: &Value,
depth: usize,
pointer: &mut String,
budget: &Budget,
truncation: &mut Truncation,
markers: &mut BTreeSet<String>,
) -> Value {
match value {
Value::Object(map) => {
if depth > budget.max_depth {
let marker = match map.get(TYPE_KEY).and_then(Value::as_str) {
Some(qualified) => format!("<{qualified}: {} keys cut at depth {depth}>", map.len()),
None => format!("<object: {} keys cut at depth {depth}>", map.len()),
};
return replaced(marker, pointer, CutReason::Depth, map.len(), truncation, markers);
}
let mut out = Map::new();
for (key, child) in map {
let at = pointer.len();
push_segment(pointer, key);
out.insert(
key.clone(),
shrink(child, depth + 1, pointer, budget, truncation, markers),
);
pointer.truncate(at);
}
Value::Object(out)
}
Value::Array(items) => {
if depth > budget.max_depth {
let marker = format!("<list: {} items cut at depth {depth}>", items.len());
return replaced(
marker,
pointer,
CutReason::Depth,
items.len(),
truncation,
markers,
);
}
let kept = items.len().min(budget.max_list);
if items.len() > kept {
truncation.cut.push(Cut {
pointer: pointer.clone(),
reason: CutReason::ListLength,
removed: items.len() - kept,
});
}
let mut out = Vec::with_capacity(kept);
for (index, child) in items[..kept].iter().enumerate() {
let at = pointer.len();
push_segment(pointer, &index.to_string());
out.push(shrink(child, depth + 1, pointer, budget, truncation, markers));
pointer.truncate(at);
}
Value::Array(out)
}
other => other.clone(),
}
}
fn replaced(
marker: String,
pointer: &str,
reason: CutReason,
removed: usize,
truncation: &mut Truncation,
markers: &mut BTreeSet<String>,
) -> Value {
truncation.cut.push(Cut {
pointer: pointer.to_string(),
reason,
removed,
});
markers.insert(pointer.to_string());
Value::String(marker)
}
enum Slot {
Key(String),
Index(usize),
}
impl Slot {
fn segment(&self) -> String {
match self {
Slot::Key(key) => escape(key),
Slot::Index(index) => index.to_string(),
}
}
}
fn child_mut<'a>(node: &'a mut Value, slot: &Slot) -> &'a mut Value {
match (node, slot) {
(Value::Object(map), Slot::Key(key)) => map.get_mut(key).expect("a key just listed"),
(Value::Array(items), Slot::Index(index)) => &mut items[*index],
_ => unreachable!("a slot is only built from the container it indexes"),
}
}
fn cut_largest(
node: &mut Value,
pointer: &mut String,
truncation: &mut Truncation,
markers: &mut BTreeSet<String>,
) -> Option<usize> {
let is_marker = |pointer: &str, segment: &str| markers.contains(&format!("{pointer}/{segment}"));
let (slot, size) = match &*node {
Value::Object(map) => map
.iter()
.filter(|(key, _)| key.as_str() != TYPE_KEY)
.filter(|(key, _)| !is_marker(pointer, &escape(key)))
.map(|(key, child)| (Slot::Key(key.clone()), size_of(child)))
.max_by_key(|(_, size)| *size)?,
Value::Array(items) => items
.iter()
.enumerate()
.filter(|(index, _)| !is_marker(pointer, &index.to_string()))
.map(|(index, child)| (Slot::Index(index), size_of(child)))
.max_by_key(|(_, size)| *size)?,
_ => return None,
};
let at = pointer.len();
pointer.push('/');
pointer.push_str(&slot.segment());
let child = child_mut(node, &slot);
let has_something_inside = match &*child {
Value::Object(_) => true,
Value::Array(items) => items.iter().any(|item| item.is_object() || item.is_array()),
_ => false,
};
let inner = if has_something_inside {
cut_largest(child, pointer, truncation, markers)
} else {
None
};
if inner.is_some() {
pointer.truncate(at);
return inner;
}
let typed = matches!(&*child, Value::Object(map) if map.contains_key(TYPE_KEY));
let marker = Value::String(format!("<cut: {size} bytes>"));
let marker_size = size_of(&marker);
let saved = if !typed && size > marker_size {
truncation.cut.push(Cut {
pointer: pointer.clone(),
reason: CutReason::Bytes,
removed: size,
});
markers.insert(pointer.clone());
*child = marker;
Some(size - marker_size)
} else {
None
};
pointer.truncate(at);
saved
}
fn size_of(value: &Value) -> usize {
match value {
Value::Null => 4,
Value::Bool(true) => 4,
Value::Bool(false) => 5,
Value::Number(number) => number.to_string().len(),
Value::String(text) => string_size(text),
Value::Array(items) => 2 + items.iter().map(size_of).sum::<usize>() + items.len().saturating_sub(1),
Value::Object(map) => {
2 + map
.iter()
.map(|(key, child)| string_size(key) + 1 + size_of(child))
.sum::<usize>()
+ map.len().saturating_sub(1)
}
}
}
fn string_size(text: &str) -> usize {
serde_json::to_string(text).map_or(text.len() + 2, |quoted| quoted.len())
}
fn escape(segment: &str) -> String {
segment.replace('~', "~0").replace('/', "~1")
}
fn push_segment(pointer: &mut String, segment: &str) {
pointer.push('/');
pointer.push_str(&escape(segment));
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn pointers(truncation: &Truncation, reason: CutReason) -> Vec<&str> {
let mut out: Vec<&str> = truncation
.cut
.iter()
.filter(|cut| cut.reason == reason)
.map(|cut| cut.pointer.as_str())
.collect();
out.sort_unstable();
out
}
fn compact(value: &Value) -> usize {
serde_json::to_string(value).unwrap().len()
}
#[test]
fn a_value_within_budget_is_returned_unchanged_with_nothing_said() {
let value = json!({"$type": "a.B", "xs": [1, 2, 3], "c": {"$type": "a.C", "n": 1}});
let (out, truncation) = truncate(&value, &Budget::new(5, 10, 10_000));
assert_eq!(out, value);
assert!(truncation.is_empty());
let (_, truncation) = truncate(&value, &Budget::new(5, 2, 10_000));
assert!(!truncation.is_empty());
}
#[test]
fn size_of_is_the_length_of_the_compact_serialization() {
let value = json!({
"a": [1, -2.5, true, false, null, "tab\there \"quoted\" รฉ"],
"b/c": {"$type": "x.Y", "n": 1.0e10, "s": ""},
"empty": [], "none": {}
});
assert_eq!(size_of(&value), compact(&value));
}
#[test]
fn deeper_levels_become_a_marker_that_keeps_the_type() {
let value = json!({"a": {"$type": "x.Y", "b": {"$type": "x.Z", "c": {"d": 1}, "e": 2}}});
let (out, truncation) = truncate(&value, &Budget::new(1, 10, 10_000));
assert_eq!(out["a"]["b"], json!("<x.Z: 3 keys cut at depth 2>"));
assert_eq!(
out["a"]["$type"],
json!("x.Y"),
"what is within depth is untouched"
);
assert_eq!(
truncation.cut,
vec![Cut {
pointer: "/a/b".into(),
reason: CutReason::Depth,
removed: 3
}]
);
let (out, truncation) = truncate(&value, &Budget::new(2, 10, 10_000));
assert_eq!(out["a"]["b"]["e"], json!(2));
assert_eq!(out["a"]["b"]["c"], json!("<object: 1 keys cut at depth 3>"));
assert_eq!(pointers(&truncation, CutReason::Depth), vec!["/a/b/c"]);
}
#[test]
fn a_list_beyond_the_limit_is_shortened_and_the_cut_is_on_the_list() {
let value = json!({"xs": [1, 2, 3, 4, 5], "ys": [[1, 2, 3], [4]]});
let (out, truncation) = truncate(&value, &Budget::new(5, 2, 10_000));
assert_eq!(out["xs"], json!([1, 2]));
assert_eq!(out["ys"], json!([[1, 2], [4]]));
assert_eq!(pointers(&truncation, CutReason::ListLength), vec!["/xs", "/ys/0"]);
let removed = |pointer: &str| {
truncation
.cut
.iter()
.find(|c| c.pointer == pointer)
.unwrap()
.removed
};
assert_eq!((removed("/xs"), removed("/ys/0")), (3, 1));
assert_eq!(truncation.cut.len(), 2);
let (out, truncation) = truncate(&json!({"xs": [1, 2]}), &Budget::new(5, 2, 10_000));
assert_eq!(out["xs"], json!([1, 2]));
assert!(truncation.is_empty());
}
#[test]
fn the_largest_values_are_cut_until_it_fits_and_each_cut_is_listed() {
let value = json!({
"$type": "a.B",
"small": 1,
"big": "x".repeat(500),
"medium": "y".repeat(300),
});
let (out, truncation) = truncate(&value, &Budget::new(5, 10, 200));
assert!(compact(&out) <= 200, "{}", compact(&out));
assert!(!truncation.exceeds);
assert_eq!(out["$type"], json!("a.B"));
assert_eq!(out["small"], json!(1), "the small value is not touched");
assert_eq!(pointers(&truncation, CutReason::Bytes), vec!["/big", "/medium"]);
let big = truncation.cut.iter().find(|c| c.pointer == "/big").unwrap();
assert_eq!(big.removed, 502, "the compact size of the string it replaced");
assert!(out["big"].as_str().unwrap().starts_with("<cut: "));
let (out, truncation) = truncate(&value, &Budget::new(5, 10, 400));
assert_eq!(pointers(&truncation, CutReason::Bytes), vec!["/big"]);
assert_eq!(out["medium"], value["medium"]);
}
#[test]
fn the_byte_pass_goes_into_a_list_of_typed_objects_and_keeps_every_type() {
let value = json!({"items": [
{"$type": "a.B", "blob": "z".repeat(400)},
{"$type": "a.B", "blob": "z".repeat(10)},
]});
let (out, truncation) = truncate(&value, &Budget::new(5, 10, 150));
assert_eq!(pointers(&truncation, CutReason::Bytes), vec!["/items/0/blob"]);
assert_eq!(out["items"][0]["$type"], json!("a.B"));
assert_eq!(out["items"][1], value["items"][1]);
assert!(compact(&out) <= 150);
}
#[test]
fn the_type_key_is_never_cut_even_when_it_is_the_largest_value() {
let long = format!("{}.Foo", "very".repeat(30));
let value = json!({"$type": long, "n": "x".repeat(30)});
let (out, truncation) = truncate(&value, &Budget::new(5, 10, 160));
assert_eq!(out["$type"], json!(long));
assert!(!truncation.exceeds);
assert_eq!(pointers(&truncation, CutReason::Bytes), vec!["/n"]);
let (out, truncation) = truncate(&json!({"$type": long}), &Budget::new(5, 10, 10));
assert_eq!(out["$type"], json!(long));
assert!(truncation.exceeds);
assert!(!truncation.is_empty());
}
#[test]
fn a_budget_that_cannot_be_met_is_said_not_hidden() {
let (out, truncation) = truncate(&json!("x".repeat(100)), &Budget::new(5, 10, 10));
assert_eq!(out, json!("x".repeat(100)), "the root is not replaceable");
assert!(truncation.exceeds);
assert!(truncation.cut.is_empty());
}
#[test]
fn pointers_escape_the_tilde_and_the_slash() {
let value = json!({"a/b": {"m~n": {"deep": 1}}, "list/1": [1, 2, 3]});
let (_, truncation) = truncate(&value, &Budget::new(1, 2, 10_000));
assert_eq!(pointers(&truncation, CutReason::Depth), vec!["/a~1b/m~0n"]);
assert_eq!(pointers(&truncation, CutReason::ListLength), vec!["/list~11"]);
assert_ne!(pointers(&truncation, CutReason::Depth), vec!["/a/b/m~n"]);
let pointer = &pointers(&truncation, CutReason::Depth)[0].to_string();
assert_eq!(value.pointer(pointer), Some(&json!({"deep": 1})));
}
}