1use std::cmp::Ordering;
30use std::collections::HashMap;
31
32use crate::model::{Entry, Layer};
33use crate::store::stats::Score;
34
35#[derive(Debug, Clone, Copy)]
37pub struct Candidate<'a> {
38 pub entry: &'a Entry,
39 pub cmd: &'a str,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44enum Quality {
45 Inside,
47 WordStart,
49 Prefix,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
55enum Field {
56 Tags,
57 Desc,
58 Cmd,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66struct Hit {
67 whole: bool,
70 field: Field,
71 quality: Quality,
72}
73
74impl Hit {
75 fn new(field: Field, quality: Quality) -> Self {
76 Self {
77 whole: quality != Quality::Inside,
78 field,
79 quality,
80 }
81 }
82}
83
84struct Rank {
86 quality: Option<Hit>,
87 leads: bool,
89 pinned: bool,
90 layer: Option<Layer>,
93 frecency: f64,
94 length: usize,
96}
97
98pub fn rank(
100 candidates: &[Candidate<'_>],
101 scores: &HashMap<String, Score>,
102 query: &str,
103) -> Vec<usize> {
104 let terms = terms(query);
105 let phrase = terms.join(" ");
106 let mut ranked: Vec<(usize, Rank)> = Vec::with_capacity(candidates.len());
107
108 for (index, candidate) in candidates.iter().enumerate() {
109 let quality = if terms.is_empty() {
110 None
111 } else if let Some(quality) = all_terms(candidate, &terms) {
112 Some(quality)
113 } else {
114 continue;
115 };
116
117 let score = scores.get(&candidate.entry.id);
118
119 ranked.push((
120 index,
121 Rank {
122 quality,
123 leads: !phrase.is_empty() && starts_with_ignore_case(candidate.cmd, &phrase),
124 pinned: score.is_some_and(|s| s.pinned),
125 layer: quality.is_none().then_some(candidate.entry.layer),
126 frecency: score.map(|s| s.value).unwrap_or_default(),
127 length: candidate.cmd.chars().count(),
128 },
129 ));
130 }
131
132 ranked.sort_by(|(left_index, left), (right_index, right)| {
133 compare(left, right).then_with(|| {
134 candidates[*left_index]
137 .entry
138 .id
139 .cmp(&candidates[*right_index].entry.id)
140 })
141 });
142
143 ranked.into_iter().map(|(index, _)| index).collect()
144}
145
146pub fn highlight(haystack: &str, query: &str) -> Vec<u32> {
151 let mut found: Vec<u32> = terms(query)
152 .iter()
153 .filter_map(|term| find(haystack, term))
154 .flat_map(|(_, covered)| covered)
155 .collect();
156
157 found.sort_unstable();
158 found.dedup();
159 found
160}
161
162pub fn matches(haystack: &str, query: &str) -> bool {
167 terms(query)
168 .iter()
169 .all(|term| find(haystack, term).is_some())
170}
171
172fn terms(query: &str) -> Vec<String> {
173 query
174 .split_whitespace()
175 .map(|term| term.to_lowercase())
176 .collect()
177}
178
179fn all_terms(candidate: &Candidate<'_>, terms: &[String]) -> Option<Hit> {
184 let tags = candidate.entry.tags.join(" ");
185 let fields = [
186 (Field::Cmd, candidate.cmd),
187 (Field::Desc, candidate.entry.desc.as_str()),
188 (Field::Tags, tags.as_str()),
189 ];
190
191 terms
192 .iter()
193 .map(|term| {
194 fields
195 .iter()
196 .filter_map(|(field, haystack)| {
197 quality_of(haystack, term).map(|quality| Hit::new(*field, quality))
198 })
199 .max()
200 })
201 .try_fold(None, |weakest: Option<Hit>, best| {
202 let best = best?;
203 Some(Some(match weakest {
204 Some(weakest) => weakest.min(best),
205 None => best,
206 }))
207 })
208 .flatten()
209}
210
211fn quality_of(haystack: &str, term: &str) -> Option<Quality> {
212 let (at, _) = find(haystack, term)?;
213
214 if at == 0 {
215 return Some(Quality::Prefix);
216 }
217 if starts_word(haystack, at) {
218 return Some(Quality::WordStart);
219 }
220 Some(Quality::Inside)
221}
222
223fn find(haystack: &str, needle: &str) -> Option<(usize, Vec<u32>)> {
226 if needle.is_empty() {
227 return None;
228 }
229
230 let length = needle.chars().count();
231 haystack
232 .char_indices()
233 .enumerate()
234 .find(|(_, (offset, _))| starts_with_ignore_case(&haystack[*offset..], needle))
235 .map(|(position, (offset, _))| {
236 let covered = (position..position + length).map(|n| n as u32).collect();
237 (offset, covered)
238 })
239}
240
241fn starts_word(haystack: &str, at: usize) -> bool {
243 haystack[..at]
244 .chars()
245 .next_back()
246 .is_none_or(|previous| !previous.is_alphanumeric())
247}
248
249fn starts_with_ignore_case(haystack: &str, needle: &str) -> bool {
250 let mut haystack = haystack.chars().flat_map(char::to_lowercase);
251 let mut needle = needle.chars().flat_map(char::to_lowercase);
252
253 loop {
254 match (needle.next(), haystack.next()) {
255 (None, _) => return true,
256 (Some(_), None) => return false,
257 (Some(wanted), Some(found)) if wanted != found => return false,
258 _ => {}
259 }
260 }
261}
262
263fn compare(left: &Rank, right: &Rank) -> Ordering {
264 right
265 .quality
266 .cmp(&left.quality)
267 .then(right.leads.cmp(&left.leads))
268 .then(right.pinned.cmp(&left.pinned))
269 .then(right.layer.cmp(&left.layer))
270 .then(right.frecency.total_cmp(&left.frecency))
271 .then(left.length.cmp(&right.length))
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::model::{CommandBody, Entry, Layer};
278 use std::collections::BTreeMap;
279
280 fn entry(id: &str, cmd: &str, desc: &str, tags: &[&str], layer: Layer) -> Entry {
281 Entry {
282 id: id.to_string(),
283 cmd: CommandBody::Shared(cmd.to_string()),
284 desc: desc.to_string(),
285 tags: tags.iter().map(|t| t.to_string()).collect(),
286 params: BTreeMap::new(),
287 danger: false,
288 layer,
289 }
290 }
291
292 fn candidates(entries: &[Entry]) -> Vec<Candidate<'_>> {
293 entries
294 .iter()
295 .map(|entry| Candidate {
296 entry,
297 cmd: match &entry.cmd {
298 CommandBody::Shared(cmd) => cmd.as_str(),
299 CommandBody::PerShell(_) => unreachable!("test entries are shared"),
300 },
301 })
302 .collect()
303 }
304
305 fn scored(pairs: &[(&str, f64)]) -> HashMap<String, Score> {
306 pairs
307 .iter()
308 .map(|(id, value)| {
309 (
310 id.to_string(),
311 Score {
312 value: *value,
313 pinned: false,
314 },
315 )
316 })
317 .collect()
318 }
319
320 fn order<'a>(
321 entries: &'a [Entry],
322 scores: &HashMap<String, Score>,
323 query: &str,
324 ) -> Vec<&'a str> {
325 let candidates = candidates(entries);
326 rank(&candidates, scores, query)
327 .into_iter()
328 .map(|index| candidates[index].entry.id.as_str())
329 .collect()
330 }
331
332 fn sample() -> Vec<Entry> {
333 vec![
334 entry(
335 "docker.ps",
336 "docker ps -a",
337 "List containers",
338 &["docker"],
339 Layer::Builtin,
340 ),
341 entry(
342 "git.log",
343 "git log --oneline",
344 "Show history",
345 &["git"],
346 Layer::Builtin,
347 ),
348 entry(
349 "git.push",
350 "git push --force-with-lease",
351 "Publish the branch",
352 &["git"],
353 Layer::User,
354 ),
355 ]
356 }
357
358 #[test]
359 fn an_empty_query_keeps_everything() {
360 let entries = sample();
361 assert_eq!(order(&entries, &HashMap::new(), "").len(), 3);
362 }
363
364 #[test]
365 fn an_empty_query_puts_user_entries_before_builtins() {
366 let entries = sample();
367 assert_eq!(order(&entries, &HashMap::new(), "")[0], "git.push");
368 }
369
370 #[test]
371 fn an_empty_query_ranks_by_frecency_within_a_layer() {
372 let entries = sample();
373 let scores = scored(&[("git.log", 5.0), ("docker.ps", 1.0)]);
374 assert_eq!(
375 order(&entries, &scores, ""),
376 ["git.push", "git.log", "docker.ps"]
377 );
378 }
379
380 #[test]
381 fn a_query_filters_out_entries_that_do_not_match() {
382 let entries = sample();
383 assert_eq!(order(&entries, &HashMap::new(), "docker"), ["docker.ps"]);
384 }
385
386 #[test]
387 fn scattered_letters_do_not_count_as_a_match() {
388 let entries = vec![
389 entry(
390 "sys.list",
391 "Get-ChildItem -Path <dir> -Recurse",
392 "Find files under a directory",
393 &[],
394 Layer::Builtin,
395 ),
396 entry(
397 "git.log",
398 "git log --oneline",
399 "Show history",
400 &[],
401 Layer::Builtin,
402 ),
403 ];
404 assert_eq!(order(&entries, &HashMap::new(), "git"), ["git.log"]);
406 }
407
408 #[test]
409 fn letters_are_never_gathered_from_separate_words() {
410 let entries = sample();
411 assert!(order(&entries, &HashMap::new(), "dps").is_empty());
413 }
414
415 #[test]
416 fn every_term_has_to_be_found() {
417 let entries = vec![
418 entry(
419 "git.clean",
420 "git clean -nfdx",
421 "Preview a clean",
422 &[],
423 Layer::Builtin,
424 ),
425 entry(
426 "git.log",
427 "git log --oneline",
428 "Show history",
429 &[],
430 Layer::Builtin,
431 ),
432 ];
433 assert_eq!(order(&entries, &HashMap::new(), "git cl"), ["git.clean"]);
434 }
435
436 #[test]
437 fn terms_may_land_in_different_fields() {
438 let entries = vec![entry(
439 "docker.logs",
440 "docker logs -f <container>",
441 "Follow the output of a running container",
442 &["debug"],
443 Layer::Builtin,
444 )];
445 assert_eq!(
447 order(&entries, &HashMap::new(), "docker running"),
448 ["docker.logs"]
449 );
450 }
451
452 #[test]
453 fn a_term_inside_a_word_still_matches() {
454 let entries = sample();
455 assert_eq!(order(&entries, &HashMap::new(), "onelin"), ["git.log"]);
456 }
457
458 #[test]
459 fn a_query_nothing_contains_matches_nothing() {
460 let entries = sample();
461 assert!(order(&entries, &HashMap::new(), "zzzzq").is_empty());
462 }
463
464 #[test]
465 fn match_quality_outranks_frecency() {
466 let entries = sample();
467 let scores = scored(&[("docker.ps", 500.0)]);
468 let ranked = order(&entries, &scores, "git");
469 assert!(!ranked.contains(&"docker.ps"));
470 }
471
472 #[test]
473 fn match_quality_outranks_the_user_layer() {
474 let entries = vec![
475 entry(
476 "user.thing",
477 "kubectl describe thing",
478 "Describe a thing",
479 &[],
480 Layer::User,
481 ),
482 entry(
483 "builtin.kubectl",
484 "kubectl get pods",
485 "List pods",
486 &[],
487 Layer::Builtin,
488 ),
489 ];
490 assert_eq!(
491 order(&entries, &HashMap::new(), "kubectl get")[0],
492 "builtin.kubectl"
493 );
494 }
495
496 #[test]
497 fn frecency_breaks_ties_inside_a_quality_bucket() {
498 let entries = sample();
499 let scores = scored(&[("git.push", 1.0), ("git.log", 9.0)]);
500 assert_eq!(order(&entries, &scores, "git"), ["git.log", "git.push"]);
501 }
502
503 #[test]
504 fn a_command_match_outranks_a_description_match() {
505 let entries = vec![
506 entry(
507 "by.desc",
508 "ls -la",
509 "show docker containers",
510 &[],
511 Layer::Builtin,
512 ),
513 entry(
514 "by.cmd",
515 "docker ps",
516 "list running things",
517 &[],
518 Layer::Builtin,
519 ),
520 ];
521 assert_eq!(order(&entries, &HashMap::new(), "docker")[0], "by.cmd");
522 }
523
524 #[test]
525 fn a_description_match_finds_a_command_by_intent() {
526 let entries = sample();
527 assert_eq!(order(&entries, &HashMap::new(), "history"), ["git.log"]);
528 }
529
530 #[test]
531 fn a_tag_match_still_finds_the_entry() {
532 let entries = vec![entry(
533 "sys.ports",
534 "ss -tulpn",
535 "Show listening sockets",
536 &["network", "troubleshooting"],
537 Layer::Builtin,
538 )];
539 assert_eq!(order(&entries, &HashMap::new(), "network"), ["sys.ports"]);
540 }
541
542 #[test]
543 fn pinning_wins_inside_the_empty_state() {
544 let entries = sample();
545 let mut scores = scored(&[("docker.ps", 0.1)]);
546 scores.get_mut("docker.ps").unwrap().pinned = true;
547 assert_eq!(order(&entries, &scores, "")[0], "docker.ps");
548 }
549
550 #[test]
551 fn pinning_never_overrides_match_quality() {
552 let entries = sample();
553 let mut scores = scored(&[("docker.ps", 100.0)]);
554 scores.get_mut("docker.ps").unwrap().pinned = true;
555 assert!(!order(&entries, &scores, "git").contains(&"docker.ps"));
556 }
557
558 #[test]
559 fn a_word_start_outranks_a_match_inside_a_word() {
560 let entries = vec![
561 entry("inside", "cargo build --release", "", &[], Layer::Builtin),
563 entry("word.start", "git argocd sync", "", &[], Layer::Builtin),
565 ];
566 assert_eq!(
567 order(&entries, &HashMap::new(), "arg"),
568 ["word.start", "inside"]
569 );
570 }
571
572 #[test]
576 fn a_query_found_whole_in_the_command_outranks_one_spread_across_fields() {
577 let entries = vec![
578 entry(
579 "git.add.all",
580 "git add -A",
581 "Stage every change",
582 &[],
583 Layer::Builtin,
584 ),
585 entry(
586 "git.status",
587 "git status",
588 "See what has changed",
589 &[],
590 Layer::Builtin,
591 ),
592 ];
593 assert_eq!(order(&entries, &HashMap::new(), "git st")[0], "git.status");
594 }
595
596 #[test]
597 fn a_command_that_begins_with_the_query_comes_first() {
598 let entries = vec![
599 entry(
600 "git.cherry-pick",
601 "git cherry-pick <commit>",
602 "Copy one commit",
603 &[],
604 Layer::Builtin,
605 ),
606 entry(
607 "git.commit",
608 "git commit -m \"<message>\"",
609 "Record the staged changes",
610 &[],
611 Layer::Builtin,
612 ),
613 ];
614 assert_eq!(
615 order(&entries, &HashMap::new(), "git commit")[0],
616 "git.commit"
617 );
618 }
619
620 #[test]
624 fn the_plainer_command_wins_a_tie() {
625 let entries = vec![
626 entry(
627 "git.stash",
628 "git stash push -u",
629 "Put changes aside",
630 &[],
631 Layer::Builtin,
632 ),
633 entry(
634 "git.status",
635 "git status",
636 "See what has changed",
637 &[],
638 Layer::Builtin,
639 ),
640 ];
641 assert_eq!(order(&entries, &HashMap::new(), "git st")[0], "git.status");
642 }
643
644 #[test]
645 fn a_used_command_still_beats_a_shorter_one() {
646 let entries = vec![
647 entry(
648 "git.stash",
649 "git stash push -u",
650 "Put changes aside",
651 &[],
652 Layer::Builtin,
653 ),
654 entry(
655 "git.status",
656 "git status",
657 "See what has changed",
658 &[],
659 Layer::Builtin,
660 ),
661 ];
662 let scores = scored(&[("git.stash", 1.0)]);
663 assert_eq!(order(&entries, &scores, "git st")[0], "git.stash");
664 }
665
666 #[test]
670 fn everyday_queries_find_the_everyday_command_first() {
671 let entries = crate::store::definitions::load(None).unwrap();
672 let candidates: Vec<Candidate<'_>> = entries
673 .iter()
674 .filter_map(|entry| {
675 entry
676 .cmd_for(crate::model::ShellFamily::Posix)
677 .map(|cmd| Candidate { entry, cmd })
678 })
679 .collect();
680
681 let expectations = [
682 ("deleted", "git.log.pickaxe"),
683 ("git st", "git.status"),
684 ("git push", "git.push"),
685 ("git commit", "git.commit"),
686 ("docker ps", "docker.ps"),
687 ("docker logs", "docker.logs"),
688 ("kubectl logs", "k8s.logs.follow"),
689 ("disk", "sys.disk.free"),
690 ];
691
692 for (query, expected) in expectations {
693 let first = rank(&candidates, &HashMap::new(), query)
694 .first()
695 .map(|&index| candidates[index].entry.id.as_str());
696 assert_eq!(first, Some(expected), "query {query:?}");
697 }
698 }
699
700 #[test]
701 fn ordering_is_stable_when_nothing_distinguishes_entries() {
702 let entries = sample();
703 let first = order(&entries, &HashMap::new(), "");
704 assert_eq!(first, order(&entries, &HashMap::new(), ""));
705 }
706
707 #[test]
708 fn highlight_marks_the_term_it_found() {
709 assert_eq!(highlight("docker ps", "ps"), [7, 8]);
710 assert_eq!(highlight("docker ps", "docker"), [0, 1, 2, 3, 4, 5]);
711 assert!(highlight("docker ps", "").is_empty());
712 }
713
714 #[test]
715 fn highlight_marks_nothing_it_did_not_match() {
716 assert!(highlight("docker ps", "dps").is_empty());
717 }
718
719 #[test]
723 #[ignore = "measurement, not a pass or fail"]
724 fn measure_ranking_cost_at_scale() {
725 let entries: Vec<Entry> = (0..2000)
726 .map(|n| {
727 entry(
728 &format!("ns{}.entry{n}", n % 20),
729 &format!("kubectl get pods -n namespace{n} -o wide --context cluster{n}"),
730 &format!("List pods in namespace {n} with node and address columns"),
731 &["kubernetes", "kubectl", "pods"],
732 Layer::Builtin,
733 )
734 })
735 .collect();
736
737 let candidates = candidates(&entries);
738 let scores = HashMap::new();
739
740 for query in ["", "k", "ku", "kub", "kube", "pods", "get pods", "zzz"] {
741 let started = std::time::Instant::now();
742 let ranked = rank(&candidates, &scores, query);
743 println!(
744 "query {:>9?}: {:>5} matches in {:>8.3?}",
745 query,
746 ranked.len(),
747 started.elapsed()
748 );
749 }
750 }
751}