1use std::path::Path;
2use std::process::{Command, Stdio};
3use std::sync::Arc;
4
5use crate::tui::components::select_list::SelectItem;
6
7#[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#[derive(Debug, Clone)]
27pub struct AutocompleteSuggestions {
28 pub items: Vec<AutocompleteItem>,
29 pub prefix: String,
31}
32
33#[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 pub argument_completions: Option<Vec<AutocompleteItem>>,
44 pub get_argument_completions: Option<Arc<dyn Fn(&str) -> Vec<AutocompleteItem> + Send + Sync>>,
48}
49
50pub trait AutocompleteProvider {
52 fn trigger_characters(&self) -> &[char];
54
55 fn get_suggestions(
58 &self,
59 lines: &[String],
60 cursor_line: usize,
61 cursor_col: usize,
62 force: bool,
63 ) -> Option<AutocompleteSuggestions>;
64
65 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 fn should_trigger_file_completion(
77 &self,
78 lines: &[String],
79 cursor_line: usize,
80 cursor_col: usize,
81 ) -> bool;
82}
83
84fn 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
101fn 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
125fn 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
191fn 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
217const PATH_DELIMITERS: &[char] = &[' ', '\t', '"', '\'', '='];
220
221fn 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 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 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
256fn 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
270fn 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
293pub 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 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 (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 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 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 = ¤t_line[..cursor_col.min(current_line.len())];
556
557 if text_before.starts_with('/') && !text_before.contains(' ') {
559 let cmd = &text_before[1..];
560 return self.get_slash_suggestions(cmd);
561 }
562
563 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 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 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 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 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 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 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.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 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 = ¤t_line[..prefix_start];
670 let after = ¤t_line[cursor_col..];
671
672 let (new_line, new_col) = if prefix.starts_with('/') {
673 (
675 format!("{}/{} {}", before, item.value, after),
676 before.len() + 1 + item.value.len() + 1,
677 )
678 } else {
679 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}