harn_vm/composition/
typescript.rs1use std::collections::BTreeSet;
2
3use serde_json::Value;
4
5use super::manifest::{BindingManifest, BindingPolicyDisposition};
6
7pub fn composition_typescript_declarations(manifest: &BindingManifest) -> String {
8 let mut out = String::from(
9 "export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };\n",
10 );
11 out.push_str("export type CompositionToolResult = JsonValue;\n\n");
12 for binding in &manifest.bindings {
13 if binding.policy.disposition != BindingPolicyDisposition::Allowed {
14 continue;
15 }
16 let args_type = json_schema_to_typescript(&binding.input_schema);
17 let result_type = binding
18 .output_schema
19 .as_ref()
20 .map(json_schema_to_typescript)
21 .unwrap_or_else(|| "CompositionToolResult".to_string());
22 out.push_str(&format!(
23 "export declare function {}(args: {}): Promise<{}>;\n",
24 binding.binding, args_type, result_type
25 ));
26 }
27 out
28}
29
30fn json_schema_to_typescript(schema: &Value) -> String {
31 if let Some(shorthand) = schema.as_str() {
32 return match shorthand {
33 "string" => "string".to_string(),
34 "int" | "integer" | "float" | "number" => "number".to_string(),
35 "bool" | "boolean" => "boolean".to_string(),
36 "list" | "array" => "JsonValue[]".to_string(),
37 "dict" | "object" => "{ [key: string]: JsonValue }".to_string(),
38 _ => "JsonValue".to_string(),
39 };
40 }
41 let schema_type = schema.get("type").and_then(Value::as_str);
42 match schema_type {
43 Some("string") => enum_string_literals(schema).unwrap_or_else(|| "string".to_string()),
44 Some("integer") | Some("number") => "number".to_string(),
45 Some("boolean") => "boolean".to_string(),
46 Some("array") => {
47 let item_type = schema
48 .get("items")
49 .map(json_schema_to_typescript)
50 .unwrap_or_else(|| "JsonValue".to_string());
51 format!("{item_type}[]")
52 }
53 Some("object") | None if schema.get("properties").is_some() => {
54 let required = schema
55 .get("required")
56 .and_then(Value::as_array)
57 .map(|items| {
58 items
59 .iter()
60 .filter_map(Value::as_str)
61 .collect::<BTreeSet<_>>()
62 })
63 .unwrap_or_default();
64 let mut fields = Vec::new();
65 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
66 for (name, value) in properties {
67 let marker = if required.contains(name.as_str()) {
68 ""
69 } else {
70 "?"
71 };
72 fields.push(format!(
73 "{}{}: {}",
74 typescript_property_name(name),
75 marker,
76 json_schema_to_typescript(value)
77 ));
78 }
79 }
80 if fields.is_empty() {
81 "{ [key: string]: JsonValue }".to_string()
82 } else {
83 format!("{{ {} }}", fields.join("; "))
84 }
85 }
86 None if schema.as_object().is_some() => {
87 let fields = schema
88 .as_object()
89 .into_iter()
90 .flat_map(|properties| properties.iter())
91 .map(|(name, value)| {
92 let marker = if value
93 .get("required")
94 .and_then(Value::as_bool)
95 .unwrap_or(true)
96 {
97 ""
98 } else {
99 "?"
100 };
101 format!(
102 "{}{}: {}",
103 typescript_property_name(name),
104 marker,
105 json_schema_to_typescript(value)
106 )
107 })
108 .collect::<Vec<_>>();
109 if fields.is_empty() {
110 "{ [key: string]: JsonValue }".to_string()
111 } else {
112 format!("{{ {} }}", fields.join("; "))
113 }
114 }
115 Some("object") => "{ [key: string]: JsonValue }".to_string(),
116 _ => "JsonValue".to_string(),
117 }
118}
119
120fn enum_string_literals(schema: &Value) -> Option<String> {
121 let variants = schema.get("enum")?.as_array()?;
122 let strings = variants
123 .iter()
124 .map(|value| value.as_str().map(|text| format!("{text:?}")))
125 .collect::<Option<Vec<_>>>()?;
126 (!strings.is_empty()).then(|| strings.join(" | "))
127}
128
129fn typescript_property_name(name: &str) -> String {
130 if name.chars().enumerate().all(|(idx, ch)| {
131 ch == '_' || ch.is_ascii_alphanumeric() && (idx > 0 || !ch.is_ascii_digit())
132 }) {
133 name.to_string()
134 } else {
135 format!("{name:?}")
136 }
137}