1use std::collections::HashMap;
13
14use serde_json::Value;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ToolResultKind {
19 FileRead,
21 Shell,
23 Search,
25 Other,
27}
28
29pub fn classify_tool_name(name: &str) -> ToolResultKind {
34 let n = name.to_ascii_lowercase();
35
36 const FILE_READ: &[&str] = &[
38 "read_file",
39 "readfile",
40 "file_read",
41 "fsread",
42 "fs_read",
43 "view_file",
44 "viewfile",
45 "open_file",
46 "notebookread",
47 "notebook_read",
48 "cat_file",
49 "get_file",
50 "fetch_file",
51 "ctx_read",
52 "ctx_multi_read",
53 "multi_read",
54 "multiread",
55 "read_many", "read_files",
57 "str_replace_editor", "ctx_patch", "ctx_refactor", "ctx_callgraph", ];
62 if FILE_READ.iter().any(|k| n.contains(k)) {
63 return ToolResultKind::FileRead;
64 }
65 if matches!(n.as_str(), "read" | "view" | "cat" | "open") {
67 return ToolResultKind::FileRead;
68 }
69
70 const SEARCH: &[&str] = &[
71 "grep",
72 "ripgrep",
73 "search",
74 "find",
75 "glob",
76 "list_dir",
77 "listdir",
78 "list_files",
79 "listfiles",
80 "ls",
81 "codebase_search",
82 "ctx_search",
83 "ctx_tree",
84 ];
85 if SEARCH.iter().any(|k| n.contains(k)) {
86 return ToolResultKind::Search;
87 }
88
89 const SHELL: &[&str] = &[
90 "bash",
91 "shell",
92 "terminal",
93 "run_command",
94 "run_terminal",
95 "runterminal",
96 "execute_command",
97 "exec_command",
98 "command_exec",
99 "ctx_shell",
100 ];
101 if SHELL.iter().any(|k| n.contains(k)) {
102 return ToolResultKind::Shell;
103 }
104 if matches!(n.as_str(), "run" | "exec" | "execute" | "command" | "sh") {
105 return ToolResultKind::Shell;
106 }
107
108 for seg in n.split(|c: char| !c.is_ascii_alphanumeric()) {
115 match seg {
116 "read" | "view" | "cat" | "open" => return ToolResultKind::FileRead,
117 "grep" | "search" | "find" | "glob" | "ls" | "rg" => return ToolResultKind::Search,
118 "shell" | "bash" | "exec" | "run" | "terminal" | "cmd" => return ToolResultKind::Shell,
119 _ => {}
120 }
121 }
122
123 ToolResultKind::Other
124}
125
126pub fn anthropic_tool_names(messages: &[Value]) -> HashMap<String, String> {
130 let mut map = HashMap::new();
131 for msg in messages {
132 let Some(blocks) = msg.get("content").and_then(|c| c.as_array()) else {
133 continue;
134 };
135 for block in blocks {
136 if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
137 continue;
138 }
139 if let (Some(id), Some(name)) = (
140 block.get("id").and_then(|v| v.as_str()),
141 block.get("name").and_then(|v| v.as_str()),
142 ) {
143 map.insert(id.to_string(), name.to_string());
144 }
145 }
146 }
147 map
148}
149
150pub fn openai_tool_names(messages: &[Value]) -> HashMap<String, String> {
153 let mut map = HashMap::new();
154 for msg in messages {
155 let Some(calls) = msg.get("tool_calls").and_then(|c| c.as_array()) else {
156 continue;
157 };
158 for call in calls {
159 let id = call.get("id").and_then(|v| v.as_str());
160 let name = call
161 .get("function")
162 .and_then(|f| f.get("name"))
163 .and_then(|v| v.as_str());
164 if let (Some(id), Some(name)) = (id, name) {
165 map.insert(id.to_string(), name.to_string());
166 }
167 }
168 }
169 map
170}
171
172pub fn responses_tool_names(input: &[Value]) -> HashMap<String, String> {
175 let mut map = HashMap::new();
176 for item in input {
177 if item.get("type").and_then(|t| t.as_str()) != Some("function_call") {
178 continue;
179 }
180 if let (Some(id), Some(name)) = (
181 item.get("call_id").and_then(|v| v.as_str()),
182 item.get("name").and_then(|v| v.as_str()),
183 ) {
184 map.insert(id.to_string(), name.to_string());
185 }
186 }
187 map
188}
189
190pub fn should_protect(kind: ToolResultKind, content: &str) -> bool {
197 match kind {
198 ToolResultKind::FileRead => true,
199 ToolResultKind::Other => looks_like_source_code(content),
200 ToolResultKind::Shell | ToolResultKind::Search => false,
201 }
202}
203
204pub fn looks_like_source_code(content: &str) -> bool {
219 let mut code_signals = 0usize;
220 let mut shell_signals = 0usize;
221 let mut considered = 0usize;
222
223 for raw in content.lines().take(200) {
224 let line = raw.trim_end();
225 let trimmed = line.trim_start();
226 if trimmed.is_empty() {
227 continue;
228 }
229
230 if trimmed.starts_with("//")
238 || trimmed.starts_with("/*")
239 || trimmed.starts_with("*/")
240 || trimmed.starts_with("* ")
241 {
242 continue;
243 }
244
245 considered += 1;
246
247 if trimmed.starts_with("$ ")
249 || trimmed.starts_with("% ")
250 || trimmed.starts_with(">>> ")
251 || trimmed.starts_with("warning:")
252 || trimmed.starts_with("error:")
253 || trimmed.starts_with("error[")
254 || trimmed.starts_with("INFO ")
255 || trimmed.starts_with("WARN ")
256 || trimmed.starts_with("DEBUG ")
257 || trimmed.starts_with("ERROR ")
258 || trimmed.starts_with("Compiling ")
259 || trimmed.starts_with("Downloaded ")
260 || trimmed.starts_with("test result:")
261 {
262 shell_signals += 1;
263 continue;
264 }
265
266 let is_indented = line.len() != trimmed.len();
268 let has_code_punct = trimmed.ends_with('{')
269 || trimmed.ends_with('}')
270 || trimmed.ends_with(';')
271 || trimmed.ends_with("=>")
272 || trimmed.ends_with("->")
273 || trimmed.ends_with(':');
274 let is_call_or_closer = (trimmed.contains('(') && trimmed.contains(')'))
281 || trimmed.starts_with('}')
282 || trimmed.starts_with(')');
283 let has_keyword = [
284 "fn ",
285 "def ",
286 "class ",
287 "import ",
288 "from ",
289 "function ",
290 "func ",
291 "pub ",
292 "const ",
293 "let ",
294 "var ",
295 "package ",
296 "public ",
297 "private ",
298 "struct ",
299 "enum ",
300 "impl ",
301 "#include",
302 "return ",
303 "async ",
304 "export ",
305 ]
306 .iter()
307 .any(|k| trimmed.starts_with(k) || trimmed.contains(k));
308
309 let has_code_shape = has_code_punct && (is_indented || is_call_or_closer);
312 if has_code_shape || has_keyword {
313 code_signals += 1;
314 }
315 }
316
317 if considered < 5 || shell_signals > 0 {
318 return false;
319 }
320 code_signals * 2 >= considered
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn classifies_file_read_tools() {
330 for name in [
331 "Read",
332 "read_file",
333 "view_file",
334 "ctx_read",
335 "mcp__fs__readFile",
336 "ctx_multi_read",
338 "read_many_files",
339 ] {
340 assert_eq!(
341 classify_tool_name(name),
342 ToolResultKind::FileRead,
343 "{name} should be FileRead"
344 );
345 }
346 }
347
348 #[test]
349 fn classifies_shell_and_search() {
350 assert_eq!(classify_tool_name("Bash"), ToolResultKind::Shell);
351 assert_eq!(
352 classify_tool_name("run_terminal_cmd"),
353 ToolResultKind::Shell
354 );
355 assert_eq!(classify_tool_name("Grep"), ToolResultKind::Search);
356 assert_eq!(
357 classify_tool_name("codebase_search"),
358 ToolResultKind::Search
359 );
360 }
361
362 #[test]
363 fn unknown_tool_is_other() {
364 assert_eq!(classify_tool_name("submit_pr"), ToolResultKind::Other);
365 }
366
367 #[test]
368 fn classifies_vendor_prefixed_foreign_tools() {
369 assert_eq!(classify_tool_name("forge_read"), ToolResultKind::FileRead);
373 assert_eq!(classify_tool_name("pi.read"), ToolResultKind::FileRead);
374 assert_eq!(classify_tool_name("forge_shell"), ToolResultKind::Shell);
375 assert_eq!(classify_tool_name("forge_exec"), ToolResultKind::Shell);
376 assert_eq!(classify_tool_name("fs:grep"), ToolResultKind::Search);
377 }
378
379 #[test]
380 fn segment_fallback_has_no_substring_false_positives() {
381 assert_eq!(classify_tool_name("thread_create"), ToolResultKind::Other);
384 assert_eq!(classify_tool_name("spread_values"), ToolResultKind::Other);
385 assert_eq!(
386 classify_tool_name("readme_generator"),
387 ToolResultKind::Other
388 );
389 assert_eq!(classify_tool_name("submit_pull"), ToolResultKind::Other);
390 }
391
392 #[test]
393 fn anthropic_names_resolve_from_tool_use() {
394 let messages = vec![
395 serde_json::json!({
396 "role": "assistant",
397 "content": [
398 {"type": "text", "text": "reading"},
399 {"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {}}
400 ]
401 }),
402 serde_json::json!({
403 "role": "user",
404 "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "x"}]
405 }),
406 ];
407 let names = anthropic_tool_names(&messages);
408 assert_eq!(names.get("toolu_1").map(String::as_str), Some("Read"));
409 }
410
411 #[test]
412 fn openai_names_resolve_from_tool_calls() {
413 let messages = vec![serde_json::json!({
414 "role": "assistant",
415 "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file"}}]
416 })];
417 let names = openai_tool_names(&messages);
418 assert_eq!(names.get("call_1").map(String::as_str), Some("read_file"));
419 }
420
421 #[test]
422 fn responses_names_resolve_from_function_call() {
423 let input = vec![serde_json::json!({
424 "type": "function_call", "call_id": "call_1", "name": "Read", "arguments": "{}"
425 })];
426 let names = responses_tool_names(&input);
427 assert_eq!(names.get("call_1").map(String::as_str), Some("Read"));
428 }
429
430 #[test]
431 fn source_code_detected() {
432 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}";
433 assert!(looks_like_source_code(code));
434 }
435
436 #[test]
437 fn command_output_not_code() {
438 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";
439 assert!(!looks_like_source_code(log));
440 }
441
442 #[test]
443 fn plain_prose_not_code() {
444 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.";
445 assert!(!looks_like_source_code(prose));
446 }
447
448 #[test]
455 fn test_file_with_separator_comments_is_source() {
456 let code = "import { describe, it, expect } from \"vitest\";\n\
457 \n\
458 // ————————————————————————————————————————————————————————\n\
459 // Section: arithmetic\n\
460 // ————————————————————————————————————————————————————————\n\
461 describe(\"add\", () => {\n\
462 \x20 it(\"adds\", () => {\n\
463 \x20 expect(1 + 1).toBe(2);\n\
464 \x20 });\n\
465 });\n\
466 \n\
467 // ----------------------------------------------------------\n\
468 // Section: strings\n\
469 // ----------------------------------------------------------\n\
470 describe(\"concat\", () => {\n\
471 \x20 it(\"joins\", () => {\n\
472 \x20 expect(\"a\" + \"b\").toBe(\"ab\");\n\
473 \x20 });\n\
474 });\n";
475 assert!(
476 looks_like_source_code(code),
477 "a .test.ts with decorative separator comments must read as source"
478 );
479 assert!(
480 should_protect(ToolResultKind::Other, code),
481 "an unrecognized tool returning this source must still be protected"
482 );
483 }
484
485 #[test]
488 fn comment_heavy_source_still_detected() {
489 let code = "/*\n\
490 \x20* Copyright (c) 2026. All rights reserved.\n\
491 \x20* This module wires the request pipeline.\n\
492 \x20*/\n\
493 export function build(cfg) {\n\
494 \x20 const app = create();\n\
495 \x20 app.use(cfg);\n\
496 \x20 return app;\n\
497 }\n";
498 assert!(looks_like_source_code(code));
499 }
500
501 #[test]
505 fn parenthesized_log_output_still_not_code() {
506 let log = "INFO starting worker (pid=4211)\n\
507 processing batch (size=128) ok\n\
508 processing batch (size=64) ok\n\
509 WARN slow response (842ms) from upstream\n\
510 done in 3.2s (0 errors)\n";
511 assert!(!looks_like_source_code(log));
512 }
513
514 #[test]
517 fn ctx_patch_and_refactor_classified_as_file_read() {
518 assert_eq!(classify_tool_name("ctx_patch"), ToolResultKind::FileRead);
519 assert_eq!(classify_tool_name("ctx_refactor"), ToolResultKind::FileRead);
520 assert_eq!(
521 classify_tool_name("ctx_callgraph"),
522 ToolResultKind::FileRead
523 );
524 }
525
526 #[test]
528 fn diff_preview_is_protected_when_kind_is_file_read() {
529 let diff = "--- src/main.rs\n\
530 - fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), ShellError> {\n\
531 - if allowlist.is_empty() {\n\
532 - return Ok(());\n\
533 + fn check_all_segments(cmd: &str, list: &[String]) -> Result<(), ShellError> {\n\
534 + if list.is_empty() {\n\
535 + return Ok(());\n";
536 assert!(
537 should_protect(ToolResultKind::FileRead, diff),
538 "diff preview must be protected when kind is FileRead"
539 );
540 }
541}