Skip to main content

spin_templates/
template.rs

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/// 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    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    /// A human-readable description of where the template was installed
266    /// from.
267    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    // TODO: we should resolve this once at the start of Run and then use that forever
277    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(); // TODO: for now
289        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    /// Checks if the template supports the specified variant mode.
315    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(); // TODO: for now
321        variant.snippets
322    }
323
324    /// Creates a runner for the template, governed by the given options. Call
325    /// the relevant associated function of the `Run` to execute the template
326    /// as appropriate to your application (e.g. `interactive()` to prompt the user
327    /// for values and interact with the user at the console).
328    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, // Fail forgiving
353        }
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(); // TODO: for now
454        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        // The application we are merging into might not have a trigger yet, in which case
462        // we're good to go.
463        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        // We can't load the manifest template because it's not valid TOML until
493        // substituted, so GO BIG or at least GO CRUDE.
494        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        // If we have not positively identified a format, err on the side of forgiveness
501        let positively_identified = is_v1_tpl ^ is_v2_tpl; // exactly one should be true
502        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| &parameter.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        // the conditional skip_files and skip_parameters are added to the variant's skip lists
792        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        // the conditional skip_snippets are *removed from* the variant's snippets list
798        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}