mecha10-cli 0.8.0

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

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

/// Whether both stdin and stdout are attached to a TTY, i.e. whether it's safe to run an
/// interactive prompt/wizard rather than falling back to non-interactive defaults.
///
/// Shared by every command handler that decides whether to prompt (`add`, `build`, `init`)
/// instead of each re-checking `atty::is` on both streams itself.
pub fn is_interactive_terminal() -> bool {
    atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
}

/// 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()
    }
}

macro_rules! interact {
    ($builder:expr, $method:ident, $kind:literal) => {
        $builder
            .$method()
            .map_err(|e| anyhow::anyhow!(concat!("Failed to get user ", $kind, ": {}"), e))
    };
}

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

/// 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();
    interact!(
        Select::with_theme(&theme).with_prompt(prompt).items(items),
        interact,
        "selection"
    )
}

/// Prompt user for text input
#[allow(dead_code)]
pub fn input(prompt: &str) -> anyhow::Result<String> {
    let theme = create_enhanced_theme();
    interact!(
        Input::<String>::with_theme(&theme).with_prompt(prompt),
        interact_text,
        "input"
    )
}

/// 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 selects every item.
/// 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`]: maps rendered-list indices back to `items` indices,
/// returning every item if "select all" (index 0) was picked.
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() {
        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() {
        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());
    }
}