mecha10-cli 4.2.13

Mecha10 CLI tool
Documentation
//! Mecha10 CLI
//!
//! Command-line interface for managing Mecha10 robot projects.
//!
//! # Commands
//!
//! - `mecha10 init` - Initialize a new robot project
//! - `mecha10 dev` - Start development mode with hot-reload
//! - `mecha10 run` - Run the robot in production mode
//! - `mecha10 build` - Build the project
//! - `mecha10 train` - Submit a training job
//! - `mecha10 logs` - View logs from nodes, framework, and services (watch mode by default)
//! - `mecha10 sim` - Generate robot/environment simulation scenes
//! - `mecha10 target` - Show or update which platform(s) a target builds for

#![allow(
    clippy::print_stdout,
    clippy::print_stderr,
    reason = "this crate IS the CLI's terminal UI -- println!/eprintln! to the user's terminal is its intended output mechanism, not debug leftovers"
)]

mod commands;
mod component_catalog;
mod config_validation;
mod context;
mod dev;
mod handlers;
mod infrastructure;
mod init;
mod paths;
mod run;
mod services;
mod sim;
mod templates;
mod types;
mod ui;

use anyhow::Result;
use clap::Parser;
use context::{CliContext, CliContextBuilder};
use types::{Cli, Commands};

#[tokio::main]
async fn main() -> Result<()> {
    let _ = dotenvy::dotenv();

    let sentry_dsn = std::env::var("SENTRY_DSN_CLI").unwrap_or_else(|_| {
        "https://23908dad56709a668ae2f899e2c19924@o4506107376631808.ingest.us.sentry.io/4512334108098560".to_string()
    });
    let mut sentry_opts = sentry::ClientOptions::default();
    sentry_opts.dsn = sentry_dsn.parse().ok();
    sentry_opts.release = sentry::release_name!();
    // `sentry::init` applies Sentry's default integrations (`ClientOptions::default_integrations`
    // defaults to `true` and is left untouched here), which includes `sentry-panic`'s panic hook.
    // That hook captures a Sentry event the instant a panic occurs, synchronously, independent of
    // this guard's `Drop`. An uncaught panic then unwinds normally out of `main` (Rust's runtime
    // wraps the user `main` in `catch_unwind`), so `_sentry_guard`'s `Drop` impl still runs during
    // that unwind and flushes the already-captured event -- panics are covered with no extra code.
    //
    // The `Err(...)` path from `run()` below needs explicit capture+flush instead, because:
    //   (a) a bare `anyhow::Error` returned via `?` is never seen by `sentry-tracing`'s
    //       `ErrorLayer` unless something logged it via `tracing::error!` first, so nothing would
    //       ever be queued for it otherwise, and
    //   (b) `main` returning `Err` makes Rust's `Termination` impl print the error and call
    //       `std::process::exit(1)`, which does NOT run destructors -- so `_sentry_guard`'s
    //       `Drop`-based flush would never fire even if something had been queued.
    let _sentry_guard = sentry::init(sentry_opts);

    let cli = Cli::parse();

    let log_level = match cli.log_level.as_str() {
        "debug" => tracing::Level::DEBUG,
        "info" => tracing::Level::INFO,
        "warn" => tracing::Level::WARN,
        "error" => tracing::Level::ERROR,
        _ => tracing::Level::INFO,
    };

    let _file_log_guard = if matches!(cli.command, Commands::Dev(_)) {
        let working_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
        let logs_dir = working_dir.join(paths::project::LOGS_DIR);
        let _ = std::fs::create_dir_all(&logs_dir);
        let file_appender = tracing_appender::rolling::never(&logs_dir, "mecha10-dev.log");
        let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);

        // Capped at WARN regardless of `--log-level`: `dev` runs long-lived, chatty node
        // processes, and every `info!` would otherwise print twice (once here, once in the
        // file layer below). The file layer keeps the user's requested verbosity so nothing
        // is actually lost, just moved off the terminal.
        let terminal_level = log_level.min(tracing::Level::WARN);

        use tracing_subscriber::prelude::*;
        tracing_subscriber::registry()
            .with(
                tracing_subscriber::fmt::layer()
                    .with_target(false)
                    .with_filter(tracing_subscriber::filter::LevelFilter::from_level(terminal_level)),
            )
            .with(
                tracing_subscriber::fmt::layer()
                    .with_target(false)
                    .with_ansi(false)
                    .with_writer(non_blocking)
                    .with_filter(tracing_subscriber::filter::LevelFilter::from_level(log_level)),
            )
            // `sentry_tracing::layer()` (the legacy combined layer) is deprecated in favor of
            // individual layers -- see `sentry_tracing::SentryLayer`'s migration docs. This crate
            // doesn't enable sentry-tracing's `logs` feature, so `LogLayer` isn't available; these
            // three layers reproduce the legacy layer's own default (non-`logs`) behavior:
            // `ERROR`/`WARN`/`INFO` spans become Sentry spans/transactions, `WARN`/`INFO` events
            // become breadcrumbs attached to subsequent errors, and `ERROR` events become Sentry
            // error events.
            .with(sentry_tracing::SpanLayer::new().with_filter(tracing_subscriber::filter::LevelFilter::INFO))
            .with(
                sentry_tracing::BreadcrumbLayer::new().with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
                    matches!(*metadata.level(), tracing::Level::WARN | tracing::Level::INFO)
                })),
            )
            .with(sentry_tracing::ErrorLayer::new().with_filter(tracing_subscriber::filter::LevelFilter::ERROR))
            .init();

        Some(guard)
    } else {
        tracing_subscriber::fmt()
            .with_max_level(log_level)
            .with_target(false)
            .init();

        None
    };

    let mut ctx = CliContextBuilder::new()
        .config_path(cli.config.clone())
        .log_level(log_level)
        .build()?;

    if let Err(err) = run(&cli, &mut ctx).await {
        // Explicitly capture the error to Sentry -- see the comment on `sentry::init` above for
        // why neither `sentry-tracing`'s layers nor the guard's `Drop` can be relied on here.
        sentry::integrations::anyhow::capture_anyhow(&err);
        // Flush synchronously *before* returning, since returning `Err` from `main` exits via
        // `std::process::exit`, which skips `_sentry_guard`'s `Drop` (and therefore its flush).
        _sentry_guard.flush(Some(std::time::Duration::from_secs(2)));
        return Err(err);
    }

    Ok(())
}

