Skip to main content

faucet_cli/
init_template.rs

1//! Schema-driven YAML template emitter for `faucet init`.
2//!
3//! Given a [`schemars`]-emitted JSON Schema for a connector config struct,
4//! [`schema_to_yaml_template`] walks the schema's `properties` and produces a
5//! comment-annotated YAML block suitable for pasting under `config:` in a
6//! generated pipeline file. Required fields are surfaced with placeholder
7//! values and a `# REQUIRED` comment; optional fields are commented out so
8//! users can opt in by uncommenting and editing.
9//!
10//! The emitter is intentionally a string builder rather than a `serde_yaml`
11//! round-trip: comments and required/optional distinctions matter to the
12//! reader and would be lost by any round-trip through a generic YAML value.
13
14use std::collections::HashMap;
15
16use serde_json::Value;
17
18/// A top-level property whose schema is a tagged enum (`oneOf` with a `const`
19/// discriminator field). Returned by [`discover_tagged_enum_fields`] so the
20/// CLI's interactive mode can prompt the user for the variant before emitting
21/// the scaffold.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct TaggedEnumField {
24    /// Property name on the root object — e.g. `"auth"`, `"pagination"`,
25    /// `"credentials"`.
26    pub path: String,
27    /// Variant tag values in the order they appear in `oneOf` — e.g.
28    /// `["None", "Bearer", "Basic", "ApiKey"]`.
29    pub variants: Vec<String>,
30}
31
32/// Render the `properties` of `schema` as a YAML block, indented by
33/// `indent_spaces` columns. Returns a string that always ends with `\n`.
34///
35/// Tagged-enum fields get their first variant inlined and the remaining
36/// variants emitted as commented-out "alternative" blocks the user can
37/// uncomment to switch. Use [`schema_to_yaml_template_with_choices`] to
38/// override which variant is inlined.
39pub 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
43/// Like [`schema_to_yaml_template`], but each entry in `choices` overrides
44/// the inlined variant for a tagged-enum field whose property name matches
45/// the key. (e.g. `choices.insert("auth", "Bearer")` makes the Bearer variant
46/// the inlined default.) Unknown variant tags fall back to the first variant.
47pub 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
62/// Walk the top-level properties of `schema` and return every property whose
63/// schema is a tagged enum. `$ref`s are resolved against `$defs`.
64pub 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)] // schema-walking helpers thread several context refs.
107fn 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    // Tagged-enum (oneOf with `type` discriminator) — emit the chosen variant
124    // inline and append the remaining variants as commented-out alternatives
125    // so users see every option without having to leave the file.
126    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    // Nested object with its own `properties` — recurse when required, flatten
141    // to a comment when optional.
142    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)] // schema-walking helpers thread several context refs.
190fn 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        // Optional tagged enum: flatten the chosen variant to a single
233        // commented line. Also emit the alternatives block so the user can
234        // see every option.
235        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
244/// Emit the non-chosen variants as a commented-out alternatives block.
245/// Lines are indented to the same column as the chosen variant's fields so
246/// removing the leading `# ` from a block produces valid YAML.
247fn 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            // Adjacent tagging nests a variant's real fields under a `config`
277            // object — drill into it so the commented alternative shows the
278            // actual fields, not an opaque `{ ... }`.
279            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
298/// Emit every property of an object schema as commented-out YAML lines at
299/// `indent`, recursing into nested objects. Used for the commented "alternative
300/// variant" blocks where the whole block is already prefixed with `# `.
301fn 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    // A directly tagged enum exposes `oneOf`. An `AuthSpec`-style wrapper
344    // (`#[serde(untagged)]` over the inline auth enum plus a `{ ref }` struct)
345    // exposes `anyOf`; unwrap to the inline member that is itself a tagged enum
346    // so `auth: { ref }` fields still render their inline-auth variants.
347    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    // Discover the discriminator from the first variant.
362    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        // Non-trivial composite defaults are rendered as compact JSON, which
425        // happens to be a valid YAML flow-style literal.
426        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
453/// Collapse runs of whitespace into single spaces and truncate to a
454/// reader-friendly preview: the first sentence, or 120 chars, whichever comes
455/// first. Long rustdoc paragraphs become illegible when inlined as a YAML
456/// comment, and the full text is still available via `faucet schema`.
457fn 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        // schemars emits adjacent-tagged enums (`#[serde(tag="type", content="config")]`)
687        // with the variant's real fields nested under a `config` object property.
688        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        // Inline (chosen = none) + bearer in the alternatives with nested config.
717        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        // The chosen variant (None by default) is inlined.
780        assert!(yaml.contains("type: none"), "yaml: {yaml}");
781        // Every other variant's tag appears in the alternatives block.
782        assert!(yaml.contains("type: bearer"), "yaml: {yaml}");
783        assert!(yaml.contains("type: basic"), "yaml: {yaml}");
784        // Other variants' fields appear too, commented out.
785        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        // Bearer is the chosen variant, so its `token` field is emitted
821        // uncommented with a REQUIRED marker.
822        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        // The `none` variant still appears in the alternatives block,
835        // commented out.
836        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}