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