1use 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
33pub 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 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 #[cfg(feature = "semantic-search")]
86 fn get_or_init_index(&self) -> Result<&Arc<SemanticIndex>, VfsError> {
87 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 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 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 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
144fn 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(®ex_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 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 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 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 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}