kaish-kernel 0.8.2

Core kernel for kaish: lexer, parser, interpreter, and runtime
Documentation
//! Shared helpers for integration tests.
//!
//! Lives at `tests/common/mod.rs` (rather than `tests/common.rs`) because
//! Cargo treats every top-level `tests/*.rs` file as its own test binary.
//! The `common/mod.rs` form is the documented escape hatch for shared
//! test code, and is the only place in the workspace where we use a
//! `mod.rs` (the project otherwise prefers `module_name.rs`).

use std::path::Path;
use std::process::Command;

use kaish_kernel::{Kernel, KernelConfig};

/// Build a kernel rooted at `dir` with passthrough (real-FS) access.
///
/// This is the harness for *kernel-routed* builtin tests: instead of
/// constructing a builtin and calling `.execute()` directly (which skips
/// glob pre-expansion, flag canonicalization, and validation), tests drive
/// real command strings through `kernel.execute()` so the full pipeline
/// runs — lex → parse → validate → glob pre-expansion → dispatch → builtin.
///
/// Pair with `tempfile::tempdir()` so each test owns an isolated real-FS
/// root (per the project's no-hardcoded-system-paths rule).
#[allow(dead_code)] // not every test binary that includes `common` uses this
pub fn kernel_at(dir: &Path) -> Kernel {
    // Force latch/trash off so `rm`-style tests are deterministic regardless of
    // the developer's KAISH_LATCH / KAISH_TRASH env (which `repl()` reads).
    let config = KernelConfig::repl()
        .with_cwd(dir.to_path_buf())
        .with_latch(false)
        .with_trash(false);
    Kernel::new(config).expect("failed to create kernel")
}

/// Run a script through the kernel and return `(trimmed stdout, exit code)`.
#[allow(dead_code)]
pub async fn run(kernel: &Kernel, script: &str) -> (String, i64) {
    let result = kernel.execute(script).await.expect("kernel execute");
    (result.text_out().trim().to_string(), result.code)
}

/// What `bash -c` produced when we ran a script under it.
#[allow(dead_code)] // only the compat-test binaries construct this
pub struct BashOutput {
    pub stdout: String,
    // Each integration test binary is its own crate; some include compat
    // tests that never use `exit:` and so never read this field.
    #[allow(dead_code)]
    pub code: i64,
}

/// Run a script via `bash -c` if KAISH_BASH_COMPAT is set; otherwise return
/// `None` and let the caller short-circuit. Panics if the user opted in but
/// `bash` is missing or errored — they explicitly asked us to compare.
#[allow(dead_code)] // only the compat-test binaries call this
pub fn run_bash_if_enabled(script: &str) -> Option<BashOutput> {
    if std::env::var_os("KAISH_BASH_COMPAT").is_none() {
        return None;
    }
    let output = Command::new("bash")
        .arg("-c")
        .arg(script)
        .output()
        .expect("KAISH_BASH_COMPAT set but failed to run bash; install bash or unset the var");
    Some(BashOutput {
        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
        // signaled processes (no exit code) collapse to -1; bash itself exits
        // 128+sig in those cases, so this branch is rare for synthetic tests.
        code: output.status.code().map(i64::from).unwrap_or(-1),
    })
}

