use serde_json::{Map, Value};
const NOOP_KEYS: [&str; 4] = ["$schema", "title", "examples", "$comment"];
pub(crate) fn minify_schema(schema: &mut Value, max_desc_chars: usize) {
minify_value(schema, max_desc_chars);
}
fn minify_value(node: &mut Value, max_desc_chars: usize) {
match node {
Value::Object(obj) => minify_object(obj, max_desc_chars),
Value::Array(arr) => {
for v in arr.iter_mut() {
minify_value(v, max_desc_chars);
}
}
_ => {}
}
}
fn minify_object(obj: &mut Map<String, Value>, max_desc_chars: usize) {
for k in NOOP_KEYS {
obj.remove(k);
}
if let Some(Value::String(d)) = obj.get_mut("description") {
if d.trim().is_empty() {
obj.remove("description");
} else {
crate::provider::truncate_chars(d, max_desc_chars);
}
}
if let Some(Value::Array(types)) = obj.get("type")
&& types.len() == 1
&& types[0].is_string()
{
let only = types[0].clone();
obj.insert("type".to_string(), only);
}
for key in ["properties", "patternProperties", "$defs", "definitions"] {
if let Some(Value::Object(props)) = obj.get_mut(key) {
dedup_descriptions(props);
for v in props.values_mut() {
minify_value(v, max_desc_chars);
}
}
}
for key in [
"items",
"additionalItems",
"contains",
"not",
"propertyNames",
] {
if let Some(v) = obj.get_mut(key) {
minify_value(v, max_desc_chars);
}
}
for key in ["allOf", "anyOf", "oneOf", "prefixItems"] {
if let Some(Value::Array(arr)) = obj.get_mut(key) {
for v in arr.iter_mut() {
minify_value(v, max_desc_chars);
}
}
}
if let Some(ap @ Value::Object(_)) = obj.get_mut("additionalProperties") {
minify_value(ap, max_desc_chars);
}
}
fn dedup_descriptions(props: &mut Map<String, Value>) {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for prop in props.values_mut() {
let Some(obj) = prop.as_object_mut() else {
continue;
};
let dup = match obj.get("description").and_then(Value::as_str) {
Some(d) if !d.trim().is_empty() => !seen.insert(d.to_string()),
_ => false,
};
if dup {
obj.remove("description");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn count_tokens(v: &Value) -> usize {
serde_json::to_string(v).unwrap().split_whitespace().count()
}
#[test]
fn drops_annotation_noops_keeps_semantic() {
let mut s = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "QueryParams",
"type": "object",
"additionalProperties": false,
"properties": {
"q": {"type": "string", "title": "Query", "description": "the search text"},
"limit": {"type": "integer", "default": 10, "examples": [5, 20]}
},
"required": ["q"]
});
minify_schema(&mut s, 300);
assert!(s.get("$schema").is_none());
assert!(s.get("title").is_none());
assert!(s.pointer("/properties/q/title").is_none());
assert!(s.pointer("/properties/limit/examples").is_none());
assert_eq!(s.get("additionalProperties"), Some(&json!(false)));
assert_eq!(s.pointer("/properties/limit/default"), Some(&json!(10)));
assert_eq!(s.get("required"), Some(&json!(["q"])));
assert_eq!(
s.pointer("/properties/q/description")
.and_then(Value::as_str),
Some("the search text")
);
}
#[test]
fn collapses_single_element_type_array() {
let mut s = json!({"type": ["string"]});
minify_schema(&mut s, 300);
assert_eq!(s.get("type"), Some(&json!("string")));
let mut u = json!({"type": ["string", "null"]});
minify_schema(&mut u, 300);
assert_eq!(u.get("type"), Some(&json!(["string", "null"])));
}
#[test]
fn dedups_repeated_property_descriptions() {
let boiler = "Set to true to enable this option. Defaults to false when omitted.";
let mut s = json!({
"type": "object",
"properties": {
"a": {"type": "boolean", "description": boiler},
"b": {"type": "boolean", "description": boiler},
"c": {"type": "boolean", "description": boiler},
"d": {"type": "boolean", "description": "A genuinely distinct note."}
}
});
minify_schema(&mut s, 300);
assert_eq!(
s.pointer("/properties/a/description")
.and_then(Value::as_str),
Some(boiler)
);
assert!(s.pointer("/properties/b/description").is_none());
assert!(s.pointer("/properties/c/description").is_none());
assert_eq!(
s.pointer("/properties/d/description")
.and_then(Value::as_str),
Some("A genuinely distinct note.")
);
}
#[test]
fn trims_per_property_description_to_cap() {
let long = "x".repeat(400);
let mut s = json!({
"type": "object",
"properties": {"p": {"type": "string", "description": long}}
});
minify_schema(&mut s, 50);
let d = s
.pointer("/properties/p/description")
.and_then(Value::as_str)
.unwrap();
assert!(d.chars().count() <= 51, "trimmed to cap + ellipsis");
}
#[test]
fn drops_empty_description() {
let mut s = json!({"type": "object", "description": " ",
"properties": {"p": {"type": "string", "description": ""}}});
minify_schema(&mut s, 300);
assert!(s.get("description").is_none());
assert!(s.pointer("/properties/p/description").is_none());
}
#[test]
fn recurses_into_nested_subschemas() {
let mut s = json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "object", "title": "Item",
"properties": {"id": {"type": ["integer"], "$comment": "drop me"}}}
}
}
});
minify_schema(&mut s, 300);
assert!(s.pointer("/properties/items/items/title").is_none());
assert!(
s.pointer("/properties/items/items/properties/id/$comment")
.is_none()
);
assert_eq!(
s.pointer("/properties/items/items/properties/id/type"),
Some(&json!("integer")),
"single-type array collapsed deep in the tree"
);
}
#[test]
fn strict_mode_schema_survives_untouched_in_semantics() {
let strict = json!({
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {"type": "string", "enum": ["fast", "slow"], "default": "fast"},
"retries": {"type": "integer", "default": 3, "minimum": 0, "maximum": 9}
},
"required": ["mode"]
});
let mut s = strict.clone();
minify_schema(&mut s, 300);
assert_eq!(s, strict, "strict schema is semantics-stable");
}
#[test]
fn deterministic_byte_stable_across_runs() {
let make = || {
json!({
"$schema": "x", "title": "T", "type": "object",
"properties": {
"a": {"type": ["string"], "description": "same", "title": "A"},
"b": {"type": "string", "description": "same"}
}
})
};
let mut a = make();
let mut b = make();
minify_schema(&mut a, 300);
minify_schema(&mut b, 300);
assert_eq!(
serde_json::to_string(&a).unwrap(),
serde_json::to_string(&b).unwrap(),
"minification is byte-stable"
);
}
#[test]
fn round_trips_as_valid_json() {
let mut s = json!({
"$schema": "x", "type": "object",
"properties": {"q": {"type": ["string"], "description": "text"}},
"required": ["q"], "additionalProperties": false
});
minify_schema(&mut s, 300);
let bytes = serde_json::to_string(&s).unwrap();
let reparsed: Value = serde_json::from_str(&bytes).unwrap();
assert_eq!(reparsed, s, "minified schema is valid, stable JSON");
}
#[test]
fn token_reduction_on_verbose_fixture() {
let boiler = "Optional. Provide a value to override the server default for this field.";
let before = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ListResourcesArguments",
"type": "object",
"additionalProperties": false,
"properties": {
"cursor": {"type": ["string"], "title": "Cursor", "description": boiler},
"limit": {"type": ["integer"], "title": "Limit", "description": boiler, "default": 50},
"filter": {"type": ["string"], "title": "Filter", "description": boiler},
"order": {"type": ["string"], "title": "Order", "description": boiler, "enum": ["asc", "desc"]},
"verbose": {"type": ["boolean"], "title": "Verbose", "description": boiler, "default": false},
"fields": {"type": ["string"], "title": "Fields", "description": boiler}
},
"required": ["cursor"]
});
let mut after = before.clone();
minify_schema(&mut after, 300);
let (b, a) = (count_tokens(&before), count_tokens(&after));
let saved = 100.0 - (a as f64 / b as f64 * 100.0);
assert!(
saved >= 30.0,
"expected ≥30% schema token reduction, got {saved:.1}%"
);
assert_eq!(after.get("additionalProperties"), Some(&json!(false)));
assert_eq!(after.get("required"), Some(&json!(["cursor"])));
assert_eq!(after.pointer("/properties/limit/default"), Some(&json!(50)));
assert_eq!(
after.pointer("/properties/order/enum"),
Some(&json!(["asc", "desc"]))
);
assert_eq!(
after
.pointer("/properties/cursor/type")
.and_then(Value::as_str),
Some("string"),
"type arrays collapsed"
);
}
}