spin_templates/
run.rs

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
23/// A set of partials to be included in a Liquid template.
24type PartialsBuilder = liquid::partials::EagerCompiler<liquid::partials::InMemorySource>;
25
26/// Executes a template to the point where it is ready to generate
27/// artefacts.
28pub struct Run {
29    pub(crate) template: Template,
30    pub(crate) options: RunOptions,
31}
32
33/// Options controlling the execution of a template.
34pub struct RunOptions {
35    /// The variant mode in which to run the template.
36    pub variant: TemplateVariantInfo,
37    /// The name of the generated item.
38    pub name: String,
39    /// The path at which to generate artefacts.
40    pub output_path: PathBuf,
41    /// The values to use for template parameters.
42    pub values: HashMap<String, String>,
43    /// If true accept default values where available
44    pub accept_defaults: bool,
45    /// If true, do not create a .gitignore file
46    pub no_vcs: bool,
47    /// Skip the overwrite prompt if the output directory already contains files
48    /// (or, if silent, allow overwrite instead of erroring).
49    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    /// Runs the template interactively. The user will be prompted for any
58    /// information or input the template needs, such as parameter values.
59    /// Execution will block while waiting on user responses.
60    pub async fn interactive(&self) -> anyhow::Result<()> {
61        self.run(Interactive).await
62    }
63
64    /// Runs the template silently. The template will be executed without
65    /// user interaction, and will not wait on the user. If the template needs
66    /// any information or input that was not provided in the `RunOptions`,
67    /// execution will fail and result in an error.
68    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    // The 'raw' in this refers to the output type, which is an ugly representation
91    // of cancellation: Ok(Some(...)) means a result, Ok(None) means cancelled, Err
92    // means error. Why have this ugly representation? Because it makes it terser to
93    // write using the Rust `?` operator to early-return. It would be lovely to find
94    // a better way but I don't see one yet...
95    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        // TODO: rationalise `path` and `dir`
103        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 user asked for no_vcs
172        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            // TODO: better to provide this as a structured object and let the caller choose how to present it
230            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(()), // Fail forgiving - don't block the user if things are under construction
259                }
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(()), // Fail forgiving - don't block the user if things are under construction
273                }
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    // TODO: async when we know where things sit
403    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        // Strip optional .tmpl extension
413        // Templates can use this if they don't want to store files with their final extensions
414        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}