Skip to main content

shaperail_codegen/
diagnostics.rs

1use shaperail_core::{FieldType, HttpMethod, ResourceDefinition, WASM_HOOK_PREFIX};
2
3/// A structured diagnostic with error code, human message, fix suggestion, and example.
4#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
5pub struct Diagnostic {
6    /// Stable error code (e.g., "SR001").
7    pub code: &'static str,
8    /// Human-readable error message.
9    pub error: String,
10    /// Suggested fix action.
11    pub fix: String,
12    /// Inline YAML example showing the correct pattern.
13    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
22/// Validate a parsed `ResourceDefinition` and return structured diagnostics
23/// with fix suggestions. This is the "AI-friendly" counterpart to `validator::validate_resource`.
24pub 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    // Tenant key validation
154    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    // Endpoint validation
187    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            // Input/filter/search/sort fields must exist
249            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            // SR073 / SR074: subscriber event and handler must not be empty
351            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            // SR075: non-convention endpoints must declare a handler
379            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    // Relation validation
398    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    // Index validation
456    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}