mecha10-cli 0.6.3

Mecha10 CLI tool
Documentation
//! User interaction utilities (prompts, confirmations, selections)

use console::Style;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select};

/// Create a custom theme with enhanced cursor visibility
fn create_enhanced_theme() -> ColorfulTheme {
    ColorfulTheme {
        active_item_style: Style::new().cyan().bold(),
        active_item_prefix: console::style("❯".to_string()).cyan().bold(),
        inactive_item_prefix: console::style(" ".to_string()),
        ..Default::default()
    }
}

/// Prompt user for confirmation
#[allow(dead_code)] // UI utility for future use
pub fn confirm(prompt: &str, default: bool) -> anyhow::Result<bool> {
    let theme = create_enhanced_theme();
    Confirm::with_theme(&theme)
        .with_prompt(prompt)
        .default(default)
        .interact()
        .map_err(|e| anyhow::anyhow!("Failed to get user confirmation: {}", e))
}

/// Prompt user to select from a list of options
#[allow(dead_code)]
pub fn select<T: ToString + std::fmt::Display>(prompt: &str, items: &[T]) -> anyhow::Result<usize> {
    let theme = create_enhanced_theme();
    Select::with_theme(&theme)
        .with_prompt(prompt)
        .items(items)
        .interact()
        .map_err(|e| anyhow::anyhow!("Failed to get user selection: {}", e))
}

/// Prompt user for text input
#[allow(dead_code)] // UI utility for future use
pub fn input(prompt: &str) -> anyhow::Result<String> {
    let theme = create_enhanced_theme();
    Input::<String>::with_theme(&theme)
        .with_prompt(prompt)
        .interact_text()
        .map_err(|e| anyhow::anyhow!("Failed to get user input: {}", e))
}

/// Prompt the user to pick any number of `items` (space to toggle, enter to confirm).
///
/// A synthetic "Select all" entry is always rendered first. Picking it (alone or alongside
/// other entries) selects every item in `items`, regardless of what else was toggled - see
/// [`resolve_multiselect`] for the pure selection logic this delegates to (kept separate so it's
/// unit-testable without a TTY, since `dialoguer::MultiSelect::interact()` requires one).
///
/// `checked` marks which of `items` (by index) should start pre-selected, e.g. the modes/targets
/// already inferred from a component's tags - same length as `items`, or empty for none checked.
/// Returns the indices (into `items`, NOT into the rendered list) of everything selected.
pub fn multiselect(prompt: &str, items: &[&str], checked: &[bool]) -> anyhow::Result<Vec<usize>> {
    const SELECT_ALL_LABEL: &str = "Select all";

    let theme = create_enhanced_theme();

    let mut display_items = Vec::with_capacity(items.len() + 1);
    display_items.push(SELECT_ALL_LABEL);
    display_items.extend_from_slice(items);

    let mut display_checked = Vec::with_capacity(items.len() + 1);
    display_checked.push(false);
    if checked.len() == items.len() {
        display_checked.extend_from_slice(checked);
    } else {
        display_checked.extend(std::iter::repeat(false).take(items.len()));
    }

    let selections = MultiSelect::with_theme(&theme)
        .with_prompt(prompt)
        .items(&display_items)
        .defaults(&display_checked)
        .interact()
        .map_err(|e| anyhow::anyhow!("Failed to get user selection: {}", e))?;

    Ok(resolve_multiselect(&selections, items.len()))
}

/// Pure post-processing for [`multiselect`]: `raw_selections` are indices into the rendered list
/// (index 0 is the synthetic "select all" entry, indices `1..=num_items` map to real item
/// `index - 1`). If "select all" (index 0) was among them, every real item is returned regardless
/// of what else was picked; otherwise the real items are returned as selected, 0-indexed and
/// sorted.
fn resolve_multiselect(raw_selections: &[usize], num_items: usize) -> Vec<usize> {
    if raw_selections.contains(&0) {
        return (0..num_items).collect();
    }

    let mut resolved: Vec<usize> = raw_selections.iter().filter_map(|i| i.checked_sub(1)).collect();
    resolved.sort_unstable();
    resolved
}

#[cfg(test)]
mod multiselect_tests {
    use super::*;

    #[test]
    fn resolve_multiselect_maps_single_real_selection_to_zero_indexed_item() {
        // Rendered index 2 ("select all" is 0) is real item index 1.
        assert_eq!(resolve_multiselect(&[2], 3), vec![1]);
    }

    #[test]
    fn resolve_multiselect_returns_multiple_selections_sorted() {
        assert_eq!(resolve_multiselect(&[3, 1], 3), vec![0, 2]);
    }

    #[test]
    fn resolve_multiselect_select_all_alone_returns_every_item() {
        assert_eq!(resolve_multiselect(&[0], 4), vec![0, 1, 2, 3]);
    }

    #[test]
    fn resolve_multiselect_select_all_plus_others_still_returns_every_item() {
        // Picking "select all" alongside individually-toggled items must not narrow the result.
        assert_eq!(resolve_multiselect(&[0, 1], 4), vec![0, 1, 2, 3]);
    }

    #[test]
    fn resolve_multiselect_empty_selection_returns_empty() {
        assert_eq!(resolve_multiselect(&[], 3), Vec::<usize>::new());
    }
}