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    WorkspaceTextRange, WorkspaceTextReader, WorkspaceWriteOutcome,
20};
21use anyhow::{anyhow, Result};
22use async_trait::async_trait;
23use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
24use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
25use std::hash::{Hash, Hasher};
26use std::path::{Component, Path, PathBuf};
27use std::sync::{Arc, RwLock};
28use std::time::{Duration, SystemTime, UNIX_EPOCH};
29use tokio::sync::{broadcast, mpsc};
30
31mod scanner;
32use scanner::is_relevant_event;
33pub use scanner::scan_workspace_files;
34
35const WATCH_DEBOUNCE: Duration = Duration::from_millis(150);
36const WATCH_STARTUP_SCAN_INTERVAL: Duration = Duration::from_secs(1);
37const SNAPSHOT_CHANNEL_CAPACITY: usize = 16;
38const RECENT_FILE_LIMIT: usize = 128;
39const RECENT_DECAY_HALF_LIFE_MS: f32 = 10.0 * 60.0 * 1000.0;
40const RECENT_FREQUENCY_NORMALIZER: f32 = 16.0;
41const RECENT_RECENCY_WEIGHT: f32 = 0.75;
42
43/// 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 WorkspaceTextReader for ManifestWorkspaceBackend {
439    async fn read_text_range(
440        &self,
441        path: &WorkspacePath,
442        offset: usize,
443        limit: usize,
444    ) -> WorkspaceResult<WorkspaceTextRange> {
445        let range = self.local.read_text_range(path, offset, limit).await?;
446        self.manifest.touch_file(path.as_str());
447        Ok(range)
448    }
449}
450
451#[async_trait]
452impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
453    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
454        self.local.exec(request).await
455    }
456}
457
458#[async_trait]
459impl WorkspaceSearch for ManifestWorkspaceBackend {
460    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
461        validate_relative_pattern(&request.pattern, "glob pattern")?;
462        let Some(search_snapshot) = self.manifest_ready() else {
463            return self.fallback_search().glob(request).await;
464        };
465        let pattern = glob::Pattern::new(&request.pattern)
466            .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
467        let candidates =
468            candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
469        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
470
471        let mut matches = Vec::new();
472        for file_index in
473            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
474        {
475            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
476                continue;
477            };
478            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
479                continue;
480            };
481            if glob_matches(&pattern, relative_to_base) {
482                matches.push(WorkspacePath::from_normalized(file.path.clone()));
483            }
484        }
485
486        sort_paths_by_recent(&mut matches, &recent_ranks);
487        Ok(WorkspaceGlobResult { matches })
488    }
489
490    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
491        Ok(self.grep_with_sources(request).await?.result)
492    }
493
494    async fn grep_with_sources(
495        &self,
496        request: WorkspaceGrepRequest,
497    ) -> Result<WorkspaceGrepOutcome> {
498        if let Some(ref glob) = request.glob {
499            validate_relative_pattern(glob, "grep glob filter")?;
500        }
501        let Some(search_snapshot) = self.manifest_ready() else {
502            return self.fallback_search().grep_with_sources(request).await;
503        };
504
505        let regex_pattern = if request.case_insensitive {
506            format!("(?i){}", request.pattern)
507        } else {
508            request.pattern.clone()
509        };
510        let regex = regex::Regex::new(&regex_pattern)
511            .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
512        let glob = request
513            .glob
514            .as_deref()
515            .map(glob::Pattern::new)
516            .transpose()
517            .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
518
519        let mut output = String::new();
520        let mut match_count = 0;
521        let mut file_count = 0;
522        let mut total_size = 0;
523        let mut matched_paths = Vec::new();
524
525        let candidates = request
526            .glob
527            .as_deref()
528            .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
529            .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
530        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
531
532        for file_index in
533            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
534        {
535            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
536                continue;
537            };
538            if file.binary {
539                continue;
540            }
541            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
542                continue;
543            };
544            if let Some(glob) = &glob {
545                if !glob_matches(glob, relative_to_base) {
546                    continue;
547                }
548            }
549
550            let full_path = search_snapshot.snapshot.root.join(&file.path);
551            let content = match std::fs::read_to_string(&full_path) {
552                Ok(content) => content,
553                Err(_) => continue,
554            };
555            let lines: Vec<&str> = content.lines().collect();
556            let file_matches = lines
557                .iter()
558                .enumerate()
559                .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
560                .collect::<Vec<_>>();
561
562            if file_matches.is_empty() {
563                continue;
564            }
565
566            file_count += 1;
567            let workspace_path = WorkspacePath::from_normalized(file.path.clone());
568            let display_path = escape_control_chars_for_display(&file.path);
569            let mut path_recorded = false;
570            for &match_idx in &file_matches {
571                if total_size > request.max_output_size {
572                    return Ok(WorkspaceGrepOutcome {
573                        result: WorkspaceGrepResult {
574                            output,
575                            match_count,
576                            file_count,
577                            truncated: true,
578                        },
579                        matched_paths: Some(matched_paths),
580                    });
581                }
582
583                if !path_recorded {
584                    matched_paths.push(workspace_path.clone());
585                    path_recorded = true;
586                }
587                match_count += 1;
588                let start = match_idx.saturating_sub(request.context_lines);
589                let end = (match_idx + request.context_lines + 1).min(lines.len());
590
591                for (i, line) in lines[start..end].iter().enumerate() {
592                    let abs_i = start + i;
593                    let prefix = if abs_i == match_idx { ">" } else { " " };
594                    let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
595                    total_size += line.len();
596                    output.push_str(&line);
597                }
598
599                if request.context_lines > 0 {
600                    output.push_str("--\n");
601                    total_size += 3;
602                }
603            }
604        }
605
606        Ok(WorkspaceGrepOutcome {
607            result: WorkspaceGrepResult {
608                output,
609                match_count,
610                file_count,
611                truncated: false,
612            },
613            matched_paths: Some(matched_paths),
614        })
615    }
616}
617
618#[async_trait]
619impl WorkspaceGit for ManifestWorkspaceBackend {
620    async fn is_repository(&self) -> Result<bool> {
621        self.local.is_repository().await
622    }
623
624    async fn status(&self) -> Result<WorkspaceGitStatus> {
625        self.local.status().await
626    }
627
628    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
629        self.local.log(max_count).await
630    }
631
632    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
633        self.local.list_branches().await
634    }
635
636    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
637        self.local.create_branch(request).await
638    }
639
640    async fn checkout(
641        &self,
642        request: WorkspaceGitCheckoutRequest,
643    ) -> Result<WorkspaceGitCheckoutOutput> {
644        self.local.checkout(request).await
645    }
646
647    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
648        self.local.diff(request).await
649    }
650
651    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
652        self.local.list_remotes().await
653    }
654}
655
656#[async_trait]
657impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
658    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
659        self.local.list_stashes().await
660    }
661
662    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
663        self.local.stash(request).await
664    }
665}
666
667#[async_trait]
668impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
669    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
670        self.local.list_worktrees().await
671    }
672
673    async fn create_worktree(
674        &self,
675        request: WorkspaceGitCreateWorktreeRequest,
676    ) -> Result<WorkspaceGitWorktreeMutation> {
677        self.local.create_worktree(request).await
678    }
679
680    async fn remove_worktree(
681        &self,
682        request: WorkspaceGitRemoveWorktreeRequest,
683    ) -> Result<WorkspaceGitWorktreeMutation> {
684        self.local.remove_worktree(request).await
685    }
686}
687
688async fn run_manifest_task(
689    root: PathBuf,
690    state: Arc<RwLock<ManifestState>>,
691    snapshots: broadcast::Sender<LocalWorkspaceManifestSnapshot>,
692) {
693    let (event_tx, mut event_rx) = mpsc::unbounded_channel();
694    // Readiness must not depend on the platform watcher service. Watcher
695    // construction is blocking on some platforms and can be slow or fail
696    // under resource pressure, while the initial manifest is still useful.
697    publish_scan(&root, &state, &snapshots).await;
698
699    let watcher_root = root.clone();
700    let mut watcher_task = tokio::task::spawn_blocking(move || {
701        RecommendedWatcher::new(
702            move |event| {
703                let _ = event_tx.send(event);
704            },
705            Config::default(),
706        )
707        .and_then(|mut watcher| {
708            watcher.watch(&watcher_root, RecursiveMode::Recursive)?;
709            Ok(watcher)
710        })
711    });
712    let watcher = loop {
713        tokio::select! {
714            result = &mut watcher_task => break result,
715            _ = tokio::time::sleep(WATCH_STARTUP_SCAN_INTERVAL) => {
716                // Continue providing a fresh manifest while the platform
717                // watcher service is slow to initialize.
718                publish_scan(&root, &state, &snapshots).await;
719            }
720        }
721    };
722    let Ok(Ok(_watcher)) = watcher else {
723        return;
724    };
725    // Close the scan-to-watch registration window: files changed while the
726    // watcher was being constructed are captured by this second scan.
727    publish_scan(&root, &state, &snapshots).await;
728
729    while let Some(event) = event_rx.recv().await {
730        let Ok(event) = event else {
731            continue;
732        };
733        if !is_relevant_event(&event, &root) {
734            continue;
735        }
736        tokio::time::sleep(WATCH_DEBOUNCE).await;
737        while let Ok(event) = event_rx.try_recv() {
738            if let Ok(event) = event {
739                if !is_relevant_event(&event, &root) {
740                    continue;
741                }
742            }
743        }
744        publish_scan(&root, &state, &snapshots).await;
745    }
746}
747
748async fn publish_scan(
749    root: &Path,
750    state: &Arc<RwLock<ManifestState>>,
751    snapshots: &broadcast::Sender<LocalWorkspaceManifestSnapshot>,
752) {
753    let root = root.to_path_buf();
754    let Ok(files) = tokio::task::spawn_blocking(move || scan_workspace_files(&root)).await else {
755        return;
756    };
757    let Some(snapshot) = update_state(state, files) else {
758        return;
759    };
760    let _ = snapshots.send(snapshot);
761}
762
763fn update_state(
764    state: &Arc<RwLock<ManifestState>>,
765    files: Vec<LocalWorkspaceFile>,
766) -> Option<LocalWorkspaceManifestSnapshot> {
767    let fingerprint = fingerprint_files(&files);
768    let index = Arc::new(ManifestIndex::build(&files));
769    let Ok(mut state) = state.write() else {
770        return None;
771    };
772    if state.snapshot.version > 0 && state.fingerprint == fingerprint {
773        return None;
774    }
775    state.fingerprint = fingerprint;
776    state.index = index;
777    state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
778        version: state.snapshot.version + 1,
779        root: state.snapshot.root.clone(),
780        files,
781        scanned_at_ms: now_ms(),
782    });
783    Some((*state.snapshot).clone())
784}
785
786enum CandidateIndices<'a> {
787    Indexed(&'a [usize]),
788    Single(Option<usize>),
789}
790
791impl<'a> CandidateIndices<'a> {
792    fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
793        match self {
794            Self::Indexed(indices) => Box::new(indices.iter().copied()),
795            Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
796            Self::Single(None) => Box::new(std::iter::empty()),
797        }
798    }
799
800    fn len(&self) -> usize {
801        match self {
802            Self::Indexed(indices) => indices.len(),
803            Self::Single(Some(_)) => 1,
804            Self::Single(None) => 0,
805        }
806    }
807
808    fn contains(&self, index: usize) -> bool {
809        match self {
810            Self::Indexed(indices) => indices.contains(&index),
811            Self::Single(Some(candidate)) => *candidate == index,
812            Self::Single(None) => false,
813        }
814    }
815}
816
817fn recent_first_candidate_indices(
818    candidates: &CandidateIndices<'_>,
819    index: &ManifestIndex,
820    recent_ranks: &HashMap<String, usize>,
821) -> Vec<usize> {
822    if recent_ranks.is_empty() {
823        return candidates.iter().collect();
824    }
825
826    let mut hot = recent_ranks
827        .iter()
828        .filter_map(|(path, rank)| {
829            let file_index = *index.by_path.get(path)?;
830            candidates
831                .contains(file_index)
832                .then_some((*rank, file_index))
833        })
834        .collect::<Vec<_>>();
835    hot.sort_unstable_by_key(|(rank, _)| *rank);
836
837    let mut out = Vec::with_capacity(candidates.len());
838    let mut seen = HashSet::with_capacity(hot.len());
839    for (_, file_index) in hot {
840        if seen.insert(file_index) {
841            out.push(file_index);
842        }
843    }
844    out.extend(
845        candidates
846            .iter()
847            .filter(|file_index| !seen.contains(file_index)),
848    );
849    out
850}
851
852fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
853    paths.sort_by(|left, right| {
854        recent_ranks
855            .get(left.as_str())
856            .copied()
857            .unwrap_or(usize::MAX)
858            .cmp(
859                &recent_ranks
860                    .get(right.as_str())
861                    .copied()
862                    .unwrap_or(usize::MAX),
863            )
864            .then_with(|| left.as_str().cmp(right.as_str()))
865    });
866}
867
868fn candidate_indices_for_glob<'a>(
869    index: &'a ManifestIndex,
870    base: &WorkspacePath,
871    pattern: &str,
872) -> CandidateIndices<'a> {
873    if !has_glob_meta(pattern) && pattern.contains('/') {
874        return CandidateIndices::Single(
875            literal_workspace_path(base, pattern)
876                .and_then(|path| index.by_path.get(&path).copied()),
877        );
878    }
879
880    if let Some(name) = literal_terminal_segment(pattern) {
881        return index
882            .by_basename
883            .get(name)
884            .map(|indices| CandidateIndices::Indexed(indices))
885            .unwrap_or(CandidateIndices::Single(None));
886    }
887
888    if let Some(extension) = simple_extension_terminal(pattern) {
889        return index
890            .by_extension
891            .get(extension)
892            .map(|indices| CandidateIndices::Indexed(indices))
893            .unwrap_or(CandidateIndices::Single(None));
894    }
895
896    CandidateIndices::Indexed(&index.all)
897}
898
899fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
900    let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
901    if pattern.is_empty() {
902        return None;
903    }
904    if base.is_root() {
905        Some(pattern)
906    } else {
907        Some(format!(
908            "{}/{}",
909            base.as_str().trim_end_matches('/'),
910            pattern
911        ))
912    }
913}
914
915fn literal_terminal_segment(pattern: &str) -> Option<&str> {
916    let terminal = pattern
917        .trim_end_matches('/')
918        .rsplit('/')
919        .next()
920        .filter(|segment| !segment.is_empty())?;
921    (!has_glob_meta(terminal)).then_some(terminal)
922}
923
924fn simple_extension_terminal(pattern: &str) -> Option<&str> {
925    let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
926    let extension = terminal.strip_prefix("*.")?;
927    (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
928}
929
930fn has_glob_meta(pattern: &str) -> bool {
931    pattern
932        .bytes()
933        .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
934}
935
936fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
937    let mut hasher = DefaultHasher::new();
938    files.hash(&mut hasher);
939    hasher.finish()
940}
941
942fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
943    let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
944    let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
945    let frequency =
946        ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
947    RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
948}
949
950fn normalize_recent_file_path(path: &str) -> Option<String> {
951    let path = path.trim();
952    if path.is_empty() {
953        return None;
954    }
955    let normalized = normalize_relative_path_lossy(Path::new(path))?;
956    (!normalized.is_empty()).then_some(normalized)
957}
958
959fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
960    let mut parts = Vec::new();
961    for component in path.components() {
962        match component {
963            Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
964            Component::CurDir => {}
965            _ => return None,
966        }
967    }
968    Some(parts.join("/"))
969}
970
971fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
972    if base.is_root() {
973        return Some(path);
974    }
975    let base = base.as_str().trim_end_matches('/');
976    if path == base {
977        Some("")
978    } else {
979        path.strip_prefix(base)
980            .and_then(|tail| tail.strip_prefix('/'))
981            .filter(|tail| !tail.is_empty())
982    }
983}
984
985fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
986    let path = Path::new(path);
987    pattern.matches_path(path)
988        || path
989            .file_name()
990            .and_then(|name| name.to_str())
991            .is_some_and(|name| pattern.matches(name))
992}
993
994fn now_ms() -> u64 {
995    system_time_ms(SystemTime::now())
996}
997
998fn system_time_ms(time: SystemTime) -> u64 {
999    time.duration_since(UNIX_EPOCH)
1000        .map(|duration| duration.as_millis() as u64)
1001        .unwrap_or_default()
1002}
1003
1004#[cfg(test)]
1005#[path = "manifest/tests.rs"]
1006mod tests;