Skip to main content

cli/
ts_codegen.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Generate TypeScript types from heddle's runtime JSON-Schema introspection.
3//!
4//! This is the source of truth for the `clients/npm` wrapper (#581/#584): it
5//! walks every schema verb ([`schema_verbs`]), renders the schemars-derived
6//! JSON Schema for each ([`schema_for_verb`]), and produces both the raw
7//! schemas and hand-free TypeScript declarations a Node/Electron harness can
8//! import.
9//!
10//! The output is deterministic (everything sorted), so regenerating on an
11//! unchanged contract produces a no-op diff. The `gen_ts_types` example writes
12//! it to disk; `tests/ts_types_in_sync.rs` asserts the checked-in files match.
13
14use std::{
15    collections::{BTreeMap, BTreeSet},
16    fmt::Write as _,
17};
18
19use serde_json::{Map, Value};
20
21use crate::cli::commands::{schema_for_verb, schema_verbs};
22
23/// Schema-contract version the emitted types are pinned to. Tracks the
24/// `heddle-cli` crate version: a contract change ships in a new CLI release,
25/// so the crate version is the coarsest-correct pin for "which heddle do
26/// these types describe".
27pub const SCHEMA_VERSION: &str = env!("CARGO_PKG_VERSION");
28
29/// The generated wrapper artifacts.
30pub struct Generated {
31    /// `heddle-schemas.ts` — TypeScript types + verb map + version pin.
32    pub typescript: String,
33    /// `heddle-schemas.json` — raw JSON Schemas keyed by verb.
34    pub json: String,
35}
36
37/// Render the TypeScript module and raw-schema JSON from the live catalog.
38pub fn generate() -> Generated {
39    let mut verbs: Vec<&str> = schema_verbs().to_vec();
40    verbs.sort_unstable();
41    let verb_schemas: Vec<(String, Value)> = verbs
42        .into_iter()
43        .filter_map(|verb| schema_for_verb(verb).map(|schema| (verb.to_string(), schema)))
44        .collect();
45    generate_from(verb_schemas)
46}
47
48/// A single global name registry that every emitted type — `$def` *and* root —
49/// flows through, so no two distinct bodies can ever share a sanitized name.
50///
51/// This closes the name-collision class structurally: defs and roots are not
52/// two separate namespaces that can clobber each other, they are one allocation
53/// pass. When two distinct bodies want the same sanitized name (root-vs-root,
54/// root-vs-def, or def-vs-def across verbs) the later one is disambiguated with
55/// a numeric suffix instead of overwriting, and every `$ref` that pointed at the
56/// renamed def is rewritten to its allocated name so it still resolves to the
57/// intended definition.
58struct NameRegistry {
59    /// final emitted name -> type body.
60    types: BTreeMap<String, Value>,
61    /// desired base name -> allocated `(final_name, closure_signature)` pairs.
62    /// A new request reuses an existing final name only when its full ref
63    /// closure is byte-identical (i.e. genuinely the same type); otherwise it
64    /// gets a fresh, suffixed name.
65    by_base: BTreeMap<String, Vec<(String, String)>>,
66}
67
68impl NameRegistry {
69    fn new() -> Self {
70        Self {
71            types: BTreeMap::new(),
72            by_base: BTreeMap::new(),
73        }
74    }
75
76    /// Reserve a unique final name for `base`. `sig` is the body's ref-closure
77    /// signature: identical signatures dedup to one shared type, distinct ones
78    /// are kept apart. Returns `(final_name, is_new)`; when `is_new` the caller
79    /// must store the (ref-rewritten) body under `final_name`.
80    fn allocate(&mut self, base: &str, sig: &str) -> (String, bool) {
81        if let Some(existing) = self.by_base.get(base) {
82            for (final_name, existing_sig) in existing {
83                if existing_sig == sig {
84                    return (final_name.clone(), false);
85                }
86            }
87        }
88        let mut candidate = base.to_string();
89        let mut n = 1;
90        while self.types.contains_key(&candidate) {
91            n += 1;
92            candidate = format!("{base}{n}");
93        }
94        // Claim the name immediately so concurrent allocations in the same pass
95        // can't pick it; the real body is written by the caller when new.
96        self.types.insert(candidate.clone(), Value::Null);
97        self.by_base
98            .entry(base.to_string())
99            .or_default()
100            .push((candidate.clone(), sig.to_string()));
101        (candidate, true)
102    }
103}
104
105/// Core codegen over an explicit `(verb, schema)` list. Split out from
106/// [`generate`] so collision handling is unit-testable without the live
107/// catalog.
108fn generate_from(verb_schemas: Vec<(String, Value)>) -> Generated {
109    // Deterministic processing order so suffix assignment is stable.
110    let mut verb_schemas = verb_schemas;
111    verb_schemas.sort_by(|a, b| a.0.cmp(&b.0));
112
113    let mut registry = NameRegistry::new();
114    let mut verb_to_type: BTreeMap<String, String> = BTreeMap::new();
115    let mut raw: BTreeMap<String, Value> = BTreeMap::new();
116
117    // A title shared by >1 verb can't name both verbs' root types, so the
118    // colliding verbs each fall back to a distinct per-verb type name.
119    let mut title_counts: BTreeMap<String, usize> = BTreeMap::new();
120    for (verb, schema) in &verb_schemas {
121        let title = root_title(verb, schema);
122        *title_counts.entry(title).or_default() += 1;
123    }
124
125    for (verb, schema) in &verb_schemas {
126        // 1. This verb's `$defs`, keyed by their ORIGINAL (unsanitized) names —
127        //    the stable unique key. `$ref`s resolve by original name too, so two
128        //    defs that *sanitize* to the same identifier (e.g. `Foo-Bar` and
129        //    `Foo_Bar`) stay distinct here and are disambiguated at allocation
130        //    instead of one silently clobbering the other.
131        let mut defs: BTreeMap<String, Value> = BTreeMap::new();
132        if let Some(obj) = schema.get("$defs").and_then(Value::as_object) {
133            for (name, body) in obj {
134                defs.insert(name.clone(), body.clone());
135            }
136        }
137
138        // 2. Allocate a global name for every def, building this verb's
139        //    rename map (original def name -> final emitted name). The desired
140        //    base is the sanitized form; collisions are suffixed, never dropped.
141        let mut rename: BTreeMap<String, String> = BTreeMap::new();
142        let mut newly: Vec<(String, String)> = Vec::new();
143        for name in defs.keys() {
144            let sig = body_sig(&defs[name], &defs);
145            let (final_name, is_new) = registry.allocate(&sanitize_ident(name), &sig);
146            if is_new {
147                newly.push((name.clone(), final_name.clone()));
148            }
149            rename.insert(name.clone(), final_name);
150        }
151
152        // 3. Store each newly-allocated def body with its `$ref`s rewritten to
153        //    the verb's resolved names. Shared (deduped) defs are already
154        //    present and structurally identical, so they are left untouched.
155        for (orig, final_name) in &newly {
156            let mut body = defs[orig].clone();
157            rewrite_refs(&mut body, &rename);
158            registry.types.insert(final_name.clone(), body);
159        }
160
161        // 4. The root: same registry, so it can never overwrite a def of a
162        //    different shape — a collision suffixes the root instead.
163        let title = root_title(verb, schema);
164        let desired = if title_counts.get(&title).copied().unwrap_or(0) > 1 {
165            verb_type_name(verb)
166        } else {
167            title
168        };
169        let mut root_body = strip_root_meta(schema);
170        // Sign the root by its *content* (raw refs, like the defs above) so a
171        // root that is genuinely the same shape as a same-named `$def` shares
172        // one type, while a root that differs (e.g. carries a runtime-injected
173        // discriminator the `$def` lacks) gets a distinct, suffixed name instead
174        // of overwriting the `$def`.
175        let root_sig = body_sig(&root_body, &defs);
176        rewrite_refs(&mut root_body, &rename);
177        let (final_name, is_new) = registry.allocate(&desired, &root_sig);
178        if is_new {
179            registry.types.insert(final_name.clone(), root_body);
180        }
181        verb_to_type.insert(verb.clone(), final_name);
182
183        raw.insert(verb.clone(), schema.clone());
184    }
185
186    let types = registry.types;
187    let typescript = render_ts(&types, &verb_to_type);
188    let json = serde_json::to_string_pretty(&serde_json::json!({
189        "schemaVersion": SCHEMA_VERSION,
190        "verbs": raw,
191    }))
192    .expect("raw schemas serialize")
193        + "\n";
194
195    Generated { typescript, json }
196}
197
198/// The sanitized type name a verb's root *wants*, before collision handling:
199/// its schema `title`, or a verb-derived name when the schema has no title.
200fn root_title(verb: &str, schema: &Value) -> String {
201    schema
202        .get("title")
203        .and_then(Value::as_str)
204        .map(sanitize_ident)
205        .unwrap_or_else(|| verb_type_name(verb))
206}
207
208/// Recursively rewrite every `$ref` so its terminal name resolves through
209/// `rename` (original def name -> final emitted name). Every intra-verb ref is
210/// in the map, so all are rewritten to their allocated name; refs to unknown
211/// targets keep their original form. This keeps `$ref`s pointing at the right
212/// definition after a colliding def was given a suffixed name.
213fn rewrite_refs(value: &mut Value, rename: &BTreeMap<String, String>) {
214    match value {
215        Value::Object(map) => {
216            let remapped = map.get("$ref").and_then(Value::as_str).and_then(|r| {
217                let terminal = r.rsplit('/').next().unwrap_or(r);
218                rename
219                    .get(terminal)
220                    .map(|final_name| format!("#/$defs/{final_name}"))
221            });
222            if let Some(new_ref) = remapped {
223                map.insert("$ref".to_string(), Value::String(new_ref));
224            }
225            for v in map.values_mut() {
226                rewrite_refs(v, rename);
227            }
228        }
229        Value::Array(items) => {
230            for v in items.iter_mut() {
231                rewrite_refs(v, rename);
232            }
233        }
234        _ => {}
235    }
236}
237
238/// A deterministic signature of a type body's *full ref closure* within one
239/// verb's `$defs`. Two bodies (def or root) share a name only when their
240/// signatures match — i.e. they are the same type all the way down — so a
241/// shallow same-name/same-body coincidence whose nested refs differ is never
242/// wrongly merged, and a root identical to a same-named `$def` is unified rather
243/// than duplicated.
244fn body_sig(body: &Value, defs: &BTreeMap<String, Value>) -> String {
245    let mut visited = BTreeSet::new();
246    let mut buf = String::new();
247    sig_value(body, defs, &mut visited, &mut buf);
248    buf
249}
250
251fn sig_node(
252    name: &str,
253    defs: &BTreeMap<String, Value>,
254    visited: &mut BTreeSet<String>,
255    buf: &mut String,
256) {
257    if !visited.insert(name.to_string()) {
258        // Back-reference into a cycle: record the name, don't re-expand.
259        buf.push('@');
260        buf.push_str(name);
261        buf.push(';');
262        return;
263    }
264    match defs.get(name) {
265        Some(body) => {
266            buf.push_str(name);
267            buf.push('=');
268            sig_value(body, defs, visited, buf);
269            buf.push(';');
270        }
271        // Ref into another verb / unknown def: name alone, can't expand.
272        None => {
273            buf.push('?');
274            buf.push_str(name);
275            buf.push(';');
276        }
277    }
278}
279
280fn sig_value(
281    value: &Value,
282    defs: &BTreeMap<String, Value>,
283    visited: &mut BTreeSet<String>,
284    buf: &mut String,
285) {
286    match value {
287        Value::Object(map) => {
288            if let Some(reference) = map.get("$ref").and_then(Value::as_str) {
289                let terminal = reference.rsplit('/').next().unwrap_or(reference);
290                buf.push_str("ref(");
291                sig_node(terminal, defs, visited, buf);
292                buf.push(')');
293                return;
294            }
295            buf.push('{');
296            for (k, v) in map {
297                buf.push_str(k);
298                buf.push(':');
299                sig_value(v, defs, visited, buf);
300                buf.push(',');
301            }
302            buf.push('}');
303        }
304        Value::Array(items) => {
305            buf.push('[');
306            for v in items {
307                sig_value(v, defs, visited, buf);
308                buf.push(',');
309            }
310            buf.push(']');
311        }
312        other => buf.push_str(&other.to_string()),
313    }
314}
315
316/// Drop the JSON-Schema envelope keys, keeping only the type body so a root
317/// can be emitted exactly like a `$def`.
318fn strip_root_meta(schema: &Value) -> Value {
319    let Some(obj) = schema.as_object() else {
320        return schema.clone();
321    };
322    let mut out = Map::new();
323    for (k, v) in obj {
324        if matches!(k.as_str(), "$schema" | "$defs" | "title") {
325            continue;
326        }
327        out.insert(k.clone(), v.clone());
328    }
329    Value::Object(out)
330}
331
332fn render_ts(types: &BTreeMap<String, Value>, verb_to_type: &BTreeMap<String, String>) -> String {
333    let mut out = String::new();
334    out.push_str(
335        "// GENERATED by `cargo run -p heddle-cli --example gen_ts_types` — DO NOT EDIT.\n",
336    );
337    out.push_str("// Source of truth: heddle's runtime JSON-Schema introspection\n");
338    out.push_str("// (`heddle schemas <verb>` / `crates/cli/src/cli/commands/schemas.rs`).\n");
339    out.push_str(
340        "// Regenerate with `scripts/gen-ts-types.sh`; a drift test keeps it in sync.\n\n",
341    );
342    let _ = writeln!(
343        out,
344        "export const HEDDLE_SCHEMA_VERSION = {:?} as const;\n",
345        SCHEMA_VERSION
346    );
347
348    for (name, body) in types {
349        emit_type(&mut out, name, body);
350    }
351
352    out.push_str("/** Maps each `--output json` verb to its output payload type. */\n");
353    out.push_str("export interface HeddleVerbOutputs {\n");
354    for (verb, ty) in verb_to_type {
355        let _ = writeln!(out, "  {}: {};", quote_key(verb), ty);
356    }
357    out.push_str("}\n\n");
358
359    out.push_str("/** Every verb that emits a schema-backed `--output json` payload. */\n");
360    out.push_str("export type HeddleSchemaVerb = keyof HeddleVerbOutputs;\n\n");
361
362    out.push_str("export const HEDDLE_SCHEMA_VERBS: readonly HeddleSchemaVerb[] = [\n");
363    for verb in verb_to_type.keys() {
364        let _ = writeln!(out, "  {},", json_string(verb));
365    }
366    out.push_str("] as const;\n");
367
368    out
369}
370
371fn emit_type(out: &mut String, name: &str, body: &Value) {
372    let is_object = body.get("type").and_then(Value::as_str) == Some("object")
373        && body.get("properties").is_some();
374
375    if let Some(desc) = body.get("description").and_then(Value::as_str) {
376        emit_jsdoc(out, desc, "");
377    }
378
379    if is_object {
380        let _ = writeln!(out, "export interface {name} {{");
381        emit_object_body(out, body, "  ");
382        out.push_str("}\n\n");
383    } else {
384        let _ = writeln!(out, "export type {name} = {};\n", ts_type(body));
385    }
386}
387
388/// Emit the fields of an object schema into an already-open `{ ... }` block.
389fn emit_object_body(out: &mut String, body: &Value, indent: &str) {
390    let required: Vec<&str> = body
391        .get("required")
392        .and_then(Value::as_array)
393        .map(|a| a.iter().filter_map(Value::as_str).collect())
394        .unwrap_or_default();
395
396    if let Some(props) = body.get("properties").and_then(Value::as_object) {
397        for (field, schema) in props {
398            if let Some(desc) = schema.get("description").and_then(Value::as_str) {
399                emit_jsdoc(out, desc, indent);
400            }
401            let opt = if required.contains(&field.as_str()) {
402                ""
403            } else {
404                "?"
405            };
406            let _ = writeln!(
407                out,
408                "{indent}{}{opt}: {};",
409                quote_key(field),
410                ts_type(schema)
411            );
412        }
413    }
414
415    // Open record / flattened-map shapes.
416    match body.get("additionalProperties") {
417        Some(Value::Bool(true)) => {
418            let _ = writeln!(out, "{indent}[key: string]: unknown;");
419        }
420        Some(v @ Value::Object(_)) => {
421            let _ = writeln!(out, "{indent}[key: string]: {};", ts_type(v));
422        }
423        _ => {}
424    }
425}
426
427/// Convert a JSON-Schema node into a TypeScript type expression.
428fn ts_type(node: &Value) -> String {
429    match node {
430        Value::Bool(true) => return "unknown".to_string(),
431        Value::Bool(false) => return "never".to_string(),
432        _ => {}
433    }
434    let Some(obj) = node.as_object() else {
435        return "unknown".to_string();
436    };
437
438    if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
439        return ref_name(reference);
440    }
441
442    if let Some(values) = obj.get("enum").and_then(Value::as_array) {
443        let mut parts: Vec<String> = values.iter().map(literal).collect();
444        parts.dedup();
445        return parts.join(" | ");
446    }
447
448    for key in ["anyOf", "oneOf"] {
449        if let Some(variants) = obj.get(key).and_then(Value::as_array) {
450            let mut parts: Vec<String> = variants.iter().map(ts_type).collect();
451            parts.dedup();
452            return union(parts);
453        }
454    }
455
456    if let Some(all) = obj.get("allOf").and_then(Value::as_array) {
457        let parts: Vec<String> = all.iter().map(ts_type).collect();
458        return parts.join(" & ");
459    }
460
461    match obj.get("type") {
462        Some(Value::String(t)) => ts_scalar(t, obj),
463        Some(Value::Array(kinds)) => {
464            let mut parts: Vec<String> = kinds
465                .iter()
466                .filter_map(Value::as_str)
467                .map(|t| ts_scalar(t, obj))
468                .collect();
469            parts.dedup();
470            union(parts)
471        }
472        _ => {
473            if obj.contains_key("properties") || obj.contains_key("additionalProperties") {
474                inline_object(obj)
475            } else {
476                "unknown".to_string()
477            }
478        }
479    }
480}
481
482fn ts_scalar(t: &str, obj: &Map<String, Value>) -> String {
483    match t {
484        "string" => "string".to_string(),
485        "integer" | "number" => "number".to_string(),
486        "boolean" => "boolean".to_string(),
487        "null" => "null".to_string(),
488        "array" => {
489            let item = obj
490                .get("items")
491                .map(ts_type)
492                .unwrap_or_else(|| "unknown".to_string());
493            if item.contains(' ') || item.contains('|') || item.contains('&') {
494                format!("({item})[]")
495            } else {
496                format!("{item}[]")
497            }
498        }
499        "object" => inline_object(obj),
500        other => format!("unknown /* {other} */"),
501    }
502}
503
504fn inline_object(obj: &Map<String, Value>) -> String {
505    if obj.get("properties").and_then(Value::as_object).is_none() {
506        return match obj.get("additionalProperties") {
507            Some(v @ Value::Object(_)) => format!("Record<string, {}>", ts_type(v)),
508            _ => "Record<string, unknown>".to_string(),
509        };
510    }
511    let body = Value::Object(obj.clone());
512    let mut inner = String::new();
513    emit_object_body(&mut inner, &body, "");
514    let fields: Vec<&str> = inner
515        .lines()
516        .map(str::trim)
517        .filter(|l| !l.is_empty())
518        .collect();
519    format!("{{ {} }}", fields.join(" "))
520}
521
522fn union(mut parts: Vec<String>) -> String {
523    parts.retain(|p| !p.is_empty());
524    if parts.is_empty() {
525        return "unknown".to_string();
526    }
527    parts.join(" | ")
528}
529
530fn ref_name(reference: &str) -> String {
531    let raw = reference.rsplit('/').next().unwrap_or(reference);
532    sanitize_ident(raw)
533}
534
535fn literal(v: &Value) -> String {
536    match v {
537        Value::String(s) => json_string(s),
538        Value::Bool(b) => b.to_string(),
539        Value::Number(n) => n.to_string(),
540        Value::Null => "null".to_string(),
541        other => json_string(&other.to_string()),
542    }
543}
544
545fn json_string(s: &str) -> String {
546    Value::String(s.to_string()).to_string()
547}
548
549/// Object keys that are valid bare TS identifiers stay bare; everything else
550/// (verbs with spaces, etc.) gets quoted.
551fn quote_key(key: &str) -> String {
552    let bare = !key.is_empty()
553        && key
554            .chars()
555            .enumerate()
556            .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit()));
557    if bare {
558        key.to_string()
559    } else {
560        json_string(key)
561    }
562}
563
564fn sanitize_ident(name: &str) -> String {
565    let mut out: String = name
566        .chars()
567        .map(|c| {
568            if c.is_ascii_alphanumeric() || c == '_' {
569                c
570            } else {
571                '_'
572            }
573        })
574        .collect();
575    if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
576        out.insert(0, '_');
577    }
578    out
579}
580
581fn verb_type_name(verb: &str) -> String {
582    let camel: String = verb
583        .split([' ', '-', '_'])
584        .filter(|s| !s.is_empty())
585        .map(|word| {
586            let mut chars = word.chars();
587            match chars.next() {
588                Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
589                None => String::new(),
590            }
591        })
592        .collect();
593    format!("{camel}Schema")
594}
595
596fn emit_jsdoc(out: &mut String, desc: &str, indent: &str) {
597    let one_line = desc.split_whitespace().collect::<Vec<_>>().join(" ");
598    let safe = one_line.replace("*/", "*\\/");
599    let _ = writeln!(out, "{indent}/** {safe} */");
600}
601
602#[cfg(test)]
603mod tests {
604    use serde_json::json;
605
606    use super::*;
607
608    /// Two verbs whose schemas share a `title` must each emit their own root
609    /// body — neither overwritten. Regression guard for the title-keyed roots
610    /// map that dropped all-but-the-last verb's root.
611    #[test]
612    fn shared_title_preserves_each_verbs_root_body() {
613        let schema_a = json!({
614            "title": "SharedTitle",
615            "type": "object",
616            "properties": { "alpha": { "type": "string" } },
617            "required": ["alpha"],
618        });
619        let schema_b = json!({
620            "title": "SharedTitle",
621            "type": "object",
622            "properties": { "beta": { "type": "number" } },
623            "required": ["beta"],
624        });
625
626        let generated = generate_from(vec![
627            ("verb_a".to_string(), schema_a),
628            ("verb_b".to_string(), schema_b),
629        ]);
630        let ts = &generated.typescript;
631
632        // Both verbs' distinct fields survive — the earlier root isn't clobbered.
633        assert!(ts.contains("alpha"), "verb_a root body missing:\n{ts}");
634        assert!(ts.contains("beta"), "verb_b root body missing:\n{ts}");
635        // And each verb is mapped to a type in the verb->payload map.
636        assert!(ts.contains("verb_a:"), "verb_a not mapped:\n{ts}");
637        assert!(ts.contains("verb_b:"), "verb_b not mapped:\n{ts}");
638    }
639
640    /// Conformance guard for the full collision class across BOTH namespaces:
641    /// (a) two verbs share a root `title`, and (b) a third verb's root name
642    /// collides with a `$def` emitted by another verb. Every distinct type must
643    /// survive and every `$ref` must still resolve to its intended definition —
644    /// no global overwrite of the shared `$def`.
645    #[test]
646    fn root_and_def_name_collisions_emit_distinct_types() {
647        // verb_a: title shared with verb_b (root-vs-root), owns a `Widget` $def
648        // that its own root references by $ref.
649        let schema_a = json!({
650            "title": "SharedTitle",
651            "type": "object",
652            "properties": {
653                "alpha": { "type": "string" },
654                "widget": { "$ref": "#/$defs/Widget" },
655            },
656            "required": ["alpha", "widget"],
657            "$defs": {
658                "Widget": {
659                    "type": "object",
660                    "properties": { "gamma": { "type": "string" } },
661                    "required": ["gamma"],
662                },
663            },
664        });
665        // verb_b: same title as verb_a — root-vs-root collision.
666        let schema_b = json!({
667            "title": "SharedTitle",
668            "type": "object",
669            "properties": { "beta": { "type": "number" } },
670            "required": ["beta"],
671        });
672        // verb_c: its root title sanitizes to `Widget`, colliding with verb_a's
673        // $def name — but it's a different shape (carries `delta`, not `gamma`).
674        let schema_c = json!({
675            "title": "Widget",
676            "type": "object",
677            "properties": { "delta": { "type": "boolean" } },
678            "required": ["delta"],
679        });
680
681        let generated = generate_from(vec![
682            ("verb_c".to_string(), schema_c),
683            ("verb_a".to_string(), schema_a),
684            ("verb_b".to_string(), schema_b),
685        ]);
686        let ts = &generated.typescript;
687
688        // Root-vs-root: both shared-title verbs keep their own distinct body.
689        assert!(ts.contains("alpha"), "verb_a root body missing:\n{ts}");
690        assert!(ts.contains("beta"), "verb_b root body missing:\n{ts}");
691
692        // The shared `$def` is emitted intact and is NOT overwritten by verb_c's
693        // same-named root — `gamma` (the def) and `delta` (the root) coexist as
694        // separate types.
695        assert!(
696            ts.contains("export interface Widget {"),
697            "Widget $def missing:\n{ts}"
698        );
699        let widget_def = ts
700            .split("export interface Widget {")
701            .nth(1)
702            .and_then(|rest| rest.split('}').next())
703            .unwrap_or("");
704        assert!(
705            widget_def.contains("gamma") && !widget_def.contains("delta"),
706            "Widget $def was overwritten by verb_c's root:\n{ts}"
707        );
708
709        // verb_a's $ref still resolves to the `Widget` $def (not verb_c's root).
710        assert!(
711            ts.contains("widget: Widget;"),
712            "verb_a root $ref no longer resolves to the Widget def:\n{ts}"
713        );
714
715        // verb_c's colliding root got a distinct name carrying its own `delta`,
716        // and is mapped — proving it wasn't silently dropped onto the $def.
717        let verb_c_type = generated
718            .typescript
719            .lines()
720            .find_map(|l| {
721                l.trim()
722                    .strip_prefix("verb_c: ")
723                    .map(|t| t.trim_end_matches(';').to_string())
724            })
725            .expect("verb_c mapped");
726        assert_ne!(
727            verb_c_type, "Widget",
728            "verb_c root collided onto the $def name:\n{ts}"
729        );
730        let verb_c_def = ts
731            .split(&format!("export interface {verb_c_type} {{"))
732            .nth(1)
733            .and_then(|rest| rest.split('}').next())
734            .unwrap_or("");
735        assert!(
736            verb_c_def.contains("delta"),
737            "verb_c root body ({verb_c_type}) missing its own field:\n{ts}"
738        );
739    }
740
741    /// Definitive close-the-class guard: every collision sub-case in ONE run,
742    /// including the one that drips kept reappearing —
743    ///   (a) two verbs share a root `title` (root-vs-root),
744    ///   (b) a verb's root name collides with another verb's `$def` (root-vs-def),
745    ///   (c) two `$defs` WITHIN one schema sanitize to the same identifier
746    ///       (`Foo-Bar` + `Foo_Bar`, def-vs-def intra-schema).
747    /// Because name allocation is keyed by each def's *original* name (not its
748    /// sanitized form) and disambiguates on collision, no body is ever dropped
749    /// and every `$ref` resolves to its intended type.
750    #[test]
751    fn all_name_collision_subcases_emit_distinct_types() {
752        // verb_a: shares title with verb_b (a); owns a `Widget` $def that verb_c
753        // will collide with (b); AND two intra-schema defs that sanitize to the
754        // same ident with DISTINCT bodies, each referenced by the root (c).
755        let schema_a = json!({
756            "title": "SharedTitle",
757            "type": "object",
758            "properties": {
759                "alpha": { "type": "string" },
760                "widget": { "$ref": "#/$defs/Widget" },
761                "fooDash": { "$ref": "#/$defs/Foo-Bar" },
762                "fooUnder": { "$ref": "#/$defs/Foo_Bar" },
763            },
764            "required": ["alpha", "widget", "fooDash", "fooUnder"],
765            "$defs": {
766                "Widget": {
767                    "type": "object",
768                    "properties": { "gamma": { "type": "string" } },
769                    "required": ["gamma"],
770                },
771                "Foo-Bar": {
772                    "type": "object",
773                    "properties": { "dashField": { "type": "string" } },
774                    "required": ["dashField"],
775                },
776                "Foo_Bar": {
777                    "type": "object",
778                    "properties": { "underField": { "type": "number" } },
779                    "required": ["underField"],
780                },
781            },
782        });
783        let schema_b = json!({
784            "title": "SharedTitle",
785            "type": "object",
786            "properties": { "beta": { "type": "number" } },
787            "required": ["beta"],
788        });
789        let schema_c = json!({
790            "title": "Widget",
791            "type": "object",
792            "properties": { "delta": { "type": "boolean" } },
793            "required": ["delta"],
794        });
795
796        let generated = generate_from(vec![
797            ("verb_c".to_string(), schema_c),
798            ("verb_a".to_string(), schema_a),
799            ("verb_b".to_string(), schema_b),
800        ]);
801        let ts = &generated.typescript;
802
803        let iface_body = |name: &str| -> String {
804            ts.split(&format!("export interface {name} {{"))
805                .nth(1)
806                .and_then(|rest| rest.split('}').next())
807                .unwrap_or("")
808                .to_string()
809        };
810        let verb_type = |verb: &str| -> String {
811            ts.lines()
812                .find_map(|l| {
813                    l.trim()
814                        .strip_prefix(&format!("{verb}: "))
815                        .map(|t| t.trim_end_matches(';').to_string())
816                })
817                .unwrap_or_else(|| panic!("{verb} not mapped:\n{ts}"))
818        };
819
820        // (a) root-vs-root: both shared-title verbs keep their own body.
821        assert!(ts.contains("alpha"), "verb_a root body missing:\n{ts}");
822        assert!(ts.contains("beta"), "verb_b root body missing:\n{ts}");
823        assert_ne!(
824            verb_type("verb_a"),
825            verb_type("verb_b"),
826            "roots collapsed:\n{ts}"
827        );
828
829        // (b) root-vs-def: the `Widget` $def survives intact, verb_c's same-named
830        // root got a distinct name, and verb_a's $ref still points at the def.
831        assert!(
832            iface_body("Widget").contains("gamma") && !iface_body("Widget").contains("delta"),
833            "Widget $def overwritten by verb_c root:\n{ts}"
834        );
835        assert_ne!(
836            verb_type("verb_c"),
837            "Widget",
838            "verb_c root collided onto the def:\n{ts}"
839        );
840        assert!(
841            iface_body(&verb_type("verb_c")).contains("delta"),
842            "verb_c body lost:\n{ts}"
843        );
844
845        // (c) def-vs-def intra-schema: BOTH `Foo-Bar` and `Foo_Bar` are emitted as
846        // distinct types (one suffixed), neither dropped, and the root's two refs
847        // resolve to the correct one each.
848        let a_root = iface_body(&verb_type("verb_a"));
849        let dash_ty = a_root
850            .lines()
851            .find_map(|l| {
852                l.trim()
853                    .strip_prefix("fooDash: ")
854                    .map(|t| t.trim_end_matches(';').to_string())
855            })
856            .expect("fooDash field present");
857        let under_ty = a_root
858            .lines()
859            .find_map(|l| {
860                l.trim()
861                    .strip_prefix("fooUnder: ")
862                    .map(|t| t.trim_end_matches(';').to_string())
863            })
864            .expect("fooUnder field present");
865        assert_ne!(
866            dash_ty, under_ty,
867            "two intra-schema defs collapsed to one type:\n{ts}"
868        );
869        assert!(
870            iface_body(&dash_ty).contains("dashField"),
871            "fooDash ({dash_ty}) resolved to the wrong def:\n{ts}"
872        );
873        assert!(
874            iface_body(&under_ty).contains("underField"),
875            "fooUnder ({under_ty}) resolved to the wrong def:\n{ts}"
876        );
877    }
878}