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