sublime_cli_tools/interactive/
select.rs

1//! Enhanced selection prompts with fuzzy search.
2//!
3//! This module provides enhanced selection prompts that support fuzzy search,
4//! better visual indicators, and improved user experience for package and
5//! environment selection.
6//!
7//! # What
8//!
9//! Provides:
10//! - Fuzzy search for filtering large lists of items
11//! - Enhanced multi-select with better visual feedback
12//! - Single-select with search capability
13//! - Pre-selection based on detected changes
14//! - Clear instructions and help text
15//!
16//! # How
17//!
18//! Uses:
19//! - `fuzzy-matcher` for fuzzy string matching
20//! - `dialoguer` for terminal interaction
21//! - Custom theme for consistent styling
22//! - Real-time filtering as user types
23//!
24//! The fuzzy search allows users to quickly narrow down large lists by typing
25//! a search query. Results are ranked by relevance and displayed in real-time.
26//!
27//! # Why
28//!
29//! Enhanced selection improves UX by:
30//! - Making it easy to find items in large lists
31//! - Reducing cognitive load through filtering
32//! - Providing immediate visual feedback
33//! - Supporting keyboard-driven workflows
34//!
35//! # Examples
36//!
37//! ```rust,no_run
38//! use sublime_cli_tools::interactive::select::{fuzzy_select, fuzzy_multi_select};
39//!
40//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
41//! let items = vec!["package-a", "package-b", "package-core", "utils"];
42//!
43//! // Single select with fuzzy search
44//! let selection = fuzzy_select("Select a package", &items, None, false)?;
45//! println!("Selected: {}", items[selection]);
46//!
47//! // Multi-select with fuzzy search and defaults
48//! let defaults = vec![0, 1]; // Pre-select first two items
49//! let selections = fuzzy_multi_select("Select packages", &items, &defaults, false)?;
50//! println!("Selected: {} items", selections.len());
51//! # Ok(())
52//! # }
53//! ```
54
55use crate::error::{CliError, Result};
56use crate::interactive::theme::WntTheme;
57use dialoguer::{FuzzySelect, MultiSelect, Select};
58use fuzzy_matcher::FuzzyMatcher;
59use fuzzy_matcher::skim::SkimMatcherV2;
60
61/// Performs a single selection with fuzzy search capability.
62///
63/// Displays a searchable list where users can type to filter options.
64/// Results are ranked by relevance to the search query.
65///
66/// # Arguments
67///
68/// * `prompt` - The prompt message to display
69/// * `items` - The list of items to choose from
70/// * `default` - Optional default selection index
71/// * `no_color` - Whether to disable colored output
72///
73/// # Returns
74///
75/// * `Result<usize>` - The index of the selected item
76///
77/// # Errors
78///
79/// Returns `CliError::User` if:
80/// - User cancels the prompt (Ctrl+C)
81/// - Terminal interaction fails
82///
83/// # Examples
84///
85/// ```rust,no_run
86/// use sublime_cli_tools::interactive::select::fuzzy_select;
87///
88/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
89/// let packages = vec!["pkg-a", "pkg-b", "pkg-c"];
90/// let selection = fuzzy_select("Choose a package", &packages, Some(0), false)?;
91/// println!("Selected: {}", packages[selection]);
92/// # Ok(())
93/// # }
94/// ```
95pub fn fuzzy_select<T: ToString>(
96    prompt: &str,
97    items: &[T],
98    default: Option<usize>,
99    no_color: bool,
100) -> Result<usize> {
101    if items.is_empty() {
102        return Err(CliError::validation("No items available to select"));
103    }
104
105    let theme = WntTheme::new(no_color);
106    let mut builder = FuzzySelect::with_theme(&theme).with_prompt(prompt);
107
108    if let Some(default_idx) = default {
109        builder = builder.default(default_idx);
110    }
111
112    let items_str: Vec<String> = items.iter().map(std::string::ToString::to_string).collect();
113    builder = builder.items(&items_str);
114
115    builder.interact().map_err(|e| CliError::user(format!("Selection cancelled: {e}")))
116}
117
118/// Performs a multi-selection with fuzzy search capability.
119///
120/// Displays a searchable list where users can:
121/// - Type to filter options with fuzzy matching
122/// - Use Space to toggle selection
123/// - Use arrow keys to navigate
124/// - Press Enter to confirm
125///
126/// # Arguments
127///
128/// * `prompt` - The prompt message to display
129/// * `items` - The list of items to choose from
130/// * `defaults` - Indices of items to pre-select
131/// * `no_color` - Whether to disable colored output
132///
133/// # Returns
134///
135/// * `Result<Vec<usize>>` - Indices of the selected items
136///
137/// # Errors
138///
139/// Returns `CliError::User` if:
140/// - User cancels the prompt (Ctrl+C)
141/// - Terminal interaction fails
142///
143/// Returns `CliError::Validation` if:
144/// - No items are selected
145///
146/// # Examples
147///
148/// ```rust,no_run
149/// use sublime_cli_tools::interactive::select::fuzzy_multi_select;
150///
151/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
152/// let packages = vec!["pkg-a", "pkg-b", "pkg-c"];
153/// let defaults = vec![0]; // Pre-select first item
154/// let selections = fuzzy_multi_select("Select packages", &packages, &defaults, false)?;
155/// # Ok(())
156/// # }
157/// ```
158pub fn fuzzy_multi_select<T: ToString>(
159    prompt: &str,
160    items: &[T],
161    defaults: &[usize],
162    no_color: bool,
163) -> Result<Vec<usize>> {
164    if items.is_empty() {
165        return Err(CliError::validation("No items available to select"));
166    }
167
168    let theme = WntTheme::new(no_color);
169    let items_str: Vec<String> = items.iter().map(std::string::ToString::to_string).collect();
170
171    // Create defaults array
172    let defaults_bool: Vec<bool> = (0..items.len()).map(|i| defaults.contains(&i)).collect();
173
174    let selections = MultiSelect::with_theme(&theme)
175        .with_prompt(prompt)
176        .items(&items_str)
177        .defaults(&defaults_bool)
178        .interact()
179        .map_err(|e| CliError::user(format!("Selection cancelled: {e}")))?;
180
181    if selections.is_empty() {
182        Err(CliError::validation(
183            "At least one item must be selected. Use Space to select, Enter to confirm.",
184        ))
185    } else {
186        Ok(selections)
187    }
188}
189
190/// Performs a single selection without fuzzy search.
191///
192/// This is a simpler version for cases where fuzzy search is not needed.
193///
194/// # Arguments
195///
196/// * `prompt` - The prompt message to display
197/// * `items` - The list of items to choose from
198/// * `default` - Optional default selection index
199/// * `no_color` - Whether to disable colored output
200///
201/// # Returns
202///
203/// * `Result<usize>` - The index of the selected item
204///
205/// # Errors
206///
207/// Returns `CliError::User` if:
208/// - User cancels the prompt (Ctrl+C)
209/// - Terminal interaction fails
210///
211/// # Examples
212///
213/// ```rust,no_run
214/// use sublime_cli_tools::interactive::select::simple_select;
215///
216/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
217/// let options = vec!["patch", "minor", "major"];
218/// let selection = simple_select("Select bump type", &options, Some(0), false)?;
219/// # Ok(())
220/// # }
221/// ```
222pub fn simple_select<T: ToString>(
223    prompt: &str,
224    items: &[T],
225    default: Option<usize>,
226    no_color: bool,
227) -> Result<usize> {
228    if items.is_empty() {
229        return Err(CliError::validation("No items available to select"));
230    }
231
232    let theme = WntTheme::new(no_color);
233    let mut builder = Select::with_theme(&theme).with_prompt(prompt);
234
235    if let Some(default_idx) = default {
236        builder = builder.default(default_idx);
237    }
238
239    let items_str: Vec<String> = items.iter().map(std::string::ToString::to_string).collect();
240    builder = builder.items(&items_str);
241
242    builder.interact().map_err(|e| CliError::user(format!("Selection cancelled: {e}")))
243}
244
245/// Filters items using fuzzy matching and returns ranked results.
246///
247/// This is a utility function that can be used independently of the prompt
248/// functions for custom filtering logic.
249///
250/// # Arguments
251///
252/// * `items` - The list of items to filter
253/// * `query` - The search query
254///
255/// # Returns
256///
257/// A vector of (index, score) tuples, sorted by relevance (highest score first)
258///
259/// # Examples
260///
261/// ```rust
262/// use sublime_cli_tools::interactive::select::fuzzy_filter;
263///
264/// let items = vec!["package-a", "package-b", "package-core", "utils"];
265/// let results = fuzzy_filter(&items, "pkg");
266///
267/// // Results are sorted by relevance
268/// assert!(!results.is_empty());
269/// for (idx, score) in results {
270///     println!("{}: {} (score: {})", idx, items[idx], score);
271/// }
272/// ```
273pub fn fuzzy_filter<T: AsRef<str>>(items: &[T], query: &str) -> Vec<(usize, i64)> {
274    if query.is_empty() {
275        // Return all items with a default score
276        return items.iter().enumerate().map(|(idx, _)| (idx, 0)).collect();
277    }
278
279    let matcher = SkimMatcherV2::default();
280    let mut matches: Vec<(usize, i64)> = items
281        .iter()
282        .enumerate()
283        .filter_map(|(idx, item)| {
284            matcher.fuzzy_match(item.as_ref(), query).map(|score| (idx, score))
285        })
286        .collect();
287
288    // Sort by score (highest first)
289    matches.sort_by(|a, b| b.1.cmp(&a.1));
290
291    matches
292}
293
294/// Performs a multi-selection with fuzzy filtering by package names.
295///
296/// This is a specialized version of `fuzzy_multi_select` that:
297/// - Takes string slices instead of generic items
298/// - Converts indices to actual values
299/// - Returns the selected string values instead of indices
300///
301/// # Arguments
302///
303/// * `prompt` - The prompt message to display
304/// * `items` - The list of items to choose from
305/// * `detected` - Items to pre-select
306/// * `no_color` - Whether to disable colored output
307///
308/// # Returns
309///
310/// * `Result<Vec<String>>` - The selected item values
311///
312/// # Errors
313///
314/// Returns `CliError::User` if:
315/// - User cancels the prompt (Ctrl+C)
316/// - Terminal interaction fails
317///
318/// Returns `CliError::Validation` if:
319/// - No items are selected
320///
321/// # Examples
322///
323/// ```rust,no_run
324/// use sublime_cli_tools::interactive::select::select_packages;
325///
326/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
327/// let packages = vec!["pkg-a".to_string(), "pkg-b".to_string()];
328/// let detected = vec!["pkg-a".to_string()];
329/// let selected = select_packages("Select packages", &packages, &detected, false)?;
330/// # Ok(())
331/// # }
332/// ```
333pub fn select_packages(
334    prompt: &str,
335    items: &[String],
336    detected: &[String],
337    no_color: bool,
338) -> Result<Vec<String>> {
339    if items.is_empty() {
340        return Err(CliError::validation("No packages available to select"));
341    }
342
343    // Find indices of detected items
344    let default_indices: Vec<usize> =
345        detected.iter().filter_map(|det| items.iter().position(|item| item == det)).collect();
346
347    // Perform multi-select
348    let selected_indices = fuzzy_multi_select(prompt, items, &default_indices, no_color)?;
349
350    // Convert indices to values
351    let selected: Vec<String> = selected_indices.iter().map(|&idx| items[idx].clone()).collect();
352
353    Ok(selected)
354}
355
356/// Performs a multi-selection with fuzzy filtering by environment names.
357///
358/// Similar to `select_packages` but specialized for environments.
359///
360/// # Arguments
361///
362/// * `prompt` - The prompt message to display
363/// * `items` - The list of environment names to choose from
364/// * `defaults` - Environment names to pre-select
365/// * `no_color` - Whether to disable colored output
366///
367/// # Returns
368///
369/// * `Result<Vec<String>>` - The selected environment names
370///
371/// # Errors
372///
373/// Returns `CliError::User` if:
374/// - User cancels the prompt (Ctrl+C)
375/// - Terminal interaction fails
376///
377/// Returns `CliError::Validation` if:
378/// - No items are selected
379///
380/// # Examples
381///
382/// ```rust,no_run
383/// use sublime_cli_tools::interactive::select::select_environments;
384///
385/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
386/// let envs = vec!["dev".to_string(), "staging".to_string(), "prod".to_string()];
387/// let defaults = vec!["staging".to_string(), "prod".to_string()];
388/// let selected = select_environments("Select environments", &envs, &defaults, false)?;
389/// # Ok(())
390/// # }
391/// ```
392pub fn select_environments(
393    prompt: &str,
394    items: &[String],
395    defaults: &[String],
396    no_color: bool,
397) -> Result<Vec<String>> {
398    if items.is_empty() {
399        return Err(CliError::validation("No environments available to select"));
400    }
401
402    // Find indices of default items
403    let default_indices: Vec<usize> =
404        defaults.iter().filter_map(|def| items.iter().position(|item| item == def)).collect();
405
406    // Perform multi-select
407    let selected_indices = fuzzy_multi_select(prompt, items, &default_indices, no_color)?;
408
409    // Convert indices to values
410    let selected: Vec<String> = selected_indices.iter().map(|&idx| items[idx].clone()).collect();
411
412    Ok(selected)
413}