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}