use serde_json::Value;
static FIELDS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
static MAX_CHARS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
pub fn configure(fields: Vec<String>, max_chars: Option<usize>) {
if !fields.is_empty() {
let _ = FIELDS.set(fields);
}
if let Some(max) = max_chars {
let _ = MAX_CHARS.set(max);
}
}
pub fn emit_json(mut value: Value) -> anyhow::Result<()> {
if let Some(max) = MAX_CHARS.get() {
truncate_bodies(&mut value, *max);
}
if let Some(fields) = FIELDS.get() {
project_fields(&mut value, fields)?;
}
println!("{}", serde_json::to_string_pretty(&value)?);
Ok(())
}
#[derive(Debug, thiserror::Error)]
#[error("unknown field '{field}' — valid fields: {valid}")]
pub struct UnknownField {
pub field: String,
pub valid: String,
}
pub fn project_fields(value: &mut Value, fields: &[String]) -> Result<(), UnknownField> {
let items: &mut Vec<Value> = match value {
Value::Array(items) => items,
Value::Object(map) => match map.get_mut("items") {
Some(Value::Array(items)) => items,
_ => return Ok(()),
},
_ => return Ok(()),
};
if let Some(first) = items.first()
&& let Some(obj) = first.as_object()
{
for field in fields {
if !obj.contains_key(field) {
return Err(UnknownField {
field: field.clone(),
valid: obj.keys().cloned().collect::<Vec<_>>().join(", "),
});
}
}
}
for item in items.iter_mut() {
if let Some(obj) = item.as_object_mut() {
obj.retain(|k, _| fields.iter().any(|f| f == k));
}
}
Ok(())
}
pub fn truncate_bodies(value: &mut Value, max: usize) {
fn truncate(s: &mut String, max: usize) {
let count = s.chars().count();
if count > max {
let kept: String = s.chars().take(max).collect();
*s = format!("{kept}…[truncated {} chars]", count - max);
}
}
fn walk(v: &mut Value, max: usize) {
match v {
Value::Array(items) => items.iter_mut().for_each(|i| walk(i, max)),
Value::Object(map) => {
for (key, val) in map.iter_mut() {
if (key == "body_text" || key == "preview") && val.is_string() {
if let Value::String(s) = val {
truncate(s, max);
}
} else {
walk(val, max);
}
}
}
_ => {}
}
}
walk(value, max);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn projection_keeps_requested_and_errors_on_unknown() {
let mut v = json!([{"id": "1", "subject": "s", "body_text": "b"}]);
project_fields(&mut v, &["id".into(), "subject".into()]).unwrap();
assert_eq!(v, json!([{"id": "1", "subject": "s"}]));
let mut v = json!([{"id": "1"}]);
let err = project_fields(&mut v, &["nope".into()]).unwrap_err();
assert!(err.to_string().contains("valid fields: id"));
}
#[test]
fn projection_reaches_into_items_envelope() {
let mut v = json!({"items": [{"id": "1", "x": 2}], "next_cursor": "c"});
project_fields(&mut v, &["id".into()]).unwrap();
assert_eq!(v["items"], json!([{"id": "1"}]));
assert_eq!(v["next_cursor"], "c");
}
#[test]
fn truncation_marks_the_cut() {
let mut v = json!([{"body_text": "abcdefghij", "preview": "xy"}]);
truncate_bodies(&mut v, 4);
assert_eq!(v[0]["body_text"], "abcd…[truncated 6 chars]");
assert_eq!(v[0]["preview"], "xy");
}
}