Skip to main content

oxicode_ai/dialect/
coercion.rs

1//! Tool-argument shape detection for dialect rendering/parsing.
2//!
3//! Port of omp's `dialect/coercion.ts`. The dialect renderer needs to know which
4//! arguments are *string-typed* so it can emit their values verbatim (no JSON
5//! quoting), while non-string arguments are JSON-encoded. The parser uses the
6//! same shapes to decide whether a raw parameter value is read literally or
7//! JSON-decoded.
8
9use crate::tools::Tool;
10use serde_json::Value as JsonValue;
11use std::collections::{HashMap, HashSet};
12
13/// Per-tool argument shape derived from its JSON Schema.
14#[derive(Debug, Clone, Default)]
15pub struct ToolArgShape {
16    /// Argument names whose schema is string-only (emit/parse verbatim).
17    pub string_args: HashSet<String>,
18    /// The `properties` map from the schema (for order/lookup).
19    pub properties: HashMap<String, JsonValue>,
20    /// Parameter declaration order.
21    pub parameter_order: Vec<String>,
22}
23
24/// Build argument shapes for a set of tools.
25pub fn build_arg_shapes(tools: &[Tool]) -> HashMap<String, ToolArgShape> {
26    let mut shapes = HashMap::new();
27    for tool in tools {
28        let props = resolve_properties(&tool.parameters);
29        let mut string_args = HashSet::new();
30        let mut parameter_order = Vec::new();
31        // JSON objects preserve insertion order via serde_json's Map when the
32        // "preserve_order" feature is on; otherwise order is alphabetical. Either
33        // is fine for shape detection — only membership matters here.
34        if let Some(obj) = props {
35            for (key, schema) in obj {
36                parameter_order.push(key.clone());
37                if is_string_only_schema(&schema) {
38                    string_args.insert(key);
39                }
40            }
41        }
42        shapes.insert(
43            tool.name.clone(),
44            ToolArgShape {
45                string_args,
46                properties: HashMap::new(),
47                parameter_order,
48            },
49        );
50    }
51    shapes
52}
53
54/// Extract the `properties` object from a tool parameter schema, if present.
55fn resolve_properties(parameters: &JsonValue) -> Option<serde_json::Map<String, JsonValue>> {
56    let obj = parameters.as_object()?;
57    obj.get("properties")?.as_object().cloned()
58}
59
60/// Whether a schema denotes a string-only value (string, ignoring `null`).
61///
62/// Mirrors omp's `isStringOnlySchema`: collect all declared types, drop `null`,
63/// and require exactly `{"string"}` to remain.
64pub fn is_string_only_schema(schema: &JsonValue) -> bool {
65    let mut types = collect_schema_types(schema, 0);
66    types.remove("null");
67    types.len() == 1 && types.contains("string")
68}
69
70/// Collect the JSON type names a schema can take (bounded recursion).
71fn collect_schema_types(schema: &JsonValue, depth: usize) -> HashSet<String> {
72    let mut out = HashSet::new();
73    if depth > 8 {
74        return out;
75    }
76    let Some(node) = schema.as_object() else {
77        return out;
78    };
79
80    match node.get("type") {
81        Some(JsonValue::String(t)) => {
82            out.insert(t.clone());
83        }
84        Some(JsonValue::Array(arr)) => {
85            for t in arr {
86                if let JsonValue::String(s) = t {
87                    out.insert(s.clone());
88                }
89            }
90        }
91        _ => {}
92    }
93
94    // enum without type → infer types from the enum values.
95    if !node.contains_key("type") {
96        if let Some(JsonValue::Array(en)) = node.get("enum") {
97            for v in en {
98                out.insert(json_type_of(v).to_string());
99            }
100        }
101        if let Some(c) = node.get("const") {
102            out.insert(json_type_of(c).to_string());
103        }
104    }
105
106    for key in ["anyOf", "oneOf", "allOf"] {
107        if let Some(JsonValue::Array(branches)) = node.get(key) {
108            for sub in branches {
109                out.extend(collect_schema_types(sub, depth + 1));
110            }
111        }
112    }
113
114    out
115}
116
117/// The JSON type name of a runtime value.
118fn json_type_of(value: &JsonValue) -> &'static str {
119    match value {
120        JsonValue::Null => "null",
121        JsonValue::Bool(_) => "boolean",
122        JsonValue::Number(_) => "number",
123        JsonValue::String(_) => "string",
124        JsonValue::Array(_) | JsonValue::Object(_) => "object",
125    }
126}
127
128/// Decode a raw parameter value: JSON if it parses, else the literal string.
129///
130/// Mirrors omp's `decodeValue`. Empty/whitespace values decode to themselves.
131pub fn decode_value(raw: &str) -> JsonValue {
132    let trimmed = raw.trim();
133    if trimmed.is_empty() {
134        return JsonValue::String(trimmed.to_string());
135    }
136    match serde_json::from_str::<JsonValue>(trimmed) {
137        Ok(v) => v,
138        Err(_) => JsonValue::String(raw.to_string()),
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use serde_json::json;
146
147    fn tool(name: &str, params: JsonValue) -> Tool {
148        Tool {
149            name: name.to_string(),
150            description: String::new(),
151            parameters: params,
152        }
153    }
154
155    #[test]
156    fn detects_string_only_args() {
157        let t = tool(
158            "write",
159            json!({
160                "type": "object",
161                "properties": {
162                    "path": {"type": "string"},
163                    "content": {"type": "string"},
164                    "lines": {"type": "integer"},
165                    "force": {"type": ["boolean", "null"]},
166                }
167            }),
168        );
169        let shapes = build_arg_shapes(&[t]);
170        let shape = &shapes["write"];
171        assert!(shape.string_args.contains("path"));
172        assert!(shape.string_args.contains("content"));
173        assert!(!shape.string_args.contains("lines"));
174        // ["boolean","null"] → drop null → {boolean}, not string-only.
175        assert!(!shape.string_args.contains("force"));
176    }
177
178    #[test]
179    fn nullable_string_is_string_only() {
180        assert!(is_string_only_schema(&json!({"type": ["string", "null"]})));
181        assert!(!is_string_only_schema(
182            &json!({"type": ["string", "number"]})
183        ));
184    }
185
186    #[test]
187    fn enum_infers_types() {
188        assert!(is_string_only_schema(&json!({"enum": ["a", "b"]})));
189        assert!(!is_string_only_schema(&json!({"enum": [1, 2]})));
190    }
191
192    #[test]
193    fn anyof_union_collects_types() {
194        let schema = json!({"anyOf": [{"type": "string"}, {"type": "null"}]});
195        assert!(is_string_only_schema(&schema));
196    }
197
198    #[test]
199    fn decode_value_prefers_json() {
200        assert_eq!(decode_value("42"), json!(42));
201        assert_eq!(decode_value("true"), json!(true));
202        assert_eq!(decode_value(r#""hi""#), json!("hi"));
203        // Non-JSON falls back to the literal string.
204        assert_eq!(decode_value("hello world"), json!("hello world"));
205        assert_eq!(decode_value("  "), json!(""));
206    }
207}