use serde_json::{Value, json};
use super::computer_toolset;
use crate::constants::COMMAND_MAX_TIMEOUT_SECS;
use crate::models::config::NativeTools;
use crate::models::tool_call::FunctionCall;
pub(super) const TEXT_EDITOR_NAME: &str = "str_replace_based_edit_tool";
const TEXT_EDITOR_TYPE: &str = "text_editor_20250728";
pub(super) const BASH_NAME: &str = "bash";
const BASH_TYPE: &str = "bash_20250124";
pub(super) const REJECTION: &str = "native_tools";
pub(super) const REJECTION_NAMES: &[&str] = &[
TEXT_EDITOR_TYPE,
BASH_TYPE,
TEXT_EDITOR_NAME,
"text_editor_",
"bash_",
];
const EDITOR_TOOLS: [&str; 3] = ["read_file", "write_file", "edit_file"];
const SHELL_TOOL: &str = "execute_command";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) struct Advertised {
pub(super) text_editor: bool,
pub(super) bash: bool,
pub(super) computer: bool,
}
impl Advertised {
pub(super) fn resolve(wanted: NativeTools, registered: &[&str]) -> Self {
Self {
text_editor: wanted.text_editor && EDITOR_TOOLS.iter().all(|t| registered.contains(t)),
bash: wanted.shell && registered.contains(&SHELL_TOOL),
computer: wanted.computer && registered.contains(&computer_toolset::TOOL),
}
}
pub(super) fn without_computer(self) -> Self {
Self {
computer: false,
..self
}
}
pub(super) fn without_editor_and_bash(self) -> Self {
Self {
text_editor: false,
bash: false,
..self
}
}
pub(super) fn replaces(self, name: &str) -> bool {
(self.text_editor && EDITOR_TOOLS.contains(&name))
|| (self.computer && name == computer_toolset::TOOL)
}
pub(super) fn declarations(self) -> Vec<Value> {
let mut out = Vec::new();
if self.text_editor {
out.push(json!({"type": TEXT_EDITOR_TYPE, "name": TEXT_EDITOR_NAME}));
}
if self.bash {
out.push(json!({"type": BASH_TYPE, "name": BASH_NAME}));
}
if self.computer {
out.push(computer_toolset::declaration());
}
out
}
pub(super) fn to_computer_member(self, call: &FunctionCall) -> Option<(String, Value)> {
self.computer
.then(|| computer_toolset::to_native(call))
.flatten()
}
pub(super) fn to_native(self, call: &FunctionCall) -> Option<(&'static str, Value)> {
let (name, input) = to_native(call)?;
let offered = match name {
TEXT_EDITOR_NAME => self.text_editor,
_ => self.bash,
};
offered.then_some((name, input))
}
}
pub(super) fn canonicalize(call: &mut FunctionCall) -> bool {
let translated = match call.name.as_str() {
TEXT_EDITOR_NAME => editor_to_canonical(&call.arguments),
BASH_NAME => bash_to_canonical(&call.arguments),
_ => None,
};
let Some((name, arguments)) = translated else {
return false;
};
call.name = name.to_string();
call.arguments = arguments;
true
}
fn str_field<'a>(input: &'a Value, key: &str) -> Option<&'a str> {
input.get(key).and_then(Value::as_str)
}
fn editor_to_canonical(input: &Value) -> Option<(&'static str, Value)> {
let path = str_field(input, "path")?;
match str_field(input, "command")? {
"view" => {
let mut args = json!({"path": path, "line_numbers": true});
if let Some(range) = input.get("view_range").filter(|r| !r.is_null()) {
args["view_range"] = range.clone();
}
Some(("read_file", args))
},
"create" => Some((
"write_file",
json!({"path": path, "content": str_field(input, "file_text")?}),
)),
"str_replace" => Some((
"edit_file",
json!({
"path": path,
"target_content": str_field(input, "old_str")?,
"replacement_content": str_field(input, "new_str").unwrap_or_default(),
}),
)),
"insert" => Some((
"edit_file",
json!({
"path": path,
"insert_line": input.get("insert_line")?.as_u64()?,
"replacement_content": str_field(input, "insert_text")
.or_else(|| str_field(input, "new_str"))?,
}),
)),
_ => None,
}
}
fn bash_to_canonical(input: &Value) -> Option<(&'static str, Value)> {
if input.get("restart").and_then(Value::as_bool) == Some(true) {
return Some((SHELL_TOOL, json!({"restart": true})));
}
Some((
SHELL_TOOL,
json!({
"command": str_field(input, "command")?,
"timeout": COMMAND_MAX_TIMEOUT_SECS,
}),
))
}
#[must_use]
pub fn unrunnable(name: &str) -> Option<&'static str> {
match name {
TEXT_EDITOR_NAME => Some(
"nothing ran: Mermaid runs the text editor's view (path, optional \
view_range), create (path, file_text), str_replace (path, old_str, \
new_str) and insert (path, insert_line, insert_text) commands, and this \
call is none of them with its required fields",
),
BASH_NAME => Some("nothing ran: a bash call needs `command`, or `restart: true`"),
_ => None,
}
}
fn to_native(call: &FunctionCall) -> Option<(&'static str, Value)> {
let args = &call.arguments;
let path = || str_field(args, "path");
match call.name.as_str() {
"read_file" => {
let mut input = json!({"command": "view", "path": path()?});
if let Some(range) = args.get("view_range") {
input["view_range"] = range.clone();
}
Some((TEXT_EDITOR_NAME, input))
},
"write_file" => Some((
TEXT_EDITOR_NAME,
json!({
"command": "create",
"path": path()?,
"file_text": str_field(args, "content")?,
}),
)),
"edit_file" => {
let text = str_field(args, "replacement_content")?;
let input = match args.get("insert_line") {
Some(line) => json!({
"command": "insert",
"path": path()?,
"insert_line": line,
"insert_text": text,
}),
None => json!({
"command": "str_replace",
"path": path()?,
"old_str": str_field(args, "target_content")?,
"new_str": text,
}),
};
Some((TEXT_EDITOR_NAME, input))
},
SHELL_TOOL if args.get("restart").is_some() => Some((BASH_NAME, json!({"restart": true}))),
SHELL_TOOL => Some((BASH_NAME, json!({"command": str_field(args, "command")?}))),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn call(name: &str, arguments: Value) -> FunctionCall {
FunctionCall {
name: name.to_string(),
arguments,
}
}
fn all() -> NativeTools {
NativeTools {
text_editor: true,
shell: true,
computer: false,
}
}
const FULL: [&str; 5] = [
"read_file",
"write_file",
"edit_file",
"apply_patch",
"execute_command",
];
#[test]
fn each_native_command_maps_onto_the_tool_it_stands_for() {
let cases = [
(
json!({"command": "view", "path": "a.rs"}),
"read_file",
json!({"path": "a.rs", "line_numbers": true}),
),
(
json!({"command": "view", "path": "a.rs", "view_range": [3, -1]}),
"read_file",
json!({"path": "a.rs", "line_numbers": true, "view_range": [3, -1]}),
),
(
json!({"command": "create", "path": "n.rs", "file_text": "x\n"}),
"write_file",
json!({"path": "n.rs", "content": "x\n"}),
),
(
json!({"command": "str_replace", "path": "a.rs", "old_str": "a", "new_str": "b"}),
"edit_file",
json!({"path": "a.rs", "target_content": "a", "replacement_content": "b"}),
),
(
json!({"command": "str_replace", "path": "a.rs", "old_str": "a"}),
"edit_file",
json!({"path": "a.rs", "target_content": "a", "replacement_content": ""}),
),
(
json!({"command": "insert", "path": "a.rs", "insert_line": 0, "insert_text": "top\n"}),
"edit_file",
json!({"path": "a.rs", "insert_line": 0, "replacement_content": "top\n"}),
),
];
for (input, name, args) in cases {
let mut c = call(TEXT_EDITOR_NAME, input.clone());
assert!(canonicalize(&mut c), "{input}");
assert_eq!(c.name, name, "{input}");
assert_eq!(c.arguments, args, "{input}");
}
}
#[test]
fn bash_runs_through_execute_command_with_the_longest_timeout() {
let mut c = call(BASH_NAME, json!({"command": "cargo build"}));
assert!(canonicalize(&mut c));
assert_eq!(c.name, "execute_command");
assert_eq!(
c.arguments,
json!({"command": "cargo build", "timeout": COMMAND_MAX_TIMEOUT_SECS})
);
let mut restart = call(BASH_NAME, json!({"restart": true}));
assert!(canonicalize(&mut restart));
assert_eq!(restart.arguments, json!({"restart": true}));
}
#[test]
fn a_call_mermaid_cannot_run_is_left_for_the_registry_to_answer() {
for input in [
json!({"command": "undo_edit", "path": "a.rs"}),
json!({"command": "create", "path": "a.rs"}),
json!({"command": "view"}),
] {
let mut c = call(TEXT_EDITOR_NAME, input.clone());
assert!(!canonicalize(&mut c), "{input}");
assert_eq!(c.name, TEXT_EDITOR_NAME);
assert_eq!(c.arguments, input);
}
let mut other = call("read_file", json!({"path": "a"}));
assert!(!canonicalize(&mut other));
assert_eq!(other.name, "read_file");
}
#[test]
fn history_gets_back_exactly_what_the_model_sent() {
let adv = Advertised::resolve(all(), &FULL);
for (name, input) in [
(TEXT_EDITOR_NAME, json!({"command": "view", "path": "a.rs"})),
(
TEXT_EDITOR_NAME,
json!({"command": "view", "path": "a.rs", "view_range": [1, 9]}),
),
(
TEXT_EDITOR_NAME,
json!({"command": "create", "path": "n.rs", "file_text": "x"}),
),
(
TEXT_EDITOR_NAME,
json!({"command": "str_replace", "path": "a", "old_str": "o", "new_str": "n"}),
),
(
TEXT_EDITOR_NAME,
json!({"command": "insert", "path": "a", "insert_line": 4, "insert_text": "t"}),
),
(BASH_NAME, json!({"command": "ls"})),
(BASH_NAME, json!({"restart": true})),
] {
let mut c = call(name, input.clone());
assert!(canonicalize(&mut c));
assert_eq!(adv.to_native(&c), Some((name, input)));
}
}
#[test]
fn the_editor_needs_every_tool_it_stands_for() {
let adv = Advertised::resolve(all(), &["read_file", "execute_command"]);
assert!(!adv.text_editor, "a read-only toolset cannot offer create");
assert!(adv.bash);
assert!(!adv.replaces("read_file"));
assert_eq!(adv.declarations().len(), 1);
let none = Advertised::resolve(NativeTools::default(), &FULL);
assert_eq!(none, Advertised::default());
assert!(none.declarations().is_empty());
}
#[test]
fn declarations_carry_only_type_and_name() {
let adv = Advertised::resolve(all(), &FULL);
assert_eq!(
adv.declarations(),
vec![
json!({"type": "text_editor_20250728", "name": "str_replace_based_edit_tool"}),
json!({"type": "bash_20250124", "name": "bash"}),
]
);
assert!(adv.replaces("edit_file"));
assert!(!adv.replaces("execute_command"), "bash sits beside it");
assert!(!adv.replaces("apply_patch"));
}
#[test]
fn a_call_is_replayed_natively_only_while_its_tool_is_offered() {
let mut c = call(BASH_NAME, json!({"command": "ls"}));
assert!(canonicalize(&mut c));
let editor_only = Advertised::resolve(
NativeTools {
text_editor: true,
shell: false,
computer: false,
},
&FULL,
);
assert_eq!(editor_only.to_native(&c), None);
}
}