1use std::cmp::Ordering;
15use std::ops::Range;
16use std::path::{Path, PathBuf};
17use std::sync::OnceLock;
18use std::time::{Duration, Instant};
19
20use crate::resources::ResourceLoader;
21use ignore::WalkBuilder;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AutocompleteItemKind {
25 SlashCommand,
26 ExtensionCommand,
27 PromptTemplate,
28 Skill,
29 Model,
30 File,
31 Path,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AutocompleteItem {
36 pub kind: AutocompleteItemKind,
37 pub label: String,
38 pub insert: String,
39 pub description: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct AutocompleteResponse {
44 pub replace: Range<usize>,
45 pub items: Vec<AutocompleteItem>,
46}
47
48#[derive(Debug, Clone, Default)]
49pub struct AutocompleteCatalog {
50 pub prompt_templates: Vec<NamedEntry>,
51 pub skills: Vec<NamedEntry>,
52 pub extension_commands: Vec<NamedEntry>,
53 pub enable_skill_commands: bool,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct NamedEntry {
58 pub name: String,
59 pub description: Option<String>,
60}
61
62impl AutocompleteCatalog {
63 #[must_use]
64 pub fn from_resources(resources: &ResourceLoader) -> Self {
65 let mut prompt_templates = resources
66 .prompts()
67 .iter()
68 .map(|template| NamedEntry {
69 name: template.name.clone(),
70 description: Some(template.description.clone()).filter(|d| !d.trim().is_empty()),
71 })
72 .collect::<Vec<_>>();
73
74 prompt_templates.sort_by(|a, b| a.name.cmp(&b.name));
75
76 let mut skills = resources
77 .skills()
78 .iter()
79 .map(|skill| NamedEntry {
80 name: skill.name.clone(),
81 description: Some(skill.description.clone()).filter(|d| !d.trim().is_empty()),
82 })
83 .collect::<Vec<_>>();
84
85 skills.sort_by(|a, b| a.name.cmp(&b.name));
86
87 Self {
88 prompt_templates,
89 skills,
90 extension_commands: Vec::new(),
91 enable_skill_commands: resources.enable_skill_commands(),
92 }
93 }
94}
95
96#[derive(Debug)]
97pub struct AutocompleteProvider {
98 cwd: PathBuf,
99 home_dir_override: Option<PathBuf>,
100 catalog: AutocompleteCatalog,
101 file_cache: FileCache,
102 max_items: usize,
103}
104
105impl AutocompleteProvider {
106 #[must_use]
107 pub const fn new(cwd: PathBuf, catalog: AutocompleteCatalog) -> Self {
108 Self {
109 cwd,
110 home_dir_override: None,
111 catalog,
112 file_cache: FileCache::new(),
113 max_items: 50,
114 }
115 }
116
117 pub fn set_catalog(&mut self, catalog: AutocompleteCatalog) {
118 self.catalog = catalog;
119 }
120
121 pub fn set_cwd(&mut self, cwd: PathBuf) {
122 self.cwd = cwd;
123 self.file_cache.invalidate();
124 }
125
126 pub const fn max_items(&self) -> usize {
127 self.max_items
128 }
129
130 pub fn set_max_items(&mut self, max_items: usize) {
131 self.max_items = max_items.max(1);
132 }
133
134 pub(crate) fn refresh_background(&mut self) {
135 self.file_cache.refresh_if_needed(&self.cwd);
136 }
137
138 #[must_use]
144 pub fn suggest(&mut self, text: &str, cursor: usize) -> AutocompleteResponse {
145 let cursor = clamp_cursor(text, cursor);
146 if let Some(token) = auth_provider_argument_token(text, cursor) {
147 return self.suggest_auth_provider_argument(&token);
148 }
149 if let Some(token) = model_argument_token(text, cursor) {
150 return self.suggest_model_argument(&token);
151 }
152 let segment = token_at_cursor(text, cursor);
153
154 if segment.text.starts_with('/') {
155 let path_response = self.suggest_path(&segment);
156 if should_prefer_absolute_path_completion(segment.text, &path_response) {
157 return path_response;
158 }
159 return self.suggest_slash(&segment);
160 }
161
162 if segment.text.starts_with('@') {
163 return self.suggest_file_ref(&segment);
164 }
165
166 if is_path_like(segment.text) {
167 return self.suggest_path(&segment);
168 }
169
170 AutocompleteResponse {
171 replace: cursor..cursor,
172 items: Vec::new(),
173 }
174 }
175
176 pub(crate) fn resolve_file_ref(&mut self, candidate: &str) -> Option<String> {
177 let normalized = normalize_file_ref_candidate(candidate);
178 if normalized.is_empty() {
179 return None;
180 }
181
182 if is_absolute_like(&normalized) {
183 return Some(normalized);
184 }
185
186 self.file_cache.refresh_if_needed(&self.cwd);
187 let stripped = normalized.strip_prefix("./").unwrap_or(&normalized);
188 if self.file_cache.files.iter().any(|path| path == stripped) {
189 return Some(stripped.to_string());
190 }
191
192 None
193 }
194
195 #[allow(clippy::too_many_lines)]
196 fn suggest_slash(&self, token: &TokenAtCursor<'_>) -> AutocompleteResponse {
197 let query = token.text.trim_start_matches('/');
198
199 if let Some(skill_query) = query.strip_prefix("skill:") {
201 if !self.catalog.enable_skill_commands {
202 return AutocompleteResponse {
203 replace: token.range.clone(),
204 items: Vec::new(),
205 };
206 }
207
208 let mut items = self
209 .catalog
210 .skills
211 .iter()
212 .filter_map(|skill| {
213 let (is_prefix, score) = fuzzy_match_score(&skill.name, skill_query)?;
214 Some(ScoredItem {
215 is_prefix,
216 score,
217 kind_rank: kind_rank(AutocompleteItemKind::Skill),
218 label: format!("/skill:{}", skill.name),
219 item: AutocompleteItem {
220 kind: AutocompleteItemKind::Skill,
221 label: format!("/skill:{}", skill.name),
222 insert: format!("/skill:{}", skill.name),
223 description: skill.description.clone(),
224 },
225 })
226 })
227 .collect::<Vec<_>>();
228
229 sort_scored_items(&mut items);
230 let items = items
231 .into_iter()
232 .take(self.max_items)
233 .map(|s| s.item)
234 .collect();
235
236 return AutocompleteResponse {
237 replace: token.range.clone(),
238 items,
239 };
240 }
241
242 let mut items = Vec::new();
243
244 for cmd in builtin_slash_commands() {
246 if let Some((is_prefix, score)) = fuzzy_match_score(cmd.name, query) {
247 let label = format!("/{}", cmd.name);
248 items.push(ScoredItem {
249 is_prefix,
250 score,
251 kind_rank: kind_rank(AutocompleteItemKind::SlashCommand),
252 label: label.clone(),
253 item: AutocompleteItem {
254 kind: AutocompleteItemKind::SlashCommand,
255 label: label.clone(),
256 insert: label,
257 description: Some(cmd.description.to_string()),
258 },
259 });
260 }
261 }
262
263 for cmd in &self.catalog.extension_commands {
265 if let Some((is_prefix, score)) = fuzzy_match_score(&cmd.name, query) {
266 let label = format!("/{}", cmd.name);
267 items.push(ScoredItem {
268 is_prefix,
269 score,
270 kind_rank: kind_rank(AutocompleteItemKind::ExtensionCommand),
271 label: label.clone(),
272 item: AutocompleteItem {
273 kind: AutocompleteItemKind::ExtensionCommand,
274 label: label.clone(),
275 insert: label,
276 description: cmd.description.clone(),
277 },
278 });
279 }
280 }
281
282 for template in &self.catalog.prompt_templates {
284 if let Some((is_prefix, score)) = fuzzy_match_score(&template.name, query) {
285 let label = format!("/{}", template.name);
286 items.push(ScoredItem {
287 is_prefix,
288 score,
289 kind_rank: kind_rank(AutocompleteItemKind::PromptTemplate),
290 label: label.clone(),
291 item: AutocompleteItem {
292 kind: AutocompleteItemKind::PromptTemplate,
293 label: label.clone(),
294 insert: label,
295 description: template.description.clone(),
296 },
297 });
298 }
299 }
300
301 sort_scored_items(&mut items);
302 let items = items
303 .into_iter()
304 .take(self.max_items)
305 .map(|s| s.item)
306 .collect();
307
308 AutocompleteResponse {
309 replace: token.range.clone(),
310 items,
311 }
312 }
313
314 fn suggest_file_ref(&mut self, token: &TokenAtCursor<'_>) -> AutocompleteResponse {
315 let query = token.text.strip_prefix('@').unwrap_or(token.text);
316 self.file_cache.refresh_if_needed(&self.cwd);
317
318 let mut items = self
319 .file_cache
320 .files
321 .iter()
322 .filter_map(|path| {
323 let (is_prefix, score) = fuzzy_match_score(path, query)?;
324 let label = format!("@{path}");
325 Some(ScoredItem {
326 is_prefix,
327 score,
328 kind_rank: kind_rank(AutocompleteItemKind::File),
329 label: label.clone(),
330 item: AutocompleteItem {
331 kind: AutocompleteItemKind::File,
332 label: label.clone(),
333 insert: label,
334 description: None,
335 },
336 })
337 })
338 .collect::<Vec<_>>();
339
340 sort_scored_items(&mut items);
341 let items = items
342 .into_iter()
343 .take(self.max_items)
344 .map(|s| s.item)
345 .collect();
346
347 AutocompleteResponse {
348 replace: token.range.clone(),
349 items,
350 }
351 }
352
353 fn suggest_path(&self, token: &TokenAtCursor<'_>) -> AutocompleteResponse {
354 let raw = token.text.trim();
355 let (dir_part_raw, base_part) = split_path_prefix(raw);
356 let separator = preferred_path_separator(raw, &dir_part_raw);
357
358 let Some(dir_path) =
359 resolve_dir_path(&self.cwd, &dir_part_raw, self.home_dir_override.as_deref())
360 else {
361 return AutocompleteResponse {
362 replace: token.range.clone(),
363 items: Vec::new(),
364 };
365 };
366
367 let mut items = Vec::new();
368 for entry in WalkBuilder::new(&dir_path)
369 .require_git(false)
370 .max_depth(Some(1))
371 .build()
372 .filter_map(Result::ok)
373 {
374 if entry.depth() != 1 {
375 continue;
376 }
377
378 let Some(file_name) = entry.file_name().to_str() else {
379 continue;
380 };
381
382 if !base_part.is_empty() && !file_name.starts_with(base_part.as_str()) {
383 continue;
384 }
385
386 let mut insert = if dir_part_raw == "." {
387 if raw.starts_with("./") || raw.starts_with(".\\") {
388 format!(".{separator}{file_name}")
389 } else {
390 file_name.to_string()
391 }
392 } else if dir_part_raw.ends_with('/') || dir_part_raw.ends_with('\\') {
393 format!("{dir_part_raw}{file_name}")
394 } else {
395 format!("{dir_part_raw}{separator}{file_name}")
396 };
397
398 let is_dir = entry.file_type().is_some_and(|ty| ty.is_dir());
399 if is_dir {
400 insert.push(separator);
401 }
402
403 let label = insert.clone();
404 items.push(ScoredItem {
405 is_prefix: true,
406 score: 0,
407 kind_rank: kind_rank(AutocompleteItemKind::Path),
408 label: label.clone(),
409 item: AutocompleteItem {
410 kind: AutocompleteItemKind::Path,
411 label,
412 insert,
413 description: None,
414 },
415 });
416 }
417
418 sort_scored_items(&mut items);
419 let items = items
420 .into_iter()
421 .take(self.max_items)
422 .map(|s| s.item)
423 .collect();
424
425 AutocompleteResponse {
426 replace: token.range.clone(),
427 items,
428 }
429 }
430
431 fn suggest_model_argument(&self, token: &TokenAtCursor<'_>) -> AutocompleteResponse {
432 let query = token.text.trim();
433 let mut items = crate::models::model_autocomplete_candidates()
434 .iter()
435 .filter_map(|candidate| {
436 let (is_prefix, score) = fuzzy_match_score(&candidate.slug, query)?;
437 Some(ScoredItem {
438 is_prefix,
439 score,
440 kind_rank: kind_rank(AutocompleteItemKind::Model),
441 label: candidate.slug.clone(),
442 item: AutocompleteItem {
443 kind: AutocompleteItemKind::Model,
444 label: candidate.slug.clone(),
445 insert: candidate.slug.clone(),
446 description: candidate.description.clone(),
447 },
448 })
449 })
450 .collect::<Vec<_>>();
451
452 sort_scored_items(&mut items);
453 let items = items
454 .into_iter()
455 .take(self.max_items)
456 .map(|s| s.item)
457 .collect();
458
459 AutocompleteResponse {
460 replace: token.range.clone(),
461 items,
462 }
463 }
464
465 fn suggest_auth_provider_argument(&self, token: &TokenAtCursor<'_>) -> AutocompleteResponse {
466 let query = token.text.trim();
467 let mut items = Vec::new();
468
469 for meta in crate::provider_metadata::PROVIDER_METADATA {
470 if let Some((is_prefix, score)) = fuzzy_match_score(meta.canonical_id, query) {
471 items.push(ScoredItem {
472 is_prefix,
473 score,
474 kind_rank: kind_rank(AutocompleteItemKind::SlashCommand),
475 label: meta.canonical_id.to_string(),
476 item: AutocompleteItem {
477 kind: AutocompleteItemKind::SlashCommand,
478 label: meta.canonical_id.to_string(),
479 insert: meta.canonical_id.to_string(),
480 description: meta
481 .display_name
482 .map(|name| format!("Provider: {name}"))
483 .or_else(|| Some("Provider".to_string())),
484 },
485 });
486 }
487
488 for alias in meta.aliases {
489 if let Some((is_prefix, score)) = fuzzy_match_score(alias, query) {
490 items.push(ScoredItem {
491 is_prefix,
492 score,
493 kind_rank: kind_rank(AutocompleteItemKind::SlashCommand),
494 label: alias.to_string(),
495 item: AutocompleteItem {
496 kind: AutocompleteItemKind::SlashCommand,
497 label: alias.to_string(),
498 insert: alias.to_string(),
499 description: Some(format!("Alias for {}", meta.canonical_id)),
500 },
501 });
502 }
503 }
504 }
505
506 sort_scored_items(&mut items);
507 let mut dedup = std::collections::HashSet::new();
508 let items = items
509 .into_iter()
510 .filter(|entry| dedup.insert(entry.item.insert.clone()))
511 .take(self.max_items)
512 .map(|s| s.item)
513 .collect();
514
515 AutocompleteResponse {
516 replace: token.range.clone(),
517 items,
518 }
519 }
520}
521
522#[derive(Debug)]
523struct FileCache {
524 files: Vec<String>,
525 last_update_request: Option<Instant>,
526 update_rx: Option<std::sync::mpsc::Receiver<Vec<String>>>,
527 updating: bool,
528}
529
530impl FileCache {
531 const TTL: Duration = Duration::from_secs(30);
532
533 const fn new() -> Self {
534 Self {
535 files: Vec::new(),
536 last_update_request: None,
537 update_rx: None,
538 updating: false,
539 }
540 }
541
542 fn invalidate(&mut self) {
543 self.files.clear();
544 self.last_update_request = None;
545 self.update_rx = None;
547 self.updating = false;
548 }
549
550 fn refresh_if_needed(&mut self, cwd: &Path) {
551 if let Some(rx) = &self.update_rx {
553 match rx.try_recv() {
554 Ok(files) => {
555 self.files = files;
556 self.updating = false;
557 }
558 Err(std::sync::mpsc::TryRecvError::Empty) => {}
559 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
560 self.updating = false;
561 self.update_rx = None;
562 }
563 }
564 }
565
566 let now = Instant::now();
567 let is_fresh = self
568 .last_update_request
569 .is_some_and(|t| now.duration_since(t) <= Self::TTL);
570
571 if !is_fresh && !self.updating {
572 self.updating = true;
573 self.last_update_request = Some(now);
574 let cwd_buf = cwd.to_path_buf();
575 let (tx, rx) = std::sync::mpsc::channel();
576 self.update_rx = Some(rx);
577
578 std::thread::spawn(move || {
579 let files = collect_project_files(&cwd_buf);
580 let _ = tx.send(files);
581 });
582 }
583 }
584}
585
586const MAX_FILE_CACHE_ENTRIES: usize = 5000;
587
588fn collect_project_files(cwd: &Path) -> Vec<String> {
589 let mut files = find_fd_binary().map_or_else(
590 || walk_project_files(cwd),
591 |bin| run_fd_list_files(bin, cwd).unwrap_or_else(|| walk_project_files(cwd)),
592 );
593
594 if files.len() > MAX_FILE_CACHE_ENTRIES {
595 files.truncate(MAX_FILE_CACHE_ENTRIES);
596 }
597 files
598}
599
600fn normalize_file_ref_candidate(candidate: &str) -> String {
601 candidate.trim().replace('\\', "/")
602}
603
604fn is_absolute_like(candidate: &str) -> bool {
605 if candidate.is_empty() {
606 return false;
607 }
608 if candidate.starts_with('~') {
609 return true;
610 }
611 if candidate.starts_with("//") {
612 return true;
613 }
614 if Path::new(candidate).is_absolute() {
615 return true;
616 }
617 candidate.as_bytes().get(1) == Some(&b':')
618}
619
620static FD_BINARY_CACHE: OnceLock<Option<&'static str>> = OnceLock::new();
623
624fn find_fd_binary() -> Option<&'static str> {
625 *FD_BINARY_CACHE.get_or_init(|| {
626 ["fd", "fdfind"].into_iter().find(|&candidate| {
627 std::process::Command::new(candidate)
628 .arg("--version")
629 .stdin(std::process::Stdio::null())
630 .stdout(std::process::Stdio::null())
631 .stderr(std::process::Stdio::null())
632 .status()
633 .is_ok()
634 })
635 })
636}
637
638fn run_fd_list_files(bin: &str, cwd: &Path) -> Option<Vec<String>> {
639 let output = std::process::Command::new(bin)
640 .current_dir(cwd)
641 .arg("--type")
642 .arg("f")
643 .arg("--strip-cwd-prefix")
644 .output()
645 .ok()?;
646
647 if !output.status.success() {
648 return None;
649 }
650
651 let stdout = String::from_utf8_lossy(&output.stdout);
652 let mut files = stdout
653 .lines()
654 .map(str::trim)
655 .filter(|line| !line.is_empty())
656 .map(|line| line.replace('\\', "/"))
657 .collect::<Vec<_>>();
658 files.sort();
659 files.dedup();
660 Some(files)
661}
662
663fn walk_project_files(cwd: &Path) -> Vec<String> {
664 let mut files = Vec::new();
665
666 let walker = ignore::WalkBuilder::new(cwd)
667 .hidden(false)
668 .follow_links(false)
669 .standard_filters(true)
670 .build();
671
672 for entry in walker.flatten() {
673 let path = entry.path();
674 if !entry.file_type().is_some_and(|ty| ty.is_file()) {
675 continue;
676 }
677 if let Ok(rel) = path.strip_prefix(cwd) {
678 let rel = rel.display().to_string().replace('\\', "/");
679 if !rel.is_empty() && !rel.starts_with("..") {
680 files.push(rel);
681 }
682 }
683 }
684
685 files.sort();
686 files.dedup();
687 files
688}
689
690#[derive(Debug, Clone, Copy)]
691struct BuiltinSlashCommand {
692 name: &'static str,
693 description: &'static str,
694}
695
696const fn builtin_slash_commands() -> &'static [BuiltinSlashCommand] {
697 &[
698 BuiltinSlashCommand {
699 name: "help",
700 description: "Show help for interactive commands",
701 },
702 BuiltinSlashCommand {
703 name: "login",
704 description: "OAuth login (provider-specific)",
705 },
706 BuiltinSlashCommand {
707 name: "logout",
708 description: "Remove stored OAuth credentials",
709 },
710 BuiltinSlashCommand {
711 name: "clear",
712 description: "Clear conversation history",
713 },
714 BuiltinSlashCommand {
715 name: "model",
716 description: "Show or change the current model",
717 },
718 BuiltinSlashCommand {
719 name: "thinking",
720 description: "Set thinking level (off/minimal/low/medium/high/xhigh)",
721 },
722 BuiltinSlashCommand {
723 name: "scoped-models",
724 description: "Show or set model scope patterns",
725 },
726 BuiltinSlashCommand {
727 name: "exit",
728 description: "Exit Pi",
729 },
730 BuiltinSlashCommand {
731 name: "history",
732 description: "Show input history",
733 },
734 BuiltinSlashCommand {
735 name: "export",
736 description: "Export conversation to HTML",
737 },
738 BuiltinSlashCommand {
739 name: "session",
740 description: "Show session info",
741 },
742 BuiltinSlashCommand {
743 name: "settings",
744 description: "Show current settings summary",
745 },
746 BuiltinSlashCommand {
747 name: "theme",
748 description: "List or switch themes",
749 },
750 BuiltinSlashCommand {
751 name: "resume",
752 description: "Pick and resume a previous session",
753 },
754 BuiltinSlashCommand {
755 name: "new",
756 description: "Start a new session",
757 },
758 BuiltinSlashCommand {
759 name: "copy",
760 description: "Copy last assistant message to clipboard",
761 },
762 BuiltinSlashCommand {
763 name: "name",
764 description: "Set session display name",
765 },
766 BuiltinSlashCommand {
767 name: "hotkeys",
768 description: "Show keyboard shortcuts",
769 },
770 BuiltinSlashCommand {
771 name: "changelog",
772 description: "Show changelog entries",
773 },
774 BuiltinSlashCommand {
775 name: "tree",
776 description: "Show session branch tree summary",
777 },
778 BuiltinSlashCommand {
779 name: "fork",
780 description: "Branch from a previous user message",
781 },
782 BuiltinSlashCommand {
783 name: "compact",
784 description: "Compact older context",
785 },
786 BuiltinSlashCommand {
787 name: "reload",
788 description: "Reload resources from disk",
789 },
790 BuiltinSlashCommand {
791 name: "share",
792 description: "Export to a temp HTML file and show path",
793 },
794 ]
795}
796
797const fn kind_rank(kind: AutocompleteItemKind) -> u8 {
798 match kind {
799 AutocompleteItemKind::SlashCommand => 0,
800 AutocompleteItemKind::ExtensionCommand => 1,
801 AutocompleteItemKind::PromptTemplate => 2,
802 AutocompleteItemKind::Skill => 3,
803 AutocompleteItemKind::Model => 4,
804 AutocompleteItemKind::File => 5,
805 AutocompleteItemKind::Path => 6,
806 }
807}
808
809#[derive(Debug)]
810struct ScoredItem {
811 is_prefix: bool,
812 score: i32,
813 kind_rank: u8,
814 label: String,
815 item: AutocompleteItem,
816}
817
818fn sort_scored_items(items: &mut [ScoredItem]) {
819 items.sort_by(|a, b| {
820 let prefix_cmp = b.is_prefix.cmp(&a.is_prefix);
821 if prefix_cmp != Ordering::Equal {
822 return prefix_cmp;
823 }
824 let score_cmp = b.score.cmp(&a.score);
825 if score_cmp != Ordering::Equal {
826 return score_cmp;
827 }
828 let kind_cmp = a.kind_rank.cmp(&b.kind_rank);
829 if kind_cmp != Ordering::Equal {
830 return kind_cmp;
831 }
832 a.label.cmp(&b.label)
833 });
834}
835
836fn clamp_usize_to_i32(value: usize) -> i32 {
837 i32::try_from(value).unwrap_or(i32::MAX)
838}
839
840fn fuzzy_match_score(candidate: &str, query: &str) -> Option<(bool, i32)> {
841 let query = query.trim();
842 if query.is_empty() {
843 return Some((true, 0));
844 }
845
846 let cand = candidate.to_ascii_lowercase();
847 let query = query.to_ascii_lowercase();
848
849 if cand.starts_with(&query) {
850 let penalty =
852 clamp_usize_to_i32(cand.len()).saturating_sub(clamp_usize_to_i32(query.len()));
853 return Some((true, 1_000 - penalty));
854 }
855
856 if let Some(idx) = cand.find(&query) {
857 return Some((false, 700 - clamp_usize_to_i32(idx)));
858 }
859
860 let mut score = 500i32;
862 let mut search_from = 0usize;
863 for q in query.chars() {
864 let pos = cand[search_from..].find(q)?;
865 let abs = search_from + pos;
866 let gap = clamp_usize_to_i32(abs.saturating_sub(search_from));
867 score -= gap;
868 search_from = abs + q.len_utf8();
869 }
870
871 score -= clamp_usize_to_i32(cand.len()) / 10;
873 Some((false, score))
874}
875
876fn is_path_like(text: &str) -> bool {
877 let text = text.trim();
878 if text.is_empty() {
879 return false;
880 }
881 if text.starts_with('~') {
882 return true;
883 }
884 text.starts_with("./")
885 || text.starts_with(".\\")
886 || text.starts_with("../")
887 || text.starts_with("..\\")
888 || text.starts_with("~/")
889 || text.starts_with("~\\")
890 || text.starts_with('/')
891 || text.starts_with('\\')
892 || text.contains('/')
893 || text.contains('\\')
894}
895
896fn expand_tilde(text: &str) -> String {
897 let text = text.trim();
898 if let Some(rest) = text.strip_prefix("~/") {
899 if let Some(home) = dirs::home_dir() {
900 return home.join(rest).display().to_string();
901 }
902 }
903 text.to_string()
904}
905
906fn resolve_dir_path(cwd: &Path, dir_part: &str, home_override: Option<&Path>) -> Option<PathBuf> {
907 let dir_part = dir_part.trim();
908 let home_dir = || home_override.map(Path::to_path_buf).or_else(dirs::home_dir);
909
910 if dir_part == "~" {
911 return home_dir();
912 }
913 if let Some(rest) = dir_part
914 .strip_prefix("~/")
915 .or_else(|| dir_part.strip_prefix("~\\"))
916 {
917 return home_dir().map(|home| home.join(rest));
918 }
919 if is_absolute_dir_path_like(dir_part) {
920 return Some(PathBuf::from(dir_part));
921 }
922
923 Some(cwd.join(dir_part))
924}
925
926fn split_path_prefix(path: &str) -> (String, String) {
927 let path = path.trim();
928 if path == "~" {
929 return ("~".to_string(), String::new());
930 }
931 if path.ends_with('/') || path.ends_with('\\') {
932 return (path.to_string(), String::new());
933 }
934 let Some(separator_index) = path.rfind(['/', '\\']) else {
935 return (".".to_string(), path.to_string());
936 };
937
938 let separator = path[separator_index..].chars().next().unwrap_or('/');
939 let base_start = separator_index + separator.len_utf8();
940 let base = path[base_start..].to_string();
941
942 let dir = if separator_index == 0 {
943 separator.to_string()
944 } else if separator_index == 2 && path.as_bytes().get(1) == Some(&b':') {
945 path[..base_start].to_string()
946 } else {
947 path[..separator_index].to_string()
948 };
949
950 (dir, base)
951}
952
953fn is_absolute_dir_path_like(path: &str) -> bool {
954 if path.is_empty() {
955 return false;
956 }
957
958 if Path::new(path).is_absolute() {
959 return true;
960 }
961
962 path.starts_with('/')
963 || path.starts_with('\\')
964 || (path.as_bytes().get(1) == Some(&b':')
965 && matches!(path.as_bytes().get(2), Some(&b'/' | &b'\\')))
966}
967
968fn preferred_path_separator(raw: &str, dir_part_raw: &str) -> char {
969 if raw.contains('\\') || dir_part_raw.contains('\\') {
970 '\\'
971 } else {
972 '/'
973 }
974}
975
976#[derive(Debug, Clone)]
977struct TokenAtCursor<'a> {
978 text: &'a str,
979 range: Range<usize>,
980}
981
982fn token_at_cursor(text: &str, cursor: usize) -> TokenAtCursor<'_> {
983 let cursor = clamp_cursor(text, cursor);
984
985 let start = text[..cursor].rfind(char::is_whitespace).map_or(0, |idx| {
986 idx + text[idx..].chars().next().unwrap_or(' ').len_utf8()
987 });
988 let end = text[cursor..]
989 .find(char::is_whitespace)
990 .map_or(text.len(), |idx| cursor + idx);
991
992 let start = clamp_to_char_boundary(text, start.min(end));
993 let end = clamp_to_char_boundary(text, end.max(start));
994
995 TokenAtCursor {
996 text: &text[start..end],
997 range: start..end,
998 }
999}
1000
1001fn slash_first_argument_token<'a>(
1002 text: &'a str,
1003 cursor: usize,
1004 commands: &[&str],
1005) -> Option<TokenAtCursor<'a>> {
1006 let cursor = clamp_cursor(text, cursor);
1007 let line_start = text[..cursor].rfind('\n').map_or(0, |idx| idx + 1);
1008 let prefix = &text[line_start..cursor];
1009 let trimmed = prefix.trim_start();
1010 let leading_ws = prefix.len().saturating_sub(trimmed.len());
1011
1012 let command = commands.iter().copied().find(|command| {
1013 trimmed.starts_with(command)
1014 && text
1015 .get(line_start + leading_ws + command.len()..)
1016 .and_then(|tail| tail.chars().next())
1017 .is_none_or(char::is_whitespace)
1018 })?;
1019
1020 let command_end = line_start + leading_ws + command.len();
1021 if cursor <= command_end {
1022 return None;
1023 }
1024
1025 let segment = token_at_cursor(text, cursor);
1026 if text[command_end..segment.range.start]
1027 .chars()
1028 .any(|ch| !ch.is_whitespace())
1029 {
1030 return None;
1031 }
1032
1033 Some(segment)
1034}
1035
1036fn model_argument_token(text: &str, cursor: usize) -> Option<TokenAtCursor<'_>> {
1037 slash_first_argument_token(text, cursor, &["/model", "/m"])
1038}
1039
1040fn auth_provider_argument_token(text: &str, cursor: usize) -> Option<TokenAtCursor<'_>> {
1041 slash_first_argument_token(text, cursor, &["/login", "/logout"])
1042}
1043
1044fn should_prefer_absolute_path_completion(
1045 token_text: &str,
1046 path_response: &AutocompleteResponse,
1047) -> bool {
1048 let token_text = token_text.trim();
1049 if !token_text.starts_with('/') {
1050 return false;
1051 }
1052
1053 if token_text == "/" {
1054 return false;
1055 }
1056
1057 if token_text.starts_with("/.") || token_text[1..].contains('/') {
1058 return true;
1059 }
1060
1061 path_response
1062 .items
1063 .iter()
1064 .any(|item| item.insert.starts_with(token_text))
1065}
1066
1067fn clamp_cursor(text: &str, cursor: usize) -> usize {
1068 clamp_to_char_boundary(text, cursor.min(text.len()))
1069}
1070
1071fn clamp_to_char_boundary(text: &str, mut idx: usize) -> usize {
1072 while idx > 0 && !text.is_char_boundary(idx) {
1073 idx -= 1;
1074 }
1075 idx
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn slash_suggests_builtins() {
1084 let mut provider =
1085 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1086 let resp = provider.suggest("/he", 3);
1087 assert_eq!(resp.replace, 0..3);
1088 assert!(
1089 resp.items
1090 .iter()
1091 .any(|item| item.insert == "/help"
1092 && item.kind == AutocompleteItemKind::SlashCommand)
1093 );
1094 }
1095
1096 #[test]
1097 fn slash_suggests_templates() {
1098 let catalog = AutocompleteCatalog {
1099 prompt_templates: vec![NamedEntry {
1100 name: "review".to_string(),
1101 description: Some("Code review".to_string()),
1102 }],
1103 skills: Vec::new(),
1104 extension_commands: Vec::new(),
1105 enable_skill_commands: false,
1106 };
1107 let mut provider = AutocompleteProvider::new(PathBuf::from("."), catalog);
1108 let resp = provider.suggest("/rev", 4);
1109 assert!(
1110 resp.items.iter().any(|item| item.insert == "/review"
1111 && item.kind == AutocompleteItemKind::PromptTemplate)
1112 );
1113 }
1114
1115 #[test]
1116 fn skill_suggests_only_when_enabled() {
1117 let catalog = AutocompleteCatalog {
1118 prompt_templates: Vec::new(),
1119 skills: vec![NamedEntry {
1120 name: "rustfmt".to_string(),
1121 description: None,
1122 }],
1123 extension_commands: Vec::new(),
1124 enable_skill_commands: true,
1125 };
1126 let mut provider = AutocompleteProvider::new(PathBuf::from("."), catalog);
1127 let resp = provider.suggest("/skill:ru", "/skill:ru".len());
1128 assert!(resp.items.iter().any(
1129 |item| item.insert == "/skill:rustfmt" && item.kind == AutocompleteItemKind::Skill
1130 ));
1131
1132 provider.set_catalog(AutocompleteCatalog {
1133 prompt_templates: Vec::new(),
1134 skills: vec![NamedEntry {
1135 name: "rustfmt".to_string(),
1136 description: None,
1137 }],
1138 extension_commands: Vec::new(),
1139 enable_skill_commands: false,
1140 });
1141 let resp = provider.suggest("/skill:ru", "/skill:ru".len());
1142 assert!(resp.items.is_empty());
1143 }
1144
1145 #[test]
1146 fn set_catalog_updates_prompt_templates() {
1147 let mut provider =
1148 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1149
1150 let query = "/zzz_reload_test_template";
1151 let resp = provider.suggest(query, query.len());
1152 assert!(
1153 !resp
1154 .items
1155 .iter()
1156 .any(|item| item.insert == query
1157 && item.kind == AutocompleteItemKind::PromptTemplate)
1158 );
1159
1160 provider.set_catalog(AutocompleteCatalog {
1161 prompt_templates: vec![NamedEntry {
1162 name: "zzz_reload_test_template".to_string(),
1163 description: None,
1164 }],
1165 skills: Vec::new(),
1166 extension_commands: Vec::new(),
1167 enable_skill_commands: false,
1168 });
1169 let resp = provider.suggest(query, query.len());
1170 assert!(
1171 resp.items
1172 .iter()
1173 .any(|item| item.insert == query
1174 && item.kind == AutocompleteItemKind::PromptTemplate)
1175 );
1176
1177 provider.set_catalog(AutocompleteCatalog::default());
1178 let resp = provider.suggest(query, query.len());
1179 assert!(
1180 !resp
1181 .items
1182 .iter()
1183 .any(|item| item.insert == query
1184 && item.kind == AutocompleteItemKind::PromptTemplate)
1185 );
1186 }
1187
1188 #[test]
1189 fn file_ref_uses_cached_project_files() {
1190 let tmp = tempfile::tempdir().expect("tempdir");
1191 std::fs::write(tmp.path().join("hello.txt"), "hi").expect("write");
1192 std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
1193 std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
1194
1195 let mut provider =
1196 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
1197 provider.file_cache.files = walk_project_files(tmp.path());
1199 let resp = provider.suggest("@ma", 3);
1200 assert!(resp.items.iter().any(|item| item.insert == "@src/main.rs"));
1201 }
1202
1203 #[test]
1204 fn path_suggests_children_for_prefix() {
1205 let tmp = tempfile::tempdir().expect("tempdir");
1206 std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
1207 std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
1208 std::fs::write(tmp.path().join("src/lib.rs"), "pub fn lib() {}").expect("write");
1209
1210 let mut provider =
1211 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
1212 let resp = provider.suggest("src/ma", "src/ma".len());
1213 assert_eq!(resp.replace, 0..6);
1214 assert!(
1215 resp.items.iter().any(|item| item.insert == "src/main.rs"
1216 && item.kind == AutocompleteItemKind::Path)
1217 );
1218 assert!(!resp.items.iter().any(|item| item.insert == "src/lib.rs"));
1219 }
1220
1221 #[test]
1222 fn path_suggest_respects_gitignore_and_preserves_dot_slash() {
1223 let tmp = tempfile::tempdir().expect("tempdir");
1224 std::fs::write(tmp.path().join(".gitignore"), "target/\n").expect("write");
1225 std::fs::create_dir_all(tmp.path().join("target")).expect("mkdir");
1226 std::fs::create_dir_all(tmp.path().join("tags")).expect("mkdir");
1227
1228 let mut provider =
1229 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
1230 let resp = provider.suggest("./ta", "./ta".len());
1231 assert!(
1232 resp.items
1233 .iter()
1234 .any(|item| item.insert == "./tags/" && item.kind == AutocompleteItemKind::Path)
1235 );
1236 assert!(!resp.items.iter().any(|item| item.insert == "./target/"));
1237 }
1238
1239 #[test]
1240 fn path_like_accepts_tilde() {
1241 assert!(is_path_like("~"));
1242 assert!(is_path_like("~/"));
1243 }
1244
1245 #[test]
1246 fn split_path_prefix_handles_tilde() {
1247 assert_eq!(split_path_prefix("~"), ("~".to_string(), String::new()));
1248 assert_eq!(
1249 split_path_prefix("~/notes.txt"),
1250 ("~".to_string(), "notes.txt".to_string())
1251 );
1252 }
1253
1254 #[test]
1255 fn fuzzy_match_prefers_prefix_and_shorter() {
1256 let (prefix_short, score_short) = fuzzy_match_score("help", "he").expect("match help");
1257 let (prefix_long, score_long) = fuzzy_match_score("hello", "he").expect("match hello");
1258 assert!(prefix_short && prefix_long);
1259 assert!(score_short > score_long);
1260 }
1261
1262 #[test]
1263 fn fuzzy_match_accepts_subsequence() {
1264 let (is_prefix, score) = fuzzy_match_score("autocomplete", "acmp").expect("subsequence");
1265 assert!(!is_prefix);
1266 assert!(score > 0);
1267 }
1268
1269 #[test]
1270 fn suggest_replaces_only_current_token() {
1271 let mut provider =
1272 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1273 let resp = provider.suggest("foo /he bar", "foo /he".len());
1274 assert_eq!(resp.replace, 4..7);
1275 }
1276
1277 #[test]
1278 fn slash_suggests_extension_commands() {
1279 let catalog = AutocompleteCatalog {
1280 prompt_templates: Vec::new(),
1281 skills: Vec::new(),
1282 extension_commands: vec![NamedEntry {
1283 name: "deploy".to_string(),
1284 description: Some("Deploy to production".to_string()),
1285 }],
1286 enable_skill_commands: false,
1287 };
1288 let mut provider = AutocompleteProvider::new(PathBuf::from("."), catalog);
1289 let resp = provider.suggest("/dep", 4);
1290 assert!(resp.items.iter().any(|item| item.insert == "/deploy"
1291 && item.kind == AutocompleteItemKind::ExtensionCommand
1292 && item.description == Some("Deploy to production".to_string())));
1293
1294 let empty_catalog = AutocompleteCatalog::default();
1296 provider.set_catalog(empty_catalog);
1297 let resp = provider.suggest("/dep", 4);
1298 assert!(
1299 !resp
1300 .items
1301 .iter()
1302 .any(|item| item.kind == AutocompleteItemKind::ExtensionCommand)
1303 );
1304 }
1305
1306 #[test]
1307 fn model_command_suggests_model_catalog_candidates() {
1308 let mut provider =
1309 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1310 let input = "/model gpt-5.2-cod";
1311 let resp = provider.suggest(input, input.len());
1312 assert!(
1313 resp.items
1314 .iter()
1315 .any(|item| item.kind == AutocompleteItemKind::Model
1316 && item.insert == "openai/gpt-5.2-codex")
1317 );
1318 }
1319
1320 #[test]
1321 fn model_shorthand_command_suggests_model_catalog_candidates() {
1322 let mut provider =
1323 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1324 let input = "/m claude-sonnet-4";
1325 let resp = provider.suggest(input, input.len());
1326 assert!(
1327 resp.items
1328 .iter()
1329 .any(|item| item.kind == AutocompleteItemKind::Model)
1330 );
1331 }
1332
1333 #[test]
1334 fn login_command_suggests_provider_argument_candidates() {
1335 let mut provider =
1336 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1337 let input = "/login openai-cod";
1338 let resp = provider.suggest(input, input.len());
1339 assert!(resp.items.iter().any(|item| item.insert == "openai-codex"));
1340 }
1341
1342 #[test]
1343 fn logout_command_suggests_provider_alias_candidates() {
1344 let mut provider =
1345 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1346 let input = "/logout cop";
1347 let resp = provider.suggest(input, input.len());
1348 assert!(resp.items.iter().any(|item| item.insert == "copilot"));
1349 }
1350
1351 #[test]
1352 fn model_autocomplete_does_not_trigger_for_later_arguments() {
1353 let mut provider =
1354 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1355 let input = "/model openai/gpt-5 extra";
1356 let resp = provider.suggest(input, input.len());
1357 assert!(
1358 !resp
1359 .items
1360 .iter()
1361 .any(|item| item.kind == AutocompleteItemKind::Model)
1362 );
1363 }
1364
1365 #[test]
1366 fn auth_provider_autocomplete_does_not_trigger_for_later_arguments() {
1367 let mut provider =
1368 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1369 let input = "/login openai extra";
1370 let resp = provider.suggest(input, input.len());
1371 assert!(!resp.items.iter().any(|item| {
1372 item.insert == "openai"
1373 || item.insert == "openai-codex"
1374 || item.insert == "openai-responses"
1375 }));
1376 }
1377
1378 #[test]
1379 fn login_without_argument_keeps_slash_completion_behavior() {
1380 let mut provider =
1381 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1382 let input = "/log";
1383 let resp = provider.suggest(input, input.len());
1384 assert!(resp.items.iter().any(|item| item.insert == "/login"));
1385 }
1386
1387 #[cfg(unix)]
1388 #[test]
1389 fn absolute_path_token_prefers_path_completion_over_slash_commands() {
1390 let mut provider =
1391 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1392 let input = "/tmp";
1393 let resp = provider.suggest(input, input.len());
1394 assert!(!resp.items.is_empty(), "expected /tmp path suggestions");
1395 assert!(
1396 resp.items
1397 .iter()
1398 .all(|item| item.kind == AutocompleteItemKind::Path)
1399 );
1400 assert!(
1401 resp.items
1402 .iter()
1403 .any(|item| item.insert.starts_with("/tmp"))
1404 );
1405 }
1406
1407 #[test]
1410 fn clamp_cursor_stays_within_bounds() {
1411 assert_eq!(clamp_cursor("hello", 0), 0);
1412 assert_eq!(clamp_cursor("hello", 5), 5);
1413 assert_eq!(clamp_cursor("hello", 100), 5);
1414 }
1415
1416 #[test]
1417 fn clamp_cursor_avoids_mid_char_boundary() {
1418 let text = "café"; let clamped = clamp_cursor(text, 4);
1421 assert!(text.is_char_boundary(clamped));
1422 }
1423
1424 #[test]
1425 fn clamp_cursor_empty_string() {
1426 assert_eq!(clamp_cursor("", 0), 0);
1427 assert_eq!(clamp_cursor("", 10), 0);
1428 }
1429
1430 #[test]
1431 fn clamp_to_char_boundary_retreats_to_valid_position() {
1432 let text = "a🎉b"; let clamped = clamp_to_char_boundary(text, 2);
1435 assert_eq!(clamped, 1);
1436 assert!(text.is_char_boundary(clamped));
1437 }
1438
1439 #[test]
1442 fn token_at_cursor_single_word() {
1443 let tok = token_at_cursor("hello", 3);
1444 assert_eq!(tok.text, "hello");
1445 assert_eq!(tok.range, 0..5);
1446 }
1447
1448 #[test]
1449 fn token_at_cursor_multiple_words() {
1450 let tok = token_at_cursor("foo bar baz", 5);
1451 assert_eq!(tok.text, "bar");
1452 assert_eq!(tok.range, 4..7);
1453 }
1454
1455 #[test]
1456 fn token_at_cursor_at_boundary() {
1457 let tok = token_at_cursor("foo bar", 4);
1459 assert_eq!(tok.text, "bar");
1460 assert_eq!(tok.range, 4..7);
1461 }
1462
1463 #[test]
1464 fn token_at_cursor_at_end() {
1465 let tok = token_at_cursor("foo bar", 7);
1466 assert_eq!(tok.text, "bar");
1467 assert_eq!(tok.range, 4..7);
1468 }
1469
1470 #[test]
1471 fn token_at_cursor_empty_string() {
1472 let tok = token_at_cursor("", 0);
1473 assert_eq!(tok.text, "");
1474 assert_eq!(tok.range, 0..0);
1475 }
1476
1477 #[test]
1478 fn token_at_cursor_cursor_at_start() {
1479 let tok = token_at_cursor("hello world", 0);
1480 assert_eq!(tok.text, "hello");
1481 assert_eq!(tok.range, 0..5);
1482 }
1483
1484 #[test]
1487 fn fuzzy_match_empty_query_returns_prefix_zero() {
1488 let result = fuzzy_match_score("anything", "");
1489 assert_eq!(result, Some((true, 0)));
1490 }
1491
1492 #[test]
1493 fn fuzzy_match_whitespace_query_returns_prefix_zero() {
1494 let result = fuzzy_match_score("anything", " ");
1495 assert_eq!(result, Some((true, 0)));
1496 }
1497
1498 #[test]
1499 fn fuzzy_match_exact_prefix() {
1500 let (is_prefix, score) = fuzzy_match_score("help", "help").unwrap();
1501 assert!(is_prefix);
1502 assert_eq!(score, 1000); }
1504
1505 #[test]
1506 fn fuzzy_match_case_insensitive() {
1507 let (is_prefix, _) = fuzzy_match_score("Help", "he").unwrap();
1508 assert!(is_prefix);
1509 }
1510
1511 #[test]
1512 fn fuzzy_match_substring_not_prefix() {
1513 let (is_prefix, score) = fuzzy_match_score("xhelp", "help").unwrap();
1514 assert!(!is_prefix);
1515 assert_eq!(score, 699);
1517 }
1518
1519 #[test]
1520 fn fuzzy_match_no_match() {
1521 let result = fuzzy_match_score("help", "xyz");
1522 assert!(result.is_none());
1523 }
1524
1525 #[test]
1526 fn fuzzy_match_subsequence_with_gaps() {
1527 let (is_prefix, score) = fuzzy_match_score("model", "mdl").unwrap();
1528 assert!(!is_prefix);
1529 assert!(score > 0, "Subsequence match should have positive score");
1530 }
1531
1532 #[test]
1535 fn is_path_like_empty_returns_false() {
1536 assert!(!is_path_like(""));
1537 assert!(!is_path_like(" "));
1538 }
1539
1540 #[test]
1541 fn is_path_like_dot_slash() {
1542 assert!(is_path_like("./foo"));
1543 assert!(is_path_like("../bar"));
1544 }
1545
1546 #[test]
1547 fn is_path_like_absolute() {
1548 assert!(is_path_like("/usr/bin"));
1549 }
1550
1551 #[test]
1552 fn is_path_like_contains_slash() {
1553 assert!(is_path_like("src/main.rs"));
1554 }
1555
1556 #[test]
1557 fn is_path_like_windows_separators() {
1558 assert!(is_path_like(".\\foo"));
1559 assert!(is_path_like("src\\main.rs"));
1560 assert!(is_path_like("~\\notes.txt"));
1561 assert!(is_path_like("\\\\server\\share"));
1562 }
1563
1564 #[test]
1565 fn is_path_like_plain_word_not_path() {
1566 assert!(!is_path_like("hello"));
1567 assert!(!is_path_like("foo.bar"));
1568 }
1569
1570 #[test]
1573 fn expand_tilde_no_tilde() {
1574 assert_eq!(expand_tilde("/foo/bar"), "/foo/bar");
1575 assert_eq!(expand_tilde("hello"), "hello");
1576 }
1577
1578 #[test]
1579 fn expand_tilde_with_home() {
1580 let expanded = expand_tilde("~/notes.txt");
1581 if dirs::home_dir().is_some() {
1583 assert!(!expanded.starts_with("~/"));
1584 assert!(expanded.ends_with("notes.txt"));
1585 }
1586 }
1587
1588 #[test]
1591 fn resolve_dir_path_absolute() {
1592 let result = resolve_dir_path(Path::new("/tmp"), "/usr/bin", None);
1593 assert_eq!(result, Some(PathBuf::from("/usr/bin")));
1594 }
1595
1596 #[test]
1597 fn resolve_dir_path_relative() {
1598 let result = resolve_dir_path(Path::new("/home/user"), "src", None);
1599 assert_eq!(result, Some(PathBuf::from("/home/user/src")));
1600 }
1601
1602 #[test]
1603 fn resolve_dir_path_tilde_with_override() {
1604 let result = resolve_dir_path(Path::new("/cwd"), "~/docs", Some(Path::new("/mock_home")));
1605 assert_eq!(result, Some(PathBuf::from("/mock_home/docs")));
1606 }
1607
1608 #[test]
1609 fn resolve_dir_path_tilde_backslash_with_override() {
1610 let result = resolve_dir_path(Path::new("/cwd"), "~\\docs", Some(Path::new("/mock_home")));
1611 assert_eq!(result, Some(PathBuf::from("/mock_home/docs")));
1612 }
1613
1614 #[test]
1615 fn resolve_dir_path_tilde_alone() {
1616 let result = resolve_dir_path(Path::new("/cwd"), "~", Some(Path::new("/mock_home")));
1617 assert_eq!(result, Some(PathBuf::from("/mock_home")));
1618 }
1619
1620 #[test]
1621 fn resolve_dir_path_windows_drive_root_is_absolute_like() {
1622 let result = resolve_dir_path(Path::new("/cwd"), "C:\\Users", None);
1623 assert_eq!(result, Some(PathBuf::from("C:\\Users")));
1624 }
1625
1626 #[test]
1627 fn resolve_dir_path_windows_rooted_backslash_is_absolute_like() {
1628 let result = resolve_dir_path(Path::new("/cwd"), "\\Users", None);
1629 assert_eq!(result, Some(PathBuf::from("\\Users")));
1630 }
1631
1632 #[test]
1635 fn split_path_prefix_simple_file() {
1636 assert_eq!(
1637 split_path_prefix("hello.txt"),
1638 (".".to_string(), "hello.txt".to_string())
1639 );
1640 }
1641
1642 #[test]
1643 fn split_path_prefix_trailing_slash() {
1644 assert_eq!(
1645 split_path_prefix("src/"),
1646 ("src/".to_string(), String::new())
1647 );
1648 }
1649
1650 #[test]
1651 fn split_path_prefix_nested_path() {
1652 assert_eq!(
1653 split_path_prefix("src/main.rs"),
1654 ("src".to_string(), "main.rs".to_string())
1655 );
1656 }
1657
1658 #[test]
1659 fn split_path_prefix_windows_relative_path() {
1660 assert_eq!(
1661 split_path_prefix("src\\main.rs"),
1662 ("src".to_string(), "main.rs".to_string())
1663 );
1664 }
1665
1666 #[test]
1667 fn split_path_prefix_windows_drive_root_path() {
1668 assert_eq!(
1669 split_path_prefix("C:\\notes.txt"),
1670 ("C:\\".to_string(), "notes.txt".to_string())
1671 );
1672 }
1673
1674 #[test]
1675 fn split_path_prefix_root_path() {
1676 assert_eq!(
1677 split_path_prefix("/main.rs"),
1678 ("/".to_string(), "main.rs".to_string())
1679 );
1680 }
1681
1682 #[test]
1685 fn normalize_file_ref_trims_whitespace() {
1686 assert_eq!(normalize_file_ref_candidate(" hello "), "hello");
1687 }
1688
1689 #[test]
1690 fn normalize_file_ref_replaces_backslashes() {
1691 assert_eq!(normalize_file_ref_candidate("src\\main.rs"), "src/main.rs");
1692 }
1693
1694 #[test]
1695 fn normalize_file_ref_empty() {
1696 assert_eq!(normalize_file_ref_candidate(""), "");
1697 assert_eq!(normalize_file_ref_candidate(" "), "");
1698 }
1699
1700 #[test]
1703 fn is_absolute_like_empty() {
1704 assert!(!is_absolute_like(""));
1705 }
1706
1707 #[test]
1708 fn is_absolute_like_tilde() {
1709 assert!(is_absolute_like("~/foo"));
1710 assert!(is_absolute_like("~"));
1711 }
1712
1713 #[test]
1714 fn is_absolute_like_double_slash() {
1715 assert!(is_absolute_like("//network/share"));
1716 }
1717
1718 #[test]
1719 #[cfg(unix)]
1720 fn is_absolute_like_absolute_path() {
1721 assert!(is_absolute_like("/usr/bin"));
1722 }
1723
1724 #[test]
1725 fn is_absolute_like_relative_path() {
1726 assert!(!is_absolute_like("src/main.rs"));
1727 assert!(!is_absolute_like("./foo"));
1728 }
1729
1730 #[test]
1733 fn kind_rank_ordering() {
1734 assert!(
1735 kind_rank(AutocompleteItemKind::SlashCommand)
1736 < kind_rank(AutocompleteItemKind::ExtensionCommand)
1737 );
1738 assert!(
1739 kind_rank(AutocompleteItemKind::ExtensionCommand)
1740 < kind_rank(AutocompleteItemKind::PromptTemplate)
1741 );
1742 assert!(
1743 kind_rank(AutocompleteItemKind::PromptTemplate)
1744 < kind_rank(AutocompleteItemKind::Skill)
1745 );
1746 assert!(kind_rank(AutocompleteItemKind::Skill) < kind_rank(AutocompleteItemKind::Model));
1747 assert!(kind_rank(AutocompleteItemKind::Model) < kind_rank(AutocompleteItemKind::File));
1748 assert!(kind_rank(AutocompleteItemKind::File) < kind_rank(AutocompleteItemKind::Path));
1749 }
1750
1751 #[test]
1754 fn sort_scored_items_prefix_first() {
1755 let mut items = vec![
1756 ScoredItem {
1757 is_prefix: false,
1758 score: 900,
1759 kind_rank: 0,
1760 label: "b".to_string(),
1761 item: AutocompleteItem {
1762 kind: AutocompleteItemKind::SlashCommand,
1763 label: "b".to_string(),
1764 insert: "b".to_string(),
1765 description: None,
1766 },
1767 },
1768 ScoredItem {
1769 is_prefix: true,
1770 score: 100,
1771 kind_rank: 0,
1772 label: "a".to_string(),
1773 item: AutocompleteItem {
1774 kind: AutocompleteItemKind::SlashCommand,
1775 label: "a".to_string(),
1776 insert: "a".to_string(),
1777 description: None,
1778 },
1779 },
1780 ];
1781 sort_scored_items(&mut items);
1782 assert_eq!(items[0].label, "a");
1784 }
1785
1786 #[test]
1787 fn sort_scored_items_higher_score_first() {
1788 let mut items = vec![
1789 ScoredItem {
1790 is_prefix: true,
1791 score: 100,
1792 kind_rank: 0,
1793 label: "low".to_string(),
1794 item: AutocompleteItem {
1795 kind: AutocompleteItemKind::SlashCommand,
1796 label: "low".to_string(),
1797 insert: "low".to_string(),
1798 description: None,
1799 },
1800 },
1801 ScoredItem {
1802 is_prefix: true,
1803 score: 900,
1804 kind_rank: 0,
1805 label: "high".to_string(),
1806 item: AutocompleteItem {
1807 kind: AutocompleteItemKind::SlashCommand,
1808 label: "high".to_string(),
1809 insert: "high".to_string(),
1810 description: None,
1811 },
1812 },
1813 ];
1814 sort_scored_items(&mut items);
1815 assert_eq!(items[0].label, "high");
1816 }
1817
1818 #[test]
1819 fn sort_scored_items_kind_rank_tiebreaker() {
1820 let mut items = vec![
1821 ScoredItem {
1822 is_prefix: true,
1823 score: 500,
1824 kind_rank: kind_rank(AutocompleteItemKind::PromptTemplate),
1825 label: "template".to_string(),
1826 item: AutocompleteItem {
1827 kind: AutocompleteItemKind::PromptTemplate,
1828 label: "template".to_string(),
1829 insert: "template".to_string(),
1830 description: None,
1831 },
1832 },
1833 ScoredItem {
1834 is_prefix: true,
1835 score: 500,
1836 kind_rank: kind_rank(AutocompleteItemKind::SlashCommand),
1837 label: "command".to_string(),
1838 item: AutocompleteItem {
1839 kind: AutocompleteItemKind::SlashCommand,
1840 label: "command".to_string(),
1841 insert: "command".to_string(),
1842 description: None,
1843 },
1844 },
1845 ];
1846 sort_scored_items(&mut items);
1847 assert_eq!(items[0].label, "command");
1849 }
1850
1851 #[test]
1852 fn sort_scored_items_label_tiebreaker() {
1853 let mut items = vec![
1854 ScoredItem {
1855 is_prefix: true,
1856 score: 500,
1857 kind_rank: 0,
1858 label: "zebra".to_string(),
1859 item: AutocompleteItem {
1860 kind: AutocompleteItemKind::SlashCommand,
1861 label: "zebra".to_string(),
1862 insert: "zebra".to_string(),
1863 description: None,
1864 },
1865 },
1866 ScoredItem {
1867 is_prefix: true,
1868 score: 500,
1869 kind_rank: 0,
1870 label: "apple".to_string(),
1871 item: AutocompleteItem {
1872 kind: AutocompleteItemKind::SlashCommand,
1873 label: "apple".to_string(),
1874 insert: "apple".to_string(),
1875 description: None,
1876 },
1877 },
1878 ];
1879 sort_scored_items(&mut items);
1880 assert_eq!(items[0].label, "apple");
1881 }
1882
1883 #[test]
1886 fn clamp_usize_to_i32_within_range() {
1887 assert_eq!(clamp_usize_to_i32(0), 0);
1888 assert_eq!(clamp_usize_to_i32(42), 42);
1889 assert_eq!(clamp_usize_to_i32(i32::MAX as usize), i32::MAX);
1890 }
1891
1892 #[test]
1893 fn clamp_usize_to_i32_overflow() {
1894 assert_eq!(clamp_usize_to_i32(usize::MAX), i32::MAX);
1895 assert_eq!(clamp_usize_to_i32(i32::MAX as usize + 1), i32::MAX);
1896 }
1897
1898 #[test]
1901 fn builtin_slash_commands_not_empty() {
1902 let cmds = builtin_slash_commands();
1903 assert!(!cmds.is_empty());
1904 }
1905
1906 #[test]
1907 fn builtin_slash_commands_contains_help() {
1908 let cmds = builtin_slash_commands();
1909 assert!(cmds.iter().any(|c| c.name == "help"));
1910 }
1911
1912 #[test]
1913 fn builtin_slash_commands_contains_exit() {
1914 let cmds = builtin_slash_commands();
1915 assert!(cmds.iter().any(|c| c.name == "exit"));
1916 }
1917
1918 #[test]
1919 fn builtin_slash_commands_all_unique_names() {
1920 let cmds = builtin_slash_commands();
1921 let mut names: Vec<_> = cmds.iter().map(|c| c.name).collect();
1922 let orig_len = names.len();
1923 names.sort_unstable();
1924 names.dedup();
1925 assert_eq!(names.len(), orig_len, "Duplicate slash command names found");
1926 }
1927
1928 #[test]
1931 fn set_max_items_clamps_to_one() {
1932 let mut provider =
1933 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1934 provider.set_max_items(0);
1935 assert_eq!(provider.max_items(), 1);
1936 }
1937
1938 #[test]
1939 fn set_max_items_accepts_large_value() {
1940 let mut provider =
1941 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1942 provider.set_max_items(1000);
1943 assert_eq!(provider.max_items(), 1000);
1944 }
1945
1946 #[test]
1949 fn suggest_respects_max_items() {
1950 let mut provider =
1951 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1952 provider.set_max_items(3);
1953 let resp = provider.suggest("/", 1);
1955 assert!(resp.items.len() <= 3);
1956 }
1957
1958 #[test]
1961 fn suggest_plain_text_returns_empty() {
1962 let mut provider =
1963 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1964 let resp = provider.suggest("hello world", 5);
1965 assert!(resp.items.is_empty());
1966 }
1967
1968 #[test]
1971 fn suggest_slash_alone_returns_all_builtins() {
1972 let mut provider =
1973 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1974 let resp = provider.suggest("/", 1);
1975 let builtin_count = builtin_slash_commands().len();
1976 println!("Items: {:?}", resp.items);
1977 assert_eq!(resp.items.len(), builtin_count);
1978 }
1979
1980 #[test]
1983 fn set_cwd_invalidates_file_cache() {
1984 let tmp1 = tempfile::tempdir().expect("tempdir");
1985 std::fs::write(tmp1.path().join("one.txt"), "1").expect("write");
1986
1987 let tmp2 = tempfile::tempdir().expect("tempdir");
1988 std::fs::write(tmp2.path().join("two.txt"), "2").expect("write");
1989
1990 let mut provider =
1991 AutocompleteProvider::new(tmp1.path().to_path_buf(), AutocompleteCatalog::default());
1992 provider.file_cache.files = walk_project_files(tmp1.path());
1994 let resp = provider.suggest("@on", 3);
1995 assert!(resp.items.iter().any(|i| i.insert == "@one.txt"));
1996
1997 provider.set_cwd(tmp2.path().to_path_buf());
1998 provider.file_cache.files = walk_project_files(tmp2.path());
2000 let resp = provider.suggest("@tw", 3);
2001 assert!(resp.items.iter().any(|i| i.insert == "@two.txt"));
2002 let resp = provider.suggest("@on", 3);
2004 assert!(!resp.items.iter().any(|i| i.insert == "@one.txt"));
2005 }
2006
2007 #[test]
2010 fn walk_project_files_returns_sorted_deduped() {
2011 let tmp = tempfile::tempdir().expect("tempdir");
2012 std::fs::create_dir_all(tmp.path().join("sub")).expect("mkdir");
2013 std::fs::write(tmp.path().join("b.txt"), "b").expect("write");
2014 std::fs::write(tmp.path().join("a.txt"), "a").expect("write");
2015 std::fs::write(tmp.path().join("sub/c.txt"), "c").expect("write");
2016
2017 let files = walk_project_files(tmp.path());
2018 assert!(files.contains(&"a.txt".to_string()));
2019 assert!(files.contains(&"b.txt".to_string()));
2020 assert!(files.contains(&"sub/c.txt".to_string()));
2021 let mut sorted = files.clone();
2023 sorted.sort();
2024 assert_eq!(files, sorted);
2025 }
2026
2027 #[test]
2028 fn walk_project_files_empty_dir() {
2029 let tmp = tempfile::tempdir().expect("tempdir");
2030 let files = walk_project_files(tmp.path());
2031 assert!(files.is_empty());
2032 }
2033
2034 #[test]
2037 #[cfg(unix)]
2038 fn resolve_file_ref_absolute_returns_normalized() {
2039 let mut provider =
2040 AutocompleteProvider::new(PathBuf::from("/tmp"), AutocompleteCatalog::default());
2041 let result = provider.resolve_file_ref("/some/absolute/path.txt");
2042 assert_eq!(result, Some("/some/absolute/path.txt".to_string()));
2043 }
2044
2045 #[test]
2046 fn resolve_file_ref_tilde_returns_normalized() {
2047 let mut provider =
2048 AutocompleteProvider::new(PathBuf::from("/tmp"), AutocompleteCatalog::default());
2049 let result = provider.resolve_file_ref("~/notes.txt");
2050 assert_eq!(result, Some("~/notes.txt".to_string()));
2051 }
2052
2053 #[test]
2054 fn resolve_file_ref_empty_returns_none() {
2055 let mut provider =
2056 AutocompleteProvider::new(PathBuf::from("/tmp"), AutocompleteCatalog::default());
2057 assert!(provider.resolve_file_ref("").is_none());
2058 assert!(provider.resolve_file_ref(" ").is_none());
2059 }
2060
2061 #[test]
2062 fn resolve_file_ref_matches_project_file() {
2063 let tmp = tempfile::tempdir().expect("tempdir");
2064 std::fs::write(tmp.path().join("README.md"), "hi").expect("write");
2065
2066 let mut provider =
2067 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
2068 provider.file_cache.files = walk_project_files(tmp.path());
2070 let result = provider.resolve_file_ref("README.md");
2071 assert_eq!(result, Some("README.md".to_string()));
2072 }
2073
2074 #[test]
2075 fn resolve_file_ref_nonexistent_file_returns_none() {
2076 let tmp = tempfile::tempdir().expect("tempdir");
2077 let mut provider =
2078 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
2079 assert!(provider.resolve_file_ref("nonexistent.txt").is_none());
2080 }
2081
2082 #[test]
2083 fn resolve_file_ref_strips_dot_slash() {
2084 let tmp = tempfile::tempdir().expect("tempdir");
2085 std::fs::write(tmp.path().join("foo.txt"), "hi").expect("write");
2086
2087 let mut provider =
2088 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
2089 provider.file_cache.files = walk_project_files(tmp.path());
2091 let result = provider.resolve_file_ref("./foo.txt");
2092 assert_eq!(result, Some("foo.txt".to_string()));
2093 }
2094
2095 #[test]
2098 fn autocomplete_catalog_default_is_empty() {
2099 let catalog = AutocompleteCatalog::default();
2100 assert!(catalog.prompt_templates.is_empty());
2101 assert!(catalog.skills.is_empty());
2102 assert!(catalog.extension_commands.is_empty());
2103 assert!(!catalog.enable_skill_commands);
2104 }
2105
2106 #[test]
2109 fn suggest_cursor_past_end_clamps() {
2110 let mut provider =
2111 AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
2112 let resp = provider.suggest("/he", 1000);
2114 assert!(resp.items.iter().any(|i| i.insert == "/help"));
2116 }
2117
2118 #[test]
2121 fn slash_suggests_mixed_sources_sorted_by_kind() {
2122 let catalog = AutocompleteCatalog {
2123 prompt_templates: vec![NamedEntry {
2124 name: "test-prompt".to_string(),
2125 description: Some("A test".to_string()),
2126 }],
2127 skills: Vec::new(),
2128 extension_commands: vec![NamedEntry {
2129 name: "test-ext".to_string(),
2130 description: Some("An extension".to_string()),
2131 }],
2132 enable_skill_commands: false,
2133 };
2134 let mut provider = AutocompleteProvider::new(PathBuf::from("."), catalog);
2135 let resp = provider.suggest("/test", 5);
2136
2137 assert!(
2138 resp.items
2139 .iter()
2140 .any(|i| i.kind == AutocompleteItemKind::ExtensionCommand)
2141 );
2142 assert!(
2143 resp.items
2144 .iter()
2145 .any(|i| i.kind == AutocompleteItemKind::PromptTemplate)
2146 );
2147 }
2148
2149 #[test]
2152 fn skill_query_disabled_returns_empty() {
2153 let catalog = AutocompleteCatalog {
2154 prompt_templates: Vec::new(),
2155 skills: vec![NamedEntry {
2156 name: "deploy".to_string(),
2157 description: None,
2158 }],
2159 extension_commands: Vec::new(),
2160 enable_skill_commands: false,
2161 };
2162 let mut provider = AutocompleteProvider::new(PathBuf::from("."), catalog);
2163 let resp = provider.suggest("/skill:de", "/skill:de".len());
2164 assert!(resp.items.is_empty());
2165 }
2166
2167 #[test]
2170 fn file_ref_suggest_empty_query_returns_all_files() {
2171 let tmp = tempfile::tempdir().expect("tempdir");
2172 std::fs::write(tmp.path().join("a.txt"), "a").expect("write");
2173 std::fs::write(tmp.path().join("b.txt"), "b").expect("write");
2174
2175 let mut provider =
2176 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
2177 provider.file_cache.files = walk_project_files(tmp.path());
2179 let resp = provider.suggest("@", 1);
2181 assert!(resp.items.len() >= 2);
2182 }
2183
2184 #[test]
2187 fn path_completion_with_home_override() {
2188 let tmp = tempfile::tempdir().expect("tempdir");
2189 let mock_home = tmp.path().join("home");
2190 std::fs::create_dir_all(&mock_home).expect("mkdir");
2191 std::fs::write(mock_home.join("notes.txt"), "hi").expect("write");
2192
2193 let mut provider =
2194 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
2195 provider.home_dir_override = Some(mock_home);
2196
2197 let resp = provider.suggest("~/no", 4);
2198 assert!(resp.items.iter().any(|i| i.insert.contains("notes.txt")));
2199 }
2200
2201 #[test]
2202 fn path_completion_windows_relative_input_preserves_backslashes() {
2203 let tmp = tempfile::tempdir().expect("tempdir");
2204 std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
2205 std::fs::write(tmp.path().join("src").join("main.rs"), "hi").expect("write");
2206
2207 let mut provider =
2208 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
2209
2210 let resp = provider.suggest("src\\ma", "src\\ma".len());
2211 assert!(resp.items.iter().any(|i| i.insert == "src\\main.rs"));
2212 }
2213
2214 #[test]
2215 fn path_completion_windows_dot_prefix_preserves_backslashes() {
2216 let tmp = tempfile::tempdir().expect("tempdir");
2217 std::fs::write(tmp.path().join("main.rs"), "hi").expect("write");
2218
2219 let mut provider =
2220 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
2221
2222 let resp = provider.suggest(".\\ma", ".\\ma".len());
2223 assert!(resp.items.iter().any(|i| i.insert == ".\\main.rs"));
2224 }
2225
2226 #[test]
2227 fn path_completion_windows_directory_input_preserves_backslashes_for_directories() {
2228 let tmp = tempfile::tempdir().expect("tempdir");
2229 std::fs::create_dir_all(tmp.path().join("src").join("nested")).expect("mkdir");
2230
2231 let mut provider =
2232 AutocompleteProvider::new(tmp.path().to_path_buf(), AutocompleteCatalog::default());
2233
2234 let resp = provider.suggest("src\\n", "src\\n".len());
2235 assert!(resp.items.iter().any(|i| i.insert == "src\\nested\\"));
2236 assert!(!resp.items.iter().any(|i| i.insert == "src\\nested/"));
2237 }
2238
2239 #[test]
2242 fn file_cache_invalidate_clears_files() {
2243 let mut cache = FileCache::new();
2244 cache.files = vec!["a.txt".to_string()];
2245 cache.last_update_request = Some(Instant::now());
2246 let (_tx, rx) = std::sync::mpsc::channel();
2247 cache.update_rx = Some(rx);
2248 cache.updating = true;
2249
2250 cache.invalidate();
2251 assert!(cache.files.is_empty());
2252 assert!(cache.last_update_request.is_none());
2253 assert!(cache.update_rx.is_none());
2254 assert!(!cache.updating);
2255 }
2256
2257 #[test]
2260 fn named_entry_equality() {
2261 let a = NamedEntry {
2262 name: "test".to_string(),
2263 description: Some("desc".to_string()),
2264 };
2265 let b = a.clone();
2266 assert_eq!(a, b);
2267 }
2268
2269 mod proptest_autocomplete {
2270 use super::*;
2271 use proptest::prelude::*;
2272
2273 proptest! {
2274 #[test]
2276 fn clamp_usize_saturates(val in 0..usize::MAX) {
2277 let result = clamp_usize_to_i32(val);
2278 let expected = i32::try_from(val).unwrap_or(i32::MAX);
2279 assert_eq!(result, expected);
2280 }
2281
2282 #[test]
2284 fn clamp_to_char_boundary_valid(s in "\\PC{1,30}", idx in 0..60usize) {
2285 let clamped = clamp_to_char_boundary(&s, idx);
2286 assert!(s.is_char_boundary(clamped));
2287 assert!(clamped <= s.len());
2288 }
2289
2290 #[test]
2292 fn clamp_cursor_valid(s in "\\PC{1,30}", cursor in 0..100usize) {
2293 let clamped = clamp_cursor(&s, cursor);
2294 assert!(s.is_char_boundary(clamped));
2295 assert!(clamped <= s.len());
2296 }
2297
2298 #[test]
2300 fn fuzzy_empty_query_matches_all(cand in "[a-z]{1,20}") {
2301 assert_eq!(fuzzy_match_score(&cand, ""), Some((true, 0)));
2302 assert_eq!(fuzzy_match_score(&cand, " "), Some((true, 0)));
2303 }
2304
2305 #[test]
2307 fn fuzzy_prefix_match(base in "[a-z]{2,10}", suffix in "[a-z]{0,5}") {
2308 let candidate = format!("{base}{suffix}");
2309 let result = fuzzy_match_score(&candidate, &base);
2310 assert!(result.is_some());
2311 let (is_prefix, score) = result.unwrap();
2312 assert!(is_prefix, "prefix match should be flagged");
2313 assert!(score >= 900, "prefix score should be high, got {score}");
2314 }
2315
2316 #[test]
2318 fn fuzzy_case_insensitive(cand in "[a-z]{2,10}", query in "[a-z]{1,5}") {
2319 let lower = fuzzy_match_score(&cand, &query);
2320 let upper = fuzzy_match_score(&cand, &query.to_uppercase());
2321 assert_eq!(lower.is_some(), upper.is_some());
2322 if let (Some((lp, ls)), Some((up, us))) = (lower, upper) {
2323 assert_eq!(lp, up);
2324 assert_eq!(ls, us);
2325 }
2326 }
2327
2328 #[test]
2330 fn is_path_like_common_prefixes(name in "[a-z]{1,10}") {
2331 assert!(is_path_like(&format!("./{name}")));
2332 assert!(is_path_like(&format!("../{name}")));
2333 assert!(is_path_like(&format!("~/{name}")));
2334 assert!(is_path_like(&format!("/{name}")));
2335 }
2336
2337 #[test]
2339 fn is_path_like_false_for_words(word in "[a-z]{1,10}") {
2340 assert!(!is_path_like(word.trim()));
2341 }
2342
2343 #[test]
2345 fn is_path_like_empty_false(ws in "[ \\t]{0,5}") {
2346 assert!(!is_path_like(&ws));
2347 }
2348
2349 #[test]
2351 fn split_path_prefix_reconstructs(dir in "[a-z]{1,5}", file in "[a-z]{1,5}") {
2352 let path = format!("{dir}/{file}");
2353 let (d, f) = split_path_prefix(&path);
2354 assert_eq!(d, dir);
2355 assert_eq!(f, file);
2356 }
2357
2358 #[test]
2360 fn split_path_prefix_tilde(_dummy in 0..1u8) {
2361 let (d, f) = split_path_prefix("~");
2362 assert_eq!(d, "~");
2363 assert!(f.is_empty());
2364 }
2365
2366 #[test]
2368 fn split_path_prefix_trailing_slash(dir in "[a-z]{1,10}") {
2369 let path = format!("{dir}/");
2370 let (d, f) = split_path_prefix(&path);
2371 assert_eq!(d, path);
2372 assert!(f.is_empty());
2373 }
2374
2375 #[test]
2377 fn split_path_prefix_no_slash(word in "[a-z]{1,10}") {
2378 let (d, f) = split_path_prefix(&word);
2379 assert_eq!(d, ".");
2380 assert_eq!(f, word);
2381 }
2382
2383 #[test]
2385 fn token_at_cursor_bounds(text in "[a-z ]{1,30}", cursor in 0..40usize) {
2386 let tok = token_at_cursor(&text, cursor);
2387 assert!(tok.range.start <= tok.range.end);
2388 assert!(tok.range.end <= text.len());
2389 assert_eq!(&text[tok.range.clone()], tok.text);
2390 }
2391
2392 #[test]
2394 fn token_at_cursor_no_whitespace(text in "[a-z ]{1,20}", cursor in 0..30usize) {
2395 let tok = token_at_cursor(&text, cursor);
2396 assert!(!tok.text.contains(char::is_whitespace) || tok.text.is_empty());
2397 }
2398
2399 #[test]
2401 fn kind_rank_distinct(idx in 0..7usize) {
2402 let kinds = [
2403 AutocompleteItemKind::SlashCommand,
2404 AutocompleteItemKind::ExtensionCommand,
2405 AutocompleteItemKind::PromptTemplate,
2406 AutocompleteItemKind::Skill,
2407 AutocompleteItemKind::Model,
2408 AutocompleteItemKind::File,
2409 AutocompleteItemKind::Path,
2410 ];
2411 let expected = [0_u8, 1, 2, 3, 4, 5, 6][idx];
2412 assert_eq!(kind_rank(kinds[idx]), expected);
2413 }
2414
2415 #[test]
2417 fn resolve_dir_absolute(dir in "[a-z]{1,10}") {
2418 let abs = format!("/{dir}");
2419 let result = resolve_dir_path(Path::new("/cwd"), &abs, None);
2420 assert_eq!(result, Some(PathBuf::from(&abs)));
2421 }
2422
2423 #[test]
2425 fn resolve_dir_relative(dir in "[a-z]{1,10}") {
2426 let result = resolve_dir_path(Path::new("/cwd"), &dir, None);
2427 assert_eq!(result, Some(PathBuf::from(format!("/cwd/{dir}"))));
2428 }
2429 }
2430 }
2431}