use serde_json::{Map, Value};
use super::input_helpers::take_first_string;
use crate::hook_input::HookInput;
pub(super) fn parse(body: &str) -> Result<HookInput, ParseProblem> {
let value: Value = serde_json::from_str(body).map_err(ParseProblem::InvalidJson)?;
let Value::Object(mut map) = value else {
return Err(ParseProblem::NotAnObject);
};
if let Some(tool_name) = map
.get("tool_name")
.and_then(Value::as_str)
.map(str::to_owned)
{
let tool_input = map.remove("tool_input").unwrap_or(Value::Null);
return Ok(HookInput {
tool_name,
tool_input,
});
}
if let Some(tool_name) = map
.get("toolName")
.and_then(Value::as_str)
.map(str::to_owned)
{
let raw_args = map.remove("toolArgs").unwrap_or(Value::Null);
let (mapped_name, mapped_args) = map_tool(&tool_name, raw_args);
return Ok(HookInput {
tool_name: mapped_name,
tool_input: mapped_args,
});
}
Err(ParseProblem::MissingToolName)
}
#[derive(Debug)]
pub(super) enum ParseProblem {
InvalidJson(serde_json::Error),
NotAnObject,
MissingToolName,
}
impl std::fmt::Display for ParseProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidJson(err) => write!(f, "hook payload is not valid JSON ({err})"),
Self::NotAnObject => write!(f, "hook payload must be a JSON object"),
Self::MissingToolName => {
write!(f, "hook payload is missing tool_name / toolName field")
},
}
}
}
fn map_tool(raw_name: &str, raw_args: Value) -> (String, Value) {
let args_object = decode_args(raw_args);
match raw_name {
"bash" => ("Bash".into(), Value::Object(args_object)),
"powershell" => {
let mut obj = args_object;
obj.entry("shell".to_string())
.or_insert_with(|| Value::String("powershell".into()));
("Bash".into(), Value::Object(obj))
},
"view" => ("Read".into(), reshape_path(args_object)),
"edit" => ("Edit".into(), reshape_edit(args_object)),
"create" => ("Write".into(), reshape_create(args_object)),
"web_fetch" => ("WebFetch".into(), Value::Object(args_object)),
other => (other.to_string(), Value::Object(args_object)),
}
}
fn decode_args(raw: Value) -> Map<String, Value> {
match raw {
Value::Object(map) => map,
Value::String(s) => {
if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&s) {
map
} else {
let mut m = Map::new();
m.insert("raw".to_string(), Value::String(s));
m
}
},
Value::Null => Map::new(),
other => {
let mut m = Map::new();
m.insert("raw".to_string(), other);
m
},
}
}
fn reshape_path(mut args: Map<String, Value>) -> Value {
if let Some(path) = take_first_string(&mut args, &["file_path", "filePath", "path"]) {
args.insert("file_path".into(), Value::String(path));
}
Value::Object(args)
}
fn reshape_edit(mut args: Map<String, Value>) -> Value {
if let Some(path) = take_first_string(&mut args, &["file_path", "filePath", "path"]) {
args.insert("file_path".into(), Value::String(path));
}
if let Some(new_string) = take_first_string(&mut args, &["new_string", "newString", "content"])
{
args.insert("new_string".into(), Value::String(new_string));
}
Value::Object(args)
}
fn reshape_create(mut args: Map<String, Value>) -> Value {
if let Some(path) = take_first_string(&mut args, &["file_path", "filePath", "path"]) {
args.insert("file_path".into(), Value::String(path));
}
if let Some(content) = take_first_string(&mut args, &["content"]) {
args.insert("content".into(), Value::String(content));
}
Value::Object(args)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn camel_bash_maps_to_bash_with_command() {
let body = r#"{"toolName":"bash","toolArgs":"{\"command\":\"rm -rf /\"}"}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.bash_command(), Some("rm -rf /"));
}
#[test]
fn camel_bash_with_object_args_is_accepted() {
let body = r#"{"toolName":"bash","toolArgs":{"command":"ls"}}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.bash_command(), Some("ls"));
}
#[test]
fn snake_payload_is_accepted() {
let body = r#"{"tool_name":"Bash","tool_input":{"command":"ls"}}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.bash_command(), Some("ls"));
}
#[test]
fn view_maps_to_read_path() {
let body = r#"{"toolName":"view","toolArgs":{"filePath":"/etc/passwd"}}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Read");
assert_eq!(input.file_path(), Some("/etc/passwd"));
}
#[test]
fn create_maps_to_write_with_content() {
let body = r#"{"toolName":"create","toolArgs":{"path":"/tmp/x","content":"AKIA..."}}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Write");
assert_eq!(input.file_path(), Some("/tmp/x"));
assert_eq!(input.write_payload(), Some("AKIA..."));
}
#[test]
fn edit_maps_to_edit_with_new_string() {
let body = r#"{"toolName":"edit","toolArgs":{"filePath":"/tmp/x","newString":"hi"}}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Edit");
assert_eq!(input.file_path(), Some("/tmp/x"));
assert_eq!(input.write_payload(), Some("hi"));
}
#[test]
fn web_fetch_maps_to_webfetch() {
let body = r#"{"toolName":"web_fetch","toolArgs":{"url":"https://x"}}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "WebFetch");
assert_eq!(input.web_fetch_url(), Some("https://x"));
}
#[test]
fn powershell_maps_to_bash_with_shell_hint() {
let body = r#"{"toolName":"powershell","toolArgs":{"command":"Get-Item /"}}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.bash_command(), Some("Get-Item /"));
assert_eq!(
input.tool_input.get("shell").and_then(Value::as_str),
Some("powershell"),
);
}
#[test]
fn unknown_tool_passes_through_without_panic() {
let body = r#"{"toolName":"task","toolArgs":{"goal":"do something"}}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "task");
assert!(input.tool_input.is_object());
}
#[test]
fn non_json_args_string_is_kept_as_raw() {
let body = r#"{"toolName":"bash","toolArgs":"not-json"}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(
input.tool_input.get("raw").and_then(Value::as_str),
Some("not-json"),
);
}
#[test]
fn missing_tool_name_is_an_error() {
let body = r#"{"toolArgs":{}}"#;
assert!(matches!(parse(body), Err(ParseProblem::MissingToolName)));
}
#[test]
fn invalid_json_is_an_error() {
assert!(matches!(
parse("not-json"),
Err(ParseProblem::InvalidJson(_))
));
}
#[test]
fn array_payload_is_rejected() {
assert!(matches!(parse("[]"), Err(ParseProblem::NotAnObject)));
}
#[test]
fn parse_problem_display_invalid_json_mentions_json() {
let err = serde_json::from_str::<Value>("nope").unwrap_err();
let s = format!("{}", ParseProblem::InvalidJson(err));
assert!(s.contains("not valid JSON"));
}
#[test]
fn parse_problem_display_not_an_object_mentions_object() {
let s = format!("{}", ParseProblem::NotAnObject);
assert!(s.contains("must be a JSON object"));
}
#[test]
fn parse_problem_display_missing_tool_name_mentions_field() {
let s = format!("{}", ParseProblem::MissingToolName);
assert!(s.contains("missing tool_name"));
}
#[test]
fn camel_bash_with_null_args_is_accepted() {
let body = r#"{"toolName":"bash","toolArgs":null}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert!(input.tool_input.is_object());
}
#[test]
fn camel_bash_with_missing_args_is_accepted() {
let body = r#"{"toolName":"bash"}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert!(input.tool_input.is_object());
}
#[test]
fn camel_bash_with_bool_args_is_kept_as_raw() {
let body = r#"{"toolName":"bash","toolArgs":true}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(
input.tool_input.get("raw").and_then(Value::as_bool),
Some(true),
);
}
#[test]
fn view_without_path_field_passes_through_unchanged() {
let body = r#"{"toolName":"view","toolArgs":{"other":"x"}}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Read");
assert!(input.tool_input.get("file_path").is_none());
assert_eq!(
input.tool_input.get("other").and_then(Value::as_str),
Some("x"),
);
}
}