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 "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 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 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 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 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 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 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 let original = match fs::read_to_string(&path) {
194 Ok(c) => c,
195 Err(e) => {
196 return ToolResult::failure(
199 "apply_diff",
200 format!("Failed to read base file {:?}: {}", path, e),
201 );
202 }
203 };
204
205 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 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 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 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 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 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 fn write_file(&self, call: &ToolCall) -> ToolResult {
362 self.apply_patch(call)
364 }
365
366 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 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 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 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 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 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 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 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
504pub 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 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#[derive(Debug, Clone)]
641pub struct ToolDefinition {
642 pub name: String,
643 pub description: String,
644 pub parameters: Vec<ToolParameter>,
645}
646
647#[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 file.write_all(b"Hello world\nThis is a test\n").unwrap();
701
702 let tools = AgentTools::new(temp_dir.clone(), true);
703
704 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
728pub 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
755pub 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
764pub 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
778pub 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
797pub 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
816pub 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 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 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_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 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}