Skip to main content

synwire_agent/vfs/
local.rs

1//! Local filesystem VFS provider.
2
3use std::collections::HashMap;
4use std::path::{Component, PathBuf};
5use std::sync::{Arc, Mutex, RwLock};
6use std::time::SystemTime;
7
8use synwire_core::BoxFuture;
9use synwire_core::vfs::agentic_ignore::AgenticIgnore;
10use synwire_core::vfs::error::VfsError;
11use synwire_core::vfs::grep_options::{GrepOptions, GrepOutputMode};
12use synwire_core::vfs::protocol::Vfs;
13use synwire_core::vfs::types::{
14    CpOptions, DirEntry, EditResult, FileContent, FindEntry, FindOptions, FindType, GlobEntry,
15    GrepMatch, LsOptions, RmOptions, TransferResult, VfsCapabilities, WriteResult,
16};
17
18use regex::Regex;
19
20#[cfg(feature = "semantic-search")]
21use {
22    once_cell::sync::OnceCell as OnceLock,
23    std::path::Path,
24    synwire_core::vectorstores::VectorStore,
25    synwire_core::vfs::types::{
26        IndexHandle, IndexOptions, IndexStatus, SemanticSearchOptions, SemanticSearchResult,
27    },
28    synwire_embeddings_local::{LocalEmbeddings, LocalReranker},
29    synwire_index::{IndexConfig, SemanticIndex, StoreFactory},
30    synwire_vectorstore_lancedb::LanceDbVectorStore,
31};
32
33/// Local filesystem VFS provider with path-traversal protection.
34///
35/// All operations are scoped to `root`.  Relative paths are resolved from the
36/// current working directory (`cwd`), which itself must stay inside `root`.
37pub struct LocalProvider {
38    root: PathBuf,
39    cwd: Mutex<PathBuf>,
40    watched: Arc<RwLock<HashMap<String, SystemTime>>>,
41    agentic_ignore: AgenticIgnore,
42    #[cfg(feature = "semantic-search")]
43    semantic_index: OnceLock<Arc<SemanticIndex>>,
44}
45
46impl std::fmt::Debug for LocalProvider {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("LocalProvider")
49            .field("root", &self.root)
50            .finish_non_exhaustive()
51    }
52}
53
54impl LocalProvider {
55    /// Create a new provider rooted at `root`.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if `root` does not exist or is not a directory.
60    pub fn new(root: impl Into<PathBuf>) -> Result<Self, VfsError> {
61        let root = root.into().canonicalize()?;
62        if !root.is_dir() {
63            return Err(VfsError::NotFound(root.display().to_string()));
64        }
65        let cwd = root.clone();
66        let agentic_ignore = AgenticIgnore::discover(&root);
67        Ok(Self {
68            root,
69            cwd: Mutex::new(cwd),
70            watched: Arc::new(RwLock::new(HashMap::new())),
71            agentic_ignore,
72            #[cfg(feature = "semantic-search")]
73            semantic_index: OnceLock::new(),
74        })
75    }
76
77    /// Lazily initialise and return the shared [`SemanticIndex`].
78    ///
79    /// On first call, creates `LocalEmbeddings`, `LocalReranker`, and a
80    /// LanceDB-backed store factory.  Subsequent calls return the cached value.
81    /// Lazily initialise and return the shared [`SemanticIndex`].
82    ///
83    /// On first call, creates `LocalEmbeddings`, `LocalReranker`, and a
84    /// LanceDB-backed store factory.  Subsequent calls return the cached value.
85    #[cfg(feature = "semantic-search")]
86    fn get_or_init_index(&self) -> Result<&Arc<SemanticIndex>, VfsError> {
87        // OnceLock::get_or_try_init is stable since Rust 1.83 (our MSRV is 1.85).
88        self.semantic_index.get_or_try_init(|| {
89            let embeddings = LocalEmbeddings::new()
90                .map_err(|e| VfsError::Io(std::io::Error::other(e.to_string())))?;
91            let reranker = LocalReranker::new()
92                .map_err(|e| VfsError::Io(std::io::Error::other(e.to_string())))?;
93            let dims = 384usize;
94            let factory: StoreFactory = Box::new(move |cache_dir: &Path| {
95                let lance_path = cache_dir.join("lance");
96                let path_str = lance_path.to_string_lossy().to_string();
97                // LanceDbVectorStore::open is async; run it on the current
98                // tokio handle via block_in_place (requires multi-thread runtime).
99                let store = tokio::task::block_in_place(|| {
100                    tokio::runtime::Handle::current()
101                        .block_on(LanceDbVectorStore::open(&path_str, "chunks", dims))
102                })
103                .map_err(|e| Box::<dyn std::error::Error + Send + Sync>::from(e.to_string()))?;
104                Ok(Arc::new(store) as Arc<dyn VectorStore>)
105            });
106            let idx = SemanticIndex::new(
107                Arc::new(embeddings),
108                Some(Arc::new(reranker)),
109                factory,
110                IndexConfig::default(),
111                None,
112            );
113            Ok(Arc::new(idx))
114        })
115    }
116
117    /// Resolve `path` relative to cwd, rejecting traversal outside root.
118    fn resolve(&self, path: &str) -> Result<PathBuf, VfsError> {
119        let cwd = self
120            .cwd
121            .lock()
122            .map_err(|_| VfsError::Unsupported("mutex poisoned".into()))?
123            .clone();
124
125        let candidate = if path.starts_with('/') {
126            self.root.join(path.trim_start_matches('/'))
127        } else {
128            cwd.join(path)
129        };
130
131        // Canonicalise without requiring the path to exist.
132        let normalised = normalise_path(&candidate);
133
134        if !normalised.starts_with(&self.root) {
135            return Err(VfsError::PathTraversal {
136                attempted: normalised.display().to_string(),
137                root: self.root.display().to_string(),
138            });
139        }
140        Ok(normalised)
141    }
142}
143
144/// Normalise a path (collapse `.` / `..`) without requiring existence.
145fn normalise_path(path: &std::path::Path) -> PathBuf {
146    let mut out = PathBuf::new();
147    for comp in path.components() {
148        match comp {
149            Component::Prefix(p) => out.push(p.as_os_str()),
150            Component::RootDir => out.push(std::path::MAIN_SEPARATOR_STR),
151            Component::CurDir => {}
152            Component::ParentDir => {
153                let _ = out.pop();
154            }
155            Component::Normal(n) => out.push(n),
156        }
157    }
158    out
159}
160
161impl Vfs for LocalProvider {
162    fn ls(&self, path: &str, _opts: LsOptions) -> BoxFuture<'_, Result<Vec<DirEntry>, VfsError>> {
163        let path = path.to_string();
164        Box::pin(async move {
165            let resolved = self.resolve(&path)?;
166            let mut entries = Vec::new();
167            let mut rd = tokio::fs::read_dir(&resolved).await.map_err(VfsError::Io)?;
168            while let Some(entry) = rd.next_entry().await.map_err(VfsError::Io)? {
169                if self
170                    .agentic_ignore
171                    .is_ignored(&entry.path(), entry.path().is_dir())
172                {
173                    continue;
174                }
175                let meta = entry.metadata().await.map_err(VfsError::Io)?;
176                #[cfg(unix)]
177                let permissions = {
178                    use std::os::unix::fs::PermissionsExt;
179                    Some(meta.permissions().mode())
180                };
181                #[cfg(not(unix))]
182                let permissions: Option<u32> = None;
183
184                entries.push(DirEntry {
185                    name: entry.file_name().to_string_lossy().into_owned(),
186                    path: entry.path().display().to_string(),
187                    is_dir: meta.is_dir(),
188                    size: if meta.is_file() {
189                        Some(meta.len())
190                    } else {
191                        None
192                    },
193                    modified: meta.modified().ok().and_then(|t| {
194                        let secs = t
195                            .duration_since(std::time::UNIX_EPOCH)
196                            .unwrap_or_default()
197                            .as_secs();
198                        chrono::DateTime::from_timestamp(i64::try_from(secs).unwrap_or(i64::MAX), 0)
199                    }),
200                    permissions,
201                    is_symlink: meta.is_symlink(),
202                });
203            }
204            Ok(entries)
205        })
206    }
207
208    fn read(&self, path: &str) -> BoxFuture<'_, Result<FileContent, VfsError>> {
209        let path = path.to_string();
210        Box::pin(async move {
211            let resolved = self.resolve(&path)?;
212            let content = tokio::fs::read(&resolved).await.map_err(VfsError::Io)?;
213            Ok(FileContent {
214                content,
215                mime_type: None,
216            })
217        })
218    }
219
220    fn write(&self, path: &str, content: &[u8]) -> BoxFuture<'_, Result<WriteResult, VfsError>> {
221        let path = path.to_string();
222        let content = content.to_vec();
223        Box::pin(async move {
224            let resolved = self.resolve(&path)?;
225            if let Some(parent) = resolved.parent() {
226                tokio::fs::create_dir_all(parent)
227                    .await
228                    .map_err(VfsError::Io)?;
229            }
230            let bytes_written = content.len() as u64;
231            tokio::fs::write(&resolved, &content)
232                .await
233                .map_err(VfsError::Io)?;
234            Ok(WriteResult {
235                path: resolved.display().to_string(),
236                bytes_written,
237            })
238        })
239    }
240
241    fn edit(
242        &self,
243        path: &str,
244        old: &str,
245        new: &str,
246    ) -> BoxFuture<'_, Result<EditResult, VfsError>> {
247        let path = path.to_string();
248        let old = old.to_string();
249        let new = new.to_string();
250        Box::pin(async move {
251            let resolved = self.resolve(&path)?;
252            let bytes = tokio::fs::read(&resolved).await.map_err(VfsError::Io)?;
253            let text = String::from_utf8(bytes)
254                .map_err(|_| VfsError::Unsupported("binary file".into()))?;
255            if !text.contains(&old) {
256                return Ok(EditResult {
257                    path: resolved.display().to_string(),
258                    edits_applied: 0,
259                    content_after: Some(text),
260                });
261            }
262            let replaced = text.replacen(&old, &new, 1);
263            let after = replaced.clone();
264            tokio::fs::write(&resolved, replaced.as_bytes())
265                .await
266                .map_err(VfsError::Io)?;
267            Ok(EditResult {
268                path: resolved.display().to_string(),
269                edits_applied: 1,
270                content_after: Some(after),
271            })
272        })
273    }
274
275    fn grep(
276        &self,
277        pattern: &str,
278        opts: GrepOptions,
279    ) -> BoxFuture<'_, Result<Vec<GrepMatch>, VfsError>> {
280        let pattern = pattern.to_string();
281        Box::pin(async move {
282            let regex_pattern = if opts.case_insensitive {
283                format!("(?i){pattern}")
284            } else {
285                pattern
286            };
287            let re = Regex::new(&regex_pattern)
288                .map_err(|e| VfsError::Unsupported(format!("invalid regex: {e}")))?;
289
290            let root = match &opts.path {
291                Some(p) => self.resolve(p)?,
292                None => self
293                    .cwd
294                    .lock()
295                    .map_err(|_| VfsError::Unsupported("mutex poisoned".into()))?
296                    .clone(),
297            };
298
299            let after = opts.context.unwrap_or(opts.after_context);
300            let before = opts.context.unwrap_or(opts.before_context);
301            let mut matches = Vec::new();
302            let mut total = 0usize;
303
304            grep_dir(
305                &root,
306                &re,
307                &opts,
308                before,
309                after,
310                &mut matches,
311                &mut total,
312                &self.agentic_ignore,
313            )?;
314            Ok(matches)
315        })
316    }
317
318    fn glob(&self, pattern: &str) -> BoxFuture<'_, Result<Vec<GlobEntry>, VfsError>> {
319        let pattern = pattern.to_string();
320        Box::pin(async move {
321            let root = self
322                .cwd
323                .lock()
324                .map_err(|_| VfsError::Unsupported("mutex poisoned".into()))?
325                .clone();
326            let mut entries = Vec::new();
327            glob_dir(&root, &pattern, &mut entries, &self.agentic_ignore)?;
328            Ok(entries)
329        })
330    }
331
332    fn upload(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
333        let from = from.to_string();
334        let to = to.to_string();
335        Box::pin(async move {
336            let dst = self.resolve(&to)?;
337            let content = tokio::fs::read(&from).await.map_err(VfsError::Io)?;
338            let bytes = content.len() as u64;
339            tokio::fs::write(&dst, &content)
340                .await
341                .map_err(VfsError::Io)?;
342            Ok(TransferResult {
343                path: dst.display().to_string(),
344                bytes_transferred: bytes,
345            })
346        })
347    }
348
349    fn download(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
350        let from = from.to_string();
351        let to = to.to_string();
352        Box::pin(async move {
353            let src = self.resolve(&from)?;
354            let content = tokio::fs::read(&src).await.map_err(VfsError::Io)?;
355            let bytes = content.len() as u64;
356            tokio::fs::write(&to, &content)
357                .await
358                .map_err(VfsError::Io)?;
359            Ok(TransferResult {
360                path: to,
361                bytes_transferred: bytes,
362            })
363        })
364    }
365
366    fn pwd(&self) -> BoxFuture<'_, Result<String, VfsError>> {
367        Box::pin(async move {
368            self.cwd
369                .lock()
370                .map(|g| g.display().to_string())
371                .map_err(|_| VfsError::Unsupported("mutex poisoned".into()))
372        })
373    }
374
375    fn cd(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
376        let path = path.to_string();
377        Box::pin(async move {
378            let resolved = self.resolve(&path)?;
379            if !resolved.is_dir() {
380                return Err(VfsError::NotFound(resolved.display().to_string()));
381            }
382            *self
383                .cwd
384                .lock()
385                .map_err(|_| VfsError::Unsupported("mutex poisoned".into()))? = resolved;
386            Ok(())
387        })
388    }
389
390    fn rm(&self, path: &str, _opts: RmOptions) -> BoxFuture<'_, Result<(), VfsError>> {
391        let path = path.to_string();
392        Box::pin(async move {
393            let resolved = self.resolve(&path)?;
394            if resolved.is_dir() {
395                tokio::fs::remove_dir_all(&resolved)
396                    .await
397                    .map_err(VfsError::Io)?;
398            } else {
399                tokio::fs::remove_file(&resolved)
400                    .await
401                    .map_err(VfsError::Io)?;
402            }
403            Ok(())
404        })
405    }
406
407    fn cp(
408        &self,
409        from: &str,
410        to: &str,
411        _opts: CpOptions,
412    ) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
413        let from = from.to_string();
414        let to = to.to_string();
415        Box::pin(async move {
416            let src = self.resolve(&from)?;
417            let dst = self.resolve(&to)?;
418            let bytes = tokio::fs::copy(&src, &dst).await.map_err(VfsError::Io)?;
419            Ok(TransferResult {
420                path: dst.display().to_string(),
421                bytes_transferred: bytes,
422            })
423        })
424    }
425
426    fn mv_file(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
427        let from = from.to_string();
428        let to = to.to_string();
429        Box::pin(async move {
430            let src = self.resolve(&from)?;
431            let dst = self.resolve(&to)?;
432            let meta = tokio::fs::metadata(&src).await.map_err(VfsError::Io)?;
433            let bytes = meta.len();
434            tokio::fs::rename(&src, &dst).await.map_err(VfsError::Io)?;
435            Ok(TransferResult {
436                path: dst.display().to_string(),
437                bytes_transferred: bytes,
438            })
439        })
440    }
441
442    fn watch(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
443        let path = path.to_string();
444        Box::pin(async move {
445            let resolved = self.resolve(&path)?;
446            let mtime = std::fs::metadata(&resolved)?.modified()?;
447            let key = resolved.display().to_string();
448            let _ = self
449                .watched
450                .write()
451                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?
452                .insert(key, mtime);
453            Ok(())
454        })
455    }
456
457    fn check_stale(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
458        let path = path.to_string();
459        Box::pin(async move {
460            let resolved = self.resolve(&path)?;
461            let key = resolved.display().to_string();
462            let recorded = {
463                let guard = self
464                    .watched
465                    .read()
466                    .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
467                match guard.get(&key) {
468                    Some(&t) => t,
469                    None => return Ok(()),
470                }
471            };
472            let current = std::fs::metadata(&resolved)?.modified()?;
473            if current != recorded {
474                return Err(VfsError::StaleRead { path });
475            }
476            Ok(())
477        })
478    }
479
480    fn find(
481        &self,
482        path: &str,
483        opts: FindOptions,
484    ) -> BoxFuture<'_, Result<Vec<FindEntry>, VfsError>> {
485        let path = path.to_string();
486        Box::pin(async move {
487            let resolved = self.resolve(&path)?;
488            let mut results = Vec::new();
489            find_dir(&resolved, &opts, 0, &mut results, &self.agentic_ignore)?;
490            Ok(results)
491        })
492    }
493
494    #[cfg(feature = "semantic-search")]
495    fn index(
496        &self,
497        path: &str,
498        opts: IndexOptions,
499    ) -> BoxFuture<'_, Result<IndexHandle, VfsError>> {
500        let path = path.to_string();
501        Box::pin(async move {
502            let resolved = self.resolve(&path)?;
503            let idx = self.get_or_init_index()?;
504            idx.index(&resolved, &opts).await
505        })
506    }
507
508    #[cfg(feature = "semantic-search")]
509    fn index_status(&self, index_id: &str) -> BoxFuture<'_, Result<IndexStatus, VfsError>> {
510        let id = index_id.to_string();
511        Box::pin(async move {
512            let idx = self.get_or_init_index()?;
513            idx.status(&id).await
514        })
515    }
516
517    #[cfg(feature = "semantic-search")]
518    fn semantic_search(
519        &self,
520        query: &str,
521        opts: SemanticSearchOptions,
522    ) -> BoxFuture<'_, Result<Vec<SemanticSearchResult>, VfsError>> {
523        let query = query.to_string();
524        let root = self.root.clone();
525        Box::pin(async move {
526            let idx = self.get_or_init_index()?;
527            idx.search(&root, &query, &opts).await
528        })
529    }
530
531    fn capabilities(&self) -> VfsCapabilities {
532        #[cfg(feature = "semantic-search")]
533        return VfsCapabilities::all();
534        #[cfg(not(feature = "semantic-search"))]
535        return VfsCapabilities::all()
536            & !VfsCapabilities::INDEX
537            & !VfsCapabilities::SEMANTIC_SEARCH;
538    }
539
540    fn provider_name(&self) -> &'static str {
541        "LocalProvider"
542    }
543}
544
545#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
546fn grep_dir(
547    dir: &std::path::Path,
548    re: &Regex,
549    opts: &GrepOptions,
550    before: u32,
551    after: u32,
552    matches: &mut Vec<GrepMatch>,
553    total: &mut usize,
554    agentic_ignore: &AgenticIgnore,
555) -> Result<(), VfsError> {
556    let rd = std::fs::read_dir(dir)?;
557    for entry in rd {
558        let entry = entry?;
559        let path = entry.path();
560        if agentic_ignore.is_ignored(&path, path.is_dir()) {
561            continue;
562        }
563        if path.is_dir() {
564            grep_dir(
565                &path,
566                re,
567                opts,
568                before,
569                after,
570                matches,
571                total,
572                agentic_ignore,
573            )?;
574            continue;
575        }
576        if let Some(ft) = &opts.file_type {
577            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
578            if !synwire_core::vfs::memory::matches_file_type_pub(ft, ext) {
579                continue;
580            }
581        }
582        if let Some(glob) = &opts.glob {
583            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
584            if !synwire_core::vfs::memory::glob_matches_pub(glob, name) {
585                continue;
586            }
587        }
588        let Ok(content) = std::fs::read(&path) else {
589            continue;
590        };
591        if content.contains(&0u8) {
592            continue;
593        }
594        let Ok(text) = std::str::from_utf8(&content) else {
595            continue;
596        };
597        let file_str = path.display().to_string();
598        let lines: Vec<&str> = text.lines().collect();
599        let mut file_count = 0;
600        let mode = opts.output_mode;
601
602        for (i, &line) in lines.iter().enumerate() {
603            let line_matches = if opts.invert {
604                !re.is_match(line)
605            } else {
606                re.is_match(line)
607            };
608            if !line_matches {
609                continue;
610            }
611            file_count += 1;
612            *total += 1;
613            if mode == GrepOutputMode::FilesWithMatches {
614                matches.push(GrepMatch {
615                    file: file_str.clone(),
616                    line_number: 0,
617                    column: 0,
618                    line_content: String::new(),
619                    before: Vec::new(),
620                    after: Vec::new(),
621                });
622                break;
623            }
624            if mode == GrepOutputMode::Count {
625                continue;
626            }
627            let b_start = i.saturating_sub(before as usize);
628            let a_end = (i + 1 + after as usize).min(lines.len());
629            matches.push(GrepMatch {
630                file: file_str.clone(),
631                line_number: if opts.line_numbers { i + 1 } else { 0 },
632                column: if opts.invert {
633                    0
634                } else {
635                    re.find(line).map_or(0, |m| m.start())
636                },
637                line_content: line.to_string(),
638                before: lines[b_start..i].iter().map(ToString::to_string).collect(),
639                after: lines[i + 1..a_end]
640                    .iter()
641                    .map(ToString::to_string)
642                    .collect(),
643            });
644            if let Some(max) = opts.max_matches
645                && *total >= max
646            {
647                return Ok(());
648            }
649        }
650        if mode == GrepOutputMode::Count && file_count > 0 {
651            matches.push(GrepMatch {
652                file: file_str,
653                line_number: file_count,
654                column: 0,
655                line_content: file_count.to_string(),
656                before: Vec::new(),
657                after: Vec::new(),
658            });
659        }
660    }
661    Ok(())
662}
663
664fn glob_dir(
665    dir: &std::path::Path,
666    pattern: &str,
667    entries: &mut Vec<GlobEntry>,
668    agentic_ignore: &AgenticIgnore,
669) -> Result<(), VfsError> {
670    let rd = std::fs::read_dir(dir)?;
671    for entry in rd {
672        let entry = entry?;
673        let path = entry.path();
674        if agentic_ignore.is_ignored(&path, path.is_dir()) {
675            continue;
676        }
677        let name = entry.file_name().to_string_lossy().into_owned();
678        if path.is_dir() {
679            glob_dir(&path, pattern, entries, agentic_ignore)?;
680        }
681        if synwire_core::vfs::memory::glob_matches_pub(pattern, &name) {
682            let meta = entry.metadata().ok();
683            entries.push(GlobEntry {
684                path: path.display().to_string(),
685                is_dir: path.is_dir(),
686                size: meta
687                    .as_ref()
688                    .filter(|m| m.is_file())
689                    .map(std::fs::Metadata::len),
690            });
691        }
692    }
693    Ok(())
694}
695
696fn find_dir(
697    dir: &std::path::Path,
698    opts: &FindOptions,
699    depth: usize,
700    results: &mut Vec<FindEntry>,
701    agentic_ignore: &AgenticIgnore,
702) -> Result<(), VfsError> {
703    if let Some(max) = opts.max_depth
704        && depth > max
705    {
706        return Ok(());
707    }
708    let rd = std::fs::read_dir(dir)?;
709    for entry in rd {
710        let entry = entry?;
711        let path = entry.path();
712        let is_dir = path.is_dir();
713        let is_symlink = path.symlink_metadata().is_ok_and(|m| m.is_symlink());
714
715        if agentic_ignore.is_ignored(&path, is_dir) {
716            continue;
717        }
718
719        let meta = entry.metadata().ok();
720
721        // Type filter — still recurse into directories even if they don't match.
722        if let Some(ref ft) = opts.entry_type {
723            let matches_type = match ft {
724                FindType::File => !is_dir && !is_symlink,
725                FindType::Directory => is_dir,
726                FindType::Symlink => is_symlink,
727                _ => true,
728            };
729            if !matches_type {
730                if is_dir {
731                    find_dir(&path, opts, depth + 1, results, agentic_ignore)?;
732                }
733                continue;
734            }
735        }
736
737        // Name glob filter — still recurse into directories.
738        if let Some(ref name_pat) = opts.name {
739            let name = entry.file_name().to_string_lossy().into_owned();
740            if !synwire_core::vfs::memory::glob_matches_pub(name_pat, &name) {
741                if is_dir {
742                    find_dir(&path, opts, depth + 1, results, agentic_ignore)?;
743                }
744                continue;
745            }
746        }
747
748        // Size filters (files only).
749        if let Some(ref m) = meta
750            && !is_dir
751        {
752            if let Some(min) = opts.min_size
753                && m.len() < min
754            {
755                continue;
756            }
757            if let Some(max) = opts.max_size
758                && m.len() > max
759            {
760                continue;
761            }
762        }
763
764        // Time filters — still recurse into directories.
765        let modified_dt = meta.as_ref().and_then(|m| {
766            let secs = m
767                .modified()
768                .ok()?
769                .duration_since(std::time::UNIX_EPOCH)
770                .unwrap_or_default()
771                .as_secs();
772            chrono::DateTime::from_timestamp(i64::try_from(secs).unwrap_or(i64::MAX), 0)
773        });
774
775        if let Some(ref newer) = opts.newer_than
776            && modified_dt.is_none_or(|t| t < *newer)
777        {
778            if is_dir {
779                find_dir(&path, opts, depth + 1, results, agentic_ignore)?;
780            }
781            continue;
782        }
783        if let Some(ref older) = opts.older_than
784            && modified_dt.is_none_or(|t| t > *older)
785        {
786            if is_dir {
787                find_dir(&path, opts, depth + 1, results, agentic_ignore)?;
788            }
789            continue;
790        }
791
792        results.push(FindEntry {
793            path: path.display().to_string(),
794            is_dir,
795            is_symlink,
796            size: meta
797                .as_ref()
798                .filter(|_| !is_dir)
799                .map(std::fs::Metadata::len),
800            modified: modified_dt,
801        });
802
803        if is_dir {
804            find_dir(&path, opts, depth + 1, results, agentic_ignore)?;
805        }
806    }
807    Ok(())
808}