From 4814ad90bba96967ad3e9e77a73608a64d2282c2 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Feb 2026 23:20:19 +0300 Subject: [PATCH] ui: add interactive prompts and typo suggestions Signed-off-by: NotAShelf Change-Id: Iec773550dca1f0ddc2f60360e6b7cb956a6a6964 --- src/ui_utils.rs | 166 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/src/ui_utils.rs b/src/ui_utils.rs index 095e4d9..62589b5 100644 --- a/src/ui_utils.rs +++ b/src/ui_utils.rs @@ -2,7 +2,7 @@ use std::io; -use dialoguer::{Confirm, MultiSelect, Select, theme::ColorfulTheme}; +use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme}; /// Creates a terminal hyperlink using OSC 8 escape sequence /// Format: \x1b]8;;\x1b\\\x1b]8;;\x1b\\ @@ -58,6 +58,136 @@ pub fn curseforge_project_url(project_id: &str) -> String { format!("https://www.curseforge.com/minecraft/mc-mods/{project_id}") } +/// Calculate Levenshtein edit distance between two strings +#[allow(clippy::needless_range_loop)] +fn levenshtein_distance(a: &str, b: &str) -> usize { + let a = a.to_lowercase(); + let b = b.to_lowercase(); + let a_len = a.chars().count(); + let b_len = b.chars().count(); + + if a_len == 0 { + return b_len; + } + if b_len == 0 { + return a_len; + } + + let mut matrix = vec![vec![0usize; b_len + 1]; a_len + 1]; + + for i in 0..=a_len { + matrix[i][0] = i; + } + for j in 0..=b_len { + matrix[0][j] = j; + } + + let a_chars: Vec = a.chars().collect(); + let b_chars: Vec = b.chars().collect(); + + for i in 1..=a_len { + for j in 1..=b_len { + let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]); + + matrix[i][j] = (matrix[i - 1][j] + 1) // deletion + .min(matrix[i][j - 1] + 1) // insertion + .min(matrix[i - 1][j - 1] + cost); // substitution + } + } + + matrix[a_len][b_len] +} + +/// Find similar strings to the input using Levenshtein distance. +/// Returns suggestions sorted by similarity (most similar first). +/// Only returns suggestions with distance <= `max_distance`. +pub fn suggest_similar<'a>( + input: &str, + candidates: &'a [String], + max_distance: usize, +) -> Vec<&'a str> { + let mut scored: Vec<(&str, usize)> = candidates + .iter() + .map(|c| (c.as_str(), levenshtein_distance(input, c))) + .filter(|(_, dist)| *dist <= max_distance && *dist > 0) + .collect(); + + scored.sort_by_key(|(_, dist)| *dist); + scored.into_iter().map(|(s, _)| s).collect() +} + +/// Prompt user if they meant a similar project name. +/// Returns `Some(suggested_name)` if user confirms, None otherwise. +pub fn prompt_typo_suggestion( + input: &str, + candidates: &[String], +) -> io::Result> { + // Use a max distance based on input length for reasonable suggestions + let max_distance = (input.len() / 2).clamp(2, 4); + let suggestions = suggest_similar(input, candidates, max_distance); + + if let Some(first_suggestion) = suggestions.first() + && prompt_yes_no(&format!("Did you mean '{first_suggestion}'?"), true)? + { + return Ok(Some((*first_suggestion).to_string())); + } + + Ok(None) +} + +/// Prompt for text input with optional default value +pub fn prompt_input(prompt: &str, default: Option<&str>) -> io::Result { + let theme = ColorfulTheme::default(); + let mut input = Input::::with_theme(&theme).with_prompt(prompt); + + if let Some(def) = default { + input = input.default(def.to_string()); + } + + input.interact_text().map_err(io::Error::other) +} + +/// Prompt for text input, returning None if empty +pub fn prompt_input_optional(prompt: &str) -> io::Result> { + let input: String = Input::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .allow_empty(true) + .interact_text() + .map_err(io::Error::other)?; + + if input.is_empty() { + Ok(None) + } else { + Ok(Some(input)) + } +} + +/// Prompt for `CurseForge` API key when authentication fails. +/// Returns the API key if provided, None if cancelled. +pub fn prompt_curseforge_api_key() -> io::Result> { + use dialoguer::Password; + + println!(); + println!("CurseForge API key is required but not configured."); + println!("Get your API key from: https://console.curseforge.com/"); + println!(); + + if !prompt_yes_no("Would you like to enter your API key now?", true)? { + return Ok(None); + } + + let key: String = Password::with_theme(&ColorfulTheme::default()) + .with_prompt("CurseForge API key") + .interact() + .map_err(io::Error::other)?; + + if key.is_empty() { + Ok(None) + } else { + Ok(Some(key)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -74,4 +204,38 @@ mod tests { let url = modrinth_project_url("sodium"); assert_eq!(url, "https://modrinth.com/mod/sodium"); } + + #[test] + fn test_levenshtein_distance() { + assert_eq!(levenshtein_distance("kitten", "sitting"), 3); + assert_eq!(levenshtein_distance("saturday", "sunday"), 3); + assert_eq!(levenshtein_distance("", "abc"), 3); + assert_eq!(levenshtein_distance("abc", ""), 3); + assert_eq!(levenshtein_distance("abc", "abc"), 0); + assert_eq!(levenshtein_distance("ABC", "abc"), 0); // case insensitive + } + + #[test] + fn test_suggest_similar() { + let candidates = vec![ + "sodium".to_string(), + "lithium".to_string(), + "phosphor".to_string(), + "iris".to_string(), + "fabric-api".to_string(), + ]; + + // Close typo should be suggested + let suggestions = suggest_similar("sodim", &candidates, 2); + assert!(!suggestions.is_empty()); + assert_eq!(suggestions[0], "sodium"); + + // Complete mismatch should return empty + let suggestions = suggest_similar("xyz123", &candidates, 2); + assert!(suggestions.is_empty()); + + // Exact match returns empty (distance 0 filtered out) + let suggestions = suggest_similar("sodium", &candidates, 2); + assert!(suggestions.is_empty()); + } }