1use 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#[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 #[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 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 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 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 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(®ex_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 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 if !file_path.starts_with(&prefix) && file_path != &search_root {
283 continue;
284 }
285
286 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 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 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 if resolved != "/" {
446 let prefix = format!("{}/", resolved.trim_end_matches('/'));
447 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 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 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 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 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 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 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 let hunk_start_i = i.saturating_sub(ctx);
713 let hunk_start_j = j.saturating_sub(ctx);
714 let mut lines = Vec::new();
715 for line in la.iter().take(i).skip(hunk_start_i) {
717 lines.push(DiffLine::Context((*line).to_string()));
718 }
719 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 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 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 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
1038fn 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
1115pub 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 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(®ex).is_ok_and(|re| re.is_match(name))
1142}
1143
1144pub 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 #[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 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 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 #[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, "/"); }
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}