1use crate::content::types::Fixture;
2use std::fs;
3use std::io::Write;
4use std::path::PathBuf;
5use std::process::{Command, Stdio};
6use std::sync::mpsc;
7use std::thread;
8use std::time::Duration;
9use thiserror::Error;
10
11const TIMEOUT_SECS: u64 = 3;
12
13#[derive(Debug, Clone)]
14pub struct ExecutionResult {
15 pub stdout: String,
16 pub stderr: String,
17 pub timed_out: bool,
18}
19
20#[derive(Debug, Error)]
21pub enum ExecutorError {
22 #[error("Failed to create temp directory: {0}")]
23 TempDir(#[from] std::io::Error),
24 #[error("Failed to write fixture '{0}': {1}")]
25 FixtureWrite(String, std::io::Error),
26}
27
28pub struct Executor;
29
30impl Executor {
31 pub fn run(command: &str, fixtures: &[Fixture]) -> Result<ExecutionResult, ExecutorError> {
32 let tmp = create_temp_dir()?;
33 write_fixtures(&tmp, fixtures)?;
34
35 let path_env = std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_string());
36 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
37 let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
38
39 let mut child = Command::new("sh")
40 .arg("-c")
41 .arg(command)
42 .current_dir(&tmp)
43 .env_clear()
44 .env("PATH", &path_env)
45 .env("HOME", &home)
46 .env("USER", &user)
47 .env("TERM", "xterm")
48 .stdout(Stdio::piped())
49 .stderr(Stdio::piped())
50 .spawn()
51 .map_err(ExecutorError::TempDir)?;
52
53 let (tx, rx) = mpsc::channel();
55 let child_id = child.id();
56 thread::spawn(move || {
57 thread::sleep(Duration::from_secs(TIMEOUT_SECS));
58 let _ = tx.send(());
59 unsafe {
61 libc_kill(child_id);
62 }
63 });
64
65 let stdout_handle = child.stdout.take();
66 let stderr_handle = child.stderr.take();
67
68 let stdout_thread = thread::spawn(move || {
70 use std::io::Read;
71 let mut buf = String::new();
72 if let Some(mut s) = stdout_handle {
73 let _ = s.read_to_string(&mut buf);
74 }
75 buf
76 });
77 let stderr_thread = thread::spawn(move || {
78 use std::io::Read;
79 let mut buf = String::new();
80 if let Some(mut s) = stderr_handle {
81 let _ = s.read_to_string(&mut buf);
82 }
83 buf
84 });
85
86 let status = child.wait().map_err(ExecutorError::TempDir)?;
87 let stdout = stdout_thread.join().unwrap_or_default();
88 let stderr = stderr_thread.join().unwrap_or_default();
89
90 let timed_out = rx.try_recv().is_ok() || !status.success() && stderr.contains("Killed");
91
92 let _ = fs::remove_dir_all(&tmp);
93
94 Ok(ExecutionResult {
95 stdout,
96 stderr,
97 timed_out,
98 })
99 }
100}
101
102fn create_temp_dir() -> Result<PathBuf, ExecutorError> {
103 use std::sync::atomic::{AtomicU64, Ordering};
104 static COUNTER: AtomicU64 = AtomicU64::new(0);
105 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
106 let base = std::env::temp_dir().join(format!("cli-tutor-{}-{}", std::process::id(), n));
107 fs::create_dir_all(&base).map_err(ExecutorError::TempDir)?;
108 Ok(base)
109}
110
111fn write_fixtures(dir: &std::path::Path, fixtures: &[Fixture]) -> Result<(), ExecutorError> {
112 for fixture in fixtures {
113 let path = dir.join(&fixture.filename);
115 if let Some(parent) = path.parent() {
116 fs::create_dir_all(parent)
117 .map_err(|e| ExecutorError::FixtureWrite(fixture.filename.clone(), e))?;
118 }
119 let mut f = fs::File::create(&path)
120 .map_err(|e| ExecutorError::FixtureWrite(fixture.filename.clone(), e))?;
121 f.write_all(fixture.content.as_bytes())
122 .map_err(|e| ExecutorError::FixtureWrite(fixture.filename.clone(), e))?;
123 }
124 Ok(())
125}
126
127#[allow(unsafe_code)]
128unsafe fn libc_kill(pid: u32) {
129 libc_kill_raw(pid as i32, 15);
131}
132
133#[cfg(unix)]
134fn libc_kill_raw(pid: i32, sig: i32) {
135 unsafe {
136 libc_sys_kill(pid, sig);
137 }
138}
139
140#[cfg(not(unix))]
141fn libc_kill_raw(_pid: i32, _sig: i32) {}
142
143#[cfg(unix)]
145extern "C" {
146 fn kill(pid: i32, sig: i32) -> i32;
147}
148
149#[cfg(unix)]
150unsafe fn libc_sys_kill(pid: i32, sig: i32) {
151 kill(pid, sig);
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn test_basic_stdout_capture() {
160 let result = Executor::run("echo hello", &[]).unwrap();
161 assert_eq!(result.stdout.trim(), "hello");
162 assert!(!result.timed_out);
163 }
164
165 #[test]
166 fn test_stderr_capture() {
167 let result = Executor::run("echo err >&2", &[]).unwrap();
168 assert_eq!(result.stderr.trim(), "err");
169 }
170
171 #[test]
172 fn test_fixture_written() {
173 let fixtures = vec![Fixture {
174 filename: "test.txt".to_string(),
175 content: "hello\nworld\n".to_string(),
176 }];
177 let result = Executor::run("cat test.txt", &fixtures).unwrap();
178 assert_eq!(result.stdout, "hello\nworld\n");
179 }
180
181 #[test]
182 fn test_timeout() {
183 let result = Executor::run("sleep 10", &[]).unwrap();
184 assert!(result.timed_out || !result.stderr.is_empty());
185 }
186
187 #[test]
188 fn test_empty_env_strips_custom_vars() {
189 let result = Executor::run("echo ${MY_SECRET:-not_set}", &[]).unwrap();
190 assert_eq!(result.stdout.trim(), "not_set");
191 }
192}