summaryrefslogblamecommitdiff
path: root/makima/src/daemon/tui/fuzzy.rs
blob: 44c27ad132a7fa85a911c218b4c4553bcb9b4e07 (plain) (tree)
























































































































































































































                                                                                              
//! Fuzzy matching wrapper for search functionality.
//!
//! This module provides a wrapper around the `fuzzy-matcher` crate's
//! `SkimMatcherV2` algorithm, offering:
//!
//! - Single-term fuzzy matching with score and matched indices
//! - Multi-term search (space-separated patterns)
//! - Recency-adjusted scoring for time-aware results
//! - Case-insensitive matching by default
//!
//! # Examples
//!
//! ```
//! use makima::daemon::tui::fuzzy::FuzzyMatcher;
//!
//! let matcher = FuzzyMatcher::new();
//!
//! // Single pattern matching
//! if let Some((score, indices)) = matcher.fuzzy_match("hello world", "hlo") {
//!     println!("Score: {}, Matched positions: {:?}", score, indices);
//! }
//!
//! // Multi-term search
//! if let Some(score) = matcher.fuzzy_match_all("fix authentication bug", "fix bug") {
//!     println!("All terms matched with score: {}", score);
//! }
//! ```

use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher as FuzzyMatcherTrait;

/// Fuzzy matcher wrapper providing search functionality.
///
/// Wraps the `SkimMatcherV2` algorithm which provides:
/// - Smart case matching (case-insensitive unless pattern has uppercase)
/// - Word boundary bonuses
/// - Consecutive character bonuses
pub struct FuzzyMatcher {
    matcher: SkimMatcherV2,
}

impl FuzzyMatcher {
    /// Create a new fuzzy matcher with default settings.
    pub fn new() -> Self {
        Self {
            matcher: SkimMatcherV2::default(),
        }
    }

    /// Match a pattern against a string, returning score and matched indices.
    ///
    /// Returns `Some((score, indices))` if the pattern matches, where:
    /// - `score` is a relevance score (higher is better)
    /// - `indices` are the positions of matched characters in the text
    ///
    /// Returns `None` if the pattern doesn't match the text.
    ///
    /// # Arguments
    ///
    /// * `text` - The text to search in
    /// * `pattern` - The pattern to search for
    pub fn fuzzy_match(&self, text: &str, pattern: &str) -> Option<(i64, Vec<usize>)> {
        self.matcher.fuzzy_indices(text, pattern)
    }

    /// Match multiple patterns (space-separated) against a string.
    ///
    /// All patterns must match for the function to return a score.
    /// The returned score is the sum of individual pattern scores.
    ///
    /// # Arguments
    ///
    /// * `text` - The text to search in
    /// * `patterns` - Space-separated patterns (e.g., "fix bug" matches both "fix" and "bug")
    ///
    /// # Returns
    ///
    /// `Some(total_score)` if all patterns match, `None` otherwise.
    pub fn fuzzy_match_all(&self, text: &str, patterns: &str) -> Option<i64> {
        let patterns: Vec<&str> = patterns.split_whitespace().collect();

        if patterns.is_empty() {
            return Some(0);
        }

        let mut total_score = 0i64;

        for pattern in patterns {
            if let Some((score, _)) = self.matcher.fuzzy_indices(text, pattern) {
                total_score += score;
            } else {
                return None;
            }
        }

        Some(total_score)
    }

    /// Calculate a recency-adjusted score for time-aware sorting.
    ///
    /// Items with lower indices (more recent) receive a bonus to their score,
    /// making them rank higher in search results.
    ///
    /// # Arguments
    ///
    /// * `base_score` - The original fuzzy match score
    /// * `index` - The item's position in the list (0 = most recent)
    /// * `total_items` - Total number of items in the list
    ///
    /// # Returns
    ///
    /// An adjusted score that factors in recency.
    pub fn recency_adjusted_score(base_score: i64, index: usize, total_items: usize) -> i64 {
        if total_items == 0 {
            return base_score;
        }

        // Recency bonus: items at the beginning get up to 20% bonus
        // Formula: bonus = base_score * 0.2 * (1 - index/total_items)
        let recency_factor = 1.0 - (index as f64 / total_items as f64);
        let bonus = (base_score as f64 * 0.2 * recency_factor) as i64;

        base_score + bonus
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_fuzzy_match_exact() {
        let matcher = FuzzyMatcher::new();
        let result = matcher.fuzzy_match("hello world", "hello");
        assert!(result.is_some());
    }

    #[test]
    fn test_fuzzy_match_partial() {
        let matcher = FuzzyMatcher::new();
        let result = matcher.fuzzy_match("authentication", "auth");
        assert!(result.is_some());
    }

    #[test]
    fn test_fuzzy_match_no_match() {
        let matcher = FuzzyMatcher::new();
        let result = matcher.fuzzy_match("hello", "xyz");
        assert!(result.is_none());
    }

    #[test]
    fn test_multi_term_search() {
        let matcher = FuzzyMatcher::new();
        let result = matcher.fuzzy_match_all("fix authentication bug", "fix bug");
        assert!(result.is_some());
    }

    #[test]
    fn test_case_insensitive() {
        let matcher = FuzzyMatcher::new();
        let result = matcher.fuzzy_match("Hello World", "hello");
        assert!(result.is_some());
    }

    #[test]
    fn test_recency_bonus() {
        // Earlier items (lower index) should get higher recency bonus
        let score1 = FuzzyMatcher::recency_adjusted_score(100, 0, 50);
        let score2 = FuzzyMatcher::recency_adjusted_score(100, 10, 50);
        assert!(score1 > score2);
    }

    #[test]
    fn test_fuzzy_match_returns_indices() {
        let matcher = FuzzyMatcher::new();
        let result = matcher.fuzzy_match("hello world", "hlo");
        assert!(result.is_some());
        let (_, indices) = result.unwrap();
        // Should have matched 3 characters
        assert_eq!(indices.len(), 3);
    }

    #[test]
    fn test_multi_term_empty_pattern() {
        let matcher = FuzzyMatcher::new();
        let result = matcher.fuzzy_match_all("hello world", "");
        assert!(result.is_some());
        assert_eq!(result.unwrap(), 0);
    }

    #[test]
    fn test_multi_term_partial_match_fails() {
        let matcher = FuzzyMatcher::new();
        // "xyz" doesn't match, so the whole search should fail
        let result = matcher.fuzzy_match_all("fix authentication bug", "fix xyz");
        assert!(result.is_none());
    }

    #[test]
    fn test_recency_bonus_edge_cases() {
        // Zero total items should return base score
        let score = FuzzyMatcher::recency_adjusted_score(100, 0, 0);
        assert_eq!(score, 100);

        // Last item should get minimal bonus
        let score_last = FuzzyMatcher::recency_adjusted_score(100, 49, 50);
        let score_first = FuzzyMatcher::recency_adjusted_score(100, 0, 50);
        assert!(score_first > score_last);
    }
}