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