spin_templates/
app_info.rs

1// Information about the application manifest that is of
2// interest to the template system.  spin_loader does too
3// much processing to fit our needs here.
4
5use std::{
6    collections::HashSet,
7    path::{Path, PathBuf},
8};
9
10use anyhow::ensure;
11use serde::Deserialize;
12use spin_manifest::schema::v1;
13
14use crate::store::TemplateLayout;
15
16pub(crate) struct AppInfo {
17    manifest_format: u32,
18    trigger_type: Option<String>, // None = v2 template does not contain any triggers yet
19}
20
21impl AppInfo {
22    pub fn from_layout(layout: &TemplateLayout) -> Option<anyhow::Result<AppInfo>> {
23        Self::layout_manifest_path(layout)
24            .map(|manifest_path| Self::from_existent_template(&manifest_path))
25    }
26
27    pub fn from_file(manifest_path: &Path) -> Option<anyhow::Result<AppInfo>> {
28        if manifest_path.exists() {
29            Some(Self::from_existent_file(manifest_path))
30        } else {
31            None
32        }
33    }
34
35    fn layout_manifest_path(layout: &TemplateLayout) -> Option<PathBuf> {
36        let manifest_path = layout.content_dir().join("spin.toml");
37        if manifest_path.exists() {
38            Some(manifest_path)
39        } else {
40            None
41        }
42    }
43
44    fn from_existent_file(manifest_path: &Path) -> anyhow::Result<Self> {
45        let manifest_str = std::fs::read_to_string(manifest_path)?;
46        Self::from_manifest_text(&manifest_str)
47    }
48
49    fn from_manifest_text(manifest_str: &str) -> anyhow::Result<Self> {
50        let manifest_version = spin_manifest::ManifestVersion::detect(manifest_str)?;
51        let manifest_format = match manifest_version {
52            spin_manifest::ManifestVersion::V1 => 1,
53            spin_manifest::ManifestVersion::V2 => 2,
54        };
55        let trigger_type = match manifest_version {
56            spin_manifest::ManifestVersion::V1 => Some(
57                toml::from_str::<ManifestV1TriggerProbe>(manifest_str)?
58                    .trigger
59                    .trigger_type,
60            ),
61            spin_manifest::ManifestVersion::V2 => {
62                let triggers = toml::from_str::<ManifestV2TriggerProbe>(manifest_str)?
63                    .trigger
64                    .unwrap_or_default();
65                let type_count = triggers.len();
66                ensure!(
67                    type_count <= 1,
68                    "only 1 trigger type currently supported; got {type_count}"
69                );
70                triggers.into_iter().next().map(|t| t.0)
71            }
72        };
73        Ok(Self {
74            manifest_format,
75            trigger_type,
76        })
77    }
78
79    fn from_existent_template(manifest_path: &Path) -> anyhow::Result<Self> {
80        // This has to be cruder, because (with the v2 style of component) a template
81        // is no longer valid TOML, so `from_existent_file` fails at manifest
82        // version inference.
83        let read_to_string = std::fs::read_to_string(manifest_path)?;
84        let manifest_tpl_str = read_to_string;
85
86        Self::from_template_text(&manifest_tpl_str)
87    }
88
89    fn from_template_text(manifest_tpl_str: &str) -> anyhow::Result<Self> {
90        // TODO: investigate using a TOML parser or regex to be more accurate
91        let is_v1_tpl = manifest_tpl_str.contains("spin_manifest_version = \"1\"");
92        let is_v2_tpl = manifest_tpl_str.contains("spin_manifest_version = 2");
93        if is_v1_tpl {
94            // V1 manifest templates are valid TOML
95            return Self::from_manifest_text(manifest_tpl_str);
96        }
97        if !is_v2_tpl {
98            // The system will default to being permissive in this case
99            anyhow::bail!("Unsure of template manifest version");
100        }
101
102        Self::from_v2_template_text(manifest_tpl_str)
103    }
104
105    fn from_v2_template_text(manifest_tpl_str: &str) -> anyhow::Result<Self> {
106        let trigger_types: HashSet<_> = manifest_tpl_str
107            .lines()
108            .filter_map(infer_trigger_type_from_raw_line)
109            .collect();
110        let type_count = trigger_types.len();
111        ensure!(
112            type_count <= 1,
113            "only 1 trigger type currently supported; got {type_count}"
114        );
115        let trigger_type = trigger_types.into_iter().next();
116
117        Ok(Self {
118            manifest_format: 2,
119            trigger_type,
120        })
121    }
122
123    pub fn manifest_format(&self) -> u32 {
124        self.manifest_format
125    }
126
127    pub fn trigger_type(&self) -> Option<&str> {
128        self.trigger_type.as_deref()
129    }
130}
131
132lazy_static::lazy_static! {
133    static ref EXTRACT_TRIGGER: regex::Regex =
134        regex::Regex::new(r"^\s*\[\[trigger\.(?<trigger>[a-zA-Z0-9-]+)").expect("Invalid unknown filter regex");
135}
136
137fn infer_trigger_type_from_raw_line(line: &str) -> Option<String> {
138    EXTRACT_TRIGGER
139        .captures(line)
140        .map(|c| c["trigger"].to_owned())
141}
142
143#[derive(Deserialize)]
144struct ManifestV1TriggerProbe {
145    // `trigger = { type = "<type>", ...}`
146    trigger: v1::AppTriggerV1,
147}
148
149#[derive(Deserialize)]
150struct ManifestV2TriggerProbe {
151    /// `[trigger.<type>]` - empty will not have a trigger table in v2
152    trigger: Option<toml::value::Table>,
153}
154
155#[cfg(test)]
156mod test {
157    use super::*;
158
159    #[test]
160    fn can_extract_triggers() {
161        assert_eq!(
162            "http",
163            infer_trigger_type_from_raw_line("[[trigger.http]]").unwrap()
164        );
165        assert_eq!(
166            "http",
167            infer_trigger_type_from_raw_line("  [[trigger.http]]").unwrap()
168        );
169        assert_eq!(
170            "fie",
171            infer_trigger_type_from_raw_line("  [[trigger.fie]]").unwrap()
172        );
173        assert_eq!(
174            "x-y",
175            infer_trigger_type_from_raw_line("  [[trigger.x-y]]").unwrap()
176        );
177
178        assert_eq!(None, infer_trigger_type_from_raw_line("# [[trigger.http]]"));
179        assert_eq!(None, infer_trigger_type_from_raw_line("trigger. But,"));
180        assert_eq!(None, infer_trigger_type_from_raw_line("[[trigger.  snerk"));
181    }
182
183    #[test]
184    fn can_read_app_info_from_template_v1() {
185        let tpl = r#"spin_manifest_version = "1"
186        name = "{{ thingy }}"
187        version = "1.2.3"
188        trigger = { type = "triggy", arg = "{{ another-thingy }}" }
189
190        [[component]]
191        id = "{{ thingy | kebab_case }}"
192        source = "path/to/{{ thingy | snake_case }}.wasm"
193        [component.trigger]
194        spork = "{{ utensil }}"
195        "#;
196
197        let info = AppInfo::from_template_text(tpl).unwrap();
198        assert_eq!(1, info.manifest_format);
199        assert_eq!("triggy", info.trigger_type.unwrap());
200    }
201
202    #[test]
203    fn can_read_app_info_from_template_v2() {
204        let tpl = r#"spin_manifest_version = 2
205        name = "{{ thingy }}"
206        version = "1.2.3"
207
208        [application.trigger.triggy]
209        arg = "{{ another-thingy }}"
210
211        [[trigger.triggy]]
212        spork = "{{ utensil }}"
213        component = "{{ thingy | kebab_case }}"
214
215        [component.{{ thingy | kebab_case }}]
216        source = "path/to/{{ thingy | snake_case }}.wasm"
217        "#;
218
219        let info = AppInfo::from_template_text(tpl).unwrap();
220        assert_eq!(2, info.manifest_format);
221        assert_eq!("triggy", info.trigger_type.unwrap());
222    }
223
224    #[test]
225    fn can_read_app_info_from_triggerless_template_v2() {
226        let tpl = r#"spin_manifest_version = 2
227        name = "{{ thingy }}"
228        version = "1.2.3"
229        "#;
230
231        let info = AppInfo::from_template_text(tpl).unwrap();
232        assert_eq!(2, info.manifest_format);
233        assert_eq!(None, info.trigger_type);
234    }
235}