kaptein_viewmodel/
fuzzy.rs1#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct FuzzyMatch {
14 pub candidate: String,
16 pub score: i32,
18}
19
20pub fn fuzzy_jump<'a>(
24 candidates: impl IntoIterator<Item = &'a str>,
25 query: &str,
26) -> Vec<FuzzyMatch> {
27 let query = query.trim().to_ascii_lowercase();
28 if query.is_empty() {
29 return candidates
30 .into_iter()
31 .map(|c| FuzzyMatch {
32 candidate: c.to_string(),
33 score: 0,
34 })
35 .collect();
36 }
37
38 let mut matches: Vec<FuzzyMatch> = candidates
39 .into_iter()
40 .filter_map(|candidate| {
41 fuzzy_score(candidate, &query).map(|score| FuzzyMatch {
42 candidate: candidate.to_string(),
43 score,
44 })
45 })
46 .collect();
47
48 matches.sort_by(|a, b| {
50 b.score
51 .cmp(&a.score)
52 .then_with(|| a.candidate.cmp(&b.candidate))
53 });
54 matches
55}
56
57fn fuzzy_score(candidate: &str, query: &str) -> Option<i32> {
60 let lower = candidate.to_ascii_lowercase();
61 let candidate_chars: Vec<char> = lower.chars().collect();
62 let query_chars: Vec<char> = query.chars().collect();
63
64 if query_chars.is_empty() {
65 return Some(0);
66 }
67
68 let mut score: i32 = 0;
70 let mut qi = 0usize; let mut prev_match: Option<usize> = None;
72
73 for (ci, c) in candidate_chars.iter().enumerate() {
74 if qi < query_chars.len() && *c == query_chars[qi] {
75 score += 1;
77
78 if let Some(prev) = prev_match {
79 if ci == prev + 1 {
81 score += 3;
82 }
83 } else if ci == 0 {
84 score += 4;
86 } else {
87 let prev_char = candidate_chars[ci - 1];
89 if prev_char == ' '
90 || prev_char == '-'
91 || prev_char == '_'
92 || prev_char == '.'
93 || prev_char.is_uppercase()
94 {
95 score += 2;
96 }
97 }
98 prev_match = Some(ci);
99 qi += 1;
100 }
101 }
102
103 if qi == query_chars.len() {
105 Some(score)
106 } else {
107 None
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn subsequence_matches_rank_contiguous_higher() {
117 let names = ["nginx-ingress-controller", "nagios", "nginxxxxxxxxx"];
118 let ranked = fuzzy_jump(names, "nginx");
119 assert_eq!(ranked[0].candidate, "nginx-ingress-controller");
121 }
122
123 #[test]
124 fn empty_query_matches_all() {
125 let names = ["a", "b"];
126 let ranked = fuzzy_jump(names, "");
127 assert_eq!(ranked.len(), 2);
128 }
129
130 #[test]
131 fn no_match_returns_empty() {
132 let names = ["pod-a", "pod-b"];
133 let ranked = fuzzy_jump(names, "zzz");
134 assert!(ranked.is_empty());
135 }
136
137 #[test]
138 fn case_insensitive() {
139 let names = ["Deployment"];
140 let ranked = fuzzy_jump(names, "DEP");
141 assert_eq!(ranked.len(), 1);
142 }
143
144 #[test]
145 fn camel_case_boundary_boost() {
146 let names = ["imagePullBackOff", "ImagePullError"];
147 let ranked = fuzzy_jump(names, "ipbo");
148 assert_eq!(ranked[0].candidate, "imagePullBackOff");
150 }
151}