mecha10-cli 0.7.0

Mecha10 CLI tool
Documentation
//! Real-binary integration tests (LAB-4127)
//!
//! Every other test under `packages/cli/tests/` -- including, despite its name,
//! `sim_integration_test.rs` -- calls handler/service functions in-process. None of them spawn
//! the actual built `mecha10` binary, so none verify real CLI argument parsing (clap wiring,
//! exit codes) or a real multi-command workflow the way a user actually runs it from a shell.
//!
//! These tests close that gap with [`assert_cmd`], which builds and runs the real `mecha10`
//! binary as a subprocess against a temp project directory, chaining real commands the way a
//! user would.
//!
//! # Scope decision (the thing this ticket was blocked on)
//!
//! `mecha10 init`, `mecha10 build`, `mecha10 upload`, and `mecha10 train` all shell out to a
//! network call (template/binary download, crates registry, the control plane) or a real cargo
//! cross-compile in their happy path -- confirmed by hand: `mecha10 init --non-interactive` hangs
//! until it times out when there's no network and no locally `--install`ed dev template cache
//! (see `TemplateDownloadService::resolve_version`'s `vdev` short-circuit in
//! `packages/mecha10-templates/src/services/template_download.rs` -- replicating that cache
//! layout well enough for `init` to succeed *and* for the result to then pass `validate` would
//! mean hand-maintaining a shadow copy of the real release template tarball, which drifts the
//! moment templates change). That's a real CI-scoping cost, not just a "write more code" one, so
//! this suite deliberately does NOT drive those commands through their network/build-dependent
//! happy path. Covering that path for real belongs in a separate, explicitly slow/opt-in suite
//! wired to a local template/binary fixture server -- tracked as follow-up, not this ticket.
//!
//! What's in scope here instead -- all real binary, real arg parsing, no network/docker/cargo
//! cross-compile, so it runs by default under plain `cargo test` alongside every other fast
//! unit/integration test in this crate:
//! - top-level and per-subcommand `--help`/`--version`/unknown-subcommand clap wiring
//! - a real chained workflow (`validate` -> `target` -> `config show`) against a project dir
//!   seeded the way `mecha10 init` would have left it, run as separate `mecha10` process
//!   invocations communicating only through the filesystem, the way a user actually runs them
//! - the error paths of commands that *do* need a project/network/build artifact (`validate`,
//!   `target`, `upload`) when run without one -- these fail fast offline, so they're worth
//!   pinning: a regression here (wrong exit code, panic instead of a clean error) would be an
//!   arg-parsing/wiring bug exactly like the ones in scope above.

use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;

/// Spawn the real built `mecha10` binary (not a handler function call in-process).
fn mecha10() -> Command {
    Command::cargo_bin("mecha10").expect("mecha10 binary should be built by `cargo test`")
}

/// Minimal valid `mecha10.json`, matching `schemas/mecha10.schema.json` (`name`/`version`
/// required, everything else defaulted) -- the same shape `mecha10 init` would have written,
/// without going through `init`'s network-dependent template download (see module docs).
fn write_minimal_project(dir: &std::path::Path, name: &str) {
    fs::write(
        dir.join("mecha10.json"),
        format!(
            r#"{{
  "name": "{name}",
  "version": "0.1.0",
  "robot_id": "{name}-001",
  "nodes": {{}}
}}"#
        ),
    )
    .expect("failed to write mecha10.json fixture");
}

// ---------------------------------------------------------------------------
// Top-level arg parsing / wiring
// ---------------------------------------------------------------------------

#[test]
fn top_level_help_lists_real_subcommands() {
    mecha10()
        .arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("Mecha10 CLI"))
        .stdout(predicate::str::contains("init"))
        .stdout(predicate::str::contains("build"))
        .stdout(predicate::str::contains("upload"));
}

#[test]
fn version_flag_prints_a_version_and_exits_zero() {
    mecha10()
        .arg("--version")
        .assert()
        .success()
        .stdout(predicate::str::contains("mecha10"));
}

#[test]
fn unknown_subcommand_is_a_clap_usage_error() {
    mecha10()
        .arg("totally-not-a-real-command")
        .assert()
        .failure()
        .code(2)
        .stderr(predicate::str::contains("unrecognized subcommand"));
}

#[test]
fn build_without_a_target_subcommand_is_a_clap_usage_error() {
    // `build` requires `robot` or `remote` -- omitting both should fail arg parsing, not reach
    // the (network/cargo-dependent) build handler at all.
    mecha10().arg("build").assert().failure().code(2);
}

