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            // Power Tools (OS-level)
86            "sed_replace" => self.sed_replace(call),
87            "awk_filter" => self.awk_filter(call),
88            "diff_files" => self.diff_files(call),
89            _ => ToolResult::failure(&call.name, format!("Unknown tool: {}", call.name)),
90        }
91    }
92
93    /// Read a file's contents
94    fn read_file(&self, call: &ToolCall) -> ToolResult {
95        let path = match call.arguments.get("path") {
96            Some(p) => self.resolve_path(p),
97            None => return ToolResult::failure("read_file", "Missing 'path' argument".to_string()),
98        };
99
100        match fs::read_to_string(&path) {
101            Ok(content) => ToolResult::success("read_file", content),
102            Err(e) => ToolResult::failure("read_file", format!("Failed to read {:?}: {}", path, e)),
103        }
104    }
105
106    /// Search for code patterns using grep
107    fn search_code(&self, call: &ToolCall) -> ToolResult {
108        let query = match call.arguments.get("query") {
109            Some(q) => q,
110            None => {
111                return ToolResult::failure("search_code", "Missing 'query' argument".to_string())
112            }
113        };
114
115        let path = call
116            .arguments
117            .get("path")
118            .map(|p| self.resolve_path(p))
119            .unwrap_or_else(|| self.working_dir.clone());
120
121        // Use ripgrep if available, fallback to grep
122        let output = Command::new("rg")
123            .args(["--json", "-n", query])
124            .current_dir(&path)
125            .output()
126            .or_else(|_| {
127                Command::new("grep")
128                    .args(["-rn", query, "."])
129                    .current_dir(&path)
130                    .output()
131            });
132
133        match output {
134            Ok(out) => {
135                let stdout = String::from_utf8_lossy(&out.stdout).to_string();
136                ToolResult::success("search_code", stdout)
137            }
138            Err(e) => ToolResult::failure("search_code", format!("Search failed: {}", e)),
139        }
140    }
141
142    /// Apply a patch to a file
143    fn apply_patch(&self, call: &ToolCall) -> ToolResult {
144        let path = match call.arguments.get("path") {
145            Some(p) => self.resolve_path(p),
146            None => {
147                return ToolResult::failure("apply_patch", "Missing 'path' argument".to_string())
148            }
149        };
150
151        let content = match call.arguments.get("content") {
152            Some(c) => c,
153            None => {
154                return ToolResult::failure("apply_patch", "Missing 'content' argument".to_string())
155            }
156        };
157
158        // Create parent directories if needed
159        if let Some(parent) = path.parent() {
160            if let Err(e) = fs::create_dir_all(parent) {
161                return ToolResult::failure(
162                    "apply_patch",
163                    format!("Failed to create directories: {}", e),
164                );
165            }
166        }
167
168        match fs::write(&path, content) {
169            Ok(_) => ToolResult::success("apply_patch", format!("Successfully wrote {:?}", path)),
170            Err(e) => {
171                ToolResult::failure("apply_patch", format!("Failed to write {:?}: {}", path, e))
172            }
173        }
174    }
175
176    /// Apply a unified diff patch to a file
177    fn apply_diff(&self, call: &ToolCall) -> ToolResult {
178        let path = match call.arguments.get("path") {
179            Some(p) => self.resolve_path(p),
180            None => {
181                return ToolResult::failure("apply_diff", "Missing 'path' argument".to_string())
182            }
183        };
184
185        let diff_content = match call.arguments.get("diff") {
186            Some(c) => c,
187            None => {
188                return ToolResult::failure("apply_diff", "Missing 'diff' argument".to_string())
189            }
190        };
191
192        // Read original file
193        let original = match fs::read_to_string(&path) {
194            Ok(c) => c,
195            Err(e) => {
196                // If file doesn't exist, we can't patch it.
197                // (Unless it's a new file creation patch, but diffy usually assumes base text)
198                return ToolResult::failure(
199                    "apply_diff",
200                    format!("Failed to read base file {:?}: {}", path, e),
201                );
202            }
203        };
204
205        // Parse patch
206        let patch = match Patch::from_str(diff_content) {
207            Ok(p) => p,
208            Err(e) => {
209                return ToolResult::failure("apply_diff", format!("Failed to parse diff: {}", e));
210            }
211        };
212
213        // Apply patch
214        match apply(&original, &patch) {
215            Ok(patched) => match fs::write(&path, patched) {
216                Ok(_) => {
217                    ToolResult::success("apply_diff", format!("Successfully patched {:?}", path))
218                }
219                Err(e) => ToolResult::failure(
220                    "apply_diff",
221                    format!("Failed to write patched file: {}", e),
222                ),
223            },
224            Err(e) => ToolResult::failure("apply_diff", format!("Failed to apply patch: {}", e)),
225        }
226    }
227
228    /// Run a shell command (requires approval unless auto-approve is set)
229    async fn run_command(&self, call: &ToolCall) -> ToolResult {
230        let cmd_str = match call.arguments.get("command") {
231            Some(c) => c,
232            None => {
233                return ToolResult::failure("run_command", "Missing 'command' argument".to_string())
234            }
235        };
236
237        // PSP-5 Phase 4: Sanitize command through policy before execution
238        match perspt_policy::sanitize_command(cmd_str) {
239            Ok(sr) if sr.rejected => {
240                return ToolResult::failure(
241                    "run_command",
242                    format!(
243                        "Command rejected by policy: {}",
244                        sr.rejection_reason
245                            .unwrap_or_else(|| "unknown reason".to_string())
246                    ),
247                );
248            }
249            Ok(sr) => {
250                for warning in &sr.warnings {
251                    log::warn!("Command policy warning: {}", warning);
252                }
253            }
254            Err(e) => {
255                return ToolResult::failure(
256                    "run_command",
257                    format!("Command sanitization failed: {}", e),
258                );
259            }
260        }
261
262        // Validate workspace bounds
263        if let Err(e) = perspt_policy::validate_workspace_bound(cmd_str, &self.working_dir) {
264            return ToolResult::failure("run_command", format!("Command rejected: {}", e));
265        }
266
267        if self.require_approval {
268            log::info!("Command requires approval: {}", cmd_str);
269        }
270
271        let mut child = match AsyncCommand::new("sh")
272            .args(["-c", cmd_str])
273            .current_dir(&self.working_dir)
274            .stdout(Stdio::piped())
275            .stderr(Stdio::piped())
276            .spawn()
277        {
278            Ok(child) => child,
279            Err(e) => return ToolResult::failure("run_command", format!("Failed to spawn: {}", e)),
280        };
281
282        let stdout = child.stdout.take().expect("Failed to open stdout");
283        let stderr = child.stderr.take().expect("Failed to open stderr");
284        let sender = self.event_sender.clone();
285
286        let stdout_handle = tokio::spawn(async move {
287            let mut reader = BufReader::new(stdout).lines();
288            let mut output = String::new();
289            while let Ok(Some(line)) = reader.next_line().await {
290                if let Some(ref s) = sender {
291                    let _ = s.send(perspt_core::AgentEvent::Log(line.clone()));
292                }
293                output.push_str(&line);
294                output.push('\n');
295            }
296            output
297        });
298
299        let sender_err = self.event_sender.clone();
300        let stderr_handle = tokio::spawn(async move {
301            let mut reader = BufReader::new(stderr).lines();
302            let mut output = String::new();
303            while let Ok(Some(line)) = reader.next_line().await {
304                if let Some(ref s) = sender_err {
305                    let _ = s.send(perspt_core::AgentEvent::Log(format!("ERR: {}", line)));
306                }
307                output.push_str(&line);
308                output.push('\n');
309            }
310            output
311        });
312
313        let status = match child.wait().await {
314            Ok(s) => s,
315            Err(e) => return ToolResult::failure("run_command", format!("Failed to wait: {}", e)),
316        };
317
318        let stdout_str = stdout_handle.await.unwrap_or_default();
319        let stderr_str = stderr_handle.await.unwrap_or_default();
320
321        if status.success() {
322            ToolResult::success("run_command", stdout_str)
323        } else {
324            ToolResult::failure(
325                "run_command",
326                format!("Exit code: {:?}\n{}", status.code(), stderr_str),
327            )
328        }
329    }
330
331    /// List files in a directory
332    fn list_files(&self, call: &ToolCall) -> ToolResult {
333        let path = call
334            .arguments
335            .get("path")
336            .map(|p| self.resolve_path(p))
337            .unwrap_or_else(|| self.working_dir.clone());
338
339        match fs::read_dir(&path) {
340            Ok(entries) => {
341                let files: Vec<String> = entries
342                    .filter_map(|e| e.ok())
343                    .map(|e| {
344                        let name = e.file_name().to_string_lossy().to_string();
345                        if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
346                            format!("{}/", name)
347                        } else {
348                            name
349                        }
350                    })
351                    .collect();
352                ToolResult::success("list_files", files.join("\n"))
353            }
354            Err(e) => {
355                ToolResult::failure("list_files", format!("Failed to list {:?}: {}", path, e))
356            }
357        }
358    }
359
360    /// Write content to a file
361    fn write_file(&self, call: &ToolCall) -> ToolResult {
362        // Alias for apply_patch with different semantics
363        self.apply_patch(call)
364    }
365
366    /// Resolve a path relative to working directory
367    fn resolve_path(&self, path: &str) -> PathBuf {
368        let p = Path::new(path);
369        if p.is_absolute() {
370            p.to_path_buf()
371        } else {
372            self.working_dir.join(p)
373        }
374    }
375
376    // =========================================================================
377    // Power Tools (OS-level operations)
378    // =========================================================================
379
380    /// Replace text in a file using sed-like pattern matching
381    fn sed_replace(&self, call: &ToolCall) -> ToolResult {
382        let path = match call.arguments.get("path") {
383            Some(p) => self.resolve_path(p),
384            None => {
385                return ToolResult::failure("sed_replace", "Missing 'path' argument".to_string())
386            }
387        };
388
389        let pattern = match call.arguments.get("pattern") {
390            Some(p) => p,
391            None => {
392                return ToolResult::failure("sed_replace", "Missing 'pattern' argument".to_string())
393            }
394        };
395
396        let replacement = match call.arguments.get("replacement") {
397            Some(r) => r,
398            None => {
399                return ToolResult::failure(
400                    "sed_replace",
401                    "Missing 'replacement' argument".to_string(),
402                )
403            }
404        };
405
406        // Read file, perform replacement, write back
407        match fs::read_to_string(&path) {
408            Ok(content) => {
409                let new_content = content.replace(pattern, replacement);
410                match fs::write(&path, &new_content) {
411                    Ok(_) => ToolResult::success(
412                        "sed_replace",
413                        format!(
414                            "Replaced '{}' with '{}' in {:?}",
415                            pattern, replacement, path
416                        ),
417                    ),
418                    Err(e) => ToolResult::failure("sed_replace", format!("Failed to write: {}", e)),
419                }
420            }
421            Err(e) => {
422                ToolResult::failure("sed_replace", format!("Failed to read {:?}: {}", path, e))
423            }
424        }
425    }
426
427    /// Filter file content using awk-like field selection
428    fn awk_filter(&self, call: &ToolCall) -> ToolResult {
429        let path = match call.arguments.get("path") {
430            Some(p) => self.resolve_path(p),
431            None => {
432                return ToolResult::failure("awk_filter", "Missing 'path' argument".to_string())
433            }
434        };
435
436        let filter = match call.arguments.get("filter") {
437            Some(f) => f,
438            None => {
439                return ToolResult::failure("awk_filter", "Missing 'filter' argument".to_string())
440            }
441        };
442
443        // Use awk command for filtering
444        let output = Command::new("awk").arg(filter).arg(&path).output();
445
446        match output {
447            Ok(out) => {
448                if out.status.success() {
449                    ToolResult::success(
450                        "awk_filter",
451                        String::from_utf8_lossy(&out.stdout).to_string(),
452                    )
453                } else {
454                    ToolResult::failure(
455                        "awk_filter",
456                        String::from_utf8_lossy(&out.stderr).to_string(),
457                    )
458                }
459            }
460            Err(e) => ToolResult::failure("awk_filter", format!("Failed to run awk: {}", e)),
461        }
462    }
463
464    /// Show differences between two files
465    fn diff_files(&self, call: &ToolCall) -> ToolResult {
466        let file1 = match call.arguments.get("file1") {
467            Some(p) => self.resolve_path(p),
468            None => {
469                return ToolResult::failure("diff_files", "Missing 'file1' argument".to_string())
470            }
471        };
472
473        let file2 = match call.arguments.get("file2") {
474            Some(p) => self.resolve_path(p),
475            None => {
476                return ToolResult::failure("diff_files", "Missing 'file2' argument".to_string())
477            }
478        };
479
480        // Use diff command
481        let output = Command::new("diff")
482            .args([
483                "--unified",
484                &file1.to_string_lossy(),
485                &file2.to_string_lossy(),
486            ])
487            .output();
488
489        match output {
490            Ok(out) => {
491                // diff exits with 0 if files are same, 1 if different, 2 if error
492                let stdout = String::from_utf8_lossy(&out.stdout).to_string();
493                if stdout.is_empty() {
494                    ToolResult::success("diff_files", "Files are identical".to_string())
495                } else {
496                    ToolResult::success("diff_files", stdout)
497                }
498            }
499            Err(e) => ToolResult::failure("diff_files", format!("Failed to run diff: {}", e)),
500        }
501    }
502}
503
504/// Get tool definitions for LLM function calling
505pub fn get_tool_definitions() -> Vec<ToolDefinition> {
506    vec![
507        ToolDefinition {
508            name: "read_file".to_string(),
509            description: "Read the contents of a file".to_string(),
510            parameters: vec![ToolParameter {
511                name: "path".to_string(),
512                description: "Path to the file to read".to_string(),
513                required: true,
514            }],
515        },
516        ToolDefinition {
517            name: "search_code".to_string(),
518            description: "Search for code patterns in the workspace using grep/ripgrep".to_string(),
519            parameters: vec![
520                ToolParameter {
521                    name: "query".to_string(),
522                    description: "Search pattern (regex supported)".to_string(),
523                    required: true,
524                },
525                ToolParameter {
526                    name: "path".to_string(),
527                    description: "Directory to search in (default: working directory)".to_string(),
528                    required: false,
529                },
530            ],
531        },
532        ToolDefinition {
533            name: "apply_patch".to_string(),
534            description: "Write or replace file contents".to_string(),
535            parameters: vec![
536                ToolParameter {
537                    name: "path".to_string(),
538                    description: "Path to the file to write".to_string(),
539                    required: true,
540                },
541                ToolParameter {
542                    name: "content".to_string(),
543                    description: "New file contents".to_string(),
544                    required: true,
545                },
546            ],
547        },
548        ToolDefinition {
549            name: "apply_diff".to_string(),
550            description: "Apply a Unified Diff patch to a file".to_string(),
551            parameters: vec![
552                ToolParameter {
553                    name: "path".to_string(),
554                    description: "Path to the file to patch".to_string(),
555                    required: true,
556                },
557                ToolParameter {
558                    name: "diff".to_string(),
559                    description: "Unified Diff content".to_string(),
560                    required: true,
561                },
562            ],
563        },
564        ToolDefinition {
565            name: "run_command".to_string(),
566            description: "Execute a shell command in the working directory".to_string(),
567            parameters: vec![ToolParameter {
568                name: "command".to_string(),
569                description: "Shell command to execute".to_string(),
570                required: true,
571            }],
572        },
573        ToolDefinition {
574            name: "list_files".to_string(),
575            description: "List files in a directory".to_string(),
576            parameters: vec![ToolParameter {
577                name: "path".to_string(),
578                description: "Directory path (default: working directory)".to_string(),
579                required: false,
580            }],
581        },
582        // Power Tools
583        ToolDefinition {
584            name: "sed_replace".to_string(),
585            description: "Replace text in a file using sed-like pattern matching".to_string(),
586            parameters: vec![
587                ToolParameter {
588                    name: "path".to_string(),
589                    description: "Path to the file".to_string(),
590                    required: true,
591                },
592                ToolParameter {
593                    name: "pattern".to_string(),
594                    description: "Search pattern".to_string(),
595                    required: true,
596                },
597                ToolParameter {
598                    name: "replacement".to_string(),
599                    description: "Replacement text".to_string(),
600                    required: true,
601                },
602            ],
603        },
604        ToolDefinition {
605            name: "awk_filter".to_string(),
606            description: "Filter file content using awk-like field selection".to_string(),
607            parameters: vec![
608                ToolParameter {
609                    name: "path".to_string(),
610                    description: "Path to the file".to_string(),
611                    required: true,
612                },
613                ToolParameter {
614                    name: "filter".to_string(),
615                    description: "Awk filter expression (e.g., '$1 == \"error\"')".to_string(),
616                    required: true,
617                },
618            ],
619        },
620        ToolDefinition {
621            name: "diff_files".to_string(),
622            description: "Show differences between two files".to_string(),
623            parameters: vec![
624                ToolParameter {
625                    name: "file1".to_string(),
626                    description: "First file path".to_string(),
627                    required: true,
628                },
629                ToolParameter {
630                    name: "file2".to_string(),
631                    description: "Second file path".to_string(),
632                    required: true,
633                },
634            ],
635        },
636    ]
637}
638
639/// Tool definition for LLM function calling
640#[derive(Debug, Clone)]
641pub struct ToolDefinition {
642    pub name: String,
643    pub description: String,
644    pub parameters: Vec<ToolParameter>,
645}
646
647/// Tool parameter definition
648#[derive(Debug, Clone)]
649pub struct ToolParameter {
650    pub name: String,
651    pub description: String,
652    pub required: bool,
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use std::env::temp_dir;
659
660    #[tokio::test]
661    async fn test_read_file() {
662        let dir = temp_dir();
663        let test_file = dir.join("test_read.txt");
664        fs::write(&test_file, "Hello, World!").unwrap();
665
666        let tools = AgentTools::new(dir.clone(), false);
667        let call = ToolCall {
668            name: "read_file".to_string(),
669            arguments: [("path".to_string(), test_file.to_string_lossy().to_string())]
670                .into_iter()
671                .collect(),
672        };
673
674        let result = tools.execute(&call).await;
675        assert!(result.success);
676        assert_eq!(result.output, "Hello, World!");
677    }
678
679    #[tokio::test]
680    async fn test_list_files() {
681        let dir = temp_dir();
682        let tools = AgentTools::new(dir.clone(), false);
683        let call = ToolCall {
684            name: "list_files".to_string(),
685            arguments: HashMap::new(),
686        };
687
688        let result = tools.execute(&call).await;
689        assert!(result.success);
690    }
691
692    #[tokio::test]
693    async fn test_apply_diff_tool() {
694        use std::collections::HashMap;
695        use std::io::Write;
696        let temp_dir = temp_dir();
697        let file_path = temp_dir.join("test_diff.txt");
698        let mut file = std::fs::File::create(&file_path).unwrap();
699        // Explicitly write bytes with unix newlines
700        file.write_all(b"Hello world\nThis is a test\n").unwrap();
701
702        let tools = AgentTools::new(temp_dir.clone(), true);
703
704        // Exact string with newlines
705        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";
706
707        let mut args = HashMap::new();
708        args.insert("path".to_string(), "test_diff.txt".to_string());
709        args.insert("diff".to_string(), diff.to_string());
710
711        let call = ToolCall {
712            name: "apply_diff".to_string(),
713            arguments: args,
714        };
715
716        let result = tools.apply_diff(&call);
717        assert!(
718            result.success,
719            "Diff application failed: {:?}",
720            result.error
721        );
722
723        let content = fs::read_to_string(&file_path).unwrap();
724        assert_eq!(content, "Hello diffy\nThis is a test\n");
725    }
726}
727
728// =============================================================================
729// PSP-5 Phase 6: Sandbox workspace helpers
730// =============================================================================
731
732/// Create a sandbox workspace for provisional verification.
733///
734/// Copies key project files into a session-scoped temporary directory so
735/// speculative verification does not pollute committed workspace state.
736/// Returns the path to the sandbox root.
737pub fn create_sandbox(
738    working_dir: &Path,
739    session_id: &str,
740    branch_id: &str,
741) -> std::io::Result<PathBuf> {
742    let sandbox_root = working_dir
743        .join(".perspt")
744        .join("sandboxes")
745        .join(session_id)
746        .join(branch_id);
747
748    fs::create_dir_all(&sandbox_root)?;
749
750    log::debug!("Created sandbox workspace at {}", sandbox_root.display());
751
752    Ok(sandbox_root)
753}
754
755/// Clean up a specific sandbox workspace.
756pub fn cleanup_sandbox(sandbox_dir: &Path) -> std::io::Result<()> {
757    if sandbox_dir.exists() {
758        fs::remove_dir_all(sandbox_dir)?;
759        log::debug!("Cleaned up sandbox at {}", sandbox_dir.display());
760    }
761    Ok(())
762}
763
764/// Clean up all sandbox workspaces for a session.
765pub fn cleanup_session_sandboxes(working_dir: &Path, session_id: &str) -> std::io::Result<()> {
766    let session_sandbox = working_dir
767        .join(".perspt")
768        .join("sandboxes")
769        .join(session_id);
770
771    if session_sandbox.exists() {
772        fs::remove_dir_all(&session_sandbox)?;
773        log::debug!("Cleaned up all sandboxes for session {}", session_id);
774    }
775    Ok(())
776}
777
778/// Copy a file from the workspace into a sandbox, preserving relative paths.
779pub fn copy_to_sandbox(
780    working_dir: &Path,
781    sandbox_dir: &Path,
782    relative_path: &str,
783) -> std::io::Result<()> {
784    let src = working_dir.join(relative_path);
785    let dst = sandbox_dir.join(relative_path);
786
787    if let Some(parent) = dst.parent() {
788        fs::create_dir_all(parent)?;
789    }
790
791    if src.exists() {
792        fs::copy(&src, &dst)?;
793    }
794    Ok(())
795}
796
797/// Copy a file from a sandbox back to the live workspace, preserving relative paths.
798pub fn copy_from_sandbox(
799    sandbox_dir: &Path,
800    working_dir: &Path,
801    relative_path: &str,
802) -> std::io::Result<()> {
803    let src = sandbox_dir.join(relative_path);
804    let dst = working_dir.join(relative_path);
805
806    if let Some(parent) = dst.parent() {
807        fs::create_dir_all(parent)?;
808    }
809
810    if src.exists() {
811        fs::copy(&src, &dst)?;
812    }
813    Ok(())
814}
815
816/// List all files in a sandbox directory as workspace-relative paths.
817pub fn list_sandbox_files(sandbox_dir: &Path) -> std::io::Result<Vec<String>> {
818    let mut files = Vec::new();
819    if !sandbox_dir.exists() {
820        return Ok(files);
821    }
822    fn walk(dir: &Path, base: &Path, out: &mut Vec<String>) -> std::io::Result<()> {
823        for entry in fs::read_dir(dir)? {
824            let entry = entry?;
825            let path = entry.path();
826            if path.is_dir() {
827                walk(&path, base, out)?;
828            } else if let Ok(rel) = path.strip_prefix(base) {
829                let normalized = rel
830                    .components()
831                    .map(|component| component.as_os_str().to_string_lossy().into_owned())
832                    .collect::<Vec<_>>()
833                    .join("/");
834                out.push(normalized);
835            }
836        }
837        Ok(())
838    }
839    walk(sandbox_dir, sandbox_dir, &mut files)?;
840    Ok(files)
841}
842
843#[cfg(test)]
844mod sandbox_tests {
845    use super::*;
846    use tempfile::tempdir;
847
848    #[test]
849    fn test_create_sandbox() {
850        let dir = tempdir().unwrap();
851        let sandbox = create_sandbox(dir.path(), "sess1", "branch1").unwrap();
852        assert!(sandbox.exists());
853        assert!(sandbox.ends_with("sess1/branch1"));
854    }
855
856    #[test]
857    fn test_cleanup_sandbox() {
858        let dir = tempdir().unwrap();
859        let sandbox = create_sandbox(dir.path(), "sess1", "branch1").unwrap();
860        assert!(sandbox.exists());
861        cleanup_sandbox(&sandbox).unwrap();
862        assert!(!sandbox.exists());
863    }
864
865    #[test]
866    fn test_cleanup_session_sandboxes() {
867        let dir = tempdir().unwrap();
868        create_sandbox(dir.path(), "sess1", "b1").unwrap();
869        create_sandbox(dir.path(), "sess1", "b2").unwrap();
870        let session_dir = dir.path().join(".perspt").join("sandboxes").join("sess1");
871        assert!(session_dir.exists());
872        cleanup_session_sandboxes(dir.path(), "sess1").unwrap();
873        assert!(!session_dir.exists());
874    }
875
876    #[test]
877    fn test_copy_to_sandbox() {
878        let dir = tempdir().unwrap();
879        // Create a source file
880        let src_dir = dir.path().join("src");
881        fs::create_dir_all(&src_dir).unwrap();
882        fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
883
884        let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
885        copy_to_sandbox(dir.path(), &sandbox, "src/main.rs").unwrap();
886
887        let copied = sandbox.join("src/main.rs");
888        assert!(copied.exists());
889        assert_eq!(fs::read_to_string(copied).unwrap(), "fn main() {}");
890    }
891
892    #[test]
893    fn test_copy_from_sandbox() {
894        let dir = tempdir().unwrap();
895        let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
896
897        // Create a file inside the sandbox
898        let sandbox_src = sandbox.join("out");
899        fs::create_dir_all(&sandbox_src).unwrap();
900        fs::write(sandbox_src.join("result.txt"), "hello").unwrap();
901
902        // Copy back to live workspace
903        copy_from_sandbox(&sandbox, dir.path(), "out/result.txt").unwrap();
904
905        let dest = dir.path().join("out/result.txt");
906        assert!(dest.exists());
907        assert_eq!(fs::read_to_string(dest).unwrap(), "hello");
908    }
909
910    #[test]
911    fn test_list_sandbox_files_empty() {
912        let dir = tempdir().unwrap();
913        let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
914        let files = list_sandbox_files(&sandbox).unwrap();
915        assert!(files.is_empty());
916    }
917
918    #[test]
919    fn test_list_sandbox_files_nested() {
920        let dir = tempdir().unwrap();
921        let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
922
923        // Create nested structure
924        let nested = sandbox.join("a/b");
925        fs::create_dir_all(&nested).unwrap();
926        fs::write(sandbox.join("top.txt"), "x").unwrap();
927        fs::write(nested.join("deep.txt"), "y").unwrap();
928
929        let mut files = list_sandbox_files(&sandbox).unwrap();
930        files.sort();
931        assert_eq!(files, vec!["a/b/deep.txt", "top.txt"]);
932    }
933}