1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition, WASM_HOOK_PREFIX};
2
3#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
5pub struct Diagnostic {
6 pub code: &'static str,
8 pub error: String,
10 pub fix: String,
12 pub example: String,
14}
15
16impl std::fmt::Display for Diagnostic {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 write!(f, "[{}] {}", self.code, self.error)
19 }
20}
21
22pub fn diagnose_resource(rd: &ResourceDefinition) -> Vec<Diagnostic> {
25 let mut diags = Vec::new();
26 let res = &rd.resource;
27
28 if res.is_empty() {
29 diags.push(Diagnostic {
30 code: "SR001",
31 error: "resource name must not be empty".into(),
32 fix: "add a snake_case plural name to the 'resource' key".into(),
33 example: "resource: users".into(),
34 });
35 }
36
37 if rd.version == 0 {
38 diags.push(Diagnostic {
39 code: "SR002",
40 error: format!("resource '{res}': version must be >= 1"),
41 fix: "set version to 1 or higher".into(),
42 example: "version: 1".into(),
43 });
44 }
45
46 if rd.schema.is_empty() {
47 diags.push(Diagnostic {
48 code: "SR003",
49 error: format!("resource '{res}': schema must have at least one field"),
50 fix: "add at least an id field to the schema section".into(),
51 example: "schema:\n id: { type: uuid, primary: true, generated: true }".into(),
52 });
53 }
54
55 let primary_count = rd.schema.values().filter(|f| f.primary).count();
56 if primary_count == 0 && !rd.schema.is_empty() {
57 diags.push(Diagnostic {
58 code: "SR004",
59 error: format!("resource '{res}': schema must have a primary key field"),
60 fix: "add 'primary: true' to one field (typically 'id')".into(),
61 example: "id: { type: uuid, primary: true, generated: true }".into(),
62 });
63 } else if primary_count > 1 {
64 diags.push(Diagnostic {
65 code: "SR005",
66 error: format!(
67 "resource '{res}': schema must have exactly one primary key, found {primary_count}"
68 ),
69 fix: "remove 'primary: true' from all fields except one".into(),
70 example: "id: { type: uuid, primary: true, generated: true }".into(),
71 });
72 }
73
74 for (name, field) in &rd.schema {
75 if field.field_type == FieldType::Enum && field.values.is_none() {
76 diags.push(Diagnostic {
77 code: "SR010",
78 error: format!("resource '{res}': field '{name}' is type enum but has no values"),
79 fix: format!("add 'values: [value1, value2]' to the '{name}' field"),
80 example: format!("{name}: {{ type: enum, values: [option_a, option_b] }}"),
81 });
82 }
83
84 if field.field_type != FieldType::Enum && field.values.is_some() {
85 diags.push(Diagnostic {
86 code: "SR011",
87 error: format!("resource '{res}': field '{name}' has values but is not type enum"),
88 fix: format!("change the type to 'enum' or remove 'values' from '{name}'"),
89 example: format!("{name}: {{ type: enum, values: [...] }}"),
90 });
91 }
92
93 if field.reference.is_some() && field.field_type != FieldType::Uuid {
94 diags.push(Diagnostic {
95 code: "SR012",
96 error: format!("resource '{res}': field '{name}' has ref but is not type uuid"),
97 fix: format!("change the type of '{name}' to uuid"),
98 example: format!(
99 "{name}: {{ type: uuid, ref: {}, required: true }}",
100 field.reference.as_deref().unwrap_or("resource.id")
101 ),
102 });
103 }
104
105 if let Some(ref reference) = field.reference {
106 if !reference.contains('.') {
107 diags.push(Diagnostic {
108 code: "SR013",
109 error: format!(
110 "resource '{res}': field '{name}' ref must be in 'resource.field' format, got '{reference}'"
111 ),
112 fix: "use 'resource_name.field_name' format for the ref value".into(),
113 example: format!("{name}: {{ type: uuid, ref: organizations.id }}"),
114 });
115 }
116 }
117
118 if field.field_type == FieldType::Array && field.items.is_none() {
119 diags.push(Diagnostic {
120 code: "SR014",
121 error: format!("resource '{res}': field '{name}' is type array but has no items"),
122 fix: format!("add 'items: <element_type>' to the '{name}' field"),
123 example: format!("{name}: {{ type: array, items: string }}"),
124 });
125 }
126
127 if let Some(items_spec) = &field.items {
128 if items_spec.field_type == FieldType::Array {
129 diags.push(Diagnostic {
130 code: "SR076",
131 error: format!("resource '{res}': field '{name}' has nested array items"),
132 fix: "change items to type: json (nested arrays are not supported)".to_string(),
133 example: format!("{name}: {{ type: json }}"),
134 });
135 }
136 if items_spec.field_type == FieldType::Enum && items_spec.values.is_none() {
137 diags.push(Diagnostic {
138 code: "SR077",
139 error: format!("resource '{res}': field '{name}' enum items missing values"),
140 fix: "add `values: [...]` to items".to_string(),
141 example: format!(
142 "{name}: {{ type: array, items: {{ type: enum, values: [a, b] }} }}"
143 ),
144 });
145 }
146 if items_spec.format.is_some() && items_spec.field_type != FieldType::String {
147 diags.push(Diagnostic {
148 code: "SR078",
149 error: format!(
150 "resource '{res}': field '{name}' items.format only valid on string"
151 ),
152 fix: "remove items.format or change items.type to string".to_string(),
153 example: format!(
154 "{name}: {{ type: array, items: {{ type: string, format: email }} }}"
155 ),
156 });
157 }
158 if items_spec.reference.is_some() && items_spec.field_type != FieldType::Uuid {
159 diags.push(Diagnostic {
160 code: "SR079",
161 error: format!(
162 "resource '{res}': field '{name}' items.ref requires items.type uuid"
163 ),
164 fix: "change items.type to uuid, or remove items.ref".to_string(),
165 example: format!(
166 "{name}: {{ type: array, items: {{ type: uuid, ref: organizations.id }} }}"
167 ),
168 });
169 }
170 if let Some(reference) = &items_spec.reference {
171 if !reference.contains('.') {
172 diags.push(Diagnostic {
173 code: "SR080",
174 error: format!(
175 "resource '{res}': field '{name}' items.ref must be 'resource.field'"
176 ),
177 fix: "write items.ref as 'resource_name.column_name'".to_string(),
178 example: "items: { type: uuid, ref: organizations.id }".to_string(),
179 });
180 }
181 }
182 }
183
184 if field.format.is_some() && field.field_type != FieldType::String {
185 diags.push(Diagnostic {
186 code: "SR015",
187 error: format!(
188 "resource '{res}': field '{name}' has format but is not type string"
189 ),
190 fix: format!("change the type of '{name}' to string, or remove 'format'"),
191 example: format!(
192 "{name}: {{ type: string, format: {} }}",
193 field.format.as_deref().unwrap_or("email")
194 ),
195 });
196 }
197
198 if field.primary && !field.generated && !field.required {
199 diags.push(Diagnostic {
200 code: "SR016",
201 error: format!(
202 "resource '{res}': primary key field '{name}' must be generated or required"
203 ),
204 fix: format!("add 'generated: true' or 'required: true' to '{name}'"),
205 example: format!("{name}: {{ type: uuid, primary: true, generated: true }}"),
206 });
207 }
208 }
209
210 if let Some(ref tenant_key) = rd.tenant_key {
212 match rd.schema.get(tenant_key) {
213 Some(field) => {
214 if field.field_type != FieldType::Uuid {
215 diags.push(Diagnostic {
216 code: "SR020",
217 error: format!(
218 "resource '{res}': tenant_key '{tenant_key}' must reference a uuid field, found {}",
219 field.field_type
220 ),
221 fix: format!("change the type of '{tenant_key}' to uuid"),
222 example: format!(
223 "{tenant_key}: {{ type: uuid, ref: organizations.id, required: true }}"
224 ),
225 });
226 }
227 }
228 None => {
229 diags.push(Diagnostic {
230 code: "SR021",
231 error: format!(
232 "resource '{res}': tenant_key '{tenant_key}' not found in schema"
233 ),
234 fix: format!("add a '{tenant_key}' uuid field to the schema"),
235 example: format!(
236 "{tenant_key}: {{ type: uuid, ref: organizations.id, required: true }}"
237 ),
238 });
239 }
240 }
241 }
242
243 if let Some(endpoints) = &rd.endpoints {
245 for (action, ep) in endpoints {
246 if let Some(controller) = &ep.controller {
247 if let Some(before) = &controller.before {
248 if before.is_empty() {
249 diags.push(Diagnostic {
250 code: "SR030",
251 error: format!(
252 "resource '{res}': endpoint '{action}' has an empty controller.before name"
253 ),
254 fix: "provide a function name for controller.before".into(),
255 example: "controller: { before: validate_input }".into(),
256 });
257 }
258 diagnose_controller_name(res, action, "before", before, &mut diags);
259 }
260 if let Some(after) = &controller.after {
261 if after.is_empty() {
262 diags.push(Diagnostic {
263 code: "SR031",
264 error: format!(
265 "resource '{res}': endpoint '{action}' has an empty controller.after name"
266 ),
267 fix: "provide a function name for controller.after".into(),
268 example: "controller: { after: enrich_response }".into(),
269 });
270 }
271 diagnose_controller_name(res, action, "after", after, &mut diags);
272 }
273 }
274
275 if let Some(events) = &ep.events {
276 for event in events {
277 if event.is_empty() {
278 diags.push(Diagnostic {
279 code: "SR032",
280 error: format!(
281 "resource '{res}': endpoint '{action}' has an empty event name"
282 ),
283 fix: "use 'resource.action' format for event names".into(),
284 example: format!("events: [{res}.created]"),
285 });
286 }
287 }
288 }
289
290 if let Some(jobs) = &ep.jobs {
291 for job in jobs {
292 if job.is_empty() {
293 diags.push(Diagnostic {
294 code: "SR033",
295 error: format!(
296 "resource '{res}': endpoint '{action}' has an empty job name"
297 ),
298 fix: "provide a snake_case job name".into(),
299 example: "jobs: [send_notification]".into(),
300 });
301 }
302 }
303 }
304
305 for (field_kind, fields) in [
307 ("input", &ep.input),
308 ("filter", &ep.filters),
309 ("search", &ep.search),
310 ("sort", &ep.sort),
311 ] {
312 if let Some(fields) = fields {
313 for field_name in fields {
314 if !rd.schema.contains_key(field_name) {
315 diags.push(Diagnostic {
316 code: "SR040",
317 error: format!(
318 "resource '{res}': endpoint '{action}' {field_kind} field '{field_name}' not found in schema"
319 ),
320 fix: format!(
321 "add '{field_name}' to the schema, or remove it from {field_kind}"
322 ),
323 example: format!("{field_name}: {{ type: string, required: true }}"),
324 });
325 }
326 }
327 }
328 }
329
330 if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
331 diags.push(Diagnostic {
332 code: "SR041",
333 error: format!(
334 "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
335 ),
336 fix: "add 'deleted_at: { type: timestamp, nullable: true }' to the schema".into(),
337 example: "deleted_at: { type: timestamp, nullable: true }".into(),
338 });
339 }
340
341 if let Some(upload) = &ep.upload {
342 if !matches!(
343 *ep.method(),
344 HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put
345 ) {
346 diags.push(Diagnostic {
347 code: "SR050",
348 error: format!(
349 "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
350 ),
351 fix: "change the method to POST, PATCH, or PUT".into(),
352 example: "method: POST".into(),
353 });
354 }
355
356 match rd.schema.get(&upload.field) {
357 Some(field) if field.field_type == FieldType::File => {}
358 Some(_) => diags.push(Diagnostic {
359 code: "SR051",
360 error: format!(
361 "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
362 upload.field
363 ),
364 fix: format!("change '{}' to type file in the schema", upload.field),
365 example: format!("{}: {{ type: file, required: true }}", upload.field),
366 }),
367 None => diags.push(Diagnostic {
368 code: "SR052",
369 error: format!(
370 "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
371 upload.field
372 ),
373 fix: format!("add '{}' as a file field in the schema", upload.field),
374 example: format!("{}: {{ type: file, required: true }}", upload.field),
375 }),
376 }
377
378 if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
379 diags.push(Diagnostic {
380 code: "SR053",
381 error: format!(
382 "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
383 upload.storage
384 ),
385 fix: "use one of: local, s3, gcs, azure".into(),
386 example: "upload: { field: file, storage: s3, max_size: 5mb }".into(),
387 });
388 }
389
390 if !ep
391 .input
392 .as_ref()
393 .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
394 {
395 diags.push(Diagnostic {
396 code: "SR054",
397 error: format!(
398 "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
399 upload.field
400 ),
401 fix: format!("add '{}' to the input array", upload.field),
402 example: format!("input: [{}]", upload.field),
403 });
404 }
405 }
406
407 if let Some(subs) = &ep.subscribers {
409 for (i, sub) in subs.iter().enumerate() {
410 if sub.event.is_empty() {
411 diags.push(Diagnostic {
412 code: "SR073",
413 error: format!(
414 "resource '{res}': endpoint '{action}' subscriber[{i}] has an empty event pattern"
415 ),
416 fix: "provide a non-empty event pattern (e.g., \"user.created\" or \"*.deleted\")".into(),
417 example: format!(
418 "subscribers:\n - event: {res}.created\n handler: my_handler"
419 ),
420 });
421 }
422 if sub.handler.is_empty() {
423 diags.push(Diagnostic {
424 code: "SR074",
425 error: format!(
426 "resource '{res}': endpoint '{action}' subscriber[{i}] has an empty handler name"
427 ),
428 fix: "provide a non-empty handler name (e.g., \"send_welcome_email\")".into(),
429 example: "subscribers:\n - event: user.created\n handler: send_welcome_email".into(),
430 });
431 }
432 }
433 }
434
435 const CONVENTIONS: &[&str] = &["list", "get", "create", "update", "delete"];
437 if !CONVENTIONS.contains(&action.as_str()) && ep.handler.is_none() {
438 diags.push(Diagnostic {
439 code: "SR075",
440 error: format!(
441 "resource '{res}': endpoint '{action}' is not a standard action (list/get/create/update/delete) and has no 'handler:' declared",
442 ),
443 fix: "add a 'handler: <function_name>' field pointing to a function in resources/<resource>.controller.rs".into(),
444 example: format!(
445 "{action}:\n method: POST\n path: /{name}/{action}\n auth: [admin]\n handler: {action}_{name}",
446 action = action,
447 name = rd.resource
448 ),
449 });
450 }
451 }
452 }
453
454 if let Some(relations) = &rd.relations {
456 for (name, rel) in relations {
457 use shaperail_core::RelationType;
458
459 if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
460 diags.push(Diagnostic {
461 code: "SR060",
462 error: format!(
463 "resource '{res}': relation '{name}' is belongs_to but has no key"
464 ),
465 fix: format!("add 'key: {res}_id' to the relation (the local FK field)"),
466 example: format!(
467 "{name}: {{ resource: {}, type: belongs_to, key: {}_id }}",
468 rel.resource, rel.resource
469 ),
470 });
471 }
472
473 if matches!(
474 rel.relation_type,
475 RelationType::HasMany | RelationType::HasOne
476 ) && rel.foreign_key.is_none()
477 {
478 diags.push(Diagnostic {
479 code: "SR061",
480 error: format!(
481 "resource '{res}': relation '{name}' is {} but has no foreign_key",
482 rel.relation_type
483 ),
484 fix: format!(
485 "add 'foreign_key: {res}_id' to the relation (the FK on the related table)"
486 ),
487 example: format!(
488 "{name}: {{ resource: {}, type: {}, foreign_key: {res}_id }}",
489 rel.resource, rel.relation_type
490 ),
491 });
492 }
493
494 if let Some(key) = &rel.key {
495 if !rd.schema.contains_key(key) {
496 diags.push(Diagnostic {
497 code: "SR062",
498 error: format!(
499 "resource '{res}': relation '{name}' key '{key}' not found in schema"
500 ),
501 fix: format!("add '{key}' as a uuid field in the schema"),
502 example: format!(
503 "{key}: {{ type: uuid, ref: {}.id, required: true }}",
504 rel.resource
505 ),
506 });
507 }
508 }
509 }
510 }
511
512 if let Some(indexes) = &rd.indexes {
514 for (i, idx) in indexes.iter().enumerate() {
515 if idx.fields.is_empty() {
516 diags.push(Diagnostic {
517 code: "SR070",
518 error: format!("resource '{res}': index {i} has no fields"),
519 fix: "add at least one field to the index".into(),
520 example: "- { fields: [field_name] }".into(),
521 });
522 }
523 for field_name in &idx.fields {
524 if !rd.schema.contains_key(field_name) {
525 diags.push(Diagnostic {
526 code: "SR071",
527 error: format!(
528 "resource '{res}': index {i} references field '{field_name}' not in schema"
529 ),
530 fix: format!("add '{field_name}' to the schema, or remove it from the index"),
531 example: format!("{field_name}: {{ type: string, required: true }}"),
532 });
533 }
534 }
535 if let Some(order) = &idx.order {
536 if order != "asc" && order != "desc" {
537 diags.push(Diagnostic {
538 code: "SR072",
539 error: format!(
540 "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
541 ),
542 fix: "use 'asc' or 'desc' for the index order".into(),
543 example: "- { fields: [created_at], order: desc }".into(),
544 });
545 }
546 }
547 }
548 }
549
550 diags
551}
552
553fn diagnose_controller_name(
554 res: &str,
555 action: &str,
556 phase: &str,
557 name: &str,
558 diags: &mut Vec<Diagnostic>,
559) {
560 if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
561 if wasm_path.is_empty() {
562 diags.push(Diagnostic {
563 code: "SR035",
564 error: format!(
565 "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
566 ),
567 fix: "provide a .wasm file path after the 'wasm:' prefix".into(),
568 example: format!("controller: {{ {phase}: \"wasm:./plugins/validator.wasm\" }}"),
569 });
570 } else if !wasm_path.ends_with(".wasm") {
571 diags.push(Diagnostic {
572 code: "SR036",
573 error: format!(
574 "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
575 ),
576 fix: "ensure the WASM plugin path ends with '.wasm'".into(),
577 example: format!("controller: {{ {phase}: \"wasm:./plugins/validator.wasm\" }}"),
578 });
579 }
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use crate::parser::parse_resource;
587
588 #[test]
589 fn valid_resource_produces_no_diagnostics() {
590 let yaml = include_str!("../../resources/users.yaml");
591 let rd = parse_resource(yaml).unwrap();
592 let diags = diagnose_resource(&rd);
593 assert!(diags.is_empty(), "Expected no diagnostics, got: {diags:?}");
594 }
595
596 #[test]
597 fn enum_without_values_has_fix_suggestion() {
598 let yaml = r#"
599resource: items
600version: 1
601schema:
602 id: { type: uuid, primary: true, generated: true }
603 status: { type: enum, required: true }
604"#;
605 let rd = parse_resource(yaml).unwrap();
606 let diags = diagnose_resource(&rd);
607 let d = diags.iter().find(|d| d.code == "SR010").unwrap();
608 assert!(d.fix.contains("values"));
609 assert!(d.example.contains("values:"));
610 }
611
612 #[test]
613 fn missing_primary_key_has_fix_suggestion() {
614 let yaml = r#"
615resource: items
616version: 1
617schema:
618 name: { type: string, required: true }
619"#;
620 let rd = parse_resource(yaml).unwrap();
621 let diags = diagnose_resource(&rd);
622 let d = diags.iter().find(|d| d.code == "SR004").unwrap();
623 assert!(d.fix.contains("primary: true"));
624 }
625
626 #[test]
627 fn diagnostics_serialize_to_json() {
628 let d = Diagnostic {
629 code: "SR010",
630 error: "field 'role' is type enum but has no values".into(),
631 fix: "add values".into(),
632 example: "role: { type: enum, values: [a, b] }".into(),
633 };
634 let json = serde_json::to_string(&d).unwrap();
635 assert!(json.contains("SR010"));
636 assert!(json.contains("fix"));
637 }
638
639 #[test]
640 fn subscriber_with_empty_event_has_fix_suggestion() {
641 let yaml = r#"
642resource: items
643version: 1
644schema:
645 id: { type: uuid, primary: true, generated: true }
646endpoints:
647 create:
648 auth: [admin]
649 subscribers:
650 - event: ""
651 handler: my_handler
652"#;
653 let rd = parse_resource(yaml).unwrap();
654 let diags = diagnose_resource(&rd);
655 let d = diags.iter().find(|d| d.code == "SR073");
656 assert!(
657 d.is_some(),
658 "Expected SR073 diagnostic for empty subscriber event"
659 );
660 assert!(d.unwrap().fix.contains("event"));
661 }
662
663 #[test]
664 fn subscriber_with_empty_handler_has_fix_suggestion() {
665 let yaml = r#"
666resource: items
667version: 1
668schema:
669 id: { type: uuid, primary: true, generated: true }
670endpoints:
671 create:
672 auth: [admin]
673 subscribers:
674 - event: items.created
675 handler: ""
676"#;
677 let rd = parse_resource(yaml).unwrap();
678 let diags = diagnose_resource(&rd);
679 let d = diags.iter().find(|d| d.code == "SR074");
680 assert!(
681 d.is_some(),
682 "Expected SR074 diagnostic for empty subscriber handler"
683 );
684 assert!(d.unwrap().fix.contains("handler"));
685 }
686
687 #[test]
688 fn non_convention_endpoint_without_handler_produces_sr075() {
689 let yaml = r#"
690resource: items
691version: 1
692schema:
693 id: { type: uuid, primary: true, generated: true }
694endpoints:
695 archive:
696 method: POST
697 path: /items/:id/archive
698 auth: [admin]
699"#;
700 let rd = parse_resource(yaml).unwrap();
701 let diags = diagnose_resource(&rd);
702 let d = diags.iter().find(|d| d.code == "SR075");
703 assert!(
704 d.is_some(),
705 "Expected SR075 for non-convention endpoint missing handler"
706 );
707 assert!(d.unwrap().fix.contains("handler"));
708 }
709
710 #[test]
711 fn non_convention_endpoint_with_handler_no_sr075() {
712 let yaml = r#"
713resource: items
714version: 1
715schema:
716 id: { type: uuid, primary: true, generated: true }
717endpoints:
718 archive:
719 method: POST
720 path: /items/:id/archive
721 auth: [admin]
722 handler: archive_item
723"#;
724 let rd = parse_resource(yaml).unwrap();
725 let diags = diagnose_resource(&rd);
726 let has_sr075 = diags.iter().any(|d| d.code == "SR075");
727 assert!(!has_sr075, "SR075 should not fire when handler is present");
728 }
729
730 #[test]
733 fn diagnostic_display_format() {
734 let d = Diagnostic {
735 code: "SR010",
736 error: "field 'role' is type enum but has no values".into(),
737 fix: "add values".into(),
738 example: "role: { type: enum, values: [a, b] }".into(),
739 };
740 let s = d.to_string();
741 assert!(s.contains("SR010"), "Expected code in display");
742 assert!(s.contains("enum"), "Expected error text in display");
743 }
744
745 #[test]
748 fn format_feature_warnings_empty_returns_empty_string() {
749 use crate::feature_check::format_feature_warnings;
750 let s = format_feature_warnings(&[]);
751 assert!(s.is_empty());
752 }
753
754 #[test]
755 fn format_feature_warnings_nonempty_includes_feature_name() {
756 use crate::feature_check::{format_feature_warnings, RequiredFeature};
757 let feats = vec![RequiredFeature {
758 feature: "wasm-plugins",
759 reason: "resource 'items' uses WASM".into(),
760 enable_hint: "Add to Cargo.toml: shaperail-runtime = { features = [\"wasm-plugins\"] }"
761 .into(),
762 }];
763 let s = format_feature_warnings(&feats);
764 assert!(s.contains("wasm-plugins"));
765 assert!(s.contains("WASM"));
766 }
767
768 #[test]
771 fn sr002_version_zero() {
772 let yaml = r#"
773resource: items
774version: 0
775schema:
776 id: { type: uuid, primary: true, generated: true }
777"#;
778 let rd = parse_resource(yaml).unwrap();
779 let diags = diagnose_resource(&rd);
780 assert!(
781 diags.iter().any(|d| d.code == "SR002"),
782 "Expected SR002, got: {diags:?}"
783 );
784 let d = diags.iter().find(|d| d.code == "SR002").unwrap();
785 assert!(d.fix.contains("version"));
786 }
787
788 #[test]
791 fn sr003_empty_schema() {
792 use indexmap::IndexMap;
793 use shaperail_core::ResourceDefinition;
794 let rd = ResourceDefinition {
795 resource: "items".to_string(),
796 version: 1,
797 db: None,
798 tenant_key: None,
799 schema: IndexMap::new(),
800 endpoints: None,
801 relations: None,
802 indexes: None,
803 };
804 let diags = diagnose_resource(&rd);
805 assert!(
806 diags.iter().any(|d| d.code == "SR003"),
807 "Expected SR003, got: {diags:?}"
808 );
809 }
810
811 #[test]
814 fn sr005_multiple_primary_keys() {
815 let yaml = r#"
816resource: items
817version: 1
818schema:
819 id: { type: uuid, primary: true, generated: true }
820 alt: { type: uuid, primary: true, generated: true }
821 name: { type: string, required: true }
822"#;
823 let rd = parse_resource(yaml).unwrap();
824 let diags = diagnose_resource(&rd);
825 assert!(
826 diags.iter().any(|d| d.code == "SR005"),
827 "Expected SR005, got: {diags:?}"
828 );
829 }
830
831 #[test]
834 fn sr011_non_enum_with_values() {
835 let yaml = r#"
836resource: items
837version: 1
838schema:
839 id: { type: uuid, primary: true, generated: true }
840 name: { type: string, required: true, values: ["a", "b"] }
841"#;
842 let rd = parse_resource(yaml).unwrap();
843 let diags = diagnose_resource(&rd);
844 assert!(
845 diags.iter().any(|d| d.code == "SR011"),
846 "Expected SR011, got: {diags:?}"
847 );
848 let d = diags.iter().find(|d| d.code == "SR011").unwrap();
849 assert!(d.fix.contains("enum") || d.fix.contains("values"));
850 }
851
852 #[test]
855 fn sr012_ref_on_non_uuid() {
856 let yaml = r#"
857resource: items
858version: 1
859schema:
860 id: { type: uuid, primary: true, generated: true }
861 org_id: { type: string, ref: organizations.id }
862"#;
863 let rd = parse_resource(yaml).unwrap();
864 let diags = diagnose_resource(&rd);
865 assert!(
866 diags.iter().any(|d| d.code == "SR012"),
867 "Expected SR012, got: {diags:?}"
868 );
869 }
870
871 #[test]
874 fn sr013_ref_bad_format() {
875 let yaml = r#"
876resource: items
877version: 1
878schema:
879 id: { type: uuid, primary: true, generated: true }
880 org_id: { type: uuid, ref: organizations }
881"#;
882 let rd = parse_resource(yaml).unwrap();
883 let diags = diagnose_resource(&rd);
884 assert!(
885 diags.iter().any(|d| d.code == "SR013"),
886 "Expected SR013, got: {diags:?}"
887 );
888 let d = diags.iter().find(|d| d.code == "SR013").unwrap();
889 assert!(d.fix.contains("resource_name.field_name") || d.fix.contains("format"));
890 }
891
892 #[test]
895 fn sr014_array_without_items() {
896 let yaml = r#"
897resource: items
898version: 1
899schema:
900 id: { type: uuid, primary: true, generated: true }
901 tags: { type: array }
902"#;
903 let rd = parse_resource(yaml).unwrap();
904 let diags = diagnose_resource(&rd);
905 assert!(
906 diags.iter().any(|d| d.code == "SR014"),
907 "Expected SR014, got: {diags:?}"
908 );
909 }
910
911 #[test]
914 fn sr015_format_on_non_string() {
915 let yaml = r#"
916resource: items
917version: 1
918schema:
919 id: { type: uuid, primary: true, generated: true }
920 age: { type: integer, required: true, format: email }
921"#;
922 let rd = parse_resource(yaml).unwrap();
923 let diags = diagnose_resource(&rd);
924 assert!(
925 diags.iter().any(|d| d.code == "SR015"),
926 "Expected SR015, got: {diags:?}"
927 );
928 }
929
930 #[test]
933 fn sr016_primary_not_generated_or_required() {
934 let yaml = r#"
935resource: items
936version: 1
937schema:
938 id: { type: uuid, primary: true }
939"#;
940 let rd = parse_resource(yaml).unwrap();
941 let diags = diagnose_resource(&rd);
942 assert!(
943 diags.iter().any(|d| d.code == "SR016"),
944 "Expected SR016, got: {diags:?}"
945 );
946 let d = diags.iter().find(|d| d.code == "SR016").unwrap();
947 assert!(d.fix.contains("generated: true") || d.fix.contains("required: true"));
948 }
949
950 #[test]
953 fn sr020_tenant_key_not_uuid() {
954 let yaml = r#"
955resource: items
956version: 1
957tenant_key: org_name
958schema:
959 id: { type: uuid, primary: true, generated: true }
960 org_name: { type: string, required: true }
961"#;
962 let rd = parse_resource(yaml).unwrap();
963 let diags = diagnose_resource(&rd);
964 assert!(
965 diags.iter().any(|d| d.code == "SR020"),
966 "Expected SR020, got: {diags:?}"
967 );
968 }
969
970 #[test]
973 fn sr021_tenant_key_not_in_schema() {
974 let yaml = r#"
975resource: items
976version: 1
977tenant_key: missing_field
978schema:
979 id: { type: uuid, primary: true, generated: true }
980"#;
981 let rd = parse_resource(yaml).unwrap();
982 let diags = diagnose_resource(&rd);
983 assert!(
984 diags.iter().any(|d| d.code == "SR021"),
985 "Expected SR021, got: {diags:?}"
986 );
987 let d = diags.iter().find(|d| d.code == "SR021").unwrap();
988 assert!(d.fix.contains("missing_field") || d.fix.contains("add"));
989 }
990
991 #[test]
994 fn sr035_wasm_empty_path() {
995 let yaml = r#"
996resource: items
997version: 1
998schema:
999 id: { type: uuid, primary: true, generated: true }
1000 name: { type: string, required: true }
1001endpoints:
1002 create:
1003 input: [name]
1004 controller: { before: "wasm:" }
1005"#;
1006 let rd = parse_resource(yaml).unwrap();
1007 let diags = diagnose_resource(&rd);
1008 assert!(
1009 diags.iter().any(|d| d.code == "SR035"),
1010 "Expected SR035, got: {diags:?}"
1011 );
1012 }
1013
1014 #[test]
1017 fn sr036_wasm_path_no_extension() {
1018 let yaml = r#"
1019resource: items
1020version: 1
1021schema:
1022 id: { type: uuid, primary: true, generated: true }
1023 name: { type: string, required: true }
1024endpoints:
1025 create:
1026 input: [name]
1027 controller: { after: "wasm:./plugins/my_plugin" }
1028"#;
1029 let rd = parse_resource(yaml).unwrap();
1030 let diags = diagnose_resource(&rd);
1031 assert!(
1032 diags.iter().any(|d| d.code == "SR036"),
1033 "Expected SR036, got: {diags:?}"
1034 );
1035 }
1036
1037 #[test]
1040 fn sr040_input_field_not_in_schema() {
1041 let yaml = r#"
1042resource: items
1043version: 1
1044schema:
1045 id: { type: uuid, primary: true, generated: true }
1046 name: { type: string, required: true }
1047endpoints:
1048 create:
1049 input: [name, ghost_field]
1050"#;
1051 let rd = parse_resource(yaml).unwrap();
1052 let diags = diagnose_resource(&rd);
1053 assert!(
1054 diags.iter().any(|d| d.code == "SR040"),
1055 "Expected SR040, got: {diags:?}"
1056 );
1057 }
1058
1059 #[test]
1060 fn sr040_filter_field_not_in_schema() {
1061 let yaml = r#"
1062resource: items
1063version: 1
1064schema:
1065 id: { type: uuid, primary: true, generated: true }
1066 name: { type: string, required: true }
1067endpoints:
1068 list:
1069 auth: public
1070 filters: [name, missing_filter]
1071"#;
1072 let rd = parse_resource(yaml).unwrap();
1073 let diags = diagnose_resource(&rd);
1074 assert!(
1075 diags.iter().any(|d| d.code == "SR040"),
1076 "Expected SR040, got: {diags:?}"
1077 );
1078 }
1079
1080 #[test]
1083 fn sr041_soft_delete_without_deleted_at() {
1084 let yaml = r#"
1085resource: items
1086version: 1
1087schema:
1088 id: { type: uuid, primary: true, generated: true }
1089 name: { type: string, required: true }
1090endpoints:
1091 delete:
1092 auth: [admin]
1093 soft_delete: true
1094"#;
1095 let rd = parse_resource(yaml).unwrap();
1096 let diags = diagnose_resource(&rd);
1097 assert!(
1098 diags.iter().any(|d| d.code == "SR041"),
1099 "Expected SR041, got: {diags:?}"
1100 );
1101 let d = diags.iter().find(|d| d.code == "SR041").unwrap();
1102 assert!(d.fix.contains("deleted_at"));
1103 }
1104
1105 #[test]
1108 fn sr060_belongs_to_without_key() {
1109 let yaml = r#"
1110resource: items
1111version: 1
1112schema:
1113 id: { type: uuid, primary: true, generated: true }
1114relations:
1115 org: { resource: organizations, type: belongs_to }
1116"#;
1117 let rd = parse_resource(yaml).unwrap();
1118 let diags = diagnose_resource(&rd);
1119 assert!(
1120 diags.iter().any(|d| d.code == "SR060"),
1121 "Expected SR060, got: {diags:?}"
1122 );
1123 let d = diags.iter().find(|d| d.code == "SR060").unwrap();
1124 assert!(d.fix.contains("key"));
1125 }
1126
1127 #[test]
1130 fn sr061_has_many_without_foreign_key() {
1131 let yaml = r#"
1132resource: users
1133version: 1
1134schema:
1135 id: { type: uuid, primary: true, generated: true }
1136relations:
1137 orders: { resource: orders, type: has_many }
1138"#;
1139 let rd = parse_resource(yaml).unwrap();
1140 let diags = diagnose_resource(&rd);
1141 assert!(
1142 diags.iter().any(|d| d.code == "SR061"),
1143 "Expected SR061, got: {diags:?}"
1144 );
1145 let d = diags.iter().find(|d| d.code == "SR061").unwrap();
1146 assert!(d.fix.contains("foreign_key"));
1147 }
1148
1149 #[test]
1150 fn sr061_has_one_without_foreign_key() {
1151 let yaml = r#"
1152resource: users
1153version: 1
1154schema:
1155 id: { type: uuid, primary: true, generated: true }
1156relations:
1157 profile: { resource: profiles, type: has_one }
1158"#;
1159 let rd = parse_resource(yaml).unwrap();
1160 let diags = diagnose_resource(&rd);
1161 assert!(
1162 diags.iter().any(|d| d.code == "SR061"),
1163 "Expected SR061 for has_one, got: {diags:?}"
1164 );
1165 }
1166
1167 #[test]
1170 fn sr062_relation_key_not_in_schema() {
1171 let yaml = r#"
1172resource: items
1173version: 1
1174schema:
1175 id: { type: uuid, primary: true, generated: true }
1176relations:
1177 org: { resource: organizations, type: belongs_to, key: missing_fk }
1178"#;
1179 let rd = parse_resource(yaml).unwrap();
1180 let diags = diagnose_resource(&rd);
1181 assert!(
1182 diags.iter().any(|d| d.code == "SR062"),
1183 "Expected SR062, got: {diags:?}"
1184 );
1185 }
1186
1187 #[test]
1190 fn sr070_index_no_fields() {
1191 use indexmap::IndexMap;
1192 use shaperail_core::{FieldSchema, FieldType, IndexSpec, ResourceDefinition};
1193
1194 let mut schema = IndexMap::new();
1195 schema.insert(
1196 "id".to_string(),
1197 FieldSchema {
1198 field_type: FieldType::Uuid,
1199 primary: true,
1200 generated: true,
1201 required: false,
1202 unique: false,
1203 nullable: false,
1204 reference: None,
1205 min: None,
1206 max: None,
1207 format: None,
1208 values: None,
1209 default: None,
1210 sensitive: false,
1211 search: false,
1212 items: None,
1213 transient: false,
1214 },
1215 );
1216 let rd = ResourceDefinition {
1217 resource: "items".to_string(),
1218 version: 1,
1219 db: None,
1220 tenant_key: None,
1221 schema,
1222 endpoints: None,
1223 relations: None,
1224 indexes: Some(vec![IndexSpec {
1225 fields: vec![],
1226 unique: false,
1227 order: None,
1228 }]),
1229 };
1230 let diags = diagnose_resource(&rd);
1231 assert!(
1232 diags.iter().any(|d| d.code == "SR070"),
1233 "Expected SR070, got: {diags:?}"
1234 );
1235 }
1236
1237 #[test]
1240 fn sr071_index_field_not_in_schema() {
1241 let yaml = r#"
1242resource: items
1243version: 1
1244schema:
1245 id: { type: uuid, primary: true, generated: true }
1246indexes:
1247 - fields: [missing_field]
1248"#;
1249 let rd = parse_resource(yaml).unwrap();
1250 let diags = diagnose_resource(&rd);
1251 assert!(
1252 diags.iter().any(|d| d.code == "SR071"),
1253 "Expected SR071, got: {diags:?}"
1254 );
1255 }
1256
1257 #[test]
1260 fn sr072_index_invalid_order() {
1261 let yaml = r#"
1262resource: items
1263version: 1
1264schema:
1265 id: { type: uuid, primary: true, generated: true }
1266 created_at: { type: timestamp, generated: true }
1267indexes:
1268 - fields: [created_at]
1269 order: INVALID
1270"#;
1271 let rd = parse_resource(yaml).unwrap();
1272 let diags = diagnose_resource(&rd);
1273 assert!(
1274 diags.iter().any(|d| d.code == "SR072"),
1275 "Expected SR072, got: {diags:?}"
1276 );
1277 let d = diags.iter().find(|d| d.code == "SR072").unwrap();
1278 assert!(d.fix.contains("asc") || d.fix.contains("desc"));
1279 }
1280
1281 #[test]
1284 fn sr030_empty_controller_before_name() {
1285 let yaml = r#"
1286resource: items
1287version: 1
1288schema:
1289 id: { type: uuid, primary: true, generated: true }
1290endpoints:
1291 create:
1292 input: [id]
1293 controller: { before: "" }
1294"#;
1295 let rd = parse_resource(yaml).unwrap();
1296 let diags = diagnose_resource(&rd);
1297 assert!(
1298 diags.iter().any(|d| d.code == "SR030"),
1299 "Expected SR030, got: {diags:?}"
1300 );
1301 }
1302
1303 #[test]
1304 fn sr031_empty_controller_after_name() {
1305 let yaml = r#"
1306resource: items
1307version: 1
1308schema:
1309 id: { type: uuid, primary: true, generated: true }
1310endpoints:
1311 create:
1312 input: [id]
1313 controller: { after: "" }
1314"#;
1315 let rd = parse_resource(yaml).unwrap();
1316 let diags = diagnose_resource(&rd);
1317 assert!(
1318 diags.iter().any(|d| d.code == "SR031"),
1319 "Expected SR031, got: {diags:?}"
1320 );
1321 }
1322
1323 #[test]
1324 fn sr032_empty_event_name_in_diagnostics() {
1325 let yaml = r#"
1326resource: items
1327version: 1
1328schema:
1329 id: { type: uuid, primary: true, generated: true }
1330endpoints:
1331 create:
1332 input: [id]
1333 events: [""]
1334"#;
1335 let rd = parse_resource(yaml).unwrap();
1336 let diags = diagnose_resource(&rd);
1337 assert!(
1338 diags.iter().any(|d| d.code == "SR032"),
1339 "Expected SR032, got: {diags:?}"
1340 );
1341 }
1342
1343 #[test]
1344 fn sr033_empty_job_name_in_diagnostics() {
1345 let yaml = r#"
1346resource: items
1347version: 1
1348schema:
1349 id: { type: uuid, primary: true, generated: true }
1350endpoints:
1351 create:
1352 input: [id]
1353 jobs: [""]
1354"#;
1355 let rd = parse_resource(yaml).unwrap();
1356 let diags = diagnose_resource(&rd);
1357 assert!(
1358 diags.iter().any(|d| d.code == "SR033"),
1359 "Expected SR033, got: {diags:?}"
1360 );
1361 }
1362
1363 #[test]
1364 fn sr050_upload_on_get_method() {
1365 let yaml = r#"
1366resource: assets
1367version: 1
1368schema:
1369 id: { type: uuid, primary: true, generated: true }
1370 file: { type: file, required: true }
1371endpoints:
1372 upload_file:
1373 method: GET
1374 path: /assets/upload
1375 input: [file]
1376 upload:
1377 field: file
1378 storage: s3
1379 max_size: 5mb
1380"#;
1381 let rd = parse_resource(yaml).unwrap();
1382 let diags = diagnose_resource(&rd);
1383 assert!(
1384 diags.iter().any(|d| d.code == "SR050"),
1385 "Expected SR050 (upload on GET), got: {diags:?}"
1386 );
1387 }
1388
1389 #[test]
1390 fn sr051_upload_field_wrong_type() {
1391 let yaml = r#"
1392resource: assets
1393version: 1
1394schema:
1395 id: { type: uuid, primary: true, generated: true }
1396 title: { type: string, required: true }
1397endpoints:
1398 upload_file:
1399 method: POST
1400 path: /assets/upload
1401 input: [title]
1402 upload:
1403 field: title
1404 storage: s3
1405 max_size: 5mb
1406"#;
1407 let rd = parse_resource(yaml).unwrap();
1408 let diags = diagnose_resource(&rd);
1409 assert!(
1410 diags.iter().any(|d| d.code == "SR051"),
1411 "Expected SR051 (upload field not type file), got: {diags:?}"
1412 );
1413 }
1414
1415 #[test]
1416 fn sr052_upload_field_missing_from_schema() {
1417 let yaml = r#"
1418resource: assets
1419version: 1
1420schema:
1421 id: { type: uuid, primary: true, generated: true }
1422endpoints:
1423 upload_file:
1424 method: POST
1425 path: /assets/upload
1426 input: [attachment]
1427 upload:
1428 field: attachment
1429 storage: s3
1430 max_size: 5mb
1431"#;
1432 let rd = parse_resource(yaml).unwrap();
1433 let diags = diagnose_resource(&rd);
1434 assert!(
1435 diags.iter().any(|d| d.code == "SR052"),
1436 "Expected SR052 (upload field not in schema), got: {diags:?}"
1437 );
1438 }
1439
1440 #[test]
1441 fn sr053_upload_invalid_storage_backend() {
1442 let yaml = r#"
1443resource: assets
1444version: 1
1445schema:
1446 id: { type: uuid, primary: true, generated: true }
1447 file: { type: file, required: true }
1448endpoints:
1449 upload_file:
1450 method: POST
1451 path: /assets/upload
1452 input: [file]
1453 upload:
1454 field: file
1455 storage: ftp
1456 max_size: 5mb
1457"#;
1458 let rd = parse_resource(yaml).unwrap();
1459 let diags = diagnose_resource(&rd);
1460 assert!(
1461 diags.iter().any(|d| d.code == "SR053"),
1462 "Expected SR053 (invalid storage 'ftp'), got: {diags:?}"
1463 );
1464 }
1465
1466 #[test]
1467 fn sr054_upload_field_not_in_input() {
1468 let yaml = r#"
1469resource: assets
1470version: 1
1471schema:
1472 id: { type: uuid, primary: true, generated: true }
1473 file: { type: file, required: true }
1474endpoints:
1475 upload_file:
1476 method: POST
1477 path: /assets/upload
1478 input: []
1479 upload:
1480 field: file
1481 storage: s3
1482 max_size: 5mb
1483"#;
1484 let rd = parse_resource(yaml).unwrap();
1485 let diags = diagnose_resource(&rd);
1486 assert!(
1487 diags.iter().any(|d| d.code == "SR054"),
1488 "Expected SR054 (upload field not in input), got: {diags:?}"
1489 );
1490 }
1491
1492 #[test]
1493 fn sr050_to_sr054_all_clear_for_valid_upload_endpoint() {
1494 let yaml = r#"
1495resource: assets
1496version: 1
1497schema:
1498 id: { type: uuid, primary: true, generated: true }
1499 file: { type: file, required: true }
1500 title: { type: string, required: true }
1501endpoints:
1502 upload_file:
1503 method: POST
1504 path: /assets/upload
1505 input: [file, title]
1506 upload:
1507 field: file
1508 storage: s3
1509 max_size: 10mb
1510"#;
1511 let rd = parse_resource(yaml).unwrap();
1512 let diags = diagnose_resource(&rd);
1513 let upload_diags: Vec<_> = diags
1514 .iter()
1515 .filter(|d| ["SR050", "SR051", "SR052", "SR053", "SR054"].contains(&d.code))
1516 .collect();
1517 assert!(
1518 upload_diags.is_empty(),
1519 "Valid upload endpoint should produce no SR050-054 diags, got: {upload_diags:?}"
1520 );
1521 }
1522
1523 #[test]
1524 fn sr073_subscriber_empty_event() {
1525 let yaml = r#"
1526resource: notifications
1527version: 1
1528schema:
1529 id: { type: uuid, primary: true, generated: true }
1530endpoints:
1531 on_user_created:
1532 method: POST
1533 path: /notifications/on_user_created
1534 handler: on_user_created_handler
1535 subscribers:
1536 - event: ""
1537 handler: send_welcome
1538"#;
1539 let rd = parse_resource(yaml).unwrap();
1540 let diags = diagnose_resource(&rd);
1541 assert!(
1542 diags.iter().any(|d| d.code == "SR073"),
1543 "Expected SR073, got: {diags:?}"
1544 );
1545 }
1546
1547 #[test]
1548 fn sr074_subscriber_empty_handler() {
1549 let yaml = r#"
1550resource: notifications
1551version: 1
1552schema:
1553 id: { type: uuid, primary: true, generated: true }
1554endpoints:
1555 on_user_created:
1556 method: POST
1557 path: /notifications/on_user_created
1558 handler: on_user_created_handler
1559 subscribers:
1560 - event: user.created
1561 handler: ""
1562"#;
1563 let rd = parse_resource(yaml).unwrap();
1564 let diags = diagnose_resource(&rd);
1565 assert!(
1566 diags.iter().any(|d| d.code == "SR074"),
1567 "Expected SR074, got: {diags:?}"
1568 );
1569 }
1570}