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 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 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 #[must_use]
102 pub fn file(mut self, path: &str, content: &str) -> Self {
103 self.add_file(path, content);
104 self
105 }
106
107 #[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 #[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 #[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 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 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 #[must_use]
202 pub fn file(mut self, path: &str, content: &str) -> Self {
203 self.add_file(path, content);
204 self
205 }
206
207 #[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 #[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 #[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 #[must_use]
243 pub fn path(&self) -> &Path {
244 &self.resolved_temp_dir
245 }
246
247 #[must_use]
249 pub fn path_join(&self, path: &str) -> PathBuf {
250 self.resolved_temp_dir.join(clean_path(path))
251 }
252
253 #[must_use]
255 pub fn exists(&self, path: &str) -> bool {
256 self.path_join(path).exists()
257 }
258
259 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 pub fn read_file(&self, path: &str) -> io::Result<String> {
279 fs::read_to_string(self.path_join(path))
280 }
281
282 pub fn remove_file(&self, path: &str) -> io::Result<()> {
288 fs::remove_file(self.path_join(path))
289 }
290
291 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
306fn 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}