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}