golem_templates/
lib.rs

1// Copyright 2024-2025 Golem Cloud
2//
3// Licensed under the Golem Source License v1.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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        &parameters.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                &parameters,
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            &parameters,
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                    // TODO: solve this more nicely, for now golem.yaml-s are always transformed,
270                    //       even if transform is set to false
271                    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", &parameters.component_name.to_kebab_case())
389        .replace("ComponentName", &parameters.component_name.to_pascal_case())
390        .replace("componentName", &parameters.component_name.to_camel_case())
391        .replace("component_name", &parameters.component_name.to_snake_case())
392        .replace(
393            "pack::name",
394            &parameters.package_name.to_string_with_double_colon(),
395        )
396        .replace("pa_ck::na_me", &parameters.package_name.to_rust_binding())
397        .replace("pack:name", &parameters.package_name.to_string_with_colon())
398        .replace("pack_name", &parameters.package_name.to_snake_case())
399        .replace("pack-name", &parameters.package_name.to_kebab_case())
400        .replace("pack/name", &parameters.package_name.to_string_with_slash())
401        .replace("PackName", &parameters.package_name.to_pascal_case())
402        .replace("pack-ns", &parameters.package_name.namespace())
403        .replace("PackNs", &parameters.package_name.namespace_title_case())
404        .replace("__pack__", &parameters.package_name.namespace_snake_case())
405        .replace("__name__", &parameters.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") // HACK because cargo package ignores every subdirectory containing a Cargo.toml
431}
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        // TODO: this is just a quickfix for hiding "<lang>-app-<component>" prefixes, let's decide later if we want
585        //       reorganize the template directories directly
586        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}