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}