返回 CodeWhale
schema_canonicalize.rs
根目录 / crates / tui / src / tools / schema_canonicalize.rs
1 //! Byte-level canonicalization of JSON Schema for prefix-cache stability.
2 //!
3 //! When MCP servers return tool schemas, the field order within each schema
4 //! object and the order of entries in `required` / `dependentRequired` arrays
5 //! can vary across reconnections. This module normalizes those orderings so
6 //! that two logically equivalent schemas always produce identical bytes after
7 //! serialization.
8 //!
9 //! The approach mirrors `reasonix/internal/provider/schema_canonicalize.go`:
10 //!
11 //! 1. Sort every `"required"` array alphabetically.
12 //! 2. Sort every `"dependentRequired"` sub-array alphabetically.
13 //! 3. Recurse into all nested objects and arrays.
14 //!
15 //! `serde_json::Value::Object` uses `IndexMap` when `preserve_order` is
16 //! enabled (which this crate does). We therefore rebuild the map with sorted
17 //! keys to guarantee deterministic key ordering.
18
19 use serde_json::Value;
20
21 /// Recursively canonicalize a JSON Schema value in-place.
22 ///
23 /// After canonicalization, two schemas that are semantically equivalent
24 /// (same keys, same `required` set, same `dependentRequired` sets) will
25 /// serialize to byte-identical JSON regardless of the original field or
26 /// array order.
27 pub fn canonicalize_schema(value: &mut Value) {
28 match value {
29 Value::Object(map) => {
30 // Sort `required` arrays (they are sets per JSON Schema spec).
31 if let Some(Value::Array(req)) = map.get_mut("required") {
32 sort_string_array(req);
33 }
34 // Sort `dependentRequired` sub-arrays.
35 if let Some(Value::Object(deps)) = map.get_mut("dependentRequired") {
36 for dep_value in deps.values_mut() {
37 if let Value::Array(arr) = dep_value {
38 sort_string_array(arr);
39 }
40 }
41 }
42 // Recurse into every child value.
43 for v in map.values_mut() {
44 canonicalize_schema(v);
45 }
46 // Rebuild the map with sorted keys so serialization is deterministic.
47 // serde_json::Map backed by IndexMap (preserve_order) doesn't have
48 // drain(), so we swap to a temporary and rebuild.
49 let old = std::mem::take(map);
50 let mut entries: Vec<(String, Value)> = old.into_iter().collect();
51 entries.sort_by(|a, b| a.0.cmp(&b.0));
52 for (k, v) in entries {
53 map.insert(k, v);
54 }
55 }
56 Value::Array(arr) => {
57 for v in arr.iter_mut() {
58 canonicalize_schema(v);
59 }
60 }
61 _ => {}
62 }
63 }
64
65 /// Sort a JSON array of string values alphabetically in-place.
66 ///
67 /// Non-string entries are left at the end in their original relative order.
68 fn sort_string_array(arr: &mut [Value]) {
69 arr.sort_by(|a, b| match (a.as_str(), b.as_str()) {
70 (Some(x), Some(y)) => x.cmp(y),
71 (Some(_), None) => std::cmp::Ordering::Less,
72 (None, Some(_)) => std::cmp::Ordering::Greater,
73 (None, None) => std::cmp::Ordering::Equal,
74 });
75 }
76
77 #[cfg(test)]
78 mod tests {
79 use super::*;
80 use serde_json::json;
81
82 #[test]
83 fn sorts_required_array() {
84 let mut schema = json!({
85 "type": "object",
86 "required": ["z", "a", "m"],
87 "properties": {}
88 });
89 canonicalize_schema(&mut schema);
90 assert_eq!(schema["required"], json!(["a", "m", "z"]));
91 }
92
93 #[test]
94 fn equivalent_ordering_matches() {
95 // Two schemas that differ only in field order and required order
96 // must serialize to identical bytes.
97 let mut a = json!({
98 "required": ["b", "a"],
99 "properties": {"x": {}, "y": {}},
100 "type": "object"
101 });
102 let mut b = json!({
103 "type": "object",
104 "properties": {"y": {}, "x": {}},
105 "required": ["a", "b"]
106 });
107 canonicalize_schema(&mut a);
108 canonicalize_schema(&mut b);
109 assert_eq!(
110 serde_json::to_string(&a).unwrap(),
111 serde_json::to_string(&b).unwrap(),
112 "logically equivalent schemas must produce identical bytes"
113 );
114 }
115
116 #[test]
117 fn sorts_dependent_required() {
118 let mut schema = json!({
119 "type": "object",
120 "dependentRequired": {
121 "x": ["z", "a"],
122 "y": ["m", "b"]
123 }
124 });
125 canonicalize_schema(&mut schema);
126 assert_eq!(schema["dependentRequired"]["x"], json!(["a", "z"]));
127 assert_eq!(schema["dependentRequired"]["y"], json!(["b", "m"]));
128 }
129
130 #[test]
131 fn recursive_into_properties() {
132 let mut schema = json!({
133 "type": "object",
134 "properties": {
135 "nested": {
136 "type": "object",
137 "required": ["z", "a"],
138 "properties": {}
139 }
140 }
141 });
142 canonicalize_schema(&mut schema);
143 assert_eq!(
144 schema["properties"]["nested"]["required"],
145 json!(["a", "z"])
146 );
147 }
148
149 #[test]
150 fn preserves_non_required_array_order() {
151 // Arrays that are not `required` or `dependentRequired` should
152 // keep their semantic order (e.g. enum values, oneOf items).
153 let mut schema = json!({
154 "type": "string",
155 "enum": ["z", "a", "m"]
156 });
157 canonicalize_schema(&mut schema);
158 assert_eq!(schema["enum"], json!(["z", "a", "m"]));
159 }
160
161 #[test]
162 fn handles_empty_schema() {
163 let mut schema = json!({});
164 canonicalize_schema(&mut schema);
165 assert_eq!(schema, json!({}));
166 }
167
168 #[test]
169 fn handles_deeply_nested() {
170 let mut schema = json!({
171 "type": "object",
172 "properties": {
173 "level1": {
174 "type": "object",
175 "properties": {
176 "level2": {
177 "type": "object",
178 "required": ["z", "a"]
179 }
180 }
181 }
182 }
183 });
184 canonicalize_schema(&mut schema);
185 assert_eq!(
186 schema["properties"]["level1"]["properties"]["level2"]["required"],
187 json!(["a", "z"])
188 );
189 }
190
191 #[test]
192 fn key_order_is_alphabetical_after_canonicalize() {
193 let mut schema = json!({
194 "z_field": 1,
195 "a_field": 2,
196 "m_field": 3
197 });
198 canonicalize_schema(&mut schema);
199 let keys: Vec<&str> = schema
200 .as_object()
201 .unwrap()
202 .keys()
203 .map(|s| s.as_str())
204 .collect();
205 assert_eq!(keys, vec!["a_field", "m_field", "z_field"]);
206 }
207 }
208
208 lines RUST