1use 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
21pub struct RepoFile {
24 path: Utf8PathBuf,
26 name: Utf8PathBuf,
28 template: bool,
30}
31
32impl RepoFile {
33 #[must_use]
35 pub fn name(&self) -> &Utf8Path {
36 &self.name
37 }
38}
39
40pub fn collect_files(dir: &Utf8Path) -> Result<Vec<RepoFile>> {
41 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
80fn 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
92pub 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
131pub 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}