#[test]
fn init_help_documents_non_interactive_flags_without_touching_the_network() {
    // `--help` always short-circuits clap before any handler runs, so this is safe to assert
    // even though a real `mecha10 init` invocation needs the network (see module docs).
    mecha10()
        .arg("init")
        .arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("--non-interactive"))
        .stdout(predicate::str::contains("--template"));
}

// ---------------------------------------------------------------------------
// Chained real workflow against a seeded project dir
// ---------------------------------------------------------------------------

#[test]
fn validate_then_target_then_config_show_chain_against_a_real_project() {
    let project = TempDir::new().expect("failed to create temp project dir");
    write_minimal_project(project.path(), "test-robot");

    // 1. `mecha10 validate` (the `mecha10 config validate` alias) against the real binary.
    mecha10()
        .current_dir(project.path())
        .arg("validate")
        .assert()
        .success()
        .stdout(predicate::str::contains("is valid"));

    // 2. `mecha10 target` reads the same mecha10.json the previous command just validated.
    mecha10()
        .current_dir(project.path())
        .arg("target")
        .assert()
        .success()
        .stdout(predicate::str::contains("No targets configured"));

    // 3. `mecha10 config show` reports the project identity from the same file, chaining a
    // third real process invocation through nothing but the filesystem state on disk -- the
    // way a user actually runs a sequence of CLI commands.
    mecha10()
        .current_dir(project.path())
        .arg("config")
        .arg("show")
        .assert()
        .success()
        .stdout(predicate::str::contains("Project name:"))
        .stdout(predicate::str::contains("test-robot"))
        .stdout(predicate::str::contains("test-robot-001"));
}

#[test]
fn config_validate_path_flag_targets_an_arbitrary_file_instead_of_the_project() {
    let project = TempDir::new().expect("failed to create temp project dir");
    let config_path = project.path().join("standalone.json");
    fs::write(
        &config_path,
        r#"{"name": "standalone-robot", "version": "1.0.0", "nodes": {}}"#,
    )
    .unwrap();

    mecha10()
        .current_dir(project.path())
        .arg("config")
        .arg("validate")
        .arg("--path")
        .arg("standalone.json")
        .assert()
        .success()
        .stdout(predicate::str::contains("standalone.json"))
        .stdout(predicate::str::contains("is valid"));
}

// ---------------------------------------------------------------------------
// Offline error paths of project-dependent commands
// ---------------------------------------------------------------------------

#[test]
fn validate_outside_a_project_fails_fast_with_exit_code_one() {
    let empty_dir = TempDir::new().expect("failed to create temp dir");

    mecha10()
        .current_dir(empty_dir.path())
        .arg("validate")
        .assert()
        .failure()
        .code(1)
        .stdout(predicate::str::contains("Not in a Mecha10 project directory"));
}

#[test]
fn target_outside_a_project_prints_guidance_and_exits_zero() {
    // Unlike `validate`, `mecha10 target` treats "no project here" as informational rather
    // than an error (see `handlers/target.rs::show_targets`) -- pinning the exit code
    // distinction so a future refactor can't silently flip one into the other.
    let empty_dir = TempDir::new().expect("failed to create temp dir");

    mecha10()
        .current_dir(empty_dir.path())
        .arg("target")
        .assert()
        .success()
        .stdout(predicate::str::contains("Not inside a mecha10 project"));
}

#[test]
fn validate_rejects_a_config_with_an_invalid_version_string() {
    let project = TempDir::new().expect("failed to create temp project dir");
    // `version` must match `^\d+\.\d+\.\d+(-.+)?$` per schemas/mecha10.schema.json -- "latest"
    // doesn't, so this should fail schema validation, not panic.
    fs::write(
        project.path().join("mecha10.json"),
        r#"{"name": "bad-robot", "version": "latest", "nodes": {}}"#,
    )
    .unwrap();

    mecha10()
        .current_dir(project.path())
        .arg("validate")
        .assert()
        .failure()
        .code(1)
        .stdout(predicate::str::contains("Config validation failed"));
}

#[test]
fn upload_dry_run_without_a_built_binary_fails_offline_with_exit_code_one() {
    // `mecha10 upload` resolves its default binary path from mecha10.json and checks for it
    // before ever reaching the (network-dependent) actual upload -- so this stays fully
    // offline and fast while still exercising the upload handler's real argument wiring
    // (`--dry-run`, defaulted `--binary`/`--config` paths).
    let project = TempDir::new().expect("failed to create temp project dir");
    write_minimal_project(project.path(), "test-robot");

    mecha10()
        .current_dir(project.path())
        .arg("upload")
        .arg("--dry-run")
        .assert()
        .failure()
        .code(1)
        .stdout(predicate::str::contains("Binary not found"));
}