1use std::{
2 collections::HashMap,
3 path::{Path, PathBuf},
4};
5
6use anyhow::{Context, anyhow};
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" => match &self.options.variant {
297 TemplateVariantInfo::AddComponent { manifest_path } => {
298 Ok(RenderOperation::AppendToml(manifest_path.clone(), content))
299 }
300 TemplateVariantInfo::NewApplication => Err(anyhow::anyhow!(
301 "Spin doesn't know what to do with a 'component' snippet outside an 'add component' operation"
302 )),
303 },
304 "application_trigger" => match &self.options.variant {
305 TemplateVariantInfo::AddComponent { manifest_path } => {
306 Ok(RenderOperation::AppendToml(manifest_path.clone(), content))
307 }
308 TemplateVariantInfo::NewApplication => Err(anyhow::anyhow!(
309 "Spin doesn't know what to do with an 'application_trigger' snippet outside an 'add component' operation"
310 )),
311 },
312 "variables" => match &self.options.variant {
313 TemplateVariantInfo::AddComponent { manifest_path } => {
314 Ok(RenderOperation::MergeToml(
315 manifest_path.clone(),
316 MergeTarget::Application("variables"),
317 content,
318 ))
319 }
320 TemplateVariantInfo::NewApplication => Err(anyhow::anyhow!(
321 "Spin doesn't know what to do with a 'variables' snippet outside an 'add component' operation"
322 )),
323 },
324 _ => Err(anyhow::anyhow!(
325 "Spin doesn't know what to do with snippet {id}",
326 )),
327 }
328 }
329
330 fn extra_operation(&self, extra: &ExtraOutputAction) -> anyhow::Result<RenderOperation> {
331 match extra {
332 ExtraOutputAction::CreateDirectory(_, template, at) => {
333 let component_path = self.options.output_path.clone();
334 let base_path = match at {
335 crate::reader::CreateLocation::Component => component_path,
336 crate::reader::CreateLocation::Manifest => match &self.options.variant {
337 TemplateVariantInfo::NewApplication => component_path,
338 TemplateVariantInfo::AddComponent { manifest_path } => manifest_path
339 .parent()
340 .map(|p| p.to_owned())
341 .unwrap_or(component_path),
342 },
343 };
344 Ok(RenderOperation::CreateDirectory(
345 base_path,
346 template.clone(),
347 ))
348 }
349 }
350 }
351
352 async fn maybe_initialise_git(&self) -> anyhow::Result<()> {
353 if !matches!(self.options.variant, TemplateVariantInfo::NewApplication) {
354 return Ok(());
355 }
356
357 if self.options.no_vcs {
358 return Ok(());
359 }
360
361 let target_dir = self.generation_target_dir();
362
363 let skip_initing_repo = git::is_in_git_repo(&target_dir).await.unwrap_or(true);
364
365 if skip_initing_repo {
366 return Ok(());
367 }
368
369 if let Err(e) = git::init_git_repo(&target_dir).await
370 && !matches!(e, git::GitError::ProgramNotFound)
371 {
372 terminal::warn!(
373 "Spin was unable to initialise a Git repository. Run `git init` manually if you want one."
374 );
375 }
376
377 Ok(())
378 }
379
380 fn list_content_files(from: &Path) -> anyhow::Result<Vec<PathBuf>> {
381 let walker = WalkDir::new(from);
382 let files = walker
383 .into_iter()
384 .filter_map(|entry| match entry {
385 Err(e) => Some(Err(e)),
386 Ok(de) => {
387 if de.file_type().is_file() {
388 Some(Ok(de.path().to_owned()))
389 } else {
390 None
391 }
392 }
393 })
394 .collect::<Result<Vec<_>, _>>()?;
395 Ok(files)
396 }
397
398 fn read_all(
400 &self,
401 paths: Vec<PathBuf>,
402 template_parser: &liquid::Parser,
403 ) -> anyhow::Result<Vec<(PathBuf, TemplateContent)>> {
404 let contents = paths
405 .iter()
406 .map(|path| TemplateContent::infer_from_bytes(std::fs::read(path)?, template_parser))
407 .collect::<Result<Vec<_>, _>>()?;
408 let paths = paths.into_iter().map(|p| {
411 if p.extension().is_some_and(|e| e == "tmpl") {
412 p.with_extension("")
413 } else {
414 p
415 }
416 });
417 let pairs = paths.zip(contents).collect();
418 Ok(pairs)
419 }
420
421 fn to_output_paths<T>(
422 src_dir: &Path,
423 dest_dir: &Path,
424 contents: Vec<(PathBuf, T)>,
425 ) -> Vec<(PathBuf, T)> {
426 contents
427 .into_iter()
428 .filter_map(|f| Self::to_output_path(src_dir, dest_dir, f))
429 .collect()
430 }
431
432 fn to_output_path<T>(
433 src_dir: &Path,
434 dest_dir: &Path,
435 (source, cont): (PathBuf, T),
436 ) -> Option<(PathBuf, T)> {
437 pathdiff::diff_paths(source, src_dir).map(|rel| (dest_dir.join(rel), cont))
438 }
439
440 fn template_parser(
441 partials: impl liquid::partials::PartialCompiler,
442 ) -> anyhow::Result<liquid::Parser> {
443 let builder = liquid::ParserBuilder::with_stdlib()
444 .partials(partials)
445 .filter(crate::filters::KebabCaseFilterParser)
446 .filter(crate::filters::PascalCaseFilterParser)
447 .filter(crate::filters::DottedPascalCaseFilterParser)
448 .filter(crate::filters::SnakeCaseFilterParser)
449 .filter(crate::filters::HttpWildcardFilterParser);
450 builder
451 .build()
452 .context("Template error: unable to build parser")
453 }
454
455 fn partials(&self) -> anyhow::Result<impl liquid::partials::PartialCompiler> {
456 let mut partials = PartialsBuilder::empty();
457
458 if let Some(partials_dir) = self.template.partials_dir() {
459 let partials_dir = std::fs::read_dir(partials_dir)
460 .context("Error opening template partials directory")?;
461 for partial_file in partials_dir {
462 let partial_file =
463 partial_file.context("Error scanning template partials directory")?;
464 if !partial_file.file_type().is_ok_and(|t| t.is_file()) {
465 anyhow::bail!("Non-file in partials directory: {partial_file:?}");
466 }
467 let partial_name = partial_file
468 .file_name()
469 .into_string()
470 .map_err(|f| anyhow!("Unusable partial name {f:?}"))?;
471 let partial_file = partial_file.path();
472 let content = std::fs::read_to_string(&partial_file)
473 .with_context(|| format!("Invalid partial template {partial_file:?}"))?;
474 partials.add(partial_name, content);
475 }
476 }
477
478 Ok(partials)
479 }
480}
481
482#[cfg(test)]
483mod test {
484 use super::*;
485
486 #[test]
487 fn test_filters() {
488 let data = liquid::object!({
489 "snaky": "originally_snaky",
490 "kebabby": "originally-kebabby",
491 "dotted": "originally.semi-dotted"
492 });
493 let no_partials = super::PartialsBuilder::empty();
494 let parser = Run::template_parser(no_partials).unwrap();
495
496 let eval = |s: &str| parser.parse(s).unwrap().render(&data).unwrap();
497
498 let kebab = eval("{{ snaky | kebab_case }}");
499 assert_eq!("originally-snaky", kebab);
500
501 let snek = eval("{{ kebabby | snake_case }}");
502 assert_eq!("originally_kebabby", snek);
503
504 let pascal = eval("{{ snaky | pascal_case }}");
505 assert_eq!("OriginallySnaky", pascal);
506
507 let dotpas = eval("{{ dotted | dotted_pascal_case }}");
508 assert_eq!("Originally.SemiDotted", dotpas);
509 }
510}