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 DirectWriteGuard, LocalWorkspaceAccessPolicy, LocalWorkspaceBackend, WorkspaceCommandRunner,
17 WorkspaceDirEntry, WorkspaceFileSystem, WorkspaceGit, WorkspaceGitBranch,
18 WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest, WorkspaceGitCommit,
19 WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest,
20 WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash,
21 WorkspaceGitStashProvider, 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::A3sVec,
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::A3sVec)?;
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 = "a3s-vec-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::A3sVec) {
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 a3s-vec index unavailable; using session-local lexical catalog"
837 );
838 None
839 }
840 }
841 }
842 #[cfg(not(feature = "a3s-vec-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 DirectWriteGuard for ManifestWorkspaceBackend {
896 fn refuse_direct_write(&self, path: &WorkspacePath) -> anyhow::Result<()> {
897 self.local.refuse_direct_write(path)
898 }
899}
900
901impl WorkspacePathResolver for ManifestWorkspaceBackend {
902 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
903 self.local.normalize(input)
904 }
905}
906
907#[async_trait]
908impl WorkspaceFileSystem for ManifestWorkspaceBackend {
909 async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
910 let content = self.local.read_text(path).await?;
911 self.manifest.touch_file(path.as_str());
912 Ok(content)
913 }
914
915 async fn write_text(
916 &self,
917 path: &WorkspacePath,
918 content: &str,
919 ) -> WorkspaceResult<WorkspaceWriteOutcome> {
920 let outcome = self.local.write_text(path, content).await?;
921 self.manifest.touch_file(path.as_str());
922 Ok(outcome)
923 }
924
925 async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
926 self.local.list_dir(path).await
927 }
928}
929
930#[async_trait]
931impl WorkspaceTextReader for ManifestWorkspaceBackend {
932 async fn read_text_range(
933 &self,
934 path: &WorkspacePath,
935 offset: usize,
936 limit: usize,
937 ) -> WorkspaceResult<WorkspaceTextRange> {
938 let range = self.local.read_text_range(path, offset, limit).await?;
939 self.manifest.touch_file(path.as_str());
940 Ok(range)
941 }
942}
943
944#[async_trait]
945impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
946 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
947 self.local.exec(request).await
948 }
949}
950
951#[async_trait]
952impl WorkspaceSearch for ManifestWorkspaceBackend {
953 async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
954 validate_relative_pattern(&request.pattern, "glob pattern")?;
955 let Some(search_snapshot) = self.manifest_ready() else {
956 return self.fallback_search().glob(request).await;
957 };
958 let pattern = glob::Pattern::new(&request.pattern)
959 .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
960 let candidates =
961 candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
962 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
963
964 let mut matches = Vec::new();
965 for file_index in
966 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
967 {
968 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
969 continue;
970 };
971 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
972 continue;
973 };
974 if glob_matches(&pattern, relative_to_base) {
975 matches.push(WorkspacePath::from_normalized(file.path.clone()));
976 }
977 }
978
979 sort_paths_by_recent(&mut matches, &recent_ranks);
980 Ok(WorkspaceGlobResult { matches })
981 }
982
983 async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
984 Ok(self.grep_with_sources(request).await?.result)
985 }
986
987 async fn grep_with_sources(
988 &self,
989 request: WorkspaceGrepRequest,
990 ) -> Result<WorkspaceGrepOutcome> {
991 if let Some(ref glob) = request.glob {
992 validate_relative_pattern(glob, "grep glob filter")?;
993 }
994 self.local.ensure_search_base_allowed(&request.base)?;
995 let Some(search_snapshot) = self.manifest_ready() else {
996 return self.fallback_search().grep_with_sources(request).await;
997 };
998
999 let regex_pattern = if request.case_insensitive {
1000 format!("(?i){}", request.pattern)
1001 } else {
1002 request.pattern.clone()
1003 };
1004 let regex = regex::Regex::new(®ex_pattern)
1005 .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
1006 let glob = request
1007 .glob
1008 .as_deref()
1009 .map(glob::Pattern::new)
1010 .transpose()
1011 .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
1012
1013 let mut output = String::new();
1014 let mut match_count = 0;
1015 let mut file_count = 0;
1016 let mut total_size = 0;
1017 let mut matched_paths = Vec::new();
1018 let metadata_only = request.max_output_size == 0;
1019
1020 let candidates = request
1021 .glob
1022 .as_deref()
1023 .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
1024 .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
1025 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
1026 let content_filter = self.grep_content_candidate_paths(
1027 &request.pattern,
1028 request.case_insensitive,
1029 &search_snapshot,
1030 );
1031
1032 for file_index in
1033 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
1034 {
1035 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
1036 continue;
1037 };
1038 if file.binary {
1039 continue;
1040 }
1041 if let Some(ref allowed) = content_filter {
1042 if !allowed.contains(&file.path) {
1043 continue;
1044 }
1045 }
1046 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
1047 continue;
1048 };
1049 if let Some(glob) = &glob {
1050 if !glob_matches(glob, relative_to_base) {
1051 continue;
1052 }
1053 }
1054
1055 let workspace_path = WorkspacePath::from_normalized(file.path.clone());
1056 let Some(content) = self.local.read_search_file(&workspace_path) else {
1057 continue;
1058 };
1059 let lines: Vec<&str> = content.lines().collect();
1060 let file_matches = lines
1061 .iter()
1062 .enumerate()
1063 .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
1064 .collect::<Vec<_>>();
1065
1066 if file_matches.is_empty() {
1067 continue;
1068 }
1069
1070 file_count += 1;
1071 let display_path = escape_control_chars_for_display(&file.path);
1072 let mut path_recorded = false;
1073 for &match_idx in &file_matches {
1074 if !metadata_only && total_size > request.max_output_size {
1075 return Ok(WorkspaceGrepOutcome {
1076 result: WorkspaceGrepResult {
1077 output,
1078 match_count,
1079 file_count,
1080 truncated: true,
1081 },
1082 matched_paths: Some(matched_paths),
1083 });
1084 }
1085
1086 if !path_recorded {
1087 matched_paths.push(workspace_path.clone());
1088 path_recorded = true;
1089 }
1090 match_count += 1;
1091 if metadata_only {
1092 continue;
1093 }
1094 let start = match_idx.saturating_sub(request.context_lines);
1095 let end = (match_idx + request.context_lines + 1).min(lines.len());
1096
1097 for (i, line) in lines[start..end].iter().enumerate() {
1098 let abs_i = start + i;
1099 let prefix = if abs_i == match_idx { ">" } else { " " };
1100 let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
1101 total_size += line.len();
1102 output.push_str(&line);
1103 }
1104
1105 if request.context_lines > 0 {
1106 output.push_str("--\n");
1107 total_size += 3;
1108 }
1109 }
1110 }
1111
1112 Ok(WorkspaceGrepOutcome {
1113 result: WorkspaceGrepResult {
1114 output,
1115 match_count,
1116 file_count,
1117 truncated: false,
1118 },
1119 matched_paths: Some(matched_paths),
1120 })
1121 }
1122}
1123
1124#[async_trait]
1125impl WorkspaceGit for ManifestWorkspaceBackend {
1126 async fn is_repository(&self) -> Result<bool> {
1127 self.local.is_repository().await
1128 }
1129
1130 async fn status(&self) -> Result<WorkspaceGitStatus> {
1131 self.local.status().await
1132 }
1133
1134 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
1135 self.local.log(max_count).await
1136 }
1137
1138 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
1139 self.local.list_branches().await
1140 }
1141
1142 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
1143 self.local.create_branch(request).await
1144 }
1145
1146 async fn checkout(
1147 &self,
1148 request: WorkspaceGitCheckoutRequest,
1149 ) -> Result<WorkspaceGitCheckoutOutput> {
1150 self.local.checkout(request).await
1151 }
1152
1153 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
1154 self.local.diff(request).await
1155 }
1156
1157 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
1158 self.local.list_remotes().await
1159 }
1160}
1161
1162#[async_trait]
1163impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
1164 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
1165 self.local.list_stashes().await
1166 }
1167
1168 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
1169 self.local.stash(request).await
1170 }
1171}
1172
1173#[async_trait]
1174impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
1175 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
1176 self.local.list_worktrees().await
1177 }
1178
1179 async fn create_worktree(
1180 &self,
1181 request: WorkspaceGitCreateWorktreeRequest,
1182 ) -> Result<WorkspaceGitWorktreeMutation> {
1183 self.local.create_worktree(request).await
1184 }
1185
1186 async fn remove_worktree(
1187 &self,
1188 request: WorkspaceGitRemoveWorktreeRequest,
1189 ) -> Result<WorkspaceGitWorktreeMutation> {
1190 self.local.remove_worktree(request).await
1191 }
1192}
1193
1194fn update_state(
1195 state: &Arc<RwLock<ManifestState>>,
1196 files: Vec<LocalWorkspaceFile>,
1197) -> Option<LocalWorkspaceManifestSnapshot> {
1198 let fingerprint = fingerprint_files(&files);
1199 let index = Arc::new(ManifestIndex::build(&files));
1200 let Ok(mut state) = state.write() else {
1201 return None;
1202 };
1203 if state.snapshot.version > 0 && state.fingerprint == fingerprint {
1204 return None;
1205 }
1206 state.fingerprint = fingerprint;
1207 state.index = index;
1208 state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
1209 version: state.snapshot.version + 1,
1210 root: state.snapshot.root.clone(),
1211 files,
1212 scanned_at_ms: now_ms(),
1213 });
1214 Some((*state.snapshot).clone())
1215}
1216
1217enum CandidateIndices<'a> {
1218 Indexed(&'a [usize]),
1219 Single(Option<usize>),
1220}
1221
1222impl<'a> CandidateIndices<'a> {
1223 fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
1224 match self {
1225 Self::Indexed(indices) => Box::new(indices.iter().copied()),
1226 Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
1227 Self::Single(None) => Box::new(std::iter::empty()),
1228 }
1229 }
1230
1231 fn len(&self) -> usize {
1232 match self {
1233 Self::Indexed(indices) => indices.len(),
1234 Self::Single(Some(_)) => 1,
1235 Self::Single(None) => 0,
1236 }
1237 }
1238
1239 fn contains(&self, index: usize) -> bool {
1240 match self {
1241 Self::Indexed(indices) => indices.contains(&index),
1242 Self::Single(Some(candidate)) => *candidate == index,
1243 Self::Single(None) => false,
1244 }
1245 }
1246}
1247
1248fn recent_first_candidate_indices(
1249 candidates: &CandidateIndices<'_>,
1250 index: &ManifestIndex,
1251 recent_ranks: &HashMap<String, usize>,
1252) -> Vec<usize> {
1253 if recent_ranks.is_empty() {
1254 return candidates.iter().collect();
1255 }
1256
1257 let mut hot = recent_ranks
1258 .iter()
1259 .filter_map(|(path, rank)| {
1260 let file_index = *index.by_path.get(path)?;
1261 candidates
1262 .contains(file_index)
1263 .then_some((*rank, file_index))
1264 })
1265 .collect::<Vec<_>>();
1266 hot.sort_unstable_by_key(|(rank, _)| *rank);
1267
1268 let mut out = Vec::with_capacity(candidates.len());
1269 let mut seen = HashSet::with_capacity(hot.len());
1270 for (_, file_index) in hot {
1271 if seen.insert(file_index) {
1272 out.push(file_index);
1273 }
1274 }
1275 out.extend(
1276 candidates
1277 .iter()
1278 .filter(|file_index| !seen.contains(file_index)),
1279 );
1280 out
1281}
1282
1283fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
1284 paths.sort_by(|left, right| {
1285 recent_ranks
1286 .get(left.as_str())
1287 .copied()
1288 .unwrap_or(usize::MAX)
1289 .cmp(
1290 &recent_ranks
1291 .get(right.as_str())
1292 .copied()
1293 .unwrap_or(usize::MAX),
1294 )
1295 .then_with(|| left.as_str().cmp(right.as_str()))
1296 });
1297}
1298
1299fn candidate_indices_for_glob<'a>(
1300 index: &'a ManifestIndex,
1301 base: &WorkspacePath,
1302 pattern: &str,
1303) -> CandidateIndices<'a> {
1304 if !has_glob_meta(pattern) && pattern.contains('/') {
1305 return CandidateIndices::Single(
1306 literal_workspace_path(base, pattern)
1307 .and_then(|path| index.by_path.get(&path).copied()),
1308 );
1309 }
1310
1311 if let Some(name) = literal_terminal_segment(pattern) {
1312 return index
1313 .by_basename
1314 .get(name)
1315 .map(|indices| CandidateIndices::Indexed(indices))
1316 .unwrap_or(CandidateIndices::Single(None));
1317 }
1318
1319 if let Some(extension) = simple_extension_terminal(pattern) {
1320 return index
1321 .by_extension
1322 .get(extension)
1323 .map(|indices| CandidateIndices::Indexed(indices))
1324 .unwrap_or(CandidateIndices::Single(None));
1325 }
1326
1327 CandidateIndices::Indexed(&index.all)
1328}
1329
1330fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
1331 let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
1332 if pattern.is_empty() {
1333 return None;
1334 }
1335 if base.is_root() {
1336 Some(pattern)
1337 } else {
1338 Some(format!(
1339 "{}/{}",
1340 base.as_str().trim_end_matches('/'),
1341 pattern
1342 ))
1343 }
1344}
1345
1346fn literal_terminal_segment(pattern: &str) -> Option<&str> {
1347 let terminal = pattern
1348 .trim_end_matches('/')
1349 .rsplit('/')
1350 .next()
1351 .filter(|segment| !segment.is_empty())?;
1352 (!has_glob_meta(terminal)).then_some(terminal)
1353}
1354
1355fn simple_extension_terminal(pattern: &str) -> Option<&str> {
1356 let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
1357 let extension = terminal.strip_prefix("*.")?;
1358 (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
1359}
1360
1361fn has_glob_meta(pattern: &str) -> bool {
1362 pattern
1363 .bytes()
1364 .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
1365}
1366
1367fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
1368 let mut hasher = DefaultHasher::new();
1369 files.hash(&mut hasher);
1370 hasher.finish()
1371}
1372
1373fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
1374 let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
1375 let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
1376 let frequency =
1377 ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
1378 RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
1379}
1380
1381fn normalize_recent_file_path(path: &str) -> Option<String> {
1382 let path = path.trim();
1383 if path.is_empty() {
1384 return None;
1385 }
1386 let normalized = normalize_relative_path_lossy(Path::new(path))?;
1387 (!normalized.is_empty()).then_some(normalized)
1388}
1389
1390fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
1391 let mut parts = Vec::new();
1392 for component in path.components() {
1393 match component {
1394 Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
1395 Component::CurDir => {}
1396 _ => return None,
1397 }
1398 }
1399 Some(parts.join("/"))
1400}
1401
1402fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
1403 if base.is_root() {
1404 return Some(path);
1405 }
1406 let base = base.as_str().trim_end_matches('/');
1407 if path == base {
1408 Some("")
1409 } else {
1410 path.strip_prefix(base)
1411 .and_then(|tail| tail.strip_prefix('/'))
1412 .filter(|tail| !tail.is_empty())
1413 }
1414}
1415
1416fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
1417 let path = Path::new(path);
1418 pattern.matches_path(path)
1419 || path
1420 .file_name()
1421 .and_then(|name| name.to_str())
1422 .is_some_and(|name| pattern.matches(name))
1423}
1424
1425fn now_ms() -> u64 {
1426 system_time_ms(SystemTime::now())
1427}
1428
1429fn system_time_ms(time: SystemTime) -> u64 {
1430 time.duration_since(UNIX_EPOCH)
1431 .map(|duration| duration.as_millis() as u64)
1432 .unwrap_or_default()
1433}
1434
1435#[cfg(test)]
1436#[path = "manifest/tests.rs"]
1437mod tests;