//! 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)> { 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 { 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); } }