1use std::io::{self, BufRead, Write};
2
3#[derive(Debug, Clone)]
5pub struct ScoredMatch {
6 pub index: usize,
7 pub text: String,
8 pub score: i64,
9 pub matched_indices: Vec<usize>,
10}
11
12pub fn fuzzy_match(query: &str, items: &[String]) -> Vec<ScoredMatch> {
15 if query.is_empty() {
16 return items
17 .iter()
18 .enumerate()
19 .map(|(i, text)| ScoredMatch {
20 index: i,
21 text: text.clone(),
22 score: 0,
23 matched_indices: vec![],
24 })
25 .collect();
26 }
27
28 let query_lower: Vec<char> = query.to_lowercase().chars().collect();
29
30 let mut matches: Vec<ScoredMatch> = items
31 .iter()
32 .enumerate()
33 .filter_map(|(i, text)| {
34 let (score, indices) = score_match(&query_lower, text);
35 if score > 0 {
36 Some(ScoredMatch {
37 index: i,
38 text: text.clone(),
39 score,
40 matched_indices: indices,
41 })
42 } else {
43 None
44 }
45 })
46 .collect();
47
48 matches.sort_by(|a, b| b.score.cmp(&a.score));
49 matches
50}
51
52fn score_match(query: &[char], text: &str) -> (i64, Vec<usize>) {
55 let text_lower: Vec<char> = text.to_lowercase().chars().collect();
56
57 let mut indices = Vec::new();
59 let mut text_idx = 0;
60
61 for &qch in query {
62 let mut found = false;
63 while text_idx < text_lower.len() {
64 if text_lower[text_idx] == qch {
65 indices.push(text_idx);
66 text_idx += 1;
67 found = true;
68 break;
69 }
70 text_idx += 1;
71 }
72 if !found {
73 return (0, vec![]);
74 }
75 }
76
77 let mut score: i64 = 100;
79
80 let text_lower_str: String = text_lower.iter().collect();
82 let query_str: String = query.iter().collect();
83 if text_lower_str.contains(&query_str) {
84 score += 50;
85 }
86
87 if text_lower_str.starts_with(&query_str) {
89 score += 30;
90 }
91
92 let mut consecutive_bonus = 0i64;
94 for window in indices.windows(2) {
95 if window[1] == window[0] + 1 {
96 consecutive_bonus += 10;
97 }
98 }
99 score += consecutive_bonus;
100
101 for &idx in &indices {
103 if idx == 0 {
104 score += 15;
105 } else if let Some(&prev_ch) = text.as_bytes().get(idx - 1)
106 && (prev_ch == b'_' || prev_ch == b':' || prev_ch == b'.' || prev_ch == b'/')
107 {
108 score += 10;
109 }
110 }
111
112 if indices.len() >= 2 {
114 let spread = indices.last().unwrap() - indices.first().unwrap();
115 let min_spread = indices.len() - 1;
116 let excess_spread = spread.saturating_sub(min_spread);
117 score -= excess_spread as i64 * 2;
118 }
119
120 score -= (text.len() as i64 - query.len() as i64).abs();
122
123 score = score.max(1); (score, indices)
125}
126
127pub fn highlight_match(text: &str, matched_indices: &[usize]) -> String {
129 if matched_indices.is_empty() {
130 return text.to_string();
131 }
132
133 let chars: Vec<char> = text.chars().collect();
134 let mut result = String::new();
135 let mut in_highlight = false;
136
137 for (i, ch) in chars.iter().enumerate() {
138 let is_matched = matched_indices.contains(&i);
139
140 if is_matched && !in_highlight {
141 result.push_str("\x1b[1;33m"); in_highlight = true;
143 } else if !is_matched && in_highlight {
144 result.push_str("\x1b[0m"); in_highlight = false;
146 }
147
148 result.push(*ch);
149 }
150
151 if in_highlight {
152 result.push_str("\x1b[0m");
153 }
154
155 result
156}
157
158pub fn interactive_pick(test_names: &[String], prompt: &str) -> io::Result<Vec<String>> {
161 if test_names.is_empty() {
162 eprintln!("No tests available to pick from.");
163 return Ok(vec![]);
164 }
165
166 let stdin = io::stdin();
167 let mut stdout = io::stdout();
168
169 eprintln!("{}", prompt);
170 eprintln!("Type to filter, enter number(s) to select (comma-separated), 'q' to cancel:");
171 eprintln!();
172
173 display_items(test_names, &[], 20);
175
176 eprint!("\n> ");
177 stdout.flush()?;
178
179 let mut line = String::new();
180 stdin.lock().read_line(&mut line)?;
181 let input = line.trim();
182
183 if input.eq_ignore_ascii_case("q") || input.is_empty() {
184 return Ok(vec![]);
185 }
186
187 let numbers: std::result::Result<Vec<usize>, _> = input
189 .split(',')
190 .map(|s| s.trim().parse::<usize>())
191 .collect();
192
193 if let Ok(nums) = numbers {
194 let selected: Vec<String> = nums
195 .into_iter()
196 .filter(|&n| n > 0 && n <= test_names.len())
197 .map(|n| test_names[n - 1].clone())
198 .collect();
199 return Ok(selected);
200 }
201
202 let matches = fuzzy_match(input, test_names);
204 if matches.is_empty() {
205 eprintln!("No tests match '{}'", input);
206 return Ok(vec![]);
207 }
208
209 eprintln!("\nMatches for '{}':", input);
210 for (i, m) in matches.iter().enumerate().take(20) {
211 eprintln!(
212 " {:>3}. {}",
213 i + 1,
214 highlight_match(&m.text, &m.matched_indices)
215 );
216 }
217 if matches.len() > 20 {
218 eprintln!(" ... and {} more", matches.len() - 20);
219 }
220
221 eprint!("\nSelect number(s) or press Enter for all matches > ");
222 stdout.flush()?;
223
224 let mut line2 = String::new();
225 stdin.lock().read_line(&mut line2)?;
226 let input2 = line2.trim();
227
228 if input2.is_empty() {
229 return Ok(matches.into_iter().map(|m| m.text).collect());
231 }
232
233 if input2.eq_ignore_ascii_case("q") {
234 return Ok(vec![]);
235 }
236
237 let nums: std::result::Result<Vec<usize>, _> = input2
238 .split(',')
239 .map(|s| s.trim().parse::<usize>())
240 .collect();
241
242 if let Ok(nums) = nums {
243 let selected: Vec<String> = nums
244 .into_iter()
245 .filter(|&n| n > 0 && n <= matches.len())
246 .map(|n| matches[n - 1].text.clone())
247 .collect();
248 Ok(selected)
249 } else {
250 eprintln!("Invalid selection.");
251 Ok(vec![])
252 }
253}
254
255fn display_items(items: &[String], matched: &[ScoredMatch], max: usize) {
256 if matched.is_empty() {
257 for (i, item) in items.iter().enumerate().take(max) {
258 eprintln!(" {:>3}. {}", i + 1, item);
259 }
260 } else {
261 for (i, m) in matched.iter().enumerate().take(max) {
262 eprintln!(
263 " {:>3}. {}",
264 i + 1,
265 highlight_match(&m.text, &m.matched_indices)
266 );
267 }
268 }
269
270 let total = if matched.is_empty() {
271 items.len()
272 } else {
273 matched.len()
274 };
275 if total > max {
276 eprintln!(" ... and {} more", total - max);
277 }
278}
279
280pub fn batch_fuzzy_filter(query: &str, test_names: &[String]) -> Vec<String> {
282 let matches = fuzzy_match(query, test_names);
283 matches.into_iter().map(|m| m.text).collect()
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn fuzzy_match_empty_query() {
292 let items = vec!["test_a".to_string(), "test_b".to_string()];
293 let results = fuzzy_match("", &items);
294 assert_eq!(results.len(), 2);
295 }
296
297 #[test]
298 fn fuzzy_match_empty_items() {
299 let results = fuzzy_match("query", &[]);
300 assert!(results.is_empty());
301 }
302
303 #[test]
304 fn fuzzy_match_exact() {
305 let items = vec![
306 "test_alpha".to_string(),
307 "test_beta".to_string(),
308 "test_gamma".to_string(),
309 ];
310 let results = fuzzy_match("test_beta", &items);
311 assert!(!results.is_empty());
312 assert_eq!(results[0].text, "test_beta");
313 }
314
315 #[test]
316 fn fuzzy_match_partial() {
317 let items = vec![
318 "test_connection_pool".to_string(),
319 "test_database_query".to_string(),
320 "test_cache_invalidation".to_string(),
321 ];
322 let results = fuzzy_match("conn", &items);
323 assert!(!results.is_empty());
324 assert_eq!(results[0].text, "test_connection_pool");
325 }
326
327 #[test]
328 fn fuzzy_match_case_insensitive() {
329 let items = vec!["TestAlpha".to_string(), "testBeta".to_string()];
330 let results = fuzzy_match("alpha", &items);
331 assert!(!results.is_empty());
332 assert_eq!(results[0].text, "TestAlpha");
333 }
334
335 #[test]
336 fn fuzzy_match_abbreviation() {
337 let items = vec![
338 "test_connection_pool_cleanup".to_string(),
339 "test_everything_else".to_string(),
340 ];
341 let results = fuzzy_match("tcp", &items);
342 assert!(!results.is_empty());
344 assert_eq!(results[0].text, "test_connection_pool_cleanup");
345 }
346
347 #[test]
348 fn fuzzy_match_no_match() {
349 let items = vec!["test_alpha".to_string()];
350 let results = fuzzy_match("zzz", &items);
351 assert!(results.is_empty());
352 }
353
354 #[test]
355 fn fuzzy_match_ordering() {
356 let items = vec![
357 "something_unrelated".to_string(),
358 "test_parse_output".to_string(),
359 "parse".to_string(),
360 "test_parse".to_string(),
361 ];
362 let results = fuzzy_match("parse", &items);
363 assert!(!results.is_empty());
365 assert_eq!(results[0].text, "parse");
366 }
367
368 #[test]
369 fn fuzzy_match_word_boundary_bonus() {
370 let items = vec!["xyzparseabc".to_string(), "test_parse_output".to_string()];
371 let results = fuzzy_match("parse", &items);
372 assert_eq!(results.len(), 2);
374 assert_eq!(results[0].text, "test_parse_output");
375 }
376
377 #[test]
378 fn score_match_basic() {
379 let query: Vec<char> = "abc".chars().collect();
380 let (score, indices) = score_match(&query, "abc");
381 assert!(score > 0);
382 assert_eq!(indices, vec![0, 1, 2]);
383 }
384
385 #[test]
386 fn score_match_no_match() {
387 let query: Vec<char> = "xyz".chars().collect();
388 let (score, _) = score_match(&query, "abc");
389 assert_eq!(score, 0);
390 }
391
392 #[test]
393 fn score_match_partial_order() {
394 let query: Vec<char> = "ac".chars().collect();
395 let (score, indices) = score_match(&query, "abc");
396 assert!(score > 0);
397 assert_eq!(indices, vec![0, 2]);
398 }
399
400 #[test]
401 fn score_match_out_of_order_fails() {
402 let query: Vec<char> = "ba".chars().collect();
403 let (score, _) = score_match(&query, "abc");
404 assert_eq!(score, 0);
406 }
407
408 #[test]
409 fn highlight_match_basic() {
410 let output = highlight_match("test_alpha", &[5, 6, 7, 8, 9]);
411 assert!(output.contains("\x1b[1;33m")); assert!(output.contains("\x1b[0m")); }
414
415 #[test]
416 fn highlight_match_empty() {
417 let output = highlight_match("test", &[]);
418 assert_eq!(output, "test");
419 }
420
421 #[test]
422 fn batch_fuzzy_filter_basic() {
423 let items = vec![
424 "test_alpha".to_string(),
425 "test_beta".to_string(),
426 "test_gamma".to_string(),
427 ];
428 let results = batch_fuzzy_filter("alpha", &items);
429 assert_eq!(results.len(), 1);
430 assert_eq!(results[0], "test_alpha");
431 }
432
433 #[test]
434 fn batch_fuzzy_filter_multiple() {
435 let items = vec![
436 "test_parse_json".to_string(),
437 "test_parse_xml".to_string(),
438 "test_format_json".to_string(),
439 ];
440 let results = batch_fuzzy_filter("parse", &items);
441 assert_eq!(results.len(), 2);
442 }
443
444 #[test]
445 fn batch_fuzzy_filter_empty_query() {
446 let items = vec!["a".to_string(), "b".to_string()];
447 let results = batch_fuzzy_filter("", &items);
448 assert_eq!(results.len(), 2);
449 }
450
451 #[test]
452 fn matched_indices_tracked() {
453 let items = vec!["abcdef".to_string()];
454 let results = fuzzy_match("ace", &items);
455 assert_eq!(results.len(), 1);
456 assert_eq!(results[0].matched_indices, vec![0, 2, 4]);
457 }
458
459 #[test]
460 fn prefix_match_scored_higher() {
461 let items = vec!["zzz_test".to_string(), "test_zzz".to_string()];
462 let results = fuzzy_match("test", &items);
463 assert_eq!(results.len(), 2);
464 assert_eq!(results[0].text, "test_zzz");
466 }
467
468 #[test]
469 fn consecutive_matches_bonus() {
470 let items = vec!["t_e_s_t".to_string(), "test_xyz".to_string()];
471 let results = fuzzy_match("test", &items);
472 assert_eq!(results.len(), 2);
473 assert_eq!(results[0].text, "test_xyz");
475 }
476}