run-kit 0.7.1

Universal multi-language runner and smart REPL
Documentation
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use tempfile::{Builder, TempDir};

use super::{
    ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession, run_version_command,
};

pub struct BashEngine {
    executable: PathBuf,
}

impl Default for BashEngine {
    fn default() -> Self {
        Self::new()
    }
}

impl BashEngine {
    pub fn new() -> Self {
        let executable = resolve_bash_binary();
        Self { executable }
    }

    fn binary(&self) -> &Path {
        &self.executable
    }

    fn run_command(&self) -> Command {
        Command::new(self.binary())
    }
}

impl LanguageEngine for BashEngine {
    fn id(&self) -> &'static str {
        "bash"
    }

    fn display_name(&self) -> &'static str {
        "Bash"
    }

    fn aliases(&self) -> &[&'static str] {
        &["sh"]
    }

    fn supports_sessions(&self) -> bool {
        true
    }

    fn validate(&self) -> Result<()> {
        let mut cmd = self.run_command();
        cmd.arg("--version")
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        cmd.status()
            .with_context(|| format!("failed to invoke {}", self.binary().display()))?
            .success()
            .then_some(())
            .ok_or_else(|| anyhow::anyhow!("{} is not executable", self.binary().display()))
    }

    fn toolchain_version(&self) -> Result<Option<String>> {
        let mut cmd = self.run_command();
        cmd.arg("--version");
        let context = format!("{}", self.binary().display());
        run_version_command(cmd, &context)
    }

    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
        let start = Instant::now();
        let args = payload.args();
        let output = match payload {
            ExecutionPayload::Inline { code, .. } => {
                let mut cmd = self.run_command();
                cmd.arg("-c").arg(code).args(args);
                cmd.stdin(Stdio::inherit());
                cmd.output()
            }
            ExecutionPayload::File { path, .. } => {
                let mut cmd = self.run_command();
                cmd.arg(path).args(args);
                cmd.stdin(Stdio::inherit());
                cmd.output()
            }
            ExecutionPayload::Stdin { code, .. } => {
                let mut cmd = self.run_command();
                cmd.args(args);
                cmd.stdin(Stdio::piped())
                    .stdout(Stdio::piped())
                    .stderr(Stdio::piped());
                let mut child = cmd.spawn().with_context(|| {
                    format!(
                        "failed to start {} for stdin execution",
                        self.binary().display()
                    )
                })?;
                if let Some(mut stdin) = child.stdin.take() {
                    stdin.write_all(code.as_bytes())?;
                    if !code.ends_with('\n') {
                        stdin.write_all(b"\n")?;
                    }
                    stdin.flush()?;
                }
                child.wait_with_output()
            }
        }?;

        Ok(ExecutionOutcome {
            language: self.id().to_string(),
            exit_code: output.status.code(),
            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
            duration: start.elapsed(),
        })
    }

    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
        Ok(Box::new(BashSession::new(self.executable.clone())?))
    }
}

fn resolve_bash_binary() -> PathBuf {
    let candidates = ["bash", "sh"];
    for name in candidates {
        if let Ok(path) = which::which(name) {
            return path;
        }
    }
    PathBuf::from("/bin/bash")
}

struct BashSession {
    executable: PathBuf,
    dir: TempDir,
    script_path: PathBuf,
    statements: Vec<String>,
    previous_stdout: String,
    previous_stderr: String,
}

impl BashSession {
    fn new(executable: PathBuf) -> Result<Self> {
        let dir = Builder::new()
            .prefix("run-bash-repl")
            .tempdir()
            .context("failed to create temporary directory for bash repl")?;
        let script_path = dir.path().join("session.sh");
        fs::write(&script_path, "#!/usr/bin/env bash\nset -e\n")
            .with_context(|| format!("failed to initialize {}", script_path.display()))?;

        Ok(Self {
            executable,
            dir,
            script_path,
            statements: Vec::new(),
            previous_stdout: String::new(),
            previous_stderr: String::new(),
        })
    }

