bot-forge 1.0.2

Rust CLI for installing agent skills and developer tools from configurable forms.
Documentation
//! Interactive profile launcher used by the no-subcommand CLI path.

use std::io::{self, Write};

use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};

use crate::cancellation::requested;
use crate::model::Profile;
use crate::ui::{self, RawModeGuard};

const ITEM_COUNT: usize = 7;
const DEFAULT_CURSOR: usize = 1;
const RECOMMENDED_INDEX: usize = 1;

#[derive(Clone)]
/// Terminal result of the interactive launcher.
pub(crate) enum LauncherChoice {
    Install(Profile),
    Status,
    Doctor,
    Help,
    Exit,
}

/// Run the interactive launcher for the displayed bot-forge version.
///
/// # Errors
///
/// Returns a user-facing error when terminal setup, input, rendering, or the selected action fails.
pub(crate) fn choose(version: &str) -> Result<LauncherChoice, String> {
    let mut cursor = DEFAULT_CURSOR;
    loop {
        render(cursor, version)?;
        let (choice, next_cursor) = read_choice(cursor)?;
        cursor = next_cursor;
        if let Some(choice) = choice {
            return Ok(choice);
        }
    }
}

fn read_choice(cursor: usize) -> Result<(Option<LauncherChoice>, usize), String> {
    let _raw_mode = RawModeGuard::acquire()
        .map_err(|error| format!("failed to enable launcher input: {error}"))?;
    loop {
        if requested() {
            return Err("operation interrupted by Ctrl-C".to_string());
        }
        if !event::poll(std::time::Duration::from_millis(50))
            .map_err(|error| format!("failed to poll launcher input: {error}"))?
        {
            continue;
        }
        let event =
            event::read().map_err(|error| format!("failed to read launcher input: {error}"))?;
        let Event::Key(key) = event else {
            continue;
        };
        if !is_actionable_key_event(key.kind) {
            continue;
        }
        if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
            return Err("operation interrupted by Ctrl-C".to_string());
        }
        let choice = match key.code {
            KeyCode::Char('1') => Some(LauncherChoice::Install(Profile::Minimal)),
            KeyCode::Char('2') => Some(LauncherChoice::Install(Profile::Standard)),
            KeyCode::Char('3') => Some(LauncherChoice::Install(Profile::Advanced)),
            KeyCode::Char('4') => Some(LauncherChoice::Status),
            KeyCode::Char('5') => Some(LauncherChoice::Doctor),
            KeyCode::Char('6' | 'h' | 'H') => Some(LauncherChoice::Help),
            KeyCode::Char('0' | 'q' | 'Q') | KeyCode::Esc => Some(LauncherChoice::Exit),
            _ => None,
        };
        if let Some(choice) = choice {
            return Ok((Some(choice), cursor));
        }
        let next = match key.code {
            KeyCode::Up | KeyCode::Left | KeyCode::Char('k' | 'K') => move_cursor(cursor, -1),
            KeyCode::Down | KeyCode::Right | KeyCode::Tab | KeyCode::Char('j' | 'J') => {
                move_cursor(cursor, 1)
            }
            KeyCode::Home => 0,
            KeyCode::End => ITEM_COUNT - 1,
            KeyCode::Enter => return Ok((Some(choice_at(cursor)), cursor)),
            _ => continue,
        };
        return Ok((None, next));
    }
}

fn move_cursor(cursor: usize, delta: isize) -> usize {
    (isize::try_from(cursor).unwrap_or_default() + delta)
        .rem_euclid(isize::try_from(ITEM_COUNT).unwrap_or(1)) as usize
}

fn choice_at(index: usize) -> LauncherChoice {
    match index {
        0 => LauncherChoice::Install(Profile::Minimal),
        1 => LauncherChoice::Install(Profile::Standard),
        2 => LauncherChoice::Install(Profile::Advanced),
        3 => LauncherChoice::Status,
        4 => LauncherChoice::Doctor,
        5 => LauncherChoice::Help,
        _ => LauncherChoice::Exit,
    }
}

