nex-cli 6.4.0

A keyboard-first launcher for Windows
Documentation
use std::collections::BTreeSet;

pub const SAFE_HOTKEY_PRESETS: [&str; 6] = [
    "Ctrl+Shift+Space",
    "Ctrl+Alt+Space",
    "Alt+Shift+Space",
    "Ctrl+Shift+P",
    "Ctrl+Alt+P",
    "Ctrl+Shift+O",
];

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettingsDraft {
    pub hotkey: String,
    pub max_results: u16,
    pub launch_at_startup: bool,
}

pub fn validate_hotkey(input: &str) -> Result<String, String> {
    let raw_parts: Vec<&str> = input
        .split('+')
        .map(|part| part.trim())
        .filter(|part| !part.is_empty())
        .collect();

    if raw_parts.len() < 2 {
        return Err("Hotkey must include at least one modifier and one key.".to_string());
    }

    let key_raw = raw_parts[raw_parts.len() - 1];
    let key = normalize_key(key_raw)?;

    let mut modifiers: BTreeSet<&'static str> = BTreeSet::new();
    for part in &raw_parts[..raw_parts.len() - 1] {
        let modifier = normalize_modifier(part)?;
        modifiers.insert(modifier);
    }

    if modifiers.is_empty() {
        return Err("Hotkey must include at least one modifier.".to_string());
    }

    let canonical = canonical_hotkey(&modifiers, &key);
    if is_reserved_hotkey(&canonical) {
        return Err(
            "This hotkey is commonly reserved by Windows. Choose a different one.".to_string(),
        );
    }

    Ok(canonical)
}

pub fn validate_max_results(value: u16) -> Result<(), String> {
    if (5..=100).contains(&value) {
        Ok(())
    } else {
        Err("Max results must be between 5 and 100.".to_string())
    }
}

pub fn suggested_hotkey_presets(current: &str, limit: usize) -> Vec<String> {
    if limit == 0 {
        return Vec::new();
    }

    let current_canonical = validate_hotkey(current).ok();
    SAFE_HOTKEY_PRESETS
        .iter()
        .filter_map(|preset| validate_hotkey(preset).ok())
        .filter(|preset| current_canonical.as_ref() != Some(preset))
        .take(limit)
        .collect()
}

fn normalize_modifier(input: &str) -> Result<&'static str, String> {
    match input.to_ascii_lowercase().as_str() {
        "ctrl" | "control" => Ok("Ctrl"),
        "alt" => Ok("Alt"),
        "shift" => Ok("Shift"),
        "win" | "windows" | "meta" => Err("Win/Meta combinations are not supported.".to_string()),
        _ => Err(format!(
            "Unsupported modifier '{input}'. Use Ctrl, Alt, or Shift."
        )),
    }
}

fn normalize_key(input: &str) -> Result<String, String> {
    let raw = input.trim();
    if raw.is_empty() {
        return Err("Hotkey key is required.".to_string());
    }

    let upper = raw.to_ascii_uppercase();
    if upper == "SPACE" {
        return Ok("Space".to_string());
    }

    if let Some(number) = upper.strip_prefix('F') {
        if let Ok(parsed) = number.parse::<u8>() {
            if (1..=24).contains(&parsed) {
                return Ok(format!("F{parsed}"));
            }
        }
        return Err("Function key must be between F1 and F24.".to_string());
    }

    if upper.len() == 1 {
        let c = upper.chars().next().unwrap_or_default();
        if c.is_ascii_alphanumeric() {
            return Ok(upper);
        }
    }

    Err("Key must be A-Z, 0-9, Space, or F1-F24.".to_string())
}

fn canonical_hotkey(modifiers: &BTreeSet<&'static str>, key: &str) -> String {
    let mut ordered = Vec::new();
    if modifiers.contains("Ctrl") {
        ordered.push("Ctrl");
    }
    if modifiers.contains("Alt") {
        ordered.push("Alt");
    }
    if modifiers.contains("Shift") {
        ordered.push("Shift");
    }
    ordered.push(key);
    ordered.join("+")
}

fn is_reserved_hotkey(canonical: &str) -> bool {
    matches!(
        canonical,
        "Alt+Tab" | "Alt+F4" | "Ctrl+Esc" | "Alt+Esc" | "Ctrl+Shift+Esc" | "Alt+Space"
    )
}