Skip to main content

mcp_execution_files/
filesystem.rs

1//! In-memory filesystem and export functionality.
2//!
3//! Provides an in-memory filesystem for MCP tool definitions with
4//! high-performance export to real filesystem.
5//!
6//! # Core Features
7//!
8//! - **In-memory storage**: Files stored in `HashMap` for O(1) lookup
9//! - **Filesystem export**: Sequential and parallel export modes
10//! - **Atomic writes**: Optional atomic file operations
11//! - **Thread-safe**: All types are `Send + Sync`
12//!
13//! # Atomicity
14//!
15//! [`FileSystem::export_to_filesystem`] stages the entire export in a sibling
16//! temporary directory and only publishes it by renaming that directory into
17//! place once every file has been written successfully. A process interrupted
18//! mid-export leaves the previous export (or nothing, on a first export)
19//! untouched at the target path — never a partially written tree.
20//!
21//! # Performance Optimizations
22//!
23//! 1. **Directory Pre-creation**: Creates all directories first in single pass
24//! 2. **Parallel Writes**: Uses rayon for parallel file writing (opt-in)
25//! 3. **Atomic Operations**: Writes to temp file then renames
26//! 4. **Minimal Allocations**: Reuses path buffers, caches canonicalized base
27//!
28//! # Examples
29//!
30//! ## Basic usage
31//!
32//! ```
33//! use mcp_execution_files::FileSystem;
34//!
35//! let mut fs = FileSystem::new();
36//! fs.add_file("/mcp-tools/test.ts", "export const VERSION = '1.0';").unwrap();
37//!
38//! let content = fs.read_file("/mcp-tools/test.ts").unwrap();
39//! assert_eq!(content, "export const VERSION = '1.0';");
40//! ```
41//!
42//! ## Export to filesystem
43//!
44//! ```
45//! use mcp_execution_files::FilesBuilder;
46//! # use tempfile::TempDir;
47//!
48//! # let temp_dir = TempDir::new().unwrap();
49//! # let output_dir = temp_dir.path();
50//! let fs = FilesBuilder::new()
51//!     .add_file("/tools/create.ts", "export function create() {}")
52//!     .add_file("/tools/update.ts", "export function update() {}")
53//!     .build()
54//!     .unwrap();
55//!
56//! // Export to filesystem
57//! fs.export_to_filesystem(output_dir).unwrap();
58//!
59//! assert!(output_dir.join("tools/create.ts").exists());
60//! # Ok::<(), Box<dyn std::error::Error>>(())
61//! ```
62
63use crate::types::{FileEntry, FilePath, FilesError, Result};
64use std::collections::{HashMap, HashSet};
65use std::fs;
66use std::io::Write;
67use std::path::{Path, PathBuf};
68use std::sync::atomic::{AtomicU64, Ordering};
69use std::time::{Duration, SystemTime, UNIX_EPOCH};
70use tempfile::TempDir;
71
72/// Minimum age (by mtime) a staging/stale sibling must have before
73/// [`FileSystem::sweep_stale_artifacts`] will remove it.
74///
75/// A genuinely concurrent sibling export's staging and displaced
76/// directories are kept younger than this: `staging` is freshly created via
77/// `tempfile` for every export, and [`FileSystem::displace_existing_target`]
78/// refreshes `target`'s mtime via [`FileSystem::touch_dir`] immediately
79/// *before* renaming it aside to `displaced` — a rename preserves the
80/// source's mtime, so `displaced` carries that fresh timestamp from the
81/// instant it exists, rather than inheriting `target`'s (typically much
82/// older) original mtime and being immediately sweep-eligible. All of the
83/// work between
84/// creating/refreshing one of these directories and either publishing or
85/// rolling it back happens within a single
86/// `export_to_filesystem_with_options` call, which completes in well under
87/// this window even for large tool sets — assuming that call keeps adding
88/// or removing immediate children of `staging` as it writes, since that is
89/// what keeps its mtime fresh on the filesystems this crate supports.
90/// Gating on age means the sweep can only ever reclaim directories that are
91/// old enough to be leftovers from a process that was killed (e.g.
92/// `SIGKILL`) before it could clean up after itself — never a live
93/// sibling's in-flight artifacts. This is what closes the data-loss race
94/// described in issue #169: previously the sweep matched purely on name, so
95/// a concurrent export of the same target could delete another in-flight
96/// export's staging directory, and — if the timing landed between
97/// `swap_into_place`'s two renames — the displaced original too, defeating
98/// the rollback and permanently losing the target.
99const STALE_ARTIFACT_MIN_AGE: Duration = Duration::from_mins(5);
100
101/// An in-memory virtual filesystem for MCP tool definitions.
102///
103/// `FileSystem` provides a read-only filesystem structure that stores generated
104/// TypeScript files in memory. Files are organized in a hierarchical structure
105/// like `/mcp-tools/servers/<server-id>/...`.
106///
107/// # Thread Safety
108///
109/// This type is `Send` and `Sync`, making it safe to use across threads.
110///
111/// # Examples
112///
113/// ```
114/// use mcp_execution_files::FileSystem;
115///
116/// let mut vfs = FileSystem::new();
117/// vfs.add_file("/mcp-tools/manifest.json", "{}").unwrap();
118///
119/// assert!(vfs.exists("/mcp-tools/manifest.json"));
120/// assert_eq!(vfs.file_count(), 1);
121/// ```
122#[derive(Debug, Clone)]
123pub struct FileSystem {
124    files: HashMap<FilePath, FileEntry>,
125}
126
127impl FileSystem {
128    /// Creates a new empty virtual filesystem.
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use mcp_execution_files::FileSystem;
134    ///
135    /// let vfs = FileSystem::new();
136    /// assert_eq!(vfs.file_count(), 0);
137    /// ```
138    #[must_use]
139    pub fn new() -> Self {
140        Self {
141            files: HashMap::new(),
142        }
143    }
144
145    /// Adds a file to the virtual filesystem.
146    ///
147    /// If a file already exists at the path, it will be replaced.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the path is invalid (not absolute, contains '..', etc.).
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use mcp_execution_files::FileSystem;
157    ///
158    /// let mut vfs = FileSystem::new();
159    /// vfs.add_file("/mcp-tools/test.ts", "console.log('hello');").unwrap();
160    ///
161    /// assert!(vfs.exists("/mcp-tools/test.ts"));
162    /// # Ok::<(), mcp_execution_files::FilesError>(())
163    /// ```
164    pub fn add_file(&mut self, path: impl AsRef<Path>, content: impl Into<String>) -> Result<()> {
165        let vfs_path = FilePath::new(path)?;
166        let file = FileEntry::new(content);
167        self.files.insert(vfs_path, file);
168        Ok(())
169    }
170
171    /// Reads the content of a file.
172    ///
173    /// # Errors
174    ///
175    /// Returns `FilesError::FileNotFound` if the file does not exist.
176    /// Returns `FilesError::InvalidPath` if the path is invalid.
177    ///
178    /// # Examples
179    ///
180    /// ```
181    /// use mcp_execution_files::FileSystem;
182    ///
183    /// let mut vfs = FileSystem::new();
184    /// vfs.add_file("/test.ts", "export {}").unwrap();
185    ///
186    /// let content = vfs.read_file("/test.ts").unwrap();
187    /// assert_eq!(content, "export {}");
188    /// # Ok::<(), mcp_execution_files::FilesError>(())
189    /// ```
190    pub fn read_file(&self, path: impl AsRef<Path>) -> Result<&str> {
191        let vfs_path = FilePath::new(path)?;
192        self.files
193            .get(&vfs_path)
194            .map(FileEntry::content)
195            .ok_or_else(|| FilesError::FileNotFound {
196                path: vfs_path.as_str().to_string(),
197            })
198    }
199
200    /// Checks if a file exists at the given path.
201    ///
202    /// Returns `false` if the path is invalid.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use mcp_execution_files::FileSystem;
208    ///
209    /// let mut vfs = FileSystem::new();
210    /// vfs.add_file("/exists.ts", "").unwrap();
211    ///
212    /// assert!(vfs.exists("/exists.ts"));
213    /// assert!(!vfs.exists("/missing.ts"));
214    /// ```
215    #[must_use]
216    pub fn exists(&self, path: impl AsRef<Path>) -> bool {
217        FilePath::new(path)
218            .ok()
219            .and_then(|p| self.files.get(&p))
220            .is_some()
221    }
222
223    /// Lists all files and directories in a directory.
224    ///
225    /// Returns an empty vector if the directory is empty or does not exist.
226    ///
227    /// # Errors
228    ///
229    /// Returns `FilesError::InvalidPath` if the path is invalid.
230    /// Returns `FilesError::NotADirectory` if the path points to a file.
231    ///
232    /// # Examples
233    ///
234    /// ```
235    /// use mcp_execution_files::FileSystem;
236    ///
237    /// let mut vfs = FileSystem::new();
238    /// vfs.add_file("/mcp-tools/servers/test1.ts", "").unwrap();
239    /// vfs.add_file("/mcp-tools/servers/test2.ts", "").unwrap();
240    ///
241    /// let entries = vfs.list_dir("/mcp-tools/servers").unwrap();
242    /// assert_eq!(entries.len(), 2);
243    /// # Ok::<(), mcp_execution_files::FilesError>(())
244    /// ```
245    pub fn list_dir(&self, path: impl AsRef<Path>) -> Result<Vec<FilePath>> {
246        let vfs_path = FilePath::new(path)?;
247        let path_str = vfs_path.as_str();
248
249        // Check if the path itself is a file
250        if self.files.contains_key(&vfs_path) {
251            return Err(FilesError::NotADirectory {
252                path: path_str.to_string(),
253            });
254        }
255
256        // Collect all direct children
257        let mut children = Vec::new();
258        let normalized_dir = if path_str.ends_with('/') {
259            path_str.to_string()
260        } else {
261            format!("{path_str}/")
262        };
263
264        for file_path in self.files.keys() {
265            let file_str = file_path.as_str();
266
267            // Check if this file is under the directory
268            if file_str.starts_with(&normalized_dir) {
269                let relative = &file_str[normalized_dir.len()..];
270
271                // Only include direct children (no subdirectories)
272                if !relative.contains('/') && !relative.is_empty() {
273                    children.push(file_path.clone());
274                } else if let Some(idx) = relative.find('/') {
275                    // This is a subdirectory, add the directory path
276                    let subdir = format!("{}{}", normalized_dir, &relative[..idx]);
277                    if let Ok(subdir_path) = FilePath::new(subdir)
278                        && !children.contains(&subdir_path)
279                    {
280                        children.push(subdir_path);
281                    }
282                }
283            }
284        }
285
286        children.sort_by(|a, b| a.as_str().cmp(b.as_str()));
287        Ok(children)
288    }
289
290    /// Returns the total number of files in the VFS.
291    ///
292    /// # Examples
293    ///
294    /// ```
295    /// use mcp_execution_files::FileSystem;
296    ///
297    /// let mut vfs = FileSystem::new();
298    /// assert_eq!(vfs.file_count(), 0);
299    ///
300    /// vfs.add_file("/test1.ts", "").unwrap();
301    /// vfs.add_file("/test2.ts", "").unwrap();
302    /// assert_eq!(vfs.file_count(), 2);
303    /// ```
304    #[must_use]
305    pub fn file_count(&self) -> usize {
306        self.files.len()
307    }
308
309    /// Returns all file paths in the VFS.
310    ///
311    /// The paths are returned in sorted order.
312    ///
313    /// # Examples
314    ///
315    /// ```
316    /// use mcp_execution_files::FileSystem;
317    ///
318    /// let mut vfs = FileSystem::new();
319    /// vfs.add_file("/a.ts", "").unwrap();
320    /// vfs.add_file("/b.ts", "").unwrap();
321    ///
322    /// let paths = vfs.all_paths();
323    /// assert_eq!(paths.len(), 2);
324    /// ```
325    #[must_use]
326    pub fn all_paths(&self) -> Vec<&FilePath> {
327        let mut paths: Vec<_> = self.files.keys().collect();
328        paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));
329        paths
330    }
331
332    /// Returns an iterator over all files in the VFS.
333    ///
334    /// Each item is a tuple of `(&FilePath, &FileEntry)`.
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// use mcp_execution_files::FileSystem;
340    ///
341    /// let mut vfs = FileSystem::new();
342    /// vfs.add_file("/a.ts", "content a").unwrap();
343    /// vfs.add_file("/b.ts", "content b").unwrap();
344    ///
345    /// let files: Vec<_> = vfs.files().collect();
346    /// assert_eq!(files.len(), 2);
347    /// ```
348    pub fn files(&self) -> impl Iterator<Item = (&FilePath, &FileEntry)> {
349        self.files.iter()
350    }
351
352    /// Removes all files from the VFS.
353    ///
354    /// # Examples
355    ///
356    /// ```
357    /// use mcp_execution_files::FileSystem;
358    ///
359    /// let mut vfs = FileSystem::new();
360    /// vfs.add_file("/test.ts", "").unwrap();
361    /// assert_eq!(vfs.file_count(), 1);
362    ///
363    /// vfs.clear();
364    /// assert_eq!(vfs.file_count(), 0);
365    /// ```
366    pub fn clear(&mut self) {
367        self.files.clear();
368    }
369
370    /// Exports VFS contents to real filesystem.
371    ///
372    /// This is a high-performance implementation optimized for the progressive
373    /// loading pattern. It pre-creates all directories and writes files sequentially.
374    ///
375    /// # Performance
376    ///
377    /// Target: <50ms for 30 files (GitHub server typical case)
378    ///
379    /// Optimizations:
380    /// - Single pass directory creation
381    /// - Cached canonicalized base path
382    /// - Minimal allocations
383    ///
384    /// # Errors
385    ///
386    /// Returns error if:
387    /// - Base path doesn't exist or isn't a directory
388    /// - Permission denied
389    /// - I/O error during write
390    ///
391    /// # Examples
392    ///
393    /// ```
394    /// use mcp_execution_files::FilesBuilder;
395    /// # use tempfile::TempDir;
396    ///
397    /// # let temp = TempDir::new().unwrap();
398    /// # let base = temp.path();
399    /// let vfs = FilesBuilder::new()
400    ///     .add_file("/manifest.json", "{}")
401    ///     .build()
402    ///     .unwrap();
403    ///
404    /// vfs.export_to_filesystem(base).unwrap();
405    /// assert!(base.join("manifest.json").exists());
406    /// # Ok::<(), Box<dyn std::error::Error>>(())
407    /// ```
408    pub fn export_to_filesystem(&self, base_path: impl AsRef<Path>) -> Result<()> {
409        self.export_to_filesystem_with_options(base_path, &ExportOptions::default())
410    }
411
412    /// Exports VFS contents with custom options.
413    ///
414    /// The export is staged in a temporary sibling directory next to
415    /// `base_path` and published atomically: only once every file has been
416    /// written to the staging directory is it renamed into place. If the
417    /// process is interrupted at any point before publishing, `base_path` is
418    /// left exactly as it was — either untouched or, if it did not exist yet,
419    /// still absent. See the [module-level docs](self) for details.
420    ///
421    /// Concurrent exports of the *same* `base_path` from different processes
422    /// are not locked against each other and can still race on the final
423    /// swap — one export's result may be silently overwritten by another's
424    /// (a lost update). The age-gated sweep of orphaned staging/displaced
425    /// directories (`sweep_stale_artifacts`) guarantees neither export's
426    /// staging/displaced directories can be deleted out from under it before
427    /// its own rollback path runs, so this can no longer result in the
428    /// target, its staging directory, and its displaced backup all being
429    /// lost at once. Callers that need stronger-than-last-write-wins
430    /// semantics for the same target should serialize writes themselves
431    /// (e.g. a per-target lock) — see `mcp-execution-server`'s
432    /// `introspector_for` for the pattern this project already uses for a
433    /// similar problem. Concurrent exports of different targets sharing a
434    /// parent directory are safe.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if:
439    /// - The parent directory of `base_path` does not exist
440    /// - The staging directory cannot be created or canonicalized
441    /// - I/O operations fail during directory creation, file writing, or the
442    ///   final publish step
443    ///
444    /// # Examples
445    ///
446    /// ```
447    /// use mcp_execution_files::{FilesBuilder, ExportOptions};
448    /// # use tempfile::TempDir;
449    ///
450    /// # let temp = TempDir::new().unwrap();
451    /// # let base = temp.path();
452    /// let vfs = FilesBuilder::new()
453    ///     .add_file("/test.ts", "export {}")
454    ///     .build()
455    ///     .unwrap();
456    ///
457    /// let options = ExportOptions::default().with_atomic_writes(false);
458    /// vfs.export_to_filesystem_with_options(base, &options).unwrap();
459    /// # Ok::<(), Box<dyn std::error::Error>>(())
460    /// ```
461    pub fn export_to_filesystem_with_options(
462        &self,
463        base_path: impl AsRef<Path>,
464        options: &ExportOptions,
465    ) -> Result<()> {
466        let target = base_path.as_ref();
467
468        let parent = target.parent().ok_or_else(|| FilesError::InvalidPath {
469            path: format!("Target path has no parent directory: {}", target.display()),
470        })?;
471
472        if !parent.exists() {
473            return Err(FilesError::FileNotFound {
474                path: parent.display().to_string(),
475            });
476        }
477
478        // Best-effort cleanup of orphaned staging/displaced directories left
479        // behind by a previous run that was killed (e.g. `SIGKILL`) before it
480        // could clean up after itself — `TempDir::drop` never runs in that
481        // case. This bounds the leak to at most one generation between
482        // crashes rather than letting full tree copies accumulate forever.
483        // Scoped to this `target`'s own name so it never touches a sibling
484        // export's in-flight staging directory (e.g. two `generate` runs for
485        // different servers publishing into the same `~/.claude/servers/`).
486        Self::sweep_stale_artifacts(parent, target);
487
488        // Stage the export in a sibling directory on the same filesystem so the
489        // final publish step below is a single directory rename rather than a
490        // sequence of individually-visible file writes. The prefix is scoped to
491        // `target`'s own name for the same reason as the sweep above.
492        let staging = tempfile::Builder::new()
493            .prefix(&Self::staging_prefix(target))
494            .tempdir_in(parent)
495            .map_err(|e| FilesError::IoError {
496                path: parent.display().to_string(),
497                source: e,
498            })?;
499
500        let canonical_staging =
501            staging
502                .path()
503                .canonicalize()
504                .map_err(|e| FilesError::InvalidPath {
505                    path: format!("Failed to canonicalize {}: {}", staging.path().display(), e),
506                })?;
507
508        // Phase 1: Collect all unique directories
509        let dirs = self.collect_directories(&canonical_staging);
510
511        // Phase 2: Create all directories in one pass
512        Self::create_directories(&dirs)?;
513
514        // Phase 3: Write all files into the staging directory. If this fails,
515        // `staging` is dropped here and its `Drop` impl removes the partial
516        // tree — `target` is never touched.
517        self.write_files(&canonical_staging, options)?;
518
519        // Every file landed successfully; publish by swapping the staged
520        // directory into place.
521        Self::publish_staged_export(staging, target)
522    }
523
524    /// Exports VFS contents using parallel writes (requires 'parallel' feature).
525    ///
526    /// Faster for large numbers of files (>50), but may not preserve write order.
527    ///
528    /// # Errors
529    ///
530    /// Returns error if:
531    /// - Base path doesn't exist or isn't a directory
532    /// - Permission denied during directory creation or file write
533    /// - I/O error during parallel write operations
534    ///
535    /// # Examples
536    ///
537    /// ```
538    /// use mcp_execution_files::FilesBuilder;
539    /// # use tempfile::TempDir;
540    ///
541    /// # let temp = TempDir::new().unwrap();
542    /// # let base = temp.path();
543    /// let vfs = FilesBuilder::new()
544    ///     .add_file("/tool1.ts", "export {}")
545    ///     .add_file("/tool2.ts", "export {}")
546    ///     .build()
547    ///     .unwrap();
548    ///
549    /// #[cfg(feature = "parallel")]
550    /// vfs.export_to_filesystem_parallel(base).unwrap();
551    /// # Ok::<(), Box<dyn std::error::Error>>(())
552    /// ```
553    // TODO(critic): not directory-atomic — needs staging treatment before any
554    // production caller is wired up (currently unused outside this crate's
555    // own tests/benches, so the interrupted-export bug fixed in
556    // `export_to_filesystem` does not apply here yet).
557    #[cfg(feature = "parallel")]
558    pub fn export_to_filesystem_parallel(&self, base_path: impl AsRef<Path>) -> Result<()> {
559        use rayon::prelude::*;
560
561        let base = base_path.as_ref();
562        let canonical_base = base.canonicalize().map_err(|e| FilesError::InvalidPath {
563            path: format!("Failed to canonicalize {}: {}", base.display(), e),
564        })?;
565
566        // Phase 1: Collect and create directories (must be sequential)
567        let dirs = self.collect_directories(&canonical_base);
568        Self::create_directories(&dirs)?;
569
570        // Phase 2: Write files in parallel
571        let files: Vec<_> = self.files().collect();
572        let options = ExportOptions::default();
573
574        files
575            .par_iter()
576            .try_for_each(|(vfs_path, file)| -> Result<()> {
577                let disk_path = Self::vfs_to_disk_path(vfs_path.as_str(), &canonical_base);
578                write_file_atomic(&disk_path, file.content(), options.atomic)
579            })?;
580
581        Ok(())
582    }
583
584    /// Collects all unique directory paths needed for export.
585    ///
586    /// This is done in a single pass to minimize allocations.
587    fn collect_directories(&self, base: &Path) -> HashSet<PathBuf> {
588        let mut dirs = HashSet::new();
589
590        for (vfs_path, _) in self.files() {
591            let disk_path = Self::vfs_to_disk_path(vfs_path.as_str(), base);
592
593            // Add all parent directories
594            if let Some(parent) = disk_path.parent() {
595                // Insert parent and all ancestors
596                let mut current = parent;
597                while current != base && dirs.insert(current.to_path_buf()) {
598                    if let Some(p) = current.parent() {
599                        current = p;
600                    } else {
601                        break;
602                    }
603                }
604            }
605        }
606
607        dirs
608    }
609
610    /// Creates all directories in one pass.
611    ///
612    /// Uses `fs::create_dir_all` which is efficient for creating directory trees.
613    fn create_directories(dirs: &HashSet<PathBuf>) -> Result<()> {
614        for dir in dirs {
615            fs::create_dir_all(dir).map_err(|e| FilesError::InvalidPath {
616                path: format!("Failed to create directory {}: {}", dir.display(), e),
617            })?;
618        }
619        Ok(())
620    }
621
622    /// Writes all files into the staging directory ahead of publishing.
623    fn write_files(&self, staging_base: &Path, options: &ExportOptions) -> Result<()> {
624        for (vfs_path, file) in self.files() {
625            let staging_disk_path = Self::vfs_to_disk_path(vfs_path.as_str(), staging_base);
626            write_file_atomic(&staging_disk_path, file.content(), options.atomic)?;
627        }
628        Ok(())
629    }
630
631    /// Publishes a fully staged export by atomically swapping it into `target`.
632    ///
633    /// If the swap itself fails, the staging directory is removed so no
634    /// orphaned staging directories (see [`Self::staging_prefix`]) accumulate
635    /// next to `target`.
636    fn publish_staged_export(staging: TempDir, target: &Path) -> Result<()> {
637        // Disown the `TempDir` guard: ownership of cleanup now belongs to
638        // `swap_into_place` (on failure) or to `target` itself (on success).
639        let staging_path = staging.keep();
640
641        if let Err(err) = Self::swap_into_place(&staging_path, target) {
642            let _ = fs::remove_dir_all(&staging_path);
643            return Err(err);
644        }
645
646        Ok(())
647    }
648
649    /// Atomically replaces `target` with the directory at `staging_path`.
650    ///
651    /// A directory rename cannot replace a non-empty destination on any
652    /// platform this crate supports, so an existing `target` is first moved
653    /// aside to a unique sibling path, the staged directory is renamed into
654    /// `target`, and only then is the displaced directory removed. If this
655    /// function *returns* an error, the second rename failed and the original
656    /// directory has been moved back, so `target` is never left missing as
657    /// observed by a caller of this function.
658    ///
659    /// This guarantee does not extend to a process that is killed (e.g.
660    /// `SIGKILL`) between the two renames: in that narrow window `target` is
661    /// transiently absent and the previous export sits at a `.stale-*`
662    /// sibling until [`FileSystem::sweep_stale_artifacts`] reclaims it on a
663    /// later export. That failure mode is louder (a missing directory) than
664    /// the silent broken-import bug this fix replaces.
665    fn swap_into_place(staging_path: &Path, target: &Path) -> Result<()> {
666        if !target.exists() {
667            return fs::rename(staging_path, target).map_err(|e| FilesError::IoError {
668                path: target.display().to_string(),
669                source: e,
670            });
671        }
672
673        let parent = target.parent().ok_or_else(|| FilesError::InvalidPath {
674            path: format!("Target path has no parent directory: {}", target.display()),
675        })?;
676        let displaced = Self::displace_existing_target(target, parent)?;
677
678        if let Err(e) = fs::rename(staging_path, target) {
679            // Roll back so `target` is never left missing.
680            let _ = fs::rename(&displaced, target);
681            return Err(FilesError::IoError {
682                path: target.display().to_string(),
683                source: e,
684            });
685        }
686
687        let _ = fs::remove_dir_all(&displaced);
688        Ok(())
689    }
690
691    /// Moves an existing `target` aside to a unique sibling path in `parent`
692    /// so the staged directory can be renamed into `target`'s place,
693    /// returning the displaced path.
694    ///
695    /// A directory rename does not reset the renamed directory's own mtime,
696    /// so without an explicit refresh `displaced` would inherit `target`'s
697    /// original mtime — almost always well past [`STALE_ARTIFACT_MIN_AGE`],
698    /// since a target being regenerated has typically existed since a prior
699    /// export. That would make it immediately eligible for a concurrent
700    /// sibling's [`Self::sweep_stale_artifacts`] pass, defeating the age
701    /// gate for exactly the directory [`Self::swap_into_place`]'s own
702    /// rollback depends on (issue #169). [`Self::touch_dir`] refreshes
703    /// `target`'s mtime *before* the rename rather than `displaced`'s
704    /// *after* it, since a rename preserves the source's mtime — so
705    /// `displaced` carries a fresh mtime from the instant it exists, with
706    /// no window in which it is stale even momentarily.
707    fn displace_existing_target(target: &Path, parent: &Path) -> Result<PathBuf> {
708        let displaced = parent.join(Self::unique_sibling_name(target));
709
710        Self::touch_dir(target);
711
712        fs::rename(target, &displaced).map_err(|e| FilesError::IoError {
713            path: target.display().to_string(),
714            source: e,
715        })?;
716
717        Ok(displaced)
718    }
719
720    /// Best-effort refresh of `dir`'s modification time.
721    ///
722    /// Deliberately avoids opening `dir` as a `File` to call
723    /// [`std::fs::File::set_times`], since opening a directory for write
724    /// access is not portable across every platform this crate supports.
725    /// Instead, creating and immediately removing a marker file inside
726    /// `dir` achieves the same effect portably: any filesystem that tracks
727    /// directory mtimes bumps them when an immediate child is added or
728    /// removed. Best-effort like [`Self::sweep_stale_artifacts`]: failure
729    /// (e.g. a read-only directory) is silently ignored rather than failing
730    /// the export.
731    fn touch_dir(dir: &Path) {
732        let marker = dir.join(format!(".mtime-touch-{}", std::process::id()));
733        if fs::File::create(&marker).is_ok() {
734            let _ = fs::remove_file(&marker);
735        }
736    }
737
738    /// Returns `target`'s file name for use as a namespacing stem in sibling
739    /// artifact names, so that concurrent exports of *different* targets in
740    /// the same parent directory (e.g. two `generate` runs publishing into
741    /// the same `~/.claude/servers/`) never collide or interfere with one
742    /// another's staging/displaced directories.
743    fn target_stem(target: &Path) -> &str {
744        target
745            .file_name()
746            .and_then(|n| n.to_str())
747            .unwrap_or("export")
748    }
749
750    /// Returns the `tempfile` prefix used for `target`'s staging directory.
751    fn staging_prefix(target: &Path) -> String {
752        format!(".{}.staging-", Self::target_stem(target))
753    }
754
755    /// Generates a unique sibling file name used to temporarily displace
756    /// `target` during the atomic swap.
757    fn unique_sibling_name(target: &Path) -> PathBuf {
758        static COUNTER: AtomicU64 = AtomicU64::new(0);
759
760        let stem = Self::target_stem(target);
761        let nanos = SystemTime::now()
762            .duration_since(UNIX_EPOCH)
763            .map_or(0, |d| d.as_nanos());
764        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
765
766        PathBuf::from(format!(
767            ".{stem}.stale-{}-{nanos}-{seq}",
768            std::process::id()
769        ))
770    }
771
772    /// Removes orphaned staging/displaced directories left next to `target`
773    /// by a previous export of the *same* `target` that was killed before it
774    /// could clean up after itself (`TempDir::drop` and the rollback in
775    /// [`Self::swap_into_place`] both require the process to still be
776    /// running). Scoped to `target`'s own name (see [`Self::target_stem`]) so
777    /// it never touches a sibling export's in-flight artifacts.
778    ///
779    /// Best-effort: this is a hygiene pass, not part of the export's
780    /// correctness, so any I/O error while scanning or removing an entry is
781    /// silently ignored rather than failing the export.
782    ///
783    /// Only removes entries at least [`STALE_ARTIFACT_MIN_AGE`] old (by
784    /// mtime) — see that constant's doc for why this age gate is what makes
785    /// the sweep safe to run against a concurrently in-flight sibling
786    /// export.
787    fn sweep_stale_artifacts(parent: &Path, target: &Path) {
788        Self::sweep_stale_artifacts_older_than(parent, target, STALE_ARTIFACT_MIN_AGE);
789    }
790
791    /// Inner implementation of [`Self::sweep_stale_artifacts`], parameterized
792    /// over the age threshold so tests can exercise the sweep logic without
793    /// backdating real file mtimes (pass [`Duration::ZERO`] to sweep
794    /// unconditionally).
795    fn sweep_stale_artifacts_older_than(parent: &Path, target: &Path, min_age: Duration) {
796        let stem = Self::target_stem(target);
797        let staging_prefix = Self::staging_prefix(target);
798        let stale_prefix = format!(".{stem}.stale-");
799
800        let Ok(entries) = fs::read_dir(parent) else {
801            return;
802        };
803
804        for entry in entries.flatten() {
805            let is_orphan = entry.file_name().to_str().is_some_and(|name| {
806                name.starts_with(&staging_prefix) || name.starts_with(&stale_prefix)
807            });
808            if !is_orphan {
809                continue;
810            }
811
812            // Too young to be a crash orphan — could be a live sibling
813            // export's in-flight artifacts; leave it for a later sweep once
814            // it ages past `min_age`. Metadata errors (entry vanished,
815            // permission issue) are treated the same way — skip rather than
816            // guess, since this is a best-effort hygiene pass, not part of
817            // export correctness.
818            let old_enough = entry
819                .metadata()
820                .and_then(|m| m.modified())
821                .is_ok_and(|modified| {
822                    SystemTime::now()
823                        .duration_since(modified)
824                        .is_ok_and(|age| age >= min_age)
825                });
826
827            if old_enough {
828                let _ = fs::remove_dir_all(entry.path());
829            }
830        }
831    }
832
833    /// Converts VFS path to disk path.
834    ///
835    /// Strips leading '/' and joins with base path.
836    ///
837    /// # Panics
838    ///
839    /// Panics if path contains `..` (path traversal attempt).
840    /// This is defense-in-depth since `FilePath::new()` also validates.
841    fn vfs_to_disk_path(vfs_path: &str, base: &Path) -> PathBuf {
842        // Strip leading '/' from VFS path
843        let relative = vfs_path.strip_prefix('/').unwrap_or(vfs_path);
844
845        // Defense-in-depth: reject path traversal attempts
846        // Primary validation is in FilePath::new(), this is a safety net
847        assert!(
848            !relative.contains(".."),
849            "SECURITY: Path traversal attempt detected in VFS path: {vfs_path}"
850        );
851
852        // Convert forward slashes to platform-specific separators
853        let relative_path = if cfg!(target_os = "windows") {
854            PathBuf::from(relative.replace('/', "\\"))
855        } else {
856            PathBuf::from(relative)
857        };
858
859        base.join(relative_path)
860    }
861}
862
863impl Default for FileSystem {
864    fn default() -> Self {
865        Self::new()
866    }
867}
868
869/// Options for filesystem export operations.
870///
871/// # Examples
872///
873/// ```
874/// use mcp_execution_files::ExportOptions;
875///
876/// let options = ExportOptions::default().with_atomic_writes(true);
877/// ```
878#[derive(Debug, Clone)]
879pub struct ExportOptions {
880    /// Use atomic writes (write to temp file, then rename)
881    pub atomic: bool,
882}
883
884impl ExportOptions {
885    /// Creates new export options with defaults.
886    ///
887    /// Defaults:
888    /// - atomic: true (safer)
889    #[must_use]
890    pub const fn new() -> Self {
891        Self { atomic: true }
892    }
893
894    /// Sets whether to use atomic writes.
895    #[must_use]
896    pub const fn with_atomic_writes(mut self, atomic: bool) -> Self {
897        self.atomic = atomic;
898        self
899    }
900}
901
902impl Default for ExportOptions {
903    fn default() -> Self {
904        Self::new()
905    }
906}
907
908/// Writes file content to disk.
909///
910/// If `atomic` is `true`, writes to a temp file then renames it into place.
911/// Otherwise, writes directly.
912fn write_file_atomic(path: &Path, content: &str, atomic: bool) -> Result<()> {
913    if atomic {
914        // Atomic write: temp file + rename
915        let temp_path = path.with_added_extension("tmp");
916
917        // Write to temp file
918        let mut file = fs::File::create(&temp_path).map_err(|e| FilesError::InvalidPath {
919            path: format!("Failed to create temp file {}: {}", temp_path.display(), e),
920        })?;
921
922        file.write_all(content.as_bytes())
923            .map_err(|e| FilesError::InvalidPath {
924                path: format!("Failed to write to {}: {}", temp_path.display(), e),
925            })?;
926
927        file.sync_all().map_err(|e| FilesError::InvalidPath {
928            path: format!("Failed to sync {}: {}", temp_path.display(), e),
929        })?;
930
931        // Rename to final location
932        fs::rename(&temp_path, path).map_err(|e| FilesError::InvalidPath {
933            path: format!(
934                "Failed to rename {} to {}: {}",
935                temp_path.display(),
936                path.display(),
937                e
938            ),
939        })?;
940    } else {
941        // Direct write (faster, but not atomic)
942        fs::write(path, content).map_err(|e| FilesError::InvalidPath {
943            path: format!("Failed to write {}: {}", path.display(), e),
944        })?;
945    }
946
947    Ok(())
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use crate::FilesBuilder;
954    use tempfile::TempDir;
955
956    // FileSystem core tests
957    #[test]
958    fn test_vfs_new() {
959        let vfs = FileSystem::new();
960        assert_eq!(vfs.file_count(), 0);
961    }
962
963    #[test]
964    fn test_vfs_default() {
965        let vfs = FileSystem::default();
966        assert_eq!(vfs.file_count(), 0);
967    }
968
969    #[test]
970    fn test_add_file() {
971        let mut vfs = FileSystem::new();
972        vfs.add_file("/test.ts", "content").unwrap();
973        assert_eq!(vfs.file_count(), 1);
974    }
975
976    #[test]
977    fn test_add_file_invalid_path() {
978        let mut vfs = FileSystem::new();
979        let result = vfs.add_file("relative/path", "content");
980        assert!(result.is_err());
981    }
982
983    #[test]
984    fn test_read_file() {
985        let mut vfs = FileSystem::new();
986        vfs.add_file("/test.ts", "hello world").unwrap();
987
988        let content = vfs.read_file("/test.ts").unwrap();
989        assert_eq!(content, "hello world");
990    }
991
992    #[test]
993    fn test_read_file_not_found() {
994        let vfs = FileSystem::new();
995        let result = vfs.read_file("/missing.ts");
996        assert!(result.is_err());
997        assert!(result.unwrap_err().is_not_found());
998    }
999
1000    #[test]
1001    fn test_exists() {
1002        let mut vfs = FileSystem::new();
1003        vfs.add_file("/exists.ts", "").unwrap();
1004
1005        assert!(vfs.exists("/exists.ts"));
1006        assert!(!vfs.exists("/missing.ts"));
1007    }
1008
1009    #[test]
1010    fn test_exists_invalid_path() {
1011        let vfs = FileSystem::new();
1012        assert!(!vfs.exists("relative/path"));
1013    }
1014
1015    #[test]
1016    fn test_list_dir() {
1017        let mut vfs = FileSystem::new();
1018        vfs.add_file("/mcp-tools/servers/test1.ts", "").unwrap();
1019        vfs.add_file("/mcp-tools/servers/test2.ts", "").unwrap();
1020
1021        let entries = vfs.list_dir("/mcp-tools/servers").unwrap();
1022        assert_eq!(entries.len(), 2);
1023    }
1024
1025    #[test]
1026    fn test_list_dir_empty() {
1027        let vfs = FileSystem::new();
1028        let entries = vfs.list_dir("/empty").unwrap();
1029        assert_eq!(entries.len(), 0);
1030    }
1031
1032    #[test]
1033    fn test_list_dir_not_a_directory() {
1034        let mut vfs = FileSystem::new();
1035        vfs.add_file("/file.ts", "").unwrap();
1036
1037        let result = vfs.list_dir("/file.ts");
1038        assert!(result.is_err());
1039        assert!(result.unwrap_err().is_not_directory());
1040    }
1041
1042    #[test]
1043    fn test_list_dir_subdirectories() {
1044        let mut vfs = FileSystem::new();
1045        vfs.add_file("/mcp-tools/servers/test/file1.ts", "")
1046            .unwrap();
1047        vfs.add_file("/mcp-tools/servers/test/file2.ts", "")
1048            .unwrap();
1049        vfs.add_file("/mcp-tools/servers/other.ts", "").unwrap();
1050
1051        let entries = vfs.list_dir("/mcp-tools/servers").unwrap();
1052        // Should include 'test' directory and 'other.ts' file
1053        assert_eq!(entries.len(), 2);
1054    }
1055
1056    #[test]
1057    fn test_file_count() {
1058        let mut vfs = FileSystem::new();
1059        assert_eq!(vfs.file_count(), 0);
1060
1061        vfs.add_file("/test1.ts", "").unwrap();
1062        assert_eq!(vfs.file_count(), 1);
1063
1064        vfs.add_file("/test2.ts", "").unwrap();
1065        assert_eq!(vfs.file_count(), 2);
1066    }
1067
1068    #[test]
1069    fn test_all_paths() {
1070        let mut vfs = FileSystem::new();
1071        vfs.add_file("/b.ts", "").unwrap();
1072        vfs.add_file("/a.ts", "").unwrap();
1073
1074        let paths = vfs.all_paths();
1075        assert_eq!(paths.len(), 2);
1076        // Should be sorted
1077        assert_eq!(paths[0].as_str(), "/a.ts");
1078        assert_eq!(paths[1].as_str(), "/b.ts");
1079    }
1080
1081    #[test]
1082    fn test_clear() {
1083        let mut vfs = FileSystem::new();
1084        vfs.add_file("/test1.ts", "").unwrap();
1085        vfs.add_file("/test2.ts", "").unwrap();
1086        assert_eq!(vfs.file_count(), 2);
1087
1088        vfs.clear();
1089        assert_eq!(vfs.file_count(), 0);
1090    }
1091
1092    #[test]
1093    fn test_replace_file() {
1094        let mut vfs = FileSystem::new();
1095        vfs.add_file("/test.ts", "original").unwrap();
1096        assert_eq!(vfs.read_file("/test.ts").unwrap(), "original");
1097
1098        vfs.add_file("/test.ts", "updated").unwrap();
1099        assert_eq!(vfs.read_file("/test.ts").unwrap(), "updated");
1100        assert_eq!(vfs.file_count(), 1);
1101    }
1102
1103    #[test]
1104    fn test_vfs_is_send_sync() {
1105        fn assert_send<T: Send>() {}
1106        fn assert_sync<T: Sync>() {}
1107
1108        assert_send::<FileSystem>();
1109        assert_sync::<FileSystem>();
1110    }
1111
1112    // Export tests
1113    #[test]
1114    fn test_export_single_file() {
1115        let temp = TempDir::new().unwrap();
1116        let vfs = FilesBuilder::new()
1117            .add_file("/test.ts", "export const VERSION = '1.0';")
1118            .build()
1119            .unwrap();
1120
1121        vfs.export_to_filesystem(temp.path()).unwrap();
1122
1123        let exported = temp.path().join("test.ts");
1124        assert!(exported.exists());
1125        assert_eq!(
1126            fs::read_to_string(exported).unwrap(),
1127            "export const VERSION = '1.0';"
1128        );
1129    }
1130
1131    #[test]
1132    fn test_export_nested_files() {
1133        let temp = TempDir::new().unwrap();
1134        let vfs = FilesBuilder::new()
1135            .add_file("/tools/create.ts", "export function create() {}")
1136            .add_file("/tools/update.ts", "export function update() {}")
1137            .add_file("/manifest.json", "{}")
1138            .build()
1139            .unwrap();
1140
1141        vfs.export_to_filesystem(temp.path()).unwrap();
1142
1143        assert!(temp.path().join("tools/create.ts").exists());
1144        assert!(temp.path().join("tools/update.ts").exists());
1145        assert!(temp.path().join("manifest.json").exists());
1146    }
1147
1148    #[test]
1149    fn test_export_overwrite() {
1150        let temp = TempDir::new().unwrap();
1151        let path = temp.path().join("test.ts");
1152
1153        // Write initial file
1154        fs::write(&path, "old content").unwrap();
1155
1156        let vfs = FilesBuilder::new()
1157            .add_file("/test.ts", "new content")
1158            .build()
1159            .unwrap();
1160
1161        vfs.export_to_filesystem(temp.path()).unwrap();
1162
1163        assert_eq!(fs::read_to_string(path).unwrap(), "new content");
1164    }
1165
1166    #[test]
1167    fn test_export_atomic_writes() {
1168        let temp = TempDir::new().unwrap();
1169        let vfs = FilesBuilder::new()
1170            .add_file("/test.ts", "atomic content")
1171            .build()
1172            .unwrap();
1173
1174        let options = ExportOptions::default().with_atomic_writes(true);
1175        vfs.export_to_filesystem_with_options(temp.path(), &options)
1176            .unwrap();
1177
1178        let path = temp.path().join("test.ts");
1179        assert!(path.exists());
1180        assert_eq!(fs::read_to_string(path).unwrap(), "atomic content");
1181
1182        // Temp file should be cleaned up
1183        let temp_path = temp.path().join("test.tmp");
1184        assert!(!temp_path.exists());
1185    }
1186
1187    #[test]
1188    fn test_export_non_atomic_writes() {
1189        let temp = TempDir::new().unwrap();
1190        let vfs = FilesBuilder::new()
1191            .add_file("/test.ts", "direct content")
1192            .build()
1193            .unwrap();
1194
1195        let options = ExportOptions::default().with_atomic_writes(false);
1196        vfs.export_to_filesystem_with_options(temp.path(), &options)
1197            .unwrap();
1198
1199        let path = temp.path().join("test.ts");
1200        assert_eq!(fs::read_to_string(path).unwrap(), "direct content");
1201    }
1202
1203    #[test]
1204    fn test_export_invalid_base_path() {
1205        let vfs = FilesBuilder::new()
1206            .add_file("/test.ts", "")
1207            .build()
1208            .unwrap();
1209
1210        let result = vfs.export_to_filesystem("/nonexistent/path/that/does/not/exist");
1211        assert!(result.is_err());
1212    }
1213
1214    #[test]
1215    fn test_export_failure_leaves_existing_target_untouched() {
1216        let temp = TempDir::new().unwrap();
1217        let target = temp.path().join("out");
1218        fs::create_dir_all(&target).unwrap();
1219        fs::write(target.join("keep.ts"), "keep").unwrap();
1220
1221        // "/conflict/child.ts" forces "/conflict" to be created as a directory
1222        // in the staging tree, while "/conflict" is also added as a file —
1223        // writing it always fails (renaming a file onto an existing
1224        // directory), regardless of `HashMap` iteration order.
1225        let vfs = FilesBuilder::new()
1226            .add_file("/conflict", "file content")
1227            .add_file("/conflict/child.ts", "child content")
1228            .build()
1229            .unwrap();
1230
1231        let result = vfs.export_to_filesystem(&target);
1232        assert!(result.is_err());
1233
1234        // The previous export must be completely untouched by the failure.
1235        assert!(target.join("keep.ts").exists());
1236        assert_eq!(fs::read_to_string(target.join("keep.ts")).unwrap(), "keep");
1237        assert!(!target.join("conflict").exists());
1238
1239        // Nothing but `target` itself should remain next to it — no orphaned
1240        // staging directory left behind by the failed export.
1241        let siblings: Vec<_> = fs::read_dir(temp.path())
1242            .unwrap()
1243            .filter_map(std::result::Result::ok)
1244            .filter(|entry| entry.path() != target)
1245            .collect();
1246        assert!(siblings.is_empty(), "unexpected siblings: {siblings:?}");
1247    }
1248
1249    #[test]
1250    fn test_swap_into_place_replaces_non_empty_target() {
1251        let temp = TempDir::new().unwrap();
1252        let target = temp.path().join("out");
1253        fs::create_dir_all(&target).unwrap();
1254        fs::write(target.join("old.ts"), "old").unwrap();
1255
1256        let staging = temp.path().join("staging");
1257        fs::create_dir_all(&staging).unwrap();
1258        fs::write(staging.join("new.ts"), "new").unwrap();
1259
1260        FileSystem::swap_into_place(&staging, &target).unwrap();
1261
1262        assert!(target.join("new.ts").exists());
1263        assert!(!target.join("old.ts").exists());
1264        assert_eq!(fs::read_to_string(target.join("new.ts")).unwrap(), "new");
1265    }
1266
1267    #[test]
1268    fn test_swap_into_place_rolls_back_on_publish_failure() {
1269        let temp = TempDir::new().unwrap();
1270        let target = temp.path().join("out");
1271        fs::create_dir_all(&target).unwrap();
1272        fs::write(target.join("keep.ts"), "keep").unwrap();
1273
1274        // A staging path that does not exist forces the publish rename to fail.
1275        let missing_staging = temp.path().join("does-not-exist-staging");
1276
1277        let result = FileSystem::swap_into_place(&missing_staging, &target);
1278        assert!(result.is_err());
1279
1280        // `target` must be restored to its exact prior state, not left missing.
1281        assert!(target.join("keep.ts").exists());
1282        assert_eq!(fs::read_to_string(target.join("keep.ts")).unwrap(), "keep");
1283    }
1284
1285    #[test]
1286    fn test_sweep_stale_artifacts_removes_orphans_only() {
1287        let temp = TempDir::new().unwrap();
1288        let target = temp.path().join("out");
1289
1290        let orphan_staging = temp.path().join(".out.staging-abc123");
1291        let orphan_stale = temp.path().join(".out.stale-999-1-1");
1292        let unrelated = temp.path().join("unrelated-dir");
1293        // A staging leftover belonging to a *different* target must survive:
1294        // sweeping for `out` must never touch a concurrent export of `other`.
1295        let other_target_staging = temp.path().join(".other.staging-abc123");
1296        fs::create_dir_all(&orphan_staging).unwrap();
1297        fs::create_dir_all(&orphan_stale).unwrap();
1298        fs::create_dir_all(&unrelated).unwrap();
1299        fs::create_dir_all(&other_target_staging).unwrap();
1300
1301        // Bypass the production age gate (`Duration::ZERO`) to exercise the
1302        // name-matching logic in isolation, without backdating real mtimes.
1303        FileSystem::sweep_stale_artifacts_older_than(temp.path(), &target, Duration::ZERO);
1304
1305        assert!(!orphan_staging.exists());
1306        assert!(!orphan_stale.exists());
1307        assert!(unrelated.exists());
1308        assert!(other_target_staging.exists());
1309    }
1310
1311    #[test]
1312    fn test_export_does_not_sweep_recent_sibling_artifacts() {
1313        let temp = TempDir::new().unwrap();
1314        let target = temp.path().join("out");
1315        fs::create_dir_all(&target).unwrap();
1316
1317        // Simulate a concurrent sibling export's in-flight staging
1318        // directory, built the same way `export_to_filesystem_with_options`
1319        // builds it (fresh via `tempfile`).
1320        let sibling_staging = tempfile::Builder::new()
1321            .prefix(&FileSystem::staging_prefix(&target))
1322            .tempdir_in(temp.path())
1323            .unwrap();
1324        let sibling_staging_path = sibling_staging.path().to_path_buf();
1325
1326        // Simulate a concurrent sibling export's displaced backup, built the
1327        // same way `swap_into_place` builds it: an existing (decoy) target
1328        // renamed aside via `displace_existing_target`. Regression test for
1329        // issue #169: previously `sweep_stale_artifacts` matched purely by
1330        // name, so a real export through this same public entry point would
1331        // delete this sibling's staging and displaced directories out from
1332        // under it. `decoy_target` is fresh at test time, so this does not
1333        // itself exercise the S1 mtime-inheritance defect (a fresh target's
1334        // displaced copy would read young even without `touch_dir`) — that
1335        // is covered by `displace_existing_target_refreshes_displaced_mtime`
1336        // below. This test instead proves the sweep spares young siblings
1337        // through the full public `export_to_filesystem` path, not just the
1338        // private sweep helper.
1339        let decoy_target = temp.path().join("decoy-out");
1340        fs::create_dir_all(&decoy_target).unwrap();
1341        let sibling_displaced =
1342            FileSystem::displace_existing_target(&decoy_target, temp.path()).unwrap();
1343
1344        let vfs = FilesBuilder::new()
1345            .add_file("/tool.ts", "export {}")
1346            .build()
1347            .unwrap();
1348        vfs.export_to_filesystem(&target).unwrap();
1349
1350        assert!(
1351            sibling_staging_path.exists(),
1352            "too young to be a crash orphan"
1353        );
1354        assert!(
1355            sibling_displaced.exists(),
1356            "too young to be a crash orphan (mtime just refreshed)"
1357        );
1358        assert!(target.join("tool.ts").exists());
1359    }
1360
1361    #[test]
1362    fn displace_existing_target_refreshes_displaced_mtime() {
1363        let tmp = TempDir::new().unwrap();
1364        let target = tmp.path().join("out");
1365        fs::create_dir_all(&target).unwrap();
1366        let target_created_at = fs::metadata(&target).unwrap().modified().unwrap();
1367
1368        // Give the filesystem's mtime clock room to move past `target`'s
1369        // creation time before it gets renamed aside, so the comparison
1370        // below can distinguish "displaced kept target's original mtime"
1371        // (issue #169's S1 gap: a rename alone does not reset mtime) from
1372        // "displaced's mtime was refreshed by `touch_dir`" (the fix).
1373        std::thread::sleep(Duration::from_millis(1100));
1374
1375        let displaced = FileSystem::displace_existing_target(&target, tmp.path()).unwrap();
1376        let displaced_modified = fs::metadata(&displaced).unwrap().modified().unwrap();
1377
1378        assert!(
1379            displaced_modified > target_created_at,
1380            "displaced's mtime must be refreshed around the rename, not inherited from target"
1381        );
1382    }
1383
1384    #[test]
1385    fn sweep_stale_artifacts_skips_recent_orphans() {
1386        let tmp = TempDir::new().unwrap();
1387        let target = tmp.path().join("out");
1388
1389        let staging = tempfile::Builder::new()
1390            .prefix(&FileSystem::staging_prefix(&target))
1391            .tempdir_in(tmp.path())
1392            .unwrap();
1393        let staging_path = staging.path().to_path_buf();
1394
1395        // Real production threshold: a just-created dir must never be swept.
1396        FileSystem::sweep_stale_artifacts(tmp.path(), &target);
1397
1398        assert!(staging_path.exists(), "a fresh sibling must not be swept");
1399    }
1400
1401    #[test]
1402    fn sweep_stale_artifacts_older_than_removes_aged_orphans() {
1403        let tmp = TempDir::new().unwrap();
1404        let target = tmp.path().join("out");
1405
1406        let staging = tempfile::Builder::new()
1407            .prefix(&FileSystem::staging_prefix(&target))
1408            .tempdir_in(tmp.path())
1409            .unwrap();
1410        let staging_path = staging.keep(); // disown so the assert below is meaningful
1411
1412        FileSystem::sweep_stale_artifacts_older_than(tmp.path(), &target, Duration::ZERO);
1413
1414        assert!(
1415            !staging_path.exists(),
1416            "an orphan past the age threshold must be swept"
1417        );
1418    }
1419
1420    #[test]
1421    fn test_export_to_nonexistent_target_directory() {
1422        let temp = TempDir::new().unwrap();
1423        let target = temp.path().join("fresh-output");
1424
1425        let vfs = FilesBuilder::new()
1426            .add_file("/tools/create.ts", "export function create() {}")
1427            .build()
1428            .unwrap();
1429
1430        assert!(!target.exists());
1431        vfs.export_to_filesystem(&target).unwrap();
1432
1433        assert!(target.join("tools/create.ts").exists());
1434
1435        // Nothing but `target` itself should remain next to it — no orphaned
1436        // staging directory left behind.
1437        let siblings: Vec<_> = fs::read_dir(temp.path())
1438            .unwrap()
1439            .filter_map(std::result::Result::ok)
1440            .filter(|entry| entry.path() != target)
1441            .collect();
1442        assert!(siblings.is_empty(), "unexpected siblings: {siblings:?}");
1443    }
1444
1445    #[test]
1446    fn test_publish_staged_export_cleans_up_staging_on_failure() {
1447        let temp = TempDir::new().unwrap();
1448        let staging = TempDir::new_in(temp.path()).unwrap();
1449        fs::write(staging.path().join("file.ts"), "content").unwrap();
1450        let staging_path = staging.path().to_path_buf();
1451
1452        // Target's parent does not exist, so the publish rename fails.
1453        let target = temp.path().join("missing-parent").join("out");
1454
1455        let result = FileSystem::publish_staged_export(staging, &target);
1456        assert!(result.is_err());
1457
1458        // The now-disowned staging directory must be cleaned up, not leaked.
1459        assert!(!staging_path.exists());
1460    }
1461
1462    #[test]
1463    fn test_export_many_files() {
1464        let temp = TempDir::new().unwrap();
1465        let mut builder = FilesBuilder::new();
1466
1467        // Add 30 files (GitHub server typical case)
1468        for i in 0..30 {
1469            builder = builder.add_file(
1470                format!("/tools/tool{i}.ts"),
1471                format!("export function tool{i}() {{}}"),
1472            );
1473        }
1474
1475        let vfs = builder.build().unwrap();
1476        vfs.export_to_filesystem(temp.path()).unwrap();
1477
1478        // Verify all files exist
1479        for i in 0..30 {
1480            assert!(temp.path().join(format!("tools/tool{i}.ts")).exists());
1481        }
1482    }
1483
1484    #[test]
1485    fn test_export_deep_nesting() {
1486        let temp = TempDir::new().unwrap();
1487        let vfs = FilesBuilder::new()
1488            .add_file("/a/b/c/d/e/deep.ts", "export {}")
1489            .build()
1490            .unwrap();
1491
1492        vfs.export_to_filesystem(temp.path()).unwrap();
1493
1494        assert!(temp.path().join("a/b/c/d/e/deep.ts").exists());
1495    }
1496
1497    #[test]
1498    #[cfg(feature = "parallel")]
1499    fn test_export_parallel() {
1500        let temp = TempDir::new().unwrap();
1501        let mut builder = FilesBuilder::new();
1502
1503        for i in 0..100 {
1504            builder = builder.add_file(format!("/file{i}.ts"), format!("export const N = {i};"));
1505        }
1506
1507        let vfs = builder.build().unwrap();
1508        vfs.export_to_filesystem_parallel(temp.path()).unwrap();
1509
1510        // Verify all files exist
1511        for i in 0..100 {
1512            let path = temp.path().join(format!("file{i}.ts"));
1513            assert!(path.exists());
1514        }
1515    }
1516
1517    #[test]
1518    fn test_export_options_default() {
1519        let options = ExportOptions::default();
1520        assert!(options.atomic);
1521    }
1522
1523    #[test]
1524    fn test_export_options_builder() {
1525        let options = ExportOptions::new().with_atomic_writes(false);
1526
1527        assert!(!options.atomic);
1528    }
1529}