fs_fixture/
lib.rs

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