Skip to main content

harn_vm/composition/
harn_api.rs

1use std::collections::BTreeSet;
2
3use serde_json::Value;
4
5use super::manifest::{BindingManifest, BindingPolicyDisposition};
6
7pub fn composition_harn_api(manifest: &BindingManifest) -> String {
8    let mut out = String::from(
9        "// Harn Code Mode API. Snippets call these bindings; the executor supplies __composition_call.\n\
10         // Use map_bounded(items, { item -> ... }, {concurrency: N}) for settled fan-out.\n",
11    );
12    out.push_str("type JsonValue = unknown\n\n");
13    for binding in &manifest.bindings {
14        if binding.policy.disposition != BindingPolicyDisposition::Allowed {
15            continue;
16        }
17        let args_type_name = unique_type_name(&binding.binding, "Args");
18        let args_type = json_schema_to_harn_type(&binding.input_schema);
19        let result_type = binding
20            .output_schema
21            .as_ref()
22            .map(json_schema_to_harn_type)
23            .unwrap_or_else(|| "JsonValue".to_string());
24        if let Some(server) = binding
25            .metadata
26            .get("_mcp_server")
27            .or_else(|| binding.metadata.get("mcp_server"))
28            .and_then(Value::as_str)
29        {
30            let tool = binding
31                .metadata
32                .get("_mcp_tool_name")
33                .and_then(Value::as_str)
34                .unwrap_or(&binding.name);
35            out.push_str(&format!("// MCP {server}/{tool} -> {}\n", binding.binding));
36        } else if binding.source != "harn" {
37            out.push_str(&format!("// {} -> {}\n", binding.source, binding.binding));
38        }
39        out.push_str(&format!("type {args_type_name} = {args_type}\n"));
40        out.push_str(&format!(
41            "fn {}(args: {args_type_name}) -> {result_type} {{\n  return __composition_call({}, args)\n}}\n\n",
42            binding.binding,
43            harn_string_literal(&binding.name),
44        ));
45    }
46    out
47}
48
49fn unique_type_name(binding: &str, suffix: &str) -> String {
50    let mut out = String::new();
51    let mut upper_next = true;
52    for ch in binding.chars() {
53        if ch == '_' || ch == '-' || ch == '.' || ch == '/' {
54            upper_next = true;
55            continue;
56        }
57        if ch.is_ascii_alphanumeric() {
58            if out.is_empty() && ch.is_ascii_digit() {
59                out.push_str("Tool");
60            }
61            if upper_next {
62                out.push(ch.to_ascii_uppercase());
63                upper_next = false;
64            } else {
65                out.push(ch);
66            }
67        }
68    }
69    if out.is_empty() {
70        out.push_str("Tool");
71    }
72    out.push_str(suffix);
73    out
74}
75
76fn json_schema_to_harn_type(schema: &Value) -> String {
77    if let Some(shorthand) = schema.as_str() {
78        return match shorthand {
79            "string" => "string".to_string(),
80            "int" | "integer" => "int".to_string(),
81            "float" | "number" => "float".to_string(),
82            "bool" | "boolean" => "bool".to_string(),
83            "list" | "array" => "list<JsonValue>".to_string(),
84            "dict" | "object" => "dict<string, JsonValue>".to_string(),
85            _ => "JsonValue".to_string(),
86        };
87    }
88    let schema_type = schema.get("type").and_then(Value::as_str);
89    match schema_type {
90        Some("string") => enum_string_literals(schema).unwrap_or_else(|| "string".to_string()),
91        Some("integer") => "int".to_string(),
92        Some("number") => "float".to_string(),
93        Some("boolean") => "bool".to_string(),
94        Some("array") => {
95            let item_type = schema
96                .get("items")
97                .map(json_schema_to_harn_type)
98                .unwrap_or_else(|| "JsonValue".to_string());
99            format!("list<{item_type}>")
100        }
101        Some("object") | None if schema.get("properties").is_some() => object_shape(schema),
102        None if schema.as_object().is_some() => object_shape_from_shorthand(schema),
103        Some("object") => "dict<string, JsonValue>".to_string(),
104        _ => "JsonValue".to_string(),
105    }
106}
107
108fn object_shape(schema: &Value) -> String {
109    let required = schema
110        .get("required")
111        .and_then(Value::as_array)
112        .map(|items| {
113            items
114                .iter()
115                .filter_map(Value::as_str)
116                .collect::<BTreeSet<_>>()
117        })
118        .unwrap_or_default();
119    let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
120        return "dict<string, JsonValue>".to_string();
121    };
122    let mut fields = Vec::new();
123    for (name, value) in properties {
124        if !is_harn_identifier(name) {
125            return "dict<string, JsonValue>".to_string();
126        }
127        let optional = if required.contains(name.as_str()) {
128            ""
129        } else {
130            "?"
131        };
132        fields.push(format!(
133            "{name}{optional}: {}",
134            json_schema_to_harn_type(value)
135        ));
136    }
137    if fields.is_empty() {
138        "dict<string, JsonValue>".to_string()
139    } else {
140        format!("{{{}}}", fields.join(", "))
141    }
142}
143
144fn object_shape_from_shorthand(schema: &Value) -> String {
145    let Some(properties) = schema.as_object() else {
146        return "dict<string, JsonValue>".to_string();
147    };
148    let mut fields = Vec::new();
149    for (name, value) in properties {
150        if !is_harn_identifier(name) {
151            return "dict<string, JsonValue>".to_string();
152        }
153        let optional = if value
154            .get("required")
155            .and_then(Value::as_bool)
156            .unwrap_or(true)
157        {
158            ""
159        } else {
160            "?"
161        };
162        fields.push(format!(
163            "{name}{optional}: {}",
164            json_schema_to_harn_type(value)
165        ));
166    }
167    if fields.is_empty() {
168        "dict<string, JsonValue>".to_string()
169    } else {
170        format!("{{{}}}", fields.join(", "))
171    }
172}
173
174fn enum_string_literals(schema: &Value) -> Option<String> {
175    let variants = schema.get("enum")?.as_array()?;
176    let strings = variants
177        .iter()
178        .map(|value| value.as_str().map(harn_string_literal))
179        .collect::<Option<Vec<_>>>()?;
180    (!strings.is_empty()).then(|| strings.join(" | "))
181}
182
183fn is_harn_identifier(name: &str) -> bool {
184    let mut chars = name.chars();
185    let Some(first) = chars.next() else {
186        return false;
187    };
188    if first != '_' && !first.is_ascii_alphabetic() {
189        return false;
190    }
191    chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) && !HARN_KEYWORDS.contains(&name)
192}
193
194fn harn_string_literal(value: &str) -> String {
195    serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
196}
197
198const HARN_KEYWORDS: &[&str] = &[
199    "agent",
200    "as",
201    "await",
202    "break",
203    "catch",
204    "continue",
205    "defer",
206    "else",
207    "enum",
208    "false",
209    "fn",
210    "for",
211    "if",
212    "impl",
213    "import",
214    "in",
215    "interface",
216    "let",
217    "match",
218    "nil",
219    "pipeline",
220    "pub",
221    "return",
222    "skill",
223    "spawn",
224    "struct",
225    "throw",
226    "true",
227    "try",
228    "type",
229    "var",
230    "while",
231];