Skip to main content

shaperail_codegen/diagnostics/
inner.rs

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