aven 0.1.7

Local-first task manager CLI and sync server
Documentation
use crossterm::event::KeyCode;

use super::{Action, COMMANDS, CommandContext, CommandSpec, KeySequence};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CommandLookup {
    Empty,
    Found(Action),
    Ambiguous,
    Missing,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CommandCompletion {
    Empty,
    Missing,
    Unchanged,
    Completed(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ShortcutLookup {
    Found(Action),
    Prefix,
    Ambiguous(Action),
    Missing,
}

pub(crate) fn key_label(code: KeyCode) -> String {
    match code {
        KeyCode::Char(ch) => ch.to_string(),
        KeyCode::Enter => "Enter".to_string(),
        KeyCode::Esc => "Esc".to_string(),
        KeyCode::Backspace => "Backspace".to_string(),
        KeyCode::Tab => "Tab".to_string(),
        KeyCode::BackTab => "Shift+Tab".to_string(),
        KeyCode::Home => "Home".to_string(),
        KeyCode::End => "End".to_string(),
        KeyCode::Up => "Up".to_string(),
        KeyCode::Down => "Down".to_string(),
        KeyCode::Left => "Left".to_string(),
        KeyCode::Right => "Right".to_string(),
        _ => format!("{code:?}"),
    }
}

pub(crate) fn shortcut_label(codes: &[KeyCode]) -> String {
    codes
        .iter()
        .map(|code| key_label(*code))
        .collect::<Vec<_>>()
        .join(" ")
}

pub(crate) fn resolve_shortcut(input: &[KeyCode]) -> ShortcutLookup {
    resolve_shortcut_for(CommandContext::Normal, input)
}

pub(crate) fn resolve_shortcut_for(context: CommandContext, input: &[KeyCode]) -> ShortcutLookup {
    resolve_shortcut_in(context.commands(), input)
}

pub(crate) fn resolve_shortcut_in(commands: &[CommandSpec], input: &[KeyCode]) -> ShortcutLookup {
    if input.is_empty() {
        return ShortcutLookup::Missing;
    }

    let mut exact = Vec::new();
    let mut prefix = false;

    for command in commands {
        for key in command.keys {
            if key.codes == input {
                exact.push(command.action);
            } else if key.codes.starts_with(input) {
                prefix = true;
            }
        }
    }

    match (exact.as_slice(), prefix) {
        ([action], false) => ShortcutLookup::Found(*action),
        ([action], true) => ShortcutLookup::Ambiguous(*action),
        ([action, ..], _) => ShortcutLookup::Ambiguous(*action),
        ([], true) => ShortcutLookup::Prefix,
        ([], false) => ShortcutLookup::Missing,
    }
}

pub(crate) fn matching_commands(input: &str) -> Vec<&'static CommandSpec> {
    let input = normalize_command_input(input);
    if input.is_empty() {
        return COMMANDS.iter().collect();
    }
    let mut matches = COMMANDS
        .iter()
        .filter_map(|command| command_match_rank(command, input).map(|rank| (rank, command)))
        .collect::<Vec<_>>();
    matches.sort_by_key(|(rank, _)| *rank);
    matches.into_iter().map(|(_, command)| command).collect()
}

fn normalize_command_input(input: &str) -> &str {
    input.trim().strip_prefix(':').unwrap_or(input.trim())
}

fn command_match_rank(command: &CommandSpec, input: &str) -> Option<u8> {
    if command.name == input || command.aliases.contains(&input) {
        Some(0)
    } else if command.name.starts_with(input)
        || command.aliases.iter().any(|alias| alias.starts_with(input))
        || dashless_eq(command.name, input)
        || command
            .aliases
            .iter()
            .any(|alias| dashless_eq(alias, input))
    {
        Some(1)
    } else if dashless_starts_with(command.name, input)
        || command
            .aliases
            .iter()
            .any(|alias| dashless_starts_with(alias, input))
    {
        Some(2)
    } else if command
        .name
        .split('-')
        .skip(1)
        .any(|segment| segment.starts_with(input))
    {
        Some(3)
    } else {
        None
    }
}

fn dashless_eq(value: &str, input: &str) -> bool {
    if !value.contains('-') {
        return false;
    }
    let dashless = value.chars().filter(|ch| *ch != '-').collect::<String>();
    dashless == input
}

fn dashless_starts_with(value: &str, input: &str) -> bool {
    if !value.contains('-') {
        return false;
    }
    let dashless = value.chars().filter(|ch| *ch != '-').collect::<String>();
    dashless.starts_with(input)
}

pub(crate) fn complete_command(input: &str) -> CommandCompletion {
    let input = normalize_command_input(input);
    if input.is_empty() {
        return CommandCompletion::Empty;
    }
    let names = best_match_names(input);
    if names.is_empty() {
        return CommandCompletion::Missing;
    }
    if names.len() > 1 {
        return CommandCompletion::Unchanged;
    }
    let completion = names[0].to_string();
    if completion.len() > input.len() {
        CommandCompletion::Completed(completion)
    } else {
        CommandCompletion::Unchanged
    }
}

pub(crate) fn command_cycle_options(input: &str) -> Vec<&'static str> {
    let input = normalize_command_input(input);
    if input.is_empty() {
        return Vec::new();
    }
    let matches = ranked_matches(input);
    let Some(best_rank) = matches.iter().map(|(rank, _)| *rank).min() else {
        return Vec::new();
    };
    if best_rank == 0 {
        matches
            .iter()
            .filter(|(rank, _)| *rank == best_rank)
            .map(|(_, command)| command.name)
            .collect()
    } else {
        matches.iter().map(|(_, command)| command.name).collect()
    }
}

fn best_match_names(input: &str) -> Vec<&'static str> {
    let matches = ranked_matches(input);
    let Some(best_rank) = matches.iter().map(|(rank, _)| *rank).min() else {
        return Vec::new();
    };
    matches
        .iter()
        .filter(|(rank, _)| *rank == best_rank)
        .map(|(_, command)| command.name)
        .collect()
}

