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 candidate in history.into_iter().take(history_keep) {
311        if response.candidates.len() >= limit {
312            break;
313        }
314        if seen.insert(candidate.display.clone()) {
315            response.candidates.push(candidate);
316        }
317    }
318    let path_limit = response.candidates.len().saturating_add(path_slots);
319    for path in paths {
320        if response.candidates.len() >= path_limit || response.candidates.len() >= limit {
321            break;
322        }
323        if seen.insert(path.display.clone()) {
324            response.candidates.push(path);
325        }
326    }
327    for candidate in trailing {
328        if response.candidates.len() >= limit {
329            break;
330        }
331        if seen.insert(candidate.display.clone()) {
332            response.candidates.push(candidate);
333        }
334    }
335}
336
337fn resolve_directory(directory_text: &str, cwd: &str) -> PathBuf {
338    if directory_text == "~/" {
339        return std::env::var_os("HOME")
340            .map(PathBuf::from)
341            .unwrap_or_else(|| PathBuf::from(cwd));
342    }
343    if let Some(relative) = directory_text.strip_prefix("~/") {
344        return std::env::var_os("HOME")
345            .map(PathBuf::from)
346            .unwrap_or_else(|| PathBuf::from(cwd))
347            .join(relative);
348    }
349    let directory = Path::new(directory_text);
350    if directory.is_absolute() {
351        directory.to_owned()
352    } else {
353        Path::new(cwd).join(directory)
354    }
355}
356
357fn unescape_path_token(value: &str) -> Option<String> {
358    let mut unescaped = String::new();
359    let mut escaped = false;
360    for character in value.chars() {
361        if escaped {
362            unescaped.push(character);
363            escaped = false;
364        } else if character == '\\' {
365            escaped = true;
366        } else if character.is_control() || "'\";&|><$`(){}[]!*?".contains(character) {
367            return None;
368        } else {
369            unescaped.push(character);
370        }
371    }
372    (!escaped).then_some(unescaped)
373}
374
375fn current_argument_start(buffer: &str) -> Option<usize> {
376    let mut start = None;
377    let mut escaped = false;
378    for (index, character) in buffer.char_indices() {
379        if escaped {
380            escaped = false;
381        } else if character == '\\' {
382            escaped = true;
383        } else if character.is_whitespace() {
384            start = Some(index + character.len_utf8());
385        }
386    }
387    start
388}
389
390fn escape_path_suffix(value: &str) -> String {
391    let mut escaped = String::new();
392    for character in value.chars() {
393        if character.is_ascii()
394            && !(character.is_ascii_alphanumeric() || "_-+.@%,".contains(character))
395        {
396            escaped.push('\\');
397        }
398        escaped.push(character);
399    }
400    escaped
401}
402
403fn option_context(buffer: &str) -> Option<(&str, &str)> {
404    if buffer
405        .chars()
406        .any(|character| character.is_control() || "'\"\\;&|><$`(){}[]!*?".contains(character))
407    {
408        return None;
409    }
410    let argument_start = buffer.rfind(char::is_whitespace)? + 1;
411    let prefix = &buffer[argument_start..];
412    if !prefix.starts_with('-') {
413        return None;
414    }
415    let mut words = buffer[..argument_start].split_ascii_whitespace();
416    let command = words.next()?;
417    if !valid_command_prefix(command) || words.any(|word| !word.starts_with('-') || word == "--") {
418        return None;
419    }
420    Some((command, prefix))
421}
422
423fn fzf_indexes(candidates: &[Candidate], query: &str, limit: usize) -> Result<Vec<usize>> {
424    if candidates.is_empty() || limit == 0 {
425        return Ok(Vec::new());
426    }
427    let mut input = Vec::new();
428    for (index, candidate) in candidates.iter().enumerate() {
429        write!(input, "{index}\t{}\0", candidate.display)?;
430    }
431
432    let mut child = Command::new("fzf")
433        .args([
434            "--read0",
435            "--print0",
436            "--no-multi",
437            "--delimiter=\\t",
438            "--nth=2..",
439            "--tiebreak=index",
440            "--filter",
441            query,
442        ])
443        .env_remove("FZF_DEFAULT_OPTS")
444        .env_remove("FZF_DEFAULT_OPTS_FILE")
445        .stdin(Stdio::piped())
446        .stdout(Stdio::piped())
447        .stderr(Stdio::null())
448        .spawn()?;
449    let mut stdin = child.stdin.take().expect("fzf stdin is piped");
450    let writer = thread::spawn(move || stdin.write_all(&input));
451    let output = child.wait_with_output()?;
452    writer.join().expect("fzf input writer panicked")?;
453    if output.status.code() == Some(1) {
454        return Ok(Vec::new());
455    }
456    if !output.status.success() {
457        bail!("fzf fuzzy filter failed with {}", output.status);
458    }
459
460    let mut indexes = Vec::new();
461    for record in output.stdout.split(|byte| *byte == 0) {
462        if record.is_empty() || indexes.len() >= limit {
463            continue;
464        }
465        let Some(tab) = record.iter().position(|byte| *byte == b'\t') else {
466            continue;
467        };
468        let index = std::str::from_utf8(&record[..tab])?.parse::<usize>()?;
469        if index < candidates.len() {
470            indexes.push(index);
471        }
472    }
473    Ok(indexes)
474}
475
476fn valid_command_prefix(buffer: &str) -> bool {
477    !buffer.is_empty()
478        && !buffer.starts_with('.')
479        && buffer
480            .bytes()
481            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'+'))
482}
483
484fn history_description(uses: usize, same_cwd: bool) -> String {
485    match (uses, same_cwd) {
486        (1, true) => "used here".to_owned(),
487        (1, false) => "used once".to_owned(),
488        (uses, true) => format!("used {uses}x, here"),
489        (uses, false) => format!("used {uses}x"),
490    }
491}
492
493fn sanitize_display(value: &str) -> String {
494    let mut display = String::with_capacity(value.len());
495    for character in value.chars() {
496        if character.is_control() {
497            display.extend(character.escape_default());
498        } else {
499            display.push(character);
500        }
501    }
502    display
503}
504
505pub fn next_segment(suffix: &str) -> String {
506    let mut saw_non_whitespace = false;
507    let mut escaped = false;
508    let mut quote = None;
509    let mut bracket_depth: usize = 0;
510    let mut characters = suffix.char_indices().peekable();
511    while let Some((index, character)) = characters.next() {
512        let end = index + character.len_utf8();
513        if escaped {
514            escaped = false;
515            saw_non_whitespace = true;
516            continue;
517        }
518        if character == '\\' && quote != Some('\'') {
519            escaped = true;
520            saw_non_whitespace = true;
521            continue;
522        }
523        if matches!(character, '\'' | '"') {
524            if quote == Some(character) {
525                quote = None;
526            } else if quote.is_none() {
527                quote = Some(character);
528            }
529            saw_non_whitespace = true;
530            continue;
531        }
532        if quote.is_some() {
533            saw_non_whitespace = true;
534            continue;
535        }
536        if character.is_whitespace() {
537            if saw_non_whitespace {
538                return suffix[..end].to_owned();
539            }
540            continue;
541        }
542        saw_non_whitespace = true;
543        match character {
544            '[' => bracket_depth += 1,
545            ']' => bracket_depth = bracket_depth.saturating_sub(1),
546            '@' | '=' | ',' if bracket_depth == 0 => return suffix[..end].to_owned(),
547            ':' if bracket_depth == 0 => {
548                let mut boundary = end;
549                while let Some((next_index, next)) = characters.peek().copied() {
550                    if !matches!(next, ':' | '/') {
551                        break;
552                    }
553                    characters.next();
554                    boundary = next_index + next.len_utf8();
555                }
556                return suffix[..boundary].to_owned();
557            }
558            '/' if bracket_depth == 0 => {
559                let mut boundary = end;
560                while let Some((next_index, '/')) = characters.peek().copied() {
561                    characters.next();
562                    boundary = next_index + 1;
563                }
564                return suffix[..boundary].to_owned();
565            }
566            _ => {}
567        }
568    }
569    suffix.to_owned()
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use crate::commands::{CommandCatalog, CommandEntry, OptionMatch};
576    use crate::config::Settings;
577    use crate::store::Store;
578    use tempfile::tempdir;
579
580    #[test]
581    fn accepts_one_path_segment() {
582        assert_eq!(next_segment("ev/gitrepos/aster"), "ev/");
583    }
584
585    #[test]
586    fn accepts_one_shell_word() {
587        assert_eq!(next_segment(" checkout feature/topic"), " checkout ");
588    }
589
590    #[test]
591    fn accepts_remaining_text_without_boundary() {
592        assert_eq!(next_segment("status"), "status");
593    }
594
595    #[test]
596    fn accepts_ssh_destinations_in_semantic_segments() {
597        assert_eq!(next_segment("lice@example.com"), "lice@");
598        assert_eq!(next_segment("example.com:/srv/app/file"), "example.com:/");
599        assert_eq!(next_segment("srv/app/file"), "srv/");
600    }
601
602    #[test]
603    fn accepts_common_structured_values_in_semantic_segments() {
604        assert_eq!(next_segment("output=value"), "output=");
605        assert_eq!(next_segment("https://example.com/path"), "https://");
606        assert_eq!(next_segment("host::module/path"), "host::");
607        assert_eq!(next_segment("one,two"), "one,");
608    }
609
610    #[test]
611    fn preserves_quoted_escaped_and_ipv6_separators() {
612        assert_eq!(next_segment("'user@host'/path"), "'user@host'/");
613        assert_eq!(next_segment("user\\@host/path"), "user\\@host/");
614        assert_eq!(next_segment("user@[2001:db8::1]:/srv/app"), "user@");
615        assert_eq!(next_segment("[2001:db8::1]:/srv/app"), "[2001:db8::1]:/");
616    }
617
618    #[test]
619    fn escapes_control_characters_in_display_text() {
620        assert_eq!(sanitize_display("echo\t\u{1b}"), "echo\\t\\u{1b}");
621    }
622
623    #[test]
624    fn completes_filesystem_entries_at_argument_positions() {
625        let directory = tempdir().unwrap();
626        fs::create_dir(directory.path().join("alpha-dir")).unwrap();
627        fs::write(directory.path().join("alpha-file"), "file").unwrap();
628        fs::write(directory.path().join("alpha space"), "file").unwrap();
629        fs::write(directory.path().join(".hidden"), "file").unwrap();
630        fs::write(directory.path().join("-rf"), "file").unwrap();
631        fs::write(directory.path().join("=command"), "file").unwrap();
632        fs::create_dir(directory.path().join("space dir")).unwrap();
633        fs::write(directory.path().join("space dir/child"), "file").unwrap();
634
635        let buffer = "scp -r alpha";
636        let candidates =
637            filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
638                .unwrap();
639        assert_eq!(candidates[0].display, "scp -r alpha-dir/");
640        assert_eq!(candidates[0].kind, CandidateKind::Directory);
641        assert!(candidates.iter().any(|candidate| {
642            candidate.display == "scp -r alpha-file" && candidate.kind == CandidateKind::File
643        }));
644        assert!(
645            candidates
646                .iter()
647                .any(|candidate| candidate.display == "scp -r alpha\\ space")
648        );
649        assert!(
650            !candidates
651                .iter()
652                .any(|candidate| candidate.display.contains(".hidden"))
653        );
654
655        let nested = "scp -r space\\ dir/ch";
656        let candidates =
657            filesystem_candidates(nested, nested.len(), directory.path().to_str().unwrap(), 10)
658                .unwrap();
659        assert_eq!(candidates[0].display, "scp -r space\\ dir/child");
660
661        let blank = "scp -r ";
662        let candidates =
663            filesystem_candidates(blank, blank.len(), directory.path().to_str().unwrap(), 20)
664                .unwrap();
665        assert!(
666            candidates
667                .iter()
668                .any(|candidate| candidate.display == "scp -r ./-rf")
669        );
670        assert!(
671            candidates
672                .iter()
673                .any(|candidate| candidate.display == "scp -r \\=command")
674        );
675    }
676
677    #[test]
678    fn lists_paths_after_an_empty_argument_and_hides_them_in_command_position() {
679        let directory = tempdir().unwrap();
680        fs::write(directory.path().join("visible"), "file").unwrap();
681
682        let buffer = "command ";
683        let candidates =
684            filesystem_candidates(buffer, buffer.len(), directory.path().to_str().unwrap(), 10)
685                .unwrap();
686        assert!(
687            candidates
688                .iter()
689                .any(|candidate| candidate.display == "command visible")
690        );
691        assert!(
692            filesystem_candidates("com", 3, directory.path().to_str().unwrap(), 10)
693                .unwrap()
694                .is_empty()
695        );
696    }
697
698    #[test]
699    fn history_stays_first_while_filesystem_candidates_reserve_capacity() {
700        let mut response = CompletionResponse {
701            replace_start_byte: 5,
702            replace_end_byte: 5,
703            candidates: (0..4)
704                .map(|index| Candidate {
705                    display: format!("cmd history-{index}"),
706                    description: String::new(),
707                    description_pending: false,
708                    kind: CandidateKind::History,
709                    insert_text: index.to_string(),
710                    accept_text: index.to_string(),
711                    source: CandidateSource::History,
712                })
713                .collect(),
714            enrichment_pending: false,
715        };
716        let paths = (0..2)
717            .map(|index| Candidate {
718                display: format!("cmd path-{index}"),
719                description: "File".to_owned(),
720                description_pending: false,
721                kind: CandidateKind::File,
722                insert_text: index.to_string(),
723                accept_text: index.to_string(),
724                source: CandidateSource::Filesystem,
725            })
726            .collect();
727        merge_filesystem_candidates(&mut response, paths, 4);
728        assert_eq!(response.candidates.len(), 4);
729        assert_eq!(response.candidates[0].source, CandidateSource::History);
730        assert_eq!(
731            response
732                .candidates
733                .iter()
734                .filter(|candidate| candidate.source == CandidateSource::Filesystem)
735                .count(),
736            2
737        );
738
739        let mut response = CompletionResponse::empty(0);
740        let paths = (0..2)
741            .map(|index| Candidate {
742                display: format!("cmd path-{index}"),
743                description: "File".to_owned(),
744                description_pending: false,
745                kind: CandidateKind::File,
746                insert_text: index.to_string(),
747                accept_text: index.to_string(),
748                source: CandidateSource::Filesystem,
749            })
750            .collect();
751        merge_filesystem_candidates(&mut response, paths, 1);
752        assert_eq!(response.candidates[0].description, "File (more matches)");
753    }
754
755    #[test]
756    fn recognizes_only_safe_root_option_contexts() {
757        assert_eq!(option_context("git --ver"), Some(("git", "--ver")));
758        assert_eq!(option_context("git status --short"), None);
759        assert_eq!(
760            option_context("git --quiet --short"),
761            Some(("git", "--short"))
762        );
763        assert_eq!(option_context("git -- --literal"), None);
764        assert_eq!(option_context("git \"--ver"), None);
765        assert_eq!(option_context("--ver"), None);
766    }
767
768    #[test]
769    fn completes_cached_root_command_options() {
770        let store = Store::in_memory().unwrap();
771        let commands = CommandCatalog::from_options(
772            "tool",
773            vec![
774                OptionMatch {
775                    spelling: "--verbose".to_owned(),
776                    description: "Show verbose output".to_owned(),
777                },
778                OptionMatch {
779                    spelling: "--version".to_owned(),
780                    description: "Print version".to_owned(),
781                },
782            ],
783        );
784        let completion = complete(
785            &store,
786            &commands,
787            "tool --ver",
788            "tool --ver".len(),
789            "/repo",
790            None,
791            &Settings::default(),
792        )
793        .unwrap();
794        assert_eq!(completion.candidates.len(), 2);
795        assert_eq!(completion.candidates[0].display, "tool --verbose");
796        assert_eq!(completion.candidates[0].kind, CandidateKind::Option);
797        assert_eq!(completion.candidates[0].source, CandidateSource::Help);
798        assert_eq!(completion.candidates[1].display, "tool --version");
799    }
800
801    #[test]
802    fn describes_history_usage_and_directory() {
803        assert_eq!(history_description(1, true), "used here");
804        assert_eq!(history_description(3, true), "used 3x, here");
805        assert_eq!(history_description(2, false), "used 2x");
806    }
807
808    #[test]
809    fn completes_history_with_a_single_segment() {
810        let store = Store::in_memory().unwrap();
811        let mut settings = Settings::default();
812        settings.completion.accept = AcceptMode::Segment;
813        store
814            .record("cd ~/dev/gitrepos/aster", "/repo", 0, 100, "test", true)
815            .unwrap();
816
817        let completion = complete(
818            &store,
819            &CommandCatalog::default(),
820            "cd ~/d",
821            "cd ~/d".len(),
822            "/repo",
823            None,
824            &settings,
825        )
826        .unwrap();
827
828        assert_eq!(completion.candidates.len(), 1);
829        assert_eq!(completion.candidates[0].insert_text, "ev/gitrepos/aster");
830        assert_eq!(completion.candidates[0].accept_text, "ev/");
831        assert_eq!(completion.candidates[0].description, "used here");
832
833        let completion = complete(
834            &store,
835            &CommandCatalog::default(),
836            "cd ~/d",
837            "cd ~/d".len(),
838            "/repo",
839            None,
840            &Settings::default(),
841        )
842        .unwrap();
843        assert_eq!(completion.candidates[0].accept_text, "ev/gitrepos/aster");
844    }
845
846    #[test]
847    fn fuzzy_searches_history_without_a_prefix() {
848        if Command::new("fzf").arg("--version").output().is_err() {
849            return;
850        }
851        let store = Store::in_memory().unwrap();
852        store
853            .record("cargo test --all", "/repo", 0, 100, "test", true)
854            .unwrap();
855        let completion = fuzzy(
856            &store,
857            &CommandCatalog::default(),
858            "cgt",
859            "/repo",
860            None,
861            &Settings::default(),
862        )
863        .unwrap();
864        assert_eq!(completion.candidates[0].display, "cargo test --all");
865    }
866
867    #[test]
868    fn abstains_from_mid_line_completion() {
869        let store = Store::in_memory().unwrap();
870        let completion = complete(
871            &store,
872            &CommandCatalog::default(),
873            "git status",
874            3,
875            "/repo",
876            None,
877            &Settings::default(),
878        )
879        .unwrap();
880        assert!(completion.candidates.is_empty());
881    }
882
883    #[test]
884    fn discovers_commands_when_history_abstains() {
885        let store = Store::in_memory().unwrap();
886        let commands = CommandCatalog::from_entries([CommandEntry {
887            name: "atlas".to_owned(),
888            description: "CLI tool to manage MongoDB Atlas".to_owned(),
889        }]);
890
891        let completion = complete(
892            &store,
893            &commands,
894            "atl",
895            3,
896            "/repo",
897            None,
898            &Settings::default(),
899        )
900        .unwrap();
901
902        assert_eq!(completion.candidates[0].display, "atlas");
903        assert_eq!(completion.candidates[0].accept_text, "as");
904        assert_eq!(completion.candidates[0].kind, CandidateKind::Command);
905    }
906
907    #[test]
908    fn history_precedes_command_inventory() {
909        let store = Store::in_memory().unwrap();
910        store
911            .record("git status", "/repo", 0, 100, "test", true)
912            .unwrap();
913        let commands = CommandCatalog::from_entries([CommandEntry {
914            name: "git-town".to_owned(),
915            description: "Git workflow automation".to_owned(),
916        }]);
917
918        let completion = complete(
919            &store,
920            &commands,
921            "git",
922            3,
923            "/repo",
924            None,
925            &Settings::default(),
926        )
927        .unwrap();
928
929        assert_eq!(completion.candidates[0].source, CandidateSource::History);
930        assert_eq!(completion.candidates[1].source, CandidateSource::Command);
931    }
932
933    #[test]
934    fn history_deduplicates_command_inventory() {
935        let store = Store::in_memory().unwrap();
936        store
937            .record("atlas", "/repo", 0, 100, "test", true)
938            .unwrap();
939        let commands = CommandCatalog::from_entries([
940            CommandEntry {
941                name: "atlas".to_owned(),
942                description: "CLI tool to manage MongoDB Atlas".to_owned(),
943            },
944            CommandEntry {
945                name: "atlantis".to_owned(),
946                description: "Terraform pull request automation".to_owned(),
947            },
948        ]);
949
950        let completion = complete(
951            &store,
952            &commands,
953            "atl",
954            3,
955            "/repo",
956            None,
957            &Settings::default(),
958        )
959        .unwrap();
960
961        assert_eq!(completion.candidates.len(), 2);
962        assert_eq!(completion.candidates[0].display, "atlas");
963        assert_eq!(completion.candidates[0].source, CandidateSource::History);
964        assert_eq!(completion.candidates[1].display, "atlantis");
965    }
966}