1use std::collections::HashMap;
12
13use serde_json::Value;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ToolResultKind {
18 FileRead,
20 Shell,
22 Search,
24 Other,
26}
27
28pub fn classify_tool_name(name: &str) -> ToolResultKind {
33 let n = name.to_ascii_lowercase();
34
35 const FILE_READ: &[&str] = &[
37 "read_file",
38 "readfile",
39 "file_read",
40 "fsread",
41 "fs_read",
42 "view_file",
43 "viewfile",
44 "open_file",
45 "notebookread",
46 "notebook_read",
47 "cat_file",
48 "get_file",
49 "fetch_file",
50 "ctx_read",
51 "ctx_multi_read",
52 "multi_read",
53 "multiread",
54 "read_many", "read_files",
56 "str_replace_editor", ];
58 if FILE_READ.iter().any(|k| n.contains(k)) {
59 return ToolResultKind::FileRead;
60 }
61 if matches!(n.as_str(), "read" | "view" | "cat" | "open") {
63 return ToolResultKind::FileRead;
64 }
65
66 const SEARCH: &[&str] = &[
67 "grep",
68 "ripgrep",
69 "search",
70 "find",
71 "glob",
72 "list_dir",
73 "listdir",
74 "list_files",
75 "listfiles",
76 "ls",
77 "codebase_search",
78 "ctx_search",
79 "ctx_tree",
80 ];
81 if SEARCH.iter().any(|k| n.contains(k)) {
82 return ToolResultKind::Search;
83 }
84
85 const SHELL: &[&str] = &[
86 "bash",
87 "shell",
88 "terminal",
89 "run_command",
90 "run_terminal",
91 "runterminal",
92 "execute_command",
93 "exec_command",
94 "command_exec",
95 "ctx_shell",
96 ];
97 if SHELL.iter().any(|k| n.contains(k)) {
98 return ToolResultKind::Shell;
99 }
100 if matches!(n.as_str(), "run" | "exec" | "execute" | "command" | "sh") {
101 return ToolResultKind::Shell;
102 }
103
104 for seg in n.split(|c: char| !c.is_ascii_alphanumeric()) {
111 match seg {
112 "read" | "view" | "cat" | "open" => return ToolResultKind::FileRead,
113 "grep" | "search" | "find" | "glob" | "ls" | "rg" => return ToolResultKind::Search,
114 "shell" | "bash" | "exec" | "run" | "terminal" | "cmd" => return ToolResultKind::Shell,
115 _ => {}
116 }
117 }
118
119 ToolResultKind::Other
120}
121
122pub fn anthropic_tool_names(messages: &[Value]) -> HashMap<String, String> {
126 let mut map = HashMap::new();
127 for msg in messages {
128 let Some(blocks) = msg.get("content").and_then(|c| c.as_array()) else {
129 continue;
130 };
131 for block in blocks {
132 if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
133 continue;
134 }
135 if let (Some(id), Some(name)) = (
136 block.get("id").and_then(|v| v.as_str()),
137 block.get("name").and_then(|v| v.as_str()),
138 ) {
139 map.insert(id.to_string(), name.to_string());
140 }
141 }
142 }
143 map
144}
145
146pub fn openai_tool_names(messages: &[Value]) -> HashMap<String, String> {
149 let mut map = HashMap::new();
150 for msg in messages {
151 let Some(calls) = msg.get("tool_calls").and_then(|c| c.as_array()) else {
152 continue;
153 };
154 for call in calls {
155 let id = call.get("id").and_then(|v| v.as_str());
156 let name = call
157 .get("function")
158 .and_then(|f| f.get("name"))
159 .and_then(|v| v.as_str());
160 if let (Some(id), Some(name)) = (id, name) {
161 map.insert(id.to_string(), name.to_string());
162 }
163 }
164 }
165 map
166}
167
168pub fn responses_tool_names(input: &[Value]) -> HashMap<String, String> {
171 let mut map = HashMap::new();
172 for item in input {
173 if item.get("type").and_then(|t| t.as_str()) != Some("function_call") {
174 continue;
175 }
176 if let (Some(id), Some(name)) = (
177 item.get("call_id").and_then(|v| v.as_str()),
178 item.get("name").and_then(|v| v.as_str()),
179 ) {
180 map.insert(id.to_string(), name.to_string());
181 }
182 }
183 map
184}
185
186pub fn should_protect(kind: ToolResultKind, content: &str) -> bool {
193 match kind {
194 ToolResultKind::FileRead => true,
195 ToolResultKind::Other => looks_like_source_code(content),
196 ToolResultKind::Shell | ToolResultKind::Search => false,
197 }
198}
199
200pub fn looks_like_source_code(content: &str) -> bool {
206 let mut code_signals = 0usize;
207 let mut shell_signals = 0usize;
208 let mut considered = 0usize;
209
210 for raw in content.lines().take(200) {
211 let line = raw.trim_end();
212 let trimmed = line.trim_start();
213 if trimmed.is_empty() {
214 continue;
215 }
216 considered += 1;
217
218 if trimmed.starts_with("$ ")
220 || trimmed.starts_with("% ")
221 || trimmed.starts_with(">>> ")
222 || trimmed.starts_with("warning:")
223 || trimmed.starts_with("error:")
224 || trimmed.starts_with("error[")
225 || trimmed.starts_with("INFO ")
226 || trimmed.starts_with("WARN ")
227 || trimmed.starts_with("DEBUG ")
228 || trimmed.starts_with("ERROR ")
229 || trimmed.starts_with("Compiling ")
230 || trimmed.starts_with("Downloaded ")
231 || trimmed.starts_with("test result:")
232 {
233 shell_signals += 1;
234 continue;
235 }
236
237 let is_indented = line.len() != trimmed.len();
239 let has_code_punct = trimmed.ends_with('{')
240 || trimmed.ends_with('}')
241 || trimmed.ends_with(';')
242 || trimmed.ends_with("=>")
243 || trimmed.ends_with("->")
244 || trimmed.ends_with(':');
245 let has_keyword = [
246 "fn ",
247 "def ",
248 "class ",
249 "import ",
250 "from ",
251 "function ",
252 "func ",
253 "pub ",
254 "const ",
255 "let ",
256 "var ",
257 "package ",
258 "public ",
259 "private ",
260 "struct ",
261 "enum ",
262 "impl ",
263 "#include",
264 "return ",
265 "async ",
266 "export ",
267 ]
268 .iter()
269 .any(|k| trimmed.starts_with(k) || trimmed.contains(k));
270
271 if (is_indented && has_code_punct) || has_keyword {
272 code_signals += 1;
273 }
274 }
275
276 if considered < 5 || shell_signals > 0 {
277 return false;
278 }
279 code_signals * 2 >= considered
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn classifies_file_read_tools() {
289 for name in [
290 "Read",
291 "read_file",
292 "view_file",
293 "ctx_read",
294 "mcp__fs__readFile",
295 "ctx_multi_read",
297 "read_many_files",
298 ] {
299 assert_eq!(
300 classify_tool_name(name),
301 ToolResultKind::FileRead,
302 "{name} should be FileRead"
303 );
304 }
305 }
306
307 #[test]
308 fn classifies_shell_and_search() {
309 assert_eq!(classify_tool_name("Bash"), ToolResultKind::Shell);
310 assert_eq!(
311 classify_tool_name("run_terminal_cmd"),
312 ToolResultKind::Shell
313 );
314 assert_eq!(classify_tool_name("Grep"), ToolResultKind::Search);
315 assert_eq!(
316 classify_tool_name("codebase_search"),
317 ToolResultKind::Search
318 );
319 }
320
321 #[test]
322 fn unknown_tool_is_other() {
323 assert_eq!(classify_tool_name("submit_pr"), ToolResultKind::Other);
324 }
325
326 #[test]
327 fn classifies_vendor_prefixed_foreign_tools() {
328 assert_eq!(classify_tool_name("forge_read"), ToolResultKind::FileRead);
332 assert_eq!(classify_tool_name("pi.read"), ToolResultKind::FileRead);
333 assert_eq!(classify_tool_name("forge_shell"), ToolResultKind::Shell);
334 assert_eq!(classify_tool_name("forge_exec"), ToolResultKind::Shell);
335 assert_eq!(classify_tool_name("fs:grep"), ToolResultKind::Search);
336 }
337
338 #[test]
339 fn segment_fallback_has_no_substring_false_positives() {
340 assert_eq!(classify_tool_name("thread_create"), ToolResultKind::Other);
343 assert_eq!(classify_tool_name("spread_values"), ToolResultKind::Other);
344 assert_eq!(
345 classify_tool_name("readme_generator"),
346 ToolResultKind::Other
347 );
348 assert_eq!(classify_tool_name("submit_pull"), ToolResultKind::Other);
349 }
350
351 #[test]
352 fn anthropic_names_resolve_from_tool_use() {
353 let messages = vec![
354 serde_json::json!({
355 "role": "assistant",
356 "content": [
357 {"type": "text", "text": "reading"},
358 {"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {}}
359 ]
360 }),
361 serde_json::json!({
362 "role": "user",
363 "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "x"}]
364 }),
365 ];
366 let names = anthropic_tool_names(&messages);
367 assert_eq!(names.get("toolu_1").map(String::as_str), Some("Read"));
368 }
369
370 #[test]
371 fn openai_names_resolve_from_tool_calls() {
372 let messages = vec![serde_json::json!({
373 "role": "assistant",
374 "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file"}}]
375 })];
376 let names = openai_tool_names(&messages);
377 assert_eq!(names.get("call_1").map(String::as_str), Some("read_file"));
378 }
379
380 #[test]
381 fn responses_names_resolve_from_function_call() {
382 let input = vec![serde_json::json!({
383 "type": "function_call", "call_id": "call_1", "name": "Read", "arguments": "{}"
384 })];
385 let names = responses_tool_names(&input);
386 assert_eq!(names.get("call_1").map(String::as_str), Some("Read"));
387 }
388
389 #[test]
390 fn source_code_detected() {
391 let code = "pub fn build(cfg: &Config) -> Result<App> {\n let mut app = App::new();\n app.configure(cfg);\n for route in cfg.routes() {\n app.register(route);\n }\n Ok(app)\n}";
392 assert!(looks_like_source_code(code));
393 }
394
395 #[test]
396 fn command_output_not_code() {
397 let log = "$ cargo build\n Compiling foo v0.1.0\n Compiling bar v0.2.0\nwarning: unused variable\n Finished dev target\nerror: could not compile";
398 assert!(!looks_like_source_code(log));
399 }
400
401 #[test]
402 fn plain_prose_not_code() {
403 let prose = "This is a normal paragraph of text.\nIt has several sentences.\nNone of them are code.\nThey are just words on lines.\nMore words follow here.";
404 assert!(!looks_like_source_code(prose));
405 }
406}