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