use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tracing::{error, info};
#[derive(Debug, Clone)]
pub struct CompilationSandbox {
_original_dir: PathBuf,
work_dir: PathBuf,
}
#[derive(Debug, Clone)]
pub struct CompileResult {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
impl CompilationSandbox {
pub fn new(project_root: impl AsRef<Path>) -> Result<Self> {
let original_dir = project_root.as_ref().to_path_buf();
let work_dir = original_dir.join(".selfware-sandbox");
info!("Setting up compilation sandbox at {:?}", work_dir);
if work_dir.exists() {
std::fs::remove_dir_all(&work_dir)?;
}
let status = Command::new("git")
.arg("clone")
.arg("--no-hardlinks")
.arg(&original_dir)
.arg(&work_dir)
.status()?;
if !status.success() {
return Err(anyhow!("Failed to clone repository into sandbox"));
}
let diff_output = Command::new("git")
.args(["diff", "HEAD"])
.current_dir(&original_dir)
.output()?;
if diff_output.status.success() && !diff_output.stdout.is_empty() {
let mut apply = Command::new("git")
.args(["apply", "--allow-empty"])
.current_dir(&work_dir)
.stdin(std::process::Stdio::piped())
.spawn()?;
if let Some(ref mut stdin) = apply.stdin {
use std::io::Write;
stdin.write_all(&diff_output.stdout)?;
}
let apply_status = apply.wait()?;
if !apply_status.success() {
info!("Some uncommitted changes could not be applied to sandbox (merge conflict); proceeding with committed state");
}
}
Ok(Self {
_original_dir: original_dir,
work_dir,
})
}
pub fn work_dir(&self) -> &Path {
&self.work_dir
}
pub fn check(&self) -> Result<CompileResult> {
info!("Running 'cargo check' in sandbox");
let output = Command::new("cargo")
.arg("check")
.current_dir(&self.work_dir)
.output()?;
self.parse_output(output)
}
pub fn build_release(&self) -> Result<CompileResult> {
info!("Running 'cargo build --release' in sandbox");
let output = Command::new("cargo")
.arg("build")
.arg("--release")
.current_dir(&self.work_dir)
.output()?;
self.parse_output(output)
}
pub fn test(&self) -> Result<CompileResult> {
info!("Running 'cargo test' in sandbox");
let output = Command::new("cargo")
.arg("test")
.current_dir(&self.work_dir)
.output()?;
self.parse_output(output)
}
pub fn verify(&self) -> Result<bool> {
let check_res = self.check()?;
if !check_res.success {
error!(
"Sandbox check failed:
{}",
check_res.stderr
);
return Ok(false);
}
let test_res = self.test()?;
if !test_res.success {
error!(
"Sandbox test failed:
{}",
test_res.stderr
);
return Ok(false);
}
Ok(true)
}
pub fn cleanup(self) -> Result<()> {
if self.work_dir.exists() {
std::fs::remove_dir_all(&self.work_dir)?;
}
Ok(())
}
fn parse_output(&self, output: Output) -> Result<CompileResult> {
Ok(CompileResult {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::{Command, Output};
fn dummy_sandbox() -> CompilationSandbox {
CompilationSandbox {
_original_dir: PathBuf::from("/tmp/dummy-original"),
work_dir: PathBuf::from("/tmp/dummy-sandbox"),
}
}
fn make_output(exit_code: i32, stdout: &str, stderr: &str) -> Output {
let cmd_str = format!(
"printf '{}'; printf '{}' >&2; exit {}",
stdout.replace('\'', "'\\''"),
stderr.replace('\'', "'\\''"),
exit_code
);
Command::new("sh")
.arg("-c")
.arg(&cmd_str)
.output()
.expect("failed to run helper command")
}
#[test]
fn test_parse_output_success() {
let sandbox = dummy_sandbox();
let output = make_output(0, "all good", "");
let result = sandbox.parse_output(output).unwrap();
assert!(result.success);
assert_eq!(result.stdout, "all good");
assert!(result.stderr.is_empty());
}
#[test]
fn test_parse_output_failure() {
let sandbox = dummy_sandbox();
let output = make_output(1, "", "error: something broke");
let result = sandbox.parse_output(output).unwrap();
assert!(!result.success);
assert!(result.stdout.is_empty());
assert_eq!(result.stderr, "error: something broke");
}
#[test]
fn test_parse_output_empty() {
let sandbox = dummy_sandbox();
let output = make_output(0, "", "");
let result = sandbox.parse_output(output).unwrap();
assert!(result.success);
assert!(result.stdout.is_empty());
assert!(result.stderr.is_empty());
}
#[test]
fn test_parse_output_mixed_stdout_stderr() {
let sandbox = dummy_sandbox();
let output = make_output(0, "compiled OK", "warning: unused variable");
let result = sandbox.parse_output(output).unwrap();
assert!(result.success);
assert_eq!(result.stdout, "compiled OK");
assert_eq!(result.stderr, "warning: unused variable");
}
#[test]
fn test_parse_output_nonzero_exit_with_both_streams() {
let sandbox = dummy_sandbox();
let output = make_output(42, "partial output", "fatal error");
let result = sandbox.parse_output(output).unwrap();
assert!(!result.success);
assert_eq!(result.stdout, "partial output");
assert_eq!(result.stderr, "fatal error");
}
#[test]
fn test_new_with_nonexistent_path() {
let result = CompilationSandbox::new("/tmp/nonexistent-selfware-test-path-abc123xyz");
assert!(result.is_err());
}
#[test]
fn test_cleanup_nonexistent_dir_no_panic() {
let sandbox = CompilationSandbox {
_original_dir: PathBuf::from("/tmp/does-not-exist-original"),
work_dir: PathBuf::from("/tmp/does-not-exist-sandbox-xyz123"),
};
let result = sandbox.cleanup();
assert!(result.is_ok());
}
#[test]
fn test_compile_result_debug() {
let result = CompileResult {
success: true,
stdout: "ok".to_string(),
stderr: String::new(),
};
let debug_str = format!("{:?}", result);
assert!(debug_str.contains("CompileResult"));
assert!(debug_str.contains("true"));
}
}