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
22type PartialsBuilder = liquid::partials::EagerCompiler<liquid::partials::InMemorySource>;
24
25pub struct Run {
28 pub(crate) template: Template,
29 pub(crate) options: RunOptions,
30}
31
32pub struct RunOptions {
34 pub variant: TemplateVariantInfo,
36 pub name: String,
38 pub output_path: PathBuf,
40 pub values: HashMap<String, String>,
42 pub accept_defaults: bool,
44 pub no_vcs: bool,
46 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 pub async fn interactive(&self) -> anyhow::Result<()> {
60 self.run(Interactive).await
61 }
62
63 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 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 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 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 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(()), }
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(()), }
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 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 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}