Skip to main content

perspt_agent/
tools.rs

1//! Agent Tooling
2//!
3//! Tools available to agents for interacting with the workspace.
4//! Implements: read_file, search_code, apply_patch, run_command
5
6use diffy::{apply, Patch};
7use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use tokio::io::{AsyncBufReadExt, BufReader};
12use tokio::process::Command as AsyncCommand;
13
14/// Tool result from agent execution
15#[derive(Debug, Clone)]
16pub struct ToolResult {
17    pub tool_name: String,
18    pub success: bool,
19    pub output: String,
20    pub error: Option<String>,
21}
22
23impl ToolResult {
24    pub fn success(tool_name: &str, output: String) -> Self {
25        Self {
26            tool_name: tool_name.to_string(),
27            success: true,
28            output,
29            error: None,
30        }
31    }
32
33    pub fn failure(tool_name: &str, error: String) -> Self {
34        Self {
35            tool_name: tool_name.to_string(),
36            success: false,
37            output: String::new(),
38            error: Some(error),
39        }
40    }
41}
42
43/// Tool call request from LLM
44#[derive(Debug, Clone)]
45pub struct ToolCall {
46    pub name: String,
47    pub arguments: HashMap<String, String>,
48}
49
50/// Agent tools for workspace interaction
51pub struct AgentTools {
52    /// Working directory (sandbox root)
53    working_dir: PathBuf,
54    /// Whether to require user approval for commands
55    require_approval: bool,
56    /// Event sender for streaming output
57    event_sender: Option<perspt_core::events::channel::EventSender>,
58}
59
60impl AgentTools {
61    /// Create new agent tools instance
62    pub fn new(working_dir: PathBuf, require_approval: bool) -> Self {
63        Self {
64            working_dir,
65            require_approval,
66            event_sender: None,
67        }
68    }
69
70    /// Set event sender for streaming output
71    pub fn set_event_sender(&mut self, sender: perspt_core::events::channel::EventSender) {
72        self.event_sender = Some(sender);
73    }
74
75    /// Execute a tool call
76    pub async fn execute(&self, call: &ToolCall) -> ToolResult {
77        match call.name.as_str() {
78            "read_file" => self.read_file(call),
79            "search_code" => self.search_code(call),
80            "apply_patch" => self.apply_patch(call),
81            "run_command" => self.run_command(call).await,
82            "list_files" => self.list_files(call),
83            "write_file" => self.write_file(call),
84            "apply_diff" => self.apply_diff(call),
85            "delete_file" => self.delete_file(call),
86            "move_file" => self.move_file(call),
87            // Power Tools (OS-level)
88            "sed_replace" => self.sed_replace(call),
89            "awk_filter" => self.awk_filter(call),
90            "diff_files" => self.diff_files(call),
91            _ => ToolResult::failure(&call.name, format!("Unknown tool: {}", call.name)),
92        }
93    }
94
95    /// Read a file's contents
96    fn read_file(&self, call: &ToolCall) -> ToolResult {
97        let path = match call.arguments.get("path") {
98            Some(p) => self.resolve_path(p),
99            None => return ToolResult::failure("read_file", "Missing 'path' argument".to_string()),
100        };
101
102        match fs::read_to_string(&path) {
103            Ok(content) => ToolResult::success("read_file", content),
104            Err(e) => ToolResult::failure("read_file", format!("Failed to read {:?}: {}", path, e)),
105        }
106    }
107
108    /// Search for code patterns using grep
109    fn search_code(&self, call: &ToolCall) -> ToolResult {
110        let query = match call.arguments.get("query") {
111            Some(q) => q,
112            None => {
113                return ToolResult::failure("search_code", "Missing 'query' argument".to_string())
114            }
115        };
116
117        let path = call
118            .arguments
119            .get("path")
120            .map(|p| self.resolve_path(p))
121            .unwrap_or_else(|| self.working_dir.clone());
122
123        // Use ripgrep if available, fallback to grep
124        let output = Command::new("rg")
125            .args(["--json", "-n", query])
126            .current_dir(&path)
127            .output()
128            .or_else(|_| {
129                Command::new("grep")
130                    .args(["-rn", query, "."])
131                    .current_dir(&path)
132                    .output()
133            });
134
135        match output {
136            Ok(out) => {
137                let stdout = String::from_utf8_lossy(&out.stdout).to_string();
138                ToolResult::success("search_code", stdout)
139            }
140            Err(e) => ToolResult::failure("search_code", format!("Search failed: {}", e)),
141        }
142    }
143
144    /// Apply a patch to a file
145    fn apply_patch(&self, call: &ToolCall) -> ToolResult {
146        let path = match call.arguments.get("path") {
147            Some(p) => self.resolve_path(p),
148            None => {
149                return ToolResult::failure("apply_patch", "Missing 'path' argument".to_string())
150            }
151        };
152
153        let content = match call.arguments.get("content") {
154            Some(c) => c,
155            None => {
156                return ToolResult::failure("apply_patch", "Missing 'content' argument".to_string())
157            }
158        };
159
160        // Create parent directories if needed
161        if let Some(parent) = path.parent() {
162            if let Err(e) = fs::create_dir_all(parent) {
163                return ToolResult::failure(
164                    "apply_patch",
165                    format!("Failed to create directories: {}", e),
166                );
167            }
168        }
169
170        match fs::write(&path, content) {
171            Ok(_) => ToolResult::success("apply_patch", format!("Successfully wrote {:?}", path)),
172            Err(e) => {
173                ToolResult::failure("apply_patch", format!("Failed to write {:?}: {}", path, e))
174            }
175        }
176    }
177
178    /// Apply a unified diff patch to a file
179    fn apply_diff(&self, call: &ToolCall) -> ToolResult {
180        let path = match call.arguments.get("path") {
181            Some(p) => self.resolve_path(p),
182            None => {
183                return ToolResult::failure("apply_diff", "Missing 'path' argument".to_string())
184            }
185        };
186
187        let diff_content = match call.arguments.get("diff") {
188            Some(c) => c,
189            None => {
190                return ToolResult::failure("apply_diff", "Missing 'diff' argument".to_string())
191            }
192        };
193
194        // Read original file
195        let original = match fs::read_to_string(&path) {
196            Ok(c) => c,
197            Err(e) => {
198                // If file doesn't exist, we can't patch it.
199                // (Unless it's a new file creation patch, but diffy usually assumes base text)
200                return ToolResult::failure(
201                    "apply_diff",
202                    format!("Failed to read base file {:?}: {}", path, e),
203                );
204            }
205        };
206
207        // Parse patch
208        let patch = match Patch::from_str(diff_content) {
209            Ok(p) => p,
210            Err(e) => {
211                return ToolResult::failure("apply_diff", format!("Failed to parse diff: {}", e));
212            }
213        };
214
215        // Apply patch
216        match apply(&original, &patch) {
217            Ok(patched) => match fs::write(&path, patched) {
218                Ok(_) => {
219                    ToolResult::success("apply_diff", format!("Successfully patched {:?}", path))
220                }
221                Err(e) => ToolResult::failure(
222                    "apply_diff",
223                    format!("Failed to write patched file: {}", e),
224                ),
225            },
226            Err(e) => ToolResult::failure("apply_diff", format!("Failed to apply patch: {}", e)),
227        }
228    }
229
230    /// Run a shell command (requires approval unless auto-approve is set)
231    async fn run_command(&self, call: &ToolCall) -> ToolResult {
232        let cmd_str = match call.arguments.get("command") {
233            Some(c) => c,
234            None => {
235                return ToolResult::failure("run_command", "Missing 'command' argument".to_string())
236            }
237        };
238
239        // Honor explicit working_dir from the caller (e.g. sandbox path),
240        // falling back to self.working_dir (the main workspace).
241        let effective_dir = call
242            .arguments
243            .get("working_dir")
244            .map(PathBuf::from)
245            .filter(|d| d.is_dir())
246            .unwrap_or_else(|| self.working_dir.clone());
247
248        // PSP-5 Phase 4: Sanitize command through policy before execution
249        match perspt_policy::sanitize_command(cmd_str) {
250            Ok(sr) if sr.rejected => {
251                return ToolResult::failure(
252                    "run_command",
253                    format!(
254                        "Command rejected by policy: {}",
255                        sr.rejection_reason
256                            .unwrap_or_else(|| "unknown reason".to_string())
257                    ),
258                );
259            }
260            Ok(sr) => {
261                for warning in &sr.warnings {
262                    log::warn!("Command policy warning: {}", warning);
263                }
264            }
265            Err(e) => {
266                return ToolResult::failure(
267                    "run_command",
268                    format!("Command sanitization failed: {}", e),
269                );
270            }
271        }
272
273        // Validate workspace bounds
274        if let Err(e) = perspt_policy::validate_workspace_bound(cmd_str, &self.working_dir) {
275            return ToolResult::failure("run_command", format!("Command rejected: {}", e));
276        }
277
278        if self.require_approval {
279            log::info!("Command requires approval: {}", cmd_str);
280        }
281
282        let mut child = match AsyncCommand::new("sh")
283            .args(["-c", cmd_str])
284            .current_dir(&effective_dir)
285            .env_remove("VIRTUAL_ENV")
286            .stdout(Stdio::piped())
287            .stderr(Stdio::piped())
288            .spawn()
289        {
290            Ok(child) => child,
291            Err(e) => return ToolResult::failure("run_command", format!("Failed to spawn: {}", e)),
292        };
293
294        let stdout = child.stdout.take().expect("Failed to open stdout");
295        let stderr = child.stderr.take().expect("Failed to open stderr");
296        let sender = self.event_sender.clone();
297
298        let stdout_handle = tokio::spawn(async move {
299            let mut reader = BufReader::new(stdout).lines();
300            let mut output = String::new();
301            while let Ok(Some(line)) = reader.next_line().await {
302                if let Some(ref s) = sender {
303                    let _ = s.send(perspt_core::AgentEvent::Log(line.clone()));
304                }
305                output.push_str(&line);
306                output.push('\n');
307            }
308            output
309        });
310
311        let sender_err = self.event_sender.clone();
312        let stderr_handle = tokio::spawn(async move {
313            let mut reader = BufReader::new(stderr).lines();
314            let mut output = String::new();
315            while let Ok(Some(line)) = reader.next_line().await {
316                if let Some(ref s) = sender_err {
317                    let _ = s.send(perspt_core::AgentEvent::Log(format!("ERR: {}", line)));
318                }
319                output.push_str(&line);
320                output.push('\n');
321            }
322            output
323        });
324
325        let status = match child.wait().await {
326            Ok(s) => s,
327            Err(e) => return ToolResult::failure("run_command", format!("Failed to wait: {}", e)),
328        };
329
330        let stdout_str = stdout_handle.await.unwrap_or_default();
331        let stderr_str = stderr_handle.await.unwrap_or_default();
332
333        if status.success() {
334            ToolResult::success("run_command", stdout_str)
335        } else {
336            ToolResult::failure(
337                "run_command",
338                format!("Exit code: {:?}\n{}", status.code(), stderr_str),
339            )
340        }
341    }
342
343    /// List files in a directory
344    fn list_files(&self, call: &ToolCall) -> ToolResult {
345        let path = call
346            .arguments
347            .get("path")
348            .map(|p| self.resolve_path(p))
349            .unwrap_or_else(|| self.working_dir.clone());
350
351        match fs::read_dir(&path) {
352            Ok(entries) => {
353                let files: Vec<String> = entries
354                    .filter_map(|e| e.ok())
355                    .map(|e| {
356                        let name = e.file_name().to_string_lossy().to_string();
357                        if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
358                            format!("{}/", name)
359                        } else {
360                            name
361                        }
362                    })
363                    .collect();
364                ToolResult::success("list_files", files.join("\n"))
365            }
366            Err(e) => {
367                ToolResult::failure("list_files", format!("Failed to list {:?}: {}", path, e))
368            }
369        }
370    }
371
372    /// Write content to a file
373    fn write_file(&self, call: &ToolCall) -> ToolResult {
374        // Alias for apply_patch with different semantics
375        self.apply_patch(call)
376    }
377
378    /// Delete a file from the workspace
379    fn delete_file(&self, call: &ToolCall) -> ToolResult {
380        let path = match call.arguments.get("path") {
381            Some(p) => self.resolve_path(p),
382            None => {
383                return ToolResult::failure("delete_file", "Missing 'path' argument".to_string())
384            }
385        };
386
387        if !path.exists() {
388            return ToolResult::success(
389                "delete_file",
390                format!("Path does not exist, nothing to delete: {:?}", path),
391            );
392        }
393
394        if path.is_dir() {
395            return ToolResult::failure(
396                "delete_file",
397                format!(
398                    "Cannot delete directory {:?}; only files are supported",
399                    path
400                ),
401            );
402        }
403
404        match std::fs::remove_file(&path) {
405            Ok(()) => ToolResult::success("delete_file", format!("Deleted {:?}", path)),
406            Err(e) => {
407                ToolResult::failure("delete_file", format!("Failed to delete {:?}: {}", path, e))
408            }
409        }
410    }
411
412    /// Move/rename a file within the workspace
413    fn move_file(&self, call: &ToolCall) -> ToolResult {
414        let from = match call.arguments.get("from") {
415            Some(p) => self.resolve_path(p),
416            None => return ToolResult::failure("move_file", "Missing 'from' argument".to_string()),
417        };
418        let to = match call.arguments.get("to") {
419            Some(p) => self.resolve_path(p),
420            None => return ToolResult::failure("move_file", "Missing 'to' argument".to_string()),
421        };
422
423        if !from.exists() {
424            return ToolResult::failure(
425                "move_file",
426                format!("Source path does not exist: {:?}", from),
427            );
428        }
429
430        // Ensure destination parent directory exists
431        if let Some(parent) = to.parent() {
432            if !parent.exists() {
433                if let Err(e) = std::fs::create_dir_all(parent) {
434                    return ToolResult::failure(
435                        "move_file",
436                        format!("Failed to create destination directory {:?}: {}", parent, e),
437                    );
438                }
439            }
440        }
441
442        match std::fs::rename(&from, &to) {
443            Ok(()) => ToolResult::success("move_file", format!("Moved {:?} -> {:?}", from, to)),
444            Err(e) => ToolResult::failure(
445                "move_file",
446                format!("Failed to move {:?} -> {:?}: {}", from, to, e),
447            ),
448        }
449    }
450
451    /// Resolve a path relative to working directory
452    fn resolve_path(&self, path: &str) -> PathBuf {
453        let p = Path::new(path);
454        if p.is_absolute() {
455            p.to_path_buf()
456        } else {
457            self.working_dir.join(p)
458        }
459    }
460
461    // =========================================================================
462    // Power Tools (OS-level operations)
463    // =========================================================================
464
465    /// Replace text in a file using sed-like pattern matching
466    fn sed_replace(&self, call: &ToolCall) -> ToolResult {
467        let path = match call.arguments.get("path") {
468            Some(p) => self.resolve_path(p),
469            None => {
470                return ToolResult::failure("sed_replace", "Missing 'path' argument".to_string())
471            }
472        };
473
474        let pattern = match call.arguments.get("pattern") {
475            Some(p) => p,
476            None => {
477                return ToolResult::failure("sed_replace", "Missing 'pattern' argument".to_string())
478            }
479        };
480
481        let replacement = match call.arguments.get("replacement") {
482            Some(r) => r,
483            None => {
484                return ToolResult::failure(
485                    "sed_replace",
486                    "Missing 'replacement' argument".to_string(),
487                )
488            }
489        };
490
491        // Read file, perform replacement, write back
492        match fs::read_to_string(&path) {
493            Ok(content) => {
494                let new_content = content.replace(pattern, replacement);
495                match fs::write(&path, &new_content) {
496                    Ok(_) => ToolResult::success(
497                        "sed_replace",
498                        format!(
499                            "Replaced '{}' with '{}' in {:?}",
500                            pattern, replacement, path
501                        ),
502                    ),
503                    Err(e) => ToolResult::failure("sed_replace", format!("Failed to write: {}", e)),
504                }
505            }
506            Err(e) => {
507                ToolResult::failure("sed_replace", format!("Failed to read {:?}: {}", path, e))
508            }
509        }
510    }
511
512    /// Filter file content using awk-like field selection
513    fn awk_filter(&self, call: &ToolCall) -> ToolResult {
514        let path = match call.arguments.get("path") {
515            Some(p) => self.resolve_path(p),
516            None => {
517                return ToolResult::failure("awk_filter", "Missing 'path' argument".to_string())
518            }
519        };
520
521        let filter = match call.arguments.get("filter") {
522            Some(f) => f,
523            None => {
524                return ToolResult::failure("awk_filter", "Missing 'filter' argument".to_string())
525            }
526        };
527
528        // Use awk command for filtering
529        let output = Command::new("awk").arg(filter).arg(&path).output();
530
531        match output {
532            Ok(out) => {
533                if out.status.success() {
534                    ToolResult::success(
535                        "awk_filter",
536                        String::from_utf8_lossy(&out.stdout).to_string(),
537                    )
538                } else {
539                    ToolResult::failure(
540                        "awk_filter",
541                        String::from_utf8_lossy(&out.stderr).to_string(),
542                    )
543                }
544            }
545            Err(e) => ToolResult::failure("awk_filter", format!("Failed to run awk: {}", e)),
546        }
547    }
548
549    /// Show differences between two files
550    fn diff_files(&self, call: &ToolCall) -> ToolResult {
551        let file1 = match call.arguments.get("file1") {
552            Some(p) => self.resolve_path(p),
553            None => {
554                return ToolResult::failure("diff_files", "Missing 'file1' argument".to_string())
555            }
556        };
557
558        let file2 = match call.arguments.get("file2") {
559            Some(p) => self.resolve_path(p),
560            None => {
561                return ToolResult::failure("diff_files", "Missing 'file2' argument".to_string())
562            }
563        };
564
565        // Use diff command
566        let output = Command::new("diff")
567            .args([
568                "--unified",
569                &file1.to_string_lossy(),
570                &file2.to_string_lossy(),
571            ])
572            .output();
573
574        match output {
575            Ok(out) => {
576                // diff exits with 0 if files are same, 1 if different, 2 if error
577                let stdout = String::from_utf8_lossy(&out.stdout).to_string();
578                if stdout.is_empty() {
579                    ToolResult::success("diff_files", "Files are identical".to_string())
580                } else {
581                    ToolResult::success("diff_files", stdout)
582                }
583            }
584            Err(e) => ToolResult::failure("diff_files", format!("Failed to run diff: {}", e)),
585        }
586    }
587}
588
589/// Get tool definitions for LLM function calling
590pub fn get_tool_definitions() -> Vec<ToolDefinition> {
591    vec![
592        ToolDefinition {
593            name: "read_file".to_string(),
594            description: "Read the contents of a file".to_string(),
595            parameters: vec![ToolParameter {
596                name: "path".to_string(),
597                description: "Path to the file to read".to_string(),
598                required: true,
599            }],
600        },
601        ToolDefinition {
602            name: "search_code".to_string(),
603            description: "Search for code patterns in the workspace using grep/ripgrep".to_string(),
604            parameters: vec![
605                ToolParameter {
606                    name: "query".to_string(),
607                    description: "Search pattern (regex supported)".to_string(),
608                    required: true,
609                },
610                ToolParameter {
611                    name: "path".to_string(),
612                    description: "Directory to search in (default: working directory)".to_string(),
613                    required: false,
614                },
615            ],
616        },
617        ToolDefinition {
618            name: "apply_patch".to_string(),
619            description: "Write or replace file contents".to_string(),
620            parameters: vec![
621                ToolParameter {
622                    name: "path".to_string(),
623                    description: "Path to the file to write".to_string(),
624                    required: true,
625                },
626                ToolParameter {
627                    name: "content".to_string(),
628                    description: "New file contents".to_string(),
629                    required: true,
630                },
631            ],
632        },
633        ToolDefinition {
634            name: "apply_diff".to_string(),
635            description: "Apply a Unified Diff patch to a file".to_string(),
636            parameters: vec![
637                ToolParameter {
638                    name: "path".to_string(),
639                    description: "Path to the file to patch".to_string(),
640                    required: true,
641                },
642                ToolParameter {
643                    name: "diff".to_string(),
644                    description: "Unified Diff content".to_string(),
645                    required: true,
646                },
647            ],
648        },
649        ToolDefinition {
650            name: "run_command".to_string(),
651            description: "Execute a shell command in the working directory".to_string(),
652            parameters: vec![ToolParameter {
653                name: "command".to_string(),
654                description: "Shell command to execute".to_string(),
655                required: true,
656            }],
657        },
658        ToolDefinition {
659            name: "list_files".to_string(),
660            description: "List files in a directory".to_string(),
661            parameters: vec![ToolParameter {
662                name: "path".to_string(),
663                description: "Directory path (default: working directory)".to_string(),
664                required: false,
665            }],
666        },
667        // Power Tools
668        ToolDefinition {
669            name: "sed_replace".to_string(),
670            description: "Replace text in a file using sed-like pattern matching".to_string(),
671            parameters: vec![
672                ToolParameter {
673                    name: "path".to_string(),
674                    description: "Path to the file".to_string(),
675                    required: true,
676                },
677                ToolParameter {
678                    name: "pattern".to_string(),
679                    description: "Search pattern".to_string(),
680                    required: true,
681                },
682                ToolParameter {
683                    name: "replacement".to_string(),
684                    description: "Replacement text".to_string(),
685                    required: true,
686                },
687            ],
688        },
689        ToolDefinition {
690            name: "awk_filter".to_string(),
691            description: "Filter file content using awk-like field selection".to_string(),
692            parameters: vec![
693                ToolParameter {
694                    name: "path".to_string(),
695                    description: "Path to the file".to_string(),
696                    required: true,
697                },
698                ToolParameter {
699                    name: "filter".to_string(),
700                    description: "Awk filter expression (e.g., '$1 == \"error\"')".to_string(),
701                    required: true,
702                },
703            ],
704        },
705        ToolDefinition {
706            name: "diff_files".to_string(),
707            description: "Show differences between two files".to_string(),
708            parameters: vec![
709                ToolParameter {
710                    name: "file1".to_string(),
711                    description: "First file path".to_string(),
712                    required: true,
713                },
714                ToolParameter {
715                    name: "file2".to_string(),
716                    description: "Second file path".to_string(),
717                    required: true,
718                },
719            ],
720        },
721    ]
722}
723
724/// Tool definition for LLM function calling
725#[derive(Debug, Clone)]
726pub struct ToolDefinition {
727    pub name: String,
728    pub description: String,
729    pub parameters: Vec<ToolParameter>,
730}
731
732/// Tool parameter definition
733#[derive(Debug, Clone)]
734pub struct ToolParameter {
735    pub name: String,
736    pub description: String,
737    pub required: bool,
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use std::env::temp_dir;
744
745    #[tokio::test]
746    async fn test_read_file() {
747        let dir = temp_dir();
748        let test_file = dir.join("test_read.txt");
749        fs::write(&test_file, "Hello, World!").unwrap();
750
751        let tools = AgentTools::new(dir.clone(), false);
752        let call = ToolCall {
753            name: "read_file".to_string(),
754            arguments: [("path".to_string(), test_file.to_string_lossy().to_string())]
755                .into_iter()
756                .collect(),
757        };
758
759        let result = tools.execute(&call).await;
760        assert!(result.success);
761        assert_eq!(result.output, "Hello, World!");
762    }
763
764    #[tokio::test]
765    async fn test_list_files() {
766        let dir = temp_dir();
767        let tools = AgentTools::new(dir.clone(), false);
768        let call = ToolCall {
769            name: "list_files".to_string(),
770            arguments: HashMap::new(),
771        };
772
773        let result = tools.execute(&call).await;
774        assert!(result.success);
775    }
776
777    #[tokio::test]
778    async fn test_apply_diff_tool() {
779        use std::collections::HashMap;
780        use std::io::Write;
781        let temp_dir = temp_dir();
782        let file_path = temp_dir.join("test_diff.txt");
783        let mut file = std::fs::File::create(&file_path).unwrap();
784        // Explicitly write bytes with unix newlines
785        file.write_all(b"Hello world\nThis is a test\n").unwrap();
786
787        let tools = AgentTools::new(temp_dir.clone(), true);
788
789        // Exact string with newlines
790        let diff = "--- test_diff.txt\n+++ test_diff.txt\n@@ -1,2 +1,2 @@\n-Hello world\n+Hello diffy\n This is a test\n";
791
792        let mut args = HashMap::new();
793        args.insert("path".to_string(), "test_diff.txt".to_string());
794        args.insert("diff".to_string(), diff.to_string());
795
796        let call = ToolCall {
797            name: "apply_diff".to_string(),
798            arguments: args,
799        };
800
801        let result = tools.apply_diff(&call);
802        assert!(
803            result.success,
804            "Diff application failed: {:?}",
805            result.error
806        );
807
808        let content = fs::read_to_string(&file_path).unwrap();
809        assert_eq!(content, "Hello diffy\nThis is a test\n");
810    }
811
812    #[tokio::test]
813    async fn test_delete_file() {
814        let dir = temp_dir();
815        let test_file = dir.join("test_delete_me.txt");
816        fs::write(&test_file, "temporary").unwrap();
817        assert!(test_file.exists());
818
819        let tools = AgentTools::new(dir.clone(), false);
820        let mut args = HashMap::new();
821        args.insert("path".to_string(), test_file.to_string_lossy().to_string());
822        let call = ToolCall {
823            name: "delete_file".to_string(),
824            arguments: args,
825        };
826        let result = tools.execute(&call).await;
827        assert!(result.success, "Delete should succeed: {:?}", result.error);
828        assert!(!test_file.exists(), "File should be gone");
829    }
830
831    #[tokio::test]
832    async fn test_delete_nonexistent_file_succeeds() {
833        let dir = temp_dir();
834        let tools = AgentTools::new(dir.clone(), false);
835        let mut args = HashMap::new();
836        args.insert(
837            "path".to_string(),
838            "/tmp/does_not_exist_xyz.txt".to_string(),
839        );
840        let call = ToolCall {
841            name: "delete_file".to_string(),
842            arguments: args,
843        };
844        let result = tools.execute(&call).await;
845        assert!(result.success);
846    }
847
848    #[tokio::test]
849    async fn test_move_file() {
850        let dir = temp_dir();
851        let src = dir.join("test_move_src.txt");
852        let dst = dir.join("test_move_dst.txt");
853        fs::write(&src, "move me").unwrap();
854
855        let tools = AgentTools::new(dir.clone(), false);
856        let mut args = HashMap::new();
857        args.insert("from".to_string(), src.to_string_lossy().to_string());
858        args.insert("to".to_string(), dst.to_string_lossy().to_string());
859        // move_file also needs "path" in args (set by bundle handler)
860        args.insert("path".to_string(), src.to_string_lossy().to_string());
861        let call = ToolCall {
862            name: "move_file".to_string(),
863            arguments: args,
864        };
865        let result = tools.execute(&call).await;
866        assert!(result.success, "Move should succeed: {:?}", result.error);
867        assert!(!src.exists(), "Source should be gone");
868        assert!(dst.exists(), "Destination should exist");
869        assert_eq!(fs::read_to_string(&dst).unwrap(), "move me");
870        let _ = fs::remove_file(&dst);
871    }
872
873    #[tokio::test]
874    async fn test_delete_directory_rejected() {
875        let dir = temp_dir().join("test_delete_dir");
876        fs::create_dir_all(&dir).unwrap();
877
878        let tools = AgentTools::new(temp_dir(), false);
879        let mut args = HashMap::new();
880        args.insert("path".to_string(), dir.to_string_lossy().to_string());
881        let call = ToolCall {
882            name: "delete_file".to_string(),
883            arguments: args,
884        };
885        let result = tools.execute(&call).await;
886        assert!(!result.success, "Should reject directory deletion");
887        let _ = fs::remove_dir(&dir);
888    }
889
890    #[tokio::test]
891    async fn test_move_file_creates_parent_dirs() {
892        let dir = temp_dir();
893        let src = dir.join("test_move_nested_src.txt");
894        let dst = dir
895            .join("nested")
896            .join("deep")
897            .join("test_move_nested_dst.txt");
898        fs::write(&src, "nested move").unwrap();
899
900        let tools = AgentTools::new(dir.clone(), false);
901        let mut args = HashMap::new();
902        args.insert("from".to_string(), src.to_string_lossy().to_string());
903        args.insert("to".to_string(), dst.to_string_lossy().to_string());
904        args.insert("path".to_string(), src.to_string_lossy().to_string());
905        let call = ToolCall {
906            name: "move_file".to_string(),
907            arguments: args,
908        };
909        let result = tools.execute(&call).await;
910        assert!(
911            result.success,
912            "Move with nested dirs should succeed: {:?}",
913            result.error
914        );
915        assert!(!src.exists());
916        assert!(dst.exists());
917        assert_eq!(fs::read_to_string(&dst).unwrap(), "nested move");
918        let _ = fs::remove_dir_all(dir.join("nested"));
919    }
920}
921
922// =============================================================================
923// PSP-5 Phase 6: Sandbox workspace helpers
924// =============================================================================
925
926/// Create a sandbox workspace for provisional verification.
927///
928/// Copies key project files into a session-scoped temporary directory so
929/// speculative verification does not pollute committed workspace state.
930/// Returns the path to the sandbox root.
931pub fn create_sandbox(
932    working_dir: &Path,
933    session_id: &str,
934    branch_id: &str,
935) -> std::io::Result<PathBuf> {
936    let sandbox_root = working_dir
937        .join(".perspt")
938        .join("sandboxes")
939        .join(session_id)
940        .join(branch_id);
941
942    fs::create_dir_all(&sandbox_root)?;
943
944    log::debug!("Created sandbox workspace at {}", sandbox_root.display());
945
946    Ok(sandbox_root)
947}
948
949/// Seed a sandbox with plugin-identified project manifests (Cargo.toml,
950/// pyproject.toml, etc.) so that build/test commands can find them.
951///
952/// Walks the workspace looking for each plugin's `key_files()` and copies
953/// any that exist into the sandbox at the same relative path.
954pub fn seed_sandbox_manifests(
955    working_dir: &Path,
956    sandbox_dir: &Path,
957    plugins: &[&str],
958) -> std::io::Result<()> {
959    let registry = perspt_core::plugin::PluginRegistry::new();
960    let mut seeded = Vec::new();
961
962    for plugin_name in plugins {
963        if let Some(plugin) = registry.get(plugin_name) {
964            for key_file in plugin.key_files() {
965                // Check workspace root
966                if working_dir.join(key_file).exists() {
967                    copy_to_sandbox(working_dir, sandbox_dir, key_file)?;
968                    seeded.push(key_file.to_string());
969                }
970                // Also walk up to two levels of subdirectories
971                // (e.g. crates/*/Cargo.toml, packages/*/package.json)
972                if let Ok(entries) = fs::read_dir(working_dir) {
973                    for entry in entries.flatten() {
974                        let path = entry.path();
975                        if path.is_dir() && path.file_name().is_none_or(|n| n != ".perspt") {
976                            // Level 1: e.g. crates/Cargo.toml (unlikely but check)
977                            let sub_key = path.join(key_file);
978                            if sub_key.exists() {
979                                let rel = sub_key
980                                    .strip_prefix(working_dir)
981                                    .unwrap_or(&sub_key)
982                                    .to_string_lossy()
983                                    .to_string();
984                                let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
985                                seeded.push(rel);
986                            }
987                            // Level 2: e.g. crates/cfd-core/Cargo.toml
988                            if let Ok(sub_entries) = fs::read_dir(&path) {
989                                for sub_entry in sub_entries.flatten() {
990                                    let sub_path = sub_entry.path();
991                                    if sub_path.is_dir() {
992                                        let deep_key = sub_path.join(key_file);
993                                        if deep_key.exists() {
994                                            let rel = deep_key
995                                                .strip_prefix(working_dir)
996                                                .unwrap_or(&deep_key)
997                                                .to_string_lossy()
998                                                .to_string();
999                                            let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
1000                                            seeded.push(rel);
1001                                        }
1002                                    }
1003                                }
1004                            }
1005                        }
1006                    }
1007                }
1008            }
1009        }
1010    }
1011
1012    if !seeded.is_empty() {
1013        log::debug!("Seeded sandbox with manifests: {}", seeded.join(", "));
1014    }
1015
1016    // For Rust workspaces: ensure every workspace member in the sandbox has
1017    // at minimum a valid Cargo.toml + source target, so commands like
1018    // `cargo add -p <crate>` can resolve the workspace graph.
1019    if plugins.contains(&"rust") {
1020        ensure_rust_workspace_members_in_sandbox(working_dir, sandbox_dir);
1021    }
1022
1023    // For Python projects: symlink .venv and seed src/<pkg>/ so uv run
1024    // commands work immediately in the sandbox.
1025    if plugins.contains(&"python") {
1026        seed_python_sandbox(working_dir, sandbox_dir);
1027    }
1028
1029    Ok(())
1030}
1031
1032/// Ensure all Cargo workspace members in a sandbox have valid Cargo.toml +
1033/// source target stubs.  Without this, `cargo add -p X` (or any cargo
1034/// command) fails with "failed to load manifest for workspace member Y"
1035/// because the sandbox only gets the current node's files but the root
1036/// Cargo.toml references ALL members.
1037fn ensure_rust_workspace_members_in_sandbox(working_dir: &Path, sandbox_dir: &Path) {
1038    let cargo_toml = sandbox_dir.join("Cargo.toml");
1039    let content = match fs::read_to_string(&cargo_toml) {
1040        Ok(c) => c,
1041        Err(_) => return,
1042    };
1043
1044    // Quick parse: extract [workspace] members
1045    let mut in_workspace = false;
1046    let mut members: Vec<String> = Vec::new();
1047    for raw_line in content.lines() {
1048        let line = raw_line.trim();
1049        if line.starts_with('[') {
1050            in_workspace = line == "[workspace]";
1051            continue;
1052        }
1053        if in_workspace && line.starts_with("members") {
1054            if let Some((_, value)) = line.split_once('=') {
1055                let raw = value.trim();
1056                if raw.starts_with('[') {
1057                    let inner = raw.trim_start_matches('[').trim_end_matches(']');
1058                    for item in inner.split(',') {
1059                        let member = item.trim().trim_matches('"').trim_matches('\'');
1060                        if !member.is_empty() {
1061                            members.push(member.to_string());
1062                        }
1063                    }
1064                }
1065            }
1066        }
1067    }
1068
1069    for member in &members {
1070        let member_dir = sandbox_dir.join(member);
1071        let member_cargo = member_dir.join("Cargo.toml");
1072
1073        // Try to copy from main workspace first (preserves any real content)
1074        let src_cargo = working_dir.join(member).join("Cargo.toml");
1075        if src_cargo.exists() && !member_cargo.exists() {
1076            let _ = fs::create_dir_all(&member_dir);
1077            let _ = fs::copy(&src_cargo, &member_cargo);
1078        }
1079
1080        // Create a stub Cargo.toml if still missing
1081        if !member_cargo.exists() {
1082            let _ = fs::create_dir_all(&member_dir);
1083            let name = member.rsplit('/').next().unwrap_or(member);
1084            let stub = format!(
1085                "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1086                name
1087            );
1088            let _ = fs::write(&member_cargo, &stub);
1089        }
1090
1091        // Ensure at least one source target exists (src/lib.rs or src/main.rs)
1092        let src_dir = member_dir.join("src");
1093        let has_lib = src_dir.join("lib.rs").exists();
1094        let has_main = src_dir.join("main.rs").exists();
1095        if !has_lib && !has_main {
1096            let _ = fs::create_dir_all(&src_dir);
1097            // Try copying from main workspace
1098            let ws_lib = working_dir.join(member).join("src").join("lib.rs");
1099            let ws_main = working_dir.join(member).join("src").join("main.rs");
1100            if ws_lib.exists() {
1101                let _ = fs::copy(&ws_lib, src_dir.join("lib.rs"));
1102            } else if ws_main.exists() {
1103                let _ = fs::copy(&ws_main, src_dir.join("main.rs"));
1104            } else {
1105                // Create minimal stub so cargo doesn't complain about missing targets
1106                let _ = fs::write(
1107                    src_dir.join("lib.rs"),
1108                    "// stub — will be replaced by agent\n",
1109                );
1110            }
1111        }
1112    }
1113
1114    if !members.is_empty() {
1115        log::debug!(
1116            "Ensured {} workspace member(s) have valid stubs in sandbox",
1117            members.len()
1118        );
1119    }
1120}
1121
1122/// Seed a Python project sandbox with the workspace `.venv/` (via symlink)
1123/// and the `src/<pkg>/` package directory tree so that `uv run` commands
1124/// work immediately without a full re-sync.
1125fn seed_python_sandbox(working_dir: &Path, sandbox_dir: &Path) {
1126    // Symlink .venv/ so uv run reuses the workspace venv instead of
1127    // recreating one per sandbox (saves ~2-3s per node).
1128    let workspace_venv = working_dir.join(".venv");
1129    let sandbox_venv = sandbox_dir.join(".venv");
1130    if workspace_venv.is_dir() && !sandbox_venv.exists() {
1131        #[cfg(unix)]
1132        {
1133            if let Err(e) = std::os::unix::fs::symlink(&workspace_venv, &sandbox_venv) {
1134                log::debug!("Could not symlink .venv into sandbox: {}", e);
1135            } else {
1136                log::debug!("Symlinked .venv into sandbox");
1137            }
1138        }
1139        #[cfg(not(unix))]
1140        {
1141            // On Windows, symlinks require elevated privileges; skip the
1142            // optimisation — uv will auto-create a venv when needed.
1143            log::debug!("Skipping .venv symlink on non-Unix platform");
1144        }
1145    }
1146
1147    // Seed ancillary files that `uv add` / `uv sync` need when building
1148    // the project inside the sandbox.  In particular, `uv init` generates
1149    // `readme = "README.md"` in pyproject.toml, so the sandbox build fails
1150    // with "failed to open file README.md" if we don't copy it.
1151    for ancillary in &["README.md", "README.rst", "README", ".python-version"] {
1152        let src = working_dir.join(ancillary);
1153        if src.is_file() {
1154            let dst = sandbox_dir.join(ancillary);
1155            if !dst.exists() {
1156                let _ = fs::copy(&src, &dst);
1157            }
1158        }
1159    }
1160
1161    // Copy the src/<pkg>/ directory tree so imports resolve.  We walk one
1162    // level under src/ looking for Python packages (__init__.py present).
1163    let workspace_src = working_dir.join("src");
1164    if workspace_src.is_dir() {
1165        if let Ok(entries) = fs::read_dir(&workspace_src) {
1166            for entry in entries.flatten() {
1167                let pkg_dir = entry.path();
1168                if pkg_dir.is_dir() && pkg_dir.join("__init__.py").exists() {
1169                    // Recursively copy all .py files from this package
1170                    if let Err(e) = copy_dir_to_sandbox(working_dir, sandbox_dir, &pkg_dir) {
1171                        log::debug!(
1172                            "Could not seed src/{} into sandbox: {}",
1173                            entry.file_name().to_string_lossy(),
1174                            e
1175                        );
1176                    }
1177                }
1178            }
1179        }
1180    }
1181
1182    // Also copy conftest.py / tests/ directory if present (needed for pytest)
1183    for extra in &["conftest.py", "tests"] {
1184        let src = working_dir.join(extra);
1185        if src.is_file() {
1186            let rel = extra.to_string();
1187            let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
1188        } else if src.is_dir() {
1189            let _ = copy_dir_to_sandbox(working_dir, sandbox_dir, &src);
1190        }
1191    }
1192}
1193
1194/// Recursively copy a directory from workspace into sandbox, preserving
1195/// relative paths.  Skips `.venv`, `__pycache__`, and bytecode files.
1196fn copy_dir_to_sandbox(
1197    working_dir: &Path,
1198    sandbox_dir: &Path,
1199    src_dir: &Path,
1200) -> std::io::Result<()> {
1201    const SKIP: &[&str] = &[".venv", "__pycache__", ".mypy_cache", ".pytest_cache"];
1202    for entry in fs::read_dir(src_dir)? {
1203        let entry = entry?;
1204        let path = entry.path();
1205        let name = entry.file_name();
1206        let name_str = name.to_string_lossy();
1207
1208        if path.is_dir() {
1209            if SKIP.iter().any(|s| *s == &*name_str) {
1210                continue;
1211            }
1212            copy_dir_to_sandbox(working_dir, sandbox_dir, &path)?;
1213        } else if !name_str.ends_with(".pyc") {
1214            if let Ok(rel) = path.strip_prefix(working_dir) {
1215                let rel_str = rel.to_string_lossy().to_string();
1216                copy_to_sandbox(working_dir, sandbox_dir, &rel_str)?;
1217            }
1218        }
1219    }
1220    Ok(())
1221}
1222
1223/// Clean up a specific sandbox workspace.
1224pub fn cleanup_sandbox(sandbox_dir: &Path) -> std::io::Result<()> {
1225    if sandbox_dir.exists() {
1226        fs::remove_dir_all(sandbox_dir)?;
1227        log::debug!("Cleaned up sandbox at {}", sandbox_dir.display());
1228    }
1229    Ok(())
1230}
1231
1232/// Clean up all sandbox workspaces for a session.
1233pub fn cleanup_session_sandboxes(working_dir: &Path, session_id: &str) -> std::io::Result<()> {
1234    let session_sandbox = working_dir
1235        .join(".perspt")
1236        .join("sandboxes")
1237        .join(session_id);
1238
1239    if session_sandbox.exists() {
1240        fs::remove_dir_all(&session_sandbox)?;
1241        log::debug!("Cleaned up all sandboxes for session {}", session_id);
1242    }
1243    Ok(())
1244}
1245
1246/// Copy a file from the workspace into a sandbox, preserving relative paths.
1247pub fn copy_to_sandbox(
1248    working_dir: &Path,
1249    sandbox_dir: &Path,
1250    relative_path: &str,
1251) -> std::io::Result<()> {
1252    let src = working_dir.join(relative_path);
1253    let dst = sandbox_dir.join(relative_path);
1254
1255    if let Some(parent) = dst.parent() {
1256        fs::create_dir_all(parent)?;
1257    }
1258
1259    if src.exists() {
1260        fs::copy(&src, &dst)?;
1261    }
1262    Ok(())
1263}
1264
1265/// Copy a file from a sandbox back to the live workspace, preserving relative paths.
1266pub fn copy_from_sandbox(
1267    sandbox_dir: &Path,
1268    working_dir: &Path,
1269    relative_path: &str,
1270) -> std::io::Result<()> {
1271    let src = sandbox_dir.join(relative_path);
1272    let dst = working_dir.join(relative_path);
1273
1274    if let Some(parent) = dst.parent() {
1275        fs::create_dir_all(parent)?;
1276    }
1277
1278    if src.exists() {
1279        fs::copy(&src, &dst)?;
1280    }
1281    Ok(())
1282}
1283
1284/// List all files in a sandbox directory as workspace-relative paths.
1285pub fn list_sandbox_files(sandbox_dir: &Path) -> std::io::Result<Vec<String>> {
1286    let mut files = Vec::new();
1287    if !sandbox_dir.exists() {
1288        return Ok(files);
1289    }
1290    /// Directories that should never be exported from sandbox back to
1291    /// workspace — virtual-environments, bytecode caches, build artifacts.
1292    const SKIP_DIRS: &[&str] = &[
1293        ".venv",
1294        "__pycache__",
1295        "node_modules",
1296        ".mypy_cache",
1297        ".pytest_cache",
1298        ".ruff_cache",
1299    ];
1300    fn walk(dir: &Path, base: &Path, out: &mut Vec<String>) -> std::io::Result<()> {
1301        for entry in fs::read_dir(dir)? {
1302            let entry = entry?;
1303            let path = entry.path();
1304            if path.is_dir() {
1305                let name = entry.file_name();
1306                let name_str = name.to_string_lossy();
1307                if SKIP_DIRS.iter().any(|s| *s == &*name_str) {
1308                    continue;
1309                }
1310                walk(&path, base, out)?;
1311            } else if let Ok(rel) = path.strip_prefix(base) {
1312                let normalized = rel
1313                    .components()
1314                    .map(|component| component.as_os_str().to_string_lossy().into_owned())
1315                    .collect::<Vec<_>>()
1316                    .join("/");
1317                // Skip bytecode / lock artifacts that shouldn't transfer
1318                if !normalized.ends_with(".pyc") {
1319                    out.push(normalized);
1320                }
1321            }
1322        }
1323        Ok(())
1324    }
1325    walk(sandbox_dir, sandbox_dir, &mut files)?;
1326    Ok(files)
1327}
1328
1329#[cfg(test)]
1330mod sandbox_tests {
1331    use super::*;
1332    use tempfile::tempdir;
1333
1334    #[test]
1335    fn test_create_sandbox() {
1336        let dir = tempdir().unwrap();
1337        let sandbox = create_sandbox(dir.path(), "sess1", "branch1").unwrap();
1338        assert!(sandbox.exists());
1339        assert!(sandbox.ends_with("sess1/branch1"));
1340    }
1341
1342    #[test]
1343    fn test_cleanup_sandbox() {
1344        let dir = tempdir().unwrap();
1345        let sandbox = create_sandbox(dir.path(), "sess1", "branch1").unwrap();
1346        assert!(sandbox.exists());
1347        cleanup_sandbox(&sandbox).unwrap();
1348        assert!(!sandbox.exists());
1349    }
1350
1351    #[test]
1352    fn test_cleanup_session_sandboxes() {
1353        let dir = tempdir().unwrap();
1354        create_sandbox(dir.path(), "sess1", "b1").unwrap();
1355        create_sandbox(dir.path(), "sess1", "b2").unwrap();
1356        let session_dir = dir.path().join(".perspt").join("sandboxes").join("sess1");
1357        assert!(session_dir.exists());
1358        cleanup_session_sandboxes(dir.path(), "sess1").unwrap();
1359        assert!(!session_dir.exists());
1360    }
1361
1362    #[test]
1363    fn test_copy_to_sandbox() {
1364        let dir = tempdir().unwrap();
1365        // Create a source file
1366        let src_dir = dir.path().join("src");
1367        fs::create_dir_all(&src_dir).unwrap();
1368        fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
1369
1370        let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
1371        copy_to_sandbox(dir.path(), &sandbox, "src/main.rs").unwrap();
1372
1373        let copied = sandbox.join("src/main.rs");
1374        assert!(copied.exists());
1375        assert_eq!(fs::read_to_string(copied).unwrap(), "fn main() {}");
1376    }
1377
1378    #[test]
1379    fn test_copy_from_sandbox() {
1380        let dir = tempdir().unwrap();
1381        let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
1382
1383        // Create a file inside the sandbox
1384        let sandbox_src = sandbox.join("out");
1385        fs::create_dir_all(&sandbox_src).unwrap();
1386        fs::write(sandbox_src.join("result.txt"), "hello").unwrap();
1387
1388        // Copy back to live workspace
1389        copy_from_sandbox(&sandbox, dir.path(), "out/result.txt").unwrap();
1390
1391        let dest = dir.path().join("out/result.txt");
1392        assert!(dest.exists());
1393        assert_eq!(fs::read_to_string(dest).unwrap(), "hello");
1394    }
1395
1396    #[test]
1397    fn test_list_sandbox_files_empty() {
1398        let dir = tempdir().unwrap();
1399        let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
1400        let files = list_sandbox_files(&sandbox).unwrap();
1401        assert!(files.is_empty());
1402    }
1403
1404    #[test]
1405    fn test_list_sandbox_files_nested() {
1406        let dir = tempdir().unwrap();
1407        let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
1408
1409        // Create nested structure
1410        let nested = sandbox.join("a/b");
1411        fs::create_dir_all(&nested).unwrap();
1412        fs::write(sandbox.join("top.txt"), "x").unwrap();
1413        fs::write(nested.join("deep.txt"), "y").unwrap();
1414
1415        let mut files = list_sandbox_files(&sandbox).unwrap();
1416        files.sort();
1417        assert_eq!(files, vec!["a/b/deep.txt", "top.txt"]);
1418    }
1419}