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(
339        "// (`heddle schemas <verb>` / `crates/cli-contract/src/cli/commands/schemas.rs`).\n",
340    );
341    out.push_str(
342        "// Regenerate with `scripts/gen-ts-types.sh`; a drift test keeps it in sync.\n\n",
343    );
344    let _ = writeln!(
345        out,
346        "export const HEDDLE_SCHEMA_VERSION = {:?} as const;\n",
347        SCHEMA_VERSION
348    );
349
350    for (name, body) in types {
351        emit_type(&mut out, name, body);
352    }
353
354    out.push_str("/** Maps each `--output json` verb to its output payload type. */\n");
355    out.push_str("export interface HeddleVerbOutputs {\n");
356    for (verb, ty) in verb_to_type {
357        let _ = writeln!(out, "  {}: {};", quote_key(verb), ty);
358    }
359    out.push_str("}\n\n");
360
361    out.push_str("/** Every verb that emits a schema-backed `--output json` payload. */\n");
362    out.push_str("export type HeddleSchemaVerb = keyof HeddleVerbOutputs;\n\n");
363
364    out.push_str("export const HEDDLE_SCHEMA_VERBS: readonly HeddleSchemaVerb[] = [\n");
365    for verb in verb_to_type.keys() {
366        let _ = writeln!(out, "  {},", json_string(verb));
367    }
368    out.push_str("] as const;\n");
369
370    out
371}
372
373fn emit_type(out: &mut String, name: &str, body: &Value) {
374    let is_object = body.get("type").and_then(Value::as_str) == Some("object")
375        && body.get("properties").is_some();
376
377    if let Some(desc) = body.get("description").and_then(Value::as_str) {
378        emit_jsdoc(out, desc, "");
379    }
380
381    if is_object {
382        let _ = writeln!(out, "export interface {name} {{");
383        emit_object_body(out, body, "  ");
384        out.push_str("}\n\n");
385    } else {
386        let _ = writeln!(out, "export type {name} = {};\n", ts_type(body));
387    }
388}
389
390/// Emit the fields of an object schema into an already-open `{ ... }` block.
391fn emit_object_body(out: &mut String, body: &Value, indent: &str) {
392    let required: Vec<&str> = body
393        .get("required")
394        .and_then(Value::as_array)
395        .map(|a| a.iter().filter_map(Value::as_str).collect())
396        .unwrap_or_default();
397
398    if let Some(props) = body.get("properties").and_then(Value::as_object) {
399        for (field, schema) in props {
400            if let Some(desc) = schema.get("description").and_then(Value::as_str) {
401                emit_jsdoc(out, desc, indent);
402            }
403            let opt = if required.contains(&field.as_str()) {
404                ""
405            } else {
406                "?"
407            };
408            let _ = writeln!(
409                out,
410                "{indent}{}{opt}: {};",
411                quote_key(field),
412                ts_type(schema)
413            );
414        }
415    }
416
417    // Open record / flattened-map shapes.
418    match body.get("additionalProperties") {
419        Some(Value::Bool(true)) => {
420            let _ = writeln!(out, "{indent}[key: string]: unknown;");
421        }
422        Some(v @ Value::Object(_)) => {
423            let _ = writeln!(out, "{indent}[key: string]: {};", ts_type(v));
424        }
425        _ => {}
426    }
427}
428
429/// Convert a JSON-Schema node into a TypeScript type expression.
430fn ts_type(node: &Value) -> String {
431    match node {
432        Value::Bool(true) => return "unknown".to_string(),
433        Value::Bool(false) => return "never".to_string(),
434        _ => {}
435    }
436    let Some(obj) = node.as_object() else {
437        return "unknown".to_string();
438    };
439
440    if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
441        return ref_name(reference);
442    }
443
444    if let Some(values) = obj.get("enum").and_then(Value::as_array) {
445        let mut parts: Vec<String> = values.iter().map(literal).collect();
446        parts.dedup();
447        return parts.join(" | ");
448    }
449
450    for key in ["anyOf", "oneOf"] {
451        if let Some(variants) = obj.get(key).and_then(Value::as_array) {
452            let mut parts: Vec<String> = variants.iter().map(ts_type).collect();
453            parts.dedup();
454            return union(parts);
455        }
456    }
457
458    if let Some(all) = obj.get("allOf").and_then(Value::as_array) {
459        let parts: Vec<String> = all.iter().map(ts_type).collect();
460        return parts.join(" & ");
461    }
462
463    match obj.get("type") {
464        Some(Value::String(t)) => ts_scalar(t, obj),
465        Some(Value::Array(kinds)) => {
466            let mut parts: Vec<String> = kinds
467                .iter()
468                .filter_map(Value::as_str)
469                .map(|t| ts_scalar(t, obj))
470                .collect();
471            parts.dedup();
472            union(parts)
473        }
474        _ => {
475            if obj.contains_key("properties") || obj.contains_key("additionalProperties") {
476                inline_object(obj)
477            } else {
478                "unknown".to_string()
479            }
480        }
481    }
482}
483
484fn ts_scalar(t: &str, obj: &Map<String, Value>) -> String {
485    match t {
486        "string" => "string".to_string(),
487        "integer" | "number" => "number".to_string(),
488        "boolean" => "boolean".to_string(),
489        "null" => "null".to_string(),
490        "array" => {
491            let item = obj
492                .get("items")
493                .map(ts_type)
494                .unwrap_or_else(|| "unknown".to_string());
495            if item.contains(' ') || item.contains('|') || item.contains('&') {
496                format!("({item})[]")
497            } else {
498                format!("{item}[]")
499            }
500        }
501        "object" => inline_object(obj),
502        other => format!("unknown /* {other} */"),
503    }
504}
505
506fn inline_object(obj: &Map<String, Value>) -> String {
507    if obj.get("properties").and_then(Value::as_object).is_none() {
508        return match obj.get("additionalProperties") {
509            Some(v @ Value::Object(_)) => format!("Record<string, {}>", ts_type(v)),
510            _ => "Record<string, unknown>".to_string(),
511        };
512    }
513    let body = Value::Object(obj.clone());
514    let mut inner = String::new();
515    emit_object_body(&mut inner, &body, "");
516    let fields: Vec<&str> = inner
517        .lines()
518        .map(str::trim)
519        .filter(|l| !l.is_empty())
520        .collect();
521    format!("{{ {} }}", fields.join(" "))
522}
523
524fn union(mut parts: Vec<String>) -> String {
525    parts.retain(|p| !p.is_empty());
526    if parts.is_empty() {
527        return "unknown".to_string();
528    }
529    parts.join(" | ")
530}
531
532fn ref_name(reference: &str) -> String {
533    let raw = reference.rsplit('/').next().unwrap_or(reference);
534    sanitize_ident(raw)
535}
536
537fn literal(v: &Value) -> String {
538    match v {
539        Value::String(s) => json_string(s),
540        Value::Bool(b) => b.to_string(),
541        Value::Number(n) => n.to_string(),
542        Value::Null => "null".to_string(),
543        other => json_string(&other.to_string()),
544    }
545}
546
547fn json_string(s: &str) -> String {
548    Value::String(s.to_string()).to_string()
549}
550
551/// Object keys that are valid bare TS identifiers stay bare; everything else
552/// (verbs with spaces, etc.) gets quoted.
553fn quote_key(key: &str) -> String {
554    let bare = !key.is_empty()
555        && key
556            .chars()
557            .enumerate()
558            .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit()));
559    if bare {
560        key.to_string()
561    } else {
562        json_string(key)
563    }
564}
565
566fn sanitize_ident(name: &str) -> String {
567    let mut out: String = name
568        .chars()
569        .map(|c| {
570            if c.is_ascii_alphanumeric() || c == '_' {
571                c
572            } else {
573                '_'
574            }
575        })
576        .collect();
577    if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
578        out.insert(0, '_');
579    }
580    out
581}
582
583fn verb_type_name(verb: &str) -> String {
584    let camel: String = verb
585        .split([' ', '-', '_'])
586        .filter(|s| !s.is_empty())
587        .map(|word| {
588            let mut chars = word.chars();
589            match chars.next() {
590                Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
591                None => String::new(),
592            }
593        })
594        .collect();
595    format!("{camel}Schema")
596}
597
598fn emit_jsdoc(out: &mut String, desc: &str, indent: &str) {
599    let one_line = desc.split_whitespace().collect::<Vec<_>>().join(" ");
600    let safe = one_line.replace("*/", "*\\/");
601    let _ = writeln!(out, "{indent}/** {safe} */");
602}
603
604#[cfg(test)]
605mod tests {
606    use serde_json::json;
607
608    use super::*;
609
610    /// Two verbs whose schemas share a `title` must each emit their own root
611    /// body — neither overwritten. Regression guard for the title-keyed roots
612    /// map that dropped all-but-the-last verb's root.
613    #[test]
614    fn shared_title_preserves_each_verbs_root_body() {
615        let schema_a = json!({
616            "title": "SharedTitle",
617            "type": "object",
618            "properties": { "alpha": { "type": "string" } },
619            "required": ["alpha"],
620        });
621        let schema_b = json!({
622            "title": "SharedTitle",
623            "type": "object",
624            "properties": { "beta": { "type": "number" } },
625            "required": ["beta"],
626        });
627
628        let generated = generate_from(vec![
629            ("verb_a".to_string(), schema_a),
630            ("verb_b".to_string(), schema_b),
631        ]);
632        let ts = &generated.typescript;
633
634        // Both verbs' distinct fields survive — the earlier root isn't clobbered.
635        assert!(ts.contains("alpha"), "verb_a root body missing:\n{ts}");
636        assert!(ts.contains("beta"), "verb_b root body missing:\n{ts}");
637        // And each verb is mapped to a type in the verb->payload map.
638        assert!(ts.contains("verb_a:"), "verb_a not mapped:\n{ts}");
639        assert!(ts.contains("verb_b:"), "verb_b not mapped:\n{ts}");
640    }
641
642    /// Conformance guard for the full collision class across BOTH namespaces:
643    /// (a) two verbs share a root `title`, and (b) a third verb's root name
644    /// collides with a `$def` emitted by another verb. Every distinct type must
645    /// survive and every `$ref` must still resolve to its intended definition —
646    /// no global overwrite of the shared `$def`.
647    #[test]
648    fn root_and_def_name_collisions_emit_distinct_types() {
649        // verb_a: title shared with verb_b (root-vs-root), owns a `Widget` $def
650        // that its own root references by $ref.
651        let schema_a = json!({
652            "title": "SharedTitle",
653            "type": "object",
654            "properties": {
655                "alpha": { "type": "string" },
656                "widget": { "$ref": "#/$defs/Widget" },
657            },
658            "required": ["alpha", "widget"],
659            "$defs": {
660                "Widget": {
661                    "type": "object",
662                    "properties": { "gamma": { "type": "string" } },
663                    "required": ["gamma"],
664                },
665            },
666        });
667        // verb_b: same title as verb_a — root-vs-root collision.
668        let schema_b = json!({
669            "title": "SharedTitle",
670            "type": "object",
671            "properties": { "beta": { "type": "number" } },
672            "required": ["beta"],
673        });
674        // verb_c: its root title sanitizes to `Widget`, colliding with verb_a's
675        // $def name — but it's a different shape (carries `delta`, not `gamma`).
676        let schema_c = json!({
677            "title": "Widget",
678            "type": "object",
679            "properties": { "delta": { "type": "boolean" } },
680            "required": ["delta"],
681        });
682
683        let generated = generate_from(vec![
684            ("verb_c".to_string(), schema_c),
685            ("verb_a".to_string(), schema_a),
686            ("verb_b".to_string(), schema_b),
687        ]);
688        let ts = &generated.typescript;
689
690        // Root-vs-root: both shared-title verbs keep their own distinct body.
691        assert!(ts.contains("alpha"), "verb_a root body missing:\n{ts}");
692        assert!(ts.contains("beta"), "verb_b root body missing:\n{ts}");
693
694        // The shared `$def` is emitted intact and is NOT overwritten by verb_c's
695        // same-named root — `gamma` (the def) and `delta` (the root) coexist as
696        // separate types.
697        assert!(
698            ts.contains("export interface Widget {"),
699            "Widget $def missing:\n{ts}"
700        );
701        let widget_def = ts
702            .split("export interface Widget {")
703            .nth(1)
704            .and_then(|rest| rest.split('}').next())
705            .unwrap_or("");
706        assert!(
707            widget_def.contains("gamma") && !widget_def.contains("delta"),
708            "Widget $def was overwritten by verb_c's root:\n{ts}"
709        );
710
711        // verb_a's $ref still resolves to the `Widget` $def (not verb_c's root).
712        assert!(
713            ts.contains("widget: Widget;"),
714            "verb_a root $ref no longer resolves to the Widget def:\n{ts}"
715        );
716
717        // verb_c's colliding root got a distinct name carrying its own `delta`,
718        // and is mapped — proving it wasn't silently dropped onto the $def.
719        let verb_c_type = generated
720            .typescript
721            .lines()
722            .find_map(|l| {
723                l.trim()
724                    .strip_prefix("verb_c: ")
725                    .map(|t| t.trim_end_matches(';').to_string())
726            })
727            .expect("verb_c mapped");
728        assert_ne!(
729            verb_c_type, "Widget",
730            "verb_c root collided onto the $def name:\n{ts}"
731        );
732        let verb_c_def = ts
733            .split(&format!("export interface {verb_c_type} {{"))
734            .nth(1)
735            .and_then(|rest| rest.split('}').next())
736            .unwrap_or("");
737        assert!(
738            verb_c_def.contains("delta"),
739            "verb_c root body ({verb_c_type}) missing its own field:\n{ts}"
740        );
741    }
742
743    /// Definitive close-the-class guard: every collision sub-case in ONE run,
744    /// including the one that drips kept reappearing —
745    ///   (a) two verbs share a root `title` (root-vs-root),
746    ///   (b) a verb's root name collides with another verb's `$def` (root-vs-def),
747    ///   (c) two `$defs` WITHIN one schema sanitize to the same identifier
748    ///       (`Foo-Bar` + `Foo_Bar`, def-vs-def intra-schema).
749    /// Because name allocation is keyed by each def's *original* name (not its
750    /// sanitized form) and disambiguates on collision, no body is ever dropped
751    /// and every `$ref` resolves to its intended type.
752    #[test]
753    fn all_name_collision_subcases_emit_distinct_types() {
754        // verb_a: shares title with verb_b (a); owns a `Widget` $def that verb_c
755        // will collide with (b); AND two intra-schema defs that sanitize to the
756        // same ident with DISTINCT bodies, each referenced by the root (c).
757        let schema_a = json!({
758            "title": "SharedTitle",
759            "type": "object",
760            "properties": {
761                "alpha": { "type": "string" },
762                "widget": { "$ref": "#/$defs/Widget" },
763                "fooDash": { "$ref": "#/$defs/Foo-Bar" },
764                "fooUnder": { "$ref": "#/$defs/Foo_Bar" },
765            },
766            "required": ["alpha", "widget", "fooDash", "fooUnder"],
767            "$defs": {
768                "Widget": {
769                    "type": "object",
770                    "properties": { "gamma": { "type": "string" } },
771                    "required": ["gamma"],
772                },
773                "Foo-Bar": {
774                    "type": "object",
775                    "properties": { "dashField": { "type": "string" } },
776                    "required": ["dashField"],
777                },
778                "Foo_Bar": {
779                    "type": "object",
780                    "properties": { "underField": { "type": "number" } },
781                    "required": ["underField"],
782                },
783            },
784        });
785        let schema_b = json!({
786            "title": "SharedTitle",
787            "type": "object",
788            "properties": { "beta": { "type": "number" } },
789            "required": ["beta"],
790        });
791        let schema_c = json!({
792            "title": "Widget",
793            "type": "object",
794            "properties": { "delta": { "type": "boolean" } },
795            "required": ["delta"],
796        });
797
798        let generated = generate_from(vec![
799            ("verb_c".to_string(), schema_c),
800            ("verb_a".to_string(), schema_a),
801            ("verb_b".to_string(), schema_b),
802        ]);
803        let ts = &generated.typescript;
804
805        let iface_body = |name: &str| -> String {
806            ts.split(&format!("export interface {name} {{"))
807                .nth(1)
808                .and_then(|rest| rest.split('}').next())
809                .unwrap_or("")
810                .to_string()
811        };
812        let verb_type = |verb: &str| -> String {
813            ts.lines()
814                .find_map(|l| {
815                    l.trim()
816                        .strip_prefix(&format!("{verb}: "))
817                        .map(|t| t.trim_end_matches(';').to_string())
818                })
819                .unwrap_or_else(|| panic!("{verb} not mapped:\n{ts}"))
820        };
821
822        // (a) root-vs-root: both shared-title verbs keep their own body.
823        assert!(ts.contains("alpha"), "verb_a root body missing:\n{ts}");
824        assert!(ts.contains("beta"), "verb_b root body missing:\n{ts}");
825        assert_ne!(
826            verb_type("verb_a"),
827            verb_type("verb_b"),
828            "roots collapsed:\n{ts}"
829        );
830
831        // (b) root-vs-def: the `Widget` $def survives intact, verb_c's same-named
832        // root got a distinct name, and verb_a's $ref still points at the def.
833        assert!(
834            iface_body("Widget").contains("gamma") && !iface_body("Widget").contains("delta"),
835            "Widget $def overwritten by verb_c root:\n{ts}"
836        );
837        assert_ne!(
838            verb_type("verb_c"),
839            "Widget",
840            "verb_c root collided onto the def:\n{ts}"
841        );
842        assert!(
843            iface_body(&verb_type("verb_c")).contains("delta"),
844            "verb_c body lost:\n{ts}"
845        );
846
847        // (c) def-vs-def intra-schema: BOTH `Foo-Bar` and `Foo_Bar` are emitted as
848        // distinct types (one suffixed), neither dropped, and the root's two refs
849        // resolve to the correct one each.
850        let a_root = iface_body(&verb_type("verb_a"));
851        let dash_ty = a_root
852            .lines()
853            .find_map(|l| {
854                l.trim()
855                    .strip_prefix("fooDash: ")
856                    .map(|t| t.trim_end_matches(';').to_string())
857            })
858            .expect("fooDash field present");
859        let under_ty = a_root
860            .lines()
861            .find_map(|l| {
862                l.trim()
863                    .strip_prefix("fooUnder: ")
864                    .map(|t| t.trim_end_matches(';').to_string())
865            })
866            .expect("fooUnder field present");
867        assert_ne!(
868            dash_ty, under_ty,
869            "two intra-schema defs collapsed to one type:\n{ts}"
870        );
871        assert!(
872            iface_body(&dash_ty).contains("dashField"),
873            "fooDash ({dash_ty}) resolved to the wrong def:\n{ts}"
874        );
875        assert!(
876            iface_body(&under_ty).contains("underField"),
877            "fooUnder ({under_ty}) resolved to the wrong def:\n{ts}"
878        );
879    }
880}