fn ranked_matches(input: &str) -> Vec<(u8, &'static CommandSpec)> {
    let mut matches = COMMANDS
        .iter()
        .filter_map(|command| command_match_rank(command, input).map(|rank| (rank, command)))
        .collect::<Vec<_>>();
    matches.sort_by_key(|(rank, _)| *rank);
    matches
}

pub(crate) fn prefix_hint_commands(
    context: CommandContext,
    pending: &[String],
) -> Vec<(&'static CommandSpec, &'static KeySequence, String)> {
    context
        .commands()
        .iter()
        .flat_map(|command| {
            command.keys.iter().filter_map(move |key| {
                if key.codes.len() <= pending.len() {
                    return None;
                }
                let labels = key
                    .codes
                    .iter()
                    .map(|code| key_label(*code))
                    .collect::<Vec<_>>();
                if labels.len() <= pending.len()
                    || !labels
                        .iter()
                        .zip(pending.iter())
                        .all(|(actual, expected)| actual == expected)
                {
                    return None;
                }
                Some((command, key, labels[pending.len()..].join(" ")))
            })
        })
        .collect()
}

pub(crate) fn lookup_command(input: &str) -> CommandLookup {
    let input = normalize_command_input(input);
    if input.is_empty() {
        return CommandLookup::Empty;
    }
    let matches = COMMANDS
        .iter()
        .filter_map(|command| command_match_rank(command, input).map(|rank| (rank, command)))
        .collect::<Vec<_>>();
    let Some(best_rank) = matches.iter().map(|(rank, _)| *rank).min() else {
        return CommandLookup::Missing;
    };
    let mut best_matches = matches
        .into_iter()
        .filter(|(rank, _)| *rank == best_rank)
        .map(|(_, command)| command);
    let Some(command) = best_matches.next() else {
        return CommandLookup::Missing;
    };
    if best_matches.next().is_some() {
        CommandLookup::Ambiguous
    } else {
        CommandLookup::Found(command.action)
    }
}