cargo_hatch/
templates.rs

1//! Main templating handling, which involves finding files, filtering them and finally rendering
2//! them to a target directory.
3//!
4//! Rendering means, that the file is processed through the [`Tera`] templating engine, in case it
5//! is considered a template file.
6
7use std::{
8    fs::{self, File},
9    io::{BufWriter, Write},
10};
11
12use anyhow::{Context, Result};
13use camino::{Utf8Path, Utf8PathBuf};
14use globset::{GlobBuilder, GlobSetBuilder};
15use ignore::WalkBuilder;
16use mime_guess::mime;
17use tera::{Context as TeraContext, Tera};
18
19use crate::settings::IgnorePattern;
20
21/// A single file from a template repository, that shall be rendered into a target directory. If it
22/// is considered a template, it's processed through the [`Tera`] engine.
23pub struct RepoFile {
24    /// Full path to the file for reading.
25    path: Utf8PathBuf,
26    /// Relative path in regards to the directory it came from.
27    name: Utf8PathBuf,
28    /// Whether the file is considered a template. If not, it's copied over instead.
29    template: bool,
30}
31
32impl RepoFile {
33    /// Path of this file, relative to the directory it was loaded from.
34    #[must_use]
35    pub fn name(&self) -> &Utf8Path {
36        &self.name
37    }
38}
39
40pub fn collect_files(dir: &Utf8Path) -> Result<Vec<RepoFile>> {
41    // Builtin filters for files or dirs that are always ignored
42    static FILTERS: &[&str] = &[".git", ".hatch.toml", ".hatchignore"];
43
44    let mut files = Vec::new();
45    let walk = WalkBuilder::new(dir)
46        .standard_filters(false)
47        .git_ignore(true)
48        .ignore(true)
49        .add_custom_ignore_filename(".hatchignore")
50        .filter_entry(|entry| {
51            entry
52                .file_name()
53                .to_str()
54                .map_or(false, |name| !FILTERS.contains(&name))
55        })
56        .build();
57
58    for entry in walk {
59        let entry = entry?;
60
61        if entry.file_type().map_or(false, |ty| ty.is_file()) {
62            let path = entry.path();
63            let path = Utf8Path::from_path(path)
64                .with_context(|| format!("{path:?} is not a valid UTF8 path"))?;
65            let name = path
66                .strip_prefix(dir)
67                .with_context(|| format!("failed to get relative path for {path}"))?;
68
69            files.push(RepoFile {
70                path: path.to_owned(),
71                name: name.to_owned(),
72                template: !is_binary(name),
73            });
74        }
75    }
76
77    Ok(files)
78}
79
80/// Determine, whether the given path is considered a binary file, that should not be treated as
81/// template in further processing.
82fn is_binary(path: &Utf8Path) -> bool {
83    let mime = mime_guess::from_path(path).first_or_text_plain();
84
85    match mime.type_() {
86        mime::AUDIO | mime::FONT | mime::IMAGE | mime::VIDEO => true,
87        mime::APPLICATION => matches!(mime.subtype(), mime::OCTET_STREAM | mime::PDF),
88        _ => false,
89    }
90}
91
92/// Filter out the collected files from [`collect_files`] with the given ignore rules.
93pub fn filter_ignored(
94    files: Vec<RepoFile>,
95    context: &TeraContext,
96    ignore: Vec<IgnorePattern>,
97) -> Result<Vec<RepoFile>> {
98    let mut set = GlobSetBuilder::new();
99
100    for rule in ignore {
101        if let Some(condition) = &rule.condition {
102            let result = Tera::one_off(condition, context, false)
103                .context("failed to execute condition template")?;
104            let active = result.trim().parse::<bool>().with_context(|| {
105                format!("condition did not evaluate to a boolean, but `{result}`")
106            })?;
107
108            if !active {
109                continue;
110            }
111        }
112
113        for path in rule.paths {
114            set.add(
115                GlobBuilder::new(path.as_str())
116                    .literal_separator(true)
117                    .build()
118                    .with_context(|| format!("invalid glob pattern `{path}`"))?,
119            );
120        }
121    }
122
123    let filter = set.build().context("failed to build the glob set")?;
124
125    Ok(files
126        .into_iter()
127        .filter(|file| !filter.is_match(&file.name))
128        .collect())
129}
130
131/// Render all the given files to the target path.
132///
133/// - If the a file is a template, it is processed through the [`Tera`] engine.
134/// - Otherwise, it's copied as-is, without any further processing.
135pub fn render(files: &[RepoFile], context: &TeraContext, target: &Utf8Path) -> Result<()> {
136    let tera = {
137        let mut tera = Tera::default();
138        tera.add_template_files(
139            files
140                .iter()
141                .filter_map(|f| f.template.then_some((&f.path, Some(&f.name)))),
142        )
143        .context("failed loading templates")?;
144        tera
145    };
146
147    fs::create_dir_all(target)?;
148
149    for file in files {
150        if let Some(parent) = file.name.parent() {
151            fs::create_dir_all(target.join(parent))
152                .with_context(|| format!("failed to directories for `{parent}`"))?;
153        }
154
155        if file.template {
156            let mut out = BufWriter::new(File::create(target.join(&file.name))?);
157            tera.render_to(file.name.as_str(), context, &mut out)
158                .with_context(|| format!("failed to render template for `{}`", file.name))?;
159            out.flush().context("failed to flush output file")?;
160        } else {
161            fs::copy(&file.path, target.join(&file.name))
162                .with_context(|| format!("faile to copy file `{}`", file.name))?;
163        }
164    }
165
166    Ok(())
167}