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, Copy, PartialEq, Eq, Debug, Default)]
79pub enum DisplayPolicy {
80 #[default]
85 WorkspaceAlias,
86 BackendNative,
91}
92
93#[derive(Clone)]
95struct Mount {
96 mount_point: String,
99 backend: Arc<dyn SessionFileSystem>,
101 backend_root: String,
103 primary_workspace: bool,
108}
109
110#[derive(Clone)]
111struct ResolvedMount {
112 mount_point: String,
113 backend: Arc<dyn SessionFileSystem>,
114 backend_root: String,
115 backend_path: String,
116 primary_workspace: bool,
117}
118
119pub struct MountFs {
122 mounts: Vec<Mount>,
125 primary: Arc<dyn SessionFileSystem>,
127 cwd: String,
131 display_policy: DisplayPolicy,
135}
136
137impl MountFs {
138 pub fn new(workspace: Arc<dyn SessionFileSystem>) -> Self {
144 let mounts = vec![
145 Mount {
146 mount_point: "/".to_string(),
147 backend: workspace.clone(),
148 backend_root: "/".to_string(),
149 primary_workspace: true,
150 },
151 Mount {
152 mount_point: WORKSPACE_MOUNT.to_string(),
153 backend: workspace.clone(),
154 backend_root: "/".to_string(),
155 primary_workspace: true,
156 },
157 ];
158 let mut fs = Self {
159 mounts,
160 primary: workspace,
161 cwd: WORKSPACE_MOUNT.to_string(),
162 display_policy: DisplayPolicy::default(),
163 };
164 fs.sort_mounts();
165 fs
166 }
167
168 pub fn with_display_policy(mut self, policy: DisplayPolicy) -> Self {
171 self.display_policy = policy;
172 self
173 }
174
175 pub fn with_backend_display(self) -> Self {
185 self.with_display_policy(DisplayPolicy::BackendNative)
186 }
187
188 fn present_primary_key(&self, canonical_key: &str) -> String {
192 match self.display_policy {
193 DisplayPolicy::WorkspaceAlias => display_backend_path(WORKSPACE_MOUNT, canonical_key),
195 DisplayPolicy::BackendNative => self.primary.display_path(canonical_key),
197 }
198 }
199
200 pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
202 Arc::new(Self::new(workspace))
203 }
204
205 pub fn wrap_if_needed(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
210 if workspace.is_mount_resolver() {
211 workspace
212 } else {
213 Self::wrap(workspace)
214 }
215 }
216
217 pub fn with_mount(
220 mut self,
221 mount_point: impl Into<String>,
222 backend: Arc<dyn SessionFileSystem>,
223 backend_root: impl Into<String>,
224 ) -> Self {
225 self.mounts.push(Mount {
226 mount_point: normalize_virtual(&mount_point.into(), "/"),
227 backend,
228 backend_root: normalize_virtual(&backend_root.into(), "/"),
229 primary_workspace: false,
230 });
231 self.sort_mounts();
232 self
233 }
234
235 pub fn cwd(&self) -> String {
237 self.cwd.clone()
238 }
239
240 fn sort_mounts(&mut self) {
241 self.mounts
243 .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
244 }
245
246 fn resolve(&self, input: &str) -> Result<ResolvedMount> {
252 reject_additional_root_traversal(input, &self.cwd)?;
253 let virtual_path = normalize_virtual(input, &self.cwd());
254 for mount in &self.mounts {
255 if let Some(rest) = mount_suffix(&mount.mount_point, &virtual_path) {
256 return Ok(ResolvedMount {
257 mount_point: mount.mount_point.clone(),
258 backend: mount.backend.clone(),
259 backend_root: mount.backend_root.clone(),
260 backend_path: join_backend_path(&mount.backend_root, &rest),
261 primary_workspace: mount.primary_workspace,
262 });
263 }
264 }
265 Ok(ResolvedMount {
268 mount_point: "/".to_string(),
269 backend: self.primary.clone(),
270 backend_root: "/".to_string(),
271 backend_path: virtual_path,
272 primary_workspace: true,
273 })
274 }
275
276 fn grep_mounts(&self) -> Vec<ResolvedMount> {
277 let mut out: Vec<ResolvedMount> = Vec::new();
278 for mount in self.mounts.iter().rev() {
279 if out.iter().any(|existing| {
280 Arc::ptr_eq(&existing.backend, &mount.backend)
281 && existing.backend_root == mount.backend_root
282 }) {
283 continue;
284 }
285 out.push(ResolvedMount {
286 mount_point: mount.mount_point.clone(),
287 backend: mount.backend.clone(),
288 backend_root: mount.backend_root.clone(),
289 backend_path: mount.backend_root.clone(),
290 primary_workspace: mount.primary_workspace,
291 });
292 }
293 out
294 }
295}
296
297impl ResolvedMount {
298 fn map_session_file(&self, mut file: SessionFile) -> SessionFile {
299 file.path = self.to_virtual_output_path(&file.path);
300 file.name = FileInfo::name_from_path(&file.path);
301 file
302 }
303
304 fn map_file_info(&self, mut info: FileInfo) -> FileInfo {
305 info.path = self.to_virtual_output_path(&info.path);
306 info.name = FileInfo::name_from_path(&info.path);
307 info
308 }
309
310 fn map_file_stat(&self, mut stat: FileStat) -> FileStat {
311 stat.path = self.to_virtual_output_path(&stat.path);
312 stat.name = FileInfo::name_from_path(&stat.path);
313 stat
314 }
315
316 fn map_grep_match(&self, mut grep_match: GrepMatch) -> GrepMatch {
317 grep_match.path = self.to_virtual_output_path(&grep_match.path);
318 grep_match
319 }
320
321 fn map_grep_result(&self, mut result: GrepSearchResult) -> GrepSearchResult {
322 for grep_match in &mut result.matches {
323 grep_match.path = self.to_virtual_output_path(&grep_match.path);
324 }
325 for block in &mut result.blocks {
326 block.path = self.to_virtual_output_path(&block.path);
327 }
328 result
329 }
330
331 fn to_virtual_output_path(&self, backend_path: &str) -> String {
332 if self.primary_workspace {
333 return normalize_virtual(backend_path, "/");
334 }
335 let normalized = normalize_virtual(backend_path, "/");
336 let rest = mount_suffix(&self.backend_root, &normalized).unwrap_or(normalized);
337 join_backend_path(&self.mount_point, &rest)
338 }
339}
340
341fn normalize_virtual(input: &str, cwd: &str) -> String {
344 let combined = if input.starts_with('/') {
345 input.to_string()
346 } else {
347 format!("{}/{}", cwd.trim_end_matches('/'), input)
348 };
349 let mut stack: Vec<&str> = Vec::new();
350 for segment in combined.split('/') {
351 match segment {
352 "" | "." => {}
353 ".." => {
354 stack.pop();
355 }
356 other => stack.push(other),
357 }
358 }
359 if stack.is_empty() {
360 "/".to_string()
361 } else {
362 format!("/{}", stack.join("/"))
363 }
364}
365
366fn reject_additional_root_traversal(input: &str, cwd: &str) -> Result<()> {
367 let combined = if input.starts_with('/') {
368 input.to_string()
369 } else {
370 format!("{}/{}", cwd.trim_end_matches('/'), input)
371 };
372 let segments: Vec<&str> = combined
373 .split('/')
374 .filter(|segment| !segment.is_empty())
375 .collect();
376 for window_start in 0..segments.len().saturating_sub(2) {
377 if segments[window_start] == "workspace" && segments[window_start + 1] == "roots" {
378 let root_name_idx = window_start + 2;
379 if segments[root_name_idx].is_empty() {
380 continue;
381 }
382 if segments
383 .iter()
384 .skip(root_name_idx + 1)
385 .any(|segment| *segment == "..")
386 {
387 return Err(AgentLoopError::tool(format!(
388 "path traversal rejected: {input}"
389 )));
390 }
391 }
392 }
393 Ok(())
394}
395
396fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
400 if mount_point == "/" {
401 return Some(virtual_path.to_string());
403 }
404 if virtual_path == mount_point {
405 return Some("/".to_string());
406 }
407 virtual_path
408 .strip_prefix(mount_point)
409 .filter(|rest| rest.starts_with('/'))
410 .map(|rest| rest.to_string())
411}
412
413fn join_backend_path(backend_root: &str, rest: &str) -> String {
415 if backend_root == "/" {
416 return rest.to_string();
417 }
418 if rest == "/" {
419 return backend_root.to_string();
420 }
421 format!("{backend_root}{rest}")
422}
423
424fn display_backend_path(display_root: &str, path: &str) -> String {
426 let normalized = normalize_virtual(path, "/");
427 if normalized == "/" {
428 display_root.to_string()
429 } else if display_root == "/" {
430 normalized
431 } else {
432 format!("{}{normalized}", display_root.trim_end_matches('/'))
433 }
434}
435
436#[async_trait]
437impl SessionFileSystem for MountFs {
438 fn display_root(&self) -> String {
439 match self.display_policy {
444 DisplayPolicy::WorkspaceAlias => WORKSPACE_MOUNT.to_string(),
445 DisplayPolicy::BackendNative => self.primary.display_root(),
446 }
447 }
448
449 fn is_mount_resolver(&self) -> bool {
450 true
451 }
452
453 fn resolve_path(&self, input: &str) -> String {
454 let virtual_path = normalize_virtual(input, &self.cwd());
462 match self.resolve(&virtual_path) {
463 Ok(resolved) if resolved.primary_workspace => {
464 self.present_primary_key(&resolved.backend_path)
465 }
466 _ => virtual_path,
467 }
468 }
469
470 fn display_path(&self, path: &str) -> String {
471 let virtual_path = normalize_virtual(path, "/");
480 match self.resolve(&virtual_path) {
481 Ok(resolved) if !resolved.primary_workspace => virtual_path,
482 _ => self.present_primary_key(&virtual_path),
483 }
484 }
485
486 async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
487 let resolved = self.resolve(path)?;
488 Ok(resolved
489 .backend
490 .read_file(session_id, &resolved.backend_path)
491 .await?
492 .map(|file| resolved.map_session_file(file)))
493 }
494
495 async fn write_file(
496 &self,
497 session_id: SessionId,
498 path: &str,
499 content: &str,
500 encoding: &str,
501 ) -> Result<SessionFile> {
502 let resolved = self.resolve(path)?;
503 Ok(resolved.map_session_file(
504 resolved
505 .backend
506 .write_file(session_id, &resolved.backend_path, content, encoding)
507 .await?,
508 ))
509 }
510
511 async fn write_file_if_content_matches(
512 &self,
513 session_id: SessionId,
514 path: &str,
515 expected_content: &str,
516 expected_encoding: &str,
517 content: &str,
518 encoding: &str,
519 ) -> Result<Option<SessionFile>> {
520 let resolved = self.resolve(path)?;
521 Ok(resolved
522 .backend
523 .write_file_if_content_matches(
524 session_id,
525 &resolved.backend_path,
526 expected_content,
527 expected_encoding,
528 content,
529 encoding,
530 )
531 .await?
532 .map(|file| resolved.map_session_file(file)))
533 }
534
535 async fn delete_file(
536 &self,
537 session_id: SessionId,
538 path: &str,
539 recursive: bool,
540 ) -> Result<bool> {
541 let resolved = self.resolve(path)?;
542 resolved
543 .backend
544 .delete_file(session_id, &resolved.backend_path, recursive)
545 .await
546 }
547
548 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
549 let resolved = self.resolve(path)?;
550 Ok(resolved
551 .backend
552 .list_directory(session_id, &resolved.backend_path)
553 .await?
554 .into_iter()
555 .map(|info| resolved.map_file_info(info))
556 .collect())
557 }
558
559 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
560 let resolved = self.resolve(path)?;
561 Ok(resolved
562 .backend
563 .stat_file(session_id, &resolved.backend_path)
564 .await?
565 .map(|stat| resolved.map_file_stat(stat)))
566 }
567
568 async fn grep_files(
569 &self,
570 session_id: SessionId,
571 pattern: &str,
572 path_pattern: Option<&str>,
573 ) -> Result<Vec<GrepMatch>> {
574 match path_pattern {
575 Some(pp) => {
576 let matcher = crate::session_path::GrepPathPattern::new(pp)?;
577 if matcher.is_glob() && (!pp.starts_with('/') || pp.starts_with(WORKSPACE_MOUNT)) {
578 let mut matches = Vec::new();
579 for resolved in self.grep_mounts() {
580 matches.extend(
581 resolved
582 .backend
583 .grep_files(session_id, pattern, Some(&resolved.backend_path))
584 .await?
585 .into_iter()
586 .map(|grep_match| resolved.map_grep_match(grep_match))
587 .filter(|grep_match| matcher.is_match(&grep_match.path)),
588 );
589 }
590 matches.sort_by(|a, b| {
591 a.path
592 .cmp(&b.path)
593 .then(a.line_number.cmp(&b.line_number))
594 .then(a.line.cmp(&b.line))
595 });
596 return Ok(matches);
597 }
598 let resolved = self.resolve(pp)?;
599 Ok(resolved
600 .backend
601 .grep_files(session_id, pattern, Some(&resolved.backend_path))
602 .await?
603 .into_iter()
604 .map(|grep_match| resolved.map_grep_match(grep_match))
605 .collect())
606 }
607 None => {
608 let mut matches = Vec::new();
609 for resolved in self.grep_mounts() {
610 matches.extend(
611 resolved
612 .backend
613 .grep_files(session_id, pattern, Some(&resolved.backend_path))
614 .await?
615 .into_iter()
616 .map(|grep_match| resolved.map_grep_match(grep_match)),
617 );
618 }
619 matches.sort_by(|a, b| {
620 a.path
621 .cmp(&b.path)
622 .then(a.line_number.cmp(&b.line_number))
623 .then(a.line.cmp(&b.line))
624 });
625 Ok(matches)
626 }
627 }
628 }
629
630 async fn grep_files_with_options(
631 &self,
632 session_id: SessionId,
633 pattern: &str,
634 options: &GrepOptions,
635 ) -> Result<GrepSearchResult> {
636 if let Some(path_pattern) = options.path_pattern.as_deref()
637 && path_pattern.starts_with('/')
638 && !path_pattern.starts_with(WORKSPACE_MOUNT)
639 {
640 let resolved = self.resolve(path_pattern)?;
641 let mut backend_options = options.clone();
642 backend_options.path_pattern = Some(resolved.backend_path.clone());
643 return resolved
644 .backend
645 .grep_files_with_options(session_id, pattern, &backend_options)
646 .await
647 .map(|result| resolved.map_grep_result(result));
648 }
649
650 let mounts = self.grep_mounts();
651 if mounts.len() == 1 {
652 let resolved = &mounts[0];
653 let mut backend_options = options.clone();
654 backend_options.path_pattern = options.path_pattern.as_ref().map(|path| {
655 if path.starts_with(WORKSPACE_MOUNT) {
656 path.strip_prefix(WORKSPACE_MOUNT)
657 .unwrap_or(path)
658 .to_string()
659 } else {
660 path.clone()
661 }
662 });
663 return resolved
664 .backend
665 .grep_files_with_options(session_id, pattern, &backend_options)
666 .await
667 .map(|result| resolved.map_grep_result(result));
668 }
669
670 let mut backend_options = options.clone();
671 backend_options.offset = 0;
672 backend_options.limit = usize::MAX;
673 backend_options.max_bytes = usize::MAX;
674 let path_matcher = options
675 .path_pattern
676 .as_deref()
677 .map(crate::session_path::GrepPathPattern::new)
678 .transpose()?;
679 let mut results = Vec::new();
680 for resolved in mounts {
681 let mut mount_options = backend_options.clone();
682 mount_options.path_pattern = Some(resolved.backend_path.clone());
683 let result = resolved
684 .backend
685 .grep_files_with_options(session_id, pattern, &mount_options)
686 .await?;
687 let mut mapped = resolved.map_grep_result(result);
688 if let Some(matcher) = &path_matcher {
689 mapped.matches.retain(|item| matcher.is_match(&item.path));
690 mapped.blocks.retain(|block| matcher.is_match(&block.path));
691 }
692 results.push(mapped);
693 }
694 Ok(crate::session_file::merge_grep_search_results(
695 results, options,
696 ))
697 }
698
699 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
700 let resolved = self.resolve(path)?;
701 Ok(resolved.map_file_info(
702 resolved
703 .backend
704 .create_directory(session_id, &resolved.backend_path)
705 .await?,
706 ))
707 }
708
709 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
710 let resolved = self.resolve(&file.path)?;
711 let seeded = InitialFile {
712 path: resolved.backend_path,
713 content: file.content.clone(),
714 encoding: file.encoding.clone(),
715 is_readonly: file.is_readonly,
716 };
717 resolved
718 .backend
719 .seed_initial_file(session_id, &seeded)
720 .await
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use crate::session_path::GrepPathPattern;
728
729 fn sid() -> SessionId {
730 SessionId::from_seed(1)
731 }
732
733 #[derive(Default)]
736 struct FlatStore {
737 files: std::sync::Mutex<std::collections::HashMap<String, String>>,
738 host_root: Option<String>,
742 }
743
744 #[async_trait]
745 impl SessionFileSystem for FlatStore {
746 fn is_mount_resolver(&self) -> bool {
747 false
748 }
749
750 fn display_root(&self) -> String {
751 match &self.host_root {
752 Some(root) => root.clone(),
753 None => crate::session_path::WORKSPACE_PREFIX.to_string(),
754 }
755 }
756
757 fn display_path(&self, path: &str) -> String {
758 match &self.host_root {
759 Some(root) => {
760 let normalized = normalize_virtual(path, "/");
761 if normalized == "/" {
762 root.clone()
763 } else {
764 format!("{root}{normalized}")
765 }
766 }
767 None => crate::session_path::to_display_path(path),
768 }
769 }
770
771 async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
772 let files = self.files.lock().unwrap();
773 Ok(files.get(path).map(|content| SessionFile {
774 id: uuid::Uuid::nil(),
775 session_id: sid.uuid(),
776 path: path.to_string(),
777 name: path.rsplit('/').next().unwrap_or("").to_string(),
778 content: Some(content.clone()),
779 encoding: "text".to_string(),
780 is_directory: false,
781 is_readonly: false,
782 size_bytes: content.len() as i64,
783 created_at: chrono::Utc::now(),
784 updated_at: chrono::Utc::now(),
785 }))
786 }
787 async fn write_file(
788 &self,
789 sid: SessionId,
790 path: &str,
791 content: &str,
792 encoding: &str,
793 ) -> Result<SessionFile> {
794 self.files
795 .lock()
796 .unwrap()
797 .insert(path.to_string(), content.to_string());
798 Ok(SessionFile {
799 id: uuid::Uuid::nil(),
800 session_id: sid.uuid(),
801 path: path.to_string(),
802 name: path.rsplit('/').next().unwrap_or("").to_string(),
803 content: Some(content.to_string()),
804 encoding: encoding.to_string(),
805 is_directory: false,
806 is_readonly: false,
807 size_bytes: content.len() as i64,
808 created_at: chrono::Utc::now(),
809 updated_at: chrono::Utc::now(),
810 })
811 }
812 async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
813 Ok(self.files.lock().unwrap().remove(path).is_some())
814 }
815 async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
816 Ok(vec![])
817 }
818 async fn stat_file(&self, _: SessionId, path: &str) -> Result<Option<FileStat>> {
819 let files = self.files.lock().unwrap();
820 Ok(files.get(path).map(|content| FileStat {
821 path: path.to_string(),
822 name: path.rsplit('/').next().unwrap_or("").to_string(),
823 is_directory: false,
824 is_readonly: false,
825 size_bytes: content.len() as i64,
826 created_at: chrono::Utc::now(),
827 updated_at: chrono::Utc::now(),
828 }))
829 }
830 async fn grep_files(
831 &self,
832 _: SessionId,
833 pattern: &str,
834 path_pattern: Option<&str>,
835 ) -> Result<Vec<GrepMatch>> {
836 let path_pattern = path_pattern.map(GrepPathPattern::new).transpose()?;
837 let files = self.files.lock().unwrap();
838 let mut matches = Vec::new();
839 for (path, content) in files.iter() {
840 if let Some(filter) = &path_pattern
841 && !filter.is_match(path)
842 {
843 continue;
844 }
845 for (idx, line) in content.lines().enumerate() {
846 if line.contains(pattern) {
847 matches.push(GrepMatch {
848 path: path.clone(),
849 line_number: idx + 1,
850 line: line.to_string(),
851 });
852 }
853 }
854 }
855 Ok(matches)
856 }
857 async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
858 Ok(FileInfo {
859 id: uuid::Uuid::nil(),
860 session_id: sid.uuid(),
861 name: path.rsplit('/').next().unwrap_or("").to_string(),
862 path: path.to_string(),
863 is_directory: true,
864 is_readonly: false,
865 size_bytes: 0,
866 created_at: chrono::Utc::now(),
867 updated_at: chrono::Utc::now(),
868 })
869 }
870 }
871
872 #[test]
873 fn normalize_resolves_relative_against_cwd() {
874 assert_eq!(
875 normalize_virtual("foo/bar", "/workspace"),
876 "/workspace/foo/bar"
877 );
878 assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
879 assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
880 assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
881 assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
882 assert_eq!(normalize_virtual("/", "/workspace"), "/");
883 }
884
885 #[tokio::test]
886 async fn workspace_and_root_address_the_same_file() {
887 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
888 let fs = MountFs::new(backend);
889
890 fs.write_file(sid(), "/workspace/src/lib.rs", "X", "text")
892 .await
893 .unwrap();
894 let via_root = fs.read_file(sid(), "/src/lib.rs").await.unwrap().unwrap();
895 assert_eq!(via_root.content.as_deref(), Some("X"));
896 assert_eq!(via_root.path, "/src/lib.rs");
898 }
899
900 #[tokio::test]
901 async fn relative_paths_resolve_against_cwd() {
902 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
903 let fs = MountFs::new(backend);
904 assert_eq!(fs.cwd(), "/workspace");
905
906 fs.write_file(sid(), "notes.md", "hi", "text")
907 .await
908 .unwrap();
909 let read = fs.read_file(sid(), "/notes.md").await.unwrap().unwrap();
911 assert_eq!(read.content.as_deref(), Some("hi"));
912 }
913
914 #[tokio::test]
915 async fn legacy_subtree_paths_pass_through_root_mount() {
916 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
917 let fs = MountFs::new(backend);
918 fs.write_file(sid(), "/outputs/call.stdout", "out", "text")
920 .await
921 .unwrap();
922 let read = fs
923 .read_file(sid(), "/workspace/outputs/call.stdout")
924 .await
925 .unwrap()
926 .unwrap();
927 assert_eq!(read.content.as_deref(), Some("out"));
928 }
929
930 #[test]
931 fn display_uses_workspace_alias_for_primary() {
932 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
933 let fs = MountFs::new(backend);
934 assert_eq!(fs.display_root(), "/workspace");
935 assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
936 assert_eq!(fs.display_path("/"), "/workspace");
937 assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
938 }
939
940 #[test]
941 fn workspace_alias_is_the_default_even_over_a_host_backend() {
942 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
945 host_root: Some("/host/root".to_string()),
946 ..Default::default()
947 });
948 let fs = MountFs::new(backend);
949 assert_eq!(fs.display_root(), "/workspace");
950 assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
951 assert_eq!(fs.resolve_path("src/lib.rs"), "/workspace/src/lib.rs");
952 }
953
954 #[test]
955 fn backend_display_exposes_host_paths_while_routing_is_unchanged() {
956 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore {
960 host_root: Some("/host/root".to_string()),
961 ..Default::default()
962 });
963 let fs = MountFs::new(backend).with_backend_display();
964
965 assert_eq!(fs.display_root(), "/host/root");
966 assert_eq!(fs.display_path("/src/lib.rs"), "/host/root/src/lib.rs");
967 assert_eq!(fs.display_path("/"), "/host/root");
968 assert_eq!(fs.resolve_path("src/lib.rs"), "/host/root/src/lib.rs");
971 assert_eq!(
972 fs.resolve_path("/workspace/src/lib.rs"),
973 "/host/root/src/lib.rs"
974 );
975 }
976
977 #[tokio::test]
978 async fn display_preserves_literal_backend_workspace_segment() {
979 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
980 backend
981 .write_file(sid(), "/workspace/collide.txt", "literal", "text")
982 .await
983 .unwrap();
984 backend
985 .write_file(sid(), "/collide.txt", "alias", "text")
986 .await
987 .unwrap();
988 let fs = MountFs::new(backend);
989
990 let literal = fs
991 .read_file(sid(), "workspace/collide.txt")
992 .await
993 .unwrap()
994 .unwrap();
995 let display_path = fs.display_path(&literal.path);
996 assert_eq!(display_path, "/workspace/workspace/collide.txt");
997
998 let round_trip = fs.read_file(sid(), &display_path).await.unwrap().unwrap();
999 assert_eq!(round_trip.content.as_deref(), Some("literal"));
1000 }
1001
1002 #[tokio::test]
1003 async fn additional_mount_routes_to_its_backend() {
1004 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1005 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1006 let fs = MountFs::new(workspace).with_mount("/data", volume.clone(), "/");
1007
1008 fs.write_file(sid(), "/data/report.csv", "1,2,3", "text")
1009 .await
1010 .unwrap();
1011 let from_volume = volume
1013 .read_file(sid(), "/report.csv")
1014 .await
1015 .unwrap()
1016 .unwrap();
1017 assert_eq!(from_volume.content.as_deref(), Some("1,2,3"));
1018 }
1019
1020 #[tokio::test]
1021 async fn additional_mount_outputs_use_virtual_paths() {
1022 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1023 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1024 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1025
1026 let written = fs
1027 .write_file(
1028 sid(),
1029 "/workspace/roots/backend/Cargo.toml",
1030 "name = \"backend\"",
1031 "text",
1032 )
1033 .await
1034 .unwrap();
1035 assert_eq!(written.path, "/workspace/roots/backend/Cargo.toml");
1036
1037 let stat = fs
1038 .stat_file(sid(), "/workspace/roots/backend/Cargo.toml")
1039 .await
1040 .unwrap()
1041 .unwrap();
1042 assert_eq!(stat.path, "/workspace/roots/backend/Cargo.toml");
1043 assert_eq!(
1044 fs.display_path(&stat.path),
1045 "/workspace/roots/backend/Cargo.toml"
1046 );
1047 assert_eq!(
1048 fs.resolve_path("/workspace/roots/backend/Cargo.toml"),
1049 "/workspace/roots/backend/Cargo.toml"
1050 );
1051 }
1052
1053 #[tokio::test]
1054 async fn grep_without_path_searches_all_mounts() {
1055 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1056 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1057 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1058
1059 fs.write_file(sid(), "/workspace/README.md", "needle primary", "text")
1060 .await
1061 .unwrap();
1062 fs.write_file(
1063 sid(),
1064 "/workspace/roots/backend/Cargo.toml",
1065 "needle backend",
1066 "text",
1067 )
1068 .await
1069 .unwrap();
1070
1071 let matches = fs.grep_files(sid(), "needle", None).await.unwrap();
1072 let paths: Vec<_> = matches.into_iter().map(|m| m.path).collect();
1073 assert_eq!(
1074 paths,
1075 vec![
1076 "/README.md".to_string(),
1077 "/workspace/roots/backend/Cargo.toml".to_string()
1078 ]
1079 );
1080 }
1081
1082 #[tokio::test]
1083 async fn grep_resolves_workspace_glob_to_backend_namespace() {
1084 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1085 let fs = MountFs::new(backend);
1086 fs.write_file(sid(), "/workspace/src/lib.rs", "needle", "text")
1087 .await
1088 .unwrap();
1089 fs.write_file(sid(), "/workspace/docs/readme.md", "needle", "text")
1090 .await
1091 .unwrap();
1092
1093 let matches = fs
1094 .grep_files(sid(), "needle", Some("/workspace/src/**/*.rs"))
1095 .await
1096 .unwrap();
1097
1098 assert_eq!(matches.len(), 1);
1099 assert_eq!(matches[0].path, "/src/lib.rs");
1100 }
1101
1102 #[tokio::test]
1103 async fn grep_glob_searches_every_matching_mount() {
1104 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1105 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1106 let fs = MountFs::new(workspace).with_mount("/workspace/roots/backend", volume, "/");
1107 fs.write_file(sid(), "/workspace/Cargo.toml", "needle", "text")
1108 .await
1109 .unwrap();
1110 fs.write_file(
1111 sid(),
1112 "/workspace/roots/backend/Cargo.toml",
1113 "needle",
1114 "text",
1115 )
1116 .await
1117 .unwrap();
1118
1119 let paths: Vec<_> = fs
1120 .grep_files(sid(), "needle", Some("**/*.toml"))
1121 .await
1122 .unwrap()
1123 .into_iter()
1124 .map(|hit| hit.path)
1125 .collect();
1126 assert_eq!(
1127 paths,
1128 vec![
1129 "/Cargo.toml".to_string(),
1130 "/workspace/roots/backend/Cargo.toml".to_string()
1131 ]
1132 );
1133 }
1134
1135 #[test]
1136 fn mount_fs_identifies_as_resolver() {
1137 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
1138 let fs = MountFs::wrap(workspace);
1139 assert!(fs.is_mount_resolver());
1140 let again = MountFs::wrap_if_needed(fs.clone());
1141 assert!(Arc::ptr_eq(&fs, &again));
1142 }
1143}