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('.') && !file_prefix.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                    // When rel_part has a trailing slash (e.g., ".rab/agent/"),
478                    // use it directly as the base to preserve the last folder.
479                    // Path::new().parent() would strip it (e.g., ".rab/agent/" → ".rab").
480                    let base = if rel_part.ends_with('/') {
481                        format!("~/{}", rel_part)
482                    } else {
483                        let parent_path = Path::new(rel_part)
484                            .parent()
485                            .map(|p| p.to_string_lossy().to_string())
486                            .unwrap_or_default();
487                        if rel_part.is_empty() || parent_path.is_empty() || parent_path == "." {
488                            "~/".to_string()
489                        } else {
490                            format!("~/{}/", parent_path)
491                        }
492                    };
493                    format!("{}{}{}", base, name, suffix)
494                } else if prefix == "~" {
495                    format!("~/{}{}", name, suffix)
496                } else if prefix.ends_with('/') {
497                    format!("{}{}{}", prefix, name, suffix)
498                } else if prefix.contains('/') {
499                    let p = Path::new(prefix);
500                    let parent = p
501                        .parent()
502                        .map(|p| p.to_string_lossy().to_string())
503                        .unwrap_or_default();
504                    let base = if parent.is_empty() || parent == "." {
505                        String::new()
506                    } else {
507                        format!("{}/", parent)
508                    };
509                    if prefix.starts_with("./") && !base.starts_with("./") {
510                        format!("./{}{}{}", base, name, suffix)
511                    } else {
512                        format!("{}{}{}", base, name, suffix)
513                    }
514                } else {
515                    format!("{}{}", name, suffix)
516                };
517
518                items.push(AutocompleteItem {
519                    value: display,
520                    label: format!("{}{}", name, suffix),
521                    description: None,
522                });
523            }
524        }
525
526        items.sort_by(|a, b| {
527            let a_is_dir = a.value.ends_with('/');
528            let b_is_dir = b.value.ends_with('/');
529            if a_is_dir && !b_is_dir {
530                std::cmp::Ordering::Less
531            } else if !a_is_dir && b_is_dir {
532                std::cmp::Ordering::Greater
533            } else {
534                a.label.to_lowercase().cmp(&b.label.to_lowercase())
535            }
536        });
537
538        if items.is_empty() {
539            return None;
540        }
541        Some(AutocompleteSuggestions {
542            items,
543            prefix: prefix.to_string(),
544        })
545    }
546}
547
548impl AutocompleteProvider for CombinedAutocompleteProvider {
549    fn trigger_characters(&self) -> &[char] {
550        &['/', '@', '#']
551    }
552
553    fn get_suggestions(
554        &self,
555        lines: &[String],
556        cursor_line: usize,
557        cursor_col: usize,
558        force: bool,
559    ) -> Option<AutocompleteSuggestions> {
560        let current_line = lines.get(cursor_line)?;
561        let text_before = &current_line[..cursor_col.min(current_line.len())];
562
563        // ── Slash command completion ──
564        if text_before.starts_with('/') && !text_before.contains(' ') {
565            let cmd = &text_before[1..];
566            if let Some(suggestions) = self.get_slash_suggestions(cmd) {
567                return Some(suggestions);
568            }
569            // No slash command match – fall through to file completion for absolute paths like /tmp
570        }
571
572        // ── Slash command argument completion ──
573        if let Some(space_pos) = text_before.find(' ') {
574            if space_pos == 0 {
575                return None;
576            }
577            let cmd_name = &text_before[1..space_pos];
578            let arg_text = &text_before[space_pos + 1..];
579            for cmd in &self.slash_commands {
580                if cmd.name == cmd_name {
581                    // Check for dynamic argument completions callback (pi-style)
582                    if let Some(ref get_completions) = cmd.get_argument_completions {
583                        let items = get_completions(arg_text);
584                        if !items.is_empty() {
585                            return Some(AutocompleteSuggestions {
586                                items,
587                                prefix: arg_text.to_string(),
588                            });
589                        }
590                    }
591                    // Check for static argument completions (pi-compat)
592                    if let Some(ref completions) = cmd.argument_completions {
593                        let lower = arg_text.to_lowercase();
594                        let filtered: Vec<AutocompleteItem> = completions
595                            .iter()
596                            .filter(|c| c.value.to_lowercase().starts_with(&lower))
597                            .cloned()
598                            .collect();
599                        if !filtered.is_empty() {
600                            return Some(AutocompleteSuggestions {
601                                items: filtered,
602                                prefix: arg_text.to_string(),
603                            });
604                        }
605                    }
606                    // Fall back to file path completion
607                    if force
608                        || arg_text.contains('/')
609                        || arg_text.contains('.')
610                        || arg_text.is_empty()
611                    {
612                        return self.get_file_suggestions(arg_text);
613                    }
614                    return None;
615                }
616            }
617        }
618
619        // ── Quoted prefix (@""" or """ for paths with spaces, pi-style) ──
620        if let Some((_start, full_prefix)) = find_unclosed_quote_prefix(text_before) {
621            let (query, _is_at, _is_quoted) = parse_completion_prefix(full_prefix);
622            // Use fd for simple queries (no /) to find files anywhere
623            if !query.contains('/')
624                && !query.contains('.')
625                && self.fd_path.is_some()
626                && !query.is_empty()
627                && let Some(suggestions) = self.get_fuzzy_file_suggestions(query)
628            {
629                return Some(suggestions);
630            }
631            return self.get_file_suggestions(query);
632        }
633
634        // ── @ and # file/attachment completion ──
635        if let Some(pos) = text_before.rfind(['@', '#']) {
636            let is_token_start =
637                pos == 0 || text_before[..pos].ends_with(' ') || text_before[..pos].ends_with('\t');
638            if is_token_start {
639                let path = &text_before[pos + 1..];
640                // If path doesn't contain / and fd is available, use fd for project-wide search
641                if !path.contains('/')
642                    && self.fd_path.is_some()
643                    && !path.is_empty()
644                    && let Some(suggestions) = self.get_fuzzy_file_suggestions(path)
645                {
646                    return Some(suggestions);
647                }
648                return self.get_file_suggestions(path);
649            }
650        }
651
652        // ── ~ path completion (tilde expansion) ──
653        if let Some(pos) = text_before.rfind('~') {
654            let is_token_start =
655                pos == 0 || text_before[..pos].ends_with(' ') || text_before[..pos].ends_with('\t');
656            if is_token_start {
657                let path = &text_before[pos..];
658                return self.get_file_suggestions(path);
659            }
660        }
661
662        // ── Absolute path completion (/) – automatic (non-force) fallback for paths
663        //     that didn't match any slash command ──
664        if text_before.starts_with('/') && !text_before.contains(' ') && text_before.len() > 1 {
665            return self.get_file_suggestions(text_before);
666        }
667
668        // ── Forced completion (Tab) ──
669        if force && self.should_trigger_file_completion(lines, cursor_line, cursor_col) {
670            let last_space = text_before.rfind(|c: char| c.is_whitespace());
671            let token = if let Some(pos) = last_space {
672                &text_before[pos + 1..]
673            } else {
674                text_before
675            };
676            if !token.is_empty() {
677                return self.get_file_suggestions(token);
678            }
679        }
680
681        None
682    }
683
684    fn apply_completion(
685        &self,
686        lines: &[String],
687        cursor_line: usize,
688        cursor_col: usize,
689        item: &AutocompleteItem,
690        prefix: &str,
691    ) -> (Vec<String>, usize, usize) {
692        let current_line = lines[cursor_line].clone();
693        let prefix_start = cursor_col.saturating_sub(prefix.len());
694        let before = &current_line[..prefix_start];
695        let after = &current_line[cursor_col..];
696
697        // Determine if this is a slash command completion or a file path completion.
698        // Slash commands have item.value = "help" (no leading /, ~, or . path chars).
699        // File paths have item.value = "/tmp/", "~/.rab/agent/", or "src/main.rs".
700        let is_slash_command = prefix.starts_with('/')
701            && !item.value.starts_with('/')
702            && !item.value.starts_with('~')
703            && !item.value.starts_with('.');
704
705        let (new_line, new_col) = if is_slash_command {
706            // Slash command: insert with trailing space
707            (
708                format!("{}/{} {}", before, item.value, after),
709                before.len() + 1 + item.value.len() + 1,
710            )
711        } else {
712            // File path: use the item value directly (it's already built by the provider)
713            let item_val = &item.value;
714            let suffix = if item_val.ends_with('/') { "" } else { " " };
715            (
716                format!("{}{}{}{}", before, item_val, suffix, after),
717                before.len() + item_val.len() + suffix.len(),
718            )
719        };
720
721        let mut new_lines = lines.to_vec();
722        new_lines[cursor_line] = new_line;
723        (new_lines, cursor_line, new_col)
724    }
725
726    fn should_trigger_file_completion(
727        &self,
728        lines: &[String],
729        cursor_line: usize,
730        cursor_col: usize,
731    ) -> bool {
732        let current_line = lines
733            .get(cursor_line)
734            .map(|l| &l[..cursor_col.min(l.len())]);
735        match current_line {
736            Some(text) => {
737                // Only block Tab completion for known slash commands on line 0.
738                // Absolute paths like /usr/share/ should still get file completion.
739                if text.starts_with('/') && !text.contains(' ') && cursor_line == 0 {
740                    let cmd_input = text[1..].trim();
741                    if cmd_input.is_empty() {
742                        // Just "/" — don't trigger file completion yet
743                        return false;
744                    }
745                    // If text matches a known slash command, don't trigger file completion
746                    if self
747                        .slash_commands
748                        .iter()
749                        .any(|c| c.name.starts_with(cmd_input))
750                    {
751                        return false;
752                    }
753                    // Otherwise it's an absolute path — allow file completion
754                }
755                true
756            }
757            None => false,
758        }
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765
766    fn build_completion_value(
767        path: &str,
768        is_directory: bool,
769        is_at_prefix: bool,
770        is_quoted_prefix: bool,
771    ) -> String {
772        let needs_quotes = is_quoted_prefix || path.contains(' ');
773        let at = if is_at_prefix { "@" } else { "" };
774        let suffix = if is_directory { "/" } else { "" };
775        if needs_quotes {
776            format!("{}\"{}{}\"", at, path, suffix)
777        } else {
778            format!("{}{}{}", at, path, suffix)
779        }
780    }
781
782    #[test]
783    fn test_slash_suggestions() {
784        let provider = CombinedAutocompleteProvider::new(
785            vec![
786                SlashCommand {
787                    name: "help".into(),
788                    description: Some("Show help".into()),
789                    argument_hint: None,
790                    argument_completions: None,
791                    get_argument_completions: None,
792                },
793                SlashCommand {
794                    name: "history".into(),
795                    description: Some("Show history".into()),
796                    argument_hint: None,
797                    argument_completions: None,
798                    get_argument_completions: None,
799                },
800            ],
801            "/tmp".into(),
802        );
803
804        let lines = vec!["/he".into()];
805        let result = provider.get_suggestions(&lines, 0, 3, false);
806        assert!(result.is_some());
807        let suggestions = result.unwrap();
808        assert_eq!(suggestions.items.len(), 1);
809        assert_eq!(suggestions.items[0].value, "help");
810    }
811
812    #[test]
813    fn test_no_slash_matches() {
814        let provider = CombinedAutocompleteProvider::new(
815            vec![SlashCommand {
816                name: "help".into(),
817                description: None,
818                argument_hint: None,
819                argument_completions: None,
820                get_argument_completions: None,
821            }],
822            "/tmp".into(),
823        );
824
825        let lines = vec!["/unknown".into()];
826        let result = provider.get_suggestions(&lines, 0, 8, false);
827        assert!(result.is_none());
828    }
829
830    #[test]
831    fn test_trigger_characters() {
832        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
833        assert_eq!(provider.trigger_characters(), &['/', '@', '#']);
834    }
835
836    #[test]
837    fn test_apply_completion_slash() {
838        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
839        let item = AutocompleteItem {
840            value: "help".into(),
841            label: "/help".into(),
842            description: None,
843        };
844        let lines = vec!["/".into()];
845        let (new_lines, new_line, new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
846        assert_eq!(new_lines[0], "/help ");
847        assert_eq!(new_line, 0);
848        assert_eq!(new_col, 6);
849    }
850
851    #[test]
852    fn test_find_unclosed_quote_prefix_basic() {
853        assert!(find_unclosed_quote_prefix("hello \"world").is_some());
854        assert!(find_unclosed_quote_prefix("hello \"world\"").is_none());
855        assert!(find_unclosed_quote_prefix("no quotes").is_none());
856    }
857
858    #[test]
859    fn test_find_unclosed_quote_prefix_at() {
860        let result = find_unclosed_quote_prefix("hello @\"path");
861        assert!(result.is_some());
862        let (_start, prefix) = result.unwrap();
863        assert_eq!(&prefix[..1], "@");
864    }
865
866    #[test]
867    fn test_parse_completion_prefix() {
868        let (q, at, quoted) = parse_completion_prefix("@\"path");
869        assert_eq!(q, "path");
870        assert!(at);
871        assert!(quoted);
872
873        let (q, at, quoted) = parse_completion_prefix("\"path");
874        assert_eq!(q, "path");
875        assert!(!at);
876        assert!(quoted);
877
878        let (q, at, quoted) = parse_completion_prefix("@path");
879        assert_eq!(q, "path");
880        assert!(at);
881        assert!(!quoted);
882
883        let (q, at, quoted) = parse_completion_prefix("path");
884        assert_eq!(q, "path");
885        assert!(!at);
886        assert!(!quoted);
887    }
888
889    #[test]
890    fn test_build_completion_value() {
891        let v = build_completion_value("foo.rs", false, true, false);
892        assert_eq!(v, "@foo.rs");
893
894        let v = build_completion_value("foo.rs", false, false, false);
895        assert_eq!(v, "foo.rs");
896
897        let v = build_completion_value("my dir/file.rs", false, true, false);
898        assert_eq!(v, "@\"my dir/file.rs\"");
899    }
900
901    #[test]
902    fn test_is_empty_items_on_empty_dir() {
903        let tmp = std::env::temp_dir();
904        let provider = CombinedAutocompleteProvider::new(vec![], tmp.to_string_lossy().to_string());
905        let result = provider.get_file_suggestions("");
906        assert!(result.is_some(), "Should find files in temp dir");
907    }
908
909    #[test]
910    fn test_build_fd_path_query() {
911        assert_eq!(build_fd_path_query("hello"), "hello");
912        assert_eq!(build_fd_path_query("src/main.rs"), "src[\\\\/]main\\.rs");
913        assert!(build_fd_path_query("src/").ends_with("[\\\\/]"));
914    }
915
916    #[test]
917    fn test_score_entry() {
918        let s = score_entry("src/main.rs", "main", false);
919        assert!(s > 0, "Should score positive for matching name");
920        let s = score_entry("src/main.rs", "nomatch", false);
921        assert_eq!(s, 0, "Should score zero for no match");
922    }
923
924    // ── Tests for fixed bugs ──
925
926    #[test]
927    fn test_apply_completion_absolute_path_no_double_slash() {
928        // Bug 1: completing / → tmp/ should give /tmp/ not //tmp/
929        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
930        // Absolute path file completion (item.value starts with /)
931        let item = AutocompleteItem {
932            value: "/tmp/".into(),
933            label: "tmp/".into(),
934            description: None,
935        };
936        let lines = vec!["/".into()];
937        let (new_lines, _new_line, _new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
938        // Should NOT produce //tmp/
939        assert_eq!(
940            new_lines[0], "/tmp/",
941            "Absolute path completion must not add extra slash"
942        );
943    }
944
945    #[test]
946    fn test_apply_completion_slash_command_still_works() {
947        // Slash commands should still produce /cmd (with one slash)
948        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
949        let item = AutocompleteItem {
950            value: "help".into(),
951            label: "/help".into(),
952            description: None,
953        };
954        let lines = vec!["/".into()];
955        let (new_lines, _new_line, new_col) = provider.apply_completion(&lines, 0, 1, &item, "/");
956        assert_eq!(new_lines[0], "/help ");
957        assert_eq!(new_col, 6);
958    }
959
960    #[test]
961    fn test_get_file_suggestions_absolute_path() {
962        // Bug 1: get_suggestions for absolute paths like /tmp should work
963        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
964        let lines = vec!["/tmp".into()];
965        let result = provider.get_suggestions(&lines, 0, 4, false);
966        // /tmp is a directory, should show its contents
967        assert!(
968            result.is_some(),
969            "Absolute path /tmp should produce suggestions"
970        );
971        let suggestions = result.unwrap();
972        assert!(
973            !suggestions.items.is_empty(),
974            "Should have entries from /tmp"
975        );
976        assert_eq!(suggestions.prefix, "/tmp");
977    }
978
979    #[test]
980    fn test_get_suggestions_slash_falls_through_to_file_completion() {
981        // When no slash command matches, absolute paths should get file completion
982        let provider = CombinedAutocompleteProvider::new(
983            vec![SlashCommand {
984                name: "help".into(),
985                description: None,
986                argument_hint: None,
987                argument_completions: None,
988                get_argument_completions: None,
989            }],
990            "/tmp".into(),
991        );
992        let lines = vec!["/tmp".into()];
993        // /tmp doesn't match any slash command, should fall through to file completion
994        let result = provider.get_suggestions(&lines, 0, 4, false);
995        assert!(
996            result.is_some(),
997            "/tmp should fall through to file completion"
998        );
999    }
1000
1001    #[test]
1002    fn test_get_suggestions_tilde_path() {
1003        // Bug 2: ~ paths should trigger file completion (non-force)
1004        let home = std::env::var("HOME").unwrap_or_default();
1005        if home.is_empty() || !std::path::Path::new(&home).is_dir() {
1006            // Skip if HOME is not set or not a directory
1007            return;
1008        }
1009        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
1010        let lines = vec!["~/".into()];
1011        let result = provider.get_suggestions(&lines, 0, 2, false);
1012        assert!(result.is_some(), "~ path should produce file suggestions");
1013    }
1014
1015    #[test]
1016    fn test_hidden_file_filter_with_dot_prefix() {
1017        // Bug 2: when query starts with '.', hidden files should be shown
1018        let tmp = std::env::temp_dir();
1019        // Create a temp dir with a hidden file
1020        let dir = tmp.join("autocomplete_test_dot");
1021        let _ = std::fs::remove_dir_all(&dir);
1022        std::fs::create_dir_all(&dir).unwrap();
1023        std::fs::write(dir.join(".hidden_file"), "").unwrap();
1024        std::fs::write(dir.join("visible_file"), "").unwrap();
1025        std::fs::create_dir(dir.join(".hidden_dir")).unwrap();
1026        std::fs::create_dir(dir.join("visible_dir")).unwrap();
1027
1028        let provider = CombinedAutocompleteProvider::new(vec![], dir.to_string_lossy().to_string());
1029        let dir_str = dir.to_string_lossy();
1030
1031        // Query with dot prefix should show hidden files
1032        let result = provider.get_file_suggestions(&format!("{}/.h", dir_str));
1033        assert!(
1034            result.is_some(),
1035            "Dot prefix query should find hidden files"
1036        );
1037        if let Some(suggestions) = result {
1038            let values: Vec<&str> = suggestions.items.iter().map(|i| i.value.as_str()).collect();
1039            assert!(
1040                values.iter().any(|v| v.contains(".hidden")),
1041                "Should find .hidden_file or .hidden_dir, got: {:?}",
1042                values
1043            );
1044        }
1045
1046        // Query without dot prefix should NOT show hidden files
1047        let result2 = provider.get_file_suggestions(&format!("{}/v", dir_str));
1048        assert!(result2.is_some(), "Non-dot prefix query should find files");
1049        if let Some(suggestions) = result2 {
1050            let values: Vec<&str> = suggestions.items.iter().map(|i| i.value.as_str()).collect();
1051            assert!(
1052                values.iter().any(|v| v.contains("visible")),
1053                "Should find visible_file or visible_dir"
1054            );
1055            assert!(
1056                !values.iter().any(|v| v.contains(".hidden")),
1057                "Should NOT find hidden files with non-dot prefix"
1058            );
1059        }
1060
1061        let _ = std::fs::remove_dir_all(&dir);
1062    }
1063
1064    #[test]
1065    fn test_get_suggestions_slash_command_still_works() {
1066        // Existing slash command completion should not be broken
1067        let provider = CombinedAutocompleteProvider::new(
1068            vec![SlashCommand {
1069                name: "help".into(),
1070                description: Some("Show help".into()),
1071                argument_hint: None,
1072                argument_completions: None,
1073                get_argument_completions: None,
1074            }],
1075            "/tmp".into(),
1076        );
1077
1078        let lines = vec!["/he".into()];
1079        let result = provider.get_suggestions(&lines, 0, 3, false);
1080        assert!(result.is_some());
1081        let suggestions = result.unwrap();
1082        assert_eq!(suggestions.items.len(), 1);
1083        assert_eq!(suggestions.items[0].value, "help");
1084    }
1085
1086    // ── Path completion regression tests ──
1087
1088    /// Create a temp directory structure for path completion tests.
1089    /// Structure:
1090    ///   temp/
1091    ///     src/
1092    ///       autocomplete/
1093    ///         mod.rs
1094    ///       editor.rs
1095    ///       components/
1096    ///         select_list.rs
1097    fn setup_path_test_dir() -> (tempfile::TempDir, String) {
1098        let dir = tempfile::tempdir().expect("create temp dir");
1099        let root = dir.path().to_string_lossy().to_string();
1100
1101        // Create structure
1102        std::fs::create_dir_all(format!("{}/src/autocomplete", root)).unwrap();
1103        std::fs::create_dir_all(format!("{}/src/components", root)).unwrap();
1104        std::fs::write(format!("{}/src/autocomplete/mod.rs", root), "").unwrap();
1105        std::fs::write(format!("{}/src/editor.rs", root), "").unwrap();
1106        std::fs::write(format!("{}/src/components/select_list.rs", root), "").unwrap();
1107
1108        (dir, root)
1109    }
1110
1111    #[test]
1112    fn test_get_file_suggestions_relative_path_with_folder() {
1113        let (_dir, root) = setup_path_test_dir();
1114        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1115
1116        // Typed "src/au" -> should find "src/autocomplete/"
1117        let result = provider.get_file_suggestions("src/au");
1118        assert!(result.is_some(), "src/au should produce suggestions");
1119        let suggestions = result.unwrap();
1120        assert_eq!(
1121            suggestions.prefix, "src/au",
1122            "prefix should be the typed text"
1123        );
1124        assert!(
1125            !suggestions.items.is_empty(),
1126            "should have at least one item"
1127        );
1128
1129        // The item value should include the full relative path
1130        let has_autocomplete = suggestions
1131            .items
1132            .iter()
1133            .any(|i| i.value == "src/autocomplete/");
1134        assert!(
1135            has_autocomplete,
1136            "should contain src/autocomplete/ as a completion candidate, got: {:?}",
1137            suggestions
1138                .items
1139                .iter()
1140                .map(|i| &i.value)
1141                .collect::<Vec<_>>()
1142        );
1143    }
1144
1145    #[test]
1146    fn test_get_file_suggestions_relative_path_trailing_slash() {
1147        let (_dir, root) = setup_path_test_dir();
1148        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1149
1150        // Typed "src/" -> should show contents of src/
1151        let result = provider.get_file_suggestions("src/");
1152        assert!(result.is_some(), "src/ should produce suggestions");
1153        let suggestions = result.unwrap();
1154        assert_eq!(suggestions.prefix, "src/", "prefix should be src/");
1155
1156        // Should contain entries like src/autocomplete/, src/editor.rs, src/components/
1157        let values: Vec<&str> = suggestions.items.iter().map(|i| i.value.as_str()).collect();
1158        assert!(
1159            values.contains(&"src/autocomplete/"),
1160            "should contain src/autocomplete/, got: {:?}",
1161            values
1162        );
1163        assert!(
1164            values.contains(&"src/editor.rs"),
1165            "should contain src/editor.rs, got: {:?}",
1166            values
1167        );
1168        assert!(
1169            values.contains(&"src/components/"),
1170            "should contain src/components/, got: {:?}",
1171            values
1172        );
1173    }
1174
1175    #[test]
1176    fn test_get_file_suggestions_deep_path() {
1177        let (_dir, root) = setup_path_test_dir();
1178        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1179
1180        // Typed "src/components/s" -> should find "src/components/select_list.rs"
1181        let result = provider.get_file_suggestions("src/components/s");
1182        assert!(
1183            result.is_some(),
1184            "src/components/s should produce suggestions"
1185        );
1186        let suggestions = result.unwrap();
1187        assert_eq!(suggestions.prefix, "src/components/s");
1188
1189        let has_select_list = suggestions
1190            .items
1191            .iter()
1192            .any(|i| i.value == "src/components/select_list.rs");
1193        assert!(
1194            has_select_list,
1195            "should contain src/components/select_list.rs, got: {:?}",
1196            suggestions
1197                .items
1198                .iter()
1199                .map(|i| &i.value)
1200                .collect::<Vec<_>>()
1201        );
1202    }
1203
1204    #[test]
1205    fn test_get_suggestions_force_triggers_file_completion() {
1206        let (_dir, root) = setup_path_test_dir();
1207        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1208
1209        // Simulate Tab (force=true) with "src/au" typed
1210        let lines = vec!["src/au".into()];
1211        let result = provider.get_suggestions(&lines, 0, 6, true);
1212        assert!(
1213            result.is_some(),
1214            "Force should trigger file completion for src/au"
1215        );
1216        let suggestions = result.unwrap();
1217        assert_eq!(suggestions.prefix, "src/au");
1218
1219        let has_autocomplete = suggestions
1220            .items
1221            .iter()
1222            .any(|i| i.value == "src/autocomplete/");
1223        assert!(
1224            has_autocomplete,
1225            "Should suggest src/autocomplete/, got: {:?}",
1226            suggestions
1227                .items
1228                .iter()
1229                .map(|i| &i.value)
1230                .collect::<Vec<_>>()
1231        );
1232    }
1233
1234    #[test]
1235    fn test_get_suggestions_at_prefix_file_completion() {
1236        let (_dir, root) = setup_path_test_dir();
1237        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1238
1239        // Typed "@src/au" should complete to "@src/autocomplete/"
1240        let lines = vec!["@src/au".into()];
1241        let result = provider.get_suggestions(&lines, 0, 7, false);
1242        assert!(result.is_some(), "@src/au should produce suggestions");
1243        let suggestions = result.unwrap();
1244        // Prefix should NOT include the @
1245        assert_eq!(suggestions.prefix, "src/au", "prefix should not include @");
1246
1247        let has_autocomplete = suggestions
1248            .items
1249            .iter()
1250            .any(|i| i.value == "src/autocomplete/");
1251        assert!(
1252            has_autocomplete,
1253            "Should suggest src/autocomplete/, got: {:?}",
1254            suggestions
1255                .items
1256                .iter()
1257                .map(|i| &i.value)
1258                .collect::<Vec<_>>()
1259        );
1260    }
1261
1262    #[test]
1263    fn test_apply_completion_relative_path_with_folder() {
1264        let (_dir, root) = setup_path_test_dir();
1265        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1266
1267        // User typed "src/au", cursor at end. Accept proposal "src/autocomplete/"
1268        let item = AutocompleteItem {
1269            value: "src/autocomplete/".into(),
1270            label: "autocomplete/".into(),
1271            description: None,
1272        };
1273        let lines = vec!["src/au".into()];
1274        let (new_lines, new_line, new_col) =
1275            provider.apply_completion(&lines, 0, 6, &item, "src/au");
1276
1277        assert_eq!(
1278            new_lines[0], "src/autocomplete/",
1279            "Should replace src/au with src/autocomplete/"
1280        );
1281        assert_eq!(new_line, 0);
1282        assert_eq!(new_col, 17); // "src/autocomplete/".len() = 17
1283    }
1284
1285    #[test]
1286    fn test_apply_completion_relative_path_trailing_slash() {
1287        let (_dir, root) = setup_path_test_dir();
1288        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1289
1290        // User typed "src/", cursor at end. Accept proposal "src/autocomplete/"
1291        let item = AutocompleteItem {
1292            value: "src/autocomplete/".into(),
1293            label: "autocomplete/".into(),
1294            description: None,
1295        };
1296        let lines = vec!["src/".into()];
1297        let (new_lines, new_line, new_col) = provider.apply_completion(&lines, 0, 4, &item, "src/");
1298
1299        assert_eq!(
1300            new_lines[0], "src/autocomplete/",
1301            "Should replace src/ with src/autocomplete/"
1302        );
1303        assert_eq!(new_line, 0);
1304        assert_eq!(new_col, 17);
1305    }
1306
1307    #[test]
1308    fn test_apply_completion_at_prefix() {
1309        let (_dir, root) = setup_path_test_dir();
1310        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1311
1312        // User typed "@src/au", cursor at end. Accept proposal "src/autocomplete/"
1313        let item = AutocompleteItem {
1314            value: "src/autocomplete/".into(),
1315            label: "autocomplete/".into(),
1316            description: None,
1317        };
1318        let lines = vec!["@src/au".into()];
1319        // cursor_col = 7 (position after "@src/au"), prefix = "src/au" (without @)
1320        let (new_lines, new_line, new_col) =
1321            provider.apply_completion(&lines, 0, 7, &item, "src/au");
1322
1323        assert_eq!(
1324            new_lines[0], "@src/autocomplete/",
1325            "Should replace src/au with src/autocomplete/, keeping @ prefix"
1326        );
1327        assert_eq!(new_line, 0);
1328        assert_eq!(new_col, 18); // "@src/autocomplete/".len() = 18
1329    }
1330
1331    #[test]
1332    fn test_apply_completion_deep_path() {
1333        let (_dir, root) = setup_path_test_dir();
1334        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1335
1336        // User typed "src/components/s", cursor at end. Accept "src/components/select_list.rs"
1337        let item = AutocompleteItem {
1338            value: "src/components/select_list.rs".into(),
1339            label: "select_list.rs".into(),
1340            description: None,
1341        };
1342        let lines = vec!["src/components/s".into()];
1343        let (new_lines, new_line, new_col) =
1344            provider.apply_completion(&lines, 0, 16, &item, "src/components/s");
1345
1346        assert_eq!(
1347            new_lines[0], "src/components/select_list.rs ",
1348            "Should complete deep path correctly"
1349        );
1350        assert_eq!(new_line, 0);
1351        assert_eq!(new_col, 30); // "src/components/select_list.rs ".len() = 30
1352    }
1353
1354    #[test]
1355    fn test_apply_completion_at_prefix_deep_path() {
1356        let (_dir, root) = setup_path_test_dir();
1357        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1358
1359        // User typed "@src/components/s", cursor at end. Accept "src/components/select_list.rs"
1360        let item = AutocompleteItem {
1361            value: "src/components/select_list.rs".into(),
1362            label: "select_list.rs".into(),
1363            description: None,
1364        };
1365        let lines = vec!["@src/components/s".into()];
1366        // cursor_col = 17 (position after "@src/components/s"), prefix = "src/components/s"
1367        let (new_lines, new_line, new_col) =
1368            provider.apply_completion(&lines, 0, 17, &item, "src/components/s");
1369
1370        assert_eq!(
1371            new_lines[0], "@src/components/select_list.rs ",
1372            "Should complete deep @-path correctly"
1373        );
1374        assert_eq!(new_line, 0);
1375        assert_eq!(new_col, 31); // "@src/components/select_list.rs ".len() = 31
1376    }
1377
1378    #[test]
1379    fn test_apply_completion_after_folder_completion_then_deeper() {
1380        // Regression: after completing src/ -> src/autocomplete/, then typing more to go deeper
1381        let (_dir, root) = setup_path_test_dir();
1382        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1383
1384        // Step 1: complete src/ -> src/autocomplete/
1385        let item1 = AutocompleteItem {
1386            value: "src/autocomplete/".into(),
1387            label: "autocomplete/".into(),
1388            description: None,
1389        };
1390        let lines = vec!["src/".into()];
1391        let (new_lines, _, _) = provider.apply_completion(&lines, 0, 4, &item1, "src/");
1392        assert_eq!(new_lines[0], "src/autocomplete/");
1393
1394        // Step 2: user types more, now text is "src/autocomplete/m"
1395        let text = format!("{}m", new_lines[0]);
1396        let cursor_col = text.len(); // "src/autocomplete/m" is 18 chars
1397        let lines2 = vec![text];
1398        // Get suggestions
1399        let result = provider.get_suggestions(&lines2, 0, cursor_col, true);
1400        assert!(
1401            result.is_some(),
1402            "src/autocomplete/m should produce suggestions"
1403        );
1404        let suggestions = result.unwrap();
1405        assert_eq!(suggestions.prefix, "src/autocomplete/m");
1406
1407        // Should find "src/autocomplete/mod.rs"
1408        let has_mod = suggestions
1409            .items
1410            .iter()
1411            .any(|i| i.value == "src/autocomplete/mod.rs");
1412        assert!(
1413            has_mod,
1414            "Should suggest src/autocomplete/mod.rs, got: {:?}",
1415            suggestions
1416                .items
1417                .iter()
1418                .map(|i| &i.value)
1419                .collect::<Vec<_>>()
1420        );
1421
1422        // Step 3: accept the completion
1423        let item2 = AutocompleteItem {
1424            value: "src/autocomplete/mod.rs".into(),
1425            label: "mod.rs".into(),
1426            description: None,
1427        };
1428        let (final_lines, _, _) =
1429            provider.apply_completion(&lines2, 0, cursor_col, &item2, "src/autocomplete/m");
1430        assert_eq!(
1431            final_lines[0], "src/autocomplete/mod.rs ",
1432            "After completing deeper, should keep the full path"
1433        );
1434    }
1435
1436    /// Test that get_file_suggestions produces item values that, when passed
1437    /// back to apply_completion, produce the correct result (round-trip test).
1438    #[test]
1439    fn test_file_suggestions_roundtrip() {
1440        let (_dir, root) = setup_path_test_dir();
1441        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1442
1443        // Get suggestions for "src/au"
1444        let result = provider.get_file_suggestions("src/au").unwrap();
1445        assert_eq!(result.prefix, "src/au");
1446
1447        // For each suggestion, verify that apply_completion works correctly
1448        for item in &result.items {
1449            let lines = vec!["src/au".into()];
1450            let (new_lines, _, _) = provider.apply_completion(&lines, 0, 6, item, "src/au");
1451            let _expected_len = "src/au".len() + item.value.len() - "src/au".len();
1452            // The item.value should be the replacement text (replacing the prefix)
1453            // Since the prefix is at the start, the result should start with item.value
1454            assert!(
1455                new_lines[0].starts_with(item.value.trim_end_matches(' ')),
1456                "apply_completion({}, {:?}) should produce text starting with '{}', got '{}'",
1457                "src/au",
1458                item.value,
1459                item.value.trim_end_matches(' '),
1460                new_lines[0]
1461            );
1462        }
1463    }
1464
1465    #[test]
1466    fn test_at_suggestions_roundtrip() {
1467        let (_dir, root) = setup_path_test_dir();
1468        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1469
1470        // Get suggestions for "@src/au" (prefix should be "src/au")
1471        let lines = vec!["@src/au".into()];
1472        let result = provider.get_suggestions(&lines, 0, 7, false).unwrap();
1473        assert_eq!(result.prefix, "src/au");
1474
1475        // For each suggestion, verify that apply_completion works correctly
1476        for item in &result.items {
1477            let lines = vec!["@src/au".into()];
1478            let (new_lines, _, _) = provider.apply_completion(&lines, 0, 7, item, "src/au");
1479
1480            // The @ should be preserved, followed by the completion value
1481            assert!(
1482                new_lines[0].starts_with('@'),
1483                "apply_completion for @src/au should preserve @ prefix, got '{}'",
1484                new_lines[0]
1485            );
1486            // The @ should be followed by the completion value (minus trailing space)
1487            let after_at = &new_lines[0][1..];
1488            let trimmed = after_at.trim_end_matches(' ');
1489            assert_eq!(
1490                trimmed, item.value,
1491                "Text after @ should match item.value, got '{}' vs '{}'",
1492                trimmed, item.value
1493            );
1494        }
1495    }
1496
1497    #[test]
1498    fn test_tilde_path_completion_does_not_drop_folder() {
1499        // Regression: completing ~/.rab/agent/skills must NOT produce ~/.rab/skills/
1500        let (_dir, root) = setup_path_test_dir();
1501
1502        // Create a nested structure matching the user's scenario:
1503        //   temp/
1504        //     sub/
1505        //       deep/
1506        //         target/
1507        //           file.txt
1508        // To test: complete "sub/deep/tar" -> "sub/deep/target/"
1509        std::fs::create_dir_all(format!("{}/sub/deep/target", root)).unwrap();
1510        std::fs::write(format!("{}/sub/deep/target/file.txt", root), "").unwrap();
1511
1512        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1513
1514        // Test get_file_suggestions produces correct relative path
1515        let result = provider.get_file_suggestions("sub/deep/tar");
1516        assert!(result.is_some(), "sub/deep/tar should produce suggestions");
1517        let suggestions = result.unwrap();
1518        assert_eq!(suggestions.prefix, "sub/deep/tar");
1519
1520        let has_target = suggestions
1521            .items
1522            .iter()
1523            .any(|i| i.value == "sub/deep/target/");
1524        assert!(
1525            has_target,
1526            "Should suggest sub/deep/target/, not target/ alone. Got: {:?}",
1527            suggestions
1528                .items
1529                .iter()
1530                .map(|i| &i.value)
1531                .collect::<Vec<_>>()
1532        );
1533
1534        // Test apply_completion produces the full path
1535        let item = AutocompleteItem {
1536            value: "sub/deep/target/".into(),
1537            label: "target/".into(),
1538            description: None,
1539        };
1540        let lines = vec!["sub/deep/tar".into()];
1541        let (new_lines, _, _) = provider.apply_completion(&lines, 0, 12, &item, "sub/deep/tar");
1542        assert_eq!(
1543            new_lines[0], "sub/deep/target/",
1544            "Must produce sub/deep/target/ not target/ alone"
1545        );
1546    }
1547
1548    #[test]
1549    fn test_nested_path_with_get_suggestions_force() {
1550        let (_dir, root) = setup_path_test_dir();
1551
1552        std::fs::create_dir_all(format!("{}/sub/deep/target", root)).unwrap();
1553        std::fs::write(format!("{}/sub/deep/target/file.txt", root), "").unwrap();
1554
1555        let provider = CombinedAutocompleteProvider::new(vec![], root.clone());
1556
1557        // Simulate Tab (force) with "sub/deep/tar"
1558        let lines = vec!["sub/deep/tar".into()];
1559        let result = provider.get_suggestions(&lines, 0, 13, true);
1560        assert!(
1561            result.is_some(),
1562            "Force should trigger file completion for sub/deep/tar"
1563        );
1564        let suggestions = result.unwrap();
1565        assert_eq!(suggestions.prefix, "sub/deep/tar");
1566
1567        let has_target = suggestions
1568            .items
1569            .iter()
1570            .any(|i| i.value == "sub/deep/target/");
1571        assert!(
1572            has_target,
1573            "Force should suggest sub/deep/target/. Got: {:?}",
1574            suggestions
1575                .items
1576                .iter()
1577                .map(|i| &i.value)
1578                .collect::<Vec<_>>()
1579        );
1580    }
1581
1582    #[test]
1583    fn test_nested_path_with_tilde_prefix() {
1584        // Test that ~/ path completion preserves nested folders
1585        let home = std::env::var("HOME").unwrap_or_default();
1586        if home.is_empty() {
1587            return;
1588        }
1589
1590        // Create nested dir inside home
1591        let test_dir = std::path::Path::new(&home).join(".rab_test_autocomplete");
1592        let _ = std::fs::remove_dir_all(&test_dir);
1593        std::fs::create_dir_all(test_dir.join("sub/deep/target")).unwrap();
1594        std::fs::write(test_dir.join("sub/deep/target/file.txt"), "").unwrap();
1595
1596        // The CWD doesn't matter for ~/ paths since we use HOME
1597        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
1598
1599        let tilde_path = format!("~/.rab_test_autocomplete/sub/deep/tar");
1600        let result = provider.get_file_suggestions(&tilde_path);
1601        assert!(result.is_some(), "~/ path should produce suggestions");
1602        let suggestions = result.unwrap();
1603        assert_eq!(suggestions.prefix, tilde_path);
1604
1605        let expected_value = format!("~/.rab_test_autocomplete/sub/deep/target/");
1606        let has_target = suggestions.items.iter().any(|i| i.value == expected_value);
1607        assert!(
1608            has_target,
1609            "Should suggest ~/.rab_test_autocomplete/sub/deep/target/, not target/ alone. Got: {:?}",
1610            suggestions
1611                .items
1612                .iter()
1613                .map(|i| &i.value)
1614                .collect::<Vec<_>>()
1615        );
1616
1617        // Test apply_completion preserves the full ~/ path
1618        let item = AutocompleteItem {
1619            value: expected_value.clone(),
1620            label: "target/".into(),
1621            description: None,
1622        };
1623        let lines = vec![tilde_path.clone()];
1624        let cursor_col = tilde_path.len();
1625        let (new_lines, _, _) =
1626            provider.apply_completion(&lines, 0, cursor_col, &item, &tilde_path);
1627        assert_eq!(
1628            new_lines[0], expected_value,
1629            "Must preserve full ~/ path, not drop folders"
1630        );
1631
1632        // Clean up
1633        let _ = std::fs::remove_dir_all(&test_dir);
1634    }
1635
1636    #[test]
1637    fn test_tilde_path_with_trailing_slash_preserves_folder() {
1638        // Regression: completing "~/.rab/agent/" and selecting "skills"
1639        // should produce "~/.rab/agent/skills/", not "~/.rab/skills/"
1640        let home = std::env::var("HOME").unwrap_or_default();
1641        if home.is_empty() {
1642            return;
1643        }
1644
1645        let test_dir = std::path::Path::new(&home).join(".rab_test_trailing");
1646        let _ = std::fs::remove_dir_all(&test_dir);
1647        // Create: ~/test_trailing/sub/deep/target/
1648        std::fs::create_dir_all(test_dir.join("sub/deep/target")).unwrap();
1649        std::fs::write(test_dir.join("sub/deep/target/file.txt"), "").unwrap();
1650
1651        let provider = CombinedAutocompleteProvider::new(vec![], "/tmp".into());
1652
1653        // User typed "~/.rab_test_trailing/sub/deep/" (trailing slash)
1654        let tilde_path = format!("~/.rab_test_trailing/sub/deep/");
1655        let result = provider.get_file_suggestions(&tilde_path);
1656        assert!(
1657            result.is_some(),
1658            "~/ path with trailing slash should produce suggestions"
1659        );
1660        let suggestions = result.unwrap();
1661        assert_eq!(suggestions.prefix, tilde_path);
1662
1663        // The suggestion value should include the full path, not just the last component
1664        let expected_value = format!("~/.rab_test_trailing/sub/deep/target/");
1665        let has_target = suggestions.items.iter().any(|i| i.value == expected_value);
1666        assert!(
1667            has_target,
1668            "Must suggest full path ~/.rab_test_trailing/sub/deep/target/, not target/ alone. Got: {:?}",
1669            suggestions
1670                .items
1671                .iter()
1672                .map(|i| &i.value)
1673                .collect::<Vec<_>>()
1674        );
1675
1676        // Test apply_completion with this prefix
1677        let item = AutocompleteItem {
1678            value: expected_value.clone(),
1679            label: "target/".into(),
1680            description: None,
1681        };
1682        let lines = vec![tilde_path.clone()];
1683        let cursor_col = tilde_path.len();
1684        let (new_lines, _, _) =
1685            provider.apply_completion(&lines, 0, cursor_col, &item, &tilde_path);
1686        assert_eq!(
1687            new_lines[0], expected_value,
1688            "Must produce full path, not drop the last folder"
1689        );
1690
1691        // Clean up
1692        let _ = std::fs::remove_dir_all(&test_dir);
1693    }
1694}