Skip to main content

everruns_core/
mount_fs.rs

1// Mount-based virtual filesystem resolver (EVE-660).
2//
3// `MountFs` is the single resolution seam for the agent's filesystem. It owns:
4//
5//   * a **mount table** — named mount points, each backed by a
6//     `SessionFileSystem`, with a per-mount root in the backend's keyspace, and
7//   * a **current working directory** — relative paths resolve against it.
8//
9// Resolution is uniform and POSIX-shaped: normalize the input against cwd,
10// collapse `.`/`..`, then dispatch to the longest matching mount. `/workspace`
11// is just a mount point (and the default cwd), not a magic prefix re-implemented
12// in every store. Adding `/outputs`, `/.agents/skills`, or volume mounts backed
13// by *different* stores later is `with_mount(...)` — the resolver does not change.
14//
15// Today there is a single workspace backend, so the table holds the root mount
16// (`/` → backend, for legacy backend-native paths like `/AGENTS.md`,
17// `/outputs/…`) and the `/workspace` view of the same backend. Both resolve to
18// the same files; `/workspace` wins by longest-prefix so `/workspace/foo`
19// ≡ `/foo`.
20//
21// See `specs/file-store.md` for the contract and the migration plan.
22
23use 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
31/// The conventional mount point and default cwd for the workspace. Models
32/// trained on cloud-agent layouts address files here; it is a real mount, not a
33/// strip-prefix. Same string as [`crate::session_path::WORKSPACE_PREFIX`] (the
34/// display alias) — kept as one source of truth.
35pub const WORKSPACE_MOUNT: &str = crate::session_path::WORKSPACE_PREFIX;
36
37/// A single entry in the mount table.
38#[derive(Clone)]
39struct Mount {
40    /// Virtual mount point: normalized, absolute, no trailing slash (`/` for
41    /// root).
42    mount_point: String,
43    /// Backend serving this mount.
44    backend: Arc<dyn SessionFileSystem>,
45    /// Path inside the backend's own keyspace that `mount_point` maps to.
46    backend_root: String,
47    /// Whether returned backend paths should remain in the backend's canonical
48    /// keyspace. The root and `/workspace` mounts preserve the legacy primary
49    /// workspace contract (`/src/lib.rs`); named mounts report their mounted
50    /// virtual path (`/workspace/roots/backend/src/lib.rs`).
51    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
63/// Mount-based resolver. Implements `SessionFileSystem`, so it drops into
64/// `ToolContext` / `SystemPromptContext` wherever the file store is wired.
65pub struct MountFs {
66    /// Sorted by mount-point length descending so the first match is the
67    /// longest (most specific) mount.
68    mounts: Vec<Mount>,
69    /// The workspace backend — used for grep, display, and host mapping.
70    primary: Arc<dyn SessionFileSystem>,
71    /// Current working directory (a normalized virtual path). Relative inputs
72    /// resolve against it; defaults to [`WORKSPACE_MOUNT`]. Fixed at
73    /// construction — persistent `cd` across tool calls is not a feature yet.
74    cwd: String,
75}
76
77impl MountFs {
78    /// Build a resolver over a single workspace backend.
79    ///
80    /// The backend is mounted at both `/` (its native keyspace, for legacy
81    /// absolute paths) and `/workspace` (the model-facing view). cwd defaults to
82    /// `/workspace`.
83    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    /// Build a resolver and return it as a trait object.
108    pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
109        Arc::new(Self::new(workspace))
110    }
111
112    /// Wrap only when `workspace` is not already a [`MountFs`].
113    ///
114    /// Re-wrapping would collapse nested mount tables (e.g. multi-root
115    /// workspaces) into a single primary view and break named-mount display.
116    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    /// Register an additional mount (e.g. a read-only skills source or a named
125    /// volume) backed by a different store. Longest-prefix wins at resolution.
126    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    /// The current working directory (normalized virtual path).
143    pub fn cwd(&self) -> String {
144        self.cwd.clone()
145    }
146
147    fn sort_mounts(&mut self) {
148        // Longest mount point first, so resolution picks the most specific mount.
149        self.mounts
150            .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
151    }
152
153    /// Resolve any input path to `(backend, backend_path)`.
154    ///
155    /// Relative inputs are joined to cwd; `.`/`..` are collapsed (clamped at
156    /// root); the longest matching mount is selected and the remainder is mapped
157    /// into that backend's keyspace.
158    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        // The root mount matches every absolute path, so this is unreachable in
173        // practice; fall back to the primary backend with the literal path.
174        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
238/// Normalize an input into an absolute virtual path: join cwd if relative, then
239/// collapse `.`/`..` segments (a leading `..` is clamped at root).
240fn 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
293/// If `virtual_path` is at or under `mount_point`, return the suffix as a
294/// `/`-rooted remainder (`/` for an exact match). Segment-aware: `/workspacefoo`
295/// is not under `/workspace`.
296fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
297    if mount_point == "/" {
298        // The root mount owns the whole path.
299        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
310/// Join a backend root with a `/`-rooted remainder into a backend keyspace path.
311fn 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        // The absolute virtual path: relative inputs resolve against cwd,
333        // `.`/`..` collapse, leading `..` clamps at root. This is the namespace
334        // the shell sees — `/workspace` is just the default cwd, and any path
335        // is reachable from the root mount.
336        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    // A minimal `/`-rooted in-memory backend for resolver tests (kept local to
534    // avoid a dependency on everruns-runtime).
535    #[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        // Write via the /workspace view; read back via the backend-native path.
666        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        // The backend keyed it at /src/lib.rs (no /workspace in the keyspace).
672        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        // cwd is /workspace, so the relative write landed at backend /notes.md.
685        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        // Internal callers write /outputs/... and /AGENTS.md directly.
694        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        // It went to the volume backend at /report.csv, not the workspace.
725        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}