use async_trait::async_trait;
use std::sync::Arc;
use crate::error::Result;
use crate::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
use crate::traits::SessionFileSystem;
use crate::typed_id::SessionId;
pub const WORKSPACE_MOUNT: &str = crate::session_path::WORKSPACE_PREFIX;
#[derive(Clone)]
struct Mount {
mount_point: String,
backend: Arc<dyn SessionFileSystem>,
backend_root: String,
}
pub struct MountFs {
mounts: Vec<Mount>,
primary: Arc<dyn SessionFileSystem>,
cwd: String,
}
impl MountFs {
pub fn new(workspace: Arc<dyn SessionFileSystem>) -> Self {
let mounts = vec![
Mount {
mount_point: "/".to_string(),
backend: workspace.clone(),
backend_root: "/".to_string(),
},
Mount {
mount_point: WORKSPACE_MOUNT.to_string(),
backend: workspace.clone(),
backend_root: "/".to_string(),
},
];
let mut fs = Self {
mounts,
primary: workspace,
cwd: WORKSPACE_MOUNT.to_string(),
};
fs.sort_mounts();
fs
}
pub fn wrap(workspace: Arc<dyn SessionFileSystem>) -> Arc<dyn SessionFileSystem> {
Arc::new(Self::new(workspace))
}
pub fn with_mount(
mut self,
mount_point: impl Into<String>,
backend: Arc<dyn SessionFileSystem>,
backend_root: impl Into<String>,
) -> Self {
self.mounts.push(Mount {
mount_point: normalize_virtual(&mount_point.into(), "/"),
backend,
backend_root: normalize_virtual(&backend_root.into(), "/"),
});
self.sort_mounts();
self
}
pub fn cwd(&self) -> String {
self.cwd.clone()
}
fn sort_mounts(&mut self) {
self.mounts
.sort_by_key(|m| std::cmp::Reverse(m.mount_point.len()));
}
fn resolve(&self, input: &str) -> (Arc<dyn SessionFileSystem>, String) {
let virtual_path = normalize_virtual(input, &self.cwd());
for mount in &self.mounts {
if let Some(rest) = mount_suffix(&mount.mount_point, &virtual_path) {
return (
mount.backend.clone(),
join_backend_path(&mount.backend_root, &rest),
);
}
}
(self.primary.clone(), virtual_path)
}
}
fn normalize_virtual(input: &str, cwd: &str) -> String {
let combined = if input.starts_with('/') {
input.to_string()
} else {
format!("{}/{}", cwd.trim_end_matches('/'), input)
};
let mut stack: Vec<&str> = Vec::new();
for segment in combined.split('/') {
match segment {
"" | "." => {}
".." => {
stack.pop();
}
other => stack.push(other),
}
}
if stack.is_empty() {
"/".to_string()
} else {
format!("/{}", stack.join("/"))
}
}
fn mount_suffix(mount_point: &str, virtual_path: &str) -> Option<String> {
if mount_point == "/" {
return Some(virtual_path.to_string());
}
if virtual_path == mount_point {
return Some("/".to_string());
}
virtual_path
.strip_prefix(mount_point)
.filter(|rest| rest.starts_with('/'))
.map(|rest| rest.to_string())
}
fn join_backend_path(backend_root: &str, rest: &str) -> String {
if backend_root == "/" {
return rest.to_string();
}
if rest == "/" {
return backend_root.to_string();
}
format!("{backend_root}{rest}")
}
#[async_trait]
impl SessionFileSystem for MountFs {
fn display_root(&self) -> String {
WORKSPACE_MOUNT.to_string()
}
fn resolve_path(&self, input: &str) -> String {
normalize_virtual(input, &self.cwd())
}
fn display_path(&self, path: &str) -> String {
crate::session_path::to_display_path(path)
}
async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
let (backend, backend_path) = self.resolve(path);
backend.read_file(session_id, &backend_path).await
}
async fn write_file(
&self,
session_id: SessionId,
path: &str,
content: &str,
encoding: &str,
) -> Result<SessionFile> {
let (backend, backend_path) = self.resolve(path);
backend
.write_file(session_id, &backend_path, content, encoding)
.await
}
async fn write_file_if_content_matches(
&self,
session_id: SessionId,
path: &str,
expected_content: &str,
expected_encoding: &str,
content: &str,
encoding: &str,
) -> Result<Option<SessionFile>> {
let (backend, backend_path) = self.resolve(path);
backend
.write_file_if_content_matches(
session_id,
&backend_path,
expected_content,
expected_encoding,
content,
encoding,
)
.await
}
async fn delete_file(
&self,
session_id: SessionId,
path: &str,
recursive: bool,
) -> Result<bool> {
let (backend, backend_path) = self.resolve(path);
backend
.delete_file(session_id, &backend_path, recursive)
.await
}
async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
let (backend, backend_path) = self.resolve(path);
backend.list_directory(session_id, &backend_path).await
}
async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
let (backend, backend_path) = self.resolve(path);
backend.stat_file(session_id, &backend_path).await
}
async fn grep_files(
&self,
session_id: SessionId,
pattern: &str,
path_pattern: Option<&str>,
) -> Result<Vec<GrepMatch>> {
match path_pattern {
Some(pp) => {
let (backend, backend_path) = self.resolve(pp);
backend
.grep_files(session_id, pattern, Some(&backend_path))
.await
}
None => self.primary.grep_files(session_id, pattern, None).await,
}
}
async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
let (backend, backend_path) = self.resolve(path);
backend.create_directory(session_id, &backend_path).await
}
async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
let (backend, backend_path) = self.resolve(&file.path);
let seeded = InitialFile {
path: backend_path,
content: file.content.clone(),
encoding: file.encoding.clone(),
is_readonly: file.is_readonly,
};
backend.seed_initial_file(session_id, &seeded).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sid() -> SessionId {
SessionId::from_seed(1)
}
#[derive(Default)]
struct FlatStore {
files: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
#[async_trait]
impl SessionFileSystem for FlatStore {
async fn read_file(&self, sid: SessionId, path: &str) -> Result<Option<SessionFile>> {
let files = self.files.lock().unwrap();
Ok(files.get(path).map(|content| SessionFile {
id: uuid::Uuid::nil(),
session_id: sid.uuid(),
path: path.to_string(),
name: path.rsplit('/').next().unwrap_or("").to_string(),
content: Some(content.clone()),
encoding: "text".to_string(),
is_directory: false,
is_readonly: false,
size_bytes: content.len() as i64,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}))
}
async fn write_file(
&self,
sid: SessionId,
path: &str,
content: &str,
encoding: &str,
) -> Result<SessionFile> {
self.files
.lock()
.unwrap()
.insert(path.to_string(), content.to_string());
Ok(SessionFile {
id: uuid::Uuid::nil(),
session_id: sid.uuid(),
path: path.to_string(),
name: path.rsplit('/').next().unwrap_or("").to_string(),
content: Some(content.to_string()),
encoding: encoding.to_string(),
is_directory: false,
is_readonly: false,
size_bytes: content.len() as i64,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
})
}
async fn delete_file(&self, _: SessionId, path: &str, _: bool) -> Result<bool> {
Ok(self.files.lock().unwrap().remove(path).is_some())
}
async fn list_directory(&self, _: SessionId, _: &str) -> Result<Vec<FileInfo>> {
Ok(vec![])
}
async fn stat_file(&self, _: SessionId, _: &str) -> Result<Option<FileStat>> {
Ok(None)
}
async fn grep_files(
&self,
_: SessionId,
_: &str,
_: Option<&str>,
) -> Result<Vec<GrepMatch>> {
Ok(vec![])
}
async fn create_directory(&self, sid: SessionId, path: &str) -> Result<FileInfo> {
Ok(FileInfo {
id: uuid::Uuid::nil(),
session_id: sid.uuid(),
name: path.rsplit('/').next().unwrap_or("").to_string(),
path: path.to_string(),
is_directory: true,
is_readonly: false,
size_bytes: 0,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
})
}
}
#[test]
fn normalize_resolves_relative_against_cwd() {
assert_eq!(
normalize_virtual("foo/bar", "/workspace"),
"/workspace/foo/bar"
);
assert_eq!(normalize_virtual("/foo", "/workspace"), "/foo");
assert_eq!(normalize_virtual("a/../b", "/workspace"), "/workspace/b");
assert_eq!(normalize_virtual("../../x", "/workspace"), "/x");
assert_eq!(normalize_virtual(".", "/workspace"), "/workspace");
assert_eq!(normalize_virtual("/", "/workspace"), "/");
}
#[tokio::test]
async fn workspace_and_root_address_the_same_file() {
let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
let fs = MountFs::new(backend);
fs.write_file(sid(), "/workspace/src/lib.rs", "X", "text")
.await
.unwrap();
let via_root = fs.read_file(sid(), "/src/lib.rs").await.unwrap().unwrap();
assert_eq!(via_root.content.as_deref(), Some("X"));
assert_eq!(via_root.path, "/src/lib.rs");
}
#[tokio::test]
async fn relative_paths_resolve_against_cwd() {
let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
let fs = MountFs::new(backend);
assert_eq!(fs.cwd(), "/workspace");
fs.write_file(sid(), "notes.md", "hi", "text")
.await
.unwrap();
let read = fs.read_file(sid(), "/notes.md").await.unwrap().unwrap();
assert_eq!(read.content.as_deref(), Some("hi"));
}
#[tokio::test]
async fn legacy_subtree_paths_pass_through_root_mount() {
let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
let fs = MountFs::new(backend);
fs.write_file(sid(), "/outputs/call.stdout", "out", "text")
.await
.unwrap();
let read = fs
.read_file(sid(), "/workspace/outputs/call.stdout")
.await
.unwrap()
.unwrap();
assert_eq!(read.content.as_deref(), Some("out"));
}
#[test]
fn display_is_the_workspace_view() {
let backend: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
let fs = MountFs::new(backend);
assert_eq!(fs.display_root(), "/workspace");
assert_eq!(fs.display_path("/src/lib.rs"), "/workspace/src/lib.rs");
assert_eq!(fs.display_path("/"), "/workspace");
}
#[tokio::test]
async fn additional_mount_routes_to_its_backend() {
let workspace: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
let volume: Arc<dyn SessionFileSystem> = Arc::new(FlatStore::default());
let fs = MountFs::new(workspace).with_mount("/data", volume.clone(), "/");
fs.write_file(sid(), "/data/report.csv", "1,2,3", "text")
.await
.unwrap();
let from_volume = volume
.read_file(sid(), "/report.csv")
.await
.unwrap()
.unwrap();
assert_eq!(from_volume.content.as_deref(), Some("1,2,3"));
}
}