1use async_trait::async_trait;
24use std::sync::Arc;
25
26use crate::error::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}
48
49pub struct MountFs {
52 mounts: Vec<Mount>,
55 primary: Arc<dyn SessionFileSystem>,
57 cwd: String,
61}
62
63impl MountFs {
64 pub fn new(workspace: Arc<dyn SessionFileSystem>) -> Self {
70 let mounts = vec![
71 Mount {
72 mount_point: "/".to_string(),
73 backend: workspace.clone(),
74 backend_root: "/".to_string(),
75 },
76 Mount {
77 mount_point: WORKSPACE_MOUNT.to_string(),
78 backend: workspace.clone(),
79 backend_root: "/".to_string(),
80 },
81 ];
82 let mut fs = Self {
83 mounts,
84 primary: workspace,
85 cwd: WORKSPACE_MOUNT.to_string(),
86 };
87 fs.sort_mounts();
88 fs
89 }
90
91 pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
93 Arc::new(Self::new(workspace))
94 }
95
96 pub fn with_mount(
99 mut self,
100 mount_point: impl Into<String>,
101 backend: Arc<dyn SessionFileSystem>,
102 backend_root: impl Into<String>,
103 ) -> Self {
104 self.mounts.push(Mount {
105 mount_point: normalize_virtual(&mount_point.into(), "/"),
106 backend,
107 backend_root: normalize_virtual(&backend_root.into(), "/"),
108 });
109 self.sort_mounts();
110 self
111 }
112
113 pub fn cwd(&self) -> String {
115 self.cwd.clone()
116 }
117
118 fn sort_mounts(&mut self) {
119 self.mounts
121 .sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
122 }
123
124 fn resolve(&self, input: &str) -> (Arc<dyn SessionFileSystem>, String) {
130 let virtual_path = normalize_virtual(input, &self.cwd());
131 for mount in &self.mounts {
132 if let Some(rest) = mount_suffix(&mount.mount_point, &virtual_path) {
133 return (
134 mount.backend.clone(),
135 join_backend_path(&mount.backend_root, &rest),
136 );
137 }
138 }
139 (self.primary.clone(), virtual_path)
142 }
143}
144
145fn normalize_virtual(input: &str, cwd: &str) -> String {
148 let combined = if input.starts_with('/') {
149 input.to_string()
150 } else {
151 format!("{}/{}", cwd.trim_end_matches('/'), input)
152 };
153 let mut stack: Vec<&str> = Vec::new();
154 for segment in combined.split('/') {
155 match segment {
156 "" | "." => {}
157 ".." => {
158 stack.pop();
159 }
160 other => stack.push(other),
161 }
162 }
163 if stack.is_empty() {
164 "/".to_string()
165 } else {
166 format!("/{}", stack.join("/"))
167 }
168}
169
170fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
174 if mount_point == "/" {
175 return Some(virtual_path.to_string());
177 }
178 if virtual_path == mount_point {
179 return Some("/".to_string());
180 }
181 virtual_path
182 .strip_prefix(mount_point)
183 .filter(|rest| rest.starts_with('/'))
184 .map(|rest| rest.to_string())
185}
186
187fn join_backend_path(backend_root: &str, rest: &str) -> String {
189 if backend_root == "/" {
190 return rest.to_string();
191 }
192 if rest == "/" {
193 return backend_root.to_string();
194 }
195 format!("{backend_root}{rest}")
196}
197
198#[async_trait]
199impl SessionFileSystem for MountFs {
200 fn display_root(&self) -> String {
201 WORKSPACE_MOUNT.to_string()
202 }
203
204 fn resolve_path(&self, input: &str) -> String {
205 normalize_virtual(input, &self.cwd())
210 }
211
212 fn display_path(&self, path: &str) -> String {
213 crate::session_path::to_display_path(path)
216 }
217
218 async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
219 let (backend, backend_path) = self.resolve(path);
220 backend.read_file(session_id, &backend_path).await
221 }
222
223 async fn write_file(
224 &self,
225 session_id: SessionId,
226 path: &str,
227 content: &str,
228 encoding: &str,
229 ) -> Result<SessionFile> {
230 let (backend, backend_path) = self.resolve(path);
231 backend
232 .write_file(session_id, &backend_path, content, encoding)
233 .await
234 }
235
236 async fn write_file_if_content_matches(
237 &self,
238 session_id: SessionId,
239 path: &str,
240 expected_content: &str,
241 expected_encoding: &str,
242 content: &str,
243 encoding: &str,
244 ) -> Result<Option<SessionFile>> {
245 let (backend, backend_path) = self.resolve(path);
246 backend
247 .write_file_if_content_matches(
248 session_id,
249 &backend_path,
250 expected_content,
251 expected_encoding,
252 content,
253 encoding,
254 )
255 .await
256 }
257
258 async fn delete_file(
259 &self,
260 session_id: SessionId,
261 path: &str,
262 recursive: bool,
263 ) -> Result<bool> {
264 let (backend, backend_path) = self.resolve(path);
265 backend
266 .delete_file(session_id, &backend_path, recursive)
267 .await
268 }
269
270 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
271 let (backend, backend_path) = self.resolve(path);
272 backend.list_directory(session_id, &backend_path).await
273 }
274
275 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
276 let (backend, backend_path) = self.resolve(path);
277 backend.stat_file(session_id, &backend_path).await
278 }
279
280 async fn grep_files(
281 &self,
282 session_id: SessionId,
283 pattern: &str,
284 path_pattern: Option<&str>,
285 ) -> Result<Vec<GrepMatch>> {
286 match path_pattern {
287 Some(pp) => {
288 let (backend, backend_path) = self.resolve(pp);
289 backend
290 .grep_files(session_id, pattern, Some(&backend_path))
291 .await
292 }
293 None => self.primary.grep_files(session_id, pattern, None).await,
294 }
295 }
296
297 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
298 let (backend, backend_path) = self.resolve(path);
299 backend.create_directory(session_id, &backend_path).await
300 }
301
302 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
303 let (backend, backend_path) = self.resolve(&file.path);
304 let seeded = InitialFile {
305 path: backend_path,
306 content: file.content.clone(),
307 encoding: file.encoding.clone(),
308 is_readonly: file.is_readonly,
309 };
310 backend.seed_initial_file(session_id, &seeded).await
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn sid() -> SessionId {
319 SessionId::from_seed(1)
320 }
321
322 #[derive(Default)]
325 struct FlatStore {
326 files: std::sync::Mutex<std::collections::HashMap<String, String>>,
327 }
328
329 #[async_trait]
330 impl SessionFileSystem for FlatStore {
331 async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
332 let files = self.files.lock().unwrap();
333 Ok(files.get(path).map(|content| SessionFile {
334 id: uuid::Uuid::nil(),
335 session_id: sid.uuid(),
336 path: path.to_string(),
337 name: path.rsplit('/').next().unwrap_or("").to_string(),
338 content: Some(content.clone()),
339 encoding: "text".to_string(),
340 is_directory: false,
341 is_readonly: false,
342 size_bytes: content.len() as i64,
343 created_at: chrono::Utc::now(),
344 updated_at: chrono::Utc::now(),
345 }))
346 }
347 async fn write_file(
348 &self,
349 sid: SessionId,
350 path: &str,
351 content: &str,
352 encoding: &str,
353 ) -> Result<SessionFile> {
354 self.files
355 .lock()
356 .unwrap()
357 .insert(path.to_string(), content.to_string());
358 Ok(SessionFile {
359 id: uuid::Uuid::nil(),
360 session_id: sid.uuid(),
361 path: path.to_string(),
362 name: path.rsplit('/').next().unwrap_or("").to_string(),
363 content: Some(content.to_string()),
364 encoding: encoding.to_string(),
365 is_directory: false,
366 is_readonly: false,
367 size_bytes: content.len() as i64,
368 created_at: chrono::Utc::now(),
369 updated_at: chrono::Utc::now(),
370 })
371 }
372 async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
373 Ok(self.files.lock().unwrap().remove(path).is_some())
374 }
375 async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
376 Ok(vec![])
377 }
378 async fn stat_file(&self, _: SessionId, _: &str) -> Result<Option<FileStat>> {
379 Ok(None)
380 }
381 async fn grep_files(
382 &self,
383 _: SessionId,
384 _: &str,
385 _: Option<&str>,
386 ) -> Result<Vec<GrepMatch>> {
387 Ok(vec![])
388 }
389 async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
390 Ok(FileInfo {
391 id: uuid::Uuid::nil(),
392 session_id: sid.uuid(),
393 name: path.rsplit('/').next().unwrap_or("").to_string(),
394 path: path.to_string(),
395 is_directory: true,
396 is_readonly: false,
397 size_bytes: 0,
398 created_at: chrono::Utc::now(),
399 updated_at: chrono::Utc::now(),
400 })
401 }
402 }
403
404 #[test]
405 fn normalize_resolves_relative_against_cwd() {
406 assert_eq!(
407 normalize_virtual("foo/bar", "/workspace"),
408 "/workspace/foo/bar"
409 );
410 assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
411 assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
412 assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
413 assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
414 assert_eq!(normalize_virtual("/", "/workspace"), "/");
415 }
416
417 #[tokio::test]
418 async fn workspace_and_root_address_the_same_file() {
419 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
420 let fs = MountFs::new(backend);
421
422 fs.write_file(sid(), "/workspace/src/lib.rs", "X", "text")
424 .await
425 .unwrap();
426 let via_root = fs.read_file(sid(), "/src/lib.rs").await.unwrap().unwrap();
427 assert_eq!(via_root.content.as_deref(), Some("X"));
428 assert_eq!(via_root.path, "/src/lib.rs");
430 }
431
432 #[tokio::test]
433 async fn relative_paths_resolve_against_cwd() {
434 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
435 let fs = MountFs::new(backend);
436 assert_eq!(fs.cwd(), "/workspace");
437
438 fs.write_file(sid(), "notes.md", "hi", "text")
439 .await
440 .unwrap();
441 let read = fs.read_file(sid(), "/notes.md").await.unwrap().unwrap();
443 assert_eq!(read.content.as_deref(), Some("hi"));
444 }
445
446 #[tokio::test]
447 async fn legacy_subtree_paths_pass_through_root_mount() {
448 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
449 let fs = MountFs::new(backend);
450 fs.write_file(sid(), "/outputs/call.stdout", "out", "text")
452 .await
453 .unwrap();
454 let read = fs
455 .read_file(sid(), "/workspace/outputs/call.stdout")
456 .await
457 .unwrap()
458 .unwrap();
459 assert_eq!(read.content.as_deref(), Some("out"));
460 }
461
462 #[test]
463 fn display_is_the_workspace_view() {
464 let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
465 let fs = MountFs::new(backend);
466 assert_eq!(fs.display_root(), "/workspace");
467 assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
468 assert_eq!(fs.display_path("/"), "/workspace");
469 }
470
471 #[tokio::test]
472 async fn additional_mount_routes_to_its_backend() {
473 let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
474 let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
475 let fs = MountFs::new(workspace).with_mount("/data", volume.clone(), "/");
476
477 fs.write_file(sid(), "/data/report.csv", "1,2,3", "text")
478 .await
479 .unwrap();
480 let from_volume = volume
482 .read_file(sid(), "/report.csv")
483 .await
484 .unwrap()
485 .unwrap();
486 assert_eq!(from_volume.content.as_deref(), Some("1,2,3"));
487 }
488}