    fn render_script(&self) -> String {
        let mut script = String::from("#!/usr/bin/env bash\nset -e\n");
        for stmt in &self.statements {
            script.push_str(stmt);
            if !stmt.ends_with('\n') {
                script.push('\n');
            }
        }
        script
    }

    fn write_script(&self, contents: &str) -> Result<()> {
        fs::write(&self.script_path, contents).with_context(|| {
            format!(
                "failed to write generated Bash REPL script to {}",
                self.script_path.display()
            )
        })
    }

    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
        let script = self.render_script();
        self.write_script(&script)?;

        let output = self.run_script()?;
        let stdout_full = normalize_output(&output.stdout);
        let stderr_full = normalize_output(&output.stderr);

        let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
        let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);

        let success = output.status.success();
        if success {
            self.previous_stdout = stdout_full;
            self.previous_stderr = stderr_full;
        }

        let outcome = ExecutionOutcome {
            language: "bash".to_string(),
            exit_code: output.status.code(),
            stdout: stdout_delta,
            stderr: stderr_delta,
            duration: start.elapsed(),
        };

        Ok((outcome, success))
    }

    fn run_script(&self) -> Result<std::process::Output> {
        let mut cmd = Command::new(&self.executable);
        cmd.arg(&self.script_path)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .current_dir(self.dir.path());
        cmd.output().with_context(|| {
            format!(
                "failed to execute bash session script {} with {}",
                self.script_path.display(),
                self.executable.display()
            )
        })
    }

    fn run_snippet(&mut self, snippet: String) -> Result<ExecutionOutcome> {
        self.statements.push(snippet);
        let start = Instant::now();
        let (outcome, success) = self.run_current(start)?;
        if !success {
            let _ = self.statements.pop();
            let script = self.render_script();
            self.write_script(&script)?;
        }
        Ok(outcome)
    }

    fn reset_state(&mut self) -> Result<()> {
        self.statements.clear();
        self.previous_stdout.clear();
        self.previous_stderr.clear();
        let script = self.render_script();
        self.write_script(&script)
    }
}

impl LanguageSession for BashSession {
    fn language_id(&self) -> &str {
        "bash"
    }

    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
        let trimmed = code.trim();
        if trimmed.is_empty() {
            return Ok(ExecutionOutcome {
                language: self.language_id().to_string(),
                exit_code: None,
                stdout: String::new(),
                stderr: String::new(),
                duration: Duration::default(),
            });
        }

        if trimmed.eq_ignore_ascii_case(":reset") {
            self.reset_state()?;
            return Ok(ExecutionOutcome {
                language: self.language_id().to_string(),
                exit_code: None,
                stdout: String::new(),
                stderr: String::new(),
                duration: Duration::default(),
            });
        }

        if trimmed.eq_ignore_ascii_case(":help") {
            return Ok(ExecutionOutcome {
                language: self.language_id().to_string(),
                exit_code: None,
                stdout:
                    "Bash commands:\n  :reset - clear session state\n  :help  - show this message\n"
                        .to_string(),
                stderr: String::new(),
                duration: Duration::default(),
            });
        }

        let snippet = ensure_trailing_newline(code);
        self.run_snippet(snippet)
    }

    fn shutdown(&mut self) -> Result<()> {
        Ok(())
    }
}

fn ensure_trailing_newline(code: &str) -> String {
    let mut owned = code.to_string();
    if !owned.ends_with('\n') {
        owned.push('\n');
    }
    owned
}

fn diff_output(previous: &str, current: &str) -> String {
    if let Some(stripped) = current.strip_prefix(previous) {
        stripped.to_string()
    } else {
        current.to_string()
    }
}

fn normalize_output(bytes: &[u8]) -> String {
    String::from_utf8_lossy(bytes)
        .replace("\r\n", "\n")
        .replace('\r', "")
}