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