1use std::collections::HashMap;
8use std::fs;
9use std::io::Read;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command, Stdio};
13use std::thread;
14use std::time::{Duration, Instant, SystemTime};
15
16use ignore::WalkBuilder;
17use sa3p_engine::{
18 EngineError, FileHash, NodeKind, ProcessSignal, Result, TerminalExecution, TerminalProvider,
19 TreeNode, VirtualFileSystem,
20};
21use sha2::{Digest, Sha256};
22
23pub struct LocalVfs {
24 root: PathBuf,
25 recent_window: Duration,
26}
27
28impl LocalVfs {
29 pub fn new(root: impl Into<PathBuf>) -> Self {
30 Self {
31 root: root.into(),
32 recent_window: Duration::from_secs(60 * 60 * 24),
33 }
34 }
35
36 pub fn with_recent_window(mut self, recent_window: Duration) -> Self {
37 self.recent_window = recent_window;
38 self
39 }
40
41 fn resolve(&self, path: &Path) -> PathBuf {
42 if path.is_absolute() {
43 path.to_path_buf()
44 } else {
45 self.root.join(path)
46 }
47 }
48
49 fn to_relative(&self, path: &Path) -> PathBuf {
50 path.strip_prefix(&self.root)
51 .map(PathBuf::from)
52 .unwrap_or_else(|_| path.to_path_buf())
53 }
54
55 fn walk_builder(&self, root: &Path) -> WalkBuilder {
56 let mut builder = WalkBuilder::new(root);
57 builder.hidden(false);
58 builder.git_ignore(true);
59 builder.git_exclude(true);
60 builder.ignore(true);
61 builder.add_custom_ignore_filename(".sa3pignore");
62 builder
63 }
64}
65
66impl VirtualFileSystem for LocalVfs {
67 fn read(&self, path: &Path) -> Result<Vec<u8>> {
68 let absolute = self.resolve(path);
69 fs::read(&absolute)
70 .map_err(|err| EngineError::Vfs(format!("read {} failed: {err}", absolute.display())))
71 }
72
73 fn write_atomic(&self, path: &Path, bytes: &[u8]) -> Result<()> {
74 let absolute = self.resolve(path);
75 let parent = absolute.parent().ok_or_else(|| {
76 EngineError::Vfs(format!(
77 "cannot determine parent directory for {}",
78 absolute.display()
79 ))
80 })?;
81 fs::create_dir_all(parent).map_err(|err| {
82 EngineError::Vfs(format!("mkdir -p {} failed: {err}", parent.display()))
83 })?;
84
85 let tmp_name = format!(
86 ".sa3p.{}.{}.tmp",
87 std::process::id(),
88 SystemTime::now()
89 .duration_since(SystemTime::UNIX_EPOCH)
90 .map_err(|err| EngineError::Vfs(format!("system clock error: {err}")))?
91 .as_nanos()
92 );
93 let tmp_path = parent.join(tmp_name);
94
95 {
96 let mut tmp_file = fs::File::create(&tmp_path).map_err(|err| {
97 EngineError::Vfs(format!("create {} failed: {err}", tmp_path.display()))
98 })?;
99 tmp_file.write_all(bytes).map_err(|err| {
100 EngineError::Vfs(format!("write {} failed: {err}", tmp_path.display()))
101 })?;
102 tmp_file.sync_all().map_err(|err| {
103 EngineError::Vfs(format!("sync {} failed: {err}", tmp_path.display()))
104 })?;
105 }
106
107 fs::rename(&tmp_path, &absolute).map_err(|err| {
108 EngineError::Vfs(format!(
109 "rename {} -> {} failed: {err}",
110 tmp_path.display(),
111 absolute.display()
112 ))
113 })?;
114
115 if should_be_executable(path, bytes) {
116 make_executable(&absolute)?;
117 }
118
119 Ok(())
120 }
121
122 fn hash(&self, path: &Path) -> Result<String> {
123 let bytes = self.read(path)?;
124 Ok(sha256_hex(&bytes))
125 }
126
127 fn cwd(&self) -> Result<PathBuf> {
128 Ok(self.root.clone())
129 }
130
131 fn list_tree(&self, path: &Path) -> Result<Vec<TreeNode>> {
132 let target = self.resolve(path);
133 if !target.exists() {
134 return Err(EngineError::Vfs(format!(
135 "list root {} does not exist",
136 target.display()
137 )));
138 }
139
140 let mut files = Vec::<PathBuf>::new();
141 let mut dirs = HashMap::<PathBuf, bool>::new();
142 let now = SystemTime::now();
143
144 for entry in self.walk_builder(&target).build() {
145 let entry = entry.map_err(|err| EngineError::Vfs(format!("walk failed: {err}")))?;
146 let absolute = entry.path();
147 if absolute == target {
148 continue;
149 }
150
151 let file_type = entry
152 .file_type()
153 .ok_or_else(|| EngineError::Vfs("missing file type during walk".to_string()))?;
154 let metadata = entry
155 .metadata()
156 .map_err(|err| EngineError::Vfs(format!("metadata failed: {err}")))?;
157 let modified_recently = metadata
158 .modified()
159 .ok()
160 .and_then(|modified| now.duration_since(modified).ok())
161 .is_some_and(|age| age <= self.recent_window);
162
163 let rel = self.to_relative(absolute);
164 if file_type.is_file() {
165 files.push(rel.clone());
166 } else if file_type.is_dir() {
167 dirs.insert(rel.clone(), modified_recently);
168 }
169 }
170
171 let mut descendant_counts = HashMap::<PathBuf, usize>::new();
172 for file in &files {
173 let mut parent = file.parent();
174 while let Some(dir) = parent {
175 if dir.as_os_str().is_empty() {
176 break;
177 }
178 *descendant_counts.entry(dir.to_path_buf()).or_default() += 1;
179 parent = dir.parent();
180 }
181 }
182
183 let mut nodes = Vec::new();
184 for (dir, modified_recently) in dirs {
185 nodes.push(TreeNode {
186 path: dir.clone(),
187 kind: NodeKind::Directory,
188 descendant_file_count: *descendant_counts.get(&dir).unwrap_or(&0),
189 modified_recently,
190 });
191 }
192
193 for file in files {
194 let absolute = self.resolve(&file);
195 let modified_recently = fs::metadata(&absolute)
196 .and_then(|meta| meta.modified())
197 .ok()
198 .and_then(|modified| now.duration_since(modified).ok())
199 .is_some_and(|age| age <= self.recent_window);
200 nodes.push(TreeNode {
201 path: file,
202 kind: NodeKind::File,
203 descendant_file_count: 0,
204 modified_recently,
205 });
206 }
207
208 Ok(nodes)
209 }
210
211 fn recent_file_hashes(&self, limit: usize) -> Result<Vec<FileHash>> {
212 let mut candidates = Vec::<(PathBuf, SystemTime)>::new();
213
214 for entry in self.walk_builder(&self.root).build() {
215 let entry = entry.map_err(|err| EngineError::Vfs(format!("walk failed: {err}")))?;
216 let file_type = match entry.file_type() {
217 Some(file_type) => file_type,
218 None => continue,
219 };
220 if !file_type.is_file() {
221 continue;
222 }
223 let metadata = entry
224 .metadata()
225 .map_err(|err| EngineError::Vfs(format!("metadata failed: {err}")))?;
226 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
227 candidates.push((entry.path().to_path_buf(), modified));
228 }
229
230 candidates.sort_by(|a, b| b.1.cmp(&a.1));
231 candidates.truncate(limit);
232
233 let mut out = Vec::new();
234 for (absolute, _) in candidates {
235 let bytes = fs::read(&absolute).map_err(|err| {
236 EngineError::Vfs(format!("read {} failed: {err}", absolute.display()))
237 })?;
238 out.push(FileHash {
239 path: self.to_relative(&absolute),
240 sha256: sha256_hex(&bytes),
241 });
242 }
243
244 Ok(out)
245 }
246}
247
248pub struct LocalTerminal {
249 cwd: PathBuf,
250 shell: String,
251 background: HashMap<u32, Child>,
252}
253
254impl LocalTerminal {
255 pub fn new(cwd: impl Into<PathBuf>) -> Self {
256 Self {
257 cwd: cwd.into(),
258 shell: "zsh".to_string(),
259 background: HashMap::new(),
260 }
261 }
262
263 pub fn with_shell(mut self, shell: impl Into<String>) -> Self {
264 self.shell = shell.into();
265 self
266 }
267
268 fn reap_background(&mut self) {
269 let pids: Vec<u32> = self.background.keys().copied().collect();
270 for pid in pids {
271 let Some(child) = self.background.get_mut(&pid) else {
272 continue;
273 };
274 if child.try_wait().ok().flatten().is_some() {
275 self.background.remove(&pid);
276 }
277 }
278 }
279}
280
281impl TerminalProvider for LocalTerminal {
282 fn run(&mut self, command: &str, timeout: Duration) -> Result<TerminalExecution> {
283 if command.trim().is_empty() {
284 return Err(EngineError::Terminal("empty terminal command".to_string()));
285 }
286
287 self.reap_background();
288
289 let mut child = Command::new(&self.shell)
290 .arg("-lc")
291 .arg(command)
292 .current_dir(&self.cwd)
293 .stdout(Stdio::piped())
294 .stderr(Stdio::piped())
295 .spawn()
296 .map_err(|err| EngineError::Terminal(format!("spawn failed: {err}")))?;
297
298 let started = Instant::now();
299 loop {
300 if let Some(status) = child
301 .try_wait()
302 .map_err(|err| EngineError::Terminal(format!("wait failed: {err}")))?
303 {
304 let mut stdout = Vec::new();
305 if let Some(mut pipe) = child.stdout.take() {
306 pipe.read_to_end(&mut stdout).map_err(|err| {
307 EngineError::Terminal(format!("stdout read failed: {err}"))
308 })?;
309 }
310
311 let mut stderr = Vec::new();
312 if let Some(mut pipe) = child.stderr.take() {
313 pipe.read_to_end(&mut stderr).map_err(|err| {
314 EngineError::Terminal(format!("stderr read failed: {err}"))
315 })?;
316 }
317
318 let mut output = String::new();
319 output.push_str(&String::from_utf8_lossy(&stdout));
320 output.push_str(&String::from_utf8_lossy(&stderr));
321 if !output.is_empty() && !output.ends_with('\n') {
322 output.push('\n');
323 }
324 output.push_str(&format!(
325 "[EXIT_CODE: {} | CWD: {}]",
326 status.code().unwrap_or(-1),
327 self.cwd.display()
328 ));
329
330 return Ok(TerminalExecution {
331 output,
332 exit_code: status.code(),
333 cwd: self.cwd.clone(),
334 detached_pid: None,
335 });
336 }
337
338 if started.elapsed() >= timeout {
339 let pid = child.id();
340 self.background.insert(pid, child);
341 return Ok(TerminalExecution {
342 output: format!(
343 "[PROCESS DETACHED: PID {}. Running in background | CWD: {}]",
344 pid,
345 self.cwd.display()
346 ),
347 exit_code: None,
348 cwd: self.cwd.clone(),
349 detached_pid: Some(pid),
350 });
351 }
352
353 thread::sleep(Duration::from_millis(25));
354 }
355 }
356
357 fn signal(&mut self, pid: u32, signal: ProcessSignal) -> Result<()> {
358 self.reap_background();
359
360 let signal_code = match signal {
361 ProcessSignal::SigInt => libc::SIGINT,
362 ProcessSignal::SigTerm => libc::SIGTERM,
363 ProcessSignal::SigKill => libc::SIGKILL,
364 };
365
366 #[cfg(unix)]
367 {
368 let rc = unsafe { libc::kill(pid as i32, signal_code) };
369 if rc != 0 {
370 return Err(EngineError::Terminal(format!(
371 "failed to signal pid {} with {}",
372 pid, signal_code
373 )));
374 }
375 }
376
377 #[cfg(not(unix))]
378 {
379 let _ = signal_code;
380 return Err(EngineError::Terminal(
381 "terminal signaling is only implemented on unix hosts".to_string(),
382 ));
383 }
384
385 if let Some(mut child) = self.background.remove(&pid) {
386 let _ = child.try_wait();
387 }
388
389 Ok(())
390 }
391
392 fn active_pids(&self) -> Vec<u32> {
393 let mut pids: Vec<u32> = self.background.keys().copied().collect();
394 pids.sort_unstable();
395 pids
396 }
397}
398
399fn should_be_executable(path: &Path, bytes: &[u8]) -> bool {
400 bytes.starts_with(b"#!") || path.components().any(|c| c.as_os_str() == "bin")
401}
402
403fn make_executable(path: &Path) -> Result<()> {
404 #[cfg(unix)]
405 {
406 use std::os::unix::fs::PermissionsExt;
407
408 let metadata = fs::metadata(path).map_err(|err| {
409 EngineError::Vfs(format!("metadata {} failed: {err}", path.display()))
410 })?;
411 let mut perms = metadata.permissions();
412 perms.set_mode(perms.mode() | 0o111);
413 fs::set_permissions(path, perms).map_err(|err| {
414 EngineError::Vfs(format!("chmod +x {} failed: {err}", path.display()))
415 })?;
416 }
417 Ok(())
418}
419
420fn sha256_hex(bytes: &[u8]) -> String {
421 let mut hasher = Sha256::new();
422 hasher.update(bytes);
423 format!("{:x}", hasher.finalize())
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use std::os::unix::fs::PermissionsExt;
430 use tempfile::tempdir;
431
432 #[test]
433 fn write_atomic_creates_parents_and_writes_content() {
434 let dir = tempdir().expect("tmpdir");
435 let vfs = LocalVfs::new(dir.path());
436
437 vfs.write_atomic(Path::new("src/main.rs"), b"fn main() {}")
438 .expect("write should succeed");
439
440 let body = fs::read_to_string(dir.path().join("src/main.rs")).expect("read file");
441 assert_eq!(body, "fn main() {}");
442 }
443
444 #[test]
445 fn write_atomic_auto_chmods_shebang_files() {
446 let dir = tempdir().expect("tmpdir");
447 let vfs = LocalVfs::new(dir.path());
448
449 vfs.write_atomic(
450 Path::new("scripts/run.sh"),
451 b"#!/usr/bin/env bash\necho hi\n",
452 )
453 .expect("write should succeed");
454
455 let mode = fs::metadata(dir.path().join("scripts/run.sh"))
456 .expect("metadata")
457 .permissions()
458 .mode();
459 assert_ne!(mode & 0o111, 0);
460 }
461
462 #[test]
463 fn list_tree_respects_sa3pignore() {
464 let dir = tempdir().expect("tmpdir");
465 fs::write(dir.path().join(".sa3pignore"), "ignored_dir/\n*.tmp\n").expect("write ignore");
466 fs::create_dir_all(dir.path().join("ignored_dir")).expect("mkdir");
467 fs::write(dir.path().join("ignored_dir/secret.txt"), "x").expect("write");
468 fs::create_dir_all(dir.path().join("src")).expect("mkdir");
469 fs::write(dir.path().join("src/lib.rs"), "pub fn x() {}\n").expect("write");
470 fs::write(dir.path().join("notes.tmp"), "temp").expect("write");
471
472 let vfs = LocalVfs::new(dir.path());
473 let nodes = vfs.list_tree(Path::new(".")).expect("list tree");
474
475 assert!(nodes.iter().any(|n| n.path == Path::new("src/lib.rs")));
476 assert!(
477 !nodes
478 .iter()
479 .any(|n| n.path.to_string_lossy().contains("ignored_dir"))
480 );
481 assert!(
482 !nodes
483 .iter()
484 .any(|n| n.path.to_string_lossy().contains("notes.tmp"))
485 );
486 }
487
488 #[test]
489 fn recent_hashes_returns_paths_and_hashes() {
490 let dir = tempdir().expect("tmpdir");
491 fs::create_dir_all(dir.path().join("src")).expect("mkdir");
492 fs::write(dir.path().join("src/a.rs"), "a").expect("write");
493 fs::write(dir.path().join("src/b.rs"), "b").expect("write");
494
495 let vfs = LocalVfs::new(dir.path());
496 let hashes = vfs.recent_file_hashes(10).expect("recent hashes");
497
498 assert!(
499 hashes
500 .iter()
501 .any(|entry| entry.path == Path::new("src/a.rs"))
502 );
503 assert!(hashes.iter().all(|entry| !entry.sha256.is_empty()));
504 }
505
506 #[test]
507 fn terminal_run_completes_with_exit_code() {
508 let dir = tempdir().expect("tmpdir");
509 let mut terminal = LocalTerminal::new(dir.path());
510
511 let result = terminal
512 .run("echo hello", Duration::from_secs(2))
513 .expect("terminal run");
514
515 assert!(result.output.contains("hello"));
516 assert_eq!(result.exit_code, Some(0));
517 assert!(result.detached_pid.is_none());
518 }
519
520 #[test]
521 fn terminal_detaches_and_can_be_signaled() {
522 let dir = tempdir().expect("tmpdir");
523 let mut terminal = LocalTerminal::new(dir.path());
524
525 let result = terminal
526 .run("sleep 5", Duration::from_millis(50))
527 .expect("terminal run");
528
529 let pid = result.detached_pid.expect("expected detached pid");
530 assert!(terminal.active_pids().contains(&pid));
531
532 terminal
533 .signal(pid, ProcessSignal::SigTerm)
534 .expect("signal should succeed");
535 }
536}