1use crate::errors::LitError;
2use crate::response::SandboxResponse;
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use walkdir::WalkDir;
8
9const SANDBOX_META: &str = ".sandbox.toml";
11
12fn validate_sandbox_name(name: &str) -> Result<(), LitError> {
16 if name.is_empty() || name.len() > 128 {
17 return Err(LitError::general(
18 "sandbox name must be 1-128 characters".to_string(),
19 ));
20 }
21 if name.starts_with('.') {
22 return Err(LitError::general(
23 "sandbox name must not start with '.'".to_string(),
24 ));
25 }
26 if !name
27 .chars()
28 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
29 {
30 return Err(LitError::general(
31 "sandbox name contains invalid characters (allowed: a-z, A-Z, 0-9, -, _, .)"
32 .to_string(),
33 ));
34 }
35 Ok(())
36}
37
38fn sandbox_base(repo_root: &Path) -> PathBuf {
40 repo_root.join(".lit").join("sandboxes")
41}
42
43fn sandbox_dir(repo_root: &Path, name: &str) -> PathBuf {
45 sandbox_base(repo_root).join(name)
46}
47
48pub fn execute_init(name: Option<String>) -> Result<SandboxResponse, LitError> {
52 let repo_root = crate::core::find_repo_root()?;
53
54 let cwd =
57 std::env::current_dir().map_err(|e| LitError::io(format!("cannot determine cwd: {e}")))?;
58 let source = if cwd.starts_with(&repo_root) && cwd != repo_root {
59 cwd.clone()
60 } else {
61 repo_root.clone()
62 };
63
64 let name =
65 name.unwrap_or_else(|| format!("sandbox-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S")));
66 validate_sandbox_name(&name)?;
67
68 let sb_dir = sandbox_dir(&repo_root, &name);
69 if sb_dir.exists() {
70 return Err(LitError::general(format!(
71 "sandbox '{}' already exists at {}",
72 name,
73 sb_dir.display()
74 )));
75 }
76 fs::create_dir_all(&sb_dir)
77 .map_err(|e| LitError::io(format!("failed to create sandbox dir: {e}")))?;
78
79 copy_tree(&source, &sb_dir, &repo_root)?;
81
82 let meta = format!(
84 "# Lit sandbox metadata\ncreated = \"{}\"\nsource = \"{}\"\nname = \"{}\"\n",
85 chrono::Utc::now().to_rfc3339(),
86 source.display(),
87 name,
88 );
89 fs::write(sb_dir.join(SANDBOX_META), &meta)
90 .map_err(|e| LitError::io(format!("failed to write sandbox metadata: {e}")))?;
91
92 Ok(SandboxResponse {
93 action: "init".into(),
94 name: name.clone(),
95 path: sb_dir.display().to_string(),
96 message: format!("sandbox '{}' created", name),
97 output: None,
98 exit_code: None,
99 })
100}
101
102pub fn execute_run(name: String, cmd: Vec<String>) -> Result<SandboxResponse, LitError> {
104 let repo_root = crate::core::find_repo_root()?;
105 validate_sandbox_name(&name)?;
106 let sb_dir = sandbox_dir(&repo_root, &name);
107
108 if !sb_dir.join(SANDBOX_META).exists() {
109 return Err(LitError::general(format!(
110 "sandbox '{}' not found (expected at {})",
111 name,
112 sb_dir.display()
113 )));
114 }
115
116 if cmd.is_empty() {
117 return Err(LitError::general(String::from(
118 "no command specified - use: lit sandbox run <name> -- <command> [args...]",
119 )));
120 }
121
122 let program = &cmd[0];
123 let args = &cmd[1..];
124
125 let env = sandboxed_env(&sb_dir);
127
128 let result = Command::new(program)
129 .args(args)
130 .current_dir(&sb_dir)
131 .env_clear()
132 .envs(&env)
133 .output()
134 .map_err(|e| LitError::io(format!("failed to spawn command: {e}")))?;
135
136 let stdout = String::from_utf8_lossy(&result.stdout).to_string();
137 let stderr = String::from_utf8_lossy(&result.stderr).to_string();
138 let combined = if stderr.is_empty() {
139 stdout
140 } else {
141 format!("{stdout}\n{stderr}")
142 };
143
144 let code = result.status.code().unwrap_or(-1);
145
146 Ok(SandboxResponse {
147 action: "run".into(),
148 name,
149 path: sb_dir.display().to_string(),
150 message: if result.status.success() {
151 "command completed successfully".into()
152 } else {
153 format!("command exited with code {code}")
154 },
155 output: Some(combined),
156 exit_code: Some(code),
157 })
158}
159
160pub fn execute_list() -> Result<SandboxResponse, LitError> {
162 let repo_root = crate::core::find_repo_root()?;
163 let base = sandbox_base(&repo_root);
164
165 let mut entries = Vec::new();
166 if base.exists() {
167 for entry in fs::read_dir(&base)
168 .map_err(|e| LitError::io(format!("failed to read sandbox dir: {e}")))?
169 {
170 let entry = entry.map_err(|e| LitError::io(format!("failed to read entry: {e}")))?;
171 if entry.path().join(SANDBOX_META).exists() {
172 entries.push(entry.file_name().to_string_lossy().to_string());
173 }
174 }
175 }
176 entries.sort();
177
178 let message = if entries.is_empty() {
179 "no sandboxes".into()
180 } else {
181 entries.join("\n")
182 };
183
184 Ok(SandboxResponse {
185 action: "list".into(),
186 name: String::new(),
187 path: base.display().to_string(),
188 message,
189 output: None,
190 exit_code: None,
191 })
192}
193
194pub fn execute_destroy(name: String) -> Result<SandboxResponse, LitError> {
196 let repo_root = crate::core::find_repo_root()?;
197 validate_sandbox_name(&name)?;
198 let sb_dir = sandbox_dir(&repo_root, &name);
199
200 if !sb_dir.join(SANDBOX_META).exists() {
201 return Err(LitError::general(format!(
202 "sandbox '{}' not found (expected at {})",
203 name,
204 sb_dir.display()
205 )));
206 }
207
208 fs::remove_dir_all(&sb_dir)
209 .map_err(|e| LitError::io(format!("failed to remove sandbox: {e}")))?;
210
211 Ok(SandboxResponse {
212 action: "destroy".into(),
213 name: name.clone(),
214 path: sb_dir.display().to_string(),
215 message: format!("sandbox '{}' destroyed", name),
216 output: None,
217 exit_code: None,
218 })
219}
220
221fn copy_tree(src: &Path, dst: &Path, repo_root: &Path) -> Result<(), LitError> {
225 let skip_dirs: std::collections::HashSet<&str> =
226 [".lit", ".git", ".hg", "node_modules", "target"]
227 .iter()
228 .copied()
229 .collect();
230
231 for entry in WalkDir::new(src).into_iter().filter_entry(|e| {
232 let name = e.file_name().to_string_lossy();
233 if e.path() == sandbox_base(repo_root) {
235 return false;
236 }
237 if e.path_is_symlink() {
239 return false;
240 }
241 if e.file_type().is_dir() && skip_dirs.contains(name.as_ref()) {
242 return false;
243 }
244 true
245 }) {
246 let entry = match entry {
247 Ok(e) => e,
248 Err(_) => continue,
249 };
250
251 let rel = entry
252 .path()
253 .strip_prefix(src)
254 .map_err(|e| LitError::io(format!("path strip error: {e}")))?;
255
256 if rel.as_os_str().is_empty() {
257 continue;
258 }
259
260 let target = dst.join(rel);
261
262 if entry.file_type().is_dir() {
263 fs::create_dir_all(&target)
264 .map_err(|e| LitError::io(format!("mkdir {}: {e}", target.display())))?;
265 } else if entry.file_type().is_file() {
266 if let Some(parent) = target.parent() {
267 fs::create_dir_all(parent)
268 .map_err(|e| LitError::io(format!("mkdir {}: {e}", parent.display())))?;
269 }
270 fs::copy(entry.path(), &target).map_err(|e| {
271 LitError::io(format!(
272 "copy {} -> {}: {e}",
273 entry.path().display(),
274 target.display()
275 ))
276 })?;
277 }
278 }
279 Ok(())
280}
281
282fn sandboxed_env(sandbox_root: &Path) -> HashMap<String, String> {
287 let mut env = HashMap::new();
288
289 #[cfg(windows)]
291 {
292 let sys_root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
293 env.insert("PATH".into(), format!(r"{sys_root}\System32;{sys_root}"));
294 env.insert("SystemRoot".into(), sys_root.clone());
295 env.insert("SYSTEMDRIVE".into(), "C:".into());
296 env.insert("COMSPEC".into(), format!(r"{sys_root}\System32\cmd.exe"));
297 }
298 #[cfg(not(windows))]
299 {
300 env.insert("PATH".into(), "/usr/bin:/bin".into());
301 }
302
303 let sb = sandbox_root.display().to_string();
305 env.insert("HOME".into(), sb.clone());
306 #[cfg(windows)]
307 env.insert("USERPROFILE".into(), sb.clone());
308
309 env.insert("GIT_CONFIG_NOSYSTEM".into(), "1".into());
311 env.insert("GIT_TERMINAL_PROMPT".into(), "0".into());
312
313 env.insert("LIT_OUTPUT".into(), "json".into());
315 env.insert("LIT_AIRGAPPED".into(), "1".into());
316
317 if let Ok(tz) = std::env::var("TZ") {
319 env.insert("TZ".into(), tz);
320 }
321
322 let tmp = sandbox_root.join("tmp");
324 let _ = fs::create_dir_all(&tmp);
325 let tmp_str = tmp.display().to_string();
326 env.insert("TMPDIR".into(), tmp_str.clone());
327 env.insert("TEMP".into(), tmp_str.clone());
328 env.insert("TMP".into(), tmp_str);
329
330 env
331}