fn items() -> [(&'static str, &'static str); ITEM_COUNT] {
    [
        ("Install minimal", "Minimal Rust development environment"),
        ("Install standard", "Rust development and quality toolchain"),
        ("Install advanced", "Advanced analysis and automation tools"),
        ("Status", "Managed tools and transaction status"),
        ("Doctor", "System, network, and platform diagnostics"),
        ("Help", "Command reference and options"),
        ("Exit", "Close bot-forge"),
    ]
}

fn render(cursor: usize, version: &str) -> Result<(), String> {
    let mut stdout = io::stdout();
    let options = ui::RenderOptions::stdout();
    crossterm::execute!(
        stdout,
        crossterm::cursor::MoveTo(0, 0),
        crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
    )
    .map_err(|error| format!("failed to draw launcher: {error}"))?;
    writeln!(stdout, "{}", ui::page_title("bot-forge", version, &options))
        .map_err(|error| format!("failed to write launcher: {error}"))?;
    writeln!(stdout).map_err(|error| format!("failed to write launcher: {error}"))?;
    writeln!(stdout, "{}", ui::section("Actions", &options))
        .map_err(|error| format!("failed to write launcher: {error}"))?;
    writeln!(stdout, "  {}", ui::launcher_key_help(&options))
        .map_err(|error| format!("failed to write launcher: {error}"))?;
    for (index, (label, description)) in items().into_iter().enumerate() {
        if index == 3 {
            writeln!(
                stdout,
                "{}",
                ui::launcher_group_label("Utilities", &options)
            )
            .map_err(|error| format!("failed to write launcher: {error}"))?;
        }
        writeln!(
            stdout,
            "{}",
            ui::choice_line_with_recommendation(
                index == cursor,
                index == cursor,
                false,
                label,
                description,
                index == RECOMMENDED_INDEX,
                &options,
            )
        )
        .map_err(|error| format!("failed to write launcher: {error}"))?;
    }
    stdout
        .flush()
        .map_err(|error| format!("failed to flush launcher: {error}"))
}

fn is_actionable_key_event(kind: KeyEventKind) -> bool {
    kind == KeyEventKind::Press
}

#[cfg(test)]
mod tests {
    use crate::cli::launcher::{
        DEFAULT_CURSOR, LauncherChoice, choice_at, is_actionable_key_event, items, move_cursor,
    };
    use crate::model::Profile;
    use crossterm::event::KeyEventKind;

    #[test]
    fn ignores_key_release_events() {
        assert!(is_actionable_key_event(KeyEventKind::Press));
        assert!(!is_actionable_key_event(KeyEventKind::Repeat));
        assert!(!is_actionable_key_event(KeyEventKind::Release));
    }

    #[test]
    fn navigation_wraps_like_the_suite_choice_list() {
        assert_eq!(move_cursor(0, -1), 6);
        assert_eq!(move_cursor(6, 1), 0);
        assert!(matches!(
            choice_at(0),
            LauncherChoice::Install(Profile::Minimal)
        ));
        assert!(matches!(choice_at(6), LauncherChoice::Exit));
    }

    #[test]
    fn launcher_defaults_to_second_row_standard_and_remains_navigable() {
        assert_eq!(items()[DEFAULT_CURSOR].0, "Install standard");
        assert!(matches!(
            choice_at(DEFAULT_CURSOR),
            LauncherChoice::Install(Profile::Standard)
        ));
        assert!(matches!(
            choice_at(move_cursor(DEFAULT_CURSOR, -1)),
            LauncherChoice::Install(Profile::Minimal)
        ));
        assert!(matches!(
            choice_at(move_cursor(DEFAULT_CURSOR, 1)),
            LauncherChoice::Install(Profile::Advanced)
        ));
        assert_eq!(items()[1].0, "Install standard");
    }
}