shaperail_codegen/
feature_check.rs1use shaperail_core::ResourceDefinition;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct RequiredFeature {
6 pub feature: &'static str,
8 pub reason: String,
10 pub enable_hint: String,
12}
13
14pub 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 if let Some(endpoints) = &resource.endpoints {
29 for (action, ep) in endpoints {
30 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 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 required.sort_by(|a, b| a.feature.cmp(b.feature));
59 required.dedup_by(|a, b| a.feature == b.feature);
60 required
61}
62
63pub 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 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}