Skip to main content

sa3p_host_impl/
lib.rs

1//! Local host implementations for `sa3p-engine` traits.
2//!
3//! - `LocalVfs` implements atomic file operations, hashing, and tree listing.
4//! - `LocalTerminal` implements command execution, timeout detachment, and
5//!   process signaling for detached jobs.
6
7use 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}