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::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-wo23-{label}-{}-{ordinal}",
            std::process::id()
        ));
        fs::create_dir_all(&root).unwrap();
        fs::write(
            root.join("Noxid.toml"),
            "[app]\ntitle = \"WO-23 fixture\"\n\n[server]\napi_docs = true\nmcp = true\n",
        )
        .unwrap();
        fs::write(root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
        Self { root }
    }

    fn path(&self, relative: &str) -> PathBuf {
        self.root.join(relative)
    }

    fn write(&self, relative: &str, contents: &str) {
        let path = self.path(relative);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, contents).unwrap();
    }

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

    fn build(&self) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .args(["build", ".", "--out-dir", "dist"])
            .current_dir(&self.root)
            .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)
    );
}

fn baseline_sources(fixture: &Fixture) {
    fixture.write(
        "server/api/save.post.nox",
        r#"endpoint Save {
  version: 1
  body { value: Number removed: String }
  result: Number
  handler { return value }
}
"#,
    );
    fixture.write(
        "server/api/changed.post.nox",
        "endpoint Changed { version: 1 result: String handler { return \"ok\" } }\n",
    );
    fixture.write(
        "server/api/removed.get.nox",
        "endpoint Removed { version: 1 result: String handler { return \"ok\" } }\n",
    );
}

#[test]
fn gate_names_every_breaking_class_and_version_bumps_accept_changes() {
    let fixture = Fixture::new("breaking");
    baseline_sources(&fixture);
    assert_success(&fixture.gate(), "initialize committed contract");
    let baseline = fs::read_to_string(fixture.path("api-contract.json")).unwrap();

    fs::remove_file(fixture.path("server/api/removed.get.nox")).unwrap();
    fs::rename(
        fixture.path("server/api/changed.post.nox"),
        fixture.path("server/api/moved.put.nox"),
    )
    .unwrap();
    fixture.write(
        "server/api/save.post.nox",
        r#"endpoint Save {
  version: 1
  body { value: Int required: Boolean }
  result: Int
  handler { return value }
}
"#,
    );
    let failed = fixture.gate();
    assert!(
        !failed.status.success(),
        "breaking gate unexpectedly passed"
    );
    let stderr = String::from_utf8_lossy(&failed.stderr);
    for expected in [
        "error[API_CONTRACT_BREAKING_CHANGE]",
        "removed endpoint `Removed`",
        "method changed from POST to PUT",
        "path changed from `/api/changed` to `/api/moved`",
        "removed body field `removed`",
        "narrowed body.value type from `Number` to `Int`",
        "added required body field `required`",
        "narrowed result type from `Number` to `Int`",
    ] {
        assert!(stderr.contains(expected), "missing {expected}:\n{stderr}");
    }
    assert_eq!(
        fs::read_to_string(fixture.path("api-contract.json")).unwrap(),
        baseline,
        "failed gate rewrote the committed contract"
    );

    fixture.write(
        "server/api/removed.get.nox",
        "endpoint Removed { version: 1 result: String handler { return \"ok\" } }\n",
    );
    fixture.write(
        "server/api/moved.put.nox",
        "endpoint Changed { version: 2 result: String handler { return \"ok\" } }\n",
    );
    fixture.write(
        "server/api/save.post.nox",
        r#"endpoint Save {
  version: 2
  body { value: Int required: Boolean }
  result: Int
  handler { return value }
}
"#,
    );
    assert_success(&fixture.gate(), "accept versioned breaking changes");
    let accepted = fs::read_to_string(fixture.path("api-contract.json")).unwrap();
    assert_ne!(accepted, baseline);
    assert!(accepted.contains("\"semanticId\":\"endpoint:Save@2\""));
    assert!(accepted.contains("\"semanticId\":\"endpoint:Changed@2\""));
}

