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, 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 owns_manifest: bool,
465}
466
467impl ManifestWorkspaceBackend {
468 pub fn new(root: impl Into<PathBuf>) -> Arc<Self> {
469 Self::new_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
470 }
471
472 pub fn new_with_access_policy(
473 root: impl Into<PathBuf>,
474 access_policy: LocalWorkspaceAccessPolicy,
475 ) -> Arc<Self> {
476 Self::new_with_access_policy_and_activation(root, access_policy, true)
477 }
478
479 pub fn new_deferred(root: impl Into<PathBuf>) -> Arc<Self> {
482 Self::new_deferred_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
483 }
484
485 pub fn new_deferred_with_access_policy(
488 root: impl Into<PathBuf>,
489 access_policy: LocalWorkspaceAccessPolicy,
490 ) -> Arc<Self> {
491 Self::new_with_access_policy_and_activation(root, access_policy, false)
492 }
493
494 fn new_with_access_policy_and_activation(
495 root: impl Into<PathBuf>,
496 access_policy: LocalWorkspaceAccessPolicy,
497 active: bool,
498 ) -> Arc<Self> {
499 let root = root.into();
500 let local = Arc::new(LocalWorkspaceBackend::new_with_access_policy(
501 root,
502 access_policy,
503 ));
504 let catalog_local = Arc::new(LocalWorkspaceBackend::new_with_source_egress_policy(
505 local.root.clone(),
506 ));
507 let manifest = if active {
508 LocalWorkspaceManifest::start(local.root.clone())
509 } else {
510 LocalWorkspaceManifest::start_deferred(local.root.clone())
511 };
512 Arc::new(Self {
513 local,
514 catalog_local,
515 manifest,
516 catalog_runtime: OnceLock::new(),
517 persistent_index: OnceLock::new(),
518 owns_manifest: true,
519 })
520 }
521
522 pub fn from_manifest(
523 local: Arc<LocalWorkspaceBackend>,
524 manifest: Arc<LocalWorkspaceManifest>,
525 ) -> Arc<Self> {
526 let catalog_local = Arc::new(LocalWorkspaceBackend::new_with_source_egress_policy(
527 local.root.clone(),
528 ));
529 Arc::new(Self {
530 catalog_local,
531 local,
532 manifest,
533 catalog_runtime: OnceLock::new(),
534 persistent_index: OnceLock::new(),
535 owns_manifest: false,
536 })
537 }
538
539 pub fn manifest(&self) -> Arc<LocalWorkspaceManifest> {
540 Arc::clone(&self.manifest)
541 }
542
543 pub fn chunk_catalog(&self) -> Arc<WorkspaceChunkCatalog> {
553 self.ensure_default_persistent_index(WorkspaceLexicalEngine::default());
554 self.catalog_runtime
555 .get_or_init(|| {
556 let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
557 let persistent = self.persistent_index.get().cloned();
558 if let Some(persistent) = persistent {
559 let catalog = WorkspaceChunkCatalog::default_catalog_with_engines(
566 WorkspaceLexicalEngine::ZvecRust,
567 WorkspaceLexicalEngine::Portable,
568 );
569 LocalWorkspaceCatalogRuntime::start_with_catalog_and_persistent(
570 Arc::clone(&self.manifest),
571 file_system,
572 catalog,
573 Some(persistent),
574 )
575 } else {
576 LocalWorkspaceCatalogRuntime::start(Arc::clone(&self.manifest), file_system)
577 }
578 })
579 .catalog()
580 }
581
582 pub fn configure_persistent_index(
587 &self,
588 root: impl Into<PathBuf>,
589 ) -> Result<Arc<WorkspacePersistentIndex>, WorkspaceIndexError> {
590 if self.catalog_runtime.get().is_some() {
591 return Err(WorkspaceIndexError::InvalidConfig(
592 "persistent workspace indexing must be configured before the chunk catalog"
593 .to_owned(),
594 ));
595 }
596 let index = WorkspacePersistentIndex::open(root, WorkspaceLexicalEngine::ZvecRust)?;
597 self.persistent_index.set(Arc::clone(&index)).map_err(|_| {
598 WorkspaceIndexError::InvalidConfig(
599 "persistent workspace index was already configured".to_owned(),
600 )
601 })?;
602 Ok(index)
603 }
604
605 pub fn persistent_index(&self) -> Option<Arc<WorkspacePersistentIndex>> {
606 self.persistent_index.get().cloned()
607 }
608
609 pub fn configure_chunk_catalog(
617 &self,
618 strategy: WorkspaceChunkingStrategy,
619 chunking: ChunkingConfig,
620 limits: ChunkCatalogLimits,
621 ) -> Result<Arc<WorkspaceChunkCatalog>, WorkspaceIndexError> {
622 self.configure_chunk_catalog_with_engine(
623 strategy,
624 chunking,
625 limits,
626 WorkspaceLexicalEngine::default(),
627 )
628 }
629
630 pub fn configure_chunk_catalog_with_engine(
632 &self,
633 strategy: WorkspaceChunkingStrategy,
634 chunking: ChunkingConfig,
635 limits: ChunkCatalogLimits,
636 lexical_engine: WorkspaceLexicalEngine,
637 ) -> Result<Arc<WorkspaceChunkCatalog>, WorkspaceIndexError> {
638 let catalog = WorkspaceChunkCatalog::new_with_strategy_and_engine(
639 strategy,
640 chunking,
641 limits,
642 lexical_engine,
643 )?;
644 self.ensure_default_persistent_index(lexical_engine);
645 let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
646 let runtime = LocalWorkspaceCatalogRuntime::start_with_catalog_and_persistent(
647 Arc::clone(&self.manifest),
648 file_system,
649 Arc::clone(&catalog),
650 self.persistent_index.get().cloned(),
651 );
652 if let Err(runtime) = self.catalog_runtime.set(runtime) {
653 runtime.shutdown();
654 return Err(WorkspaceIndexError::InvalidConfig(
655 "workspace chunk catalog was already initialized".to_owned(),
656 ));
657 }
658 Ok(catalog)
659 }
660
661 pub(crate) fn shutdown(&self) {
663 if let Some(runtime) = self.catalog_runtime.get() {
664 runtime.shutdown();
665 }
666 self.manifest.shutdown();
667 if let Some(index) = self.persistent_index.get() {
668 index.wait_for_idle();
669 }
670 }
671
672 pub fn local_root(&self) -> &Path {
673 &self.local.root
674 }
675
676 fn ensure_default_persistent_index(&self, engine: WorkspaceLexicalEngine) {
682 #[cfg(feature = "zvec-rust-fts")]
683 {
684 if engine != WorkspaceLexicalEngine::ZvecRust
685 || self.persistent_index.get().is_some()
686 || self.catalog_runtime.get().is_some()
687 {
688 return;
689 }
690 let root = self.local.root.join(".a3s-code").join("index");
691 if let Err(error) = self.configure_persistent_index(root) {
692 tracing::debug!(%error, "workspace persistent zvec index unavailable; using session-local lexical catalog");
693 }
694 }
695 #[cfg(not(feature = "zvec-rust-fts"))]
696 {
697 let _ = engine;
698 }
699 }
700
701 fn manifest_ready(&self) -> Option<ManifestSearchSnapshot> {
702 let state = self.manifest.state.read().ok()?;
703 (state.snapshot.version > 0).then(|| ManifestSearchSnapshot {
704 snapshot: Arc::clone(&state.snapshot),
705 index: Arc::clone(&state.index),
706 })
707 }
708
709 fn fallback_search(&self) -> Arc<LocalWorkspaceBackend> {
710 Arc::clone(&self.local)
711 }
712
713 fn recent_path_ranks(&self, index: &ManifestIndex) -> HashMap<String, usize> {
714 self.manifest
715 .recent
716 .read()
717 .map(|recent| {
718 recent
719 .entries(Some(index), RECENT_FILE_LIMIT, now_ms())
720 .into_iter()
721 .enumerate()
722 .map(|(rank, entry)| (entry.path, rank))
723 .collect()
724 })
725 .unwrap_or_default()
726 }
727}
728
729impl Drop for ManifestWorkspaceBackend {
730 fn drop(&mut self) {
731 if self.owns_manifest {
736 self.shutdown();
737 } else {
738 if let Some(runtime) = self.catalog_runtime.get() {
739 runtime.shutdown();
740 }
741 if let Some(index) = self.persistent_index.get() {
742 index.wait_for_idle();
743 }
744 }
745 }
746}
747
748impl WorkspacePathResolver for ManifestWorkspaceBackend {
749 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
750 self.local.normalize(input)
751 }
752}
753
754#[async_trait]
755impl WorkspaceFileSystem for ManifestWorkspaceBackend {
756 async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
757 let content = self.local.read_text(path).await?;
758 self.manifest.touch_file(path.as_str());
759 Ok(content)
760 }
761
762 async fn write_text(
763 &self,
764 path: &WorkspacePath,
765 content: &str,
766 ) -> WorkspaceResult<WorkspaceWriteOutcome> {
767 let outcome = self.local.write_text(path, content).await?;
768 self.manifest.touch_file(path.as_str());
769 Ok(outcome)
770 }
771
772 async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
773 self.local.list_dir(path).await
774 }
775}
776
777#[async_trait]
778impl WorkspaceTextReader for ManifestWorkspaceBackend {
779 async fn read_text_range(
780 &self,
781 path: &WorkspacePath,
782 offset: usize,
783 limit: usize,
784 ) -> WorkspaceResult<WorkspaceTextRange> {
785 let range = self.local.read_text_range(path, offset, limit).await?;
786 self.manifest.touch_file(path.as_str());
787 Ok(range)
788 }
789}
790
791#[async_trait]
792impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
793 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
794 self.local.exec(request).await
795 }
796}
797
798#[async_trait]
799impl WorkspaceSearch for ManifestWorkspaceBackend {
800 async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
801 validate_relative_pattern(&request.pattern, "glob pattern")?;
802 let Some(search_snapshot) = self.manifest_ready() else {
803 return self.fallback_search().glob(request).await;
804 };
805 let pattern = glob::Pattern::new(&request.pattern)
806 .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
807 let candidates =
808 candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
809 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
810
811 let mut matches = Vec::new();
812 for file_index in
813 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
814 {
815 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
816 continue;
817 };
818 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
819 continue;
820 };
821 if glob_matches(&pattern, relative_to_base) {
822 matches.push(WorkspacePath::from_normalized(file.path.clone()));
823 }
824 }
825
826 sort_paths_by_recent(&mut matches, &recent_ranks);
827 Ok(WorkspaceGlobResult { matches })
828 }
829
830 async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
831 Ok(self.grep_with_sources(request).await?.result)
832 }
833
834 async fn grep_with_sources(
835 &self,
836 request: WorkspaceGrepRequest,
837 ) -> Result<WorkspaceGrepOutcome> {
838 if let Some(ref glob) = request.glob {
839 validate_relative_pattern(glob, "grep glob filter")?;
840 }
841 self.local.ensure_search_base_allowed(&request.base)?;
842 let Some(search_snapshot) = self.manifest_ready() else {
843 return self.fallback_search().grep_with_sources(request).await;
844 };
845
846 let regex_pattern = if request.case_insensitive {
847 format!("(?i){}", request.pattern)
848 } else {
849 request.pattern.clone()
850 };
851 let regex = regex::Regex::new(®ex_pattern)
852 .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
853 let glob = request
854 .glob
855 .as_deref()
856 .map(glob::Pattern::new)
857 .transpose()
858 .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
859
860 let mut output = String::new();
861 let mut match_count = 0;
862 let mut file_count = 0;
863 let mut total_size = 0;
864 let mut matched_paths = Vec::new();
865 let metadata_only = request.max_output_size == 0;
866
867 let candidates = request
868 .glob
869 .as_deref()
870 .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
871 .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
872 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
873
874 for file_index in
875 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
876 {
877 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
878 continue;
879 };
880 if file.binary {
881 continue;
882 }
883 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
884 continue;
885 };
886 if let Some(glob) = &glob {
887 if !glob_matches(glob, relative_to_base) {
888 continue;
889 }
890 }
891
892 let workspace_path = WorkspacePath::from_normalized(file.path.clone());
893 let Some(content) = self.local.read_search_file(&workspace_path) else {
894 continue;
895 };
896 let lines: Vec<&str> = content.lines().collect();
897 let file_matches = lines
898 .iter()
899 .enumerate()
900 .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
901 .collect::<Vec<_>>();
902
903 if file_matches.is_empty() {
904 continue;
905 }
906
907 file_count += 1;
908 let display_path = escape_control_chars_for_display(&file.path);
909 let mut path_recorded = false;
910 for &match_idx in &file_matches {
911 if !metadata_only && total_size > request.max_output_size {
912 return Ok(WorkspaceGrepOutcome {
913 result: WorkspaceGrepResult {
914 output,
915 match_count,
916 file_count,
917 truncated: true,
918 },
919 matched_paths: Some(matched_paths),
920 });
921 }
922
923 if !path_recorded {
924 matched_paths.push(workspace_path.clone());
925 path_recorded = true;
926 }
927 match_count += 1;
928 if metadata_only {
929 continue;
930 }
931 let start = match_idx.saturating_sub(request.context_lines);
932 let end = (match_idx + request.context_lines + 1).min(lines.len());
933
934 for (i, line) in lines[start..end].iter().enumerate() {
935 let abs_i = start + i;
936 let prefix = if abs_i == match_idx { ">" } else { " " };
937 let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
938 total_size += line.len();
939 output.push_str(&line);
940 }
941
942 if request.context_lines > 0 {
943 output.push_str("--\n");
944 total_size += 3;
945 }
946 }
947 }
948
949 Ok(WorkspaceGrepOutcome {
950 result: WorkspaceGrepResult {
951 output,
952 match_count,
953 file_count,
954 truncated: false,
955 },
956 matched_paths: Some(matched_paths),
957 })
958 }
959}
960
961#[async_trait]
962impl WorkspaceGit for ManifestWorkspaceBackend {
963 async fn is_repository(&self) -> Result<bool> {
964 self.local.is_repository().await
965 }
966
967 async fn status(&self) -> Result<WorkspaceGitStatus> {
968 self.local.status().await
969 }
970
971 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
972 self.local.log(max_count).await
973 }
974
975 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
976 self.local.list_branches().await
977 }
978
979 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
980 self.local.create_branch(request).await
981 }
982
983 async fn checkout(
984 &self,
985 request: WorkspaceGitCheckoutRequest,
986 ) -> Result<WorkspaceGitCheckoutOutput> {
987 self.local.checkout(request).await
988 }
989
990 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
991 self.local.diff(request).await
992 }
993
994 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
995 self.local.list_remotes().await
996 }
997}
998
999#[async_trait]
1000impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
1001 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
1002 self.local.list_stashes().await
1003 }
1004
1005 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
1006 self.local.stash(request).await
1007 }
1008}
1009
1010#[async_trait]
1011impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
1012 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
1013 self.local.list_worktrees().await
1014 }
1015
1016 async fn create_worktree(
1017 &self,
1018 request: WorkspaceGitCreateWorktreeRequest,
1019 ) -> Result<WorkspaceGitWorktreeMutation> {
1020 self.local.create_worktree(request).await
1021 }
1022
1023 async fn remove_worktree(
1024 &self,
1025 request: WorkspaceGitRemoveWorktreeRequest,
1026 ) -> Result<WorkspaceGitWorktreeMutation> {
1027 self.local.remove_worktree(request).await
1028 }
1029}
1030
1031fn update_state(
1032 state: &Arc<RwLock<ManifestState>>,
1033 files: Vec<LocalWorkspaceFile>,
1034) -> Option<LocalWorkspaceManifestSnapshot> {
1035 let fingerprint = fingerprint_files(&files);
1036 let index = Arc::new(ManifestIndex::build(&files));
1037 let Ok(mut state) = state.write() else {
1038 return None;
1039 };
1040 if state.snapshot.version > 0 && state.fingerprint == fingerprint {
1041 return None;
1042 }
1043 state.fingerprint = fingerprint;
1044 state.index = index;
1045 state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
1046 version: state.snapshot.version + 1,
1047 root: state.snapshot.root.clone(),
1048 files,
1049 scanned_at_ms: now_ms(),
1050 });
1051 Some((*state.snapshot).clone())
1052}
1053
1054enum CandidateIndices<'a> {
1055 Indexed(&'a [usize]),
1056 Single(Option<usize>),
1057}
1058
1059impl<'a> CandidateIndices<'a> {
1060 fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
1061 match self {
1062 Self::Indexed(indices) => Box::new(indices.iter().copied()),
1063 Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
1064 Self::Single(None) => Box::new(std::iter::empty()),
1065 }
1066 }
1067
1068 fn len(&self) -> usize {
1069 match self {
1070 Self::Indexed(indices) => indices.len(),
1071 Self::Single(Some(_)) => 1,
1072 Self::Single(None) => 0,
1073 }
1074 }
1075
1076 fn contains(&self, index: usize) -> bool {
1077 match self {
1078 Self::Indexed(indices) => indices.contains(&index),
1079 Self::Single(Some(candidate)) => *candidate == index,
1080 Self::Single(None) => false,
1081 }
1082 }
1083}
1084
1085fn recent_first_candidate_indices(
1086 candidates: &CandidateIndices<'_>,
1087 index: &ManifestIndex,
1088 recent_ranks: &HashMap<String, usize>,
1089) -> Vec<usize> {
1090 if recent_ranks.is_empty() {
1091 return candidates.iter().collect();
1092 }
1093
1094 let mut hot = recent_ranks
1095 .iter()
1096 .filter_map(|(path, rank)| {
1097 let file_index = *index.by_path.get(path)?;
1098 candidates
1099 .contains(file_index)
1100 .then_some((*rank, file_index))
1101 })
1102 .collect::<Vec<_>>();
1103 hot.sort_unstable_by_key(|(rank, _)| *rank);
1104
1105 let mut out = Vec::with_capacity(candidates.len());
1106 let mut seen = HashSet::with_capacity(hot.len());
1107 for (_, file_index) in hot {
1108 if seen.insert(file_index) {
1109 out.push(file_index);
1110 }
1111 }
1112 out.extend(
1113 candidates
1114 .iter()
1115 .filter(|file_index| !seen.contains(file_index)),
1116 );
1117 out
1118}
1119
1120fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
1121 paths.sort_by(|left, right| {
1122 recent_ranks
1123 .get(left.as_str())
1124 .copied()
1125 .unwrap_or(usize::MAX)
1126 .cmp(
1127 &recent_ranks
1128 .get(right.as_str())
1129 .copied()
1130 .unwrap_or(usize::MAX),
1131 )
1132 .then_with(|| left.as_str().cmp(right.as_str()))
1133 });
1134}
1135
1136fn candidate_indices_for_glob<'a>(
1137 index: &'a ManifestIndex,
1138 base: &WorkspacePath,
1139 pattern: &str,
1140) -> CandidateIndices<'a> {
1141 if !has_glob_meta(pattern) && pattern.contains('/') {
1142 return CandidateIndices::Single(
1143 literal_workspace_path(base, pattern)
1144 .and_then(|path| index.by_path.get(&path).copied()),
1145 );
1146 }
1147
1148 if let Some(name) = literal_terminal_segment(pattern) {
1149 return index
1150 .by_basename
1151 .get(name)
1152 .map(|indices| CandidateIndices::Indexed(indices))
1153 .unwrap_or(CandidateIndices::Single(None));
1154 }
1155
1156 if let Some(extension) = simple_extension_terminal(pattern) {
1157 return index
1158 .by_extension
1159 .get(extension)
1160 .map(|indices| CandidateIndices::Indexed(indices))
1161 .unwrap_or(CandidateIndices::Single(None));
1162 }
1163
1164 CandidateIndices::Indexed(&index.all)
1165}
1166
1167fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
1168 let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
1169 if pattern.is_empty() {
1170 return None;
1171 }
1172 if base.is_root() {
1173 Some(pattern)
1174 } else {
1175 Some(format!(
1176 "{}/{}",
1177 base.as_str().trim_end_matches('/'),
1178 pattern
1179 ))
1180 }
1181}
1182
1183fn literal_terminal_segment(pattern: &str) -> Option<&str> {
1184 let terminal = pattern
1185 .trim_end_matches('/')
1186 .rsplit('/')
1187 .next()
1188 .filter(|segment| !segment.is_empty())?;
1189 (!has_glob_meta(terminal)).then_some(terminal)
1190}
1191
1192fn simple_extension_terminal(pattern: &str) -> Option<&str> {
1193 let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
1194 let extension = terminal.strip_prefix("*.")?;
1195 (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
1196}
1197
1198fn has_glob_meta(pattern: &str) -> bool {
1199 pattern
1200 .bytes()
1201 .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
1202}
1203
1204fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
1205 let mut hasher = DefaultHasher::new();
1206 files.hash(&mut hasher);
1207 hasher.finish()
1208}
1209
1210fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
1211 let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
1212 let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
1213 let frequency =
1214 ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
1215 RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
1216}
1217
1218fn normalize_recent_file_path(path: &str) -> Option<String> {
1219 let path = path.trim();
1220 if path.is_empty() {
1221 return None;
1222 }
1223 let normalized = normalize_relative_path_lossy(Path::new(path))?;
1224 (!normalized.is_empty()).then_some(normalized)
1225}
1226
1227fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
1228 let mut parts = Vec::new();
1229 for component in path.components() {
1230 match component {
1231 Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
1232 Component::CurDir => {}
1233 _ => return None,
1234 }
1235 }
1236 Some(parts.join("/"))
1237}
1238
1239fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
1240 if base.is_root() {
1241 return Some(path);
1242 }
1243 let base = base.as_str().trim_end_matches('/');
1244 if path == base {
1245 Some("")
1246 } else {
1247 path.strip_prefix(base)
1248 .and_then(|tail| tail.strip_prefix('/'))
1249 .filter(|tail| !tail.is_empty())
1250 }
1251}
1252
1253fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
1254 let path = Path::new(path);
1255 pattern.matches_path(path)
1256 || path
1257 .file_name()
1258 .and_then(|name| name.to_str())
1259 .is_some_and(|name| pattern.matches(name))
1260}
1261
1262fn now_ms() -> u64 {
1263 system_time_ms(SystemTime::now())
1264}
1265
1266fn system_time_ms(time: SystemTime) -> u64 {
1267 time.duration_since(UNIX_EPOCH)
1268 .map(|duration| duration.as_millis() as u64)
1269 .unwrap_or_default()
1270}
1271
1272#[cfg(test)]
1273#[path = "manifest/tests.rs"]
1274mod tests;