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("// (`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
388fn 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 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
427fn 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
549fn 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 #[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 assert!(ts.contains("alpha"), "verb_a root body missing:\n{ts}");
634 assert!(ts.contains("beta"), "verb_b root body missing:\n{ts}");
635 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 #[test]
646 fn root_and_def_name_collisions_emit_distinct_types() {
647 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 let schema_b = json!({
667 "title": "SharedTitle",
668 "type": "object",
669 "properties": { "beta": { "type": "number" } },
670 "required": ["beta"],
671 });
672 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 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 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 assert!(
711 ts.contains("widget: Widget;"),
712 "verb_a root $ref no longer resolves to the Widget def:\n{ts}"
713 );
714
715 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 #[test]
751 fn all_name_collision_subcases_emit_distinct_types() {
752 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 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 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 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}