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