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    interaction::{InteractionStrategy, Interactive, Silent},
14    renderer::MergeTarget,
15    template::{ExtraOutputAction, TemplateVariantInfo},
16};
17use crate::{
18    renderer::{RenderOperation, TemplateContent, TemplateRenderer},
19    template::Template,
20};
21
22/// A set of partials to be included in a Liquid template.
23type PartialsBuilder = liquid::partials::EagerCompiler<liquid::partials::InMemorySource>;
24
25/// Executes a template to the point where it is ready to generate
26/// artefacts.
27pub struct Run {
28    pub(crate) template: Template,
29    pub(crate) options: RunOptions,
30}
31
32/// Options controlling the execution of a template.
33pub struct RunOptions {
34    /// The variant mode in which to run the template.
35    pub variant: TemplateVariantInfo,
36    /// The name of the generated item.
37    pub name: String,
38    /// The path at which to generate artefacts.
39    pub output_path: PathBuf,
40    /// The values to use for template parameters.
41    pub values: HashMap<String, String>,
42    /// If true accept default values where available
43    pub accept_defaults: bool,
44    /// If true, do not create a .gitignore file
45    pub no_vcs: bool,
46    /// Skip the overwrite prompt if the output directory already contains files
47    /// (or, if silent, allow overwrite instead of erroring).
48    pub allow_overwrite: bool,
49}
50
51impl Run {
52    pub(crate) fn new(template: Template, options: RunOptions) -> Self {
53        Self { template, options }
54    }
55
56    /// Runs the template interactively. The user will be prompted for any
57    /// information or input the template needs, such as parameter values.
58    /// Execution will block while waiting on user responses.
59    pub async fn interactive(&self) -> anyhow::Result<()> {
60        self.run(Interactive).await
61    }
62
63    /// Runs the template silently. The template will be executed without
64    /// user interaction, and will not wait on the user. If the template needs
65    /// any information or input that was not provided in the `RunOptions`,
66    /// execution will fail and result in an error.
67    pub async fn silent(&self) -> anyhow::Result<()> {
68        self.run(Silent).await
69    }
70
71    async fn run(&self, interaction: impl InteractionStrategy) -> anyhow::Result<()> {
72        self.build_renderer(interaction)
73            .await
74            .and_then(|t| t.render())
75            .and_then_async(|o| async move { o.write().await })
76            .await
77            .err()
78    }
79
80    async fn build_renderer(
81        &self,
82        interaction: impl InteractionStrategy,
83    ) -> Cancellable<TemplateRenderer, anyhow::Error> {
84        self.build_renderer_raw(interaction).await.into()
85    }
86
87    // The 'raw' in this refers to the output type, which is an ugly representation
88    // of cancellation: Ok(Some(...)) means a result, Ok(None) means cancelled, Err
89    // means error. Why have this ugly representation? Because it makes it terser to
90    // write using the Rust `?` operator to early-return. It would be lovely to find
91    // a better way but I don't see one yet...
92    async fn build_renderer_raw(
93        &self,
94        interaction: impl InteractionStrategy,
95    ) -> anyhow::Result<Option<TemplateRenderer>> {
96        self.validate_version()?;
97        self.validate_trigger()?;
98
99        // TODO: rationalise `path` and `dir`
100        let to = self.generation_target_dir();
101
102        if !self.options.allow_overwrite {
103            match interaction.allow_generate_into(&to) {
104                Cancellable::Cancelled => return Ok(None),
105                Cancellable::Ok(_) => (),
106                Cancellable::Err(e) => return Err(e),
107            };
108        }
109
110        let partials = self.partials()?;
111        let parser = Self::template_parser(partials)?;
112
113        self.validate_provided_values()?;
114
115        let files = match self.template.content_dir() {
116            None => vec![],
117            Some(path) => {
118                let from = path
119                    .absolutize()
120                    .context("Failed to get absolute path of template directory")?;
121                self.included_files(&from, &to, &parser)?
122            }
123        };
124
125        let snippets = self
126            .template
127            .snippets(&self.options.variant)
128            .iter()
129            .map(|(id, path)| self.snippet_operation(id, path, &parser))
130            .collect::<anyhow::Result<Vec<_>>>()?;
131
132        let extras = self
133            .template
134            .extra_outputs()
135            .iter()
136            .map(|extra| self.extra_operation(extra))
137            .collect::<anyhow::Result<Vec<_>>>()?;
138
139        let render_operations = files.into_iter().chain(snippets).chain(extras).collect();
140
141        match interaction.populate_parameters(self) {
142            Cancellable::Ok(parameter_values) => {
143                let values = self
144                    .special_values()
145                    .await
146                    .into_iter()
147                    .chain(parameter_values)
148                    .collect();
149                let prepared_template = TemplateRenderer {
150                    render_operations,
151                    parameter_values: values,
152                };
153                Ok(Some(prepared_template))
154            }
155            Cancellable::Cancelled => Ok(None),
156            Cancellable::Err(e) => Err(e),
157        }
158    }
159
160    fn included_files(
161        &self,
162        from: &Path,
163        to: &Path,
164        parser: &liquid::Parser,
165    ) -> anyhow::Result<Vec<RenderOperation>> {
166        let gitignore = ".gitignore";
167        let mut all_content_files = Self::list_content_files(from)?;
168        // If user asked for no_vcs
169        if self.options.no_vcs {
170            all_content_files.retain(|file| match file.file_name() {
171                None => true,
172                Some(file_name) => file_name.to_os_string() != gitignore,
173            });
174        }
175        let included_files =
176            self.template
177                .included_files(from, all_content_files, &self.options.variant);
178        let template_contents = self.read_all(included_files, parser)?;
179        let outputs = Self::to_output_paths(from, to, template_contents);
180        let file_ops = outputs
181            .into_iter()
182            .map(|(path, content)| RenderOperation::WriteFile(path, content))
183            .collect();
184        Ok(file_ops)
185    }
186
187    async fn special_values(&self) -> HashMap<String, String> {
188        let mut values = HashMap::new();
189
190        let authors = crate::environment::get_authors().await.unwrap_or_default();
191        values.insert("authors".into(), authors.author);
192        values.insert("username".into(), authors.username);
193        values.insert("project-name".into(), self.options.name.clone());
194        values.insert(
195            "output-path".into(),
196            self.relative_target_dir().to_string_lossy().to_string(),
197        );
198
199        values
200    }
201
202    fn relative_target_dir(&self) -> &Path {
203        &self.options.output_path
204    }
205
206    fn generation_target_dir(&self) -> PathBuf {
207        match &self.options.variant {
208            TemplateVariantInfo::NewApplication => self.options.output_path.clone(),
209            TemplateVariantInfo::AddComponent { manifest_path } => manifest_path
210                .parent()
211                .unwrap()
212                .join(&self.options.output_path),
213        }
214    }
215
216    fn validate_provided_values(&self) -> anyhow::Result<()> {
217        let errors = self
218            .options
219            .values
220            .iter()
221            .filter_map(|(n, v)| self.validate_value(n, v))
222            .collect_vec();
223        if errors.is_empty() {
224            Ok(())
225        } else {
226            // TODO: better to provide this as a structured object and let the caller choose how to present it
227            let errors_msg = errors.iter().map(|s| format!("- {}", s)).join("\n");
228            Err(anyhow!(
229                "The following provided value(s) are invalid according to the template:\n{}",
230                errors_msg
231            ))
232        }
233    }
234
235    fn validate_value(&self, name: &str, value: &str) -> Option<String> {
236        match self.template.parameter(name) {
237            None => Some(format!(
238                "Template does not contain a parameter named '{}'",
239                name
240            )),
241            Some(p) => match p.validate_value(value) {
242                Ok(_) => None,
243                Err(e) => Some(format!("{}: {}", name, e)),
244            },
245        }
246    }
247
248    fn validate_trigger(&self) -> anyhow::Result<()> {
249        match &self.options.variant {
250            TemplateVariantInfo::NewApplication => Ok(()),
251            TemplateVariantInfo::AddComponent { manifest_path } => {
252                match crate::app_info::AppInfo::from_file(manifest_path) {
253                    Some(Ok(app_info)) if app_info.manifest_format() == 1 => self
254                        .template
255                        .check_compatible_trigger(app_info.trigger_type()),
256                    _ => Ok(()), // Fail forgiving - don't block the user if things are under construction
257                }
258            }
259        }
260    }
261
262    fn validate_version(&self) -> anyhow::Result<()> {
263        match &self.options.variant {
264            TemplateVariantInfo::NewApplication => Ok(()),
265            TemplateVariantInfo::AddComponent { manifest_path } => {
266                match crate::app_info::AppInfo::from_file(manifest_path) {
267                    Some(Ok(app_info)) => self
268                        .template
269                        .check_compatible_manifest_format(app_info.manifest_format()),
270                    _ => Ok(()), // Fail forgiving - don't block the user if things are under construction
271                }
272            }
273        }
274    }
275
276    fn snippet_operation(
277        &self,
278        id: &str,
279        snippet_file: &str,
280        parser: &liquid::Parser,
281    ) -> anyhow::Result<RenderOperation> {
282        let snippets_dir = self
283            .template
284            .snippets_dir()
285            .as_ref()
286            .ok_or_else(|| anyhow::anyhow!("Template snippets directory not found"))?;
287        let abs_snippet_file = snippets_dir.join(snippet_file);
288        let file_content = std::fs::read(abs_snippet_file)
289            .with_context(|| format!("Error reading snippet file {}", snippet_file))?;
290        let content = TemplateContent::infer_from_bytes(file_content, parser)
291            .with_context(|| format!("Error parsing snippet file {}", snippet_file))?;
292
293        match id {
294            "component" => {
295                match &self.options.variant {
296                    TemplateVariantInfo::AddComponent { manifest_path } =>
297                        Ok(RenderOperation::AppendToml(
298                            manifest_path.clone(),
299                            content,
300                        )),
301                    TemplateVariantInfo::NewApplication =>
302                        Err(anyhow::anyhow!("Spin doesn't know what to do with a 'component' snippet outside an 'add component' operation")),
303                }
304            },
305            "application_trigger" => {
306                match &self.options.variant {
307                    TemplateVariantInfo::AddComponent { manifest_path } =>
308                        Ok(RenderOperation::AppendToml(
309                            manifest_path.clone(),
310                            content,
311                        )),
312                    TemplateVariantInfo::NewApplication =>
313                        Err(anyhow::anyhow!("Spin doesn't know what to do with an 'application_trigger' snippet outside an 'add component' operation")),
314                    }
315            },
316            "variables" => {
317                match &self.options.variant {
318                    TemplateVariantInfo::AddComponent { manifest_path } =>
319                        Ok(RenderOperation::MergeToml(
320                            manifest_path.clone(),
321                            MergeTarget::Application("variables"),
322                            content,
323                        )),
324                    TemplateVariantInfo::NewApplication =>
325                        Err(anyhow::anyhow!("Spin doesn't know what to do with a 'variables' snippet outside an 'add component' operation")),
326                }
327            },
328            _ => Err(anyhow::anyhow!(
329                "Spin doesn't know what to do with snippet {id}",
330            )),
331        }
332    }
333
334    fn extra_operation(&self, extra: &ExtraOutputAction) -> anyhow::Result<RenderOperation> {
335        match extra {
336            ExtraOutputAction::CreateDirectory(_, template, at) => {
337                let component_path = self.options.output_path.clone();
338                let base_path = match at {
339                    crate::reader::CreateLocation::Component => component_path,
340                    crate::reader::CreateLocation::Manifest => match &self.options.variant {
341                        TemplateVariantInfo::NewApplication => component_path,
342                        TemplateVariantInfo::AddComponent { manifest_path } => manifest_path
343                            .parent()
344                            .map(|p| p.to_owned())
345                            .unwrap_or(component_path),
346                    },
347                };
348                Ok(RenderOperation::CreateDirectory(
349                    base_path,
350                    template.clone(),
351                ))
352            }
353        }
354    }
355
356    fn list_content_files(from: &Path) -> anyhow::Result<Vec<PathBuf>> {
357        let walker = WalkDir::new(from);
358        let files = walker
359            .into_iter()
360            .filter_map(|entry| match entry {
361                Err(e) => Some(Err(e)),
362                Ok(de) => {
363                    if de.file_type().is_file() {
364                        Some(Ok(de.path().to_owned()))
365                    } else {
366                        None
367                    }
368                }
369            })
370            .collect::<Result<Vec<_>, _>>()?;
371        Ok(files)
372    }
373
374    // TODO: async when we know where things sit
375    fn read_all(
376        &self,
377        paths: Vec<PathBuf>,
378        template_parser: &liquid::Parser,
379    ) -> anyhow::Result<Vec<(PathBuf, TemplateContent)>> {
380        let contents = paths
381            .iter()
382            .map(|path| TemplateContent::infer_from_bytes(std::fs::read(path)?, template_parser))
383            .collect::<Result<Vec<_>, _>>()?;
384        // Strip optional .tmpl extension
385        // Templates can use this if they don't want to store files with their final extensions
386        let paths = paths.into_iter().map(|p| {
387            if p.extension().is_some_and(|e| e == "tmpl") {
388                p.with_extension("")
389            } else {
390                p
391            }
392        });
393        let pairs = paths.zip(contents).collect();
394        Ok(pairs)
395    }
396
397    fn to_output_paths<T>(
398        src_dir: &Path,
399        dest_dir: &Path,
400        contents: Vec<(PathBuf, T)>,
401    ) -> Vec<(PathBuf, T)> {
402        contents
403            .into_iter()
404            .filter_map(|f| Self::to_output_path(src_dir, dest_dir, f))
405            .collect()
406    }
407
408    fn to_output_path<T>(
409        src_dir: &Path,
410        dest_dir: &Path,
411        (source, cont): (PathBuf, T),
412    ) -> Option<(PathBuf, T)> {
413        pathdiff::diff_paths(source, src_dir).map(|rel| (dest_dir.join(rel), cont))
414    }
415
416    fn template_parser(
417        partials: impl liquid::partials::PartialCompiler,
418    ) -> anyhow::Result<liquid::Parser> {
419        let builder = liquid::ParserBuilder::with_stdlib()
420            .partials(partials)
421            .filter(crate::filters::KebabCaseFilterParser)
422            .filter(crate::filters::PascalCaseFilterParser)
423            .filter(crate::filters::DottedPascalCaseFilterParser)
424            .filter(crate::filters::SnakeCaseFilterParser)
425            .filter(crate::filters::HttpWildcardFilterParser);
426        builder
427            .build()
428            .context("Template error: unable to build parser")
429    }
430
431    fn partials(&self) -> anyhow::Result<impl liquid::partials::PartialCompiler> {
432        let mut partials = PartialsBuilder::empty();
433
434        if let Some(partials_dir) = self.template.partials_dir() {
435            let partials_dir = std::fs::read_dir(partials_dir)
436                .context("Error opening template partials directory")?;
437            for partial_file in partials_dir {
438                let partial_file =
439                    partial_file.context("Error scanning template partials directory")?;
440                if !partial_file.file_type().is_ok_and(|t| t.is_file()) {
441                    anyhow::bail!("Non-file in partials directory: {partial_file:?}");
442                }
443                let partial_name = partial_file
444                    .file_name()
445                    .into_string()
446                    .map_err(|f| anyhow!("Unusable partial name {f:?}"))?;
447                let partial_file = partial_file.path();
448                let content = std::fs::read_to_string(&partial_file)
449                    .with_context(|| format!("Invalid partial template {partial_file:?}"))?;
450                partials.add(partial_name, content);
451            }
452        }
453
454        Ok(partials)
455    }
456}
457
458#[cfg(test)]
459mod test {
460    use super::*;
461
462    #[test]
463    fn test_filters() {
464        let data = liquid::object!({
465            "snaky": "originally_snaky",
466            "kebabby": "originally-kebabby",
467            "dotted": "originally.semi-dotted"
468        });
469        let no_partials = super::PartialsBuilder::empty();
470        let parser = Run::template_parser(no_partials).unwrap();
471
472        let eval = |s: &str| parser.parse(s).unwrap().render(&data).unwrap();
473
474        let kebab = eval("{{ snaky | kebab_case }}");
475        assert_eq!("originally-snaky", kebab);
476
477        let snek = eval("{{ kebabby | snake_case }}");
478        assert_eq!("originally_kebabby", snek);
479
480        let pascal = eval("{{ snaky | pascal_case }}");
481        assert_eq!("OriginallySnaky", pascal);
482
483        let dotpas = eval("{{ dotted | dotted_pascal_case }}");
484        assert_eq!("Originally.SemiDotted", dotpas);
485    }
486}