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