summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui/fuzzy.rs
blob: 44c27ad132a7fa85a911c218b4c4553bcb9b4e07 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
//! 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);
    }
}