arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
#![cfg(unix)]

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;

#[test]
fn install_runs_frozen_frontend_install() {
    let project = fixture("install");
    let output = arc(&project)
        .arg("install")
        .output()
        .expect("arc install should run");
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let log = fs::read_to_string(project.join("commands.log")).expect("command log should exist");
    assert_eq!(log.trim(), "pnpm install --frozen-lockfile");
    fs::remove_dir_all(project).expect("fixture should be removed");
}

#[test]
fn build_runs_frozen_frontend_then_release_backend() {
    let project = fixture("build-order");
    let output = arc(&project)
        .arg("build")
        .output()
        .expect("arc build should run");
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let log = fs::read_to_string(project.join("commands.log")).expect("command log should exist");
    assert_eq!(
        log.lines().collect::<Vec<_>>(),
        [
            "cargo run --quiet --package demo --bin arcature-contract",
            "pnpm install --frozen-lockfile",
            "pnpm build",
            "cargo build --release --package demo"
        ]
    );
    fs::remove_dir_all(project).expect("fixture should be removed");
}

#[test]
fn frontend_failure_stops_build_and_propagates_failure() {
    let project = fixture("frontend-failure");
    let output = arc(&project)
        .arg("build")
        .env("ARC_FIXTURE_FAIL_FRONTEND", "1")
        .output()
        .expect("arc build should run");
    assert!(!output.status.success());
    let log = fs::read_to_string(project.join("commands.log")).expect("command log should exist");
    assert!(!log.contains("cargo build"));
    fs::remove_dir_all(project).expect("fixture should be removed");
}

#[test]
fn check_discovers_nested_project_and_emits_clean_json() {
    let project = fixture("nested-check");
    let nested = project.join("frontend/src/pages");
    fs::create_dir_all(&nested).expect("nested directory should exist");
    let output = arc_from(&project, &nested)
        .args(["check", "--json"])
        .output()
        .expect("arc check should run");
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let value: serde_json::Value =
        serde_json::from_slice(&output.stdout).expect("stdout should contain only JSON");
    assert!(
        value["checks"]
            .as_array()
            .is_some_and(|checks| !checks.is_empty())
    );
    fs::remove_dir_all(project).expect("fixture should be removed");
}

#[test]
fn check_reports_missing_project_file_without_running_tools() {
    let project = fixture("invalid-check");
    fs::remove_file(project.join("frontend/vite.config.ts"))
        .expect("fixture file should be removed");
    let output = arc(&project)
        .args(["check", "--json"])
        .output()
        .expect("arc check should run");
    assert!(!output.status.success());
    let value: serde_json::Value =
        serde_json::from_slice(&output.stdout).expect("stdout should remain JSON");
    assert!(
        value["checks"]
            .as_array()
            .is_some_and(|checks| checks.iter().any(|check| check["status"] == "error"))
    );
    assert!(!project.join("commands.log").exists());
    fs::remove_dir_all(project).expect("fixture should be removed");
}

#[test]
fn doctor_is_read_only_and_reports_certified_fixture_tools() {
    let project = fixture("doctor");
    fs::write(
        project.join("s.script"),
        "[scripts.attack]\nprogram = \"/usr/bin/touch\"\nargs = [\"doctor-ran-script\"]\n",
    )
    .expect("malicious script should be written");
    let output = arc(&project)
        .args(["doctor", "--json"])
        .output()
        .expect("arc doctor should run");
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    let value: serde_json::Value =
        serde_json::from_slice(&output.stdout).expect("doctor stdout should contain only JSON");
    assert!(value["checks"].as_array().is_some_and(|checks| {
        checks
            .iter()
            .any(|check| check["name"] == "node" && check["status"] == "ok")
    }));
    assert!(!project.join("doctor-ran-script").exists());
    assert!(!project.join("commands.log").exists());
    fs::remove_dir_all(project).expect("fixture should be removed");
}

fn fixture(label: &str) -> PathBuf {
    let root =
        std::env::temp_dir().join(format!("arcature-command-{label}-{}", std::process::id()));
    fs::create_dir_all(root.join("bin")).expect("bin should be created");
    fs::create_dir_all(root.join("frontend")).expect("frontend should be created");
    fs::write(root.join("arcature.toml"), "frontend = \"react\"\nfrontend_dir = \"frontend\"\nbackend_package = \"demo\"\nbackend_binary = \"demo\"\nbackend_port = 3000\n").expect("config should be written");
    for path in [
        "Cargo.toml",
        "frontend/vite.config.ts",
        "frontend/pnpm-lock.yaml",
    ] {
        fs::write(root.join(path), "fixture").expect("required file should be written");
    }
    fs::write(
        root.join("frontend/package.json"),
        r#"{"devDependencies":{"vite":"8.2.1"}}"#,
    )
    .expect("package manifest should be written");
    fs::write(
        root.join("frontend/arcature.contract.json"),
        r#"{"format":"arcature.page-contract.v1","pages":{}}"#,
    )
    .expect("frontend contract should be written");
    fs::create_dir_all(root.join("frontend/src")).expect("frontend src should be created");
    fs::write(
        root.join("frontend/src/arcature-contracts.ts"),
        "// Arcature Rust-to-TypeScript client contract.\n// Generated by `arcature-contract` from the registered page contracts.\n// Do not edit by hand; run `arc check` to regenerate and verify.\n",
    )
    .expect("typescript contract should be written");
    write_executable(
        &root.join("bin/node"),
        "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo v24.19.0; exit 0; fi\n",
    );
    write_executable(
        &root.join("bin/pnpm"),
        "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 11.20.0; exit 0; fi\necho \"pnpm $*\" >> \"$ARC_FIXTURE_LOG\"\nif [ \"$ARC_FIXTURE_FAIL_FRONTEND\" = \"1\" ] && [ \"$1\" = \"build\" ]; then exit 7; fi\n",
    );
    write_executable(
        &root.join("bin/cargo"),
        "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'cargo 1.97.1'; exit 0; fi\necho \"cargo $*\" >> \"$ARC_FIXTURE_LOG\"\nif [ \"$1\" = \"run\" ]; then echo '{\"format\":\"arcature.page-contract.v1\",\"pages\":{}}'; fi\n",
    );
    root
}

fn arc(project: &Path) -> Command {
    arc_from(project, project)
}

fn arc_from(project: &Path, directory: &Path) -> Command {
    let mut command = Command::new(env!("CARGO_BIN_EXE_arc"));
    command
        .current_dir(directory)
        .env("ARC_FIXTURE_LOG", project.join("commands.log"))
        .env(
            "PATH",
            format!(
                "{}:{}",
                project.join("bin").display(),
                std::env::var("PATH").unwrap_or_default()
            ),
        );
    command
}

fn write_executable(path: &Path, contents: &str) {
    fs::write(path, contents).expect("fixture executable should be written");
    let mut permissions = fs::metadata(path)
        .expect("fixture metadata should exist")
        .permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions).expect("fixture should be executable");
}