Skip to main content

ralph/tools/
mod.rs

1pub mod file_tools;
2pub mod gh_tools;
3pub mod lsp_tools;
4pub mod memory_tools;
5pub mod meta_tools;
6pub mod search_tools;
7pub mod shell_tools;
8pub mod symbol_tools;
9
10use crate::config::Config;
11use crate::errors::{RalphError, Result};
12use crate::guardrails::GuardrailChecker;
13use crate::lsp_client::LspClient;
14use crate::memory::MemoryStore;
15use crate::output::{ConfirmResult, Printer};
16use crate::providers::ToolDef;
17use crate::symbol_index::SymbolIndex;
18use serde_json::{json, Value};
19use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{Arc, Mutex};
22
23// ── Tool argument / result types ──────────────────────────────────────────────
24
25#[derive(Debug, Clone)]
26pub struct ToolCall {
27    pub id: String,
28    pub name: String,
29    pub arguments: Value,
30}
31
32#[derive(Debug, Clone)]
33pub struct ToolResult {
34    pub call_id: String,
35    pub tool_name: String,
36    pub output: String,
37    pub is_error: bool,
38}
39
40// ── Registry ──────────────────────────────────────────────────────────────────
41
42pub struct ToolRegistry {
43    workspace: PathBuf,
44    config: Config,
45    guardrails: GuardrailChecker,
46    printer: Arc<Printer>,
47    /// Tracks files Ralph has written/edited/deleted this session.
48    pub modified_files: Vec<PathBuf>,
49    /// Whether the user has whitelisted `run_command` for this session.
50    command_confirmed_once: bool,
51    no_confirm: bool,
52    /// Lazy-built symbol index for the workspace.
53    symbol_index: Option<SymbolIndex>,
54    /// Shared project memory store.
55    memory: Arc<Mutex<MemoryStore>>,
56    /// Active LSP clients, one per `--lsp <language>` flag.
57    lsp_clients: Vec<LspClient>,
58    /// When true, always show a diff and ask confirmation before every write_file.
59    diff_preview: Arc<AtomicBool>,
60}
61
62impl ToolRegistry {
63    pub fn new(
64        workspace: PathBuf,
65        config: Config,
66        printer: Arc<Printer>,
67        no_confirm: bool,
68        memory: Arc<Mutex<MemoryStore>>,
69        lsp_languages: Vec<String>,
70        diff_preview: Arc<AtomicBool>,
71    ) -> Self {
72        let guardrails = GuardrailChecker::new(workspace.clone(), config.guardrails.clone());
73        let lsp_clients = lsp_languages
74            .iter()
75            .map(|lang| LspClient::new(lang, &workspace))
76            .collect();
77        Self {
78            workspace,
79            config,
80            guardrails,
81            printer,
82            modified_files: Vec::new(),
83            command_confirmed_once: false,
84            no_confirm,
85            symbol_index: None,
86            memory,
87            lsp_clients,
88            diff_preview,
89        }
90    }
91
92    /// Build the symbol index on first use.
93    fn ensure_symbol_index(&mut self) {
94        if self.symbol_index.is_none() {
95            self.printer
96                .print(crate::output::Phase::Observe, "Building symbol index...");
97            let idx = SymbolIndex::build(&self.workspace);
98            self.printer.print(
99                crate::output::Phase::Observe,
100                &format!("Symbol index ready ({} symbols).", idx.symbol_count()),
101            );
102            self.symbol_index = Some(idx);
103        }
104    }
105
106    pub async fn execute(&mut self, call: &ToolCall) -> Result<ToolResult> {
107        // Run guardrail checks first
108        self.guardrails
109            .check_tool_call(&call.name, &call.arguments)?;
110
111        match call.name.as_str() {
112            "read_file" => self.exec_read_file(call),
113            "read_file_outline" => self.exec_read_file_outline(call),
114            "load_files" => self.exec_load_files(call),
115            "explain_code" => self.exec_explain_code(call),
116            "list_dir" => self.exec_list_dir(call),
117            "glob" => self.exec_glob(call),
118            "write_file" => self.exec_write_file(call).await,
119            "edit_file" => self.exec_edit_file(call).await,
120            "edit_file_multi" => self.exec_edit_file_multi(call).await,
121            "delete_file" => self.exec_delete_file(call).await,
122            "view_diff" => self.exec_view_diff(call).await,
123            "run_command" => self.exec_run_command(call).await,
124            "run_test" => self.exec_run_test(call).await,
125            "run_build" => self.exec_run_build(call).await,
126            "search_web" => self.exec_search_web(call).await,
127            "search_codebase" => self.exec_search_codebase(call),
128            "search_in_file" => self.exec_search_in_file(call),
129            "find_symbol" => self.exec_find_symbol(call),
130            "read_symbol" => self.exec_read_symbol(call),
131            "go_to_definition" => self.exec_go_to_definition(call).await,
132            "find_references" => self.exec_find_references(call).await,
133            "hover" => self.exec_hover(call).await,
134            "remember" => self.exec_remember(call),
135            "recall" => self.exec_recall(call),
136            "create_pr" => self.exec_create_pr(call).await,
137            "get_ci_status" => self.exec_get_ci_status(call).await,
138            "ask_user" => self.exec_ask_user(call),
139            "declare_done" => self.exec_declare_done(call),
140            "declare_failed" => self.exec_declare_failed(call),
141            unknown => Err(RalphError::ToolFailed {
142                tool: unknown.to_string(),
143                message: "Unknown tool".to_string(),
144            }),
145        }
146    }
147
148    // ── File tools ───────────────────────────────────────────────────────────
149
150    fn exec_read_file(&self, call: &ToolCall) -> Result<ToolResult> {
151        let path = str_arg(&call.arguments, "path")?;
152        let full = self.resolve_path(&path)?;
153        let offset = call.arguments["offset"].as_u64().map(|n| n as usize);
154        let limit = call.arguments["limit"].as_u64().map(|n| n as usize);
155        let content = file_tools::read_file_ranged(&full, offset, limit)?;
156        Ok(ok_result(call, content))
157    }
158
159    fn exec_load_files(&self, call: &ToolCall) -> Result<ToolResult> {
160        let pattern = str_arg(&call.arguments, "pattern")?;
161        let root = str_arg(&call.arguments, "path")
162            .ok()
163            .map(|p| self.workspace.join(p))
164            .unwrap_or_else(|| self.workspace.clone());
165        let content = file_tools::load_files(&pattern, &root)?;
166        Ok(ok_result(call, content))
167    }
168
169    fn exec_explain_code(&self, call: &ToolCall) -> Result<ToolResult> {
170        let root = str_arg(&call.arguments, "path")
171            .ok()
172            .map(|p| self.resolve_path(&p))
173            .transpose()?
174            .unwrap_or_else(|| self.workspace.clone());
175        let report = file_tools::explain_code(&root)?;
176        Ok(ok_result(call, report))
177    }
178
179    fn exec_list_dir(&self, call: &ToolCall) -> Result<ToolResult> {
180        let path = str_arg(&call.arguments, "path").unwrap_or_else(|_| ".".to_string());
181        let full = self.resolve_path(&path)?;
182        let listing = file_tools::list_dir(&full)?;
183        Ok(ok_result(call, listing))
184    }
185
186    fn exec_read_file_outline(&self, call: &ToolCall) -> Result<ToolResult> {
187        let path = str_arg(&call.arguments, "path")?;
188        let full = self.resolve_path(&path)?;
189        let outline = file_tools::read_file_outline(&full)?;
190        Ok(ok_result(call, outline))
191    }
192
193    fn exec_glob(&self, call: &ToolCall) -> Result<ToolResult> {
194        let pattern = str_arg(&call.arguments, "pattern")?;
195        let root = str_arg(&call.arguments, "path")
196            .ok()
197            .map(|p| self.workspace.join(p))
198            .unwrap_or_else(|| self.workspace.clone());
199        let result = search_tools::glob_files(&pattern, &root)?;
200        Ok(ok_result(call, result))
201    }
202
203    fn exec_search_in_file(&self, call: &ToolCall) -> Result<ToolResult> {
204        let pattern = str_arg(&call.arguments, "pattern")?;
205        let path = str_arg(&call.arguments, "path")?;
206        let context = call.arguments["context"].as_u64().unwrap_or(3) as usize;
207        let full = self.resolve_path(&path)?;
208        let result = search_tools::search_in_file(&pattern, &full, context)?;
209        Ok(ok_result(call, result))
210    }
211
212    async fn exec_view_diff(&self, call: &ToolCall) -> Result<ToolResult> {
213        let file_filter = call.arguments["path"].as_str().map(|p| p.to_string());
214        let result = view_git_diff(&self.workspace, file_filter.as_deref()).await;
215        Ok(ok_result(call, result))
216    }
217
218    async fn exec_write_file(&mut self, call: &ToolCall) -> Result<ToolResult> {
219        let path = str_arg(&call.arguments, "path")?;
220        let content = str_arg(&call.arguments, "content")?;
221        let full = self.resolve_path(&path)?;
222
223        if self.diff_preview.load(Ordering::Relaxed) && !self.no_confirm {
224            // Diff-preview mode: always show diff (empty→new for new files) and ask.
225            let existing = std::fs::read_to_string(&full).unwrap_or_default();
226            self.printer.print_diff(&path, &existing, &content);
227            let result = self.printer.confirm("Proceed with write?", false, false);
228            if result != ConfirmResult::Yes {
229                return Err(RalphError::UserAborted);
230            }
231        } else if full.exists() && !self.no_confirm {
232            // Normal mode: only prompt when overwriting.
233            let auto_cp = self.config.checkpoints.auto_checkpoint_before_destructive;
234            let result = self.printer.confirm(
235                &format!("write_file({:?}) will overwrite an existing file.", path),
236                true,
237                auto_cp,
238            );
239            match result {
240                ConfirmResult::No => return Err(RalphError::UserAborted),
241                ConfirmResult::ShowDiff => {
242                    let existing = std::fs::read_to_string(&full).unwrap_or_default();
243                    self.printer.print_diff(&path, &existing, &content);
244                    let result2 = self.printer.confirm("Proceed with write?", false, false);
245                    if result2 != ConfirmResult::Yes {
246                        return Err(RalphError::UserAborted);
247                    }
248                }
249                ConfirmResult::CheckpointAndProceed => {}
250                ConfirmResult::Yes => {}
251            }
252        }
253
254        // Check for secrets before writing
255        self.guardrails.check_content_for_secrets(&content)?;
256
257        file_tools::write_file(&full, &content)?;
258        self.modified_files.push(full);
259        Ok(ok_result(call, format!("Written: {}", path)))
260    }
261
262    async fn exec_edit_file(&mut self, call: &ToolCall) -> Result<ToolResult> {
263        let path = str_arg(&call.arguments, "path")?;
264        let old_string = str_arg(&call.arguments, "old_string")?;
265        let new_string = str_arg(&call.arguments, "new_string")?;
266        let full = self.resolve_path(&path)?;
267        match file_tools::edit_file(&full, &old_string, &new_string) {
268            Ok(()) => {
269                self.modified_files.push(full);
270                Ok(ok_result(call, format!("Edited: {}", path)))
271            }
272            Err(RalphError::EditNotFound { .. }) => {
273                let content = std::fs::read_to_string(&full).unwrap_or_default();
274                let hint = file_tools::find_closest_match_hint(&content, &old_string);
275                let hint_section = if hint.is_empty() {
276                    String::new()
277                } else {
278                    format!("\n\n{}", hint)
279                };
280                Ok(ToolResult {
281                    call_id: call.id.clone(),
282                    tool_name: call.name.clone(),
283                    output: format!(
284                        "edit_file failed: old_string not found in {}.\n\
285                         The text must match the file byte-for-byte (check whitespace/indentation).\n\
286                         Use `search_in_file` or `read_file` with offset/limit to see the exact text.{}",
287                        path, hint_section
288                    ),
289                    is_error: true,
290                })
291            }
292            Err(e) => Err(e),
293        }
294    }
295
296    async fn exec_edit_file_multi(&mut self, call: &ToolCall) -> Result<ToolResult> {
297        let path = str_arg(&call.arguments, "path")?;
298        let full = self.resolve_path(&path)?;
299        let edits_val = call.arguments["edits"].as_array().ok_or_else(|| {
300            RalphError::MalformedToolCall("edit_file_multi: 'edits' must be an array".to_string())
301        })?;
302
303        let edits: Vec<(String, String)> = edits_val
304            .iter()
305            .enumerate()
306            .map(|(i, e)| {
307                let old = e["old_string"]
308                    .as_str()
309                    .ok_or_else(|| {
310                        RalphError::MalformedToolCall(format!("edit[{}]: missing old_string", i))
311                    })?
312                    .to_string();
313                let new = e["new_string"]
314                    .as_str()
315                    .ok_or_else(|| {
316                        RalphError::MalformedToolCall(format!("edit[{}]: missing new_string", i))
317                    })?
318                    .to_string();
319                Ok::<_, RalphError>((old, new))
320            })
321            .collect::<Result<Vec<_>>>()?;
322
323        match file_tools::edit_file_multi(&full, &edits) {
324            Ok(applied) => {
325                self.modified_files.push(full);
326                Ok(ok_result(
327                    call,
328                    format!("Edited {}: {}", path, applied.join(", ")),
329                ))
330            }
331            Err(e) => Ok(ToolResult {
332                call_id: call.id.clone(),
333                tool_name: call.name.clone(),
334                output: format!("edit_file_multi failed: {}", e),
335                is_error: true,
336            }),
337        }
338    }
339
340    async fn exec_delete_file(&mut self, call: &ToolCall) -> Result<ToolResult> {
341        let path = str_arg(&call.arguments, "path")?;
342        let full = self.resolve_path(&path)?;
343
344        if !self.no_confirm {
345            let result = self.printer.confirm(
346                &format!("delete_file({:?}). This cannot be undone.", path),
347                false,
348                self.config.checkpoints.auto_checkpoint_before_destructive,
349            );
350            if result == ConfirmResult::No {
351                return Err(RalphError::UserAborted);
352            }
353        }
354
355        file_tools::delete_file(&full)?;
356        self.modified_files.push(full);
357        Ok(ok_result(call, format!("Deleted: {}", path)))
358    }
359
360    // ── Shell tools ──────────────────────────────────────────────────────────
361
362    async fn exec_run_command(&mut self, call: &ToolCall) -> Result<ToolResult> {
363        let cmd = str_arg(&call.arguments, "cmd")?;
364        let cwd = str_arg(&call.arguments, "cwd")
365            .ok()
366            .map(|c| self.workspace.join(c))
367            .unwrap_or_else(|| self.workspace.clone());
368
369        if !self.command_confirmed_once && !self.no_confirm {
370            let result = self
371                .printer
372                .confirm(&format!("run_command: `{}`", cmd), false, false);
373            if result == ConfirmResult::No {
374                return Err(RalphError::UserAborted);
375            }
376            self.command_confirmed_once = true;
377        }
378
379        let output = shell_tools::run_command(&cmd, &cwd).await?;
380        Ok(ok_result(call, output))
381    }
382
383    async fn exec_run_test(&self, call: &ToolCall) -> Result<ToolResult> {
384        let cmd = str_arg(&call.arguments, "cmd")?;
385        let output = shell_tools::run_command(&cmd, &self.workspace).await?;
386        Ok(ok_result(call, output))
387    }
388
389    async fn exec_run_build(&self, call: &ToolCall) -> Result<ToolResult> {
390        let cmd = str_arg(&call.arguments, "cmd")?;
391        let output = shell_tools::run_command(&cmd, &self.workspace).await?;
392        Ok(ok_result(call, output))
393    }
394
395    // ── Search tools ─────────────────────────────────────────────────────────
396
397    async fn exec_search_web(&self, call: &ToolCall) -> Result<ToolResult> {
398        let query = str_arg(&call.arguments, "query")?;
399        let brave_key = std::env::var(&self.config.search.brave_api_key_env).ok();
400        let serp_key = std::env::var(&self.config.search.serp_api_key_env).ok();
401        let results =
402            search_tools::search_web(&query, brave_key.as_deref(), serp_key.as_deref()).await?;
403        Ok(ok_result(call, results))
404    }
405
406    fn exec_search_codebase(&self, call: &ToolCall) -> Result<ToolResult> {
407        let pattern = str_arg(&call.arguments, "pattern")?;
408        let path = str_arg(&call.arguments, "path")
409            .ok()
410            .map(|p| self.workspace.join(p))
411            .unwrap_or_else(|| self.workspace.clone());
412        let glob = call.arguments["glob"].as_str();
413        let context = call.arguments["context"].as_u64().unwrap_or(0) as usize;
414        let results = search_tools::search_codebase_filtered(&pattern, &path, glob, context)?;
415        Ok(ok_result(call, results))
416    }
417
418    // ── Symbol tools ─────────────────────────────────────────────────────────
419
420    fn exec_find_symbol(&mut self, call: &ToolCall) -> Result<ToolResult> {
421        let query = str_arg(&call.arguments, "query")?;
422        self.ensure_symbol_index();
423        let output = symbol_tools::find_symbol(self.symbol_index.as_ref().unwrap(), &query);
424        Ok(ok_result(call, output))
425    }
426
427    fn exec_read_symbol(&mut self, call: &ToolCall) -> Result<ToolResult> {
428        let name = str_arg(&call.arguments, "name")?;
429        self.ensure_symbol_index();
430        let output = symbol_tools::read_symbol(self.symbol_index.as_ref().unwrap(), &name);
431        Ok(ok_result(call, output))
432    }
433
434    // ── LSP / navigation tools ───────────────────────────────────────────────
435
436    async fn exec_go_to_definition(&mut self, call: &ToolCall) -> Result<ToolResult> {
437        let file = str_arg(&call.arguments, "file")?;
438        let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
439        let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
440        let file_path = Path::new(&file);
441        let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
442
443        for client in &mut self.lsp_clients {
444            if client.handles_extension(ext) {
445                let out = client.go_to_definition(file_path, line, col).await?;
446                return Ok(ok_result(call, format!("[LSP] {}", out)));
447            }
448        }
449        let out = lsp_tools::go_to_definition_grep(&self.workspace, file_path, line, col);
450        Ok(ok_result(call, out))
451    }
452
453    async fn exec_find_references(&mut self, call: &ToolCall) -> Result<ToolResult> {
454        let file = str_arg(&call.arguments, "file")?;
455        let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
456        let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
457        let file_path = Path::new(&file);
458        let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
459
460        for client in &mut self.lsp_clients {
461            if client.handles_extension(ext) {
462                let out = client.find_references(file_path, line, col).await?;
463                return Ok(ok_result(call, format!("[LSP] {}", out)));
464            }
465        }
466        let out = lsp_tools::find_references_grep(&self.workspace, file_path, line, col);
467        Ok(ok_result(call, out))
468    }
469
470    async fn exec_hover(&mut self, call: &ToolCall) -> Result<ToolResult> {
471        let file = str_arg(&call.arguments, "file")?;
472        let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
473        let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
474        let file_path = Path::new(&file);
475        let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
476
477        for client in &mut self.lsp_clients {
478            if client.handles_extension(ext) {
479                let out = client.hover(file_path, line, col).await?;
480                return Ok(ok_result(call, format!("[LSP] {}", out)));
481            }
482        }
483        let out = lsp_tools::hover_grep(&self.workspace, file_path, line, col);
484        Ok(ok_result(call, out))
485    }
486
487    // ── Memory tools ─────────────────────────────────────────────────────────
488
489    fn exec_remember(&self, call: &ToolCall) -> Result<ToolResult> {
490        let key = str_arg(&call.arguments, "key")?;
491        let value = str_arg(&call.arguments, "value")?;
492        let output = memory_tools::remember(
493            &mut self.memory.lock().unwrap_or_else(|e| e.into_inner()),
494            &key,
495            &value,
496        );
497        Ok(ok_result(call, output))
498    }
499
500    fn exec_recall(&self, call: &ToolCall) -> Result<ToolResult> {
501        let query = str_arg(&call.arguments, "query").unwrap_or_default();
502        let output = memory_tools::recall(
503            &self.memory.lock().unwrap_or_else(|e| e.into_inner()),
504            &query,
505        );
506        Ok(ok_result(call, output))
507    }
508
509    // ── GitHub / CI tools ────────────────────────────────────────────────────
510
511    async fn exec_create_pr(&self, call: &ToolCall) -> Result<ToolResult> {
512        let title = str_arg(&call.arguments, "title")?;
513        let body = str_arg(&call.arguments, "body").unwrap_or_default();
514        let draft = call.arguments["draft"].as_bool().unwrap_or(false);
515        let base = call.arguments["base"].as_str().map(|s| s.to_string());
516        let url =
517            gh_tools::create_pr(&title, &body, draft, base.as_deref(), &self.workspace).await?;
518        Ok(ok_result(call, format!("PR created: {}", url)))
519    }
520
521    async fn exec_get_ci_status(&self, call: &ToolCall) -> Result<ToolResult> {
522        let branch = call.arguments["branch"].as_str().map(|s| s.to_string());
523        let status = gh_tools::get_ci_status(branch.as_deref(), &self.workspace).await?;
524        Ok(ok_result(call, status))
525    }
526
527    // ── Meta tools ───────────────────────────────────────────────────────────
528
529    fn exec_ask_user(&self, call: &ToolCall) -> Result<ToolResult> {
530        let question = str_arg(&call.arguments, "question")?;
531        let answer = meta_tools::ask_user(&question);
532        Ok(ok_result(call, answer))
533    }
534
535    fn exec_declare_done(&self, call: &ToolCall) -> Result<ToolResult> {
536        let summary = str_arg(&call.arguments, "summary").unwrap_or_default();
537        Ok(ToolResult {
538            call_id: call.id.clone(),
539            tool_name: call.name.clone(),
540            output: format!("DONE: {}", summary),
541            is_error: false,
542        })
543    }
544
545    fn exec_declare_failed(&self, call: &ToolCall) -> Result<ToolResult> {
546        let reason = str_arg(&call.arguments, "reason").unwrap_or_default();
547        Ok(ToolResult {
548            call_id: call.id.clone(),
549            tool_name: call.name.clone(),
550            output: format!("FAILED: {}", reason),
551            is_error: true,
552        })
553    }
554
555    // ── Helpers ───────────────────────────────────────────────────────────────
556
557    fn resolve_path(&self, path: &str) -> Result<PathBuf> {
558        let joined = if std::path::Path::new(path).is_absolute() {
559            PathBuf::from(path)
560        } else {
561            self.workspace.join(path)
562        };
563        let canonical = joined.canonicalize().unwrap_or_else(|_| joined.clone());
564        let ws_canonical = self
565            .workspace
566            .canonicalize()
567            .unwrap_or_else(|_| self.workspace.clone());
568        if !canonical.starts_with(&ws_canonical) {
569            return Err(RalphError::PathEscape(path.to_string()));
570        }
571        Ok(canonical)
572    }
573}
574
575/// Run `git diff HEAD` (or `git diff HEAD -- <path>`) in the workspace and return the output.
576pub async fn view_git_diff(workspace: &std::path::Path, file_filter: Option<&str>) -> String {
577    let mut cmd = tokio::process::Command::new("git");
578    cmd.arg("diff").arg("HEAD");
579    if let Some(f) = file_filter {
580        cmd.arg("--").arg(f);
581    }
582    cmd.current_dir(workspace);
583
584    match cmd.output().await {
585        Ok(out) => {
586            let diff = String::from_utf8_lossy(&out.stdout).to_string();
587            if diff.trim().is_empty() {
588                // Try diff without HEAD (unstaged changes vs index)
589                let fallback = tokio::process::Command::new("git")
590                    .args(["diff"])
591                    .current_dir(workspace)
592                    .output()
593                    .await
594                    .ok()
595                    .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
596                    .unwrap_or_default();
597                if fallback.trim().is_empty() {
598                    "(no changes from last commit)".to_string()
599                } else {
600                    fallback
601                }
602            } else {
603                diff
604            }
605        }
606        Err(e) => format!("(git not available: {})", e),
607    }
608}
609
610fn str_arg(args: &Value, key: &str) -> Result<String> {
611    args.get(key)
612        .and_then(|v| v.as_str())
613        .map(|s| s.to_string())
614        .ok_or_else(|| RalphError::MalformedToolCall(format!("missing argument: {}", key)))
615}
616
617fn ok_result(call: &ToolCall, output: String) -> ToolResult {
618    ToolResult {
619        call_id: call.id.clone(),
620        tool_name: call.name.clone(),
621        output,
622        is_error: false,
623    }
624}
625
626// ── Parallel read-only execution ─────────────────────────────────────────────
627
628/// Returns true for tools that are pure reads with no side-effects.
629/// These can safely be executed in parallel when the LLM issues several at once.
630pub fn is_read_only(name: &str) -> bool {
631    matches!(
632        name,
633        "read_file"
634            | "read_file_outline"
635            | "load_files"
636            | "list_dir"
637            | "glob"
638            | "explain_code"
639            | "search_codebase"
640            | "search_in_file"
641            | "search_web"
642            | "find_symbol"
643            | "read_symbol"
644            | "go_to_definition"
645            | "find_references"
646            | "hover"
647            | "recall"
648    )
649}
650
651/// Minimal shared context passed to every parallel read-only task.
652pub struct ReadOnlyContext {
653    pub workspace: PathBuf,
654    pub config: crate::config::Config,
655    pub printer: Arc<Printer>,
656    /// Lazily-built symbol index; shared across parallel tasks in the same turn.
657    pub symbol_index: Arc<tokio::sync::Mutex<Option<SymbolIndex>>>,
658    pub memory: Arc<Mutex<MemoryStore>>,
659}
660
661/// Execute a single read-only tool call without holding a `&mut ToolRegistry`.
662/// Navigation tools (`go_to_definition`, `find_references`, `hover`) always use
663/// the grep fallback here — LSP is reserved for the sequential registry path.
664pub async fn execute_read_only(
665    ctx: Arc<ReadOnlyContext>,
666    call: ToolCall,
667) -> crate::errors::Result<ToolResult> {
668    // Lightweight path-containment helper (mirrors ToolRegistry::resolve_path).
669    let resolve = |path: &str| -> crate::errors::Result<PathBuf> {
670        let joined = if Path::new(path).is_absolute() {
671            PathBuf::from(path)
672        } else {
673            ctx.workspace.join(path)
674        };
675        let canonical = joined.canonicalize().unwrap_or_else(|_| joined.clone());
676        let ws = ctx
677            .workspace
678            .canonicalize()
679            .unwrap_or_else(|_| ctx.workspace.clone());
680        if !canonical.starts_with(&ws) {
681            return Err(crate::errors::RalphError::PathEscape(path.to_string()));
682        }
683        Ok(joined)
684    };
685
686    let output: String = match call.name.as_str() {
687        "read_file" => {
688            let path = str_arg(&call.arguments, "path")?;
689            let offset = call.arguments["offset"].as_u64().map(|n| n as usize);
690            let limit = call.arguments["limit"].as_u64().map(|n| n as usize);
691            file_tools::read_file_ranged(&resolve(&path)?, offset, limit)?
692        }
693        "read_file_outline" => {
694            let path = str_arg(&call.arguments, "path")?;
695            file_tools::read_file_outline(&resolve(&path)?)?
696        }
697        "load_files" => {
698            let pattern = str_arg(&call.arguments, "pattern")?;
699            let root = str_arg(&call.arguments, "path")
700                .ok()
701                .map(|p| ctx.workspace.join(p))
702                .unwrap_or_else(|| ctx.workspace.clone());
703            file_tools::load_files(&pattern, &root)?
704        }
705        "list_dir" => {
706            let path = str_arg(&call.arguments, "path").unwrap_or_else(|_| ".".to_string());
707            file_tools::list_dir(&resolve(&path)?)?
708        }
709        "glob" => {
710            let pattern = str_arg(&call.arguments, "pattern")?;
711            let root = str_arg(&call.arguments, "path")
712                .ok()
713                .map(|p| ctx.workspace.join(p))
714                .unwrap_or_else(|| ctx.workspace.clone());
715            search_tools::glob_files(&pattern, &root)?
716        }
717        "explain_code" => {
718            let root = str_arg(&call.arguments, "path")
719                .ok()
720                .map(|p| ctx.workspace.join(p))
721                .unwrap_or_else(|| ctx.workspace.clone());
722            file_tools::explain_code(&root)?
723        }
724        "search_codebase" => {
725            let pattern = str_arg(&call.arguments, "pattern")?;
726            let path = str_arg(&call.arguments, "path")
727                .ok()
728                .map(|p| ctx.workspace.join(p))
729                .unwrap_or_else(|| ctx.workspace.clone());
730            let glob = call.arguments["glob"].as_str();
731            let context = call.arguments["context"].as_u64().unwrap_or(0) as usize;
732            search_tools::search_codebase_filtered(&pattern, &path, glob, context)?
733        }
734        "search_in_file" => {
735            let pattern = str_arg(&call.arguments, "pattern")?;
736            let path = str_arg(&call.arguments, "path")?;
737            let context = call.arguments["context"].as_u64().unwrap_or(3) as usize;
738            search_tools::search_in_file(&pattern, &resolve(&path)?, context)?
739        }
740        "search_web" => {
741            let query = str_arg(&call.arguments, "query")?;
742            let brave = std::env::var(&ctx.config.search.brave_api_key_env).ok();
743            let serp = std::env::var(&ctx.config.search.serp_api_key_env).ok();
744            search_tools::search_web(&query, brave.as_deref(), serp.as_deref()).await?
745        }
746        "find_symbol" => {
747            let query = str_arg(&call.arguments, "query")?;
748            let mut guard = ctx.symbol_index.lock().await;
749            if guard.is_none() {
750                *guard = Some(SymbolIndex::build(&ctx.workspace));
751            }
752            symbol_tools::find_symbol(guard.as_ref().unwrap(), &query)
753        }
754        "read_symbol" => {
755            let name = str_arg(&call.arguments, "name")?;
756            let mut guard = ctx.symbol_index.lock().await;
757            if guard.is_none() {
758                *guard = Some(SymbolIndex::build(&ctx.workspace));
759            }
760            symbol_tools::read_symbol(guard.as_ref().unwrap(), &name)
761        }
762        "go_to_definition" => {
763            let file = str_arg(&call.arguments, "file")?;
764            let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
765            let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
766            lsp_tools::go_to_definition_grep(&ctx.workspace, Path::new(&file), line, col)
767        }
768        "find_references" => {
769            let file = str_arg(&call.arguments, "file")?;
770            let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
771            let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
772            lsp_tools::find_references_grep(&ctx.workspace, Path::new(&file), line, col)
773        }
774        "hover" => {
775            let file = str_arg(&call.arguments, "file")?;
776            let line = call.arguments["line"].as_u64().unwrap_or(1) as u32;
777            let col = call.arguments["col"].as_u64().unwrap_or(1) as u32;
778            lsp_tools::hover_grep(&ctx.workspace, Path::new(&file), line, col)
779        }
780        "recall" => {
781            let query = str_arg(&call.arguments, "query").unwrap_or_default();
782            memory_tools::recall(
783                &ctx.memory.lock().unwrap_or_else(|e| e.into_inner()),
784                &query,
785            )
786        }
787        other => {
788            return Err(crate::errors::RalphError::ToolFailed {
789                tool: other.to_string(),
790                message: "Not a read-only tool".to_string(),
791            })
792        }
793    };
794
795    Ok(ToolResult {
796        call_id: call.id,
797        tool_name: call.name,
798        output,
799        is_error: false,
800    })
801}
802
803// ── Tool definitions for the LLM ─────────────────────────────────────────────
804
805/// Return the tool definitions to send to the LLM.
806/// `search_web` is gated on a search key being present.
807/// `create_pr` / `get_ci_status` are gated on `pr_enabled`.
808pub fn tool_defs(search_enabled: bool, pr_enabled: bool) -> Vec<ToolDef> {
809    let mut defs = vec![
810        ToolDef {
811            name: "read_file".to_string(),
812            description: "Read a file in the workspace. Use offset/limit to page through large files.".to_string(),
813            parameters: json!({
814                "type": "object",
815                "properties": {
816                    "path":   { "type": "string",  "description": "Relative path to the file." },
817                    "offset": { "type": "integer", "description": "0-based line index to start reading from (default: 0)." },
818                    "limit":  { "type": "integer", "description": "Maximum number of lines to return (default: 150)." }
819                },
820                "required": ["path"]
821            }),
822        },
823        ToolDef {
824            name: "list_dir".to_string(),
825            description: "List the contents of a directory, respecting .gitignore.".to_string(),
826            parameters: json!({
827                "type": "object",
828                "properties": {
829                    "path": { "type": "string", "description": "Relative path to directory. Defaults to workspace root." }
830                },
831                "required": []
832            }),
833        },
834        ToolDef {
835            name: "write_file".to_string(),
836            description: "Create or overwrite a file in the workspace.".to_string(),
837            parameters: json!({
838                "type": "object",
839                "properties": {
840                    "path": { "type": "string", "description": "Relative path for the file." },
841                    "content": { "type": "string", "description": "Full content to write." }
842                },
843                "required": ["path", "content"]
844            }),
845        },
846        ToolDef {
847            name: "edit_file".to_string(),
848            description: "Replace an exact string in a file. Fails if old_string is not found.".to_string(),
849            parameters: json!({
850                "type": "object",
851                "properties": {
852                    "path": { "type": "string" },
853                    "old_string": { "type": "string", "description": "Exact string to find (must be unique)." },
854                    "new_string": { "type": "string", "description": "Replacement string." }
855                },
856                "required": ["path", "old_string", "new_string"]
857            }),
858        },
859        ToolDef {
860            name: "delete_file".to_string(),
861            description: "Delete a file from the workspace.".to_string(),
862            parameters: json!({
863                "type": "object",
864                "properties": {
865                    "path": { "type": "string" }
866                },
867                "required": ["path"]
868            }),
869        },
870        ToolDef {
871            name: "run_command".to_string(),
872            description: "Execute a shell command. Requires user confirmation on first use.".to_string(),
873            parameters: json!({
874                "type": "object",
875                "properties": {
876                    "cmd": { "type": "string", "description": "Shell command to run." },
877                    "cwd": { "type": "string", "description": "Working directory relative to workspace (optional)." }
878                },
879                "required": ["cmd"]
880            }),
881        },
882        ToolDef {
883            name: "run_test".to_string(),
884            description: "Run the project test suite (whitelisted, no confirmation needed).".to_string(),
885            parameters: json!({
886                "type": "object",
887                "properties": {
888                    "cmd": { "type": "string", "description": "Test command (e.g. 'cargo test')." }
889                },
890                "required": ["cmd"]
891            }),
892        },
893        ToolDef {
894            name: "run_build".to_string(),
895            description: "Run the project build (whitelisted, no confirmation needed).".to_string(),
896            parameters: json!({
897                "type": "object",
898                "properties": {
899                    "cmd": { "type": "string", "description": "Build command (e.g. 'cargo build')." }
900                },
901                "required": ["cmd"]
902            }),
903        },
904        ToolDef {
905            name: "load_files".to_string(),
906            description: "Load all files matching a glob pattern. Returns each file with its path as a header.".to_string(),
907            parameters: json!({
908                "type": "object",
909                "properties": {
910                    "pattern": { "type": "string", "description": "Glob pattern relative to root, e.g. 'src/**/*.rs' or '**/*.md'." },
911                    "path": { "type": "string", "description": "Sub-path to restrict the search root (optional)." }
912                },
913                "required": ["pattern"]
914            }),
915        },
916        ToolDef {
917            name: "explain_code".to_string(),
918            description: "Analyze code structure at a path and return project type, directory tree, and entry points.".to_string(),
919            parameters: json!({
920                "type": "object",
921                "properties": {
922                    "path": { "type": "string", "description": "Sub-path to analyze (optional, defaults to workspace root)." }
923                },
924                "required": []
925            }),
926        },
927        ToolDef {
928            name: "search_codebase".to_string(),
929            description: "Search the workspace codebase using a regex pattern. Supports file-glob filter and context lines.".to_string(),
930            parameters: json!({
931                "type": "object",
932                "properties": {
933                    "pattern": { "type": "string", "description": "Regex pattern to search for." },
934                    "path":    { "type": "string", "description": "Sub-path to restrict search (optional)." },
935                    "glob":    { "type": "string", "description": "File glob filter, e.g. '*.py' or 'src/**/*.rs' (optional)." },
936                    "context": { "type": "integer", "description": "Lines of context around each match (default: 0)." }
937                },
938                "required": ["pattern"]
939            }),
940        },
941        ToolDef {
942            name: "search_in_file".to_string(),
943            description: "Search a single file for a regex pattern, returning matching lines with surrounding context. More precise than search_codebase when you know which file to look in.".to_string(),
944            parameters: json!({
945                "type": "object",
946                "properties": {
947                    "path":    { "type": "string", "description": "Relative file path." },
948                    "pattern": { "type": "string", "description": "Regex pattern to search for." },
949                    "context": { "type": "integer", "description": "Lines of context before and after each match (default: 3)." }
950                },
951                "required": ["path", "pattern"]
952            }),
953        },
954        ToolDef {
955            name: "glob".to_string(),
956            description: "List files matching a glob pattern (e.g. '**/*.py', 'src/**/*.rs'). Returns sorted file paths. Use before load_files to verify which files will be loaded.".to_string(),
957            parameters: json!({
958                "type": "object",
959                "properties": {
960                    "pattern": { "type": "string", "description": "Glob pattern, e.g. '**/*.py' or 'tests/test_*.py'." },
961                    "path":    { "type": "string", "description": "Sub-directory to restrict to (optional)." }
962                },
963                "required": ["pattern"]
964            }),
965        },
966        ToolDef {
967            name: "read_file_outline".to_string(),
968            description: "Get a structural outline of a file — function/class/struct signatures with line numbers, without bodies. Use this on large files to find which section to read instead of loading the whole file.".to_string(),
969            parameters: json!({
970                "type": "object",
971                "properties": {
972                    "path": { "type": "string", "description": "Relative file path." }
973                },
974                "required": ["path"]
975            }),
976        },
977        ToolDef {
978            name: "edit_file_multi".to_string(),
979            description: "Apply multiple find-and-replace edits to a single file in one atomic call. All edits are validated before any are applied. Use this instead of multiple edit_file calls on the same file.".to_string(),
980            parameters: json!({
981                "type": "object",
982                "properties": {
983                    "path": { "type": "string", "description": "Relative file path." },
984                    "edits": {
985                        "type": "array",
986                        "description": "Ordered list of replacements.",
987                        "items": {
988                            "type": "object",
989                            "properties": {
990                                "old_string": { "type": "string", "description": "Exact text to find (must be unique in file)." },
991                                "new_string": { "type": "string", "description": "Replacement text." }
992                            },
993                            "required": ["old_string", "new_string"]
994                        }
995                    }
996                },
997                "required": ["path", "edits"]
998            }),
999        },
1000        ToolDef {
1001            name: "view_diff".to_string(),
1002            description: "Show the current git diff (changes since last commit). Use this to review all your changes before calling declare_done, or to understand what has been modified so far.".to_string(),
1003            parameters: json!({
1004                "type": "object",
1005                "properties": {
1006                    "path": { "type": "string", "description": "Restrict diff to a specific file or directory (optional)." }
1007                },
1008                "required": []
1009            }),
1010        },
1011        ToolDef {
1012            name: "ask_user".to_string(),
1013            description: "Pause and ask the user a clarifying question.".to_string(),
1014            parameters: json!({
1015                "type": "object",
1016                "properties": {
1017                    "question": { "type": "string" }
1018                },
1019                "required": ["question"]
1020            }),
1021        },
1022        ToolDef {
1023            name: "declare_done".to_string(),
1024            description: "Signal that the task is complete.".to_string(),
1025            parameters: json!({
1026                "type": "object",
1027                "properties": {
1028                    "summary": { "type": "string", "description": "Brief summary of what was accomplished." }
1029                },
1030                "required": ["summary"]
1031            }),
1032        },
1033        ToolDef {
1034            name: "declare_failed".to_string(),
1035            description: "Signal that the task cannot be completed, with a reason.".to_string(),
1036            parameters: json!({
1037                "type": "object",
1038                "properties": {
1039                    "reason": { "type": "string" }
1040                },
1041                "required": ["reason"]
1042            }),
1043        },
1044        ToolDef {
1045            name: "go_to_definition".to_string(),
1046            description: "Jump to the definition of a symbol at a file position. Uses LSP when available.".to_string(),
1047            parameters: json!({
1048                "type": "object",
1049                "properties": {
1050                    "file": { "type": "string", "description": "Relative file path." },
1051                    "line": { "type": "integer", "description": "1-based line number." },
1052                    "col":  { "type": "integer", "description": "1-based column number." }
1053                },
1054                "required": ["file", "line", "col"]
1055            }),
1056        },
1057        ToolDef {
1058            name: "find_references".to_string(),
1059            description: "Find all usages of a symbol at a file position. Uses LSP when available.".to_string(),
1060            parameters: json!({
1061                "type": "object",
1062                "properties": {
1063                    "file": { "type": "string", "description": "Relative file path." },
1064                    "line": { "type": "integer", "description": "1-based line number." },
1065                    "col":  { "type": "integer", "description": "1-based column number." }
1066                },
1067                "required": ["file", "line", "col"]
1068            }),
1069        },
1070        ToolDef {
1071            name: "hover".to_string(),
1072            description: "Get type info and docs for a symbol at a file position. Uses LSP when available.".to_string(),
1073            parameters: json!({
1074                "type": "object",
1075                "properties": {
1076                    "file": { "type": "string", "description": "Relative file path." },
1077                    "line": { "type": "integer", "description": "1-based line number." },
1078                    "col":  { "type": "integer", "description": "1-based column number." }
1079                },
1080                "required": ["file", "line", "col"]
1081            }),
1082        },
1083        ToolDef {
1084            name: "find_symbol".to_string(),
1085            description: "Search the symbol index by name. Returns matching functions, structs, classes with file and line.".to_string(),
1086            parameters: json!({
1087                "type": "object",
1088                "properties": {
1089                    "query": { "type": "string", "description": "Partial or full symbol name to search for." }
1090                },
1091                "required": ["query"]
1092            }),
1093        },
1094        ToolDef {
1095            name: "read_symbol".to_string(),
1096            description: "Read the full source body of a named symbol (exact name, case-insensitive). Use find_symbol first if unsure of the exact name.".to_string(),
1097            parameters: json!({
1098                "type": "object",
1099                "properties": {
1100                    "name": { "type": "string", "description": "Exact symbol name." }
1101                },
1102                "required": ["name"]
1103            }),
1104        },
1105        ToolDef {
1106            name: "remember".to_string(),
1107            description: "Store a persistent key/value fact about this project, included in future sessions.".to_string(),
1108            parameters: json!({
1109                "type": "object",
1110                "properties": {
1111                    "key": { "type": "string", "description": "Short descriptive key." },
1112                    "value": { "type": "string", "description": "The fact to remember." }
1113                },
1114                "required": ["key", "value"]
1115            }),
1116        },
1117        ToolDef {
1118            name: "recall".to_string(),
1119            description: "Look up stored project facts. Leave query empty to see all.".to_string(),
1120            parameters: json!({
1121                "type": "object",
1122                "properties": {
1123                    "query": { "type": "string", "description": "Search term (empty = return all facts)." }
1124                },
1125                "required": []
1126            }),
1127        },
1128    ];
1129
1130    if search_enabled {
1131        defs.push(ToolDef {
1132            name: "search_web".to_string(),
1133            description: "Search the web. Use this before making any assumptions about APIs, library versions, or recent changes.".to_string(),
1134            parameters: json!({
1135                "type": "object",
1136                "properties": {
1137                    "query": { "type": "string", "description": "Search query." }
1138                },
1139                "required": ["query"]
1140            }),
1141        });
1142    }
1143
1144    if pr_enabled {
1145        defs.push(ToolDef {
1146            name: "create_pr".to_string(),
1147            description: "Create a GitHub pull request for the current branch using the gh CLI.".to_string(),
1148            parameters: json!({
1149                "type": "object",
1150                "properties": {
1151                    "title": { "type": "string", "description": "PR title." },
1152                    "body":  { "type": "string", "description": "PR description (markdown)." },
1153                    "draft": { "type": "boolean", "description": "Create as draft PR (default false)." },
1154                    "base":  { "type": "string",  "description": "Target branch (default: repo default)." }
1155                },
1156                "required": ["title", "body"]
1157            }),
1158        });
1159        defs.push(ToolDef {
1160            name: "get_ci_status".to_string(),
1161            description: "Get the status of recent CI runs for the current (or specified) branch.".to_string(),
1162            parameters: json!({
1163                "type": "object",
1164                "properties": {
1165                    "branch": { "type": "string", "description": "Branch name (default: current branch)." }
1166                },
1167                "required": []
1168            }),
1169        });
1170    }
1171
1172    defs
1173}