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