use super::{
WorkspaceDirEntry, WorkspaceError, WorkspaceFileSystem, WorkspaceFileSystemExt, WorkspacePath,
WorkspaceResult, WorkspaceVersionConflict, WorkspaceWriteOutcome,
};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub(crate) async fn assert_filesystem_conformance(fs: Arc<dyn WorkspaceFileSystem>, ctx: &str) {
fs_read_after_write_roundtrip(&fs, ctx).await;
fs_read_nonexistent_errors(&fs, ctx).await;
fs_write_overwrites_existing(&fs, ctx).await;
fs_write_creates_parent_components(&fs, ctx).await;
fs_list_dir_root_succeeds(&fs, ctx).await;
fs_list_dir_after_write_sees_the_entry(&fs, ctx).await;
fs_list_dir_nonexistent_errors(&fs, ctx).await;
}
pub(crate) async fn assert_filesystem_ext_conformance(
fs: Arc<dyn WorkspaceFileSystem>,
ext: Arc<dyn WorkspaceFileSystemExt>,
ctx: &str,
) {
ext_version_token_is_non_empty(&fs, &ext, ctx).await;
ext_write_with_matching_version_succeeds(&fs, &ext, ctx).await;
ext_write_with_stale_version_yields_conflict(&fs, &ext, ctx).await;
ext_empty_expected_version_is_rejected(&ext, ctx).await;
}
async fn fs_read_after_write_roundtrip(fs: &Arc<dyn WorkspaceFileSystem>, ctx: &str) {
let path = WorkspacePath::from_normalized("conformance/roundtrip.txt");
fs.write_text(&path, "hello world")
.await
.unwrap_or_else(|e| panic!("[{ctx}] write_text failed: {e}"));
let content = fs
.read_text(&path)
.await
.unwrap_or_else(|e| panic!("[{ctx}] read_text after write failed: {e}"));
assert_eq!(
content, "hello world",
"[{ctx}] read_text must return exactly what write_text wrote"
);
}
async fn fs_read_nonexistent_errors(fs: &Arc<dyn WorkspaceFileSystem>, ctx: &str) {
let path = WorkspacePath::from_normalized("conformance/definitely-not-there.txt");
let result = fs.read_text(&path).await;
assert!(
result.is_err(),
"[{ctx}] read_text on a path that was never written must error, got Ok({:?})",
result.ok()
);
}
async fn fs_write_overwrites_existing(fs: &Arc<dyn WorkspaceFileSystem>, ctx: &str) {
let path = WorkspacePath::from_normalized("conformance/overwrite.txt");
fs.write_text(&path, "v1").await.unwrap();
fs.write_text(&path, "v2").await.unwrap();
let content = fs.read_text(&path).await.unwrap();
assert_eq!(
content, "v2",
"[{ctx}] second write_text must overwrite first"
);
}
async fn fs_write_creates_parent_components(fs: &Arc<dyn WorkspaceFileSystem>, ctx: &str) {
let path = WorkspacePath::from_normalized("conformance/deep/nested/path/file.txt");
fs.write_text(&path, "deep").await.unwrap_or_else(|e| {
panic!("[{ctx}] write_text must create missing parent components: {e}")
});
let content = fs.read_text(&path).await.unwrap();
assert_eq!(content, "deep", "[{ctx}] write_text round-trip at depth");
}
async fn fs_list_dir_root_succeeds(fs: &Arc<dyn WorkspaceFileSystem>, ctx: &str) {
let result = fs.list_dir(&WorkspacePath::root()).await;
assert!(
result.is_ok(),
"[{ctx}] list_dir on the workspace root must always succeed (may be empty), got {:?}",
result
);
}
async fn fs_list_dir_after_write_sees_the_entry(fs: &Arc<dyn WorkspaceFileSystem>, ctx: &str) {
let dir = WorkspacePath::from_normalized("conformance/listing-test");
let file = WorkspacePath::from_normalized("conformance/listing-test/visible.txt");
fs.write_text(&file, "hello").await.unwrap();
let entries = fs
.list_dir(&dir)
.await
.unwrap_or_else(|e| panic!("[{ctx}] list_dir after write failed: {e}"));
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(
names.contains(&"visible.txt"),
"[{ctx}] list_dir must include just-written entry; got {names:?}"
);
}
async fn fs_list_dir_nonexistent_errors(fs: &Arc<dyn WorkspaceFileSystem>, ctx: &str) {
let path = WorkspacePath::from_normalized("conformance/never-created");
let result = fs.list_dir(&path).await;
assert!(
result.is_err(),
"[{ctx}] list_dir on a nonexistent path must error, got Ok({:?}); \
a silent empty result masks user typos",
result.ok()
);
}
async fn ext_version_token_is_non_empty(
fs: &Arc<dyn WorkspaceFileSystem>,
ext: &Arc<dyn WorkspaceFileSystemExt>,
ctx: &str,
) {
let path = WorkspacePath::from_normalized("conformance/cas-version.txt");
fs.write_text(&path, "seed").await.unwrap();
let (content, version) = ext
.read_text_with_version(&path)
.await
.unwrap_or_else(|e| panic!("[{ctx}] read_text_with_version failed: {e}"));
assert_eq!(content, "seed");
assert!(
!version.is_empty(),
"[{ctx}] read_text_with_version must return a non-empty opaque token"
);
}
async fn ext_write_with_matching_version_succeeds(
fs: &Arc<dyn WorkspaceFileSystem>,
ext: &Arc<dyn WorkspaceFileSystemExt>,
ctx: &str,
) {
let path = WorkspacePath::from_normalized("conformance/cas-match.txt");
fs.write_text(&path, "v0").await.unwrap();
let (_, version) = ext.read_text_with_version(&path).await.unwrap();
ext.write_text_if_version(&path, "v1", &version)
.await
.unwrap_or_else(|e| {
panic!("[{ctx}] write_text_if_version with matching version must succeed: {e}")
});
assert_eq!(fs.read_text(&path).await.unwrap(), "v1");
}
async fn ext_write_with_stale_version_yields_conflict(
fs: &Arc<dyn WorkspaceFileSystem>,
ext: &Arc<dyn WorkspaceFileSystemExt>,
ctx: &str,
) {
let path = WorkspacePath::from_normalized("conformance/cas-conflict.txt");
fs.write_text(&path, "alpha").await.unwrap();
let (_, stale_version) = ext.read_text_with_version(&path).await.unwrap();
fs.write_text(&path, "concurrent").await.unwrap();
let err = ext
.write_text_if_version(&path, "beta", &stale_version)
.await
.expect_err(&format!(
"[{ctx}] write_text_if_version with stale version must reject"
));
assert!(
matches!(err, WorkspaceError::VersionConflict(_)),
"[{ctx}] CAS rejection must produce WorkspaceError::VersionConflict; got: {err:?}"
);
}
async fn ext_empty_expected_version_is_rejected(ext: &Arc<dyn WorkspaceFileSystemExt>, ctx: &str) {
let path = WorkspacePath::from_normalized("conformance/cas-empty-ver.txt");
let err = ext
.write_text_if_version(&path, "anything", "")
.await
.expect_err(&format!(
"[{ctx}] write_text_if_version with empty expected version must reject"
));
let _ = err;
}
struct InMemoryState {
files: HashMap<String, (String, String)>,
counter: u64,
}
pub(crate) struct InMemoryFileSystem {
state: Mutex<InMemoryState>,
}
impl InMemoryFileSystem {
pub(crate) fn new() -> Self {
Self {
state: Mutex::new(InMemoryState {
files: HashMap::new(),
counter: 0,
}),
}
}
fn bump_version(state: &mut InMemoryState) -> String {
state.counter += 1;
format!("v{}", state.counter)
}
}
impl Default for InMemoryFileSystem {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl WorkspaceFileSystem for InMemoryFileSystem {
async fn read_text(&self, path: &WorkspacePath) -> WorkspaceResult<String> {
self.state
.lock()
.unwrap()
.files
.get(path.as_str())
.map(|(c, _)| c.clone())
.ok_or_else(|| WorkspaceError::NotFound {
path: path.as_str().to_string(),
})
}
async fn write_text(
&self,
path: &WorkspacePath,
content: &str,
) -> WorkspaceResult<WorkspaceWriteOutcome> {
let mut state = self.state.lock().unwrap();
let version = Self::bump_version(&mut state);
state
.files
.insert(path.as_str().to_string(), (content.to_string(), version));
Ok(WorkspaceWriteOutcome {
bytes: content.len(),
lines: content.lines().count(),
})
}
async fn list_dir(&self, path: &WorkspacePath) -> WorkspaceResult<Vec<WorkspaceDirEntry>> {
let prefix: String = if path.is_root() {
String::new()
} else {
format!("{}/", path.as_str())
};
let state = self.state.lock().unwrap();
let mut seen_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut entries: Vec<WorkspaceDirEntry> = Vec::new();
let mut any = false;
for (key, (content, _)) in state.files.iter() {
let Some(rest) = key.strip_prefix(&prefix) else {
continue;
};
if rest.is_empty() {
continue;
}
any = true;
if let Some((dir_name, _)) = rest.split_once('/') {
if seen_dirs.insert(dir_name.to_string()) {
entries.push(WorkspaceDirEntry {
name: dir_name.to_string(),
kind: super::WorkspaceFileType::Directory,
size: 0,
});
}
} else {
entries.push(WorkspaceDirEntry {
name: rest.to_string(),
kind: super::WorkspaceFileType::File,
size: content.len() as u64,
});
}
}
if !path.is_root() && !any {
return Err(WorkspaceError::NotFound {
path: path.as_str().to_string(),
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
}
#[async_trait]
impl WorkspaceFileSystemExt for InMemoryFileSystem {
async fn read_text_with_version(
&self,
path: &WorkspacePath,
) -> WorkspaceResult<(String, String)> {
self.state
.lock()
.unwrap()
.files
.get(path.as_str())
.cloned()
.ok_or_else(|| WorkspaceError::NotFound {
path: path.as_str().to_string(),
})
}
async fn write_text_if_version(
&self,
path: &WorkspacePath,
content: &str,
expected_version: &str,
) -> WorkspaceResult<WorkspaceWriteOutcome> {
if expected_version.is_empty() {
return Err(WorkspaceError::InvalidArgument {
message: "expected_version must not be empty".to_string(),
});
}
let mut state = self.state.lock().unwrap();
let actual = state.files.get(path.as_str()).map(|(_, v)| v.clone());
match actual {
Some(actual) if actual == expected_version => {
let new_version = Self::bump_version(&mut state);
state.files.insert(
path.as_str().to_string(),
(content.to_string(), new_version),
);
Ok(WorkspaceWriteOutcome {
bytes: content.len(),
lines: content.lines().count(),
})
}
Some(actual) => Err(WorkspaceError::VersionConflict(WorkspaceVersionConflict {
path: path.as_str().to_string(),
expected: expected_version.to_string(),
actual: Some(actual),
})),
None => Err(WorkspaceError::NotFound {
path: path.as_str().to_string(),
}),
}
}
}
#[cfg(test)]
mod self_tests {
use super::*;
use crate::workspace::LocalWorkspaceBackend;
#[tokio::test]
async fn in_memory_backend_satisfies_filesystem_conformance() {
let fs: Arc<dyn WorkspaceFileSystem> = Arc::new(InMemoryFileSystem::new());
assert_filesystem_conformance(fs, "InMemoryFileSystem").await;
}
#[tokio::test]
async fn in_memory_backend_satisfies_filesystem_ext_conformance() {
let backend = Arc::new(InMemoryFileSystem::new());
let fs: Arc<dyn WorkspaceFileSystem> = backend.clone();
let ext: Arc<dyn WorkspaceFileSystemExt> = backend;
assert_filesystem_ext_conformance(fs, ext, "InMemoryFileSystem").await;
}
#[tokio::test]
async fn local_backend_satisfies_filesystem_conformance() {
let temp = tempfile::tempdir().unwrap();
let fs: Arc<dyn WorkspaceFileSystem> =
Arc::new(LocalWorkspaceBackend::new(temp.path().to_path_buf()));
assert_filesystem_conformance(fs, "LocalWorkspaceBackend").await;
}
}