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-wasip1/release/hello.wasm\""));
880        assert!(spin_toml.contains("source = \"encore/target/wasm32-wasip1/release/hello_2.wasm\""));
881    }
882
883    #[tokio::test]
884    async fn can_add_variables_from_template() {
885        let manager = TempManager::new();
886        manager.install_test_data_templates().await;
887        manager.install_this_repo_templates().await; // We will need some of the standard templates too
888
889        let dest_temp_dir = tempdir().unwrap();
890        let application_dir = dest_temp_dir.path().join("spinvars");
891
892        // Set up the containing app
893        {
894            let template = manager.get("http-rust").unwrap().unwrap();
895
896            let options = run_options("my various project", &application_dir, |_| {});
897
898            template.run(options).silent().await.unwrap();
899        }
900
901        let spin_toml_path = application_dir.join("spin.toml");
902        assert!(spin_toml_path.exists(), "expected spin.toml to be created");
903
904        // Now add the variables
905        {
906            let template = manager.get("add-variables").unwrap().unwrap();
907
908            let options = run_options("insertvars", "hello", |opts| {
909                opts.variant = add_component_to(&spin_toml_path);
910                opts.values = make_values([("service-url", "https://service.example.com")])
911            });
912
913            template.run(options).silent().await.unwrap();
914        }
915
916        let spin_toml = tokio::fs::read_to_string(&spin_toml_path).await.unwrap();
917
918        assert!(spin_toml.contains("[variables]\nsecret"));
919        assert!(spin_toml.contains("url = { default = \"https://service.example.com\" }"));
920
921        assert!(spin_toml.contains("[component.insertvars]"));
922        assert!(spin_toml.contains("[component.insertvars.variables]"));
923        assert!(spin_toml.contains("kv_credentials = \"{{ secret }}\""));
924    }
925
926    #[tokio::test]
927    async fn can_overwrite_existing_variables() {
928        let manager = TempManager::new();
929        manager.install_test_data_templates().await;
930        manager.install_this_repo_templates().await; // We will need some of the standard templates too
931
932        let dest_temp_dir = tempdir().unwrap();
933        let application_dir = dest_temp_dir.path().join("spinvars");
934
935        // Set up the containing app
936        {
937            let template = manager.get("http-rust").unwrap().unwrap();
938
939            let options = run_options("my various project", &application_dir, |_| {});
940
941            template.run(options).silent().await.unwrap();
942        }
943
944        let spin_toml_path = application_dir.join("spin.toml");
945        assert!(spin_toml_path.exists(), "expected spin.toml to be created");
946
947        // Now add the variables
948        {
949            let template = manager.get("add-variables").unwrap().unwrap();
950
951            let options = run_options("insertvars", "hello", |opts| {
952                opts.variant = add_component_to(&spin_toml_path);
953                opts.values = make_values([("service-url", "https://service.example.com")]);
954            });
955
956            template.run(options).silent().await.unwrap();
957        }
958
959        // Now add them again but with different values
960        {
961            let template = manager.get("add-variables").unwrap().unwrap();
962
963            let options = run_options("insertvarsagain", "hello", |opts| {
964                opts.variant = add_component_to(&spin_toml_path);
965                opts.values = make_values([("service-url", "https://other.example.com")]);
966            });
967
968            template.run(options).silent().await.unwrap();
969        }
970
971        let spin_toml = tokio::fs::read_to_string(&spin_toml_path).await.unwrap();
972        assert!(spin_toml.contains("url = { default = \"https://other.example.com\" }"));
973        assert!(!spin_toml.contains("service.example.com"));
974    }
975
976    #[tokio::test]
977    async fn component_new_no_vcs() {
978        let manager = TempManager::new_with_this_repo_templates().await;
979
980        let dest_temp_dir = tempdir().unwrap();
981        let application_dir = dest_temp_dir.path().join("no-vcs-new");
982
983        let template = manager.get("http-rust").unwrap().unwrap();
984
985        let options = run_options("no vcs new", &application_dir, |opts| {
986            opts.no_vcs = true;
987        });
988
989        template.run(options).silent().await.unwrap();
990        let gitignore = application_dir.join(".gitignore");
991        assert!(
992            !gitignore.exists(),
993            "expected .gitignore to not have been created"
994        );
995    }
996
997    #[tokio::test]
998    async fn component_add_no_vcs() {
999        let manager = TempManager::new_with_this_repo_templates().await;
1000
1001        let dest_temp_dir = tempdir().unwrap();
1002        let application_dir = dest_temp_dir.path().join("no-vcs-add");
1003
1004        // Set up the containing app
1005        {
1006            let template = manager.get("http-empty").unwrap().unwrap();
1007
1008            let options = run_options("my multi project", &application_dir, |opts| {
1009                opts.values = make_values([("project-description", "my desc")]);
1010                opts.no_vcs = true;
1011            });
1012
1013            template.run(options).silent().await.unwrap();
1014        }
1015
1016        let gitignore = application_dir.join(".gitignore");
1017        let spin_toml_path = application_dir.join("spin.toml");
1018        assert!(
1019            !gitignore.exists(),
1020            "expected .gitignore to not have been created"
1021        );
1022
1023        {
1024            let template = manager.get("http-rust").unwrap().unwrap();
1025
1026            let options = run_options("added_component", "hello", |opts| {
1027                opts.variant = add_component_to(&spin_toml_path);
1028                opts.no_vcs = true;
1029            });
1030            template.run(options).silent().await.unwrap();
1031        }
1032
1033        let gitignore_add = application_dir.join("hello").join(".gitignore");
1034        assert!(
1035            !gitignore_add.exists(),
1036            "expected .gitignore to not have been created"
1037        );
1038    }
1039
1040    #[tokio::test]
1041    async fn can_add_component_with_different_trigger() {
1042        let manager = TempManager::new_with_this_repo_templates().await;
1043
1044        let dest_temp_dir = tempdir().unwrap();
1045        let application_dir = dest_temp_dir.path().join("multi");
1046
1047        // Set up the containing app
1048        {
1049            let template = manager.get("redis-rust").unwrap().unwrap();
1050
1051            let options = run_options("my multi project", &application_dir, |opts| {
1052                opts.values = make_values([
1053                    ("project-description", "my desc"),
1054                    ("redis-address", "redis://localhost:6379"),
1055                    ("redis-channel", "the-horrible-knuckles"),
1056                ])
1057            });
1058
1059            template.run(options).silent().await.unwrap();
1060        }
1061
1062        let spin_toml_path = application_dir.join("spin.toml");
1063        assert!(spin_toml_path.exists(), "expected spin.toml to be created");
1064
1065        // Now add a component
1066        {
1067            let template = manager.get("http-rust").unwrap().unwrap();
1068
1069            let options = run_options("hello", "hello", |opts| {
1070                opts.variant = add_component_to(&spin_toml_path);
1071            });
1072
1073            template.run(options).silent().await.unwrap();
1074        }
1075
1076        let spin_toml = tokio::fs::read_to_string(&spin_toml_path).await.unwrap();
1077        assert!(spin_toml.contains("[[trigger.redis]]"));
1078        assert!(spin_toml.contains("[[trigger.http]]"));
1079        assert!(spin_toml.contains("[component.my-multi-project]"));
1080        assert!(spin_toml.contains("[component.hello]"));
1081    }
1082
1083    #[tokio::test]
1084    async fn cannot_add_component_that_does_not_match_manifest() {
1085        let manager = TempManager::new_with_this_repo_templates().await;
1086
1087        let dest_temp_dir = tempdir().unwrap();
1088        let application_dir = dest_temp_dir.path().join("multi");
1089
1090        // Set up the containing app
1091        {
1092            let fake_v1_src = test_data_root().join("v1manifest.toml");
1093            let fake_v1_dest = application_dir.join("spin.toml");
1094            tokio::fs::create_dir_all(&application_dir).await.unwrap();
1095            tokio::fs::copy(fake_v1_src, fake_v1_dest).await.unwrap();
1096        }
1097
1098        let spin_toml_path = application_dir.join("spin.toml");
1099        assert!(
1100            spin_toml_path.exists(),
1101            "expected v1 spin.toml to be created"
1102        );
1103
1104        // Now add a component
1105        {
1106            let template = manager.get("http-rust").unwrap().unwrap();
1107
1108            let options = run_options("hello", "hello", |opts| {
1109                opts.variant = add_component_to(&spin_toml_path);
1110            });
1111
1112            template
1113                .run(options)
1114                .silent()
1115                .await
1116                .expect_err("Expected to fail to add component, but it succeeded");
1117        }
1118    }
1119
1120    #[tokio::test]
1121    async fn cannot_generate_over_existing_files_by_default() {
1122        let manager = TempManager::new_with_this_repo_templates().await;
1123
1124        let template = manager.get("http-rust").unwrap().unwrap();
1125
1126        let dest_temp_dir = tempdir().unwrap();
1127        let output_dir = dest_temp_dir.path().join("myproj");
1128
1129        tokio::fs::create_dir_all(&output_dir).await.unwrap();
1130        let manifest_path = output_dir.join("spin.toml");
1131        tokio::fs::write(&manifest_path, "cookies").await.unwrap();
1132
1133        let options = run_options("my project", &output_dir, |_| {});
1134
1135        template
1136            .run(options)
1137            .silent()
1138            .await
1139            .expect_err("generate into existing dir should have failed");
1140
1141        assert!(tokio::fs::read_to_string(&manifest_path)
1142            .await
1143            .unwrap()
1144            .contains("cookies"));
1145    }
1146
1147    #[tokio::test]
1148    async fn can_generate_over_existing_files_if_so_configured() {
1149        let manager = TempManager::new_with_this_repo_templates().await;
1150
1151        let template = manager.get("http-rust").unwrap().unwrap();
1152
1153        let dest_temp_dir = tempdir().unwrap();
1154        let output_dir = dest_temp_dir.path().join("myproj");
1155
1156        tokio::fs::create_dir_all(&output_dir).await.unwrap();
1157        let manifest_path = output_dir.join("spin.toml");
1158        tokio::fs::write(&manifest_path, "cookies").await.unwrap();
1159
1160        let options = run_options("my project", &output_dir, |opts| {
1161            opts.allow_overwrite = true;
1162        });
1163
1164        template
1165            .run(options)
1166            .silent()
1167            .await
1168            .expect("generate into existing dir should have succeeded");
1169
1170        assert!(tokio::fs::read_to_string(&manifest_path)
1171            .await
1172            .unwrap()
1173            .contains("[[trigger.http]]"));
1174    }
1175
1176    #[tokio::test]
1177    async fn cannot_new_a_component_only_template() {
1178        let manager = TempManager::new();
1179        manager.install_test_data_templates().await;
1180        manager.install_this_repo_templates().await;
1181
1182        let dummy_dir = manager.store_dir().join("dummy");
1183        let manifest_path = dummy_dir.join("ignored_spin.toml");
1184        let add_component = TemplateVariantInfo::AddComponent { manifest_path };
1185
1186        let redirect = manager.get("add-only-redirect").unwrap().unwrap();
1187        assert!(!redirect.supports_variant(&TemplateVariantInfo::NewApplication));
1188        assert!(redirect.supports_variant(&add_component));
1189
1190        let http_rust = manager.get("http-rust").unwrap().unwrap();
1191        assert!(http_rust.supports_variant(&TemplateVariantInfo::NewApplication));
1192        assert!(http_rust.supports_variant(&add_component));
1193
1194        let http_empty = manager.get("http-empty").unwrap().unwrap();
1195        assert!(http_empty.supports_variant(&TemplateVariantInfo::NewApplication));
1196        assert!(!http_empty.supports_variant(&add_component));
1197    }
1198
1199    #[tokio::test]
1200    async fn can_render_partials() {
1201        let manager = TempManager::new();
1202        manager.install_test_data_templates().await;
1203
1204        let partials_template = manager.get("partials").unwrap().unwrap();
1205
1206        let dest_temp_dir = tempdir().unwrap();
1207        let output_dir = dest_temp_dir.path().join("myproj");
1208        let options = run_options("my project", &output_dir, |opts| {
1209            opts.values = [("example-value".to_owned(), "myvalue".to_owned())]
1210                .into_iter()
1211                .collect();
1212        });
1213
1214        partials_template.run(options).silent().await.unwrap();
1215
1216        let generated_file = output_dir.join("test.txt");
1217        let generated_text = std::fs::read_to_string(&generated_file).unwrap();
1218        let generated_lines = generated_text.lines().collect::<Vec<_>>();
1219
1220        assert_eq!("Value: myvalue", generated_lines[0]);
1221        assert_eq!("Partial 1: Hello from P1", generated_lines[1]);
1222        assert_eq!("Partial 2: Value is myvalue", generated_lines[2]);
1223    }
1224
1225    #[tokio::test]
1226    async fn fails_on_unknown_filter() {
1227        let manager = TempManager::new();
1228        manager.install_test_data_templates().await;
1229
1230        let template = manager.get("bad-non-existent-filter").unwrap().unwrap();
1231
1232        let dest_temp_dir = tempdir().unwrap();
1233        let output_dir = dest_temp_dir.path().join("myproj");
1234        let options = run_options("bad-filter-should-fail", &output_dir, move |opts| {
1235            opts.values = make_values([("p1", "biscuits")]);
1236        });
1237
1238        let err = template
1239            .run(options)
1240            .silent()
1241            .await
1242            .expect_err("Expected template to fail but it passed");
1243
1244        let err_str = err.to_string();
1245
1246        assert_contains(&err_str, "internal error");
1247        assert_contains(&err_str, "unknown filter 'lol_snort'");
1248    }
1249
1250    fn assert_contains(actual: &str, expected: &str) {
1251        assert!(
1252            actual.contains(expected),
1253            "expected string containing '{expected}' but got '{actual}'"
1254        );
1255    }
1256}