Skip to main content

aster/
engine.rs

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    // Mid-line replacement requires shell-aware token ranges. Abstain until that
36    // parser exists rather than risking corruption of the user's command.
37    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}