1use super::{
10 escape_control_chars_for_display, validate_relative_pattern, CommandOutput, CommandRequest,
11 LocalWorkspaceBackend, WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceFileSystem,
12 WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest,
13 WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest,
14 WorkspaceGitDiffRequest, WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest,
15 WorkspaceGitStash, WorkspaceGitStashProvider, WorkspaceGitStashRequest, WorkspaceGitStatus,
16 WorkspaceGitWorktree, WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider,
17 WorkspaceGlobRequest, WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest,
18 WorkspaceGrepResult, WorkspacePath, WorkspacePathResolver, WorkspaceResult, WorkspaceSearch,
19 WorkspaceTextRange, WorkspaceTextReader, WorkspaceWriteOutcome,
20};
21use anyhow::{anyhow, Result};
22use async_trait::async_trait;
23use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
24use std::hash::{Hash, Hasher};
25use std::path::{Component, Path, PathBuf};
26use std::sync::{
27 atomic::{AtomicBool, Ordering},
28 Arc, RwLock,
29};
30#[cfg(test)]
31use std::time::Duration;
32use std::time::{SystemTime, UNIX_EPOCH};
33use tokio::sync::broadcast;
34
35mod scanner;
36mod watcher;
37use scanner::is_relevant_event;
38pub use scanner::scan_workspace_files;
39use watcher::run_manifest_task;
40
41const SNAPSHOT_CHANNEL_CAPACITY: usize = 16;
42const FILE_CHANGE_CHANNEL_CAPACITY: usize = 256;
43const RECENT_FILE_LIMIT: usize = 128;
44const RECENT_DECAY_HALF_LIFE_MS: f32 = 10.0 * 60.0 * 1000.0;
45const RECENT_FREQUENCY_NORMALIZER: f32 = 16.0;
46const RECENT_RECENCY_WEIGHT: f32 = 0.75;
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
50pub enum LocalWorkspaceFileStatus {
51 Tracked,
52 Untracked,
53 Unknown,
54}
55
56#[derive(Clone, Debug, Eq, PartialEq, Hash)]
58pub struct LocalWorkspaceFile {
59 pub path: String,
60 pub size: u64,
61 pub modified_ms: Option<u64>,
62 pub language: Option<String>,
63 pub status: LocalWorkspaceFileStatus,
64 pub binary: bool,
65 pub generated: bool,
66}
67
68#[derive(Clone, Debug, PartialEq)]
73pub struct RecentWorkspaceFile {
74 pub path: String,
75 pub score: f32,
76 pub touched_at_ms: u64,
77 pub touch_count: u32,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct LocalWorkspaceManifestSnapshot {
83 pub version: u64,
84 pub root: PathBuf,
85 pub files: Vec<LocalWorkspaceFile>,
86 pub scanned_at_ms: u64,
87}
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
91pub enum WorkspaceFileChangeKind {
92 Created,
93 Changed,
94 Deleted,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq, Hash)]
99pub struct WorkspaceFileChange {
100 pub path: WorkspacePath,
101 pub kind: WorkspaceFileChangeKind,
102}
103
104impl LocalWorkspaceManifestSnapshot {
105 pub fn empty(root: PathBuf) -> Self {
106 Self {
107 version: 0,
108 root,
109 files: Vec::new(),
110 scanned_at_ms: now_ms(),
111 }
112 }
113
114 pub fn file_paths(&self) -> Vec<String> {
115 self.files.iter().map(|file| file.path.clone()).collect()
116 }
117}
118
119pub struct LocalWorkspaceManifest {
121 state: Arc<RwLock<ManifestState>>,
122 recent: Arc<RwLock<RecentFiles>>,
123 snapshots: broadcast::Sender<LocalWorkspaceManifestSnapshot>,
124 changes: broadcast::Sender<WorkspaceFileChange>,
125 scan_cancelled: Arc<AtomicBool>,
126 task: tokio::task::JoinHandle<()>,
127}
128
129impl LocalWorkspaceManifest {
130 pub fn start(root: impl Into<PathBuf>) -> Arc<Self> {
132 let root = root.into();
133 let root = root.canonicalize().unwrap_or_else(|_| root.clone());
134 let initial = LocalWorkspaceManifestSnapshot::empty(root.clone());
135 let state = Arc::new(RwLock::new(ManifestState {
136 fingerprint: fingerprint_files(&initial.files),
137 index: Arc::new(ManifestIndex::build(&initial.files)),
138 snapshot: Arc::new(initial),
139 }));
140 let recent = Arc::new(RwLock::new(RecentFiles::default()));
141 let (snapshots, _) = broadcast::channel(SNAPSHOT_CHANNEL_CAPACITY);
142 let (changes, _) = broadcast::channel(FILE_CHANGE_CHANNEL_CAPACITY);
143 let scan_cancelled = Arc::new(AtomicBool::new(false));
144 let task_state = Arc::clone(&state);
145 let task_snapshots = snapshots.clone();
146 let task_changes = changes.clone();
147 let task_scan_cancelled = Arc::clone(&scan_cancelled);
148 let task = tokio::spawn(async move {
149 run_manifest_task(
150 root,
151 task_state,
152 task_snapshots,
153 task_changes,
154 task_scan_cancelled,
155 )
156 .await;
157 });
158 Arc::new(Self {
159 state,
160 recent,
161 snapshots,
162 changes,
163 scan_cancelled,
164 task,
165 })
166 }
167
168 pub fn snapshot(&self) -> LocalWorkspaceManifestSnapshot {
169 self.state
170 .read()
171 .map(|state| (*state.snapshot).clone())
172 .unwrap_or_else(|_| LocalWorkspaceManifestSnapshot::empty(PathBuf::new()))
173 }
174
175 pub fn subscribe(&self) -> broadcast::Receiver<LocalWorkspaceManifestSnapshot> {
176 self.snapshots.subscribe()
177 }
178
179 pub fn subscribe_changes(&self) -> broadcast::Receiver<WorkspaceFileChange> {
181 self.changes.subscribe()
182 }
183
184 pub fn shutdown(&self) {
190 self.scan_cancelled.store(true, Ordering::Release);
191 self.task.abort();
192 }
193
194 pub fn touch_file(&self, path: impl AsRef<str>) -> bool {
201 let Some(path) = normalize_recent_file_path(path.as_ref()) else {
202 return false;
203 };
204 let Ok(mut recent) = self.recent.write() else {
205 return false;
206 };
207 recent.touch(path, now_ms());
208 true
209 }
210
211 pub fn recent_file_entries(&self, limit: usize) -> Vec<RecentWorkspaceFile> {
213 if limit == 0 {
214 return Vec::new();
215 }
216 let Some(index) = self.state.read().ok().map(|state| Arc::clone(&state.index)) else {
217 return Vec::new();
218 };
219 self.recent
220 .read()
221 .map(|recent| recent.entries(Some(&index), limit, now_ms()))
222 .unwrap_or_default()
223 }
224
225 pub fn recent_file_paths(&self, limit: usize) -> Vec<String> {
227 self.recent_file_entries(limit)
228 .into_iter()
229 .map(|entry| entry.path)
230 .collect()
231 }
232}
233
234impl Drop for LocalWorkspaceManifest {
235 fn drop(&mut self) {
236 self.shutdown();
240 }
241}
242
243struct ManifestState {
244 fingerprint: u64,
245 index: Arc<ManifestIndex>,
246 snapshot: Arc<LocalWorkspaceManifestSnapshot>,
247}
248
249#[derive(Debug, Default)]
250struct RecentFiles {
251 entries: HashMap<String, RecentFileState>,
252 next_sequence: u64,
253}
254
255impl RecentFiles {
256 fn touch(&mut self, path: String, now: u64) {
257 self.next_sequence = self.next_sequence.saturating_add(1);
258 let sequence = self.next_sequence;
259 self.entries
260 .entry(path.clone())
261 .and_modify(|entry| {
262 entry.touched_at_ms = now;
263 entry.touch_count = entry.touch_count.saturating_add(1);
264 entry.sequence = sequence;
265 })
266 .or_insert(RecentFileState {
267 path,
268 touched_at_ms: now,
269 touch_count: 1,
270 sequence,
271 });
272 self.prune(now);
273 }
274
275 fn entries(
276 &self,
277 index: Option<&ManifestIndex>,
278 limit: usize,
279 now: u64,
280 ) -> Vec<RecentWorkspaceFile> {
281 let mut entries = self
282 .entries
283 .values()
284 .filter(|entry| {
285 index
286 .map(|index| index.by_path.contains_key(&entry.path))
287 .unwrap_or(true)
288 })
289 .map(|entry| {
290 let score = recent_score(entry, now);
291 (
292 entry.sequence,
293 RecentWorkspaceFile {
294 path: entry.path.clone(),
295 score,
296 touched_at_ms: entry.touched_at_ms,
297 touch_count: entry.touch_count,
298 },
299 )
300 })
301 .collect::<Vec<_>>();
302
303 entries.sort_by(|(left_sequence, left), (right_sequence, right)| {
304 right
305 .score
306 .total_cmp(&left.score)
307 .then_with(|| right.touched_at_ms.cmp(&left.touched_at_ms))
308 .then_with(|| right_sequence.cmp(left_sequence))
309 .then_with(|| left.path.cmp(&right.path))
310 });
311 entries
312 .into_iter()
313 .take(limit)
314 .map(|(_, entry)| entry)
315 .collect()
316 }
317
318 fn prune(&mut self, now: u64) {
319 if self.entries.len() <= RECENT_FILE_LIMIT {
320 return;
321 }
322
323 let keep = self
324 .entries
325 .values()
326 .map(|entry| (entry.path.clone(), recent_score(entry, now), entry.sequence))
327 .collect::<Vec<_>>();
328 let mut keep = keep;
329 keep.sort_by(|left, right| {
330 right
331 .1
332 .total_cmp(&left.1)
333 .then_with(|| right.2.cmp(&left.2))
334 .then_with(|| left.0.cmp(&right.0))
335 });
336 let keep = keep
337 .into_iter()
338 .take(RECENT_FILE_LIMIT)
339 .map(|(path, _, _)| path)
340 .collect::<HashSet<_>>();
341 self.entries.retain(|path, _| keep.contains(path));
342 }
343}
344
345#[derive(Debug)]
346struct RecentFileState {
347 path: String,
348 touched_at_ms: u64,
349 touch_count: u32,
350 sequence: u64,
351}
352
353#[derive(Debug, Default)]
354struct ManifestIndex {
355 all: Vec<usize>,
356 by_path: HashMap<String, usize>,
357 by_basename: HashMap<String, Vec<usize>>,
358 by_extension: HashMap<String, Vec<usize>>,
359}
360
361impl ManifestIndex {
362 fn build(files: &[LocalWorkspaceFile]) -> Self {
363 let mut index = Self {
364 all: Vec::with_capacity(files.len()),
365 by_path: HashMap::with_capacity(files.len()),
366 by_basename: HashMap::new(),
367 by_extension: HashMap::new(),
368 };
369
370 for (file_index, file) in files.iter().enumerate() {
371 index.all.push(file_index);
372 index.by_path.insert(file.path.clone(), file_index);
373 if let Some(name) = Path::new(&file.path)
374 .file_name()
375 .and_then(|name| name.to_str())
376 {
377 index
378 .by_basename
379 .entry(name.to_string())
380 .or_default()
381 .push(file_index);
382 }
383 if let Some(extension) = Path::new(&file.path)
384 .extension()
385 .and_then(|extension| extension.to_str())
386 .filter(|extension| !extension.is_empty())
387 {
388 index
389 .by_extension
390 .entry(extension.to_string())
391 .or_default()
392 .push(file_index);
393 }
394 }
395
396 index
397 }
398}
399
400struct ManifestSearchSnapshot {
401 snapshot: Arc<LocalWorkspaceManifestSnapshot>,
402 index: Arc<ManifestIndex>,
403}
404
405pub struct ManifestWorkspaceBackend {
407 local: Arc<LocalWorkspaceBackend>,
408 manifest: Arc<LocalWorkspaceManifest>,
409}
410
411impl ManifestWorkspaceBackend {
412 pub fn new(root: impl Into<PathBuf>) -> Arc<Self> {
413 let local = Arc::new(LocalWorkspaceBackend::new(root.into()));
414 let manifest = LocalWorkspaceManifest::start(local.root.clone());
415 Self::from_manifest(local, manifest)
416 }
417
418 pub fn from_manifest(
419 local: Arc<LocalWorkspaceBackend>,
420 manifest: Arc<LocalWorkspaceManifest>,
421 ) -> Arc<Self> {
422 Arc::new(Self { local, manifest })
423 }
424
425 pub fn manifest(&self) -> Arc<LocalWorkspaceManifest> {
426 Arc::clone(&self.manifest)
427 }
428
429 pub fn local_root(&self) -> &Path {
430 &self.local.root
431 }
432
433 fn manifest_ready(&self) -> Option<ManifestSearchSnapshot> {
434 let state = self.manifest.state.read().ok()?;
435 (state.snapshot.version > 0).then(|| ManifestSearchSnapshot {
436 snapshot: Arc::clone(&state.snapshot),
437 index: Arc::clone(&state.index),
438 })
439 }
440
441 fn fallback_search(&self) -> Arc<LocalWorkspaceBackend> {
442 Arc::clone(&self.local)
443 }
444
445 fn recent_path_ranks(&self, index: &ManifestIndex) -> HashMap<String, usize> {
446 self.manifest
447 .recent
448 .read()
449 .map(|recent| {
450 recent
451 .entries(Some(index), RECENT_FILE_LIMIT, now_ms())
452 .into_iter()
453 .enumerate()
454 .map(|(rank, entry)| (entry.path, rank))
455 .collect()
456 })
457 .unwrap_or_default()
458 }
459}
460
461impl WorkspacePathResolver for ManifestWorkspaceBackend {
462 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
463 self.local.normalize(input)
464 }
465}
466
467#[async_trait]
468impl WorkspaceFileSystem for ManifestWorkspaceBackend {
469 async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
470 let content = self.local.read_text(path).await?;
471 self.manifest.touch_file(path.as_str());
472 Ok(content)
473 }
474
475 async fn write_text(
476 &self,
477 path: &WorkspacePath,
478 content: &str,
479 ) -> WorkspaceResult<WorkspaceWriteOutcome> {
480 let outcome = self.local.write_text(path, content).await?;
481 self.manifest.touch_file(path.as_str());
482 Ok(outcome)
483 }
484
485 async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
486 self.local.list_dir(path).await
487 }
488}
489
490#[async_trait]
491impl WorkspaceTextReader for ManifestWorkspaceBackend {
492 async fn read_text_range(
493 &self,
494 path: &WorkspacePath,
495 offset: usize,
496 limit: usize,
497 ) -> WorkspaceResult<WorkspaceTextRange> {
498 let range = self.local.read_text_range(path, offset, limit).await?;
499 self.manifest.touch_file(path.as_str());
500 Ok(range)
501 }
502}
503
504#[async_trait]
505impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
506 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
507 self.local.exec(request).await
508 }
509}
510
511#[async_trait]
512impl WorkspaceSearch for ManifestWorkspaceBackend {
513 async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
514 validate_relative_pattern(&request.pattern, "glob pattern")?;
515 let Some(search_snapshot) = self.manifest_ready() else {
516 return self.fallback_search().glob(request).await;
517 };
518 let pattern = glob::Pattern::new(&request.pattern)
519 .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
520 let candidates =
521 candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
522 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
523
524 let mut matches = Vec::new();
525 for file_index in
526 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
527 {
528 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
529 continue;
530 };
531 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
532 continue;
533 };
534 if glob_matches(&pattern, relative_to_base) {
535 matches.push(WorkspacePath::from_normalized(file.path.clone()));
536 }
537 }
538
539 sort_paths_by_recent(&mut matches, &recent_ranks);
540 Ok(WorkspaceGlobResult { matches })
541 }
542
543 async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
544 Ok(self.grep_with_sources(request).await?.result)
545 }
546
547 async fn grep_with_sources(
548 &self,
549 request: WorkspaceGrepRequest,
550 ) -> Result<WorkspaceGrepOutcome> {
551 if let Some(ref glob) = request.glob {
552 validate_relative_pattern(glob, "grep glob filter")?;
553 }
554 let Some(search_snapshot) = self.manifest_ready() else {
555 return self.fallback_search().grep_with_sources(request).await;
556 };
557
558 let regex_pattern = if request.case_insensitive {
559 format!("(?i){}", request.pattern)
560 } else {
561 request.pattern.clone()
562 };
563 let regex = regex::Regex::new(®ex_pattern)
564 .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
565 let glob = request
566 .glob
567 .as_deref()
568 .map(glob::Pattern::new)
569 .transpose()
570 .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
571
572 let mut output = String::new();
573 let mut match_count = 0;
574 let mut file_count = 0;
575 let mut total_size = 0;
576 let mut matched_paths = Vec::new();
577
578 let candidates = request
579 .glob
580 .as_deref()
581 .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
582 .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
583 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
584
585 for file_index in
586 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
587 {
588 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
589 continue;
590 };
591 if file.binary {
592 continue;
593 }
594 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
595 continue;
596 };
597 if let Some(glob) = &glob {
598 if !glob_matches(glob, relative_to_base) {
599 continue;
600 }
601 }
602
603 let full_path = search_snapshot.snapshot.root.join(&file.path);
604 let content = match std::fs::read_to_string(&full_path) {
605 Ok(content) => content,
606 Err(_) => continue,
607 };
608 let lines: Vec<&str> = content.lines().collect();
609 let file_matches = lines
610 .iter()
611 .enumerate()
612 .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
613 .collect::<Vec<_>>();
614
615 if file_matches.is_empty() {
616 continue;
617 }
618
619 file_count += 1;
620 let workspace_path = WorkspacePath::from_normalized(file.path.clone());
621 let display_path = escape_control_chars_for_display(&file.path);
622 let mut path_recorded = false;
623 for &match_idx in &file_matches {
624 if total_size > request.max_output_size {
625 return Ok(WorkspaceGrepOutcome {
626 result: WorkspaceGrepResult {
627 output,
628 match_count,
629 file_count,
630 truncated: true,
631 },
632 matched_paths: Some(matched_paths),
633 });
634 }
635
636 if !path_recorded {
637 matched_paths.push(workspace_path.clone());
638 path_recorded = true;
639 }
640 match_count += 1;
641 let start = match_idx.saturating_sub(request.context_lines);
642 let end = (match_idx + request.context_lines + 1).min(lines.len());
643
644 for (i, line) in lines[start..end].iter().enumerate() {
645 let abs_i = start + i;
646 let prefix = if abs_i == match_idx { ">" } else { " " };
647 let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
648 total_size += line.len();
649 output.push_str(&line);
650 }
651
652 if request.context_lines > 0 {
653 output.push_str("--\n");
654 total_size += 3;
655 }
656 }
657 }
658
659 Ok(WorkspaceGrepOutcome {
660 result: WorkspaceGrepResult {
661 output,
662 match_count,
663 file_count,
664 truncated: false,
665 },
666 matched_paths: Some(matched_paths),
667 })
668 }
669}
670
671#[async_trait]
672impl WorkspaceGit for ManifestWorkspaceBackend {
673 async fn is_repository(&self) -> Result<bool> {
674 self.local.is_repository().await
675 }
676
677 async fn status(&self) -> Result<WorkspaceGitStatus> {
678 self.local.status().await
679 }
680
681 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
682 self.local.log(max_count).await
683 }
684
685 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
686 self.local.list_branches().await
687 }
688
689 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
690 self.local.create_branch(request).await
691 }
692
693 async fn checkout(
694 &self,
695 request: WorkspaceGitCheckoutRequest,
696 ) -> Result<WorkspaceGitCheckoutOutput> {
697 self.local.checkout(request).await
698 }
699
700 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
701 self.local.diff(request).await
702 }
703
704 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
705 self.local.list_remotes().await
706 }
707}
708
709#[async_trait]
710impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
711 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
712 self.local.list_stashes().await
713 }
714
715 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
716 self.local.stash(request).await
717 }
718}
719
720#[async_trait]
721impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
722 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
723 self.local.list_worktrees().await
724 }
725
726 async fn create_worktree(
727 &self,
728 request: WorkspaceGitCreateWorktreeRequest,
729 ) -> Result<WorkspaceGitWorktreeMutation> {
730 self.local.create_worktree(request).await
731 }
732
733 async fn remove_worktree(
734 &self,
735 request: WorkspaceGitRemoveWorktreeRequest,
736 ) -> Result<WorkspaceGitWorktreeMutation> {
737 self.local.remove_worktree(request).await
738 }
739}
740
741fn update_state(
742 state: &Arc<RwLock<ManifestState>>,
743 files: Vec<LocalWorkspaceFile>,
744) -> Option<LocalWorkspaceManifestSnapshot> {
745 let fingerprint = fingerprint_files(&files);
746 let index = Arc::new(ManifestIndex::build(&files));
747 let Ok(mut state) = state.write() else {
748 return None;
749 };
750 if state.snapshot.version > 0 && state.fingerprint == fingerprint {
751 return None;
752 }
753 state.fingerprint = fingerprint;
754 state.index = index;
755 state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
756 version: state.snapshot.version + 1,
757 root: state.snapshot.root.clone(),
758 files,
759 scanned_at_ms: now_ms(),
760 });
761 Some((*state.snapshot).clone())
762}
763
764enum CandidateIndices<'a> {
765 Indexed(&'a [usize]),
766 Single(Option<usize>),
767}
768
769impl<'a> CandidateIndices<'a> {
770 fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
771 match self {
772 Self::Indexed(indices) => Box::new(indices.iter().copied()),
773 Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
774 Self::Single(None) => Box::new(std::iter::empty()),
775 }
776 }
777
778 fn len(&self) -> usize {
779 match self {
780 Self::Indexed(indices) => indices.len(),
781 Self::Single(Some(_)) => 1,
782 Self::Single(None) => 0,
783 }
784 }
785
786 fn contains(&self, index: usize) -> bool {
787 match self {
788 Self::Indexed(indices) => indices.contains(&index),
789 Self::Single(Some(candidate)) => *candidate == index,
790 Self::Single(None) => false,
791 }
792 }
793}
794
795fn recent_first_candidate_indices(
796 candidates: &CandidateIndices<'_>,
797 index: &ManifestIndex,
798 recent_ranks: &HashMap<String, usize>,
799) -> Vec<usize> {
800 if recent_ranks.is_empty() {
801 return candidates.iter().collect();
802 }
803
804 let mut hot = recent_ranks
805 .iter()
806 .filter_map(|(path, rank)| {
807 let file_index = *index.by_path.get(path)?;
808 candidates
809 .contains(file_index)
810 .then_some((*rank, file_index))
811 })
812 .collect::<Vec<_>>();
813 hot.sort_unstable_by_key(|(rank, _)| *rank);
814
815 let mut out = Vec::with_capacity(candidates.len());
816 let mut seen = HashSet::with_capacity(hot.len());
817 for (_, file_index) in hot {
818 if seen.insert(file_index) {
819 out.push(file_index);
820 }
821 }
822 out.extend(
823 candidates
824 .iter()
825 .filter(|file_index| !seen.contains(file_index)),
826 );
827 out
828}
829
830fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
831 paths.sort_by(|left, right| {
832 recent_ranks
833 .get(left.as_str())
834 .copied()
835 .unwrap_or(usize::MAX)
836 .cmp(
837 &recent_ranks
838 .get(right.as_str())
839 .copied()
840 .unwrap_or(usize::MAX),
841 )
842 .then_with(|| left.as_str().cmp(right.as_str()))
843 });
844}
845
846fn candidate_indices_for_glob<'a>(
847 index: &'a ManifestIndex,
848 base: &WorkspacePath,
849 pattern: &str,
850) -> CandidateIndices<'a> {
851 if !has_glob_meta(pattern) && pattern.contains('/') {
852 return CandidateIndices::Single(
853 literal_workspace_path(base, pattern)
854 .and_then(|path| index.by_path.get(&path).copied()),
855 );
856 }
857
858 if let Some(name) = literal_terminal_segment(pattern) {
859 return index
860 .by_basename
861 .get(name)
862 .map(|indices| CandidateIndices::Indexed(indices))
863 .unwrap_or(CandidateIndices::Single(None));
864 }
865
866 if let Some(extension) = simple_extension_terminal(pattern) {
867 return index
868 .by_extension
869 .get(extension)
870 .map(|indices| CandidateIndices::Indexed(indices))
871 .unwrap_or(CandidateIndices::Single(None));
872 }
873
874 CandidateIndices::Indexed(&index.all)
875}
876
877fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
878 let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
879 if pattern.is_empty() {
880 return None;
881 }
882 if base.is_root() {
883 Some(pattern)
884 } else {
885 Some(format!(
886 "{}/{}",
887 base.as_str().trim_end_matches('/'),
888 pattern
889 ))
890 }
891}
892
893fn literal_terminal_segment(pattern: &str) -> Option<&str> {
894 let terminal = pattern
895 .trim_end_matches('/')
896 .rsplit('/')
897 .next()
898 .filter(|segment| !segment.is_empty())?;
899 (!has_glob_meta(terminal)).then_some(terminal)
900}
901
902fn simple_extension_terminal(pattern: &str) -> Option<&str> {
903 let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
904 let extension = terminal.strip_prefix("*.")?;
905 (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
906}
907
908fn has_glob_meta(pattern: &str) -> bool {
909 pattern
910 .bytes()
911 .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
912}
913
914fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
915 let mut hasher = DefaultHasher::new();
916 files.hash(&mut hasher);
917 hasher.finish()
918}
919
920fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
921 let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
922 let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
923 let frequency =
924 ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
925 RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
926}
927
928fn normalize_recent_file_path(path: &str) -> Option<String> {
929 let path = path.trim();
930 if path.is_empty() {
931 return None;
932 }
933 let normalized = normalize_relative_path_lossy(Path::new(path))?;
934 (!normalized.is_empty()).then_some(normalized)
935}
936
937fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
938 let mut parts = Vec::new();
939 for component in path.components() {
940 match component {
941 Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
942 Component::CurDir => {}
943 _ => return None,
944 }
945 }
946 Some(parts.join("/"))
947}
948
949fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
950 if base.is_root() {
951 return Some(path);
952 }
953 let base = base.as_str().trim_end_matches('/');
954 if path == base {
955 Some("")
956 } else {
957 path.strip_prefix(base)
958 .and_then(|tail| tail.strip_prefix('/'))
959 .filter(|tail| !tail.is_empty())
960 }
961}
962
963fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
964 let path = Path::new(path);
965 pattern.matches_path(path)
966 || path
967 .file_name()
968 .and_then(|name| name.to_str())
969 .is_some_and(|name| pattern.matches(name))
970}
971
972fn now_ms() -> u64 {
973 system_time_ms(SystemTime::now())
974}
975
976fn system_time_ms(time: SystemTime) -> u64 {
977 time.duration_since(UNIX_EPOCH)
978 .map(|duration| duration.as_millis() as u64)
979 .unwrap_or_default()
980}
981
982#[cfg(test)]
983#[path = "manifest/tests.rs"]
984mod tests;