use crate::errors::LitError;
use crate::response::SandboxResponse;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;
const SANDBOX_META: &str = ".sandbox.toml";
fn validate_sandbox_name(name: &str) -> Result<(), LitError> {
if name.is_empty() || name.len() > 128 {
return Err(LitError::general(
"sandbox name must be 1-128 characters".to_string(),
));
}
if name.starts_with('.') {
return Err(LitError::general(
"sandbox name must not start with '.'".to_string(),
));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(LitError::general(
"sandbox name contains invalid characters (allowed: a-z, A-Z, 0-9, -, _, .)"
.to_string(),
));
}
Ok(())
}
fn sandbox_base(repo_root: &Path) -> PathBuf {
repo_root.join(".lit").join("sandboxes")
}
fn sandbox_dir(repo_root: &Path, name: &str) -> PathBuf {
sandbox_base(repo_root).join(name)
}
pub fn execute_init(name: Option<String>) -> Result<SandboxResponse, LitError> {
let repo_root = crate::core::find_repo_root()?;
let cwd =
std::env::current_dir().map_err(|e| LitError::io(format!("cannot determine cwd: {e}")))?;
let source = if cwd.starts_with(&repo_root) && cwd != repo_root {
cwd.clone()
} else {
repo_root.clone()
};
let name =
name.unwrap_or_else(|| format!("sandbox-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S")));
validate_sandbox_name(&name)?;
let sb_dir = sandbox_dir(&repo_root, &name);
if sb_dir.exists() {
return Err(LitError::general(format!(
"sandbox '{}' already exists at {}",
name,
sb_dir.display()
)));
}
fs::create_dir_all(&sb_dir)
.map_err(|e| LitError::io(format!("failed to create sandbox dir: {e}")))?;
copy_tree(&source, &sb_dir, &repo_root)?;
let meta = format!(
"# Lit sandbox metadata\ncreated = \"{}\"\nsource = \"{}\"\nname = \"{}\"\n",
chrono::Utc::now().to_rfc3339(),
source.display(),
name,
);
fs::write(sb_dir.join(SANDBOX_META), &meta)
.map_err(|e| LitError::io(format!("failed to write sandbox metadata: {e}")))?;
Ok(SandboxResponse {
action: "init".into(),
name: name.clone(),
path: sb_dir.display().to_string(),
message: format!("sandbox '{}' created", name),
output: None,
exit_code: None,
})
}
pub fn execute_run(name: String, cmd: Vec<String>) -> Result<SandboxResponse, LitError> {
let repo_root = crate::core::find_repo_root()?;
validate_sandbox_name(&name)?;
let sb_dir = sandbox_dir(&repo_root, &name);
if !sb_dir.join(SANDBOX_META).exists() {
return Err(LitError::general(format!(
"sandbox '{}' not found (expected at {})",
name,
sb_dir.display()
)));
}
if cmd.is_empty() {
return Err(LitError::general(String::from(
"no command specified - use: lit sandbox run <name> -- <command> [args...]",
)));
}
let program = &cmd[0];
let args = &cmd[1..];
let env = sandboxed_env(&sb_dir);
let result = Command::new(program)
.args(args)
.current_dir(&sb_dir)
.env_clear()
.envs(&env)
.output()
.map_err(|e| LitError::io(format!("failed to spawn command: {e}")))?;
let stdout = String::from_utf8_lossy(&result.stdout).to_string();
let stderr = String::from_utf8_lossy(&result.stderr).to_string();
let combined = if stderr.is_empty() {
stdout
} else {
format!("{stdout}\n{stderr}")
};
let code = result.status.code().unwrap_or(-1);
Ok(SandboxResponse {
action: "run".into(),
name,
path: sb_dir.display().to_string(),
message: if result.status.success() {
"command completed successfully".into()
} else {
format!("command exited with code {code}")
},
output: Some(combined),
exit_code: Some(code),
})
}
pub fn execute_list() -> Result<SandboxResponse, LitError> {
let repo_root = crate::core::find_repo_root()?;
let base = sandbox_base(&repo_root);
let mut entries = Vec::new();
if base.exists() {
for entry in fs::read_dir(&base)
.map_err(|e| LitError::io(format!("failed to read sandbox dir: {e}")))?
{
let entry = entry.map_err(|e| LitError::io(format!("failed to read entry: {e}")))?;
if entry.path().join(SANDBOX_META).exists() {
entries.push(entry.file_name().to_string_lossy().to_string());
}
}
}
entries.sort();
let message = if entries.is_empty() {
"no sandboxes".into()
} else {
entries.join("\n")
};
Ok(SandboxResponse {
action: "list".into(),
name: String::new(),
path: base.display().to_string(),
message,
output: None,
exit_code: None,
})
}
pub fn execute_destroy(name: String) -> Result<SandboxResponse, LitError> {
let repo_root = crate::core::find_repo_root()?;
validate_sandbox_name(&name)?;
let sb_dir = sandbox_dir(&repo_root, &name);
if !sb_dir.join(SANDBOX_META).exists() {
return Err(LitError::general(format!(
"sandbox '{}' not found (expected at {})",
name,
sb_dir.display()
)));
}
fs::remove_dir_all(&sb_dir)
.map_err(|e| LitError::io(format!("failed to remove sandbox: {e}")))?;
Ok(SandboxResponse {
action: "destroy".into(),
name: name.clone(),
path: sb_dir.display().to_string(),
message: format!("sandbox '{}' destroyed", name),
output: None,
exit_code: None,
})
}
fn copy_tree(src: &Path, dst: &Path, repo_root: &Path) -> Result<(), LitError> {
let skip_dirs: std::collections::HashSet<&str> =
[".lit", ".git", ".hg", "node_modules", "target"]
.iter()
.copied()
.collect();
for entry in WalkDir::new(src).into_iter().filter_entry(|e| {
let name = e.file_name().to_string_lossy();
if e.path() == sandbox_base(repo_root) {
return false;
}
if e.path_is_symlink() {
return false;
}
if e.file_type().is_dir() && skip_dirs.contains(name.as_ref()) {
return false;
}
true
}) {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let rel = entry
.path()
.strip_prefix(src)
.map_err(|e| LitError::io(format!("path strip error: {e}")))?;
if rel.as_os_str().is_empty() {
continue;
}
let target = dst.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target)
.map_err(|e| LitError::io(format!("mkdir {}: {e}", target.display())))?;
} else if entry.file_type().is_file() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.map_err(|e| LitError::io(format!("mkdir {}: {e}", parent.display())))?;
}
fs::copy(entry.path(), &target).map_err(|e| {
LitError::io(format!(
"copy {} -> {}: {e}",
entry.path().display(),
target.display()
))
})?;
}
}
Ok(())
}
fn sandboxed_env(sandbox_root: &Path) -> HashMap<String, String> {
let mut env = HashMap::new();
#[cfg(windows)]
{
let sys_root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
env.insert("PATH".into(), format!(r"{sys_root}\System32;{sys_root}"));
env.insert("SystemRoot".into(), sys_root.clone());
env.insert("SYSTEMDRIVE".into(), "C:".into());
env.insert("COMSPEC".into(), format!(r"{sys_root}\System32\cmd.exe"));
}
#[cfg(not(windows))]
{
env.insert("PATH".into(), "/usr/bin:/bin".into());
}
let sb = sandbox_root.display().to_string();
env.insert("HOME".into(), sb.clone());
#[cfg(windows)]
env.insert("USERPROFILE".into(), sb.clone());
env.insert("GIT_CONFIG_NOSYSTEM".into(), "1".into());
env.insert("GIT_TERMINAL_PROMPT".into(), "0".into());
env.insert("LIT_OUTPUT".into(), "json".into());
env.insert("LIT_AIRGAPPED".into(), "1".into());
if let Ok(tz) = std::env::var("TZ") {
env.insert("TZ".into(), tz);
}
let tmp = sandbox_root.join("tmp");
let _ = fs::create_dir_all(&tmp);
let tmp_str = tmp.display().to_string();
env.insert("TMPDIR".into(), tmp_str.clone());
env.insert("TEMP".into(), tmp_str.clone());
env.insert("TMP".into(), tmp_str);
env
}