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