1use std::ffi;
2use std::fs;
3use std::io::Write;
4use std::path;
5
6use crate::error::Result;
7use anyhow::Context as _;
8use ignore::Match;
9use ignore::gitignore::{Gitignore, GitignoreBuilder};
10use log::debug;
11use log::trace;
12use normalize_line_endings::normalized;
13use walkdir::{DirEntry, WalkDir};
14
15pub struct FilesBuilder {
16 root_dir: path::PathBuf,
17 subtree: Option<path::PathBuf>,
18 ignore: Vec<String>,
19 ignore_hidden: bool,
20 extensions: Vec<ffi::OsString>,
21}
22
23impl FilesBuilder {
24 pub fn new<R: Into<path::PathBuf>>(root_dir: R) -> Result<Self> {
25 Self::new_from_path(root_dir.into())
26 }
27
28 fn new_from_path(root_dir: path::PathBuf) -> Result<Self> {
29 let builder = FilesBuilder {
30 root_dir,
31 subtree: Default::default(),
32 ignore: Default::default(),
33 ignore_hidden: true,
34 extensions: Default::default(),
35 };
36
37 Ok(builder)
38 }
39
40 pub fn add_ignore(&mut self, line: &str) -> Result<&mut Self> {
41 trace!(
42 "{}: adding '{}' ignore pattern",
43 self.root_dir.display(),
44 line
45 );
46 self.ignore.push(line.to_owned());
47 Ok(self)
48 }
49
50 pub fn ignore_hidden(&mut self, ignore: bool) -> Result<&mut Self> {
51 self.ignore_hidden = ignore;
52 Ok(self)
53 }
54
55 pub fn limit(&mut self, subtree: path::PathBuf) -> Result<&mut Self> {
56 self.subtree = Some(subtree);
57 Ok(self)
58 }
59
60 pub fn add_extension(&mut self, ext: &str) -> Result<&mut FilesBuilder> {
61 trace!("{}: adding '{}' extension", self.root_dir.display(), ext);
62 self.extensions.push(ext.into());
63 Ok(self)
64 }
65
66 pub fn build(&self) -> Result<Files> {
67 let mut ignore = GitignoreBuilder::new(&self.root_dir);
68 if self.ignore_hidden {
69 ignore.add_line(None, ".*")?;
70 ignore.add_line(None, "_*")?;
71 }
72 for line in &self.ignore {
73 ignore.add_line(None, line)?;
74 }
75 let ignore = ignore.build()?;
76
77 let files = Files {
78 root_dir: self.root_dir.clone(),
79 subtree: self
80 .subtree
81 .as_ref()
82 .map(|subtree| self.root_dir.join(subtree)),
83 ignore,
84 extensions: self.extensions.clone(),
85 };
86 Ok(files)
87 }
88}
89
90pub struct FilesIterator<'a> {
91 inner: Box<dyn Iterator<Item = path::PathBuf> + 'a>,
92}
93
94impl<'a> FilesIterator<'a> {
95 fn new(files: &'a Files) -> FilesIterator<'a> {
96 let walker = WalkDir::new(files.root_dir.as_path())
97 .min_depth(1)
98 .follow_links(false)
99 .sort_by(|a, b| a.file_name().cmp(b.file_name()))
100 .into_iter()
101 .filter_entry(move |e| files.includes_entry(e))
102 .filter_map(|e| e.ok())
103 .filter(|e| e.file_type().is_file())
104 .map(move |e| e.path().to_path_buf());
105 FilesIterator {
106 inner: Box::new(walker),
107 }
108 }
109}
110
111impl Iterator for FilesIterator<'_> {
112 type Item = path::PathBuf;
113
114 fn next(&mut self) -> Option<path::PathBuf> {
115 self.inner.next()
116 }
117}
118
119#[derive(Debug, Clone)]
120pub struct Files {
121 root_dir: path::PathBuf,
122 subtree: Option<path::PathBuf>,
123 ignore: Gitignore,
124 extensions: Vec<ffi::OsString>,
125}
126
127impl Files {
128 pub fn root(&self) -> &path::Path {
129 &self.root_dir
130 }
131
132 pub fn subtree(&self) -> &path::Path {
133 self.subtree.as_deref().unwrap_or(self.root_dir.as_path())
134 }
135
136 pub fn includes_file(&self, file: &path::Path) -> bool {
137 if !self.ext_contains(file) {
138 return false;
139 }
140 let is_dir = false;
141 if let Some(ref subtree) = self.subtree {
142 if !file.starts_with(subtree) {
143 return false;
144 }
145 }
146 self.includes_path(file, is_dir)
147 }
148
149 #[cfg(test)]
150 pub fn includes_dir(&self, dir: &path::Path) -> bool {
151 let is_dir = true;
152 if let Some(ref subtree) = self.subtree {
153 if !dir.starts_with(subtree) {
154 return false;
155 }
156 }
157 self.includes_path(dir, is_dir)
158 }
159
160 pub fn files(&self) -> FilesIterator<'_> {
161 FilesIterator::new(self)
162 }
163
164 fn ext_contains(&self, file: &path::Path) -> bool {
165 if self.extensions.is_empty() {
166 return true;
167 }
168
169 file.extension()
170 .map(|ext| self.extensions.iter().any(|e| e == ext))
171 .unwrap_or(false)
172 }
173
174 fn includes_entry(&self, entry: &DirEntry) -> bool {
175 let file = entry.path();
176 let is_dir = entry.file_type().is_dir();
177 if !is_dir && !self.ext_contains(file) {
178 return false;
179 }
180
181 if let Some(ref subtree) = self.subtree {
182 if !file.starts_with(subtree) {
183 return false;
184 }
185 }
186
187 self.includes_path_leaf(file, is_dir)
189 }
190
191 fn includes_path(&self, path: &path::Path, is_dir: bool) -> bool {
192 if path == self.root_dir {
193 return true;
194 }
195
196 let parent = path.parent();
197 if let Some(mut parent) = parent {
198 if parent.starts_with(&self.root_dir) {
199 if parent == path::Path::new(".") {
202 parent = path::Path::new("./");
203 }
204 if !self.includes_path(parent, parent.is_dir()) {
205 return false;
206 }
207 }
208 }
209
210 self.includes_path_leaf(path, is_dir)
211 }
212
213 fn includes_path_leaf(&self, path: &path::Path, is_dir: bool) -> bool {
214 match self.ignore.matched(path, is_dir) {
215 Match::None => true,
216 Match::Ignore(glob) => {
217 trace!("{}: ignored {:?}", path.display(), glob.original());
218 false
219 }
220 Match::Whitelist(glob) => {
221 trace!("{}: allowed {:?}", path.display(), glob.original());
222 true
223 }
224 }
225 }
226}
227
228impl<'a> IntoIterator for &'a Files {
229 type Item = path::PathBuf;
230 type IntoIter = FilesIterator<'a>;
231
232 fn into_iter(self) -> FilesIterator<'a> {
233 self.files()
234 }
235}
236
237pub fn find_project_file<P: Into<path::PathBuf>>(dir: P, name: &str) -> Option<path::PathBuf> {
238 find_project_file_internal(dir.into(), name)
239}
240
241fn find_project_file_internal(dir: path::PathBuf, name: &str) -> Option<path::PathBuf> {
242 let mut file_path = dir;
243 file_path.push(name);
244 while !file_path.exists() {
245 file_path.pop(); let hit_bottom = !file_path.pop();
247 if hit_bottom {
248 return None;
249 }
250 file_path.push(name);
251 }
252 Some(file_path)
253}
254
255pub fn cleanup_path(path: &str) -> String {
256 let stripped = path.trim_start_matches("./");
257 if stripped == "." {
258 String::new()
259 } else {
260 stripped.to_owned()
261 }
262}
263
264pub fn read_file<P: AsRef<path::Path>>(path: P) -> Result<String> {
265 let text = fs::read_to_string(path.as_ref())?;
266 let text: String = normalized(text.chars()).collect();
267 Ok(text)
268}
269
270pub fn copy_file(src_file: &path::Path, dest_file: &path::Path) -> Result<()> {
271 if let Some(parent) = dest_file.parent() {
273 fs::create_dir_all(parent)
274 .with_context(|| anyhow::format_err!("Could not create {}", parent.display()))?;
275 }
276
277 debug!(
278 "Copying `{}` to `{}`",
279 src_file.display(),
280 dest_file.display()
281 );
282 fs::copy(src_file, dest_file).with_context(|| {
283 anyhow::format_err!(
284 "Could not copy {} into {}",
285 src_file.display(),
286 dest_file.display()
287 )
288 })?;
289 Ok(())
290}
291
292pub fn write_document_file<S: AsRef<str>, P: AsRef<path::Path>>(
293 content: S,
294 dest_file: P,
295) -> Result<()> {
296 write_document_file_internal(content.as_ref(), dest_file.as_ref())
297}
298
299fn write_document_file_internal(content: &str, dest_file: &path::Path) -> Result<()> {
300 if let Some(parent) = dest_file.parent() {
302 fs::create_dir_all(parent)
303 .with_context(|| anyhow::format_err!("Could not create {}", parent.display()))?;
304 }
305
306 let mut file = fs::File::create(dest_file)
307 .with_context(|| anyhow::format_err!("Could not create {}", dest_file.display()))?;
308
309 file.write_all(content.as_bytes())?;
310 trace!("Wrote {}", dest_file.display());
311 Ok(())
312}
313
314#[cfg(test)]
315mod tests {
316 #![allow(clippy::bool_assert_comparison)]
317
318 use super::*;
319
320 macro_rules! assert_includes_dir {
321 ($root:expr_2021, $ignores:expr_2021, $test:expr_2021, $included:expr_2021) => {
322 let mut files = FilesBuilder::new(path::Path::new($root)).unwrap();
323 let ignores: &[&str] = $ignores;
324 for ignore in ignores {
325 files.add_ignore(ignore).unwrap();
326 }
327 let files = files.build().unwrap();
328 assert_eq!(files.includes_dir(path::Path::new($test)), $included);
329 };
330 }
331 macro_rules! assert_includes_file {
332 ($root:expr_2021, $ignores:expr_2021, $test:expr_2021, $included:expr_2021) => {
333 let mut files = FilesBuilder::new(path::Path::new($root)).unwrap();
334 let ignores: &[&str] = $ignores;
335 for ignore in ignores {
336 files.add_ignore(ignore).unwrap();
337 }
338 let files = files.build().unwrap();
339 assert_eq!(files.includes_file(path::Path::new($test)), $included);
340 };
341 }
342
343 #[test]
344 fn files_includes_root_dir() {
345 assert_includes_dir!("/usr/cobalt/site", &[], "/usr/cobalt/site", true);
346
347 assert_includes_dir!("./", &[], "./", true);
348 }
349
350 #[test]
351 fn files_includes_child_dir() {
352 assert_includes_dir!("/usr/cobalt/site", &[], "/usr/cobalt/site/child", true);
353
354 assert_includes_dir!("./", &[], "./child", true);
355 }
356
357 #[test]
358 fn files_excludes_hidden_dir() {
359 assert_includes_dir!("/usr/cobalt/site", &[], "/usr/cobalt/site/_child", false);
360 assert_includes_dir!(
361 "/usr/cobalt/site",
362 &[],
363 "/usr/cobalt/site/child/_child",
364 false
365 );
366 assert_includes_dir!(
367 "/usr/cobalt/site",
368 &[],
369 "/usr/cobalt/site/_child/child",
370 false
371 );
372
373 assert_includes_dir!("./", &[], "./_child", false);
374 assert_includes_dir!("./", &[], "./child/_child", false);
375 assert_includes_dir!("./", &[], "./_child/child", false);
376 }
377
378 #[test]
379 fn files_excludes_dot_dir() {
380 assert_includes_dir!("/usr/cobalt/site", &[], "/usr/cobalt/site/.child", false);
381 assert_includes_dir!(
382 "/usr/cobalt/site",
383 &[],
384 "/usr/cobalt/site/child/.child",
385 false
386 );
387 assert_includes_dir!(
388 "/usr/cobalt/site",
389 &[],
390 "/usr/cobalt/site/.child/child",
391 false
392 );
393
394 assert_includes_dir!("./", &[], "./.child", false);
395 assert_includes_dir!("./", &[], "./child/.child", false);
396 assert_includes_dir!("./", &[], "./.child/child", false);
397 }
398
399 #[test]
400 fn files_includes_file() {
401 assert_includes_file!("/usr/cobalt/site", &[], "/usr/cobalt/site/child.txt", true);
402
403 assert_includes_file!("./", &[], "./child.txt", true);
404 }
405
406 #[test]
407 fn files_includes_child_dir_file() {
408 assert_includes_file!(
409 "/usr/cobalt/site",
410 &[],
411 "/usr/cobalt/site/child/child.txt",
412 true
413 );
414
415 assert_includes_file!("./", &[], "./child/child.txt", true);
416 }
417
418 #[test]
419 fn files_excludes_hidden_file() {
420 assert_includes_file!(
421 "/usr/cobalt/site",
422 &[],
423 "/usr/cobalt/site/_child.txt",
424 false
425 );
426 assert_includes_file!(
427 "/usr/cobalt/site",
428 &[],
429 "/usr/cobalt/site/child/_child.txt",
430 false
431 );
432
433 assert_includes_file!("./", &[], "./_child.txt", false);
434 assert_includes_file!("./", &[], "./child/_child.txt", false);
435 }
436
437 #[test]
438 fn files_excludes_hidden_dir_file() {
439 assert_includes_file!(
440 "/usr/cobalt/site",
441 &[],
442 "/usr/cobalt/site/_child/child.txt",
443 false
444 );
445 assert_includes_file!(
446 "/usr/cobalt/site",
447 &[],
448 "/usr/cobalt/site/child/_child/child.txt",
449 false
450 );
451
452 assert_includes_file!("./", &[], "./_child/child.txt", false);
453 assert_includes_file!("./", &[], "./child/_child/child.txt", false);
454 }
455
456 #[test]
457 fn files_excludes_dot_file() {
458 assert_includes_file!(
459 "/usr/cobalt/site",
460 &[],
461 "/usr/cobalt/site/.child.txt",
462 false
463 );
464 assert_includes_file!(
465 "/usr/cobalt/site",
466 &[],
467 "/usr/cobalt/site/child/.child.txt",
468 false
469 );
470
471 assert_includes_file!("./", &[], "./.child.txt", false);
472 assert_includes_file!("./", &[], "./child/.child.txt", false);
473 }
474
475 #[test]
476 fn files_excludes_dot_dir_file() {
477 assert_includes_file!(
478 "/usr/cobalt/site",
479 &[],
480 "/usr/cobalt/site/.child/child.txt",
481 false
482 );
483 assert_includes_file!(
484 "/usr/cobalt/site",
485 &[],
486 "/usr/cobalt/site/child/.child/child.txt",
487 false
488 );
489
490 assert_includes_file!("./", &[], "./.child/child.txt", false);
491 assert_includes_file!("./", &[], "./child/.child/child.txt", false);
492 }
493
494 #[test]
495 fn files_excludes_ignored_file() {
496 let ignores = &["README", "**/*.scss"];
497
498 assert_includes_file!(
499 "/usr/cobalt/site",
500 ignores,
501 "/usr/cobalt/site/README",
502 false
503 );
504 assert_includes_file!(
505 "/usr/cobalt/site",
506 ignores,
507 "/usr/cobalt/site/child/README",
508 false
509 );
510 assert_includes_file!(
511 "/usr/cobalt/site",
512 ignores,
513 "/usr/cobalt/site/blog.scss",
514 false
515 );
516 assert_includes_file!(
517 "/usr/cobalt/site",
518 ignores,
519 "/usr/cobalt/site/child/blog.scss",
520 false
521 );
522
523 assert_includes_file!("./", ignores, "./README", false);
524 assert_includes_file!("./", ignores, "./child/README", false);
525 assert_includes_file!("./", ignores, "./blog.scss", false);
526 assert_includes_file!("./", ignores, "./child/blog.scss", false);
527 }
528
529 #[test]
530 fn files_includes_overridden_file() {
531 let ignores = &["!.htaccess"];
532
533 assert_includes_file!(
534 "/usr/cobalt/site",
535 ignores,
536 "/usr/cobalt/site/.htaccess",
537 true
538 );
539 assert_includes_file!(
540 "/usr/cobalt/site",
541 ignores,
542 "/usr/cobalt/site/child/.htaccess",
543 true
544 );
545
546 assert_includes_file!("./", ignores, "./.htaccess", true);
547 assert_includes_file!("./", ignores, "./child/.htaccess", true);
548 }
549
550 #[test]
551 fn files_includes_overridden_dir() {
552 let ignores = &[
553 "!/_posts",
554 "!/_posts/**",
555 "/_posts/**/_*",
556 "/_posts/**/_*/**",
557 ];
558
559 assert_includes_dir!("/usr/cobalt/site", ignores, "/usr/cobalt/site/_posts", true);
560 assert_includes_dir!(
561 "/usr/cobalt/site",
562 ignores,
563 "/usr/cobalt/site/_posts/child",
564 true
565 );
566
567 assert_includes_dir!(
568 "/usr/cobalt/site",
569 ignores,
570 "/usr/cobalt/site/child/_posts",
571 false
572 );
573 assert_includes_dir!(
574 "/usr/cobalt/site",
575 ignores,
576 "/usr/cobalt/site/child/_posts/child",
577 false
578 );
579
580 assert_includes_dir!(
581 "/usr/cobalt/site",
582 ignores,
583 "/usr/cobalt/site/_posts/child/_child",
584 false
585 );
586 assert_includes_dir!(
587 "/usr/cobalt/site",
588 ignores,
589 "/usr/cobalt/site/_posts/child/_child/child",
590 false
591 );
592
593 assert_includes_dir!("./", ignores, "./_posts", true);
594 assert_includes_dir!("./", ignores, "./_posts/child", true);
595
596 assert_includes_dir!("./", ignores, "./child/_posts", false);
597 assert_includes_dir!("./", ignores, "./child/_posts/child", false);
598
599 assert_includes_dir!("./", ignores, "./_posts/child/_child", false);
600 assert_includes_dir!("./", ignores, "./_posts/child/_child/child", false);
601 }
602
603 #[test]
604 fn files_includes_overridden_dir_file() {
605 let ignores = &[
606 "!/_posts",
607 "!/_posts/**",
608 "/_posts/**/_*",
609 "/_posts/**/_*/**",
610 ];
611
612 assert_includes_file!(
613 "/usr/cobalt/site",
614 ignores,
615 "/usr/cobalt/site/_posts/child.txt",
616 true
617 );
618 assert_includes_file!(
619 "/usr/cobalt/site",
620 ignores,
621 "/usr/cobalt/site/_posts/child/child.txt",
622 true
623 );
624
625 assert_includes_file!(
626 "/usr/cobalt/site",
627 ignores,
628 "/usr/cobalt/site/child/_posts/child.txt",
629 false
630 );
631 assert_includes_file!(
632 "/usr/cobalt/site",
633 ignores,
634 "/usr/cobalt/site/child/_posts/child/child.txt",
635 false
636 );
637
638 assert_includes_file!(
639 "/usr/cobalt/site",
640 ignores,
641 "/usr/cobalt/site/_posts/child/_child.txt",
642 false
643 );
644 assert_includes_file!(
645 "/usr/cobalt/site",
646 ignores,
647 "/usr/cobalt/site/_posts/child/_child/child.txt",
648 false
649 );
650
651 assert_includes_file!("./", ignores, "./_posts/child.txt", true);
652 assert_includes_file!("./", ignores, "./_posts/child/child.txt", true);
653
654 assert_includes_file!("./", ignores, "./child/_posts/child.txt", false);
655 assert_includes_file!("./", ignores, "./child/_posts/child/child.txt", false);
656
657 assert_includes_file!("./", ignores, "./_posts/child/_child.txt", false);
658 assert_includes_file!("./", ignores, "./_posts/child/_child/child.txt", false);
659 }
660
661 #[test]
662 fn files_includes_limit() {
663 let root = "/usr/cobalt/site";
664 let limit = "limit";
665 let files = FilesBuilder::new(path::Path::new(root))
666 .unwrap()
667 .limit(limit.into())
668 .unwrap()
669 .build()
670 .unwrap();
671 assert!(files.includes_file(path::Path::new("/usr/cobalt/site/limit")));
672 assert!(files.includes_dir(path::Path::new("/usr/cobalt/site/limit")));
673
674 assert!(files.includes_file(path::Path::new("/usr/cobalt/site/limit/child")));
675 assert!(files.includes_dir(path::Path::new("/usr/cobalt/site/limit/child")));
676 }
677
678 #[test]
679 fn files_includes_limit_outside() {
680 let root = "/usr/cobalt/site";
681 let limit = "limit";
682 let files = FilesBuilder::new(path::Path::new(root))
683 .unwrap()
684 .limit(limit.into())
685 .unwrap()
686 .build()
687 .unwrap();
688
689 assert!(!files.includes_dir(path::Path::new("/usr/cobalt/site/limit_foo")));
690 assert!(!files.includes_file(path::Path::new("/usr/cobalt/site/limit_foo")));
691
692 assert!(!files.includes_dir(path::Path::new("/usr/cobalt/site/bird")));
693 assert!(!files.includes_file(path::Path::new("/usr/cobalt/site/bird")));
694
695 assert!(!files.includes_dir(path::Path::new("/usr/cobalt/site/bird/limit")));
696 assert!(!files.includes_file(path::Path::new("/usr/cobalt/site/bird/limit")));
697 }
698
699 #[test]
700 fn files_iter_matches_include() {
701 let root_dir = path::Path::new("tests/fixtures/hidden_files");
702 let files = FilesBuilder::new(root_dir).unwrap().build().unwrap();
703 let mut actual: Vec<_> = files
704 .files()
705 .map(|f| f.strip_prefix(root_dir).unwrap().to_owned())
706 .collect();
707 actual.sort();
708
709 let expected = vec![
710 path::Path::new("child/child.txt").to_path_buf(),
711 path::Path::new("child.txt").to_path_buf(),
712 ];
713
714 assert_eq!(expected, actual);
715 }
716
717 #[test]
718 fn find_project_file_same_dir() {
719 let actual = find_project_file("tests/fixtures/config", "_cobalt.yml").unwrap();
720 let expected = path::Path::new("tests/fixtures/config/_cobalt.yml");
721 assert_eq!(actual, expected);
722 }
723
724 #[test]
725 fn find_project_file_parent_dir() {
726 let actual = find_project_file("tests/fixtures/config/child", "_cobalt.yml").unwrap();
727 let expected = path::Path::new("tests/fixtures/config/_cobalt.yml");
728 assert_eq!(actual, expected);
729 }
730
731 #[test]
732 fn find_project_file_doesnt_exist() {
733 let expected = path::Path::new("<NOT FOUND>");
734 let actual =
735 find_project_file("tests/fixtures/", "_cobalt.yml").unwrap_or_else(|| expected.into());
736 assert_eq!(actual, expected);
737 }
738
739 #[test]
740 fn cleanup_path_empty() {
741 assert_eq!(cleanup_path(""), "");
742 }
743
744 #[test]
745 fn cleanup_path_dot() {
746 assert_eq!(cleanup_path("."), "");
747 }
748
749 #[test]
750 fn cleanup_path_current_dir() {
751 assert_eq!(cleanup_path("./"), "");
752 }
753
754 #[test]
755 fn cleanup_path_current_dir_extreme() {
756 assert_eq!(cleanup_path("././././."), "");
757 }
758
759 #[test]
760 fn cleanup_path_current_dir_child() {
761 assert_eq!(cleanup_path("./build/file.txt"), "build/file.txt");
762 }
763}