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