cobalt/cobalt_model/
files.rs

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        // Assumption: The parent paths will have been checked before we even get to this point.
188        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                // HACK: Gitignore seems to act differently on Windows/Linux, so putting this in to
200                // get them to act the same
201                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(); // filename
246        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    // create target directories if any exist
272    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    // create target directories if any exist
301    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}