arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
use std::fs;
use std::path::PathBuf;
use std::process::Command;

fn temporary_root(label: &str) -> PathBuf {
    std::env::temp_dir().join(format!(
        "arcature-cli-{label}-{}-{}",
        std::process::id(),
        unique_suffix()
    ))
}

fn unique_suffix() -> u128 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |duration| duration.as_nanos())
}

fn arc() -> Command {
    Command::new(env!("CARGO_BIN_EXE_arc"))
}

/// Files every preset generates (the base layer). The A15 rewrite moved the
/// generated app onto the `application!` / `module!` / `routes!` /
/// `#[controller]` / `#[page]` model: a feature-first `app/home/` module
/// replaces the old empty visual-symmetry seam directories
/// (`controllers/`, `models/`, `middleware/`, `validation/`, `jobs/`,
/// `mail/`, `pages/`) and the no-DB app no longer carries a `state.rs` or
/// split `routes/web.rs` / `routes/api.rs` (AGENTS.md ยง2 small-app-small).
///
/// ADR-0008 moved the backend source root from `src/` to `app/`, the binary
/// entry to `bootstrap/main.rs`, the frontend to `resources/`, and migrations
/// to `database/migrations/`. These destination paths are the authoritative
/// canonical generated tree; `Cargo.toml` declares `[lib] path` and explicit
/// `[[bin]]` paths so Cargo's `src/`-relative autodiscovery no longer applies.
const BASE_FILES: &[&str] = &[
    ".gitignore",
    "Cargo.toml",
    "arcature.toml",
    "s.script",
    "bootstrap/main.rs",
    "app/lib.rs",
    "app/run.rs",
    "app/application.rs",
    "app/proxy.rs",
    "app/errors.rs",
    "app/root_document.rs",
    "app/frontend_asset.rs",
    "app/bin/arcature-contract.rs",
    "app/bin/arcature-metadata.rs",
    "app/routes/mod.rs",
    "app/home/mod.rs",
    "app/home/home_controller.rs",
    "app/home/home_page.rs",
    "tests/smoke.rs",
    "resources/index.html",
    "resources/tsconfig.json",
    "resources/pnpm-workspace.yaml",
    "resources/src/style.css",
];

/// Files the database overlay adds or replaces. The DB preset restores a
/// stateful `state.rs` (the no-DB app is stateless), adds the `app/links/`
/// feature module (SeaORM entity, resource, service, controller, request)
/// and the `database/migrations/` package with the `links` table migration.
/// The `links` routes are declared in `app/routes/mod.rs` via the stateful
/// `routes!` DSL (no separate `links_routes.rs`).
const DB_FILES: &[&str] = &[
    ".env.example",
    "app/state.rs",
    "app/links/mod.rs",
    "app/links/link.rs",
    "app/links/link_entity.rs",
    "app/links/link_resource.rs",
    "app/links/link_service.rs",
    "app/links/links_controller.rs",
    "app/links/store_link_request.rs",
    "database/migrations/Cargo.toml",
    "database/migrations/src/lib.rs",
    "database/migrations/src/main.rs",
    "database/migrations/src/m20260814_create_links.rs",
];

#[test]
fn generates_complete_react_and_vue_projects() {
    let root = temporary_root("generation");
    fs::create_dir_all(&root).expect("test root should be created");
    for (name, frontend, entry, page) in [
        ("react-app", None, "app.tsx", "Home.tsx"),
        ("vue-app", Some("vue"), "app.ts", "Home.vue"),
    ] {
        let target = root.join(name);
        let mut command = arc();
        command.arg("new").arg(&target);
        if let Some(value) = frontend {
            command.args(["--frontend", value]);
        }
        assert!(command.status().expect("arc should run").success());
        for path in BASE_FILES {
            assert!(target.join(path).is_file(), "missing {path}");
        }
        for path in DB_FILES {
            assert!(target.join(path).is_file(), "missing {path}");
        }
        assert!(target.join("resources/package.json").is_file());
        // No committed `pnpm-lock.yaml`: the canonical template ships the real
        // npm dependency (`@arcature/client@<version>`, not a monorepo
        // `file:../../../client` path), and a lockfile is generated by
        // `pnpm install`, not committed (Issue 1 / AGENTS.md ยง31). Committing a
        // lockfile here would leak the `directory: ../../../client` staging
        // path used by repository CI.
        assert!(!target.join("resources/pnpm-lock.yaml").exists());
        assert!(target.join("resources/vite.config.ts").is_file());
        assert!(target.join("resources/src").join(entry).is_file());
        assert!(target.join("resources/src/pages").join(page).is_file());
        // ADR-0008: the canonical backend source root is `app/`, not `src/`.
        // Cargo's `src/`-relative autodiscovery is disabled by the explicit
        // `[lib] path` + `[[bin]]` paths in `Cargo.toml`, so a generated
        // project must NOT carry a root `src/` directory.
        assert!(
            !target.join("src").exists(),
            "canonical generated app must not have a root src/ directory"
        );
        // The canonical frontend root is `resources/`, not `frontend/`.
        assert!(
            !target.join("frontend").exists(),
            "canonical generated app must not have a root frontend/ directory"
        );
        for entry in walk(&target) {
            let text = fs::read_to_string(&entry).expect("generated text should be readable");
            assert!(!text.contains("__PROJECT_NAME__"));
            assert!(!text.contains("__RUST_NAME__"));
            assert!(!text.contains("__FRONTEND__"));
            assert!(!text.contains("__ENTRY_EXT__"));
            assert!(!text.contains("__REACT_REFRESH__"));
        }
    }
    fs::remove_dir_all(root).expect("test root should be removed");
}

