spin_templates/
template.rs

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/// A Spin template.
22#[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>, // TODO: maybe always need a spin.toml file in there?
35}
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/// The variant mode in which a template should be run.
52#[derive(Clone, Debug)]
53pub enum TemplateVariantInfo {
54    /// Create a new application from the template.
55    NewApplication,
56    /// Create a new component in an existing application from the template.
57    AddComponent {
58        /// The manifest to which the component will be added.
59        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    /// A human-readable description of the variant.
72    pub fn description(&self) -> &'static str {
73        match self {
74            Self::NewApplication => "new application",
75            Self::AddComponent { .. } => "add component",
76        }
77    }
78
79    /// The noun that should be used for the variant in a prompt
80    pub fn prompt_noun(&self) -> &'static str {
81        match self {
82            Self::NewApplication => "application",
83            Self::AddComponent { .. } => "component",
84        }
85    }
86
87    /// The noun that should be used for the variant in a prompt,
88    /// qualified with the appropriate a/an article for English
89    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, // TODO: possibly abstract to a ValidationCriteria type?
135    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    /// The ID of the template. This is used to identify the template
215    /// on the Spin command line.
216    pub fn id(&self) -> &str {
217        &self.id
218    }
219
220    /// Returns true if the templates matches the provided set of tags.
221    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    /// The set of tags associated with the template, provided by the
228    /// template author.
229    pub fn tags(&self) -> &HashSet<String> {
230        &self.tags
231    }
232
233    /// A human-readable description of the template, provided by the
234    /// template author.
235    pub fn description(&self) -> &Option<String> {
236        &self.description
237    }
238
239    /// A human-readable description of the template, provided by the
240    /// template author, or an empty string if no description was
241    /// provided.
242    pub fn description_or_empty(&self) -> &str {
243        match &self.description {
244            Some(s) => s,
245            None => "",
246        }
247    }
248
249    /// The Git repository from which the template was installed, if
250    /// it was installed from Git; otherwise None.
251    pub fn source_repo(&self) -> Option<&str> {
252        // TODO: this is kind of specialised - should we do the discarding of
253        // non-Git sources at the application layer?
254        match &self.installed_from {
255            InstalledFrom::Git(url) => Some(url),
256            _ => None,
257        }
258    }
259
260    /// A human-readable description of where the template was installed
261    /// from.
262    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    // TODO: we should resolve this once at the start of Run and then use that forever
272    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(); // TODO: for now
284        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    /// Checks if the template supports the specified variant mode.
310    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(); // TODO: for now
316        variant.snippets
317    }
318
319    /// Creates a runner for the template, governed by the given options. Call
320    /// the relevant associated function of the `Run` to execute the template
321    /// as appropriate to your application (e.g. `interactive()` to prompt the user
322    /// for values and interact with the user at the console).
323    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, // Fail forgiving
348        }
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(); // TODO: for now
449        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        // The application we are merging into might not have a trigger yet, in which case
457        // we're good to go.
458        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        // We can't load the manifest template because it's not valid TOML until
486        // substituted, so GO BIG or at least GO CRUDE.
487        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        // If we have not positively identified a format, err on the side of forgiveness
494        let positively_identified = is_v1_tpl ^ is_v2_tpl; // exactly one should be true
495        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| &parameter.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        // the conditional skip_files and skip_parameters are added to the variant's skip lists
783        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        // the conditional skip_snippets are *removed from* the variant's snippets list
789        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}