Skip to main content

a3s_code_core/workspace/
manifest.rs

1//! Manifest-backed local workspace services.
2//!
3//! The manifest is an in-memory index of workspace files. It is built
4//! asynchronously, refreshed from filesystem notifications, and used by the
5//! local search backend (`glob`/`grep`) to avoid walking the filesystem for
6//! every agent tool call. File I/O, command execution, and git operations still
7//! delegate to [`LocalWorkspaceBackend`].
8
9use 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/// Git/workspace status for a file in the manifest.
40#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
41pub enum LocalWorkspaceFileStatus {
42    Tracked,
43    Untracked,
44    Unknown,
45}
46
47/// One manifest entry.
48#[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/// Recency/usage score for a workspace file the user or agent touched.
60///
61/// Hosts should treat this as a ranking hint, not as an authoritative file
62/// list. The manifest filters deleted files when exposing recent entries.
63#[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/// Immutable manifest snapshot.
72#[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
95/// Shared in-memory workspace manifest.
96pub 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    /// Start the manifest scanner/watcher for `root`.
105    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    /// Record that a workspace-relative file was opened, read, or written.
141    ///
142    /// This intentionally does not require the initial manifest scan to have
143    /// completed. The public recent-file views filter against the current
144    /// manifest index, so early touches become visible after the file is indexed
145    /// and deleted files disappear automatically.
146    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    /// Return the hottest known files, newest/frequently used first.
158    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    /// Return recent file paths only, preserving hot-file order.
172    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
348/// Local backend that uses an in-memory manifest for search.
349pub 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(&regex_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}