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