1use async_trait::async_trait;
24use std::sync::Arc;
25
26use crate::error::{AgentLoopError, Result};
27use crate::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
28use crate::traits::SessionFileSystem;
29use crate::typed_id::SessionId;
30
31pub const WORKSPACE_MOUNT: &str = crate::session_path::WORKSPACE_PREFIX;
36
37#[derive(Clone)]
39struct Mount {
40 mount_point: String,
43 backend: Arc<dyn SessionFileSystem>,
45 backend_root: String,
47 primary_workspace: bool,
52}
53
54#[derive(Clone)]
55struct ResolvedMount {
56 mount_point: String,
57 backend: Arc<dyn SessionFileSystem>,
58 backend_root: String,
59 backend_path: String,
60 primary_workspace: bool,
61}
62
63pub struct MountFs {
66 mounts: Vec<Mount>,
69 primary: Arc<dyn SessionFileSystem>,
71 cwd: String,
75}
76
77impl MountFs {
78 pub fn new(workspace: Arc<dyn SessionFileSystem>) -> Self {
84 let mounts = vec![
85 Mount {
86 mount_point: "/".to_string(),
87 backend: workspace.clone(),
88 backend_root: "/".to_string(),
89 primary_workspace: true,
90 },
91 Mount {
92 mount_point: WORKSPACE_MOUNT.to_string(),
93 backend: workspace.clone(),
94 backend_root: "/".to_string(),
95 primary_workspace: true,
96 },
97 ];
98 let mut fs = Self {
99 mounts,
100 primary: workspace,
101 cwd: WORKSPACE_MOUNT.to_string(),
102 };
103 fs.sort_mounts();
104 fs
105 }
106
107 pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
109 Arc::new(Self::new(workspace))
110 }
111
112 pub fn wrap_if_needed(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
117 if workspace.is_mount_resolver() {
118 workspace
119 } else {
120 Self::wrap(workspace)
121 }
122 }
123
124 pub fn with_mount(
127 mut self,
128 mount_point: impl Into<String>,
129 backend: Arc<dyn SessionFileSystem>,
130 backend_root: impl Into<String>,
131 ) -> Self {
132 self.mounts.push(Mount {
133 mount_point: normalize_virtual(&mount_point.into(), "/"),
134 backend,
135 backend_root: normalize_virtual(&backend_root.into(), "/"),
136 primary_workspace: false,
137 });
138 self.sort_mounts();
139 self
140 }
141
142 pub fn cwd(&self) -> String {
144 self.cwd.clone()
145 }
146
147 fn sort_mounts(&mut self) {
148 self.mounts
150 .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
151 }
152
153 fn resolve(&self, input: &str) -> Result<ResolvedMount> {
159 reject_additional_root_traversal(input, &self.cwd)?;
160 let virtual_path = normalize_virtual(input, &self.cwd());
161 for mount in &self.mounts {
162 if let Some(rest) = mount_suffix(&mount.mount_point, &virtual_path) {
163 return Ok(ResolvedMount {
164 mount_point: mount.mount_point.clone(),
165 backend: mount.backend.clone(),
166 backend_root: mount.backend_root.clone(),
167 backend_path: join_backend_path(&mount.backend_root, &rest),
168 primary_workspace: mount.primary_workspace,
169 });
170 }
171 }
172 Ok(ResolvedMount {
175 mount_point: "/".to_string(),
176 backend: self.primary.clone(),
177 backend_root: "/".to_string(),
178 backend_path: virtual_path,
179 primary_workspace: true,
180 })
181 }
182
183 fn grep_mounts(&self) -> Vec<ResolvedMount> {
184 let mut out: Vec<ResolvedMount> = Vec::new();
185 for mount in self.mounts.iter().rev() {
186 if out.iter().any(|existing| {
187 Arc::ptr_eq(&existing.backend, &mount.backend)
188 && existing.backend_root == mount.backend_root
189 }) {
190 continue;
191 }
192 out.push(ResolvedMount {
193 mount_point: mount.mount_point.clone(),
194 backend: mount.backend.clone(),
195 backend_root: mount.backend_root.clone(),
196 backend_path: mount.backend_root.clone(),
197 primary_workspace: mount.primary_workspace,
198 });
199 }
200 out
201 }
202}
203
204impl ResolvedMount {
205 fn map_session_file(&self, mut file: SessionFile) -> SessionFile {
206 file.path = self.to_virtual_output_path(&file.path);
207 file.name = FileInfo::name_from_path(&file.path);
208 file
209 }
210
211 fn map_file_info(&self, mut info: FileInfo) -> FileInfo {
212 info.path = self.to_virtual_output_path(&info.path);
213 info.name = FileInfo::name_from_path(&info.path);
214 info
215 }
216
217 fn map_file_stat(&self, mut stat: FileStat) -> FileStat {
218 stat.path = self.to_virtual_output_path(&stat.path);
219 stat.name = FileInfo::name_from_path(&stat.path);
220 stat
221 }
222
223 fn map_grep_match(&self, mut grep_match: GrepMatch) -> GrepMatch {
224 grep_match.path = self.to_virtual_output_path(&grep_match.path);
225 grep_match
226 }
227
228 fn to_virtual_output_path(&self, backend_path: &str) -> String {
229 if self.primary_workspace {
230 return normalize_virtual(backend_path, "/");
231 }
232 let normalized = normalize_virtual(backend_path, "/");
233 let rest = mount_suffix(&self.backend_root, &normalized).unwrap_or(normalized);
234 join_backend_path(&self.mount_point, &rest)
235 }
236}
237
238fn normalize_virtual(input: &str, cwd: &str) -> String {
241 let combined = if input.starts_with('/') {
242 input.to_string()
243 } else {
244 format!("{}/{}", cwd.trim_end_matches('/'), input)
245 };
246 let mut stack: Vec<&str> = Vec::new();
247 for segment in combined.split('/') {
248 match segment {
249 "" | "." => {}
250 ".." => {
251 stack.pop();
252 }
253 other => stack.push(other),
254 }
255 }
256 if stack.is_empty() {
257 "/".to_string()
258 } else {
259 format!("/{}", stack.join("/"))
260 }
261}
262
263fn reject_additional_root_traversal(input: &str, cwd: &str) -> Result<()> {
264 let combined = if input.starts_with('/') {
265 input.to_string()
266 } else {
267 format!("{}/{}", cwd.trim_end_matches('/'), input)
268 };
269 let segments: Vec<&str> = combined
270 .split('/')
271 .filter(|segment| !segment.is_empty())
272 .collect();
273 for window_start in 0..segments.len().saturating_sub(2) {
274 if segments[window_start] == "workspace" && segments[window_start + 1] == "roots" {
275 let root_name_idx = window_start + 2;
276 if segments[root_name_idx].is_empty() {
277 continue;
278 }
279 if segments
280 .iter()
281 .skip(root_name_idx + 1)
282 .any(|segment| *segment == "..")
283 {
284 return Err(AgentLoopError::tool(format!(
285 "path traversal rejected: {input}"
286 )));
287 }
288 }
289 }
290 Ok(())
291}
292
293fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
297 if mount_point == "/" {
298 return Some(virtual_path.to_string());
300 }
301 if virtual_path == mount_point {
302 return Some("/".to_string());
303 }
304 virtual_path
305 .strip_prefix(mount_point)
306 .filter(|rest| rest.starts_with('/'))
307 .map(|rest| rest.to_string())
308}
309
310fn join_backend_path(backend_root: &str, rest: &str) -> String {
312 if backend_root == "/" {
313 return rest.to_string();
314 }
315 if rest == "/" {
316 return backend_root.to_string();
317 }
318 format!("{backend_root}{rest}")
319}
320
321#[async_trait]
322impl SessionFileSystem for MountFs {
323 fn display_root(&self) -> String {
324 self.primary.display_root()
325 }
326
327 fn is_mount_resolver(&self) -> bool {
328 true
329 }
330
331 fn resolve_path(&self, input: &str) -> String {
332 let virtual_path = normalize_virtual(input, &self.cwd());
337 match self.resolve(&virtual_path) {
338 Ok(resolved) if resolved.primary_workspace => {
339 resolved.backend.display_path(&resolved.backend_path)
340 }
341 _ => virtual_path,
342 }
343 }
344
345 fn display_path(&self, path: &str) -> String {
346 let virtual_path = normalize_virtual(path, "/");
347 match self.resolve(&virtual_path) {
348 Ok(resolved) if resolved.primary_workspace => {
349 resolved.backend.display_path(&resolved.backend_path)
350 }
351 _ => virtual_path,
352 }
353 }
354
355 async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
356 let resolved = self.resolve(path)?;
357 Ok(resolved
358 .backend
359 .read_file(session_id, &resolved.backend_path)
360 .await?
361 .map(|file| resolved.map_session_file(file)))
362 }
363
364 async fn write_file(
365 &self,
366 session_id: SessionId,
367 path: &str,
368 content: &str,
369 encoding: &str,
370 ) -> Result<SessionFile> {
371 let resolved = self.resolve(path)?;
372 Ok(resolved.map_session_file(
373 resolved
374 .backend
375 .write_file(session_id, &resolved.backend_path, content, encoding)
376 .await?,
377 ))
378 }
379
380 async fn write_file_if_content_matches(
381 &self,
382 session_id: SessionId,
383 path: &str,
384 expected_content: &str,
385 expected_encoding: &str,
386 content: &str,
387 encoding: &str,
388 ) -> Result<Option<SessionFile>> {
389 let resolved = self.resolve(path)?;
390 Ok(resolved
391 .backend
392 .write_file_if_content_matches(
393 session_id,
394 &resolved.backend_path,
395 expected_content,
396 expected_encoding,
397 content,
398 encoding,
399 )
400 .await?
401 .map(|file| resolved.map_session_file(file)))
402 }
403
404 async fn delete_file(
405 &self,
406 session_id: SessionId,
407 path: &str,
408 recursive: bool,
409 ) -> Result<bool> {
410 let resolved = self.resolve(path)?;
411 resolved
412 .backend
413 .delete_file(session_id, &resolved.backend_path, recursive)
414 .await
415 }
416
417 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
418 let resolved = self.resolve(path)?;
419 Ok(resolved
420 .backend
421 .list_directory(session_id, &resolved.backend_path)
422 .await?
423 .into_iter()
424 .map(|info| resolved.map_file_info(info))
425 .collect())
426 }
427
428 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
429 let resolved = self.resolve(path)?;
430 Ok(resolved
431 .backend
432 .stat_file(session_id, &resolved.backend_path)
433 .await?
434 .map(|stat| resolved.map_file_stat(stat)))
435 }
436
437 async fn grep_files(
438 &self,
439 session_id: SessionId,
440 pattern: &str,
441 path_pattern: Option<&str>,
442 ) -> Result<Vec<GrepMatch>> {
443 match path_pattern {
444 Some(pp) => {
445 let matcher = crate::session_path::GrepPathPattern::new(pp)?;
446 if matcher.is_glob() && (!pp.starts_with('/') || pp.starts_with(WORKSPACE_MOUNT)) {
447 let mut matches = Vec::new();
448 for resolved in self.grep_mounts() {
449 matches.extend(
450 resolved
451 .backend
452 .grep_files(session_id, pattern, Some(&resolved.backend_path))
453 .await?
454 .into_iter()
455 .map(|grep_match| resolved.map_grep_match(grep_match))
456 .filter(|grep_match| matcher.is_match(&grep_match.path)),
457 );
458 }
459 matches.sort_by(|a, b| {
460 a.path
461 .cmp(&b.path)
462 .then(a.line_number.cmp(&b.line_number))
463 .then(a.line.cmp(&b.line))
464 });
465 return Ok(matches);
466 }
467 let resolved = self.resolve(pp)?;
468 Ok(resolved
469 .backend
470 .grep_files(session_id, pattern, Some(&resolved.backend_path))
471 .await?
472 .into_iter()
473 .map(|grep_match| resolved.map_grep_match(grep_match))
474 .collect())
475 }
476 None => {
477 let mut matches = Vec::new();
478 for resolved in self.grep_mounts() {
479 matches.extend(
480 resolved
481 .backend
482 .grep_files(session_id, pattern, Some(&resolved.backend_path))
483 .await?
484 .into_iter()
485 .map(|grep_match| resolved.map_grep_match(grep_match)),
486 );
487 }
488 matches.sort_by(|a, b| {
489 a.path
490 .cmp(&b.path)
491 .then(a.line_number.cmp(&b.line_number))
492 .then(a.line.cmp(&b.line))
493 });
494 Ok(matches)
495 }
496 }
497 }
498
499 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
500 let resolved = self.resolve(path)?;
501 Ok(resolved.map_file_info(
502 resolved
503 .backend
504 .create_directory(session_id, &resolved.backend_path)
505 .await?,
506 ))
507 }
508
509 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
510 let resolved = self.resolve(&file.path)?;
511 let seeded = InitialFile {
512 path: resolved.backend_path,
513 content: file.content.clone(),
514 encoding: file.encoding.clone(),
515 is_readonly: file.is_readonly,
516 };
517 resolved
518 .backend
519 .seed_initial_file(session_id, &seeded)
520 .await
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::session_path::GrepPathPattern;
528
529 fn sid() -> SessionId {
530 SessionId::from_seed(1)
531 }
532
533 #[derive(Default)]
536 struct FlatStore {
537 files: std::sync::Mutex<std::collections::HashMap<String, String>>,
538 }
539
540 #[async_trait]
541 impl SessionFileSystem for FlatStore {
542 fn is_mount_resolver(&self) -> bool {
543 false
544 }
545
546 async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
547 let files = self.files.lock().unwrap();
548 Ok(files.get(path).map(|content| SessionFile {
549 id: uuid::Uuid::nil(),
550 session_id: sid.uuid(),
551 path: path.to_string(),
552 name: path.rsplit('/').next().unwrap_or("").to_string(),
553 content: Some(content.clone()),
554 encoding: "text".to_string(),
555 is_directory: false,
556 is_readonly: false,
557 size_bytes: content.len() as i64,
558 created_at: chrono::Utc::now(),
559 updated_at: chrono::Utc::now(),
560 }))
561 }
562 async fn write_file(
563 &self,
564 sid: SessionId,
565 path: &str,
566 content: &str,
567 encoding: &str,
568 ) -> Result<SessionFile> {
569 self.files
570 .lock()
571 .unwrap()
572 .insert(path.to_string(), content.to_string());
573 Ok(SessionFile {
574 id: uuid::Uuid::nil(),
575 session_id: sid.uuid(),
576 path: path.to_string(),
577 name: path.rsplit('/').next().unwrap_or("").to_string(),
578 content: Some(content.to_string()),
579 encoding: encoding.to_string(),
580 is_directory: false,
581 is_readonly: false,
582 size_bytes: content.len() as i64,
583 created_at: chrono::Utc::now(),
584 updated_at: chrono::Utc::now(),
585 })
586 }
587 async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
588 Ok(self.files.lock().unwrap().remove(path).is_some())
589 }
590 async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
591 Ok(vec![])
592 }
593 async fn stat_file(&self, _: SessionId, path: &str) -> Result<Option<FileStat>> {
594 let files = self.files.lock().unwrap();
595 Ok(files.get(path).map(|content| FileStat {
596 path: path.to_string(),
597 name: path.rsplit('/').next().unwrap_or("").to_string(),
598 is_directory: false,
599 is_readonly: false,
600 size_bytes: content.len() as i64,
601 created_at: chrono::Utc::now(),
602 updated_at: chrono::Utc::now(),
603 }))
604 }
605 async fn grep_files(
606 &self,
607 _: SessionId,
608 pattern: &str,
609 path_pattern: Option<&str>,
610 ) -> Result<Vec<GrepMatch>> {
611 let path_pattern = path_pattern.map(GrepPathPattern::new).transpose()?;
612 let files = self.files.lock().unwrap();
613 let mut matches = Vec::new();
614 for (path, content) in files.iter() {
615 if let Some(filter) = &path_pattern
616 && !filter.is_match(path)
617 {
618 continue;
619 }
620 for (idx, line) in content.lines().enumerate() {
621 if line.contains(pattern) {
622 matches.push(GrepMatch {
623 path: path.clone(),
624 line_number: idx + 1,
625 line: line.to_string(),
626 });
627 }
628 }
629 }
630 Ok(matches)
631 }
632 async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
633 Ok(FileInfo {
634 id: uuid::Uuid::nil(),
635 session_id: sid.uuid(),
636 name: path.rsplit('/').next().unwrap_or("").to_string(),
637 path: path.to_string(),
638 is_directory: true,
639 is_readonly: false,
640 size_bytes: 0,
641 created_at: chrono::Utc::now(),
642 updated_at: chrono::Utc::now(),
643 })
644 }
645 }
646
647 #[test]
648 fn normalize_resolves_relative_against_cwd() {
649 assert_eq!(
650 normalize_virtual("foo/bar", "/workspace"),
651 "/workspace/foo/bar"
652 );
653 assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
654 assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
655 assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
656 assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
657 assert_eq!(normalize_virtual("/", "/workspace"), "/");
658 }
659
660 #[tokio::test]
661 async fn workspace_and_root_address_the_same_file() {
662 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
663 let fs = MountFs::new(backend);
664
665 fs.write_file(sid(), "/workspace/src/lib.rs", "X", "text")
667 .await
668 .unwrap();
669 let via_root = fs.read_file(sid(), "/src/lib.rs").await.unwrap().unwrap();
670 assert_eq!(via_root.content.as_deref(), Some("X"));
671 assert_eq!(via_root.path, "/src/lib.rs");
673 }
674
675 #[tokio::test]
676 async fn relative_paths_resolve_against_cwd() {
677 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
678 let fs = MountFs::new(backend);
679 assert_eq!(fs.cwd(), "/workspace");
680
681 fs.write_file(sid(), "notes.md", "hi", "text")
682 .await
683 .unwrap();
684 let read = fs.read_file(sid(), "/notes.md").await.unwrap().unwrap();
686 assert_eq!(read.content.as_deref(), Some("hi"));
687 }
688
689 #[tokio::test]
690 async fn legacy_subtree_paths_pass_through_root_mount() {
691 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
692 let fs = MountFs::new(backend);
693 fs.write_file(sid(), "/outputs/call.stdout", "out", "text")
695 .await
696 .unwrap();
697 let read = fs
698 .read_file(sid(), "/workspace/outputs/call.stdout")
699 .await
700 .unwrap()
701 .unwrap();
702 assert_eq!(read.content.as_deref(), Some("out"));
703 }
704
705 #[test]
706 fn display_uses_the_primary_backends_identity() {
707 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
708 let fs = MountFs::new(backend);
709 assert_eq!(fs.display_root(), "/workspace");
710 assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
711 assert_eq!(fs.display_path("/"), "/workspace");
712 assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
713 }
714
715 #[tokio::test]
716 async fn additional_mount_routes_to_its_backend() {
717 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
718 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
719 let fs = MountFs::new(workspace).with_mount("/data", volume.clone(), "/");
720
721 fs.write_file(sid(), "/data/report.csv", "1,2,3", "text")
722 .await
723 .unwrap();
724 let from_volume = volume
726 .read_file(sid(), "/report.csv")
727 .await
728 .unwrap()
729 .unwrap();
730 assert_eq!(from_volume.content.as_deref(), Some("1,2,3"));
731 }
732
733 #[tokio::test]
734 async fn additional_mount_outputs_use_virtual_paths() {
735 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
736 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
737 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
738
739 let written = fs
740 .write_file(
741 sid(),
742 "/workspace/roots/backend/Cargo.toml",
743 "name = \"backend\"",
744 "text",
745 )
746 .await
747 .unwrap();
748 assert_eq!(written.path, "/workspace/roots/backend/Cargo.toml");
749
750 let stat = fs
751 .stat_file(sid(), "/workspace/roots/backend/Cargo.toml")
752 .await
753 .unwrap()
754 .unwrap();
755 assert_eq!(stat.path, "/workspace/roots/backend/Cargo.toml");
756 assert_eq!(
757 fs.display_path(&stat.path),
758 "/workspace/roots/backend/Cargo.toml"
759 );
760 assert_eq!(
761 fs.resolve_path("/workspace/roots/backend/Cargo.toml"),
762 "/workspace/roots/backend/Cargo.toml"
763 );
764 }
765
766 #[tokio::test]
767 async fn grep_without_path_searches_all_mounts() {
768 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
769 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
770 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
771
772 fs.write_file(sid(), "/workspace/README.md", "needle primary", "text")
773 .await
774 .unwrap();
775 fs.write_file(
776 sid(),
777 "/workspace/roots/backend/Cargo.toml",
778 "needle backend",
779 "text",
780 )
781 .await
782 .unwrap();
783
784 let matches = fs.grep_files(sid(), "needle", None).await.unwrap();
785 let paths: Vec<_> = matches.into_iter().map(|m| m.path).collect();
786 assert_eq!(
787 paths,
788 vec![
789 "/README.md".to_string(),
790 "/workspace/roots/backend/Cargo.toml".to_string()
791 ]
792 );
793 }
794
795 #[tokio::test]
796 async fn grep_resolves_workspace_glob_to_backend_namespace() {
797 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
798 let fs = MountFs::new(backend);
799 fs.write_file(sid(), "/workspace/src/lib.rs", "needle", "text")
800 .await
801 .unwrap();
802 fs.write_file(sid(), "/workspace/docs/readme.md", "needle", "text")
803 .await
804 .unwrap();
805
806 let matches = fs
807 .grep_files(sid(), "needle", Some("/workspace/src/**/*.rs"))
808 .await
809 .unwrap();
810
811 assert_eq!(matches.len(), 1);
812 assert_eq!(matches[0].path, "/src/lib.rs");
813 }
814
815 #[tokio::test]
816 async fn grep_glob_searches_every_matching_mount() {
817 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
818 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
819 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
820 fs.write_file(sid(), "/workspace/Cargo.toml", "needle", "text")
821 .await
822 .unwrap();
823 fs.write_file(
824 sid(),
825 "/workspace/roots/backend/Cargo.toml",
826 "needle",
827 "text",
828 )
829 .await
830 .unwrap();
831
832 let paths: Vec<_> = fs
833 .grep_files(sid(), "needle", Some("**/*.toml"))
834 .await
835 .unwrap()
836 .into_iter()
837 .map(|hit| hit.path)
838 .collect();
839 assert_eq!(
840 paths,
841 vec![
842 "/Cargo.toml".to_string(),
843 "/workspace/roots/backend/Cargo.toml".to_string()
844 ]
845 );
846 }
847
848 #[test]
849 fn mount_fs_identifies_as_resolver() {
850 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
851 let fs = MountFs::wrap(workspace);
852 assert!(fs.is_mount_resolver());
853 let again = MountFs::wrap_if_needed(fs.clone());
854 assert!(Arc::ptr_eq(&fs, &again));
855 }
856}