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