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