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