Skip to main content

rab/tui/
autocomplete.rs

1use std::path::Path;
2use std::process::{Command, Stdio};
3
4use crate::tui::components::select_list::SelectItem;
5
6/// A suggestion item for autocomplete.
7#[derive(Debug, Clone)]
8pub struct AutocompleteItem {
9    pub value: String,
10    pub label: String,
11    pub description: Option<String>,
12}
13
14impl From<AutocompleteItem> for SelectItem {
15    fn from(item: AutocompleteItem) -> Self {
16        let mut si = SelectItem::new(item.value, item.label);
17        if let Some(desc) = item.description {
18            si = si.with_description(desc);
19        }
20        si
21    }
22}
23
24/// Suggestions returned by an autocomplete provider.
25#[derive(Debug, Clone)]
26pub struct AutocompleteSuggestions {
27    pub items: Vec<AutocompleteItem>,
28    /// The prefix that was matched (e.g., "/" or "src/").
29    pub prefix: String,
30}
31
32/// A slash command definition.
33#[derive(Clone)]
34pub struct SlashCommand {
35    pub name: String,
36    pub description: Option<String>,
37    pub argument_hint: Option<String>,
38    /// Static argument completions (pi-compat: `getArgumentCompletions`).
39    /// When set, these are filtered by the typed prefix and shown.
40    /// When None, file completion is used for arguments.
41    pub argument_completions: Option<Vec<AutocompleteItem>>,
42}
43
44/// Provider that generates autocomplete suggestions.
45pub trait AutocompleteProvider {
46    /// Characters that should naturally trigger this provider at token boundaries.
47    fn trigger_characters(&self) -> &[char];
48
49    /// Get suggestions for the current text/cursor position.
50    /// Returns None if no suggestions available.
51    fn get_suggestions(
52        &self,
53        lines: &[String],
54        cursor_line: usize,
55        cursor_col: usize,
56        force: bool,
57    ) -> Option<AutocompleteSuggestions>;
58
59    /// Apply the selected completion item.
60    fn apply_completion(
61        &self,
62        lines: &[String],
63        cursor_line: usize,
64        cursor_col: usize,
65        item: &AutocompleteItem,
66        prefix: &str,
67    ) -> (Vec<String>, usize, usize);
68
69    /// Whether to trigger file completion on Tab.
70    fn should_trigger_file_completion(
71        &self,
72        lines: &[String],
73        cursor_line: usize,
74        cursor_col: usize,
75    ) -> bool;
76}
77
78// ── fd helpers (pi-compat) ───────────────────────────────────────────
79
80/// Find the `fd` binary in PATH.
81fn find_fd() -> Option<String> {
82    std::env::var("PATH").ok().and_then(|path| {
83        for dir in path.split(':') {
84            for name in &["fd", "fdfind"] {
85                let p = format!("{}/{}", dir, name);
86                if std::path::Path::new(&p).is_file() {
87                    return Some(p);
88                }
89            }
90        }
91        None
92    })
93}
94
95/// Build the fd query from a user-typed path prefix (matches pi's buildFdPathQuery).
96fn build_fd_path_query(query: &str) -> String {
97    let normalized = query.replace('\\', "/");
98    if !normalized.contains('/') {
99        return normalized;
100    }
101    let has_trailing = normalized.ends_with('/');
102    let trimmed = normalized.trim_matches('/');
103    if trimmed.is_empty() {
104        return normalized;
105    }
106    let sep = "[\\\\/]";
107    let segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
108    let mut pattern = segments
109        .iter()
110        .map(|s| regex::escape(s))
111        .collect::<Vec<_>>()
112        .join(sep);
113    if has_trailing {
114        pattern.push_str(sep);
115    }
116    pattern
117}
118
119/// Walk directory tree with `fd` (fast, respects .gitignore).
120/// Mirrors pi's walkDirectoryWithFd().
121fn walk_directory_with_fd(
122    fd_path: &str,
123    base_dir: &str,
124    query: &str,
125    max_results: usize,
126) -> Vec<(String, bool)> {
127    let mr = max_results.to_string();
128    let mut cmd = Command::new(fd_path);
129    cmd.arg("--base-directory")
130        .arg(base_dir)
131        .arg("--max-results")
132        .arg(&mr)
133        .arg("--type")
134        .arg("f")
135        .arg("--type")
136        .arg("d")
137        .arg("--follow")
138        .arg("--hidden")
139        .arg("--exclude")
140        .arg(".git")
141        .arg("--exclude")
142        .arg(".git/*")
143        .arg("--exclude")
144        .arg(".git/**");
145
146    if query.contains('/') {
147        cmd.arg("--full-path");
148    }
149
150    if !query.is_empty() {
151        cmd.arg(build_fd_path_query(query));
152    }
153
154    cmd.stdout(Stdio::piped()).stderr(Stdio::null());
155
156    let output = match cmd.output() {
157        Ok(o) => o,
158        Err(_) => return Vec::new(),
159    };
160
161    if !output.status.success() {
162        return Vec::new();
163    }
164
165    let stdout = String::from_utf8_lossy(&output.stdout);
166    stdout
167        .lines()
168        .filter(|line| !line.is_empty())
169        .filter_map(|line| {
170            let display = line.replace('\\', "/");
171            if display == ".git" || display.starts_with(".git/") || display.contains("/.git/") {
172                return None;
173            }
174            let has_trailing = display.ends_with('/');
175            let normalized = if has_trailing {
176                &display[..display.len() - 1]
177            } else {
178                &display
179            };
180            Some((normalized.to_string(), has_trailing))
181        })
182        .collect()
183}
184
185/// Score an entry against the query (higher = better match).
186/// Directories get a bonus to appear first.
187fn score_entry(file_path: &str, query: &str, is_directory: bool) -> usize {
188    let file_name = Path::new(file_path)
189        .file_name()
190        .map(|f| f.to_string_lossy().to_string())
191        .unwrap_or_default();
192    let lower_name = file_name.to_lowercase();
193    let lower_query = query.to_lowercase();
194
195    let mut score: usize = 0;
196    if lower_name == lower_query {
197        score = 100;
198    } else if lower_name.starts_with(&lower_query) {
199        score = 80;
200    } else if lower_name.contains(&lower_query) {
201        score = 50;
202    } else if file_path.to_lowercase().contains(&lower_query) {
203        score = 30;
204    }
205    if is_directory && score > 0 {
206        score += 10;
207    }
208    score
209}
210
211// ── Quoted prefix helpers (pi-compat) ─────────────────────────────────
212
213const PATH_DELIMITERS: &[char] = &[' ', '\t', '"', '\'', '='];
214
215/// Find an unclosed `"` or `@"` start in the text before cursor.
216/// Returns the start index and the prefix slice (including @ if present).
217fn find_unclosed_quote_prefix(text: &str) -> Option<(usize, &str)> {
218    let mut in_quotes = false;
219    let mut quote_start = 0;
220    for (i, c) in text.char_indices() {
221        if c == '"' {
222            in_quotes = !in_quotes;
223            if in_quotes {
224                quote_start = i;
225            }
226        }
227    }
228    if !in_quotes {
229        return None;
230    }
231    // Check for @" prefix
232    if quote_start > 0 && text.as_bytes().get(quote_start - 1) == Some(&b'@') {
233        let before_at = if quote_start > 1 {
234            &text[..quote_start - 1]
235        } else {
236            ""
237        };
238        if before_at.is_empty() || before_at.ends_with(PATH_DELIMITERS) {
239            return Some((quote_start - 1, &text[quote_start - 1..]));
240        }
241    }
242    // Check for plain " prefix (token boundary)
243    let before = &text[..quote_start];
244    if before.is_empty() || before.ends_with(PATH_DELIMITERS) {
245        return Some((quote_start, &text[quote_start..]));
246    }
247    None
248}
249
250/// Parse a prefix (possibly with @ or "@) into its components.
251/// Returns (stripped_query, is_at_prefix, is_quoted).
252fn parse_completion_prefix(prefix: &str) -> (&str, bool, bool) {
253    if let Some(stripped) = prefix.strip_prefix("@\"") {
254        (stripped, true, true)
255    } else if let Some(stripped) = prefix.strip_prefix('"') {
256        (stripped, false, true)
257    } else if let Some(stripped) = prefix.strip_prefix('@') {
258        (stripped, true, false)
259    } else {
260        (prefix, false, false)
261    }
262}
263
264/// Build the completion value with appropriate quoting (pi-style).
265#[allow(dead_code)]
266fn build_completion_value(
267    path: &str,
268    is_directory: bool,
269    is_at_prefix: bool,
270    is_quoted_prefix: bool,
271) -> String {
272    let needs_quotes = is_quoted_prefix || path.contains(' ');
273    let at = if is_at_prefix { "@" } else { "" };
274    let suffix = if is_directory { "/" } else { "" };
275    if needs_quotes {
276        format!("{}\"{}{}\"", at, path, suffix)
277    } else {
278        format!("{}{}{}", at, path, suffix)
279    }
280}
281
282/// Resolve a scoped fd query: split `src/au` into base_dir=`CWD/src/` and query=`au`.
283fn resolve_scoped_fd_query(raw_query: &str, base_path: &str) -> Option<(String, String, String)> {
284    let normalized = raw_query.replace('\\', "/");
285    let slash_index = normalized.rfind('/')?;
286    let display_base = normalized[..=slash_index].to_string();
287    let query = normalized[slash_index + 1..].to_string();
288
289    let base_dir = if let Some(stripped) = display_base.strip_prefix("~/") {
290        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
291        format!("{}/{}", home, stripped)
292    } else if display_base.starts_with('/') {
293        display_base.clone()
294    } else {
295        format!("{}/{}", base_path, display_base)
296    };
297
298    if !Path::new(&base_dir).is_dir() {
299        return None;
300    }
301
302    Some((base_dir, query, display_base))
303}
304
305// =============================================================================
306// CombinedAutocompleteProvider — handles slash commands + file paths
307// =============================================================================
308
309/// Combined provider that handles slash commands and file path completion.
310pub struct CombinedAutocompleteProvider {
311    slash_commands: Vec<SlashCommand>,
312    base_path: String,
313    fd_path: Option<String>,
314}
315
316impl CombinedAutocompleteProvider {
317    pub fn new(slash_commands: Vec<SlashCommand>, base_path: String) -> Self {
318        let fd_path = find_fd();
319        Self {
320            slash_commands,
321            base_path,
322            fd_path,
323        }
324    }
325
326    fn get_slash_suggestions(&self, prefix: &str) -> Option<AutocompleteSuggestions> {
327        let lower_prefix = prefix.to_lowercase();
328        let matching: Vec<AutocompleteItem> = self
329            .slash_commands
330            .iter()
331            .filter(|cmd| cmd.name.to_lowercase().starts_with(&lower_prefix))
332            .map(|cmd| {
333                let desc = match (&cmd.description, &cmd.argument_hint) {
334                    (Some(d), Some(h)) => Some(format!("{} — {}", h, d)),
335                    (Some(d), None) => Some(d.clone()),
336                    (None, Some(h)) => Some(h.clone()),
337                    (None, None) => None,
338                };
339                AutocompleteItem {
340                    value: cmd.name.clone(),
341                    label: format!("/{}", cmd.name),
342                    description: desc,
343                }
344            })
345            .collect();
346
347        if matching.is_empty() {
348            return None;
349        }
350        Some(AutocompleteSuggestions {
351            items: matching,
352            prefix: format!("/{}", prefix),
353        })
354    }
355
356    /// Fuzzy file search using `fd` (fast, respects .gitignore).
357    /// Matches pi's getFuzzyFileSuggestions().
358    fn get_fuzzy_file_suggestions(&self, query: &str) -> Option<AutocompleteSuggestions> {
359        let fd_path = self.fd_path.as_ref()?;
360
361        let (fd_base_dir, fd_query, display_base) = resolve_scoped_fd_query(query, &self.base_path)
362            .unwrap_or_else(|| {
363                // No scope — search from base_path with the full query
364                (self.base_path.clone(), query.to_string(), String::new())
365            });
366
367        let entries = walk_directory_with_fd(fd_path, &fd_base_dir, &fd_query, 100);
368        if entries.is_empty() {
369            return None;
370        }
371
372        let scored: Vec<(String, bool, usize)> = entries
373            .into_iter()
374            .map(|(path, is_dir)| {
375                let score = if fd_query.is_empty() {
376                    1
377                } else {
378                    score_entry(&path, &fd_query, is_dir)
379                };
380                (path, is_dir, score)
381            })
382            .filter(|(_, _, score)| *score > 0)
383            .collect();
384
385        if scored.is_empty() {
386            return None;
387        }
388
389        // Sort by score descending, then take top 20
390        let mut scored = scored;
391        scored.sort_by_key(|b| std::cmp::Reverse(b.2));
392        scored.truncate(20);
393
394        let items: Vec<AutocompleteItem> = scored
395            .into_iter()
396            .map(|(entry_path, is_dir, _score)| {
397                let entry_name = Path::new(&entry_path)
398                    .file_name()
399                    .map(|f| f.to_string_lossy().to_string())
400                    .unwrap_or_default();
401                let display_path = if display_base.is_empty() {
402                    entry_path.clone()
403                } else {
404                    format!("{}{}", display_base, entry_path)
405                };
406                let completion_path = if is_dir {
407                    format!("{}/", display_path)
408                } else {
409                    display_path.clone()
410                };
411                AutocompleteItem {
412                    value: completion_path,
413                    label: format!("{}/", entry_name),
414                    description: Some(display_path),
415                }
416            })
417            .collect();
418
419        Some(AutocompleteSuggestions {
420            items,
421            prefix: query.to_string(),
422        })
423    }
424
425    fn get_file_suggestions(&self, prefix: &str) -> Option<AutocompleteSuggestions> {
426        // Determine search directory and file prefix
427        let expanded = if let Some(stripped) = prefix.strip_prefix("~/") {
428            let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
429            format!("{}/{}", home, stripped)
430        } else if prefix == "~" {
431            std::env::var("HOME").unwrap_or_else(|_| "/tmp".into())
432        } else if prefix.starts_with('/') {
433            prefix.to_string()
434        } else {
435            format!("{}/{}", self.base_path, prefix)
436        };
437
438        let expanded_clone = expanded.clone();
439        let (dir, file_prefix) = if expanded.ends_with('/') {
440            (expanded_clone, String::new())
441        } else {
442            let p = Path::new(&expanded);
443            let parent = p
444                .parent()
445                .map(|p| p.to_string_lossy().to_string())
446                .unwrap_or("/".into());
447            let file = p
448                .file_name()
449                .map(|f| f.to_string_lossy().to_string())
450                .unwrap_or_default();
451            (
452                if parent.is_empty() {
453                    "/".into()
454                } else {
455                    parent
456                },
457                file,
458            )
459        };
460
461        let dir_path = Path::new(&dir);
462        if !dir_path.exists() || !dir_path.is_dir() {
463            return None;
464        }
465
466        let lower_prefix = file_prefix.to_lowercase();
467        let mut items: Vec<AutocompleteItem> = Vec::new();
468
469        if let Ok(entries) = std::fs::read_dir(dir_path) {
470            for entry in entries.flatten() {
471                let name = entry.file_name().to_string_lossy().to_string();
472                if name == ".git" || name.starts_with('.') {
473                    continue;
474                }
475                if !name.to_lowercase().starts_with(&lower_prefix) {
476                    continue;
477                }
478                let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
479                let suffix = if is_dir { "/" } else { "" };
480
481                let display = if prefix.starts_with('/') {
482                    let base_dir = dir.clone();
483                    if base_dir.ends_with('/') {
484                        format!("{}{}{}", base_dir, name, suffix)
485                    } else {
486                        format!("{}/{}{}", base_dir, name, suffix)
487                    }
488                } else if let Some(rel_part) = prefix.strip_prefix("~/") {
489                    let parent_path = Path::new(rel_part)
490                        .parent()
491                        .map(|p| p.to_string_lossy().to_string())
492                        .unwrap_or_default();
493                    let base =
494                        if rel_part.is_empty() || parent_path.is_empty() || parent_path == "." {
495                            "~/".to_string()
496                        } else {
497                            format!("~/{}/", parent_path)
498                        };
499                    format!("{}{}{}", base, name, suffix)
500                } else if prefix == "~" {
501                    format!("~/{}{}", name, suffix)
502                } else if prefix.ends_with('/') {
503                    format!("{}{}{}", prefix, name, suffix)
504                } else if prefix.contains('/') {
505                    let p = Path::new(prefix);
506                    let parent = p
507                        .parent()
508                        .map(|p| p.to_string_lossy().to_string())
509                        .unwrap_or_default();
510                    let base = if parent.is_empty() || parent == "." {
511                        String::new()
512                    } else {
513                        format!("{}/", parent)
514                    };
515                    if prefix.starts_with("./") && !base.starts_with("./") {
516                        format!("./{}{}{}", base, name, suffix)
517                    } else {
518                        format!("{}{}{}", base, name, suffix)
519                    }
520                } else {
521                    format!("{}{}", name, suffix)
522                };
523
524                items.push(AutocompleteItem {
525                    value: display,
526                    label: format!("{}{}", name, suffix),
527                    description: None,
528                });
529            }
530        }
531
532        items.sort_by(|a, b| {
533            let a_is_dir = a.value.ends_with('/');
534            let b_is_dir = b.value.ends_with('/');
535            if a_is_dir && !b_is_dir {
536                std::cmp::Ordering::Less
537            } else if !a_is_dir && b_is_dir {
538                std::cmp::Ordering::Greater
539            } else {
540                a.label.to_lowercase().cmp(&b.label.to_lowercase())
541            }
542        });
543
544        if items.is_empty() {
545            return None;
546        }
547        Some(AutocompleteSuggestions {
548            items,
549            prefix: prefix.to_string(),
550        })
551    }
552}
553
554impl AutocompleteProvider for CombinedAutocompleteProvider {
555    fn trigger_characters(&self) -> &[char] {
556        &['/', '@', '#']
557    }
558
559    fn get_suggestions(
560        &self,
561        lines: &[String],
562        cursor_line: usize,
563        cursor_col: usize,
564        force: bool,
565    ) -> Option<AutocompleteSuggestions> {
566        let current_line = lines.get(cursor_line)?;
567        let text_before = &current_line[..cursor_col.min(current_line.len())];
568
569        // ── Slash command completion ──
570        if text_before.starts_with('/') && !text_before.contains(' ') {
571            let cmd = &text_before[1..];
572            return self.get_slash_suggestions(cmd);
573        }
574
575        // ── Slash command argument completion ──
576        if let Some(space_pos) = text_before.find(' ') {
577            if space_pos == 0 {
578                return None;
579            }
580            let cmd_name = &text_before[1..space_pos];
581            let arg_text = &text_before[space_pos + 1..];
582            for cmd in &self.slash_commands {
583                if cmd.name == cmd_name {
584                    // Check for static argument completions (pi-compat)
585                    if let Some(ref completions) = cmd.argument_completions {
586                        let lower = arg_text.to_lowercase();
587                        let filtered: Vec<AutocompleteItem> = completions
588                            .iter()
589                            .filter(|c| c.value.to_lowercase().starts_with(&lower))
590                            .cloned()
591                            .collect();
592                        if !filtered.is_empty() {
593                            return Some(AutocompleteSuggestions {
594                                items: filtered,
595                                prefix: arg_text.to_string(),
596                            });
597                        }
598                    }
599                    // Fall back to file path completion
600                    if force
601                        || arg_text.contains('/')
602                        || arg_text.contains('.')
603                        || arg_text.is_empty()
604                    {
605                        return self.get_file_suggestions(arg_text);
606                    }
607                    return None;
608                }
609            }
610        }
611
612        // ── Quoted prefix (@""" or """ for paths with spaces, pi-style) ──
613        if let Some((_start, full_prefix)) = find_unclosed_quote_prefix(text_before) {
614            let (query, _is_at, _is_quoted) = parse_completion_prefix(full_prefix);
615            // Use fd for simple queries (no /) to find files anywhere
616            if !query.contains('/')
617                && !query.contains('.')
618                && self.fd_path.is_some()
619                && !query.is_empty()
620                && let Some(suggestions) = self.get_fuzzy_file_suggestions(query)
621            {
622                return Some(suggestions);
623            }
624            return self.get_file_suggestions(query);
625        }
626
627        // ── @ and # file/attachment completion ──
628        if let Some(pos) = text_before.rfind(['@', '#']) {
629            let is_token_start =
630                pos == 0 || text_before[..pos].ends_with(' ') || text_before[..pos].ends_with('\t');
631            if is_token_start {
632                let path = &text_before[pos + 1..];
633                // If path doesn't contain / and fd is available, use fd for project-wide search
634                if !path.contains('/')
635                    && self.fd_path.is_some()
636                    && !path.is_empty()
637                    && let Some(suggestions) = self.get_fuzzy_file_suggestions(path)
638                {
639                    return Some(suggestions);
640                }
641                return self.get_file_suggestions(path);
642            }
643        }
644
645        // ── Forced completion (Tab) ──
646        if force && self.should_trigger_file_completion(lines, cursor_line, cursor_col) {
647            let last_space = text_before.rfind(|c: char| c.is_whitespace());
648            let token = if let Some(pos) = last_space {
649                &text_before[pos + 1..]
650            } else {
651                text_before
652            };
653            if !token.is_empty() {
654                return self.get_file_suggestions(token);
655            }
656        }
657
658        None
659    }
660
661    fn apply_completion(
662        &self,
663        lines: &[String],
664        cursor_line: usize,
665        cursor_col: usize,
666        item: &AutocompleteItem,
667        prefix: &str,
668    ) -> (Vec<String>, usize, usize) {
669        let current_line = lines[cursor_line].clone();
670        let prefix_start = cursor_col.saturating_sub(prefix.len());
671        let before = &current_line[..prefix_start];
672        let after = &current_line[cursor_col..];
673
674        let (new_line, new_col) = if prefix.starts_with('/') {
675            // Slash command: insert with trailing space
676            (
677                format!("{}/{} {}", before, item.value, after),
678                before.len() + 1 + item.value.len() + 1,
679            )
680        } else {
681            // File path: use the item value directly (it's already built by the provider)
682            let item_val = &item.value;
683            let suffix = if item_val.ends_with('/') { "" } else { " " };
684            (
685                format!("{}{}{}{}", before, item_val, suffix, after),
686                before.len() + item_val.len() + suffix.len(),
687            )
688        };
689
690        let mut new_lines = lines.to_vec();
691        new_lines[cursor_line] = new_line;
692        (new_lines, cursor_line, new_col)
693    }
694
695    fn should_trigger_file_completion(
696        &self,
697        lines: &[String],
698        cursor_line: usize,
699        cursor_col: usize,
700    ) -> bool {
701        let current_line = lines
702            .get(cursor_line)
703            .map(|l| &l[..cursor_col.min(l.len())]);
704        match current_line {
705            Some(text) => {
706                if text.starts_with('/') && !text.contains(' ') {
707                    return false;
708                }
709                true
710            }
711            None => false,
712        }
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    #[test]
721    fn test_slash_suggestions() {
722        let provider = CombinedAutocompleteProvider::new(
723            vec![
724                SlashCommand {
725                    name: "help".into(),
726                    description: Some("Show help".into()),
727                    argument_hint: None,
728                    argument_completions: None,
729                },
730                SlashCommand {
731                    name: "history".into(),
732                    description: Some("Show history".into()),
733                    argument_hint: None,
734                    argument_completions: None,
735                },
736            ],
737            "/tmp".into(),
738        );
739
740        let lines = vec!["/he".into()];
741        let result = provider.get_suggestions(&lines, 0, 3, false);
742        assert!(result.is_some());
743        let suggestions = result.unwrap();
744        assert_eq!(suggestions.items.len(), 1);
745        assert_eq!(suggestions.items[0].value, "help");
746    }
747
748    #[test]
749    fn test_no_slash_matches() {
750        let provider = CombinedAutocompleteProvider::new(
751            vec![SlashCommand {
752                name: "help".into(),
753                description: None,
754                argument_hint: None,
755                argument_completions: None,
756            }],
757            "/tmp".into(),
758        );
759
760        let lines = vec!["/unknown".into()];
761        let result = provider.get_suggestions(&lines, 0, 8, false);
762        assert!(result.is_none());
763    }
764
765    #[test]
766    fn test_trigger_characters() {
767        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
768        assert_eq!(provider.trigger_characters(), &['/', '@', '#']);
769    }
770
771    #[test]
772    fn test_apply_completion_slash() {
773        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
774        let item = AutocompleteItem {
775            value: "help".into(),
776            label: "/help".into(),
777            description: None,
778        };
779        let lines = vec!["/".into()];
780        let (new_lines, new_line, new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
781        assert_eq!(new_lines[0], "/help ");
782        assert_eq!(new_line, 0);
783        assert_eq!(new_col, 6);
784    }
785
786    #[test]
787    fn test_find_unclosed_quote_prefix_basic() {
788        assert!(find_unclosed_quote_prefix("hello \"world").is_some());
789        assert!(find_unclosed_quote_prefix("hello \"world\"").is_none());
790        assert!(find_unclosed_quote_prefix("no quotes").is_none());
791    }
792
793    #[test]
794    fn test_find_unclosed_quote_prefix_at() {
795        let result = find_unclosed_quote_prefix("hello @\"path");
796        assert!(result.is_some());
797        let (_start, prefix) = result.unwrap();
798        assert_eq!(&prefix[..1], "@");
799    }
800
801    #[test]
802    fn test_parse_completion_prefix() {
803        let (q, at, quoted) = parse_completion_prefix("@\"path");
804        assert_eq!(q, "path");
805        assert!(at);
806        assert!(quoted);
807
808        let (q, at, quoted) = parse_completion_prefix("\"path");
809        assert_eq!(q, "path");
810        assert!(!at);
811        assert!(quoted);
812
813        let (q, at, quoted) = parse_completion_prefix("@path");
814        assert_eq!(q, "path");
815        assert!(at);
816        assert!(!quoted);
817
818        let (q, at, quoted) = parse_completion_prefix("path");
819        assert_eq!(q, "path");
820        assert!(!at);
821        assert!(!quoted);
822    }
823
824    #[test]
825    fn test_build_completion_value() {
826        let v = build_completion_value("foo.rs", false, true, false);
827        assert_eq!(v, "@foo.rs");
828
829        let v = build_completion_value("foo.rs", false, false, false);
830        assert_eq!(v, "foo.rs");
831
832        let v = build_completion_value("my dir/file.rs", false, true, false);
833        assert_eq!(v, "@\"my dir/file.rs\"");
834    }
835
836    #[test]
837    fn test_is_empty_items_on_empty_dir() {
838        let tmp = std::env::temp_dir();
839        let provider = CombinedAutocompleteProvider::new(vec![], tmp.to_string_lossy().to_string());
840        let result = provider.get_file_suggestions("");
841        assert!(result.is_some(), "Should find files in temp dir");
842    }
843
844    #[test]
845    fn test_find_fd() {
846        // fd may or may not be installed, but the function should not panic
847        let _ = find_fd();
848    }
849
850    #[test]
851    fn test_build_fd_path_query() {
852        assert_eq!(build_fd_path_query("hello"), "hello");
853        assert_eq!(build_fd_path_query("src/main.rs"), "src[\\\\/]main\\.rs");
854        assert!(build_fd_path_query("src/").ends_with("[\\\\/]"));
855    }
856
857    #[test]
858    fn test_score_entry() {
859        let s = score_entry("src/main.rs", "main", false);
860        assert!(s > 0, "Should score positive for matching name");
861        let s = score_entry("src/main.rs", "nomatch", false);
862        assert_eq!(s, 0, "Should score zero for no match");
863    }
864}