summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui/fuzzy.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon/tui/fuzzy.rs')
-rw-r--r--makima/src/daemon/tui/fuzzy.rs217
1 files changed, 217 insertions, 0 deletions
diff --git a/makima/src/daemon/tui/fuzzy.rs b/makima/src/daemon/tui/fuzzy.rs
new file mode 100644
index 0000000..44c27ad
--- /dev/null
+++ b/makima/src/daemon/tui/fuzzy.rs
@@ -0,0 +1,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);
+ }
+}