Skip to main content

ffs_search/
shared.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak};
3use std::time::{Duration, Instant};
4
5use crate::error::Error;
6use crate::file_picker::FilePicker;
7use crate::frecency::FrecencyTracker;
8use crate::git::GitStatusCache;
9use crate::query_tracker::QueryTracker;
10use crate::scan::ScanJob;
11
12/// Poll `.git/index.lock` until it disappears (git write completed), giving up
13/// after [`GIT_LOCK_MAX_WAIT`]. Used by [`SharedPicker::refresh_git_status`]
14/// to avoid reading a half-updated index when the watcher fires mid-`git add`.
15///
16/// The wait is bounded and cheap: the lock file is typically cleared within
17/// a few milliseconds of the git command exiting.
18fn wait_for_git_index_lock_release(git_root: &Path) {
19    const GIT_LOCK_POLL: Duration = Duration::from_millis(10);
20    const GIT_LOCK_MAX_WAIT: Duration = Duration::from_millis(500);
21
22    let lock = git_root.join(".git").join("index.lock");
23    // Fast path: no lock present.
24    if !lock.exists() {
25        return;
26    }
27    let deadline = Instant::now() + GIT_LOCK_MAX_WAIT;
28    while lock.exists() && Instant::now() < deadline {
29        std::thread::sleep(GIT_LOCK_POLL);
30    }
31    if lock.exists() {
32        tracing::warn!(
33            "Proceeding with git status refresh despite lingering \
34             .git/index.lock at {} — will retry once it clears",
35            lock.display()
36        );
37    }
38}
39
40/// Thread-safe shared handle to the [`FilePicker`] instance.
41/// This accumulates only asynchronous non-blocking operations against the
42/// file picker: creating, triggering various rescans and so on.
43///
44/// For blocking access use internal picker via `.read()` or `.write()`
45///
46/// ```ignore
47/// let shared_picker = SharedFilePicker::default();
48///
49/// if let Some(picker) = shared_picker.read()?.as_ref() {
50///     let files = picker.fuzzy_search(&query, options);
51///     println!("Found {} files", files.len());
52/// } else {
53///     println!("Picker not initialized");
54/// }
55/// ```
56#[derive(Clone, Default)]
57pub struct SharedFilePicker(pub(crate) Arc<SharedPickerInner>);
58
59pub struct SharedPickerInner {
60    picker: parking_lot::RwLock<Option<FilePicker>>,
61}
62
63impl Default for SharedPickerInner {
64    fn default() -> Self {
65        Self {
66            picker: parking_lot::RwLock::new(None),
67        }
68    }
69}
70
71/// Non-owning handle to a [`SharedPicker`].
72#[derive(Clone)]
73pub(crate) struct WeakFilePicker(Weak<SharedPickerInner>);
74
75impl WeakFilePicker {
76    /// Try to promote the weak handle back to a strong [`SharedPicker`].
77    ///
78    /// Returns `None` once every strong `SharedPicker` clone has been
79    /// dropped. Callers should treat that as "the picker is being
80    /// torn down" and exit their current iteration cleanly.
81    pub(crate) fn upgrade(&self) -> Option<SharedFilePicker> {
82        self.0.upgrade().map(SharedFilePicker)
83    }
84}
85
86impl std::fmt::Debug for SharedFilePicker {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_tuple("SharedPicker").field(&"..").finish()
89    }
90}
91
92impl SharedFilePicker {
93    pub fn read(&self) -> Result<parking_lot::RwLockReadGuard<'_, Option<FilePicker>>, Error> {
94        Ok(self.0.picker.read())
95    }
96
97    pub fn write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, Option<FilePicker>>, Error> {
98        Ok(self.0.picker.write())
99    }
100
101    /// Produce a non-owning handle to the same inner picker.
102    /// Use it if you don't need to block internal threads from dropping while owning this ref
103    pub(crate) fn weaken(&self) -> WeakFilePicker {
104        WeakFilePicker(Arc::downgrade(&self.0))
105    }
106
107    /// Return `true` if this is an instance of the picker that requires a complicated post-scan
108    /// indexing/cache warmup job. The indexing is not crazy but it takes time.
109    pub fn need_complex_rebuild(&self) -> bool {
110        let guard = self.0.picker.read();
111        guard
112            .as_ref()
113            .is_some_and(|p| p.has_mmap_cache() || p.has_content_indexing())
114    }
115
116    /// Block until the background filesystem scan finishes.
117    /// Returns `true` if scan completed, `false` on timeout.
118    pub fn wait_for_scan(&self, timeout: Duration) -> bool {
119        let signal = {
120            let guard = self.0.picker.read();
121            match &*guard {
122                Some(picker) => Arc::clone(&picker.signals.scanning),
123                None => return true,
124            }
125        };
126
127        let start = std::time::Instant::now();
128        while signal.load(std::sync::atomic::Ordering::Acquire) {
129            if start.elapsed() >= timeout {
130                return false;
131            }
132            std::thread::sleep(Duration::from_millis(10));
133        }
134        true
135    }
136
137    /// Block until the background file watcher is ready.
138    /// Returns `true` if watcher ready, `false` on timeout.
139    pub fn wait_for_watcher(&self, timeout: Duration) -> bool {
140        let watch_ready_signal = {
141            let guard = self.0.picker.read();
142            match &*guard {
143                Some(picker) => Arc::clone(&picker.signals.watcher_ready),
144                None => return true,
145            }
146        };
147
148        let start = std::time::Instant::now();
149        while !watch_ready_signal.load(std::sync::atomic::Ordering::Acquire) {
150            if start.elapsed() >= timeout {
151                return false;
152            }
153            std::thread::sleep(Duration::from_millis(10));
154        }
155        true
156    }
157
158    /// Trigger a full filesystem rescan without blocking the caller.
159    /// Performs a safe async rescan. Guarantees only single active rescan per picker.
160    /// If many rescans requested the last one guaranteed to be finished.
161    pub fn trigger_full_rescan_async(&self, shared_frecency: &SharedFrecency) -> Result<(), Error> {
162        match ScanJob::new_rescan(self, shared_frecency)? {
163            Some(job) => {
164                job.spawn();
165            }
166            None => {
167                // we can not abort the ongoing sync, but if the events
168                if let Ok(guard) = self.read()
169                    && let Some(picker) = guard.as_ref()
170                {
171                    picker
172                        .scan_signals()
173                        .rescan_pending
174                        .store(true, std::sync::atomic::Ordering::Release);
175                    tracing::info!(
176                        "Full rescan requested while another scan is active — \
177                         deferred via rescan_pending flag"
178                    );
179                }
180            }
181        }
182        Ok(())
183    }
184
185    /// Refresh git statuses for all indexed files.
186    pub fn refresh_git_status(&self, shared_frecency: &SharedFrecency) -> Result<usize, Error> {
187        use tracing::debug;
188
189        let git_status = {
190            let guard = self.read()?;
191            let Some(ref picker) = *guard else {
192                return Err(Error::FilePickerMissing);
193            };
194
195            let git_root = picker.git_root().map(|p| p.to_path_buf());
196            drop(guard); // updating git status could take very long time, there is not risky as we
197            // do not allow any mutations and deletions of files from the sync
198
199            debug!(?git_root, "Refreshing git status for picker");
200
201            if let Some(ref root) = git_root {
202                wait_for_git_index_lock_release(root);
203            }
204
205            GitStatusCache::read_git_status(
206                git_root.as_deref(),
207                &mut crate::git::default_status_options(),
208            )
209        };
210
211        let mut guard = self.write()?;
212        let picker = guard.as_mut().ok_or(Error::FilePickerMissing)?;
213
214        let statuses_count = if let Some(git_status) = git_status {
215            let count = git_status.statuses_len();
216            picker.update_git_statuses(git_status, shared_frecency)?;
217            count
218        } else {
219            0
220        };
221
222        Ok(statuses_count)
223    }
224}
225
226/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
227#[derive(Clone)]
228pub struct SharedFrecency {
229    inner: Arc<RwLock<Option<FrecencyTracker>>>,
230    enabled: bool,
231}
232
233impl Default for SharedFrecency {
234    fn default() -> Self {
235        Self {
236            inner: Arc::new(RwLock::new(None)),
237            enabled: true,
238        }
239    }
240}
241
242impl std::fmt::Debug for SharedFrecency {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_tuple("SharedFrecency").field(&"..").finish()
245    }
246}
247
248impl SharedFrecency {
249    /// Creates a disabled instance that silently ignores all writes.
250    pub fn noop() -> Self {
251        Self {
252            inner: Arc::new(RwLock::new(None)),
253            enabled: false,
254        }
255    }
256
257    pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<FrecencyTracker>>, Error> {
258        self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
259    }
260
261    pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<FrecencyTracker>>, Error> {
262        self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
263    }
264
265    /// Initialize the frecency tracker. No-op if this is a disabled instance.
266    pub fn init(&self, tracker: FrecencyTracker) -> Result<(), Error> {
267        if !self.enabled {
268            return Ok(());
269        }
270        let mut guard = self.write()?;
271        *guard = Some(tracker);
272        Ok(())
273    }
274
275    /// Spawn a background GC thread for this frecency tracker.
276    pub fn spawn_gc(&self, db_path: String) -> crate::Result<std::thread::JoinHandle<()>> {
277        FrecencyTracker::spawn_gc(self.clone(), db_path)
278    }
279
280    /// Drop the in-memory tracker and delete the on-disk database directory.
281    ///
282    /// Acquires the write lock, ensuring all readers (including any active mmap
283    /// access) are finished before the LMDB environment is closed and the files
284    /// are removed.
285    ///
286    /// Returns `Ok(Some(path))` with the deleted path, or `Ok(None)` if no
287    /// tracker was initialized.
288    pub fn destroy(&self) -> Result<Option<PathBuf>, Error> {
289        let mut guard = self.write()?;
290        let Some(tracker) = guard.take() else {
291            return Ok(None);
292        };
293        let db_path = tracker.db_path().to_path_buf();
294        // Drop closes the LMDB env and unmaps the files
295        drop(tracker);
296        drop(guard);
297        std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
298            path: db_path.clone(),
299            source,
300        })?;
301        Ok(Some(db_path))
302    }
303}
304
305/// Thread-safe shared handle to the [`QueryTracker`] instance.
306#[derive(Clone)]
307pub struct SharedQueryTracker {
308    inner: Arc<RwLock<Option<QueryTracker>>>,
309    enabled: bool,
310}
311
312impl Default for SharedQueryTracker {
313    fn default() -> Self {
314        Self {
315            inner: Arc::new(RwLock::new(None)),
316            enabled: true,
317        }
318    }
319}
320
321impl std::fmt::Debug for SharedQueryTracker {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        f.debug_tuple("SharedQueryTracker").field(&"..").finish()
324    }
325}
326
327impl SharedQueryTracker {
328    /// Creates a disabled instance that silently ignores all writes.
329    pub fn noop() -> Self {
330        Self {
331            inner: Arc::new(RwLock::new(None)),
332            enabled: false,
333        }
334    }
335
336    pub fn read(&self) -> Result<RwLockReadGuard<'_, Option<QueryTracker>>, Error> {
337        self.inner.read().map_err(|_| Error::AcquireFrecencyLock)
338    }
339
340    pub fn write(&self) -> Result<RwLockWriteGuard<'_, Option<QueryTracker>>, Error> {
341        self.inner.write().map_err(|_| Error::AcquireFrecencyLock)
342    }
343
344    /// Initialize the query tracker. No-op if this is a disabled instance.
345    pub fn init(&self, tracker: QueryTracker) -> Result<(), Error> {
346        if !self.enabled {
347            return Ok(());
348        }
349        let mut guard = self.write()?;
350        *guard = Some(tracker);
351        Ok(())
352    }
353
354    /// Drop the in-memory tracker and delete the on-disk database directory.
355    ///
356    /// Acquires the write lock, ensuring all readers (including any active mmap
357    /// access) are finished before the LMDB environment is closed and the files
358    /// are removed.
359    ///
360    /// Returns `Ok(Some(path))` with the deleted path, or `Ok(None)` if no
361    /// tracker was initialized.
362    pub fn destroy(&self) -> Result<Option<PathBuf>, Error> {
363        let mut guard = self.write()?;
364        let Some(tracker) = guard.take() else {
365            return Ok(None);
366        };
367        let db_path = tracker.db_path().to_path_buf();
368        drop(tracker);
369        drop(guard);
370        std::fs::remove_dir_all(&db_path).map_err(|source| Error::RemoveDbDir {
371            path: db_path.clone(),
372            source,
373        })?;
374        Ok(Some(db_path))
375    }
376}