Skip to main content

golem_templates/
model.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 fancy_regex::{Match, Regex};
16use heck::{ToLowerCamelCase, ToPascalCase, ToSnakeCase, ToTitleCase};
17use serde::{Deserialize, Serialize};
18use std::collections::HashSet;
19use std::fmt::Formatter;
20use std::path::PathBuf;
21use std::str::FromStr;
22use std::sync::LazyLock;
23use std::{fmt, io};
24use strum::IntoEnumIterator;
25use strum_macros::EnumIter;
26
27#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
28pub struct ComponentName(String);
29
30static COMPONENT_NAME_SPLIT_REGEX: LazyLock<Regex> =
31    LazyLock::new(|| Regex::new("(?=[A-Z\\-_:])").unwrap());
32
33impl ComponentName {
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37
38    pub fn parts(&self) -> Vec<String> {
39        let matches: Vec<Result<Match, fancy_regex::Error>> =
40            COMPONENT_NAME_SPLIT_REGEX.find_iter(&self.0).collect();
41        let mut parts: Vec<&str> = vec![];
42        let mut last = 0;
43        for m in matches.into_iter().flatten() {
44            let part = &self.0[last..m.start()];
45            if !part.is_empty() {
46                parts.push(part);
47            }
48            last = m.end();
49        }
50        parts.push(&self.0[last..]);
51
52        let mut result: Vec<String> = Vec::with_capacity(parts.len());
53        for part in parts {
54            let s = part.to_lowercase();
55            let s = s.strip_prefix('-').unwrap_or(&s);
56            let s = s.strip_prefix('_').unwrap_or(s);
57            let s = s.strip_prefix(':').unwrap_or(s);
58            result.push(s.to_string());
59        }
60        result
61    }
62
63    pub fn to_kebab_case(&self) -> String {
64        self.parts().join("-")
65    }
66
67    pub fn to_snake_case(&self) -> String {
68        self.parts().join("_")
69    }
70
71    pub fn to_pascal_case(&self) -> String {
72        self.parts().iter().map(|s| s.to_title_case()).collect()
73    }
74
75    pub fn to_camel_case(&self) -> String {
76        self.to_pascal_case().to_lower_camel_case()
77    }
78}
79
80impl From<&str> for ComponentName {
81    fn from(name: &str) -> Self {
82        ComponentName(name.to_string())
83    }
84}
85
86impl From<String> for ComponentName {
87    fn from(name: String) -> Self {
88        ComponentName(name)
89    }
90}
91
92impl fmt::Display for ComponentName {
93    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
94        write!(f, "{}", self.0)
95    }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub enum TemplateKind {
100    Standalone,
101    ComposableAppCommon {
102        group: ComposableAppGroupName,
103        skip_if_exists: Option<PathBuf>,
104    },
105    ComposableAppComponent {
106        group: ComposableAppGroupName,
107    },
108}
109
110impl TemplateKind {
111    pub fn is_common(&self) -> bool {
112        matches!(self, TemplateKind::ComposableAppCommon { .. })
113    }
114}
115
116#[derive(
117    Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, EnumIter, Serialize, Deserialize,
118)]
119pub enum GuestLanguage {
120    Rust,
121    TypeScript,
122}
123
124impl GuestLanguage {
125    pub fn from_string(s: impl AsRef<str>) -> Option<GuestLanguage> {
126        match s.as_ref().to_lowercase().as_str() {
127            "rust" => Some(GuestLanguage::Rust),
128            "ts" | "typescript" => Some(GuestLanguage::TypeScript),
129            _ => None,
130        }
131    }
132
133    pub fn id(&self) -> String {
134        match self {
135            GuestLanguage::Rust => "rust".to_string(),
136            GuestLanguage::TypeScript => "ts".to_string(),
137        }
138    }
139
140    pub fn tier(&self) -> GuestLanguageTier {
141        match self {
142            GuestLanguage::Rust => GuestLanguageTier::Tier1,
143            GuestLanguage::TypeScript => GuestLanguageTier::Tier1,
144        }
145    }
146
147    pub fn name(&self) -> &'static str {
148        match self {
149            GuestLanguage::Rust => "Rust",
150            GuestLanguage::TypeScript => "TypeScript",
151        }
152    }
153}
154
155impl fmt::Display for GuestLanguage {
156    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
157        write!(f, "{}", self.name())
158    }
159}
160
161impl FromStr for GuestLanguage {
162    type Err = String;
163
164    fn from_str(s: &str) -> Result<Self, Self::Err> {
165        GuestLanguage::from_string(s).ok_or({
166            let all = GuestLanguage::iter()
167                .map(|x| format!("\"{x}\""))
168                .collect::<Vec<String>>()
169                .join(", ");
170            format!("Unknown guest language: {s}. Expected one of {all}")
171        })
172    }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, EnumIter, Serialize, Deserialize)]
176pub enum GuestLanguageTier {
177    Tier1,
178    Tier2,
179    Tier3,
180}
181
182impl GuestLanguageTier {
183    pub fn from_string(s: impl AsRef<str>) -> Option<GuestLanguageTier> {
184        match s.as_ref().to_lowercase().as_str() {
185            "tier1" | "1" => Some(GuestLanguageTier::Tier1),
186            "tier2" | "2" => Some(GuestLanguageTier::Tier2),
187            "tier3" | "3" => Some(GuestLanguageTier::Tier3),
188            _ => None,
189        }
190    }
191
192    pub fn level(&self) -> u8 {
193        match self {
194            GuestLanguageTier::Tier1 => 1,
195            GuestLanguageTier::Tier2 => 2,
196            GuestLanguageTier::Tier3 => 3,
197        }
198    }
199
200    pub fn name(&self) -> &'static str {
201        match self {
202            GuestLanguageTier::Tier1 => "tier1",
203            GuestLanguageTier::Tier2 => "tier2",
204            GuestLanguageTier::Tier3 => "tier3",
205        }
206    }
207}
208
209impl fmt::Display for GuestLanguageTier {
210    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
211        write!(f, "{}", self.name())
212    }
213}
214
215impl FromStr for GuestLanguageTier {
216    type Err = String;
217
218    fn from_str(s: &str) -> Result<Self, Self::Err> {
219        GuestLanguageTier::from_string(s).ok_or(format!("Unexpected guest language tier {s}"))
220    }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
224pub struct PackageName((String, String));
225
226impl PackageName {
227    pub fn from_string(s: impl AsRef<str>) -> Option<PackageName> {
228        let parts: Vec<&str> = s.as_ref().split(':').collect();
229        match parts.as_slice() {
230            &[n1, n2] if !n1.is_empty() && !n2.is_empty() => {
231                Some(PackageName((n1.to_string(), n2.to_string())))
232            }
233            _ => None,
234        }
235    }
236
237    pub fn to_pascal_case(&self) -> String {
238        format!(
239            "{}{}",
240            self.0 .0.to_pascal_case(),
241            self.0 .1.to_pascal_case()
242        )
243    }
244
245    pub fn to_snake_case(&self) -> String {
246        format!(
247            "{}_{}",
248            self.0 .0.to_snake_case(),
249            self.0 .1.to_snake_case()
250        )
251    }
252
253    pub fn to_string_with_double_colon(&self) -> String {
254        format!("{}::{}", self.0 .0, self.0 .1)
255    }
256
257    pub fn to_string_with_colon(&self) -> String {
258        format!("{}:{}", self.0 .0, self.0 .1)
259    }
260
261    pub fn to_string_with_slash(&self) -> String {
262        format!("{}/{}", self.0 .0, self.0 .1)
263    }
264
265    pub fn to_kebab_case(&self) -> String {
266        format!("{}-{}", self.0 .0, self.0 .1)
267    }
268
269    pub fn to_rust_binding(&self) -> String {
270        format!(
271            "{}::{}",
272            self.0 .0.to_snake_case(),
273            self.0 .1.to_snake_case()
274        )
275    }
276
277    pub fn namespace(&self) -> String {
278        self.0 .0.to_string()
279    }
280
281    pub fn namespace_title_case(&self) -> String {
282        self.0 .0.to_title_case()
283    }
284
285    pub fn namespace_snake_case(&self) -> String {
286        self.0 .0.to_snake_case()
287    }
288
289    pub fn name_snake_case(&self) -> String {
290        self.0 .1.to_snake_case()
291    }
292}
293
294impl fmt::Display for PackageName {
295    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
296        write!(f, "{}", self.to_string_with_colon())
297    }
298}
299
300impl FromStr for PackageName {
301    type Err = String;
302
303    fn from_str(s: &str) -> Result<Self, Self::Err> {
304        PackageName::from_string(s).ok_or(format!(
305            "Unexpected package name {s}. Must be in 'pack:name' format"
306        ))
307    }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
311pub struct TemplateName(String);
312
313impl TemplateName {
314    pub fn as_str(&self) -> &str {
315        &self.0
316    }
317}
318
319impl From<&str> for TemplateName {
320    fn from(s: &str) -> Self {
321        TemplateName(s.to_string())
322    }
323}
324
325impl From<String> for TemplateName {
326    fn from(s: String) -> Self {
327        TemplateName(s)
328    }
329}
330
331impl fmt::Display for TemplateName {
332    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
333        write!(f, "{}", self.0)
334    }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
338pub struct ComposableAppGroupName(String);
339
340impl ComposableAppGroupName {
341    pub fn as_str(&self) -> &str {
342        &self.0
343    }
344}
345
346impl Default for ComposableAppGroupName {
347    fn default() -> Self {
348        ComposableAppGroupName("default".to_string())
349    }
350}
351
352impl From<&str> for ComposableAppGroupName {
353    fn from(s: &str) -> Self {
354        ComposableAppGroupName(s.to_string())
355    }
356}
357
358impl From<String> for ComposableAppGroupName {
359    fn from(s: String) -> Self {
360        ComposableAppGroupName(s)
361    }
362}
363
364impl fmt::Display for ComposableAppGroupName {
365    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
366        write!(f, "{}", self.0)
367    }
368}
369
370#[derive(Debug, Copy, Clone)]
371pub enum TargetExistsResolveMode {
372    Skip,
373    MergeOrSkip,
374    Fail,
375    MergeOrFail,
376}
377
378pub type MergeContents = Box<dyn FnOnce(&[u8]) -> io::Result<Vec<u8>>>;
379
380pub enum TargetExistsResolveDecision {
381    Skip,
382    Merge(MergeContents),
383}
384
385#[derive(Debug, Clone)]
386pub struct Template {
387    pub name: TemplateName,
388    pub kind: TemplateKind,
389    pub language: GuestLanguage,
390    pub description: String,
391    pub template_path: PathBuf,
392    pub instructions: String,
393    pub wit_deps: Vec<PathBuf>,
394    pub wit_deps_targets: Option<Vec<PathBuf>>,
395    pub exclude: HashSet<String>,
396    pub transform_exclude: HashSet<String>,
397    pub transform: bool,
398    pub dev_only: bool,
399}
400
401#[derive(Debug, Clone)]
402pub struct TemplateParameters {
403    pub component_name: ComponentName,
404    pub package_name: PackageName,
405    pub target_path: PathBuf,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub(crate) struct TemplateMetadata {
410    pub description: String,
411    #[serde(rename = "appCommonGroup")]
412    pub app_common_group: Option<String>,
413    #[serde(rename = "appCommonSkipIfExists")]
414    pub app_common_skip_if_exists: Option<String>,
415    #[serde(rename = "appComponentGroup")]
416    pub app_component_group: Option<String>,
417    #[serde(rename = "requiresGolemHostWIT")]
418    pub requires_golem_host_wit: Option<bool>,
419    #[serde(rename = "requiresWASI")]
420    pub requires_wasi: Option<bool>,
421    #[serde(rename = "witDepsPaths")]
422    pub wit_deps_paths: Option<Vec<String>>,
423    pub exclude: Option<Vec<String>>,
424    pub instructions: Option<String>,
425    #[serde(rename = "transformExclude")]
426    pub transform_exclude: Option<Vec<String>>,
427    pub transform: Option<bool>,
428    #[serde(rename = "devOnly")]
429    pub dev_only: Option<bool>,
430}
431
432#[cfg(test)]
433mod tests {
434    use crate::model::{ComponentName, PackageName};
435    use test_r::test;
436
437    #[allow(dead_code)]
438    fn n1() -> ComponentName {
439        "my-test-component".into()
440    }
441
442    #[allow(dead_code)]
443    fn n2() -> ComponentName {
444        "MyTestComponent".into()
445    }
446
447    #[allow(dead_code)]
448    fn n3() -> ComponentName {
449        "myTestComponent".into()
450    }
451
452    #[allow(dead_code)]
453    fn n4() -> ComponentName {
454        "my_test_component".into()
455    }
456
457    #[test]
458    pub fn component_name_to_pascal_case() {
459        assert_eq!(n1().to_pascal_case(), "MyTestComponent");
460        assert_eq!(n2().to_pascal_case(), "MyTestComponent");
461        assert_eq!(n3().to_pascal_case(), "MyTestComponent");
462        assert_eq!(n4().to_pascal_case(), "MyTestComponent");
463    }
464
465    #[test]
466    pub fn component_name_to_camel_case() {
467        assert_eq!(n1().to_camel_case(), "myTestComponent");
468        assert_eq!(n2().to_camel_case(), "myTestComponent");
469        assert_eq!(n3().to_camel_case(), "myTestComponent");
470        assert_eq!(n4().to_camel_case(), "myTestComponent");
471    }
472
473    #[test]
474    pub fn component_name_to_snake_case() {
475        assert_eq!(n1().to_snake_case(), "my_test_component");
476        assert_eq!(n2().to_snake_case(), "my_test_component");
477        assert_eq!(n3().to_snake_case(), "my_test_component");
478        assert_eq!(n4().to_snake_case(), "my_test_component");
479    }
480
481    #[test]
482    pub fn component_name_to_kebab_case() {
483        assert_eq!(n1().to_kebab_case(), "my-test-component");
484        assert_eq!(n2().to_kebab_case(), "my-test-component");
485        assert_eq!(n3().to_kebab_case(), "my-test-component");
486        assert_eq!(n4().to_kebab_case(), "my-test-component");
487    }
488
489    #[allow(dead_code)]
490    fn p1() -> PackageName {
491        PackageName::from_string("foo:bar").unwrap()
492    }
493
494    #[allow(dead_code)]
495    fn p2() -> PackageName {
496        PackageName::from_string("foo:bar-baz").unwrap()
497    }
498
499    #[test]
500    pub fn package_name_to_pascal_case() {
501        assert_eq!(p1().to_pascal_case(), "FooBar");
502        assert_eq!(p2().to_pascal_case(), "FooBarBaz");
503    }
504
505    #[test]
506    pub fn package_name_with_number() {
507        assert_eq!(
508            PackageName::from_string("example:demo1")
509                .unwrap()
510                .to_rust_binding(),
511            "example::demo1"
512        )
513    }
514}