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 skip_if_all_match: bool,
29}
30
31impl InstallOptions {
32 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 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)] enum InstallationResult {
74 Installed(Template),
75 Skipped(String, SkippedReason),
76}
77
78pub enum SkippedReason {
80 AlreadyExists,
82 InvalidManifest(String),
84 CouldNotRemove,
86}
87
88pub struct InstallationResults {
90 pub installed: Vec<Template>,
92 pub skipped: Vec<(String, SkippedReason)>,
94 pub removed: Vec<String>,
96}
97
98#[derive(Debug)]
100pub struct ListResults {
101 pub templates: Vec<Template>,
103 pub warnings: Vec<(String, InstalledTemplateWarning)>,
105 pub skipped: Vec<Template>,
107}
108
109impl ListResults {
110 pub fn needs_install(&self) -> bool {
114 self.templates.is_empty() && self.skipped.is_empty() && std::io::stderr().is_terminal()
115 }
116}
117
118#[derive(Debug)]
120pub enum InstalledTemplateWarning {
121 InvalidManifest(String),
123}
124
125impl TemplateManager {
126 pub fn try_default() -> anyhow::Result<Self> {
128 let store = TemplateStore::try_default()?;
129 Ok(Self::new(store))
130 }
131
132 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 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(); 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 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 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 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 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 return false;
351 }
352
353 let (expected_repo, expected_tag) = match source {
354 TemplateSource::Git(g) => (g.repo(), g.branch()),
355 _ => return false, };
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 let stage_dir = dest_dir.with_extension(".stage");
372 let unstage_dir = dest_dir.with_extension(".unstage");
374
375 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 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 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 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 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 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, ©_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 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 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 }
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 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 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 ©_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"); };
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 {
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 {
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 {
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; let dest_temp_dir = tempdir().unwrap();
983 let application_dir = dest_temp_dir.path().join("spinvars");
984
985 {
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 {
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; let dest_temp_dir = tempdir().unwrap();
1026 let application_dir = dest_temp_dir.path().join("spinvars");
1027
1028 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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}