1use 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#[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#[derive(Debug, Clone)]
45pub struct ToolCall {
46 pub name: String,
47 pub arguments: HashMap<String, String>,
48}
49
50pub struct AgentTools {
52 working_dir: PathBuf,
54 require_approval: bool,
56 event_sender: Option<perspt_core::events::channel::EventSender>,
58}
59
60impl AgentTools {
61 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 pub fn set_event_sender(&mut self, sender: perspt_core::events::channel::EventSender) {
72 self.event_sender = Some(sender);
73 }
74
75 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 "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 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 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 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 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 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 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 let original = match fs::read_to_string(&path) {
196 Ok(c) => c,
197 Err(e) => {
198 return ToolResult::failure(
201 "apply_diff",
202 format!("Failed to read base file {:?}: {}", path, e),
203 );
204 }
205 };
206
207 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 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 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 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 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 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 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 fn write_file(&self, call: &ToolCall) -> ToolResult {
374 self.apply_patch(call)
376 }
377
378 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 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 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 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 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 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 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 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 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 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 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
589pub 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 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#[derive(Debug, Clone)]
726pub struct ToolDefinition {
727 pub name: String,
728 pub description: String,
729 pub parameters: Vec<ToolParameter>,
730}
731
732#[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 file.write_all(b"Hello world\nThis is a test\n").unwrap();
786
787 let tools = AgentTools::new(temp_dir.clone(), true);
788
789 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 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
922pub 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
949pub 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 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 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 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 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 if plugins.contains(&"rust") {
1020 ensure_rust_workspace_members_in_sandbox(working_dir, sandbox_dir);
1021 }
1022
1023 if plugins.contains(&"python") {
1026 seed_python_sandbox(working_dir, sandbox_dir);
1027 }
1028
1029 Ok(())
1030}
1031
1032fn 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 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 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 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 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 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 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
1122fn seed_python_sandbox(working_dir: &Path, sandbox_dir: &Path) {
1126 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 log::debug!("Skipping .venv symlink on non-Unix platform");
1144 }
1145 }
1146
1147 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 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 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 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
1194fn 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
1223pub 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
1232pub 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
1246pub 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
1265pub 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
1284pub 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 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 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 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 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_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 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}