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, server: &str) -> Self {
        let ordinal = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed);
        let root = std::env::temp_dir().join(format!(
            "noxid-wo22-openapi-{label}-{}-{ordinal}",
            std::process::id()
        ));
        fs::create_dir_all(root.join("server/api/items/[id]")).unwrap();
        fs::write(
            root.join("Noxid.toml"),
            format!("[app]\ntitle = \"Inventory API\"\n\n[server]\n{server}"),
        )
        .unwrap();
        fs::write(root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
        fs::write(
            root.join("server/api/items/[id]/update.post.nox"),
            r#"
endpoint UpdateItem {
  description: "Updates an inventory item."
  params { id: Int }
  query { notify: Optional<Boolean> }
  body { count: Int }
  result: Int
  timeout: 3s
  limit: 2 per minute per ip
}
"#,
        )
        .unwrap();
        fs::write(
            root.join("server/host.js"),
            "export const endpoints = { 'endpoint:UpdateItem@1': async ({ count }) => count };\n",
        )
        .unwrap();
        Self { root }
    }

    fn build(&self) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .args(["build", ".", "--out-dir", "dist"])
            .current_dir(&self.root)
            .output()
            .unwrap()
    }

    fn node(&self, source: &str) -> Output {
        let path = self.root.join("dist/openapi-check.mjs");
        fs::write(&path, source).unwrap();
        Command::new("node")
            .arg(path.file_name().unwrap())
            .current_dir(self.root.join("dist"))
            .output()
            .unwrap()
    }
}

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 build_always_emits_openapi_but_default_off_door_is_404() {
    let fixture = Fixture::new("off", "");
    let build = fixture.build();
    assert_success(&build, "build default-off OpenAPI fixture");
    let document = fs::read_to_string(fixture.root.join("dist/api.openapi.json")).unwrap();
    assert!(document.contains("\"openapi\": \"3.1.0\""), "{document}");
    assert!(
        document.contains("\"/api/items/{id}/update\""),
        "{document}"
    );
    assert!(document.contains("\"post\""), "{document}");
    assert!(
        document.contains("Updates an inventory item."),
        "{document}"
    );
    assert!(
        document.contains("UpdateItem(params { id: Int }, query { notify: Optional<Boolean> }, body { count: Int }) -> Int"),
        "{document}"
    );
    let manifest =
        fs::read_to_string(fixture.root.join("dist/server/security.manifest.json")).unwrap();
    assert!(
        manifest.contains("\"surfaces\":{\"apiDocs\":false,\"mcp\":false}"),
        "{manifest}"
    );
    let node = fixture.node(
        r#"
import { fetch } from "./server/handler.js";
const response = await fetch(new Request("http://noxid.test/_noxid/openapi.json"));
if (response.status !== 404) throw new Error(`default-off docs returned ${response.status}`);
"#,
    );
    assert_success(&node, "default-off OpenAPI route");
}

#[test]
fn api_docs_serves_exact_artifact_and_error_payload_matches_published_schema() {
    let fixture = Fixture::new("on", "api_docs = true\nmcp = false\n");
    let build = fixture.build();
    assert_success(&build, "build enabled OpenAPI fixture");
    let manifest =
        fs::read_to_string(fixture.root.join("dist/server/security.manifest.json")).unwrap();
    assert!(
        manifest.contains("\"surfaces\":{\"apiDocs\":true,\"mcp\":false}"),
        "{manifest}"
    );
    let node = fixture.node(
        r#"
import fs from "node:fs";
import { fetch } from "./server/handler.js";
const published = fs.readFileSync("api.openapi.json", "utf8");
const docs = await fetch(new Request("http://noxid.test/_noxid/openapi.json"));
if (docs.status !== 200 || docs.headers.get("content-type") !== "application/json; charset=utf-8") throw new Error(`docs response ${docs.status} ${docs.headers.get("content-type")}`);
if (await docs.text() !== published) throw new Error("served OpenAPI differs from build artifact");
const method = await fetch(new Request("http://noxid.test/_noxid/openapi.json", { method: "POST" }));
if (method.status !== 405) throw new Error(`non-GET OpenAPI door returned ${method.status}`);

const refusal = await fetch(new Request("http://noxid.test/api/items/4/update", {
  method: "POST",
  headers: { "content-type": "application/json", "x-real-ip": "127.0.0.1" },
  body: JSON.stringify({ count: "wrong" }),
}));
if (refusal.status !== 422) throw new Error(`typed refusal returned ${refusal.status}`);
const value = await refusal.json();
const document = JSON.parse(published);
const schema = document.components.schemas.NoxidErrorResponse;
function matches(schema, value) {
  if (schema.$ref) return matches(document.components.schemas[schema.$ref.split("/").at(-1)], value);
  if (Object.hasOwn(schema, "const") && value !== schema.const) return false;
  if (Array.isArray(schema.type)) {
    if (!schema.type.some((type) => matches({ type }, value))) return false;
  } else if (schema.type === "object" && (value === null || typeof value !== "object" || Array.isArray(value))) return false;
  else if (schema.type === "string" && typeof value !== "string") return false;
  else if (schema.type === "null" && value !== null) return false;
  if (schema.required?.some((key) => !Object.hasOwn(value, key))) return false;
  if (schema.properties) {
    for (const [key, property] of Object.entries(schema.properties)) if (Object.hasOwn(value, key) && !matches(property, value[key])) return false;
    if (schema.additionalProperties === false && Object.keys(value).some((key) => !Object.hasOwn(schema.properties, key))) return false;
  }
  return true;
}
if (!matches(schema, value)) throw new Error(`actual refusal violates NoxidErrorResponse: ${JSON.stringify(value)}`);
"#,
    );
    assert_success(&node, "served OpenAPI and response schema parity");
}

#[test]
fn agent_surface_keys_require_real_toml_booleans() {
    let fixture = Fixture::new("invalid-bool", "api_docs = \"true\"\n");
    let build = fixture.build();
    assert!(!build.status.success());
    let stderr = String::from_utf8_lossy(&build.stderr);
    assert!(
        stderr.contains("error[SERVER_AGENT_SURFACE_BOOLEAN_REQUIRED]")
            && stderr.contains("must be the boolean `true` or `false`"),
        "{stderr}"
    );
    assert!(!fixture.root.join("dist/api.openapi.json").exists());
}