Skip to main content

pi/
autocomplete.rs

1//! Autocomplete provider for interactive editor input.
2//!
3//! This module is intentionally rendering-agnostic: it takes editor text + cursor
4//! position and returns structured suggestions plus the range that should be
5//! replaced when applying a selection.
6//!
7//! Current suggestion sources (legacy parity targets):
8//! - Built-in slash commands (e.g., `/help`, `/model`)
9//! - Prompt templates (`/<template>`) from the resource loader
10//! - Skills (`/skill:<name>`) when skill commands are enabled
11//! - File references (`@path`) with a cached project file index
12//! - Path completions when the cursor is in a path-like token
13
14use 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    /// Return suggestions for the given editor state.
135    ///
136    /// `cursor` is interpreted as a byte offset into `text`. If it is out of
137    /// bounds or not on a UTF-8 boundary, it is clamped to the nearest safe
138    /// boundary.
139    #[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        // `/skill:<name>` is special-cased.
192        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        // Built-in slash commands.
237        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        // Extension commands.
256        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        // Prompt templates.
275        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        // Drop stale in-flight updates so old cwd results cannot repopulate cache.
539        self.update_rx = None;
540        self.updating = false;
541    }
542
543    fn refresh_if_needed(&mut self, cwd: &Path) {
544        // Poll for completed updates
545        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
613/// Cached result of fd binary detection.
614/// Uses OnceLock to avoid spawning processes on every file cache refresh.
615static 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        // Prefer shorter completions for prefix matches.
843        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    // Subsequence match with a gap penalty.
853    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    // Prefer shorter candidates if the match score ties.
864    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        // Pre-populate the file cache (refresh_if_needed is async).
1143        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        // Verify extension commands don't appear with empty catalog
1240        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    // ── clamp_cursor / clamp_to_char_boundary ────────────────────────
1306
1307    #[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é"; // é is 2 bytes
1317        // Try clamping to byte 4, which is the middle of é (bytes 3,4)
1318        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"; // 🎉 is 4 bytes, starts at byte 1
1331        // Byte 2 is mid-emoji, should retreat to byte 1
1332        let clamped = clamp_to_char_boundary(text, 2);
1333        assert_eq!(clamped, 1);
1334        assert!(text.is_char_boundary(clamped));
1335    }
1336
1337    // ── token_at_cursor ─────────────────────────────────────────────
1338
1339    #[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        // Cursor at start of "bar"
1356        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    // ── fuzzy_match_score ────────────────────────────────────────────
1383
1384    #[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); // exact match → 0 penalty
1401    }
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        // substring found at index 1 → 700 - 1 = 699
1414        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    // ── is_path_like ─────────────────────────────────────────────────
1431
1432    #[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    // ── expand_tilde ─────────────────────────────────────────────────
1461
1462    #[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 there is a home dir, the path should not start with ~/
1472        if dirs::home_dir().is_some() {
1473            assert!(!expanded.starts_with("~/"));
1474            assert!(expanded.ends_with("notes.txt"));
1475        }
1476    }
1477
1478    // ── resolve_dir_path ─────────────────────────────────────────────
1479
1480    #[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    // ── split_path_prefix ────────────────────────────────────────────
1505
1506    #[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    // ── normalize_file_ref_candidate ─────────────────────────────────
1539
1540    #[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    // ── is_absolute_like ─────────────────────────────────────────────
1557
1558    #[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    // ── kind_rank ordering ──────────────────────────────────────────
1587
1588    #[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    // ── sort_scored_items ───────────────────────────────────────────
1608
1609    #[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        // Prefix match comes first despite lower score
1639        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        // SlashCommand has lower kind_rank, so it comes first
1704        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    // ── clamp_usize_to_i32 ──────────────────────────────────────────
1740
1741    #[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    // ── builtin_slash_commands ───────────────────────────────────────
1755
1756    #[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    // ── set_max_items ────────────────────────────────────────────────
1785
1786    #[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    // ── max_items limits output ──────────────────────────────────────
1803
1804    #[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        // "/" with empty query returns all builtins, should be capped at 3
1810        let resp = provider.suggest("/", 1);
1811        assert!(resp.items.len() <= 3);
1812    }
1813
1814    // ── suggest with non-matching input ──────────────────────────────
1815
1816    #[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    // ── suggest_slash with empty query ───────────────────────────────
1825
1826    #[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    // ── set_cwd invalidates cache ────────────────────────────────────
1836
1837    #[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        // Pre-populate cache (refresh_if_needed is async).
1848        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        // Re-populate after cwd change (invalidate clears the cache).
1854        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        // Old file should no longer appear
1858        let resp = provider.suggest("@on", 3);
1859        assert!(!resp.items.iter().any(|i| i.insert == "@one.txt"));
1860    }
1861
1862    // ── walk_project_files ──────────────────────────────────────────
1863
1864    #[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        // Verify sorted
1877        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    // ── resolve_file_ref ─────────────────────────────────────────────
1890
1891    #[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        // Pre-populate cache (refresh_if_needed is async).
1924        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        // Pre-populate cache (refresh_if_needed is async).
1945        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    // ── AutocompleteCatalog ──────────────────────────────────────────
1951
1952    #[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    // ── AutocompleteResponse empty ──────────────────────────────────
1962
1963    #[test]
1964    fn suggest_cursor_past_end_clamps() {
1965        let mut provider =
1966            AutocompleteProvider::new(PathBuf::from("."), AutocompleteCatalog::default());
1967        // Cursor way past end of text
1968        let resp = provider.suggest("/he", 1000);
1969        // Should still find /help since cursor clamps to end
1970        assert!(resp.items.iter().any(|i| i.insert == "/help"));
1971    }
1972
1973    // ── Mixed catalog with slash query ───────────────────────────────
1974
1975    #[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    // ── skill commands disabled returns empty ────────────────────────
2005
2006    #[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    // ── file ref suggest with @ ──────────────────────────────────────
2023
2024    #[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        // Pre-populate the file cache (refresh_if_needed is async).
2033        provider.file_cache.files = walk_project_files(tmp.path());
2034        // Just "@" with no query
2035        let resp = provider.suggest("@", 1);
2036        assert!(resp.items.len() >= 2);
2037    }
2038
2039    // ── path completion with tilde override ──────────────────────────
2040
2041    #[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    // ── FileCache invalidation ───────────────────────────────────────
2057
2058    #[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    // ── NamedEntry equality ──────────────────────────────────────────
2075
2076    #[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            /// `clamp_usize_to_i32` saturates at `i32::MAX`.
2092            #[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            /// `clamp_to_char_boundary` always returns a valid char boundary.
2100            #[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            /// `clamp_cursor` always returns a valid char boundary <= len.
2108            #[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            /// `fuzzy_match_score` with empty query always returns `Some((true, 0))`.
2116            #[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            /// Prefix matches report `is_prefix=true` and score >= 900.
2123            #[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            /// `fuzzy_match_score` is case-insensitive.
2134            #[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            /// `is_path_like` recognizes paths starting with ./ ../ ~/ or /
2146            #[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            /// `is_path_like` returns false for simple words without slashes.
2155            #[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            /// `is_path_like` empty or whitespace returns false.
2161            #[test]
2162            fn is_path_like_empty_false(ws in "[ \\t]{0,5}") {
2163                assert!(!is_path_like(&ws));
2164            }
2165
2166            /// `split_path_prefix` reconstructs the original path.
2167            #[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            /// `split_path_prefix("~")` returns `("~", "")`.
2176            #[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            /// `split_path_prefix` with trailing slash returns dir=path, file="".
2184            #[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            /// `split_path_prefix` with no slash returns dir=".", file=path.
2193            #[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            /// `token_at_cursor` result range is within text bounds.
2201            #[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            /// `token_at_cursor` result text contains no whitespace.
2210            #[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            /// `kind_rank` covers all variants with distinct ranks 0..=6.
2217            #[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            /// `resolve_dir_path` with absolute path returns it unchanged.
2233            #[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            /// `resolve_dir_path` with relative path joins to cwd.
2241            #[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}