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 Fixture {
    root: PathBuf,
}

impl Fixture {
    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-{label}-{}-{nonce}", std::process::id()));
        fs::create_dir_all(root.join("server/db/migrations"))
            .expect("create fixture migration directory");
        fs::create_dir(root.join("data")).expect("create fixture data directory");
        fs::write(
            root.join("Noxid.toml"),
            "[project]\nname = \"sqlite-test\"\n",
        )
        .expect("write project manifest");
        Self { root }
    }

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

    fn database_url(&self) -> &'static str {
        "sqlite://data/app.db"
    }

    fn noxid(&self, arguments: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .current_dir(&self.root)
            .args(arguments)
            .env("DATABASE_URL", self.database_url())
            .output()
            .expect("execute noxid")
    }
}

impl Drop for Fixture {
    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 sqlite_adapter_url_affinity_and_round_trip_contracts_pass_without_docker() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
    let output = Command::new("node")
        .args(["--test", "plugins/drizzle-orm/sqlite.test.mjs"])
        .current_dir(root)
        .output()
        .expect("Node.js is required by the workspace test gate");
    assert!(output.status.success(), "{}", output_text(&output));
}

#[test]
fn sqlite_migration_runner_has_status_apply_rollback_and_checksum_parity() {
    let fixture = Fixture::new("migration-parity");
    let first_sql = "CREATE TABLE migration_probe (id INTEGER PRIMARY KEY);\nINSERT INTO migration_probe VALUES (1);\n";
    fixture.migration("0001-create-probe.sql", first_sql);

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

    let migrated = fixture.noxid(&["db", "migrate"]);
    assert_success(&migrated, "apply SQLite migration");
    assert!(
        output_text(&migrated).contains("applied 0001-create-probe.sql"),
        "{}",
        output_text(&migrated)
    );

    let applied = fixture.noxid(&["db", "status"]);
    assert_success(&applied, "read applied SQLite migration status");
    assert!(
        output_text(&applied).contains("applied 0001-create-probe.sql"),
        "{}",
        output_text(&applied)
    );

    fixture.migration(
        "0002-rollback.sql",
        "CREATE TABLE rollback_probe (id INTEGER PRIMARY KEY);\nINSERT INTO missing_table VALUES (1);\n",
    );
    let failed = fixture.noxid(&["db", "migrate"]);
    assert!(!failed.status.success(), "migration unexpectedly succeeded");
    fixture.migration(
        "0002-rollback.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 transactional rollback",
    );

    fixture.migration("0001-create-probe.sql", "SELECT 1;\n");
    let checksum = fixture.noxid(&["db", "status"]);
    assert!(!checksum.status.success());
    assert!(
        output_text(&checksum)
            .contains("checksum mismatch for applied migration 0001-create-probe.sql"),
        "{}",
        output_text(&checksum)
    );
}

#[test]
fn sqlite_migration_runner_refuses_missing_parent_before_opening_a_database() {
    let fixture = Fixture::new("missing-parent");
    let output = Command::new(env!("CARGO_BIN_EXE_noxid"))
        .current_dir(&fixture.root)
        .args(["db", "status"])
        .env("DATABASE_URL", "sqlite://typo/app.db")
        .output()
        .expect("execute noxid");
    assert!(!output.status.success());
    let text = output_text(&output);
    assert!(
        text.contains("error[SQLITE_DATABASE_PARENT_MISSING]"),
        "{text}"
    );
    assert!(!fixture.root.join("typo").exists());
}