Skip to main content

a3s_code_core/workspace/
manifest.rs

1//! Manifest-backed local workspace services.
2//!
3//! The manifest is an in-memory index of workspace files. It is built
4//! asynchronously, refreshed from filesystem notifications, and used by the
5//! local search backend (`glob`/`grep`) to avoid walking the filesystem for
6//! every agent tool call. File I/O, command execution, and git operations still
7//! delegate to [`LocalWorkspaceBackend`].
8
9use super::{
10    escape_control_chars_for_display, validate_relative_pattern, CommandOutput, CommandRequest,
11    LocalWorkspaceBackend, WorkspaceCommandRunner, WorkspaceDirEntry, WorkspaceFileSystem,
12    WorkspaceGit, WorkspaceGitBranch, WorkspaceGitCheckoutOutput, WorkspaceGitCheckoutRequest,
13    WorkspaceGitCommit, WorkspaceGitCreateBranchRequest, WorkspaceGitCreateWorktreeRequest,
14    WorkspaceGitDiffRequest, WorkspaceGitRemote, WorkspaceGitRemoveWorktreeRequest,
15    WorkspaceGitStash, WorkspaceGitStashProvider, WorkspaceGitStashRequest, WorkspaceGitStatus,
16    WorkspaceGitWorktree, WorkspaceGitWorktreeMutation, WorkspaceGitWorktreeProvider,
17    WorkspaceGlobRequest, WorkspaceGlobResult, WorkspaceGrepOutcome, WorkspaceGrepRequest,
18    WorkspaceGrepResult, WorkspacePath, WorkspacePathResolver, WorkspaceResult, WorkspaceSearch,
19    WorkspaceTextRange, WorkspaceTextReader, WorkspaceWriteOutcome,
20};
21use anyhow::{anyhow, Result};
22use async_trait::async_trait;
23use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
24use std::hash::{Hash, Hasher};
25use std::path::{Component, Path, PathBuf};
26use std::sync::{
27    atomic::{AtomicBool, Ordering},
28    Arc, RwLock,
29};
30#[cfg(test)]
31use std::time::Duration;
32use std::time::{SystemTime, UNIX_EPOCH};
33use tokio::sync::broadcast;
34
35mod scanner;
36mod watcher;
37use scanner::is_relevant_event;
38pub use scanner::scan_workspace_files;
39use watcher::run_manifest_task;
40
41const SNAPSHOT_CHANNEL_CAPACITY: usize = 16;
42const FILE_CHANGE_CHANNEL_CAPACITY: usize = 256;
43const RECENT_FILE_LIMIT: usize = 128;
44const RECENT_DECAY_HALF_LIFE_MS: f32 = 10.0 * 60.0 * 1000.0;
45const RECENT_FREQUENCY_NORMALIZER: f32 = 16.0;
46const RECENT_RECENCY_WEIGHT: f32 = 0.75;
47
48/// Git/workspace status for a file in the manifest.
49#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
50pub enum LocalWorkspaceFileStatus {
51    Tracked,
52    Untracked,
53    Unknown,
54}
55
56/// One manifest entry.
57#[derive(Clone, Debug, Eq, PartialEq, Hash)]
58pub struct LocalWorkspaceFile {
59    pub path: String,
60    pub size: u64,
61    pub modified_ms: Option<u64>,
62    pub language: Option<String>,
63    pub status: LocalWorkspaceFileStatus,
64    pub binary: bool,
65    pub generated: bool,
66}
67
68/// Recency/usage score for a workspace file the user or agent touched.
69///
70/// Hosts should treat this as a ranking hint, not as an authoritative file
71/// list. The manifest filters deleted files when exposing recent entries.
72#[derive(Clone, Debug, PartialEq)]
73pub struct RecentWorkspaceFile {
74    pub path: String,
75    pub score: f32,
76    pub touched_at_ms: u64,
77    pub touch_count: u32,
78}
79
80/// Immutable manifest snapshot.
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct LocalWorkspaceManifestSnapshot {
83    pub version: u64,
84    pub root: PathBuf,
85    pub files: Vec<LocalWorkspaceFile>,
86    pub scanned_at_ms: u64,
87}
88
89/// The normalized kind of a workspace file change.
90#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
91pub enum WorkspaceFileChangeKind {
92    Created,
93    Changed,
94    Deleted,
95}
96
97/// A filesystem change for one normalized workspace-relative file path.
98#[derive(Clone, Debug, Eq, PartialEq, Hash)]
99pub struct WorkspaceFileChange {
100    pub path: WorkspacePath,
101    pub kind: WorkspaceFileChangeKind,
102}
103
104impl LocalWorkspaceManifestSnapshot {
105    pub fn empty(root: PathBuf) -> Self {
106        Self {
107            version: 0,
108            root,
109            files: Vec::new(),
110            scanned_at_ms: now_ms(),
111        }
112    }
113
114    pub fn file_paths(&self) -> Vec<String> {
115        self.files.iter().map(|file| file.path.clone()).collect()
116    }
117}
118
119/// Shared in-memory workspace manifest.
120pub struct LocalWorkspaceManifest {
121    state: Arc<RwLock<ManifestState>>,
122    recent: Arc<RwLock<RecentFiles>>,
123    snapshots: broadcast::Sender<LocalWorkspaceManifestSnapshot>,
124    changes: broadcast::Sender<WorkspaceFileChange>,
125    scan_cancelled: Arc<AtomicBool>,
126    task: tokio::task::JoinHandle<()>,
127}
128
129impl LocalWorkspaceManifest {
130    /// Start the manifest scanner/watcher for `root`.
131    pub fn start(root: impl Into<PathBuf>) -> Arc<Self> {
132        let root = root.into();
133        let root = root.canonicalize().unwrap_or_else(|_| root.clone());
134        let initial = LocalWorkspaceManifestSnapshot::empty(root.clone());
135        let state = Arc::new(RwLock::new(ManifestState {
136            fingerprint: fingerprint_files(&initial.files),
137            index: Arc::new(ManifestIndex::build(&initial.files)),
138            snapshot: Arc::new(initial),
139        }));
140        let recent = Arc::new(RwLock::new(RecentFiles::default()));
141        let (snapshots, _) = broadcast::channel(SNAPSHOT_CHANNEL_CAPACITY);
142        let (changes, _) = broadcast::channel(FILE_CHANGE_CHANNEL_CAPACITY);
143        let scan_cancelled = Arc::new(AtomicBool::new(false));
144        let task_state = Arc::clone(&state);
145        let task_snapshots = snapshots.clone();
146        let task_changes = changes.clone();
147        let task_scan_cancelled = Arc::clone(&scan_cancelled);
148        let task = tokio::spawn(async move {
149            run_manifest_task(
150                root,
151                task_state,
152                task_snapshots,
153                task_changes,
154                task_scan_cancelled,
155            )
156            .await;
157        });
158        Arc::new(Self {
159            state,
160            recent,
161            snapshots,
162            changes,
163            scan_cancelled,
164            task,
165        })
166    }
167
168    pub fn snapshot(&self) -> LocalWorkspaceManifestSnapshot {
169        self.state
170            .read()
171            .map(|state| (*state.snapshot).clone())
172            .unwrap_or_else(|_| LocalWorkspaceManifestSnapshot::empty(PathBuf::new()))
173    }
174
175    pub fn subscribe(&self) -> broadcast::Receiver<LocalWorkspaceManifestSnapshot> {
176        self.snapshots.subscribe()
177    }
178
179    /// Subscribe to debounced, workspace-relative filesystem changes.
180    pub fn subscribe_changes(&self) -> broadcast::Receiver<WorkspaceFileChange> {
181        self.changes.subscribe()
182    }
183
184    /// Stop background discovery without waiting for an in-flight synchronous scan.
185    ///
186    /// Hosts with an explicit lifecycle should call this before shutting down
187    /// their Tokio runtime. [`Drop`] is only a fallback because other background
188    /// services may retain an `Arc` to the manifest until runtime teardown.
189    pub fn shutdown(&self) {
190        self.scan_cancelled.store(true, Ordering::Release);
191        self.task.abort();
192    }
193
194    /// Record that a workspace-relative file was opened, read, or written.
195    ///
196    /// This intentionally does not require the initial manifest scan to have
197    /// completed. The public recent-file views filter against the current
198    /// manifest index, so early touches become visible after the file is indexed
199    /// and deleted files disappear automatically.
200    pub fn touch_file(&self, path: impl AsRef<str>) -> bool {
201        let Some(path) = normalize_recent_file_path(path.as_ref()) else {
202            return false;
203        };
204        let Ok(mut recent) = self.recent.write() else {
205            return false;
206        };
207        recent.touch(path, now_ms());
208        true
209    }
210
211    /// Return the hottest known files, newest/frequently used first.
212    pub fn recent_file_entries(&self, limit: usize) -> Vec<RecentWorkspaceFile> {
213        if limit == 0 {
214            return Vec::new();
215        }
216        let Some(index) = self.state.read().ok().map(|state| Arc::clone(&state.index)) else {
217            return Vec::new();
218        };
219        self.recent
220            .read()
221            .map(|recent| recent.entries(Some(&index), limit, now_ms()))
222            .unwrap_or_default()
223    }
224
225    /// Return recent file paths only, preserving hot-file order.
226    pub fn recent_file_paths(&self, limit: usize) -> Vec<String> {
227        self.recent_file_entries(limit)
228            .into_iter()
229            .map(|entry| entry.path)
230            .collect()
231    }
232}
233
234impl Drop for LocalWorkspaceManifest {
235    fn drop(&mut self) {
236        // Aborting the async owner does not stop synchronous discovery that has
237        // already begun. Signal the scanner first so a detached traversal stops
238        // consuming filesystem resources after its host has gone away.
239        self.shutdown();
240    }
241}
242
243struct ManifestState {
244    fingerprint: u64,
245    index: Arc<ManifestIndex>,
246    snapshot: Arc<LocalWorkspaceManifestSnapshot>,
247}
248
249#[derive(Debug, Default)]
250struct RecentFiles {
251    entries: HashMap<String, RecentFileState>,
252    next_sequence: u64,
253}
254
255impl RecentFiles {
256    fn touch(&mut self, path: String, now: u64) {
257        self.next_sequence = self.next_sequence.saturating_add(1);
258        let sequence = self.next_sequence;
259        self.entries
260            .entry(path.clone())
261            .and_modify(|entry| {
262                entry.touched_at_ms = now;
263                entry.touch_count = entry.touch_count.saturating_add(1);
264                entry.sequence = sequence;
265            })
266            .or_insert(RecentFileState {
267                path,
268                touched_at_ms: now,
269                touch_count: 1,
270                sequence,
271            });
272        self.prune(now);
273    }
274
275    fn entries(
276        &self,
277        index: Option<&ManifestIndex>,
278        limit: usize,
279        now: u64,
280    ) -> Vec<RecentWorkspaceFile> {
281        let mut entries = self
282            .entries
283            .values()
284            .filter(|entry| {
285                index
286                    .map(|index| index.by_path.contains_key(&entry.path))
287                    .unwrap_or(true)
288            })
289            .map(|entry| {
290                let score = recent_score(entry, now);
291                (
292                    entry.sequence,
293                    RecentWorkspaceFile {
294                        path: entry.path.clone(),
295                        score,
296                        touched_at_ms: entry.touched_at_ms,
297                        touch_count: entry.touch_count,
298                    },
299                )
300            })
301            .collect::<Vec<_>>();
302
303        entries.sort_by(|(left_sequence, left), (right_sequence, right)| {
304            right
305                .score
306                .total_cmp(&left.score)
307                .then_with(|| right.touched_at_ms.cmp(&left.touched_at_ms))
308                .then_with(|| right_sequence.cmp(left_sequence))
309                .then_with(|| left.path.cmp(&right.path))
310        });
311        entries
312            .into_iter()
313            .take(limit)
314            .map(|(_, entry)| entry)
315            .collect()
316    }
317
318    fn prune(&mut self, now: u64) {
319        if self.entries.len() <= RECENT_FILE_LIMIT {
320            return;
321        }
322
323        let keep = self
324            .entries
325            .values()
326            .map(|entry| (entry.path.clone(), recent_score(entry, now), entry.sequence))
327            .collect::<Vec<_>>();
328        let mut keep = keep;
329        keep.sort_by(|left, right| {
330            right
331                .1
332                .total_cmp(&left.1)
333                .then_with(|| right.2.cmp(&left.2))
334                .then_with(|| left.0.cmp(&right.0))
335        });
336        let keep = keep
337            .into_iter()
338            .take(RECENT_FILE_LIMIT)
339            .map(|(path, _, _)| path)
340            .collect::<HashSet<_>>();
341        self.entries.retain(|path, _| keep.contains(path));
342    }
343}
344
345#[derive(Debug)]
346struct RecentFileState {
347    path: String,
348    touched_at_ms: u64,
349    touch_count: u32,
350    sequence: u64,
351}
352
353#[derive(Debug, Default)]
354struct ManifestIndex {
355    all: Vec<usize>,
356    by_path: HashMap<String, usize>,
357    by_basename: HashMap<String, Vec<usize>>,
358    by_extension: HashMap<String, Vec<usize>>,
359}
360
361impl ManifestIndex {
362    fn build(files: &[LocalWorkspaceFile]) -> Self {
363        let mut index = Self {
364            all: Vec::with_capacity(files.len()),
365            by_path: HashMap::with_capacity(files.len()),
366            by_basename: HashMap::new(),
367            by_extension: HashMap::new(),
368        };
369
370        for (file_index, file) in files.iter().enumerate() {
371            index.all.push(file_index);
372            index.by_path.insert(file.path.clone(), file_index);
373            if let Some(name) = Path::new(&file.path)
374                .file_name()
375                .and_then(|name| name.to_str())
376            {
377                index
378                    .by_basename
379                    .entry(name.to_string())
380                    .or_default()
381                    .push(file_index);
382            }
383            if let Some(extension) = Path::new(&file.path)
384                .extension()
385                .and_then(|extension| extension.to_str())
386                .filter(|extension| !extension.is_empty())
387            {
388                index
389                    .by_extension
390                    .entry(extension.to_string())
391                    .or_default()
392                    .push(file_index);
393            }
394        }
395
396        index
397    }
398}
399
400struct ManifestSearchSnapshot {
401    snapshot: Arc<LocalWorkspaceManifestSnapshot>,
402    index: Arc<ManifestIndex>,
403}
404
405/// Local backend that uses an in-memory manifest for search.
406pub struct ManifestWorkspaceBackend {
407    local: Arc<LocalWorkspaceBackend>,
408    manifest: Arc<LocalWorkspaceManifest>,
409}
410
411impl ManifestWorkspaceBackend {
412    pub fn new(root: impl Into<PathBuf>) -> Arc<Self> {
413        let local = Arc::new(LocalWorkspaceBackend::new(root.into()));
414        let manifest = LocalWorkspaceManifest::start(local.root.clone());
415        Self::from_manifest(local, manifest)
416    }
417
418    pub fn from_manifest(
419        local: Arc<LocalWorkspaceBackend>,
420        manifest: Arc<LocalWorkspaceManifest>,
421    ) -> Arc<Self> {
422        Arc::new(Self { local, manifest })
423    }
424
425    pub fn manifest(&self) -> Arc<LocalWorkspaceManifest> {
426        Arc::clone(&self.manifest)
427    }
428
429    pub fn local_root(&self) -> &Path {
430        &self.local.root
431    }
432
433    fn manifest_ready(&self) -> Option<ManifestSearchSnapshot> {
434        let state = self.manifest.state.read().ok()?;
435        (state.snapshot.version > 0).then(|| ManifestSearchSnapshot {
436            snapshot: Arc::clone(&state.snapshot),
437            index: Arc::clone(&state.index),
438        })
439    }
440
441    fn fallback_search(&self) -> Arc<LocalWorkspaceBackend> {
442        Arc::clone(&self.local)
443    }
444
445    fn recent_path_ranks(&self, index: &ManifestIndex) -> HashMap<String, usize> {
446        self.manifest
447            .recent
448            .read()
449            .map(|recent| {
450                recent
451                    .entries(Some(index), RECENT_FILE_LIMIT, now_ms())
452                    .into_iter()
453                    .enumerate()
454                    .map(|(rank, entry)| (entry.path, rank))
455                    .collect()
456            })
457            .unwrap_or_default()
458    }
459}
460
461impl WorkspacePathResolver for ManifestWorkspaceBackend {
462    fn normalize(&self, input: &str) -> Result<WorkspacePath> {
463        self.local.normalize(input)
464    }
465}
466
467#[async_trait]
468impl WorkspaceFileSystem for ManifestWorkspaceBackend {
469    async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
470        let content = self.local.read_text(path).await?;
471        self.manifest.touch_file(path.as_str());
472        Ok(content)
473    }
474
475    async fn write_text(
476        &self,
477        path: &WorkspacePath,
478        content: &str,
479    ) -> WorkspaceResult<WorkspaceWriteOutcome> {
480        let outcome = self.local.write_text(path, content).await?;
481        self.manifest.touch_file(path.as_str());
482        Ok(outcome)
483    }
484
485    async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
486        self.local.list_dir(path).await
487    }
488}
489
490#[async_trait]
491impl WorkspaceTextReader for ManifestWorkspaceBackend {
492    async fn read_text_range(
493        &self,
494        path: &WorkspacePath,
495        offset: usize,
496        limit: usize,
497    ) -> WorkspaceResult<WorkspaceTextRange> {
498        let range = self.local.read_text_range(path, offset, limit).await?;
499        self.manifest.touch_file(path.as_str());
500        Ok(range)
501    }
502}
503
504#[async_trait]
505impl WorkspaceCommandRunner for ManifestWorkspaceBackend {
506    async fn exec(&self, request: CommandRequest) -> Result<CommandOutput> {
507        self.local.exec(request).await
508    }
509}
510
511#[async_trait]
512impl WorkspaceSearch for ManifestWorkspaceBackend {
513    async fn glob(&self, request: WorkspaceGlobRequest) -> Result<WorkspaceGlobResult> {
514        validate_relative_pattern(&request.pattern, "glob pattern")?;
515        let Some(search_snapshot) = self.manifest_ready() else {
516            return self.fallback_search().glob(request).await;
517        };
518        let pattern = glob::Pattern::new(&request.pattern)
519            .map_err(|e| anyhow!("Invalid glob pattern '{}': {}", request.pattern, e))?;
520        let candidates =
521            candidate_indices_for_glob(&search_snapshot.index, &request.base, &request.pattern);
522        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
523
524        let mut matches = Vec::new();
525        for file_index in
526            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
527        {
528            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
529                continue;
530            };
531            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
532                continue;
533            };
534            if glob_matches(&pattern, relative_to_base) {
535                matches.push(WorkspacePath::from_normalized(file.path.clone()));
536            }
537        }
538
539        sort_paths_by_recent(&mut matches, &recent_ranks);
540        Ok(WorkspaceGlobResult { matches })
541    }
542
543    async fn grep(&self, request: WorkspaceGrepRequest) -> Result<WorkspaceGrepResult> {
544        Ok(self.grep_with_sources(request).await?.result)
545    }
546
547    async fn grep_with_sources(
548        &self,
549        request: WorkspaceGrepRequest,
550    ) -> Result<WorkspaceGrepOutcome> {
551        if let Some(ref glob) = request.glob {
552            validate_relative_pattern(glob, "grep glob filter")?;
553        }
554        let Some(search_snapshot) = self.manifest_ready() else {
555            return self.fallback_search().grep_with_sources(request).await;
556        };
557
558        let regex_pattern = if request.case_insensitive {
559            format!("(?i){}", request.pattern)
560        } else {
561            request.pattern.clone()
562        };
563        let regex = regex::Regex::new(&regex_pattern)
564            .map_err(|e| anyhow!("Invalid regex pattern '{}': {}", request.pattern, e))?;
565        let glob = request
566            .glob
567            .as_deref()
568            .map(glob::Pattern::new)
569            .transpose()
570            .map_err(|e| anyhow!("Invalid grep glob filter: {e}"))?;
571
572        let mut output = String::new();
573        let mut match_count = 0;
574        let mut file_count = 0;
575        let mut total_size = 0;
576        let mut matched_paths = Vec::new();
577
578        let candidates = request
579            .glob
580            .as_deref()
581            .map(|glob| candidate_indices_for_glob(&search_snapshot.index, &request.base, glob))
582            .unwrap_or_else(|| CandidateIndices::Indexed(&search_snapshot.index.all));
583        let recent_ranks = self.recent_path_ranks(&search_snapshot.index);
584
585        for file_index in
586            recent_first_candidate_indices(&candidates, &search_snapshot.index, &recent_ranks)
587        {
588            let Some(file) = search_snapshot.snapshot.files.get(file_index) else {
589                continue;
590            };
591            if file.binary {
592                continue;
593            }
594            let Some(relative_to_base) = relative_to_base(&file.path, &request.base) else {
595                continue;
596            };
597            if let Some(glob) = &glob {
598                if !glob_matches(glob, relative_to_base) {
599                    continue;
600                }
601            }
602
603            let full_path = search_snapshot.snapshot.root.join(&file.path);
604            let content = match std::fs::read_to_string(&full_path) {
605                Ok(content) => content,
606                Err(_) => continue,
607            };
608            let lines: Vec<&str> = content.lines().collect();
609            let file_matches = lines
610                .iter()
611                .enumerate()
612                .filter_map(|(line_idx, line)| regex.is_match(line).then_some(line_idx))
613                .collect::<Vec<_>>();
614
615            if file_matches.is_empty() {
616                continue;
617            }
618
619            file_count += 1;
620            let workspace_path = WorkspacePath::from_normalized(file.path.clone());
621            let display_path = escape_control_chars_for_display(&file.path);
622            let mut path_recorded = false;
623            for &match_idx in &file_matches {
624                if total_size > request.max_output_size {
625                    return Ok(WorkspaceGrepOutcome {
626                        result: WorkspaceGrepResult {
627                            output,
628                            match_count,
629                            file_count,
630                            truncated: true,
631                        },
632                        matched_paths: Some(matched_paths),
633                    });
634                }
635
636                if !path_recorded {
637                    matched_paths.push(workspace_path.clone());
638                    path_recorded = true;
639                }
640                match_count += 1;
641                let start = match_idx.saturating_sub(request.context_lines);
642                let end = (match_idx + request.context_lines + 1).min(lines.len());
643
644                for (i, line) in lines[start..end].iter().enumerate() {
645                    let abs_i = start + i;
646                    let prefix = if abs_i == match_idx { ">" } else { " " };
647                    let line = format!("{}{}:{}: {}\n", prefix, display_path, abs_i + 1, line);
648                    total_size += line.len();
649                    output.push_str(&line);
650                }
651
652                if request.context_lines > 0 {
653                    output.push_str("--\n");
654                    total_size += 3;
655                }
656            }
657        }
658
659        Ok(WorkspaceGrepOutcome {
660            result: WorkspaceGrepResult {
661                output,
662                match_count,
663                file_count,
664                truncated: false,
665            },
666            matched_paths: Some(matched_paths),
667        })
668    }
669}
670
671#[async_trait]
672impl WorkspaceGit for ManifestWorkspaceBackend {
673    async fn is_repository(&self) -> Result<bool> {
674        self.local.is_repository().await
675    }
676
677    async fn status(&self) -> Result<WorkspaceGitStatus> {
678        self.local.status().await
679    }
680
681    async fn log(&self, max_count: usize) -> Result<Vec<WorkspaceGitCommit>> {
682        self.local.log(max_count).await
683    }
684
685    async fn list_branches(&self) -> Result<Vec<WorkspaceGitBranch>> {
686        self.local.list_branches().await
687    }
688
689    async fn create_branch(&self, request: WorkspaceGitCreateBranchRequest) -> Result<()> {
690        self.local.create_branch(request).await
691    }
692
693    async fn checkout(
694        &self,
695        request: WorkspaceGitCheckoutRequest,
696    ) -> Result<WorkspaceGitCheckoutOutput> {
697        self.local.checkout(request).await
698    }
699
700    async fn diff(&self, request: WorkspaceGitDiffRequest) -> Result<String> {
701        self.local.diff(request).await
702    }
703
704    async fn list_remotes(&self) -> Result<Vec<WorkspaceGitRemote>> {
705        self.local.list_remotes().await
706    }
707}
708
709#[async_trait]
710impl WorkspaceGitStashProvider for ManifestWorkspaceBackend {
711    async fn list_stashes(&self) -> Result<Vec<WorkspaceGitStash>> {
712        self.local.list_stashes().await
713    }
714
715    async fn stash(&self, request: WorkspaceGitStashRequest) -> Result<()> {
716        self.local.stash(request).await
717    }
718}
719
720#[async_trait]
721impl WorkspaceGitWorktreeProvider for ManifestWorkspaceBackend {
722    async fn list_worktrees(&self) -> Result<Vec<WorkspaceGitWorktree>> {
723        self.local.list_worktrees().await
724    }
725
726    async fn create_worktree(
727        &self,
728        request: WorkspaceGitCreateWorktreeRequest,
729    ) -> Result<WorkspaceGitWorktreeMutation> {
730        self.local.create_worktree(request).await
731    }
732
733    async fn remove_worktree(
734        &self,
735        request: WorkspaceGitRemoveWorktreeRequest,
736    ) -> Result<WorkspaceGitWorktreeMutation> {
737        self.local.remove_worktree(request).await
738    }
739}
740
741fn update_state(
742    state: &Arc<RwLock<ManifestState>>,
743    files: Vec<LocalWorkspaceFile>,
744) -> Option<LocalWorkspaceManifestSnapshot> {
745    let fingerprint = fingerprint_files(&files);
746    let index = Arc::new(ManifestIndex::build(&files));
747    let Ok(mut state) = state.write() else {
748        return None;
749    };
750    if state.snapshot.version > 0 && state.fingerprint == fingerprint {
751        return None;
752    }
753    state.fingerprint = fingerprint;
754    state.index = index;
755    state.snapshot = Arc::new(LocalWorkspaceManifestSnapshot {
756        version: state.snapshot.version + 1,
757        root: state.snapshot.root.clone(),
758        files,
759        scanned_at_ms: now_ms(),
760    });
761    Some((*state.snapshot).clone())
762}
763
764enum CandidateIndices<'a> {
765    Indexed(&'a [usize]),
766    Single(Option<usize>),
767}
768
769impl<'a> CandidateIndices<'a> {
770    fn iter(&self) -> Box<dyn Iterator<Item = usize> + '_> {
771        match self {
772            Self::Indexed(indices) => Box::new(indices.iter().copied()),
773            Self::Single(Some(index)) => Box::new(std::iter::once(*index)),
774            Self::Single(None) => Box::new(std::iter::empty()),
775        }
776    }
777
778    fn len(&self) -> usize {
779        match self {
780            Self::Indexed(indices) => indices.len(),
781            Self::Single(Some(_)) => 1,
782            Self::Single(None) => 0,
783        }
784    }
785
786    fn contains(&self, index: usize) -> bool {
787        match self {
788            Self::Indexed(indices) => indices.contains(&index),
789            Self::Single(Some(candidate)) => *candidate == index,
790            Self::Single(None) => false,
791        }
792    }
793}
794
795fn recent_first_candidate_indices(
796    candidates: &CandidateIndices<'_>,
797    index: &ManifestIndex,
798    recent_ranks: &HashMap<String, usize>,
799) -> Vec<usize> {
800    if recent_ranks.is_empty() {
801        return candidates.iter().collect();
802    }
803
804    let mut hot = recent_ranks
805        .iter()
806        .filter_map(|(path, rank)| {
807            let file_index = *index.by_path.get(path)?;
808            candidates
809                .contains(file_index)
810                .then_some((*rank, file_index))
811        })
812        .collect::<Vec<_>>();
813    hot.sort_unstable_by_key(|(rank, _)| *rank);
814
815    let mut out = Vec::with_capacity(candidates.len());
816    let mut seen = HashSet::with_capacity(hot.len());
817    for (_, file_index) in hot {
818        if seen.insert(file_index) {
819            out.push(file_index);
820        }
821    }
822    out.extend(
823        candidates
824            .iter()
825            .filter(|file_index| !seen.contains(file_index)),
826    );
827    out
828}
829
830fn sort_paths_by_recent(paths: &mut [WorkspacePath], recent_ranks: &HashMap<String, usize>) {
831    paths.sort_by(|left, right| {
832        recent_ranks
833            .get(left.as_str())
834            .copied()
835            .unwrap_or(usize::MAX)
836            .cmp(
837                &recent_ranks
838                    .get(right.as_str())
839                    .copied()
840                    .unwrap_or(usize::MAX),
841            )
842            .then_with(|| left.as_str().cmp(right.as_str()))
843    });
844}
845
846fn candidate_indices_for_glob<'a>(
847    index: &'a ManifestIndex,
848    base: &WorkspacePath,
849    pattern: &str,
850) -> CandidateIndices<'a> {
851    if !has_glob_meta(pattern) && pattern.contains('/') {
852        return CandidateIndices::Single(
853            literal_workspace_path(base, pattern)
854                .and_then(|path| index.by_path.get(&path).copied()),
855        );
856    }
857
858    if let Some(name) = literal_terminal_segment(pattern) {
859        return index
860            .by_basename
861            .get(name)
862            .map(|indices| CandidateIndices::Indexed(indices))
863            .unwrap_or(CandidateIndices::Single(None));
864    }
865
866    if let Some(extension) = simple_extension_terminal(pattern) {
867        return index
868            .by_extension
869            .get(extension)
870            .map(|indices| CandidateIndices::Indexed(indices))
871            .unwrap_or(CandidateIndices::Single(None));
872    }
873
874    CandidateIndices::Indexed(&index.all)
875}
876
877fn literal_workspace_path(base: &WorkspacePath, pattern: &str) -> Option<String> {
878    let pattern = normalize_relative_path_lossy(Path::new(pattern))?;
879    if pattern.is_empty() {
880        return None;
881    }
882    if base.is_root() {
883        Some(pattern)
884    } else {
885        Some(format!(
886            "{}/{}",
887            base.as_str().trim_end_matches('/'),
888            pattern
889        ))
890    }
891}
892
893fn literal_terminal_segment(pattern: &str) -> Option<&str> {
894    let terminal = pattern
895        .trim_end_matches('/')
896        .rsplit('/')
897        .next()
898        .filter(|segment| !segment.is_empty())?;
899    (!has_glob_meta(terminal)).then_some(terminal)
900}
901
902fn simple_extension_terminal(pattern: &str) -> Option<&str> {
903    let terminal = pattern.trim_end_matches('/').rsplit('/').next()?;
904    let extension = terminal.strip_prefix("*.")?;
905    (!extension.is_empty() && !has_glob_meta(extension)).then_some(extension)
906}
907
908fn has_glob_meta(pattern: &str) -> bool {
909    pattern
910        .bytes()
911        .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}'))
912}
913
914fn fingerprint_files(files: &[LocalWorkspaceFile]) -> u64 {
915    let mut hasher = DefaultHasher::new();
916    files.hash(&mut hasher);
917    hasher.finish()
918}
919
920fn recent_score(entry: &RecentFileState, now: u64) -> f32 {
921    let age_ms = now.saturating_sub(entry.touched_at_ms) as f32;
922    let recency = (-age_ms / RECENT_DECAY_HALF_LIFE_MS).exp();
923    let frequency =
924        ((entry.touch_count as f32) + 1.0).ln() / (RECENT_FREQUENCY_NORMALIZER + 1.0).ln();
925    RECENT_RECENCY_WEIGHT * recency + (1.0 - RECENT_RECENCY_WEIGHT) * frequency.min(1.0)
926}
927
928fn normalize_recent_file_path(path: &str) -> Option<String> {
929    let path = path.trim();
930    if path.is_empty() {
931        return None;
932    }
933    let normalized = normalize_relative_path_lossy(Path::new(path))?;
934    (!normalized.is_empty()).then_some(normalized)
935}
936
937fn normalize_relative_path_lossy(path: &Path) -> Option<String> {
938    let mut parts = Vec::new();
939    for component in path.components() {
940        match component {
941            Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
942            Component::CurDir => {}
943            _ => return None,
944        }
945    }
946    Some(parts.join("/"))
947}
948
949fn relative_to_base<'a>(path: &'a str, base: &WorkspacePath) -> Option<&'a str> {
950    if base.is_root() {
951        return Some(path);
952    }
953    let base = base.as_str().trim_end_matches('/');
954    if path == base {
955        Some("")
956    } else {
957        path.strip_prefix(base)
958            .and_then(|tail| tail.strip_prefix('/'))
959            .filter(|tail| !tail.is_empty())
960    }
961}
962
963fn glob_matches(pattern: &glob::Pattern, path: &str) -> bool {
964    let path = Path::new(path);
965    pattern.matches_path(path)
966        || path
967            .file_name()
968            .and_then(|name| name.to_str())
969            .is_some_and(|name| pattern.matches(name))
970}
971
972fn now_ms() -> u64 {
973    system_time_ms(SystemTime::now())
974}
975
976fn system_time_ms(time: SystemTime) -> u64 {
977    time.duration_since(UNIX_EPOCH)
978        .map(|duration| duration.as_millis() as u64)
979        .unwrap_or_default()
980}
981
982#[cfg(test)]
983#[path = "manifest/tests.rs"]
984mod tests;