1use std::collections::HashSet;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::thread;
7
8use anyhow::{Result, bail};
9
10use crate::commands::CommandCatalog;
11use crate::config::{AcceptMode, Settings};
12use crate::protocol::{Candidate, CandidateKind, CandidateSource, CompletionResponse};
13use crate::store::Store;
14
15const MAX_BUFFER_BYTES: usize = 64 * 1024;
16const FUZZY_HISTORY_LIMIT: usize = 4096;
17const MAX_DIRECTORY_ENTRIES: usize = 1024;
18
19pub fn complete(
20 store: &Store,
21 commands: &CommandCatalog,
22 buffer: &str,
23 cursor_byte: usize,
24 cwd: &str,
25 requested_limit: Option<usize>,
26 settings: &Settings,
27) -> Result<CompletionResponse> {
28 if buffer.len() > MAX_BUFFER_BYTES {
29 bail!("completion buffer exceeds {MAX_BUFFER_BYTES} bytes");
30 }
31 if cursor_byte > buffer.len() || !buffer.is_char_boundary(cursor_byte) {
32 bail!("cursor is not a valid UTF-8 byte offset");
33 }
34
35 if cursor_byte != buffer.len() || buffer.is_empty() {
38 return Ok(CompletionResponse::empty(cursor_byte));
39 }
40
41 let limit = requested_limit
42 .unwrap_or(settings.completion.max_candidates)
43 .min(settings.completion.max_candidates);
44 let history =
45 store.history_candidates(buffer, cwd, limit, settings.history.successful_first)?;
46
47 let mut candidates: Vec<_> = history
48 .into_iter()
49 .filter_map(|history| {
50 let command = history.command;
51 let suffix = command.strip_prefix(buffer)?;
52 if suffix.is_empty() {
53 return None;
54 }
55 let insert_text = suffix.to_owned();
56 let accept_text = match settings.completion.accept {
57 AcceptMode::Segment => next_segment(suffix),
58 AcceptMode::Full => insert_text.clone(),
59 };
60 Some(Candidate {
61 display: sanitize_display(&command),
62 description: history_description(history.uses, history.same_cwd),
63 description_pending: false,
64 kind: CandidateKind::History,
65 insert_text,
66 accept_text,
67 source: CandidateSource::History,
68 })
69 })
70 .collect();
71
72 if candidates.len() < limit && valid_command_prefix(buffer) {
73 let remaining = limit - candidates.len();
74 let command_candidates: Vec<_> = commands
75 .matching(buffer, limit)
76 .into_iter()
77 .filter(|entry| {
78 !candidates
79 .iter()
80 .any(|candidate| candidate.display == entry.name)
81 })
82 .take(remaining)
83 .map(|entry| {
84 let suffix = entry.name.strip_prefix(buffer).unwrap_or_default();
85 let insertion = if suffix.is_empty() { " " } else { suffix };
86 Candidate {
87 display: entry.name.clone(),
88 description: entry.description.clone(),
89 description_pending: entry.description_pending,
90 kind: CandidateKind::Command,
91 insert_text: insertion.to_owned(),
92 accept_text: insertion.to_owned(),
93 source: CandidateSource::Command,
94 }
95 })
96 .collect();
97 candidates.extend(command_candidates);
98 }
99
100 let mut enrichment_pending = false;
101 if let Some((command, prefix)) = option_context(buffer) {
102 let options = commands.matching_options(command, prefix, limit);
103 enrichment_pending = options.pending;
104 let option_candidates: Vec<_> = options
105 .entries
106 .into_iter()
107 .filter_map(|option| {
108 let suffix = option.spelling.strip_prefix(prefix)?;
109 if suffix.is_empty() {
110 return None;
111 }
112 Some(Candidate {
113 display: format!("{buffer}{suffix}"),
114 description: option.description,
115 description_pending: false,
116 kind: CandidateKind::Option,
117 insert_text: suffix.to_owned(),
118 accept_text: suffix.to_owned(),
119 source: CandidateSource::Help,
120 })
121 })
122 .collect();
123 if !option_candidates.is_empty() {
124 let option_slots = option_candidates.len().min((limit / 2).max(1));
125 candidates.truncate(limit.saturating_sub(option_slots));
126 candidates.extend(option_candidates.into_iter().take(option_slots));
127 }
128 }
129
130 Ok(CompletionResponse {
131 replace_start_byte: cursor_byte,
132 replace_end_byte: cursor_byte,
133 candidates,
134 enrichment_pending,
135 })
136}
137
138pub fn fuzzy(
139 store: &Store,
140 commands: &CommandCatalog,
141 query: &str,
142 cwd: &str,
143 requested_limit: Option<usize>,
144 settings: &Settings,
145) -> Result<CompletionResponse> {
146 if query.len() > MAX_BUFFER_BYTES {
147 bail!("fuzzy query exceeds {MAX_BUFFER_BYTES} bytes");
148 }
149 let limit = requested_limit
150 .unwrap_or(settings.completion.max_candidates)
151 .min(settings.completion.max_candidates);
152 let mut seen = HashSet::new();
153 let mut pool = Vec::new();
154
155 for history in
156 store.history_inventory(cwd, FUZZY_HISTORY_LIMIT, settings.history.successful_first)?
157 {
158 if seen.insert(history.command.clone()) {
159 pool.push(Candidate {
160 display: sanitize_display(&history.command),
161 description: history_description(history.uses, history.same_cwd),
162 description_pending: false,
163 kind: CandidateKind::History,
164 insert_text: history.command.clone(),
165 accept_text: history.command,
166 source: CandidateSource::History,
167 });
168 }
169 }
170 for command in commands.inventory() {
171 if seen.insert(command.name.clone()) {
172 pool.push(Candidate {
173 display: command.name.clone(),
174 description: command.description,
175 description_pending: false,
176 kind: CandidateKind::Command,
177 insert_text: command.name.clone(),
178 accept_text: command.name,
179 source: CandidateSource::Command,
180 });
181 }
182 }
183
184 let indexes = fzf_indexes(&pool, query, limit)?;
185 let mut candidates = Vec::with_capacity(indexes.len());
186 for index in indexes {
187 let mut candidate = pool[index].clone();
188 if candidate.source == CandidateSource::Command
189 && let Some(command) = commands
190 .matching(&candidate.display, 1)
191 .into_iter()
192 .find(|command| command.name == candidate.display)
193 {
194 candidate.description = command.description;
195 candidate.description_pending = command.description_pending;
196 }
197 candidates.push(candidate);
198 }
199 Ok(CompletionResponse {
200 replace_start_byte: 0,
201 replace_end_byte: 0,
202 candidates,
203 enrichment_pending: false,
204 })
205}
206
207pub fn filesystem_candidates(
208 buffer: &str,
209 cursor_byte: usize,
210 cwd: &str,
211 limit: usize,
212) -> Result<Vec<Candidate>> {
213 if limit == 0 || cursor_byte != buffer.len() || !buffer.is_char_boundary(cursor_byte) {
214 return Ok(Vec::new());
215 }
216 let Some(argument_start) = current_argument_start(buffer) else {
217 return Ok(Vec::new());
218 };
219 let Some(token) = unescape_path_token(&buffer[argument_start..]) else {
220 return Ok(Vec::new());
221 };
222 if token.starts_with('-') {
223 return Ok(Vec::new());
224 }
225
226 let (directory_text, name_prefix) = token
227 .rfind('/')
228 .map_or(("", token.as_str()), |slash| token.split_at(slash + 1));
229 let directory = resolve_directory(directory_text, cwd);
230 let Ok(children) = fs::read_dir(&directory) else {
231 return Ok(Vec::new());
232 };
233 let show_hidden = name_prefix.starts_with('.');
234 let mut matches = Vec::new();
235 for child in children.take(MAX_DIRECTORY_ENTRIES).flatten() {
236 let Some(name) = child.file_name().to_str().map(str::to_owned) else {
237 continue;
238 };
239 if name.is_empty()
240 || (!show_hidden && name.starts_with('.'))
241 || !name.starts_with(name_prefix)
242 || name.chars().any(char::is_control)
243 {
244 continue;
245 }
246 let Ok(file_type) = child.file_type() else {
247 continue;
248 };
249 if !(file_type.is_dir() || file_type.is_file() || file_type.is_symlink()) {
250 continue;
251 }
252 let is_directory = file_type.is_dir() || (file_type.is_symlink() && child.path().is_dir());
253 let suffix = &name[name_prefix.len()..];
254 let mut insert_text = escape_path_suffix(suffix);
255 if token.is_empty() && name.starts_with('-') {
256 insert_text.insert_str(0, "./");
257 }
258 if is_directory {
259 insert_text.push('/');
260 }
261 if insert_text.is_empty() {
262 continue;
263 }
264 matches.push((is_directory, name, insert_text));
265 }
266 matches.sort_unstable_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
267 Ok(matches
268 .into_iter()
269 .take(limit)
270 .map(|(is_directory, _, insert_text)| Candidate {
271 display: format!("{buffer}{insert_text}"),
272 description: if is_directory { "Directory" } else { "File" }.to_owned(),
273 description_pending: false,
274 kind: if is_directory {
275 CandidateKind::Directory
276 } else {
277 CandidateKind::File
278 },
279 accept_text: next_segment(&insert_text),
280 insert_text,
281 source: CandidateSource::Filesystem,
282 })
283 .collect())
284}
285
286pub fn merge_filesystem_candidates(
287 response: &mut CompletionResponse,
288 mut paths: Vec<Candidate>,
289 limit: usize,
290) {
291 if paths.is_empty() || limit == 0 {
292 return;
293 }
294 let history_count = response
295 .candidates
296 .iter()
297 .take_while(|candidate| candidate.source == CandidateSource::History)
298 .count();
299 let path_slots = paths.len().min((limit / 2).max(1));
300 if paths.len() > path_slots
301 && let Some(first) = paths.first_mut()
302 {
303 first.description.push_str(" (more matches)");
304 }
305 let history_keep = history_count.min(limit.saturating_sub(path_slots));
306 let mut original = std::mem::take(&mut response.candidates);
307 let trailing = original.split_off(history_count);
308 let history = original;
309 let mut seen = HashSet::new();
310 for path in paths {
311 if response.candidates.len() >= path_slots {
312 break;
313 }
314 if seen.insert(path.display.clone()) {
315 response.candidates.push(path);
316 }
317 }
318 for candidate in history.into_iter().take(history_keep) {
319 if response.candidates.len() >= limit {
320 break;
321 }
322 if seen.insert(candidate.display.clone()) {
323 response.candidates.push(candidate);
324 }
325 }
326 for candidate in trailing {
327 if response.candidates.len() >= limit {
328 break;
329 }
330 if seen.insert(candidate.display.clone()) {
331 response.candidates.push(candidate);
332 }
333 }
334}
335
336fn resolve_directory(directory_text: &str, cwd: &str) -> PathBuf {
337 if directory_text == "~/" {
338 return std::env::var_os("HOME")
339 .map(PathBuf::from)
340 .unwrap_or_else(|| PathBuf::from(cwd));
341 }
342 if let Some(relative) = directory_text.strip_prefix("~/") {
343 return std::env::var_os("HOME")
344 .map(PathBuf::from)
345 .unwrap_or_else(|| PathBuf::from(cwd))
346 .join(relative);
347 }
348 let directory = Path::new(directory_text);
349 if directory.is_absolute() {
350 directory.to_owned()
351 } else {
352 Path::new(cwd).join(directory)
353 }
354}
355
356fn unescape_path_token(value: &str) -> Option<String> {
357 let mut unescaped = String::new();
358 let mut escaped = false;
359 for character in value.chars() {
360 if escaped {
361 unescaped.push(character);
362 escaped = false;
363 } else if character == '\\' {
364 escaped = true;
365 } else if character.is_control() || "'\";&|><$`(){}[]!*?".contains(character) {
366 return None;
367 } else {
368 unescaped.push(character);
369 }
370 }
371 (!escaped).then_some(unescaped)
372}
373
374fn current_argument_start(buffer: &str) -> Option<usize> {
375 let mut start = None;
376 let mut escaped = false;
377 for (index, character) in buffer.char_indices() {
378 if escaped {
379 escaped = false;
380 } else if character == '\\' {
381 escaped = true;
382 } else if character.is_whitespace() {
383 start = Some(index + character.len_utf8());
384 }
385 }
386 start
387}
388
389fn escape_path_suffix(value: &str) -> String {
390 let mut escaped = String::new();
391 for character in value.chars() {
392 if character.is_ascii()
393 && !(character.is_ascii_alphanumeric() || "_-+.@%,".contains(character))
394 {
395 escaped.push('\\');
396 }
397 escaped.push(character);
398 }
399 escaped
400}
401
402fn option_context(buffer: &str) -> Option<(&str, &str)> {
403 if buffer
404 .chars()
405 .any(|character| character.is_control() || "'\"\\;&|><$`(){}[]!*?".contains(character))
406 {
407 return None;
408 }
409 let argument_start = buffer.rfind(char::is_whitespace)? + 1;
410 let prefix = &buffer[argument_start..];
411 if !prefix.starts_with('-') {
412 return None;
413 }
414 let mut words = buffer[..argument_start].split_ascii_whitespace();
415 let command = words.next()?;
416 if !valid_command_prefix(command) || words.any(|word| !word.starts_with('-') || word == "--") {
417 return None;
418 }
419 Some((command, prefix))
420}
421
422fn fzf_indexes(candidates: &[Candidate], query: &str, limit: usize) -> Result<Vec<usize>> {
423 if candidates.is_empty() || limit == 0 {
424 return Ok(Vec::new());
425 }
426 let mut input = Vec::new();
427 for (index, candidate) in candidates.iter().enumerate() {
428 write!(input, "{index}\t{}\0", candidate.display)?;
429 }
430
431 let mut child = Command::new("fzf")
432 .args([
433 "--read0",
434 "--print0",
435 "--no-multi",
436 "--delimiter=\\t",
437 "--nth=2..",
438 "--tiebreak=index",
439 "--filter",
440 query,
441 ])
442 .env_remove("FZF_DEFAULT_OPTS")
443 .env_remove("FZF_DEFAULT_OPTS_FILE")
444 .stdin(Stdio::piped())
445 .stdout(Stdio::piped())
446 .stderr(Stdio::null())
447 .spawn()?;
448 let mut stdin = child.stdin.take().expect("fzf stdin is piped");
449 let writer = thread::spawn(move || stdin.write_all(&input));
450 let output = child.wait_with_output()?;
451 writer.join().expect("fzf input writer panicked")?;
452 if output.status.code() == Some(1) {
453 return Ok(Vec::new());
454 }
455 if !output.status.success() {
456 bail!("fzf fuzzy filter failed with {}", output.status);
457 }
458
459 let mut indexes = Vec::new();
460 for record in output.stdout.split(|byte| *byte == 0) {
461 if record.is_empty() || indexes.len() >= limit {
462 continue;
463 }
464 let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
465 continue;
466 };
467 let index = std::str::from_utf8(&record[..tab])?.parse::<usize>()?;
468 if index < candidates.len() {
469 indexes.push(index);
470 }
471 }
472 Ok(indexes)
473}
474
475fn valid_command_prefix(buffer: &str) -> bool {
476 !buffer.is_empty()
477 && !buffer.starts_with('.')
478 && buffer
479 .bytes()
480 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+'))
481}
482
483fn history_description(uses: usize, same_cwd: bool) -> String {
484 match (uses, same_cwd) {
485 (1, true) => "used here".to_owned(),
486 (1, false) => "used once".to_owned(),
487 (uses, true) => format!("used {uses}x, here"),
488 (uses, false) => format!("used {uses}x"),
489 }
490}
491
492fn sanitize_display(value: &str) -> String {
493 let mut display = String::with_capacity(value.len());
494 for character in value.chars() {
495 if character.is_control() {
496 display.extend(character.escape_default());
497 } else {
498 display.push(character);
499 }
500 }
501 display
502}
503
504pub fn next_segment(suffix: &str) -> String {
505 let mut saw_non_whitespace = false;
506 for (index, character) in suffix.char_indices() {
507 let end = index + character.len_utf8();
508 if character.is_whitespace() {
509 if saw_non_whitespace {
510 return suffix[..end].to_owned();
511 }
512 continue;
513 }
514 saw_non_whitespace = true;
515 if matches!(character, '/' | '=' | ':' | ',') {
516 return suffix[..end].to_owned();
517 }
518 }
519 suffix.to_owned()
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::commands::{CommandCatalog, CommandEntry, OptionMatch};
526 use crate::config::Settings;
527 use crate::store::Store;
528 use tempfile::tempdir;
529
530 #[test]
531 fn accepts_one_path_segment() {
532 assert_eq!(next_segment("ev/gitrepos/aster"), "ev/");
533 }
534
535 #[test]
536 fn accepts_one_shell_word() {
537 assert_eq!(next_segment(" checkout feature/topic"), " checkout ");
538 }
539
540 #[test]
541 fn accepts_remaining_text_without_boundary() {
542 assert_eq!(next_segment("status"), "status");
543 }
544
545 #[test]
546 fn escapes_control_characters_in_display_text() {
547 assert_eq!(sanitize_display("echo\t\u{1b}"), "echo\\t\\u{1b}");
548 }
549
550 #[test]
551 fn completes_filesystem_entries_at_argument_positions() {
552 let directory = tempdir().unwrap();
553 fs::create_dir(directory.path().join("alpha-dir")).unwrap();
554 fs::write(directory.path().join("alpha-file"), "file").unwrap();
555 fs::write(directory.path().join("alpha space"), "file").unwrap();
556 fs::write(directory.path().join(".hidden"), "file").unwrap();
557 fs::write(directory.path().join("-rf"), "file").unwrap();
558 fs::write(directory.path().join("=command"), "file").unwrap();
559 fs::create_dir(directory.path().join("space dir")).unwrap();
560 fs::write(directory.path().join("space dir/child"), "file").unwrap();
561
562 let buffer = "scp -r alpha";
563 let candidates =
564 filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
565 .unwrap();
566 assert_eq!(candidates[0].display, "scp -r alpha-dir/");
567 assert_eq!(candidates[0].kind, CandidateKind::Directory);
568 assert!(candidates.iter().any(|candidate| {
569 candidate.display == "scp -r alpha-file" && candidate.kind == CandidateKind::File
570 }));
571 assert!(
572 candidates
573 .iter()
574 .any(|candidate| candidate.display == "scp -r alpha\\ space")
575 );
576 assert!(
577 !candidates
578 .iter()
579 .any(|candidate| candidate.display.contains(".hidden"))
580 );
581
582 let nested = "scp -r space\\ dir/ch";
583 let candidates =
584 filesystem_candidates(nested, nested.len(), directory.path().to_str().unwrap(), 10)
585 .unwrap();
586 assert_eq!(candidates[0].display, "scp -r space\\ dir/child");
587
588 let blank = "scp -r ";
589 let candidates =
590 filesystem_candidates(blank, blank.len(), directory.path().to_str().unwrap(), 20)
591 .unwrap();
592 assert!(
593 candidates
594 .iter()
595 .any(|candidate| candidate.display == "scp -r ./-rf")
596 );
597 assert!(
598 candidates
599 .iter()
600 .any(|candidate| candidate.display == "scp -r \\=command")
601 );
602 }
603
604 #[test]
605 fn lists_paths_after_an_empty_argument_and_hides_them_in_command_position() {
606 let directory = tempdir().unwrap();
607 fs::write(directory.path().join("visible"), "file").unwrap();
608
609 let buffer = "command ";
610 let candidates =
611 filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
612 .unwrap();
613 assert!(
614 candidates
615 .iter()
616 .any(|candidate| candidate.display == "command visible")
617 );
618 assert!(
619 filesystem_candidates("com", 3, directory.path().to_str().unwrap(), 10)
620 .unwrap()
621 .is_empty()
622 );
623 }
624
625 #[test]
626 fn filesystem_candidates_reserve_capacity_after_history() {
627 let mut response = CompletionResponse {
628 replace_start_byte: 5,
629 replace_end_byte: 5,
630 candidates: (0..4)
631 .map(|index| Candidate {
632 display: format!("cmd history-{index}"),
633 description: String::new(),
634 description_pending: false,
635 kind: CandidateKind::History,
636 insert_text: index.to_string(),
637 accept_text: index.to_string(),
638 source: CandidateSource::History,
639 })
640 .collect(),
641 enrichment_pending: false,
642 };
643 let paths = (0..2)
644 .map(|index| Candidate {
645 display: format!("cmd path-{index}"),
646 description: "File".to_owned(),
647 description_pending: false,
648 kind: CandidateKind::File,
649 insert_text: index.to_string(),
650 accept_text: index.to_string(),
651 source: CandidateSource::Filesystem,
652 })
653 .collect();
654 merge_filesystem_candidates(&mut response, paths, 4);
655 assert_eq!(response.candidates.len(), 4);
656 assert_eq!(response.candidates[0].source, CandidateSource::Filesystem);
657 assert_eq!(
658 response
659 .candidates
660 .iter()
661 .filter(|candidate| candidate.source == CandidateSource::Filesystem)
662 .count(),
663 2
664 );
665
666 let mut response = CompletionResponse::empty(0);
667 let paths = (0..2)
668 .map(|index| Candidate {
669 display: format!("cmd path-{index}"),
670 description: "File".to_owned(),
671 description_pending: false,
672 kind: CandidateKind::File,
673 insert_text: index.to_string(),
674 accept_text: index.to_string(),
675 source: CandidateSource::Filesystem,
676 })
677 .collect();
678 merge_filesystem_candidates(&mut response, paths, 1);
679 assert_eq!(response.candidates[0].description, "File (more matches)");
680 }
681
682 #[test]
683 fn recognizes_only_safe_root_option_contexts() {
684 assert_eq!(option_context("git --ver"), Some(("git", "--ver")));
685 assert_eq!(option_context("git status --short"), None);
686 assert_eq!(
687 option_context("git --quiet --short"),
688 Some(("git", "--short"))
689 );
690 assert_eq!(option_context("git -- --literal"), None);
691 assert_eq!(option_context("git \"--ver"), None);
692 assert_eq!(option_context("--ver"), None);
693 }
694
695 #[test]
696 fn completes_cached_root_command_options() {
697 let store = Store::in_memory().unwrap();
698 let commands = CommandCatalog::from_options(
699 "tool",
700 vec![
701 OptionMatch {
702 spelling: "--verbose".to_owned(),
703 description: "Show verbose output".to_owned(),
704 },
705 OptionMatch {
706 spelling: "--version".to_owned(),
707 description: "Print version".to_owned(),
708 },
709 ],
710 );
711 let completion = complete(
712 &store,
713 &commands,
714 "tool --ver",
715 "tool --ver".len(),
716 "/repo",
717 None,
718 &Settings::default(),
719 )
720 .unwrap();
721 assert_eq!(completion.candidates.len(), 2);
722 assert_eq!(completion.candidates[0].display, "tool --verbose");
723 assert_eq!(completion.candidates[0].kind, CandidateKind::Option);
724 assert_eq!(completion.candidates[0].source, CandidateSource::Help);
725 assert_eq!(completion.candidates[1].display, "tool --version");
726 }
727
728 #[test]
729 fn describes_history_usage_and_directory() {
730 assert_eq!(history_description(1, true), "used here");
731 assert_eq!(history_description(3, true), "used 3x, here");
732 assert_eq!(history_description(2, false), "used 2x");
733 }
734
735 #[test]
736 fn completes_history_with_a_single_segment() {
737 let store = Store::in_memory().unwrap();
738 let mut settings = Settings::default();
739 settings.completion.accept = AcceptMode::Segment;
740 store
741 .record("cd ~/dev/gitrepos/aster", "/repo", 0, 100, "test", true)
742 .unwrap();
743
744 let completion = complete(
745 &store,
746 &CommandCatalog::default(),
747 "cd ~/d",
748 "cd ~/d".len(),
749 "/repo",
750 None,
751 &settings,
752 )
753 .unwrap();
754
755 assert_eq!(completion.candidates.len(), 1);
756 assert_eq!(completion.candidates[0].insert_text, "ev/gitrepos/aster");
757 assert_eq!(completion.candidates[0].accept_text, "ev/");
758 assert_eq!(completion.candidates[0].description, "used here");
759
760 let completion = complete(
761 &store,
762 &CommandCatalog::default(),
763 "cd ~/d",
764 "cd ~/d".len(),
765 "/repo",
766 None,
767 &Settings::default(),
768 )
769 .unwrap();
770 assert_eq!(completion.candidates[0].accept_text, "ev/gitrepos/aster");
771 }
772
773 #[test]
774 fn fuzzy_searches_history_without_a_prefix() {
775 if Command::new("fzf").arg("--version").output().is_err() {
776 return;
777 }
778 let store = Store::in_memory().unwrap();
779 store
780 .record("cargo test --all", "/repo", 0, 100, "test", true)
781 .unwrap();
782 let completion = fuzzy(
783 &store,
784 &CommandCatalog::default(),
785 "cgt",
786 "/repo",
787 None,
788 &Settings::default(),
789 )
790 .unwrap();
791 assert_eq!(completion.candidates[0].display, "cargo test --all");
792 }
793
794 #[test]
795 fn abstains_from_mid_line_completion() {
796 let store = Store::in_memory().unwrap();
797 let completion = complete(
798 &store,
799 &CommandCatalog::default(),
800 "git status",
801 3,
802 "/repo",
803 None,
804 &Settings::default(),
805 )
806 .unwrap();
807 assert!(completion.candidates.is_empty());
808 }
809
810 #[test]
811 fn discovers_commands_when_history_abstains() {
812 let store = Store::in_memory().unwrap();
813 let commands = CommandCatalog::from_entries([CommandEntry {
814 name: "atlas".to_owned(),
815 description: "CLI tool to manage MongoDB Atlas".to_owned(),
816 }]);
817
818 let completion = complete(
819 &store,
820 &commands,
821 "atl",
822 3,
823 "/repo",
824 None,
825 &Settings::default(),
826 )
827 .unwrap();
828
829 assert_eq!(completion.candidates[0].display, "atlas");
830 assert_eq!(completion.candidates[0].accept_text, "as");
831 assert_eq!(completion.candidates[0].kind, CandidateKind::Command);
832 }
833
834 #[test]
835 fn history_precedes_command_inventory() {
836 let store = Store::in_memory().unwrap();
837 store
838 .record("git status", "/repo", 0, 100, "test", true)
839 .unwrap();
840 let commands = CommandCatalog::from_entries([CommandEntry {
841 name: "git-town".to_owned(),
842 description: "Git workflow automation".to_owned(),
843 }]);
844
845 let completion = complete(
846 &store,
847 &commands,
848 "git",
849 3,
850 "/repo",
851 None,
852 &Settings::default(),
853 )
854 .unwrap();
855
856 assert_eq!(completion.candidates[0].source, CandidateSource::History);
857 assert_eq!(completion.candidates[1].source, CandidateSource::Command);
858 }
859
860 #[test]
861 fn history_deduplicates_command_inventory() {
862 let store = Store::in_memory().unwrap();
863 store
864 .record("atlas", "/repo", 0, 100, "test", true)
865 .unwrap();
866 let commands = CommandCatalog::from_entries([
867 CommandEntry {
868 name: "atlas".to_owned(),
869 description: "CLI tool to manage MongoDB Atlas".to_owned(),
870 },
871 CommandEntry {
872 name: "atlantis".to_owned(),
873 description: "Terraform pull request automation".to_owned(),
874 },
875 ]);
876
877 let completion = complete(
878 &store,
879 &commands,
880 "atl",
881 3,
882 "/repo",
883 None,
884 &Settings::default(),
885 )
886 .unwrap();
887
888 assert_eq!(completion.candidates.len(), 2);
889 assert_eq!(completion.candidates[0].display, "atlas");
890 assert_eq!(completion.candidates[0].source, CandidateSource::History);
891 assert_eq!(completion.candidates[1].display, "atlantis");
892 }
893}