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