mxsh 0.2.0

Embeddable POSIX-style shell parser and runtime
Documentation
#![cfg(all(
    feature = "cli",
    feature = "embed",
    feature = "test-support",
    feature = "unix-runtime"
))]

use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Clone, Debug)]
struct ReferenceShell {
    name: &'static str,
    program: &'static str,
    args: &'static [&'static str],
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Expectation {
    Exact,
    IgnoreStderr,
    AllowKnownDifference,
}

fn project_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

fn list_shell_scripts(dir: &Path, skip: &[&str]) -> Vec<PathBuf> {
    let mut scripts: Vec<PathBuf> = fs::read_dir(dir)
        .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display()))
        .filter_map(|entry| entry.ok().map(|e| e.path()))
        .filter(|path| path.extension() == Some(OsStr::new("sh")))
        .filter(|path| {
            let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default();
            !skip.contains(&name)
        })
        .collect();
    scripts.sort();
    scripts
}

fn reference_shells() -> Vec<ReferenceShell> {
    [
        ReferenceShell {
            name: "sh",
            program: "/bin/sh",
            args: &[],
        },
        ReferenceShell {
            name: "dash",
            program: "/bin/dash",
            args: &[],
        },
        ReferenceShell {
            name: "bash-posix",
            program: "/bin/bash",
            args: &["--posix"],
        },
    ]
    .into_iter()
    .filter(|shell| Path::new(shell.program).is_file())
    .collect()
}

fn run_shell(shell: &ReferenceShell, script_path: &Path) -> (i32, String, String) {
    let output = Command::new(shell.program)
        .args(shell.args)
        .arg(script_path)
        .output()
        .unwrap_or_else(|err| {
            panic!(
                "failed to run {} ({}) {}: {err}",
                shell.name,
                shell.program,
                script_path.display()
            )
        });
    let code = output.status.code().unwrap_or(128);
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
    (code, stdout, stderr)
}

fn nocapture_enabled() -> bool {
    if std::env::args().any(|arg| arg == "--nocapture") {
        return true;
    }
    std::env::var("RUST_TEST_NOCAPTURE")
        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
        .unwrap_or(false)
}

fn maybe_print_script(script_path: &Path) {
    if !nocapture_enabled() {
        return;
    }
    println!("+ SCRIPT {}", script_path.display());
    let text = fs::read_to_string(script_path).unwrap_or_default();
    for line in text.lines() {
        println!("+ {line}");
    }
}

fn expectation_for(shell: &ReferenceShell, script: &Path) -> Expectation {
    let name = script
        .file_name()
        .and_then(OsStr::to_str)
        .unwrap_or_default();
    match shell.name {
        "sh" => match name {
            "2.2.3-alias-expansion.fail.sh" => Expectation::AllowKnownDifference,
            _ => Expectation::Exact,
        },
        "dash" => match name {
            "pipeline.sh"
            | "read.sh"
            | "2.2.2-nested-single-quotes.fail.sh"
            | "2.2.3-dquote-nonterminated-backquote.undefined.sh" => Expectation::IgnoreStderr,
            "2.2.3-backquote-nonterminated-dquote.undefined.sh"
            | "2.2.3-backquote-nonterminated-squote.undefined.sh" => {
                Expectation::AllowKnownDifference
            }
            _ => Expectation::Exact,
        },
        "bash-posix" => match name {
            "pipeline.sh" | "2.2.2-nested-single-quotes.fail.sh" => Expectation::IgnoreStderr,
            "2.2.3-alias-expansion.fail.sh" => Expectation::AllowKnownDifference,
            _ => Expectation::Exact,
        },
        _ => Expectation::Exact,
    }
}

fn outputs_match(
    expectation: Expectation,
    mx_rc: i32,
    mx_out: &str,
    mx_err: &str,
    sh_rc: i32,
    sh_out: &str,
    sh_err: &str,
) -> bool {
    match expectation {
        Expectation::Exact => mx_rc == sh_rc && mx_out == sh_out && mx_err == sh_err,
        Expectation::IgnoreStderr => mx_rc == sh_rc && mx_out == sh_out,
        Expectation::AllowKnownDifference => true,
    }
}

fn compare_suite(scripts: &[PathBuf]) {
    let mxsh = env!("CARGO_BIN_EXE_mxsh");
    let reference_shells = reference_shells();
    assert!(
        !reference_shells.is_empty(),
        "expected at least one reference shell to be available"
    );
    let mut failures = Vec::new();

    for shell in reference_shells {
        for script in scripts {
            maybe_print_script(script);
            let (mx_rc, mx_out, mx_err) = run_shell(
                &ReferenceShell {
                    name: "mxsh",
                    program: mxsh,
                    args: &[],
                },
                script,
            );
            let (sh_rc, sh_out, sh_err) = run_shell(&shell, script);
            let expectation = expectation_for(&shell, script);
            if !outputs_match(
                expectation,
                mx_rc,
                &mx_out,
                &mx_err,
                sh_rc,
                &sh_out,
                &sh_err,
            ) {
                failures.push(format!(
                    "{} against {} ({expectation:?}) rc={mx_rc}/{sh_rc}\n--- {} stdout ---\n{sh_out}\n--- mxsh stdout ---\n{mx_out}\n--- {} stderr ---\n{sh_err}\n--- mxsh stderr ---\n{mx_err}",
                    script.display(),
                    shell.name,
                    shell.name,
                    shell.name,
                ));
            }
        }
    }

    if !failures.is_empty() {
        panic!(
            "script compatibility failures ({}):\n\n{}",
            failures.len(),
            failures.join("\n\n")
        );
    }
}

#[test]
fn mrsh_test_suite_matches_bin_sh() {
    let root = project_root();
    let scripts = list_shell_scripts(&root.join("mrsh/test"), &["harness.sh"]);
    compare_suite(&scripts);
}

#[test]
fn mrsh_conformance_suite_matches_bin_sh() {
    let root = project_root();
    let scripts = list_shell_scripts(&root.join("mrsh/test/conformance"), &["harness.sh"]);
    compare_suite(&scripts);
}