#[test]
fn additive_changes_update_only_after_a_passing_gate_and_match_build_output() {
    let fixture = Fixture::new("additive");
    fixture.write(
        "server/api/value.post.nox",
        "endpoint Value { body { value: Int } result: Int handler { return value } }\n",
    );
    assert_success(&fixture.gate(), "initialize additive contract");
    let baseline = fs::read_to_string(fixture.path("api-contract.json")).unwrap();

    fixture.write(
        "server/api/value.post.nox",
        r#"endpoint Value {
  body { value: Int note: Optional<String> }
  result: Number
  handler { return value }
  scenario Broken {
    description: "contract updates wait for the complete gate"
    when: request(body: Shape(value = 1, note = None))
    expect: 2
  }
}
"#,
    );
    let scenario_failure = fixture.gate();
    assert!(!scenario_failure.status.success());
    assert_eq!(
        fs::read_to_string(fixture.path("api-contract.json")).unwrap(),
        baseline,
        "a failed scenario published an additive contract"
    );

    fixture.write(
        "server/api/value.post.nox",
        "endpoint Value { body { value: Int note: Optional<String> } result: Number handler { return value } }\n",
    );
    fixture.write(
        "server/api/new.get.nox",
        "endpoint NewValue { result: String handler { return \"new\" } }\n",
    );
    assert_success(&fixture.gate(), "accept additive contract changes");
    let committed = fs::read_to_string(fixture.path("api-contract.json")).unwrap();
    assert_ne!(committed, baseline);
    assert!(committed.contains("\"name\":\"NewValue\""));
    assert!(committed.contains("Optional<String>"));
    assert_success(&fixture.build(), "build additive fixture");
    assert_eq!(
        committed,
        fs::read_to_string(fixture.path("dist/api-contract.json")).unwrap(),
        "committed gate contract differs from fresh build artifact"
    );
}

#[test]
fn version_is_visible_in_semantic_openapi_mcp_and_security_surfaces() {
    let fixture = Fixture::new("surfaces");
    fixture.write(
        "server/api/status.get.nox",
        "endpoint Status { version: 7 description: \"Versioned status.\" result: String handler { return \"ready\" } }\n",
    );
    assert_success(&fixture.build(), "build versioned surfaces");
    for (relative, needles) in [
        (
            "dist/api.openapi.json",
            vec![
                "\"x-noxid-endpoint-id\": \"endpoint:Status@7\"",
                "\"x-noxid-version\": 7",
            ],
        ),
        (
            "dist/app.semantic-units.json",
            vec!["\"id\":\"endpoint:Status@7\"", "\"version\":7"],
        ),
        (
            "dist/server/security.manifest.json",
            vec!["\"id\":\"endpoint:Status@7\"", "\"version\":7"],
        ),
    ] {
        let text = fs::read_to_string(fixture.path(relative)).unwrap();
        for needle in needles {
            assert!(
                text.contains(needle),
                "{relative} omitted {needle}:\n{text}"
            );
        }
    }
    fixture.write(
        "dist/versioned.mjs",
        r#"import { fetch as handle } from "./server/handler.js";
const request = new Request("http://noxid.test/_noxid/mcp", {
  method: "POST",
  headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
const rpc = await (await handle(request)).json();
const tool = rpc.result.tools[0];
if (tool.name !== "Status" || tool["x-noxid-endpoint"].semanticId !== "endpoint:Status@7" || tool["x-noxid-endpoint"].version !== 7) throw new Error(JSON.stringify(tool));
"#,
    );
    let node = Command::new("node")
        .arg("versioned.mjs")
        .current_dir(fixture.path("dist"))
        .output()
        .unwrap();
    assert_success(&node, "inspect MCP version metadata");
}

#[test]
fn website_committed_contract_matches_its_fresh_build() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
    let website = root.join("website");
    let output = Command::new(env!("CARGO_BIN_EXE_noxid"))
        .args(["build", ".", "--out-dir", "dist"])
        .current_dir(&website)
        .output()
        .unwrap();
    assert_success(&output, "build website contract dogfood");
    assert_eq!(
        fs::read(website.join("api-contract.json")).unwrap(),
        fs::read(website.join("dist/api-contract.json")).unwrap(),
        "website committed contract is stale"
    );
}