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::retrieval::{
10    ChunkCatalogLimits, ChunkingConfig, LocalWorkspaceCatalogRuntime, WorkspaceChunkCatalog,
11    WorkspaceChunkingStrategy, WorkspaceIndexError,
12};
13use super::{
14    escape_control_chars_for_display, validate_relative_pattern, CommandOutput, CommandRequest,
15    LocalWorkspaceAccessPolicy, LocalWorkspaceBackend, WorkspaceCommandRunner, WorkspaceDirEntry,
16    WorkspaceFileSystem, WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput,
17    WorkspaceGitCheckoutRequest, WorkspaceGitCommit, WorkspaceGitCreateBranchRequest,
18    WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote,
19    WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, WorkspaceGitStashProvider,
20    WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree,
21    WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest,
22    WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest, WorkspaceGrepResult,
23    WorkspacePath, WorkspacePathResolver, WorkspaceResult, WorkspaceSearch, WorkspaceTextRange,
24    WorkspaceTextReader, WorkspaceWriteOutcome,
25};
26use anyhow::{anyhow, Result};
27use async_trait::async_trait;
28use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
29use std::hash::{Hash, Hasher};
30use std::path::{Component, Path, PathBuf};
31use std::sync::{
32    atomic::{AtomicBool, Ordering},
33    Arc, OnceLock, RwLock,
34};
35#[cfg(test)]
36use std::time::Duration;
37use std::time::{SystemTime, UNIX_EPOCH};
38use tokio::sync::{broadcast, watch};
39
40mod file_kind;
41mod scanner;
42mod watcher;
43use scanner::is_relevant_event;
44pub use scanner::scan_workspace_files;
45use watcher::run_manifest_task;
46
47const SNAPSHOT_CHANNEL_CAPACITY: usize = 16;
48const FILE_CHANGE_CHANNEL_CAPACITY: usize = 256;
49const RECENT_FILE_LIMIT: usize = 128;
50const RECENT_DECAY_HALF_LIFE_MS: f32 = 10.0 * 60.0 * 1000.0;
51const RECENT_FREQUENCY_NORMALIZER: f32 = 16.0;
52const RECENT_RECENCY_WEIGHT: f32 = 0.75;
53
54/// Git/workspace status for a file in the manifest.
55#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
56pub enum LocalWorkspaceFileStatus {
57    Tracked,
58    Untracked,
59    Unknown,
60}
61
62/// One manifest entry.
63#[derive(Clone, Debug, Eq, PartialEq, Hash)]
64pub struct LocalWorkspaceFile {
65    pub path: String,
66    pub size: u64,
67    pub modified_ms: Option<u64>,
68    pub language: Option<String>,
69    pub status: LocalWorkspaceFileStatus,
70    pub binary: bool,
71    pub generated: bool,
72}
73
74/// Recency/usage score for a workspace file the user or agent touched.
75///
76/// Hosts should treat this as a ranking hint, not as an authoritative file
77/// list. The manifest filters deleted files when exposing recent entries.
78#[derive(Clone, Debug, PartialEq)]
79pub struct RecentWorkspaceFile {
80    pub path: String,
81    pub score: f32,
82    pub touched_at_ms: u64,
83    pub touch_count: u32,
84}
85
86/// Immutable manifest snapshot.
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct LocalWorkspaceManifestSnapshot {
89    pub version: u64,
90    pub root: PathBuf,
91    pub files: Vec<LocalWorkspaceFile>,
92    pub scanned_at_ms: u64,
93}
94
95/// The normalized kind of a workspace file change.
96#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
97pub enum WorkspaceFileChangeKind {
98    Created,
99    Changed,
100    Deleted,
101}
102
103/// A filesystem change for one normalized workspace-relative file path.
104#[derive(Clone, Debug, Eq, PartialEq, Hash)]
105pub struct WorkspaceFileChange {
106    pub path: WorkspacePath,
107    pub kind: WorkspaceFileChangeKind,
108}
109
110impl LocalWorkspaceManifestSnapshot {
111    pub fn empty(root: PathBuf) -> Self {
112        Self {
113            version: 0,
114            root,
115            files: Vec::new(),
116            scanned_at_ms: now_ms(),
117        }
118    }
119
120    pub fn file_paths(&self) -> Vec<String> {
121        self.files.iter().map(|file| file.path.clone()).collect()
122    }
123}
124
125/// Shared in-memory workspace manifest.
126pub struct LocalWorkspaceManifest {
127    state: Arc<RwLock<ManifestState>>,
128    recent: Arc<RwLock<RecentFiles>>,
129    snapshots: broadcast::Sender<LocalWorkspaceManifestSnapshot>,
130    changes: broadcast::Sender<WorkspaceFileChange>,
131    activation: watch::Sender<bool>,
132    scan_cancelled: Arc<AtomicBool>,
133    task: tokio::task::JoinHandle<()>,
134}
135
136impl LocalWorkspaceManifest {
137    /// Start the manifest scanner/watcher for `root`.
138    pub fn start(root: impl Into<PathBuf>) -> Arc<Self> {
139        Self::start_with_activation(root, true)
140    }
141
142    /// Create an empty manifest whose scanner and watcher start only after
143    /// [`Self::activate`] is called.
144    ///
145    /// Search-capable hosts can use this during a latency-sensitive bootstrap:
146    /// manifest-backed operations retain their local filesystem fallback while
147    /// the initial snapshot is empty, so deferring discovery does not make the
148    /// workspace inaccessible.
149    pub fn start_deferred(root: impl Into<PathBuf>) -> Arc<Self> {
150        Self::start_with_activation(root, false)
151    }
152
153    fn start_with_activation(root: impl Into<PathBuf>, active: bool) -> Arc<Self> {
154        let root = root.into();
155        let root = root.canonicalize().unwrap_or_else(|_| root.clone());
156        let initial = LocalWorkspaceManifestSnapshot::empty(root.clone());
157        let state = Arc::new(RwLock::new(ManifestState {
158            fingerprint: fingerprint_files(&initial.files),
159            index: Arc::new(ManifestIndex::build(&initial.files)),
160            snapshot: Arc::new(initial),
161        }));
162        let recent = Arc::new(RwLock::new(RecentFiles::default()));
163        let (snapshots, _) = broadcast::channel(SNAPSHOT_CHANNEL_CAPACITY);
164        let (changes, _) = broadcast::channel(FILE_CHANGE_CHANNEL_CAPACITY);
165        let (activation, mut activation_rx) = watch::channel(active);
166        let scan_cancelled = Arc::new(AtomicBool::new(false));
167        let task_state = Arc::clone(&state);
168        let task_snapshots = snapshots.clone();
169        let task_changes = changes.clone();
170        let task_scan_cancelled = Arc::clone(&scan_cancelled);
171        let task = tokio::spawn(async move {
172            if !*activation_rx.borrow() && activation_rx.wait_for(|active| *active).await.is_err() {
173                return;
174            }
175            if task_scan_cancelled.load(Ordering::Acquire) {
176                return;
177            }
178            run_manifest_task(
179                root,
180                task_state,
181                task_snapshots,
182                task_changes,
183                task_scan_cancelled,
184            )
185            .await;
186        });
187        Arc::new(Self {
188            state,
189            recent,
190            snapshots,
191            changes,
192            activation,
193            scan_cancelled,
194            task,
195        })
196    }
197
198    /// Open a deferred manifest's one-way startup gate.
199    ///
200    /// Returns `true` only for the call that transitions the manifest from
201    /// deferred to active. Calling this on an eagerly started or already active
202    /// manifest is harmless and returns `false`.
203    pub fn activate(&self) -> bool {
204        self.activation.send_if_modified(|active| {
205            if *active {
206                false
207            } else {
208                *active = true;
209                true
210            }
211        })
212    }
213
214    /// Whether discovery has been activated for this manifest.
215    pub fn is_active(&self) -> bool {
216        *self.activation.borrow()
217    }
218
219    pub fn snapshot(&self) -> LocalWorkspaceManifestSnapshot {
220        self.state
221            .read()
222            .map(|state| (*state.snapshot).clone())
223            .unwrap_or_else(|_| LocalWorkspaceManifestSnapshot::empty(PathBuf::new()))
224    }
225
226    pub fn subscribe(&self) -> broadcast::Receiver<LocalWorkspaceManifestSnapshot> {
227        self.snapshots.subscribe()
228    }
229
230    /// Subscribe to debounced, workspace-relative filesystem changes.
231    pub fn subscribe_changes(&self) -> broadcast::Receiver<WorkspaceFileChange> {
232        self.changes.subscribe()
233    }
234
235    /// Stop background discovery without waiting for an in-flight synchronous scan.
236    ///
237    /// Hosts with an explicit lifecycle should call this before shutting down
238    /// their Tokio runtime. [`Drop`] is only a fallback because other background
239    /// services may retain an `Arc` to the manifest until runtime teardown.
240    pub fn shutdown(&self) {
241        self.scan_cancelled.store(true, Ordering::Release);
242        self.task.abort();
243    }
244
245    /// Record that a workspace-relative file was opened, read, or written.
246    ///
247    /// This intentionally does not require the initial manifest scan to have
248    /// completed. The public recent-file views filter against the current
249    /// manifest index, so early touches become visible after the file is indexed
250    /// and deleted files disappear automatically.
251    pub fn touch_file(&self, path: impl AsRef<str>) -> bool {
252        let Some(path) = normalize_recent_file_path(path.as_ref()) else {
253            return false;
254        };
255        let Ok(mut recent) = self.recent.write() else {
256            return false;
257        };
258        recent.touch(path, now_ms());
259        true
260    }
261
262    /// Return the hottest known files, newest/frequently used first.
263    pub fn recent_file_entries(&self, limit: usize) -> Vec<RecentWorkspaceFile> {
264        if limit == 0 {
265            return Vec::new();
266        }
267        let Some(index) = self.state.read().ok().map(|state| Arc::clone(&state.index)) else {
268            return Vec::new();
269        };
270        self.recent
271            .read()
272            .map(|recent| recent.entries(Some(&index), limit, now_ms()))
273            .unwrap_or_default()
274    }
275
276    /// Return recent file paths only, preserving hot-file order.
277    pub fn recent_file_paths(&self, limit: usize) -> Vec<String> {
278        self.recent_file_entries(limit)
279            .into_iter()
280            .map(|entry| entry.path)
281            .collect()
282    }
283}
284
285impl Drop for LocalWorkspaceManifest {
286    fn drop(&mut self) {
287        // Aborting the async owner does not stop synchronous discovery that has
288        // already begun. Signal the scanner first so a detached traversal stops
289        // consuming filesystem resources after its host has gone away.
290        self.shutdown();
291    }
292}
293
294struct ManifestState {
295    fingerprint: u64,
296    index: Arc<ManifestIndex>,
297    snapshot: Arc<LocalWorkspaceManifestSnapshot>,
298}
299
300#[derive(Debug, Default)]
301struct RecentFiles {
302    entries: HashMap<String, RecentFileState>,
303    next_sequence: u64,
304}
305
306impl RecentFiles {
307    fn touch(&mut self, path: String, now: u64) {
308        self.next_sequence = self.next_sequence.saturating_add(1);
309        let sequence = self.next_sequence;
310        self.entries
311            .entry(path.clone())
312            .and_modify(|entry| {
313                entry.touched_at_ms = now;
314                entry.touch_count = entry.touch_count.saturating_add(1);
315                entry.sequence = sequence;
316            })
317            .or_insert(RecentFileState {
318                path,
319                touched_at_ms: now,
320                touch_count: 1,
321                sequence,
322            });
323        self.prune(now);
324    }
325
326    fn entries(
327        &self,
328        index: Option<&ManifestIndex>,
329        limit: usize,
330        now: u64,
331    ) -> Vec<RecentWorkspaceFile> {
332        let mut entries = self
333            .entries
334            .values()
335            .filter(|entry| {
336                index
337                    .map(|index| index.by_path.contains_key(&entry.path))
338                    .unwrap_or(true)
339            })
340            .map(|entry| {
341                let score = recent_score(entry, now);
342                (
343                    entry.sequence,
344                    RecentWorkspaceFile {
345                        path: entry.path.clone(),
346                        score,
347                        touched_at_ms: entry.touched_at_ms,
348                        touch_count: entry.touch_count,
349                    },
350                )
351            })
352            .collect::<Vec<_>>();
353
354        entries.sort_by(|(left_sequence, left), (right_sequence, right)| {
355            right
356                .score
357                .total_cmp(&left.score)
358                .then_with(|| right.touched_at_ms.cmp(&left.touched_at_ms))
359                .then_with(|| right_sequence.cmp(left_sequence))
360                .then_with(|| left.path.cmp(&right.path))
361        });
362        entries
363            .into_iter()
364            .take(limit)
365            .map(|(_, entry)| entry)
366            .collect()
367    }
368
369    fn prune(&mut self, now: u64) {
370        if self.entries.len() <= RECENT_FILE_LIMIT {
371            return;
372        }
373
374        let keep = self
375            .entries
376            .values()
377            .map(|entry| (entry.path.clone(), recent_score(entry, now), entry.sequence))
378            .collect::<Vec<_>>();
379        let mut keep = keep;
380        keep.sort_by(|left, right| {
381            right
382                .1
383                .total_cmp(&left.1)
384                .then_with(|| right.2.cmp(&left.2))
385                .then_with(|| left.0.cmp(&right.0))
386        });
387        let keep = keep
388            .into_iter()
389            .take(RECENT_FILE_LIMIT)
390            .map(|(path, _, _)| path)
391            .collect::<HashSet<_>>();
392        self.entries.retain(|path, _| keep.contains(path));
393    }
394}
395
396#[derive(Debug)]
397struct RecentFileState {
398    path: String,
399    touched_at_ms: u64,
400    touch_count: u32,
401    sequence: u64,
402}
403
404#[derive(Debug, Default)]
405struct ManifestIndex {
406    all: Vec<usize>,
407    by_path: HashMap<String, usize>,
408    by_basename: HashMap<String, Vec<usize>>,
409    by_extension: HashMap<String, Vec<usize>>,
410}
411
412impl ManifestIndex {
413    fn build(files: &[LocalWorkspaceFile]) -> Self {
414        let mut index = Self {
415            all: Vec::with_capacity(files.len()),
416            by_path: HashMap::with_capacity(files.len()),
417            by_basename: HashMap::new(),
418            by_extension: HashMap::new(),
419        };
420
421        for (file_index, file) in files.iter().enumerate() {
422            index.all.push(file_index);
423            index.by_path.insert(file.path.clone(), file_index);
424            if let Some(name) = Path::new(&file.path)
425                .file_name()
426                .and_then(|name| name.to_str())
427            {
428                index
429                    .by_basename
430                    .entry(name.to_string())
431                    .or_default()
432                    .push(file_index);
433            }
434            if let Some(extension) = Path::new(&file.path)
435                .extension()
436                .and_then(|extension| extension.to_str())
437                .filter(|extension| !extension.is_empty())
438            {
439                index
440                    .by_extension
441                    .entry(extension.to_string())
442                    .or_default()
443                    .push(file_index);
444            }
445        }
446
447        index
448    }
449}
450
451struct ManifestSearchSnapshot {
452    snapshot: Arc<LocalWorkspaceManifestSnapshot>,
453    index: Arc<ManifestIndex>,
454}
455
456/// Local backend that uses an in-memory manifest for search.
457pub struct ManifestWorkspaceBackend {
458    local: Arc<LocalWorkspaceBackend>,
459    catalog_local: Arc<LocalWorkspaceBackend>,
460    manifest: Arc<LocalWorkspaceManifest>,
461    catalog_runtime: OnceLock<Arc<LocalWorkspaceCatalogRuntime>>,
462}
463
464impl ManifestWorkspaceBackend {
465    pub fn new(root: impl Into<PathBuf>) -> Arc<Self> {
466        Self::new_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
467    }
468
469    pub fn new_with_access_policy(
470        root: impl Into<PathBuf>,
471        access_policy: LocalWorkspaceAccessPolicy,
472    ) -> Arc<Self> {
473        Self::new_with_access_policy_and_activation(root, access_policy, true)
474    }
475
476    /// Create a manifest-backed local workspace whose initial discovery is
477    /// explicitly activated through [`LocalWorkspaceManifest::activate`].
478    pub fn new_deferred(root: impl Into<PathBuf>) -> Arc<Self> {
479        Self::new_deferred_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
480    }
481
482    /// Create a policy-constrained manifest backend without starting its
483    /// scanner or platform watcher until the manifest is activated.
484    pub fn new_deferred_with_access_policy(
485        root: impl Into<PathBuf>,
486        access_policy: LocalWorkspaceAccessPolicy,
487    ) -> Arc<Self> {
488        Self::new_with_access_policy_and_activation(root, access_policy, false)
489    }
490
491    fn new_with_access_policy_and_activation(
492        root: impl Into<PathBuf>,
493        access_policy: LocalWorkspaceAccessPolicy,
494        active: bool,
495    ) -> Arc<Self> {
496        let root = root.into();
497        let local = Arc::new(LocalWorkspaceBackend::new_with_access_policy(
498            root,
499            access_policy,
500        ));
501        let catalog_local = Arc::new(LocalWorkspaceBackend::new_with_source_egress_policy(
502            local.root.clone(),
503        ));
504        let manifest = if active {
505            LocalWorkspaceManifest::start(local.root.clone())
506        } else {
507            LocalWorkspaceManifest::start_deferred(local.root.clone())
508        };
509        Arc::new(Self {
510            local,
511            catalog_local,
512            manifest,
513            catalog_runtime: OnceLock::new(),
514        })
515    }
516
517    pub fn from_manifest(
518        local: Arc<LocalWorkspaceBackend>,
519        manifest: Arc<LocalWorkspaceManifest>,
520    ) -> Arc<Self> {
521        let catalog_local = Arc::new(LocalWorkspaceBackend::new_with_source_egress_policy(
522            local.root.clone(),
523        ));
524        Arc::new(Self {
525            catalog_local,
526            local,
527            manifest,
528            catalog_runtime: OnceLock::new(),
529        })
530    }
531
532    pub fn manifest(&self) -> Arc<LocalWorkspaceManifest> {
533        Arc::clone(&self.manifest)
534    }
535
536    /// Enable and return a session-local catalog built from the shared manifest.
537    ///
538    /// Catalog reads always use the source-egress boundary independently from
539    /// the access policy selected for ordinary workspace tools. Unless
540    /// [`Self::configure_chunk_catalog`] ran first, this enables the compatible
541    /// default catalog configuration.
542    pub fn chunk_catalog(&self) -> Arc<WorkspaceChunkCatalog> {
543        self.catalog_runtime
544            .get_or_init(|| {
545                let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
546                LocalWorkspaceCatalogRuntime::start(Arc::clone(&self.manifest), file_system)
547            })
548            .catalog()
549    }
550
551    /// Configure and enable the catalog owned by this shared manifest backend.
552    ///
553    /// Hosts that supply [`super::WorkspaceServices`] to a session must call
554    /// this before [`Self::chunk_catalog`] or a retrieval service constructor
555    /// enables the default catalog. Configuration is one-shot so later
556    /// sessions cannot silently replace the strategy or budgets owned by the
557    /// host.
558    pub fn configure_chunk_catalog(
559        &self,
560        strategy: WorkspaceChunkingStrategy,
561        chunking: ChunkingConfig,
562        limits: ChunkCatalogLimits,
563    ) -> Result<Arc<WorkspaceChunkCatalog>, WorkspaceIndexError> {
564        let catalog = WorkspaceChunkCatalog::new_with_strategy(strategy, chunking, limits)?;
565        let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
566        let runtime = LocalWorkspaceCatalogRuntime::start_with_catalog(
567            Arc::clone(&self.manifest),
568            file_system,
569            Arc::clone(&catalog),
570        );
571        if let Err(runtime) = self.catalog_runtime.set(runtime) {
572            runtime.shutdown();
573            return Err(WorkspaceIndexError::InvalidConfig(
574                "workspace chunk catalog was already initialized".to_owned(),
575            ));
576        }
577        Ok(catalog)
578    }
579
580    /// Stop the local manifest and any lazily enabled catalog projection.
581    pub(crate) fn shutdown(&self) {
582        if let Some(runtime) = self.catalog_runtime.get() {
583            runtime.shutdown();
584        }
585        self.manifest.shutdown();
586    }
587
588    pub fn local_root(&self) -> &Path {
589        &self.local.root
590    }
591
592    fn manifest_ready(&self) -> Option<ManifestSearchSnapshot> {
593        let state = self.manifest.state.read().ok()?;
594        (state.snapshot.version > 0).then(|| ManifestSearchSnapshot {
595            snapshot: Arc::clone(&state.snapshot),
596            index: Arc::clone(&state.index),
597        })
598    }
599
600    fn fallback_search(&self) -> Arc<LocalWorkspaceBackend> {
601        Arc::clone(&self.local)
602    }
603
604    fn recent_path_ranks(&self, index: &ManifestIndex) -> HashMap<String, usize> {
605        self.manifest
606            .recent
607            .read()
608            .map(|recent| {
609                recent
610                    .entries(Some(index), RECENT_FILE_LIMIT, now_ms())
611                    .into_iter()
612                    .enumerate()
613                    .map(|(rank, entry)| (entry.path, rank))
614                    .collect()
615            })
616            .unwrap_or_default()
617    }
618}
619
620impl WorkspacePathResolver for ManifestWorkspaceBackend {
621    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
622        self.local.normalize(input)
623    }
624}
625
626#[async_trait]
627impl WorkspaceFileSystem for ManifestWorkspaceBackend {
628    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
629        let content = self.local.read_text(path).await?;
630        self.manifest.touch_file(path.as_str());
631        Ok(content)
632    }
633
634    async fn write_text(
635        &self,
636        path: &WorkspacePath,
637        content: &str,
638    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
639        let outcome = self.local.write_text(path, content).await?;
640        self.manifest.touch_file(path.as_str());
641        Ok(outcome)
642    }
643
644    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
645        self.local.list_dir(path).await
646    }
647}
648
649#[async_trait]
650impl WorkspaceTextReader for ManifestWorkspaceBackend {
651    async fn read_text_range(
652        &self,
653        path: &WorkspacePath,
654        offset: usize,
655        limit: usize,
656    ) -> WorkspaceResult<WorkspaceTextRange> {
657        let range = self.local.read_text_range(path, offset, limit).await?;
658        self.manifest.touch_file(path.as_str());
659        Ok(range)
660    }
661}
662
663#[async_trait]
664impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
665    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
666        self.local.exec(request).await
667    }
668}
669
670#[async_trait]
671impl WorkspaceSearch for ManifestWorkspaceBackend {
672    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
673        validate_relative_pattern(&request.pattern, "glob pattern")?;
674        let Some(search_snapshot) = self.manifest_ready() else {
675            return self.fallback_search().glob(request).await;
676        };
677        let pattern = glob::Pattern::new(&request.pattern)
678            .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
679        let candidates =
680            candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
681        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
682
683        let mut matches = Vec::new();
684        for file_index in
685            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
686        {
687            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
688                continue;
689            };
690            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
691                continue;
692            };
693            if glob_matches(&pattern, relative_to_base) {
694                matches.push(WorkspacePath::from_normalized(file.path.clone()));
695            }
696        }
697
698        sort_paths_by_recent(&mut matches, &recent_ranks);
699        Ok(WorkspaceGlobResult { matches })
700    }
701
702    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
703        Ok(self.grep_with_sources(request).await?.result)
704    }
705
706    async fn grep_with_sources(
707        &self,
708        request: WorkspaceGrepRequest,
709    ) -> Result<WorkspaceGrepOutcome> {
710        if let Some(ref glob) = request.glob {
711            validate_relative_pattern(glob, "grep glob filter")?;
712        }
713        self.local.ensure_search_base_allowed(&request.base)?;
714        let Some(search_snapshot) = self.manifest_ready() else {
715            return self.fallback_search().grep_with_sources(request).await;
716        };
717
718        let regex_pattern = if request.case_insensitive {
719            format!("(?i){}", request.pattern)
720        } else {
721            request.pattern.clone()
722        };
723        let regex = regex::Regex::new(&regex_pattern)
724            .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
725        let glob = request
726            .glob
727            .as_deref()
728            .map(glob::Pattern::new)
729            .transpose()
730            .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
731
732        let mut output = String::new();
733        let mut match_count = 0;
734        let mut file_count = 0;
735        let mut total_size = 0;
736        let mut matched_paths = Vec::new();
737        let metadata_only = request.max_output_size == 0;
738
739        let candidates = request
740            .glob
741            .as_deref()
742            .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
743            .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
744        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
745
746        for file_index in
747            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
748        {
749            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
750                continue;
751            };
752            if file.binary {
753                continue;
754            }
755            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
756                continue;
757            };
758            if let Some(glob) = &glob {
759                if !glob_matches(glob, relative_to_base) {
760                    continue;
761                }
762            }
763
764            let workspace_path = WorkspacePath::from_normalized(file.path.clone());
765            let Some(content) = self.local.read_search_file(&workspace_path) else {
766                continue;
767            };
768            let lines: Vec<&str> = content.lines().collect();
769            let file_matches = lines
770                .iter()
771                .enumerate()
772                .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
773                .collect::<Vec<_>>();
774
775            if file_matches.is_empty() {
776                continue;
777            }
778
779            file_count += 1;
780            let display_path = escape_control_chars_for_display(&file.path);
781            let mut path_recorded = false;
782            for &match_idx in &file_matches {
783                if !metadata_only && total_size > request.max_output_size {
784                    return Ok(WorkspaceGrepOutcome {
785                        result: WorkspaceGrepResult {
786                            output,
787                            match_count,
788                            file_count,
789                            truncated: true,
790                        },
791                        matched_paths: Some(matched_paths),
792                    });
793                }
794
795                if !path_recorded {
796                    matched_paths.push(workspace_path.clone());
797                    path_recorded = true;
798                }
799                match_count += 1;
800                if metadata_only {
801                    continue;
802                }
803                let start = match_idx.saturating_sub(request.context_lines);
804                let end = (match_idx + request.context_lines + 1).min(lines.len());
805
806                for (i, line) in lines[start..end].iter().enumerate() {
807                    let abs_i = start + i;
808                    let prefix = if abs_i == match_idx { ">" } else { " " };
809                    let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
810                    total_size += line.len();
811                    output.push_str(&line);
812                }
813
814                if request.context_lines > 0 {
815                    output.push_str("--\n");
816                    total_size += 3;
817                }
818            }
819        }
820
821        Ok(WorkspaceGrepOutcome {
822            result: WorkspaceGrepResult {
823                output,
824                match_count,
825                file_count,
826                truncated: false,
827            },
828            matched_paths: Some(matched_paths),
829        })
830    }
831}
832
833#[async_trait]
834impl WorkspaceGit for ManifestWorkspaceBackend {
835    async fn is_repository(&self) -> Result<bool> {
836        self.local.is_repository().await
837    }
838
839    async fn status(&self) -> Result<WorkspaceGitStatus> {
840        self.local.status().await
841    }
842
843    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
844        self.local.log(max_count).await
845    }
846
847    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
848        self.local.list_branches().await
849    }
850
851    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
852        self.local.create_branch(request).await
853    }
854
855    async fn checkout(
856        &self,
857        request: WorkspaceGitCheckoutRequest,
858    ) -> Result<WorkspaceGitCheckoutOutput> {
859        self.local.checkout(request).await
860    }
861
862    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
863        self.local.diff(request).await
864    }
865
866    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
867        self.local.list_remotes().await
868    }
869}
870
871#[async_trait]
872impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
873    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
874        self.local.list_stashes().await
875    }
876
877    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
878        self.local.stash(request).await
879    }
880}
881
882#[async_trait]
883impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
884    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
885        self.local.list_worktrees().await
886    }
887
888    async fn create_worktree(
889        &self,
890        request: WorkspaceGitCreateWorktreeRequest,
891    ) -> Result<WorkspaceGitWorktreeMutation> {
892        self.local.create_worktree(request).await
893    }
894
895    async fn remove_worktree(
896        &self,
897        request: WorkspaceGitRemoveWorktreeRequest,
898    ) -> Result<WorkspaceGitWorktreeMutation> {
899        self.local.remove_worktree(request).await
900    }
901}
902
903fn update_state(
904    state: &Arc<RwLock<ManifestState>>,
905    files: Vec<LocalWorkspaceFile>,
906) -> Option<LocalWorkspaceManifestSnapshot> {
907    let fingerprint = fingerprint_files(&files);
908    let index = Arc::new(ManifestIndex::build(&files));
909    let Ok(mut state) = state.write() else {
910        return None;
911    };
912    if state.snapshot.version > 0 && state.fingerprint == fingerprint {
913        return None;
914    }
915    state.fingerprint = fingerprint;
916    state.index = index;
917    state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
918        version: state.snapshot.version + 1,
919        root: state.snapshot.root.clone(),
920        files,
921        scanned_at_ms: now_ms(),
922    });
923    Some((*state.snapshot).clone())
924}
925
926enum CandidateIndices<'a> {
927    Indexed(&'a [usize]),
928    Single(Option<usize>),
929}
930
931impl<'a> CandidateIndices<'a> {
932    fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
933        match self {
934            Self::Indexed(indices) => Box::new(indices.iter().copied()),
935            Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
936            Self::Single(None) => Box::new(std::iter::empty()),
937        }
938    }
939
940    fn len(&self) -> usize {
941        match self {
942            Self::Indexed(indices) => indices.len(),
943            Self::Single(Some(_)) => 1,
944            Self::Single(None) => 0,
945        }
946    }
947
948    fn contains(&self, index: usize) -> bool {
949        match self {
950            Self::Indexed(indices) => indices.contains(&index),
951            Self::Single(Some(candidate)) => *candidate == index,
952            Self::Single(None) => false,
953        }
954    }
955}
956
957fn recent_first_candidate_indices(
958    candidates: &CandidateIndices<'_>,
959    index: &ManifestIndex,
960    recent_ranks: &HashMap<String, usize>,
961) -> Vec<usize> {
962    if recent_ranks.is_empty() {
963        return candidates.iter().collect();
964    }
965
966    let mut hot = recent_ranks
967        .iter()
968        .filter_map(|(path, rank)| {
969            let file_index = *index.by_path.get(path)?;
970            candidates
971                .contains(file_index)
972                .then_some((*rank, file_index))
973        })
974        .collect::<Vec<_>>();
975    hot.sort_unstable_by_key(|(rank, _)| *rank);
976
977    let mut out = Vec::with_capacity(candidates.len());
978    let mut seen = HashSet::with_capacity(hot.len());
979    for (_, file_index) in hot {
980        if seen.insert(file_index) {
981            out.push(file_index);
982        }
983    }
984    out.extend(
985        candidates
986            .iter()
987            .filter(|file_index| !seen.contains(file_index)),
988    );
989    out
990}
991
992fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
993    paths.sort_by(|left, right| {
994        recent_ranks
995            .get(left.as_str())
996            .copied()
997            .unwrap_or(usize::MAX)
998            .cmp(
999                &recent_ranks
1000                    .get(right.as_str())
1001                    .copied()
1002                    .unwrap_or(usize::MAX),
1003            )
1004            .then_with(|| left.as_str().cmp(right.as_str()))
1005    });
1006}
1007
1008fn candidate_indices_for_glob<'a>(
1009    index: &'a ManifestIndex,
1010    base: &WorkspacePath,
1011    pattern: &str,
1012) -> CandidateIndices<'a> {
1013    if !has_glob_meta(pattern) && pattern.contains('/') {
1014        return CandidateIndices::Single(
1015            literal_workspace_path(base, pattern)
1016                .and_then(|path| index.by_path.get(&path).copied()),
1017        );
1018    }
1019
1020    if let Some(name) = literal_terminal_segment(pattern) {
1021        return index
1022            .by_basename
1023            .get(name)
1024            .map(|indices| CandidateIndices::Indexed(indices))
1025            .unwrap_or(CandidateIndices::Single(None));
1026    }
1027
1028    if let Some(extension) = simple_extension_terminal(pattern) {
1029        return index
1030            .by_extension
1031            .get(extension)
1032            .map(|indices| CandidateIndices::Indexed(indices))
1033            .unwrap_or(CandidateIndices::Single(None));
1034    }
1035
1036    CandidateIndices::Indexed(&index.all)
1037}
1038
1039fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
1040    let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
1041    if pattern.is_empty() {
1042        return None;
1043    }
1044    if base.is_root() {
1045        Some(pattern)
1046    } else {
1047        Some(format!(
1048            "{}/{}",
1049            base.as_str().trim_end_matches('/'),
1050            pattern
1051        ))
1052    }
1053}
1054
1055fn literal_terminal_segment(pattern: &str) -> Option<&str> {
1056    let terminal = pattern
1057        .trim_end_matches('/')
1058        .rsplit('/')
1059        .next()
1060        .filter(|segment| !segment.is_empty())?;
1061    (!has_glob_meta(terminal)).then_some(terminal)
1062}
1063
1064fn simple_extension_terminal(pattern: &str) -> Option<&str> {
1065    let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
1066    let extension = terminal.strip_prefix("*.")?;
1067    (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
1068}
1069
1070fn has_glob_meta(pattern: &str) -> bool {
1071    pattern
1072        .bytes()
1073        .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
1074}
1075
1076fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
1077    let mut hasher = DefaultHasher::new();
1078    files.hash(&mut hasher);
1079    hasher.finish()
1080}
1081
1082fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
1083    let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
1084    let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
1085    let frequency =
1086        ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
1087    RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
1088}
1089
1090fn normalize_recent_file_path(path: &str) -> Option<String> {
1091    let path = path.trim();
1092    if path.is_empty() {
1093        return None;
1094    }
1095    let normalized = normalize_relative_path_lossy(Path::new(path))?;
1096    (!normalized.is_empty()).then_some(normalized)
1097}
1098
1099fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
1100    let mut parts = Vec::new();
1101    for component in path.components() {
1102        match component {
1103            Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
1104            Component::CurDir => {}
1105            _ => return None,
1106        }
1107    }
1108    Some(parts.join("/"))
1109}
1110
1111fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
1112    if base.is_root() {
1113        return Some(path);
1114    }
1115    let base = base.as_str().trim_end_matches('/');
1116    if path == base {
1117        Some("")
1118    } else {
1119        path.strip_prefix(base)
1120            .and_then(|tail| tail.strip_prefix('/'))
1121            .filter(|tail| !tail.is_empty())
1122    }
1123}
1124
1125fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
1126    let path = Path::new(path);
1127    pattern.matches_path(path)
1128        || path
1129            .file_name()
1130            .and_then(|name| name.to_str())
1131            .is_some_and(|name| pattern.matches(name))
1132}
1133
1134fn now_ms() -> u64 {
1135    system_time_ms(SystemTime::now())
1136}
1137
1138fn system_time_ms(time: SystemTime) -> u64 {
1139    time.duration_since(UNIX_EPOCH)
1140        .map(|duration| duration.as_millis() as u64)
1141        .unwrap_or_default()
1142}
1143
1144#[cfg(test)]
1145#[path = "manifest/tests.rs"]
1146mod tests;