golem_templates/
lib.rs

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