1use crate::model::{
16 ComposableAppGroupName, GuestLanguage, PackageName, TargetExistsResolveDecision,
17 TargetExistsResolveMode, Template, TemplateKind, TemplateMetadata, TemplateName,
18 TemplateParameters,
19};
20use anyhow::Context;
21use include_dir::{include_dir, Dir, DirEntry};
22use indoc::indoc;
23use itertools::Itertools;
24use std::borrow::Cow;
25use std::collections::{BTreeMap, BTreeSet};
26use std::path::{Path, PathBuf};
27use std::{fs, io};
28
29pub mod model;
30
31#[cfg(test)]
32test_r::enable!();
33
34static TEMPLATES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
35static WIT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/wit/deps");
36
37static APP_MANIFEST_HEADER: &str = indoc! {"
38# Schema for IDEA:
39# $schema: https://schema.golem.cloud/app/golem/1.3.0/golem.schema.json
40# Schema for vscode-yaml:
41# yaml-language-server: $schema=https://schema.golem.cloud/app/golem/1.3.0/golem.schema.json
42
43# Field reference: https://learn.golem.cloud/app-manifest#field-reference
44# Creating HTTP APIs: https://learn.golem.cloud/invoke/making-custom-apis
45"};
46
47static APP_MANIFEST_COMPONENT_HINTS_TEMPLATE: &str = indoc! {""};
48
49fn all_templates(dev_mode: bool) -> Vec<Template> {
50 let mut result: Vec<Template> = vec![];
51 for entry in TEMPLATES.entries() {
52 if let Some(lang_dir) = entry.as_dir() {
53 let lang_dir_name = lang_dir.path().file_name().unwrap().to_str().unwrap();
54 if let Some(lang) = GuestLanguage::from_string(lang_dir_name) {
55 for sub_entry in lang_dir.entries() {
56 if let Some(template_dir) = sub_entry.as_dir() {
57 let template_dir_name =
58 template_dir.path().file_name().unwrap().to_str().unwrap();
59 if template_dir_name != "INSTRUCTIONS"
60 && !template_dir_name.starts_with('.')
61 {
62 let template = parse_template(
63 lang,
64 lang_dir.path(),
65 Path::new("INSTRUCTIONS"),
66 template_dir.path(),
67 );
68
69 if dev_mode || !template.dev_only {
70 result.push(template);
71 }
72 }
73 }
74 }
75 } else {
76 panic!("Invalid guest language name: {lang_dir_name}");
77 }
78 }
79 }
80 result
81}
82
83pub fn all_standalone_templates() -> Vec<Template> {
84 all_templates(true)
85 .into_iter()
86 .filter(|template| matches!(template.kind, TemplateKind::Standalone))
87 .collect()
88}
89
90#[derive(Debug, Default)]
91pub struct ComposableAppTemplate {
92 pub common: Option<Template>,
93 pub components: BTreeMap<TemplateName, Template>,
94}
95
96pub fn all_composable_app_templates(
97 dev_mode: bool,
98) -> BTreeMap<GuestLanguage, BTreeMap<ComposableAppGroupName, ComposableAppTemplate>> {
99 let mut templates =
100 BTreeMap::<GuestLanguage, BTreeMap<ComposableAppGroupName, ComposableAppTemplate>>::new();
101
102 fn app_templates<'a>(
103 templates: &'a mut BTreeMap<
104 GuestLanguage,
105 BTreeMap<ComposableAppGroupName, ComposableAppTemplate>,
106 >,
107 language: GuestLanguage,
108 group: &ComposableAppGroupName,
109 ) -> &'a mut ComposableAppTemplate {
110 let groups = templates.entry(language).or_default();
111 if !groups.contains_key(group) {
112 groups.insert(group.clone(), ComposableAppTemplate::default());
113 }
114 groups.get_mut(group).unwrap()
115 }
116
117 for template in all_templates(dev_mode) {
118 match &template.kind {
119 TemplateKind::Standalone => continue,
120 TemplateKind::ComposableAppCommon { group, .. } => {
121 let common = &mut app_templates(&mut templates, template.language, group).common;
122 if let Some(common) = common {
123 panic!(
124 "Multiple common templates were found for {} - {}, template paths: {}, {}",
125 template.language,
126 group,
127 common.template_path.display(),
128 template.template_path.display()
129 );
130 }
131 *common = Some(template);
132 }
133 TemplateKind::ComposableAppComponent { group } => {
134 app_templates(&mut templates, template.language, group)
135 .components
136 .insert(template.name.clone(), template);
137 }
138 }
139 }
140
141 templates
142}
143
144pub fn instantiate_template(
145 template: &Template,
146 parameters: &TemplateParameters,
147 resolve_mode: TargetExistsResolveMode,
148) -> io::Result<String> {
149 instantiate_directory(
150 &TEMPLATES,
151 &template.template_path,
152 ¶meters.target_path,
153 template,
154 parameters,
155 resolve_mode,
156 )?;
157 let wit_deps_targets = {
158 match &template.wit_deps_targets {
159 Some(paths) => paths
160 .iter()
161 .map(|path| parameters.target_path.join(path))
162 .collect(),
163 None => vec![parameters.target_path.join("wit").join("deps")],
164 }
165 };
166 for wit_dep in &template.wit_deps {
167 for target_wit_deps in &wit_deps_targets {
168 let name = wit_dep.file_name().unwrap().to_str().unwrap();
169 let target = target_wit_deps.join(name);
170 copy_all(&WIT, wit_dep, &target, TargetExistsResolveMode::MergeOrSkip)?;
171 }
172 }
173 Ok(render_template_instructions(template, parameters))
174}
175
176pub fn add_component_by_template(
177 common_template: Option<&Template>,
178 component_template: Option<&Template>,
179 target_path: &Path,
180 package_name: &PackageName,
181) -> anyhow::Result<()> {
182 let parameters = TemplateParameters {
183 component_name: package_name.to_string_with_colon().into(),
184 package_name: package_name.clone(),
185 target_path: target_path.into(),
186 };
187
188 if let Some(common_template) = common_template {
189 let skip = {
190 if let TemplateKind::ComposableAppCommon {
191 skip_if_exists: Some(file),
192 ..
193 } = &common_template.kind
194 {
195 target_path.join(file).exists()
196 } else {
197 false
198 }
199 };
200
201 if !skip {
202 instantiate_template(
203 common_template,
204 ¶meters,
205 TargetExistsResolveMode::MergeOrSkip,
206 )
207 .context(format!(
208 "Instantiating common template {}",
209 common_template.name
210 ))?;
211 }
212 }
213
214 if let Some(component_template) = component_template {
215 instantiate_template(
216 component_template,
217 ¶meters,
218 TargetExistsResolveMode::MergeOrFail,
219 )
220 .context(format!(
221 "Instantiating component template {}",
222 component_template.name
223 ))?;
224 }
225
226 Ok(())
227}
228
229pub fn render_template_instructions(
230 template: &Template,
231 parameters: &TemplateParameters,
232) -> String {
233 transform(
234 &template.instructions,
235 parameters,
236 TransformMode::PackageAndComponentOnly,
237 )
238}
239
240fn instantiate_directory(
241 catalog: &Dir<'_>,
242 source: &Path,
243 target: &Path,
244 template: &Template,
245 parameters: &TemplateParameters,
246 resolve_mode: TargetExistsResolveMode,
247) -> io::Result<()> {
248 fs::create_dir_all(target)?;
249 for entry in catalog
250 .get_dir(source)
251 .unwrap_or_else(|| panic!("Could not find entry {source:?}"))
252 .entries()
253 {
254 let name = entry.path().file_name().unwrap().to_str().unwrap();
255 if !template.exclude.contains(name) && (name != "metadata.json") {
256 let name = file_name_transform(name, parameters);
257 match entry {
258 DirEntry::Dir(dir) => {
259 instantiate_directory(
260 catalog,
261 dir.path(),
262 &target.join(&name),
263 template,
264 parameters,
265 resolve_mode,
266 )?;
267 }
268 DirEntry::File(file) => {
269 let transform = if file
272 .path()
273 .file_name()
274 .unwrap_or_default()
275 .to_string_lossy()
276 == "golem.yaml"
277 {
278 if template.kind.is_common() {
279 Some(TransformMode::ManifestHintsOnly)
280 } else {
281 Some(TransformMode::All)
282 }
283 } else {
284 (template.transform && !template.transform_exclude.contains(&name))
285 .then_some(TransformMode::PackageAndComponentOnly)
286 };
287
288 instantiate_file(
289 catalog,
290 file.path(),
291 &target.join(&name),
292 parameters,
293 transform,
294 resolve_mode,
295 )?;
296 }
297 }
298 }
299 }
300 Ok(())
301}
302
303fn instantiate_file(
304 catalog: &Dir<'_>,
305 source: &Path,
306 target: &Path,
307 parameters: &TemplateParameters,
308 transform_contents: Option<TransformMode>,
309 resolve_mode: TargetExistsResolveMode,
310) -> io::Result<()> {
311 match get_resolved_contents(catalog, source, target, resolve_mode)? {
312 Some(contents) => {
313 if let Some(transform_mode) = transform_contents {
314 fs::write(
315 target,
316 transform(
317 std::str::from_utf8(contents.as_ref()).map_err(|err| {
318 io::Error::other(format!(
319 "Failed to decode as utf8, source: {}, err: {}",
320 source.display(),
321 err
322 ))
323 })?,
324 parameters,
325 transform_mode,
326 ),
327 )
328 } else {
329 fs::write(target, contents)
330 }
331 }
332 None => Ok(()),
333 }
334}
335
336fn copy(
337 catalog: &Dir<'_>,
338 source: &Path,
339 target: &Path,
340 resolve_mode: TargetExistsResolveMode,
341) -> io::Result<()> {
342 match get_resolved_contents(catalog, source, target, resolve_mode)? {
343 Some(contents) => fs::write(target, contents),
344 None => Ok(()),
345 }
346}
347
348fn copy_all(
349 catalog: &Dir<'_>,
350 source_path: &Path,
351 target_path: &Path,
352 resolve_mode: TargetExistsResolveMode,
353) -> io::Result<()> {
354 let source_dir = catalog.get_dir(source_path).ok_or_else(|| {
355 io::Error::other(format!(
356 "Could not find dir {} in catalog",
357 source_path.display()
358 ))
359 })?;
360
361 fs::create_dir_all(target_path)?;
362
363 for file in source_dir.files() {
364 copy(
365 catalog,
366 file.path(),
367 &target_path.join(file.path().file_name().unwrap().to_str().unwrap()),
368 resolve_mode,
369 )?;
370 }
371
372 Ok(())
373}
374
375enum TransformMode {
376 All,
377 PackageAndComponentOnly,
378 ManifestHintsOnly,
379}
380
381fn transform(str: impl AsRef<str>, parameters: &TemplateParameters, mode: TransformMode) -> String {
382 let transform_pack_and_comp = |str: &str| -> String {
383 str.replace(
384 "componentnameapi",
385 &format!("{}api", parameters.component_name.parts().join("")),
386 )
387 .replace("componentname", parameters.component_name.as_str())
388 .replace("component-name", ¶meters.component_name.to_kebab_case())
389 .replace("ComponentName", ¶meters.component_name.to_pascal_case())
390 .replace("componentName", ¶meters.component_name.to_camel_case())
391 .replace("component_name", ¶meters.component_name.to_snake_case())
392 .replace(
393 "pack::name",
394 ¶meters.package_name.to_string_with_double_colon(),
395 )
396 .replace("pa_ck::na_me", ¶meters.package_name.to_rust_binding())
397 .replace("pack:name", ¶meters.package_name.to_string_with_colon())
398 .replace("pack_name", ¶meters.package_name.to_snake_case())
399 .replace("pack-name", ¶meters.package_name.to_kebab_case())
400 .replace("pack/name", ¶meters.package_name.to_string_with_slash())
401 .replace("PackName", ¶meters.package_name.to_pascal_case())
402 .replace("pack-ns", ¶meters.package_name.namespace())
403 .replace("PackNs", ¶meters.package_name.namespace_title_case())
404 .replace("__pack__", ¶meters.package_name.namespace_snake_case())
405 .replace("__name__", ¶meters.package_name.name_snake_case())
406 .replace("__cn__", "componentName")
407 };
408
409 let transform_manifest_hints = |str: &str| -> String {
410 str.replace("# golem-app-manifest-header\n", APP_MANIFEST_HEADER)
411 .replace(
412 "# golem-app-manifest-component-hints\n",
413 &transform(
414 APP_MANIFEST_COMPONENT_HINTS_TEMPLATE,
415 parameters,
416 TransformMode::PackageAndComponentOnly,
417 ),
418 )
419 };
420
421 match mode {
422 TransformMode::All => transform_manifest_hints(&transform_pack_and_comp(str.as_ref())),
423 TransformMode::PackageAndComponentOnly => transform_pack_and_comp(str.as_ref()),
424 TransformMode::ManifestHintsOnly => transform_manifest_hints(str.as_ref()),
425 }
426}
427
428fn file_name_transform(str: impl AsRef<str>, parameters: &TemplateParameters) -> String {
429 transform(str, parameters, TransformMode::PackageAndComponentOnly)
430 .replace("Cargo.toml._", "Cargo.toml") }
432
433fn check_target(
434 target: &Path,
435 resolve_mode: TargetExistsResolveMode,
436) -> io::Result<Option<TargetExistsResolveDecision>> {
437 if !target.exists() {
438 return Ok(None);
439 }
440
441 let get_merge = || -> io::Result<Option<TargetExistsResolveDecision>> {
442 let file_name = target
443 .file_name()
444 .ok_or_else(|| {
445 io::Error::other(format!(
446 "Failed to get file name for target: {}",
447 target.display()
448 ))
449 })
450 .and_then(|file_name| {
451 file_name.to_str().ok_or_else(|| {
452 io::Error::other(format!(
453 "Failed to convert file name to string: {}",
454 file_name.to_string_lossy()
455 ))
456 })
457 })?;
458
459 match file_name {
460 ".gitignore" => {
461 let target = target.to_path_buf();
462 let current_content = fs::read_to_string(&target)?;
463 Ok(Some(TargetExistsResolveDecision::Merge(Box::new(
464 move |new_content: &[u8]| -> io::Result<Vec<u8>> {
465 Ok(current_content
466 .lines()
467 .chain(
468 std::str::from_utf8(new_content).map_err(|err| {
469 io::Error::other(format!(
470 "Failed to decode new content for merge as utf8, target: {}, err: {}",
471 target.display(),
472 err
473 ))
474 })?.lines(),
475 )
476 .collect::<BTreeSet<&str>>()
477 .iter()
478 .join("\n")
479 .into_bytes())
480 },
481 ))))
482 }
483 _ => Ok(None),
484 }
485 };
486
487 let target_already_exists = || {
488 Err(io::Error::other(format!(
489 "Target ({}) already exists!",
490 target.display()
491 )))
492 };
493
494 match resolve_mode {
495 TargetExistsResolveMode::Skip => Ok(Some(TargetExistsResolveDecision::Skip)),
496 TargetExistsResolveMode::MergeOrSkip => match get_merge()? {
497 Some(merge) => Ok(Some(merge)),
498 None => Ok(Some(TargetExistsResolveDecision::Skip)),
499 },
500 TargetExistsResolveMode::Fail => target_already_exists(),
501 TargetExistsResolveMode::MergeOrFail => match get_merge()? {
502 Some(merge) => Ok(Some(merge)),
503 None => target_already_exists(),
504 },
505 }
506}
507
508fn get_contents<'a>(catalog: &Dir<'a>, source: &'a Path) -> io::Result<&'a [u8]> {
509 Ok(catalog
510 .get_file(source)
511 .ok_or_else(|| io::Error::other(format!("Could not find entry {}", source.display())))?
512 .contents())
513}
514
515fn get_resolved_contents<'a>(
516 catalog: &Dir<'a>,
517 source: &'a Path,
518 target: &'a Path,
519 resolve_mode: TargetExistsResolveMode,
520) -> io::Result<Option<Cow<'a, [u8]>>> {
521 match check_target(target, resolve_mode)? {
522 None => Ok(Some(Cow::Borrowed(get_contents(catalog, source)?))),
523 Some(TargetExistsResolveDecision::Skip) => Ok(None),
524 Some(TargetExistsResolveDecision::Merge(merge)) => {
525 Ok(Some(Cow::Owned(merge(get_contents(catalog, source)?)?)))
526 }
527 }
528}
529
530fn parse_template(
531 lang: GuestLanguage,
532 lang_path: &Path,
533 default_instructions_file_name: &Path,
534 template_root: &Path,
535) -> Template {
536 let raw_metadata = TEMPLATES
537 .get_file(template_root.join("metadata.json"))
538 .expect("Failed to read metadata JSON")
539 .contents();
540 let metadata = serde_json::from_slice::<TemplateMetadata>(raw_metadata)
541 .expect("Failed to parse metadata JSON");
542
543 let kind = match (metadata.app_common_group, metadata.app_component_group) {
544 (None, None) => TemplateKind::Standalone,
545 (Some(group), None) => TemplateKind::ComposableAppCommon {
546 group: group.into(),
547 skip_if_exists: metadata.app_common_skip_if_exists.map(PathBuf::from),
548 },
549 (None, Some(group)) => TemplateKind::ComposableAppComponent {
550 group: group.into(),
551 },
552 (Some(_), Some(_)) => panic!(
553 "Only one of appCommonGroup and appComponentGroup can be specified, template root: {}",
554 template_root.display()
555 ),
556 };
557
558 let instructions = match &kind {
559 TemplateKind::Standalone => {
560 let instructions_path = match metadata.instructions {
561 Some(instructions_file_name) => lang_path.join(instructions_file_name),
562 None => lang_path.join(default_instructions_file_name),
563 };
564
565 let raw_instructions = TEMPLATES
566 .get_file(instructions_path)
567 .expect("Failed to read instructions")
568 .contents();
569
570 String::from_utf8(raw_instructions.to_vec()).expect("Failed to decode instructions")
571 }
572 TemplateKind::ComposableAppCommon { .. } => "".to_string(),
573 TemplateKind::ComposableAppComponent { .. } => "".to_string(),
574 };
575
576 let name: TemplateName = {
577 let name = template_root
578 .file_name()
579 .unwrap()
580 .to_str()
581 .unwrap()
582 .to_string();
583
584 let segments = name.split("-").collect::<Vec<_>>();
587 if segments.len() > 2 && segments[1] == "app" {
588 if segments.len() > 3 && segments[2] == "component" {
589 segments[3..].join("-").into()
590 } else {
591 segments[2..].join("-").into()
592 }
593 } else {
594 name.into()
595 }
596 };
597
598 let mut wit_deps: Vec<PathBuf> = vec![];
599 if metadata.requires_golem_host_wit.unwrap_or(false) {
600 WIT.dirs()
601 .filter(|&dir| dir.path().starts_with("golem"))
602 .map(|dir| dir.path())
603 .for_each(|path| {
604 wit_deps.push(path.to_path_buf());
605 });
606
607 wit_deps.push(PathBuf::from("golem-1.x"));
608 wit_deps.push(PathBuf::from("golem-rpc"));
609 wit_deps.push(PathBuf::from("golem-rdbms"));
610 }
611 if metadata.requires_wasi.unwrap_or(false) {
612 wit_deps.push(PathBuf::from("blobstore"));
613 wit_deps.push(PathBuf::from("cli"));
614 wit_deps.push(PathBuf::from("clocks"));
615 wit_deps.push(PathBuf::from("filesystem"));
616 wit_deps.push(PathBuf::from("http"));
617 wit_deps.push(PathBuf::from("io"));
618 wit_deps.push(PathBuf::from("keyvalue"));
619 wit_deps.push(PathBuf::from("logging"));
620 wit_deps.push(PathBuf::from("random"));
621 wit_deps.push(PathBuf::from("sockets"));
622 }
623
624 Template {
625 name,
626 kind,
627 language: lang,
628 description: metadata.description,
629 template_path: template_root.to_path_buf(),
630 instructions,
631 wit_deps,
632 wit_deps_targets: metadata
633 .wit_deps_paths
634 .map(|dirs| dirs.iter().map(PathBuf::from).collect()),
635 exclude: metadata
636 .exclude
637 .unwrap_or_default()
638 .iter()
639 .cloned()
640 .collect(),
641 transform_exclude: metadata
642 .transform_exclude
643 .map(|te| te.iter().cloned().collect())
644 .unwrap_or_default(),
645 transform: metadata.transform.unwrap_or(true),
646 dev_only: metadata.dev_only.unwrap_or(false),
647 }
648}