use diffy::{apply, Patch};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command as AsyncCommand;
#[derive(Debug, Clone)]
pub struct ToolResult {
pub tool_name: String,
pub success: bool,
pub output: String,
pub error: Option<String>,
}
impl ToolResult {
pub fn success(tool_name: &str, output: String) -> Self {
Self {
tool_name: tool_name.to_string(),
success: true,
output,
error: None,
}
}
pub fn failure(tool_name: &str, error: String) -> Self {
Self {
tool_name: tool_name.to_string(),
success: false,
output: String::new(),
error: Some(error),
}
}
}
#[derive(Debug, Clone)]
pub struct ToolCall {
pub name: String,
pub arguments: HashMap<String, String>,
}
pub struct AgentTools {
working_dir: PathBuf,
require_approval: bool,
event_sender: Option<perspt_core::events::channel::EventSender>,
}
impl AgentTools {
pub fn new(working_dir: PathBuf, require_approval: bool) -> Self {
Self {
working_dir,
require_approval,
event_sender: None,
}
}
pub fn set_event_sender(&mut self, sender: perspt_core::events::channel::EventSender) {
self.event_sender = Some(sender);
}
pub async fn execute(&self, call: &ToolCall) -> ToolResult {
match call.name.as_str() {
"read_file" => self.read_file(call),
"search_code" => self.search_code(call),
"apply_patch" => self.apply_patch(call),
"run_command" => self.run_command(call).await,
"list_files" => self.list_files(call),
"write_file" => self.write_file(call),
"apply_diff" => self.apply_diff(call),
"delete_file" => self.delete_file(call),
"move_file" => self.move_file(call),
"sed_replace" => self.sed_replace(call),
"awk_filter" => self.awk_filter(call),
"diff_files" => self.diff_files(call),
_ => ToolResult::failure(&call.name, format!("Unknown tool: {}", call.name)),
}
}
fn read_file(&self, call: &ToolCall) -> ToolResult {
let path = match call.arguments.get("path") {
Some(p) => self.resolve_path(p),
None => return ToolResult::failure("read_file", "Missing 'path' argument".to_string()),
};
match fs::read_to_string(&path) {
Ok(content) => ToolResult::success("read_file", content),
Err(e) => ToolResult::failure("read_file", format!("Failed to read {:?}: {}", path, e)),
}
}
fn search_code(&self, call: &ToolCall) -> ToolResult {
let query = match call.arguments.get("query") {
Some(q) => q,
None => {
return ToolResult::failure("search_code", "Missing 'query' argument".to_string())
}
};
let path = call
.arguments
.get("path")
.map(|p| self.resolve_path(p))
.unwrap_or_else(|| self.working_dir.clone());
let output = Command::new("rg")
.args(["--json", "-n", query])
.current_dir(&path)
.output()
.or_else(|_| {
Command::new("grep")
.args(["-rn", query, "."])
.current_dir(&path)
.output()
});
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
ToolResult::success("search_code", stdout)
}
Err(e) => ToolResult::failure("search_code", format!("Search failed: {}", e)),
}
}
fn apply_patch(&self, call: &ToolCall) -> ToolResult {
let path = match call.arguments.get("path") {
Some(p) => self.resolve_path(p),
None => {
return ToolResult::failure("apply_patch", "Missing 'path' argument".to_string())
}
};
let content = match call.arguments.get("content") {
Some(c) => c,
None => {
return ToolResult::failure("apply_patch", "Missing 'content' argument".to_string())
}
};
if let Some(parent) = path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
return ToolResult::failure(
"apply_patch",
format!("Failed to create directories: {}", e),
);
}
}
match fs::write(&path, content) {
Ok(_) => ToolResult::success("apply_patch", format!("Successfully wrote {:?}", path)),
Err(e) => {
ToolResult::failure("apply_patch", format!("Failed to write {:?}: {}", path, e))
}
}
}
fn apply_diff(&self, call: &ToolCall) -> ToolResult {
let path = match call.arguments.get("path") {
Some(p) => self.resolve_path(p),
None => {
return ToolResult::failure("apply_diff", "Missing 'path' argument".to_string())
}
};
let diff_content = match call.arguments.get("diff") {
Some(c) => c,
None => {
return ToolResult::failure("apply_diff", "Missing 'diff' argument".to_string())
}
};
let original = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
return ToolResult::failure(
"apply_diff",
format!("Failed to read base file {:?}: {}", path, e),
);
}
};
let patch = match Patch::from_str(diff_content) {
Ok(p) => p,
Err(e) => {
return ToolResult::failure("apply_diff", format!("Failed to parse diff: {}", e));
}
};
match apply(&original, &patch) {
Ok(patched) => match fs::write(&path, patched) {
Ok(_) => {
ToolResult::success("apply_diff", format!("Successfully patched {:?}", path))
}
Err(e) => ToolResult::failure(
"apply_diff",
format!("Failed to write patched file: {}", e),
),
},
Err(e) => ToolResult::failure("apply_diff", format!("Failed to apply patch: {}", e)),
}
}
async fn run_command(&self, call: &ToolCall) -> ToolResult {
let cmd_str = match call.arguments.get("command") {
Some(c) => c,
None => {
return ToolResult::failure("run_command", "Missing 'command' argument".to_string())
}
};
let effective_dir = call
.arguments
.get("working_dir")
.map(PathBuf::from)
.filter(|d| d.is_dir())
.unwrap_or_else(|| self.working_dir.clone());
match perspt_policy::sanitize_command(cmd_str) {
Ok(sr) if sr.rejected => {
return ToolResult::failure(
"run_command",
format!(
"Command rejected by policy: {}",
sr.rejection_reason
.unwrap_or_else(|| "unknown reason".to_string())
),
);
}
Ok(sr) => {
for warning in &sr.warnings {
log::warn!("Command policy warning: {}", warning);
}
}
Err(e) => {
return ToolResult::failure(
"run_command",
format!("Command sanitization failed: {}", e),
);
}
}
if let Err(e) = perspt_policy::validate_workspace_bound(cmd_str, &self.working_dir) {
return ToolResult::failure("run_command", format!("Command rejected: {}", e));
}
if self.require_approval {
log::info!("Command requires approval: {}", cmd_str);
}
let mut child = match AsyncCommand::new("sh")
.args(["-c", cmd_str])
.current_dir(&effective_dir)
.env_remove("VIRTUAL_ENV")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => return ToolResult::failure("run_command", format!("Failed to spawn: {}", e)),
};
let stdout = child.stdout.take().expect("Failed to open stdout");
let stderr = child.stderr.take().expect("Failed to open stderr");
let sender = self.event_sender.clone();
let stdout_handle = tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
let mut output = String::new();
while let Ok(Some(line)) = reader.next_line().await {
if let Some(ref s) = sender {
let _ = s.send(perspt_core::AgentEvent::Log(line.clone()));
}
output.push_str(&line);
output.push('\n');
}
output
});
let sender_err = self.event_sender.clone();
let stderr_handle = tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
let mut output = String::new();
while let Ok(Some(line)) = reader.next_line().await {
if let Some(ref s) = sender_err {
let _ = s.send(perspt_core::AgentEvent::Log(format!("ERR: {}", line)));
}
output.push_str(&line);
output.push('\n');
}
output
});
let status = match child.wait().await {
Ok(s) => s,
Err(e) => return ToolResult::failure("run_command", format!("Failed to wait: {}", e)),
};
let stdout_str = stdout_handle.await.unwrap_or_default();
let stderr_str = stderr_handle.await.unwrap_or_default();
if status.success() {
ToolResult::success("run_command", stdout_str)
} else {
ToolResult::failure(
"run_command",
format!("Exit code: {:?}\n{}", status.code(), stderr_str),
)
}
}
fn list_files(&self, call: &ToolCall) -> ToolResult {
let path = call
.arguments
.get("path")
.map(|p| self.resolve_path(p))
.unwrap_or_else(|| self.working_dir.clone());
match fs::read_dir(&path) {
Ok(entries) => {
let files: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
format!("{}/", name)
} else {
name
}
})
.collect();
ToolResult::success("list_files", files.join("\n"))
}
Err(e) => {
ToolResult::failure("list_files", format!("Failed to list {:?}: {}", path, e))
}
}
}
fn write_file(&self, call: &ToolCall) -> ToolResult {
self.apply_patch(call)
}
fn delete_file(&self, call: &ToolCall) -> ToolResult {
let path = match call.arguments.get("path") {
Some(p) => self.resolve_path(p),
None => {
return ToolResult::failure("delete_file", "Missing 'path' argument".to_string())
}
};
if !path.exists() {
return ToolResult::success(
"delete_file",
format!("Path does not exist, nothing to delete: {:?}", path),
);
}
if path.is_dir() {
return ToolResult::failure(
"delete_file",
format!(
"Cannot delete directory {:?}; only files are supported",
path
),
);
}
match std::fs::remove_file(&path) {
Ok(()) => ToolResult::success("delete_file", format!("Deleted {:?}", path)),
Err(e) => {
ToolResult::failure("delete_file", format!("Failed to delete {:?}: {}", path, e))
}
}
}
fn move_file(&self, call: &ToolCall) -> ToolResult {
let from = match call.arguments.get("from") {
Some(p) => self.resolve_path(p),
None => return ToolResult::failure("move_file", "Missing 'from' argument".to_string()),
};
let to = match call.arguments.get("to") {
Some(p) => self.resolve_path(p),
None => return ToolResult::failure("move_file", "Missing 'to' argument".to_string()),
};
if !from.exists() {
return ToolResult::failure(
"move_file",
format!("Source path does not exist: {:?}", from),
);
}
if let Some(parent) = to.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
return ToolResult::failure(
"move_file",
format!("Failed to create destination directory {:?}: {}", parent, e),
);
}
}
}
match std::fs::rename(&from, &to) {
Ok(()) => ToolResult::success("move_file", format!("Moved {:?} -> {:?}", from, to)),
Err(e) => ToolResult::failure(
"move_file",
format!("Failed to move {:?} -> {:?}: {}", from, to, e),
),
}
}
fn resolve_path(&self, path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() {
p.to_path_buf()
} else {
self.working_dir.join(p)
}
}
fn sed_replace(&self, call: &ToolCall) -> ToolResult {
let path = match call.arguments.get("path") {
Some(p) => self.resolve_path(p),
None => {
return ToolResult::failure("sed_replace", "Missing 'path' argument".to_string())
}
};
let pattern = match call.arguments.get("pattern") {
Some(p) => p,
None => {
return ToolResult::failure("sed_replace", "Missing 'pattern' argument".to_string())
}
};
let replacement = match call.arguments.get("replacement") {
Some(r) => r,
None => {
return ToolResult::failure(
"sed_replace",
"Missing 'replacement' argument".to_string(),
)
}
};
match fs::read_to_string(&path) {
Ok(content) => {
let new_content = content.replace(pattern, replacement);
match fs::write(&path, &new_content) {
Ok(_) => ToolResult::success(
"sed_replace",
format!(
"Replaced '{}' with '{}' in {:?}",
pattern, replacement, path
),
),
Err(e) => ToolResult::failure("sed_replace", format!("Failed to write: {}", e)),
}
}
Err(e) => {
ToolResult::failure("sed_replace", format!("Failed to read {:?}: {}", path, e))
}
}
}
fn awk_filter(&self, call: &ToolCall) -> ToolResult {
let path = match call.arguments.get("path") {
Some(p) => self.resolve_path(p),
None => {
return ToolResult::failure("awk_filter", "Missing 'path' argument".to_string())
}
};
let filter = match call.arguments.get("filter") {
Some(f) => f,
None => {
return ToolResult::failure("awk_filter", "Missing 'filter' argument".to_string())
}
};
let output = Command::new("awk").arg(filter).arg(&path).output();
match output {
Ok(out) => {
if out.status.success() {
ToolResult::success(
"awk_filter",
String::from_utf8_lossy(&out.stdout).to_string(),
)
} else {
ToolResult::failure(
"awk_filter",
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
}
Err(e) => ToolResult::failure("awk_filter", format!("Failed to run awk: {}", e)),
}
}
fn diff_files(&self, call: &ToolCall) -> ToolResult {
let file1 = match call.arguments.get("file1") {
Some(p) => self.resolve_path(p),
None => {
return ToolResult::failure("diff_files", "Missing 'file1' argument".to_string())
}
};
let file2 = match call.arguments.get("file2") {
Some(p) => self.resolve_path(p),
None => {
return ToolResult::failure("diff_files", "Missing 'file2' argument".to_string())
}
};
let output = Command::new("diff")
.args([
"--unified",
&file1.to_string_lossy(),
&file2.to_string_lossy(),
])
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
if stdout.is_empty() {
ToolResult::success("diff_files", "Files are identical".to_string())
} else {
ToolResult::success("diff_files", stdout)
}
}
Err(e) => ToolResult::failure("diff_files", format!("Failed to run diff: {}", e)),
}
}
}
pub fn get_tool_definitions() -> Vec<ToolDefinition> {
vec![
ToolDefinition {
name: "read_file".to_string(),
description: "Read the contents of a file".to_string(),
parameters: vec![ToolParameter {
name: "path".to_string(),
description: "Path to the file to read".to_string(),
required: true,
}],
},
ToolDefinition {
name: "search_code".to_string(),
description: "Search for code patterns in the workspace using grep/ripgrep".to_string(),
parameters: vec![
ToolParameter {
name: "query".to_string(),
description: "Search pattern (regex supported)".to_string(),
required: true,
},
ToolParameter {
name: "path".to_string(),
description: "Directory to search in (default: working directory)".to_string(),
required: false,
},
],
},
ToolDefinition {
name: "apply_patch".to_string(),
description: "Write or replace file contents".to_string(),
parameters: vec![
ToolParameter {
name: "path".to_string(),
description: "Path to the file to write".to_string(),
required: true,
},
ToolParameter {
name: "content".to_string(),
description: "New file contents".to_string(),
required: true,
},
],
},
ToolDefinition {
name: "apply_diff".to_string(),
description: "Apply a Unified Diff patch to a file".to_string(),
parameters: vec![
ToolParameter {
name: "path".to_string(),
description: "Path to the file to patch".to_string(),
required: true,
},
ToolParameter {
name: "diff".to_string(),
description: "Unified Diff content".to_string(),
required: true,
},
],
},
ToolDefinition {
name: "run_command".to_string(),
description: "Execute a shell command in the working directory".to_string(),
parameters: vec![ToolParameter {
name: "command".to_string(),
description: "Shell command to execute".to_string(),
required: true,
}],
},
ToolDefinition {
name: "list_files".to_string(),
description: "List files in a directory".to_string(),
parameters: vec![ToolParameter {
name: "path".to_string(),
description: "Directory path (default: working directory)".to_string(),
required: false,
}],
},
ToolDefinition {
name: "sed_replace".to_string(),
description: "Replace text in a file using sed-like pattern matching".to_string(),
parameters: vec![
ToolParameter {
name: "path".to_string(),
description: "Path to the file".to_string(),
required: true,
},
ToolParameter {
name: "pattern".to_string(),
description: "Search pattern".to_string(),
required: true,
},
ToolParameter {
name: "replacement".to_string(),
description: "Replacement text".to_string(),
required: true,
},
],
},
ToolDefinition {
name: "awk_filter".to_string(),
description: "Filter file content using awk-like field selection".to_string(),
parameters: vec![
ToolParameter {
name: "path".to_string(),
description: "Path to the file".to_string(),
required: true,
},
ToolParameter {
name: "filter".to_string(),
description: "Awk filter expression (e.g., '$1 == \"error\"')".to_string(),
required: true,
},
],
},
ToolDefinition {
name: "diff_files".to_string(),
description: "Show differences between two files".to_string(),
parameters: vec![
ToolParameter {
name: "file1".to_string(),
description: "First file path".to_string(),
required: true,
},
ToolParameter {
name: "file2".to_string(),
description: "Second file path".to_string(),
required: true,
},
],
},
]
}
#[derive(Debug, Clone)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: Vec<ToolParameter>,
}
#[derive(Debug, Clone)]
pub struct ToolParameter {
pub name: String,
pub description: String,
pub required: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use std::env::temp_dir;
#[tokio::test]
async fn test_read_file() {
let dir = temp_dir();
let test_file = dir.join("test_read.txt");
fs::write(&test_file, "Hello, World!").unwrap();
let tools = AgentTools::new(dir.clone(), false);
let call = ToolCall {
name: "read_file".to_string(),
arguments: [("path".to_string(), test_file.to_string_lossy().to_string())]
.into_iter()
.collect(),
};
let result = tools.execute(&call).await;
assert!(result.success);
assert_eq!(result.output, "Hello, World!");
}
#[tokio::test]
async fn test_list_files() {
let dir = temp_dir();
let tools = AgentTools::new(dir.clone(), false);
let call = ToolCall {
name: "list_files".to_string(),
arguments: HashMap::new(),
};
let result = tools.execute(&call).await;
assert!(result.success);
}
#[tokio::test]
async fn test_apply_diff_tool() {
use std::collections::HashMap;
use std::io::Write;
let temp_dir = temp_dir();
let file_path = temp_dir.join("test_diff.txt");
let mut file = std::fs::File::create(&file_path).unwrap();
file.write_all(b"Hello world\nThis is a test\n").unwrap();
let tools = AgentTools::new(temp_dir.clone(), true);
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";
let mut args = HashMap::new();
args.insert("path".to_string(), "test_diff.txt".to_string());
args.insert("diff".to_string(), diff.to_string());
let call = ToolCall {
name: "apply_diff".to_string(),
arguments: args,
};
let result = tools.apply_diff(&call);
assert!(
result.success,
"Diff application failed: {:?}",
result.error
);
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "Hello diffy\nThis is a test\n");
}
#[tokio::test]
async fn test_delete_file() {
let dir = temp_dir();
let test_file = dir.join("test_delete_me.txt");
fs::write(&test_file, "temporary").unwrap();
assert!(test_file.exists());
let tools = AgentTools::new(dir.clone(), false);
let mut args = HashMap::new();
args.insert("path".to_string(), test_file.to_string_lossy().to_string());
let call = ToolCall {
name: "delete_file".to_string(),
arguments: args,
};
let result = tools.execute(&call).await;
assert!(result.success, "Delete should succeed: {:?}", result.error);
assert!(!test_file.exists(), "File should be gone");
}
#[tokio::test]
async fn test_delete_nonexistent_file_succeeds() {
let dir = temp_dir();
let tools = AgentTools::new(dir.clone(), false);
let mut args = HashMap::new();
args.insert(
"path".to_string(),
"/tmp/does_not_exist_xyz.txt".to_string(),
);
let call = ToolCall {
name: "delete_file".to_string(),
arguments: args,
};
let result = tools.execute(&call).await;
assert!(result.success);
}
#[tokio::test]
async fn test_move_file() {
let dir = temp_dir();
let src = dir.join("test_move_src.txt");
let dst = dir.join("test_move_dst.txt");
fs::write(&src, "move me").unwrap();
let tools = AgentTools::new(dir.clone(), false);
let mut args = HashMap::new();
args.insert("from".to_string(), src.to_string_lossy().to_string());
args.insert("to".to_string(), dst.to_string_lossy().to_string());
args.insert("path".to_string(), src.to_string_lossy().to_string());
let call = ToolCall {
name: "move_file".to_string(),
arguments: args,
};
let result = tools.execute(&call).await;
assert!(result.success, "Move should succeed: {:?}", result.error);
assert!(!src.exists(), "Source should be gone");
assert!(dst.exists(), "Destination should exist");
assert_eq!(fs::read_to_string(&dst).unwrap(), "move me");
let _ = fs::remove_file(&dst);
}
#[tokio::test]
async fn test_delete_directory_rejected() {
let dir = temp_dir().join("test_delete_dir");
fs::create_dir_all(&dir).unwrap();
let tools = AgentTools::new(temp_dir(), false);
let mut args = HashMap::new();
args.insert("path".to_string(), dir.to_string_lossy().to_string());
let call = ToolCall {
name: "delete_file".to_string(),
arguments: args,
};
let result = tools.execute(&call).await;
assert!(!result.success, "Should reject directory deletion");
let _ = fs::remove_dir(&dir);
}
#[tokio::test]
async fn test_move_file_creates_parent_dirs() {
let dir = temp_dir();
let src = dir.join("test_move_nested_src.txt");
let dst = dir
.join("nested")
.join("deep")
.join("test_move_nested_dst.txt");
fs::write(&src, "nested move").unwrap();
let tools = AgentTools::new(dir.clone(), false);
let mut args = HashMap::new();
args.insert("from".to_string(), src.to_string_lossy().to_string());
args.insert("to".to_string(), dst.to_string_lossy().to_string());
args.insert("path".to_string(), src.to_string_lossy().to_string());
let call = ToolCall {
name: "move_file".to_string(),
arguments: args,
};
let result = tools.execute(&call).await;
assert!(
result.success,
"Move with nested dirs should succeed: {:?}",
result.error
);
assert!(!src.exists());
assert!(dst.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "nested move");
let _ = fs::remove_dir_all(dir.join("nested"));
}
}
pub fn create_sandbox(
working_dir: &Path,
session_id: &str,
branch_id: &str,
) -> std::io::Result<PathBuf> {
let sandbox_root = working_dir
.join(".perspt")
.join("sandboxes")
.join(session_id)
.join(branch_id);
fs::create_dir_all(&sandbox_root)?;
log::debug!("Created sandbox workspace at {}", sandbox_root.display());
Ok(sandbox_root)
}
pub fn seed_sandbox_manifests(
working_dir: &Path,
sandbox_dir: &Path,
plugins: &[&str],
) -> std::io::Result<()> {
let registry = perspt_core::plugin::PluginRegistry::new();
let mut seeded = Vec::new();
for plugin_name in plugins {
if let Some(plugin) = registry.get(plugin_name) {
for key_file in plugin.key_files() {
if working_dir.join(key_file).exists() {
copy_to_sandbox(working_dir, sandbox_dir, key_file)?;
seeded.push(key_file.to_string());
}
if let Ok(entries) = fs::read_dir(working_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.file_name().is_none_or(|n| n != ".perspt") {
let sub_key = path.join(key_file);
if sub_key.exists() {
let rel = sub_key
.strip_prefix(working_dir)
.unwrap_or(&sub_key)
.to_string_lossy()
.to_string();
let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
seeded.push(rel);
}
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.is_dir() {
let deep_key = sub_path.join(key_file);
if deep_key.exists() {
let rel = deep_key
.strip_prefix(working_dir)
.unwrap_or(&deep_key)
.to_string_lossy()
.to_string();
let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
seeded.push(rel);
}
}
}
}
}
}
}
}
}
}
if !seeded.is_empty() {
log::debug!("Seeded sandbox with manifests: {}", seeded.join(", "));
}
if plugins.contains(&"rust") {
ensure_rust_workspace_members_in_sandbox(working_dir, sandbox_dir);
}
if plugins.contains(&"python") {
seed_python_sandbox(working_dir, sandbox_dir);
}
Ok(())
}
fn ensure_rust_workspace_members_in_sandbox(working_dir: &Path, sandbox_dir: &Path) {
let cargo_toml = sandbox_dir.join("Cargo.toml");
let content = match fs::read_to_string(&cargo_toml) {
Ok(c) => c,
Err(_) => return,
};
let mut in_workspace = false;
let mut members: Vec<String> = Vec::new();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.starts_with('[') {
in_workspace = line == "[workspace]";
continue;
}
if in_workspace && line.starts_with("members") {
if let Some((_, value)) = line.split_once('=') {
let raw = value.trim();
if raw.starts_with('[') {
let inner = raw.trim_start_matches('[').trim_end_matches(']');
for item in inner.split(',') {
let member = item.trim().trim_matches('"').trim_matches('\'');
if !member.is_empty() {
members.push(member.to_string());
}
}
}
}
}
}
for member in &members {
let member_dir = sandbox_dir.join(member);
let member_cargo = member_dir.join("Cargo.toml");
let src_cargo = working_dir.join(member).join("Cargo.toml");
if src_cargo.exists() && !member_cargo.exists() {
let _ = fs::create_dir_all(&member_dir);
let _ = fs::copy(&src_cargo, &member_cargo);
}
if !member_cargo.exists() {
let _ = fs::create_dir_all(&member_dir);
let name = member.rsplit('/').next().unwrap_or(member);
let stub = format!(
"[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
name
);
let _ = fs::write(&member_cargo, &stub);
}
let src_dir = member_dir.join("src");
let has_lib = src_dir.join("lib.rs").exists();
let has_main = src_dir.join("main.rs").exists();
if !has_lib && !has_main {
let _ = fs::create_dir_all(&src_dir);
let ws_lib = working_dir.join(member).join("src").join("lib.rs");
let ws_main = working_dir.join(member).join("src").join("main.rs");
if ws_lib.exists() {
let _ = fs::copy(&ws_lib, src_dir.join("lib.rs"));
} else if ws_main.exists() {
let _ = fs::copy(&ws_main, src_dir.join("main.rs"));
} else {
let _ = fs::write(
src_dir.join("lib.rs"),
"// stub — will be replaced by agent\n",
);
}
}
}
if !members.is_empty() {
log::debug!(
"Ensured {} workspace member(s) have valid stubs in sandbox",
members.len()
);
}
}
fn seed_python_sandbox(working_dir: &Path, sandbox_dir: &Path) {
let workspace_venv = working_dir.join(".venv");
let sandbox_venv = sandbox_dir.join(".venv");
if workspace_venv.is_dir() && !sandbox_venv.exists() {
#[cfg(unix)]
{
if let Err(e) = std::os::unix::fs::symlink(&workspace_venv, &sandbox_venv) {
log::debug!("Could not symlink .venv into sandbox: {}", e);
} else {
log::debug!("Symlinked .venv into sandbox");
}
}
#[cfg(not(unix))]
{
log::debug!("Skipping .venv symlink on non-Unix platform");
}
}
for ancillary in &["README.md", "README.rst", "README", ".python-version"] {
let src = working_dir.join(ancillary);
if src.is_file() {
let dst = sandbox_dir.join(ancillary);
if !dst.exists() {
let _ = fs::copy(&src, &dst);
}
}
}
let workspace_src = working_dir.join("src");
if workspace_src.is_dir() {
if let Ok(entries) = fs::read_dir(&workspace_src) {
for entry in entries.flatten() {
let pkg_dir = entry.path();
if pkg_dir.is_dir() && pkg_dir.join("__init__.py").exists() {
if let Err(e) = copy_dir_to_sandbox(working_dir, sandbox_dir, &pkg_dir) {
log::debug!(
"Could not seed src/{} into sandbox: {}",
entry.file_name().to_string_lossy(),
e
);
}
}
}
}
}
for extra in &["conftest.py", "tests"] {
let src = working_dir.join(extra);
if src.is_file() {
let rel = extra.to_string();
let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
} else if src.is_dir() {
let _ = copy_dir_to_sandbox(working_dir, sandbox_dir, &src);
}
}
}
fn copy_dir_to_sandbox(
working_dir: &Path,
sandbox_dir: &Path,
src_dir: &Path,
) -> std::io::Result<()> {
const SKIP: &[&str] = &[".venv", "__pycache__", ".mypy_cache", ".pytest_cache"];
for entry in fs::read_dir(src_dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if path.is_dir() {
if SKIP.iter().any(|s| *s == &*name_str) {
continue;
}
copy_dir_to_sandbox(working_dir, sandbox_dir, &path)?;
} else if !name_str.ends_with(".pyc") {
if let Ok(rel) = path.strip_prefix(working_dir) {
let rel_str = rel.to_string_lossy().to_string();
copy_to_sandbox(working_dir, sandbox_dir, &rel_str)?;
}
}
}
Ok(())
}
pub fn cleanup_sandbox(sandbox_dir: &Path) -> std::io::Result<()> {
if sandbox_dir.exists() {
fs::remove_dir_all(sandbox_dir)?;
log::debug!("Cleaned up sandbox at {}", sandbox_dir.display());
}
Ok(())
}
pub fn cleanup_session_sandboxes(working_dir: &Path, session_id: &str) -> std::io::Result<()> {
let session_sandbox = working_dir
.join(".perspt")
.join("sandboxes")
.join(session_id);
if session_sandbox.exists() {
fs::remove_dir_all(&session_sandbox)?;
log::debug!("Cleaned up all sandboxes for session {}", session_id);
}
Ok(())
}
pub fn copy_to_sandbox(
working_dir: &Path,
sandbox_dir: &Path,
relative_path: &str,
) -> std::io::Result<()> {
let src = working_dir.join(relative_path);
let dst = sandbox_dir.join(relative_path);
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
if src.exists() {
fs::copy(&src, &dst)?;
}
Ok(())
}
pub fn copy_from_sandbox(
sandbox_dir: &Path,
working_dir: &Path,
relative_path: &str,
) -> std::io::Result<()> {
let src = sandbox_dir.join(relative_path);
let dst = working_dir.join(relative_path);
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
if src.exists() {
fs::copy(&src, &dst)?;
}
Ok(())
}
pub fn list_sandbox_files(sandbox_dir: &Path) -> std::io::Result<Vec<String>> {
let mut files = Vec::new();
if !sandbox_dir.exists() {
return Ok(files);
}
const SKIP_DIRS: &[&str] = &[
".venv",
"__pycache__",
"node_modules",
".mypy_cache",
".pytest_cache",
".ruff_cache",
];
fn walk(dir: &Path, base: &Path, out: &mut Vec<String>) -> std::io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if SKIP_DIRS.iter().any(|s| *s == &*name_str) {
continue;
}
walk(&path, base, out)?;
} else if let Ok(rel) = path.strip_prefix(base) {
let normalized = rel
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
if !normalized.ends_with(".pyc") {
out.push(normalized);
}
}
}
Ok(())
}
walk(sandbox_dir, sandbox_dir, &mut files)?;
Ok(files)
}
#[cfg(test)]
mod sandbox_tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_create_sandbox() {
let dir = tempdir().unwrap();
let sandbox = create_sandbox(dir.path(), "sess1", "branch1").unwrap();
assert!(sandbox.exists());
assert!(sandbox.ends_with("sess1/branch1"));
}
#[test]
fn test_cleanup_sandbox() {
let dir = tempdir().unwrap();
let sandbox = create_sandbox(dir.path(), "sess1", "branch1").unwrap();
assert!(sandbox.exists());
cleanup_sandbox(&sandbox).unwrap();
assert!(!sandbox.exists());
}
#[test]
fn test_cleanup_session_sandboxes() {
let dir = tempdir().unwrap();
create_sandbox(dir.path(), "sess1", "b1").unwrap();
create_sandbox(dir.path(), "sess1", "b2").unwrap();
let session_dir = dir.path().join(".perspt").join("sandboxes").join("sess1");
assert!(session_dir.exists());
cleanup_session_sandboxes(dir.path(), "sess1").unwrap();
assert!(!session_dir.exists());
}
#[test]
fn test_copy_to_sandbox() {
let dir = tempdir().unwrap();
let src_dir = dir.path().join("src");
fs::create_dir_all(&src_dir).unwrap();
fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
copy_to_sandbox(dir.path(), &sandbox, "src/main.rs").unwrap();
let copied = sandbox.join("src/main.rs");
assert!(copied.exists());
assert_eq!(fs::read_to_string(copied).unwrap(), "fn main() {}");
}
#[test]
fn test_copy_from_sandbox() {
let dir = tempdir().unwrap();
let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
let sandbox_src = sandbox.join("out");
fs::create_dir_all(&sandbox_src).unwrap();
fs::write(sandbox_src.join("result.txt"), "hello").unwrap();
copy_from_sandbox(&sandbox, dir.path(), "out/result.txt").unwrap();
let dest = dir.path().join("out/result.txt");
assert!(dest.exists());
assert_eq!(fs::read_to_string(dest).unwrap(), "hello");
}
#[test]
fn test_list_sandbox_files_empty() {
let dir = tempdir().unwrap();
let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
let files = list_sandbox_files(&sandbox).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_list_sandbox_files_nested() {
let dir = tempdir().unwrap();
let sandbox = create_sandbox(dir.path(), "sess1", "b1").unwrap();
let nested = sandbox.join("a/b");
fs::create_dir_all(&nested).unwrap();
fs::write(sandbox.join("top.txt"), "x").unwrap();
fs::write(nested.join("deep.txt"), "y").unwrap();
let mut files = list_sandbox_files(&sandbox).unwrap();
files.sort();
assert_eq!(files, vec!["a/b/deep.txt", "top.txt"]);
}
}