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    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>, // TODO: maybe always need a spin.toml file in there?
34}
35
36#[derive(Debug)]
37enum InstalledFrom {
38    Git(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/// The variant mode in which a template should be run.
51#[derive(Clone, Debug)]
52pub enum TemplateVariantInfo {
53    /// Create a new application from the template.
54    NewApplication,
55    /// Create a new component in an existing application from the template.
56    AddComponent {
57        /// The manifest to which the component will be added.
58        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    /// A human-readable description of the variant.
71    pub fn description(&self) -> &'static str {
72        match self {
73            Self::NewApplication => "new application",
74            Self::AddComponent { .. } => "add component",
75        }
76    }
77
78    /// The noun that should be used for the variant in a prompt
79    pub fn prompt_noun(&self) -> &'static str {
80        match self {
81            Self::NewApplication => "application",
82            Self::AddComponent { .. } => "component",
83        }
84    }
85
86    /// The noun that should be used for the variant in a prompt,
87    /// qualified with the appropriate a/an article for English
88    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, // TODO: possibly abstract to a ValidationCriteria type?
128    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    /// The ID of the template. This is used to identify the template
207    /// on the Spin command line.
208    pub fn id(&self) -> &str {
209        &self.id
210    }
211
212    /// Returns true if the templates matches the provided set of tags.
213    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    /// The set of tags associated with the template, provided by the
220    /// template author.
221    pub fn tags(&self) -> &HashSet<String> {
222        &self.tags
223    }
224
225    /// A human-readable description of the template, provided by the
226    /// template author.
227    pub fn description(&self) -> &Option<String> {
228        &self.description
229    }
230
231    /// A human-readable description of the template, provided by the
232    /// template author, or an empty string if no description was
233    /// provided.
234    pub fn description_or_empty(&self) -> &str {
235        match &self.description {
236            Some(s) => s,
237            None => "",
238        }
239    }
240
241    /// The Git repository from which the template was installed, if
242    /// it was installed from Git; otherwise None.
243    pub fn source_repo(&self) -> Option<&str> {
244        // TODO: this is kind of specialised - should we do the discarding of
245        // non-Git sources at the application layer?
246        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    /// A human-readable description of where the template was installed
258    /// from.
259    pub fn installed_from_or_empty(&self) -> &str {
260        match &self.installed_from {
261            InstalledFrom::Git(repo) => repo,
262            InstalledFrom::Directory(path) => path,
263            InstalledFrom::RemoteTar(url) => url,
264            InstalledFrom::Unknown => "",
265        }
266    }
267
268    // TODO: we should resolve this once at the start of Run and then use that forever
269    fn variant(&self, variant_info: &TemplateVariantInfo) -> Option<TemplateVariant> {
270        let kind = variant_info.kind();
271        self.variants
272            .get(&kind)
273            .map(|vt| vt.resolve_conditions(variant_info))
274    }
275
276    pub(crate) fn parameters(
277        &self,
278        variant_kind: &TemplateVariantInfo,
279    ) -> impl Iterator<Item = &TemplateParameter> {
280        let variant = self.variant(variant_kind).unwrap(); // TODO: for now
281        self.parameters
282            .iter()
283            .filter(move |p| !variant.skip_parameter(p))
284    }
285
286    pub(crate) fn parameter(&self, name: impl AsRef<str>) -> Option<&TemplateParameter> {
287        self.parameters.iter().find(|p| p.id == name.as_ref())
288    }
289
290    pub(crate) fn extra_outputs(&self) -> &[ExtraOutputAction] {
291        &self.extra_outputs
292    }
293
294    pub(crate) fn content_dir(&self) -> &Option<PathBuf> {
295        &self.content_dir
296    }
297
298    pub(crate) fn snippets_dir(&self) -> &Option<PathBuf> {
299        &self.snippets_dir
300    }
301
302    pub(crate) fn partials_dir(&self) -> &Option<PathBuf> {
303        &self.partials_dir
304    }
305
306    /// Checks if the template supports the specified variant mode.
307    pub fn supports_variant(&self, variant: &TemplateVariantInfo) -> bool {
308        self.variants.contains_key(&variant.kind())
309    }
310
311    pub(crate) fn snippets(&self, variant_kind: &TemplateVariantInfo) -> HashMap<String, String> {
312        let variant = self.variant(variant_kind).unwrap(); // TODO: for now
313        variant.snippets
314    }
315
316    /// Creates a runner for the template, governed by the given options. Call
317    /// the relevant associated function of the `Run` to execute the template
318    /// as appropriate to your application (e.g. `interactive()` to prompt the user
319    /// for values and interact with the user at the console).
320    pub fn run(self, options: RunOptions) -> Run {
321        Run::new(self, options)
322    }
323
324    fn normalize_tags(tags: HashSet<String>) -> HashSet<String> {
325        tags.into_iter().map(|tag| tag.to_lowercase()).collect()
326    }
327
328    fn parse_template_variants(
329        new_application: Option<RawTemplateVariant>,
330        add_component: Option<RawTemplateVariant>,
331    ) -> HashMap<TemplateVariantKind, TemplateVariant> {
332        let mut variants = HashMap::default();
333        if let Some(vt) = Self::get_variant(new_application, true) {
334            variants.insert(TemplateVariantKind::NewApplication, vt);
335        }
336        if let Some(vt) = Self::get_variant(add_component, false) {
337            variants.insert(TemplateVariantKind::AddComponent, vt);
338        }
339        variants
340    }
341
342    fn get_variant(
343        raw: Option<RawTemplateVariant>,
344        default_supported: bool,
345    ) -> Option<TemplateVariant> {
346        match raw {
347            None => {
348                if default_supported {
349                    Some(Default::default())
350                } else {
351                    None
352                }
353            }
354            Some(rv) => {
355                if rv.supported.unwrap_or(true) {
356                    Some(Self::parse_template_variant(rv))
357                } else {
358                    None
359                }
360            }
361        }
362    }
363
364    fn parse_template_variant(raw: RawTemplateVariant) -> TemplateVariant {
365        TemplateVariant {
366            skip_files: raw.skip_files.unwrap_or_default(),
367            skip_parameters: raw.skip_parameters.unwrap_or_default(),
368            snippets: raw.snippets.unwrap_or_default(),
369            conditions: raw
370                .conditions
371                .unwrap_or_default()
372                .into_values()
373                .map(Self::parse_conditional)
374                .collect(),
375        }
376    }
377
378    fn parse_conditional(conditional: RawConditional) -> Conditional {
379        Conditional {
380            condition: Self::parse_condition(conditional.condition),
381            skip_files: conditional.skip_files.unwrap_or_default(),
382            skip_parameters: conditional.skip_parameters.unwrap_or_default(),
383            skip_snippets: conditional.skip_snippets.unwrap_or_default(),
384        }
385    }
386
387    fn parse_condition(condition: RawCondition) -> Condition {
388        match condition {
389            RawCondition::ManifestEntryExists(path) => {
390                Condition::ManifestEntryExists(path.split('.').map(|s| s.to_string()).collect_vec())
391            }
392        }
393    }
394
395    fn parse_parameters(
396        raw: &Option<IndexMap<String, RawParameter>>,
397    ) -> anyhow::Result<Vec<TemplateParameter>> {
398        match raw {
399            None => Ok(vec![]),
400            Some(parameters) => parameters
401                .iter()
402                .map(|(k, v)| TemplateParameter::from_raw(k, v))
403                .collect(),
404        }
405    }
406
407    fn parse_extra_outputs(
408        raw: &Option<IndexMap<String, RawExtraOutput>>,
409    ) -> anyhow::Result<Vec<ExtraOutputAction>> {
410        match raw {
411            None => Ok(vec![]),
412            Some(parameters) => parameters
413                .iter()
414                .map(|(k, v)| ExtraOutputAction::from_raw(k, v))
415                .collect(),
416        }
417    }
418
419    pub(crate) fn included_files(
420        &self,
421        base: &std::path::Path,
422        all_files: Vec<PathBuf>,
423        variant_kind: &TemplateVariantInfo,
424    ) -> Vec<PathBuf> {
425        let variant = self.variant(variant_kind).unwrap(); // TODO: for now
426        all_files
427            .into_iter()
428            .filter(|path| !variant.skip_file(base, path))
429            .collect()
430    }
431
432    pub(crate) fn check_compatible_manifest_format(
433        &self,
434        manifest_format: u32,
435    ) -> anyhow::Result<()> {
436        let Some(content_dir) = &self.content_dir else {
437            return Ok(());
438        };
439        let manifest_tpl = content_dir.join("spin.toml");
440        if !manifest_tpl.is_file() {
441            return Ok(());
442        }
443
444        // We can't load the manifest template because it's not valid TOML until
445        // substituted, so GO BIG or at least GO CRUDE.
446        let Ok(manifest_tpl_str) = std::fs::read_to_string(&manifest_tpl) else {
447            return Ok(());
448        };
449        let is_v1_tpl = manifest_tpl_str.contains("spin_manifest_version = \"1\"");
450        let is_v2_tpl = manifest_tpl_str.contains("spin_manifest_version = 2");
451
452        // If we have not positively identified a format, err on the side of forgiveness
453        let positively_identified = is_v1_tpl ^ is_v2_tpl; // exactly one should be true
454        if !positively_identified {
455            return Ok(());
456        }
457
458        let compatible = (is_v1_tpl && manifest_format == 1) || (is_v2_tpl && manifest_format == 2);
459
460        if compatible {
461            Ok(())
462        } else {
463            Err(anyhow!(
464                "This template is for a different version of the Spin manifest"
465            ))
466        }
467    }
468}
469
470impl TemplateParameter {
471    fn from_raw(id: &str, raw: &RawParameter) -> anyhow::Result<Self> {
472        let data_type = TemplateParameterDataType::parse(raw)?;
473
474        Ok(Self {
475            id: id.to_owned(),
476            data_type,
477            prompt: raw.prompt.clone(),
478            default_value: raw.default_value.clone(),
479        })
480    }
481
482    pub fn id(&self) -> &str {
483        &self.id
484    }
485
486    pub fn data_type(&self) -> &TemplateParameterDataType {
487        &self.data_type
488    }
489
490    pub fn prompt(&self) -> &str {
491        &self.prompt
492    }
493
494    pub fn default_value(&self) -> &Option<String> {
495        &self.default_value
496    }
497
498    pub fn validate_value(&self, value: impl AsRef<str>) -> anyhow::Result<String> {
499        self.data_type.validate_value(value.as_ref().to_owned())
500    }
501}
502
503impl TemplateParameterDataType {
504    fn parse(raw: &RawParameter) -> anyhow::Result<Self> {
505        match &raw.data_type[..] {
506            "string" => Ok(Self::String(parse_string_constraints(raw)?)),
507            _ => Err(anyhow!("Unrecognised data type '{}'", raw.data_type)),
508        }
509    }
510
511    fn validate_value(&self, value: String) -> anyhow::Result<String> {
512        match self {
513            TemplateParameterDataType::String(constraints) => constraints.validate(value),
514        }
515    }
516}
517
518impl ExtraOutputAction {
519    fn from_raw(id: &str, raw: &RawExtraOutput) -> anyhow::Result<Self> {
520        Ok(match raw {
521            RawExtraOutput::CreateDir(create) => {
522                let path_template =
523                    liquid::Parser::new().parse(&create.path).with_context(|| {
524                        format!("Template error: output {id} is not a valid template")
525                    })?;
526                Self::CreateDirectory(
527                    create.path.clone(),
528                    std::sync::Arc::new(path_template),
529                    create.at.unwrap_or_default(),
530                )
531            }
532        })
533    }
534}
535
536impl TemplateVariant {
537    pub(crate) fn skip_file(&self, base: &std::path::Path, path: &std::path::Path) -> bool {
538        self.skip_files
539            .iter()
540            .map(|s| base.join(s))
541            .any(|f| path == f)
542    }
543
544    pub(crate) fn skip_parameter(&self, parameter: &TemplateParameter) -> bool {
545        self.skip_parameters.iter().any(|p| &parameter.id == p)
546    }
547
548    fn resolve_conditions(&self, variant_info: &TemplateVariantInfo) -> Self {
549        let mut resolved = self.clone();
550        for condition in &self.conditions {
551            if condition.condition.is_true(variant_info) {
552                resolved
553                    .skip_files
554                    .append(&mut condition.skip_files.clone());
555                resolved
556                    .skip_parameters
557                    .append(&mut condition.skip_parameters.clone());
558                resolved
559                    .snippets
560                    .retain(|id, _| !condition.skip_snippets.contains(id));
561            }
562        }
563        resolved
564    }
565}
566
567impl Condition {
568    fn is_true(&self, variant_info: &TemplateVariantInfo) -> bool {
569        match self {
570            Self::ManifestEntryExists(path) => match variant_info {
571                TemplateVariantInfo::NewApplication => false,
572                TemplateVariantInfo::AddComponent { manifest_path } => {
573                    let Ok(toml_text) = std::fs::read_to_string(manifest_path) else {
574                        return false;
575                    };
576                    let Ok(table) = toml::from_str::<toml::Value>(&toml_text) else {
577                        return false;
578                    };
579                    crate::toml::get_at(table, path).is_some()
580                }
581            },
582            #[cfg(test)]
583            Self::Always(b) => *b,
584        }
585    }
586}
587
588fn parse_string_constraints(raw: &RawParameter) -> anyhow::Result<StringConstraints> {
589    let regex = raw.pattern.as_ref().map(|re| Regex::new(re)).transpose()?;
590
591    Ok(StringConstraints {
592        regex,
593        allowed_values: raw.allowed_values.clone(),
594    })
595}
596
597fn read_install_record(layout: &TemplateLayout) -> InstalledFrom {
598    use crate::reader::{RawInstalledFrom, parse_installed_from};
599
600    let installed_from_text = std::fs::read_to_string(layout.installation_record_file()).ok();
601    match installed_from_text.and_then(parse_installed_from) {
602        Some(RawInstalledFrom::Git { git }) => InstalledFrom::Git(git),
603        Some(RawInstalledFrom::File { dir }) => InstalledFrom::Directory(dir),
604        Some(RawInstalledFrom::RemoteTar { url }) => InstalledFrom::RemoteTar(url),
605        None => InstalledFrom::Unknown,
606    }
607}
608
609fn validate_manifest(raw: &RawTemplateManifest) -> anyhow::Result<()> {
610    match raw {
611        RawTemplateManifest::V1(raw) => validate_v1_manifest(raw),
612    }
613}
614
615fn validate_v1_manifest(raw: &RawTemplateManifestV1) -> anyhow::Result<()> {
616    if raw.custom_filters.is_some() {
617        anyhow::bail!(
618            "Custom filters are not supported in this version of Spin. Please update your template."
619        );
620    }
621    Ok(())
622}
623
624#[cfg(test)]
625mod test {
626    use super::*;
627
628    struct TempFile {
629        _temp_dir: tempfile::TempDir,
630        path: PathBuf,
631    }
632
633    impl TempFile {
634        fn path(&self) -> PathBuf {
635            self.path.clone()
636        }
637    }
638
639    fn make_temp_manifest(content: &str) -> TempFile {
640        let temp_dir = tempfile::tempdir().unwrap();
641        let temp_file = temp_dir.path().join("spin.toml");
642        std::fs::write(&temp_file, content).unwrap();
643        TempFile {
644            _temp_dir: temp_dir,
645            path: temp_file,
646        }
647    }
648
649    #[test]
650    fn manifest_entry_exists_condition_is_false_for_new_app() {
651        let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
652            "application.trigger.redis".to_owned(),
653        ));
654        assert!(!condition.is_true(&TemplateVariantInfo::NewApplication));
655    }
656
657    #[test]
658    fn manifest_entry_exists_condition_is_false_if_not_present_in_existing_manifest() {
659        let temp_file =
660            make_temp_manifest("name = \"hello\"\n[application.trigger.http]\nbase = \"/\"");
661        let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
662            "application.trigger.redis".to_owned(),
663        ));
664        assert!(!condition.is_true(&TemplateVariantInfo::AddComponent {
665            manifest_path: temp_file.path()
666        }));
667    }
668
669    #[test]
670    fn manifest_entry_exists_condition_is_true_if_present_in_existing_manifest() {
671        let temp_file = make_temp_manifest(
672            "name = \"hello\"\n[application.trigger.redis]\nchannel = \"HELLO\"",
673        );
674        let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
675            "application.trigger.redis".to_owned(),
676        ));
677        assert!(condition.is_true(&TemplateVariantInfo::AddComponent {
678            manifest_path: temp_file.path()
679        }));
680    }
681
682    #[test]
683    fn manifest_entry_exists_condition_is_false_if_path_does_not_exist() {
684        let condition = Template::parse_condition(RawCondition::ManifestEntryExists(
685            "application.trigger.redis".to_owned(),
686        ));
687        assert!(!condition.is_true(&TemplateVariantInfo::AddComponent {
688            manifest_path: PathBuf::from("this/file/does/not.exist")
689        }));
690    }
691
692    #[test]
693    fn selected_variant_respects_target() {
694        let add_component_vt = TemplateVariant {
695            conditions: vec![Conditional {
696                condition: Condition::Always(true),
697                skip_files: vec!["test2".to_owned()],
698                skip_parameters: vec!["p1".to_owned()],
699                skip_snippets: vec!["s1".to_owned()],
700            }],
701            skip_files: vec!["test1".to_owned()],
702            snippets: [
703                ("s1".to_owned(), "s1val".to_owned()),
704                ("s2".to_owned(), "s2val".to_owned()),
705            ]
706            .into_iter()
707            .collect(),
708            ..Default::default()
709        };
710        let variants = [
711            (
712                TemplateVariantKind::NewApplication,
713                TemplateVariant::default(),
714            ),
715            (TemplateVariantKind::AddComponent, add_component_vt),
716        ]
717        .into_iter()
718        .collect();
719        let template = Template {
720            id: "test".to_owned(),
721            tags: HashSet::new(),
722            description: None,
723            installed_from: InstalledFrom::Unknown,
724            variants,
725            parameters: vec![],
726            extra_outputs: vec![],
727            snippets_dir: None,
728            partials_dir: None,
729            content_dir: None,
730        };
731
732        let variant_info = TemplateVariantInfo::NewApplication;
733        let variant = template.variant(&variant_info).unwrap();
734        assert!(variant.skip_files.is_empty());
735        assert!(variant.skip_parameters.is_empty());
736        assert!(variant.snippets.is_empty());
737
738        let add_variant_info = TemplateVariantInfo::AddComponent {
739            manifest_path: PathBuf::from("dummy"),
740        };
741        let add_variant = template.variant(&add_variant_info).unwrap();
742        // the conditional skip_files and skip_parameters are added to the variant's skip lists
743        assert_eq!(2, add_variant.skip_files.len());
744        assert!(add_variant.skip_files.contains(&"test1".to_owned()));
745        assert!(add_variant.skip_files.contains(&"test2".to_owned()));
746        assert_eq!(1, add_variant.skip_parameters.len());
747        assert!(add_variant.skip_parameters.contains(&"p1".to_owned()));
748        // the conditional skip_snippets are *removed from* the variant's snippets list
749        assert_eq!(1, add_variant.snippets.len());
750        assert!(!add_variant.snippets.contains_key("s1"));
751        assert!(add_variant.snippets.contains_key("s2"));
752    }
753}