Skip to main content

perspt_agent/tools/
sandbox.rs

1use super::*;
2
3// =============================================================================
4// PSP-5 Phase 6: Sandbox workspace helpers
5// =============================================================================
6
7/// Create a sandbox workspace for provisional verification.
8///
9/// Copies key project files into a session-scoped temporary directory so
10/// speculative verification does not pollute committed workspace state.
11/// Returns the path to the sandbox root.
12pub fn create_sandbox(
13    working_dir: &Path,
14    session_id: &str,
15    branch_id: &str,
16) -> std::io::Result<PathBuf> {
17    let sandbox_root = working_dir
18        .join(".perspt")
19        .join("sandboxes")
20        .join(session_id)
21        .join(branch_id);
22
23    fs::create_dir_all(&sandbox_root)?;
24
25    log::debug!("Created sandbox workspace at {}", sandbox_root.display());
26
27    Ok(sandbox_root)
28}
29
30/// Seed a sandbox with plugin-identified project manifests (Cargo.toml,
31/// pyproject.toml, etc.) so that build/test commands can find them.
32///
33/// Walks the workspace looking for each plugin's `key_files()` and copies
34/// any that exist into the sandbox at the same relative path.
35pub fn seed_sandbox_manifests(
36    working_dir: &Path,
37    sandbox_dir: &Path,
38    plugins: &[&str],
39) -> std::io::Result<()> {
40    let registry = perspt_core::plugin::PluginRegistry::new();
41    let mut seeded = Vec::new();
42
43    for plugin_name in plugins {
44        if let Some(plugin) = registry.get(plugin_name) {
45            for key_file in plugin.key_files() {
46                // Check workspace root
47                if working_dir.join(key_file).exists() {
48                    copy_to_sandbox(working_dir, sandbox_dir, key_file)?;
49                    seeded.push(key_file.to_string());
50                }
51                // Also walk up to two levels of subdirectories
52                // (e.g. crates/*/Cargo.toml, packages/*/package.json)
53                if let Ok(entries) = fs::read_dir(working_dir) {
54                    for entry in entries.flatten() {
55                        let path = entry.path();
56                        if path.is_dir() && path.file_name().is_none_or(|n| n != ".perspt") {
57                            // Level 1: e.g. crates/Cargo.toml (unlikely but check)
58                            let sub_key = path.join(key_file);
59                            if sub_key.exists() {
60                                let rel = sub_key
61                                    .strip_prefix(working_dir)
62                                    .unwrap_or(&sub_key)
63                                    .to_string_lossy()
64                                    .to_string();
65                                let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
66                                seeded.push(rel);
67                            }
68                            // Level 2: e.g. crates/cfd-core/Cargo.toml
69                            if let Ok(sub_entries) = fs::read_dir(&path) {
70                                for sub_entry in sub_entries.flatten() {
71                                    let sub_path = sub_entry.path();
72                                    if sub_path.is_dir() {
73                                        let deep_key = sub_path.join(key_file);
74                                        if deep_key.exists() {
75                                            let rel = deep_key
76                                                .strip_prefix(working_dir)
77                                                .unwrap_or(&deep_key)
78                                                .to_string_lossy()
79                                                .to_string();
80                                            let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
81                                            seeded.push(rel);
82                                        }
83                                    }
84                                }
85                            }
86                        }
87                    }
88                }
89            }
90        }
91    }
92
93    if !seeded.is_empty() {
94        log::debug!("Seeded sandbox with manifests: {}", seeded.join(", "));
95    }
96
97    // For Rust workspaces: ensure every workspace member in the sandbox has
98    // at minimum a valid Cargo.toml + source target, so commands like
99    // `cargo add -p <crate>` can resolve the workspace graph.
100    if plugins.contains(&"rust") {
101        ensure_rust_workspace_members_in_sandbox(working_dir, sandbox_dir);
102    }
103
104    // For Python projects: symlink .venv and seed src/<pkg>/ so uv run
105    // commands work immediately in the sandbox.
106    if plugins.contains(&"python") {
107        seed_python_sandbox(working_dir, sandbox_dir);
108    }
109
110    Ok(())
111}
112
113/// Ensure all Cargo workspace members in a sandbox have valid Cargo.toml +
114/// source target stubs.  Without this, `cargo add -p X` (or any cargo
115/// command) fails with "failed to load manifest for workspace member Y"
116/// because the sandbox only gets the current node's files but the root
117/// Cargo.toml references ALL members.
118fn ensure_rust_workspace_members_in_sandbox(working_dir: &Path, sandbox_dir: &Path) {
119    let cargo_toml = sandbox_dir.join("Cargo.toml");
120    let content = match fs::read_to_string(&cargo_toml) {
121        Ok(c) => c,
122        Err(_) => return,
123    };
124    let members = parse_workspace_members(&content);
125
126    for member in &members {
127        let member_dir = sandbox_dir.join(member);
128        let member_cargo = member_dir.join("Cargo.toml");
129
130        // Try to copy from main workspace first (preserves any real content)
131        let src_cargo = working_dir.join(member).join("Cargo.toml");
132        if src_cargo.exists() && !member_cargo.exists() {
133            let _ = fs::create_dir_all(&member_dir);
134            let _ = fs::copy(&src_cargo, &member_cargo);
135        }
136
137        // Create a stub Cargo.toml if still missing
138        if !member_cargo.exists() {
139            let _ = fs::create_dir_all(&member_dir);
140            let name = member.rsplit('/').next().unwrap_or(member);
141            let stub = format!(
142                "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
143                name
144            );
145            let _ = fs::write(&member_cargo, &stub);
146        }
147
148        // Ensure at least one source target exists (src/lib.rs or src/main.rs)
149        let src_dir = member_dir.join("src");
150        let has_lib = src_dir.join("lib.rs").exists();
151        let has_main = src_dir.join("main.rs").exists();
152        if !has_lib && !has_main {
153            let _ = fs::create_dir_all(&src_dir);
154            // Try copying from main workspace
155            let ws_lib = working_dir.join(member).join("src").join("lib.rs");
156            let ws_main = working_dir.join(member).join("src").join("main.rs");
157            if ws_lib.exists() {
158                let _ = fs::copy(&ws_lib, src_dir.join("lib.rs"));
159            } else if ws_main.exists() {
160                let _ = fs::copy(&ws_main, src_dir.join("main.rs"));
161            } else {
162                // Create minimal stub so cargo doesn't complain about missing targets
163                let _ = fs::write(
164                    src_dir.join("lib.rs"),
165                    "// stub — will be replaced by agent\n",
166                );
167            }
168        }
169    }
170
171    if !members.is_empty() {
172        log::debug!(
173            "Ensured {} workspace member(s) have valid stubs in sandbox",
174            members.len()
175        );
176    }
177}
178
179/// Quick parse of a root Cargo.toml's `[workspace] members` array.
180fn parse_workspace_members(content: &str) -> Vec<String> {
181    let mut in_workspace = false;
182    let mut members: Vec<String> = Vec::new();
183    for raw_line in content.lines() {
184        let line = raw_line.trim();
185        if line.starts_with('[') {
186            in_workspace = line == "[workspace]";
187            continue;
188        }
189        if in_workspace && line.starts_with("members") {
190            if let Some((_, value)) = line.split_once('=') {
191                let raw = value.trim();
192                if raw.starts_with('[') {
193                    let inner = raw.trim_start_matches('[').trim_end_matches(']');
194                    for item in inner.split(',') {
195                        let member = item.trim().trim_matches('"').trim_matches('\'');
196                        if !member.is_empty() {
197                            members.push(member.to_string());
198                        }
199                    }
200                }
201            }
202        }
203    }
204    members
205}
206
207/// Seed a Python project sandbox with the workspace `.venv/` (via symlink)
208/// and the `src/<pkg>/` package directory tree so that `uv run` commands
209/// work immediately without a full re-sync.
210fn seed_python_sandbox(working_dir: &Path, sandbox_dir: &Path) {
211    // Symlink .venv/ so uv run reuses the workspace venv instead of
212    // recreating one per sandbox (saves ~2-3s per node).
213    let workspace_venv = working_dir.join(".venv");
214    let sandbox_venv = sandbox_dir.join(".venv");
215    if workspace_venv.is_dir() && !sandbox_venv.exists() {
216        #[cfg(unix)]
217        {
218            if let Err(e) = std::os::unix::fs::symlink(&workspace_venv, &sandbox_venv) {
219                log::debug!("Could not symlink .venv into sandbox: {}", e);
220            } else {
221                log::debug!("Symlinked .venv into sandbox");
222            }
223        }
224        #[cfg(not(unix))]
225        {
226            // On Windows, symlinks require elevated privileges; skip the
227            // optimisation — uv will auto-create a venv when needed.
228            log::debug!("Skipping .venv symlink on non-Unix platform");
229        }
230    }
231
232    // Seed ancillary files that `uv add` / `uv sync` need when building
233    // the project inside the sandbox.  In particular, `uv init` generates
234    // `readme = "README.md"` in pyproject.toml, so the sandbox build fails
235    // with "failed to open file README.md" if we don't copy it.
236    for ancillary in &["README.md", "README.rst", "README", ".python-version"] {
237        let src = working_dir.join(ancillary);
238        if src.is_file() {
239            let dst = sandbox_dir.join(ancillary);
240            if !dst.exists() {
241                let _ = fs::copy(&src, &dst);
242            }
243        }
244    }
245
246    // Copy the src/<pkg>/ directory tree so imports resolve.  We walk one
247    // level under src/ looking for Python packages (__init__.py present).
248    let workspace_src = working_dir.join("src");
249    if workspace_src.is_dir() {
250        if let Ok(entries) = fs::read_dir(&workspace_src) {
251            for entry in entries.flatten() {
252                let pkg_dir = entry.path();
253                if pkg_dir.is_dir() && pkg_dir.join("__init__.py").exists() {
254                    // Recursively copy all .py files from this package
255                    if let Err(e) = copy_dir_to_sandbox(working_dir, sandbox_dir, &pkg_dir) {
256                        log::debug!(
257                            "Could not seed src/{} into sandbox: {}",
258                            entry.file_name().to_string_lossy(),
259                            e
260                        );
261                    }
262                }
263            }
264        }
265    }
266
267    // Also copy conftest.py / tests/ directory if present (needed for pytest)
268    for extra in &["conftest.py", "tests"] {
269        let src = working_dir.join(extra);
270        if src.is_file() {
271            let rel = extra.to_string();
272            let _ = copy_to_sandbox(working_dir, sandbox_dir, &rel);
273        } else if src.is_dir() {
274            let _ = copy_dir_to_sandbox(working_dir, sandbox_dir, &src);
275        }
276    }
277}
278
279/// Recursively copy a directory from workspace into sandbox, preserving
280/// relative paths.  Skips `.venv`, `__pycache__`, and bytecode files.
281fn copy_dir_to_sandbox(
282    working_dir: &Path,
283    sandbox_dir: &Path,
284    src_dir: &Path,
285) -> std::io::Result<()> {
286    const SKIP: &[&str] = &[".venv", "__pycache__", ".mypy_cache", ".pytest_cache"];
287    for entry in fs::read_dir(src_dir)? {
288        let entry = entry?;
289        let path = entry.path();
290        let name = entry.file_name();
291        let name_str = name.to_string_lossy();
292
293        if path.is_dir() {
294            if SKIP.iter().any(|s| *s == &*name_str) {
295                continue;
296            }
297            copy_dir_to_sandbox(working_dir, sandbox_dir, &path)?;
298        } else if !name_str.ends_with(".pyc") {
299            if let Ok(rel) = path.strip_prefix(working_dir) {
300                let rel_str = rel.to_string_lossy().to_string();
301                copy_to_sandbox(working_dir, sandbox_dir, &rel_str)?;
302            }
303        }
304    }
305    Ok(())
306}
307
308/// Clean up a specific sandbox workspace.
309pub fn cleanup_sandbox(sandbox_dir: &Path) -> std::io::Result<()> {
310    if sandbox_dir.exists() {
311        fs::remove_dir_all(sandbox_dir)?;
312        log::debug!("Cleaned up sandbox at {}", sandbox_dir.display());
313    }
314    Ok(())
315}
316
317/// Clean up all sandbox workspaces for a session.
318pub fn cleanup_session_sandboxes(working_dir: &Path, session_id: &str) -> std::io::Result<()> {
319    let session_sandbox = working_dir
320        .join(".perspt")
321        .join("sandboxes")
322        .join(session_id);
323
324    if session_sandbox.exists() {
325        fs::remove_dir_all(&session_sandbox)?;
326        log::debug!("Cleaned up all sandboxes for session {}", session_id);
327    }
328    Ok(())
329}
330
331/// Copy a file from the workspace into a sandbox, preserving relative paths.
332pub fn copy_to_sandbox(
333    working_dir: &Path,
334    sandbox_dir: &Path,
335    relative_path: &str,
336) -> std::io::Result<()> {
337    let src = working_dir.join(relative_path);
338    let dst = sandbox_dir.join(relative_path);
339
340    if let Some(parent) = dst.parent() {
341        fs::create_dir_all(parent)?;
342    }
343
344    if src.exists() {
345        fs::copy(&src, &dst)?;
346    }
347    Ok(())
348}
349
350/// Copy a file from a sandbox back to the live workspace, preserving relative paths.
351pub fn copy_from_sandbox(
352    sandbox_dir: &Path,
353    working_dir: &Path,
354    relative_path: &str,
355) -> std::io::Result<()> {
356    let src = sandbox_dir.join(relative_path);
357    let dst = working_dir.join(relative_path);
358
359    if let Some(parent) = dst.parent() {
360        fs::create_dir_all(parent)?;
361    }
362
363    if src.exists() {
364        fs::copy(&src, &dst)?;
365    }
366    Ok(())
367}
368
369/// List all files in a sandbox directory as workspace-relative paths.
370pub fn list_sandbox_files(sandbox_dir: &Path) -> std::io::Result<Vec<String>> {
371    let mut files = Vec::new();
372    if !sandbox_dir.exists() {
373        return Ok(files);
374    }
375    /// Directories that should never be exported from sandbox back to
376    /// workspace — virtual-environments, bytecode caches, build artifacts.
377    const SKIP_DIRS: &[&str] = &[
378        ".venv",
379        "__pycache__",
380        "node_modules",
381        ".mypy_cache",
382        ".pytest_cache",
383        ".ruff_cache",
384    ];
385    fn walk(dir: &Path, base: &Path, out: &mut Vec<String>) -> std::io::Result<()> {
386        for entry in fs::read_dir(dir)? {
387            let entry = entry?;
388            let path = entry.path();
389            if path.is_dir() {
390                let name = entry.file_name();
391                let name_str = name.to_string_lossy();
392                if SKIP_DIRS.iter().any(|s| *s == &*name_str) {
393                    continue;
394                }
395                walk(&path, base, out)?;
396            } else if let Ok(rel) = path.strip_prefix(base) {
397                let normalized = rel
398                    .components()
399                    .map(|component| component.as_os_str().to_string_lossy().into_owned())
400                    .collect::<Vec<_>>()
401                    .join("/");
402                // Skip bytecode / lock artifacts that shouldn't transfer
403                if !normalized.ends_with(".pyc") {
404                    out.push(normalized);
405                }
406            }
407        }
408        Ok(())
409    }
410    walk(sandbox_dir, sandbox_dir, &mut files)?;
411    Ok(files)
412}