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