noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};

struct QaFixture {
    root: PathBuf,
}

impl QaFixture {
    fn new(label: &str) -> Self {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("clock after Unix epoch")
            .as_nanos();
        let root = std::env::temp_dir().join(format!(
            "noxid-wo38-qa-round1-{label}-{}-{nonce}",
            std::process::id()
        ));
        fs::create_dir_all(root.join("server/db/migrations"))
            .expect("create QA migration directory");
        fs::create_dir(root.join("data")).expect("create QA database directory");
        fs::write(root.join("Noxid.toml"), "[project]\nname = \"wo38-qa\"\n")
            .expect("write QA project manifest");
        Self { root }
    }

    fn migration(&self, name: &str, sql: &str) {
        fs::write(self.root.join("server/db/migrations").join(name), sql)
            .expect("write QA migration");
    }

    fn remove_migration(&self, name: &str) {
        fs::remove_file(self.root.join("server/db/migrations").join(name))
            .expect("remove QA migration");
    }

    fn noxid(&self, arguments: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .current_dir(&self.root)
            .args(arguments)
            .env("DATABASE_URL", "sqlite://data/qa.db")
            .output()
            .expect("execute noxid against SQLite QA fixture")
    }
}

impl Drop for QaFixture {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.root);
    }
}

fn output_text(output: &Output) -> String {
    format!(
        "stdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    )
}

fn assert_success(output: &Output, context: &str) {
    assert!(
        output.status.success(),
        "{context}: {}",
        output_text(output)
    );
}

#[test]
fn qa_round1_docker_free_sqlite_contract_lane_executes_every_node_probe() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
    let output = Command::new("node")
        .args(["--test", "plugins/drizzle-orm/sqlite.qa_round1.test.mjs"])
        .current_dir(root)
        .env("DOCKER_HOST", "unix:///definitely-not-a-docker-socket")
        .output()
        .expect("Node.js is required by the Docker-free SQLite gate");
    assert_success(&output, "execute permanent SQLite QA Node lane");
    let text = output_text(&output);
    for proof in [
        "qa_round1 SQLite URL grammar",
        "qa_round1 node:sqlite shim",
        "qa_round1 Drizzle transactions",
        "qa_round1 validatedRows",
        "qa_round1 disabled node:sqlite",
    ] {
        assert!(
            text.contains(proof),
            "missing execution proof {proof}: {text}"
        );
    }
    assert!(
        text.contains("pass 5"),
        "all five probes must execute: {text}"
    );
    assert!(
        !text.contains("skipped 1"),
        "SQLite QA lane skipped: {text}"
    );
}

#[test]
fn qa_round1_sqlite_migration_ddl_and_history_record_roll_back_together() {
    let fixture = QaFixture::new("ddl-rollback");
    fixture.migration(
        "0001-transactional-ddl.sql",
        "CREATE TABLE rollback_probe (id INTEGER PRIMARY KEY);\nINSERT INTO missing_table VALUES (1);\n",
    );

    let pending = fixture.noxid(&["db", "status"]);
    assert_success(&pending, "read pending migration before failure");
    assert!(
        output_text(&pending).contains("pending 0001-transactional-ddl.sql"),
        "{}",
        output_text(&pending)
    );

    let failed = fixture.noxid(&["db", "migrate"]);
    assert!(
        !failed.status.success(),
        "failing migration committed: {}",
        output_text(&failed)
    );
    assert!(
        !output_text(&failed).contains("warning[MYSQL_DDL_AUTOCOMMIT]"),
        "SQLite must not inherit MySQL's DDL-autocommit warning: {}",
        output_text(&failed)
    );

    let still_pending = fixture.noxid(&["db", "status"]);
    assert_success(
        &still_pending,
        "read migration status after transactional failure",
    );
    assert!(
        output_text(&still_pending).contains("pending 0001-transactional-ddl.sql"),
        "history row survived failed migration: {}",
        output_text(&still_pending)
    );

    fixture.migration(
        "0001-transactional-ddl.sql",
        "CREATE TABLE rollback_probe (id INTEGER PRIMARY KEY);\nINSERT INTO rollback_probe VALUES (1);\n",
    );
    let recovered = fixture.noxid(&["db", "migrate"]);
    assert_success(
        &recovered,
        "apply corrected migration after SQLite DDL rollback",
    );
    let recovered_text = output_text(&recovered);
    assert!(
        recovered_text.contains("applied 0001-transactional-ddl.sql"),
        "{recovered_text}"
    );
    assert!(
        !recovered_text.contains("warning[MYSQL_DDL_AUTOCOMMIT]"),
        "SQLite transaction was reported with MySQL semantics: {recovered_text}"
    );

    let applied = fixture.noxid(&["db", "status"]);
    assert_success(&applied, "read applied status after recovery");
    assert!(
        output_text(&applied).contains("applied 0001-transactional-ddl.sql"),
        "{}",
        output_text(&applied)
    );
}

#[test]
fn qa_round1_sqlite_history_refuses_reordering_and_checksum_drift() {
    let fixture = QaFixture::new("history");
    fixture.migration(
        "0002-original.sql",
        "CREATE TABLE history_probe (id INTEGER PRIMARY KEY);\n",
    );
    let migrated = fixture.noxid(&["db", "migrate"]);
    assert_success(&migrated, "apply initial migration history");

    fixture.migration("0001-inserted-before.sql", "SELECT 1;\n");
    let reordered = fixture.noxid(&["db", "status"]);
    assert!(
        !reordered.status.success(),
        "reordered history was accepted: {}",
        output_text(&reordered)
    );
    assert!(
        output_text(&reordered)
            .contains("applied migration history is not a prefix of the ordered local files"),
        "{}",
        output_text(&reordered)
    );

    fixture.remove_migration("0001-inserted-before.sql");
    fixture.migration("0002-original.sql", "SELECT 'edited after apply';\n");
    let checksum = fixture.noxid(&["db", "status"]);
    assert!(
        !checksum.status.success(),
        "checksum drift was accepted: {}",
        output_text(&checksum)
    );
    assert!(
        output_text(&checksum)
            .contains("checksum mismatch for applied migration 0002-original.sql"),
        "{}",
        output_text(&checksum)
    );
}

#[test]
fn qa_round1_sqlite_missing_parent_refuses_without_creating_a_directory_or_file() {
    let fixture = QaFixture::new("missing-parent");
    let output = Command::new(env!("CARGO_BIN_EXE_noxid"))
        .current_dir(&fixture.root)
        .args(["db", "status"])
        .env("DATABASE_URL", "sqlite://missing/nested/qa.db")
        .output()
        .expect("execute SQLite missing-parent probe");
    assert!(!output.status.success(), "missing parent was admitted");
    assert!(
        output_text(&output).contains("error[SQLITE_DATABASE_PARENT_MISSING]"),
        "{}",
        output_text(&output)
    );
    assert!(
        !fixture.root.join("missing").exists(),
        "runner created a directory for a typo'd SQLite URL"
    );
}