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