Skip to main content

synwire_core/vfs/
memory.rs

1//! Ephemeral in-memory VFS provider implementation.
2
3use std::collections::BTreeMap;
4use std::sync::Mutex;
5use std::sync::{Arc, RwLock};
6
7use regex::Regex;
8
9use crate::BoxFuture;
10use crate::vfs::error::VfsError;
11use crate::vfs::grep_options::{GrepOptions, GrepOutputMode};
12use crate::vfs::protocol::Vfs;
13use crate::vfs::types::{
14    CpOptions, DiffHunk, DiffLine, DiffOptions, DiffResult, DirEntry, DiskUsage, DiskUsageEntry,
15    DuOptions, EditResult, FileContent, FileInfo, FindEntry, FindOptions, FindType, GlobEntry,
16    GrepMatch, HeadTailOptions, LsOptions, MkdirOptions, ReadRange, RmOptions, SortField,
17    TransferResult, TreeEntry, TreeOptions, VfsCapabilities, WordCount, WriteResult,
18};
19
20/// Ephemeral in-memory VFS provider.
21///
22/// All data lives for the lifetime of the backend instance.
23/// Suitable for agent scratchpads and test fixtures.
24#[derive(Debug, Clone)]
25pub struct MemoryProvider {
26    files: Arc<RwLock<BTreeMap<String, Vec<u8>>>>,
27    cwd: Arc<Mutex<String>>,
28}
29
30impl Default for MemoryProvider {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl MemoryProvider {
37    /// Create a new empty provider with `/` as the working directory.
38    #[must_use]
39    pub fn new() -> Self {
40        Self {
41            files: Arc::new(RwLock::new(BTreeMap::new())),
42            cwd: Arc::new(Mutex::new("/".to_string())),
43        }
44    }
45
46    /// Resolve `path` relative to the current working directory.
47    fn resolve(cwd: &str, path: &str) -> Result<String, VfsError> {
48        let base = if path.starts_with('/') {
49            path.to_string()
50        } else {
51            format!("{}/{}", cwd.trim_end_matches('/'), path)
52        };
53
54        // Normalise (collapse . and ..) and reject traversal.
55        let mut parts: Vec<&str> = Vec::new();
56        for seg in base.split('/') {
57            match seg {
58                "" | "." => {}
59                ".." => {
60                    if parts.is_empty() {
61                        return Err(VfsError::PathTraversal {
62                            attempted: base.clone(),
63                            root: "/".to_string(),
64                        });
65                    }
66                    let _ = parts.pop();
67                }
68                s => parts.push(s),
69            }
70        }
71        let mut out = String::from("/");
72        out.push_str(&parts.join("/"));
73        Ok(out)
74    }
75
76    fn current_cwd(&self) -> Result<String, VfsError> {
77        self.cwd
78            .lock()
79            .map(|g| g.clone())
80            .map_err(|_| VfsError::Unsupported("mutex poisoned".into()))
81    }
82}
83
84impl Vfs for MemoryProvider {
85    fn ls(&self, path: &str, opts: LsOptions) -> BoxFuture<'_, Result<Vec<DirEntry>, VfsError>> {
86        let path = path.to_string();
87        Box::pin(async move {
88            let cwd = self.current_cwd()?;
89            let resolved = Self::resolve(&cwd, &path)?;
90            let prefix = if resolved == "/" {
91                "/".to_string()
92            } else {
93                format!("{}/", resolved.trim_end_matches('/'))
94            };
95
96            let files = self
97                .files
98                .read()
99                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
100
101            let mut seen = std::collections::HashSet::new();
102            let mut entries = Vec::new();
103
104            for key in files.keys() {
105                if !key.starts_with(&prefix) {
106                    continue;
107                }
108                let rest = &key[prefix.len()..];
109                let component = if opts.recursive {
110                    rest
111                } else {
112                    rest.split('/').next().unwrap_or("")
113                };
114                if component.is_empty() {
115                    continue;
116                }
117                // Skip hidden files unless -a.
118                if !opts.all && component.starts_with('.') {
119                    continue;
120                }
121                let is_dir = !opts.recursive && rest.contains('/');
122                let entry_name = component.to_string();
123                let entry_path = format!("{prefix}{entry_name}");
124                if seen.insert(entry_path.clone()) {
125                    let size = if is_dir {
126                        None
127                    } else {
128                        files.get(key).map(|v| v.len() as u64)
129                    };
130                    entries.push(DirEntry {
131                        name: entry_name,
132                        path: entry_path,
133                        is_dir,
134                        size,
135                        modified: None,
136                        permissions: None,
137                        is_symlink: false,
138                    });
139                }
140            }
141            drop(files);
142
143            // Sort.
144            match opts.sort {
145                SortField::Name => entries.sort_by(|a, b| a.name.cmp(&b.name)),
146                SortField::Size => entries.sort_by(|a, b| a.size.cmp(&b.size)),
147                SortField::Time => entries.sort_by(|a, b| a.modified.cmp(&b.modified)),
148                SortField::None => {}
149            }
150            if opts.reverse {
151                entries.reverse();
152            }
153
154            Ok(entries)
155        })
156    }
157
158    fn read(&self, path: &str) -> BoxFuture<'_, Result<FileContent, VfsError>> {
159        let path = path.to_string();
160        Box::pin(async move {
161            let cwd = self.current_cwd()?;
162            let resolved = Self::resolve(&cwd, &path)?;
163            let content = self
164                .files
165                .read()
166                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?
167                .get(&resolved)
168                .cloned()
169                .ok_or_else(|| VfsError::NotFound(resolved.clone()))?;
170            Ok(FileContent {
171                content,
172                mime_type: None,
173            })
174        })
175    }
176
177    fn write(&self, path: &str, content: &[u8]) -> BoxFuture<'_, Result<WriteResult, VfsError>> {
178        let path = path.to_string();
179        let content = content.to_vec();
180        Box::pin(async move {
181            let cwd = self.current_cwd()?;
182            let resolved = Self::resolve(&cwd, &path)?;
183            let bytes_written = content.len() as u64;
184            let _ = self
185                .files
186                .write()
187                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?
188                .insert(resolved.clone(), content);
189            Ok(WriteResult {
190                path: resolved,
191                bytes_written,
192            })
193        })
194    }
195
196    fn edit(
197        &self,
198        path: &str,
199        old: &str,
200        new: &str,
201    ) -> BoxFuture<'_, Result<EditResult, VfsError>> {
202        let path = path.to_string();
203        let old = old.to_string();
204        let new = new.to_string();
205        Box::pin(async move {
206            let cwd = self.current_cwd()?;
207            let resolved = Self::resolve(&cwd, &path)?;
208            let bytes = {
209                let files = self
210                    .files
211                    .read()
212                    .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
213                files
214                    .get(&resolved)
215                    .cloned()
216                    .ok_or_else(|| VfsError::NotFound(resolved.clone()))?
217            };
218            let text = String::from_utf8(bytes)
219                .map_err(|_| VfsError::Unsupported("binary file".into()))?;
220            if !text.contains(&old) {
221                return Ok(EditResult {
222                    path: resolved,
223                    edits_applied: 0,
224                    content_after: Some(text),
225                });
226            }
227            let replaced = text.replacen(&old, &new, 1);
228            let content_after = replaced.clone();
229            let _ = self
230                .files
231                .write()
232                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?
233                .insert(resolved.clone(), replaced.into_bytes());
234            Ok(EditResult {
235                path: resolved,
236                edits_applied: 1,
237                content_after: Some(content_after),
238            })
239        })
240    }
241
242    #[allow(clippy::too_many_lines, clippy::significant_drop_tightening)]
243    fn grep(
244        &self,
245        pattern: &str,
246        opts: GrepOptions,
247    ) -> BoxFuture<'_, Result<Vec<GrepMatch>, VfsError>> {
248        let pattern = pattern.to_string();
249        Box::pin(async move {
250            let regex_pattern = if opts.case_insensitive {
251                format!("(?i){pattern}")
252            } else {
253                pattern
254            };
255            let re = Regex::new(&regex_pattern)
256                .map_err(|e| VfsError::Unsupported(format!("invalid regex: {e}")))?;
257
258            let files = self
259                .files
260                .read()
261                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
262
263            let after = opts.context.unwrap_or(opts.after_context);
264            let before = opts.context.unwrap_or(opts.before_context);
265            let mut matches: Vec<GrepMatch> = Vec::new();
266            let mut total = 0usize;
267
268            // Determine search root.
269            let cwd = self.current_cwd()?;
270            let search_root = match &opts.path {
271                Some(p) => Self::resolve(&cwd, p)?,
272                None => cwd,
273            };
274            let prefix = if search_root == "/" {
275                "/".to_string()
276            } else {
277                format!("{}/", search_root.trim_end_matches('/'))
278            };
279
280            'file_loop: for (file_path, content) in files.iter() {
281                // Restrict to search root.
282                if !file_path.starts_with(&prefix) && file_path != &search_root {
283                    continue;
284                }
285
286                // File type filter.
287                if let Some(ft) = &opts.file_type {
288                    let ext = file_path.rsplit('.').next().unwrap_or("");
289                    if !matches_file_type(ft, ext) {
290                        continue;
291                    }
292                }
293
294                // Glob filter.
295                if let Some(glob) = &opts.glob {
296                    let name = file_path.rsplit('/').next().unwrap_or("");
297                    if !glob_matches(glob, name) {
298                        continue;
299                    }
300                }
301
302                // Skip binary (contains null byte).
303                if content.contains(&0u8) {
304                    continue;
305                }
306
307                let Ok(text) = std::str::from_utf8(content) else {
308                    continue;
309                };
310
311                let lines: Vec<&str> = text.lines().collect();
312                let mut file_match_count = 0usize;
313
314                for (line_idx, &line) in lines.iter().enumerate() {
315                    let is_matched = if opts.invert {
316                        !re.is_match(line)
317                    } else {
318                        re.is_match(line)
319                    };
320
321                    if !is_matched {
322                        continue;
323                    }
324
325                    file_match_count += 1;
326                    total += 1;
327
328                    if opts.output_mode == GrepOutputMode::FilesWithMatches {
329                        matches.push(GrepMatch {
330                            file: file_path.clone(),
331                            line_number: 0,
332                            column: 0,
333                            line_content: String::new(),
334                            before: Vec::new(),
335                            after: Vec::new(),
336                        });
337                        continue 'file_loop;
338                    }
339
340                    if opts.output_mode == GrepOutputMode::Count {
341                        continue;
342                    }
343
344                    let before_lines: Vec<String> = lines
345                        [line_idx.saturating_sub(before as usize)..line_idx]
346                        .iter()
347                        .map(|s| (*s).to_string())
348                        .collect();
349                    let after_end = (line_idx + 1 + after as usize).min(lines.len());
350                    let after_lines: Vec<String> = lines[line_idx + 1..after_end]
351                        .iter()
352                        .map(|s| (*s).to_string())
353                        .collect();
354
355                    let col = if opts.invert {
356                        0
357                    } else {
358                        re.find(line).map_or(0, |m| m.start())
359                    };
360
361                    matches.push(GrepMatch {
362                        file: file_path.clone(),
363                        line_number: if opts.line_numbers { line_idx + 1 } else { 0 },
364                        column: col,
365                        line_content: line.to_string(),
366                        before: before_lines,
367                        after: after_lines,
368                    });
369
370                    if let Some(max) = opts.max_matches
371                        && total >= max
372                    {
373                        break 'file_loop;
374                    }
375                }
376
377                if opts.output_mode == GrepOutputMode::Count && file_match_count > 0 {
378                    matches.push(GrepMatch {
379                        file: file_path.clone(),
380                        line_number: file_match_count,
381                        column: 0,
382                        line_content: file_match_count.to_string(),
383                        before: Vec::new(),
384                        after: Vec::new(),
385                    });
386                }
387            }
388
389            Ok(matches)
390        })
391    }
392
393    fn glob(&self, pattern: &str) -> BoxFuture<'_, Result<Vec<GlobEntry>, VfsError>> {
394        let pattern = pattern.to_string();
395        Box::pin(async move {
396            let entries = {
397                let files = self
398                    .files
399                    .read()
400                    .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
401                files
402                    .keys()
403                    .filter(|p| {
404                        let name = p.rsplit('/').next().unwrap_or("");
405                        glob_matches(&pattern, name)
406                    })
407                    .map(|p| GlobEntry {
408                        path: p.clone(),
409                        is_dir: false,
410                        size: files.get(p).map(|v| v.len() as u64),
411                    })
412                    .collect::<Vec<_>>()
413            };
414            Ok(entries)
415        })
416    }
417
418    fn upload(&self, _from: &str, _to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
419        Box::pin(async {
420            Err(VfsError::Unsupported(
421                "upload not supported on MemoryProvider".into(),
422            ))
423        })
424    }
425
426    fn download(&self, _from: &str, _to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
427        Box::pin(async {
428            Err(VfsError::Unsupported(
429                "download not supported on MemoryProvider".into(),
430            ))
431        })
432    }
433
434    fn pwd(&self) -> BoxFuture<'_, Result<String, VfsError>> {
435        Box::pin(async move { self.current_cwd() })
436    }
437
438    fn cd(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
439        let path = path.to_string();
440        Box::pin(async move {
441            let cwd = self.current_cwd()?;
442            let resolved = Self::resolve(&cwd, &path)?;
443
444            // Reject `..` that escapes root.
445            if resolved != "/" {
446                let prefix = format!("{}/", resolved.trim_end_matches('/'));
447                // Allow cd to "/" always; for other paths check that at least
448                // one entry exists under that prefix OR the path itself exists.
449                let exists = self
450                    .files
451                    .read()
452                    .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?
453                    .keys()
454                    .any(|k| k.starts_with(&prefix) || k == &resolved);
455                if !exists {
456                    return Err(VfsError::NotFound(resolved));
457                }
458            }
459
460            *self
461                .cwd
462                .lock()
463                .map_err(|_| VfsError::Unsupported("mutex poisoned".into()))? = resolved;
464            Ok(())
465        })
466    }
467
468    fn head(&self, path: &str, opts: HeadTailOptions) -> BoxFuture<'_, Result<String, VfsError>> {
469        let path = path.to_string();
470        Box::pin(async move {
471            let content = self.read(&path).await?;
472            let text = String::from_utf8(content.content)
473                .map_err(|_| VfsError::Unsupported("binary file".into()))?;
474            if let Some(n) = opts.bytes {
475                return Ok(text.chars().take(n).collect());
476            }
477            let n = opts.lines.unwrap_or(10);
478            let result: String = text.lines().take(n).collect::<Vec<_>>().join("\n");
479            Ok(result)
480        })
481    }
482
483    fn tail(&self, path: &str, opts: HeadTailOptions) -> BoxFuture<'_, Result<String, VfsError>> {
484        let path = path.to_string();
485        Box::pin(async move {
486            let content = self.read(&path).await?;
487            let text = String::from_utf8(content.content)
488                .map_err(|_| VfsError::Unsupported("binary file".into()))?;
489            if let Some(n) = opts.bytes {
490                let start = text.len().saturating_sub(n);
491                return Ok(text[start..].to_string());
492            }
493            let n = opts.lines.unwrap_or(10);
494            let lines: Vec<&str> = text.lines().collect();
495            let start = lines.len().saturating_sub(n);
496            Ok(lines[start..].join("\n"))
497        })
498    }
499
500    fn read_range(&self, path: &str, range: ReadRange) -> BoxFuture<'_, Result<String, VfsError>> {
501        let path = path.to_string();
502        Box::pin(async move {
503            let content = self.read(&path).await?;
504            let text = String::from_utf8(content.content)
505                .map_err(|_| VfsError::Unsupported("binary file".into()))?;
506
507            // Byte range takes precedence over line range.
508            if range.byte_start.is_some() || range.byte_end.is_some() {
509                let start = range.byte_start.unwrap_or(0).min(text.len());
510                let end = range.byte_end.unwrap_or(text.len()).min(text.len());
511                let start = start.min(end);
512                return Ok(text[start..end].to_string());
513            }
514
515            // Line range (1-indexed inclusive).
516            if range.line_start.is_some() || range.line_end.is_some() {
517                let lines: Vec<&str> = text.lines().collect();
518                let start = range
519                    .line_start
520                    .unwrap_or(1)
521                    .saturating_sub(1)
522                    .min(lines.len());
523                let end = range.line_end.unwrap_or(lines.len()).min(lines.len());
524                let end = end.max(start);
525                return Ok(lines[start..end].join("\n"));
526            }
527
528            // No range constraints — return full content.
529            Ok(text)
530        })
531    }
532
533    fn stat(&self, path: &str) -> BoxFuture<'_, Result<FileInfo, VfsError>> {
534        let path = path.to_string();
535        Box::pin(async move {
536            let cwd = self.current_cwd()?;
537            let resolved = Self::resolve(&cwd, &path)?;
538            let files = self
539                .files
540                .read()
541                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
542            if let Some(content) = files.get(&resolved) {
543                return Ok(FileInfo {
544                    path: resolved,
545                    size: content.len() as u64,
546                    is_dir: false,
547                    is_symlink: false,
548                    modified: None,
549                    permissions: None,
550                });
551            }
552            // Check if it's a directory (prefix of some key).
553            let prefix = format!("{}/", resolved.trim_end_matches('/'));
554            let is_dir = resolved == "/" || files.keys().any(|k| k.starts_with(&prefix));
555            drop(files);
556            if is_dir {
557                return Ok(FileInfo {
558                    path: resolved,
559                    size: 0,
560                    is_dir: true,
561                    is_symlink: false,
562                    modified: None,
563                    permissions: None,
564                });
565            }
566            Err(VfsError::NotFound(resolved))
567        })
568    }
569
570    fn wc(&self, path: &str) -> BoxFuture<'_, Result<WordCount, VfsError>> {
571        let path = path.to_string();
572        Box::pin(async move {
573            let content = self.read(&path).await?;
574            let bytes = content.content.len();
575            let text = String::from_utf8(content.content)
576                .map_err(|_| VfsError::Unsupported("binary file".into()))?;
577            let lines = text.lines().count();
578            let words = text.split_whitespace().count();
579            let chars = text.chars().count();
580            let cwd = self.current_cwd()?;
581            let resolved = Self::resolve(&cwd, &path)?;
582            Ok(WordCount {
583                path: resolved,
584                lines,
585                words,
586                bytes,
587                chars,
588            })
589        })
590    }
591
592    fn du(&self, path: &str, opts: DuOptions) -> BoxFuture<'_, Result<DiskUsage, VfsError>> {
593        let path = path.to_string();
594        Box::pin(async move {
595            let cwd = self.current_cwd()?;
596            let resolved = Self::resolve(&cwd, &path)?;
597            let prefix = if resolved == "/" {
598                "/".to_string()
599            } else {
600                format!("{}/", resolved.trim_end_matches('/'))
601            };
602            let files = self
603                .files
604                .read()
605                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
606
607            let mut total_bytes = 0u64;
608            let mut entries = Vec::new();
609            for (k, v) in files.iter() {
610                if !k.starts_with(&prefix) && k != &resolved {
611                    continue;
612                }
613                let size = v.len() as u64;
614                total_bytes += size;
615                if !opts.summary {
616                    let depth = k[prefix.len()..].matches('/').count();
617                    if opts.max_depth.is_none() || depth <= opts.max_depth.unwrap_or(0) {
618                        entries.push(DiskUsageEntry {
619                            path: k.clone(),
620                            bytes: size,
621                            is_dir: false,
622                        });
623                    }
624                }
625            }
626            drop(files);
627            Ok(DiskUsage {
628                path: resolved,
629                total_bytes,
630                entries,
631            })
632        })
633    }
634
635    fn append(&self, path: &str, content: &[u8]) -> BoxFuture<'_, Result<WriteResult, VfsError>> {
636        let path = path.to_string();
637        let content = content.to_vec();
638        Box::pin(async move {
639            let cwd = self.current_cwd()?;
640            let resolved = Self::resolve(&cwd, &path)?;
641            let mut files = self
642                .files
643                .write()
644                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
645            let entry = files.entry(resolved.clone()).or_default();
646            entry.extend_from_slice(&content);
647            drop(files);
648            let bytes_written = content.len() as u64;
649            Ok(WriteResult {
650                path: resolved,
651                bytes_written,
652            })
653        })
654    }
655
656    fn mkdir(&self, _path: &str, _opts: MkdirOptions) -> BoxFuture<'_, Result<(), VfsError>> {
657        // In-memory provider: directories are implicit (exist when files exist under them).
658        // mkdir is a no-op that always succeeds.
659        Box::pin(async { Ok(()) })
660    }
661
662    fn touch(&self, path: &str) -> BoxFuture<'_, Result<(), VfsError>> {
663        let path = path.to_string();
664        Box::pin(async move {
665            let cwd = self.current_cwd()?;
666            let resolved = Self::resolve(&cwd, &path)?;
667            let mut files = self
668                .files
669                .write()
670                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
671            let _ = files.entry(resolved).or_insert_with(Vec::new);
672            drop(files);
673            Ok(())
674        })
675    }
676
677    fn diff(
678        &self,
679        a: &str,
680        b: &str,
681        opts: DiffOptions,
682    ) -> BoxFuture<'_, Result<DiffResult, VfsError>> {
683        let a = a.to_string();
684        let b = b.to_string();
685        Box::pin(async move {
686            let ca = self.read(&a).await?;
687            let cb = self.read(&b).await?;
688            let ta = String::from_utf8(ca.content)
689                .map_err(|_| VfsError::Unsupported("binary file".into()))?;
690            let tb = String::from_utf8(cb.content)
691                .map_err(|_| VfsError::Unsupported("binary file".into()))?;
692            if ta == tb {
693                return Ok(DiffResult {
694                    equal: true,
695                    hunks: Vec::new(),
696                });
697            }
698            // Simple line-by-line diff.
699            let la: Vec<&str> = ta.lines().collect();
700            let lb: Vec<&str> = tb.lines().collect();
701            let ctx = opts.context_lines as usize;
702            let mut hunks = Vec::new();
703            let mut i = 0;
704            let mut j = 0;
705            while i < la.len() || j < lb.len() {
706                if i < la.len() && j < lb.len() && la[i] == lb[j] {
707                    i += 1;
708                    j += 1;
709                    continue;
710                }
711                // Found a difference — build a hunk.
712                let hunk_start_i = i.saturating_sub(ctx);
713                let hunk_start_j = j.saturating_sub(ctx);
714                let mut lines = Vec::new();
715                // Before context.
716                for line in la.iter().take(i).skip(hunk_start_i) {
717                    lines.push(DiffLine::Context((*line).to_string()));
718                }
719                // Changed lines.
720                while i < la.len()
721                    && (j >= lb.len() || (i < la.len() && j < lb.len() && la[i] != lb[j]))
722                {
723                    lines.push(DiffLine::Removed(la[i].to_string()));
724                    i += 1;
725                }
726                while j < lb.len()
727                    && (i >= la.len() || (i < la.len() && j < lb.len() && la.get(i) != lb.get(j)))
728                {
729                    lines.push(DiffLine::Added(lb[j].to_string()));
730                    j += 1;
731                }
732                // After context.
733                let after_end_i = (i + ctx).min(la.len());
734                let after_end_j = (j + ctx).min(lb.len());
735                let after_count = after_end_i
736                    .saturating_sub(i)
737                    .min(after_end_j.saturating_sub(j));
738                for k in 0..after_count {
739                    if i + k < la.len() {
740                        lines.push(DiffLine::Context(la[i + k].to_string()));
741                    }
742                }
743                i += after_count;
744                j += after_count;
745                hunks.push(DiffHunk {
746                    old_start: hunk_start_i + 1,
747                    old_count: i - hunk_start_i,
748                    new_start: hunk_start_j + 1,
749                    new_count: j - hunk_start_j,
750                    lines,
751                });
752            }
753            Ok(DiffResult {
754                equal: false,
755                hunks,
756            })
757        })
758    }
759
760    fn rm(&self, path: &str, opts: RmOptions) -> BoxFuture<'_, Result<(), VfsError>> {
761        let path = path.to_string();
762        Box::pin(async move {
763            let cwd = self.current_cwd()?;
764            let resolved = Self::resolve(&cwd, &path)?;
765            let mut files = self
766                .files
767                .write()
768                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
769
770            if opts.recursive {
771                let prefix = format!("{}/", resolved.trim_end_matches('/'));
772                let keys: Vec<String> = files
773                    .keys()
774                    .filter(|k| k.starts_with(&prefix) || *k == &resolved)
775                    .cloned()
776                    .collect();
777                if keys.is_empty() && !opts.force {
778                    return Err(VfsError::NotFound(resolved));
779                }
780                for k in keys {
781                    let _ = files.remove(&k);
782                }
783                drop(files);
784            } else if files.remove(&resolved).is_none() && !opts.force {
785                return Err(VfsError::NotFound(resolved));
786            }
787            Ok(())
788        })
789    }
790
791    fn cp(
792        &self,
793        from: &str,
794        to: &str,
795        opts: CpOptions,
796    ) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
797        let from = from.to_string();
798        let to = to.to_string();
799        Box::pin(async move {
800            let cwd = self.current_cwd()?;
801            let src = Self::resolve(&cwd, &from)?;
802            let dst = Self::resolve(&cwd, &to)?;
803
804            if opts.recursive {
805                let prefix = format!("{}/", src.trim_end_matches('/'));
806                let files = self
807                    .files
808                    .read()
809                    .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
810                let mut copies: Vec<(String, Vec<u8>)> = Vec::new();
811                let mut total = 0u64;
812                for (k, v) in files.iter() {
813                    if k == &src || k.starts_with(&prefix) {
814                        let rel = k.strip_prefix(src.trim_end_matches('/')).unwrap_or(k);
815                        let new_path = format!("{}{}", dst.trim_end_matches('/'), rel);
816                        total += v.len() as u64;
817                        copies.push((new_path, v.clone()));
818                    }
819                }
820                drop(files);
821                let mut files = self
822                    .files
823                    .write()
824                    .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
825                for (path, content) in copies {
826                    if opts.no_overwrite && files.contains_key(&path) {
827                        continue;
828                    }
829                    let _ = files.insert(path, content);
830                }
831                drop(files);
832                return Ok(TransferResult {
833                    path: dst,
834                    bytes_transferred: total,
835                });
836            }
837
838            let files = self
839                .files
840                .read()
841                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
842            let content = files
843                .get(&src)
844                .cloned()
845                .ok_or_else(|| VfsError::NotFound(src.clone()))?;
846            drop(files);
847
848            let bytes_transferred = content.len() as u64;
849            let mut files = self
850                .files
851                .write()
852                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
853            if opts.no_overwrite && files.contains_key(&dst) {
854                return Ok(TransferResult {
855                    path: dst,
856                    bytes_transferred: 0,
857                });
858            }
859            let _ = files.insert(dst.clone(), content);
860            drop(files);
861            Ok(TransferResult {
862                path: dst,
863                bytes_transferred,
864            })
865        })
866    }
867
868    fn find(
869        &self,
870        path: &str,
871        opts: FindOptions,
872    ) -> BoxFuture<'_, Result<Vec<FindEntry>, VfsError>> {
873        let path = path.to_string();
874        Box::pin(async move {
875            let cwd = self.current_cwd()?;
876            let resolved = Self::resolve(&cwd, &path)?;
877            let prefix = if resolved == "/" {
878                "/".to_string()
879            } else {
880                format!("{}/", resolved.trim_end_matches('/'))
881            };
882            let files = self
883                .files
884                .read()
885                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
886
887            let mut results = Vec::new();
888            let mut seen_dirs = std::collections::HashSet::new();
889
890            for (k, v) in files.iter() {
891                if !k.starts_with(&prefix) && k != &resolved {
892                    continue;
893                }
894                let rel = &k[prefix.len()..];
895                let depth = rel.matches('/').count();
896                if let Some(max) = opts.max_depth
897                    && depth > max
898                {
899                    continue;
900                }
901                // Collect intermediate directories.
902                let parts: Vec<&str> = rel.split('/').collect();
903                for i in 0..parts.len().saturating_sub(1) {
904                    let dir_path = format!("{}{}", prefix, parts[..=i].join("/"));
905                    if seen_dirs.insert(dir_path.clone()) {
906                        let dir_name = parts[i];
907                        let dir_depth = i;
908                        if let Some(max) = opts.max_depth
909                            && dir_depth > max
910                        {
911                            continue;
912                        }
913                        if let Some(ref ft) = opts.entry_type
914                            && *ft != FindType::Directory
915                        {
916                            continue;
917                        }
918                        if let Some(ref name) = opts.name
919                            && !glob_matches(name, dir_name)
920                        {
921                            continue;
922                        }
923                        results.push(FindEntry {
924                            path: dir_path,
925                            is_dir: true,
926                            is_symlink: false,
927                            size: None,
928                            modified: None,
929                        });
930                    }
931                }
932                // The file itself.
933                let name = k.rsplit('/').next().unwrap_or("");
934                if let Some(ref ft) = opts.entry_type
935                    && *ft != FindType::File
936                {
937                    continue;
938                }
939                if let Some(ref pat) = opts.name
940                    && !glob_matches(pat, name)
941                {
942                    continue;
943                }
944                let size = v.len() as u64;
945                if let Some(min) = opts.min_size
946                    && size < min
947                {
948                    continue;
949                }
950                if let Some(max) = opts.max_size
951                    && size > max
952                {
953                    continue;
954                }
955                results.push(FindEntry {
956                    path: k.clone(),
957                    is_dir: false,
958                    is_symlink: false,
959                    size: Some(size),
960                    modified: None,
961                });
962            }
963            drop(files);
964            Ok(results)
965        })
966    }
967
968    fn tree(&self, path: &str, opts: TreeOptions) -> BoxFuture<'_, Result<TreeEntry, VfsError>> {
969        let path = path.to_string();
970        Box::pin(async move {
971            let cwd = self.current_cwd()?;
972            let resolved = Self::resolve(&cwd, &path)?;
973            let files = self
974                .files
975                .read()
976                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
977            let root_name = resolved.rsplit('/').next().unwrap_or("/").to_string();
978            let tree = build_tree(&resolved, &root_name, &files, &opts, 0);
979            drop(files);
980            Ok(tree)
981        })
982    }
983
984    fn mv_file(&self, from: &str, to: &str) -> BoxFuture<'_, Result<TransferResult, VfsError>> {
985        let from = from.to_string();
986        let to = to.to_string();
987        Box::pin(async move {
988            let cwd = self.current_cwd()?;
989            let src = Self::resolve(&cwd, &from)?;
990            let dst = Self::resolve(&cwd, &to)?;
991            let mut files = self
992                .files
993                .write()
994                .map_err(|_| VfsError::Unsupported("rwlock poisoned".into()))?;
995            let content = files
996                .remove(&src)
997                .ok_or_else(|| VfsError::NotFound(src.clone()))?;
998            let bytes_transferred = content.len() as u64;
999            let _ = files.insert(dst.clone(), content);
1000            drop(files);
1001            Ok(TransferResult {
1002                path: dst,
1003                bytes_transferred,
1004            })
1005        })
1006    }
1007
1008    fn capabilities(&self) -> VfsCapabilities {
1009        VfsCapabilities::LS
1010            | VfsCapabilities::READ
1011            | VfsCapabilities::HEAD
1012            | VfsCapabilities::TAIL
1013            | VfsCapabilities::STAT
1014            | VfsCapabilities::WC
1015            | VfsCapabilities::DU
1016            | VfsCapabilities::WRITE
1017            | VfsCapabilities::APPEND
1018            | VfsCapabilities::MKDIR
1019            | VfsCapabilities::TOUCH
1020            | VfsCapabilities::EDIT
1021            | VfsCapabilities::DIFF
1022            | VfsCapabilities::GREP
1023            | VfsCapabilities::GLOB
1024            | VfsCapabilities::FIND
1025            | VfsCapabilities::TREE
1026            | VfsCapabilities::PWD
1027            | VfsCapabilities::CD
1028            | VfsCapabilities::RM
1029            | VfsCapabilities::CP
1030            | VfsCapabilities::MV
1031    }
1032
1033    fn provider_name(&self) -> &'static str {
1034        "MemoryProvider"
1035    }
1036}
1037
1038/// Build a recursive tree from the in-memory file map.
1039fn build_tree(
1040    dir_path: &str,
1041    name: &str,
1042    files: &BTreeMap<String, Vec<u8>>,
1043    opts: &TreeOptions,
1044    depth: usize,
1045) -> TreeEntry {
1046    let prefix = if dir_path == "/" {
1047        "/".to_string()
1048    } else {
1049        format!("{}/", dir_path.trim_end_matches('/'))
1050    };
1051
1052    let mut children_map: BTreeMap<String, Option<u64>> = BTreeMap::new();
1053    let mut subdirs: std::collections::HashSet<String> = std::collections::HashSet::new();
1054
1055    for (k, v) in files {
1056        if !k.starts_with(&prefix) {
1057            continue;
1058        }
1059        let rest = &k[prefix.len()..];
1060        let component = rest.split('/').next().unwrap_or("");
1061        if component.is_empty() {
1062            continue;
1063        }
1064        if !opts.all && component.starts_with('.') {
1065            continue;
1066        }
1067        if rest.contains('/') {
1068            let _ = subdirs.insert(component.to_string());
1069        } else {
1070            let _ = children_map.insert(component.to_string(), Some(v.len() as u64));
1071        }
1072    }
1073
1074    let at_depth_limit = opts.max_depth.is_some_and(|max| depth >= max);
1075    let mut children = Vec::new();
1076
1077    for dir_name in &subdirs {
1078        let child_path = format!("{prefix}{dir_name}");
1079        if at_depth_limit {
1080            children.push(TreeEntry {
1081                name: dir_name.clone(),
1082                path: child_path,
1083                is_dir: true,
1084                size: None,
1085                children: Vec::new(),
1086            });
1087        } else {
1088            children.push(build_tree(&child_path, dir_name, files, opts, depth + 1));
1089        }
1090    }
1091
1092    if !opts.dirs_only {
1093        for (file_name, size) in &children_map {
1094            if !subdirs.contains(file_name) {
1095                children.push(TreeEntry {
1096                    name: file_name.clone(),
1097                    path: format!("{prefix}{file_name}"),
1098                    is_dir: false,
1099                    size: *size,
1100                    children: Vec::new(),
1101                });
1102            }
1103        }
1104    }
1105
1106    TreeEntry {
1107        name: name.to_string(),
1108        path: dir_path.to_string(),
1109        is_dir: true,
1110        size: None,
1111        children,
1112    }
1113}
1114
1115/// Simple glob matching: `*` matches any sequence of non-separator chars,
1116/// `**` matches anything.
1117///
1118/// Exported for use by VFS providers in synwire-agent.
1119pub fn glob_matches_pub(pattern: &str, name: &str) -> bool {
1120    glob_matches(pattern, name)
1121}
1122
1123fn glob_matches(pattern: &str, name: &str) -> bool {
1124    if pattern == "**" || pattern == "*" {
1125        return true;
1126    }
1127    // Build regex from glob.
1128    let mut regex = String::from("^");
1129    for ch in pattern.chars() {
1130        match ch {
1131            '*' => regex.push_str("[^/]*"),
1132            '?' => regex.push_str("[^/]"),
1133            '.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\' => {
1134                regex.push('\\');
1135                regex.push(ch);
1136            }
1137            c => regex.push(c),
1138        }
1139    }
1140    regex.push('$');
1141    Regex::new(&regex).is_ok_and(|re| re.is_match(name))
1142}
1143
1144/// Map ripgrep-style file type names to extensions.
1145///
1146/// Exported for use by VFS providers in synwire-agent.
1147pub fn matches_file_type_pub(file_type: &str, ext: &str) -> bool {
1148    matches_file_type(file_type, ext)
1149}
1150
1151fn matches_file_type(file_type: &str, ext: &str) -> bool {
1152    match file_type {
1153        "rust" | "rs" => ext == "rs",
1154        "python" | "py" => ext == "py",
1155        "js" | "javascript" => ext == "js" || ext == "mjs" || ext == "cjs",
1156        "ts" | "typescript" => ext == "ts" || ext == "tsx",
1157        "json" => ext == "json",
1158        "yaml" | "yml" => ext == "yaml" || ext == "yml",
1159        "toml" => ext == "toml",
1160        "md" | "markdown" => ext == "md" || ext == "markdown",
1161        "go" => ext == "go",
1162        "sh" | "bash" => ext == "sh" || ext == "bash",
1163        _ => file_type == ext,
1164    }
1165}
1166
1167#[cfg(test)]
1168#[allow(
1169    clippy::unwrap_used,
1170    clippy::expect_used,
1171    clippy::panic,
1172    clippy::case_sensitive_file_extension_comparisons
1173)]
1174mod tests {
1175    use super::*;
1176
1177    fn backend_with_files(files: &[(&str, &str)]) -> MemoryProvider {
1178        let backend = MemoryProvider::new();
1179        for (path, content) in files {
1180            let mut store = backend.files.write().expect("lock");
1181            let _ = store.insert(path.to_string(), content.as_bytes().to_vec());
1182        }
1183        backend
1184    }
1185
1186    // ── grep tests ──────────────────────────────────────────────────────────
1187
1188    #[tokio::test]
1189    async fn test_grep_with_context() {
1190        let backend = backend_with_files(&[("/file.txt", "line1\nline2\nMATCH\nline4\nline5")]);
1191        let opts = GrepOptions {
1192            context: Some(3),
1193            line_numbers: true,
1194            ..Default::default()
1195        };
1196        let results = backend.grep("MATCH", opts).await.expect("grep");
1197        assert!(!results.is_empty());
1198        let m = &results[0];
1199        // Before context: up to 3 lines before MATCH.
1200        assert!(m.before.len() <= 3);
1201        assert!(m.after.len() <= 3);
1202    }
1203
1204    #[tokio::test]
1205    async fn test_grep_case_insensitive() {
1206        let backend = backend_with_files(&[("/f.txt", "Hello World")]);
1207        let opts = GrepOptions {
1208            case_insensitive: true,
1209            ..Default::default()
1210        };
1211        let results = backend.grep("hello", opts).await.expect("grep");
1212        assert!(!results.is_empty());
1213    }
1214
1215    #[tokio::test]
1216    async fn test_grep_file_type_filter() {
1217        let backend = backend_with_files(&[
1218            ("/src/main.rs", "fn main() {}"),
1219            ("/src/main.py", "def main(): pass"),
1220        ]);
1221        let opts = GrepOptions {
1222            file_type: Some("rust".into()),
1223            ..Default::default()
1224        };
1225        let results = backend.grep("main", opts).await.expect("grep");
1226        assert!(results.iter().all(|m| m.file.ends_with(".rs")));
1227    }
1228
1229    #[tokio::test]
1230    async fn test_grep_invert_match() {
1231        let backend = backend_with_files(&[("/f.txt", "apple\nbanana\ncherry")]);
1232        let opts = GrepOptions {
1233            invert: true,
1234            ..Default::default()
1235        };
1236        let results = backend.grep("banana", opts).await.expect("grep");
1237        for m in &results {
1238            assert!(!m.line_content.contains("banana"));
1239        }
1240    }
1241
1242    #[tokio::test]
1243    async fn test_grep_count_mode() {
1244        let backend = backend_with_files(&[("/f.txt", "foo\nfoo\nbar")]);
1245        let opts = GrepOptions {
1246            output_mode: GrepOutputMode::Count,
1247            ..Default::default()
1248        };
1249        let results = backend.grep("foo", opts).await.expect("grep");
1250        // line_number field holds the count.
1251        assert_eq!(results[0].line_number, 2);
1252    }
1253
1254    #[tokio::test]
1255    async fn test_grep_max_matches() {
1256        let backend = backend_with_files(&[("/f.txt", "a\na\na\na\na")]);
1257        let opts = GrepOptions {
1258            max_matches: Some(2),
1259            ..Default::default()
1260        };
1261        let results = backend.grep("a", opts).await.expect("grep");
1262        assert!(results.len() <= 2);
1263    }
1264
1265    #[tokio::test]
1266    async fn test_grep_skips_binary_files() {
1267        let backend = MemoryProvider::new();
1268        {
1269            let mut store = backend.files.write().expect("lock");
1270            let _ = store.insert("/bin.dat".to_string(), vec![0u8, 1, 2, 3]);
1271        }
1272        let results = backend
1273            .grep(".", GrepOptions::default())
1274            .await
1275            .expect("grep");
1276        assert!(results.iter().all(|m| m.file != "/bin.dat"));
1277    }
1278
1279    #[tokio::test]
1280    async fn test_grep_line_numbers() {
1281        let backend = backend_with_files(&[("/f.txt", "a\nb\nMATCH\nd")]);
1282        let opts = GrepOptions {
1283            line_numbers: true,
1284            ..Default::default()
1285        };
1286        let results = backend.grep("MATCH", opts).await.expect("grep");
1287        assert_eq!(results[0].line_number, 3);
1288    }
1289
1290    // ── cd / pwd tests ──────────────────────────────────────────────────────
1291
1292    #[tokio::test]
1293    async fn test_cd_pwd_roundtrip() {
1294        let backend = backend_with_files(&[("/home/user/file.txt", "hi")]);
1295        backend.cd("/home/user").await.expect("cd");
1296        let cwd = backend.pwd().await.expect("pwd");
1297        assert_eq!(cwd, "/home/user");
1298    }
1299
1300    #[tokio::test]
1301    async fn test_relative_path_resolution() {
1302        let backend = backend_with_files(&[("/a/b/c.txt", "data")]);
1303        backend.cd("/a").await.expect("cd");
1304        let content = backend.read("b/c.txt").await.expect("read relative");
1305        assert_eq!(content.content, b"data");
1306    }
1307
1308    #[tokio::test]
1309    async fn test_cd_to_nonexistent_fails_without_state_change() {
1310        let backend = MemoryProvider::new();
1311        let err = backend.cd("/nonexistent").await.expect_err("should fail");
1312        assert!(matches!(err, VfsError::NotFound(_)));
1313        let cwd = backend.pwd().await.expect("pwd");
1314        assert_eq!(cwd, "/"); // unchanged
1315    }
1316
1317    #[tokio::test]
1318    async fn test_cd_parent_traversal_rejected() {
1319        let backend = MemoryProvider::new();
1320        let err = backend.cd("/../etc").await.expect_err("traversal");
1321        assert!(matches!(err, VfsError::PathTraversal { .. }));
1322    }
1323}