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 {
215 let mut code_signals = 0usize;
216 let mut shell_signals = 0usize;
217 let mut considered = 0usize;
218
219 for raw in content.lines().take(200) {
220 let line = raw.trim_end();
221 let trimmed = line.trim_start();
222 if trimmed.is_empty() {
223 continue;
224 }
225
226 if trimmed.starts_with("//")
234 || trimmed.starts_with("/*")
235 || trimmed.starts_with("*/")
236 || trimmed.starts_with("* ")
237 {
238 continue;
239 }
240
241 considered += 1;
242
243 if trimmed.starts_with("$ ")
245 || trimmed.starts_with("% ")
246 || trimmed.starts_with(">>> ")
247 || trimmed.starts_with("warning:")
248 || trimmed.starts_with("error:")
249 || trimmed.starts_with("error[")
250 || trimmed.starts_with("INFO ")
251 || trimmed.starts_with("WARN ")
252 || trimmed.starts_with("DEBUG ")
253 || trimmed.starts_with("ERROR ")
254 || trimmed.starts_with("Compiling ")
255 || trimmed.starts_with("Downloaded ")
256 || trimmed.starts_with("test result:")
257 {
258 shell_signals += 1;
259 continue;
260 }
261
262 let is_indented = line.len() != trimmed.len();
264 let has_code_punct = trimmed.ends_with('{')
265 || trimmed.ends_with('}')
266 || trimmed.ends_with(';')
267 || trimmed.ends_with("=>")
268 || trimmed.ends_with("->")
269 || trimmed.ends_with(':');
270 let is_call_or_closer = (trimmed.contains('(') && trimmed.contains(')'))
277 || trimmed.starts_with('}')
278 || trimmed.starts_with(')');
279 let has_keyword = [
280 "fn ",
281 "def ",
282 "class ",
283 "import ",
284 "from ",
285 "function ",
286 "func ",
287 "pub ",
288 "const ",
289 "let ",
290 "var ",
291 "package ",
292 "public ",
293 "private ",
294 "struct ",
295 "enum ",
296 "impl ",
297 "#include",
298 "return ",
299 "async ",
300 "export ",
301 ]
302 .iter()
303 .any(|k| trimmed.starts_with(k) || trimmed.contains(k));
304
305 let has_code_shape = has_code_punct && (is_indented || is_call_or_closer);
308 if has_code_shape || has_keyword {
309 code_signals += 1;
310 }
311 }
312
313 if considered < 5 || shell_signals > 0 {
314 return false;
315 }
316 code_signals * 2 >= considered
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn classifies_file_read_tools() {
326 for name in [
327 "Read",
328 "read_file",
329 "view_file",
330 "ctx_read",
331 "mcp__fs__readFile",
332 "ctx_multi_read",
334 "read_many_files",
335 ] {
336 assert_eq!(
337 classify_tool_name(name),
338 ToolResultKind::FileRead,
339 "{name} should be FileRead"
340 );
341 }
342 }
343
344 #[test]
345 fn classifies_shell_and_search() {
346 assert_eq!(classify_tool_name("Bash"), ToolResultKind::Shell);
347 assert_eq!(
348 classify_tool_name("run_terminal_cmd"),
349 ToolResultKind::Shell
350 );
351 assert_eq!(classify_tool_name("Grep"), ToolResultKind::Search);
352 assert_eq!(
353 classify_tool_name("codebase_search"),
354 ToolResultKind::Search
355 );
356 }
357
358 #[test]
359 fn unknown_tool_is_other() {
360 assert_eq!(classify_tool_name("submit_pr"), ToolResultKind::Other);
361 }
362
363 #[test]
364 fn classifies_vendor_prefixed_foreign_tools() {
365 assert_eq!(classify_tool_name("forge_read"), ToolResultKind::FileRead);
369 assert_eq!(classify_tool_name("pi.read"), ToolResultKind::FileRead);
370 assert_eq!(classify_tool_name("forge_shell"), ToolResultKind::Shell);
371 assert_eq!(classify_tool_name("forge_exec"), ToolResultKind::Shell);
372 assert_eq!(classify_tool_name("fs:grep"), ToolResultKind::Search);
373 }
374
375 #[test]
376 fn segment_fallback_has_no_substring_false_positives() {
377 assert_eq!(classify_tool_name("thread_create"), ToolResultKind::Other);
380 assert_eq!(classify_tool_name("spread_values"), ToolResultKind::Other);
381 assert_eq!(
382 classify_tool_name("readme_generator"),
383 ToolResultKind::Other
384 );
385 assert_eq!(classify_tool_name("submit_pull"), ToolResultKind::Other);
386 }
387
388 #[test]
389 fn anthropic_names_resolve_from_tool_use() {
390 let messages = vec![
391 serde_json::json!({
392 "role": "assistant",
393 "content": [
394 {"type": "text", "text": "reading"},
395 {"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {}}
396 ]
397 }),
398 serde_json::json!({
399 "role": "user",
400 "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "x"}]
401 }),
402 ];
403 let names = anthropic_tool_names(&messages);
404 assert_eq!(names.get("toolu_1").map(String::as_str), Some("Read"));
405 }
406
407 #[test]
408 fn openai_names_resolve_from_tool_calls() {
409 let messages = vec![serde_json::json!({
410 "role": "assistant",
411 "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file"}}]
412 })];
413 let names = openai_tool_names(&messages);
414 assert_eq!(names.get("call_1").map(String::as_str), Some("read_file"));
415 }
416
417 #[test]
418 fn responses_names_resolve_from_function_call() {
419 let input = vec![serde_json::json!({
420 "type": "function_call", "call_id": "call_1", "name": "Read", "arguments": "{}"
421 })];
422 let names = responses_tool_names(&input);
423 assert_eq!(names.get("call_1").map(String::as_str), Some("Read"));
424 }
425
426 #[test]
427 fn source_code_detected() {
428 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}";
429 assert!(looks_like_source_code(code));
430 }
431
432 #[test]
433 fn command_output_not_code() {
434 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";
435 assert!(!looks_like_source_code(log));
436 }
437
438 #[test]
439 fn plain_prose_not_code() {
440 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.";
441 assert!(!looks_like_source_code(prose));
442 }
443
444 #[test]
451 fn test_file_with_separator_comments_is_source() {
452 let code = "import { describe, it, expect } from \"vitest\";\n\
453 \n\
454 // ————————————————————————————————————————————————————————\n\
455 // Section: arithmetic\n\
456 // ————————————————————————————————————————————————————————\n\
457 describe(\"add\", () => {\n\
458 \x20 it(\"adds\", () => {\n\
459 \x20 expect(1 + 1).toBe(2);\n\
460 \x20 });\n\
461 });\n\
462 \n\
463 // ----------------------------------------------------------\n\
464 // Section: strings\n\
465 // ----------------------------------------------------------\n\
466 describe(\"concat\", () => {\n\
467 \x20 it(\"joins\", () => {\n\
468 \x20 expect(\"a\" + \"b\").toBe(\"ab\");\n\
469 \x20 });\n\
470 });\n";
471 assert!(
472 looks_like_source_code(code),
473 "a .test.ts with decorative separator comments must read as source"
474 );
475 assert!(
476 should_protect(ToolResultKind::Other, code),
477 "an unrecognized tool returning this source must still be protected"
478 );
479 }
480
481 #[test]
484 fn comment_heavy_source_still_detected() {
485 let code = "/*\n\
486 \x20* Copyright (c) 2026. All rights reserved.\n\
487 \x20* This module wires the request pipeline.\n\
488 \x20*/\n\
489 export function build(cfg) {\n\
490 \x20 const app = create();\n\
491 \x20 app.use(cfg);\n\
492 \x20 return app;\n\
493 }\n";
494 assert!(looks_like_source_code(code));
495 }
496
497 #[test]
501 fn parenthesized_log_output_still_not_code() {
502 let log = "INFO starting worker (pid=4211)\n\
503 processing batch (size=128) ok\n\
504 processing batch (size=64) ok\n\
505 WARN slow response (842ms) from upstream\n\
506 done in 3.2s (0 errors)\n";
507 assert!(!looks_like_source_code(log));
508 }
509}