mecha10-cli 0.6.3

Mecha10 CLI tool
Documentation
//! Shared termination/intent model for `mecha10 dev`'s interactive TUI screens.
//!
//! Before this module existed, "what does this key mean" was answered independently by every
//! screen that owned terminal input: the outer dev-mode loop (`ops/interactive.rs`) had its own
//! `is_dev_quit_key`, the teleop sub-screen (`ui/teleop.rs`) had its own `is_quit_all_key` plus a
//! separate zero-velocity `x`/Space branch, and three more call sites each registered their own
//! `ctrlc::set_handler`. Because those handlers didn't compose, the same physical key (`x`) meant
//! two unrelated things depending on which screen happened to own input at the time: "stop the
//! simulation" at the outer dev-mode menu, or "zero velocity, stay put" inside teleop. A user who
//! pressed `s` (enter simulation, which auto-opens teleop) then `x` got the second meaning and
//! nothing stopped - the reported bug this module fixes.
//!
//! `classify_key` gives every screen the same answer for "what does this key mean", and
//! `ScreenOutcome` lets a nested screen (teleop) report what happened without needing to know
//! about the outer loop's own shutdown flag - the translation from "screen backed out" to "what
//! that means for this caller" belongs to the caller (see `ops/teleop.rs::enter_teleop_mode`),
//! not to the screen itself.
//!
//! `install_quit_signal` replaces the four independent, copy-pasted `ctrlc::set_handler`
//! registrations (`ops/interactive.rs`, `ops/lifecycle.rs::run_watch_mode`,
//! `handlers/logs.rs::follow_logs`, plus what used to be `ui/teleop.rs`'s own Ctrl+C handling
//! before it was folded into `classify_key`) with one shared helper.

use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};

/// What a termination-shaped key ("stop the current thing" or "quit everything") means.
///
/// Deliberately does not cover Space - Space is "zero velocity, stay in teleop", a distinct
/// non-terminating safety control, not a termination concept. It never goes through
/// `classify_key` at all; see `ui/teleop.rs::handle_key`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminationIntent {
    /// Stop whatever the current active mode/screen is driving, and return to its parent
    /// context. Bare `q`, `Esc`, or bare `x` (no modifiers).
    StopCurrent,
    /// Tear down the entire `mecha10 dev` session - every running node, every nested screen.
    /// Ctrl+C or Ctrl+X.
    QuitAll,
}

/// Classify a key event into a `TerminationIntent`, if it is one. Pure, dependency-free function
/// so it's unit testable without a live terminal/event loop.
///
/// - Bare `q` / `Esc` / bare `x` (no modifiers) -> `StopCurrent`.
/// - Ctrl+C / Ctrl+X (any other modifiers alongside Control, e.g. Shift from a capital-C keycap,
///   still match - `contains` rather than exact equality) -> `QuitAll`.
/// - Anything else (including Space, and `q`/`x` with unrelated modifiers) -> `None`.
pub fn classify_key(key: &KeyEvent) -> Option<TerminationIntent> {
    if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('x')) {
        return Some(TerminationIntent::QuitAll);
    }

    if key.modifiers.is_empty() && matches!(key.code, KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('x')) {
        return Some(TerminationIntent::StopCurrent);
    }

    None
}

/// What a nested screen's event loop did, for its caller to interpret. A screen that owns its own
/// sub-context (e.g. `TeleopUi`) should never mutate the outer loop's shutdown flag directly - it
/// reports what happened here, and lets the caller (which knows how it was entered) decide what
/// that means. See `ops/teleop.rs::enter_teleop_mode`, which interprets `ReturnToParent`
/// differently depending on whether it was entered via `'s'` (simulation hand-off, so backing out
/// means "stop the simulation and return to the dev-mode menu") or `'t'` (standalone teleop, so
/// backing out just closes the screen).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScreenOutcome {
    /// `TerminationIntent::StopCurrent` fired, or the screen's own back-out (e.g. `q`/Esc)
    /// completed normally.
    ReturnToParent,
    /// `TerminationIntent::QuitAll` fired - propagate outward unswallowed, all the way to the
    /// top-level `running` flag, so the outer loop's own `if !running.load() { break }` handles
    /// full session teardown.
    QuitAll,
    /// Nothing terminal happened; keep running the screen's event loop.
    Continue,
}

