Also deletes some dead_code annotations from functions that are *actually used*. Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ic815cacc93c464078ead1674e7523d8b6a6a6964
246 lines
6.7 KiB
Rust
246 lines
6.7 KiB
Rust
// 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;;<URL>\x1b\\<TEXT>\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<bool> {
|
|
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<usize> {
|
|
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<char> = a.chars().collect();
|
|
let b_chars: Vec<char> = 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<Option<String>> {
|
|
// 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<String> {
|
|
let theme = ColorfulTheme::default();
|
|
let mut input = Input::<String>::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<Option<String>> {
|
|
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<Option<String>> {
|
|
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());
|
|
}
|
|
}
|