1use camino::{Utf8Path, Utf8PathBuf};
4use include_dir::{include_dir, Dir};
5use minijinja::Environment;
6use newline_converter::dos2unix;
7use serde::Serialize;
8
9use crate::{errors::DistResult, SortedMap};
10
11const TEMPLATE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
12pub type TemplateId = &'static str;
14pub const TEMPLATE_INSTALLER_PS1: TemplateId = "installer/installer.ps1";
16pub const TEMPLATE_INSTALLER_SH: TemplateId = "installer/installer.sh";
18pub const TEMPLATE_INSTALLER_RB: TemplateId = "installer/homebrew.rb";
20pub const TEMPLATE_INSTALLER_NPM: TemplateId = "installer/npm";
22pub const TEMPLATE_INSTALLER_NPM_RUN_JS: TemplateId = "installer/npm/run.js";
24pub const TEMPLATE_INSTALLER_NPM_PACKAGE_JSON: TemplateId = "installer/package.json";
26pub const TEMPLATE_INSTALLER_NPM_SHRINKWRAP: TemplateId = "installer/npm-shrinkwrap.json";
28pub const TEMPLATE_CI_GITHUB: TemplateId = "ci/github/release.yml";
30
31type EnvId = &'static str;
33const ENV_MISC: &str = "*";
35const ENV_YAML: &str = "yml";
37const ENV_NONE: &str = "none";
39
40#[derive(Debug)]
42pub struct Templates {
43 envs: SortedMap<EnvId, Environment<'static>>,
47 raw_files: SortedMap<String, String>,
49 entries: TemplateDir,
51}
52
53#[derive(Debug)]
55pub enum TemplateEntry {
56 Dir(TemplateDir),
58 File(TemplateFile),
60}
61
62#[derive(Debug)]
64pub struct TemplateDir {
65 _name: String,
67 pub path: Utf8PathBuf,
71 pub entries: SortedMap<String, TemplateEntry>,
73}
74
75#[derive(Debug)]
77pub struct TemplateFile {
78 pub name: String,
80 pub path: Utf8PathBuf,
84 env: EnvId,
86}
87
88impl TemplateFile {
89 pub fn path_from_ancestor(&self, ancestor: &TemplateDir) -> &Utf8Path {
91 self.path
92 .strip_prefix(&ancestor.path)
93 .expect("jinja2 template path wasn't properly nested under parent")
94 }
95}
96
97impl Templates {
98 pub fn new() -> DistResult<Self> {
100 let mut envs = SortedMap::new();
102 let mut raw_files = SortedMap::new();
103 {
104 let misc_env = Environment::new();
105 envs.insert(ENV_MISC, misc_env);
106 }
107 {
108 let mut yaml_env = Environment::new();
111 yaml_env.set_syntax(
112 minijinja::syntax::SyntaxConfig::builder()
113 .block_delimiters("{{%", "%}}")
114 .variable_delimiters("{{{", "}}}")
115 .comment_delimiters("{{#", "#}}")
116 .build()
117 .expect("failed to change jinja2 syntax for yaml files"),
118 );
119 yaml_env.set_formatter(|o, s, v| {
120 let Some(value) = v.as_str() else {
121 return minijinja::escape_formatter(o, s, v);
122 };
123 if !value.trim().contains('\n') {
127 return minijinja::escape_formatter(o, s, v);
128 };
129 o.write_str(value)?;
131 Ok(())
132 });
133
134 envs.insert(ENV_YAML, yaml_env);
135 }
136 for env in envs.values_mut() {
137 env.set_debug(true);
138 env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
139
140 fn jinja_error(details: String) -> std::result::Result<String, minijinja::Error> {
141 Err(minijinja::Error::new(
142 minijinja::ErrorKind::EvalBlock,
143 details,
144 ))
145 }
146
147 env.add_function("error", jinja_error);
148
149 fn is_empty(value: &minijinja::Value) -> bool {
150 let Some(len) = value.len() else {
151 return false;
152 };
153 len == 0
154 }
155 env.add_test("empty", is_empty);
156
157 fn is_multiline(value: &minijinja::Value) -> bool {
158 let Some(s) = value.as_str() else {
159 return false;
160 };
161 s.contains('\n')
162 }
163 env.add_test("multiline", is_multiline);
164 }
165
166 let mut entries = TemplateDir {
167 _name: String::new(),
168 path: Utf8PathBuf::new(),
169 entries: SortedMap::new(),
170 };
171 Self::load_files(&mut envs, &mut raw_files, &TEMPLATE_DIR, &mut entries)
176 .expect("failed to load jinja2 templates from binary");
177
178 let templates = Self {
179 envs,
180 raw_files,
181 entries,
182 };
183
184 Ok(templates)
185 }
186
187 fn get_template_entry(&self, key: TemplateId) -> DistResult<&TemplateEntry> {
189 let mut parent = &self.entries;
190 let mut result: Option<&TemplateEntry> = None;
191 for part in key.split('/') {
192 result = parent.entries.get(part);
193 if let Some(entry) = result {
194 if let TemplateEntry::Dir(dir) = entry {
195 parent = dir;
196 }
197 } else {
198 panic!("invalid jinja2 template key: {key}")
199 }
200 }
201
202 if let Some(entry) = result {
203 Ok(entry)
204 } else {
205 panic!("invalid jinja2 template key: {key}");
206 }
207 }
208
209 pub fn get_template_file(&self, key: TemplateId) -> DistResult<&TemplateFile> {
211 if let TemplateEntry::File(file) = self.get_template_entry(key)? {
212 Ok(file)
213 } else {
214 panic!("jinja2 template key was not a file: {key}");
215 }
216 }
217
218 pub fn get_template_dir(&self, key: TemplateId) -> DistResult<&TemplateDir> {
220 if let TemplateEntry::Dir(dir) = self.get_template_entry(key)? {
221 Ok(dir)
222 } else {
223 panic!("jinja2 template key was not a dir: {key}");
224 }
225 }
226
227 pub fn render_file_to_clean_string(
229 &self,
230 key: TemplateId,
231 val: &impl Serialize,
232 ) -> DistResult<String> {
233 let file = self.get_template_file(key)?;
234 self.render_file_to_clean_string_inner(file, val)
235 }
236
237 fn render_file_to_clean_string_inner(
239 &self,
240 file: &TemplateFile,
241 val: &impl Serialize,
242 ) -> DistResult<String> {
243 if file.env == ENV_NONE {
244 self.render_raw_file_to_clean_string(file)
245 } else {
246 self.render_templated_file_to_clean_string(file, val)
247 }
248 }
249
250 fn render_raw_file_to_clean_string(&self, file: &TemplateFile) -> DistResult<String> {
253 let rendered = &self.raw_files[file.path.as_str()];
254 let cleaned = dos2unix(rendered).into_owned();
255 Ok(cleaned)
256 }
257
258 fn render_templated_file_to_clean_string(
260 &self,
261 file: &TemplateFile,
262 val: &impl Serialize,
263 ) -> DistResult<String> {
264 let template = self.envs[file.env].get_template(file.path.as_str())?;
265 let mut rendered = template.render(val)?;
266 if !rendered.ends_with('\n') {
268 rendered.push('\n');
269 }
270 let cleaned = dos2unix(&rendered).into_owned();
271 Ok(cleaned)
272 }
273
274 pub fn render_dir_to_clean_strings(
281 &self,
282 key: TemplateId,
283 val: &impl Serialize,
284 ) -> DistResult<SortedMap<Utf8PathBuf, String>> {
285 let root_dir = self.get_template_dir(key)?;
286 let mut output = SortedMap::new();
287 self.render_dir_to_clean_strings_inner(&mut output, root_dir, root_dir, val)?;
288 Ok(output)
289 }
290
291 fn render_dir_to_clean_strings_inner(
292 &self,
293 output: &mut SortedMap<Utf8PathBuf, String>,
294 root_dir: &TemplateDir,
295 dir: &TemplateDir,
296 val: &impl Serialize,
297 ) -> DistResult<()> {
298 for entry in dir.entries.values() {
299 match entry {
300 TemplateEntry::Dir(subdir) => {
301 self.render_dir_to_clean_strings_inner(output, root_dir, subdir, val)?
302 }
303 TemplateEntry::File(file) => {
304 let rendered = self.render_file_to_clean_string_inner(file, val)?;
305 let relpath = file.path_from_ancestor(root_dir);
306 output.insert(relpath.to_owned(), rendered);
307 }
308 }
309 }
310 Ok(())
311 }
312
313 fn load_files(
315 envs: &mut SortedMap<EnvId, Environment<'static>>,
316 raw_files: &mut SortedMap<String, String>,
317 dir: &'static Dir,
318 parent: &mut TemplateDir,
319 ) -> DistResult<()> {
320 for entry in dir.entries() {
321 let path = Utf8Path::from_path(entry.path()).expect("non-utf8 jinja2 template path");
322 if let Some(file) = entry.as_file() {
323 let is_jinja = path.extension().unwrap_or_default() == "j2";
324 let path = if is_jinja {
326 path.with_extension("")
327 } else {
328 path.to_owned()
329 };
330
331 let name = path
332 .file_name()
333 .expect("template didn't have a name!?")
334 .to_owned();
335 let contents = file.contents_utf8().expect("non-utf8 template").to_string();
336 let env = if !is_jinja {
337 ENV_NONE
338 } else if path.extension().unwrap_or_default() == "yml" {
339 ENV_YAML
340 } else {
341 ENV_MISC
342 };
343
344 if is_jinja {
345 envs.get_mut(env)
346 .expect("invalid template env key")
347 .add_template_owned(path.to_string(), contents)
348 .expect("failed to add template");
349 } else {
350 raw_files.insert(path.to_string(), contents);
351 }
352 parent.entries.insert(
353 name.clone(),
354 TemplateEntry::File(TemplateFile { name, path, env }),
355 );
356 }
357 if let Some(dir) = entry.as_dir() {
358 let name = path
359 .file_name()
360 .expect("jinja2 template didn't have a name!?")
361 .to_owned();
362 let mut new_dir = TemplateDir {
363 _name: name.clone(),
364 path: path.to_owned(),
365 entries: SortedMap::new(),
366 };
367 Self::load_files(envs, raw_files, dir, &mut new_dir)
368 .expect("failed to load jinja2 templates from binary");
369 parent.entries.insert(name, TemplateEntry::Dir(new_dir));
370 }
371 }
372
373 Ok(())
374 }
375}
376
377#[cfg(test)]
378mod test {
379 use super::*;
380
381 #[test]
382 fn ensure_known_templates() {
383 let templates = Templates::new().unwrap();
384
385 templates.get_template_file(TEMPLATE_INSTALLER_SH).unwrap();
386 templates.get_template_file(TEMPLATE_INSTALLER_RB).unwrap();
387 templates.get_template_file(TEMPLATE_INSTALLER_PS1).unwrap();
388 templates.get_template_dir(TEMPLATE_INSTALLER_NPM).unwrap();
389 templates
390 .get_template_file(TEMPLATE_INSTALLER_NPM_RUN_JS)
391 .unwrap();
392
393 templates.get_template_file(TEMPLATE_CI_GITHUB).unwrap();
394 }
395}