vdev/commands/build/component_docs/
schema_enum.rs1use 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 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 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 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 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 }
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 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 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}