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 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(crate) fn is_from_source_repo(&self, source_repo: &url::Url) -> bool {
261 self.source_repo()
262 .is_some_and(|r| r == source_repo.as_str())
263 }
264
265 pub fn installed_from_or_empty(&self) -> &str {
268 match &self.installed_from {
269 InstalledFrom::Git(repo) => repo,
270 InstalledFrom::Directory(path) => path,
271 InstalledFrom::RemoteTar(url) => url,
272 InstalledFrom::Unknown => "",
273 }
274 }
275
276 fn variant(&self, variant_info: &TemplateVariantInfo) -> Option<TemplateVariant> {
278 let kind = variant_info.kind();
279 self.variants
280 .get(&kind)
281 .map(|vt| vt.resolve_conditions(variant_info))
282 }
283
284 pub(crate) fn parameters(
285 &self,
286 variant_kind: &TemplateVariantInfo,
287 ) -> impl Iterator<Item = &TemplateParameter> {
288 let variant = self.variant(variant_kind).unwrap(); self.parameters
290 .iter()
291 .filter(move |p| !variant.skip_parameter(p))
292 }
293
294 pub(crate) fn parameter(&self, name: impl AsRef<str>) -> Option<&TemplateParameter> {
295 self.parameters.iter().find(|p| p.id == name.as_ref())
296 }
297
298 pub(crate) fn extra_outputs(&self) -> &[ExtraOutputAction] {
299 &self.extra_outputs
300 }
301
302 pub(crate) fn content_dir(&self) -> &Option<PathBuf> {
303 &self.content_dir
304 }
305
306 pub(crate) fn snippets_dir(&self) -> &Option<PathBuf> {
307 &self.snippets_dir
308 }
309
310 pub(crate) fn partials_dir(&self) -> &Option<PathBuf> {
311 &self.partials_dir
312 }
313
314 pub fn supports_variant(&self, variant: &TemplateVariantInfo) -> bool {
316 self.variants.contains_key(&variant.kind())
317 }
318
319 pub(crate) fn snippets(&self, variant_kind: &TemplateVariantInfo) -> HashMap<String, String> {
320 let variant = self.variant(variant_kind).unwrap(); variant.snippets
322 }
323
324 pub fn run(self, options: RunOptions) -> Run {
329 Run::new(self, options)
330 }
331
332 fn normalize_tags(tags: HashSet<String>) -> HashSet<String> {
333 tags.into_iter().map(|tag| tag.to_lowercase()).collect()
334 }
335
336 fn parse_trigger_type(
337 raw: Option<String>,
338 layout: &TemplateLayout,
339 ) -> TemplateTriggerCompatibility {
340 match raw {
341 None => Self::infer_trigger_type(layout),
342 Some(t) => TemplateTriggerCompatibility::Only(t),
343 }
344 }
345
346 fn infer_trigger_type(layout: &TemplateLayout) -> TemplateTriggerCompatibility {
347 match crate::app_info::AppInfo::from_layout(layout) {
348 Some(Ok(app_info)) => match app_info.trigger_type() {
349 None => TemplateTriggerCompatibility::Any,
350 Some(t) => TemplateTriggerCompatibility::Only(t.to_owned()),
351 },
352 _ => TemplateTriggerCompatibility::Any, }
354 }
355
356 fn parse_template_variants(
357 new_application: Option<RawTemplateVariant>,
358 add_component: Option<RawTemplateVariant>,
359 ) -> HashMap<TemplateVariantKind, TemplateVariant> {
360 let mut variants = HashMap::default();
361 if let Some(vt) = Self::get_variant(new_application, true) {
362 variants.insert(TemplateVariantKind::NewApplication, vt);
363 }
364 if let Some(vt) = Self::get_variant(add_component, false) {
365 variants.insert(TemplateVariantKind::AddComponent, vt);
366 }
367 variants
368 }
369
370 fn get_variant(
371 raw: Option<RawTemplateVariant>,
372 default_supported: bool,
373 ) -> Option<TemplateVariant> {
374 match raw {
375 None => {
376 if default_supported {
377 Some(Default::default())
378 } else {
379 None
380 }
381 }
382 Some(rv) => {
383 if rv.supported.unwrap_or(true) {
384 Some(Self::parse_template_variant(rv))
385 } else {
386 None
387 }
388 }
389 }
390 }
391
392 fn parse_template_variant(raw: RawTemplateVariant) -> TemplateVariant {
393 TemplateVariant {
394 skip_files: raw.skip_files.unwrap_or_default(),
395 skip_parameters: raw.skip_parameters.unwrap_or_default(),
396 snippets: raw.snippets.unwrap_or_default(),
397 conditions: raw
398 .conditions
399 .unwrap_or_default()
400 .into_values()
401 .map(Self::parse_conditional)
402 .collect(),
403 }
404 }
405
406 fn parse_conditional(conditional: RawConditional) -> Conditional {
407 Conditional {
408 condition: Self::parse_condition(conditional.condition),
409 skip_files: conditional.skip_files.unwrap_or_default(),
410 skip_parameters: conditional.skip_parameters.unwrap_or_default(),
411 skip_snippets: conditional.skip_snippets.unwrap_or_default(),
412 }
413 }
414
415 fn parse_condition(condition: RawCondition) -> Condition {
416 match condition {
417 RawCondition::ManifestEntryExists(path) => {
418 Condition::ManifestEntryExists(path.split('.').map(|s| s.to_string()).collect_vec())
419 }
420 }
421 }
422
423 fn parse_parameters(
424 raw: &Option<IndexMap<String, RawParameter>>,
425 ) -> anyhow::Result<Vec<TemplateParameter>> {
426 match raw {
427 None => Ok(vec![]),
428 Some(parameters) => parameters
429 .iter()
430 .map(|(k, v)| TemplateParameter::from_raw(k, v))
431 .collect(),
432 }
433 }
434
435 fn parse_extra_outputs(
436 raw: &Option<IndexMap<String, RawExtraOutput>>,
437 ) -> anyhow::Result<Vec<ExtraOutputAction>> {
438 match raw {
439 None => Ok(vec![]),
440 Some(parameters) => parameters
441 .iter()
442 .map(|(k, v)| ExtraOutputAction::from_raw(k, v))
443 .collect(),
444 }
445 }
446
447 pub(crate) fn included_files(
448 &self,
449 base: &std::path::Path,
450 all_files: Vec<PathBuf>,
451 variant_kind: &TemplateVariantInfo,
452 ) -> Vec<PathBuf> {
453 let variant = self.variant(variant_kind).unwrap(); all_files
455 .into_iter()
456 .filter(|path| !variant.skip_file(base, path))
457 .collect()
458 }
459
460 pub(crate) fn check_compatible_trigger(&self, app_trigger: Option<&str>) -> anyhow::Result<()> {
461 let Some(app_trigger) = app_trigger else {
464 return Ok(());
465 };
466 match &self.trigger {
467 TemplateTriggerCompatibility::Any => Ok(()),
468 TemplateTriggerCompatibility::Only(t) => {
469 if app_trigger == t {
470 Ok(())
471 } else {
472 Err(anyhow!(
473 "Component trigger type '{t}' does not match application trigger type '{app_trigger}'"
474 ))
475 }
476 }
477 }
478 }
479
480 pub(crate) fn check_compatible_manifest_format(
481 &self,
482 manifest_format: u32,
483 ) -> anyhow::Result<()> {
484 let Some(content_dir) = &self.content_dir else {
485 return Ok(());
486 };
487 let manifest_tpl = content_dir.join("spin.toml");
488 if !manifest_tpl.is_file() {
489 return Ok(());
490 }
491
492 let Ok(manifest_tpl_str) = std::fs::read_to_string(&manifest_tpl) else {
495 return Ok(());
496 };
497 let is_v1_tpl = manifest_tpl_str.contains("spin_manifest_version = \"1\"");
498 let is_v2_tpl = manifest_tpl_str.contains("spin_manifest_version = 2");
499
500 let positively_identified = is_v1_tpl ^ is_v2_tpl; if !positively_identified {
503 return Ok(());
504 }
505
506 let compatible = (is_v1_tpl && manifest_format == 1) || (is_v2_tpl && manifest_format == 2);
507
508 if compatible {
509 Ok(())
510 } else {
511 Err(anyhow!(
512 "This template is for a different version of the Spin manifest"
513 ))
514 }
515 }
516}
517
518impl TemplateParameter {
519 fn from_raw(id: &str, raw: &RawParameter) -> anyhow::Result<Self> {
520 let data_type = TemplateParameterDataType::parse(raw)?;
521
522 Ok(Self {
523 id: id.to_owned(),
524 data_type,
525 prompt: raw.prompt.clone(),
526 default_value: raw.default_value.clone(),
527 })
528 }
529
530 pub fn id(&self) -> &str {
531 &self.id
532 }
533
534 pub fn data_type(&self) -> &TemplateParameterDataType {
535 &self.data_type
536 }
537
538 pub fn prompt(&self) -> &str {
539 &self.prompt
540 }
541
542 pub fn default_value(&self) -> &Option<String> {
543 &self.default_value
544 }
545
546 pub fn validate_value(&self, value: impl AsRef<str>) -> anyhow::Result<String> {
547 self.data_type.validate_value(value.as_ref().to_owned())
548 }
549}
550
551impl TemplateParameterDataType {
552 fn parse(raw: &RawParameter) -> anyhow::Result<Self> {
553 match &raw.data_type[..] {
554 "string" => Ok(Self::String(parse_string_constraints(raw)?)),
555 _ => Err(anyhow!("Unrecognised data type '{}'", raw.data_type)),
556 }
557 }
558
559 fn validate_value(&self, value: String) -> anyhow::Result<String> {
560 match self {
561 TemplateParameterDataType::String(constraints) => constraints.validate(value),
562 }
563 }
564}
565
566impl ExtraOutputAction {
567 fn from_raw(id: &str, raw: &RawExtraOutput) -> anyhow::Result<Self> {
568 Ok(match raw {
569 RawExtraOutput::CreateDir(create) => {
570 let path_template =
571 liquid::Parser::new().parse(&create.path).with_context(|| {
572 format!("Template error: output {id} is not a valid template")
573 })?;
574 Self::CreateDirectory(
575 create.path.clone(),
576 std::sync::Arc::new(path_template),
577 create.at.unwrap_or_default(),
578 )
579 }
580 })
581 }
582}
583
584impl TemplateVariant {
585 pub(crate) fn skip_file(&self, base: &std::path::Path, path: &std::path::Path) -> bool {
586 self.skip_files
587 .iter()
588 .map(|s| base.join(s))
589 .any(|f| path == f)
590 }
591
592 pub(crate) fn skip_parameter(&self, parameter: &TemplateParameter) -> bool {
593 self.skip_parameters.iter().any(|p| ¶meter.id == p)
594 }
595
596 fn resolve_conditions(&self, variant_info: &TemplateVariantInfo) -> Self {
597 let mut resolved = self.clone();
598 for condition in &self.conditions {
599 if condition.condition.is_true(variant_info) {
600 resolved
601 .skip_files
602 .append(&mut condition.skip_files.clone());
603 resolved
604 .skip_parameters
605 .append(&mut condition.skip_parameters.clone());
606 resolved
607 .snippets
608 .retain(|id, _| !condition.skip_snippets.contains(id));
609 }
610 }
611 resolved
612 }
613}
614
615impl Condition {
616 fn is_true(&self, variant_info: &TemplateVariantInfo) -> bool {
617 match self {
618 Self::ManifestEntryExists(path) => match variant_info {
619 TemplateVariantInfo::NewApplication => false,
620 TemplateVariantInfo::AddComponent { manifest_path } => {
621 let Ok(toml_text) = std::fs::read_to_string(manifest_path) else {
622 return false;
623 };
624 let Ok(table) = toml::from_str::<toml::Value>(&toml_text) else {
625 return false;
626 };
627 crate::toml::get_at(table, path).is_some()
628 }
629 },
630 #[cfg(test)]
631 Self::Always(b) => *b,
632 }
633 }
634}
635
636fn parse_string_constraints(raw: &RawParameter) -> anyhow::Result<StringConstraints> {
637 let regex = raw.pattern.as_ref().map(|re| Regex::new(re)).transpose()?;
638
639 Ok(StringConstraints {
640 regex,
641 allowed_values: raw.allowed_values.clone(),
642 })
643}
644
645fn read_install_record(layout: &TemplateLayout) -> InstalledFrom {
646 use crate::reader::{RawInstalledFrom, parse_installed_from};
647
648 let installed_from_text = std::fs::read_to_string(layout.installation_record_file()).ok();
649 match installed_from_text.and_then(parse_installed_from) {
650 Some(RawInstalledFrom::Git { git }) => InstalledFrom::Git(git),
651 Some(RawInstalledFrom::File { dir }) => InstalledFrom::Directory(dir),
652 Some(RawInstalledFrom::RemoteTar { url }) => InstalledFrom::RemoteTar(url),
653 None => InstalledFrom::Unknown,
654 }
655}
656
657fn validate_manifest(raw: &RawTemplateManifest) -> anyhow::Result<()> {
658 match raw {
659 RawTemplateManifest::V1(raw) => validate_v1_manifest(raw),
660 }
661}
662
663fn validate_v1_manifest(raw: &RawTemplateManifestV1) -> anyhow::Result<()> {
664 if raw.custom_filters.is_some() {
665 anyhow::bail!(
666 "Custom filters are not supported in this version of Spin. Please update your template."
667 );
668 }
669 Ok(())
670}
671
672#[cfg(test)]
673mod test {
674 use super::*;
675
676 struct TempFile {
677 _temp_dir: tempfile::TempDir,
678 path: PathBuf,
679 }
680
681 impl TempFile {
682 fn path(&self) -> PathBuf {
683 self.path.clone()
684 }
685 }
686
687 fn make_temp_manifest(content: &str) -> TempFile {
688 let temp_dir = tempfile::tempdir().unwrap();
689 let temp_file = temp_dir.path().join("spin.toml");
690 std::fs::write(&temp_file, content).unwrap();
691 TempFile {
692 _temp_dir: temp_dir,
693 path: temp_file,
694 }
695 }
696
697 #[test]
698 fn manifest_entry_exists_condition_is_false_for_new_app() {
699 let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
700 "application.trigger.redis".to_owned(),
701 ));
702 assert!(!condition.is_true(&TemplateVariantInfo::NewApplication));
703 }
704
705 #[test]
706 fn manifest_entry_exists_condition_is_false_if_not_present_in_existing_manifest() {
707 let temp_file =
708 make_temp_manifest("name = \"hello\"\n[application.trigger.http]\nbase = \"/\"");
709 let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
710 "application.trigger.redis".to_owned(),
711 ));
712 assert!(!condition.is_true(&TemplateVariantInfo::AddComponent {
713 manifest_path: temp_file.path()
714 }));
715 }
716
717 #[test]
718 fn manifest_entry_exists_condition_is_true_if_present_in_existing_manifest() {
719 let temp_file = make_temp_manifest(
720 "name = \"hello\"\n[application.trigger.redis]\nchannel = \"HELLO\"",
721 );
722 let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
723 "application.trigger.redis".to_owned(),
724 ));
725 assert!(condition.is_true(&TemplateVariantInfo::AddComponent {
726 manifest_path: temp_file.path()
727 }));
728 }
729
730 #[test]
731 fn manifest_entry_exists_condition_is_false_if_path_does_not_exist() {
732 let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
733 "application.trigger.redis".to_owned(),
734 ));
735 assert!(!condition.is_true(&TemplateVariantInfo::AddComponent {
736 manifest_path: PathBuf::from("this/file/does/not.exist")
737 }));
738 }
739
740 #[test]
741 fn selected_variant_respects_target() {
742 let add_component_vt = TemplateVariant {
743 conditions: vec![Conditional {
744 condition: Condition::Always(true),
745 skip_files: vec!["test2".to_owned()],
746 skip_parameters: vec!["p1".to_owned()],
747 skip_snippets: vec!["s1".to_owned()],
748 }],
749 skip_files: vec!["test1".to_owned()],
750 snippets: [
751 ("s1".to_owned(), "s1val".to_owned()),
752 ("s2".to_owned(), "s2val".to_owned()),
753 ]
754 .into_iter()
755 .collect(),
756 ..Default::default()
757 };
758 let variants = [
759 (
760 TemplateVariantKind::NewApplication,
761 TemplateVariant::default(),
762 ),
763 (TemplateVariantKind::AddComponent, add_component_vt),
764 ]
765 .into_iter()
766 .collect();
767 let template = Template {
768 id: "test".to_owned(),
769 tags: HashSet::new(),
770 description: None,
771 installed_from: InstalledFrom::Unknown,
772 trigger: TemplateTriggerCompatibility::Any,
773 variants,
774 parameters: vec![],
775 extra_outputs: vec![],
776 snippets_dir: None,
777 partials_dir: None,
778 content_dir: None,
779 };
780
781 let variant_info = TemplateVariantInfo::NewApplication;
782 let variant = template.variant(&variant_info).unwrap();
783 assert!(variant.skip_files.is_empty());
784 assert!(variant.skip_parameters.is_empty());
785 assert!(variant.snippets.is_empty());
786
787 let add_variant_info = TemplateVariantInfo::AddComponent {
788 manifest_path: PathBuf::from("dummy"),
789 };
790 let add_variant = template.variant(&add_variant_info).unwrap();
791 assert_eq!(2, add_variant.skip_files.len());
793 assert!(add_variant.skip_files.contains(&"test1".to_owned()));
794 assert!(add_variant.skip_files.contains(&"test2".to_owned()));
795 assert_eq!(1, add_variant.skip_parameters.len());
796 assert!(add_variant.skip_parameters.contains(&"p1".to_owned()));
797 assert_eq!(1, add_variant.snippets.len());
799 assert!(!add_variant.snippets.contains_key("s1"));
800 assert!(add_variant.snippets.contains_key("s2"));
801 }
802}