jsonschema_annotator/schema/
parser.rs

1use schemars::Schema;
2use serde_json::Value;
3
4use super::annotation::{Annotation, AnnotationMap};
5use super::refs::resolve_refs;
6
7/// Format a JSON value as a human-readable string for display in comments
8fn format_default_value(value: &Value) -> String {
9    match value {
10        Value::Null => "null".to_string(),
11        Value::Bool(b) => b.to_string(),
12        Value::Number(n) => n.to_string(),
13        Value::String(s) => format!("\"{}\"", s),
14        Value::Array(arr) => {
15            let items: Vec<String> = arr.iter().map(format_default_value).collect();
16            format!("[{}]", items.join(", "))
17        }
18        Value::Object(obj) => {
19            let items: Vec<String> = obj
20                .iter()
21                .map(|(k, v)| format!("{}: {}", k, format_default_value(v)))
22                .collect();
23            format!("{{{}}}", items.join(", "))
24        }
25    }
26}
27
28/// Extract annotations from a JSON Schema
29///
30/// This resolves $refs and walks the schema recursively,
31/// extracting title/description for each property path.
32pub fn extract_annotations(schema: &Schema) -> AnnotationMap {
33    let resolved = resolve_refs(schema);
34    let mut annotations = AnnotationMap::new();
35    let mut path = Vec::new();
36
37    walk_schema(resolved.as_value(), &mut path, &mut annotations);
38
39    annotations
40}
41
42fn walk_schema(value: &Value, current_path: &mut Vec<String>, annotations: &mut AnnotationMap) {
43    let Some(obj) = value.as_object() else {
44        return;
45    };
46
47    // Extract title/description/default at current level
48    let title = obj.get("title").and_then(|v| v.as_str());
49    let desc = obj.get("description").and_then(|v| v.as_str());
50    let default = obj.get("default").map(format_default_value);
51
52    if title.is_some() || desc.is_some() || default.is_some() {
53        let mut ann = Annotation::new(current_path.join("."));
54        if let Some(t) = title {
55            ann = ann.with_title(t);
56        }
57        if let Some(d) = desc {
58            ann = ann.with_description(d);
59        }
60        if let Some(d) = default {
61            ann = ann.with_default(d);
62        }
63        annotations.insert(ann);
64    }
65
66    // Recurse into properties
67    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
68        for (key, val) in props {
69            current_path.push(key.clone());
70            walk_schema(val, current_path, annotations);
71            current_path.pop();
72        }
73    }
74
75    // Handle array items (annotation applies to the array key itself)
76    if let Some(items) = obj.get("items") {
77        walk_schema(items, current_path, annotations);
78    }
79
80    // Handle additionalProperties if it's a schema object
81    if let Some(additional) = obj.get("additionalProperties") {
82        if additional.is_object() {
83            walk_schema(additional, current_path, annotations);
84        }
85    }
86
87    // Handle oneOf/allOf/anyOf composition
88    for keyword in ["oneOf", "allOf", "anyOf"] {
89        if let Some(schemas) = obj.get(keyword).and_then(|v| v.as_array()) {
90            for schema in schemas {
91                walk_schema(schema, current_path, annotations);
92            }
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use serde_json::json;
101
102    #[test]
103    fn test_extract_simple() {
104        let schema_json = json!({
105            "properties": {
106                "name": {
107                    "title": "Name",
108                    "description": "User's full name"
109                },
110                "age": {
111                    "title": "Age"
112                }
113            }
114        });
115
116        let schema: Schema = schema_json.try_into().unwrap();
117        let annotations = extract_annotations(&schema);
118
119        assert_eq!(annotations.len(), 2);
120
121        let name = annotations.get("name").unwrap();
122        assert_eq!(name.title, Some("Name".to_string()));
123        assert_eq!(name.description, Some("User's full name".to_string()));
124
125        let age = annotations.get("age").unwrap();
126        assert_eq!(age.title, Some("Age".to_string()));
127        assert_eq!(age.description, None);
128    }
129
130    #[test]
131    fn test_extract_nested() {
132        let schema_json = json!({
133            "properties": {
134                "server": {
135                    "title": "Server",
136                    "description": "Server configuration",
137                    "properties": {
138                        "host": {
139                            "title": "Host",
140                            "description": "Server hostname"
141                        },
142                        "port": {
143                            "title": "Port"
144                        }
145                    }
146                }
147            }
148        });
149
150        let schema: Schema = schema_json.try_into().unwrap();
151        let annotations = extract_annotations(&schema);
152
153        assert_eq!(annotations.len(), 3);
154
155        let server = annotations.get("server").unwrap();
156        assert_eq!(server.title, Some("Server".to_string()));
157
158        let host = annotations.get("server.host").unwrap();
159        assert_eq!(host.title, Some("Host".to_string()));
160        assert_eq!(host.description, Some("Server hostname".to_string()));
161
162        let port = annotations.get("server.port").unwrap();
163        assert_eq!(port.title, Some("Port".to_string()));
164    }
165
166    #[test]
167    fn test_extract_with_refs() {
168        let schema_json = json!({
169            "$defs": {
170                "Address": {
171                    "title": "Address",
172                    "description": "A physical address",
173                    "properties": {
174                        "city": {
175                            "title": "City"
176                        }
177                    }
178                }
179            },
180            "properties": {
181                "home": {"$ref": "#/$defs/Address"},
182                "work": {"$ref": "#/$defs/Address"}
183            }
184        });
185
186        let schema: Schema = schema_json.try_into().unwrap();
187        let annotations = extract_annotations(&schema);
188
189        // Both home and work should have annotations from the Address $def
190        let home = annotations.get("home").unwrap();
191        assert_eq!(home.title, Some("Address".to_string()));
192
193        let home_city = annotations.get("home.city").unwrap();
194        assert_eq!(home_city.title, Some("City".to_string()));
195
196        let work = annotations.get("work").unwrap();
197        assert_eq!(work.title, Some("Address".to_string()));
198    }
199
200    #[test]
201    fn test_extract_root_annotation() {
202        let schema_json = json!({
203            "title": "Config",
204            "description": "Application configuration",
205            "properties": {
206                "debug": {
207                    "title": "Debug Mode"
208                }
209            }
210        });
211
212        let schema: Schema = schema_json.try_into().unwrap();
213        let annotations = extract_annotations(&schema);
214
215        // Root level annotation has empty path
216        let root = annotations.get("").unwrap();
217        assert_eq!(root.title, Some("Config".to_string()));
218        assert_eq!(root.description, Some("Application configuration".to_string()));
219
220        let debug = annotations.get("debug").unwrap();
221        assert_eq!(debug.title, Some("Debug Mode".to_string()));
222    }
223
224    #[test]
225    fn test_extract_no_annotations() {
226        let schema_json = json!({
227            "properties": {
228                "name": {"type": "string"},
229                "age": {"type": "number"}
230            }
231        });
232
233        let schema: Schema = schema_json.try_into().unwrap();
234        let annotations = extract_annotations(&schema);
235
236        assert!(annotations.is_empty());
237    }
238
239    #[test]
240    fn test_extract_array_items() {
241        let schema_json = json!({
242            "properties": {
243                "users": {
244                    "title": "Users",
245                    "description": "List of users",
246                    "items": {
247                        "properties": {
248                            "name": {
249                                "title": "User Name"
250                            }
251                        }
252                    }
253                }
254            }
255        });
256
257        let schema: Schema = schema_json.try_into().unwrap();
258        let annotations = extract_annotations(&schema);
259
260        let users = annotations.get("users").unwrap();
261        assert_eq!(users.title, Some("Users".to_string()));
262
263        // Items inherit the parent path
264        let user_name = annotations.get("users.name").unwrap();
265        assert_eq!(user_name.title, Some("User Name".to_string()));
266    }
267
268    #[test]
269    fn test_extract_oneof() {
270        let schema_json = json!({
271            "properties": {
272                "value": {
273                    "title": "Value",
274                    "oneOf": [
275                        {
276                            "properties": {
277                                "string_val": {
278                                    "title": "String Value",
279                                    "description": "A string value"
280                                }
281                            }
282                        },
283                        {
284                            "properties": {
285                                "number_val": {
286                                    "title": "Number Value"
287                                }
288                            }
289                        }
290                    ]
291                }
292            }
293        });
294
295        let schema: Schema = schema_json.try_into().unwrap();
296        let annotations = extract_annotations(&schema);
297
298        let value = annotations.get("value").unwrap();
299        assert_eq!(value.title, Some("Value".to_string()));
300
301        let string_val = annotations.get("value.string_val").unwrap();
302        assert_eq!(string_val.title, Some("String Value".to_string()));
303        assert_eq!(string_val.description, Some("A string value".to_string()));
304
305        let number_val = annotations.get("value.number_val").unwrap();
306        assert_eq!(number_val.title, Some("Number Value".to_string()));
307    }
308
309    #[test]
310    fn test_extract_allof() {
311        let schema_json = json!({
312            "allOf": [
313                {
314                    "properties": {
315                        "base": {
316                            "title": "Base Property"
317                        }
318                    }
319                },
320                {
321                    "properties": {
322                        "extended": {
323                            "title": "Extended Property"
324                        }
325                    }
326                }
327            ]
328        });
329
330        let schema: Schema = schema_json.try_into().unwrap();
331        let annotations = extract_annotations(&schema);
332
333        let base = annotations.get("base").unwrap();
334        assert_eq!(base.title, Some("Base Property".to_string()));
335
336        let extended = annotations.get("extended").unwrap();
337        assert_eq!(extended.title, Some("Extended Property".to_string()));
338    }
339
340    #[test]
341    fn test_extract_anyof() {
342        let schema_json = json!({
343            "properties": {
344                "config": {
345                    "title": "Config",
346                    "anyOf": [
347                        {
348                            "properties": {
349                                "simple": {
350                                    "title": "Simple Mode"
351                                }
352                            }
353                        },
354                        {
355                            "properties": {
356                                "advanced": {
357                                    "title": "Advanced Mode",
358                                    "description": "For power users"
359                                }
360                            }
361                        }
362                    ]
363                }
364            }
365        });
366
367        let schema: Schema = schema_json.try_into().unwrap();
368        let annotations = extract_annotations(&schema);
369
370        let config = annotations.get("config").unwrap();
371        assert_eq!(config.title, Some("Config".to_string()));
372
373        let simple = annotations.get("config.simple").unwrap();
374        assert_eq!(simple.title, Some("Simple Mode".to_string()));
375
376        let advanced = annotations.get("config.advanced").unwrap();
377        assert_eq!(advanced.title, Some("Advanced Mode".to_string()));
378        assert_eq!(advanced.description, Some("For power users".to_string()));
379    }
380
381    #[test]
382    fn test_extract_default_values() {
383        let schema_json = json!({
384            "properties": {
385                "port": {
386                    "title": "Port",
387                    "description": "The port number",
388                    "default": 8080
389                },
390                "host": {
391                    "title": "Host",
392                    "default": "localhost"
393                },
394                "enabled": {
395                    "title": "Enabled",
396                    "default": true
397                },
398                "tags": {
399                    "title": "Tags",
400                    "default": ["web", "api"]
401                }
402            }
403        });
404
405        let schema: Schema = schema_json.try_into().unwrap();
406        let annotations = extract_annotations(&schema);
407
408        let port = annotations.get("port").unwrap();
409        assert_eq!(port.title, Some("Port".to_string()));
410        assert_eq!(port.default, Some("8080".to_string()));
411
412        let host = annotations.get("host").unwrap();
413        assert_eq!(host.default, Some("\"localhost\"".to_string()));
414
415        let enabled = annotations.get("enabled").unwrap();
416        assert_eq!(enabled.default, Some("true".to_string()));
417
418        let tags = annotations.get("tags").unwrap();
419        assert_eq!(tags.default, Some("[\"web\", \"api\"]".to_string()));
420    }
421
422    #[test]
423    fn test_extract_only_default() {
424        // Test that a property with only a default value still gets extracted
425        let schema_json = json!({
426            "properties": {
427                "timeout": {
428                    "default": 30
429                }
430            }
431        });
432
433        let schema: Schema = schema_json.try_into().unwrap();
434        let annotations = extract_annotations(&schema);
435
436        let timeout = annotations.get("timeout").unwrap();
437        assert_eq!(timeout.title, None);
438        assert_eq!(timeout.description, None);
439        assert_eq!(timeout.default, Some("30".to_string()));
440    }
441}