pub mod fs_tools;
pub mod git_tools;
pub mod inspect_tools;
pub mod search_tools;
pub mod web_tools;
use crate::core::args_ref::resolve_tool_args;
use crate::core::response::{RawResult, normalize_tool_result};
use crate::tools::fs_tools::{
handle_dir_create, handle_dir_list, handle_file_edit, handle_file_edit_lines, handle_file_read,
handle_file_read_line_range, handle_file_write, handle_path_copy, handle_path_move,
handle_path_remove, handle_path_stat,
};
use crate::tools::git_tools::{
handle_git_add, handle_git_amend, handle_git_commit, handle_git_diff, handle_git_show,
handle_git_status,
};
use crate::tools::inspect_tools::handle_fs_inspect;
use crate::tools::search_tools::handle_fs_search;
use crate::tools::web_tools::{
handle_download_to_file, handle_web_extract, handle_web_fetch, handle_web_render,
};
use serde_json::{Map, Value, json};
use std::time::Instant;
pub fn dispatch_tool_call(tool_name: &str, args: Option<Value>) -> Value {
let started = Instant::now();
let result = match resolve_tool_args(args) {
Ok(args) => {
let args = absorb_arg_shape(tool_name, args);
dispatch_resolved(tool_name, &args)
}
Err(error) => RawResult::error(error),
};
normalize_tool_result(tool_name, result, started.elapsed())
}
fn dispatch_resolved(tool_name: &str, args: &Value) -> RawResult {
match tool_name {
"file-read" => handle_file_read(args),
"file-read-line-range" => handle_file_read_line_range(args),
"file-write" => handle_file_write(args),
"dir-create" => handle_dir_create(args),
"dir-list" => handle_dir_list(args),
"path-copy" => handle_path_copy(args),
"path-move" => handle_path_move(args),
"path-remove" => handle_path_remove(args),
"fs-search" => handle_fs_search(args),
"path-stat" => handle_path_stat(args),
"file-edit" => handle_file_edit(args),
"file-edit-lines" => handle_file_edit_lines(args),
"git-add" => handle_git_add(args),
"git-amend" => handle_git_amend(args),
"git-commit" => handle_git_commit(args),
"git-diff" => handle_git_diff(args),
"git-show" => handle_git_show(args),
"git-status" => handle_git_status(args),
"fs-inspect" => handle_fs_inspect(args),
"web-fetch" => handle_web_fetch(args),
"web-render" => handle_web_render(args),
"web-extract" => handle_web_extract(args),
"download-to-file" => handle_download_to_file(args),
_ => RawResult::error(format!("Unknown tool: {tool_name}")),
}
}
fn absorb_arg_shape(tool_name: &str, args: Value) -> Value {
let Value::Object(mut map) = args else {
return args;
};
coerce_json_string(&mut map, &["items", "paths"]);
if matches!(map.get("items"), Some(Value::Object(_))) && let Some(single) = map.remove("items") {
map.insert("items".to_string(), Value::Array(vec![single]));
}
match tool_name {
"file-read" => {
alias_item_keys(&mut map, &[("file_path", "path")]);
promote_single_read_item(&mut map, &["isUrl", "offset", "length"]);
}
"file-read-line-range" => {
alias_item_keys(&mut map, &[("file_path", "path")]);
promote_single_read_item(&mut map, &["start_line", "line_count"]);
}
"path-stat" | "dir-create" => normalize_paths_only(&mut map),
"file-write" => wrap_flat_item(
&mut map,
&[("file_path", "path")],
&["path"],
&[
"path",
"content",
"content_path",
"content_offset",
"content_length",
"mode",
],
),
"dir-list" => wrap_flat_item(
&mut map,
&[("file_path", "path")],
&["path"],
&[
"path",
"depth",
"maxEntries",
"excludePatterns",
"includeFiles",
"noDefaultExcludes",
],
),
"path-remove" => {
promote_paths_to_remove_items(&mut map);
wrap_flat_item(&mut map, &[], &["path"], &["path", "recursive", "force"]);
}
"path-copy" => wrap_flat_item(
&mut map,
&[("from", "source"), ("to", "destination")],
&["source", "destination"],
&["source", "destination", "recursive", "force"],
),
"path-move" => wrap_flat_item(
&mut map,
&[("from", "source"), ("to", "destination")],
&["source", "destination"],
&["source", "destination"],
),
"fs-search" => wrap_flat_item(
&mut map,
&[("file_path", "path")],
&["path", "pattern"],
&[
"path",
"pattern",
"pattern_path",
"pattern_offset",
"pattern_length",
"filePattern",
"ignoreCase",
"maxResults",
"includeHidden",
"noDefaultExcludes",
"contextLines",
"timeout_ms",
],
),
"file-edit" => wrap_flat_item(
&mut map,
&[("path", "file_path")],
&["file_path", "old_string"],
&[
"file_path",
"old_string",
"old_string_path",
"old_string_offset",
"old_string_length",
"new_string",
"new_string_path",
"new_string_offset",
"new_string_length",
"expected_replacements",
],
),
"file-edit-lines" => wrap_flat_item(
&mut map,
&[("path", "file_path")],
&["file_path", "start_line"],
&[
"file_path",
"start_line",
"end_line",
"replacement",
"replacement_path",
"replacement_offset",
"replacement_length",
"after",
"expected_lines",
],
),
"web-extract" => wrap_flat_item(
&mut map,
&[],
&["html", "path"],
&["html", "path", "dump", "baseUrl"],
),
_ => {}
}
Value::Object(map)
}
fn alias_keys(object: &mut Map<String, Value>, aliases: &[(&str, &str)]) {
for (from, to) in aliases {
if object.contains_key(*to) || !object.contains_key(*from) {
continue;
}
if let Some(value) = object.remove(*from) {
object.insert((*to).to_string(), value);
}
}
}
fn alias_item_keys(map: &mut Map<String, Value>, aliases: &[(&str, &str)]) {
if aliases.is_empty() {
return;
}
if let Some(Value::Array(items)) = map.get_mut("items") {
for item in items {
if let Value::Object(object) = item {
alias_keys(object, aliases);
}
}
}
}
fn wrap_flat_item(
map: &mut Map<String, Value>,
aliases: &[(&str, &str)],
markers: &[&str],
item_keys: &[&str],
) {
alias_item_keys(map, aliases);
alias_keys(map, aliases);
if map.contains_key("items") || !markers.iter().any(|key| map.contains_key(*key)) {
return;
}
let mut item = Map::new();
for key in item_keys {
if let Some(value) = map.remove(*key) {
item.insert((*key).to_string(), value);
}
}
map.insert("items".to_string(), Value::Array(vec![Value::Object(item)]));
}
fn promote_single_read_item(map: &mut Map<String, Value>, extra_keys: &[&str]) {
if map.contains_key("paths") || map.contains_key("items") {
return;
}
if !matches!(map.get("path"), Some(Value::String(_))) {
return;
}
let Some(path) = map.remove("path") else {
return;
};
let mut item = Map::new();
item.insert("path".to_string(), path);
for key in extra_keys {
if let Some(value) = map.remove(*key) {
item.insert((*key).to_string(), value);
}
}
map.insert("items".to_string(), Value::Array(vec![Value::Object(item)]));
}
fn normalize_paths_only(map: &mut Map<String, Value>) {
if map.contains_key("paths") {
return;
}
match map.remove("items") {
Some(Value::Array(items)) => {
let paths = items
.into_iter()
.filter_map(|item| match item {
Value::String(text) => Some(Value::String(text)),
Value::Object(mut object) => match object.remove("path") {
Some(Value::String(text)) => Some(Value::String(text)),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>();
if !paths.is_empty() {
map.insert("paths".to_string(), Value::Array(paths));
}
return;
}
Some(other) => {
map.insert("items".to_string(), other);
}
None => {}
}
if !matches!(map.get("path"), Some(Value::String(_))) {
return;
}
if let Some(path) = map.remove("path") {
map.insert("paths".to_string(), json!([path]));
}
}
fn promote_paths_to_remove_items(map: &mut Map<String, Value>) {
if map.contains_key("items") {
return;
}
if !matches!(map.get("paths"), Some(Value::Array(_))) {
return;
}
let recursive = map.get("recursive").cloned();
let force = map.get("force").cloned();
let Some(Value::Array(paths)) = map.remove("paths") else {
return;
};
let items: Vec<Value> = paths
.iter()
.filter_map(Value::as_str)
.map(|path| {
let mut item = Map::new();
item.insert("path".to_string(), Value::String(path.to_string()));
if let Some(recursive) = &recursive {
item.insert("recursive".to_string(), recursive.clone());
}
if let Some(force) = &force {
item.insert("force".to_string(), force.clone());
}
Value::Object(item)
})
.collect();
if items.is_empty() {
return;
}
map.remove("recursive");
map.remove("force");
map.insert("items".to_string(), Value::Array(items));
}
fn coerce_json_string(map: &mut Map<String, Value>, keys: &[&str]) {
for key in keys {
let text = match map.get(*key) {
Some(Value::String(text)) => text.clone(),
_ => continue,
};
let trimmed = text.trim_start();
if !(trimmed.starts_with('[') || trimmed.starts_with('{')) {
continue;
}
if let Ok(parsed) = serde_json::from_str::<Value>(&text)
&& (parsed.is_array() || parsed.is_object())
{
map.insert((*key).to_string(), parsed);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn reports_unknown_tool_as_error() {
let response = dispatch_tool_call("unknown", Some(json!({})));
assert_eq!(response["isError"], true);
}
#[test]
fn absorbs_flat_single_item_args() {
let listed = absorb_arg_shape("dir-list", json!({ "path": "C:/x", "depth": 1 }));
assert_eq!(listed["items"][0]["path"], "C:/x");
assert_eq!(listed["items"][0]["depth"], 1);
let written = absorb_arg_shape(
"file-write",
json!({ "file_path": "C:/x.txt", "content": "a" }),
);
assert_eq!(written["items"][0]["path"], "C:/x.txt");
let edited = absorb_arg_shape(
"file-edit",
json!({ "path": "C:/x.txt", "old_string": "a", "new_string": "b" }),
);
assert_eq!(edited["items"][0]["file_path"], "C:/x.txt");
assert!(edited.get("path").is_none());
}
#[test]
fn aliases_item_keys_inside_items() {
let written = absorb_arg_shape(
"file-write",
json!({ "items": [{ "file_path": "C:/y.txt", "content": "b" }] }),
);
assert_eq!(written["items"][0]["path"], "C:/y.txt");
let copied = absorb_arg_shape(
"path-copy",
json!({ "items": [{ "from": "C:/a", "to": "C:/b" }] }),
);
assert_eq!(copied["items"][0]["source"], "C:/a");
assert_eq!(copied["items"][0]["destination"], "C:/b");
}
#[test]
fn promotes_single_read_path_with_range_keys() {
let ranged = absorb_arg_shape(
"file-read-line-range",
json!({ "path": "C:/a.txt", "start_line": 3, "line_count": 2 }),
);
assert_eq!(ranged["items"][0]["path"], "C:/a.txt");
assert_eq!(ranged["items"][0]["start_line"], 3);
let read = absorb_arg_shape("file-read", json!({ "path": "C:/a.txt" }));
assert_eq!(read["items"][0]["path"], "C:/a.txt");
}
#[test]
fn normalizes_paths_only_tools() {
let stat = absorb_arg_shape(
"path-stat",
json!({ "items": [{ "path": "C:/a" }, "C:/b"] }),
);
assert_eq!(stat["paths"], json!(["C:/a", "C:/b"]));
let created = absorb_arg_shape("dir-create", json!({ "path": "C:/new" }));
assert_eq!(created["paths"], json!(["C:/new"]));
}
#[test]
fn single_items_object_becomes_array() {
let shaped = absorb_arg_shape(
"file-write",
json!({ "items": { "path": "C:/z.txt", "content": "c" } }),
);
assert_eq!(shaped["items"][0]["path"], "C:/z.txt");
}
#[test]
fn leaves_proper_batch_args_untouched() {
let args = json!({ "items": [{ "path": "C:/a" }, { "path": "C:/b" }], "allowMissing": true });
let shaped = absorb_arg_shape("dir-list", args.clone());
assert_eq!(shaped, args);
}
#[test]
fn path_remove_accepts_simple_paths_array() {
let shaped = absorb_arg_shape(
"path-remove",
json!({ "paths": ["C:/a", "C:/b"], "recursive": true }),
);
assert_eq!(shaped["items"][0]["path"], "C:/a");
assert_eq!(shaped["items"][1]["path"], "C:/b");
assert_eq!(shaped["items"][0]["recursive"], true);
assert_eq!(shaped["items"][1]["recursive"], true);
assert!(shaped.get("paths").is_none());
assert!(shaped.get("recursive").is_none());
}
#[test]
fn coerces_json_string_encoded_arrays() {
let removed = absorb_arg_shape("path-remove", json!({ "paths": "[\"C:/a\", \"C:/b\"]" }));
assert_eq!(removed["items"][0]["path"], "C:/a");
assert_eq!(removed["items"][1]["path"], "C:/b");
let stat = absorb_arg_shape("path-stat", json!({ "paths": "[\"C:/x\"]" }));
assert_eq!(stat["paths"][0], "C:/x");
}
#[test]
fn leaves_non_json_string_paths_for_handler_error() {
let shaped = absorb_arg_shape("path-stat", json!({ "paths": "C:/single" }));
assert_eq!(shaped["paths"], "C:/single");
}
}