bashkit 0.5.0

Awesomely fast virtual sandbox with bash and file system
Documentation
//! Differential tests vs the host `sqlite3` CLI.
//!
//! For each input we run the same SQL (and the same output-mode flags)
//! through both bashkit's sqlite builtin and the host `sqlite3` binary.
//! We assert their stdouts match byte-for-byte. This pins the
//! sqlite3-shell parity that `sqlite_compat_tests.rs` only spot-checks.
//!
//! Coverage:
//! - List-mode default rendering
//! - `-csv` quoting (RFC 4180)
//! - `-header` flag
//! - `-tabs` flag
//! - `-line` mode
//! - `-separator '|'` / `-separator ','`
//! - Aggregates (`COUNT/SUM/AVG/MIN/MAX`)
//! - `ORDER BY`/`LIMIT`/`OFFSET`
//! - Recursive CTE
//! - PRAGMA `user_version` round-trip
//! - String/number ordering
//! - NULL rendering with default and custom `-nullvalue`
//! - Empty result-set behavior
//!
//! Skipped gracefully when `sqlite3` is not on `$PATH` (so the suite
//! still passes on hosts that lack it). CI installs it via the Test
//! job (`runs-on: ubuntu-latest` ships sqlite3 by default).

#![cfg(feature = "sqlite")]

use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::OnceLock;

use bashkit::Bash;

fn sqlite3_available() -> bool {
    static AVAILABLE: OnceLock<bool> = OnceLock::new();
    *AVAILABLE.get_or_init(|| {
        Command::new("sqlite3")
            .arg("--version")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    })
}

/// Skip the calling test cleanly if `sqlite3` isn't on PATH.
macro_rules! require_sqlite3 {
    () => {
        if !sqlite3_available() {
            eprintln!("skip: sqlite3 not on PATH");
            return;
        }
    };
}

/// Run `sqlite3 :memory: <sql>` with the given flags. Returns stdout
/// (errors go through `unwrap` since we control the inputs).
fn run_real_sqlite3(flags: &[&str], sql: &str) -> String {
    let mut child = Command::new("sqlite3")
        .args(flags)
        .arg(":memory:")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn sqlite3");
    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(sql.as_bytes())
        .expect("feed sqlite3 stdin");
    let out = child.wait_with_output().expect("wait sqlite3");
    String::from_utf8(out.stdout).expect("sqlite3 stdout utf8")
}

/// Run the same query through bashkit's sqlite builtin.
async fn run_bashkit_sqlite(flags: &[&str], sql: &str) -> String {
    let mut bash = Bash::builder()
        .sqlite()
        .env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1")
        .build();
    // Build the command line with each flag/value single-quoted so bash
    // doesn't reinterpret characters like `;`, `(`, `)`, `*`, etc. SQL
    // is fed via stdin heredoc to keep the SQL itself opaque to bash.
    let quoted_flags: Vec<String> = flags
        .iter()
        .map(|f| {
            // Double single-quote escape for any embedded `'`.
            let escaped = f.replace('\'', "'\\''");
            format!("'{escaped}'")
        })
        .collect();
    let cmd = format!(
        "sqlite {} :memory: <<'__BASHKIT_EOF__'\n{sql}\n__BASHKIT_EOF__",
        quoted_flags.join(" "),
    );
    let r = bash.exec(&cmd).await.expect("bashkit exec");
    assert_eq!(
        r.exit_code, 0,
        "bashkit sqlite failed: stderr={:?}",
        r.stderr
    );
    r.stdout
}

/// Drive both engines with the same input and assert byte-equal output.
async fn assert_matches(flags: &[&str], sql: &str) {
    require_sqlite3!();
    let host = run_real_sqlite3(flags, sql);
    let bk = run_bashkit_sqlite(flags, sql).await;
    pretty_assertions::assert_eq!(
        bk,
        host,
        "bashkit and host sqlite3 disagree\nflags = {:?}\nsql = {sql:?}",
        flags
    );
}

// ---------------------------------------------------------------------------
// Output-mode parity
// ---------------------------------------------------------------------------

#[tokio::test]
async fn list_mode_default_separator() {
    assert_matches(&[], "SELECT 1, 'two', 3.5;").await;
}

#[tokio::test]
async fn list_mode_with_header() {
    assert_matches(
        &["-header"],
        "CREATE TABLE t(x, y); \
         INSERT INTO t VALUES (1, 'a'), (2, 'b'); \
         SELECT * FROM t ORDER BY x;",
    )
    .await;
}

#[tokio::test]
async fn csv_mode_rfc_4180_quoting() {
    assert_matches(&["-csv"], "SELECT 'she said \"hi\"' AS msg, 'a,b' AS list;").await;
}

#[tokio::test]
async fn csv_mode_with_header() {
    assert_matches(
        &["-csv", "-header"],
        "CREATE TABLE t(name TEXT, age INTEGER); \
         INSERT INTO t VALUES ('alice', 30), ('bob, jr.', 25); \
         SELECT * FROM t ORDER BY name;",
    )
    .await;
}

#[tokio::test]
async fn tabs_mode_simple_select() {
    assert_matches(
        &["-tabs"],
        "CREATE TABLE t(x, y); INSERT INTO t VALUES (1, 'a'); SELECT * FROM t;",
    )
    .await;
}

#[tokio::test]
async fn separator_flag_overrides_default() {
    assert_matches(
        &["-separator", ";"],
        "CREATE TABLE t(x, y); INSERT INTO t VALUES (1, 2); SELECT * FROM t;",
    )
    .await;
}

