eazygit 0.5.1

A fast TUI for Git with staging, conflicts, rebase, and palette-first UX
Documentation
use super::command::CommandDef;
use lru::LruCache;
use nucleo_matcher::{Matcher, Utf32String};
use std::num::NonZeroUsize;

#[derive(Clone)]
pub struct ScoredCommand {
    pub command: &'static CommandDef,
    pub score: u32,
    pub match_ranges: Vec<(usize, usize)>,
}

pub struct FuzzyMatcher {
    matcher: Matcher,
    cache: LruCache<String, Vec<ScoredCommand>>,
}

impl FuzzyMatcher {
    pub fn new() -> Self {
        let matcher = Matcher::default();
        // Cache size of 100 is safe (always non-zero)
        // This is a compile-time constant, so unwrap is safe
        let cache = LruCache::new(
            NonZeroUsize::new(100)
                .expect("Cache size must be non-zero (this is a compile-time constant)")
        );
        
        Self { matcher, cache }
    }
    
    pub fn filter_commands(
        &mut self,
        query: &str,
        commands: &[&'static CommandDef],
        workflow_context: Option<&crate::app::workflow::WorkflowContext>,
    ) -> Vec<ScoredCommand> {
        // Check cache first
        if let Some(cached) = self.cache.get(query) {
            return cached.to_vec();
        }
        
        if query.is_empty() {
            // Return all commands with neutral score
            let results: Vec<ScoredCommand> = commands
                .iter()
                .map(|cmd| ScoredCommand {
                    command: *cmd,
                    score: 1000,
                    match_ranges: Vec::new(),
                })
                .collect();
            self.cache.put(query.to_string(), results.clone());
            return results;
        }
        
        // Early termination for exact prefix matches
        // Precompute lowercase query once to avoid repeated allocations
        let query_lower = query.to_lowercase();
        let mut exact_matches = Vec::new();
        let mut fuzzy_matches = Vec::new();
        
        // Helper function for case-insensitive prefix matching
        // Uses ASCII-only optimization for common case (most commands use ASCII)
        #[inline]
        fn case_insensitive_starts_with(s: &str, prefix: &str) -> bool {
            if s.len() < prefix.len() {
                return false;
            }
            // Try ASCII-only comparison first (fast path)
            if s.is_ascii() && prefix.is_ascii() {
                s[..prefix.len()].eq_ignore_ascii_case(prefix)
            } else {
                // Fallback to full Unicode lowercase comparison
                s.to_lowercase().starts_with(prefix)
            }
        }
        
        for cmd in commands {
            // Use case-insensitive comparison without allocating new strings
            let name_matches = case_insensitive_starts_with(cmd.name, &query_lower);
            let key_matches = case_insensitive_starts_with(cmd.key, &query_lower);
            
            // Check for exact prefix match (highest priority)
            if name_matches || key_matches {
                // Defensive: ensure match range is within bounds
                let match_end = query.len().min(cmd.name.len());
                exact_matches.push(ScoredCommand {
                    command: *cmd,
                    score: 2000,
                    match_ranges: vec![(0, match_end)],
                });
                continue;
            }
            
            // Check keywords with optimized case-insensitive comparison
            let mut keyword_match = false;
            for keyword in cmd.keywords {
                if case_insensitive_starts_with(keyword, &query_lower) {
                    exact_matches.push(ScoredCommand {
                        command: *cmd,
                        score: 1800,
                        match_ranges: Vec::new(),
                    });
                    keyword_match = true;
                    break;
                }
            }
            if keyword_match {
                continue;
            }
            
            // Fuzzy match on name using nucleo-matcher
            let name_utf32 = Utf32String::from(cmd.name);
            let pattern_utf32 = Utf32String::from(query);
            // Use slice(..) to get Utf32Str from Utf32String
            let name_str = name_utf32.slice(..);
            let pattern_str = pattern_utf32.slice(..);
            
            // Try fuzzy match with indices for highlighting
            let mut indices = Vec::new();
            if let Some(score) = self.matcher.fuzzy_indices(name_str, pattern_str, &mut indices) {
                let ranges = self.indices_to_ranges_u32(&indices);
                fuzzy_matches.push(ScoredCommand {
                    command: *cmd,
                    score: score as u32,
                    match_ranges: ranges,
                });
            }
        }
        
        // Combine and sort: exact matches first, then fuzzy matches
        let mut results = exact_matches;
        results.append(&mut fuzzy_matches);
        
        // Apply context-aware scoring boost
        if let Some(context) = workflow_context {
            for result in &mut results {
                result.score += Self::get_context_boost(result.command, context);
            }
        }
        
        results.sort_by(|a, b| b.score.cmp(&a.score));
        
        // Cache results
        // Use the processed query (which may be truncated) as cache key
        // This prevents storing very long keys in cache while maintaining correctness
        // since we already checked cache with original query above
        let results_clone = results.iter().map(|r| ScoredCommand {
            command: r.command,
            score: r.score,
            match_ranges: r.match_ranges.clone(),
        }).collect();
        self.cache.put(query.to_string(), results_clone);
        
        results
    }
    
    fn indices_to_ranges_u32(&self, indices: &[u32]) -> Vec<(usize, usize)> {
        if indices.is_empty() {
            return Vec::new();
        }
        
        let mut ranges = Vec::new();
        let mut start = indices[0] as usize;
        let mut end = (indices[0] + 1) as usize;
        
        for &idx in indices.iter().skip(1) {
            let idx_usize = idx as usize;
            // Defensive: ensure indices are valid (shouldn't happen with nucleo-matcher, but be safe)
            if idx_usize == end {
                end = idx_usize + 1;
            } else {
                // Only add range if start < end (defensive check)
                if start < end {
                    ranges.push((start, end));
                }
                start = idx_usize;
                end = idx_usize + 1;
            }
        }
        // Final range
        if start < end {
            ranges.push((start, end));
        }
        ranges
    }
    
    /// Calculates a context-aware boost score for command ranking.
    /// 
    /// Commands are boosted based on the current Git workflow state to prioritize
    /// relevant actions. Higher scores appear first in the command palette.
    /// 
    /// **Scoring Strategy:**
    /// - Primary actions (most relevant): 500 points
    /// - Secondary actions (helpful): 300-400 points
    /// - Tertiary actions (related): 200 points
    /// - Unrelated actions: 0 points (no boost)
    /// 
    /// **Note:** Uses command name matching since not all actions have CommandId variants.
    fn get_context_boost(
        cmd: &CommandDef,
        context: &crate::app::workflow::WorkflowContext,
    ) -> u32 {
        use crate::app::workflow::WorkflowState;
        
        // Use case-insensitive contains check without allocating
        // Most command names are ASCII, so we can optimize for that case
        #[inline]
        fn case_insensitive_contains(haystack: &str, needle: &str) -> bool {
            // Fast path for ASCII strings (most common case)
            if haystack.is_ascii() && needle.is_ascii() {
                haystack.as_bytes().windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
            } else {
                // Fallback to lowercase comparison for Unicode
                haystack.to_lowercase().contains(&needle.to_lowercase())
            }
        }
        
        match context.state {
            WorkflowState::Conflicts => Self::boost_for_conflicts(cmd.name, &case_insensitive_contains),
            WorkflowState::RebaseInProgress => Self::boost_for_rebase(cmd.name, &case_insensitive_contains),
            WorkflowState::MergeInProgress => Self::boost_for_merge(cmd.name, &case_insensitive_contains),
            WorkflowState::Staging => Self::boost_for_staging(cmd.name, &case_insensitive_contains),
            WorkflowState::Committing => Self::boost_for_committing(cmd.name, &case_insensitive_contains),
            WorkflowState::CherryPickInProgress | WorkflowState::RevertInProgress => {
                Self::boost_for_cherry_pick_or_revert(cmd.name, &case_insensitive_contains)
            }
            WorkflowState::Clean => Self::boost_for_clean_state(cmd.name, &case_insensitive_contains),
        }
    }
    
    /// Boost scores for conflict resolution workflow.
    fn boost_for_conflicts(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
        if contains(cmd_name, "conflict") || contains(cmd_name, "resolve") {
            500 // Primary: resolve conflicts
        } else if contains(cmd_name, "stage") {
            300 // Secondary: stage resolved files
        } else if contains(cmd_name, "commit") {
            200 // Tertiary: commit after resolution
        } else {
            0
        }
    }
    
    /// Boost scores for rebase workflow.
    fn boost_for_rebase(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
        if contains(cmd_name, "rebase") && contains(cmd_name, "continue") {
            500 // Primary: continue rebase
        } else if contains(cmd_name, "rebase") && contains(cmd_name, "abort") {
            400 // Secondary: abort rebase
        } else if contains(cmd_name, "rebase") && contains(cmd_name, "todo") {
            300 // Tertiary: view rebase todo
        } else {
            0
        }
    }
    
    /// Boost scores for merge workflow.
    fn boost_for_merge(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
        if contains(cmd_name, "commit") {
            500 // Primary: complete merge
        } else if contains(cmd_name, "conflict") {
            400 // Secondary: resolve conflicts
        } else {
            0
        }
    }
    
    /// Boost scores for staging workflow.
    fn boost_for_staging(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
        if contains(cmd_name, "commit") {
            500 // Primary: commit staged changes
        } else if contains(cmd_name, "stage") {
            400 // Secondary: stage more files
        } else {
            0
        }
    }
    
    /// Boost scores for committing workflow.
    fn boost_for_committing(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
        if contains(cmd_name, "commit") {
            500 // Primary: submit or cancel commit
        } else {
            0
        }
    }
    
    /// Boost scores for cherry-pick or revert workflow.
    fn boost_for_cherry_pick_or_revert(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
        if contains(cmd_name, "commit") {
            500 // Primary: complete cherry-pick/revert
        } else {
            0
        }
    }
    
    /// Boost scores for clean state (no active workflow).
    fn boost_for_clean_state(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
        if contains(cmd_name, "push") {
            300 // Secondary: push changes
        } else if contains(cmd_name, "fetch") {
            200 // Tertiary: fetch updates
        } else {
            0
        }
    }
}

impl Default for FuzzyMatcher {
    fn default() -> Self {
        Self::new()
    }
}