use serde_json::{json, Value};
pub fn parse_tool_code(text: &str) -> Option<(String, Value)> {
let after_tag = text.find("```tool_code")? + "```tool_code".len();
let rest = &text[after_tag..];
let end = rest.find("```")?;
let code = rest[..end].trim();
if code.is_empty() {
return None;
}
let open = code.find('(')?;
let name = code[..open].trim().to_string();
if name.is_empty() || !is_ident(&name) {
return None;
}
let args_src = code[open + 1..].trim_end();
let inner = args_src.strip_suffix(')').unwrap_or(args_src);
let mut args = serde_json::Map::new();
for kv in split_top_level_commas(inner) {
let kv = kv.trim();
if kv.is_empty() {
continue;
}
if let Some(eq) = find_top_level_eq(kv) {
let key = kv[..eq].trim();
if key.is_empty() || !is_ident(key) {
continue;
}
let raw = kv[eq + 1..].trim();
args.insert(key.to_string(), py_value_to_json(raw));
}
}
Some((name, Value::Object(args)))
}
fn is_ident(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn py_value_to_json(raw: &str) -> Value {
match raw {
"True" => return json!(true),
"False" => return json!(false),
"None" => return json!(null),
_ => {}
}
if raw.len() >= 2
&& ((raw.starts_with('"') && raw.ends_with('"'))
|| (raw.starts_with('\'') && raw.ends_with('\'')))
{
return Value::String(raw[1..raw.len() - 1].to_string());
}
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}
fn find_top_level_eq(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut depth = 0i32;
let mut quote: Option<char> = None;
let mut prev = '\0';
for (i, c) in s.char_indices() {
match (quote, c) {
(Some(q), _) if c == q => quote = None,
(Some(_), _) => {}
(None, '"') | (None, '\'') => quote = Some(c),
(None, '(') | (None, '[') | (None, '{') => depth += 1,
(None, ')') | (None, ']') | (None, '}') => depth -= 1,
(None, '=') if depth == 0 => {
let next = bytes.get(i + 1).copied().unwrap_or(0);
if next != b'=' && prev != '=' && prev != '!' && prev != '<' && prev != '>' {
return Some(i);
}
}
_ => {}
}
prev = c;
}
None
}
fn split_top_level_commas(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut buf = String::new();
let mut depth = 0i32;
let mut quote: Option<char> = None;
for c in s.chars() {
match (quote, c) {
(Some(q), _) if c == q => {
quote = None;
buf.push(c);
}
(Some(_), _) => buf.push(c),
(None, '"') | (None, '\'') => {
quote = Some(c);
buf.push(c);
}
(None, '(') | (None, '[') | (None, '{') => {
depth += 1;
buf.push(c);
}
(None, ')') | (None, ']') | (None, '}') => {
depth -= 1;
buf.push(c);
}
(None, ',') if depth == 0 => out.push(std::mem::take(&mut buf)),
_ => buf.push(c),
}
}
if !buf.trim().is_empty() {
out.push(buf);
}
out
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn parses_pythonic_tool_code() {
let txt = "sure, let me look\n```tool_code\nview_file(path=\"main.rs\", limit=10)\n```";
let (name, args) = parse_tool_code(txt).expect("a parsed call");
assert_eq!(name, "view_file");
assert_eq!(args["path"], "main.rs");
assert_eq!(args["limit"], 10);
}
#[test]
fn parses_single_quoted_and_bools() {
let txt = "```tool_code\ncreate_file(path='a.txt', overwrite=True, note=None)\n```";
let (name, args) = parse_tool_code(txt).unwrap();
assert_eq!(name, "create_file");
assert_eq!(args["path"], "a.txt");
assert_eq!(args["overwrite"], true);
assert!(args["note"].is_null());
}
#[test]
fn no_fence_is_none() {
assert!(parse_tool_code("just a plain text answer, no fence").is_none());
assert!(parse_tool_code("```tool_output\n42\n```").is_none());
}
#[test]
fn empty_args_ok() {
let (name, args) = parse_tool_code("```tool_code\nfinish()\n```").unwrap();
assert_eq!(name, "finish");
assert!(args.as_object().unwrap().is_empty());
}
#[test]
fn comma_inside_string_is_not_split() {
let txt = "```tool_code\ncreate_file(path=\"a.txt\", content=\"a, b, c\")\n```";
let (_, args) = parse_tool_code(txt).unwrap();
assert_eq!(args["content"], "a, b, c");
assert_eq!(args["path"], "a.txt");
}
#[test]
fn prose_with_parens_is_rejected() {
assert!(parse_tool_code("I think (maybe) the answer is 4").is_none());
}
}