noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);

struct Fixture {
    root: PathBuf,
}

impl Fixture {
    fn new(label: &str) -> Self {
        let ordinal = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
        let root = std::env::temp_dir().join(format!(
            "noxid-wo26-deployment-{label}-{}-{ordinal}",
            std::process::id()
        ));
        fs::create_dir_all(&root).expect("create WO-26 deployment fixture");
        Self { root }
    }

    fn write(&self, relative: &str, contents: &str) {
        let path = self.root.join(relative);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create fixture parent");
        }
        fs::write(path, contents).expect("write fixture file");
    }

    fn adapt(&self, adapter: &str, output: &str) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .args(["adapt", ".", "--adapter", adapter, "--out-dir", output])
            .env_remove("DATABASE_URL")
            .current_dir(&self.root)
            .output()
            .expect("run noxid adapt")
    }
}

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

fn assert_success(output: &Output, context: &str) {
    assert!(
        output.status.success(),
        "{context}:\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

#[test]
fn vercel_max_duration_is_derived_from_project_endpoints_and_overridden_by_server_config() {
    let fixture = Fixture::new("vercel-duration");
    fixture.write(
        "Noxid.toml",
        "[app]\ntitle = \"Vercel duration\"\nbase = \"/console\"\n",
    );
    fixture.write(
        "server/api/slow.get.nox",
        "endpoint Slow { result: String timeout: 120001ms handler { return \"ok\" } }\n",
    );

    let derived = fixture.adapt("vercel", "dist-derived");
    assert_success(&derived, "adapt project with derived Vercel duration");
    let derived_config = fs::read_to_string(
        fixture
            .root
            .join("dist-derived/.vercel/output/functions/noxid.func/.vc-config.json"),
    )
    .expect("read derived Vercel function config");
    assert!(derived_config.contains("\"maxDuration\":121"));

    fixture.write(
        "Noxid.toml",
        "[app]\ntitle = \"Vercel duration\"\nbase = \"/console\"\n\n[server]\nvercel_max_duration = 17\n",
    );
    let overridden = fixture.adapt("vercel", "dist-overridden");
    assert_success(&overridden, "adapt project with overridden Vercel duration");
    let overridden_config = fs::read_to_string(
        fixture
            .root
            .join("dist-overridden/.vercel/output/functions/noxid.func/.vc-config.json"),
    )
    .expect("read overridden Vercel function config");
    assert!(overridden_config.contains("\"maxDuration\":17"));
}

#[test]
fn every_adapter_completes_a_real_noxid_adapt_run() {
    let fixture = Fixture::new("all-adapters");
    fixture.write(
        "Noxid.toml",
        "[app]\ntitle = \"Adapter matrix\"\nbase = \"/console\"\n",
    );
    fixture.write(
        "src/routes/+page.nox",
        "component Home { route { title: \"Home\" } view { <main>Home</main> } }\n",
    );

    let expectations = [
        ("static", "deployment.plan.json"),
        ("node", "server.mjs"),
        ("deno", "server.ts"),
        ("cloudflare", "_redirects"),
        ("vercel", ".vercel/output/config.json"),
        ("netlify", "netlify.toml"),
    ];
    for (adapter, artifact) in expectations {
        let output = format!("dist-{adapter}");
        let adapted = fixture.adapt(adapter, &output);
        assert_success(&adapted, &format!("adapt project for {adapter}"));
        assert!(
            fixture.root.join(output).join(artifact).is_file(),
            "{adapter} did not emit {artifact}"
        );
    }
}

#[test]
fn reachable_database_records_pending_migrations_in_the_deployment_plan() {
    let fixture = Fixture::new("migration-warning");
    fixture.write("Noxid.toml", "[app]\ntitle = \"Migration warning\"\n");
    fixture.write(
        "src/routes/+page.nox",
        "component Home { route { title: \"Home\" } view { <main>Home</main> } }\n",
    );
    fixture.write(
        "server/db/migrations/0001-create-probe.sql",
        "CREATE TABLE deployment_probe (id INTEGER PRIMARY KEY);\n",
    );
    fs::create_dir_all(fixture.root.join("data")).expect("create SQLite data directory");

    let adapted = Command::new(env!("CARGO_BIN_EXE_noxid"))
        .args(["adapt", ".", "--adapter", "static", "--out-dir", "dist"])
        .env("DATABASE_URL", "sqlite://data/deployment.db")
        .current_dir(&fixture.root)
        .output()
        .expect("adapt with reachable SQLite database");
    assert_success(&adapted, "adapt project with pending migration");
    let plan = fs::read_to_string(fixture.root.join("dist/deployment.plan.json"))
        .expect("read deployment plan");
    assert!(plan.contains("pending database migrations"), "{plan}");
    assert!(plan.contains("0001-create-probe.sql"), "{plan}");
    assert!(plan.contains("noxid db migrate"), "{plan}");
}