/// Dispatches to the handler for the parsed CLI command.
///
/// Split out of `main` so that `main` can intercept the `Result` and explicitly capture+flush to
/// Sentry on failure before the process exits -- see the comments in `main` for why that can't
/// just be a bare `?` on the match block.
async fn run(cli: &Cli, ctx: &mut CliContext) -> Result<()> {
    match &cli.command {
        Commands::Add { command } => {
            handlers::add::handle_add(ctx, command).await?;
        }

        Commands::Auth { command } => {
            use commands::auth::AuthCommand;
            match command {
                AuthCommand::Login(args) => {
                    handlers::auth::handle_login(args).await?;
                }
                AuthCommand::Logout => {
                    handlers::auth::handle_logout().await?;
                }
                AuthCommand::Whoami(args) => {
                    handlers::auth::handle_whoami(args).await?;
                }
            }
        }

        Commands::Build { command } => {
            handlers::build::handle_build(ctx, command).await?;
        }

        Commands::Cache { command } => {
            use commands::cache::CacheCommand;
            match command {
                CacheCommand::Clean => {
                    handlers::cache::handle_cache_clean().await?;
                }
            }
        }

        Commands::Config { command } => {
            use commands::config::ConfigCommand;
            match command {
                ConfigCommand::Push(args) => {
                    handlers::config::handle_config_push(ctx, args).await?;
                }
                ConfigCommand::Pull(args) => {
                    handlers::config::handle_config_pull(ctx, args).await?;
                }
                ConfigCommand::List(args) => {
                    handlers::config::handle_config_list(ctx, args).await?;
                }
                ConfigCommand::Show => {
                    handlers::config::handle_config_show(ctx).await?;
                }
                ConfigCommand::Validate(args) => {
                    handlers::config::handle_config_validate(ctx, args).await?;
                }
                ConfigCommand::Edit => {
                    handlers::config::handle_config_edit(ctx).await?;
                }
                ConfigCommand::Sync => {
                    handlers::config::handle_config_sync(ctx).await?;
                }
            }
        }

        Commands::Dev(dev_args) => {
            handlers::dev::handle_dev(ctx, dev_args).await?;
        }

        Commands::Diagnostics(args) => {
            handlers::diagnostics::handle_diagnostics(ctx, args).await?;
        }

        Commands::Format(format_args) => {
            use handlers::format::FormatArgs;
            let args = FormatArgs {
                check: format_args.check,
            };
            handlers::format::handle_format(ctx, &args).await?;
        }

        Commands::Init(init_args) => {
            handlers::init::handle_init(ctx, init_args).await?;
        }

        Commands::Lint(lint_args) => {
            use handlers::lint::LintArgs;
            let args = LintArgs { fix: lint_args.fix };
            handlers::lint::handle_lint(ctx, &args).await?;
        }

        Commands::Logs { command } => {
            handlers::logs::handle_logs_command(ctx, command).await?;
        }

        Commands::Model { command } => {
            handlers::model::handle_model(ctx, command).await?;
        }

        Commands::Ps { command } => {
            handlers::ps::handle_ps(ctx, command).await?;
        }

        Commands::Robot { command } => {
            handlers::robot::handle_robot(ctx, command).await?;
        }

        Commands::Run { command } => {
            handlers::run::handle_run(ctx, command).await?;
        }

        Commands::Setup(setup_args) => {
            use handlers::setup::SetupArgs;
            let args = SetupArgs {
                check_only: setup_args.check_only,
                install_deps: !setup_args.non_interactive,
                quiet: false,
            };
            handlers::setup::handle_setup(ctx, &args).await?;
        }

        Commands::Sim { command } => {
            handlers::sim::handle_sim(ctx, command).await?;
        }

        Commands::Stop { command } => {
            handlers::stop::handle_stop(ctx, command).await?;
        }

        Commands::Target { command } => {
            handlers::target::handle_target(ctx, command.as_ref()).await?;
        }

        Commands::Topics { command } => {
            use commands::TopicsCommand;
            use handlers::topics::TopicsListArgs;

            match command {
                TopicsCommand::List {
                    pattern,
                    verbose,
                    grouped,
                } => {
                    let args = TopicsListArgs {
                        pattern: pattern.clone(),
                        verbose: *verbose,
                        grouped: *grouped,
                    };
                    handlers::topics::handle_topics_list(ctx, &args).await?;
                }
            }
        }

        Commands::Topology(topology_args) => {
            handlers::topology::handle_topology(ctx, topology_args).await?;
        }

        Commands::Train(train_args) => {
            handlers::train::handle_train(ctx, train_args).await?;
        }

        Commands::Upload(upload_args) => {
            handlers::upload::handle_upload(ctx, upload_args).await?;
        }

        Commands::Validate(args) => {
            handlers::config::handle_config_validate(ctx, args).await?;
        }
    }

    Ok(())
}

