1use super::{
10 validate_relative_pattern, CommandOutput, CommandRequest, LocalWorkspaceBackend,
11 WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceGit,
12 WorkspaceGitBranch, WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest,
13 WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest,
14 WorkspaceGitDiffRequest, WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest,
15 WorkspaceGitStash, WorkspaceGitStashProvider, WorkspaceGitStashRequest, WorkspaceGitStatus,
16 WorkspaceGitWorktree, WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider,
17 WorkspaceGlobRequest, WorkspaceGlobResult, WorkspaceGrepRequest, WorkspaceGrepResult,
18 WorkspacePath, WorkspacePathResolver, WorkspaceResult, WorkspaceSearch, WorkspaceWriteOutcome,
19};
20use anyhow::{anyhow, Result};
21use async_trait::async_trait;
22use ignore::WalkBuilder;
23use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
24use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
25use std::hash::{Hash, Hasher};
26use std::path::{Component, Path, PathBuf};
27use std::process::Command;
28use std::sync::{Arc, RwLock};
29use std::time::{Duration, SystemTime, UNIX_EPOCH};
30use tokio::sync::{broadcast, mpsc};
31
32const WATCH_DEBOUNCE: Duration = Duration::from_millis(150);
33const SNAPSHOT_CHANNEL_CAPACITY: usize = 16;
34const RECENT_FILE_LIMIT: usize = 128;
35const RECENT_DECAY_HALF_LIFE_MS: f32 = 10.0 * 60.0 * 1000.0;
36const RECENT_FREQUENCY_NORMALIZER: f32 = 16.0;
37const RECENT_RECENCY_WEIGHT: f32 = 0.75;
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
41pub enum LocalWorkspaceFileStatus {
42 Tracked,
43 Untracked,
44 Unknown,
45}
46
47#[derive(Clone, Debug, Eq, PartialEq, Hash)]
49pub struct LocalWorkspaceFile {
50 pub path: String,
51 pub size: u64,
52 pub modified_ms: Option<u64>,
53 pub language: Option<String>,
54 pub status: LocalWorkspaceFileStatus,
55 pub binary: bool,
56 pub generated: bool,
57}
58
59#[derive(Clone, Debug, PartialEq)]
64pub struct RecentWorkspaceFile {
65 pub path: String,
66 pub score: f32,
67 pub touched_at_ms: u64,
68 pub touch_count: u32,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct LocalWorkspaceManifestSnapshot {
74 pub version: u64,
75 pub root: PathBuf,
76 pub files: Vec<LocalWorkspaceFile>,
77 pub scanned_at_ms: u64,
78}
79
80impl LocalWorkspaceManifestSnapshot {
81 pub fn empty(root: PathBuf) -> Self {
82 Self {
83 version: 0,
84 root,
85 files: Vec::new(),
86 scanned_at_ms: now_ms(),
87 }
88 }
89
90 pub fn file_paths(&self) -> Vec<String> {
91 self.files.iter().map(|file| file.path.clone()).collect()
92 }
93}
94
95pub struct LocalWorkspaceManifest {
97 state: Arc<RwLock<ManifestState>>,
98 recent: Arc<RwLock<RecentFiles>>,
99 snapshots: broadcast::Sender<LocalWorkspaceManifestSnapshot>,
100 task: tokio::task::JoinHandle<()>,
101}
102
103impl LocalWorkspaceManifest {
104 pub fn start(root: impl Into<PathBuf>) -> Arc<Self> {
106 let root = root.into();
107 let root = root.canonicalize().unwrap_or_else(|_| root.clone());
108 let initial = LocalWorkspaceManifestSnapshot::empty(root.clone());
109 let state = Arc::new(RwLock::new(ManifestState {
110 fingerprint: fingerprint_files(&initial.files),
111 index: Arc::new(ManifestIndex::build(&initial.files)),
112 snapshot: Arc::new(initial),
113 }));
114 let recent = Arc::new(RwLock::new(RecentFiles::default()));
115 let (snapshots, _) = broadcast::channel(SNAPSHOT_CHANNEL_CAPACITY);
116 let task_state = Arc::clone(&state);
117 let task_snapshots = snapshots.clone();
118 let task = tokio::spawn(async move {
119 run_manifest_task(root, task_state, task_snapshots).await;
120 });
121 Arc::new(Self {
122 state,
123 recent,
124 snapshots,
125 task,
126 })
127 }
128
129 pub fn snapshot(&self) -> LocalWorkspaceManifestSnapshot {
130 self.state
131 .read()
132 .map(|state| (*state.snapshot).clone())
133 .unwrap_or_else(|_| LocalWorkspaceManifestSnapshot::empty(PathBuf::new()))
134 }
135
136 pub fn subscribe(&self) -> broadcast::Receiver<LocalWorkspaceManifestSnapshot> {
137 self.snapshots.subscribe()
138 }
139
140 pub fn touch_file(&self, path: impl AsRef<str>) -> bool {
147 let Some(path) = normalize_recent_file_path(path.as_ref()) else {
148 return false;
149 };
150 let Ok(mut recent) = self.recent.write() else {
151 return false;
152 };
153 recent.touch(path, now_ms());
154 true
155 }
156
157 pub fn recent_file_entries(&self, limit: usize) -> Vec<RecentWorkspaceFile> {
159 if limit == 0 {
160 return Vec::new();
161 }
162 let Some(index) = self.state.read().ok().map(|state| Arc::clone(&state.index)) else {
163 return Vec::new();
164 };
165 self.recent
166 .read()
167 .map(|recent| recent.entries(Some(&index), limit, now_ms()))
168 .unwrap_or_default()
169 }
170
171 pub fn recent_file_paths(&self, limit: usize) -> Vec<String> {
173 self.recent_file_entries(limit)
174 .into_iter()
175 .map(|entry| entry.path)
176 .collect()
177 }
178}
179
180impl Drop for LocalWorkspaceManifest {
181 fn drop(&mut self) {
182 self.task.abort();
183 }
184}
185
186struct ManifestState {
187 fingerprint: u64,
188 index: Arc<ManifestIndex>,
189 snapshot: Arc<LocalWorkspaceManifestSnapshot>,
190}
191
192#[derive(Debug, Default)]
193struct RecentFiles {
194 entries: HashMap<String, RecentFileState>,
195 next_sequence: u64,
196}
197
198impl RecentFiles {
199 fn touch(&mut self, path: String, now: u64) {
200 self.next_sequence = self.next_sequence.saturating_add(1);
201 let sequence = self.next_sequence;
202 self.entries
203 .entry(path.clone())
204 .and_modify(|entry| {
205 entry.touched_at_ms = now;
206 entry.touch_count = entry.touch_count.saturating_add(1);
207 entry.sequence = sequence;
208 })
209 .or_insert(RecentFileState {
210 path,
211 touched_at_ms: now,
212 touch_count: 1,
213 sequence,
214 });
215 self.prune(now);
216 }
217
218 fn entries(
219 &self,
220 index: Option<&ManifestIndex>,
221 limit: usize,
222 now: u64,
223 ) -> Vec<RecentWorkspaceFile> {
224 let mut entries = self
225 .entries
226 .values()
227 .filter(|entry| {
228 index
229 .map(|index| index.by_path.contains_key(&entry.path))
230 .unwrap_or(true)
231 })
232 .map(|entry| {
233 let score = recent_score(entry, now);
234 (
235 entry.sequence,
236 RecentWorkspaceFile {
237 path: entry.path.clone(),
238 score,
239 touched_at_ms: entry.touched_at_ms,
240 touch_count: entry.touch_count,
241 },
242 )
243 })
244 .collect::<Vec<_>>();
245
246 entries.sort_by(|(left_sequence, left), (right_sequence, right)| {
247 right
248 .score
249 .total_cmp(&left.score)
250 .then_with(|| right.touched_at_ms.cmp(&left.touched_at_ms))
251 .then_with(|| right_sequence.cmp(left_sequence))
252 .then_with(|| left.path.cmp(&right.path))
253 });
254 entries
255 .into_iter()
256 .take(limit)
257 .map(|(_, entry)| entry)
258 .collect()
259 }
260
261 fn prune(&mut self, now: u64) {
262 if self.entries.len() <= RECENT_FILE_LIMIT {
263 return;
264 }
265
266 let keep = self
267 .entries
268 .values()
269 .map(|entry| (entry.path.clone(), recent_score(entry, now), entry.sequence))
270 .collect::<Vec<_>>();
271 let mut keep = keep;
272 keep.sort_by(|left, right| {
273 right
274 .1
275 .total_cmp(&left.1)
276 .then_with(|| right.2.cmp(&left.2))
277 .then_with(|| left.0.cmp(&right.0))
278 });
279 let keep = keep
280 .into_iter()
281 .take(RECENT_FILE_LIMIT)
282 .map(|(path, _, _)| path)
283 .collect::<HashSet<_>>();
284 self.entries.retain(|path, _| keep.contains(path));
285 }
286}
287
288#[derive(Debug)]
289struct RecentFileState {
290 path: String,
291 touched_at_ms: u64,
292 touch_count: u32,
293 sequence: u64,
294}
295
296#[derive(Debug, Default)]
297struct ManifestIndex {
298 all: Vec<usize>,
299 by_path: HashMap<String, usize>,
300 by_basename: HashMap<String, Vec<usize>>,
301 by_extension: HashMap<String, Vec<usize>>,
302}
303
304impl ManifestIndex {
305 fn build(files: &[LocalWorkspaceFile]) -> Self {
306 let mut index = Self {
307 all: Vec::with_capacity(files.len()),
308 by_path: HashMap::with_capacity(files.len()),
309 by_basename: HashMap::new(),
310 by_extension: HashMap::new(),
311 };
312
313 for (file_index, file) in files.iter().enumerate() {
314 index.all.push(file_index);
315 index.by_path.insert(file.path.clone(), file_index);
316 if let Some(name) = Path::new(&file.path)
317 .file_name()
318 .and_then(|name| name.to_str())
319 {
320 index
321 .by_basename
322 .entry(name.to_string())
323 .or_default()
324 .push(file_index);
325 }
326 if let Some(extension) = Path::new(&file.path)
327 .extension()
328 .and_then(|extension| extension.to_str())
329 .filter(|extension| !extension.is_empty())
330 {
331 index
332 .by_extension
333 .entry(extension.to_string())
334 .or_default()
335 .push(file_index);
336 }
337 }
338
339 index
340 }
341}
342
343struct ManifestSearchSnapshot {
344 snapshot: Arc<LocalWorkspaceManifestSnapshot>,
345 index: Arc<ManifestIndex>,
346}
347
348pub struct ManifestWorkspaceBackend {
350 local: Arc<LocalWorkspaceBackend>,
351 manifest: Arc<LocalWorkspaceManifest>,
352}
353
354impl ManifestWorkspaceBackend {
355 pub fn new(root: impl Into<PathBuf>) -> Arc<Self> {
356 let local = Arc::new(LocalWorkspaceBackend::new(root.into()));
357 let manifest = LocalWorkspaceManifest::start(local.root.clone());
358 Self::from_manifest(local, manifest)
359 }
360
361 pub fn from_manifest(
362 local: Arc<LocalWorkspaceBackend>,
363 manifest: Arc<LocalWorkspaceManifest>,
364 ) -> Arc<Self> {
365 Arc::new(Self { local, manifest })
366 }
367
368 pub fn manifest(&self) -> Arc<LocalWorkspaceManifest> {
369 Arc::clone(&self.manifest)
370 }
371
372 pub fn local_root(&self) -> &Path {
373 &self.local.root
374 }
375
376 fn manifest_ready(&self) -> Option<ManifestSearchSnapshot> {
377 let state = self.manifest.state.read().ok()?;
378 (state.snapshot.version > 0).then(|| ManifestSearchSnapshot {
379 snapshot: Arc::clone(&state.snapshot),
380 index: Arc::clone(&state.index),
381 })
382 }
383
384 fn fallback_search(&self) -> Arc<LocalWorkspaceBackend> {
385 Arc::clone(&self.local)
386 }
387
388 fn recent_path_ranks(&self, index: &ManifestIndex) -> HashMap<String, usize> {
389 self.manifest
390 .recent
391 .read()
392 .map(|recent| {
393 recent
394 .entries(Some(index), RECENT_FILE_LIMIT, now_ms())
395 .into_iter()
396 .enumerate()
397 .map(|(rank, entry)| (entry.path, rank))
398 .collect()
399 })
400 .unwrap_or_default()
401 }
402}
403
404impl WorkspacePathResolver for ManifestWorkspaceBackend {
405 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
406 self.local.normalize(input)
407 }
408}
409
410#[async_trait]
411impl WorkspaceFileSystem for ManifestWorkspaceBackend {
412 async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
413 let content = self.local.read_text(path).await?;
414 self.manifest.touch_file(path.as_str());
415 Ok(content)
416 }
417
418 async fn write_text(
419 &self,
420 path: &WorkspacePath,
421 content: &str,
422 ) -> WorkspaceResult<WorkspaceWriteOutcome> {
423 let outcome = self.local.write_text(path, content).await?;
424 self.manifest.touch_file(path.as_str());
425 Ok(outcome)
426 }
427
428 async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
429 self.local.list_dir(path).await
430 }
431}
432
433#[async_trait]
434impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
435 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
436 self.local.exec(request).await
437 }
438}
439
440#[async_trait]
441impl WorkspaceSearch for ManifestWorkspaceBackend {
442 async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
443 validate_relative_pattern(&request.pattern, "glob pattern")?;
444 let Some(search_snapshot) = self.manifest_ready() else {
445 return self.fallback_search().glob(request).await;
446 };
447 let pattern = glob::Pattern::new(&request.pattern)
448 .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
449 let candidates =
450 candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
451 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
452
453 let mut matches = Vec::new();
454 for file_index in
455 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
456 {
457 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
458 continue;
459 };
460 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
461 continue;
462 };
463 if glob_matches(&pattern, relative_to_base) {
464 matches.push(WorkspacePath::from_normalized(file.path.clone()));
465 }
466 }
467
468 sort_paths_by_recent(&mut matches, &recent_ranks);
469 Ok(WorkspaceGlobResult { matches })
470 }
471
472 async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
473 if let Some(ref glob) = request.glob {
474 validate_relative_pattern(glob, "grep glob filter")?;
475 }
476 let Some(search_snapshot) = self.manifest_ready() else {
477 return self.fallback_search().grep(request).await;
478 };
479
480 let regex_pattern = if request.case_insensitive {
481 format!("(?i){}", request.pattern)
482 } else {
483 request.pattern.clone()
484 };
485 let regex = regex::Regex::new(®ex_pattern)
486 .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
487 let glob = request
488 .glob
489 .as_deref()
490 .map(glob::Pattern::new)
491 .transpose()
492 .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
493
494 let mut output = String::new();
495 let mut match_count = 0;
496 let mut file_count = 0;
497 let mut total_size = 0;
498
499 let candidates = request
500 .glob
501 .as_deref()
502 .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
503 .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
504 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
505
506 for file_index in
507 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
508 {
509 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
510 continue;
511 };
512 if file.binary {
513 continue;
514 }
515 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
516 continue;
517 };
518 if let Some(glob) = &glob {
519 if !glob_matches(glob, relative_to_base) {
520 continue;
521 }
522 }
523
524 let full_path = search_snapshot.snapshot.root.join(&file.path);
525 let content = match std::fs::read_to_string(&full_path) {
526 Ok(content) => content,
527 Err(_) => continue,
528 };
529 let lines: Vec<&str> = content.lines().collect();
530 let file_matches = lines
531 .iter()
532 .enumerate()
533 .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
534 .collect::<Vec<_>>();
535
536 if file_matches.is_empty() {
537 continue;
538 }
539
540 file_count += 1;
541 for &match_idx in &file_matches {
542 if total_size > request.max_output_size {
543 return Ok(WorkspaceGrepResult {
544 output,
545 match_count,
546 file_count,
547 truncated: true,
548 });
549 }
550
551 match_count += 1;
552 let start = match_idx.saturating_sub(request.context_lines);
553 let end = (match_idx + request.context_lines + 1).min(lines.len());
554
555 for (i, line) in lines[start..end].iter().enumerate() {
556 let abs_i = start + i;
557 let prefix = if abs_i == match_idx { ">" } else { " " };
558 let line = format!("{}{}:{}: {}\n", prefix, file.path, abs_i + 1, line);
559 total_size += line.len();
560 output.push_str(&line);
561 }
562
563 if request.context_lines > 0 {
564 output.push_str("--\n");
565 total_size += 3;
566 }
567 }
568 }
569
570 Ok(WorkspaceGrepResult {
571 output,
572 match_count,
573 file_count,
574 truncated: false,
575 })
576 }
577}
578
579#[async_trait]
580impl WorkspaceGit for ManifestWorkspaceBackend {
581 async fn is_repository(&self) -> Result<bool> {
582 self.local.is_repository().await
583 }
584
585 async fn status(&self) -> Result<WorkspaceGitStatus> {
586 self.local.status().await
587 }
588
589 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
590 self.local.log(max_count).await
591 }
592
593 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
594 self.local.list_branches().await
595 }
596
597 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
598 self.local.create_branch(request).await
599 }
600
601 async fn checkout(
602 &self,
603 request: WorkspaceGitCheckoutRequest,
604 ) -> Result<WorkspaceGitCheckoutOutput> {
605 self.local.checkout(request).await
606 }
607
608 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
609 self.local.diff(request).await
610 }
611
612 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
613 self.local.list_remotes().await
614 }
615}
616
617#[async_trait]
618impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
619 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
620 self.local.list_stashes().await
621 }
622
623 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
624 self.local.stash(request).await
625 }
626}
627
628#[async_trait]
629impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
630 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
631 self.local.list_worktrees().await
632 }
633
634 async fn create_worktree(
635 &self,
636 request: WorkspaceGitCreateWorktreeRequest,
637 ) -> Result<WorkspaceGitWorktreeMutation> {
638 self.local.create_worktree(request).await
639 }
640
641 async fn remove_worktree(
642 &self,
643 request: WorkspaceGitRemoveWorktreeRequest,
644 ) -> Result<WorkspaceGitWorktreeMutation> {
645 self.local.remove_worktree(request).await
646 }
647}
648
649async fn run_manifest_task(
650 root: PathBuf,
651 state: Arc<RwLock<ManifestState>>,
652 snapshots: broadcast::Sender<LocalWorkspaceManifestSnapshot>,
653) {
654 let (event_tx, mut event_rx) = mpsc::unbounded_channel();
655 let watcher = RecommendedWatcher::new(
656 move |event| {
657 let _ = event_tx.send(event);
658 },
659 Config::default(),
660 )
661 .and_then(|mut watcher| {
662 watcher.watch(&root, RecursiveMode::Recursive)?;
663 Ok(watcher)
664 });
665
666 publish_scan(&root, &state, &snapshots).await;
667
668 let Ok(_watcher) = watcher else {
669 return;
670 };
671
672 while let Some(event) = event_rx.recv().await {
673 let Ok(event) = event else {
674 continue;
675 };
676 if !is_relevant_event(&event, &root) {
677 continue;
678 }
679 tokio::time::sleep(WATCH_DEBOUNCE).await;
680 while let Ok(event) = event_rx.try_recv() {
681 if let Ok(event) = event {
682 if !is_relevant_event(&event, &root) {
683 continue;
684 }
685 }
686 }
687 publish_scan(&root, &state, &snapshots).await;
688 }
689}
690
691async fn publish_scan(
692 root: &Path,
693 state: &Arc<RwLock<ManifestState>>,
694 snapshots: &broadcast::Sender<LocalWorkspaceManifestSnapshot>,
695) {
696 let root = root.to_path_buf();
697 let Ok(files) = tokio::task::spawn_blocking(move || scan_workspace_files(&root)).await else {
698 return;
699 };
700 let Some(snapshot) = update_state(state, files) else {
701 return;
702 };
703 let _ = snapshots.send(snapshot);
704}
705
706fn update_state(
707 state: &Arc<RwLock<ManifestState>>,
708 files: Vec<LocalWorkspaceFile>,
709) -> Option<LocalWorkspaceManifestSnapshot> {
710 let fingerprint = fingerprint_files(&files);
711 let index = Arc::new(ManifestIndex::build(&files));
712 let Ok(mut state) = state.write() else {
713 return None;
714 };
715 if state.snapshot.version > 0 && state.fingerprint == fingerprint {
716 return None;
717 }
718 state.fingerprint = fingerprint;
719 state.index = index;
720 state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
721 version: state.snapshot.version + 1,
722 root: state.snapshot.root.clone(),
723 files,
724 scanned_at_ms: now_ms(),
725 });
726 Some((*state.snapshot).clone())
727}
728
729pub fn scan_workspace_files(root: &Path) -> Vec<LocalWorkspaceFile> {
730 let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
731 let mut files = scan_with_ignore(&root);
732 if let Some(paths) = git_workspace_paths(&root) {
733 apply_git_statuses(&root, &mut files, paths);
734 }
735 sorted_dedup(files)
736}
737
738fn git_workspace_paths(root: &Path) -> Option<Vec<(PathBuf, LocalWorkspaceFileStatus)>> {
739 let mut out = Vec::new();
740 let tracked = git_ls_files(
741 root,
742 &["ls-files", "--cached", "--recurse-submodules", "-z"],
743 )
744 .or_else(|| git_ls_files(root, &["ls-files", "--cached", "-z"]))?;
745 out.extend(
746 tracked
747 .into_iter()
748 .map(|path| (path, LocalWorkspaceFileStatus::Tracked)),
749 );
750 let untracked = git_ls_files(root, &["ls-files", "--others", "--exclude-standard", "-z"])
751 .unwrap_or_default();
752 out.extend(
753 untracked
754 .into_iter()
755 .map(|path| (path, LocalWorkspaceFileStatus::Untracked)),
756 );
757 Some(out)
758}
759
760fn git_ls_files(root: &Path, args: &[&str]) -> Option<Vec<PathBuf>> {
761 let output = Command::new("git")
762 .arg("-C")
763 .arg(root)
764 .args(args)
765 .output()
766 .ok()?;
767 if !output.status.success() {
768 return None;
769 }
770 Some(
771 output
772 .stdout
773 .split(|byte| *byte == 0)
774 .filter(|raw| !raw.is_empty())
775 .map(|raw| PathBuf::from(String::from_utf8_lossy(raw).into_owned()))
776 .collect(),
777 )
778}
779
780fn apply_git_statuses(
781 root: &Path,
782 files: &mut Vec<LocalWorkspaceFile>,
783 paths: Vec<(PathBuf, LocalWorkspaceFileStatus)>,
784) {
785 let mut statuses = HashMap::<String, LocalWorkspaceFileStatus>::new();
786 for (relative, status) in paths {
787 if path_has_noise_component(&relative) {
788 continue;
789 }
790 let Some(relative) = normalize_relative_path_lossy(&relative) else {
791 continue;
792 };
793 statuses
794 .entry(relative)
795 .and_modify(|existing| *existing = preferred_status(*existing, status))
796 .or_insert(status);
797 }
798
799 for file in files.iter_mut() {
800 if let Some(status) = statuses.remove(&file.path) {
801 file.status = status;
802 }
803 }
804
805 for (relative, status) in statuses {
806 if let Some(file) = workspace_file(root, Path::new(&relative), status) {
807 files.push(file);
808 }
809 }
810}
811
812fn preferred_status(
813 existing: LocalWorkspaceFileStatus,
814 incoming: LocalWorkspaceFileStatus,
815) -> LocalWorkspaceFileStatus {
816 match (existing, incoming) {
817 (LocalWorkspaceFileStatus::Tracked, _) | (_, LocalWorkspaceFileStatus::Tracked) => {
818 LocalWorkspaceFileStatus::Tracked
819 }
820 (LocalWorkspaceFileStatus::Untracked, _) | (_, LocalWorkspaceFileStatus::Untracked) => {
821 LocalWorkspaceFileStatus::Untracked
822 }
823 _ => LocalWorkspaceFileStatus::Unknown,
824 }
825}
826
827fn scan_with_ignore(root: &Path) -> Vec<LocalWorkspaceFile> {
828 let filter_root = root.to_path_buf();
829 WalkBuilder::new(root)
830 .hidden(false)
831 .parents(true)
832 .ignore(true)
833 .git_ignore(true)
834 .git_exclude(true)
835 .git_global(true)
836 .filter_entry(move |entry| {
837 entry
838 .path()
839 .strip_prefix(&filter_root)
840 .map(|relative| !path_has_noise_component(relative))
841 .unwrap_or(true)
842 })
843 .build()
844 .filter_map(Result::ok)
845 .filter_map(|entry| {
846 let path = entry.path();
847 if path == root {
848 return None;
849 }
850 let relative = path.strip_prefix(root).ok()?;
851 workspace_file(root, relative, LocalWorkspaceFileStatus::Unknown)
852 })
853 .collect()
854}
855
856fn workspace_file(
857 root: &Path,
858 relative: &Path,
859 status: LocalWorkspaceFileStatus,
860) -> Option<LocalWorkspaceFile> {
861 let relative = normalize_relative_path_lossy(relative)?;
862 if relative.is_empty() {
863 return None;
864 }
865 let full_path = root.join(&relative);
866 let metadata = std::fs::metadata(&full_path).ok()?;
867 if !metadata.is_file() {
868 return None;
869 }
870 Some(LocalWorkspaceFile {
871 language: language_for_path(Path::new(&relative)).map(str::to_string),
872 binary: is_binary_file(&full_path, metadata.len()),
873 generated: is_generated_path(Path::new(&relative)),
874 modified_ms: metadata.modified().ok().map(system_time_ms),
875 size: metadata.len(),
876 path: relative,
877 status,
878 })
879}
880
881fn sorted_dedup(files: Vec<LocalWorkspaceFile>) -> Vec<LocalWorkspaceFile> {
882 let mut by_path = HashMap::<String, LocalWorkspaceFile>::new();
883 for file in files {
884 by_path
885 .entry(file.path.clone())
886 .and_modify(|existing| {
887 if existing.status == LocalWorkspaceFileStatus::Unknown
888 && file.status != LocalWorkspaceFileStatus::Unknown
889 {
890 *existing = file.clone();
891 }
892 })
893 .or_insert(file);
894 }
895 let mut files = by_path.into_values().collect::<Vec<_>>();
896 files.sort_by(|a, b| a.path.cmp(&b.path));
897 files
898}
899
900enum CandidateIndices<'a> {
901 Indexed(&'a [usize]),
902 Single(Option<usize>),
903}
904
905impl<'a> CandidateIndices<'a> {
906 fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
907 match self {
908 Self::Indexed(indices) => Box::new(indices.iter().copied()),
909 Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
910 Self::Single(None) => Box::new(std::iter::empty()),
911 }
912 }
913
914 fn len(&self) -> usize {
915 match self {
916 Self::Indexed(indices) => indices.len(),
917 Self::Single(Some(_)) => 1,
918 Self::Single(None) => 0,
919 }
920 }
921
922 fn contains(&self, index: usize) -> bool {
923 match self {
924 Self::Indexed(indices) => indices.contains(&index),
925 Self::Single(Some(candidate)) => *candidate == index,
926 Self::Single(None) => false,
927 }
928 }
929}
930
931fn recent_first_candidate_indices(
932 candidates: &CandidateIndices<'_>,
933 index: &ManifestIndex,
934 recent_ranks: &HashMap<String, usize>,
935) -> Vec<usize> {
936 if recent_ranks.is_empty() {
937 return candidates.iter().collect();
938 }
939
940 let mut hot = recent_ranks
941 .iter()
942 .filter_map(|(path, rank)| {
943 let file_index = *index.by_path.get(path)?;
944 candidates
945 .contains(file_index)
946 .then_some((*rank, file_index))
947 })
948 .collect::<Vec<_>>();
949 hot.sort_unstable_by_key(|(rank, _)| *rank);
950
951 let mut out = Vec::with_capacity(candidates.len());
952 let mut seen = HashSet::with_capacity(hot.len());
953 for (_, file_index) in hot {
954 if seen.insert(file_index) {
955 out.push(file_index);
956 }
957 }
958 out.extend(
959 candidates
960 .iter()
961 .filter(|file_index| !seen.contains(file_index)),
962 );
963 out
964}
965
966fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
967 paths.sort_by(|left, right| {
968 recent_ranks
969 .get(left.as_str())
970 .copied()
971 .unwrap_or(usize::MAX)
972 .cmp(
973 &recent_ranks
974 .get(right.as_str())
975 .copied()
976 .unwrap_or(usize::MAX),
977 )
978 .then_with(|| left.as_str().cmp(right.as_str()))
979 });
980}
981
982fn candidate_indices_for_glob<'a>(
983 index: &'a ManifestIndex,
984 base: &WorkspacePath,
985 pattern: &str,
986) -> CandidateIndices<'a> {
987 if !has_glob_meta(pattern) && pattern.contains('/') {
988 return CandidateIndices::Single(
989 literal_workspace_path(base, pattern)
990 .and_then(|path| index.by_path.get(&path).copied()),
991 );
992 }
993
994 if let Some(name) = literal_terminal_segment(pattern) {
995 return index
996 .by_basename
997 .get(name)
998 .map(|indices| CandidateIndices::Indexed(indices))
999 .unwrap_or(CandidateIndices::Single(None));
1000 }
1001
1002 if let Some(extension) = simple_extension_terminal(pattern) {
1003 return index
1004 .by_extension
1005 .get(extension)
1006 .map(|indices| CandidateIndices::Indexed(indices))
1007 .unwrap_or(CandidateIndices::Single(None));
1008 }
1009
1010 CandidateIndices::Indexed(&index.all)
1011}
1012
1013fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
1014 let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
1015 if pattern.is_empty() {
1016 return None;
1017 }
1018 if base.is_root() {
1019 Some(pattern)
1020 } else {
1021 Some(format!(
1022 "{}/{}",
1023 base.as_str().trim_end_matches('/'),
1024 pattern
1025 ))
1026 }
1027}
1028
1029fn literal_terminal_segment(pattern: &str) -> Option<&str> {
1030 let terminal = pattern
1031 .trim_end_matches('/')
1032 .rsplit('/')
1033 .next()
1034 .filter(|segment| !segment.is_empty())?;
1035 (!has_glob_meta(terminal)).then_some(terminal)
1036}
1037
1038fn simple_extension_terminal(pattern: &str) -> Option<&str> {
1039 let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
1040 let extension = terminal.strip_prefix("*.")?;
1041 (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
1042}
1043
1044fn has_glob_meta(pattern: &str) -> bool {
1045 pattern
1046 .bytes()
1047 .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
1048}
1049
1050fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
1051 let mut hasher = DefaultHasher::new();
1052 files.hash(&mut hasher);
1053 hasher.finish()
1054}
1055
1056fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
1057 let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
1058 let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
1059 let frequency =
1060 ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
1061 RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
1062}
1063
1064fn normalize_recent_file_path(path: &str) -> Option<String> {
1065 let path = path.trim();
1066 if path.is_empty() {
1067 return None;
1068 }
1069 let normalized = normalize_relative_path_lossy(Path::new(path))?;
1070 (!normalized.is_empty()).then_some(normalized)
1071}
1072
1073fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
1074 let mut parts = Vec::new();
1075 for component in path.components() {
1076 match component {
1077 Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
1078 Component::CurDir => {}
1079 _ => return None,
1080 }
1081 }
1082 Some(parts.join("/"))
1083}
1084
1085fn path_has_noise_component(path: &Path) -> bool {
1086 path.components().any(|component| {
1087 let Component::Normal(name) = component else {
1088 return false;
1089 };
1090 matches!(
1091 name.to_string_lossy().as_ref(),
1092 ".git" | "node_modules" | "target" | ".next" | "dist" | ".DS_Store"
1093 )
1094 })
1095}
1096
1097fn is_generated_path(path: &Path) -> bool {
1098 path.components().any(|component| {
1099 let Component::Normal(name) = component else {
1100 return false;
1101 };
1102 matches!(
1103 name.to_string_lossy().as_ref(),
1104 "target" | "node_modules" | ".next" | "dist" | "build" | "coverage"
1105 )
1106 })
1107}
1108
1109fn language_for_path(path: &Path) -> Option<&'static str> {
1110 match path.extension().and_then(|ext| ext.to_str())? {
1111 "rs" => Some("rust"),
1112 "toml" => Some("toml"),
1113 "hcl" => Some("hcl"),
1114 "js" | "mjs" | "cjs" => Some("javascript"),
1115 "jsx" => Some("javascript-react"),
1116 "ts" | "mts" | "cts" => Some("typescript"),
1117 "tsx" => Some("typescript-react"),
1118 "json" => Some("json"),
1119 "md" | "mdx" => Some("markdown"),
1120 "py" => Some("python"),
1121 "go" => Some("go"),
1122 "java" => Some("java"),
1123 "kt" | "kts" => Some("kotlin"),
1124 "swift" => Some("swift"),
1125 "c" | "h" => Some("c"),
1126 "cc" | "cpp" | "cxx" | "hpp" => Some("cpp"),
1127 "cs" => Some("csharp"),
1128 "rb" => Some("ruby"),
1129 "php" => Some("php"),
1130 "sh" | "bash" | "zsh" => Some("shell"),
1131 "yml" | "yaml" => Some("yaml"),
1132 "html" | "htm" => Some("html"),
1133 "css" => Some("css"),
1134 "scss" | "sass" => Some("scss"),
1135 "sql" => Some("sql"),
1136 "xml" => Some("xml"),
1137 _ => None,
1138 }
1139}
1140
1141fn is_binary_file(path: &Path, size: u64) -> bool {
1142 if matches!(
1143 path.extension()
1144 .and_then(|ext| ext.to_str())
1145 .unwrap_or_default()
1146 .to_ascii_lowercase()
1147 .as_str(),
1148 "png"
1149 | "jpg"
1150 | "jpeg"
1151 | "gif"
1152 | "webp"
1153 | "ico"
1154 | "pdf"
1155 | "zip"
1156 | "gz"
1157 | "tgz"
1158 | "xz"
1159 | "wasm"
1160 | "dylib"
1161 | "so"
1162 | "a"
1163 | "o"
1164 | "rlib"
1165 | "class"
1166 | "jar"
1167 ) {
1168 return true;
1169 }
1170 if is_known_text_path(path) {
1171 return false;
1172 }
1173 if size == 0 {
1174 return false;
1175 }
1176 let Ok(mut file) = std::fs::File::open(path) else {
1177 return false;
1178 };
1179 let mut buf = [0u8; 2048];
1180 use std::io::Read;
1181 match file.read(&mut buf) {
1182 Ok(n) => buf[..n].contains(&0),
1183 Err(_) => false,
1184 }
1185}
1186
1187fn is_known_text_path(path: &Path) -> bool {
1188 if language_for_path(path).is_some() {
1189 return true;
1190 }
1191 if matches!(
1192 path.extension()
1193 .and_then(|ext| ext.to_str())
1194 .unwrap_or_default()
1195 .to_ascii_lowercase()
1196 .as_str(),
1197 "txt" | "lock" | "gitignore" | "dockerignore" | "env" | "example" | "sample"
1198 ) {
1199 return true;
1200 }
1201 matches!(
1202 path.file_name()
1203 .and_then(|name| name.to_str())
1204 .unwrap_or_default()
1205 .to_ascii_lowercase()
1206 .as_str(),
1207 "dockerfile" | "makefile" | "justfile" | "license" | "notice"
1208 )
1209}
1210
1211fn is_relevant_event(event: &Event, root: &Path) -> bool {
1212 if matches!(event.kind, EventKind::Access(_)) {
1213 return false;
1214 }
1215 event.paths.iter().any(|path| {
1216 path.strip_prefix(root)
1217 .map(|relative| !path_has_noise_component(relative))
1218 .unwrap_or(false)
1219 })
1220}
1221
1222fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
1223 if base.is_root() {
1224 return Some(path);
1225 }
1226 let base = base.as_str().trim_end_matches('/');
1227 if path == base {
1228 Some("")
1229 } else {
1230 path.strip_prefix(base)
1231 .and_then(|tail| tail.strip_prefix('/'))
1232 .filter(|tail| !tail.is_empty())
1233 }
1234}
1235
1236fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
1237 let path = Path::new(path);
1238 pattern.matches_path(path)
1239 || path
1240 .file_name()
1241 .and_then(|name| name.to_str())
1242 .is_some_and(|name| pattern.matches(name))
1243}
1244
1245fn now_ms() -> u64 {
1246 system_time_ms(SystemTime::now())
1247}
1248
1249fn system_time_ms(time: SystemTime) -> u64 {
1250 time.duration_since(UNIX_EPOCH)
1251 .map(|duration| duration.as_millis() as u64)
1252 .unwrap_or_default()
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258
1259 fn write(path: &Path, body: &[u8]) {
1260 if let Some(parent) = path.parent() {
1261 std::fs::create_dir_all(parent).unwrap();
1262 }
1263 std::fs::write(path, body).unwrap();
1264 }
1265
1266 fn git_available() -> bool {
1267 Command::new("git").arg("--version").output().is_ok()
1268 }
1269
1270 fn run_git(root: &Path, args: &[&str]) {
1271 let status = Command::new("git")
1272 .arg("-C")
1273 .arg(root)
1274 .args(args)
1275 .status()
1276 .unwrap();
1277 assert!(
1278 status.success(),
1279 "git command failed: git -C {root:?} {args:?}"
1280 );
1281 }
1282
1283 fn test_file(path: &str) -> LocalWorkspaceFile {
1284 LocalWorkspaceFile {
1285 path: path.to_string(),
1286 size: 0,
1287 modified_ms: None,
1288 language: None,
1289 status: LocalWorkspaceFileStatus::Unknown,
1290 binary: false,
1291 generated: false,
1292 }
1293 }
1294
1295 #[test]
1296 fn manifest_index_reduces_glob_candidates() {
1297 let files = vec![
1298 test_file("src/main.rs"),
1299 test_file("crates/demo/src/lib.rs"),
1300 test_file("README.md"),
1301 test_file("docs/README.md"),
1302 ];
1303 let index = ManifestIndex::build(&files);
1304
1305 let exact = candidate_indices_for_glob(&index, &WorkspacePath::root(), "src/main.rs")
1306 .iter()
1307 .collect::<Vec<_>>();
1308 assert_eq!(exact, vec![0]);
1309
1310 let basename = candidate_indices_for_glob(&index, &WorkspacePath::root(), "**/README.md")
1311 .iter()
1312 .collect::<Vec<_>>();
1313 assert_eq!(basename, vec![2, 3]);
1314
1315 let extension = candidate_indices_for_glob(&index, &WorkspacePath::root(), "**/*.rs")
1316 .iter()
1317 .collect::<Vec<_>>();
1318 assert_eq!(extension, vec![0, 1]);
1319 }
1320
1321 #[test]
1322 fn recent_files_are_bounded_and_ranked_by_heat() {
1323 let mut recent = RecentFiles::default();
1324 for index in 0..(RECENT_FILE_LIMIT + 5) {
1325 recent.touch(format!("src/file_{index:03}.rs"), index as u64);
1326 }
1327 recent.touch("src/file_000.rs".to_string(), 10_000);
1328 recent.touch("src/file_000.rs".to_string(), 10_001);
1329
1330 let entries = recent.entries(None, usize::MAX, 10_001);
1331
1332 assert_eq!(entries.len(), RECENT_FILE_LIMIT);
1333 assert_eq!(entries[0].path, "src/file_000.rs");
1334 assert_eq!(entries[0].touch_count, 2);
1335 }
1336
1337 #[tokio::test]
1338 async fn manifest_search_matches_glob_and_grep() {
1339 let temp = tempfile::tempdir().unwrap();
1340 write(
1341 &temp.path().join("src/main.rs"),
1342 b"fn main() {\n println!(\"hello\");\n}\n",
1343 );
1344 write(&temp.path().join("README.md"), b"hello from docs\n");
1345
1346 let backend = ManifestWorkspaceBackend::new(temp.path());
1347 let mut rx = backend.manifest().subscribe();
1348 tokio::time::timeout(Duration::from_secs(5), rx.recv())
1349 .await
1350 .unwrap()
1351 .unwrap();
1352
1353 let glob = backend
1354 .glob(WorkspaceGlobRequest {
1355 base: backend.normalize("src").unwrap(),
1356 pattern: "*.rs".to_string(),
1357 })
1358 .await
1359 .unwrap();
1360 assert_eq!(glob.matches[0].as_str(), "src/main.rs");
1361
1362 let grep = backend
1363 .grep(WorkspaceGrepRequest {
1364 base: WorkspacePath::root(),
1365 pattern: "hello".to_string(),
1366 glob: Some("*.rs".to_string()),
1367 context_lines: 0,
1368 case_insensitive: false,
1369 max_output_size: 1024,
1370 })
1371 .await
1372 .unwrap();
1373 assert_eq!(grep.match_count, 1);
1374 assert_eq!(grep.file_count, 1);
1375 assert!(grep.output.contains("src/main.rs:2"));
1376 }
1377
1378 #[tokio::test]
1379 async fn manifest_backend_read_write_touch_recent_files() {
1380 let temp = tempfile::tempdir().unwrap();
1381 write(&temp.path().join("src/main.rs"), b"fn main() {}\n");
1382
1383 let backend = ManifestWorkspaceBackend::new(temp.path());
1384 let mut rx = backend.manifest().subscribe();
1385 tokio::time::timeout(Duration::from_secs(5), rx.recv())
1386 .await
1387 .unwrap()
1388 .unwrap();
1389
1390 let path = backend.normalize("src/main.rs").unwrap();
1391 backend.read_text(&path).await.unwrap();
1392 assert_eq!(
1393 backend.manifest().recent_file_paths(4),
1394 vec!["src/main.rs".to_string()]
1395 );
1396
1397 backend
1398 .write_text(&path, "fn main() { println!(\"hi\"); }\n")
1399 .await
1400 .unwrap();
1401 let entries = backend.manifest().recent_file_entries(4);
1402 assert_eq!(entries[0].path, "src/main.rs");
1403 assert_eq!(entries[0].touch_count, 2);
1404 }
1405
1406 #[tokio::test]
1407 async fn manifest_glob_prioritizes_recent_matching_files() {
1408 let temp = tempfile::tempdir().unwrap();
1409 write(&temp.path().join("src/a.rs"), b"pub fn a() {}\n");
1410 write(&temp.path().join("src/z.rs"), b"pub fn z() {}\n");
1411
1412 let backend = ManifestWorkspaceBackend::new(temp.path());
1413 let mut rx = backend.manifest().subscribe();
1414 tokio::time::timeout(Duration::from_secs(5), rx.recv())
1415 .await
1416 .unwrap()
1417 .unwrap();
1418
1419 backend.manifest().touch_file("src/z.rs");
1420 let glob = backend
1421 .glob(WorkspaceGlobRequest {
1422 base: WorkspacePath::root(),
1423 pattern: "**/*.rs".to_string(),
1424 })
1425 .await
1426 .unwrap();
1427
1428 assert_eq!(glob.matches[0].as_str(), "src/z.rs");
1429 assert_eq!(glob.matches[1].as_str(), "src/a.rs");
1430 }
1431
1432 #[tokio::test]
1433 async fn manifest_grep_prioritizes_recent_matches_when_truncated() {
1434 let temp = tempfile::tempdir().unwrap();
1435 write(
1436 &temp.path().join("src/a.rs"),
1437 b"pub const HIT: &str = \"a\";\n",
1438 );
1439 write(
1440 &temp.path().join("src/z.rs"),
1441 b"pub const HIT: &str = \"z\";\n",
1442 );
1443
1444 let backend = ManifestWorkspaceBackend::new(temp.path());
1445 let mut rx = backend.manifest().subscribe();
1446 tokio::time::timeout(Duration::from_secs(5), rx.recv())
1447 .await
1448 .unwrap()
1449 .unwrap();
1450
1451 backend.manifest().touch_file("src/z.rs");
1452 let grep = backend
1453 .grep(WorkspaceGrepRequest {
1454 base: WorkspacePath::root(),
1455 pattern: "HIT".to_string(),
1456 glob: Some("**/*.rs".to_string()),
1457 context_lines: 0,
1458 case_insensitive: false,
1459 max_output_size: 1,
1460 })
1461 .await
1462 .unwrap();
1463
1464 assert!(grep.truncated);
1465 assert!(grep.output.starts_with(">src/z.rs:"), "{}", grep.output);
1466 }
1467
1468 #[tokio::test]
1469 async fn manifest_refreshes_after_file_event() {
1470 let temp = tempfile::tempdir().unwrap();
1471 write(&temp.path().join("README.md"), b"# hello\n");
1472 let manifest = LocalWorkspaceManifest::start(temp.path());
1473 let mut rx = manifest.subscribe();
1474 let initial = tokio::time::timeout(Duration::from_secs(5), rx.recv())
1475 .await
1476 .unwrap()
1477 .unwrap();
1478 assert!(initial.files.iter().any(|file| file.path == "README.md"));
1479
1480 write(&temp.path().join("src/lib.rs"), b"pub fn lib() {}\n");
1481 let updated = tokio::time::timeout(Duration::from_secs(10), async {
1482 loop {
1483 let snapshot = rx.recv().await.unwrap();
1484 if snapshot.files.iter().any(|file| file.path == "src/lib.rs") {
1485 break snapshot;
1486 }
1487 }
1488 })
1489 .await
1490 .unwrap();
1491
1492 assert!(updated.version > initial.version);
1493 }
1494
1495 #[tokio::test]
1496 async fn manifest_search_falls_back_before_initial_scan() {
1497 let temp = tempfile::tempdir().unwrap();
1498 write(&temp.path().join("src/main.rs"), b"fn main() {}\n");
1499 let backend = ManifestWorkspaceBackend::new(temp.path());
1500 let glob = backend
1501 .glob(WorkspaceGlobRequest {
1502 base: backend.normalize("src").unwrap(),
1503 pattern: "*.rs".to_string(),
1504 })
1505 .await
1506 .unwrap();
1507 assert!(glob
1508 .matches
1509 .iter()
1510 .any(|path| path.as_str() == "src/main.rs"));
1511 }
1512
1513 #[test]
1514 fn scan_includes_files_inside_nested_git_workspaces() {
1515 if !git_available() {
1516 return;
1517 }
1518
1519 let temp = tempfile::tempdir().unwrap();
1520 run_git(temp.path(), &["init"]);
1521 write(&temp.path().join("README.md"), b"# root\n");
1522
1523 let nested = temp.path().join("vendor/child");
1524 std::fs::create_dir_all(&nested).unwrap();
1525 run_git(&nested, &["init"]);
1526 write(&nested.join("src/lib.rs"), b"pub fn child() {}\n");
1527
1528 let files = scan_workspace_files(temp.path());
1529 assert!(files.iter().any(|file| file.path == "README.md"));
1530 assert!(files
1531 .iter()
1532 .any(|file| file.path == "vendor/child/src/lib.rs"));
1533 }
1534}