/// Guards against `ctrlc::set_handler`'s "can only be installed once per process" restriction.
/// Safe here because `install_quit_signal`'s call sites are mutually exclusive invocation paths
/// within one process lifetime (e.g. `mecha10 dev` vs `mecha10 dev --watch` vs `mecha10 logs -f`
/// never run concurrently in the same process) - this is a defensive guard, not a concurrency
/// mechanism.
static QUIT_SIGNAL_INSTALLED: OnceLock<Arc<AtomicBool>> = OnceLock::new();

/// Install a single process-wide Ctrl+C handler and return the shared "keep running" flag it
/// clears on signal. Replaces the four independent, copy-pasted `ctrlc::set_handler`
/// registrations that used to exist across `ops/interactive.rs`, `ops/lifecycle.rs`, and
/// `handlers/logs.rs`.
///
/// Calling this more than once within a process is safe: only the first call actually registers
/// the OS-level handler (via `OnceLock`); subsequent calls just return a fresh `AtomicBool`
/// already set to `true`, since `ctrlc::set_handler` itself can only be installed once and would
/// error on a second registration attempt.
pub fn install_quit_signal() -> Result<Arc<AtomicBool>> {
    if let Some(existing) = QUIT_SIGNAL_INSTALLED.get() {
        return Ok(existing.clone());
    }

    let running = Arc::new(AtomicBool::new(true));
    let r = running.clone();
    ctrlc::set_handler(move || {
        r.store(false, Ordering::SeqCst);
    })?;

    // Best-effort: if another thread raced us and won, fall back to whatever it installed so
    // every caller observes the same flag as the handler that's actually live.
    Ok(QUIT_SIGNAL_INSTALLED.get_or_init(|| running).clone())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::KeyEvent;

    // -- classify_key: StopCurrent ------------------------------------------------------------

    #[test]
    fn plain_q_is_stop_current() {
        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
        assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
    }

    #[test]
    fn esc_is_stop_current() {
        let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
        assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
    }

    #[test]
    fn plain_x_without_control_modifier_is_stop_current() {
        // Direct regression test for the reported bug: pressing 's' then 'x' must stop the
        // simulation, not silently zero teleop's velocity and do nothing else.
        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
        assert_eq!(classify_key(&key), Some(TerminationIntent::StopCurrent));
    }

    // -- classify_key: QuitAll -----------------------------------------------------------------

    #[test]
    fn ctrl_c_is_quit_all() {
        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
    }

    #[test]
    fn ctrl_x_is_quit_all() {
        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
        assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
    }

    #[test]
    fn ctrl_shift_c_is_still_quit_all() {
        // Some terminals report additional modifiers alongside Control for this combo (e.g.
        // Shift from a capital-C keycap) - `contains` rather than exact equality means extra
        // modifiers don't defeat the match.
        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
        assert_eq!(classify_key(&key), Some(TerminationIntent::QuitAll));
    }

    // -- classify_key: None -------------------------------------------------------------------

    #[test]
    fn space_is_not_a_termination_key() {
        // Space is a distinct, non-terminating safety control ("zero velocity, stay in teleop"),
        // not a termination concept - it must never classify as either intent.
        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
        assert_eq!(classify_key(&key), None);
    }

    #[test]
    fn plain_c_without_control_modifier_is_not_a_termination_key() {
        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
        assert_eq!(classify_key(&key), None);
    }

    #[test]
    fn ctrl_q_is_not_a_termination_key() {
        // Only bare 'q' (no modifiers) is StopCurrent; Ctrl+Q isn't special-cased to anything.
        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
        assert_eq!(classify_key(&key), None);
    }

    #[test]
    fn unrelated_key_is_not_a_termination_key() {
        let key = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE);
        assert_eq!(classify_key(&key), None);
    }

    // -- install_quit_signal --------------------------------------------------------------------

    #[test]
    fn install_quit_signal_returns_a_flag_initially_true() {
        let running = install_quit_signal().expect("installing the quit signal must succeed");
        assert!(running.load(Ordering::SeqCst));
    }

    #[test]
    fn install_quit_signal_is_idempotent_across_repeated_calls() {
        // Guards against ctrlc::set_handler's "only once per process" restriction - a second
        // call in the same process must not error, and must return the same shared flag.
        let first = install_quit_signal().expect("first install must succeed");
        let second = install_quit_signal().expect("second install must not error");
        assert!(Arc::ptr_eq(&first, &second));
    }
}