Skip to main content

rab/tui/
autocomplete.rs

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