1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
57pub enum LocalWorkspaceFileStatus {
58 Tracked,
59 Untracked,
60 Unknown,
61}
62
63#[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#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
98pub enum WorkspaceFileChangeKind {
99 Created,
100 Changed,
101 Deleted,
102}
103
104#[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
126pub 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 pub fn start(root: impl Into<PathBuf>) -> Arc<Self> {
140 Self::start_with_activation(root, true)
141 }
142
143 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 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 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 pub fn subscribe_changes(&self) -> broadcast::Receiver<WorkspaceFileChange> {
233 self.changes.subscribe()
234 }
235
236 pub fn shutdown(&self) {
242 self.scan_cancelled.store(true, Ordering::Release);
243 self.task.abort();
244 }
245
246 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 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 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 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
457pub 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 grep_candidates: OnceLock<crate::workspace::SharedGrepCandidateIndex>,
466 #[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 pub fn new_deferred(root: impl Into<PathBuf>) -> Arc<Self> {
488 Self::new_deferred_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
489 }
490
491 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 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 *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 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 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 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 pub fn persistent_index(&self) -> Option<Arc<WorkspacePersistentIndex>> {
727 self.persistent_index.get().cloned()
728 }
729
730 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 pub(crate) fn catalog_is_configured(&self) -> bool {
747 self.catalog_runtime.get().is_some()
748 }
749
750 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 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 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 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 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 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(®ex_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;