Skip to main content

testx/
picker.rs

1use std::io::{self, BufRead, Write};
2
3/// A scored match for fuzzy search.
4#[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
12/// Perform fuzzy matching of a query against a list of items.
13/// Returns items sorted by score (best match first).
14pub 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
52/// Score how well a query matches a text. Returns (score, matched_indices).
53/// Returns (0, []) if there's no match.
54fn score_match(query: &[char], text: &str) -> (i64, Vec<usize>) {
55    let text_lower: Vec<char> = text.to_lowercase().chars().collect();
56
57    // Check if all query characters appear in order
58    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    // Score based on match quality
78    let mut score: i64 = 100;
79
80    // Bonus for exact substring match
81    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    // Bonus for prefix match
88    if text_lower_str.starts_with(&query_str) {
89        score += 30;
90    }
91
92    // Bonus for consecutive matches
93    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    // Bonus for matching at word boundaries (after _, ::, .)
102    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    // Penalty for spread-out matches
113    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    // Shorter matches are better (when query matches)
121    score -= (text.len() as i64 - query.len() as i64).abs();
122
123    score = score.max(1); // Ensure positive score if matched
124    (score, indices)
125}
126
127/// Render matched text with highlighting using ANSI colors.
128pub 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"); // Bold yellow
142            in_highlight = true;
143        } else if !is_matched && in_highlight {
144            result.push_str("\x1b[0m"); // Reset
145            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
158/// Interactive test picker using stdin/stdout.
159/// Returns the selected test names.
160pub 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    // Show all tests initially
174    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    // Try to parse as numbers first
188    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    // Otherwise, treat as a filter pattern and show matches
203    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 all matches
230        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
280/// Non-interactive batch fuzzy filter: returns matching test names.
281pub 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        // "tcp" should match "test_connection_pool_cleanup" via t, c, p
343        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        // "parse" (exact match) should score highest
364        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        // "test_parse_output" should score higher due to word boundary at _p
373        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        // 'b' at index 1, then 'a' needs to be after index 1 — not possible
405        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")); // Contains highlight start
412        assert!(output.contains("\x1b[0m")); // Contains reset
413    }
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        // "test_zzz" starts with "test", should score higher
465        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        // "test_xyz" has consecutive matches for "test", should score higher
474        assert_eq!(results[0].text, "test_xyz");
475    }
476}