/// TT-muncher invoked by `shell_compat!` to expand the assertion clauses on
/// the kaish side. Recognized clauses:
///
/// - `eq: "..."`        — stdout (after `.trim()`) equals the literal
/// - `eq_exact: "..."`  — stdout equals the literal *byte-for-byte*, no trim
///                        (use for heredoc tests where trailing `\n` matters)
/// - `kaish_eq: "..."`  — trimmed kaish-only (paired with `bash_eq:` for
///                        known divergences)
/// - `bash_eq: "..."`   — ignored on the kaish side
/// - `contains: "..."`  — stdout contains the substring
/// - `absent: "..."`    — stdout does not contain the substring
/// - `exit: N`          — exit code equals N
#[macro_export]
macro_rules! shell_compat_kaish_assert {
    ($out:expr, $code:expr,) => {};
    ($out:expr, $code:expr, eq: $e:expr $(, $($r:tt)*)?) => {
        assert_eq!(
            $out.trim(),
            $e,
            "kaish stdout mismatch:\n--- got ---\n{}\n-----------",
            $out,
        );
        $( $crate::shell_compat_kaish_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, eq_exact: $e:expr $(, $($r:tt)*)?) => {
        assert_eq!(
            $out,
            $e,
            "kaish stdout (exact) mismatch:\n--- got ---\n{:?}\n-----------",
            $out,
        );
        $( $crate::shell_compat_kaish_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, kaish_eq: $e:expr $(, $($r:tt)*)?) => {
        assert_eq!(
            $out.trim(),
            $e,
            "kaish stdout mismatch:\n--- got ---\n{}\n-----------",
            $out,
        );
        $( $crate::shell_compat_kaish_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, bash_eq: $e:expr $(, $($r:tt)*)?) => {
        $( $crate::shell_compat_kaish_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, contains: $n:expr $(, $($r:tt)*)?) => {
        assert!(
            $out.contains($n),
            "kaish missing substring {:?}:\n--- got ---\n{}\n-----------",
            $n,
            $out,
        );
        $( $crate::shell_compat_kaish_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, absent: $m:expr $(, $($r:tt)*)?) => {
        assert!(
            !$out.contains($m),
            "kaish unexpected substring {:?}:\n--- got ---\n{}\n-----------",
            $m,
            $out,
        );
        $( $crate::shell_compat_kaish_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, exit: $c:expr $(, $($r:tt)*)?) => {
        assert_eq!($code, $c as i64, "kaish exit code mismatch");
        $( $crate::shell_compat_kaish_assert!($out, $code, $($r)*); )?
    };
}

/// Bash-side mirror of `shell_compat_kaish_assert!`. Ignores `kaish_eq:`,
/// honors `bash_eq:`, and otherwise applies the same shared clauses.
#[macro_export]
macro_rules! shell_compat_bash_assert {
    ($out:expr, $code:expr,) => {};
    ($out:expr, $code:expr, eq: $e:expr $(, $($r:tt)*)?) => {
        assert_eq!(
            $out.trim(),
            $e,
            "bash stdout mismatch:\n--- got ---\n{}\n-----------",
            $out,
        );
        $( $crate::shell_compat_bash_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, eq_exact: $e:expr $(, $($r:tt)*)?) => {
        assert_eq!(
            $out,
            $e,
            "bash stdout (exact) mismatch:\n--- got ---\n{:?}\n-----------",
            $out,
        );
        $( $crate::shell_compat_bash_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, kaish_eq: $e:expr $(, $($r:tt)*)?) => {
        $( $crate::shell_compat_bash_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, bash_eq: $e:expr $(, $($r:tt)*)?) => {
        assert_eq!(
            $out.trim(),
            $e,
            "bash stdout mismatch:\n--- got ---\n{}\n-----------",
            $out,
        );
        $( $crate::shell_compat_bash_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, contains: $n:expr $(, $($r:tt)*)?) => {
        assert!(
            $out.contains($n),
            "bash missing substring {:?}:\n--- got ---\n{}\n-----------",
            $n,
            $out,
        );
        $( $crate::shell_compat_bash_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, absent: $m:expr $(, $($r:tt)*)?) => {
        assert!(
            !$out.contains($m),
            "bash unexpected substring {:?}:\n--- got ---\n{}\n-----------",
            $m,
            $out,
        );
        $( $crate::shell_compat_bash_assert!($out, $code, $($r)*); )?
    };
    ($out:expr, $code:expr, exit: $c:expr $(, $($r:tt)*)?) => {
        assert_eq!($code, $c as i64, "bash exit code mismatch");
        $( $crate::shell_compat_bash_assert!($out, $code, $($r)*); )?
    };
}

/// Generate a pair of tests for a single shell scenario.
///
/// Layout:
/// ```ignore
/// shell_compat! {
///     name: my_scenario,
///     script: "echo hi",
///     eq: "hi",
/// }
/// ```
///
/// Generates `my_scenario::kaish` (always runs) and `my_scenario::bash`
/// (gated on `KAISH_BASH_COMPAT=1`). Both apply the same clauses to the
/// stdout/exit-code their side produced.
#[macro_export]
macro_rules! shell_compat {
    (
        name: $name:ident,
        script: $script:expr,
        $($body:tt)*
    ) => {
        mod $name {
            use ::kaish_kernel::{Kernel, KernelConfig};

            #[tokio::test]
            #[allow(unused_variables)]
            async fn kaish() {
                // Skip validation so scripts that reference unset vars (heredoc
                // bodies, $NOT_A_VAR, etc.) reach the runtime — the compat
                // suite compares runtime behavior with bash, not validator
                // strictness.
                let kernel = Kernel::new(
                    KernelConfig::transient().with_skip_validation(true),
                ).expect("kernel");
                let result = kernel.execute($script).await.expect("kaish execute");
                let out: String = result.text_out().into_owned();
                let code: i64 = result.code;
                $crate::shell_compat_kaish_assert!(out, code, $($body)*);
            }

            #[test]
            #[allow(unused_variables)]
            fn bash() {
                let Some(b) = $crate::common::run_bash_if_enabled($script) else {
                    return;
                };
                $crate::shell_compat_bash_assert!(b.stdout, b.code, $($body)*);
            }
        }
    };
}