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 field.format.is_some() && field.field_type != FieldType::String {
128 diags.push(Diagnostic {
129 code: "SR015",
130 error: format!(
131 "resource '{res}': field '{name}' has format but is not type string"
132 ),
133 fix: format!("change the type of '{name}' to string, or remove 'format'"),
134 example: format!(
135 "{name}: {{ type: string, format: {} }}",
136 field.format.as_deref().unwrap_or("email")
137 ),
138 });
139 }
140
141 if field.primary && !field.generated && !field.required {
142 diags.push(Diagnostic {
143 code: "SR016",
144 error: format!(
145 "resource '{res}': primary key field '{name}' must be generated or required"
146 ),
147 fix: format!("add 'generated: true' or 'required: true' to '{name}'"),
148 example: format!("{name}: {{ type: uuid, primary: true, generated: true }}"),
149 });
150 }
151 }
152
153 if let Some(ref tenant_key) = rd.tenant_key {
155 match rd.schema.get(tenant_key) {
156 Some(field) => {
157 if field.field_type != FieldType::Uuid {
158 diags.push(Diagnostic {
159 code: "SR020",
160 error: format!(
161 "resource '{res}': tenant_key '{tenant_key}' must reference a uuid field, found {}",
162 field.field_type
163 ),
164 fix: format!("change the type of '{tenant_key}' to uuid"),
165 example: format!(
166 "{tenant_key}: {{ type: uuid, ref: organizations.id, required: true }}"
167 ),
168 });
169 }
170 }
171 None => {
172 diags.push(Diagnostic {
173 code: "SR021",
174 error: format!(
175 "resource '{res}': tenant_key '{tenant_key}' not found in schema"
176 ),
177 fix: format!("add a '{tenant_key}' uuid field to the schema"),
178 example: format!(
179 "{tenant_key}: {{ type: uuid, ref: organizations.id, required: true }}"
180 ),
181 });
182 }
183 }
184 }
185
186 if let Some(endpoints) = &rd.endpoints {
188 for (action, ep) in endpoints {
189 if let Some(controller) = &ep.controller {
190 if let Some(before) = &controller.before {
191 if before.is_empty() {
192 diags.push(Diagnostic {
193 code: "SR030",
194 error: format!(
195 "resource '{res}': endpoint '{action}' has an empty controller.before name"
196 ),
197 fix: "provide a function name for controller.before".into(),
198 example: "controller: { before: validate_input }".into(),
199 });
200 }
201 diagnose_controller_name(res, action, "before", before, &mut diags);
202 }
203 if let Some(after) = &controller.after {
204 if after.is_empty() {
205 diags.push(Diagnostic {
206 code: "SR031",
207 error: format!(
208 "resource '{res}': endpoint '{action}' has an empty controller.after name"
209 ),
210 fix: "provide a function name for controller.after".into(),
211 example: "controller: { after: enrich_response }".into(),
212 });
213 }
214 diagnose_controller_name(res, action, "after", after, &mut diags);
215 }
216 }
217
218 if let Some(events) = &ep.events {
219 for event in events {
220 if event.is_empty() {
221 diags.push(Diagnostic {
222 code: "SR032",
223 error: format!(
224 "resource '{res}': endpoint '{action}' has an empty event name"
225 ),
226 fix: "use 'resource.action' format for event names".into(),
227 example: format!("events: [{res}.created]"),
228 });
229 }
230 }
231 }
232
233 if let Some(jobs) = &ep.jobs {
234 for job in jobs {
235 if job.is_empty() {
236 diags.push(Diagnostic {
237 code: "SR033",
238 error: format!(
239 "resource '{res}': endpoint '{action}' has an empty job name"
240 ),
241 fix: "provide a snake_case job name".into(),
242 example: "jobs: [send_notification]".into(),
243 });
244 }
245 }
246 }
247
248 for (field_kind, fields) in [
250 ("input", &ep.input),
251 ("filter", &ep.filters),
252 ("search", &ep.search),
253 ("sort", &ep.sort),
254 ] {
255 if let Some(fields) = fields {
256 for field_name in fields {
257 if !rd.schema.contains_key(field_name) {
258 diags.push(Diagnostic {
259 code: "SR040",
260 error: format!(
261 "resource '{res}': endpoint '{action}' {field_kind} field '{field_name}' not found in schema"
262 ),
263 fix: format!(
264 "add '{field_name}' to the schema, or remove it from {field_kind}"
265 ),
266 example: format!("{field_name}: {{ type: string, required: true }}"),
267 });
268 }
269 }
270 }
271 }
272
273 if ep.soft_delete && !rd.schema.contains_key("deleted_at") {
274 diags.push(Diagnostic {
275 code: "SR041",
276 error: format!(
277 "resource '{res}': endpoint '{action}' has soft_delete but schema has no 'deleted_at' field"
278 ),
279 fix: "add 'deleted_at: { type: timestamp, nullable: true }' to the schema".into(),
280 example: "deleted_at: { type: timestamp, nullable: true }".into(),
281 });
282 }
283
284 if let Some(upload) = &ep.upload {
285 if !matches!(
286 *ep.method(),
287 HttpMethod::Post | HttpMethod::Patch | HttpMethod::Put
288 ) {
289 diags.push(Diagnostic {
290 code: "SR050",
291 error: format!(
292 "resource '{res}': endpoint '{action}' uses upload but method must be POST, PATCH, or PUT"
293 ),
294 fix: "change the method to POST, PATCH, or PUT".into(),
295 example: "method: POST".into(),
296 });
297 }
298
299 match rd.schema.get(&upload.field) {
300 Some(field) if field.field_type == FieldType::File => {}
301 Some(_) => diags.push(Diagnostic {
302 code: "SR051",
303 error: format!(
304 "resource '{res}': endpoint '{action}' upload field '{}' must be type file",
305 upload.field
306 ),
307 fix: format!("change '{}' to type file in the schema", upload.field),
308 example: format!("{}: {{ type: file, required: true }}", upload.field),
309 }),
310 None => diags.push(Diagnostic {
311 code: "SR052",
312 error: format!(
313 "resource '{res}': endpoint '{action}' upload field '{}' not found in schema",
314 upload.field
315 ),
316 fix: format!("add '{}' as a file field in the schema", upload.field),
317 example: format!("{}: {{ type: file, required: true }}", upload.field),
318 }),
319 }
320
321 if !matches!(upload.storage.as_str(), "local" | "s3" | "gcs" | "azure") {
322 diags.push(Diagnostic {
323 code: "SR053",
324 error: format!(
325 "resource '{res}': endpoint '{action}' upload storage '{}' is invalid",
326 upload.storage
327 ),
328 fix: "use one of: local, s3, gcs, azure".into(),
329 example: "upload: { field: file, storage: s3, max_size: 5mb }".into(),
330 });
331 }
332
333 if !ep
334 .input
335 .as_ref()
336 .is_some_and(|fields| fields.iter().any(|field| field == &upload.field))
337 {
338 diags.push(Diagnostic {
339 code: "SR054",
340 error: format!(
341 "resource '{res}': endpoint '{action}' upload field '{}' must appear in input",
342 upload.field
343 ),
344 fix: format!("add '{}' to the input array", upload.field),
345 example: format!("input: [{}]", upload.field),
346 });
347 }
348 }
349
350 if let Some(subs) = &ep.subscribers {
352 for (i, sub) in subs.iter().enumerate() {
353 if sub.event.is_empty() {
354 diags.push(Diagnostic {
355 code: "SR073",
356 error: format!(
357 "resource '{res}': endpoint '{action}' subscriber[{i}] has an empty event pattern"
358 ),
359 fix: "provide a non-empty event pattern (e.g., \"user.created\" or \"*.deleted\")".into(),
360 example: format!(
361 "subscribers:\n - event: {res}.created\n handler: my_handler"
362 ),
363 });
364 }
365 if sub.handler.is_empty() {
366 diags.push(Diagnostic {
367 code: "SR074",
368 error: format!(
369 "resource '{res}': endpoint '{action}' subscriber[{i}] has an empty handler name"
370 ),
371 fix: "provide a non-empty handler name (e.g., \"send_welcome_email\")".into(),
372 example: "subscribers:\n - event: user.created\n handler: send_welcome_email".into(),
373 });
374 }
375 }
376 }
377
378 const CONVENTIONS: &[&str] = &["list", "get", "create", "update", "delete"];
380 if !CONVENTIONS.contains(&action.as_str()) && ep.handler.is_none() {
381 diags.push(Diagnostic {
382 code: "SR075",
383 error: format!(
384 "resource '{res}': endpoint '{action}' is not a standard action (list/get/create/update/delete) and has no 'handler:' declared",
385 ),
386 fix: "add a 'handler: <function_name>' field pointing to a function in resources/<resource>.controller.rs".into(),
387 example: format!(
388 "{action}:\n method: POST\n path: /{name}/{action}\n auth: [admin]\n handler: {action}_{name}",
389 action = action,
390 name = rd.resource
391 ),
392 });
393 }
394 }
395 }
396
397 if let Some(relations) = &rd.relations {
399 for (name, rel) in relations {
400 use shaperail_core::RelationType;
401
402 if rel.relation_type == RelationType::BelongsTo && rel.key.is_none() {
403 diags.push(Diagnostic {
404 code: "SR060",
405 error: format!(
406 "resource '{res}': relation '{name}' is belongs_to but has no key"
407 ),
408 fix: format!("add 'key: {res}_id' to the relation (the local FK field)"),
409 example: format!(
410 "{name}: {{ resource: {}, type: belongs_to, key: {}_id }}",
411 rel.resource, rel.resource
412 ),
413 });
414 }
415
416 if matches!(
417 rel.relation_type,
418 RelationType::HasMany | RelationType::HasOne
419 ) && rel.foreign_key.is_none()
420 {
421 diags.push(Diagnostic {
422 code: "SR061",
423 error: format!(
424 "resource '{res}': relation '{name}' is {} but has no foreign_key",
425 rel.relation_type
426 ),
427 fix: format!(
428 "add 'foreign_key: {res}_id' to the relation (the FK on the related table)"
429 ),
430 example: format!(
431 "{name}: {{ resource: {}, type: {}, foreign_key: {res}_id }}",
432 rel.resource, rel.relation_type
433 ),
434 });
435 }
436
437 if let Some(key) = &rel.key {
438 if !rd.schema.contains_key(key) {
439 diags.push(Diagnostic {
440 code: "SR062",
441 error: format!(
442 "resource '{res}': relation '{name}' key '{key}' not found in schema"
443 ),
444 fix: format!("add '{key}' as a uuid field in the schema"),
445 example: format!(
446 "{key}: {{ type: uuid, ref: {}.id, required: true }}",
447 rel.resource
448 ),
449 });
450 }
451 }
452 }
453 }
454
455 if let Some(indexes) = &rd.indexes {
457 for (i, idx) in indexes.iter().enumerate() {
458 if idx.fields.is_empty() {
459 diags.push(Diagnostic {
460 code: "SR070",
461 error: format!("resource '{res}': index {i} has no fields"),
462 fix: "add at least one field to the index".into(),
463 example: "- { fields: [field_name] }".into(),
464 });
465 }
466 for field_name in &idx.fields {
467 if !rd.schema.contains_key(field_name) {
468 diags.push(Diagnostic {
469 code: "SR071",
470 error: format!(
471 "resource '{res}': index {i} references field '{field_name}' not in schema"
472 ),
473 fix: format!("add '{field_name}' to the schema, or remove it from the index"),
474 example: format!("{field_name}: {{ type: string, required: true }}"),
475 });
476 }
477 }
478 if let Some(order) = &idx.order {
479 if order != "asc" && order != "desc" {
480 diags.push(Diagnostic {
481 code: "SR072",
482 error: format!(
483 "resource '{res}': index {i} has invalid order '{order}', must be 'asc' or 'desc'"
484 ),
485 fix: "use 'asc' or 'desc' for the index order".into(),
486 example: "- { fields: [created_at], order: desc }".into(),
487 });
488 }
489 }
490 }
491 }
492
493 diags
494}
495
496fn diagnose_controller_name(
497 res: &str,
498 action: &str,
499 phase: &str,
500 name: &str,
501 diags: &mut Vec<Diagnostic>,
502) {
503 if let Some(wasm_path) = name.strip_prefix(WASM_HOOK_PREFIX) {
504 if wasm_path.is_empty() {
505 diags.push(Diagnostic {
506 code: "SR035",
507 error: format!(
508 "resource '{res}': endpoint '{action}' controller.{phase} has 'wasm:' prefix but no path"
509 ),
510 fix: "provide a .wasm file path after the 'wasm:' prefix".into(),
511 example: format!("controller: {{ {phase}: \"wasm:./plugins/validator.wasm\" }}"),
512 });
513 } else if !wasm_path.ends_with(".wasm") {
514 diags.push(Diagnostic {
515 code: "SR036",
516 error: format!(
517 "resource '{res}': endpoint '{action}' controller.{phase} WASM path must end with '.wasm', got '{wasm_path}'"
518 ),
519 fix: "ensure the WASM plugin path ends with '.wasm'".into(),
520 example: format!("controller: {{ {phase}: \"wasm:./plugins/validator.wasm\" }}"),
521 });
522 }
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use crate::parser::parse_resource;
530
531 #[test]
532 fn valid_resource_produces_no_diagnostics() {
533 let yaml = include_str!("../../resources/users.yaml");
534 let rd = parse_resource(yaml).unwrap();
535 let diags = diagnose_resource(&rd);
536 assert!(diags.is_empty(), "Expected no diagnostics, got: {diags:?}");
537 }
538
539 #[test]
540 fn enum_without_values_has_fix_suggestion() {
541 let yaml = r#"
542resource: items
543version: 1
544schema:
545 id: { type: uuid, primary: true, generated: true }
546 status: { type: enum, required: true }
547"#;
548 let rd = parse_resource(yaml).unwrap();
549 let diags = diagnose_resource(&rd);
550 let d = diags.iter().find(|d| d.code == "SR010").unwrap();
551 assert!(d.fix.contains("values"));
552 assert!(d.example.contains("values:"));
553 }
554
555 #[test]
556 fn missing_primary_key_has_fix_suggestion() {
557 let yaml = r#"
558resource: items
559version: 1
560schema:
561 name: { type: string, required: true }
562"#;
563 let rd = parse_resource(yaml).unwrap();
564 let diags = diagnose_resource(&rd);
565 let d = diags.iter().find(|d| d.code == "SR004").unwrap();
566 assert!(d.fix.contains("primary: true"));
567 }
568
569 #[test]
570 fn diagnostics_serialize_to_json() {
571 let d = Diagnostic {
572 code: "SR010",
573 error: "field 'role' is type enum but has no values".into(),
574 fix: "add values".into(),
575 example: "role: { type: enum, values: [a, b] }".into(),
576 };
577 let json = serde_json::to_string(&d).unwrap();
578 assert!(json.contains("SR010"));
579 assert!(json.contains("fix"));
580 }
581
582 #[test]
583 fn subscriber_with_empty_event_has_fix_suggestion() {
584 let yaml = r#"
585resource: items
586version: 1
587schema:
588 id: { type: uuid, primary: true, generated: true }
589endpoints:
590 create:
591 auth: [admin]
592 subscribers:
593 - event: ""
594 handler: my_handler
595"#;
596 let rd = parse_resource(yaml).unwrap();
597 let diags = diagnose_resource(&rd);
598 let d = diags.iter().find(|d| d.code == "SR073");
599 assert!(
600 d.is_some(),
601 "Expected SR073 diagnostic for empty subscriber event"
602 );
603 assert!(d.unwrap().fix.contains("event"));
604 }
605
606 #[test]
607 fn subscriber_with_empty_handler_has_fix_suggestion() {
608 let yaml = r#"
609resource: items
610version: 1
611schema:
612 id: { type: uuid, primary: true, generated: true }
613endpoints:
614 create:
615 auth: [admin]
616 subscribers:
617 - event: items.created
618 handler: ""
619"#;
620 let rd = parse_resource(yaml).unwrap();
621 let diags = diagnose_resource(&rd);
622 let d = diags.iter().find(|d| d.code == "SR074");
623 assert!(
624 d.is_some(),
625 "Expected SR074 diagnostic for empty subscriber handler"
626 );
627 assert!(d.unwrap().fix.contains("handler"));
628 }
629
630 #[test]
631 fn non_convention_endpoint_without_handler_produces_sr075() {
632 let yaml = r#"
633resource: items
634version: 1
635schema:
636 id: { type: uuid, primary: true, generated: true }
637endpoints:
638 archive:
639 method: POST
640 path: /items/:id/archive
641 auth: [admin]
642"#;
643 let rd = parse_resource(yaml).unwrap();
644 let diags = diagnose_resource(&rd);
645 let d = diags.iter().find(|d| d.code == "SR075");
646 assert!(
647 d.is_some(),
648 "Expected SR075 for non-convention endpoint missing handler"
649 );
650 assert!(d.unwrap().fix.contains("handler"));
651 }
652
653 #[test]
654 fn non_convention_endpoint_with_handler_no_sr075() {
655 let yaml = r#"
656resource: items
657version: 1
658schema:
659 id: { type: uuid, primary: true, generated: true }
660endpoints:
661 archive:
662 method: POST
663 path: /items/:id/archive
664 auth: [admin]
665 handler: archive_item
666"#;
667 let rd = parse_resource(yaml).unwrap();
668 let diags = diagnose_resource(&rd);
669 let has_sr075 = diags.iter().any(|d| d.code == "SR075");
670 assert!(!has_sr075, "SR075 should not fire when handler is present");
671 }
672}