Skip to main content

shaperail_codegen/
feature_check.rs

1use shaperail_core::ResourceDefinition;
2
3/// A feature that a resource requires but may not be enabled.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct RequiredFeature {
6    /// The Cargo feature name (e.g., "storage").
7    pub feature: &'static str,
8    /// Why this feature is needed.
9    pub reason: String,
10    /// How to enable it.
11    pub enable_hint: String,
12}
13
14/// Check what Cargo features a set of resources require.
15///
16/// Returns a list of features that resources use. The caller can then
17/// compare against the project's enabled features to detect mismatches early
18/// (before the compile-time error).
19pub fn check_required_features(resources: &[ResourceDefinition]) -> Vec<RequiredFeature> {
20    let mut required = Vec::new();
21
22    for resource in resources {
23        let res = &resource.resource;
24
25        // Upload endpoints are supported by the runtime without an extra Cargo
26        // feature. Keep validating the resource shape elsewhere, but do not
27        // emit a misleading feature warning here.
28        if let Some(endpoints) = &resource.endpoints {
29            for (action, ep) in endpoints {
30                // Check for WASM controllers → wasm-plugins feature
31                if let Some(controller) = &ep.controller {
32                    if controller.has_wasm_before() || controller.has_wasm_after() {
33                        required.push(RequiredFeature {
34                            feature: "wasm-plugins",
35                            reason: format!(
36                                "resource '{res}' endpoint '{action}' uses WASM controller"
37                            ),
38                            enable_hint: "Add to Cargo.toml: shaperail-runtime = { features = [\"wasm-plugins\"] }".into(),
39                        });
40                    }
41                }
42            }
43        }
44
45        // Check for tenant_key → implicit (always available, no feature needed)
46        // Check for multi-db → multi-db feature
47        if resource.db.is_some() {
48            required.push(RequiredFeature {
49                feature: "multi-db",
50                reason: format!("resource '{res}' uses 'db' key for multi-database routing"),
51                enable_hint: "Add to Cargo.toml: shaperail-runtime = { features = [\"multi-db\"] }"
52                    .into(),
53            });
54        }
55    }
56
57    // Deduplicate by feature name
58    required.sort_by(|a, b| a.feature.cmp(b.feature));
59    required.dedup_by(|a, b| a.feature == b.feature);
60    required
61}
62
63/// Format required features as user-facing warnings.
64pub fn format_feature_warnings(required: &[RequiredFeature]) -> String {
65    if required.is_empty() {
66        return String::new();
67    }
68
69    let mut out = String::from("Feature requirements detected:\n");
70    for feat in required {
71        out.push_str(&format!(
72            "  - feature '{}' needed: {}\n    {}\n",
73            feat.feature, feat.reason, feat.enable_hint
74        ));
75    }
76    out
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::parser::parse_resource;
83
84    #[test]
85    fn no_features_for_basic_resource() {
86        let yaml = r#"
87resource: items
88version: 1
89schema:
90  id: { type: uuid, primary: true, generated: true }
91  name: { type: string, required: true }
92endpoints:
93  list:
94    auth: public
95    pagination: cursor
96"#;
97        let rd = parse_resource(yaml).unwrap();
98        let required = check_required_features(&[rd]);
99        assert!(required.is_empty());
100    }
101
102    #[test]
103    fn upload_requires_no_extra_feature() {
104        let yaml = r#"
105resource: assets
106version: 1
107schema:
108  id: { type: uuid, primary: true, generated: true }
109  file: { type: file, required: true }
110endpoints:
111  upload:
112    method: POST
113    path: /assets/upload
114    input: [file]
115    upload:
116      field: file
117      storage: s3
118      max_size: 5mb
119"#;
120        let rd = parse_resource(yaml).unwrap();
121        let required = check_required_features(&[rd]);
122        assert!(!required.iter().any(|f| f.feature == "storage"));
123    }
124
125    #[test]
126    fn wasm_controller_requires_wasm_plugins_feature() {
127        let yaml = r#"
128resource: items
129version: 1
130schema:
131  id: { type: uuid, primary: true, generated: true }
132  name: { type: string, required: true }
133endpoints:
134  create:
135    method: POST
136    path: /items
137    input: [name]
138    controller: { before: "wasm:./plugins/validator.wasm" }
139"#;
140        let rd = parse_resource(yaml).unwrap();
141        let required = check_required_features(&[rd]);
142        assert!(required.iter().any(|f| f.feature == "wasm-plugins"));
143    }
144
145    #[test]
146    fn multi_db_requires_feature() {
147        let yaml = r#"
148resource: events
149version: 1
150db: analytics
151schema:
152  id: { type: uuid, primary: true, generated: true }
153  name: { type: string, required: true }
154"#;
155        let rd = parse_resource(yaml).unwrap();
156        let required = check_required_features(&[rd]);
157        assert!(required.iter().any(|f| f.feature == "multi-db"));
158    }
159
160    #[test]
161    fn features_are_deduplicated_across_resources() {
162        // Two resources both using WASM → only one wasm-plugins entry
163        let yaml1 = r#"
164resource: a
165version: 1
166schema:
167  id: { type: uuid, primary: true, generated: true }
168  name: { type: string, required: true }
169endpoints:
170  create:
171    method: POST
172    path: /a
173    input: [name]
174    controller: { before: "wasm:./plugins/validator_a.wasm" }
175"#;
176        let yaml2 = r#"
177resource: b
178version: 1
179schema:
180  id: { type: uuid, primary: true, generated: true }
181  name: { type: string, required: true }
182endpoints:
183  create:
184    method: POST
185    path: /b
186    input: [name]
187    controller: { before: "wasm:./plugins/validator_b.wasm" }
188"#;
189        let rd1 = parse_resource(yaml1).unwrap();
190        let rd2 = parse_resource(yaml2).unwrap();
191        let required = check_required_features(&[rd1, rd2]);
192        let wasm_count = required
193            .iter()
194            .filter(|f| f.feature == "wasm-plugins")
195            .count();
196        assert_eq!(wasm_count, 1, "WASM feature should be deduplicated");
197    }
198
199    #[test]
200    fn wasm_after_controller_requires_wasm_plugins_feature() {
201        let yaml = r#"
202resource: items
203version: 1
204schema:
205  id:   { type: uuid, primary: true, generated: true }
206  name: { type: string, required: true }
207endpoints:
208  create:
209    method: POST
210    path: /items
211    input: [name]
212    controller: { after: "wasm:./plugins/enricher.wasm" }
213"#;
214        let rd = parse_resource(yaml).unwrap();
215        let required = check_required_features(&[rd]);
216        assert!(
217            required.iter().any(|f| f.feature == "wasm-plugins"),
218            "wasm-plugins should be required for wasm after controller"
219        );
220    }
221
222    #[test]
223    fn format_feature_warnings_no_features() {
224        let s = format_feature_warnings(&[]);
225        assert!(s.is_empty());
226    }
227
228    #[test]
229    fn format_feature_warnings_with_features() {
230        let yaml = r#"
231resource: events
232version: 1
233db: analytics
234schema:
235  id: { type: uuid, primary: true, generated: true }
236"#;
237        let rd = parse_resource(yaml).unwrap();
238        let required = check_required_features(&[rd]);
239        let s = format_feature_warnings(&required);
240        assert!(s.contains("multi-db"), "Expected multi-db in warnings");
241        assert!(s.contains("Feature requirements"), "Expected header");
242    }
243}