1use serde_json::Value;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
27pub enum SchemaTier {
28 #[default]
30 Full,
31 Medium,
35 Minimal,
40}
41
42impl SchemaTier {
43 pub fn parse(s: &str) -> Option<Self> {
45 match s {
46 "full" => Some(SchemaTier::Full),
47 "medium" => Some(SchemaTier::Medium),
48 "minimal" => Some(SchemaTier::Minimal),
49 _ => None,
50 }
51 }
52
53 pub fn as_str(&self) -> &'static str {
55 match self {
56 SchemaTier::Full => "full",
57 SchemaTier::Medium => "medium",
58 SchemaTier::Minimal => "minimal",
59 }
60 }
61}
62
63pub fn minify(description: &str, parameters: &Value, tier: SchemaTier) -> (String, Value) {
72 if tier == SchemaTier::Full {
73 return (description.to_string(), parameters.clone());
74 }
75 let budget = if tier == SchemaTier::Minimal { 1 } else { 2 };
76 let new_description = truncate_sentences(description, budget);
77 let mut new_parameters = parameters.clone();
78 minify_node(&mut new_parameters, tier);
79
80 let orig_bytes = description.len()
81 + serde_json::to_string(parameters)
82 .map(|s| s.len())
83 .unwrap_or(0);
84 let new_bytes = new_description.len()
85 + serde_json::to_string(&new_parameters)
86 .map(|s| s.len())
87 .unwrap_or(0);
88 if new_bytes >= orig_bytes {
89 (description.to_string(), parameters.clone())
91 } else {
92 (new_description, new_parameters)
93 }
94}
95
96fn minify_node(node: &mut Value, tier: SchemaTier) {
111 let Some(map) = node.as_object_mut() else {
112 return;
113 };
114 map.remove("examples");
115 map.remove("title");
116
117 if let Some(Value::String(d)) = map.get("description").cloned() {
120 let n = if tier == SchemaTier::Minimal { 1 } else { 2 };
121 map.insert(
122 "description".to_string(),
123 Value::String(truncate_sentences(&d, n)),
124 );
125 }
126
127 let required: Vec<String> = map
128 .get("required")
129 .and_then(Value::as_array)
130 .map(|a| {
131 a.iter()
132 .filter_map(|v| v.as_str().map(str::to_string))
133 .collect()
134 })
135 .unwrap_or_default();
136
137 if let Some(props) = map.get_mut("properties").and_then(|p| p.as_object_mut()) {
138 let keys: Vec<String> = props.keys().cloned().collect();
139 for key in keys {
140 let is_required = required.iter().any(|r| r == &key);
141 let Some(prop) = props.get_mut(&key) else {
142 continue;
143 };
144 if let Some(pm) = prop.as_object_mut() {
145 pm.remove("examples");
146 pm.remove("title");
147 if tier == SchemaTier::Minimal && !is_required {
151 pm.remove("description");
152 } else if let Some(Value::String(d)) = pm.get("description").cloned() {
153 pm.insert(
154 "description".to_string(),
155 Value::String(truncate_sentences(&d, 1)),
156 );
157 }
158 }
159 minify_node(prop, tier);
164 }
165 }
166
167 if let Some(items) = map.get_mut("items") {
170 match items {
171 Value::Array(items) => {
172 for item in items {
173 minify_node(item, tier);
174 }
175 }
176 _ => minify_node(items, tier),
177 }
178 }
179
180 for key in ["anyOf", "oneOf", "allOf"] {
182 if let Some(Value::Array(arr)) = map.get_mut(key) {
183 for item in arr {
184 minify_node(item, tier);
185 }
186 }
187 }
188
189 for key in ["if", "then", "else"] {
191 if let Some(v) = map.get_mut(key) {
192 minify_node(v, tier);
193 }
194 }
195
196 for key in ["$defs", "definitions", "patternProperties"] {
200 if let Some(Value::Object(sub)) = map.get_mut(key) {
201 for v in sub.values_mut() {
202 minify_node(v, tier);
203 }
204 }
205 }
206}
207
208const ABBREVIATIONS: &[&str] = &["e.g.", "i.e.", "etc.", "Mr.", "Mrs.", "Dr.", "vs.", "cf."];
214
215fn truncate_sentences(s: &str, n: usize) -> String {
225 if n == 0 || s.is_empty() {
226 return s.to_string();
227 }
228 let bytes = s.as_bytes();
229 let mut count = 0;
230 for (i, &b) in bytes.iter().enumerate() {
231 if b == b'.' || b == b'!' || b == b'?' {
232 let boundary = i + 1 == bytes.len() || bytes[i + 1] == b' ' || bytes[i + 1] == b'\n';
233 if boundary {
234 if b == b'.' && ABBREVIATIONS.iter().any(|a| s[..=i].ends_with(a)) {
235 continue;
236 }
237 count += 1;
238 if count >= n {
239 return s[..=i].to_string();
240 }
241 }
242 }
243 }
244 s.to_string()
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use serde_json::json;
251
252 #[test]
253 fn full_tier_is_identity() {
254 let desc = "A very long description. With two sentences.";
255 let params = json!({"type":"object","properties":{"a":{"type":"string","description":"x","examples":["e"]}},"required":["a"]});
256 let (d, p) = minify(desc, ¶ms, SchemaTier::Full);
257 assert_eq!(d, desc);
258 assert_eq!(p, params);
259 }
260
261 #[test]
262 fn truncate_sentences_cuts_at_boundary() {
263 assert_eq!(truncate_sentences("One. Two. Three.", 1), "One.");
264 assert_eq!(truncate_sentences("One. Two. Three.", 2), "One. Two.");
265 assert_eq!(
266 truncate_sentences("No punctuation here", 1),
267 "No punctuation here"
268 );
269 assert_eq!(truncate_sentences("", 1), "");
270 }
271
272 #[test]
273 fn minimal_drops_optional_param_descriptions_keeps_required() {
274 let desc = "Does a thing. Has more detail. Even more.";
275 let params = json!({
276 "type": "object",
277 "properties": {
278 "req": {"type": "string", "description": "The required one. More detail here."},
279 "opt": {"type": "integer", "description": "The optional one. More detail here."}
280 },
281 "required": ["req"]
282 });
283 let (d, p) = minify(desc, ¶ms, SchemaTier::Minimal);
284 assert_eq!(d, "Does a thing.");
285 assert_eq!(p["properties"]["req"]["description"], "The required one.");
286 assert!(p["properties"]["opt"].get("description").is_none());
287 assert_eq!(p["properties"]["req"]["type"], "string");
289 assert_eq!(p["properties"]["opt"]["type"], "integer");
290 assert_eq!(p["required"], json!(["req"]));
291 }
292
293 #[test]
294 fn examples_and_title_stripped_at_every_tier_above_full() {
295 let params = json!({
296 "type": "object",
297 "title": "Top title",
298 "properties": {
299 "a": {"type": "string", "examples": ["x"], "title": "A title"}
300 },
301 "required": []
302 });
303 let (_, p) = minify("desc.", ¶ms, SchemaTier::Medium);
304 assert!(p.get("title").is_none());
305 assert!(p["properties"]["a"].get("examples").is_none());
306 assert!(p["properties"]["a"].get("title").is_none());
307 }
308
309 #[test]
310 fn byte_floor_never_grows_already_terse_schema() {
311 let desc = "Short.";
312 let params = json!({"type":"object","properties":{"a":{"type":"string"}},"required":["a"]});
313 let (d, p) = minify(desc, ¶ms, SchemaTier::Minimal);
314 assert_eq!(d, desc);
315 assert_eq!(p, params);
316 }
317
318 #[test]
319 fn nested_object_properties_get_required_aware_treatment_too() {
320 let params = json!({
321 "type": "object",
322 "properties": {
323 "outer": {
324 "type": "object",
325 "properties": {
326 "inner_req": {"type": "string", "description": "Inner required. More."},
327 "inner_opt": {"type": "string", "description": "Inner optional. More."}
328 },
329 "required": ["inner_req"]
330 }
331 },
332 "required": ["outer"]
333 });
334 let (_, p) = minify("desc. more.", ¶ms, SchemaTier::Minimal);
335 let outer = &p["properties"]["outer"];
336 assert_eq!(
337 outer["properties"]["inner_req"]["description"],
338 "Inner required."
339 );
340 assert!(outer["properties"]["inner_opt"]
341 .get("description")
342 .is_none());
343 assert_eq!(outer["properties"]["inner_req"]["type"], "string");
344 assert_eq!(outer["properties"]["inner_opt"]["type"], "string");
345 }
346
347 #[test]
348 fn array_items_are_recursed_into() {
349 let params = json!({
350 "type": "object",
351 "properties": {
352 "list": {
353 "type": "array",
354 "items": {
355 "type": "object",
356 "properties": {
357 "field": {"type": "string", "description": "Field desc. More detail.", "examples": ["e"]}
358 },
359 "required": []
360 }
361 }
362 },
363 "required": []
364 });
365 let (_, p) = minify("desc. more.", ¶ms, SchemaTier::Minimal);
366 let field = &p["properties"]["list"]["items"]["properties"]["field"];
367 assert!(field.get("examples").is_none());
368 assert!(
369 field.get("description").is_none(),
370 "not required at that nesting level"
371 );
372 assert_eq!(field["type"], "string");
373 }
374
375 #[test]
376 fn truncate_sentences_ignores_common_abbreviations() {
377 assert_eq!(
378 truncate_sentences("See e.g. the docs. Second sentence.", 1),
379 "See e.g. the docs."
380 );
381 assert_eq!(
382 truncate_sentences(
383 "Ask Dr. Smith for the etc. items, i.e. all of them. Next.",
384 1
385 ),
386 "Ask Dr. Smith for the etc. items, i.e. all of them."
387 );
388 assert_eq!(
390 truncate_sentences("Contact Mr. Lee. Thanks.", 2),
391 "Contact Mr. Lee. Thanks."
392 );
393 assert_eq!(
394 truncate_sentences("Contact Mr. Lee. Thanks.", 1),
395 "Contact Mr. Lee."
396 );
397 }
398
399 #[test]
404 fn combinators_are_recursed_into_and_minified() {
405 let params = json!({
406 "type": "object",
407 "properties": {
408 "payload": {
409 "anyOf": [
410 {
411 "type": "object",
412 "description": "First shape of the payload, used for the legacy request format. It carries a lot of historical baggage. Keep reading for details.",
413 "properties": {
414 "a": {"type": "string", "description": "The a field. It represents something important. More context follows here."}
415 },
416 "required": ["a"]
417 },
418 {
419 "type": "object",
420 "description": "Second shape of the payload, used for the modern request format. It is much simpler than the legacy one. Keep reading for details.",
421 "properties": {
422 "b": {"type": "integer", "description": "The b field. It represents something else important. More context follows here."}
423 },
424 "required": ["b"]
425 }
426 ]
427 },
428 "mode": {
429 "oneOf": [
430 {"type": "string", "const": "fast", "description": "Fast mode trades accuracy for speed. Use when latency matters most. Read the docs for tradeoffs."},
431 {"type": "string", "const": "slow", "description": "Slow mode trades speed for accuracy. Use when correctness matters most. Read the docs for tradeoffs."}
432 ]
433 },
434 "combo": {
435 "allOf": [
436 {
437 "type": "object",
438 "description": "Base combo shape shared by every variant. It defines the common envelope fields. Read carefully before extending.",
439 "properties": {
440 "id": {"type": "string", "description": "The identifier. Must be globally unique. Formatted as a UUID."}
441 },
442 "required": ["id"]
443 },
444 {
445 "type": "object",
446 "description": "Extension combo shape layered on top of the base envelope. It adds variant-specific fields. Read carefully before extending.",
447 "properties": {
448 "extra": {"type": "string", "description": "Extra data. Optional free-form text. Formatted as plain UTF-8."}
449 },
450 "required": []
451 }
452 ]
453 }
454 },
455 "$defs": {
456 "Widget": {
457 "type": "object",
458 "description": "A reusable widget definition referenced elsewhere in this schema via $ref. It has a long explanatory blurb here for testing.",
459 "properties": {
460 "name": {"type": "string", "description": "The widget's name. Must be unique within its namespace. Free-form text otherwise."}
461 },
462 "required": ["name"]
463 }
464 },
465 "required": ["payload"]
466 });
467
468 let orig_bytes = serde_json::to_string(¶ms).unwrap().len();
469 let (_, p) = minify("desc. more. even more.", ¶ms, SchemaTier::Minimal);
470 let new_bytes = serde_json::to_string(&p).unwrap().len();
471
472 assert!(
475 new_bytes < orig_bytes * 7 / 10,
476 "expected a meaningful size cut, got {orig_bytes} -> {new_bytes} bytes"
477 );
478
479 let any_of = &p["properties"]["payload"]["anyOf"];
481 assert_eq!(
482 any_of[0]["description"],
483 "First shape of the payload, used for the legacy request format."
484 );
485 assert_eq!(any_of[0]["properties"]["a"]["description"], "The a field.");
486 assert_eq!(any_of[0]["properties"]["a"]["type"], "string");
487 assert_eq!(any_of[0]["required"], json!(["a"]));
488 assert_eq!(any_of[1]["properties"]["b"]["type"], "integer");
489 assert_eq!(any_of[1]["required"], json!(["b"]));
490
491 let one_of = &p["properties"]["mode"]["oneOf"];
493 assert_eq!(
494 one_of[0]["description"],
495 "Fast mode trades accuracy for speed."
496 );
497 assert_eq!(one_of[0]["type"], "string");
498 assert_eq!(one_of[0]["const"], "fast");
499
500 let all_of = &p["properties"]["combo"]["allOf"];
503 assert_eq!(
504 all_of[0]["description"],
505 "Base combo shape shared by every variant."
506 );
507 assert_eq!(
508 all_of[0]["properties"]["id"]["description"],
509 "The identifier."
510 );
511 assert!(all_of[1]["properties"]["extra"]
512 .get("description")
513 .is_none());
514 assert_eq!(all_of[1]["required"], json!([]));
515
516 let widget = &p["$defs"]["Widget"];
518 assert_eq!(
519 widget["description"],
520 "A reusable widget definition referenced elsewhere in this schema via $ref."
521 );
522 assert_eq!(
523 widget["properties"]["name"]["description"],
524 "The widget's name."
525 );
526 assert_eq!(widget["properties"]["name"]["type"], "string");
527 assert_eq!(widget["required"], json!(["name"]));
528
529 assert_eq!(p["required"], json!(["payload"]));
531 assert_eq!(p["type"], "object");
532 }
533
534 #[test]
537 fn if_then_else_definitions_and_pattern_properties_are_recursed_into() {
538 let params = json!({
539 "type": "object",
540 "if": {"type": "object", "description": "Condition branch description. More detail here.", "properties": {"x": {"type": "string"}}},
541 "then": {"type": "object", "description": "Then branch description. More detail here.", "properties": {"y": {"type": "string", "description": "Y field. More detail here."}}, "required": ["y"]},
542 "else": {"type": "object", "description": "Else branch description. More detail here.", "properties": {"z": {"type": "string", "description": "Z field. More detail here."}}, "required": []},
543 "definitions": {
544 "Old": {"type": "object", "description": "Legacy definition kept for draft-7 compatibility. More detail here.", "properties": {"n": {"type": "string"}}}
545 },
546 "patternProperties": {
547 "^S_": {"type": "string", "description": "Pattern-matched string property. More detail here."}
548 },
549 "properties": {},
550 "required": []
551 });
552 let (_, p) = minify("desc. more.", ¶ms, SchemaTier::Minimal);
553 assert_eq!(p["if"]["description"], "Condition branch description.");
554 assert_eq!(p["then"]["description"], "Then branch description.");
555 assert_eq!(p["then"]["properties"]["y"]["description"], "Y field.");
556 assert_eq!(p["else"]["description"], "Else branch description.");
557 assert!(p["else"]["properties"]["z"].get("description").is_none());
558 assert_eq!(
559 p["definitions"]["Old"]["description"],
560 "Legacy definition kept for draft-7 compatibility."
561 );
562 assert_eq!(
563 p["patternProperties"]["^S_"]["description"],
564 "Pattern-matched string property."
565 );
566 }
567
568 #[test]
572 fn tuple_style_items_array_is_recursed_into() {
573 let params = json!({
574 "type": "array",
575 "items": [
576 {"type": "string", "description": "First tuple slot description. More detail here.", "examples": ["e"]},
577 {"type": "integer", "description": "Second tuple slot description. More detail here.", "title": "t"}
578 ]
579 });
580 let (_, p) = minify("desc. more.", ¶ms, SchemaTier::Minimal);
581 assert_eq!(
582 p["items"][0]["description"],
583 "First tuple slot description."
584 );
585 assert!(p["items"][0].get("examples").is_none());
586 assert_eq!(p["items"][0]["type"], "string");
587 assert_eq!(
588 p["items"][1]["description"],
589 "Second tuple slot description."
590 );
591 assert!(p["items"][1].get("title").is_none());
592 assert_eq!(p["items"][1]["type"], "integer");
593 }
594}