Skip to main content

incurs_codemode/
typescript.rs

1use std::collections::BTreeSet;
2
3use serde_json::Value;
4
5use crate::ConnectorDescription;
6
7/// Makes an arbitrary tool name safe for use as a JavaScript identifier.
8pub fn sanitize_identifier(value: &str) -> String {
9    let mut result = String::new();
10    for (index, ch) in value.chars().enumerate() {
11        if (index == 0 && !(ch == '_' || ch == '$' || ch.is_ascii_alphabetic()))
12            || (index > 0 && !(ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()))
13        {
14            result.push('_');
15        } else {
16            result.push(ch);
17        }
18    }
19    if result.is_empty() {
20        "_".to_string()
21    } else {
22        result
23    }
24}
25
26/// Converts a JSON Schema value into a model-facing TypeScript type.
27pub fn json_schema_to_type(schema: &Value) -> String {
28    convert(schema, schema, 0, &mut BTreeSet::new())
29}
30
31/// Generates the TypeScript declarations shown to the model for a connector.
32pub fn generate_types(description: &ConnectorDescription) -> String {
33    let mut result = String::new();
34    if let Some(instructions) = &description.instructions {
35        result.push_str(instructions);
36        result.push_str("\n\n");
37    }
38    for tool in &description.tools {
39        let type_name = pascal(&tool.name);
40        result.push_str(&format!(
41            "type {type_name}Input = {};\n",
42            json_schema_to_type(&tool.input_schema)
43        ));
44        if let Some(output) = &tool.output_schema {
45            result.push_str(&format!(
46                "type {type_name}Output = {};\n",
47                json_schema_to_type(output)
48            ));
49        }
50    }
51    result.push_str(&format!(
52        "declare const {}: {{\n",
53        sanitize_identifier(&description.name)
54    ));
55    for tool in &description.tools {
56        if let Some(doc) = &tool.description {
57            result.push_str(&format!("  /** {} */\n", escape_doc(doc)));
58        }
59        let type_name = pascal(&tool.name);
60        let output = tool
61            .output_schema
62            .as_ref()
63            .map(|_| format!("{type_name}Output"))
64            .unwrap_or_else(|| "unknown".to_string());
65        result.push_str(&format!(
66            "  {}(args: {type_name}Input): Promise<{output}>;\n",
67            quote_property(&tool.name)
68        ));
69    }
70    result.push_str("};");
71    result
72}
73
74fn convert(schema: &Value, root: &Value, depth: usize, seen: &mut BTreeSet<String>) -> String {
75    if depth >= 20 {
76        return "unknown".to_string();
77    }
78    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
79        if !seen.insert(reference.to_string()) {
80            return "unknown".to_string();
81        }
82        let resolved = resolve_ref(root, reference)
83            .map(|value| convert(value, root, depth + 1, seen))
84            .unwrap_or_else(|| "unknown".to_string());
85        seen.remove(reference);
86        return nullable(resolved, schema);
87    }
88    for (key, separator) in [("anyOf", " | "), ("oneOf", " | "), ("allOf", " & ")] {
89        if let Some(values) = schema.get(key).and_then(Value::as_array) {
90            let value = values
91                .iter()
92                .map(|value| convert(value, root, depth + 1, seen))
93                .collect::<Vec<_>>()
94                .join(separator);
95            return nullable(value, schema);
96        }
97    }
98    if let Some(values) = schema.get("enum").and_then(Value::as_array) {
99        return nullable(
100            values.iter().map(literal).collect::<Vec<_>>().join(" | "),
101            schema,
102        );
103    }
104    if let Some(value) = schema.get("const") {
105        return nullable(literal(value), schema);
106    }
107    let value = match schema.get("type") {
108        Some(Value::Array(types)) => types
109            .iter()
110            .map(|value| primitive(value.as_str().unwrap_or_default()))
111            .collect::<Vec<_>>()
112            .join(" | "),
113        Some(Value::String(kind)) if kind == "array" => {
114            if let Some(items) = schema.get("prefixItems").and_then(Value::as_array) {
115                format!(
116                    "[{}]",
117                    items
118                        .iter()
119                        .map(|item| convert(item, root, depth + 1, seen))
120                        .collect::<Vec<_>>()
121                        .join(", ")
122                )
123            } else if let Some(items) = schema.get("items").and_then(Value::as_array) {
124                format!(
125                    "[{}]",
126                    items
127                        .iter()
128                        .map(|item| convert(item, root, depth + 1, seen))
129                        .collect::<Vec<_>>()
130                        .join(", ")
131                )
132            } else {
133                let item = schema
134                    .get("items")
135                    .map(|item| convert(item, root, depth + 1, seen))
136                    .unwrap_or_else(|| "unknown".to_string());
137                format!("({item})[]")
138            }
139        }
140        Some(Value::String(kind)) if kind == "object" || schema.get("properties").is_some() => {
141            object_type(schema, root, depth, seen)
142        }
143        Some(Value::String(kind)) => primitive(kind).to_string(),
144        _ => "unknown".to_string(),
145    };
146    nullable(value, schema)
147}
148
149fn object_type(schema: &Value, root: &Value, depth: usize, seen: &mut BTreeSet<String>) -> String {
150    let required = schema
151        .get("required")
152        .and_then(Value::as_array)
153        .into_iter()
154        .flatten()
155        .filter_map(Value::as_str)
156        .collect::<BTreeSet<_>>();
157    let mut fields = Vec::new();
158    if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
159        for (name, value) in properties {
160            let optional = if required.contains(name.as_str()) {
161                ""
162            } else {
163                "?"
164            };
165            fields.push(format!(
166                "  {}{optional}: {};",
167                quote_property(name),
168                convert(value, root, depth + 1, seen)
169            ));
170        }
171    }
172    if let Some(additional) = schema.get("additionalProperties") {
173        match additional {
174            Value::Bool(true) => fields.push("  [key: string]: unknown;".to_string()),
175            Value::Object(_) => fields.push(format!(
176                "  [key: string]: {};",
177                convert(additional, root, depth + 1, seen)
178            )),
179            _ => {}
180        }
181    }
182    if fields.is_empty() {
183        if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
184            "{}".to_string()
185        } else {
186            "Record<string, unknown>".to_string()
187        }
188    } else {
189        format!("{{\n{}\n}}", fields.join("\n"))
190    }
191}
192
193fn resolve_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
194    if reference == "#" {
195        return Some(root);
196    }
197    let pointer = reference.strip_prefix('#')?;
198    root.pointer(pointer)
199}
200
201fn nullable(value: String, schema: &Value) -> String {
202    if schema.get("nullable").and_then(Value::as_bool) == Some(true)
203        && value != "unknown"
204        && value != "never"
205    {
206        format!("{value} | null")
207    } else {
208        value
209    }
210}
211
212fn primitive(kind: &str) -> &'static str {
213    match kind {
214        "string" => "string",
215        "number" | "integer" => "number",
216        "boolean" => "boolean",
217        "null" => "null",
218        "array" => "unknown[]",
219        "object" => "Record<string, unknown>",
220        _ => "unknown",
221    }
222}
223
224fn literal(value: &Value) -> String {
225    serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string())
226}
227
228fn pascal(value: &str) -> String {
229    value
230        .split(|ch: char| !ch.is_ascii_alphanumeric())
231        .filter(|part| !part.is_empty())
232        .map(|part| {
233            let mut chars = part.chars();
234            chars
235                .next()
236                .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str())
237                .unwrap_or_default()
238        })
239        .collect::<String>()
240}
241
242fn quote_property(value: &str) -> String {
243    let sanitized = sanitize_identifier(value);
244    if sanitized == value {
245        value.to_string()
246    } else {
247        serde_json::to_string(value).unwrap()
248    }
249}
250
251fn escape_doc(value: &str) -> String {
252    value.replace("*/", "*\\/").replace(['\r', '\n'], " ")
253}
254
255#[cfg(test)]
256mod tests {
257    use serde_json::json;
258
259    use super::*;
260
261    #[test]
262    fn converts_objects_unions_and_refs() {
263        let schema = json!({
264            "type": "object",
265            "properties": {
266                "id": {"type": "integer"},
267                "state": {"enum": ["open", "closed"]},
268                "owner": {"$ref": "#/$defs/user"}
269            },
270            "required": ["id"],
271            "$defs": {"user": {"type": "object", "properties": {"name": {"type": "string"}}}}
272        });
273        let output = json_schema_to_type(&schema);
274        assert!(output.starts_with("{\n"));
275        assert!(output.contains("  id: number;"));
276        assert!(output.contains("  state?: \"open\" | \"closed\";"));
277        assert!(output.contains("  owner?: {\n  name?: string;\n};"));
278    }
279}