use crate::types::{FileEntry, FilePath, FilesError, Result};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tempfile::TempDir;
const STALE_ARTIFACT_MIN_AGE: Duration = Duration::from_mins(5);
#[derive(Debug, Clone)]
pub struct FileSystem {
files: HashMap<FilePath, FileEntry>,
}
impl FileSystem {
#[must_use]
pub fn new() -> Self {
Self {
files: HashMap::new(),
}
}
pub fn add_file(&mut self, path: impl AsRef<Path>, content: impl Into<String>) -> Result<()> {
let vfs_path = FilePath::new(path)?;
let file = FileEntry::new(content);
self.files.insert(vfs_path, file);
Ok(())
}
pub fn read_file(&self, path: impl AsRef<Path>) -> Result<&str> {
let vfs_path = FilePath::new(path)?;
self.files
.get(&vfs_path)
.map(FileEntry::content)
.ok_or_else(|| FilesError::FileNotFound {
path: vfs_path.as_str().to_string(),
})
}
#[must_use]
pub fn exists(&self, path: impl AsRef<Path>) -> bool {
FilePath::new(path)
.ok()
.and_then(|p| self.files.get(&p))
.is_some()
}
pub fn list_dir(&self, path: impl AsRef<Path>) -> Result<Vec<FilePath>> {
let vfs_path = FilePath::new(path)?;
let path_str = vfs_path.as_str();
if self.files.contains_key(&vfs_path) {
return Err(FilesError::NotADirectory {
path: path_str.to_string(),
});
}
let mut children = Vec::new();
let normalized_dir = if path_str.ends_with('/') {
path_str.to_string()
} else {
format!("{path_str}/")
};
for file_path in self.files.keys() {
let file_str = file_path.as_str();
if file_str.starts_with(&normalized_dir) {
let relative = &file_str[normalized_dir.len()..];
if !relative.contains('/') && !relative.is_empty() {
children.push(file_path.clone());
} else if let Some(idx) = relative.find('/') {
let subdir = format!("{}{}", normalized_dir, &relative[..idx]);
if let Ok(subdir_path) = FilePath::new(subdir)
&& !children.contains(&subdir_path)
{
children.push(subdir_path);
}
}
}
}
children.sort_by(|a, b| a.as_str().cmp(b.as_str()));
Ok(children)
}
#[must_use]
pub fn file_count(&self) -> usize {
self.files.len()
}
#[must_use]
pub fn all_paths(&self) -> Vec<&FilePath> {
let mut paths: Vec<_> = self.files.keys().collect();
paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));
paths
}
pub fn files(&self) -> impl Iterator<Item = (&FilePath, &FileEntry)> {
self.files.iter()
}
pub fn clear(&mut self) {
self.files.clear();
}
pub fn export_to_filesystem(&self, base_path: impl AsRef<Path>) -> Result<()> {
self.export_to_filesystem_with_options(base_path, &ExportOptions::default())
}
pub fn export_to_filesystem_with_options(
&self,
base_path: impl AsRef<Path>,
options: &ExportOptions,
) -> Result<()> {
let target = base_path.as_ref();
let parent = target.parent().ok_or_else(|| FilesError::InvalidPath {
path: format!("Target path has no parent directory: {}", target.display()),
})?;
if !parent.exists() {
return Err(FilesError::FileNotFound {
path: parent.display().to_string(),
});
}
Self::sweep_stale_artifacts(parent, target);
let staging = tempfile::Builder::new()
.prefix(&Self::staging_prefix(target))
.tempdir_in(parent)
.map_err(|e| FilesError::IoError {
path: parent.display().to_string(),
source: e,
})?;
let canonical_staging =
staging
.path()
.canonicalize()
.map_err(|e| FilesError::InvalidPath {
path: format!("Failed to canonicalize {}: {}", staging.path().display(), e),
})?;
let dirs = self.collect_directories(&canonical_staging);
Self::create_directories(&dirs)?;
self.write_files(&canonical_staging, options)?;
Self::publish_staged_export(staging, target)
}
#[cfg(feature = "parallel")]
pub fn export_to_filesystem_parallel(&self, base_path: impl AsRef<Path>) -> Result<()> {
use rayon::prelude::*;
let base = base_path.as_ref();
let canonical_base = base.canonicalize().map_err(|e| FilesError::InvalidPath {
path: format!("Failed to canonicalize {}: {}", base.display(), e),
})?;
let dirs = self.collect_directories(&canonical_base);
Self::create_directories(&dirs)?;
let files: Vec<_> = self.files().collect();
let options = ExportOptions::default();
files
.par_iter()
.try_for_each(|(vfs_path, file)| -> Result<()> {
let disk_path = Self::vfs_to_disk_path(vfs_path.as_str(), &canonical_base);
write_file_atomic(&disk_path, file.content(), options.atomic)
})?;
Ok(())
}
fn collect_directories(&self, base: &Path) -> HashSet<PathBuf> {
let mut dirs = HashSet::new();
for (vfs_path, _) in self.files() {
let disk_path = Self::vfs_to_disk_path(vfs_path.as_str(), base);
if let Some(parent) = disk_path.parent() {
let mut current = parent;
while current != base && dirs.insert(current.to_path_buf()) {
if let Some(p) = current.parent() {
current = p;
} else {
break;
}
}
}
}
dirs
}
fn create_directories(dirs: &HashSet<PathBuf>) -> Result<()> {
for dir in dirs {
fs::create_dir_all(dir).map_err(|e| FilesError::InvalidPath {
path: format!("Failed to create directory {}: {}", dir.display(), e),
})?;
}
Ok(())
}
fn write_files(&self, staging_base: &Path, options: &ExportOptions) -> Result<()> {
for (vfs_path, file) in self.files() {
let staging_disk_path = Self::vfs_to_disk_path(vfs_path.as_str(), staging_base);
write_file_atomic(&staging_disk_path, file.content(), options.atomic)?;
}
Ok(())
}
fn publish_staged_export(staging: TempDir, target: &Path) -> Result<()> {
let staging_path = staging.keep();
if let Err(err) = Self::swap_into_place(&staging_path, target) {
let _ = fs::remove_dir_all(&staging_path);
return Err(err);
}
Ok(())
}
fn swap_into_place(staging_path: &Path, target: &Path) -> Result<()> {
if !target.exists() {
return fs::rename(staging_path, target).map_err(|e| FilesError::IoError {
path: target.display().to_string(),
source: e,
});
}
let parent = target.parent().ok_or_else(|| FilesError::InvalidPath {
path: format!("Target path has no parent directory: {}", target.display()),
})?;
let displaced = Self::displace_existing_target(target, parent)?;
if let Err(e) = fs::rename(staging_path, target) {
let _ = fs::rename(&displaced, target);
return Err(FilesError::IoError {
path: target.display().to_string(),
source: e,
});
}
let _ = fs::remove_dir_all(&displaced);
Ok(())
}
fn displace_existing_target(target: &Path, parent: &Path) -> Result<PathBuf> {
let displaced = parent.join(Self::unique_sibling_name(target));
Self::touch_dir(target);
fs::rename(target, &displaced).map_err(|e| FilesError::IoError {
path: target.display().to_string(),
source: e,
})?;
Ok(displaced)
}
fn touch_dir(dir: &Path) {
let marker = dir.join(format!(".mtime-touch-{}", std::process::id()));
if fs::File::create(&marker).is_ok() {
let _ = fs::remove_file(&marker);
}
}
fn target_stem(target: &Path) -> &str {
target
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("export")
}
fn staging_prefix(target: &Path) -> String {
format!(".{}.staging-", Self::target_stem(target))
}
fn unique_sibling_name(target: &Path) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let stem = Self::target_stem(target);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
PathBuf::from(format!(
".{stem}.stale-{}-{nanos}-{seq}",
std::process::id()
))
}
fn sweep_stale_artifacts(parent: &Path, target: &Path) {
Self::sweep_stale_artifacts_older_than(parent, target, STALE_ARTIFACT_MIN_AGE);
}
fn sweep_stale_artifacts_older_than(parent: &Path, target: &Path, min_age: Duration) {
let stem = Self::target_stem(target);
let staging_prefix = Self::staging_prefix(target);
let stale_prefix = format!(".{stem}.stale-");
let Ok(entries) = fs::read_dir(parent) else {
return;
};
for entry in entries.flatten() {
let is_orphan = entry.file_name().to_str().is_some_and(|name| {
name.starts_with(&staging_prefix) || name.starts_with(&stale_prefix)
});
if !is_orphan {
continue;
}
let old_enough = entry
.metadata()
.and_then(|m| m.modified())
.is_ok_and(|modified| {
SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age >= min_age)
});
if old_enough {
let _ = fs::remove_dir_all(entry.path());
}
}
}
fn vfs_to_disk_path(vfs_path: &str, base: &Path) -> PathBuf {
let relative = vfs_path.strip_prefix('/').unwrap_or(vfs_path);
assert!(
!relative.contains(".."),
"SECURITY: Path traversal attempt detected in VFS path: {vfs_path}"
);
let relative_path = if cfg!(target_os = "windows") {
PathBuf::from(relative.replace('/', "\\"))
} else {
PathBuf::from(relative)
};
base.join(relative_path)
}
}
impl Default for FileSystem {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ExportOptions {
pub atomic: bool,
}
impl ExportOptions {
#[must_use]
pub const fn new() -> Self {
Self { atomic: true }
}
#[must_use]
pub const fn with_atomic_writes(mut self, atomic: bool) -> Self {
self.atomic = atomic;
self
}
}
impl Default for ExportOptions {
fn default() -> Self {
Self::new()
}
}
fn write_file_atomic(path: &Path, content: &str, atomic: bool) -> Result<()> {
if atomic {
let temp_path = path.with_added_extension("tmp");
let mut file = fs::File::create(&temp_path).map_err(|e| FilesError::InvalidPath {
path: format!("Failed to create temp file {}: {}", temp_path.display(), e),
})?;
file.write_all(content.as_bytes())
.map_err(|e| FilesError::InvalidPath {
path: format!("Failed to write to {}: {}", temp_path.display(), e),
})?;
file.sync_all().map_err(|e| FilesError::InvalidPath {
path: format!("Failed to sync {}: {}", temp_path.display(), e),
})?;
fs::rename(&temp_path, path).map_err(|e| FilesError::InvalidPath {
path: format!(
"Failed to rename {} to {}: {}",
temp_path.display(),
path.display(),
e
),
})?;
} else {
fs::write(path, content).map_err(|e| FilesError::InvalidPath {
path: format!("Failed to write {}: {}", path.display(), e),
})?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::FilesBuilder;
use tempfile::TempDir;
#[test]
fn test_vfs_new() {
let vfs = FileSystem::new();
assert_eq!(vfs.file_count(), 0);
}
#[test]
fn test_vfs_default() {
let vfs = FileSystem::default();
assert_eq!(vfs.file_count(), 0);
}
#[test]
fn test_add_file() {
let mut vfs = FileSystem::new();
vfs.add_file("/test.ts", "content").unwrap();
assert_eq!(vfs.file_count(), 1);
}
#[test]
fn test_add_file_invalid_path() {
let mut vfs = FileSystem::new();
let result = vfs.add_file("relative/path", "content");
assert!(result.is_err());
}
#[test]
fn test_read_file() {
let mut vfs = FileSystem::new();
vfs.add_file("/test.ts", "hello world").unwrap();
let content = vfs.read_file("/test.ts").unwrap();
assert_eq!(content, "hello world");
}
#[test]
fn test_read_file_not_found() {
let vfs = FileSystem::new();
let result = vfs.read_file("/missing.ts");
assert!(result.is_err());
assert!(result.unwrap_err().is_not_found());
}
#[test]
fn test_exists() {
let mut vfs = FileSystem::new();
vfs.add_file("/exists.ts", "").unwrap();
assert!(vfs.exists("/exists.ts"));
assert!(!vfs.exists("/missing.ts"));
}
#[test]
fn test_exists_invalid_path() {
let vfs = FileSystem::new();
assert!(!vfs.exists("relative/path"));
}
#[test]
fn test_list_dir() {
let mut vfs = FileSystem::new();
vfs.add_file("/mcp-tools/servers/test1.ts", "").unwrap();
vfs.add_file("/mcp-tools/servers/test2.ts", "").unwrap();
let entries = vfs.list_dir("/mcp-tools/servers").unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn test_list_dir_empty() {
let vfs = FileSystem::new();
let entries = vfs.list_dir("/empty").unwrap();
assert_eq!(entries.len(), 0);
}
#[test]
fn test_list_dir_not_a_directory() {
let mut vfs = FileSystem::new();
vfs.add_file("/file.ts", "").unwrap();
let result = vfs.list_dir("/file.ts");
assert!(result.is_err());
assert!(result.unwrap_err().is_not_directory());
}
#[test]
fn test_list_dir_subdirectories() {
let mut vfs = FileSystem::new();
vfs.add_file("/mcp-tools/servers/test/file1.ts", "")
.unwrap();
vfs.add_file("/mcp-tools/servers/test/file2.ts", "")
.unwrap();
vfs.add_file("/mcp-tools/servers/other.ts", "").unwrap();
let entries = vfs.list_dir("/mcp-tools/servers").unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn test_file_count() {
let mut vfs = FileSystem::new();
assert_eq!(vfs.file_count(), 0);
vfs.add_file("/test1.ts", "").unwrap();
assert_eq!(vfs.file_count(), 1);
vfs.add_file("/test2.ts", "").unwrap();
assert_eq!(vfs.file_count(), 2);
}
#[test]
fn test_all_paths() {
let mut vfs = FileSystem::new();
vfs.add_file("/b.ts", "").unwrap();
vfs.add_file("/a.ts", "").unwrap();
let paths = vfs.all_paths();
assert_eq!(paths.len(), 2);
assert_eq!(paths[0].as_str(), "/a.ts");
assert_eq!(paths[1].as_str(), "/b.ts");
}
#[test]
fn test_clear() {
let mut vfs = FileSystem::new();
vfs.add_file("/test1.ts", "").unwrap();
vfs.add_file("/test2.ts", "").unwrap();
assert_eq!(vfs.file_count(), 2);
vfs.clear();
assert_eq!(vfs.file_count(), 0);
}
#[test]
fn test_replace_file() {
let mut vfs = FileSystem::new();
vfs.add_file("/test.ts", "original").unwrap();
assert_eq!(vfs.read_file("/test.ts").unwrap(), "original");
vfs.add_file("/test.ts", "updated").unwrap();
assert_eq!(vfs.read_file("/test.ts").unwrap(), "updated");
assert_eq!(vfs.file_count(), 1);
}
#[test]
fn test_vfs_is_send_sync() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<FileSystem>();
assert_sync::<FileSystem>();
}
#[test]
fn test_export_single_file() {
let temp = TempDir::new().unwrap();
let vfs = FilesBuilder::new()
.add_file("/test.ts", "export const VERSION = '1.0';")
.build()
.unwrap();
vfs.export_to_filesystem(temp.path()).unwrap();
let exported = temp.path().join("test.ts");
assert!(exported.exists());
assert_eq!(
fs::read_to_string(exported).unwrap(),
"export const VERSION = '1.0';"
);
}
#[test]
fn test_export_nested_files() {
let temp = TempDir::new().unwrap();
let vfs = FilesBuilder::new()
.add_file("/tools/create.ts", "export function create() {}")
.add_file("/tools/update.ts", "export function update() {}")
.add_file("/manifest.json", "{}")
.build()
.unwrap();
vfs.export_to_filesystem(temp.path()).unwrap();
assert!(temp.path().join("tools/create.ts").exists());
assert!(temp.path().join("tools/update.ts").exists());
assert!(temp.path().join("manifest.json").exists());
}
#[test]
fn test_export_overwrite() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("test.ts");
fs::write(&path, "old content").unwrap();
let vfs = FilesBuilder::new()
.add_file("/test.ts", "new content")
.build()
.unwrap();
vfs.export_to_filesystem(temp.path()).unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), "new content");
}
#[test]
fn test_export_atomic_writes() {
let temp = TempDir::new().unwrap();
let vfs = FilesBuilder::new()
.add_file("/test.ts", "atomic content")
.build()
.unwrap();
let options = ExportOptions::default().with_atomic_writes(true);
vfs.export_to_filesystem_with_options(temp.path(), &options)
.unwrap();
let path = temp.path().join("test.ts");
assert!(path.exists());
assert_eq!(fs::read_to_string(path).unwrap(), "atomic content");
let temp_path = temp.path().join("test.tmp");
assert!(!temp_path.exists());
}
#[test]
fn test_export_non_atomic_writes() {
let temp = TempDir::new().unwrap();
let vfs = FilesBuilder::new()
.add_file("/test.ts", "direct content")
.build()
.unwrap();
let options = ExportOptions::default().with_atomic_writes(false);
vfs.export_to_filesystem_with_options(temp.path(), &options)
.unwrap();
let path = temp.path().join("test.ts");
assert_eq!(fs::read_to_string(path).unwrap(), "direct content");
}
#[test]
fn test_export_invalid_base_path() {
let vfs = FilesBuilder::new()
.add_file("/test.ts", "")
.build()
.unwrap();
let result = vfs.export_to_filesystem("/nonexistent/path/that/does/not/exist");
assert!(result.is_err());
}
#[test]
fn test_export_failure_leaves_existing_target_untouched() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("out");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("keep.ts"), "keep").unwrap();
let vfs = FilesBuilder::new()
.add_file("/conflict", "file content")
.add_file("/conflict/child.ts", "child content")
.build()
.unwrap();
let result = vfs.export_to_filesystem(&target);
assert!(result.is_err());
assert!(target.join("keep.ts").exists());
assert_eq!(fs::read_to_string(target.join("keep.ts")).unwrap(), "keep");
assert!(!target.join("conflict").exists());
let siblings: Vec<_> = fs::read_dir(temp.path())
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|entry| entry.path() != target)
.collect();
assert!(siblings.is_empty(), "unexpected siblings: {siblings:?}");
}
#[test]
fn test_swap_into_place_replaces_non_empty_target() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("out");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("old.ts"), "old").unwrap();
let staging = temp.path().join("staging");
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("new.ts"), "new").unwrap();
FileSystem::swap_into_place(&staging, &target).unwrap();
assert!(target.join("new.ts").exists());
assert!(!target.join("old.ts").exists());
assert_eq!(fs::read_to_string(target.join("new.ts")).unwrap(), "new");
}
#[test]
fn test_swap_into_place_rolls_back_on_publish_failure() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("out");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("keep.ts"), "keep").unwrap();
let missing_staging = temp.path().join("does-not-exist-staging");
let result = FileSystem::swap_into_place(&missing_staging, &target);
assert!(result.is_err());
assert!(target.join("keep.ts").exists());
assert_eq!(fs::read_to_string(target.join("keep.ts")).unwrap(), "keep");
}
#[test]
fn test_sweep_stale_artifacts_removes_orphans_only() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("out");
let orphan_staging = temp.path().join(".out.staging-abc123");
let orphan_stale = temp.path().join(".out.stale-999-1-1");
let unrelated = temp.path().join("unrelated-dir");
let other_target_staging = temp.path().join(".other.staging-abc123");
fs::create_dir_all(&orphan_staging).unwrap();
fs::create_dir_all(&orphan_stale).unwrap();
fs::create_dir_all(&unrelated).unwrap();
fs::create_dir_all(&other_target_staging).unwrap();
FileSystem::sweep_stale_artifacts_older_than(temp.path(), &target, Duration::ZERO);
assert!(!orphan_staging.exists());
assert!(!orphan_stale.exists());
assert!(unrelated.exists());
assert!(other_target_staging.exists());
}
#[test]
fn test_export_does_not_sweep_recent_sibling_artifacts() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("out");
fs::create_dir_all(&target).unwrap();
let sibling_staging = tempfile::Builder::new()
.prefix(&FileSystem::staging_prefix(&target))
.tempdir_in(temp.path())
.unwrap();
let sibling_staging_path = sibling_staging.path().to_path_buf();
let decoy_target = temp.path().join("decoy-out");
fs::create_dir_all(&decoy_target).unwrap();
let sibling_displaced =
FileSystem::displace_existing_target(&decoy_target, temp.path()).unwrap();
let vfs = FilesBuilder::new()
.add_file("/tool.ts", "export {}")
.build()
.unwrap();
vfs.export_to_filesystem(&target).unwrap();
assert!(
sibling_staging_path.exists(),
"too young to be a crash orphan"
);
assert!(
sibling_displaced.exists(),
"too young to be a crash orphan (mtime just refreshed)"
);
assert!(target.join("tool.ts").exists());
}
#[test]
fn displace_existing_target_refreshes_displaced_mtime() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("out");
fs::create_dir_all(&target).unwrap();
let target_created_at = fs::metadata(&target).unwrap().modified().unwrap();
std::thread::sleep(Duration::from_millis(1100));
let displaced = FileSystem::displace_existing_target(&target, tmp.path()).unwrap();
let displaced_modified = fs::metadata(&displaced).unwrap().modified().unwrap();
assert!(
displaced_modified > target_created_at,
"displaced's mtime must be refreshed around the rename, not inherited from target"
);
}
#[test]
fn sweep_stale_artifacts_skips_recent_orphans() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("out");
let staging = tempfile::Builder::new()
.prefix(&FileSystem::staging_prefix(&target))
.tempdir_in(tmp.path())
.unwrap();
let staging_path = staging.path().to_path_buf();
FileSystem::sweep_stale_artifacts(tmp.path(), &target);
assert!(staging_path.exists(), "a fresh sibling must not be swept");
}
#[test]
fn sweep_stale_artifacts_older_than_removes_aged_orphans() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("out");
let staging = tempfile::Builder::new()
.prefix(&FileSystem::staging_prefix(&target))
.tempdir_in(tmp.path())
.unwrap();
let staging_path = staging.keep();
FileSystem::sweep_stale_artifacts_older_than(tmp.path(), &target, Duration::ZERO);
assert!(
!staging_path.exists(),
"an orphan past the age threshold must be swept"
);
}
#[test]
fn test_export_to_nonexistent_target_directory() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("fresh-output");
let vfs = FilesBuilder::new()
.add_file("/tools/create.ts", "export function create() {}")
.build()
.unwrap();
assert!(!target.exists());
vfs.export_to_filesystem(&target).unwrap();
assert!(target.join("tools/create.ts").exists());
let siblings: Vec<_> = fs::read_dir(temp.path())
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|entry| entry.path() != target)
.collect();
assert!(siblings.is_empty(), "unexpected siblings: {siblings:?}");
}
#[test]
fn test_publish_staged_export_cleans_up_staging_on_failure() {
let temp = TempDir::new().unwrap();
let staging = TempDir::new_in(temp.path()).unwrap();
fs::write(staging.path().join("file.ts"), "content").unwrap();
let staging_path = staging.path().to_path_buf();
let target = temp.path().join("missing-parent").join("out");
let result = FileSystem::publish_staged_export(staging, &target);
assert!(result.is_err());
assert!(!staging_path.exists());
}
#[test]
fn test_export_many_files() {
let temp = TempDir::new().unwrap();
let mut builder = FilesBuilder::new();
for i in 0..30 {
builder = builder.add_file(
format!("/tools/tool{i}.ts"),
format!("export function tool{i}() {{}}"),
);
}
let vfs = builder.build().unwrap();
vfs.export_to_filesystem(temp.path()).unwrap();
for i in 0..30 {
assert!(temp.path().join(format!("tools/tool{i}.ts")).exists());
}
}
#[test]
fn test_export_deep_nesting() {
let temp = TempDir::new().unwrap();
let vfs = FilesBuilder::new()
.add_file("/a/b/c/d/e/deep.ts", "export {}")
.build()
.unwrap();
vfs.export_to_filesystem(temp.path()).unwrap();
assert!(temp.path().join("a/b/c/d/e/deep.ts").exists());
}
#[test]
#[cfg(feature = "parallel")]
fn test_export_parallel() {
let temp = TempDir::new().unwrap();
let mut builder = FilesBuilder::new();
for i in 0..100 {
builder = builder.add_file(format!("/file{i}.ts"), format!("export const N = {i};"));
}
let vfs = builder.build().unwrap();
vfs.export_to_filesystem_parallel(temp.path()).unwrap();
for i in 0..100 {
let path = temp.path().join(format!("file{i}.ts"));
assert!(path.exists());
}
}
#[test]
fn test_export_options_default() {
let options = ExportOptions::default();
assert!(options.atomic);
}
#[test]
fn test_export_options_builder() {
let options = ExportOptions::new().with_atomic_writes(false);
assert!(!options.atomic);
}
}