Skip to main content

jsonschema_schema/
schema.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5use crate::ext_lintel::LintelExt;
6use crate::ext_taplo::TaploSchemaExt;
7
8/// A JSON Schema value — either a boolean schema or an object schema.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(untagged)]
11pub enum SchemaValue {
12    Bool(bool),
13    Schema(Box<Schema>),
14}
15
16/// JSON Schema `type` keyword — single type string or union array.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(untagged)]
19pub enum TypeValue {
20    Single(String),
21    Union(Vec<String>),
22}
23
24/// A JSON Schema object (draft 2020-12).
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct Schema {
27    // --- Core identifiers ---
28    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
29    pub schema: Option<String>,
30    #[serde(rename = "$id", skip_serializing_if = "Option::is_none")]
31    pub id: Option<String>,
32    #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
33    pub ref_: Option<String>,
34    #[serde(rename = "$anchor", skip_serializing_if = "Option::is_none")]
35    pub anchor: Option<String>,
36    #[serde(rename = "$dynamicRef", skip_serializing_if = "Option::is_none")]
37    pub dynamic_ref: Option<String>,
38    #[serde(rename = "$dynamicAnchor", skip_serializing_if = "Option::is_none")]
39    pub dynamic_anchor: Option<String>,
40    #[serde(rename = "$comment", skip_serializing_if = "Option::is_none")]
41    pub comment: Option<String>,
42    #[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
43    pub defs: Option<IndexMap<String, SchemaValue>>,
44
45    // --- Metadata ---
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub title: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub description: Option<String>,
50    #[serde(
51        rename = "markdownDescription",
52        skip_serializing_if = "Option::is_none"
53    )]
54    pub markdown_description: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub default: Option<Value>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub deprecated: Option<bool>,
59    #[serde(rename = "readOnly", skip_serializing_if = "Option::is_none")]
60    pub read_only: Option<bool>,
61    #[serde(rename = "writeOnly", skip_serializing_if = "Option::is_none")]
62    pub write_only: Option<bool>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub examples: Option<Vec<Value>>,
65
66    // --- Type ---
67    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
68    pub type_: Option<TypeValue>,
69    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
70    pub enum_: Option<Vec<Value>>,
71    #[serde(rename = "const", skip_serializing_if = "Option::is_none")]
72    pub const_: Option<Value>,
73
74    // --- Object ---
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub properties: Option<IndexMap<String, SchemaValue>>,
77    #[serde(rename = "patternProperties", skip_serializing_if = "Option::is_none")]
78    pub pattern_properties: Option<IndexMap<String, SchemaValue>>,
79    #[serde(
80        rename = "additionalProperties",
81        skip_serializing_if = "Option::is_none"
82    )]
83    pub additional_properties: Option<Box<SchemaValue>>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub required: Option<Vec<String>>,
86    #[serde(rename = "propertyNames", skip_serializing_if = "Option::is_none")]
87    pub property_names: Option<Box<SchemaValue>>,
88    #[serde(rename = "minProperties", skip_serializing_if = "Option::is_none")]
89    pub min_properties: Option<u64>,
90    #[serde(rename = "maxProperties", skip_serializing_if = "Option::is_none")]
91    pub max_properties: Option<u64>,
92    #[serde(
93        rename = "unevaluatedProperties",
94        skip_serializing_if = "Option::is_none"
95    )]
96    pub unevaluated_properties: Option<Box<SchemaValue>>,
97
98    // --- Array ---
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub items: Option<Box<SchemaValue>>,
101    #[serde(rename = "prefixItems", skip_serializing_if = "Option::is_none")]
102    pub prefix_items: Option<Vec<SchemaValue>>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub contains: Option<Box<SchemaValue>>,
105    #[serde(rename = "minContains", skip_serializing_if = "Option::is_none")]
106    pub min_contains: Option<u64>,
107    #[serde(rename = "maxContains", skip_serializing_if = "Option::is_none")]
108    pub max_contains: Option<u64>,
109    #[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
110    pub min_items: Option<u64>,
111    #[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
112    pub max_items: Option<u64>,
113    #[serde(rename = "uniqueItems", skip_serializing_if = "Option::is_none")]
114    pub unique_items: Option<bool>,
115    #[serde(rename = "unevaluatedItems", skip_serializing_if = "Option::is_none")]
116    pub unevaluated_items: Option<Box<SchemaValue>>,
117
118    // --- Number ---
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub minimum: Option<Value>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub maximum: Option<Value>,
123    #[serde(rename = "exclusiveMinimum", skip_serializing_if = "Option::is_none")]
124    pub exclusive_minimum: Option<Value>,
125    #[serde(rename = "exclusiveMaximum", skip_serializing_if = "Option::is_none")]
126    pub exclusive_maximum: Option<Value>,
127    #[serde(rename = "multipleOf", skip_serializing_if = "Option::is_none")]
128    pub multiple_of: Option<Value>,
129
130    // --- String ---
131    #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
132    pub min_length: Option<u64>,
133    #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
134    pub max_length: Option<u64>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub pattern: Option<String>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub format: Option<String>,
139
140    // --- Composition ---
141    #[serde(rename = "allOf", skip_serializing_if = "Option::is_none")]
142    pub all_of: Option<Vec<SchemaValue>>,
143    #[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")]
144    pub any_of: Option<Vec<SchemaValue>>,
145    #[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
146    pub one_of: Option<Vec<SchemaValue>>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub not: Option<Box<SchemaValue>>,
149
150    // --- Conditional ---
151    #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
152    pub if_: Option<Box<SchemaValue>>,
153    #[serde(rename = "then", skip_serializing_if = "Option::is_none")]
154    pub then_: Option<Box<SchemaValue>>,
155    #[serde(rename = "else", skip_serializing_if = "Option::is_none")]
156    pub else_: Option<Box<SchemaValue>>,
157
158    // --- Dependencies (2020-12) ---
159    #[serde(rename = "dependentRequired", skip_serializing_if = "Option::is_none")]
160    pub dependent_required: Option<IndexMap<String, Vec<String>>>,
161    #[serde(rename = "dependentSchemas", skip_serializing_if = "Option::is_none")]
162    pub dependent_schemas: Option<IndexMap<String, SchemaValue>>,
163
164    // --- Content ---
165    #[serde(rename = "contentMediaType", skip_serializing_if = "Option::is_none")]
166    pub content_media_type: Option<String>,
167    #[serde(rename = "contentEncoding", skip_serializing_if = "Option::is_none")]
168    pub content_encoding: Option<String>,
169    #[serde(rename = "contentSchema", skip_serializing_if = "Option::is_none")]
170    pub content_schema: Option<Box<SchemaValue>>,
171
172    // --- Extensions ---
173    #[serde(rename = "x-taplo", skip_serializing_if = "Option::is_none")]
174    pub x_taplo: Option<TaploSchemaExt>,
175    #[serde(rename = "x-taplo-info", skip_serializing_if = "Option::is_none")]
176    pub x_taplo_info: Option<Value>,
177    #[serde(rename = "x-lintel", skip_serializing_if = "Option::is_none")]
178    pub x_lintel: Option<LintelExt>,
179    #[serde(
180        rename = "x-tombi-toml-version",
181        skip_serializing_if = "Option::is_none"
182    )]
183    pub x_tombi_toml_version: Option<String>,
184    #[serde(
185        rename = "x-tombi-table-keys-order",
186        skip_serializing_if = "Option::is_none"
187    )]
188    pub x_tombi_table_keys_order: Option<Value>,
189    #[serde(
190        rename = "x-tombi-additional-key-label",
191        skip_serializing_if = "Option::is_none"
192    )]
193    pub x_tombi_additional_key_label: Option<String>,
194    #[serde(
195        rename = "x-tombi-array-values-order",
196        skip_serializing_if = "Option::is_none"
197    )]
198    pub x_tombi_array_values_order: Option<Value>,
199
200    // --- Catch-all for unknown properties ---
201    #[serde(flatten)]
202    pub extra: IndexMap<String, Value>,
203}
204
205impl SchemaValue {
206    /// Get the inner `Schema` if this is an object schema, `None` for bool schemas.
207    pub fn as_schema(&self) -> Option<&Schema> {
208        match self {
209            Self::Schema(s) => Some(s),
210            Self::Bool(_) => None,
211        }
212    }
213}
214
215impl Schema {
216    /// Parse from a `serde_json::Value` without migration.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the value cannot be deserialized into a `Schema`.
221    pub fn from_value(value: Value) -> Result<Self, serde_json::Error> {
222        serde_json::from_value(value)
223    }
224
225    /// Get the best description text, preferring `markdownDescription`.
226    pub fn description(&self) -> Option<&str> {
227        self.markdown_description
228            .as_deref()
229            .or(self.description.as_deref())
230    }
231
232    /// Get the required fields, or an empty slice.
233    pub fn required_set(&self) -> &[String] {
234        self.required.as_deref().unwrap_or_default()
235    }
236
237    /// Whether this schema is deprecated.
238    pub fn is_deprecated(&self) -> bool {
239        self.deprecated.unwrap_or(false)
240    }
241
242    /// Produce a short human-readable type string.
243    pub fn type_str(&self) -> Option<String> {
244        schema_type_str(self)
245    }
246
247    /// Look up a schema-keyword field by its JSON key name.
248    ///
249    /// Returns a reference to the `SchemaValue` stored under that keyword,
250    /// or `None` if the field is absent.
251    pub fn get_keyword(&self, key: &str) -> Option<&SchemaValue> {
252        match key {
253            "items" => self.items.as_deref(),
254            "contains" => self.contains.as_deref(),
255            "additionalProperties" => self.additional_properties.as_deref(),
256            "propertyNames" => self.property_names.as_deref(),
257            "unevaluatedProperties" => self.unevaluated_properties.as_deref(),
258            "unevaluatedItems" => self.unevaluated_items.as_deref(),
259            "not" => self.not.as_deref(),
260            "if" => self.if_.as_deref(),
261            "then" => self.then_.as_deref(),
262            "else" => self.else_.as_deref(),
263            "contentSchema" => self.content_schema.as_deref(),
264            _ => None,
265        }
266    }
267
268    /// Look up a named child within a keyword that holds a map of schemas.
269    ///
270    /// For example, `get_map_entry("properties", "name")` returns the schema
271    /// for the `name` property.
272    pub fn get_map_entry(&self, keyword: &str, key: &str) -> Option<&SchemaValue> {
273        match keyword {
274            "properties" => self.properties.as_ref()?.get(key),
275            "patternProperties" => self.pattern_properties.as_ref()?.get(key),
276            "$defs" => self.defs.as_ref()?.get(key),
277            "dependentSchemas" => self.dependent_schemas.as_ref()?.get(key),
278            _ => None,
279        }
280    }
281
282    /// Look up an indexed child within a keyword that holds an array of schemas.
283    pub fn get_array_entry(&self, keyword: &str, index: usize) -> Option<&SchemaValue> {
284        match keyword {
285            "allOf" => self.all_of.as_ref()?.get(index),
286            "anyOf" => self.any_of.as_ref()?.get(index),
287            "oneOf" => self.one_of.as_ref()?.get(index),
288            "prefixItems" => self.prefix_items.as_ref()?.get(index),
289            _ => None,
290        }
291    }
292}
293
294/// Produce a short human-readable type string for a schema.
295fn schema_type_str(schema: &Schema) -> Option<String> {
296    // Explicit type field
297    if let Some(ref ty) = schema.type_ {
298        return match ty {
299            TypeValue::Single(s) if s == "array" => {
300                let item_ty = schema
301                    .items
302                    .as_ref()
303                    .and_then(|sv| sv.as_schema())
304                    .and_then(schema_type_str);
305                match item_ty {
306                    Some(item_ty) => Some(format!("{item_ty}[]")),
307                    None => Some("array".to_string()),
308                }
309            }
310            TypeValue::Single(s) => Some(s.clone()),
311            TypeValue::Union(arr) => Some(arr.join(" | ")),
312        };
313    }
314
315    // $ref
316    if let Some(ref r) = schema.ref_ {
317        return Some(ref_name(r).to_string());
318    }
319
320    // oneOf/anyOf
321    for variants in [&schema.one_of, &schema.any_of].into_iter().flatten() {
322        let types: Vec<String> = variants
323            .iter()
324            .filter_map(|v| match v {
325                SchemaValue::Schema(s) => {
326                    schema_type_str(s).or_else(|| s.ref_.as_ref().map(|r| ref_name(r).to_string()))
327                }
328                SchemaValue::Bool(_) => None,
329            })
330            .collect();
331        if !types.is_empty() {
332            return Some(types.join(" | "));
333        }
334    }
335
336    // const
337    if let Some(ref c) = schema.const_ {
338        return Some(format!("const: {c}"));
339    }
340
341    // enum
342    if schema.enum_.is_some() {
343        return Some("enum".to_string());
344    }
345
346    None
347}
348
349/// Extract the trailing name from a `$ref` path (e.g. `"#/$defs/Foo"` -> `"Foo"`).
350pub fn ref_name(ref_str: &str) -> &str {
351    ref_str.rsplit('/').next().unwrap_or(ref_str)
352}
353
354/// Resolve a `$ref` within the same schema document.
355///
356/// If the given schema has a `$ref` that begins with `#/`, follow the path
357/// through the root schema. Otherwise return the schema unchanged.
358pub fn resolve_ref<'a>(schema: &'a Schema, root: &'a Schema) -> &'a Schema {
359    if let Some(ref ref_str) = schema.ref_
360        && let Some(path) = ref_str.strip_prefix("#/")
361    {
362        // Navigate the root using serde_json::Value for flexibility
363        let Ok(root_value) = serde_json::to_value(root) else {
364            return schema;
365        };
366        let mut current = &root_value;
367        for segment in path.split('/') {
368            let decoded = segment.replace("~1", "/").replace("~0", "~");
369            match current.get(&decoded) {
370                Some(next) => current = next,
371                None => return schema,
372            }
373        }
374        // Try to deserialize the resolved value back into a Schema.
375        // This is expensive, so we use a different approach for the explain crate.
376        // For now, just return the original schema — the explain crate has its own
377        // resolve_ref that works with SchemaValue trees directly.
378        let _ = current;
379        return schema;
380    }
381    schema
382}
383
384/// Walk a JSON Pointer path through a schema, resolving `$ref` at each step.
385///
386/// Segments are decoded per RFC 6901 (`~1` → `/`, `~0` → `~`).
387/// Returns the sub-`SchemaValue` at the given pointer, or an error.
388///
389/// # Errors
390///
391/// Returns an error if a segment in the pointer cannot be resolved.
392pub fn navigate_pointer<'a>(
393    schema: &'a SchemaValue,
394    root: &'a SchemaValue,
395    pointer: &str,
396) -> Result<&'a SchemaValue, String> {
397    let path = pointer.strip_prefix('/').unwrap_or(pointer);
398    if path.is_empty() {
399        return Ok(schema);
400    }
401
402    let mut current = resolve_schema_value_ref(schema, root);
403    let mut segments = path.split('/').peekable();
404
405    while let Some(segment) = segments.next() {
406        let decoded = segment.replace("~1", "/").replace("~0", "~");
407        current = resolve_schema_value_ref(current, root);
408
409        let Some(schema) = current.as_schema() else {
410            return Err(format!(
411                "cannot resolve segment '{decoded}' in pointer '{pointer}'"
412            ));
413        };
414
415        // Map-bearing keywords: consume this segment AND the next one.
416        if is_map_keyword(&decoded) {
417            let key_segment = segments
418                .next()
419                .ok_or_else(|| format!("expected key after '{decoded}' in pointer '{pointer}'"))?;
420            let key = key_segment.replace("~1", "/").replace("~0", "~");
421            if let Some(entry) = schema.get_map_entry(&decoded, &key) {
422                current = entry;
423                continue;
424            }
425            return Err(format!(
426                "cannot resolve segment '{key}' in '{decoded}' in pointer '{pointer}'"
427            ));
428        }
429
430        // Array-bearing keywords: consume this segment, then the next as an index.
431        if is_array_keyword(&decoded) {
432            let idx_segment = segments.next().ok_or_else(|| {
433                format!("expected index after '{decoded}' in pointer '{pointer}'")
434            })?;
435            let idx: usize = idx_segment.parse().map_err(|_| {
436                format!("expected numeric index after '{decoded}', got '{idx_segment}'")
437            })?;
438            if let Some(entry) = schema.get_array_entry(&decoded, idx) {
439                current = entry;
440                continue;
441            }
442            return Err(format!(
443                "index {idx} out of bounds in '{decoded}' in pointer '{pointer}'"
444            ));
445        }
446
447        // Single-value keywords (items, not, if, then, else, etc.)
448        if let Some(sv) = schema.get_keyword(&decoded) {
449            current = sv;
450            continue;
451        }
452
453        // Fall back: try as a key in the schema's maps (for when the
454        // pointer navigates directly into a map without naming the keyword).
455        if let Some(sv) = schema.get_map_entry_by_pointer_segment(&decoded) {
456            current = sv;
457            continue;
458        }
459
460        // Try as array index (for arrays embedded in composition keywords)
461        if let Ok(idx) = decoded.parse::<usize>() {
462            let found = ["allOf", "anyOf", "oneOf", "prefixItems"]
463                .iter()
464                .find_map(|kw| schema.get_array_entry(kw, idx));
465            if let Some(entry) = found {
466                current = entry;
467                continue;
468            }
469        }
470
471        return Err(format!(
472            "cannot resolve segment '{decoded}' in pointer '{pointer}'"
473        ));
474    }
475
476    Ok(resolve_schema_value_ref(current, root))
477}
478
479/// Whether a JSON pointer segment names a map-bearing keyword.
480fn is_map_keyword(segment: &str) -> bool {
481    matches!(
482        segment,
483        "properties" | "patternProperties" | "$defs" | "dependentSchemas"
484    )
485}
486
487/// Whether a JSON pointer segment names an array-bearing keyword.
488fn is_array_keyword(segment: &str) -> bool {
489    matches!(segment, "allOf" | "anyOf" | "oneOf" | "prefixItems")
490}
491
492/// Resolve `$ref` on a `SchemaValue`, returning the referenced `SchemaValue`.
493fn resolve_schema_value_ref<'a>(sv: &'a SchemaValue, root: &'a SchemaValue) -> &'a SchemaValue {
494    let Some(schema) = sv.as_schema() else {
495        return sv;
496    };
497    if let Some(ref ref_str) = schema.ref_
498        && let Some(path) = ref_str.strip_prefix("#/")
499    {
500        let mut current = root;
501        let mut segments = path.split('/').peekable();
502        while let Some(segment) = segments.next() {
503            let decoded = segment.replace("~1", "/").replace("~0", "~");
504            let Some(inner) = current.as_schema() else {
505                return sv;
506            };
507
508            // Map-bearing keywords: consume the next segment as a key
509            if is_map_keyword(&decoded) {
510                let Some(key_segment) = segments.next() else {
511                    return sv;
512                };
513                let key = key_segment.replace("~1", "/").replace("~0", "~");
514                match inner.get_map_entry(&decoded, &key) {
515                    Some(n) => current = n,
516                    None => return sv,
517                }
518                continue;
519            }
520
521            // Array-bearing keywords: consume the next segment as an index
522            if is_array_keyword(&decoded) {
523                let Some(idx_segment) = segments.next() else {
524                    return sv;
525                };
526                let Ok(idx) = idx_segment.parse::<usize>() else {
527                    return sv;
528                };
529                match inner.get_array_entry(&decoded, idx) {
530                    Some(n) => current = n,
531                    None => return sv,
532                }
533                continue;
534            }
535
536            // Single-value keywords
537            if let Some(n) = inner.get_keyword(&decoded) {
538                current = n;
539                continue;
540            }
541
542            // Fall back to map entry lookup
543            if let Some(n) = inner.get_map_entry_by_pointer_segment(&decoded) {
544                current = n;
545                continue;
546            }
547
548            return sv;
549        }
550        return current;
551    }
552    sv
553}
554
555impl Schema {
556    /// Look up a child by a JSON pointer segment name.
557    /// This handles both map keywords (where the segment is a key within the map)
558    /// and direct keywords.
559    fn get_map_entry_by_pointer_segment(&self, segment: &str) -> Option<&SchemaValue> {
560        // Try all map-bearing keyword fields.
561        // For pointer navigation, when we're inside a "properties" object,
562        // the segment is the property name.
563        self.properties
564            .as_ref()
565            .and_then(|m| m.get(segment))
566            .or_else(|| {
567                self.pattern_properties
568                    .as_ref()
569                    .and_then(|m| m.get(segment))
570            })
571            .or_else(|| self.defs.as_ref().and_then(|m| m.get(segment)))
572            .or_else(|| self.dependent_schemas.as_ref().and_then(|m| m.get(segment)))
573    }
574}
575
576#[cfg(test)]
577#[allow(clippy::unwrap_used)]
578mod tests {
579    use super::*;
580    use serde_json::json;
581
582    #[test]
583    fn round_trip_simple_schema() {
584        let json = json!({
585            "type": "object",
586            "title": "Test",
587            "properties": {
588                "name": { "type": "string" }
589            }
590        });
591        let schema: Schema = serde_json::from_value(json.clone()).unwrap();
592        assert_eq!(schema.title.as_deref(), Some("Test"));
593        assert!(schema.properties.is_some());
594
595        let back = serde_json::to_value(&schema).unwrap();
596        assert_eq!(back["type"], "object");
597        assert_eq!(back["title"], "Test");
598    }
599
600    #[test]
601    fn bool_schema_value() {
602        let json = json!(true);
603        let sv: SchemaValue = serde_json::from_value(json).unwrap();
604        assert!(matches!(sv, SchemaValue::Bool(true)));
605        assert!(sv.as_schema().is_none());
606    }
607
608    #[test]
609    fn schema_value_object() {
610        let json = json!({"type": "string"});
611        let sv: SchemaValue = serde_json::from_value(json).unwrap();
612        let s = sv.as_schema().unwrap();
613        assert!(matches!(s.type_, Some(TypeValue::Single(ref t)) if t == "string"));
614    }
615
616    #[test]
617    fn type_value_single() {
618        let json = json!("string");
619        let tv: TypeValue = serde_json::from_value(json).unwrap();
620        assert!(matches!(tv, TypeValue::Single(ref s) if s == "string"));
621    }
622
623    #[test]
624    fn type_value_union() {
625        let json = json!(["string", "null"]);
626        let tv: TypeValue = serde_json::from_value(json).unwrap();
627        assert!(matches!(tv, TypeValue::Union(ref v) if v.len() == 2));
628    }
629
630    #[test]
631    fn description_prefers_markdown() {
632        let schema = Schema {
633            description: Some("plain".into()),
634            markdown_description: Some("**rich**".into()),
635            ..Default::default()
636        };
637        assert_eq!(schema.description(), Some("**rich**"));
638    }
639
640    #[test]
641    fn description_falls_back() {
642        let schema = Schema {
643            description: Some("plain".into()),
644            ..Default::default()
645        };
646        assert_eq!(schema.description(), Some("plain"));
647    }
648
649    #[test]
650    fn type_str_simple() {
651        let schema = Schema {
652            type_: Some(TypeValue::Single("string".into())),
653            ..Default::default()
654        };
655        assert_eq!(schema.type_str().as_deref(), Some("string"));
656    }
657
658    #[test]
659    fn type_str_union() {
660        let schema = Schema {
661            type_: Some(TypeValue::Union(vec!["string".into(), "null".into()])),
662            ..Default::default()
663        };
664        assert_eq!(schema.type_str().as_deref(), Some("string | null"));
665    }
666
667    #[test]
668    fn type_str_array_with_items() {
669        let items = SchemaValue::Schema(Box::new(Schema {
670            type_: Some(TypeValue::Single("string".into())),
671            ..Default::default()
672        }));
673        let schema = Schema {
674            type_: Some(TypeValue::Single("array".into())),
675            items: Some(Box::new(items)),
676            ..Default::default()
677        };
678        assert_eq!(schema.type_str().as_deref(), Some("string[]"));
679    }
680
681    #[test]
682    fn type_str_ref() {
683        let schema = Schema {
684            ref_: Some("#/$defs/Foo".into()),
685            ..Default::default()
686        };
687        assert_eq!(schema.type_str().as_deref(), Some("Foo"));
688    }
689
690    #[test]
691    fn is_deprecated_default_false() {
692        let schema = Schema::default();
693        assert!(!schema.is_deprecated());
694    }
695
696    #[test]
697    fn is_deprecated_true() {
698        let schema = Schema {
699            deprecated: Some(true),
700            ..Default::default()
701        };
702        assert!(schema.is_deprecated());
703    }
704
705    #[test]
706    fn required_set_empty() {
707        let schema = Schema::default();
708        assert!(schema.required_set().is_empty());
709    }
710
711    #[test]
712    fn required_set_values() {
713        let schema = Schema {
714            required: Some(vec!["a".into(), "b".into()]),
715            ..Default::default()
716        };
717        assert_eq!(schema.required_set(), &["a", "b"]);
718    }
719
720    #[test]
721    fn extra_fields_preserved() {
722        let json = json!({
723            "type": "object",
724            "x-custom": "value",
725            "x-another": 42
726        });
727        let schema: Schema = serde_json::from_value(json).unwrap();
728        assert_eq!(schema.extra.get("x-custom").unwrap(), "value");
729        assert_eq!(schema.extra.get("x-another").unwrap(), 42);
730    }
731
732    #[test]
733    fn x_taplo_deserialization() {
734        let json = json!({
735            "type": "object",
736            "x-taplo": {
737                "hidden": true,
738                "docs": {
739                    "main": "Main docs"
740                }
741            }
742        });
743        let schema: Schema = serde_json::from_value(json).unwrap();
744        let taplo = schema.x_taplo.unwrap();
745        assert_eq!(taplo.hidden, Some(true));
746        assert_eq!(taplo.docs.unwrap().main.as_deref(), Some("Main docs"));
747    }
748
749    #[test]
750    fn x_lintel_deserialization() {
751        let json = json!({
752            "type": "object",
753            "x-lintel": {
754                "source": "https://example.com/schema.json",
755                "sourceSha256": "abc123"
756            }
757        });
758        let schema: Schema = serde_json::from_value(json).unwrap();
759        let lintel = schema.x_lintel.unwrap();
760        assert_eq!(
761            lintel.source.as_deref(),
762            Some("https://example.com/schema.json")
763        );
764        assert_eq!(lintel.source_sha256.as_deref(), Some("abc123"));
765    }
766
767    #[test]
768    fn navigate_pointer_empty() {
769        let sv = SchemaValue::Schema(Box::new(Schema {
770            type_: Some(TypeValue::Single("object".into())),
771            ..Default::default()
772        }));
773        let result = navigate_pointer(&sv, &sv, "").unwrap();
774        assert!(result.as_schema().is_some());
775    }
776
777    #[test]
778    fn navigate_pointer_properties() {
779        let name_schema = SchemaValue::Schema(Box::new(Schema {
780            type_: Some(TypeValue::Single("string".into())),
781            ..Default::default()
782        }));
783        let mut props = IndexMap::new();
784        props.insert("name".into(), name_schema);
785        let root = SchemaValue::Schema(Box::new(Schema {
786            properties: Some(props),
787            ..Default::default()
788        }));
789        let result = navigate_pointer(&root, &root, "/properties/name").unwrap();
790        let s = result.as_schema().unwrap();
791        assert!(matches!(s.type_, Some(TypeValue::Single(ref t)) if t == "string"));
792    }
793
794    #[test]
795    fn navigate_pointer_resolves_ref() {
796        let item_schema = SchemaValue::Schema(Box::new(Schema {
797            type_: Some(TypeValue::Single("object".into())),
798            description: Some("An item".into()),
799            ..Default::default()
800        }));
801        let ref_schema = SchemaValue::Schema(Box::new(Schema {
802            ref_: Some("#/$defs/Item".into()),
803            ..Default::default()
804        }));
805        let mut defs = IndexMap::new();
806        defs.insert("Item".into(), item_schema);
807        let mut props = IndexMap::new();
808        props.insert("item".into(), ref_schema);
809        let root = SchemaValue::Schema(Box::new(Schema {
810            properties: Some(props),
811            defs: Some(defs),
812            ..Default::default()
813        }));
814        let result = navigate_pointer(&root, &root, "/properties/item").unwrap();
815        let s = result.as_schema().unwrap();
816        assert_eq!(s.description.as_deref(), Some("An item"));
817    }
818
819    #[test]
820    fn navigate_pointer_bad_segment_errors() {
821        let sv = SchemaValue::Schema(Box::default());
822        let err = navigate_pointer(&sv, &sv, "/nonexistent").unwrap_err();
823        assert!(err.contains("nonexistent"));
824    }
825
826    #[test]
827    fn parse_cargo_fixture() {
828        let content =
829            std::fs::read_to_string("../jsonschema-migrate/tests/fixtures/cargo.json").unwrap();
830        let value: Value = serde_json::from_str(&content).unwrap();
831        let mut migrated = value;
832        jsonschema_migrate::migrate_to_2020_12(&mut migrated);
833        let schema: Schema = serde_json::from_value(migrated).unwrap();
834        assert!(schema.title.is_some() || schema.type_.is_some());
835        // Verify x-taplo is parsed if present
836        if schema.x_taplo.is_some() {
837            // Just verify it parsed without error
838        }
839    }
840}