#[test]
fn generates_no_db_project_without_migration_package() {
    let root = temporary_root("no-db");
    fs::create_dir_all(&root).expect("test root should be created");
    let target = root.join("no-db-app");
    let mut command = arc();
    command.arg("new").arg(&target).arg("--no-db");
    assert!(command.status().expect("arc should run").success());

    // The no-db project should NOT have database/migrations/ or .env.example.
    assert!(
        !target.join("database/migrations").exists(),
        "database/migrations/ should not exist for --no-db"
    );
    assert!(
        !target.join(".env.example").exists(),
        ".env.example should not exist for --no-db"
    );
    // The no-db Cargo.toml should NOT contain the db feature or a workspace.
    let cargo_toml = fs::read_to_string(target.join("Cargo.toml")).expect("Cargo.toml readable");
    assert!(
        !cargo_toml.contains("\"db\""),
        "no-db project should not enable the db feature"
    );
    assert!(
        !cargo_toml.contains("[workspace]"),
        "no-db project should not be a workspace"
    );

    // The no-db run.rs should NOT reference arcature::db.
    let run_rs = fs::read_to_string(target.join("app/run.rs")).expect("app/run.rs readable");
    assert!(
        !run_rs.contains("arcature::db"),
        "no-db app/run.rs should not reference arcature::db"
    );

    fs::remove_dir_all(root).expect("test root should be removed");
}

#[test]
fn generates_db_project_with_migration_package_by_default() {
    let root = temporary_root("db-default");
    fs::create_dir_all(&root).expect("test root should be created");
    let target = root.join("db-app");
    let mut command = arc();
    command.arg("new").arg(&target);
    assert!(command.status().expect("arc should run").success());

    // The default (DB-enabled) project should have database/migrations/ and
    // .env.example (ADR-0008: migrations live under `database/migrations/`).
    assert!(
        target.join("database/migrations/Cargo.toml").is_file(),
        "database/migrations/Cargo.toml should exist"
    );
    assert!(
        target.join("database/migrations/src/lib.rs").is_file(),
        "database/migrations/src/lib.rs should exist"
    );
    assert!(
        target.join("database/migrations/src/main.rs").is_file(),
        "database/migrations/src/main.rs should exist"
    );
    assert!(
        target.join(".env.example").is_file(),
        ".env.example should exist"
    );

    // The DB Cargo.toml should enable the db feature and have a workspace
    // section (the migration package is a workspace member).
    let cargo_toml = fs::read_to_string(target.join("Cargo.toml")).expect("Cargo.toml readable");
    assert!(
        cargo_toml.contains("\"db\""),
        "db project should enable the db feature on arcature"
    );
    assert!(
        cargo_toml.contains("[workspace]"),
        "db project should be a workspace"
    );

    // The DB run.rs should reference arcature::db and DbConfig.
    let run_rs = fs::read_to_string(target.join("app/run.rs")).expect("app/run.rs readable");
    assert!(
        run_rs.contains("arcature::db"),
        "db app/run.rs should reference arcature::db"
    );
    assert!(
        run_rs.contains("DbConfig"),
        "db app/run.rs should reference DbConfig"
    );

    fs::remove_dir_all(root).expect("test root should be removed");
}

#[test]
fn refuses_invalid_names_and_existing_targets() {
    let root = temporary_root("safety");
    fs::create_dir_all(&root).expect("test root should be created");
    let existing = root.join("existing");
    fs::create_dir(&existing).expect("existing target should be created");
    fs::write(existing.join("owned.txt"), "keep").expect("sentinel should be written");
    assert!(
        !arc()
            .args(["new"])
            .arg(&existing)
            .status()
            .expect("arc should run")
            .success()
    );
    assert_eq!(
        fs::read_to_string(existing.join("owned.txt")).expect("sentinel should remain"),
        "keep"
    );
    assert!(
        !arc()
            .args(["new"])
            .arg(root.join("bad_name"))
            .status()
            .expect("arc should run")
            .success()
    );
    fs::remove_dir_all(root).expect("test root should be removed");
}

fn walk(root: &std::path::Path) -> Vec<PathBuf> {
    let mut files = Vec::new();
    let mut pending = vec![root.to_path_buf()];
    while let Some(directory) = pending.pop() {
        for entry in fs::read_dir(directory).expect("generated directory should be readable") {
            let entry = entry.expect("generated entry should be readable");
            if entry
                .file_type()
                .expect("entry type should be readable")
                .is_dir()
            {
                pending.push(entry.path());
            } else {
                files.push(entry.path());
            }
        }
    }
    files
}