Skip to main content

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