#[cfg(test)]
mod sentry_tracing_layer_tests {
    // Regression test for the `sentry_tracing::layer()` deprecation (migrated to the individual
    // `SpanLayer`/`BreadcrumbLayer`/`ErrorLayer` layers in `main`'s dev-mode tracing setup, since
    // this crate doesn't enable sentry-tracing's `logs` feature for `LogLayer`). Unlike the
    // legacy combined layer, these individual layers are documented as safe to compose together
    // (only mixing them *with* the legacy layer is unsupported/can panic), so this exercises that
    // composition end-to-end -- building the registry, installing it as the default subscriber
    // for the test's scope, and emitting a span/events through it -- to guard against a future
    // edit reintroducing the deprecated call or an incompatible layer combination.
    use tracing_subscriber::prelude::*;

    #[test]
    fn sentry_layers_compose_and_handle_events_without_panicking() {
        let subscriber = tracing_subscriber::registry()
            .with(sentry_tracing::SpanLayer::new().with_filter(tracing_subscriber::filter::LevelFilter::INFO))
            .with(
                sentry_tracing::BreadcrumbLayer::new().with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
                    matches!(*metadata.level(), tracing::Level::WARN | tracing::Level::INFO)
                })),
            )
            .with(sentry_tracing::ErrorLayer::new().with_filter(tracing_subscriber::filter::LevelFilter::ERROR));

        tracing::subscriber::with_default(subscriber, || {
            let _span = tracing::info_span!("test span").entered();
            tracing::warn!("test warning breadcrumb");
            tracing::error!("test error event");
        });
    }
}

#[cfg(test)]
mod sentry_error_capture_tests {
    // Regression test for the bug where a command failing via a bare `?` out of `main` never
    // reached Sentry at all: `sentry-tracing`'s `ErrorLayer` only turns `tracing::error!`
    // log/span events into Sentry events, so it never sees a plain `anyhow::Error` propagated by
    // `?` unless something along the way separately logged it. `main` now explicitly calls
    // `sentry::integrations::anyhow::capture_anyhow` on `run()`'s `Err`, so this test exercises
    // that exact call -- using `sentry::test::with_captured_events` (the `test`-feature dev-only
    // transport, see Cargo.toml) to assert an event is actually produced, without a live network
    // call -- rather than the flush/exit-code wiring around it, which isn't observable this way.
    use anyhow::anyhow;

    #[test]
    fn capture_anyhow_on_run_error_produces_a_sentry_event() {
        let err = anyhow!("boom: something in a command handler failed");

        let events = sentry::test::with_captured_events(|| {
            sentry::integrations::anyhow::capture_anyhow(&err);
        });

        assert_eq!(events.len(), 1, "capture_anyhow should queue exactly one event");
        let message = events[0]
            .exception
            .iter()
            .find_map(|exc| exc.value.as_deref())
            .unwrap_or_default();
        assert!(
            message.contains("boom: something in a command handler failed"),
            "captured event should carry the anyhow error's message, got: {message:?}"
        );
    }
}