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