Skip to main content

fs_fixture/
lib.rs

1#![cfg_attr(not(doctest), doc = include_str!("../README.md"))]
2#![warn(clippy::pedantic)]
3
4use std::{
5    env, fs, io, iter,
6    path::{Path, PathBuf},
7};
8
9pub struct FsFixtureBuilderOptions {
10    /// A custom temp directory to write fixtures to. Defaults to `env::temp_dir()`.
11    /// Note that the fixture will be its own directory inside the temp directory.
12    pub temp_dir: PathBuf,
13}
14impl Default for FsFixtureBuilderOptions {
15    fn default() -> Self {
16        FsFixtureBuilderOptions {
17            temp_dir: env::temp_dir().canonicalize().unwrap(),
18        }
19    }
20}
21
22enum FileValue {
23    File(String),
24    Dir,
25    SymlinkFile(String),
26    SymlinkDir(String),
27}
28
29trait FileTreeBuilder {
30    fn get_files_vec(&mut self) -> &mut Vec<(String, FileValue)>;
31    fn get_prefix(&self) -> &str;
32
33    fn get_path(&self, path: &str) -> String {
34        format!("{}{}", self.get_prefix(), &clean_path(path))
35    }
36
37    fn add_file(&mut self, path: &str, content: &str) {
38        let path = self.get_path(path);
39        self.get_files_vec()
40            .push((path, FileValue::File(content.to_string())));
41    }
42
43    fn add_dir(&mut self, path: &str, cb: impl FnOnce(FsFixtureDirBuilder) -> FsFixtureDirBuilder) {
44        let path = self.get_path(path);
45        let files = self.get_files_vec();
46        let start_files_len = files.len();
47
48        let builder = FsFixtureDirBuilder::new(files, &path);
49        let _ = cb(builder);
50
51        if files.len() == start_files_len {
52            // No new files added, push the directory entry only so it's still created
53            files.push((path, FileValue::Dir));
54        }
55    }
56
57    fn add_symlink_file(&mut self, path: &str, target: &str) {
58        let path = self.get_path(path);
59        self.get_files_vec()
60            .push((path, FileValue::SymlinkFile(clean_path(target))));
61    }
62
63    fn add_symlink_dir(&mut self, path: &str, target: &str) {
64        let dir_path = self.get_path(path);
65        self.get_files_vec()
66            .push((dir_path, FileValue::SymlinkDir(clean_path(target))));
67    }
68}
69
70pub struct FsFixtureBuilder {
71    files: Vec<(String, FileValue)>,
72    options: FsFixtureBuilderOptions,
73}
74impl FileTreeBuilder for FsFixtureBuilder {
75    fn get_files_vec(&mut self) -> &mut Vec<(String, FileValue)> {
76        &mut self.files
77    }
78
79    #[expect(clippy::unnecessary_literal_bound)]
80    fn get_prefix(&self) -> &str {
81        ""
82    }
83}
84impl FsFixtureBuilder {
85    #[expect(clippy::new_without_default)]
86    #[must_use]
87    pub fn new() -> Self {
88        FsFixtureBuilder {
89            files: vec![],
90            options: FsFixtureBuilderOptions::default(),
91        }
92    }
93
94    #[must_use]
95    pub fn options(mut self, options: FsFixtureBuilderOptions) -> Self {
96        self.options = options;
97        self
98    }
99
100    /// Creates a file
101    #[must_use]
102    pub fn file(mut self, path: &str, content: &str) -> Self {
103        self.add_file(path, content);
104        self
105    }
106
107    /// Creates a directory and receives a callback to create more files or directories within it
108    #[must_use]
109    pub fn dir(
110        mut self,
111        path: &str,
112        cb: impl FnOnce(FsFixtureDirBuilder) -> FsFixtureDirBuilder,
113    ) -> Self {
114        self.add_dir(path, cb);
115        self
116    }
117
118    /// Creates a symlink to a file
119    #[must_use]
120    pub fn symlink_file(mut self, path: &str, target: &str) -> Self {
121        self.add_symlink_file(path, target);
122        self
123    }
124
125    /// Creates a symlink to a directory
126    #[must_use]
127    pub fn symlink_dir(mut self, path: &str, target: &str) -> Self {
128        self.add_symlink_dir(path, target);
129        self
130    }
131
132    /// Writes the fixture files and directories in a temporary directory
133    ///
134    /// # Errors
135    ///
136    /// This function will return an error if the creation of any files or directories fails.
137    pub fn build(self) -> io::Result<FsFixture> {
138        let temp_dir = get_temp_dir_name();
139        let resolved_temp_dir = self.options.temp_dir.join(temp_dir);
140
141        if self.files.is_empty() {
142            // If there's no files, we should still create the directory
143            fs::create_dir_all(&resolved_temp_dir)?;
144        } else {
145            for (path, value) in self.files {
146                let full_path = resolved_temp_dir.join(&path);
147                match value {
148                    FileValue::File(content) => {
149                        if let Some(parent) = full_path.parent() {
150                            fs::create_dir_all(parent)?;
151                        }
152                        fs::write(full_path, content)?;
153                    }
154                    FileValue::Dir => {
155                        fs::create_dir_all(full_path)?;
156                    }
157                    FileValue::SymlinkFile(target) => {
158                        let target = resolved_temp_dir.join(&target);
159                        if let Some(parent) = full_path.parent() {
160                            fs::create_dir_all(parent)?;
161                        }
162                        symlink_file(&target, &full_path)?;
163                    }
164                    FileValue::SymlinkDir(target) => {
165                        let target = resolved_temp_dir.join(&target);
166                        if let Some(parent) = full_path.parent() {
167                            fs::create_dir_all(parent)?;
168                        }
169                        symlink_dir(&target, &full_path)?;
170                    }
171                }
172            }
173        }
174
175        Ok(FsFixture::new(resolved_temp_dir))
176    }
177}
178
179pub struct FsFixtureDirBuilder<'a> {
180    files: &'a mut Vec<(String, FileValue)>,
181    prefix: String,
182}
183impl FileTreeBuilder for FsFixtureDirBuilder<'_> {
184    fn get_files_vec(&mut self) -> &mut Vec<(String, FileValue)> {
185        self.files
186    }
187
188    fn get_prefix(&self) -> &str {
189        &self.prefix
190    }
191}
192impl<'a> FsFixtureDirBuilder<'a> {
193    fn new(files: &'a mut Vec<(String, FileValue)>, dir: &'a str) -> Self {
194        FsFixtureDirBuilder {
195            files,
196            prefix: format!("{dir}/"),
197        }
198    }
199
200    /// Creates a file
201    #[must_use]
202    pub fn file(mut self, path: &str, content: &str) -> Self {
203        self.add_file(path, content);
204        self
205    }
206
207    /// Creates a directory and receives a callback to create more files or directories within it
208    #[must_use]
209    pub fn dir(
210        mut self,
211        path: &str,
212        cb: impl FnOnce(FsFixtureDirBuilder) -> FsFixtureDirBuilder,
213    ) -> Self {
214        self.add_dir(path, cb);
215        self
216    }
217
218    /// Creates a symlink to a file
219    #[must_use]
220    pub fn symlink_file(mut self, path: &str, target: &str) -> Self {
221        self.add_symlink_file(path, target);
222        self
223    }
224
225    /// Creates a symlink to a directory
226    #[must_use]
227    pub fn symlink_dir(mut self, path: &str, target: &str) -> Self {
228        self.add_symlink_dir(path, target);
229        self
230    }
231}
232
233pub struct FsFixture {
234    resolved_temp_dir: PathBuf,
235}
236impl FsFixture {
237    fn new(resolved_temp_dir: PathBuf) -> Self {
238        FsFixture { resolved_temp_dir }
239    }
240
241    /// Returns the path to the fixture directory
242    #[must_use]
243    pub fn path(&self) -> &Path {
244        &self.resolved_temp_dir
245    }
246
247    /// Returns the path to a file in the fixture directory
248    #[must_use]
249    pub fn path_join(&self, path: &str) -> PathBuf {
250        self.resolved_temp_dir.join(clean_path(path))
251    }
252
253    /// Checks if a file exists in the fixture directory
254    #[must_use]
255    pub fn exists(&self, path: &str) -> bool {
256        self.path_join(path).exists()
257    }
258
259    /// Writes to a file in the fixture directory
260    ///
261    /// # Errors
262    ///
263    /// This function will return an error if the file could not be written.
264    pub fn write_file(&self, path: &str, content: &str) -> io::Result<()> {
265        let full_path = self.path_join(path);
266        if let Some(parent) = full_path.parent() {
267            fs::create_dir_all(parent)?;
268        }
269        fs::write(full_path, content)?;
270        Ok(())
271    }
272
273    /// Reads a file from the fixture directory
274    ///
275    /// # Errors
276    ///
277    /// This function will return an error if the file could not be read.
278    pub fn read_file(&self, path: &str) -> io::Result<String> {
279        fs::read_to_string(self.path_join(path))
280    }
281
282    /// Removes a file from the fixture directory
283    ///
284    /// # Errors
285    ///
286    /// This function will return an error if the file could not be removed.
287    pub fn remove_file(&self, path: &str) -> io::Result<()> {
288        fs::remove_file(self.path_join(path))
289    }
290
291    /// Removes the fixture directory and all of its files
292    ///
293    /// # Errors
294    ///
295    /// This function will return an error if the fixture directory could not be removed.
296    pub fn remove(&self) -> Result<(), io::Error> {
297        fs::remove_dir_all(&self.resolved_temp_dir)
298    }
299}
300impl Drop for FsFixture {
301    fn drop(&mut self) {
302        let _ = fs::remove_dir_all(&self.resolved_temp_dir);
303    }
304}
305
306/// Specified paths should only be allowed to look like this:
307/// - "file.txt"
308/// - "dir/file.txt"
309/// - "dir"
310///
311/// Trim any "./" so it's easier to handle paths, and "../" to prevent
312/// going outside of the fixture directory
313fn clean_path(path: &str) -> String {
314    let mut path = path.replace("/../", "/").replace("/./", "/");
315
316    if path.starts_with("./") {
317        path = path[2..].to_string();
318    } else if path.starts_with("../") {
319        path = path[3..].to_string();
320    }
321
322    while path.contains("//") {
323        path = path.replace("//", "/");
324    }
325
326    while path.starts_with('/') {
327        path = path[1..].to_string();
328    }
329
330    while path.ends_with('/') {
331        path = path[0..path.len() - 1].to_string();
332    }
333
334    path
335}
336
337fn get_temp_dir_name() -> String {
338    let random_id: String = iter::repeat_with(fastrand::alphanumeric).take(8).collect();
339    format!("fs-fixture-{random_id}")
340}
341
342fn symlink_file(original: &Path, link: &Path) -> io::Result<()> {
343    #[cfg(unix)]
344    {
345        use std::os::unix::fs::symlink;
346        symlink(original, link)
347    }
348    #[cfg(windows)]
349    {
350        use std::os::windows::fs::symlink_file;
351        symlink_file(original, link)
352    }
353}
354
355fn symlink_dir(original: &Path, link: &Path) -> io::Result<()> {
356    #[cfg(unix)]
357    {
358        use std::os::unix::fs::symlink;
359        symlink(original, link)
360    }
361    #[cfg(windows)]
362    {
363        use std::os::windows::fs::symlink_dir;
364        symlink_dir(original, link)
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_clean_path() {
374        assert_eq!(clean_path("./foo.txt"), "foo.txt");
375        assert_eq!(clean_path("../foo.txt"), "foo.txt");
376        assert_eq!(clean_path("foo.txt"), "foo.txt");
377        assert_eq!(clean_path("dir/foo.txt"), "dir/foo.txt");
378        assert_eq!(clean_path("dir/../foo.txt"), "dir/foo.txt");
379        assert_eq!(clean_path("dir/./foo.txt"), "dir/foo.txt");
380        assert_eq!(clean_path("/foo.txt"), "foo.txt");
381        assert_eq!(clean_path("/dir/foo.txt"), "dir/foo.txt");
382        assert_eq!(clean_path("/dir/../foo.txt"), "dir/foo.txt");
383        assert_eq!(clean_path("/dir/./foo.txt"), "dir/foo.txt");
384        assert_eq!(clean_path("dir/"), "dir");
385        assert_eq!(clean_path("/dir/"), "dir");
386        assert_eq!(clean_path("/dir/../"), "dir");
387        assert_eq!(clean_path("dir/./"), "dir");
388        assert_eq!(clean_path("./dir/"), "dir");
389        assert_eq!(clean_path("../dir/"), "dir");
390        assert_eq!(clean_path("dir///////foo//////bar"), "dir/foo/bar");
391        assert_eq!(clean_path("dir/.././foo/.../bar"), "dir/foo/.../bar");
392    }
393}