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