1use super::retrieval::{
10 ChunkCatalogLimits, ChunkingConfig, LocalWorkspaceCatalogRuntime, WorkspaceChunkCatalog,
11 WorkspaceChunkingStrategy, WorkspaceIndexError,
12};
13use super::{
14 escape_control_chars_for_display, validate_relative_pattern, CommandOutput, CommandRequest,
15 LocalWorkspaceAccessPolicy, LocalWorkspaceBackend, WorkspaceCommandRunner, WorkspaceDirEntry,
16 WorkspaceFileSystem, WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput,
17 WorkspaceGitCheckoutRequest, WorkspaceGitCommit, WorkspaceGitCreateBranchRequest,
18 WorkspaceGitCreateWorktreeRequest, WorkspaceGitDiffRequest, WorkspaceGitRemote,
19 WorkspaceGitRemoveWorktreeRequest, WorkspaceGitStash, WorkspaceGitStashProvider,
20 WorkspaceGitStashRequest, WorkspaceGitStatus, WorkspaceGitWorktree,
21 WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider, WorkspaceGlobRequest,
22 WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest, WorkspaceGrepResult,
23 WorkspacePath, WorkspacePathResolver, WorkspaceResult, WorkspaceSearch, WorkspaceTextRange,
24 WorkspaceTextReader, WorkspaceWriteOutcome,
25};
26use anyhow::{anyhow, Result};
27use async_trait::async_trait;
28use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
29use std::hash::{Hash, Hasher};
30use std::path::{Component, Path, PathBuf};
31use std::sync::{
32 atomic::{AtomicBool, Ordering},
33 Arc, OnceLock, RwLock,
34};
35#[cfg(test)]
36use std::time::Duration;
37use std::time::{SystemTime, UNIX_EPOCH};
38use tokio::sync::{broadcast, watch};
39
40mod file_kind;
41mod scanner;
42mod watcher;
43use scanner::is_relevant_event;
44pub use scanner::scan_workspace_files;
45use watcher::run_manifest_task;
46
47const SNAPSHOT_CHANNEL_CAPACITY: usize = 16;
48const FILE_CHANGE_CHANNEL_CAPACITY: usize = 256;
49const RECENT_FILE_LIMIT: usize = 128;
50const RECENT_DECAY_HALF_LIFE_MS: f32 = 10.0 * 60.0 * 1000.0;
51const RECENT_FREQUENCY_NORMALIZER: f32 = 16.0;
52const RECENT_RECENCY_WEIGHT: f32 = 0.75;
53
54#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
56pub enum LocalWorkspaceFileStatus {
57 Tracked,
58 Untracked,
59 Unknown,
60}
61
62#[derive(Clone, Debug, Eq, PartialEq, Hash)]
64pub struct LocalWorkspaceFile {
65 pub path: String,
66 pub size: u64,
67 pub modified_ms: Option<u64>,
68 pub language: Option<String>,
69 pub status: LocalWorkspaceFileStatus,
70 pub binary: bool,
71 pub generated: bool,
72}
73
74#[derive(Clone, Debug, PartialEq)]
79pub struct RecentWorkspaceFile {
80 pub path: String,
81 pub score: f32,
82 pub touched_at_ms: u64,
83 pub touch_count: u32,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct LocalWorkspaceManifestSnapshot {
89 pub version: u64,
90 pub root: PathBuf,
91 pub files: Vec<LocalWorkspaceFile>,
92 pub scanned_at_ms: u64,
93}
94
95#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
97pub enum WorkspaceFileChangeKind {
98 Created,
99 Changed,
100 Deleted,
101}
102
103#[derive(Clone, Debug, Eq, PartialEq, Hash)]
105pub struct WorkspaceFileChange {
106 pub path: WorkspacePath,
107 pub kind: WorkspaceFileChangeKind,
108}
109
110impl LocalWorkspaceManifestSnapshot {
111 pub fn empty(root: PathBuf) -> Self {
112 Self {
113 version: 0,
114 root,
115 files: Vec::new(),
116 scanned_at_ms: now_ms(),
117 }
118 }
119
120 pub fn file_paths(&self) -> Vec<String> {
121 self.files.iter().map(|file| file.path.clone()).collect()
122 }
123}
124
125pub struct LocalWorkspaceManifest {
127 state: Arc<RwLock<ManifestState>>,
128 recent: Arc<RwLock<RecentFiles>>,
129 snapshots: broadcast::Sender<LocalWorkspaceManifestSnapshot>,
130 changes: broadcast::Sender<WorkspaceFileChange>,
131 activation: watch::Sender<bool>,
132 scan_cancelled: Arc<AtomicBool>,
133 task: tokio::task::JoinHandle<()>,
134}
135
136impl LocalWorkspaceManifest {
137 pub fn start(root: impl Into<PathBuf>) -> Arc<Self> {
139 Self::start_with_activation(root, true)
140 }
141
142 pub fn start_deferred(root: impl Into<PathBuf>) -> Arc<Self> {
150 Self::start_with_activation(root, false)
151 }
152
153 fn start_with_activation(root: impl Into<PathBuf>, active: bool) -> Arc<Self> {
154 let root = root.into();
155 let root = root.canonicalize().unwrap_or_else(|_| root.clone());
156 let initial = LocalWorkspaceManifestSnapshot::empty(root.clone());
157 let state = Arc::new(RwLock::new(ManifestState {
158 fingerprint: fingerprint_files(&initial.files),
159 index: Arc::new(ManifestIndex::build(&initial.files)),
160 snapshot: Arc::new(initial),
161 }));
162 let recent = Arc::new(RwLock::new(RecentFiles::default()));
163 let (snapshots, _) = broadcast::channel(SNAPSHOT_CHANNEL_CAPACITY);
164 let (changes, _) = broadcast::channel(FILE_CHANGE_CHANNEL_CAPACITY);
165 let (activation, mut activation_rx) = watch::channel(active);
166 let scan_cancelled = Arc::new(AtomicBool::new(false));
167 let task_state = Arc::clone(&state);
168 let task_snapshots = snapshots.clone();
169 let task_changes = changes.clone();
170 let task_scan_cancelled = Arc::clone(&scan_cancelled);
171 let task = tokio::spawn(async move {
172 if !*activation_rx.borrow() && activation_rx.wait_for(|active| *active).await.is_err() {
173 return;
174 }
175 if task_scan_cancelled.load(Ordering::Acquire) {
176 return;
177 }
178 run_manifest_task(
179 root,
180 task_state,
181 task_snapshots,
182 task_changes,
183 task_scan_cancelled,
184 )
185 .await;
186 });
187 Arc::new(Self {
188 state,
189 recent,
190 snapshots,
191 changes,
192 activation,
193 scan_cancelled,
194 task,
195 })
196 }
197
198 pub fn activate(&self) -> bool {
204 self.activation.send_if_modified(|active| {
205 if *active {
206 false
207 } else {
208 *active = true;
209 true
210 }
211 })
212 }
213
214 pub fn is_active(&self) -> bool {
216 *self.activation.borrow()
217 }
218
219 pub fn snapshot(&self) -> LocalWorkspaceManifestSnapshot {
220 self.state
221 .read()
222 .map(|state| (*state.snapshot).clone())
223 .unwrap_or_else(|_| LocalWorkspaceManifestSnapshot::empty(PathBuf::new()))
224 }
225
226 pub fn subscribe(&self) -> broadcast::Receiver<LocalWorkspaceManifestSnapshot> {
227 self.snapshots.subscribe()
228 }
229
230 pub fn subscribe_changes(&self) -> broadcast::Receiver<WorkspaceFileChange> {
232 self.changes.subscribe()
233 }
234
235 pub fn shutdown(&self) {
241 self.scan_cancelled.store(true, Ordering::Release);
242 self.task.abort();
243 }
244
245 pub fn touch_file(&self, path: impl AsRef<str>) -> bool {
252 let Some(path) = normalize_recent_file_path(path.as_ref()) else {
253 return false;
254 };
255 let Ok(mut recent) = self.recent.write() else {
256 return false;
257 };
258 recent.touch(path, now_ms());
259 true
260 }
261
262 pub fn recent_file_entries(&self, limit: usize) -> Vec<RecentWorkspaceFile> {
264 if limit == 0 {
265 return Vec::new();
266 }
267 let Some(index) = self.state.read().ok().map(|state| Arc::clone(&state.index)) else {
268 return Vec::new();
269 };
270 self.recent
271 .read()
272 .map(|recent| recent.entries(Some(&index), limit, now_ms()))
273 .unwrap_or_default()
274 }
275
276 pub fn recent_file_paths(&self, limit: usize) -> Vec<String> {
278 self.recent_file_entries(limit)
279 .into_iter()
280 .map(|entry| entry.path)
281 .collect()
282 }
283}
284
285impl Drop for LocalWorkspaceManifest {
286 fn drop(&mut self) {
287 self.shutdown();
291 }
292}
293
294struct ManifestState {
295 fingerprint: u64,
296 index: Arc<ManifestIndex>,
297 snapshot: Arc<LocalWorkspaceManifestSnapshot>,
298}
299
300#[derive(Debug, Default)]
301struct RecentFiles {
302 entries: HashMap<String, RecentFileState>,
303 next_sequence: u64,
304}
305
306impl RecentFiles {
307 fn touch(&mut self, path: String, now: u64) {
308 self.next_sequence = self.next_sequence.saturating_add(1);
309 let sequence = self.next_sequence;
310 self.entries
311 .entry(path.clone())
312 .and_modify(|entry| {
313 entry.touched_at_ms = now;
314 entry.touch_count = entry.touch_count.saturating_add(1);
315 entry.sequence = sequence;
316 })
317 .or_insert(RecentFileState {
318 path,
319 touched_at_ms: now,
320 touch_count: 1,
321 sequence,
322 });
323 self.prune(now);
324 }
325
326 fn entries(
327 &self,
328 index: Option<&ManifestIndex>,
329 limit: usize,
330 now: u64,
331 ) -> Vec<RecentWorkspaceFile> {
332 let mut entries = self
333 .entries
334 .values()
335 .filter(|entry| {
336 index
337 .map(|index| index.by_path.contains_key(&entry.path))
338 .unwrap_or(true)
339 })
340 .map(|entry| {
341 let score = recent_score(entry, now);
342 (
343 entry.sequence,
344 RecentWorkspaceFile {
345 path: entry.path.clone(),
346 score,
347 touched_at_ms: entry.touched_at_ms,
348 touch_count: entry.touch_count,
349 },
350 )
351 })
352 .collect::<Vec<_>>();
353
354 entries.sort_by(|(left_sequence, left), (right_sequence, right)| {
355 right
356 .score
357 .total_cmp(&left.score)
358 .then_with(|| right.touched_at_ms.cmp(&left.touched_at_ms))
359 .then_with(|| right_sequence.cmp(left_sequence))
360 .then_with(|| left.path.cmp(&right.path))
361 });
362 entries
363 .into_iter()
364 .take(limit)
365 .map(|(_, entry)| entry)
366 .collect()
367 }
368
369 fn prune(&mut self, now: u64) {
370 if self.entries.len() <= RECENT_FILE_LIMIT {
371 return;
372 }
373
374 let keep = self
375 .entries
376 .values()
377 .map(|entry| (entry.path.clone(), recent_score(entry, now), entry.sequence))
378 .collect::<Vec<_>>();
379 let mut keep = keep;
380 keep.sort_by(|left, right| {
381 right
382 .1
383 .total_cmp(&left.1)
384 .then_with(|| right.2.cmp(&left.2))
385 .then_with(|| left.0.cmp(&right.0))
386 });
387 let keep = keep
388 .into_iter()
389 .take(RECENT_FILE_LIMIT)
390 .map(|(path, _, _)| path)
391 .collect::<HashSet<_>>();
392 self.entries.retain(|path, _| keep.contains(path));
393 }
394}
395
396#[derive(Debug)]
397struct RecentFileState {
398 path: String,
399 touched_at_ms: u64,
400 touch_count: u32,
401 sequence: u64,
402}
403
404#[derive(Debug, Default)]
405struct ManifestIndex {
406 all: Vec<usize>,
407 by_path: HashMap<String, usize>,
408 by_basename: HashMap<String, Vec<usize>>,
409 by_extension: HashMap<String, Vec<usize>>,
410}
411
412impl ManifestIndex {
413 fn build(files: &[LocalWorkspaceFile]) -> Self {
414 let mut index = Self {
415 all: Vec::with_capacity(files.len()),
416 by_path: HashMap::with_capacity(files.len()),
417 by_basename: HashMap::new(),
418 by_extension: HashMap::new(),
419 };
420
421 for (file_index, file) in files.iter().enumerate() {
422 index.all.push(file_index);
423 index.by_path.insert(file.path.clone(), file_index);
424 if let Some(name) = Path::new(&file.path)
425 .file_name()
426 .and_then(|name| name.to_str())
427 {
428 index
429 .by_basename
430 .entry(name.to_string())
431 .or_default()
432 .push(file_index);
433 }
434 if let Some(extension) = Path::new(&file.path)
435 .extension()
436 .and_then(|extension| extension.to_str())
437 .filter(|extension| !extension.is_empty())
438 {
439 index
440 .by_extension
441 .entry(extension.to_string())
442 .or_default()
443 .push(file_index);
444 }
445 }
446
447 index
448 }
449}
450
451struct ManifestSearchSnapshot {
452 snapshot: Arc<LocalWorkspaceManifestSnapshot>,
453 index: Arc<ManifestIndex>,
454}
455
456pub struct ManifestWorkspaceBackend {
458 local: Arc<LocalWorkspaceBackend>,
459 catalog_local: Arc<LocalWorkspaceBackend>,
460 manifest: Arc<LocalWorkspaceManifest>,
461 catalog_runtime: OnceLock<Arc<LocalWorkspaceCatalogRuntime>>,
462}
463
464impl ManifestWorkspaceBackend {
465 pub fn new(root: impl Into<PathBuf>) -> Arc<Self> {
466 Self::new_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
467 }
468
469 pub fn new_with_access_policy(
470 root: impl Into<PathBuf>,
471 access_policy: LocalWorkspaceAccessPolicy,
472 ) -> Arc<Self> {
473 Self::new_with_access_policy_and_activation(root, access_policy, true)
474 }
475
476 pub fn new_deferred(root: impl Into<PathBuf>) -> Arc<Self> {
479 Self::new_deferred_with_access_policy(root, LocalWorkspaceAccessPolicy::Unrestricted)
480 }
481
482 pub fn new_deferred_with_access_policy(
485 root: impl Into<PathBuf>,
486 access_policy: LocalWorkspaceAccessPolicy,
487 ) -> Arc<Self> {
488 Self::new_with_access_policy_and_activation(root, access_policy, false)
489 }
490
491 fn new_with_access_policy_and_activation(
492 root: impl Into<PathBuf>,
493 access_policy: LocalWorkspaceAccessPolicy,
494 active: bool,
495 ) -> Arc<Self> {
496 let root = root.into();
497 let local = Arc::new(LocalWorkspaceBackend::new_with_access_policy(
498 root,
499 access_policy,
500 ));
501 let catalog_local = Arc::new(LocalWorkspaceBackend::new_with_source_egress_policy(
502 local.root.clone(),
503 ));
504 let manifest = if active {
505 LocalWorkspaceManifest::start(local.root.clone())
506 } else {
507 LocalWorkspaceManifest::start_deferred(local.root.clone())
508 };
509 Arc::new(Self {
510 local,
511 catalog_local,
512 manifest,
513 catalog_runtime: OnceLock::new(),
514 })
515 }
516
517 pub fn from_manifest(
518 local: Arc<LocalWorkspaceBackend>,
519 manifest: Arc<LocalWorkspaceManifest>,
520 ) -> Arc<Self> {
521 let catalog_local = Arc::new(LocalWorkspaceBackend::new_with_source_egress_policy(
522 local.root.clone(),
523 ));
524 Arc::new(Self {
525 catalog_local,
526 local,
527 manifest,
528 catalog_runtime: OnceLock::new(),
529 })
530 }
531
532 pub fn manifest(&self) -> Arc<LocalWorkspaceManifest> {
533 Arc::clone(&self.manifest)
534 }
535
536 pub fn chunk_catalog(&self) -> Arc<WorkspaceChunkCatalog> {
543 self.catalog_runtime
544 .get_or_init(|| {
545 let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
546 LocalWorkspaceCatalogRuntime::start(Arc::clone(&self.manifest), file_system)
547 })
548 .catalog()
549 }
550
551 pub fn configure_chunk_catalog(
559 &self,
560 strategy: WorkspaceChunkingStrategy,
561 chunking: ChunkingConfig,
562 limits: ChunkCatalogLimits,
563 ) -> Result<Arc<WorkspaceChunkCatalog>, WorkspaceIndexError> {
564 let catalog = WorkspaceChunkCatalog::new_with_strategy(strategy, chunking, limits)?;
565 let file_system: Arc<dyn WorkspaceFileSystem> = self.catalog_local.clone();
566 let runtime = LocalWorkspaceCatalogRuntime::start_with_catalog(
567 Arc::clone(&self.manifest),
568 file_system,
569 Arc::clone(&catalog),
570 );
571 if let Err(runtime) = self.catalog_runtime.set(runtime) {
572 runtime.shutdown();
573 return Err(WorkspaceIndexError::InvalidConfig(
574 "workspace chunk catalog was already initialized".to_owned(),
575 ));
576 }
577 Ok(catalog)
578 }
579
580 pub(crate) fn shutdown(&self) {
582 if let Some(runtime) = self.catalog_runtime.get() {
583 runtime.shutdown();
584 }
585 self.manifest.shutdown();
586 }
587
588 pub fn local_root(&self) -> &Path {
589 &self.local.root
590 }
591
592 fn manifest_ready(&self) -> Option<ManifestSearchSnapshot> {
593 let state = self.manifest.state.read().ok()?;
594 (state.snapshot.version > 0).then(|| ManifestSearchSnapshot {
595 snapshot: Arc::clone(&state.snapshot),
596 index: Arc::clone(&state.index),
597 })
598 }
599
600 fn fallback_search(&self) -> Arc<LocalWorkspaceBackend> {
601 Arc::clone(&self.local)
602 }
603
604 fn recent_path_ranks(&self, index: &ManifestIndex) -> HashMap<String, usize> {
605 self.manifest
606 .recent
607 .read()
608 .map(|recent| {
609 recent
610 .entries(Some(index), RECENT_FILE_LIMIT, now_ms())
611 .into_iter()
612 .enumerate()
613 .map(|(rank, entry)| (entry.path, rank))
614 .collect()
615 })
616 .unwrap_or_default()
617 }
618}
619
620impl WorkspacePathResolver for ManifestWorkspaceBackend {
621 fn normalize(&self, input: &str) -> Result<WorkspacePath> {
622 self.local.normalize(input)
623 }
624}
625
626#[async_trait]
627impl WorkspaceFileSystem for ManifestWorkspaceBackend {
628 async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
629 let content = self.local.read_text(path).await?;
630 self.manifest.touch_file(path.as_str());
631 Ok(content)
632 }
633
634 async fn write_text(
635 &self,
636 path: &WorkspacePath,
637 content: &str,
638 ) -> WorkspaceResult<WorkspaceWriteOutcome> {
639 let outcome = self.local.write_text(path, content).await?;
640 self.manifest.touch_file(path.as_str());
641 Ok(outcome)
642 }
643
644 async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
645 self.local.list_dir(path).await
646 }
647}
648
649#[async_trait]
650impl WorkspaceTextReader for ManifestWorkspaceBackend {
651 async fn read_text_range(
652 &self,
653 path: &WorkspacePath,
654 offset: usize,
655 limit: usize,
656 ) -> WorkspaceResult<WorkspaceTextRange> {
657 let range = self.local.read_text_range(path, offset, limit).await?;
658 self.manifest.touch_file(path.as_str());
659 Ok(range)
660 }
661}
662
663#[async_trait]
664impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
665 async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
666 self.local.exec(request).await
667 }
668}
669
670#[async_trait]
671impl WorkspaceSearch for ManifestWorkspaceBackend {
672 async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
673 validate_relative_pattern(&request.pattern, "glob pattern")?;
674 let Some(search_snapshot) = self.manifest_ready() else {
675 return self.fallback_search().glob(request).await;
676 };
677 let pattern = glob::Pattern::new(&request.pattern)
678 .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
679 let candidates =
680 candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
681 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
682
683 let mut matches = Vec::new();
684 for file_index in
685 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
686 {
687 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
688 continue;
689 };
690 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
691 continue;
692 };
693 if glob_matches(&pattern, relative_to_base) {
694 matches.push(WorkspacePath::from_normalized(file.path.clone()));
695 }
696 }
697
698 sort_paths_by_recent(&mut matches, &recent_ranks);
699 Ok(WorkspaceGlobResult { matches })
700 }
701
702 async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
703 Ok(self.grep_with_sources(request).await?.result)
704 }
705
706 async fn grep_with_sources(
707 &self,
708 request: WorkspaceGrepRequest,
709 ) -> Result<WorkspaceGrepOutcome> {
710 if let Some(ref glob) = request.glob {
711 validate_relative_pattern(glob, "grep glob filter")?;
712 }
713 self.local.ensure_search_base_allowed(&request.base)?;
714 let Some(search_snapshot) = self.manifest_ready() else {
715 return self.fallback_search().grep_with_sources(request).await;
716 };
717
718 let regex_pattern = if request.case_insensitive {
719 format!("(?i){}", request.pattern)
720 } else {
721 request.pattern.clone()
722 };
723 let regex = regex::Regex::new(®ex_pattern)
724 .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
725 let glob = request
726 .glob
727 .as_deref()
728 .map(glob::Pattern::new)
729 .transpose()
730 .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
731
732 let mut output = String::new();
733 let mut match_count = 0;
734 let mut file_count = 0;
735 let mut total_size = 0;
736 let mut matched_paths = Vec::new();
737 let metadata_only = request.max_output_size == 0;
738
739 let candidates = request
740 .glob
741 .as_deref()
742 .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
743 .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
744 let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
745
746 for file_index in
747 recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
748 {
749 let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
750 continue;
751 };
752 if file.binary {
753 continue;
754 }
755 let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
756 continue;
757 };
758 if let Some(glob) = &glob {
759 if !glob_matches(glob, relative_to_base) {
760 continue;
761 }
762 }
763
764 let workspace_path = WorkspacePath::from_normalized(file.path.clone());
765 let Some(content) = self.local.read_search_file(&workspace_path) else {
766 continue;
767 };
768 let lines: Vec<&str> = content.lines().collect();
769 let file_matches = lines
770 .iter()
771 .enumerate()
772 .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
773 .collect::<Vec<_>>();
774
775 if file_matches.is_empty() {
776 continue;
777 }
778
779 file_count += 1;
780 let display_path = escape_control_chars_for_display(&file.path);
781 let mut path_recorded = false;
782 for &match_idx in &file_matches {
783 if !metadata_only && total_size > request.max_output_size {
784 return Ok(WorkspaceGrepOutcome {
785 result: WorkspaceGrepResult {
786 output,
787 match_count,
788 file_count,
789 truncated: true,
790 },
791 matched_paths: Some(matched_paths),
792 });
793 }
794
795 if !path_recorded {
796 matched_paths.push(workspace_path.clone());
797 path_recorded = true;
798 }
799 match_count += 1;
800 if metadata_only {
801 continue;
802 }
803 let start = match_idx.saturating_sub(request.context_lines);
804 let end = (match_idx + request.context_lines + 1).min(lines.len());
805
806 for (i, line) in lines[start..end].iter().enumerate() {
807 let abs_i = start + i;
808 let prefix = if abs_i == match_idx { ">" } else { " " };
809 let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
810 total_size += line.len();
811 output.push_str(&line);
812 }
813
814 if request.context_lines > 0 {
815 output.push_str("--\n");
816 total_size += 3;
817 }
818 }
819 }
820
821 Ok(WorkspaceGrepOutcome {
822 result: WorkspaceGrepResult {
823 output,
824 match_count,
825 file_count,
826 truncated: false,
827 },
828 matched_paths: Some(matched_paths),
829 })
830 }
831}
832
833#[async_trait]
834impl WorkspaceGit for ManifestWorkspaceBackend {
835 async fn is_repository(&self) -> Result<bool> {
836 self.local.is_repository().await
837 }
838
839 async fn status(&self) -> Result<WorkspaceGitStatus> {
840 self.local.status().await
841 }
842
843 async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
844 self.local.log(max_count).await
845 }
846
847 async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
848 self.local.list_branches().await
849 }
850
851 async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
852 self.local.create_branch(request).await
853 }
854
855 async fn checkout(
856 &self,
857 request: WorkspaceGitCheckoutRequest,
858 ) -> Result<WorkspaceGitCheckoutOutput> {
859 self.local.checkout(request).await
860 }
861
862 async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
863 self.local.diff(request).await
864 }
865
866 async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
867 self.local.list_remotes().await
868 }
869}
870
871#[async_trait]
872impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
873 async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
874 self.local.list_stashes().await
875 }
876
877 async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
878 self.local.stash(request).await
879 }
880}
881
882#[async_trait]
883impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
884 async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
885 self.local.list_worktrees().await
886 }
887
888 async fn create_worktree(
889 &self,
890 request: WorkspaceGitCreateWorktreeRequest,
891 ) -> Result<WorkspaceGitWorktreeMutation> {
892 self.local.create_worktree(request).await
893 }
894
895 async fn remove_worktree(
896 &self,
897 request: WorkspaceGitRemoveWorktreeRequest,
898 ) -> Result<WorkspaceGitWorktreeMutation> {
899 self.local.remove_worktree(request).await
900 }
901}
902
903fn update_state(
904 state: &Arc<RwLock<ManifestState>>,
905 files: Vec<LocalWorkspaceFile>,
906) -> Option<LocalWorkspaceManifestSnapshot> {
907 let fingerprint = fingerprint_files(&files);
908 let index = Arc::new(ManifestIndex::build(&files));
909 let Ok(mut state) = state.write() else {
910 return None;
911 };
912 if state.snapshot.version > 0 && state.fingerprint == fingerprint {
913 return None;
914 }
915 state.fingerprint = fingerprint;
916 state.index = index;
917 state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
918 version: state.snapshot.version + 1,
919 root: state.snapshot.root.clone(),
920 files,
921 scanned_at_ms: now_ms(),
922 });
923 Some((*state.snapshot).clone())
924}
925
926enum CandidateIndices<'a> {
927 Indexed(&'a [usize]),
928 Single(Option<usize>),
929}
930
931impl<'a> CandidateIndices<'a> {
932 fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
933 match self {
934 Self::Indexed(indices) => Box::new(indices.iter().copied()),
935 Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
936 Self::Single(None) => Box::new(std::iter::empty()),
937 }
938 }
939
940 fn len(&self) -> usize {
941 match self {
942 Self::Indexed(indices) => indices.len(),
943 Self::Single(Some(_)) => 1,
944 Self::Single(None) => 0,
945 }
946 }
947
948 fn contains(&self, index: usize) -> bool {
949 match self {
950 Self::Indexed(indices) => indices.contains(&index),
951 Self::Single(Some(candidate)) => *candidate == index,
952 Self::Single(None) => false,
953 }
954 }
955}
956
957fn recent_first_candidate_indices(
958 candidates: &CandidateIndices<'_>,
959 index: &ManifestIndex,
960 recent_ranks: &HashMap<String, usize>,
961) -> Vec<usize> {
962 if recent_ranks.is_empty() {
963 return candidates.iter().collect();
964 }
965
966 let mut hot = recent_ranks
967 .iter()
968 .filter_map(|(path, rank)| {
969 let file_index = *index.by_path.get(path)?;
970 candidates
971 .contains(file_index)
972 .then_some((*rank, file_index))
973 })
974 .collect::<Vec<_>>();
975 hot.sort_unstable_by_key(|(rank, _)| *rank);
976
977 let mut out = Vec::with_capacity(candidates.len());
978 let mut seen = HashSet::with_capacity(hot.len());
979 for (_, file_index) in hot {
980 if seen.insert(file_index) {
981 out.push(file_index);
982 }
983 }
984 out.extend(
985 candidates
986 .iter()
987 .filter(|file_index| !seen.contains(file_index)),
988 );
989 out
990}
991
992fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
993 paths.sort_by(|left, right| {
994 recent_ranks
995 .get(left.as_str())
996 .copied()
997 .unwrap_or(usize::MAX)
998 .cmp(
999 &recent_ranks
1000 .get(right.as_str())
1001 .copied()
1002 .unwrap_or(usize::MAX),
1003 )
1004 .then_with(|| left.as_str().cmp(right.as_str()))
1005 });
1006}
1007
1008fn candidate_indices_for_glob<'a>(
1009 index: &'a ManifestIndex,
1010 base: &WorkspacePath,
1011 pattern: &str,
1012) -> CandidateIndices<'a> {
1013 if !has_glob_meta(pattern) && pattern.contains('/') {
1014 return CandidateIndices::Single(
1015 literal_workspace_path(base, pattern)
1016 .and_then(|path| index.by_path.get(&path).copied()),
1017 );
1018 }
1019
1020 if let Some(name) = literal_terminal_segment(pattern) {
1021 return index
1022 .by_basename
1023 .get(name)
1024 .map(|indices| CandidateIndices::Indexed(indices))
1025 .unwrap_or(CandidateIndices::Single(None));
1026 }
1027
1028 if let Some(extension) = simple_extension_terminal(pattern) {
1029 return index
1030 .by_extension
1031 .get(extension)
1032 .map(|indices| CandidateIndices::Indexed(indices))
1033 .unwrap_or(CandidateIndices::Single(None));
1034 }
1035
1036 CandidateIndices::Indexed(&index.all)
1037}
1038
1039fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
1040 let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
1041 if pattern.is_empty() {
1042 return None;
1043 }
1044 if base.is_root() {
1045 Some(pattern)
1046 } else {
1047 Some(format!(
1048 "{}/{}",
1049 base.as_str().trim_end_matches('/'),
1050 pattern
1051 ))
1052 }
1053}
1054
1055fn literal_terminal_segment(pattern: &str) -> Option<&str> {
1056 let terminal = pattern
1057 .trim_end_matches('/')
1058 .rsplit('/')
1059 .next()
1060 .filter(|segment| !segment.is_empty())?;
1061 (!has_glob_meta(terminal)).then_some(terminal)
1062}
1063
1064fn simple_extension_terminal(pattern: &str) -> Option<&str> {
1065 let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
1066 let extension = terminal.strip_prefix("*.")?;
1067 (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
1068}
1069
1070fn has_glob_meta(pattern: &str) -> bool {
1071 pattern
1072 .bytes()
1073 .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
1074}
1075
1076fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
1077 let mut hasher = DefaultHasher::new();
1078 files.hash(&mut hasher);
1079 hasher.finish()
1080}
1081
1082fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
1083 let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
1084 let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
1085 let frequency =
1086 ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
1087 RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
1088}
1089
1090fn normalize_recent_file_path(path: &str) -> Option<String> {
1091 let path = path.trim();
1092 if path.is_empty() {
1093 return None;
1094 }
1095 let normalized = normalize_relative_path_lossy(Path::new(path))?;
1096 (!normalized.is_empty()).then_some(normalized)
1097}
1098
1099fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
1100 let mut parts = Vec::new();
1101 for component in path.components() {
1102 match component {
1103 Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
1104 Component::CurDir => {}
1105 _ => return None,
1106 }
1107 }
1108 Some(parts.join("/"))
1109}
1110
1111fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
1112 if base.is_root() {
1113 return Some(path);
1114 }
1115 let base = base.as_str().trim_end_matches('/');
1116 if path == base {
1117 Some("")
1118 } else {
1119 path.strip_prefix(base)
1120 .and_then(|tail| tail.strip_prefix('/'))
1121 .filter(|tail| !tail.is_empty())
1122 }
1123}
1124
1125fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
1126 let path = Path::new(path);
1127 pattern.matches_path(path)
1128 || path
1129 .file_name()
1130 .and_then(|name| name.to_str())
1131 .is_some_and(|name| pattern.matches(name))
1132}
1133
1134fn now_ms() -> u64 {
1135 system_time_ms(SystemTime::now())
1136}
1137
1138fn system_time_ms(time: SystemTime) -> u64 {
1139 time.duration_since(UNIX_EPOCH)
1140 .map(|duration| duration.as_millis() as u64)
1141 .unwrap_or_default()
1142}
1143
1144#[cfg(test)]
1145#[path = "manifest/tests.rs"]
1146mod tests;