1use std::collections::BTreeSet;
24use std::path::{Path, PathBuf};
25
26use serde_json::{Map, Value};
27
28use crate::error::{Error, Result};
29use crate::toolkit::streaming::DEFAULT_CUTTABLE_KEYS;
30
31pub const SCHEMA_BLOCK_START: &str = "---BEGIN A2UI JSON SCHEMA---";
36pub const SCHEMA_BLOCK_END: &str = "---END A2UI JSON SCHEMA---";
38
39#[derive(Debug, Clone, Default, PartialEq)]
41pub struct SchemaBundle {
42 pub s2c: Value,
44 pub common_types: Value,
46 pub catalog: Value,
48 pub custom_cuttable_keys: Option<Vec<String>>,
53}
54
55impl SchemaBundle {
56 pub fn from_catalog(catalog: Value) -> Self {
58 Self {
59 catalog,
60 ..Self::default()
61 }
62 }
63
64 pub fn catalog_id(&self) -> Option<&str> {
66 self.catalog.get("catalogId").and_then(Value::as_str)
67 }
68
69 pub fn cuttable_keys(&self) -> Vec<String> {
71 match &self.custom_cuttable_keys {
72 Some(keys) => keys.clone(),
73 None => DEFAULT_CUTTABLE_KEYS
74 .iter()
75 .map(|key| (*key).to_string())
76 .collect(),
77 }
78 }
79
80 pub fn render_llm_instructions(&self) -> String {
86 let mut sections = vec![SCHEMA_BLOCK_START.to_string()];
87 sections.push(format!(
88 "### Server To Client Schema:\n{}",
89 compact(&self.s2c)
90 ));
91 if has_defs(&self.common_types) {
92 sections.push(format!(
93 "### Common Types Schema:\n{}",
94 compact(&self.common_types)
95 ));
96 }
97 sections.push(format!("### Catalog Schema:\n{}", compact(&self.catalog)));
98 sections.push(SCHEMA_BLOCK_END.to_string());
99 sections.join("\n\n")
100 }
101
102 #[must_use]
108 pub fn prune(mut self, allowed_components: &[String], allowed_messages: &[String]) -> Self {
109 if !allowed_components.is_empty() {
110 prune_components(&mut self.catalog, allowed_components);
111 }
112 if !allowed_messages.is_empty() {
113 prune_messages(&mut self.s2c, allowed_messages);
114 }
115 prune_common_types(&mut self.common_types, &[&self.catalog, &self.s2c]);
118 self
119 }
120
121 #[must_use]
127 pub fn relaxed(mut self) -> Self {
128 remove_strict_validation(&mut self.s2c);
129 remove_strict_validation(&mut self.common_types);
130 remove_strict_validation(&mut self.catalog);
131 self
132 }
133}
134
135fn compact(value: &Value) -> String {
136 serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
137}
138
139fn has_defs(schema: &Value) -> bool {
140 schema
141 .get("$defs")
142 .and_then(Value::as_object)
143 .is_some_and(|defs| !defs.is_empty())
144}
145
146pub(crate) fn prune_components(catalog: &mut Value, allowed: &[String]) {
149 let allowed: BTreeSet<&str> = allowed.iter().map(String::as_str).collect();
150 let Some(object) = catalog.as_object_mut() else {
151 return;
152 };
153 if let Some(Value::Object(components)) = object.get_mut("components") {
154 components.retain(|name, _| allowed.contains(name.as_str()));
155 }
156 if let Some(Value::Object(defs)) = object.get_mut("$defs") {
159 if let Some(Value::Object(any_component)) = defs.get_mut("anyComponent") {
160 for key in ["oneOf", "anyOf"] {
161 if let Some(Value::Array(branches)) = any_component.get_mut(key) {
162 branches.retain(|branch| match branch.get("$ref").and_then(Value::as_str) {
163 Some(reference) => reference
164 .rsplit('/')
165 .next()
166 .is_some_and(|name| allowed.contains(name)),
167 None => true,
168 });
169 }
170 }
171 }
172 }
173}
174
175pub(crate) fn prune_messages(s2c: &mut Value, allowed: &[String]) {
180 let allowed: BTreeSet<&str> = allowed.iter().map(String::as_str).collect();
181 let Some(object) = s2c.as_object_mut() else {
182 return;
183 };
184
185 let mut pruned_union = false;
186 for key in ["oneOf", "anyOf"] {
187 if let Some(Value::Array(branches)) = object.get_mut(key) {
188 branches.retain(|branch| match branch.get("$ref").and_then(Value::as_str) {
189 Some(reference) => reference
190 .rsplit('/')
191 .next()
192 .is_some_and(|name| allowed.contains(name)),
193 None => true,
194 });
195 pruned_union = true;
196 }
197 }
198 if let Some(Value::Object(properties)) = object.get_mut("properties") {
199 if !pruned_union {
200 properties.retain(|name, _| allowed.contains(name.as_str()));
201 }
202 }
203
204 if pruned_union {
205 let roots: Vec<Value> = object
206 .iter()
207 .filter(|(key, _)| *key != "$defs")
208 .map(|(_, value)| value.clone())
209 .collect();
210 let roots: Vec<&Value> = roots.iter().collect();
211 retain_reachable_defs(object, &roots, "#/$defs/");
212 }
213}
214
215pub(crate) fn prune_common_types(common_types: &mut Value, referenced_by: &[&Value]) {
217 let Some(object) = common_types.as_object_mut() else {
218 return;
219 };
220 if object.get("$defs").and_then(Value::as_object).is_none() {
221 return;
222 }
223 retain_reachable_defs(object, referenced_by, "common_types.json#/$defs/");
224}
225
226fn retain_reachable_defs(object: &mut Map<String, Value>, roots: &[&Value], external_prefix: &str) {
231 let Some(defs) = object.get("$defs").and_then(Value::as_object).cloned() else {
232 return;
233 };
234
235 let mut keep: BTreeSet<String> = BTreeSet::new();
236 let mut queue: Vec<Value> = roots.iter().map(|root| (*root).clone()).collect();
237
238 while let Some(value) = queue.pop() {
239 for reference in collect_refs(&value) {
240 let name = reference
241 .strip_prefix(external_prefix)
242 .or_else(|| reference.strip_prefix("#/$defs/"))
243 .map(str::to_string);
244 let Some(name) = name else { continue };
245 if !defs.contains_key(&name) || !keep.insert(name.clone()) {
246 continue;
247 }
248 if let Some(definition) = defs.get(&name) {
249 queue.push(definition.clone());
250 }
251 }
252 }
253
254 if let Some(Value::Object(target)) = object.get_mut("$defs") {
255 target.retain(|name, _| keep.contains(name));
256 }
257}
258
259fn collect_refs(value: &Value) -> Vec<String> {
261 let mut out = Vec::new();
262 walk_refs(value, &mut out);
263 out
264}
265
266fn walk_refs(value: &Value, out: &mut Vec<String>) {
267 match value {
268 Value::Object(map) => {
269 for (key, child) in map {
270 if key == "$ref" {
271 if let Some(reference) = child.as_str() {
272 out.push(reference.to_string());
273 }
274 }
275 walk_refs(child, out);
276 }
277 }
278 Value::Array(items) => {
279 for item in items {
280 walk_refs(item, out);
281 }
282 }
283 _ => {}
284 }
285}
286
287pub fn remove_strict_validation(schema: &mut Value) {
289 match schema {
290 Value::Object(map) => {
291 if map.get("additionalProperties") == Some(&Value::Bool(false)) {
292 map.remove("additionalProperties");
293 }
294 for child in map.values_mut() {
295 remove_strict_validation(child);
296 }
297 }
298 Value::Array(items) => {
299 for item in items {
300 remove_strict_validation(item);
301 }
302 }
303 _ => {}
304 }
305}
306
307pub fn load_examples(path: Option<&Path>, validate: bool) -> Result<String> {
322 let Some(path) = path else {
323 return Ok(String::new());
324 };
325 let mut matches = if path.is_dir() {
326 collect_matches(path, "*.json")
327 } else {
328 let pattern = path.to_string_lossy().to_string();
329 let (base, pattern) = split_pattern(&pattern);
330 collect_matches(&base, &pattern)
331 };
332 matches.sort();
333
334 let mut blocks = Vec::new();
335 for file in matches {
336 if !file.is_file() {
337 continue;
338 }
339 let stem = file
340 .file_stem()
341 .map(|stem| stem.to_string_lossy().to_string())
342 .unwrap_or_default();
343 let content = std::fs::read_to_string(&file).map_err(|e| {
344 Error::catalog(format!("Failed to read example {}: {e}", file.display()))
345 })?;
346 if validate {
347 serde_json::from_str::<Value>(&content).map_err(|e| {
348 Error::catalog(format!(
349 "Failed to validate example {}: {e}",
350 file.display()
351 ))
352 })?;
353 }
354 blocks.push(format!("---BEGIN {stem}---\n{content}\n---END {stem}---"));
355 }
356 Ok(blocks.join("\n\n"))
357}
358
359fn split_pattern(pattern: &str) -> (PathBuf, String) {
365 let segments: Vec<&str> = pattern.split('/').collect();
366 let first_wildcard = segments
367 .iter()
368 .position(|segment| segment.contains(['*', '?', '[']));
369
370 let split_at = match first_wildcard {
371 Some(index) => index,
372 None => segments.len().saturating_sub(1),
374 };
375 let base = segments[..split_at].join("/");
376 let base = match base.as_str() {
377 "" if pattern.starts_with('/') => PathBuf::from("/"),
378 "" => PathBuf::from("."),
379 other => PathBuf::from(other),
380 };
381 (base, segments[split_at..].join("/"))
382}
383
384fn collect_matches(base: &Path, pattern: &str) -> Vec<PathBuf> {
386 let recursive = pattern.contains("**");
387 let mut out = Vec::new();
388 let mut stack = vec![base.to_path_buf()];
389
390 while let Some(dir) = stack.pop() {
391 let Ok(entries) = std::fs::read_dir(&dir) else {
392 continue;
393 };
394 for entry in entries.flatten() {
395 let path = entry.path();
396 if path.is_dir() {
397 if recursive {
398 stack.push(path);
399 }
400 continue;
401 }
402 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy();
403 if glob_match(pattern, &relative) {
404 out.push(path);
405 }
406 }
407 }
408 out
409}
410
411pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
417 if let Some(rest) = pattern.strip_prefix("**/") {
419 if glob_match(rest, text) {
420 return true;
421 }
422 if let Some((_, tail)) = text.split_once('/') {
423 return glob_match(pattern, tail);
424 }
425 return false;
426 }
427 match_here(
428 &pattern.chars().collect::<Vec<_>>(),
429 &text.chars().collect::<Vec<_>>(),
430 )
431}
432
433fn match_here(pattern: &[char], text: &[char]) -> bool {
434 let mut p = 0;
435 let mut t = 0;
436 let mut star: Option<(usize, usize)> = None;
438
439 while t < text.len() {
440 match pattern.get(p) {
441 Some('*') => {
442 star = Some((p, t));
443 p += 1;
444 }
445 Some('?') if text[t] != '/' => {
446 p += 1;
447 t += 1;
448 }
449 Some('[') => match match_class(pattern, p, text[t]) {
450 Some(next) => {
451 p = next;
452 t += 1;
453 }
454 None => match retry(&mut star, &mut p, &mut t, text) {
455 true => continue,
456 false => return false,
457 },
458 },
459 Some(ch) if *ch == text[t] => {
460 p += 1;
461 t += 1;
462 }
463 _ => {
464 if !retry(&mut star, &mut p, &mut t, text) {
465 return false;
466 }
467 }
468 }
469 }
470 while pattern.get(p) == Some(&'*') {
471 p += 1;
472 }
473 p == pattern.len()
474}
475
476fn retry(star: &mut Option<(usize, usize)>, p: &mut usize, t: &mut usize, text: &[char]) -> bool {
478 match star {
479 Some((sp, st)) if text[*st] != '/' => {
481 *p = *sp + 1;
482 *st += 1;
483 *t = *st;
484 true
485 }
486 _ => false,
487 }
488}
489
490fn match_class(pattern: &[char], open: usize, ch: char) -> Option<usize> {
492 let mut i = open + 1;
493 let negated = matches!(pattern.get(i), Some('!') | Some('^'));
494 if negated {
495 i += 1;
496 }
497 let mut matched = false;
498 let mut first = true;
499 while i < pattern.len() {
500 if pattern[i] == ']' && !first {
501 return (matched != negated).then_some(i + 1);
502 }
503 first = false;
504 if pattern.get(i + 1) == Some(&'-') && pattern.get(i + 2).is_some_and(|c| *c != ']') {
506 let (low, high) = (pattern[i], pattern[i + 2]);
507 if low <= ch && ch <= high {
508 matched = true;
509 }
510 i += 3;
511 continue;
512 }
513 if pattern[i] == ch {
514 matched = true;
515 }
516 i += 1;
517 }
518 None
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use serde_json::json;
525
526 #[test]
527 fn the_schema_block_carries_all_three_documents() {
528 let bundle = SchemaBundle {
529 s2c: json!({"s2c": "schema"}),
530 common_types: json!({"$defs": {"common": "types"}}),
531 catalog: json!({"catalogId": "id_basic"}),
532 custom_cuttable_keys: None,
533 };
534 let rendered = bundle.render_llm_instructions();
535 assert_eq!(
536 rendered,
537 "---BEGIN A2UI JSON SCHEMA---\n\n\
538 ### Server To Client Schema:\n{\"s2c\":\"schema\"}\n\n\
539 ### Common Types Schema:\n{\"$defs\":{\"common\":\"types\"}}\n\n\
540 ### Catalog Schema:\n{\"catalogId\":\"id_basic\"}\n\n\
541 ---END A2UI JSON SCHEMA---"
542 );
543 }
544
545 #[test]
546 fn empty_common_types_are_left_out_of_the_block() {
547 for common in [
548 json!({}),
549 json!({"something": "else"}),
550 json!({"$defs": {}}),
551 ] {
552 let bundle = SchemaBundle {
553 s2c: json!({"s2c": "schema"}),
554 common_types: common,
555 catalog: json!({"catalogId": "id"}),
556 custom_cuttable_keys: None,
557 };
558 let rendered = bundle.render_llm_instructions();
559 assert!(!rendered.contains("Common Types Schema"), "{rendered}");
560 assert!(rendered.contains("Server To Client Schema"));
561 }
562 }
563
564 #[test]
565 fn pruning_components_also_prunes_the_union() {
566 let mut catalog = json!({
567 "catalogId": "basic",
568 "$defs": {"anyComponent": {"oneOf": [
569 {"$ref": "#/components/Text"},
570 {"$ref": "#/components/Button"},
571 {"$ref": "#/components/Image"}
572 ]}},
573 "components": {"Text": {}, "Button": {}, "Image": {}}
574 });
575 prune_components(&mut catalog, &["Text".to_string()]);
576 assert_eq!(
577 catalog,
578 json!({
579 "catalogId": "basic",
580 "$defs": {"anyComponent": {"oneOf": [{"$ref": "#/components/Text"}]}},
581 "components": {"Text": {}}
582 })
583 );
584 }
585
586 #[test]
587 fn pruning_messages_drops_unreachable_definitions() {
588 let mut s2c = json!({
589 "oneOf": [{"$ref": "#/$defs/MessageA"}],
590 "$defs": {
591 "MessageA": {"type": "object", "properties": {"shared": {"$ref": "#/$defs/Shared"}}},
592 "Shared": {"type": "string"},
593 "Unused": {"type": "number"}
594 }
595 });
596 prune_messages(&mut s2c, &["MessageA".to_string()]);
597 let defs = s2c["$defs"].as_object().unwrap();
598 assert!(defs.contains_key("MessageA"));
599 assert!(
600 defs.contains_key("Shared"),
601 "a referenced type must survive"
602 );
603 assert!(!defs.contains_key("Unused"));
604 }
605
606 #[test]
607 fn a_flat_message_map_prunes_by_property_name() {
608 let mut s2c = json!({
609 "properties": {
610 "beginRendering": {"type": "object"},
611 "surfaceUpdate": {"type": "object"},
612 "deleteSurface": {"type": "object"}
613 },
614 "required": ["surfaceId"]
615 });
616 prune_messages(
617 &mut s2c,
618 &["beginRendering".to_string(), "deleteSurface".to_string()],
619 );
620 assert_eq!(
621 s2c,
622 json!({
623 "properties": {
624 "beginRendering": {"type": "object"},
625 "deleteSurface": {"type": "object"}
626 },
627 "required": ["surfaceId"]
628 })
629 );
630 }
631
632 #[test]
633 fn common_types_follow_what_still_references_them() {
634 let bundle = SchemaBundle {
635 s2c: Value::Null,
636 common_types: json!({"$defs": {
637 "TypeForA": {"type": "string", "$ref": "#/$defs/SubtypeForA"},
638 "TypeForB": {"type": "number"},
639 "SubtypeForA": {"type": "boolean"}
640 }}),
641 catalog: json!({"catalogId": "basic", "components": {
642 "CompA": {"$ref": "common_types.json#/$defs/TypeForA"},
643 "CompB": {"$ref": "common_types.json#/$defs/TypeForB"}
644 }}),
645 custom_cuttable_keys: None,
646 };
647 let pruned = bundle.prune(&["CompA".to_string()], &[]);
648 assert_eq!(
649 pruned.common_types,
650 json!({"$defs": {
651 "TypeForA": {"type": "string", "$ref": "#/$defs/SubtypeForA"},
652 "SubtypeForA": {"type": "boolean"}
653 }})
654 );
655 }
656
657 #[test]
658 fn strict_validation_is_removed_but_explicit_true_is_kept() {
659 let mut schema = json!({
660 "type": "object",
661 "properties": {
662 "a": {"type": "string", "additionalProperties": false},
663 "b": {"type": "array", "items": {"type": "object", "additionalProperties": false}}
664 },
665 "additionalProperties": false
666 });
667 remove_strict_validation(&mut schema);
668 assert_eq!(
669 schema,
670 json!({
671 "type": "object",
672 "properties": {
673 "a": {"type": "string"},
674 "b": {"type": "array", "items": {"type": "object"}}
675 }
676 })
677 );
678
679 let mut kept = json!({"type": "object", "additionalProperties": true});
680 remove_strict_validation(&mut kept);
681 assert_eq!(
682 kept,
683 json!({"type": "object", "additionalProperties": true})
684 );
685 }
686
687 #[test]
688 fn cuttable_keys_default_until_overridden() {
689 let bundle = SchemaBundle::default();
690 assert!(bundle.cuttable_keys().contains(&"text".to_string()));
691
692 let custom = SchemaBundle {
693 custom_cuttable_keys: Some(vec!["customKey1".to_string()]),
694 ..SchemaBundle::default()
695 };
696 assert_eq!(custom.cuttable_keys(), vec!["customKey1".to_string()]);
697 }
698
699 #[test]
700 fn globs_cover_stars_classes_ranges_and_negation() {
701 assert!(glob_match("*.json", "example1.json"));
702 assert!(!glob_match("*.json", "notes.txt"));
703 assert!(glob_match("user_*.json", "user_profile.json"));
704 assert!(!glob_match("user_*.json", "admin_profile.json"));
705 assert!(glob_match("step[1-2].json", "step1.json"));
706 assert!(!glob_match("step[1-2].json", "step3.json"));
707 assert!(glob_match("[!i]*.json", "visible.json"));
708 assert!(!glob_match("[!i]*.json", "index.json"));
709 assert!(glob_match("**/*.json", "top.json"));
710 assert!(glob_match("**/*.json", "nested/deep.json"));
711 assert!(!glob_match("*.json", "nested/deep.json"));
713 assert!(glob_match("a?c.json", "abc.json"));
714 }
715
716 #[test]
717 fn a_missing_examples_path_is_not_an_error() {
718 assert_eq!(load_examples(None, false).unwrap(), "");
719 assert_eq!(
720 load_examples(Some(Path::new("/no/such/place")), false).unwrap(),
721 ""
722 );
723 }
724}