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