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