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