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