macro_files/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::io::{ErrorKind, Result};
4use std::path::Path;
5
6#[cfg(feature = "tempfile")]
7pub use tempfile;
8
9/// Create persisting directories and files.
10///
11/// For an example see [library documentation](self)
12#[macro_export]
13macro_rules! create {
14    // Hide distracting implementation details from the generated rustdoc.
15    ($($files:tt)+) => {
16        {
17            #[allow(unused_variables)]
18            let path = ::std::path::PathBuf::default();
19            $crate::create_internal!(@entries path $($files)+)
20        }
21    };
22}
23
24/// Create directories and files within a temporary directory living the time
25/// the returned `tempfile::TempDir` lives.
26///
27/// For an example see [library documentation](self)
28#[cfg(feature = "tempfile")]
29#[macro_export]
30macro_rules! create_temp {
31    // Hide distracting implementation details from the generated rustdoc.
32    ($($files:tt)+) => {
33        $crate::tempfile::tempdir()
34            .and_then(|dir| {
35                #[allow(unused_variables)]
36                let path = dir.path();
37                $crate::create_internal!(@entries path $($files)+).and(Ok(dir))
38            })
39    };
40}
41
42#[macro_export]
43#[doc(hidden)]
44macro_rules! create_internal {
45    //
46    // Parse entries rules
47    //
48
49    // Parse map entries
50    (@entries $dir_path:ident { $($files:tt)+ }) => {
51        $crate::create_internal!(@entry $dir_path () ($($files)+) ($($files)+))
52    };
53
54    // No map entries to parse
55    (@entries $dir_path:ident {}) => {
56        Ok::<(), ::std::io::Error>(())
57    };
58
59    //
60    // Parse entry rules
61    //
62
63    // Value is null, no file creation.
64    (@entry $dir_path:ident ($($file_path:tt)+) (: null $($rest:tt)*) ($($copy:tt)*)) => {
65        $crate::create_internal!(@handle $dir_path [$($file_path)+] (false) $($rest)*)
66    };
67
68    // Value is false, no file creation.
69    (@entry $dir_path:ident ($($file_path:tt)+) (: false $($rest:tt)*) ($($copy:tt)*)) => {
70        $crate::create_internal!(@handle $dir_path [$($file_path)+] (false) $($rest)*)
71    };
72
73    // Value is true, create an empty file.
74    (@entry $dir_path:ident ($($file_path:tt)+) (: true $($rest:tt)*) ($($copy:tt)*)) => {
75        $crate::create_internal!(@handle $dir_path [$($file_path)+] (true) $($rest)*)
76    };
77
78    // Value is a map with potential entries after.
79    // Create map directory, parse the map and then parse the following entries.
80    (@entry $dir_path:ident ($($file_path:tt)+) (: { $($map:tt)* } , $($rest:tt)*) ($($copy:tt)*)) => {
81        {
82            let $dir_path = &$dir_path.join($($file_path)+);
83            $crate::create_dir(&$dir_path).and_then(|_| {
84                $crate::create_internal!(@entries $dir_path { $($map)* })
85            })
86        }
87        .and_then(|_| $crate::create_internal!(@entries $dir_path { $($rest)* }))
88    };
89
90    // Missing comma after a map with following entries.
91    (@entry $dir_path:ident ($($file_path:tt)+) (: { $($map:tt)* } $($unexpected:tt)+) (: $($copy:tt)*)) => {
92        $crate::create_expect_map_comma!($($copy)*)
93    };
94
95    // Value is a map with no entries after.
96    // Create map directory and parse the inner map.
97    (@entry $dir_path:ident ($($file_path:tt)+) (: { $($map:tt)* }) ($($copy:tt)*)) => {
98        {
99            let $dir_path = &$dir_path.join($($file_path)+);
100            $crate::create_dir(&$dir_path).and_then(|_| {
101                $crate::create_internal!(@entries $dir_path { $($map)* })
102            })
103        }
104    };
105
106    // Value is an expression with potential entries after.
107    // Handle the entry and parse the following entries.
108    (@entry $dir_path:ident ($($file_path:tt)+) (: $contents:expr , $($rest:tt)*) ($($copy:tt)*)) => {
109        $crate::create_internal!(@handle $dir_path [$($file_path)+] ($contents) , $($rest)*)
110    };
111
112    // Value is an expression with no entries after.
113    // Handle the entry.
114    (@entry $dir_path:ident ($($file_path:tt)+) (: $contents:expr) ($($copy:tt)*)) => {
115        $crate::create_internal!(@handle $dir_path [$($file_path)+] ($contents))
116    };
117
118    // Missing value for last entry. Trigger a reasonable error message.
119    (@entry $dir_path:ident ($($file_path:tt)+) (:) ($($copy:tt)*)) => {
120        // "unexpected end of macro invocation"
121        $crate::create_internal!()
122    };
123
124    // Missing colon and value for last entry. Trigger a reasonable error message.
125    (@entry $dir_path:ident ($($file_path:tt)+) () ($($copy:tt)*)) => {
126        // "unexpected end of macro invocation"
127        $crate::create_internal!()
128    };
129
130    // Misplaced colon. Trigger a reasonable error message.
131    (@entry $dir_path:ident () (: $($rest:tt)*) ($colon:tt $($copy:tt)*)) => {
132        // Takes no arguments so "no rules expected the token `:`".
133        $crate::create_unexpected!($colon)
134    };
135
136    // Found a comma inside a key. Trigger a reasonable error message.
137    (@entry $dir_path:ident ($($file_path:tt)*) (, $($rest:tt)*) ($comma:tt $($copy:tt)*)) => {
138        // Takes no arguments so "no rules expected the token `,`".
139        $crate::create_unexpected!($comma)
140    };
141
142    // Name is fully parenthesized. This avoids clippy double_parens false
143    // positives because the parenthesization may be necessary here.
144    (@entry $dir_path:ident () (($file_path:expr) : $($rest:tt)*) ($($copy:tt)*)) => {
145        $crate::create_internal!(@entry $dir_path ($file_path) (: $($rest)*) (: $($rest)*))
146    };
147
148    // Expect a comma.
149    (@entry $dir_path:ident ($($file_path:tt)*) (: $($unexpected:tt)+) ($($copy:tt)*)) => {
150        // Expect a comma, so "no rules expected the token `X`".
151        $crate::create_expect_comma!($($unexpected)+)
152    };
153
154    // Unexpected map before a colon.
155    (@entry $dir_path:ident ($($file_path:tt)+) ({ $($map:tt)* } $($rest:tt)*) ($curly_bracket:tt $($copy:tt)*)) => {
156        // Takes no arguments so "no rules expected the token `{`".
157        $crate::create_unexpected!($curly_bracket)
158    };
159
160    // TT muncher, parse a path.
161    (@entry $dir_path:ident ($($path:tt)*) ($tt:tt $($rest:tt)*) ($($copy:tt)*)) => {
162        $crate::create_internal!(@entry $dir_path ($($path)* $tt) ($($rest)*) ($($rest)*))
163    };
164
165    //
166    // Handle rules
167    //
168
169    // Handle current entry and continue.
170    (@handle $dir_path:ident [$($file_path:tt)+] ($contents:tt) , $($rest:tt)*) => {
171        $crate::create_internal!(@write_file ($dir_path) ($($file_path)+) ($contents))
172            .and_then(|_| $crate::create_internal!(@entries $dir_path { $($rest)* }))
173    };
174
175    // Current entry followed by unexpected token.
176    (@handle $dir_path:ident [$($file_path:tt)+] ($contents:expr) $unexpected:tt $($rest:tt)*) => {
177        $crate::create_unexpected!($unexpected)
178    };
179
180    // Handle current entry and stop.
181    (@handle $dir_path:ident [$($file_path:tt)+] ($contents:tt)) => {
182        $crate::create_internal!(@write_file ($dir_path) ($($file_path)+) ($contents))
183    };
184
185    //
186    // Write rules
187    //
188
189    // Not write file.
190    (@write_file ($dir_path:ident) ($($file_path:tt)+) (false)) => {
191        Ok::<(), ::std::io::Error>(())
192    };
193
194    // Write an empty file.
195    (@write_file ($dir_path:ident) ($($file_path:tt)+) (true)) => {
196        $crate::write_file($dir_path.join($($file_path)+), "")
197    };
198
199    // Write a file with its contents.
200    (@write_file ($dir_path:ident) ($($file_path:tt)+) ($contents:expr)) => {
201        $crate::write_file($dir_path.join($($file_path)+), $contents)
202    };
203}
204
205#[macro_export]
206#[doc(hidden)]
207macro_rules! create_expect_comma {
208    ($e:expr , $($tt:tt)*) => {};
209}
210
211#[macro_export]
212#[doc(hidden)]
213macro_rules! create_expect_map_comma {
214    ({$($tt:tt)*} , $($rest:tt)*) => {};
215}
216
217#[macro_export]
218#[doc(hidden)]
219macro_rules! create_unexpected {
220    () => {};
221}
222
223#[cfg(not(test))]
224pub fn create_dir<P: AsRef<Path>>(path: P) -> Result<()> {
225    std::fs::create_dir_all(path)
226}
227
228#[cfg(test)]
229fn create_dir<P: AsRef<Path>>(path: P) -> Result<()> {
230    test_helper::create_dir(path)
231}
232
233#[cfg(not(test))]
234pub fn write_file<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> {
235    match std::fs::write(&path, &contents) {
236        Err(err) if err.kind() == ErrorKind::NotFound => {
237            let dir_path = path.as_ref().parent().ok_or(err)?;
238            std::fs::create_dir_all(dir_path).and_then(|_| std::fs::write(&path, &contents))
239        }
240        result => result,
241    }
242}
243
244#[cfg(test)]
245fn write_file<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> {
246    test_helper::write_file(path, contents)
247}
248
249#[cfg(test)]
250mod test_helper {
251    use std::cell::Cell;
252    use std::collections::HashSet;
253    use std::io::{Error, ErrorKind, Result};
254    use std::path::{Path, PathBuf};
255
256    thread_local!(static WRITES: Cell<Option<(Vec<Write>, HashSet<PathBuf>)>> = Cell::new(None));
257
258    pub struct Watcher<T, F>
259    where
260        T: Sized,
261        F: Fn() -> Option<T>,
262    {
263        consume_cb: F,
264    }
265
266    impl<T, F> Watcher<T, F>
267    where
268        T: Sized,
269        F: Fn() -> Option<T>,
270    {
271        pub fn consume(self) -> T {
272            (self.consume_cb)().unwrap()
273        }
274    }
275
276    impl<T, F> Drop for Watcher<T, F>
277    where
278        T: Sized,
279        F: Fn() -> Option<T>,
280    {
281        fn drop(&mut self) {
282            (self.consume_cb)();
283        }
284    }
285
286    pub fn watch_fs() -> Watcher<Vec<Write>, impl Fn() -> Option<Vec<Write>>> {
287        WRITES.with(|cell| cell.set(Some((Default::default(), Default::default()))));
288        Watcher {
289            consume_cb: || {
290                WRITES
291                    .with(|cell| cell.replace(None))
292                    .map(|(writes, _)| writes)
293            },
294        }
295    }
296
297    #[derive(PartialEq, Eq, Debug)]
298    pub enum Write {
299        Dir(PathBuf),
300        File(PathBuf, Vec<u8>),
301    }
302
303    impl Write {
304        pub fn dir(path: impl AsRef<str>) -> Write {
305            Write::Dir(path.as_ref().into())
306        }
307
308        pub fn file(path: impl AsRef<str>, contents: impl AsRef<str>) -> Write {
309            Write::File(path.as_ref().into(), contents.as_ref().into())
310        }
311    }
312
313    pub fn create_dir<P: AsRef<Path>>(path: P) -> Result<()> {
314        WRITES.with(|cell| {
315            if let Some(mut writes) = cell.take() {
316                let path = path.as_ref().to_owned();
317                if writes.1.contains(&path) {
318                    cell.replace(Some(writes));
319                    return Err(Error::from(ErrorKind::Other));
320                }
321                writes.0.push(Write::Dir(path));
322                cell.replace(Some(writes));
323            }
324            Ok(())
325        })
326    }
327
328    pub fn write_file<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> {
329        WRITES.with(|cell| {
330            if let Some(mut writes) = cell.take() {
331                let path = path.as_ref().to_owned();
332                if writes.1.contains(&path) {
333                    cell.replace(Some(writes));
334                    return Err(Error::from(ErrorKind::Other));
335                }
336                let contents = contents.as_ref().to_owned();
337                writes.0.push(Write::File(path, contents));
338                cell.replace(Some(writes));
339            }
340            Ok(())
341        })
342    }
343
344    pub fn fail_fs<P: AsRef<Path>>(path: P) {
345        WRITES.with(|cell| {
346            if let Some(mut writes) = cell.take() {
347                writes.1.insert(path.as_ref().to_owned());
348                cell.replace(Some(writes));
349            }
350        });
351    }
352}
353
354#[cfg(test)]
355mod fs_tests {
356    use super::test_helper::Write;
357    use super::*;
358
359    #[test]
360    fn test_1() {
361        let watcher = test_helper::watch_fs();
362        create!({
363            "README.md": "# Project",
364            ("LICENSE"): "MIT"
365        })
366        .unwrap();
367        let expected = vec![
368            Write::file("README.md", "# Project"),
369            Write::file("LICENSE", "MIT"),
370        ];
371        assert_eq!(watcher.consume(), expected);
372    }
373
374    #[test]
375    fn test_2() {
376        let watcher = test_helper::watch_fs();
377        create!({
378            "directory": {
379                "README.md": "# Project"
380            },
381            "sibling": {}
382        })
383        .unwrap();
384        let expected = vec![
385            Write::dir("directory"),
386            Write::file("directory/README.md", "# Project"),
387            Write::dir("sibling"),
388        ];
389        assert_eq!(watcher.consume(), expected);
390    }
391
392    #[test]
393    fn test_3() {
394        let watcher = test_helper::watch_fs();
395
396        let project_name = String::from("Rust project");
397        let adr_directory = "adr";
398        let adr_template = ["# NUMBER. TITLE", "", "Date: DATE"].join("\n");
399        fn license() -> &'static str {
400            "MIT License..."
401        }
402        fn markdown(name: &str) -> String {
403            format!("{}.md", name)
404        }
405
406        create!({
407            ["long", "path"].join("/"): {
408                markdown("README"): format!("# {}", project_name),
409                "docs": {
410                    markdown("README"): "# Documentation",
411                    "assets": {},
412                    "examples": {}
413                },
414                adr_directory: {
415                    "templates": {
416                        markdown("template"): adr_template,
417                    }
418                },
419                "LICENSE": license(),
420                ".adr-dir": adr_directory,
421            },
422            "other": {
423                "not-create-1": false,
424                "not-create-2": null,
425                ".gitkeep": true,
426                "path/as/name": true
427            },
428        })
429        .unwrap();
430        let expected = vec![
431            Write::dir("long/path"),
432            Write::file("long/path/README.md", "# Rust project"),
433            Write::dir("long/path/docs"),
434            Write::file("long/path/docs/README.md", "# Documentation"),
435            Write::dir("long/path/docs/assets"),
436            Write::dir("long/path/docs/examples"),
437            Write::dir("long/path/adr"),
438            Write::dir("long/path/adr/templates"),
439            Write::file(
440                "long/path/adr/templates/template.md",
441                "# NUMBER. TITLE\n\nDate: DATE",
442            ),
443            Write::file("long/path/LICENSE", "MIT License..."),
444            Write::file("long/path/.adr-dir", "adr"),
445            Write::dir("other"),
446            Write::file("other/.gitkeep", ""),
447            Write::file("other/path/as/name", ""),
448        ];
449        assert_eq!(watcher.consume(), expected);
450    }
451
452    #[test]
453    fn directory_fails() {
454        let watcher = test_helper::watch_fs();
455        test_helper::fail_fs("second-error");
456        let result = create!({
457            "first-success": {},
458            "second-error": {},
459            "third-not-attempted": {},
460        });
461        let expected = vec![Write::dir("first-success")];
462        assert_eq!(result.unwrap_err().kind(), ErrorKind::Other);
463        assert_eq!(watcher.consume(), expected);
464
465        let watcher = test_helper::watch_fs();
466        test_helper::fail_fs("second-error");
467        let result = create!({
468            "first-success": {
469                "README.md": "# Project 1",
470            },
471            "second-error": {
472                "README.md": "# Project 2",
473            }
474        });
475        let expected = vec![
476            Write::dir("first-success"),
477            Write::file("first-success/README.md", "# Project 1"),
478        ];
479        assert_eq!(result.unwrap_err().kind(), ErrorKind::Other);
480        assert_eq!(watcher.consume(), expected);
481    }
482
483    #[test]
484    fn file_fails() {
485        let watcher = test_helper::watch_fs();
486        test_helper::fail_fs("second-success/README.md");
487        let result = create!({
488            "first-success": {
489                "README.md": "# Project 1",
490                "LICENSE": "MIT"
491            },
492            "second-success": {
493                "README.md": "# Project error",
494                "LICENSE": "MIT"
495            },
496            "third-not-attempted": {
497                "README.md": "# Project 3",
498                "LICENSE": "MIT"
499            },
500        });
501        let expected = vec![
502            Write::dir("first-success"),
503            Write::file("first-success/README.md", "# Project 1"),
504            Write::file("first-success/LICENSE", "MIT"),
505            Write::dir("second-success"),
506        ];
507        assert_eq!(result.unwrap_err().kind(), ErrorKind::Other);
508        assert_eq!(watcher.consume(), expected);
509    }
510}