#[tokio::test]
async fn nullvalue_renders_placeholder() {
    assert_matches(&["-nullvalue", "(null)"], "SELECT NULL;").await;
}

// ---------------------------------------------------------------------------
// SQL semantic parity
// ---------------------------------------------------------------------------

#[tokio::test]
async fn aggregate_functions() {
    assert_matches(
        &[],
        "CREATE TABLE t(x INTEGER); \
         INSERT INTO t VALUES (1), (2), (3); \
         SELECT COUNT(*), SUM(x), AVG(x), MIN(x), MAX(x) FROM t;",
    )
    .await;
}

#[tokio::test]
async fn order_by_limit_offset() {
    assert_matches(
        &[],
        "CREATE TABLE t(x INTEGER); \
         INSERT INTO t VALUES (5), (1), (3), (4), (2); \
         SELECT x FROM t ORDER BY x LIMIT 2 OFFSET 1;",
    )
    .await;
}

#[tokio::test]
async fn pragma_user_version_round_trip() {
    assert_matches(&[], "PRAGMA user_version = 42; PRAGMA user_version;").await;
}

#[tokio::test]
async fn string_ordering() {
    assert_matches(
        &[],
        "CREATE TABLE t(s TEXT); \
         INSERT INTO t VALUES ('zebra'), ('alpha'), ('mango'); \
         SELECT s FROM t ORDER BY s;",
    )
    .await;
}

#[tokio::test]
async fn empty_result_set_is_empty() {
    assert_matches(&[], "CREATE TABLE t(x); SELECT * FROM t WHERE 0 = 1;").await;
}

#[tokio::test]
async fn empty_result_set_with_header() {
    // When .headers is on but the result set is empty, sqlite3 prints
    // nothing in list mode. Bashkit must agree.
    assert_matches(&["-header"], "CREATE TABLE t(x, y); SELECT * FROM t;").await;
}

#[tokio::test]
async fn group_by_ordering() {
    assert_matches(
        &["-header"],
        "CREATE TABLE t(g TEXT, v INTEGER); \
         INSERT INTO t VALUES ('a', 1), ('a', 2), ('b', 5), ('b', 7); \
         SELECT g, SUM(v) AS s FROM t GROUP BY g ORDER BY g;",
    )
    .await;
}

#[tokio::test]
async fn case_expression() {
    assert_matches(
        &[],
        "CREATE TABLE t(x INTEGER); \
         INSERT INTO t VALUES (1), (2), (3); \
         SELECT x, CASE WHEN x > 1 THEN 'big' ELSE 'small' END FROM t ORDER BY x;",
    )
    .await;
}

#[tokio::test]
async fn coalesce_and_null_handling() {
    assert_matches(
        &[],
        "CREATE TABLE t(a, b); \
         INSERT INTO t VALUES (1, NULL), (NULL, 2), (3, 4); \
         SELECT COALESCE(a, b, -1) FROM t ORDER BY rowid;",
    )
    .await;
}

#[tokio::test]
async fn subquery_in_where() {
    assert_matches(
        &[],
        "CREATE TABLE t(x INTEGER); \
         INSERT INTO t VALUES (1), (2), (3), (4); \
         SELECT x FROM t WHERE x > (SELECT AVG(x) FROM t) ORDER BY x;",
    )
    .await;
}

#[tokio::test]
async fn join_inner() {
    assert_matches(
        &["-header"],
        "CREATE TABLE a(id INTEGER, name TEXT); \
         CREATE TABLE b(a_id INTEGER, val TEXT); \
         INSERT INTO a VALUES (1, 'one'), (2, 'two'); \
         INSERT INTO b VALUES (1, 'x'), (1, 'y'), (2, 'z'); \
         SELECT a.name, b.val FROM a JOIN b ON a.id = b.a_id ORDER BY a.id, b.val;",
    )
    .await;
}

// ---------------------------------------------------------------------------
// Documented divergences — features the differential suite *expects* to
// disagree on. Convert to `assert_matches` once Turso closes the gap.
//
// Recursive CTEs: turso 0.5.3 returns
//   "Parse error: Recursive CTEs are not yet supported"
// while real sqlite3 emits 1..N. Track upstream:
//   https://github.com/tursodatabase/turso (search "WITH RECURSIVE").
// ---------------------------------------------------------------------------

#[tokio::test]
async fn recursive_cte_unsupported_in_turso() {
    require_sqlite3!();
    let sql = "WITH RECURSIVE r(n) AS ( \
                SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 5 \
             ) SELECT n FROM r;";

    // Real sqlite3 happily counts.
    let host = run_real_sqlite3(&[], sql);
    assert_eq!(host.trim(), "1\n2\n3\n4\n5".trim_end());

    // Turso is expected to reject — drive bashkit directly so we don't
    // panic in `run_bashkit_sqlite`'s exit-code assertion.
    let mut bash = Bash::builder()
        .sqlite()
        .env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1")
        .build();
    let cmd = format!("sqlite :memory: <<'__BASHKIT_EOF__'\n{sql}\n__BASHKIT_EOF__");
    let r = bash.exec(&cmd).await.unwrap();
    assert_ne!(
        r.exit_code, 0,
        "turso unexpectedly accepted a recursive CTE — rotate this test \
         into `assert_matches` and remove the divergence note in the spec"
    );
    assert!(
        r.stderr.contains("Recursive CTE") || r.stderr.to_lowercase().contains("recursive"),
        "unexpected divergence reason: {:?}",
        r.stderr,
    );
}