1use std::{
2 collections::{HashMap, HashSet},
3 path::PathBuf,
4};
5
6use anyhow::{Context, anyhow};
7use indexmap::IndexMap;
8use itertools::Itertools;
9use regex::Regex;
10
11use crate::{
12 constraints::StringConstraints,
13 reader::{
14 RawCondition, RawConditional, RawExtraOutput, RawParameter, RawTemplateManifest,
15 RawTemplateManifestV1, RawTemplateVariant,
16 },
17 run::{Run, RunOptions},
18 store::TemplateLayout,
19};
20
21#[derive(Debug)]
23pub struct Template {
24 id: String,
25 tags: HashSet<String>,
26 description: Option<String>,
27 installed_from: InstalledFrom,
28 variants: HashMap<TemplateVariantKind, TemplateVariant>,
29 parameters: Vec<TemplateParameter>,
30 extra_outputs: Vec<ExtraOutputAction>,
31 snippets_dir: Option<PathBuf>,
32 partials_dir: Option<PathBuf>,
33 content_dir: Option<PathBuf>, }
35
36#[derive(Debug)]
37enum InstalledFrom {
38 Git(String),
39 Directory(String),
40 RemoteTar(String),
41 Unknown,
42}
43
44#[derive(Debug, Eq, PartialEq, Hash)]
45enum TemplateVariantKind {
46 NewApplication,
47 AddComponent,
48}
49
50#[derive(Clone, Debug)]
52pub enum TemplateVariantInfo {
53 NewApplication,
55 AddComponent {
57 manifest_path: PathBuf,
59 },
60}
61
62impl TemplateVariantInfo {
63 fn kind(&self) -> TemplateVariantKind {
64 match self {
65 Self::NewApplication => TemplateVariantKind::NewApplication,
66 Self::AddComponent { .. } => TemplateVariantKind::AddComponent,
67 }
68 }
69
70 pub fn description(&self) -> &'static str {
72 match self {
73 Self::NewApplication => "new application",
74 Self::AddComponent { .. } => "add component",
75 }
76 }
77
78 pub fn prompt_noun(&self) -> &'static str {
80 match self {
81 Self::NewApplication => "application",
82 Self::AddComponent { .. } => "component",
83 }
84 }
85
86 pub fn articled_noun(&self) -> &'static str {
89 match self {
90 Self::NewApplication => "an application",
91 Self::AddComponent { .. } => "a component",
92 }
93 }
94}
95
96#[derive(Clone, Debug, Default)]
97pub(crate) struct TemplateVariant {
98 skip_files: Vec<String>,
99 skip_parameters: Vec<String>,
100 snippets: HashMap<String, String>,
101 conditions: Vec<Conditional>,
102}
103
104#[derive(Clone, Debug)]
105pub(crate) struct Conditional {
106 condition: Condition,
107 skip_files: Vec<String>,
108 skip_parameters: Vec<String>,
109 skip_snippets: Vec<String>,
110}
111
112#[derive(Clone, Debug)]
113pub(crate) enum Condition {
114 ManifestEntryExists(Vec<String>),
115 #[cfg(test)]
116 Always(bool),
117}
118
119#[derive(Clone, Debug)]
120pub(crate) enum TemplateParameterDataType {
121 String(StringConstraints),
122}
123
124#[derive(Debug)]
125pub(crate) struct TemplateParameter {
126 id: String,
127 data_type: TemplateParameterDataType, prompt: String,
129 default_value: Option<String>,
130}
131
132pub(crate) enum ExtraOutputAction {
133 CreateDirectory(
134 String,
135 std::sync::Arc<liquid::Template>,
136 crate::reader::CreateLocation,
137 ),
138}
139
140impl std::fmt::Debug for ExtraOutputAction {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 match self {
143 Self::CreateDirectory(orig, ..) => {
144 f.debug_tuple("CreateDirectory").field(orig).finish()
145 }
146 }
147 }
148}
149
150impl Template {
151 pub(crate) fn load_from(layout: &TemplateLayout) -> anyhow::Result<Self> {
152 let manifest_path = layout.manifest_path();
153
154 let manifest_text = std::fs::read_to_string(&manifest_path).with_context(|| {
155 format!(
156 "Failed to read template manifest file {}",
157 manifest_path.display()
158 )
159 })?;
160 let raw = crate::reader::parse_manifest_toml(manifest_text).with_context(|| {
161 format!(
162 "Manifest file {} is not a valid manifest",
163 manifest_path.display()
164 )
165 })?;
166
167 validate_manifest(&raw)?;
168
169 let content_dir = if layout.content_dir().exists() {
170 Some(layout.content_dir())
171 } else {
172 None
173 };
174
175 let snippets_dir = if layout.snippets_dir().exists() {
176 Some(layout.snippets_dir())
177 } else {
178 None
179 };
180
181 let partials_dir = if layout.partials_dir().exists() {
182 Some(layout.partials_dir())
183 } else {
184 None
185 };
186
187 let installed_from = read_install_record(layout);
188
189 let template = match raw {
190 RawTemplateManifest::V1(raw) => Self {
191 id: raw.id.clone(),
192 tags: raw.tags.map(Self::normalize_tags).unwrap_or_default(),
193 description: raw.description.clone(),
194 installed_from,
195 variants: Self::parse_template_variants(raw.new_application, raw.add_component),
196 parameters: Self::parse_parameters(&raw.parameters)?,
197 extra_outputs: Self::parse_extra_outputs(&raw.outputs)?,
198 snippets_dir,
199 partials_dir,
200 content_dir,
201 },
202 };
203 Ok(template)
204 }
205
206 pub fn id(&self) -> &str {
209 &self.id
210 }
211
212 pub fn matches_all_tags(&self, match_set: &[String]) -> bool {
214 match_set
215 .iter()
216 .all(|tag| self.tags().contains(&tag.to_lowercase()))
217 }
218
219 pub fn tags(&self) -> &HashSet<String> {
222 &self.tags
223 }
224
225 pub fn description(&self) -> &Option<String> {
228 &self.description
229 }
230
231 pub fn description_or_empty(&self) -> &str {
235 match &self.description {
236 Some(s) => s,
237 None => "",
238 }
239 }
240
241 pub fn source_repo(&self) -> Option<&str> {
244 match &self.installed_from {
247 InstalledFrom::Git(url) => Some(url),
248 _ => None,
249 }
250 }
251
252 pub(crate) fn is_from_source_repo(&self, source_repo: &url::Url) -> bool {
253 self.source_repo()
254 .is_some_and(|r| r == source_repo.as_str())
255 }
256
257 pub fn installed_from_or_empty(&self) -> &str {
260 match &self.installed_from {
261 InstalledFrom::Git(repo) => repo,
262 InstalledFrom::Directory(path) => path,
263 InstalledFrom::RemoteTar(url) => url,
264 InstalledFrom::Unknown => "",
265 }
266 }
267
268 fn variant(&self, variant_info: &TemplateVariantInfo) -> Option<TemplateVariant> {
270 let kind = variant_info.kind();
271 self.variants
272 .get(&kind)
273 .map(|vt| vt.resolve_conditions(variant_info))
274 }
275
276 pub(crate) fn parameters(
277 &self,
278 variant_kind: &TemplateVariantInfo,
279 ) -> impl Iterator<Item = &TemplateParameter> {
280 let variant = self.variant(variant_kind).unwrap(); self.parameters
282 .iter()
283 .filter(move |p| !variant.skip_parameter(p))
284 }
285
286 pub(crate) fn parameter(&self, name: impl AsRef<str>) -> Option<&TemplateParameter> {
287 self.parameters.iter().find(|p| p.id == name.as_ref())
288 }
289
290 pub(crate) fn extra_outputs(&self) -> &[ExtraOutputAction] {
291 &self.extra_outputs
292 }
293
294 pub(crate) fn content_dir(&self) -> &Option<PathBuf> {
295 &self.content_dir
296 }
297
298 pub(crate) fn snippets_dir(&self) -> &Option<PathBuf> {
299 &self.snippets_dir
300 }
301
302 pub(crate) fn partials_dir(&self) -> &Option<PathBuf> {
303 &self.partials_dir
304 }
305
306 pub fn supports_variant(&self, variant: &TemplateVariantInfo) -> bool {
308 self.variants.contains_key(&variant.kind())
309 }
310
311 pub(crate) fn snippets(&self, variant_kind: &TemplateVariantInfo) -> HashMap<String, String> {
312 let variant = self.variant(variant_kind).unwrap(); variant.snippets
314 }
315
316 pub fn run(self, options: RunOptions) -> Run {
321 Run::new(self, options)
322 }
323
324 fn normalize_tags(tags: HashSet<String>) -> HashSet<String> {
325 tags.into_iter().map(|tag| tag.to_lowercase()).collect()
326 }
327
328 fn parse_template_variants(
329 new_application: Option<RawTemplateVariant>,
330 add_component: Option<RawTemplateVariant>,
331 ) -> HashMap<TemplateVariantKind, TemplateVariant> {
332 let mut variants = HashMap::default();
333 if let Some(vt) = Self::get_variant(new_application, true) {
334 variants.insert(TemplateVariantKind::NewApplication, vt);
335 }
336 if let Some(vt) = Self::get_variant(add_component, false) {
337 variants.insert(TemplateVariantKind::AddComponent, vt);
338 }
339 variants
340 }
341
342 fn get_variant(
343 raw: Option<RawTemplateVariant>,
344 default_supported: bool,
345 ) -> Option<TemplateVariant> {
346 match raw {
347 None => {
348 if default_supported {
349 Some(Default::default())
350 } else {
351 None
352 }
353 }
354 Some(rv) => {
355 if rv.supported.unwrap_or(true) {
356 Some(Self::parse_template_variant(rv))
357 } else {
358 None
359 }
360 }
361 }
362 }
363
364 fn parse_template_variant(raw: RawTemplateVariant) -> TemplateVariant {
365 TemplateVariant {
366 skip_files: raw.skip_files.unwrap_or_default(),
367 skip_parameters: raw.skip_parameters.unwrap_or_default(),
368 snippets: raw.snippets.unwrap_or_default(),
369 conditions: raw
370 .conditions
371 .unwrap_or_default()
372 .into_values()
373 .map(Self::parse_conditional)
374 .collect(),
375 }
376 }
377
378 fn parse_conditional(conditional: RawConditional) -> Conditional {
379 Conditional {
380 condition: Self::parse_condition(conditional.condition),
381 skip_files: conditional.skip_files.unwrap_or_default(),
382 skip_parameters: conditional.skip_parameters.unwrap_or_default(),
383 skip_snippets: conditional.skip_snippets.unwrap_or_default(),
384 }
385 }
386
387 fn parse_condition(condition: RawCondition) -> Condition {
388 match condition {
389 RawCondition::ManifestEntryExists(path) => {
390 Condition::ManifestEntryExists(path.split('.').map(|s| s.to_string()).collect_vec())
391 }
392 }
393 }
394
395 fn parse_parameters(
396 raw: &Option<IndexMap<String, RawParameter>>,
397 ) -> anyhow::Result<Vec<TemplateParameter>> {
398 match raw {
399 None => Ok(vec![]),
400 Some(parameters) => parameters
401 .iter()
402 .map(|(k, v)| TemplateParameter::from_raw(k, v))
403 .collect(),
404 }
405 }
406
407 fn parse_extra_outputs(
408 raw: &Option<IndexMap<String, RawExtraOutput>>,
409 ) -> anyhow::Result<Vec<ExtraOutputAction>> {
410 match raw {
411 None => Ok(vec![]),
412 Some(parameters) => parameters
413 .iter()
414 .map(|(k, v)| ExtraOutputAction::from_raw(k, v))
415 .collect(),
416 }
417 }
418
419 pub(crate) fn included_files(
420 &self,
421 base: &std::path::Path,
422 all_files: Vec<PathBuf>,
423 variant_kind: &TemplateVariantInfo,
424 ) -> Vec<PathBuf> {
425 let variant = self.variant(variant_kind).unwrap(); all_files
427 .into_iter()
428 .filter(|path| !variant.skip_file(base, path))
429 .collect()
430 }
431
432 pub(crate) fn check_compatible_manifest_format(
433 &self,
434 manifest_format: u32,
435 ) -> anyhow::Result<()> {
436 let Some(content_dir) = &self.content_dir else {
437 return Ok(());
438 };
439 let manifest_tpl = content_dir.join("spin.toml");
440 if !manifest_tpl.is_file() {
441 return Ok(());
442 }
443
444 let Ok(manifest_tpl_str) = std::fs::read_to_string(&manifest_tpl) else {
447 return Ok(());
448 };
449 let is_v1_tpl = manifest_tpl_str.contains("spin_manifest_version = \"1\"");
450 let is_v2_tpl = manifest_tpl_str.contains("spin_manifest_version = 2");
451
452 let positively_identified = is_v1_tpl ^ is_v2_tpl; if !positively_identified {
455 return Ok(());
456 }
457
458 let compatible = (is_v1_tpl && manifest_format == 1) || (is_v2_tpl && manifest_format == 2);
459
460 if compatible {
461 Ok(())
462 } else {
463 Err(anyhow!(
464 "This template is for a different version of the Spin manifest"
465 ))
466 }
467 }
468}
469
470impl TemplateParameter {
471 fn from_raw(id: &str, raw: &RawParameter) -> anyhow::Result<Self> {
472 let data_type = TemplateParameterDataType::parse(raw)?;
473
474 Ok(Self {
475 id: id.to_owned(),
476 data_type,
477 prompt: raw.prompt.clone(),
478 default_value: raw.default_value.clone(),
479 })
480 }
481
482 pub fn id(&self) -> &str {
483 &self.id
484 }
485
486 pub fn data_type(&self) -> &TemplateParameterDataType {
487 &self.data_type
488 }
489
490 pub fn prompt(&self) -> &str {
491 &self.prompt
492 }
493
494 pub fn default_value(&self) -> &Option<String> {
495 &self.default_value
496 }
497
498 pub fn validate_value(&self, value: impl AsRef<str>) -> anyhow::Result<String> {
499 self.data_type.validate_value(value.as_ref().to_owned())
500 }
501}
502
503impl TemplateParameterDataType {
504 fn parse(raw: &RawParameter) -> anyhow::Result<Self> {
505 match &raw.data_type[..] {
506 "string" => Ok(Self::String(parse_string_constraints(raw)?)),
507 _ => Err(anyhow!("Unrecognised data type '{}'", raw.data_type)),
508 }
509 }
510
511 fn validate_value(&self, value: String) -> anyhow::Result<String> {
512 match self {
513 TemplateParameterDataType::String(constraints) => constraints.validate(value),
514 }
515 }
516}
517
518impl ExtraOutputAction {
519 fn from_raw(id: &str, raw: &RawExtraOutput) -> anyhow::Result<Self> {
520 Ok(match raw {
521 RawExtraOutput::CreateDir(create) => {
522 let path_template =
523 liquid::Parser::new().parse(&create.path).with_context(|| {
524 format!("Template error: output {id} is not a valid template")
525 })?;
526 Self::CreateDirectory(
527 create.path.clone(),
528 std::sync::Arc::new(path_template),
529 create.at.unwrap_or_default(),
530 )
531 }
532 })
533 }
534}
535
536impl TemplateVariant {
537 pub(crate) fn skip_file(&self, base: &std::path::Path, path: &std::path::Path) -> bool {
538 self.skip_files
539 .iter()
540 .map(|s| base.join(s))
541 .any(|f| path == f)
542 }
543
544 pub(crate) fn skip_parameter(&self, parameter: &TemplateParameter) -> bool {
545 self.skip_parameters.iter().any(|p| ¶meter.id == p)
546 }
547
548 fn resolve_conditions(&self, variant_info: &TemplateVariantInfo) -> Self {
549 let mut resolved = self.clone();
550 for condition in &self.conditions {
551 if condition.condition.is_true(variant_info) {
552 resolved
553 .skip_files
554 .append(&mut condition.skip_files.clone());
555 resolved
556 .skip_parameters
557 .append(&mut condition.skip_parameters.clone());
558 resolved
559 .snippets
560 .retain(|id, _| !condition.skip_snippets.contains(id));
561 }
562 }
563 resolved
564 }
565}
566
567impl Condition {
568 fn is_true(&self, variant_info: &TemplateVariantInfo) -> bool {
569 match self {
570 Self::ManifestEntryExists(path) => match variant_info {
571 TemplateVariantInfo::NewApplication => false,
572 TemplateVariantInfo::AddComponent { manifest_path } => {
573 let Ok(toml_text) = std::fs::read_to_string(manifest_path) else {
574 return false;
575 };
576 let Ok(table) = toml::from_str::<toml::Value>(&toml_text) else {
577 return false;
578 };
579 crate::toml::get_at(table, path).is_some()
580 }
581 },
582 #[cfg(test)]
583 Self::Always(b) => *b,
584 }
585 }
586}
587
588fn parse_string_constraints(raw: &RawParameter) -> anyhow::Result<StringConstraints> {
589 let regex = raw.pattern.as_ref().map(|re| Regex::new(re)).transpose()?;
590
591 Ok(StringConstraints {
592 regex,
593 allowed_values: raw.allowed_values.clone(),
594 })
595}
596
597fn read_install_record(layout: &TemplateLayout) -> InstalledFrom {
598 use crate::reader::{RawInstalledFrom, parse_installed_from};
599
600 let installed_from_text = std::fs::read_to_string(layout.installation_record_file()).ok();
601 match installed_from_text.and_then(parse_installed_from) {
602 Some(RawInstalledFrom::Git { git }) => InstalledFrom::Git(git),
603 Some(RawInstalledFrom::File { dir }) => InstalledFrom::Directory(dir),
604 Some(RawInstalledFrom::RemoteTar { url }) => InstalledFrom::RemoteTar(url),
605 None => InstalledFrom::Unknown,
606 }
607}
608
609fn validate_manifest(raw: &RawTemplateManifest) -> anyhow::Result<()> {
610 match raw {
611 RawTemplateManifest::V1(raw) => validate_v1_manifest(raw),
612 }
613}
614
615fn validate_v1_manifest(raw: &RawTemplateManifestV1) -> anyhow::Result<()> {
616 if raw.custom_filters.is_some() {
617 anyhow::bail!(
618 "Custom filters are not supported in this version of Spin. Please update your template."
619 );
620 }
621 Ok(())
622}
623
624#[cfg(test)]
625mod test {
626 use super::*;
627
628 struct TempFile {
629 _temp_dir: tempfile::TempDir,
630 path: PathBuf,
631 }
632
633 impl TempFile {
634 fn path(&self) -> PathBuf {
635 self.path.clone()
636 }
637 }
638
639 fn make_temp_manifest(content: &str) -> TempFile {
640 let temp_dir = tempfile::tempdir().unwrap();
641 let temp_file = temp_dir.path().join("spin.toml");
642 std::fs::write(&temp_file, content).unwrap();
643 TempFile {
644 _temp_dir: temp_dir,
645 path: temp_file,
646 }
647 }
648
649 #[test]
650 fn manifest_entry_exists_condition_is_false_for_new_app() {
651 let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
652 "application.trigger.redis".to_owned(),
653 ));
654 assert!(!condition.is_true(&TemplateVariantInfo::NewApplication));
655 }
656
657 #[test]
658 fn manifest_entry_exists_condition_is_false_if_not_present_in_existing_manifest() {
659 let temp_file =
660 make_temp_manifest("name = \"hello\"\n[application.trigger.http]\nbase = \"/\"");
661 let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
662 "application.trigger.redis".to_owned(),
663 ));
664 assert!(!condition.is_true(&TemplateVariantInfo::AddComponent {
665 manifest_path: temp_file.path()
666 }));
667 }
668
669 #[test]
670 fn manifest_entry_exists_condition_is_true_if_present_in_existing_manifest() {
671 let temp_file = make_temp_manifest(
672 "name = \"hello\"\n[application.trigger.redis]\nchannel = \"HELLO\"",
673 );
674 let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
675 "application.trigger.redis".to_owned(),
676 ));
677 assert!(condition.is_true(&TemplateVariantInfo::AddComponent {
678 manifest_path: temp_file.path()
679 }));
680 }
681
682 #[test]
683 fn manifest_entry_exists_condition_is_false_if_path_does_not_exist() {
684 let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
685 "application.trigger.redis".to_owned(),
686 ));
687 assert!(!condition.is_true(&TemplateVariantInfo::AddComponent {
688 manifest_path: PathBuf::from("this/file/does/not.exist")
689 }));
690 }
691
692 #[test]
693 fn selected_variant_respects_target() {
694 let add_component_vt = TemplateVariant {
695 conditions: vec![Conditional {
696 condition: Condition::Always(true),
697 skip_files: vec!["test2".to_owned()],
698 skip_parameters: vec!["p1".to_owned()],
699 skip_snippets: vec!["s1".to_owned()],
700 }],
701 skip_files: vec!["test1".to_owned()],
702 snippets: [
703 ("s1".to_owned(), "s1val".to_owned()),
704 ("s2".to_owned(), "s2val".to_owned()),
705 ]
706 .into_iter()
707 .collect(),
708 ..Default::default()
709 };
710 let variants = [
711 (
712 TemplateVariantKind::NewApplication,
713 TemplateVariant::default(),
714 ),
715 (TemplateVariantKind::AddComponent, add_component_vt),
716 ]
717 .into_iter()
718 .collect();
719 let template = Template {
720 id: "test".to_owned(),
721 tags: HashSet::new(),
722 description: None,
723 installed_from: InstalledFrom::Unknown,
724 variants,
725 parameters: vec![],
726 extra_outputs: vec![],
727 snippets_dir: None,
728 partials_dir: None,
729 content_dir: None,
730 };
731
732 let variant_info = TemplateVariantInfo::NewApplication;
733 let variant = template.variant(&variant_info).unwrap();
734 assert!(variant.skip_files.is_empty());
735 assert!(variant.skip_parameters.is_empty());
736 assert!(variant.snippets.is_empty());
737
738 let add_variant_info = TemplateVariantInfo::AddComponent {
739 manifest_path: PathBuf::from("dummy"),
740 };
741 let add_variant = template.variant(&add_variant_info).unwrap();
742 assert_eq!(2, add_variant.skip_files.len());
744 assert!(add_variant.skip_files.contains(&"test1".to_owned()));
745 assert!(add_variant.skip_files.contains(&"test2".to_owned()));
746 assert_eq!(1, add_variant.skip_parameters.len());
747 assert!(add_variant.skip_parameters.contains(&"p1".to_owned()));
748 assert_eq!(1, add_variant.snippets.len());
750 assert!(!add_variant.snippets.contains_key("s1"));
751 assert!(add_variant.snippets.contains_key("s2"));
752 }
753}