1use std::collections::HashMap;
15
16use serde_json::Value;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct TaggedEnumField {
24 pub path: String,
27 pub variants: Vec<String>,
30}
31
32pub fn schema_to_yaml_template(schema: &Value, indent_spaces: usize) -> String {
40 schema_to_yaml_template_with_choices(schema, indent_spaces, &HashMap::new())
41}
42
43pub fn schema_to_yaml_template_with_choices(
48 schema: &Value,
49 indent_spaces: usize,
50 choices: &HashMap<String, String>,
51) -> String {
52 let defs = schema.get("$defs");
53 let mut out = String::new();
54 emit_object_properties(schema, defs, indent_spaces, choices, &mut out);
55 if out.is_empty() {
56 let pad = " ".repeat(indent_spaces);
57 out.push_str(&format!("{pad}{{}}\n"));
58 }
59 out
60}
61
62pub fn discover_tagged_enum_fields(schema: &Value) -> Vec<TaggedEnumField> {
65 let defs = schema.get("$defs");
66 let resolved = resolve_ref_borrowed(schema, defs);
67 let Some(props) = resolved.get("properties").and_then(|v| v.as_object()) else {
68 return Vec::new();
69 };
70 let mut out = Vec::new();
71 for (key, prop_schema) in props {
72 let prop_resolved = resolve_ref_borrowed(prop_schema, defs);
73 if let Some(variants) = tagged_enum_variants(prop_resolved, defs) {
74 out.push(TaggedEnumField {
75 path: key.clone(),
76 variants: variants.iter().map(|v| v.tag.to_string()).collect(),
77 });
78 }
79 }
80 out
81}
82
83fn emit_object_properties(
84 schema: &Value,
85 defs: Option<&Value>,
86 indent: usize,
87 choices: &HashMap<String, String>,
88 out: &mut String,
89) {
90 let schema = resolve_ref(schema, defs);
91 let Some(props) = schema.get("properties").and_then(|v| v.as_object()) else {
92 return;
93 };
94 let required: Vec<&str> = schema
95 .get("required")
96 .and_then(|v| v.as_array())
97 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
98 .unwrap_or_default();
99
100 for (key, prop_schema) in props {
101 let is_required = required.contains(&key.as_str());
102 emit_property(key, prop_schema, is_required, defs, indent, choices, out);
103 }
104}
105
106#[allow(clippy::too_many_arguments)] fn emit_property(
108 key: &str,
109 schema: &Value,
110 required: bool,
111 defs: Option<&Value>,
112 indent: usize,
113 choices: &HashMap<String, String>,
114 out: &mut String,
115) {
116 let pad = " ".repeat(indent);
117 let resolved = resolve_ref(schema, defs);
118 let description = resolved
119 .get("description")
120 .and_then(|v| v.as_str())
121 .map(collapse_whitespace);
122
123 if let Some(variants) = tagged_enum_variants(&resolved, defs) {
127 emit_tagged_enum(
128 key,
129 &variants,
130 required,
131 &description,
132 defs,
133 indent,
134 choices,
135 out,
136 );
137 return;
138 }
139
140 if is_object_with_properties(&resolved) {
143 if required {
144 out.push_str(&format!("{pad}{key}:\n"));
145 emit_object_properties(&resolved, defs, indent + 2, choices, out);
146 } else {
147 let line_comment = describe(&description, &resolved);
148 let suffix = if line_comment.is_empty() {
149 String::new()
150 } else {
151 format!(" # {line_comment}")
152 };
153 out.push_str(&format!("{pad}# {key}: {{ ... }}{suffix}\n"));
154 }
155 return;
156 }
157
158 let placeholder = type_placeholder(&resolved);
159 let value = resolved
160 .get("default")
161 .map(render_default)
162 .unwrap_or(placeholder);
163
164 let mut comment_parts: Vec<String> = Vec::new();
165 if required {
166 comment_parts.push("REQUIRED".to_string());
167 }
168 if let Some(d) = description.as_ref()
169 && !d.is_empty()
170 {
171 comment_parts.push(d.clone());
172 }
173 if let Some(values) = enum_string_values(&resolved) {
174 comment_parts.push(format!("one of: {}", values.join(", ")));
175 }
176 let comment = if comment_parts.is_empty() {
177 String::new()
178 } else {
179 format!(" # {}", comment_parts.join(" — "))
180 };
181
182 if required {
183 out.push_str(&format!("{pad}{key}: {value}{comment}\n"));
184 } else {
185 out.push_str(&format!("{pad}# {key}: {value}{comment}\n"));
186 }
187}
188
189#[allow(clippy::too_many_arguments)] fn emit_tagged_enum(
191 key: &str,
192 variants: &[TaggedVariant<'_>],
193 required: bool,
194 _description: &Option<String>,
195 defs: Option<&Value>,
196 indent: usize,
197 choices: &HashMap<String, String>,
198 out: &mut String,
199) {
200 let pad = " ".repeat(indent);
201 let inner_pad = " ".repeat(indent + 2);
202 let all_tags: Vec<&str> = variants.iter().map(|v| v.tag).collect();
203 let chosen_idx = choices
204 .get(key)
205 .and_then(|tag| variants.iter().position(|v| v.tag == tag))
206 .unwrap_or(0);
207 let chosen = &variants[chosen_idx];
208
209 if required {
210 out.push_str(&format!("{pad}{key}:\n"));
211 out.push_str(&format!(
212 "{inner_pad}type: {tag} # one of: {tags}\n",
213 tag = chosen.tag,
214 tags = all_tags.join(", "),
215 ));
216 for (field_key, field_schema, field_required) in &chosen.fields {
217 if *field_key == chosen.discriminator {
218 continue;
219 }
220 emit_property(
221 field_key,
222 field_schema,
223 *field_required,
224 defs,
225 indent + 2,
226 choices,
227 out,
228 );
229 }
230 emit_alternative_variants(variants, chosen_idx, defs, indent + 2, out);
231 } else {
232 out.push_str(&format!(
236 "{pad}# {key}: {{ type: {tag} }} # one of: {tags}\n",
237 tag = chosen.tag,
238 tags = all_tags.join(", "),
239 ));
240 emit_alternative_variants(variants, chosen_idx, defs, indent + 2, out);
241 }
242}
243
244fn emit_alternative_variants(
248 variants: &[TaggedVariant<'_>],
249 chosen_idx: usize,
250 defs: Option<&Value>,
251 indent: usize,
252 out: &mut String,
253) {
254 let alternatives: Vec<&TaggedVariant<'_>> = variants
255 .iter()
256 .enumerate()
257 .filter_map(|(i, v)| if i == chosen_idx { None } else { Some(v) })
258 .collect();
259 if alternatives.is_empty() {
260 return;
261 }
262 let pad = " ".repeat(indent);
263 out.push_str(&format!(
264 "{pad}# --- Alternative variants — replace the block above with one of these ---\n"
265 ));
266 for (i, alt) in alternatives.iter().enumerate() {
267 if i > 0 {
268 out.push_str(&format!("{pad}#\n"));
269 }
270 out.push_str(&format!("{pad}# type: {tag}\n", tag = alt.tag));
271 for (field_key, field_schema, field_required) in &alt.fields {
272 if *field_key == alt.discriminator {
273 continue;
274 }
275 let resolved = resolve_ref(field_schema, defs);
276 if is_object_with_properties(&resolved) {
280 out.push_str(&format!("{pad}# {field_key}:\n"));
281 emit_commented_object_props(&resolved, defs, indent + 2, out);
282 } else {
283 let placeholder = resolved
284 .get("default")
285 .map(render_default)
286 .unwrap_or_else(|| type_placeholder(&resolved));
287 let marker = if *field_required {
288 " # REQUIRED"
289 } else {
290 ""
291 };
292 out.push_str(&format!("{pad}# {field_key}: {placeholder}{marker}\n"));
293 }
294 }
295 }
296}
297
298fn emit_commented_object_props(
302 schema: &Value,
303 defs: Option<&Value>,
304 indent: usize,
305 out: &mut String,
306) {
307 let pad = " ".repeat(indent);
308 let Some(props) = schema.get("properties").and_then(|v| v.as_object()) else {
309 return;
310 };
311 let required: Vec<&str> = schema
312 .get("required")
313 .and_then(|v| v.as_array())
314 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
315 .unwrap_or_default();
316 for (k, s) in props {
317 let resolved = resolve_ref(s, defs);
318 let req = required.contains(&k.as_str());
319 if is_object_with_properties(&resolved) {
320 out.push_str(&format!("{pad}# {k}:\n"));
321 emit_commented_object_props(&resolved, defs, indent + 2, out);
322 } else {
323 let placeholder = resolved
324 .get("default")
325 .map(render_default)
326 .unwrap_or_else(|| type_placeholder(&resolved));
327 let marker = if req { " # REQUIRED" } else { "" };
328 out.push_str(&format!("{pad}# {k}: {placeholder}{marker}\n"));
329 }
330 }
331}
332
333struct TaggedVariant<'a> {
334 tag: &'a str,
335 discriminator: &'a str,
336 fields: Vec<(&'a str, &'a Value, bool)>,
337}
338
339fn tagged_enum_variants<'a>(
340 schema: &'a Value,
341 defs: Option<&'a Value>,
342) -> Option<Vec<TaggedVariant<'a>>> {
343 let arr = match schema.get("oneOf").and_then(|v| v.as_array()) {
348 Some(a) => a,
349 None => {
350 let any = schema.get("anyOf")?.as_array()?;
351 let inner = any
352 .iter()
353 .map(|m| resolve_ref_borrowed(m, defs))
354 .find(|r| r.get("oneOf").is_some())?;
355 inner.get("oneOf")?.as_array()?
356 }
357 };
358 if arr.is_empty() {
359 return None;
360 }
361 let first = resolve_ref_borrowed(&arr[0], defs);
363 let props = first.get("properties")?.as_object()?;
364 let (disc, _) = props
365 .iter()
366 .find(|(_, v)| v.get("const").and_then(|c| c.as_str()).is_some())?;
367 let mut variants = Vec::new();
368 for v in arr {
369 let resolved = resolve_ref_borrowed(v, defs);
370 let v_props = resolved.get("properties").and_then(|p| p.as_object())?;
371 let tag = v_props
372 .get(disc)
373 .and_then(|t| t.get("const"))
374 .and_then(|c| c.as_str())?;
375 let required: Vec<&str> = resolved
376 .get("required")
377 .and_then(|r| r.as_array())
378 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
379 .unwrap_or_default();
380 let fields = v_props
381 .iter()
382 .map(|(k, s)| (k.as_str(), s, required.contains(&k.as_str())))
383 .collect();
384 variants.push(TaggedVariant {
385 tag,
386 discriminator: disc,
387 fields,
388 });
389 }
390 Some(variants)
391}
392
393fn is_object_with_properties(schema: &Value) -> bool {
394 schema_type(schema) == Some("object") && schema.get("properties").is_some()
395}
396
397fn schema_type(schema: &Value) -> Option<&str> {
398 match schema.get("type") {
399 Some(Value::String(s)) => Some(s.as_str()),
400 Some(Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str()).find(|s| *s != "null"),
401 _ => None,
402 }
403}
404
405fn type_placeholder(schema: &Value) -> String {
406 match schema_type(schema) {
407 Some("string") => "\"\"".to_string(),
408 Some("integer") | Some("number") => "0".to_string(),
409 Some("boolean") => "false".to_string(),
410 Some("array") => "[]".to_string(),
411 Some("object") => "{}".to_string(),
412 _ => "null".to_string(),
413 }
414}
415
416fn render_default(v: &Value) -> String {
417 match v {
418 Value::Null => "null".to_string(),
419 Value::Bool(b) => b.to_string(),
420 Value::Number(n) => n.to_string(),
421 Value::String(s) => format!("\"{}\"", s.replace('"', "\\\"")),
422 Value::Array(a) if a.is_empty() => "[]".to_string(),
423 Value::Object(o) if o.is_empty() => "{}".to_string(),
424 other => other.to_string(),
427 }
428}
429
430fn enum_string_values(schema: &Value) -> Option<Vec<&str>> {
431 let arr = schema.get("enum")?.as_array()?;
432 let values: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
433 if values.is_empty() {
434 None
435 } else {
436 Some(values)
437 }
438}
439
440fn describe(description: &Option<String>, schema: &Value) -> String {
441 let mut parts: Vec<String> = Vec::new();
442 if let Some(d) = description.as_ref()
443 && !d.is_empty()
444 {
445 parts.push(d.clone());
446 }
447 if let Some(values) = enum_string_values(schema) {
448 parts.push(format!("one of: {}", values.join(", ")));
449 }
450 parts.join(" — ")
451}
452
453fn collapse_whitespace(s: &str) -> String {
458 let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
459 const MAX: usize = 120;
460 if let Some(idx) = collapsed.find(". ") {
461 let head = &collapsed[..idx + 1];
462 return head.to_string();
463 }
464 if collapsed.chars().count() > MAX {
465 let mut truncated: String = collapsed.chars().take(MAX).collect();
466 truncated.push('…');
467 return truncated;
468 }
469 collapsed
470}
471
472fn resolve_ref(schema: &Value, defs: Option<&Value>) -> Value {
473 resolve_ref_borrowed(schema, defs).clone()
474}
475
476fn resolve_ref_borrowed<'a>(schema: &'a Value, defs: Option<&'a Value>) -> &'a Value {
477 let Some(reference) = schema.get("$ref").and_then(|v| v.as_str()) else {
478 return schema;
479 };
480 let Some(name) = reference.strip_prefix("#/$defs/") else {
481 return schema;
482 };
483 defs.and_then(|d| d.get(name)).unwrap_or(schema)
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use serde_json::json;
490
491 #[test]
492 fn empty_object_schema_emits_brace_placeholder() {
493 let schema = json!({ "type": "object" });
494 let yaml = schema_to_yaml_template(&schema, 6);
495 assert_eq!(yaml, " {}\n");
496 }
497
498 #[test]
499 fn required_string_field_gets_quoted_placeholder_and_required_marker() {
500 let schema = json!({
501 "type": "object",
502 "properties": {
503 "path": { "type": "string", "description": "Path to the output file." }
504 },
505 "required": ["path"]
506 });
507 let yaml = schema_to_yaml_template(&schema, 6);
508 assert!(yaml.contains("path: \"\""), "missing path key: {yaml}");
509 assert!(
510 yaml.contains("# REQUIRED"),
511 "missing REQUIRED comment: {yaml}"
512 );
513 assert!(yaml.contains("Path to the output file."));
514 }
515
516 #[test]
517 fn required_integer_field_gets_zero_placeholder() {
518 let schema = json!({
519 "type": "object",
520 "properties": { "port": { "type": "integer" } },
521 "required": ["port"]
522 });
523 let yaml = schema_to_yaml_template(&schema, 0);
524 assert!(yaml.contains("port: 0"));
525 assert!(yaml.contains("# REQUIRED"));
526 }
527
528 #[test]
529 fn required_boolean_field_gets_false_placeholder() {
530 let schema = json!({
531 "type": "object",
532 "properties": { "ssl": { "type": "boolean" } },
533 "required": ["ssl"]
534 });
535 let yaml = schema_to_yaml_template(&schema, 0);
536 assert!(yaml.contains("ssl: false"));
537 }
538
539 #[test]
540 fn optional_field_with_default_is_commented_out_with_default_value() {
541 let schema = json!({
542 "type": "object",
543 "properties": {
544 "batch_size": { "type": "integer", "default": 1000, "description": "Batch size." }
545 }
546 });
547 let yaml = schema_to_yaml_template(&schema, 0);
548 assert!(yaml.contains("# batch_size: 1000"));
549 assert!(yaml.contains("Batch size."));
550 }
551
552 #[test]
553 fn optional_field_without_default_is_commented_out_with_placeholder() {
554 let schema = json!({
555 "type": "object",
556 "properties": {
557 "label": { "type": "string", "description": "Friendly label." }
558 }
559 });
560 let yaml = schema_to_yaml_template(&schema, 0);
561 assert!(yaml.contains("# label: \"\""));
562 assert!(yaml.contains("Friendly label."));
563 }
564
565 #[test]
566 fn enum_values_appear_in_comment() {
567 let schema = json!({
568 "type": "object",
569 "properties": {
570 "method": {
571 "type": "string",
572 "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
573 "default": "GET",
574 "description": "HTTP method."
575 }
576 }
577 });
578 let yaml = schema_to_yaml_template(&schema, 0);
579 assert!(yaml.contains("# method: \"GET\""), "yaml: {yaml}");
580 assert!(
581 yaml.contains("one of: GET, POST, PUT, PATCH, DELETE"),
582 "yaml: {yaml}"
583 );
584 }
585
586 #[test]
587 fn required_nested_object_recurses() {
588 let schema = json!({
589 "type": "object",
590 "properties": {
591 "address": {
592 "type": "object",
593 "properties": {
594 "city": { "type": "string" }
595 },
596 "required": ["city"]
597 }
598 },
599 "required": ["address"]
600 });
601 let yaml = schema_to_yaml_template(&schema, 0);
602 assert!(yaml.contains("address:\n"), "yaml: {yaml}");
603 assert!(yaml.contains(" city: \"\""), "yaml: {yaml}");
604 assert!(yaml.contains("# REQUIRED"));
605 }
606
607 #[test]
608 fn optional_nested_object_flattens_to_comment() {
609 let schema = json!({
610 "type": "object",
611 "properties": {
612 "tls": {
613 "type": "object",
614 "properties": { "ca_path": { "type": "string" } },
615 "description": "TLS settings."
616 }
617 }
618 });
619 let yaml = schema_to_yaml_template(&schema, 0);
620 assert!(yaml.contains("# tls: { ... }"), "yaml: {yaml}");
621 assert!(yaml.contains("TLS settings."));
622 }
623
624 #[test]
625 fn tagged_enum_required_expands_first_variant_inline() {
626 let schema = json!({
627 "type": "object",
628 "properties": {
629 "auth": {
630 "oneOf": [
631 {
632 "type": "object",
633 "properties": { "type": { "const": "none" } },
634 "required": ["type"]
635 },
636 {
637 "type": "object",
638 "properties": {
639 "type": { "const": "bearer" },
640 "token": { "type": "string" }
641 },
642 "required": ["type", "token"]
643 }
644 ]
645 }
646 },
647 "required": ["auth"]
648 });
649 let yaml = schema_to_yaml_template(&schema, 0);
650 assert!(yaml.contains("auth:\n"), "yaml: {yaml}");
651 assert!(yaml.contains("type: none"), "yaml: {yaml}");
652 assert!(yaml.contains("one of: none, bearer"), "yaml: {yaml}");
653 }
654
655 #[test]
656 fn tagged_enum_optional_flattens_to_first_variant_comment() {
657 let schema = json!({
658 "type": "object",
659 "properties": {
660 "auth": {
661 "oneOf": [
662 {
663 "type": "object",
664 "properties": { "type": { "const": "none" } },
665 "required": ["type"]
666 },
667 {
668 "type": "object",
669 "properties": {
670 "type": { "const": "bearer" },
671 "token": { "type": "string" }
672 },
673 "required": ["type", "token"]
674 }
675 ]
676 }
677 }
678 });
679 let yaml = schema_to_yaml_template(&schema, 0);
680 assert!(yaml.contains("# auth: { type: none }"), "yaml: {yaml}");
681 assert!(yaml.contains("one of: none, bearer"), "yaml: {yaml}");
682 }
683
684 #[test]
685 fn adjacent_tagged_enum_nests_config_block() {
686 let schema = json!({
689 "type": "object",
690 "properties": {
691 "auth": {
692 "oneOf": [
693 {
694 "type": "object",
695 "properties": { "type": { "const": "none" } },
696 "required": ["type"]
697 },
698 {
699 "type": "object",
700 "properties": {
701 "type": { "const": "bearer" },
702 "config": {
703 "type": "object",
704 "properties": { "token": { "type": "string" } },
705 "required": ["token"]
706 }
707 },
708 "required": ["type", "config"]
709 }
710 ]
711 }
712 },
713 "required": ["auth"]
714 });
715 let yaml = schema_to_yaml_template(&schema, 0);
716 assert!(yaml.contains("type: none"), "yaml: {yaml}");
718 assert!(yaml.contains("# type: bearer"), "yaml: {yaml}");
719 assert!(yaml.contains("# config:"), "yaml: {yaml}");
720 assert!(yaml.contains("# token: \"\""), "yaml: {yaml}");
721 }
722
723 #[test]
724 fn ref_to_defs_is_resolved() {
725 let schema = json!({
726 "type": "object",
727 "properties": {
728 "creds": { "$ref": "#/$defs/Creds" }
729 },
730 "required": ["creds"],
731 "$defs": {
732 "Creds": {
733 "type": "object",
734 "properties": { "token": { "type": "string" } },
735 "required": ["token"]
736 }
737 }
738 });
739 let yaml = schema_to_yaml_template(&schema, 0);
740 assert!(yaml.contains("creds:\n"), "yaml: {yaml}");
741 assert!(yaml.contains(" token: \"\""), "yaml: {yaml}");
742 }
743
744 #[test]
745 fn tagged_enum_required_lists_other_variants_as_commented_alternatives() {
746 let schema = json!({
747 "type": "object",
748 "properties": {
749 "auth": {
750 "oneOf": [
751 {
752 "type": "object",
753 "properties": { "type": { "const": "none" } },
754 "required": ["type"]
755 },
756 {
757 "type": "object",
758 "properties": {
759 "type": { "const": "bearer" },
760 "token": { "type": "string" }
761 },
762 "required": ["type", "token"]
763 },
764 {
765 "type": "object",
766 "properties": {
767 "type": { "const": "basic" },
768 "username": { "type": "string" },
769 "password": { "type": "string" }
770 },
771 "required": ["type", "username", "password"]
772 }
773 ]
774 }
775 },
776 "required": ["auth"]
777 });
778 let yaml = schema_to_yaml_template(&schema, 0);
779 assert!(yaml.contains("type: none"), "yaml: {yaml}");
781 assert!(yaml.contains("type: bearer"), "yaml: {yaml}");
783 assert!(yaml.contains("type: basic"), "yaml: {yaml}");
784 assert!(yaml.contains("# token: \"\""), "yaml: {yaml}");
786 assert!(yaml.contains("# username: \"\""), "yaml: {yaml}");
787 assert!(yaml.contains("# password: \"\""), "yaml: {yaml}");
788 }
789
790 #[test]
791 fn tagged_enum_with_explicit_choice_inlines_that_variant() {
792 use std::collections::HashMap;
793 let schema = json!({
794 "type": "object",
795 "properties": {
796 "auth": {
797 "oneOf": [
798 {
799 "type": "object",
800 "properties": { "type": { "const": "none" } },
801 "required": ["type"]
802 },
803 {
804 "type": "object",
805 "properties": {
806 "type": { "const": "bearer" },
807 "token": { "type": "string" }
808 },
809 "required": ["type", "token"]
810 }
811 ]
812 }
813 },
814 "required": ["auth"]
815 });
816 let mut choices = HashMap::new();
817 choices.insert("auth".to_string(), "bearer".to_string());
818 let yaml = schema_to_yaml_template_with_choices(&schema, 0, &choices);
819
820 let token_line = yaml
823 .lines()
824 .find(|l| l.contains("token:"))
825 .expect("token line missing");
826 assert!(
827 !token_line.trim_start().starts_with('#'),
828 "expected chosen variant's token to be uncommented; got: {token_line:?}"
829 );
830 assert!(
831 token_line.contains("REQUIRED"),
832 "chosen variant fields should carry REQUIRED marker; got: {token_line:?}"
833 );
834 let none_line = yaml
837 .lines()
838 .find(|l| l.contains("type: none"))
839 .expect("none variant missing from alternatives");
840 assert!(
841 none_line.trim_start().starts_with('#'),
842 "non-chosen variant should be commented out; got: {none_line:?}"
843 );
844 }
845
846 #[test]
847 fn discover_tagged_enum_fields_finds_top_level_oneof_properties() {
848 let schema = json!({
849 "type": "object",
850 "properties": {
851 "auth": {
852 "oneOf": [
853 {
854 "type": "object",
855 "properties": { "type": { "const": "none" } },
856 "required": ["type"]
857 },
858 {
859 "type": "object",
860 "properties": {
861 "type": { "const": "bearer" },
862 "token": { "type": "string" }
863 },
864 "required": ["type", "token"]
865 }
866 ]
867 },
868 "path": { "type": "string" }
869 }
870 });
871 let fields = discover_tagged_enum_fields(&schema);
872 assert_eq!(fields.len(), 1, "expected exactly one tagged-enum field");
873 assert_eq!(fields[0].path, "auth");
874 assert_eq!(fields[0].variants, vec!["none", "bearer"]);
875 }
876
877 #[test]
878 fn discover_tagged_enum_fields_resolves_refs() {
879 let schema = json!({
880 "type": "object",
881 "properties": {
882 "auth": { "$ref": "#/$defs/Auth" }
883 },
884 "$defs": {
885 "Auth": {
886 "oneOf": [
887 {
888 "type": "object",
889 "properties": { "type": { "const": "none" } },
890 "required": ["type"]
891 },
892 {
893 "type": "object",
894 "properties": {
895 "type": { "const": "bearer" },
896 "token": { "type": "string" }
897 },
898 "required": ["type", "token"]
899 }
900 ]
901 }
902 }
903 });
904 let fields = discover_tagged_enum_fields(&schema);
905 assert_eq!(fields.len(), 1);
906 assert_eq!(fields[0].path, "auth");
907 assert_eq!(fields[0].variants, vec!["none", "bearer"]);
908 }
909
910 #[test]
911 fn output_indent_matches_requested_column() {
912 let schema = json!({
913 "type": "object",
914 "properties": { "name": { "type": "string" } },
915 "required": ["name"]
916 });
917 let yaml = schema_to_yaml_template(&schema, 4);
918 for line in yaml.lines() {
919 if !line.trim().is_empty() {
920 assert!(
921 line.starts_with(" "),
922 "line not indented 4 spaces: {line:?}"
923 );
924 }
925 }
926 }
927}