vdev/commands/build/component_docs/
schema_enum.rs

1use super::{SchemaContext, get_schema_metadata, schema_aware_nested_merge};
2use anyhow::{Result, bail};
3use indexmap::IndexMap;
4use serde_json::{Map, Value, json};
5use std::collections::HashSet;
6
7impl SchemaContext {
8    #[allow(clippy::too_many_lines)]
9    pub fn resolve_enum_schema(&mut self, schema: &Value) -> Result<Value> {
10        let mut subschemas = match (schema.get("oneOf"), schema.get("anyOf")) {
11            (Some(Value::Array(arr)), None) | (None, Some(Value::Array(arr))) => arr.clone(),
12            _ => bail!(
13                "Enum schema had both `oneOf` and `anyOf` specified (or neither). Schema: {schema}"
14            ),
15        };
16
17        let is_optional = get_schema_metadata(schema, "docs::optional")
18            .and_then(serde_json::Value::as_bool)
19            .unwrap_or(false);
20        subschemas.retain(|sub| {
21            sub.get("type").and_then(|t| t.as_str()) != Some("null")
22                && get_schema_metadata(sub, "docs::hidden").is_none()
23        });
24
25        let subschema_count = subschemas.len();
26
27        if is_optional && subschema_count == 1 {
28            let sub = &subschemas[0];
29            if self.get_json_schema_type(sub) == Some("all-of") {
30                debug!("Detected optional all-of schema, unwrapping all-of schema to resolve...");
31                let mut unwrapped = schema.clone();
32                let obj = unwrapped.as_object_mut().unwrap();
33                obj.shift_remove("oneOf");
34                obj.shift_remove("anyOf");
35                obj.insert("allOf".to_string(), sub.get("allOf").unwrap().clone());
36                return Ok(json!({ "_resolved": self.resolve_schema(&unwrapped)? }));
37            }
38            let mut unwrapped = schema.clone();
39            let obj = unwrapped.as_object_mut().unwrap();
40            obj.shift_remove("oneOf");
41            obj.shift_remove("anyOf");
42            schema_aware_nested_merge(&mut unwrapped, sub);
43            return Ok(json!({ "_resolved": self.resolve_schema(&unwrapped)? }));
44        }
45
46        let Some(enum_tagging) =
47            get_schema_metadata(schema, "docs::enum_tagging").and_then(|v| v.as_str())
48        else {
49            bail!(
50                "Enum schemas should never be missing the metadata for the enum tagging mode. Schema: {schema}"
51            );
52        };
53        let enum_tag_field =
54            get_schema_metadata(schema, "docs::enum_tag_field").and_then(|v| v.as_str());
55
56        // Pattern: X or array of X
57        if subschema_count == 2 {
58            let array_idx = subschemas
59                .iter()
60                .position(|s| s.get("type").and_then(|t| t.as_str()) == Some("array"));
61            if let Some(idx) = array_idx {
62                debug!(
63                    "Detected likely 'X or array of X' enum schema, applying further validation..."
64                );
65                let single_idx = usize::from(idx == 0);
66
67                let single_reduced = self.get_reduced_schema(&subschemas[single_idx]);
68                let array_reduced = self.get_reduced_schema(&subschemas[idx]);
69
70                if Some(&single_reduced) == array_reduced.get("items") {
71                    debug!("Reduced schemas match, fully resolving schema for X...");
72                    let mut single_subschema = subschemas[single_idx].clone();
73                    if self.get_json_schema_type(&single_subschema)
74                        == schema.get("default").map(|d| super::json_type_str(d))
75                    {
76                        single_subschema.as_object_mut().unwrap().insert(
77                            "default".to_string(),
78                            schema.get("default").unwrap().clone(),
79                        );
80                    }
81
82                    let resolved_subschema = self.resolve_schema(&single_subschema)?;
83                    debug!("Resolved as 'X or array of X' enum schema.");
84                    return Ok(
85                        json!({ "_resolved": resolved_subschema, "annotations": "single_or_array" }),
86                    );
87                }
88            }
89        }
90
91        // Pattern: simple internally tagged enum with named fields
92        if enum_tagging == "internal" {
93            debug!("Resolving enum subschemas to detect 'object'-ness...");
94            let mut resolved_subschemas = Vec::new();
95            for sub in &subschemas {
96                let resolved = self.resolve_schema(sub)?;
97                if self.resolved_schema_type(&resolved) == Some("object") {
98                    resolved_subschemas.push(resolved);
99                }
100            }
101
102            if resolved_subschemas.len() == subschema_count {
103                debug!("Detected likely 'internally-tagged with named fields' enum schema...");
104                let mut unique_resolved_properties = Map::new();
105                let mut unique_tag_values: IndexMap<String, Value> = IndexMap::new();
106                let tag_field = enum_tag_field.unwrap();
107
108                for resolved_subschema in &mut resolved_subschemas {
109                    let title = resolved_subschema.get("title").cloned();
110                    let desc = resolved_subschema.get("description").cloned();
111
112                    let opts = resolved_subschema
113                        .pointer_mut("/type/object/options")
114                        .unwrap()
115                        .as_object_mut()
116                        .unwrap();
117                    let mut tag_subschema = opts.shift_remove(tag_field).unwrap();
118
119                    if let Some(t) = title {
120                        tag_subschema
121                            .as_object_mut()
122                            .unwrap()
123                            .insert("title".to_string(), t);
124                    }
125                    if let Some(d) = desc {
126                        tag_subschema
127                            .as_object_mut()
128                            .unwrap()
129                            .insert("description".to_string(), d);
130                    }
131
132                    let mut tag_value = None;
133                    for allowed in ["string", "number", "integer", "boolean"] {
134                        if let Some(const_val) =
135                            tag_subschema.pointer(&format!("/type/{allowed}/const/value"))
136                        {
137                            if let Some(s) = const_val.as_str() {
138                                tag_value = Some(s.to_string());
139                            } else {
140                                tag_value = Some(const_val.to_string());
141                            }
142                            break;
143                        }
144                        if let Some(enum_vals) = tag_subschema
145                            .pointer(&format!("/type/{allowed}/enum"))
146                            .and_then(|v| v.as_object())
147                            && let Some(first_key) = enum_vals.keys().next()
148                        {
149                            tag_value = Some(first_key.clone());
150                            break;
151                        }
152                    }
153
154                    let Some(tag_val_str) = tag_value else {
155                        bail!(
156                            "All enum subschemas representing an internally-tagged enum must have the tag field use a const value. Tag field: '{tag_field}', subschema: {tag_subschema}"
157                        );
158                    };
159
160                    if unique_tag_values.contains_key(&tag_val_str) {
161                        bail!(
162                            "Found duplicate tag value '{tag_val_str}' when resolving enum subschemas. Tag field: '{tag_field}'."
163                        );
164                    }
165                    unique_tag_values.insert(tag_val_str.clone(), tag_subschema.clone());
166
167                    for (prop_name, prop_schema) in opts.iter_mut() {
168                        if let Some(existing) = unique_resolved_properties.get_mut(prop_name) {
169                            let reduced_existing = self.get_reduced_resolved_schema(existing);
170                            let reduced_new = self.get_reduced_resolved_schema(prop_schema);
171                            if reduced_existing != reduced_new {
172                                bail!(
173                                    "Had overlapping property '{prop_name}' from resolved enum subschema, but schemas differed. Existing: {reduced_existing}, new: {reduced_new}."
174                                );
175                            }
176                            existing
177                                .get_mut("relevant_when")
178                                .unwrap()
179                                .as_array_mut()
180                                .unwrap()
181                                .push(Value::String(tag_val_str.clone()));
182                        } else {
183                            prop_schema
184                                .as_object_mut()
185                                .unwrap()
186                                .insert("relevant_when".to_string(), json!([tag_val_str.clone()]));
187                            unique_resolved_properties
188                                .insert(prop_name.clone(), prop_schema.clone());
189                        }
190                    }
191                }
192
193                let unique_tags: HashSet<String> = unique_tag_values.keys().cloned().collect();
194                for (_, val) in &mut unique_resolved_properties {
195                    let val_obj = val.as_object_mut().unwrap();
196                    if let Some(Value::Array(relevant)) = val_obj.get("relevant_when") {
197                        let rel_set: HashSet<String> = relevant
198                            .iter()
199                            .map(|v| v.as_str().unwrap().to_string())
200                            .collect();
201                        if rel_set.len() == unique_tags.len() && rel_set == unique_tags {
202                            val_obj.shift_remove("relevant_when");
203                        } else {
204                            let mapped: Vec<String> = relevant
205                                .iter()
206                                .map(|v| format!("{tag_field} = {v}"))
207                                .collect();
208                            val_obj.insert(
209                                "relevant_when".to_string(),
210                                Value::String(mapped.join(" or ")),
211                            );
212                        }
213                    }
214                }
215
216                let mut enum_vals = Map::new();
217                for (k, v) in unique_tag_values {
218                    let desc = self.get_rendered_description_from_schema(&v);
219                    enum_vals.insert(k, Value::String(desc));
220                }
221
222                let mut resolved_tag_property_obj = Map::new();
223                resolved_tag_property_obj.insert("required".to_string(), Value::Bool(true));
224                resolved_tag_property_obj.insert(
225                    "type".to_string(),
226                    json!({ "string": { "enum": enum_vals } }),
227                );
228
229                let Some(tag_desc) = get_schema_metadata(schema, "docs::enum_tag_description")
230                else {
231                    bail!(
232                        "A unique tag description must be specified for enums which are internally tagged. Schema: {schema}"
233                    );
234                };
235                resolved_tag_property_obj.insert("description".to_string(), tag_desc.clone());
236
237                unique_resolved_properties.insert(
238                    tag_field.to_string(),
239                    Value::Object(resolved_tag_property_obj),
240                );
241
242                return Ok(
243                    json!({ "_resolved": { "type": { "object": { "options": unique_resolved_properties } } } }),
244                );
245            }
246        }
247
248        // Schema pattern: simple externally tagged enum with only unit variants.
249        if enum_tagging == "external" {
250            let mut tag_values: IndexMap<String, Value> = IndexMap::new();
251            let mut all_const_strings = true;
252
253            for subschema in &subschemas {
254                if let Some(const_val) = subschema.get("const") {
255                    if let Some(s) = const_val.as_str() {
256                        tag_values.insert(s.to_string(), subschema.clone());
257                    } else {
258                        all_const_strings = false;
259                        break;
260                    }
261                } else {
262                    all_const_strings = false;
263                    break;
264                }
265            }
266
267            if all_const_strings && !tag_values.is_empty() {
268                debug!("Resolved as 'externally-tagged with only unit variants' enum schema.");
269                let mut enum_vals = Map::new();
270                for (k, v) in tag_values {
271                    let desc = self.get_rendered_description_from_schema(&v);
272                    enum_vals.insert(k, Value::String(desc));
273                }
274                return Ok(json!({ "_resolved": { "type": { "string": { "enum": enum_vals } } } }));
275            }
276        }
277
278        // Schema pattern: untagged enum with narrowing constant variants and catch-all free-form variant.
279        if enum_tagging == "untagged" {
280            let mut type_def_kinds: Vec<String> = Vec::new();
281            let mut fixed_subschemas = 0;
282            let mut freeform_subschemas = 0;
283
284            for subschema in &subschemas {
285                let schema_type = self.get_json_schema_type(subschema);
286                match schema_type {
287                    None | Some("all-of" | "one-of") => {
288                        // We don't handle these cases.
289                    }
290                    Some("const") => {
291                        if let Some(const_val) = subschema.get("const") {
292                            type_def_kinds.push(super::docs_type_str(const_val).to_string());
293                        }
294                        fixed_subschemas += 1;
295                    }
296                    Some("enum") => {
297                        if let Some(Value::Array(enum_vals)) = subschema.get("enum") {
298                            for val in enum_vals {
299                                type_def_kinds.push(super::docs_type_str(val).to_string());
300                            }
301                        }
302                        fixed_subschemas += 1;
303                    }
304                    Some(t) => {
305                        type_def_kinds.push(t.to_string());
306                        freeform_subschemas += 1;
307                    }
308                }
309            }
310
311            let unique_kinds: HashSet<_> = type_def_kinds.iter().collect();
312            if unique_kinds.len() == 1 && fixed_subschemas >= 1 && freeform_subschemas == 1 {
313                debug!("Resolved as 'untagged with narrowed free-form' enum schema.");
314                let type_def_kind = type_def_kinds.first().unwrap();
315                return Ok(
316                    json!({ "_resolved": { "type": { type_def_kind: {} } }, "annotations": "narrowed_free_form" }),
317                );
318            }
319        }
320
321        // Schema pattern: simple externally tagged enum with only non-unit variants.
322        if enum_tagging == "external" {
323            let all_objects = subschemas
324                .iter()
325                .all(|s| self.get_json_schema_type(s) == Some("object"));
326
327            if all_objects {
328                let mut aggregated_properties = Map::new();
329
330                for subschema in &subschemas {
331                    let resolved_subschema = self.resolve_schema(subschema)?;
332                    if let Some(Value::Object(resolved_properties)) =
333                        resolved_subschema.pointer("/type/object/options")
334                    {
335                        if resolved_properties.len() != 1 {
336                            bail!(
337                                "Expected exactly 1 property for externally-tagged non-unit enum variant, got {len}. Schema: {subschema}",
338                                len = resolved_properties.len()
339                            );
340                        }
341                        let description = self.get_rendered_description_from_schema(subschema);
342                        for (property_name, property_schema) in resolved_properties {
343                            let mut prop = property_schema.clone();
344                            if !description.is_empty() {
345                                prop.as_object_mut().unwrap().insert(
346                                    "description".to_string(),
347                                    Value::String(description.clone()),
348                                );
349                            }
350                            aggregated_properties.insert(property_name.clone(), prop);
351                        }
352                    }
353                }
354
355                if !aggregated_properties.is_empty() {
356                    debug!(
357                        "Resolved as 'externally-tagged with only non-unit variants' enum schema."
358                    );
359                    return Ok(
360                        json!({ "_resolved": { "type": { "object": { "options": aggregated_properties } } } }),
361                    );
362                }
363            }
364        }
365
366        // Fallback schema pattern: mixed-mode enums.
367        debug!("Resolved as 'fallback mixed-mode' enum schema.");
368        debug!("Tagging mode: {}", enum_tagging);
369
370        let mut resolved_subschemas: Vec<Value> = Vec::new();
371        for subschema in &subschemas {
372            let resolved = self.resolve_schema(subschema)?;
373            if !resolved.is_null() {
374                resolved_subschemas.push(resolved);
375            }
376        }
377
378        if resolved_subschemas.is_empty() {
379            return Ok(json!({ "_resolved": { "type": { "*": {} } } }));
380        }
381
382        let mut type_defs = resolved_subschemas[0].clone();
383        for item in resolved_subschemas.iter().skip(1) {
384            schema_aware_nested_merge(&mut type_defs, item);
385        }
386
387        let mut merged_type = type_defs
388            .get("type")
389            .cloned()
390            .unwrap_or_else(|| json!({ "*": {} }));
391
392        if let Value::Object(type_map) = &mut merged_type {
393            for (_, type_def) in type_map.iter_mut() {
394                if let Value::Object(def) = type_def
395                    && let Some(Value::Array(const_arr)) = def.shift_remove("const")
396                {
397                    let mut enum_map = Map::new();
398                    for const_obj in &const_arr {
399                        if let Some(value) = const_obj.get("value").and_then(|v| v.as_str()) {
400                            let desc = self.get_rendered_description_from_schema(const_obj);
401                            enum_map.insert(value.to_string(), Value::String(desc));
402                        }
403                    }
404                    if !enum_map.is_empty() {
405                        def.insert("enum".to_string(), Value::Object(enum_map));
406                    }
407                }
408            }
409        }
410
411        Ok(json!({ "_resolved": { "type": merged_type }, "annotations": "mixed_mode" }))
412    }
413}