Skip to main content

spin_templates/
manager.rs

1use std::{io::IsTerminal, path::Path};
2
3use anyhow::Context;
4
5use crate::{
6    source::TemplateSource,
7    store::{TemplateLayout, TemplateStore},
8    template::Template,
9};
10
11/// Provides access to and operations on the set of installed
12/// templates.
13pub struct TemplateManager {
14    store: TemplateStore,
15}
16
17/// Used during template installation to report progress and
18/// current activity.
19pub trait ProgressReporter {
20    /// Report the specified message.
21    fn report(&self, message: impl AsRef<str>);
22}
23
24/// Options controlling template installation.
25#[derive(Debug)]
26pub struct InstallOptions {
27    exists_behaviour: ExistsBehaviour,
28}
29
30impl InstallOptions {
31    /// Sets the option to update existing templates. If `update` is true,
32    /// existing templates are updated. If false, existing templates are
33    /// skipped.
34    pub fn update(self, update: bool) -> Self {
35        let exists_behaviour = if update {
36            ExistsBehaviour::Update
37        } else {
38            ExistsBehaviour::Skip
39        };
40
41        Self { exists_behaviour }
42    }
43}
44
45impl Default for InstallOptions {
46    fn default() -> Self {
47        Self {
48            exists_behaviour: ExistsBehaviour::Skip,
49        }
50    }
51}
52
53#[derive(Debug)]
54enum ExistsBehaviour {
55    Skip,
56    Update,
57}
58
59#[allow(clippy::large_enum_variant)] // it's not worth it
60enum InstallationResult {
61    Installed(Template),
62    Skipped(String, SkippedReason),
63}
64
65/// The reason a template was skipped during installation.
66pub enum SkippedReason {
67    /// The template was skipped because it was already present.
68    AlreadyExists,
69    /// The template was skipped because its manifest was missing or invalid.
70    InvalidManifest(String),
71}
72
73/// The results of installing a set of templates.
74pub struct InstallationResults {
75    /// The templates that were installed during the install operation.
76    pub installed: Vec<Template>,
77    /// The templates that were skipped during the install operation.
78    pub skipped: Vec<(String, SkippedReason)>,
79}
80
81/// The result of listing templates.
82#[derive(Debug)]
83pub struct ListResults {
84    /// The installed templates.
85    pub templates: Vec<Template>,
86    /// Any warnings identified during the list operation.
87    pub warnings: Vec<(String, InstalledTemplateWarning)>,
88    /// Any skipped templates (populated as a result of filtering by tags).
89    pub skipped: Vec<Template>,
90}
91
92impl ListResults {
93    /// Returns true if no templates were found or skipped, and the UI should prompt to
94    /// install templates. This is specifically for interactive use and returns false
95    /// if not connected to a terminal.
96    pub fn needs_install(&self) -> bool {
97        self.templates.is_empty() && self.skipped.is_empty() && std::io::stderr().is_terminal()
98    }
99}
100
101/// A recoverable problem while listing templates.
102#[derive(Debug)]
103pub enum InstalledTemplateWarning {
104    /// The manifest is invalid. The directory may not represent a template.
105    InvalidManifest(String),
106}
107
108impl TemplateManager {
109    /// Creates a `TemplateManager` for the default install location.
110    pub fn try_default() -> anyhow::Result<Self> {
111        let store = TemplateStore::try_default()?;
112        Ok(Self::new(store))
113    }
114
115    pub(crate) fn new(store: TemplateStore) -> Self {
116        Self { store }
117    }
118
119    /// Installs templates from the specified source.
120    pub async fn install(
121        &self,
122        source: &TemplateSource,
123        options: &InstallOptions,
124        reporter: &impl ProgressReporter,
125    ) -> anyhow::Result<InstallationResults> {
126        if source.requires_copy() {
127            reporter.report("Copying remote template source");
128        }
129
130        let local_source = source
131            .get_local()
132            .await
133            .context("Failed to get template source")?;
134        let template_dirs = local_source
135            .template_directories()
136            .await
137            .context("Could not find templates in source")?;
138
139        let mut installed = vec![];
140        let mut skipped = vec![];
141
142        for template_dir in template_dirs {
143            let install_result = self
144                .install_one(&template_dir, options, source, reporter)
145                .await
146                .with_context(|| {
147                    format!("Failed to install template from {}", template_dir.display())
148                })?;
149            match install_result {
150                InstallationResult::Installed(template) => installed.push(template),
151                InstallationResult::Skipped(id, reason) => skipped.push((id, reason)),
152            }
153        }
154
155        installed.sort_by_key(|t| t.id().to_owned());
156        skipped.sort_by_key(|(id, _)| id.clone());
157
158        Ok(InstallationResults { installed, skipped })
159    }
160
161    async fn install_one(
162        &self,
163        source_dir: &Path,
164        options: &InstallOptions,
165        source: &TemplateSource,
166        reporter: &impl ProgressReporter,
167    ) -> anyhow::Result<InstallationResult> {
168        let layout = TemplateLayout::new(source_dir);
169        let template = match Template::load_from(&layout) {
170            Ok(t) => t,
171            Err(e) => {
172                let fake_id = source_dir
173                    .file_name()
174                    .map(|s| s.to_string_lossy().to_string())
175                    .unwrap_or_else(|| format!("{}", source_dir.display()));
176                let message = format!("{e}");
177                return Ok(InstallationResult::Skipped(
178                    fake_id,
179                    SkippedReason::InvalidManifest(message),
180                ));
181            }
182        };
183        let id = template.id();
184
185        let message = format!("Installing template {id}...");
186        reporter.report(&message);
187
188        let dest_dir = self.store.get_directory(id);
189
190        let template = if dest_dir.exists() {
191            match options.exists_behaviour {
192                ExistsBehaviour::Skip => {
193                    return Ok(InstallationResult::Skipped(
194                        id.to_owned(),
195                        SkippedReason::AlreadyExists,
196                    ));
197                }
198                ExistsBehaviour::Update => {
199                    copy_template_over_existing(id, source_dir, &dest_dir, source).await?
200                }
201            }
202        } else {
203            copy_template_into(id, source_dir, &dest_dir, source).await?
204        };
205
206        Ok(InstallationResult::Installed(template))
207    }
208
209    /// Uninstalls the specified template.
210    pub async fn uninstall(&self, template_id: impl AsRef<str>) -> anyhow::Result<()> {
211        let template_dir = self.store.get_directory(template_id);
212        tokio::fs::remove_dir_all(&template_dir)
213            .await
214            .with_context(|| {
215                format!(
216                    "Failed to delete template directory {}",
217                    template_dir.display()
218                )
219            })
220    }
221
222    /// Lists all installed templates.
223    pub async fn list(&self) -> anyhow::Result<ListResults> {
224        let mut templates = vec![];
225        let mut warnings = vec![];
226
227        for template_layout in self.store.list_layouts().await? {
228            match Template::load_from(&template_layout) {
229                Ok(template) => templates.push(template),
230                Err(e) => warnings.push(build_list_warning(&template_layout, e)?),
231            }
232        }
233
234        templates.sort_by_key(|t| t.id().to_owned());
235
236        Ok(ListResults {
237            templates,
238            warnings,
239            skipped: vec![],
240        })
241    }
242
243    /// Lists all installed templates that match all the provided tags.
244    pub async fn list_with_tags(&self, tags: &[String]) -> anyhow::Result<ListResults> {
245        let ListResults {
246            templates,
247            warnings,
248            ..
249        } = self.list().await?;
250
251        let (templates, skipped) = templates
252            .into_iter()
253            .partition(|tpl| tpl.matches_all_tags(tags));
254
255        Ok(ListResults {
256            templates,
257            warnings,
258            skipped,
259        })
260    }
261
262    /// Gets the specified template. The result will be `Ok(Some(template))` if
263    /// the template was found, and `Ok(None)` if the template was not
264    /// found.
265    pub fn get(&self, id: impl AsRef<str>) -> anyhow::Result<Option<Template>> {
266        self.store
267            .get_layout(id)
268            .map(|l| Template::load_from(&l))
269            .transpose()
270    }
271}
272
273async fn copy_template_over_existing(
274    id: &str,
275    source_dir: &Path,
276    dest_dir: &Path,
277    source: &TemplateSource,
278) -> anyhow::Result<Template> {
279    // The nearby directory to which we initially copy the source
280    let stage_dir = dest_dir.with_extension(".stage");
281    // The nearby directory to which we move the existing
282    let unstage_dir = dest_dir.with_extension(".unstage");
283
284    // Clean up temp directories in case left over from previous failures.
285    if stage_dir.exists() {
286        tokio::fs::remove_dir_all(&stage_dir)
287            .await
288            .with_context(|| {
289                format!(
290                    "Failed while deleting {} in order to update {}",
291                    stage_dir.display(),
292                    id
293                )
294            })?
295    };
296
297    if unstage_dir.exists() {
298        tokio::fs::remove_dir_all(&unstage_dir)
299            .await
300            .with_context(|| {
301                format!(
302                    "Failed while deleting {} in order to update {}",
303                    unstage_dir.display(),
304                    id
305                )
306            })?
307    };
308
309    // Copy template source into stage directory, and do best effort
310    // cleanup if it goes wrong.
311    let copy_to_stage_err = copy_template_into(id, source_dir, &stage_dir, source)
312        .await
313        .err();
314    if let Some(e) = copy_to_stage_err {
315        let _ = tokio::fs::remove_dir_all(&stage_dir).await;
316        return Err(e);
317    };
318
319    // We have a valid template in stage.  Now, move existing to unstage...
320    if let Err(e) = tokio::fs::rename(dest_dir, &unstage_dir)
321        .await
322        .with_context(|| {
323            format!(
324                "Failed to move existing template out of {} in order to update {}",
325                dest_dir.display(),
326                id
327            )
328        })
329    {
330        let _ = tokio::fs::remove_dir_all(&stage_dir).await;
331        return Err(e);
332    }
333
334    // ...and move stage into position.
335    if let Err(e) = tokio::fs::rename(&stage_dir, dest_dir)
336        .await
337        .with_context(|| {
338            format!(
339                "Failed to move new template into {} in order to update {}",
340                dest_dir.display(),
341                id
342            )
343        })
344    {
345        // Put it back quick and hope nobody notices.
346        let _ = tokio::fs::rename(&unstage_dir, dest_dir).await;
347        let _ = tokio::fs::remove_dir_all(&stage_dir).await;
348        return Err(e);
349    }
350
351    // Remove whichever directories remain.  (As we are ignoring errors, we
352    // can skip checking whether the directories exist.)
353    let _ = tokio::fs::remove_dir_all(&stage_dir).await;
354    let _ = tokio::fs::remove_dir_all(&unstage_dir).await;
355
356    load_template_from(id, dest_dir)
357}
358
359async fn copy_template_into(
360    id: &str,
361    source_dir: &Path,
362    dest_dir: &Path,
363    source: &TemplateSource,
364) -> anyhow::Result<Template> {
365    tokio::fs::create_dir_all(&dest_dir)
366        .await
367        .with_context(|| {
368            format!(
369                "Failed to create directory {} for {}",
370                dest_dir.display(),
371                id
372            )
373        })?;
374
375    fs_extra::dir::copy(source_dir, dest_dir, &copy_content()).with_context(|| {
376        format!(
377            "Failed to copy template content from {} to {} for {}",
378            source_dir.display(),
379            dest_dir.display(),
380            id
381        )
382    })?;
383
384    write_install_record(dest_dir, source);
385
386    load_template_from(id, dest_dir)
387}
388
389fn write_install_record(dest_dir: &Path, source: &TemplateSource) {
390    let layout = TemplateLayout::new(dest_dir);
391    let install_record_path = layout.installation_record_file();
392
393    // A failure here shouldn't fail the install
394    let install_record = source.to_install_record();
395    if let Ok(record_text) = toml::to_string_pretty(&install_record) {
396        _ = std::fs::write(install_record_path, record_text);
397    }
398}
399
400fn load_template_from(id: &str, dest_dir: &Path) -> anyhow::Result<Template> {
401    let layout = TemplateLayout::new(dest_dir);
402    Template::load_from(&layout).with_context(|| {
403        format!(
404            "Template {} was not copied correctly into {}",
405            id,
406            dest_dir.display()
407        )
408    })
409}
410
411fn copy_content() -> fs_extra::dir::CopyOptions {
412    let mut options = fs_extra::dir::CopyOptions::new();
413    options.content_only = true;
414    options
415}
416
417fn build_list_warning(
418    template_layout: &TemplateLayout,
419    load_err: anyhow::Error,
420) -> anyhow::Result<(String, InstalledTemplateWarning)> {
421    match template_layout.metadata_dir().parent() {
422        Some(source_dir) => {
423            let fake_id = source_dir
424                .file_name()
425                .map(|s| s.to_string_lossy().to_string())
426                .unwrap_or_else(|| format!("{}", source_dir.display()));
427            let message = format!("{load_err}");
428            Ok((fake_id, InstalledTemplateWarning::InvalidManifest(message)))
429        }
430        None => Err(load_err).context("Failed to load template but unable to determine which one"),
431    }
432}
433
434impl InstallationResults {
435    /// Gets whether the `InstallationResults` contains no templates. This
436    /// indicates that no templates were found in the installation source.
437    pub fn is_empty(&self) -> bool {
438        self.installed.is_empty() && self.skipped.is_empty()
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use std::{collections::HashMap, fs, path::PathBuf};
445
446    use tempfile::tempdir;
447
448    use crate::{RunOptions, TemplateVariantInfo};
449
450    use super::*;
451
452    struct DiscardingReporter;
453
454    impl ProgressReporter for DiscardingReporter {
455        fn report(&self, _: impl AsRef<str>) {
456            // Commit it then to the flames: for it can contain nothing but
457            // sophistry and illusion.
458        }
459    }
460
461    fn project_root() -> PathBuf {
462        let crate_dir = env!("CARGO_MANIFEST_DIR");
463        PathBuf::from(crate_dir).join("..").join("..")
464    }
465
466    fn test_data_root() -> PathBuf {
467        let crate_dir = env!("CARGO_MANIFEST_DIR");
468        PathBuf::from(crate_dir).join("tests")
469    }
470
471    // A template manager in a temp dir.  This exists to ensure the lifetime
472    // of the TempDir
473    struct TempManager {
474        temp_dir: tempfile::TempDir,
475        manager: TemplateManager,
476    }
477
478    impl TempManager {
479        fn new() -> Self {
480            let temp_dir = tempdir().unwrap();
481            let store = TemplateStore::new(temp_dir.path());
482            let manager = TemplateManager { store };
483            Self { temp_dir, manager }
484        }
485
486        async fn new_with_this_repo_templates() -> Self {
487            let mgr = Self::new();
488            mgr.install_this_repo_templates().await;
489            mgr
490        }
491
492        async fn install_this_repo_templates(&self) -> InstallationResults {
493            let source = TemplateSource::File(project_root());
494            self.manager
495                .install(&source, &InstallOptions::default(), &DiscardingReporter)
496                .await
497                .unwrap()
498        }
499
500        async fn install_test_data_templates(&self) -> InstallationResults {
501            let source = TemplateSource::File(test_data_root());
502            self.manager
503                .install(&source, &InstallOptions::default(), &DiscardingReporter)
504                .await
505                .unwrap()
506        }
507
508        fn store_dir(&self) -> &Path {
509            self.temp_dir.path()
510        }
511    }
512
513    impl std::ops::Deref for TempManager {
514        type Target = TemplateManager;
515
516        fn deref(&self) -> &Self::Target {
517            &self.manager
518        }
519    }
520
521    // Constructs an options object to control how a template is run, with defaults.
522    // to save repeating the same thing over and over! By default, the options are:
523    // * Create a mew application
524    // * Dummy parameter values to satisfy the HTTP template
525    // * All other flags are false
526    // Use the `rest` callback to modify fields not given in the `name` and `output_path`
527    // arguments. For example, to create a RunOptions with `no_vcs` turned on, do:
528    // `run_options(name, path, |opts| { opts.no_vcs = true; })`
529    fn run_options(
530        name: impl AsRef<str>,
531        output_path: impl AsRef<Path>,
532        rest: impl FnOnce(&mut RunOptions),
533    ) -> RunOptions {
534        let mut options = RunOptions {
535            variant: crate::template::TemplateVariantInfo::NewApplication,
536            name: name.as_ref().to_owned(),
537            output_path: output_path.as_ref().to_owned(),
538            values: dummy_values(),
539            accept_defaults: false,
540            no_vcs: false,
541            allow_overwrite: false,
542        };
543        rest(&mut options);
544        options
545    }
546
547    fn make_values<SK: ToString, SV: ToString>(
548        values: impl IntoIterator<Item = (SK, SV)>,
549    ) -> HashMap<String, String> {
550        values
551            .into_iter()
552            .map(|(k, v)| (k.to_string(), v.to_string()))
553            .collect()
554    }
555
556    fn dummy_values() -> HashMap<String, String> {
557        make_values([
558            ("project-description", "dummy desc"),
559            ("http-path", "/dummy/dummy/dummy/..."),
560        ])
561    }
562
563    fn add_component_to(manifest_path: impl AsRef<Path>) -> crate::template::TemplateVariantInfo {
564        crate::template::TemplateVariantInfo::AddComponent {
565            manifest_path: manifest_path.as_ref().to_owned(),
566        }
567    }
568
569    const TPLS_IN_THIS: usize = 11;
570
571    #[tokio::test]
572    async fn can_install_into_new_directory() {
573        let manager = TempManager::new();
574        assert_eq!(0, manager.list().await.unwrap().templates.len());
575
576        let install_result = manager.install_this_repo_templates().await;
577        assert_eq!(TPLS_IN_THIS, install_result.installed.len());
578        assert_eq!(0, install_result.skipped.len());
579
580        assert_eq!(TPLS_IN_THIS, manager.list().await.unwrap().templates.len());
581        assert_eq!(0, manager.list().await.unwrap().warnings.len());
582    }
583
584    #[tokio::test]
585    async fn skips_bad_templates() {
586        let manager = TempManager::new();
587
588        let temp_source = tempdir().unwrap();
589        let temp_source_tpls_dir = temp_source.path().join("templates");
590        fs_extra::dir::copy(
591            project_root().join("templates"),
592            &temp_source_tpls_dir,
593            &copy_content(),
594        )
595        .unwrap();
596        fs::create_dir(temp_source_tpls_dir.join("notta-template")).unwrap();
597        let source = TemplateSource::File(temp_source.path().to_owned());
598
599        assert_eq!(0, manager.list().await.unwrap().templates.len());
600        assert_eq!(0, manager.list().await.unwrap().warnings.len());
601
602        let install_result = manager
603            .install(&source, &InstallOptions::default(), &DiscardingReporter)
604            .await
605            .unwrap();
606        assert_eq!(TPLS_IN_THIS, install_result.installed.len());
607        assert_eq!(1, install_result.skipped.len());
608
609        assert!(matches!(
610            install_result.skipped[0].1,
611            SkippedReason::InvalidManifest(_)
612        ));
613    }
614
615    #[tokio::test]
616    async fn can_list_all_templates_with_empty_tags() {
617        let manager = TempManager::new_with_this_repo_templates().await;
618
619        let list_results = manager.list_with_tags(&[]).await.unwrap();
620        assert_eq!(0, list_results.skipped.len());
621        assert_eq!(TPLS_IN_THIS, list_results.templates.len());
622    }
623
624    #[tokio::test]
625    async fn skips_when_all_tags_do_not_match() {
626        let manager = TempManager::new_with_this_repo_templates().await;
627
628        let tags_to_match = vec!["c".to_string(), "unused_tag".to_string()];
629
630        let list_results = manager.list_with_tags(&tags_to_match).await.unwrap();
631        assert_eq!(TPLS_IN_THIS, list_results.skipped.len());
632        assert_eq!(0, list_results.templates.len());
633    }
634
635    #[tokio::test]
636    async fn can_list_templates_with_multiple_tags() {
637        let manager = TempManager::new_with_this_repo_templates().await;
638
639        let tags_to_match = vec!["http".to_string(), "c".to_string()];
640
641        let list_results = manager.list_with_tags(&tags_to_match).await.unwrap();
642        assert_eq!(TPLS_IN_THIS - 1, list_results.skipped.len());
643        assert_eq!(1, list_results.templates.len());
644    }
645
646    #[tokio::test]
647    async fn can_list_templates_with_case_insensitive_tags() {
648        let manager = TempManager::new_with_this_repo_templates().await;
649
650        let list_results = manager.list_with_tags(&["C".to_string()]).await.unwrap();
651        assert_eq!(TPLS_IN_THIS - 1, list_results.skipped.len());
652        assert_eq!(1, list_results.templates.len());
653    }
654
655    #[tokio::test]
656    async fn can_skip_templates_with_missing_tag() {
657        let manager = TempManager::new_with_this_repo_templates().await;
658
659        let list_results = manager
660            .list_with_tags(&["unused_tag".to_string()])
661            .await
662            .unwrap();
663        assert_eq!(TPLS_IN_THIS, list_results.skipped.len());
664        assert_eq!(0, list_results.templates.len());
665    }
666
667    #[tokio::test]
668    async fn can_list_if_bad_dir_in_store() {
669        let manager = TempManager::new();
670        assert_eq!(0, manager.list().await.unwrap().templates.len());
671
672        manager.install_this_repo_templates().await;
673        assert_eq!(TPLS_IN_THIS, manager.list().await.unwrap().templates.len());
674        assert_eq!(0, manager.list().await.unwrap().warnings.len());
675
676        fs::create_dir(manager.store_dir().join("i-trip-you-up")).unwrap();
677
678        let list_results = manager.list().await.unwrap();
679        assert_eq!(TPLS_IN_THIS, list_results.templates.len());
680        assert_eq!(1, list_results.warnings.len());
681        assert_eq!("i-trip-you-up", list_results.warnings[0].0);
682        assert!(matches!(
683            list_results.warnings[0].1,
684            InstalledTemplateWarning::InvalidManifest(_)
685        ));
686    }
687
688    #[tokio::test]
689    async fn can_uninstall() {
690        let manager = TempManager::new_with_this_repo_templates().await;
691        assert_eq!(TPLS_IN_THIS, manager.list().await.unwrap().templates.len());
692
693        manager.uninstall("http-rust").await.unwrap();
694
695        let installed = manager.list().await.unwrap();
696        assert_eq!(TPLS_IN_THIS - 1, installed.templates.len());
697        assert_eq!(0, installed.warnings.len());
698        assert!(!installed.templates.iter().any(|t| t.id() == "http-rust"));
699    }
700
701    #[tokio::test]
702    async fn can_install_if_some_already_exist() {
703        let manager = TempManager::new_with_this_repo_templates().await;
704        manager.uninstall("http-rust").await.unwrap();
705        manager.uninstall("http-go").await.unwrap();
706        assert_eq!(
707            TPLS_IN_THIS - 2,
708            manager.list().await.unwrap().templates.len()
709        );
710
711        let install_result = manager.install_this_repo_templates().await;
712        assert_eq!(2, install_result.installed.len());
713        assert_eq!(TPLS_IN_THIS - 2, install_result.skipped.len());
714
715        let installed = manager.list().await.unwrap().templates;
716        assert_eq!(TPLS_IN_THIS, installed.len());
717        assert!(installed.iter().any(|t| t.id() == "http-rust"));
718        assert!(installed.iter().any(|t| t.id() == "http-go"));
719    }
720
721    #[tokio::test]
722    async fn can_update_existing() {
723        let manager = TempManager::new_with_this_repo_templates().await;
724        manager.uninstall("http-rust").await.unwrap();
725        assert_eq!(
726            TPLS_IN_THIS - 1,
727            manager.list().await.unwrap().templates.len()
728        );
729
730        let source = TemplateSource::File(project_root());
731        let install_result = manager
732            .install(
733                &source,
734                &InstallOptions::default().update(true),
735                &DiscardingReporter,
736            )
737            .await
738            .unwrap();
739        assert_eq!(TPLS_IN_THIS, install_result.installed.len());
740        assert_eq!(0, install_result.skipped.len());
741
742        let installed = manager.list().await.unwrap().templates;
743        assert_eq!(TPLS_IN_THIS, installed.len());
744        assert!(installed.iter().any(|t| t.id() == "http-go"));
745    }
746
747    #[tokio::test]
748    async fn can_read_installed_template() {
749        let manager = TempManager::new_with_this_repo_templates().await;
750
751        let template = manager.get("http-rust").unwrap().unwrap();
752        assert_eq!(
753            "HTTP request handler using Rust",
754            template.description_or_empty()
755        );
756
757        let content_dir = template.content_dir().as_ref().unwrap();
758        let cargo = tokio::fs::read_to_string(content_dir.join("Cargo.toml.tmpl"))
759            .await
760            .unwrap();
761        assert!(cargo.contains("name = \"{{project-name | kebab_case}}\""));
762    }
763
764    #[tokio::test]
765    async fn can_run_template() {
766        let manager = TempManager::new_with_this_repo_templates().await;
767
768        let template = manager.get("http-rust").unwrap().unwrap();
769
770        let dest_temp_dir = tempdir().unwrap();
771        let output_dir = dest_temp_dir.path().join("myproj");
772
773        let options = run_options("my project", &output_dir, |_| {});
774
775        template.run(options).silent().await.unwrap();
776
777        let cargo = tokio::fs::read_to_string(output_dir.join("Cargo.toml"))
778            .await
779            .unwrap();
780        assert!(cargo.contains("name = \"my-project\""));
781    }
782
783    #[tokio::test]
784    async fn can_run_template_with_accept_defaults() {
785        let manager = TempManager::new_with_this_repo_templates().await;
786
787        let template = manager.get("http-rust").unwrap().unwrap();
788
789        let dest_temp_dir = tempdir().unwrap();
790        let output_dir = dest_temp_dir.path().join("myproj");
791        let options = run_options("my project", &output_dir, |opts| {
792            opts.values = HashMap::new();
793            opts.accept_defaults = true;
794        });
795
796        template.run(options).silent().await.unwrap();
797
798        let cargo = tokio::fs::read_to_string(output_dir.join("Cargo.toml"))
799            .await
800            .unwrap();
801        assert!(cargo.contains("name = \"my-project\""));
802        let spin_toml = tokio::fs::read_to_string(output_dir.join("spin.toml"))
803            .await
804            .unwrap();
805        assert!(spin_toml.contains("route = \"/...\""));
806    }
807
808    #[tokio::test]
809    async fn cannot_use_custom_filter_in_template() {
810        let manager = TempManager::new();
811
812        let install_results = manager.install_test_data_templates().await;
813
814        assert_eq!(1, install_results.skipped.len());
815
816        let (id, reason) = &install_results.skipped[0];
817        assert_eq!("testing-custom-filter", id);
818        let SkippedReason::InvalidManifest(message) = reason else {
819            panic!("skip reason should be InvalidManifest"); // clippy dislikes assert!(false...)
820        };
821        assert_contains(message, "filters");
822        assert_contains(message, "not supported");
823    }
824
825    #[tokio::test]
826    async fn can_add_component_from_template() {
827        let manager = TempManager::new_with_this_repo_templates().await;
828
829        let dest_temp_dir = tempdir().unwrap();
830        let application_dir = dest_temp_dir.path().join("multi");
831
832        // Set up the containing app
833        {
834            let template = manager.get("http-empty").unwrap().unwrap();
835
836            let options = run_options("my multi project", &application_dir, |opts| {
837                opts.values = make_values([("project-description", "my desc")])
838            });
839
840            template.run(options).silent().await.unwrap();
841        }
842
843        let spin_toml_path = application_dir.join("spin.toml");
844        assert!(spin_toml_path.exists(), "expected spin.toml to be created");
845
846        // Now add a component
847        {
848            let template = manager.get("http-rust").unwrap().unwrap();
849
850            let options = run_options("hello", "hello", |opts| {
851                opts.variant = add_component_to(&spin_toml_path);
852            });
853
854            template.run(options).silent().await.unwrap();
855        }
856
857        // And another
858        {
859            let template = manager.get("http-rust").unwrap().unwrap();
860
861            let options = run_options("hello 2", "encore", |opts| {
862                opts.variant = add_component_to(&spin_toml_path);
863            });
864
865            template.run(options).silent().await.unwrap();
866        }
867
868        let cargo1 = tokio::fs::read_to_string(application_dir.join("hello/Cargo.toml"))
869            .await
870            .unwrap();
871        assert!(cargo1.contains("name = \"hello\""));
872
873        let cargo2 = tokio::fs::read_to_string(application_dir.join("encore/Cargo.toml"))
874            .await
875            .unwrap();
876        assert!(cargo2.contains("name = \"hello-2\""));
877
878        let spin_toml = tokio::fs::read_to_string(&spin_toml_path).await.unwrap();
879        assert!(spin_toml.contains("source = \"hello/target/wasm32-wasip2/release/hello.wasm\""));
880        assert!(
881            spin_toml.contains("source = \"encore/target/wasm32-wasip2/release/hello_2.wasm\"")
882        );
883    }
884
885    #[tokio::test]
886    async fn can_add_variables_from_template() {
887        let manager = TempManager::new();
888        manager.install_test_data_templates().await;
889        manager.install_this_repo_templates().await; // We will need some of the standard templates too
890
891        let dest_temp_dir = tempdir().unwrap();
892        let application_dir = dest_temp_dir.path().join("spinvars");
893
894        // Set up the containing app
895        {
896            let template = manager.get("http-rust").unwrap().unwrap();
897
898            let options = run_options("my various project", &application_dir, |_| {});
899
900            template.run(options).silent().await.unwrap();
901        }
902
903        let spin_toml_path = application_dir.join("spin.toml");
904        assert!(spin_toml_path.exists(), "expected spin.toml to be created");
905
906        // Now add the variables
907        {
908            let template = manager.get("add-variables").unwrap().unwrap();
909
910            let options = run_options("insertvars", "hello", |opts| {
911                opts.variant = add_component_to(&spin_toml_path);
912                opts.values = make_values([("service-url", "https://service.example.com")])
913            });
914
915            template.run(options).silent().await.unwrap();
916        }
917
918        let spin_toml = tokio::fs::read_to_string(&spin_toml_path).await.unwrap();
919
920        assert!(spin_toml.contains("[variables]\nsecret"));
921        assert!(spin_toml.contains("url = { default = \"https://service.example.com\" }"));
922
923        assert!(spin_toml.contains("[component.insertvars]"));
924        assert!(spin_toml.contains("[component.insertvars.variables]"));
925        assert!(spin_toml.contains("kv_credentials = \"{{ secret }}\""));
926    }
927
928    #[tokio::test]
929    async fn can_overwrite_existing_variables() {
930        let manager = TempManager::new();
931        manager.install_test_data_templates().await;
932        manager.install_this_repo_templates().await; // We will need some of the standard templates too
933
934        let dest_temp_dir = tempdir().unwrap();
935        let application_dir = dest_temp_dir.path().join("spinvars");
936
937        // Set up the containing app
938        {
939            let template = manager.get("http-rust").unwrap().unwrap();
940
941            let options = run_options("my various project", &application_dir, |_| {});
942
943            template.run(options).silent().await.unwrap();
944        }
945
946        let spin_toml_path = application_dir.join("spin.toml");
947        assert!(spin_toml_path.exists(), "expected spin.toml to be created");
948
949        // Now add the variables
950        {
951            let template = manager.get("add-variables").unwrap().unwrap();
952
953            let options = run_options("insertvars", "hello", |opts| {
954                opts.variant = add_component_to(&spin_toml_path);
955                opts.values = make_values([("service-url", "https://service.example.com")]);
956            });
957
958            template.run(options).silent().await.unwrap();
959        }
960
961        // Now add them again but with different values
962        {
963            let template = manager.get("add-variables").unwrap().unwrap();
964
965            let options = run_options("insertvarsagain", "hello", |opts| {
966                opts.variant = add_component_to(&spin_toml_path);
967                opts.values = make_values([("service-url", "https://other.example.com")]);
968            });
969
970            template.run(options).silent().await.unwrap();
971        }
972
973        let spin_toml = tokio::fs::read_to_string(&spin_toml_path).await.unwrap();
974        assert!(spin_toml.contains("url = { default = \"https://other.example.com\" }"));
975        assert!(!spin_toml.contains("service.example.com"));
976    }
977
978    #[tokio::test]
979    async fn component_new_no_vcs() {
980        let manager = TempManager::new_with_this_repo_templates().await;
981
982        let dest_temp_dir = tempdir().unwrap();
983        let application_dir = dest_temp_dir.path().join("no-vcs-new");
984
985        let template = manager.get("http-rust").unwrap().unwrap();
986
987        let options = run_options("no vcs new", &application_dir, |opts| {
988            opts.no_vcs = true;
989        });
990
991        template.run(options).silent().await.unwrap();
992        let gitignore = application_dir.join(".gitignore");
993        assert!(
994            !gitignore.exists(),
995            "expected .gitignore to not have been created"
996        );
997    }
998
999    #[tokio::test]
1000    async fn component_add_no_vcs() {
1001        let manager = TempManager::new_with_this_repo_templates().await;
1002
1003        let dest_temp_dir = tempdir().unwrap();
1004        let application_dir = dest_temp_dir.path().join("no-vcs-add");
1005
1006        // Set up the containing app
1007        {
1008            let template = manager.get("http-empty").unwrap().unwrap();
1009
1010            let options = run_options("my multi project", &application_dir, |opts| {
1011                opts.values = make_values([("project-description", "my desc")]);
1012                opts.no_vcs = true;
1013            });
1014
1015            template.run(options).silent().await.unwrap();
1016        }
1017
1018        let gitignore = application_dir.join(".gitignore");
1019        let spin_toml_path = application_dir.join("spin.toml");
1020        assert!(
1021            !gitignore.exists(),
1022            "expected .gitignore to not have been created"
1023        );
1024
1025        {
1026            let template = manager.get("http-rust").unwrap().unwrap();
1027
1028            let options = run_options("added_component", "hello", |opts| {
1029                opts.variant = add_component_to(&spin_toml_path);
1030                opts.no_vcs = true;
1031            });
1032            template.run(options).silent().await.unwrap();
1033        }
1034
1035        let gitignore_add = application_dir.join("hello").join(".gitignore");
1036        assert!(
1037            !gitignore_add.exists(),
1038            "expected .gitignore to not have been created"
1039        );
1040    }
1041
1042    #[tokio::test]
1043    async fn can_add_component_with_different_trigger() {
1044        let manager = TempManager::new_with_this_repo_templates().await;
1045
1046        let dest_temp_dir = tempdir().unwrap();
1047        let application_dir = dest_temp_dir.path().join("multi");
1048
1049        // Set up the containing app
1050        {
1051            let template = manager.get("redis-rust").unwrap().unwrap();
1052
1053            let options = run_options("my multi project", &application_dir, |opts| {
1054                opts.values = make_values([
1055                    ("project-description", "my desc"),
1056                    ("redis-address", "redis://localhost:6379"),
1057                    ("redis-channel", "the-horrible-knuckles"),
1058                ])
1059            });
1060
1061            template.run(options).silent().await.unwrap();
1062        }
1063
1064        let spin_toml_path = application_dir.join("spin.toml");
1065        assert!(spin_toml_path.exists(), "expected spin.toml to be created");
1066
1067        // Now add a component
1068        {
1069            let template = manager.get("http-rust").unwrap().unwrap();
1070
1071            let options = run_options("hello", "hello", |opts| {
1072                opts.variant = add_component_to(&spin_toml_path);
1073            });
1074
1075            template.run(options).silent().await.unwrap();
1076        }
1077
1078        let spin_toml = tokio::fs::read_to_string(&spin_toml_path).await.unwrap();
1079        assert!(spin_toml.contains("[[trigger.redis]]"));
1080        assert!(spin_toml.contains("[[trigger.http]]"));
1081        assert!(spin_toml.contains("[component.my-multi-project]"));
1082        assert!(spin_toml.contains("[component.hello]"));
1083    }
1084
1085    #[tokio::test]
1086    async fn cannot_add_component_that_does_not_match_manifest() {
1087        let manager = TempManager::new_with_this_repo_templates().await;
1088
1089        let dest_temp_dir = tempdir().unwrap();
1090        let application_dir = dest_temp_dir.path().join("multi");
1091
1092        // Set up the containing app
1093        {
1094            let fake_v1_src = test_data_root().join("v1manifest.toml");
1095            let fake_v1_dest = application_dir.join("spin.toml");
1096            tokio::fs::create_dir_all(&application_dir).await.unwrap();
1097            tokio::fs::copy(fake_v1_src, fake_v1_dest).await.unwrap();
1098        }
1099
1100        let spin_toml_path = application_dir.join("spin.toml");
1101        assert!(
1102            spin_toml_path.exists(),
1103            "expected v1 spin.toml to be created"
1104        );
1105
1106        // Now add a component
1107        {
1108            let template = manager.get("http-rust").unwrap().unwrap();
1109
1110            let options = run_options("hello", "hello", |opts| {
1111                opts.variant = add_component_to(&spin_toml_path);
1112            });
1113
1114            template
1115                .run(options)
1116                .silent()
1117                .await
1118                .expect_err("Expected to fail to add component, but it succeeded");
1119        }
1120    }
1121
1122    #[tokio::test]
1123    async fn cannot_generate_over_existing_files_by_default() {
1124        let manager = TempManager::new_with_this_repo_templates().await;
1125
1126        let template = manager.get("http-rust").unwrap().unwrap();
1127
1128        let dest_temp_dir = tempdir().unwrap();
1129        let output_dir = dest_temp_dir.path().join("myproj");
1130
1131        tokio::fs::create_dir_all(&output_dir).await.unwrap();
1132        let manifest_path = output_dir.join("spin.toml");
1133        tokio::fs::write(&manifest_path, "cookies").await.unwrap();
1134
1135        let options = run_options("my project", &output_dir, |_| {});
1136
1137        template
1138            .run(options)
1139            .silent()
1140            .await
1141            .expect_err("generate into existing dir should have failed");
1142
1143        assert!(
1144            tokio::fs::read_to_string(&manifest_path)
1145                .await
1146                .unwrap()
1147                .contains("cookies")
1148        );
1149    }
1150
1151    #[tokio::test]
1152    async fn can_generate_over_existing_files_if_so_configured() {
1153        let manager = TempManager::new_with_this_repo_templates().await;
1154
1155        let template = manager.get("http-rust").unwrap().unwrap();
1156
1157        let dest_temp_dir = tempdir().unwrap();
1158        let output_dir = dest_temp_dir.path().join("myproj");
1159
1160        tokio::fs::create_dir_all(&output_dir).await.unwrap();
1161        let manifest_path = output_dir.join("spin.toml");
1162        tokio::fs::write(&manifest_path, "cookies").await.unwrap();
1163
1164        let options = run_options("my project", &output_dir, |opts| {
1165            opts.allow_overwrite = true;
1166        });
1167
1168        template
1169            .run(options)
1170            .silent()
1171            .await
1172            .expect("generate into existing dir should have succeeded");
1173
1174        assert!(
1175            tokio::fs::read_to_string(&manifest_path)
1176                .await
1177                .unwrap()
1178                .contains("[[trigger.http]]")
1179        );
1180    }
1181
1182    #[tokio::test]
1183    async fn cannot_new_a_component_only_template() {
1184        let manager = TempManager::new();
1185        manager.install_test_data_templates().await;
1186        manager.install_this_repo_templates().await;
1187
1188        let dummy_dir = manager.store_dir().join("dummy");
1189        let manifest_path = dummy_dir.join("ignored_spin.toml");
1190        let add_component = TemplateVariantInfo::AddComponent { manifest_path };
1191
1192        let redirect = manager.get("add-only-redirect").unwrap().unwrap();
1193        assert!(!redirect.supports_variant(&TemplateVariantInfo::NewApplication));
1194        assert!(redirect.supports_variant(&add_component));
1195
1196        let http_rust = manager.get("http-rust").unwrap().unwrap();
1197        assert!(http_rust.supports_variant(&TemplateVariantInfo::NewApplication));
1198        assert!(http_rust.supports_variant(&add_component));
1199
1200        let http_empty = manager.get("http-empty").unwrap().unwrap();
1201        assert!(http_empty.supports_variant(&TemplateVariantInfo::NewApplication));
1202        assert!(!http_empty.supports_variant(&add_component));
1203    }
1204
1205    #[tokio::test]
1206    async fn can_render_partials() {
1207        let manager = TempManager::new();
1208        manager.install_test_data_templates().await;
1209
1210        let partials_template = manager.get("partials").unwrap().unwrap();
1211
1212        let dest_temp_dir = tempdir().unwrap();
1213        let output_dir = dest_temp_dir.path().join("myproj");
1214        let options = run_options("my project", &output_dir, |opts| {
1215            opts.values = [("example-value".to_owned(), "myvalue".to_owned())]
1216                .into_iter()
1217                .collect();
1218        });
1219
1220        partials_template.run(options).silent().await.unwrap();
1221
1222        let generated_file = output_dir.join("test.txt");
1223        let generated_text = std::fs::read_to_string(&generated_file).unwrap();
1224        let generated_lines = generated_text.lines().collect::<Vec<_>>();
1225
1226        assert_eq!("Value: myvalue", generated_lines[0]);
1227        assert_eq!("Partial 1: Hello from P1", generated_lines[1]);
1228        assert_eq!("Partial 2: Value is myvalue", generated_lines[2]);
1229    }
1230
1231    #[tokio::test]
1232    async fn fails_on_unknown_filter() {
1233        let manager = TempManager::new();
1234        manager.install_test_data_templates().await;
1235
1236        let template = manager.get("bad-non-existent-filter").unwrap().unwrap();
1237
1238        let dest_temp_dir = tempdir().unwrap();
1239        let output_dir = dest_temp_dir.path().join("myproj");
1240        let options = run_options("bad-filter-should-fail", &output_dir, move |opts| {
1241            opts.values = make_values([("p1", "biscuits")]);
1242        });
1243
1244        let err = template
1245            .run(options)
1246            .silent()
1247            .await
1248            .expect_err("Expected template to fail but it passed");
1249
1250        let err_str = err.to_string();
1251
1252        assert_contains(&err_str, "internal error");
1253        assert_contains(&err_str, "unknown filter 'lol_snort'");
1254    }
1255
1256    fn assert_contains(actual: &str, expected: &str) {
1257        assert!(
1258            actual.contains(expected),
1259            "expected string containing '{expected}' but got '{actual}'"
1260        );
1261    }
1262}