// UI utility functions for terminal formatting and interactive prompts use std::io; use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme}; /// Creates a terminal hyperlink using OSC 8 escape sequence /// Format: \x1b]8;;\x1b\\\x1b]8;;\x1b\\ pub fn hyperlink(url: &str, text: &str) -> String { format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\") } /// Prompts user with a yes/no question /// Returns true for yes, false for no /// If `skip_prompts` is true, returns the default value without prompting pub fn prompt_yes_no( question: &str, default: bool, skip_prompts: bool, ) -> io::Result { if skip_prompts { return Ok(default); } Confirm::with_theme(&ColorfulTheme::default()) .with_prompt(question) .default(default) .interact() .map_err(io::Error::other) } /// Prompts user to select from a list of options /// Returns the index of the selected option pub fn prompt_select(question: &str, options: &[&str]) -> io::Result { Select::with_theme(&ColorfulTheme::default()) .with_prompt(question) .items(options) .default(0) .interact() .map_err(io::Error::other) } /// Creates a formatted project URL for Modrinth pub fn modrinth_project_url(slug: &str) -> String { format!("https://modrinth.com/mod/{slug}") } /// Creates a formatted project URL for `CurseForge` 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. /// If `skip_prompts` is true, automatically accepts the first suggestion. pub fn prompt_typo_suggestion( input: &str, candidates: &[String], skip_prompts: bool, ) -> 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, skip_prompts, )? { 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. /// If `skip_prompts` is true, returns None immediately. pub fn prompt_curseforge_api_key( skip_prompts: bool, ) -> io::Result> { if skip_prompts { return Ok(None); } 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, false)? { 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::*; #[test] fn test_hyperlink() { let result = hyperlink("https://example.com", "Example"); assert!(result.contains("https://example.com")); assert!(result.contains("Example")); } #[test] fn test_modrinth_url() { 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()); } }