run/engine/
bash.rs

1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::{Duration, Instant};
6
7use anyhow::{Context, Result};
8use tempfile::{Builder, TempDir};
9
10use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
11
12pub struct BashEngine {
13    executable: PathBuf,
14}
15
16impl BashEngine {
17    pub fn new() -> Self {
18        let executable = resolve_bash_binary();
19        Self { executable }
20    }
21
22    fn binary(&self) -> &Path {
23        &self.executable
24    }
25
26    fn run_command(&self) -> Command {
27        Command::new(self.binary())
28    }
29}
30
31impl LanguageEngine for BashEngine {
32    fn id(&self) -> &'static str {
33        "bash"
34    }
35
36    fn display_name(&self) -> &'static str {
37        "Bash"
38    }
39
40    fn aliases(&self) -> &[&'static str] {
41        &["sh"]
42    }
43
44    fn supports_sessions(&self) -> bool {
45        true
46    }
47
48    fn validate(&self) -> Result<()> {
49        let mut cmd = self.run_command();
50        cmd.arg("--version")
51            .stdout(Stdio::null())
52            .stderr(Stdio::null());
53        cmd.status()
54            .with_context(|| format!("failed to invoke {}", self.binary().display()))?
55            .success()
56            .then_some(())
57            .ok_or_else(|| anyhow::anyhow!("{} is not executable", self.binary().display()))
58    }
59
60    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
61        let start = Instant::now();
62        let output = match payload {
63            ExecutionPayload::Inline { code } => {
64                let mut cmd = self.run_command();
65                cmd.arg("-c").arg(code);
66                cmd.stdin(Stdio::inherit());
67                cmd.output()
68            }
69            ExecutionPayload::File { path } => {
70                let mut cmd = self.run_command();
71                cmd.arg(path);
72                cmd.stdin(Stdio::inherit());
73                cmd.output()
74            }
75            ExecutionPayload::Stdin { code } => {
76                let mut cmd = self.run_command();
77                cmd.stdin(Stdio::piped())
78                    .stdout(Stdio::piped())
79                    .stderr(Stdio::piped());
80                let mut child = cmd.spawn().with_context(|| {
81                    format!(
82                        "failed to start {} for stdin execution",
83                        self.binary().display()
84                    )
85                })?;
86                if let Some(mut stdin) = child.stdin.take() {
87                    stdin.write_all(code.as_bytes())?;
88                    if !code.ends_with('\n') {
89                        stdin.write_all(b"\n")?;
90                    }
91                    stdin.flush()?;
92                }
93                child.wait_with_output()
94            }
95        }?;
96
97        Ok(ExecutionOutcome {
98            language: self.id().to_string(),
99            exit_code: output.status.code(),
100            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
101            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
102            duration: start.elapsed(),
103        })
104    }
105
106    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
107        Ok(Box::new(BashSession::new(self.executable.clone())?))
108    }
109}
110
111fn resolve_bash_binary() -> PathBuf {
112    let candidates = ["bash", "sh"];
113    for name in candidates {
114        if let Ok(path) = which::which(name) {
115            return path;
116        }
117    }
118    PathBuf::from("/bin/bash")
119}
120
121struct BashSession {
122    executable: PathBuf,
123    dir: TempDir,
124    script_path: PathBuf,
125    statements: Vec<String>,
126    previous_stdout: String,
127    previous_stderr: String,
128}
129
130impl BashSession {
131    fn new(executable: PathBuf) -> Result<Self> {
132        let dir = Builder::new()
133            .prefix("run-bash-repl")
134            .tempdir()
135            .context("failed to create temporary directory for bash repl")?;
136        let script_path = dir.path().join("session.sh");
137        fs::write(&script_path, "#!/usr/bin/env bash\nset -e\n")
138            .with_context(|| format!("failed to initialize {}", script_path.display()))?;
139
140        Ok(Self {
141            executable,
142            dir,
143            script_path,
144            statements: Vec::new(),
145            previous_stdout: String::new(),
146            previous_stderr: String::new(),
147        })
148    }
149
150    fn render_script(&self) -> String {
151        let mut script = String::from("#!/usr/bin/env bash\nset -e\n");
152        for stmt in &self.statements {
153            script.push_str(stmt);
154            if !stmt.ends_with('\n') {
155                script.push('\n');
156            }
157        }
158        script
159    }
160
161    fn write_script(&self, contents: &str) -> Result<()> {
162        fs::write(&self.script_path, contents).with_context(|| {
163            format!(
164                "failed to write generated Bash REPL script to {}",
165                self.script_path.display()
166            )
167        })
168    }
169
170    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
171        let script = self.render_script();
172        self.write_script(&script)?;
173
174        let output = self.run_script()?;
175        let stdout_full = normalize_output(&output.stdout);
176        let stderr_full = normalize_output(&output.stderr);
177
178        let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
179        let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
180
181        let success = output.status.success();
182        if success {
183            self.previous_stdout = stdout_full;
184            self.previous_stderr = stderr_full;
185        }
186
187        let outcome = ExecutionOutcome {
188            language: "bash".to_string(),
189            exit_code: output.status.code(),
190            stdout: stdout_delta,
191            stderr: stderr_delta,
192            duration: start.elapsed(),
193        };
194
195        Ok((outcome, success))
196    }
197
198    fn run_script(&self) -> Result<std::process::Output> {
199        let mut cmd = Command::new(&self.executable);
200        cmd.arg(&self.script_path)
201            .stdout(Stdio::piped())
202            .stderr(Stdio::piped())
203            .current_dir(self.dir.path());
204        cmd.output().with_context(|| {
205            format!(
206                "failed to execute bash session script {} with {}",
207                self.script_path.display(),
208                self.executable.display()
209            )
210        })
211    }
212
213    fn run_snippet(&mut self, snippet: String) -> Result<ExecutionOutcome> {
214        self.statements.push(snippet);
215        let start = Instant::now();
216        let (outcome, success) = self.run_current(start)?;
217        if !success {
218            let _ = self.statements.pop();
219            let script = self.render_script();
220            self.write_script(&script)?;
221        }
222        Ok(outcome)
223    }
224
225    fn reset_state(&mut self) -> Result<()> {
226        self.statements.clear();
227        self.previous_stdout.clear();
228        self.previous_stderr.clear();
229        let script = self.render_script();
230        self.write_script(&script)
231    }
232}
233
234impl LanguageSession for BashSession {
235    fn language_id(&self) -> &str {
236        "bash"
237    }
238
239    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
240        let trimmed = code.trim();
241        if trimmed.is_empty() {
242            return Ok(ExecutionOutcome {
243                language: self.language_id().to_string(),
244                exit_code: None,
245                stdout: String::new(),
246                stderr: String::new(),
247                duration: Duration::default(),
248            });
249        }
250
251        if trimmed.eq_ignore_ascii_case(":reset") {
252            self.reset_state()?;
253            return Ok(ExecutionOutcome {
254                language: self.language_id().to_string(),
255                exit_code: None,
256                stdout: String::new(),
257                stderr: String::new(),
258                duration: Duration::default(),
259            });
260        }
261
262        if trimmed.eq_ignore_ascii_case(":help") {
263            return Ok(ExecutionOutcome {
264                language: self.language_id().to_string(),
265                exit_code: None,
266                stdout:
267                    "Bash commands:\n  :reset — clear session state\n  :help  — show this message\n"
268                        .to_string(),
269                stderr: String::new(),
270                duration: Duration::default(),
271            });
272        }
273
274        let snippet = ensure_trailing_newline(code);
275        self.run_snippet(snippet)
276    }
277
278    fn shutdown(&mut self) -> Result<()> {
279        // TempDir cleanup handled automatically.
280        Ok(())
281    }
282}
283
284fn ensure_trailing_newline(code: &str) -> String {
285    let mut owned = code.to_string();
286    if !owned.ends_with('\n') {
287        owned.push('\n');
288    }
289    owned
290}
291
292fn diff_output(previous: &str, current: &str) -> String {
293    if let Some(stripped) = current.strip_prefix(previous) {
294        stripped.to_string()
295    } else {
296        current.to_string()
297    }
298}
299
300fn normalize_output(bytes: &[u8]) -> String {
301    String::from_utf8_lossy(bytes)
302        .replace("\r\n", "\n")
303        .replace('\r', "")
304}