change_detection/
lib.rs

1/*!
2# A library to generate change detection instructions during build time
3
4## Legal
5
6Dual-licensed under `MIT` or the [UNLICENSE](http://unlicense.org/).
7
8## Features
9
10Automates task of generating change detection instructions for your static files.
11
12<https://doc.rust-lang.org/cargo/reference/build-scripts.html#change-detection>
13
14## Usage
15
16Add dependency to Cargo.toml:
17
18```toml
19[dependencies]
20change-detection = "1.2"
21```
22
23Add a call to `build.rs`:
24
25```rust
26use change_detection::ChangeDetection;
27
28fn main() {
29    ChangeDetection::path("src/hello.c").generate();
30}
31```
32
33This is basically the same, as just write:
34
35```rust
36fn main() {
37    println!("cargo:rerun-if-changed=src/hello.c");
38}
39```
40
41You can also use a directory. For example, if your resources are in `static` directory:
42
43```rust
44use change_detection::ChangeDetection;
45
46fn main() {
47    ChangeDetection::path("static").generate();
48}
49```
50
51One call to generate can have multiple `path` components:
52
53```rust
54use change_detection::ChangeDetection;
55
56fn main() {
57    ChangeDetection::path("static")
58        .path("another_path")
59        .path("build.rs")
60        .generate();
61}
62```
63
64Using `path-matchers` library you can specify include / exclude filters:
65
66```rust
67#[cfg(features = "glob")]
68use change_detection::{path_matchers::glob, ChangeDetection};
69
70fn main() {
71    #[cfg(features = "glob")]
72    ChangeDetection::exclude(glob("another_path/**/*.tmp").unwrap())
73        .path("static")
74        .path("another_path")
75        .path("build.rs")
76        .generate();
77}
78```
79
80You can find generated result with this command:
81
82```bash
83find . -name output | xargs cat
84```
85
86*/
87use ::path_matchers::PathMatcher;
88use path_slash::PathExt;
89use std::path::{Path, PathBuf};
90
91/// Reexport `path-matchers`.
92pub mod path_matchers {
93    pub use ::path_matchers::*;
94}
95
96/// A change detection entry point.
97///
98/// Creates a builder to generate change detection instructions.
99///
100/// # Examples
101///
102/// ```
103/// use change_detection::ChangeDetection;
104///
105/// fn main() {
106///     ChangeDetection::path("src/hello.c").generate();
107/// }
108/// ```
109///
110/// This is the same as just write:
111///
112/// ```ignore
113/// fn main() {
114///     println!("cargo:rerun-if-changed=src/hello.c");
115/// }
116/// ```
117///
118/// You can collect resources from a path:
119///
120/// ```
121/// # use change_detection::ChangeDetection;
122///
123/// fn main() {
124///     ChangeDetection::path("some_path").generate();
125/// }
126/// ```
127///
128/// To chain multiple directories and files:
129///
130/// ```
131/// # use change_detection::ChangeDetection;
132///
133/// fn main() {
134///     ChangeDetection::path("src/hello.c")
135///         .path("static")
136///         .path("build.rs")
137///         .generate();
138/// }
139/// ```
140pub struct ChangeDetection;
141
142impl ChangeDetection {
143    /// Collects change detection instructions from a `path`.
144    ///
145    /// A `path` can be a single file or a directory.
146    ///
147    /// # Examples:
148    ///
149    /// To generate change instructions for the directory with the name `static`:
150    ///
151    /// ```
152    /// # use change_detection::ChangeDetection;
153    /// ChangeDetection::path("static").generate();
154    /// ```
155    ///
156    /// To generate change instructions for the file with the name `build.rs`:
157    ///
158    /// ```
159    /// # use change_detection::ChangeDetection;
160    /// ChangeDetection::path("build.rs").generate();
161    /// ```
162    pub fn path<P>(path: P) -> ChangeDetectionBuilder
163    where
164        P: AsRef<Path>,
165    {
166        ChangeDetectionBuilder::default().path(path)
167    }
168
169    /// Collects change detection instructions from a `path` applying include `filter`.
170    ///
171    /// A `path` can be a single file or a directory.
172    ///
173    /// # Examples:
174    ///
175    /// To generate change instructions for the directory with the name `static` but only for files ending with `b`:
176    ///
177    /// ```
178    /// # use change_detection::ChangeDetection;
179    /// ChangeDetection::path_include("static", |path: &std::path::Path| {
180    ///     path.file_name()
181    ///         .map(|filename| filename.to_str().unwrap().ends_with("b"))
182    ///         .unwrap_or(false)
183    /// }).generate();
184    /// ```
185    pub fn path_include<P, F>(path: P, filter: F) -> ChangeDetectionBuilder
186    where
187        P: AsRef<Path>,
188        F: PathMatcher + 'static,
189    {
190        ChangeDetectionBuilder::default().path_include(path, filter)
191    }
192
193    /// Collects change detection instructions from a `path` applying exclude `filter`.
194    ///
195    /// A `path` can be a single file or a directory.
196    ///
197    /// # Examples:
198    ///
199    /// To generate change instructions for the directory with the name `static` but without files ending with `b`:
200    ///
201    /// ```
202    /// # use change_detection::ChangeDetection;
203    /// ChangeDetection::path_exclude("static", |path: &std::path::Path| {
204    ///     path.file_name()
205    ///         .map(|filename| filename.to_str().unwrap().ends_with("b"))
206    ///         .unwrap_or(false)
207    /// }).generate();
208    /// ```
209    pub fn path_exclude<P, F>(path: P, filter: F) -> ChangeDetectionBuilder
210    where
211        P: AsRef<Path>,
212        F: PathMatcher + 'static,
213    {
214        ChangeDetectionBuilder::default().path_exclude(path, filter)
215    }
216
217    /// Collects change detection instructions from a `path` applying `include` and `exclude` filters.
218    ///
219    /// A `path` can be a single file or a directory.
220    ///
221    /// # Examples:
222    ///
223    /// To generate change instructions for the directory with the name `static` including only files starting with `a` but without files ending with `b`:
224    ///
225    /// ```
226    /// # use change_detection::ChangeDetection;
227    /// ChangeDetection::path_filter("static", |path: &std::path::Path| {
228    ///     path.file_name()
229    ///         .map(|filename| filename.to_str().unwrap().starts_with("a"))
230    ///         .unwrap_or(false)
231    /// }, |path: &std::path::Path| {
232    ///     path.file_name()
233    ///         .map(|filename| filename.to_str().unwrap().ends_with("b"))
234    ///         .unwrap_or(false)
235    /// }).generate();
236    /// ```
237    pub fn path_filter<P, F1, F2>(path: P, include: F1, exclude: F2) -> ChangeDetectionBuilder
238    where
239        P: AsRef<Path>,
240        F1: PathMatcher + 'static,
241        F2: PathMatcher + 'static,
242    {
243        ChangeDetectionBuilder::default().path_filter(path, include, exclude)
244    }
245
246    /// Applies a global `include` filter to all paths.
247    ///
248    /// # Examples:
249    ///
250    /// To included only files starting with `a` for paths `static1`, `static2` and `static3`:
251    ///
252    /// ```
253    /// # use change_detection::ChangeDetection;
254    /// ChangeDetection::include(|path: &std::path::Path| {
255    ///         path.file_name()
256    ///             .map(|filename| filename.to_str().unwrap().starts_with("a"))
257    ///             .unwrap_or(false)
258    ///     })
259    ///     .path("static1")
260    ///     .path("static2")
261    ///     .path("static3")
262    ///     .generate();
263    /// ```
264    pub fn include<F>(filter: F) -> ChangeDetectionBuilder
265    where
266        F: PathMatcher + 'static,
267    {
268        ChangeDetectionBuilder::default().include(filter)
269    }
270
271    /// Applies a global `exclude` filter to all paths.
272    ///
273    /// # Examples:
274    ///
275    /// To exclude files starting with `a` for paths `static1`, `static2` and `static3`:
276    ///
277    /// ```
278    /// # use change_detection::ChangeDetection;
279    /// ChangeDetection::exclude(|path: &std::path::Path| {
280    ///         path.file_name()
281    ///             .map(|filename| filename.to_str().unwrap().starts_with("a"))
282    ///             .unwrap_or(false)
283    ///     })
284    ///     .path("static1")
285    ///     .path("static2")
286    ///     .path("static3")
287    ///     .generate();
288    /// ```
289    pub fn exclude<F>(filter: F) -> ChangeDetectionBuilder
290    where
291        F: PathMatcher + 'static,
292    {
293        ChangeDetectionBuilder::default().exclude(filter)
294    }
295
296    /// Applies a global `include` and `exclude` filters to all paths.
297    ///
298    /// # Examples:
299    ///
300    /// To include files starting with `a` for paths `static1`, `static2` and `static3`, but whose names do not end in `b`:
301    ///
302    /// ```
303    /// # use change_detection::ChangeDetection;
304    /// ChangeDetection::filter(|path: &std::path::Path| {
305    ///         path.file_name()
306    ///             .map(|filename| filename.to_str().unwrap().starts_with("a"))
307    ///             .unwrap_or(false)
308    ///     }, |path: &std::path::Path| {
309    ///         path.file_name()
310    ///             .map(|filename| filename.to_str().unwrap().ends_with("b"))
311    ///             .unwrap_or(false)
312    ///     })
313    ///     .path("static1")
314    ///     .path("static2")
315    ///     .path("static3")
316    ///     .generate();
317    /// ```
318    pub fn filter<F1, F2>(include: F1, exclude: F2) -> ChangeDetectionBuilder
319    where
320        F1: PathMatcher + 'static,
321        F2: PathMatcher + 'static,
322    {
323        ChangeDetectionBuilder::default()
324            .include(include)
325            .exclude(exclude)
326    }
327}
328
329/// A change detection builder.
330///
331/// A builder to generate change detection instructions.
332/// You should not use this directly, use [`ChangeDetection`] as an entry point instead.
333#[derive(Default)]
334pub struct ChangeDetectionBuilder {
335    include: Option<Box<dyn PathMatcher>>,
336    exclude: Option<Box<dyn PathMatcher>>,
337    paths: Vec<ChangeDetectionPath>,
338}
339
340impl ChangeDetectionBuilder {
341    /// Collects change detection instructions from a `path`.
342    ///
343    /// A `path` can be a single file or a directory.
344    ///
345    /// # Examples:
346    ///
347    /// To generate change instructions for the directory with the name `static`:
348    ///
349    /// ```
350    /// # use change_detection::ChangeDetectionBuilder;
351    /// # let builder = ChangeDetectionBuilder::default();
352    /// builder.path("static").generate();
353    /// ```
354    ///
355    /// To generate change instructions for the file with the name `build.rs`:
356    ///
357    /// ```
358    /// # use change_detection::ChangeDetectionBuilder;
359    /// # let builder = ChangeDetectionBuilder::default();
360    /// builder.path("build.rs").generate();
361    /// ```
362    pub fn path<P>(mut self, path: P) -> ChangeDetectionBuilder
363    where
364        P: Into<ChangeDetectionPath>,
365    {
366        self.paths.push(path.into());
367        self
368    }
369
370    /// Collects change detection instructions from a `path` applying include `filter`.
371    ///
372    /// A `path` can be a single file or a directory.
373    ///
374    /// # Examples:
375    ///
376    /// To generate change instructions for the directory with the name `static` but only for files ending with `b`:
377    ///
378    /// ```
379    /// # use change_detection::ChangeDetectionBuilder;
380    /// # let builder = ChangeDetectionBuilder::default();
381    /// builder.path_include("static", |path: &std::path::Path| {
382    ///     path.file_name()
383    ///         .map(|filename| filename.to_str().unwrap().ends_with("b"))
384    ///         .unwrap_or(false)
385    /// }).generate();
386    /// ```
387    pub fn path_include<P, F>(mut self, path: P, filter: F) -> ChangeDetectionBuilder
388    where
389        P: AsRef<Path>,
390        F: PathMatcher + 'static,
391    {
392        self.paths.push(ChangeDetectionPath::PathInclude(
393            path.as_ref().into(),
394            Box::new(filter),
395        ));
396        self
397    }
398
399    /// Collects change detection instructions from a `path` applying exclude `filter`.
400    ///
401    /// A `path` can be a single file or a directory.
402    ///
403    /// # Examples:
404    ///
405    /// To generate change instructions for the directory with the name `static` but without files ending with `b`:
406    ///
407    /// ```
408    /// # use change_detection::ChangeDetectionBuilder;
409    /// # let builder = ChangeDetectionBuilder::default();
410    /// builder.path_exclude("static", |path: &std::path::Path| {
411    ///     path.file_name()
412    ///         .map(|filename| filename.to_str().unwrap().ends_with("b"))
413    ///         .unwrap_or(false)
414    /// }).generate();
415    /// ```
416    pub fn path_exclude<P, F>(mut self, path: P, filter: F) -> ChangeDetectionBuilder
417    where
418        P: AsRef<Path>,
419        F: PathMatcher + 'static,
420    {
421        self.paths.push(ChangeDetectionPath::PathExclude(
422            path.as_ref().into(),
423            Box::new(filter),
424        ));
425        self
426    }
427
428    /// Collects change detection instructions from a `path` applying `include` and `exclude` filters.
429    ///
430    /// A `path` can be a single file or a directory.
431    ///
432    /// # Examples:
433    ///
434    /// To generate change instructions for the directory with the name `static` including only files starting with `a` but without files ending with `b`:
435    ///
436    /// ```
437    /// # use change_detection::ChangeDetectionBuilder;
438    /// # let builder = ChangeDetectionBuilder::default();
439    /// builder.path_filter("static", |path: &std::path::Path| {
440    ///     path.file_name()
441    ///         .map(|filename| filename.to_str().unwrap().starts_with("a"))
442    ///         .unwrap_or(false)
443    /// }, |path: &std::path::Path| {
444    ///     path.file_name()
445    ///         .map(|filename| filename.to_str().unwrap().ends_with("b"))
446    ///         .unwrap_or(false)
447    /// }).generate();
448    /// ```
449    pub fn path_filter<P, F1, F2>(
450        mut self,
451        path: P,
452        include: F1,
453        exclude: F2,
454    ) -> ChangeDetectionBuilder
455    where
456        P: AsRef<Path>,
457        F1: PathMatcher + 'static,
458        F2: PathMatcher + 'static,
459    {
460        self.paths.push(ChangeDetectionPath::PathIncludeExclude {
461            path: path.as_ref().into(),
462            include: Box::new(include),
463            exclude: Box::new(exclude),
464        });
465        self
466    }
467
468    fn include<F>(mut self, filter: F) -> ChangeDetectionBuilder
469    where
470        F: PathMatcher + 'static,
471    {
472        self.include = Some(Box::new(filter));
473        self
474    }
475
476    fn exclude<F>(mut self, filter: F) -> ChangeDetectionBuilder
477    where
478        F: PathMatcher + 'static,
479    {
480        self.exclude = Some(Box::new(filter));
481        self
482    }
483
484    pub fn generate(self) {
485        self.generate_extended(print_change_detection_instruction)
486    }
487
488    fn generate_extended<F>(self, mut f: F)
489    where
490        F: FnMut(&Path),
491    {
492        for path in &self.paths {
493            path.generate(&self, &mut f);
494        }
495    }
496
497    fn filter_include_exclude(&self, path: &Path) -> bool {
498        self.include
499            .as_ref()
500            .map_or(true, |filter| filter.matches(path))
501            && self
502                .exclude
503                .as_ref()
504                .map_or(true, |filter| !filter.matches(path))
505    }
506}
507
508pub enum ChangeDetectionPath {
509    Path(PathBuf),
510    PathInclude(PathBuf, Box<dyn PathMatcher>),
511    PathExclude(PathBuf, Box<dyn PathMatcher>),
512    PathIncludeExclude {
513        path: PathBuf,
514        include: Box<dyn PathMatcher>,
515        exclude: Box<dyn PathMatcher>,
516    },
517}
518
519fn print_change_detection_instruction(path: &Path) {
520    println!(
521        "cargo:rerun-if-changed={}",
522        path.to_slash().expect("can't convert path to utf-8 string")
523    );
524}
525
526impl ChangeDetectionPath {
527    fn collect(&self, builder: &ChangeDetectionBuilder) -> std::io::Result<Vec<PathBuf>> {
528        let filter_fn: Box<dyn Fn(&_) -> bool> =
529            Box::new(|path: &std::path::Path| builder.filter_include_exclude(path));
530
531        let (path, filter): (&PathBuf, Box<dyn Fn(&_) -> bool>) = match self {
532            ChangeDetectionPath::Path(path) => (path, filter_fn),
533            ChangeDetectionPath::PathInclude(path, include_filter) => (
534                path,
535                Box::new(move |p: &Path| filter_fn(p.as_ref()) && include_filter.matches(p)),
536            ),
537            ChangeDetectionPath::PathExclude(path, exclude_filter) => (
538                path,
539                Box::new(move |p: &Path| filter_fn(p.as_ref()) && !exclude_filter.matches(p)),
540            ),
541            ChangeDetectionPath::PathIncludeExclude {
542                path,
543                include,
544                exclude,
545            } => (
546                path,
547                Box::new(move |p: &Path| {
548                    filter_fn(p.as_ref()) && include.matches(p) && !exclude.matches(p)
549                }),
550            ),
551        };
552
553        collect_resources(path, &filter)
554    }
555
556    fn generate<F>(&self, builder: &ChangeDetectionBuilder, printer: &mut F)
557    where
558        F: FnMut(&Path),
559    {
560        for path in self.collect(builder).expect("error collecting resources") {
561            printer(path.as_ref());
562        }
563    }
564}
565
566impl<T> From<T> for ChangeDetectionPath
567where
568    T: AsRef<Path>,
569{
570    fn from(path: T) -> Self {
571        ChangeDetectionPath::Path(path.as_ref().into())
572    }
573}
574
575fn collect_resources(path: &Path, filter: &dyn PathMatcher) -> std::io::Result<Vec<PathBuf>> {
576    let mut result = vec![];
577
578    if filter.matches(path.as_ref()) {
579        result.push(path.into());
580    }
581
582    if !path.is_dir() {
583        return Ok(result);
584    }
585
586    for entry in std::fs::read_dir(&path)? {
587        let entry = entry?;
588        let path = entry.path();
589
590        let nested = collect_resources(path.as_ref(), filter)?;
591        result.extend(nested);
592    }
593
594    Ok(result)
595}
596
597#[cfg(test)]
598mod tests {
599    use super::{ChangeDetection, ChangeDetectionBuilder};
600    use std::path::{Path, PathBuf};
601
602    fn assert_change_detection(builder: ChangeDetectionBuilder, expected: &[&str]) {
603        let mut result: Vec<PathBuf> = vec![];
604        let r = &mut result;
605
606        builder.generate_extended(move |path| r.push(path.into()));
607
608        let mut expected = expected
609            .iter()
610            .map(|s| PathBuf::from(s))
611            .collect::<Vec<_>>();
612
613        expected.sort();
614        result.sort();
615
616        assert_eq!(result, expected);
617    }
618
619    #[test]
620    fn single_file() {
621        assert_change_detection(ChangeDetection::path("src/lib.rs"), &["src/lib.rs"]);
622    }
623
624    #[test]
625    fn single_path() {
626        assert_change_detection(ChangeDetection::path("src"), &["src", "src/lib.rs"]);
627    }
628
629    #[test]
630    fn fixture_01() {
631        assert_change_detection(
632            ChangeDetection::path("fixtures-01"),
633            &[
634                "fixtures-01",
635                "fixtures-01/a",
636                "fixtures-01/ab",
637                "fixtures-01/b",
638                "fixtures-01/bc",
639                "fixtures-01/c",
640                "fixtures-01/cd",
641            ],
642        );
643    }
644
645    #[test]
646    fn fixture_01_global_include() {
647        assert_change_detection(
648            ChangeDetection::include(|path: &Path| {
649                path.file_name()
650                    .map(|filename| filename.to_str().unwrap().ends_with("b"))
651                    .unwrap_or(false)
652            })
653            .path("fixtures-01"),
654            &["fixtures-01/ab", "fixtures-01/b"],
655        );
656    }
657
658    #[test]
659    fn fixture_01_global_exclude() {
660        assert_change_detection(
661            ChangeDetection::exclude(|path: &Path| {
662                path.file_name()
663                    .map(|filename| filename.to_str().unwrap().ends_with("b"))
664                    .unwrap_or(false)
665            })
666            .path("fixtures-01"),
667            &[
668                "fixtures-01",
669                "fixtures-01/a",
670                "fixtures-01/bc",
671                "fixtures-01/c",
672                "fixtures-01/cd",
673            ],
674        );
675    }
676
677    #[test]
678    fn fixture_01_global_filter() {
679        assert_change_detection(
680            ChangeDetection::filter(
681                |path: &Path| {
682                    path.file_name()
683                        .map(|filename| filename.to_str().unwrap().ends_with("b"))
684                        .unwrap_or(false)
685                },
686                |path: &Path| {
687                    path.file_name()
688                        .map(|filename| filename.to_str().unwrap().starts_with("a"))
689                        .unwrap_or(false)
690                },
691            )
692            .path("fixtures-01"),
693            &["fixtures-01/b"],
694        );
695    }
696
697    #[test]
698    fn fixture_02() {
699        assert_change_detection(
700            ChangeDetection::path("fixtures-02"),
701            &[
702                "fixtures-02",
703                "fixtures-02/abc",
704                "fixtures-02/def",
705                "fixtures-02/ghk",
706            ],
707        );
708    }
709
710    #[test]
711    fn fixture_03() {
712        assert_change_detection(
713            ChangeDetection::path("fixtures-03"),
714            &[
715                "fixtures-03",
716                "fixtures-03/hello",
717                "fixtures-03/hello.c",
718                "fixtures-03/hello.js",
719            ],
720        );
721    }
722
723    #[test]
724    fn all_fixtures() {
725        assert_change_detection(
726            ChangeDetection::path("fixtures-01")
727                .path("fixtures-02")
728                .path("fixtures-03"),
729            &[
730                "fixtures-01",
731                "fixtures-01/a",
732                "fixtures-01/ab",
733                "fixtures-01/b",
734                "fixtures-01/bc",
735                "fixtures-01/c",
736                "fixtures-01/cd",
737                "fixtures-02",
738                "fixtures-02/abc",
739                "fixtures-02/def",
740                "fixtures-02/ghk",
741                "fixtures-03",
742                "fixtures-03/hello",
743                "fixtures-03/hello.c",
744                "fixtures-03/hello.js",
745            ],
746        );
747    }
748
749    #[test]
750    #[cfg(feature = "glob")]
751    fn path_matchers() {
752        use path_matchers::glob;
753        assert_change_detection(
754            ChangeDetection::include(glob("**/a*").unwrap())
755                .path("fixtures-01")
756                .path("fixtures-02")
757                .path("fixtures-03"),
758            &["fixtures-01/a", "fixtures-01/ab", "fixtures-02/abc"],
759        );
760    }
761
762    #[test]
763    fn npm_example() {
764        assert_change_detection(
765            ChangeDetection::path_exclude("fixtures-04", |path: &Path| {
766                path.to_str() == Some("fixtures-04") || !path.is_dir()
767            }),
768            &[
769                "fixtures-04/dist",
770                "fixtures-04/dist/imgs",
771                "fixtures-04/src",
772                "fixtures-04/src/imgs",
773            ],
774        );
775    }
776}