1use 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
23pub const SCHEMA_VERSION: &str = env!("CARGO_PKG_VERSION");
28
29pub struct Generated {
31 pub typescript: String,
33 pub json: String,
35}
36
37pub 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
48struct NameRegistry {
59 types: BTreeMap<String, Value>,
61 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 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 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
105fn generate_from(verb_schemas: Vec<(String, Value)>) -> Generated {
109 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 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 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 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 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 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 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
198fn 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
208fn 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
238fn 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 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 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
316fn 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
390fn 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 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
429fn 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
551fn 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 #[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 assert!(ts.contains("alpha"), "verb_a root body missing:\n{ts}");
636 assert!(ts.contains("beta"), "verb_b root body missing:\n{ts}");
637 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 #[test]
648 fn root_and_def_name_collisions_emit_distinct_types() {
649 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 let schema_b = json!({
669 "title": "SharedTitle",
670 "type": "object",
671 "properties": { "beta": { "type": "number" } },
672 "required": ["beta"],
673 });
674 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 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 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 assert!(
713 ts.contains("widget: Widget;"),
714 "verb_a root $ref no longer resolves to the Widget def:\n{ts}"
715 );
716
717 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 #[test]
753 fn all_name_collision_subcases_emit_distinct_types() {
754 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 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 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 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}