noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
//! WO-45 phase 2 — builder suite for the project-level half.
//!
//! Phase 1 left two things that only a multi-file project can exercise: the
//! result-position validator for a distinct field reached through an imported
//! struct, and the erased server lowering of construction and `.base()`. Both
//! live here rather than in the in-process suite because both need a real
//! `noxid build` over more than one source file.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

static COUNTER: AtomicUsize = AtomicUsize::new(0);

fn scratch_root(tag: &str) -> PathBuf {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock after epoch")
        .as_nanos();
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let root = std::env::temp_dir().join(format!(
        "noxid-wo45-phase2-{tag}-{}-{nonce}-{n}",
        std::process::id()
    ));
    fs::create_dir_all(&root).expect("create WO-45 phase-2 scratch root");
    root
}

fn write(path: &Path, contents: &str) {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create parent directory");
    }
    fs::write(path, contents).expect("write project file");
}

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

fn build(root: &Path) -> Output {
    Command::new(env!("CARGO_BIN_EXE_noxid"))
        .args([
            "build",
            root.to_str().expect("utf-8 project root"),
            "--out-dir",
            root.join("dist").to_str().expect("utf-8 out dir"),
        ])
        .output()
        .expect("run noxid build")
}

const HOST: &str = "export default { async fetch(request: Request): Promise<Response> { return new Response(\"ok\"); } }\n";

/// The import closure walks an imported struct's field types to pull in the
/// other structs it needs. A `distinct` field type was not followed, so a
/// distinct reachable only through an imported struct never entered the
/// importing module's distinct table and every consumer keyed off
/// `program.distinct_types` treated it as an unknown named type — the
/// result-position validator refused it with `VALIDATOR_UNSUPPORTED_TYPE`.
///
/// Remove the distinct arm of the import closure in
/// `crates/semantics/src/lib.rs` and this test goes red on the build itself.
#[test]
fn imported_distinct_struct_fields_generate_result_position_validators() {
    let root = scratch_root("imported-result");
    write(&root.join("Noxid.toml"), "[app]\ntitle = \"Imported\"\n");
    write(
        &root.join("src/types/progress.nox"),
        "type ConceptId = distinct String\n\
         type Weight = distinct Int\n\
         type ProgressSnapshot {\n  concepts: Array<ConceptId>\n  weight: Optional<Weight>\n}\n",
    );
    write(
        &root.join("server/api/progress.get.nox"),
        "import { ProgressSnapshot } from \"../../src/types/progress.nox\"\n\n\
         endpoint Progress {\n  version: 1\n  result: ProgressSnapshot\n}\n",
    );
    write(&root.join("server/host.ts"), HOST);

    let output = build(&root);
    assert!(
        output.status.success(),
        "imported distinct fields in a result position must build:\n{}",
        combined(&output)
    );

    let validators =
        fs::read_to_string(root.join("dist/server/validators.js")).expect("emitted validators.js");
    for expected in [
        "function $validate_ValidatorConceptId(",
        "function $validate_ValidatorWeight(",
        "validateTypeProgressSnapshot",
    ] {
        assert!(
            validators.contains(expected),
            "generated validators omitted `{expected}`:\n{validators}"
        );
    }
    // Erasure: each distinct validates as its own base — String for ConceptId,
    // Int for Weight — so the wire value stays the base representation.
    assert!(
        validators.contains("(item, itemPath) => $string(item, resource, itemPath)"),
        "Array<ConceptId> is not validated element-wise as String:\n{validators}"
    );
    assert!(
        validators.contains("$int("),
        "Optional<Weight> is not validated as Int:\n{validators}"
    );

    // The contract keeps the semantic name rather than the erased base, which
    // is the whole point of carrying the distinct across the file boundary.
    let contract =
        fs::read_to_string(root.join("dist/api-contract.json")).expect("emitted api-contract.json");
    assert!(
        contract.contains("\"type\":\"Array<ConceptId>\""),
        "the API contract erased the imported distinct to its base:\n{contract}"
    );
}

/// Three emitters, one meaning: construction and `.base()` are the identity on
/// the wire in the client, SSR, and server lowerings alike. A compiler-owned
/// endpoint handler is the only place the server emitter lowers an authored
/// expression, so it is where the server arm is provable end to end. Before
/// phase 2 the server emitter had no distinct arm at all and refused this
/// handler with `REMOTE_ACTION_CALL_UNSUPPORTED`.
#[test]
fn compiler_owned_server_handlers_erase_distinct_construction_and_unwrap() {
    let root = scratch_root("server-erasure");
    write(&root.join("Noxid.toml"), "[app]\ntitle = \"Erasure\"\n");
    write(
        &root.join("src/routes/+page.nox"),
        "component Home { view { <main>Home</main> } }\n",
    );
    write(
        &root.join("server/api/echo.get.nox"),
        "type UserId = distinct String\n\
         endpoint Echo {\n  version: 1\n  query { id: UserId }\n  result: String\n  handler { return id.base() }\n}\n",
    );
    write(
        &root.join("server/api/mint.get.nox"),
        "type TicketId = distinct String\n\
         endpoint Mint {\n  version: 1\n  result: TicketId\n  handler { return TicketId(\"t-1\") }\n}\n",
    );
    write(&root.join("server/host.ts"), HOST);

    let output = build(&root);
    assert!(
        output.status.success(),
        "compiler-owned server handlers must lower distinct construction and \
         unwrap:\n{}",
        combined(&output)
    );

    let handler =
        fs::read_to_string(root.join("dist/server/handler.js")).expect("emitted handler.js");
    // Both erase to the identity: the unwrap is the plain parameter and the
    // construction is the plain literal. No wrapper survives to the wire.
    assert!(
        handler.contains("return \"t-1\";"),
        "construction did not erase to its base literal:\n{handler}"
    );
    assert!(
        !handler.contains("TicketId(") && !handler.contains("distinct-"),
        "a distinct wrapper or synthetic id leaked into the server handler:\n{handler}"
    );
}