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 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}