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
11pub struct TemplateManager {
14 store: TemplateStore,
15}
16
17pub trait ProgressReporter {
20 fn report(&self, message: impl AsRef<str>);
22}
23
24#[derive(Debug)]
26pub struct InstallOptions {
27 exists_behaviour: ExistsBehaviour,
28}
29
30impl InstallOptions {
31 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)] enum InstallationResult {
61 Installed(Template),
62 Skipped(String, SkippedReason),
63}
64
65pub enum SkippedReason {
67 AlreadyExists,
69 InvalidManifest(String),
71 CouldNotRemove,
73}
74
75pub struct InstallationResults {
77 pub installed: Vec<Template>,
79 pub skipped: Vec<(String, SkippedReason)>,
81 pub removed: Vec<String>,
83}
84
85#[derive(Debug)]
87pub struct ListResults {
88 pub templates: Vec<Template>,
90 pub warnings: Vec<(String, InstalledTemplateWarning)>,
92 pub skipped: Vec<Template>,
94}
95
96impl ListResults {
97 pub fn needs_install(&self) -> bool {
101 self.templates.is_empty() && self.skipped.is_empty() && std::io::stderr().is_terminal()
102 }
103}
104
105#[derive(Debug)]
107pub enum InstalledTemplateWarning {
108 InvalidManifest(String),
110}
111
112impl TemplateManager {
113 pub fn try_default() -> anyhow::Result<Self> {
115 let store = TemplateStore::try_default()?;
116 Ok(Self::new(store))
117 }
118
119 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 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 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 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 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 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 let stage_dir = dest_dir.with_extension(".stage");
330 let unstage_dir = dest_dir.with_extension(".unstage");
332
333 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 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 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 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 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 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, ©_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 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 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 }
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 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 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 ©_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"); };
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 {
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 {
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 {
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; let dest_temp_dir = tempdir().unwrap();
941 let application_dir = dest_temp_dir.path().join("spinvars");
942
943 {
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 {
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; let dest_temp_dir = tempdir().unwrap();
984 let application_dir = dest_temp_dir.path().join("spinvars");
985
986 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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}