1use std::{
2 collections::HashMap,
3 path::{Path, PathBuf},
4};
5
6use anyhow::{anyhow, Context};
7use itertools::Itertools;
8use path_absolutize::Absolutize;
9use walkdir::WalkDir;
10
11use crate::{
12 cancellable::Cancellable,
13 git,
14 interaction::{InteractionStrategy, Interactive, Silent},
15 renderer::MergeTarget,
16 template::{ExtraOutputAction, TemplateVariantInfo},
17};
18use crate::{
19 renderer::{RenderOperation, TemplateContent, TemplateRenderer},
20 template::Template,
21};
22
23type PartialsBuilder = liquid::partials::EagerCompiler<liquid::partials::InMemorySource>;
25
26pub struct Run {
29 pub(crate) template: Template,
30 pub(crate) options: RunOptions,
31}
32
33pub struct RunOptions {
35 pub variant: TemplateVariantInfo,
37 pub name: String,
39 pub output_path: PathBuf,
41 pub values: HashMap<String, String>,
43 pub accept_defaults: bool,
45 pub no_vcs: bool,
47 pub allow_overwrite: bool,
50}
51
52impl Run {
53 pub(crate) fn new(template: Template, options: RunOptions) -> Self {
54 Self { template, options }
55 }
56
57 pub async fn interactive(&self) -> anyhow::Result<()> {
61 self.run(Interactive).await
62 }
63
64 pub async fn silent(&self) -> anyhow::Result<()> {
69 self.run(Silent).await
70 }
71
72 async fn run(&self, interaction: impl InteractionStrategy) -> anyhow::Result<()> {
73 self.build_renderer(interaction)
74 .await
75 .and_then(|t| t.render())
76 .and_then_async(|o| async move { o.write().await })
77 .await
78 .and_then_async(|_| self.maybe_initialise_git())
79 .await
80 .err()
81 }
82
83 async fn build_renderer(
84 &self,
85 interaction: impl InteractionStrategy,
86 ) -> Cancellable<TemplateRenderer, anyhow::Error> {
87 self.build_renderer_raw(interaction).await.into()
88 }
89
90 async fn build_renderer_raw(
96 &self,
97 interaction: impl InteractionStrategy,
98 ) -> anyhow::Result<Option<TemplateRenderer>> {
99 self.validate_version()?;
100 self.validate_trigger()?;
101
102 let to = self.generation_target_dir();
104
105 if !self.options.allow_overwrite {
106 match interaction.allow_generate_into(&to) {
107 Cancellable::Cancelled => return Ok(None),
108 Cancellable::Ok(_) => (),
109 Cancellable::Err(e) => return Err(e),
110 };
111 }
112
113 let partials = self.partials()?;
114 let parser = Self::template_parser(partials)?;
115
116 self.validate_provided_values()?;
117
118 let files = match self.template.content_dir() {
119 None => vec![],
120 Some(path) => {
121 let from = path
122 .absolutize()
123 .context("Failed to get absolute path of template directory")?;
124 self.included_files(&from, &to, &parser)?
125 }
126 };
127
128 let snippets = self
129 .template
130 .snippets(&self.options.variant)
131 .iter()
132 .map(|(id, path)| self.snippet_operation(id, path, &parser))
133 .collect::<anyhow::Result<Vec<_>>>()?;
134
135 let extras = self
136 .template
137 .extra_outputs()
138 .iter()
139 .map(|extra| self.extra_operation(extra))
140 .collect::<anyhow::Result<Vec<_>>>()?;
141
142 let render_operations = files.into_iter().chain(snippets).chain(extras).collect();
143
144 match interaction.populate_parameters(self) {
145 Cancellable::Ok(parameter_values) => {
146 let values = self
147 .special_values()
148 .await
149 .into_iter()
150 .chain(parameter_values)
151 .collect();
152 let prepared_template = TemplateRenderer {
153 render_operations,
154 parameter_values: values,
155 };
156 Ok(Some(prepared_template))
157 }
158 Cancellable::Cancelled => Ok(None),
159 Cancellable::Err(e) => Err(e),
160 }
161 }
162
163 fn included_files(
164 &self,
165 from: &Path,
166 to: &Path,
167 parser: &liquid::Parser,
168 ) -> anyhow::Result<Vec<RenderOperation>> {
169 let gitignore = ".gitignore";
170 let mut all_content_files = Self::list_content_files(from)?;
171 if self.options.no_vcs {
173 all_content_files.retain(|file| match file.file_name() {
174 None => true,
175 Some(file_name) => file_name.to_os_string() != gitignore,
176 });
177 }
178 let included_files =
179 self.template
180 .included_files(from, all_content_files, &self.options.variant);
181 let template_contents = self.read_all(included_files, parser)?;
182 let outputs = Self::to_output_paths(from, to, template_contents);
183 let file_ops = outputs
184 .into_iter()
185 .map(|(path, content)| RenderOperation::WriteFile(path, content))
186 .collect();
187 Ok(file_ops)
188 }
189
190 async fn special_values(&self) -> HashMap<String, String> {
191 let mut values = HashMap::new();
192
193 let authors = crate::environment::get_authors().await.unwrap_or_default();
194 values.insert("authors".into(), authors.author);
195 values.insert("username".into(), authors.username);
196 values.insert("project-name".into(), self.options.name.clone());
197 values.insert(
198 "output-path".into(),
199 self.relative_target_dir().to_string_lossy().to_string(),
200 );
201
202 values
203 }
204
205 fn relative_target_dir(&self) -> &Path {
206 &self.options.output_path
207 }
208
209 fn generation_target_dir(&self) -> PathBuf {
210 match &self.options.variant {
211 TemplateVariantInfo::NewApplication => self.options.output_path.clone(),
212 TemplateVariantInfo::AddComponent { manifest_path } => manifest_path
213 .parent()
214 .unwrap()
215 .join(&self.options.output_path),
216 }
217 }
218
219 fn validate_provided_values(&self) -> anyhow::Result<()> {
220 let errors = self
221 .options
222 .values
223 .iter()
224 .filter_map(|(n, v)| self.validate_value(n, v))
225 .collect_vec();
226 if errors.is_empty() {
227 Ok(())
228 } else {
229 let errors_msg = errors.iter().map(|s| format!("- {s}")).join("\n");
231 Err(anyhow!(
232 "The following provided value(s) are invalid according to the template:\n{}",
233 errors_msg
234 ))
235 }
236 }
237
238 fn validate_value(&self, name: &str, value: &str) -> Option<String> {
239 match self.template.parameter(name) {
240 None => Some(format!(
241 "Template does not contain a parameter named '{name}'"
242 )),
243 Some(p) => match p.validate_value(value) {
244 Ok(_) => None,
245 Err(e) => Some(format!("{name}: {e}")),
246 },
247 }
248 }
249
250 fn validate_trigger(&self) -> anyhow::Result<()> {
251 match &self.options.variant {
252 TemplateVariantInfo::NewApplication => Ok(()),
253 TemplateVariantInfo::AddComponent { manifest_path } => {
254 match crate::app_info::AppInfo::from_file(manifest_path) {
255 Some(Ok(app_info)) if app_info.manifest_format() == 1 => self
256 .template
257 .check_compatible_trigger(app_info.trigger_type()),
258 _ => Ok(()), }
260 }
261 }
262 }
263
264 fn validate_version(&self) -> anyhow::Result<()> {
265 match &self.options.variant {
266 TemplateVariantInfo::NewApplication => Ok(()),
267 TemplateVariantInfo::AddComponent { manifest_path } => {
268 match crate::app_info::AppInfo::from_file(manifest_path) {
269 Some(Ok(app_info)) => self
270 .template
271 .check_compatible_manifest_format(app_info.manifest_format()),
272 _ => Ok(()), }
274 }
275 }
276 }
277
278 fn snippet_operation(
279 &self,
280 id: &str,
281 snippet_file: &str,
282 parser: &liquid::Parser,
283 ) -> anyhow::Result<RenderOperation> {
284 let snippets_dir = self
285 .template
286 .snippets_dir()
287 .as_ref()
288 .ok_or_else(|| anyhow::anyhow!("Template snippets directory not found"))?;
289 let abs_snippet_file = snippets_dir.join(snippet_file);
290 let file_content = std::fs::read(abs_snippet_file)
291 .with_context(|| format!("Error reading snippet file {snippet_file}"))?;
292 let content = TemplateContent::infer_from_bytes(file_content, parser)
293 .with_context(|| format!("Error parsing snippet file {snippet_file}"))?;
294
295 match id {
296 "component" => {
297 match &self.options.variant {
298 TemplateVariantInfo::AddComponent { manifest_path } =>
299 Ok(RenderOperation::AppendToml(
300 manifest_path.clone(),
301 content,
302 )),
303 TemplateVariantInfo::NewApplication =>
304 Err(anyhow::anyhow!("Spin doesn't know what to do with a 'component' snippet outside an 'add component' operation")),
305 }
306 },
307 "application_trigger" => {
308 match &self.options.variant {
309 TemplateVariantInfo::AddComponent { manifest_path } =>
310 Ok(RenderOperation::AppendToml(
311 manifest_path.clone(),
312 content,
313 )),
314 TemplateVariantInfo::NewApplication =>
315 Err(anyhow::anyhow!("Spin doesn't know what to do with an 'application_trigger' snippet outside an 'add component' operation")),
316 }
317 },
318 "variables" => {
319 match &self.options.variant {
320 TemplateVariantInfo::AddComponent { manifest_path } =>
321 Ok(RenderOperation::MergeToml(
322 manifest_path.clone(),
323 MergeTarget::Application("variables"),
324 content,
325 )),
326 TemplateVariantInfo::NewApplication =>
327 Err(anyhow::anyhow!("Spin doesn't know what to do with a 'variables' snippet outside an 'add component' operation")),
328 }
329 },
330 _ => Err(anyhow::anyhow!(
331 "Spin doesn't know what to do with snippet {id}",
332 )),
333 }
334 }
335
336 fn extra_operation(&self, extra: &ExtraOutputAction) -> anyhow::Result<RenderOperation> {
337 match extra {
338 ExtraOutputAction::CreateDirectory(_, template, at) => {
339 let component_path = self.options.output_path.clone();
340 let base_path = match at {
341 crate::reader::CreateLocation::Component => component_path,
342 crate::reader::CreateLocation::Manifest => match &self.options.variant {
343 TemplateVariantInfo::NewApplication => component_path,
344 TemplateVariantInfo::AddComponent { manifest_path } => manifest_path
345 .parent()
346 .map(|p| p.to_owned())
347 .unwrap_or(component_path),
348 },
349 };
350 Ok(RenderOperation::CreateDirectory(
351 base_path,
352 template.clone(),
353 ))
354 }
355 }
356 }
357
358 async fn maybe_initialise_git(&self) -> anyhow::Result<()> {
359 if !matches!(self.options.variant, TemplateVariantInfo::NewApplication) {
360 return Ok(());
361 }
362
363 if self.options.no_vcs {
364 return Ok(());
365 }
366
367 let target_dir = self.generation_target_dir();
368
369 let skip_initing_repo = git::is_in_git_repo(&target_dir).await.unwrap_or(true);
370
371 if skip_initing_repo {
372 return Ok(());
373 }
374
375 if let Err(e) = git::init_git_repo(&target_dir).await {
376 if !matches!(e, git::GitError::ProgramNotFound) {
377 terminal::warn!("Spin was unable to initialise a Git repository. Run `git init` manually if you want one.");
378 }
379 }
380
381 Ok(())
382 }
383
384 fn list_content_files(from: &Path) -> anyhow::Result<Vec<PathBuf>> {
385 let walker = WalkDir::new(from);
386 let files = walker
387 .into_iter()
388 .filter_map(|entry| match entry {
389 Err(e) => Some(Err(e)),
390 Ok(de) => {
391 if de.file_type().is_file() {
392 Some(Ok(de.path().to_owned()))
393 } else {
394 None
395 }
396 }
397 })
398 .collect::<Result<Vec<_>, _>>()?;
399 Ok(files)
400 }
401
402 fn read_all(
404 &self,
405 paths: Vec<PathBuf>,
406 template_parser: &liquid::Parser,
407 ) -> anyhow::Result<Vec<(PathBuf, TemplateContent)>> {
408 let contents = paths
409 .iter()
410 .map(|path| TemplateContent::infer_from_bytes(std::fs::read(path)?, template_parser))
411 .collect::<Result<Vec<_>, _>>()?;
412 let paths = paths.into_iter().map(|p| {
415 if p.extension().is_some_and(|e| e == "tmpl") {
416 p.with_extension("")
417 } else {
418 p
419 }
420 });
421 let pairs = paths.zip(contents).collect();
422 Ok(pairs)
423 }
424
425 fn to_output_paths<T>(
426 src_dir: &Path,
427 dest_dir: &Path,
428 contents: Vec<(PathBuf, T)>,
429 ) -> Vec<(PathBuf, T)> {
430 contents
431 .into_iter()
432 .filter_map(|f| Self::to_output_path(src_dir, dest_dir, f))
433 .collect()
434 }
435
436 fn to_output_path<T>(
437 src_dir: &Path,
438 dest_dir: &Path,
439 (source, cont): (PathBuf, T),
440 ) -> Option<(PathBuf, T)> {
441 pathdiff::diff_paths(source, src_dir).map(|rel| (dest_dir.join(rel), cont))
442 }
443
444 fn template_parser(
445 partials: impl liquid::partials::PartialCompiler,
446 ) -> anyhow::Result<liquid::Parser> {
447 let builder = liquid::ParserBuilder::with_stdlib()
448 .partials(partials)
449 .filter(crate::filters::KebabCaseFilterParser)
450 .filter(crate::filters::PascalCaseFilterParser)
451 .filter(crate::filters::DottedPascalCaseFilterParser)
452 .filter(crate::filters::SnakeCaseFilterParser)
453 .filter(crate::filters::HttpWildcardFilterParser);
454 builder
455 .build()
456 .context("Template error: unable to build parser")
457 }
458
459 fn partials(&self) -> anyhow::Result<impl liquid::partials::PartialCompiler> {
460 let mut partials = PartialsBuilder::empty();
461
462 if let Some(partials_dir) = self.template.partials_dir() {
463 let partials_dir = std::fs::read_dir(partials_dir)
464 .context("Error opening template partials directory")?;
465 for partial_file in partials_dir {
466 let partial_file =
467 partial_file.context("Error scanning template partials directory")?;
468 if !partial_file.file_type().is_ok_and(|t| t.is_file()) {
469 anyhow::bail!("Non-file in partials directory: {partial_file:?}");
470 }
471 let partial_name = partial_file
472 .file_name()
473 .into_string()
474 .map_err(|f| anyhow!("Unusable partial name {f:?}"))?;
475 let partial_file = partial_file.path();
476 let content = std::fs::read_to_string(&partial_file)
477 .with_context(|| format!("Invalid partial template {partial_file:?}"))?;
478 partials.add(partial_name, content);
479 }
480 }
481
482 Ok(partials)
483 }
484}
485
486#[cfg(test)]
487mod test {
488 use super::*;
489
490 #[test]
491 fn test_filters() {
492 let data = liquid::object!({
493 "snaky": "originally_snaky",
494 "kebabby": "originally-kebabby",
495 "dotted": "originally.semi-dotted"
496 });
497 let no_partials = super::PartialsBuilder::empty();
498 let parser = Run::template_parser(no_partials).unwrap();
499
500 let eval = |s: &str| parser.parse(s).unwrap().render(&data).unwrap();
501
502 let kebab = eval("{{ snaky | kebab_case }}");
503 assert_eq!("originally-snaky", kebab);
504
505 let snek = eval("{{ kebabby | snake_case }}");
506 assert_eq!("originally_kebabby", snek);
507
508 let pascal = eval("{{ snaky | pascal_case }}");
509 assert_eq!("OriginallySnaky", pascal);
510
511 let dotpas = eval("{{ dotted | dotted_pascal_case }}");
512 assert_eq!("Originally.SemiDotted", dotpas);
513 }
514}