vdev/commands/build/component_docs/
schema_utils.rs

1use super::{SchemaContext, get_schema_metadata};
2use anyhow::Result;
3use serde_json::{Map, Value};
4
5/// Render a JSON Schema `const` scalar (string, number, or bool) as a CUE enum
6/// key. CUE enum keys are strings, so non-string scalars are stringified the
7/// same way `serde_json` would print them. Non-scalar values return `None`.
8fn scalar_const_key(value: &Value) -> Option<String> {
9    match value {
10        Value::String(s) => Some(s.clone()),
11        Value::Number(n) => Some(n.to_string()),
12        Value::Bool(b) => Some(b.to_string()),
13        _ => None,
14    }
15}
16
17impl SchemaContext {
18    pub fn get_rendered_description_from_schema(&self, schema: &Value) -> String {
19        let raw_description = schema
20            .get("description")
21            .and_then(|v| v.as_str())
22            .unwrap_or("");
23        let raw_title = schema.get("title").and_then(|v| v.as_str()).unwrap_or("");
24
25        let description = if raw_title.is_empty() {
26            raw_description.to_string()
27        } else {
28            format!("{raw_title}\n\n{raw_description}")
29        };
30        description.trim().to_string()
31    }
32
33    pub fn unwrap_resolved_schema(
34        &mut self,
35        schema_name: &str,
36        friendly_name: &str,
37    ) -> Result<Map<String, Value>> {
38        info!("[*] Resolving schema definition for {}...", friendly_name);
39
40        let resolved_schema = self.resolve_schema_by_name(schema_name)?;
41
42        let unwrapped_obj = resolved_schema
43            .pointer("/type/object/options")
44            .and_then(Value::as_object)
45            .cloned()
46            .ok_or_else(|| {
47                anyhow::anyhow!(
48                    "Configuration types must always resolve to an object schema; '{schema_name}' did not. Resolved: {resolved_schema}"
49                )
50            })?;
51
52        // Recursively sort the entire schema to match Ruby's `sort_hash_nested` logic
53        Ok(Self::sort_hash_nested(&unwrapped_obj))
54    }
55
56    pub fn fix_grouped_enums_if_numeric(
57        &self,
58        grouped: &mut indexmap::IndexMap<String, Vec<Value>>,
59    ) {
60        let mut numeric_vals = Vec::new();
61        if let Some(ints) = grouped.shift_remove("integer") {
62            numeric_vals.extend(ints);
63        }
64        if let Some(nums) = grouped.shift_remove("number") {
65            numeric_vals.extend(nums);
66        }
67
68        if !numeric_vals.is_empty() {
69            let is_integer = numeric_vals.iter().all(|v| v.is_i64() || v.is_u64());
70            let within_uint = numeric_vals.iter().all(serde_json::Value::is_u64);
71            let contains_signed = numeric_vals
72                .iter()
73                .all(|v| v.is_i64() || v.as_i64().is_some());
74
75            let numeric_type = if !is_integer || (!contains_signed && !within_uint) {
76                "float"
77            } else if within_uint {
78                "uint"
79            } else if contains_signed {
80                "int"
81            } else {
82                "float"
83            };
84
85            grouped.insert(numeric_type.to_string(), numeric_vals);
86        }
87    }
88
89    #[allow(clippy::self_only_used_in_recursion)]
90    pub fn get_reduced_schema(&self, schema: &Value) -> Value {
91        let mut reduced = schema.clone();
92        if let Value::Object(ref mut map) = reduced {
93            let allowed_properties = [
94                "type",
95                "const",
96                "enum",
97                "allOf",
98                "oneOf",
99                "$ref",
100                "items",
101                "properties",
102            ];
103            map.retain(|k, _| allowed_properties.contains(&k.as_str()));
104
105            if let Some(items) = map.get_mut("items") {
106                *items = self.get_reduced_schema(items);
107            }
108
109            if let Some(Value::Object(properties)) = map.get_mut("properties") {
110                for (_, prop) in properties.iter_mut() {
111                    *prop = self.get_reduced_schema(prop);
112                }
113            }
114
115            for key in &["allOf", "oneOf"] {
116                if let Some(Value::Array(arr)) = map.get_mut(*key) {
117                    for sub in arr.iter_mut() {
118                        *sub = self.get_reduced_schema(sub);
119                    }
120                }
121            }
122        }
123        reduced
124    }
125
126    #[allow(clippy::self_only_used_in_recursion)]
127    pub fn get_reduced_resolved_schema(&self, schema: &Value) -> Value {
128        let mut reduced = schema.clone();
129        let allowed_types = [
130            "condition",
131            "object",
132            "array",
133            "enum",
134            "const",
135            "string",
136            "bool",
137            "float",
138            "int",
139            "uint",
140        ];
141
142        if let Value::Object(ref mut map) = reduced {
143            map.retain(|k, _| k == "type");
144
145            if let Some(Value::Object(type_defs)) = map.get_mut("type") {
146                type_defs.retain(|k, _| allowed_types.contains(&k.as_str()));
147
148                for (type_name, type_def) in type_defs.iter_mut() {
149                    if type_name == "object" {
150                        if let Value::Object(def_map) = type_def {
151                            def_map.retain(|k, _| k == "options");
152                            if let Some(Value::Object(opts)) = def_map.get_mut("options") {
153                                for (_, prop) in opts.iter_mut() {
154                                    *prop = self.get_reduced_resolved_schema(prop);
155                                }
156                            }
157                        }
158                    } else if type_name == "array" {
159                        if let Value::Object(def_map) = type_def {
160                            def_map.retain(|k, _| k == "items");
161                            if let Some(items) = def_map.get_mut("items") {
162                                *items = self.get_reduced_resolved_schema(items);
163                            }
164                        }
165                    } else if let Value::Object(def_map) = type_def {
166                        def_map.retain(|k, _| allowed_types.contains(&k.as_str()));
167                    }
168                }
169            }
170        }
171        reduced
172    }
173
174    pub fn find_nested_object_property_schema<'a>(
175        &self,
176        schema: &'a Value,
177        property_name: &str,
178    ) -> Option<&'a Value> {
179        if let Some(prop) = schema.get("properties").and_then(|p| p.get(property_name)) {
180            return Some(prop);
181        }
182
183        // Walk oneOf/anyOf/allOf and collect every subschema's matching property.
184        // We can only confidently apply a default through one of these branches if
185        // they all describe the same shape, so compare reduced forms and bail if
186        // they diverge.
187        let mut matches: Vec<&'a Value> = Vec::new();
188        let mut unvisited: Vec<&'a Value> = Vec::new();
189        for key in &["oneOf", "anyOf", "allOf"] {
190            if let Some(Value::Array(arr)) = schema.get(*key) {
191                unvisited.extend(arr.iter());
192            }
193        }
194
195        while let Some(sub) = unvisited.pop() {
196            if let Some(prop) = sub.get("properties").and_then(|p| p.get(property_name)) {
197                matches.push(prop);
198                continue;
199            }
200            for key in &["oneOf", "anyOf", "allOf"] {
201                if let Some(Value::Array(arr)) = sub.get(*key) {
202                    unvisited.extend(arr.iter());
203                }
204            }
205        }
206
207        let first = matches.first()?;
208        let reduced_first = self.get_reduced_schema(first);
209        for other in matches.iter().skip(1) {
210            if self.get_reduced_schema(other) != reduced_first {
211                return None;
212            }
213        }
214        Some(first)
215    }
216
217    pub fn apply_schema_default_value(
218        &self,
219        source_schema: &Value,
220        resolved_schema: &mut Value,
221    ) -> Result<()> {
222        debug!("Applying schema default values.");
223
224        let default_value = match source_schema.get("default") {
225            Some(v) if !v.is_null() => v.clone(),
226            _ => return Ok(()),
227        };
228
229        let default_value_type = self.get_docs_type_for_value(Some(source_schema), &default_value);
230
231        // The resolved schema must declare a type definition matching the default
232        // value's type. Anything else is a schema generation bug, so surface it
233        // loudly rather than silently dropping the default.
234        if resolved_schema
235            .pointer(&format!("/type/{default_value_type}"))
236            .is_none()
237        {
238            anyhow::bail!(
239                "Schema has default value declared that does not match type of resolved schema:\n\
240                 Source schema: {}\n\
241                 Default value: {} (type: {})\n\
242                 Resolved schema: {}",
243                serde_json::to_string_pretty(source_schema)?,
244                serde_json::to_string_pretty(&default_value)?,
245                default_value_type,
246                serde_json::to_string_pretty(resolved_schema)?,
247            );
248        }
249
250        if default_value_type == "object" {
251            let Value::Object(def_obj) = default_value else {
252                anyhow::bail!("Default value typed 'object' was not a JSON object");
253            };
254            let props = resolved_schema
255                .pointer_mut("/type/object/options")
256                .and_then(Value::as_object_mut)
257                .ok_or_else(|| {
258                    anyhow::anyhow!("Resolved object schema is missing /type/object/options")
259                })?;
260
261            for (prop_name, prop_default_value) in def_obj {
262                if prop_default_value.is_null() {
263                    continue;
264                }
265                let Some(resolved_prop) = props.get_mut(&prop_name) else {
266                    continue;
267                };
268
269                if let Some(source_prop) =
270                    self.find_nested_object_property_schema(source_schema, &prop_name)
271                {
272                    let mut source_with_default = source_prop.clone();
273                    source_with_default
274                        .as_object_mut()
275                        .unwrap()
276                        .insert("default".to_string(), prop_default_value);
277                    self.apply_schema_default_value(&source_with_default, resolved_prop)?;
278                } else {
279                    let value_type = self.get_docs_type_for_value(None, &prop_default_value);
280                    if let Some(Value::Object(type_obj)) = resolved_prop.get_mut("type")
281                        && let Some(Value::Object(type_def)) = type_obj.get_mut(value_type)
282                    {
283                        type_def.insert("default".to_string(), prop_default_value);
284                    }
285                }
286                resolved_prop
287                    .as_object_mut()
288                    .unwrap()
289                    .insert("required".to_string(), Value::Bool(false));
290            }
291        } else {
292            let type_def = resolved_schema
293                .pointer_mut(&format!("/type/{default_value_type}"))
294                .and_then(Value::as_object_mut)
295                .expect("/type/{default_value_type} existence verified above");
296            type_def.insert("default".to_string(), default_value);
297        }
298        Ok(())
299    }
300
301    pub fn apply_schema_metadata(&self, source_schema: &Value, resolved_schema: &mut Value) {
302        let is_templateable = get_schema_metadata(source_schema, "docs::templateable")
303            .and_then(Value::as_bool)
304            .unwrap_or(false);
305
306        if let Some(Value::Object(types)) = resolved_schema.get_mut("type")
307            && let Some(Value::Object(string_def)) = types.get_mut("string")
308            && is_templateable
309        {
310            string_def.insert("syntax".to_string(), Value::String("template".to_string()));
311        }
312
313        if let Some(examples) = get_schema_metadata(source_schema, "docs::examples") {
314            let mut flattened_examples = match examples {
315                Value::Array(arr) => arr.clone(),
316                v => vec![v.clone()],
317            };
318
319            for ex in &mut flattened_examples {
320                if let Value::Object(obj) = ex {
321                    let sorted_obj = Self::sort_hash_nested(obj);
322                    *ex = Value::Object(sorted_obj);
323                }
324            }
325
326            if let Some(Value::Object(type_obj)) = resolved_schema.get_mut("type") {
327                for (type_name, def) in type_obj.iter_mut() {
328                    if let Value::Object(def_map) = def {
329                        if type_name == "array" {
330                            if let Some(Value::Object(items_obj)) = def_map.get_mut("items")
331                                && let Some(Value::Object(subtypes)) = items_obj.get_mut("type")
332                            {
333                                for (subtype_name, subtype_def) in subtypes.iter_mut() {
334                                    if subtype_name != "array"
335                                        && let Value::Object(s_def) = subtype_def
336                                    {
337                                        s_def.insert(
338                                            "examples".to_string(),
339                                            Value::Array(flattened_examples.clone()),
340                                        );
341                                    }
342                                }
343                            }
344                        } else {
345                            def_map.insert(
346                                "examples".to_string(),
347                                Value::Array(flattened_examples.clone()),
348                            );
349                        }
350                    }
351                }
352            }
353        }
354
355        if let Some(type_unit) = get_schema_metadata(source_schema, "docs::type_unit") {
356            let unit_str = match type_unit {
357                Value::String(s) => s.clone(),
358                v => v.to_string(),
359            };
360            if let Some(schema_type) = self.numeric_schema_type(resolved_schema)
361                && let Some(Value::Object(types)) = resolved_schema.get_mut("type")
362                && let Some(Value::Object(def)) = types.get_mut(schema_type)
363            {
364                def.insert("unit".to_string(), Value::String(unit_str));
365            }
366        }
367
368        if let Some(syntax_override) = get_schema_metadata(source_schema, "docs::syntax_override") {
369            let syntax_str = match syntax_override {
370                Value::String(s) => s.clone(),
371                v => v.to_string(),
372            };
373            if self.resolved_schema_type(resolved_schema) == Some("string")
374                && let Some(Value::Object(types)) = resolved_schema.get_mut("type")
375                && let Some(Value::Object(string_def)) = types.get_mut("string")
376            {
377                string_def.insert("syntax".to_string(), Value::String(syntax_str));
378            }
379        }
380    }
381
382    pub fn sort_hash_nested(
383        input: &serde_json::Map<String, Value>,
384    ) -> serde_json::Map<String, Value> {
385        let mut sorted = serde_json::Map::new();
386        let mut keys: Vec<&String> = input.keys().collect();
387        keys.sort();
388        for key in keys {
389            let val = input.get(key).unwrap();
390            let new_val = if let Value::Object(obj) = val {
391                Value::Object(Self::sort_hash_nested(obj))
392            } else {
393                val.clone()
394            };
395            sorted.insert(key.clone(), new_val);
396        }
397        sorted
398    }
399
400    pub fn apply_object_property_fields(
401        &self,
402        parent_schema: &Value,
403        property_schema: &Value,
404        property_name: &str,
405        property: &mut Value,
406    ) {
407        let required_properties = parent_schema.get("required").and_then(|r| r.as_array());
408
409        let has_self_default_value = property_schema.get("default").is_some_and(|v| !v.is_null());
410        let has_parent_default_value = parent_schema
411            .get("default")
412            .and_then(|d| d.get(property_name))
413            .is_some_and(|v| !v.is_null());
414        let has_default_value = has_self_default_value || has_parent_default_value;
415
416        let is_required = required_properties
417            .is_some_and(|reqs| reqs.contains(&Value::String(property_name.to_string())))
418            || property_schema
419                .get("required")
420                .and_then(Value::as_bool)
421                .unwrap_or(false);
422
423        property.as_object_mut().unwrap().insert(
424            "required".to_string(),
425            Value::Bool(is_required && !has_default_value),
426        );
427    }
428
429    pub fn reconcile_resolved_schema(resolved: &mut Value) {
430        let Some(type_obj) = resolved.get("type").and_then(Value::as_object) else {
431            return;
432        };
433
434        if let Some(options) = type_obj
435            .get("object")
436            .and_then(|o| o.get("options"))
437            .and_then(Value::as_object)
438        {
439            let property_keys: Vec<String> = options.keys().cloned().collect();
440            for key in property_keys {
441                if let Some(prop) = resolved
442                    .pointer_mut(&format!("/type/object/options/{key}"))
443                    .filter(|v| v.is_object())
444                {
445                    Self::reconcile_resolved_schema(prop);
446                }
447            }
448            return;
449        }
450
451        let is_required = resolved
452            .get("required")
453            .and_then(Value::as_bool)
454            .unwrap_or(false);
455        if is_required {
456            let type_field_keys: Vec<String> = type_obj.keys().cloned().collect();
457            for type_field in &type_field_keys {
458                let pointer = format!("/type/{type_field}");
459                if let Some(Value::Object(field)) = resolved.pointer_mut(&pointer)
460                    && let Some(Value::Null) = field.get("default")
461                {
462                    field.shift_remove("default");
463                }
464            }
465        }
466
467        let schema_description = resolved
468            .get("description")
469            .and_then(Value::as_str)
470            .map(str::to_owned);
471
472        let type_field_keys: Vec<String> = resolved
473            .get("type")
474            .and_then(Value::as_object)
475            .map(|o| o.keys().cloned().collect())
476            .unwrap_or_default();
477
478        for type_field in &type_field_keys {
479            let const_pointer = format!("/type/{type_field}/const");
480            let Some(const_value) = resolved.pointer(&const_pointer).cloned() else {
481                continue;
482            };
483
484            let entries = match &const_value {
485                Value::Array(items) => items
486                    .iter()
487                    .filter_map(|item| {
488                        let key = scalar_const_key(item.get("value")?)?;
489                        let desc = item
490                            .get("description")
491                            .and_then(Value::as_str)
492                            .unwrap_or("")
493                            .to_string();
494                        Some((key, desc))
495                    })
496                    .collect::<Vec<_>>(),
497                Value::Object(single) => {
498                    let Some(key) = single.get("value").and_then(scalar_const_key) else {
499                        continue;
500                    };
501                    let desc = single
502                        .get("description")
503                        .and_then(Value::as_str)
504                        .map(str::to_owned)
505                        .or_else(|| schema_description.clone())
506                        .unwrap_or_default();
507                    vec![(key, desc)]
508                }
509                _ => continue,
510            };
511
512            let mut enum_map = Map::new();
513            for (key, desc) in entries {
514                enum_map.insert(key, Value::String(desc));
515            }
516
517            if let Some(Value::Object(field)) = resolved.pointer_mut(&format!("/type/{type_field}"))
518            {
519                field.shift_remove("const");
520                field.insert("enum".to_string(), Value::Object(enum_map));
521            }
522        }
523    }
524
525    pub fn numeric_schema_type(&self, resolved_schema: &Value) -> Option<&'static str> {
526        let schema_type = self.resolved_schema_type(resolved_schema)?;
527        if matches!(schema_type, "uint" | "int" | "float") {
528            Some(schema_type)
529        } else {
530            None
531        }
532    }
533
534    pub fn resolved_schema_type(&self, resolved_schema: &Value) -> Option<&'static str> {
535        if let Some(Value::Object(types)) = resolved_schema.get("type")
536            && types.len() == 1
537        {
538            let type_name = types.keys().next().unwrap();
539            return match type_name.as_str() {
540                "object" => Some("object"),
541                "array" => Some("array"),
542                "string" => Some("string"),
543                "bool" => Some("bool"),
544                "uint" => Some("uint"),
545                "int" => Some("int"),
546                "float" => Some("float"),
547                "condition" => Some("condition"),
548                "enum" => Some("enum"),
549                "const" => Some("const"),
550                "*" => Some("*"),
551                _ => None,
552            };
553        }
554        None
555    }
556
557    pub fn get_docs_type_for_value(&self, schema: Option<&Value>, value: &Value) -> &'static str {
558        let value_type = super::json_type_str(value);
559        if matches!(value_type, "number" | "integer")
560            && let Some(s) = schema
561            && let Some(numeric_type) =
562                get_schema_metadata(s, "docs::numeric_type").and_then(|n| n.as_str())
563        {
564            return match numeric_type {
565                "uint" => "uint",
566                "int" => "int",
567                "float" => "float",
568                _ => super::docs_type_str(value),
569            };
570        }
571        super::docs_type_str(value)
572    }
573}