Skip to main content

harn_vm/composition/
typescript.rs

1use 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}