rapid-structure-rs 1.0.0

Rust document-structure engine for PaddleOCR-family ONNX models: text detection/recognition (PP-OCRv6), table structure (SLANet-plus) and formula recognition (PP-FormulaNet) on Windows x64 via ONNX Runtime.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
//! `ServeError`、状态码映射与 JSON 错误体(§11.1,另见 §6.1、§7.6、§9.5)。
//!
//! # 规则
//!
//! 1. **禁止字符串匹配**:HTTP 状态码与 `code` 只由这里的类型匹配产生;
//!    `RapidOcrError` → 状态码/code 的映射只有一处([`classify_ocr_error`]);
//! 2. 每个变体都必须有确定的状态码与 `code`,没有"默认 500"的兜底分支;
//! 3. 响应体形状固定为 `{ "code", "message", "detail" }`,三个键**始终**存在
//!    (没有额外信息时 `detail` 是 `null`,而不是缺键)。
//!
//! # 状态码与 `code` 全表
//!
//! | 变体 | 状态码 | `code` | 出处 |
//! | --- | --- | --- | --- |
//! | `BadRequest` | 400 | `bad_request` | §4.4(Content-Length 与实际不符、方法/路径外的问题) |
//! | `Unauthorized` | 401 | `unauthorized` | §11.1 |
//! | `DownloadsDisabled` | 403 | `downloads_disabled` | §11.1 |
//! | `BadOrigin` | 403 | `bad_origin` | §7.2 |
//! | `PayloadTooLarge` | 413 | `payload_too_large` | §4.4 |
//! | `ResultTooLarge` | 413 | `result_too_large` | §4.6 |
//! | `ExportTooLarge` | 413 | `export_too_large` | §9.5 |
//! | `OriginalEvicted` | 410 | `original_evicted` | §4.2(M3 新增,见下) |
//! | `RequestTimeout` | 408 | `request_timeout` | §4.4 第 6 步(读取超时) |
//! | `BadHost` | 421 | `bad_host` | §7.2 |
//! | `JobNotFound` | 404 | `job_not_found` | §4.5 |
//! | `ModelSetNotFound` | 404 | `model_set_not_found` | §4.2(M2 新增,见下) |
//! | `Busy` | 503 | `busy` | §4.5 / §8.2 |
//! | `Evaluation`(M4) | 400 | `bad_request` | §4.2(`detail.reason` 可定位) |
//! | `ModelsMissing` | 409 | `models_missing` | §11.1 |
//! | `ModelsCorrupt` | 409 | `models_corrupt` | §11.1 |
//! | `JobNotFinished` | 409 | `job_not_finished` | §4.3 |
//! | `JobEvicted` | 410 | `job_evicted` | §4.5 |
//! | `NotCancellable` | 409 | `not_cancellable` | §4.3 |
//! | `EngineUnavailable` | 503 | `engine_unavailable` | §7.6 |
//! | `InsufficientDiskSpace` | 507 | `insufficient_disk_space` | §11.1 / §6.5(`detail` 带两个数值) |
//! | `UnsupportedInput` | 422 | `unsupported_input` | §11.1 |
//! | `Download(..)` | 502/504/507/413/409 | 见 [`DownloadErrorMapping`] | §6.1 |
//! | `Ocr(..)` | 见 [`classify_ocr_error`] | 见 [`classify_ocr_error`] | §11.1 |
//! | `Internal` | 500 | `internal` | §11.1 |
//!
//! # 与 §11.1 变体清单的偏离(都是文档别处明确要求的行为)
//!
//! - [`ServeError::ExportTooLarge`]:§9.5 要求导出超过 `--max-export-mb` 时返回
//!   **413 `export_too_large`**,而 §11.1 的枚举清单里没有任何变体能产生这个 `code`
//!   (`ResultTooLarge` 的 `code` 是 `result_too_large`,两者是**不同预算**、不同原因);
//! - [`ServeError::RequestTimeout`]:§4.4 第 6 步要求"有界流式读取 + **读取超时**",
//!   而 §11.1 的清单里没有能表达 408 的变体;把它降级成 400 会掩盖真实原因。
//! - [`ServeError::ModelSetNotFound`](M2):§4.2 的下载请求体里**只有** `set_id`,
//!   而"未知集合"必须可定位(**绝不**回落到 `sets[0]`)。它与"请求体不合法"的 400 是
//!   两件不同的事,因此不复用 `bad_request`;`detail` 给出请求的 id 与已知集合。
//! - [`ServeError::OriginalEvicted`](M3):§4.2 给 `/api/jobs/{id}/annotated.png` 冻结了
//!   **410 `original_evicted`**("原图淘汰")。它**不是** `job_evicted` 的同义词:
//!   任务记录与结果都还在、只有原图编码字节被保留预算释放掉时才是这个结论
//!   (见 `jobs.rs::JobStore::release_originals` 与 `server.rs` 的 `originals`)。
//! - [`ServeError::ProviderRejected`](M3):运行期 `POST /api/engine/reload` 携带的 provider
//!   无法生效(名称非法或对应 feature 未编译进来)。`code` 复用 §11.1 已有的
//!   `bad_request`(400),但 `detail.reason` 必须保留**库侧原文**(§7.5 第 3 条要求启动期
//!   与运行期给出同一句可定位措辞,见 `state.rs::ServeConfigPlan::validate`)。
//! - [`ServeError::Evaluation`](M4):`POST /api/evaluate` 的评估请求无法执行(清单读不出来、
//!   格式不对、用例数超过 `--max-eval-cases`、某张图解析不到)。`code` 同样是 §11.1 已有的
//!   `bad_request`(400),`detail.reason` 说明**要改哪个输入**;它不是"内部错误",
//!   也不该退化成一个没有信息量的 400 文案。
//!
//! 其余变体与 §11.1 逐项一致。`engine_unavailable` 的 `reason` 字段来自 §7.6。
//!
//! # 下载错误的来源
//!
//! [`DownloadError`] **定义在库里**(`model_store::DownloadError`,十二类,§6.1 第 12 条),
//! serve 侧只有 [`DownloadErrorMapping`] 这一层 HTTP 映射;因此 M0c 报告的接缝
//! ("同一件事有两套错误表示")在这里被彻底消除。

use rapid_structure_rs::{DownloadError, RapidOcrError};
use serde::Serialize;

use super::state::OcrAdmission;

/// serve 侧对库 [`DownloadError`] 的 HTTP 映射(§11.1)。
///
/// **错误分类只有一个定义处**:十二类在库里(`model_store::DownloadError`),
/// serve 只补状态码/`code`/`detail`——HTTP 语义不进库(§2.1)。因此 M0c 的
/// "同一件事有两套错误表示"这个接缝被彻底消除,`ServeError::Download` 也不需要
/// 任何字符串匹配。
///
/// 映射规则(§11.1 只给下载规定了三个 `code`,更细的原因进 `detail.kind`):
///
/// | `DownloadError` | 状态码 | `code` | `detail.kind` |
/// | --- | --- | --- | --- |
/// | `SchemeRejected` / `RedirectRejected` / `HostRejected` / `Network` / `HashMismatch` | 502 | `download_failed` | `scheme` / `redirect` / `host` / `network` / `hash_mismatch` |
/// | `ConnectTimeout` / `ReadTimeout` | 504 | `download_timeout` | `connect_timeout` / `read_timeout` |
/// | `TooLarge` | 413 | `payload_too_large` | `too_large` |
/// | `InsufficientSpace` | 507 | `insufficient_disk_space` | `insufficient_space` |
/// | `Cancelled` | 409 | `download_cancelled` | `cancelled` |
pub trait DownloadErrorMapping {
    /// HTTP 状态码。
    fn status_code(&self) -> u16;
    /// 机器可读的 `code`。
    fn code(&self) -> &'static str;
    /// 人类可读说明(serve 侧措辞:包含 serve 的开关名与排障提示)。
    fn message(&self) -> String;
    /// `detail` 载荷:始终带 `kind`(`kind` 本身来自库,不在这里重复实现),
    /// 其余字段按变体给出。
    fn detail(&self) -> serde_json::Value;
}

impl DownloadErrorMapping for DownloadError {
    fn status_code(&self) -> u16 {
        match self {
            Self::ConnectTimeout { .. } | Self::ReadTimeout { .. } => 504,
            Self::InsufficientSpace { .. } => 507,
            Self::TooLarge { .. } => 413,
            Self::Cancelled => 409,
            Self::SchemeRejected { .. }
            | Self::RedirectRejected { .. }
            | Self::HostRejected { .. }
            | Self::Network { .. }
            | Self::HashMismatch { .. } => 502,
        }
    }

    fn code(&self) -> &'static str {
        match self {
            Self::ConnectTimeout { .. } | Self::ReadTimeout { .. } => "download_timeout",
            Self::InsufficientSpace { .. } => "insufficient_disk_space",
            Self::TooLarge { .. } => "payload_too_large",
            Self::Cancelled => "download_cancelled",
            Self::SchemeRejected { .. }
            | Self::RedirectRejected { .. }
            | Self::HostRejected { .. }
            | Self::Network { .. }
            | Self::HashMismatch { .. } => "download_failed",
        }
    }

    fn message(&self) -> String {
        match self {
            Self::SchemeRejected { scheme } => {
                format!("only https model downloads are allowed, got scheme `{scheme}`")
            }
            Self::RedirectRejected { location } => match location {
                Some(location) => format!(
                    "the model host answered with a redirect to `{location}` whose chain is not \
                     trusted (only https, an allow-listed host, the same file name, and at most \
                     {} hops are followed; see docs/05 §6.1 item 2)",
                    rapid_structure_rs::MAX_REDIRECT_HOPS
                ),
                None => "the model host answered with a redirect that carries no usable \
                         `Location` header"
                    .to_string(),
            },
            Self::HostRejected { host } => format!(
                "`{host}` is not in the trusted download host allow-list; pass \
                 --allow-download-host to extend it explicitly"
            ),
            Self::TooLarge {
                limit_bytes,
                observed_bytes,
            } => match observed_bytes {
                Some(observed) => format!(
                    "the download is {observed} bytes, which exceeds the {limit_bytes} byte limit"
                ),
                None => format!("the download exceeds the {limit_bytes} byte limit"),
            },
            Self::Network { detail } => format!("the download failed: {detail}"),
            Self::ConnectTimeout { timeout_ms } => {
                format!("the download could not connect within {timeout_ms} ms")
            }
            Self::ReadTimeout { timeout_ms } => {
                format!("the download stalled for more than {timeout_ms} ms while reading")
            }
            Self::InsufficientSpace {
                required_bytes,
                available_bytes,
            } => format!(
                "insufficient disk space: {required_bytes} bytes required, {available_bytes} \
                 bytes available"
            ),
            Self::HashMismatch { expected, actual } => format!(
                "the downloaded file does not match the expected SHA-256 (expected {expected}, got \
                 {actual})"
            ),
            Self::Cancelled => "the download was cancelled at a file boundary".to_string(),
        }
    }

    fn detail(&self) -> serde_json::Value {
        let kind = self.kind();
        match self {
            Self::SchemeRejected { scheme } => {
                serde_json::json!({ "kind": kind, "scheme": scheme })
            }
            Self::RedirectRejected { location } => {
                serde_json::json!({ "kind": kind, "location": location })
            }
            Self::HostRejected { host } => serde_json::json!({ "kind": kind, "host": host }),
            Self::TooLarge {
                limit_bytes,
                observed_bytes,
            } => serde_json::json!({
                "kind": kind,
                "limit_bytes": limit_bytes,
                "observed_bytes": observed_bytes,
            }),
            Self::Network { detail } => serde_json::json!({ "kind": kind, "error": detail }),
            Self::ConnectTimeout { timeout_ms } => {
                serde_json::json!({ "kind": kind, "timeout_ms": timeout_ms })
            }
            Self::ReadTimeout { timeout_ms } => {
                serde_json::json!({ "kind": kind, "timeout_ms": timeout_ms })
            }
            Self::InsufficientSpace {
                required_bytes,
                available_bytes,
            } => serde_json::json!({
                "kind": kind,
                "required_bytes": required_bytes,
                "available_bytes": available_bytes,
            }),
            Self::HashMismatch { expected, actual } => serde_json::json!({
                "kind": kind,
                "expected": expected,
                "actual": actual,
            }),
            Self::Cancelled => serde_json::json!({ "kind": kind }),
        }
    }
}

/// serve 层的统一错误(§11.1)。
///
/// # 协议变体与生产者
///
/// [`Self::ExportTooLarge`]、[`Self::OriginalEvicted`] 与 [`Self::ProviderRejected`] 的状态码与
/// `code` 由 §9.5 / §4.2 / §7.5 冻结,生产者在 **M3**(`server.rs` 的导出、注释图与
/// provider 切换);它们现在都有真实构造点。
///
/// [`Self::ModelsMissing`] 与 [`Self::ModelsCorrupt`] 的关系:两者都由
/// `/api/models` 的同一份状态决定(有损坏文件就是 `models_corrupt`)。
#[derive(Debug)]
pub enum ServeError {
    BadRequest,
    PayloadTooLarge,
    ResultTooLarge,
    /// §9.5:导出文档(含内嵌图片)超过 `--max-export-mb`。
    ///
    /// 带三个字段的原因:§9.5 要求"超限 → 413 `export_too_large`,错误信息**指向可直接
    /// 下载的 `annotated.png`**(不得返回一个图片链接失效的 HTML)"。两个数值让用户知道
    /// 差多少,`annotated` 给出那条仍然可用的路径(它不受导出预算约束)。
    ExportTooLarge {
        limit_bytes: u64,
        observed_bytes: u64,
        annotated: String,
    },
    BadHost,
    BadOrigin,
    Unauthorized,
    /// §4.4 第 6 步:请求体读取超时。
    RequestTimeout,
    Busy,
    JobNotFound,
    JobEvicted,
    /// §4.2:`/annotated.png` 需要的那份**原图编码字节**已经被保留预算释放。
    ///
    /// 与 [`Self::JobEvicted`] 的区别是任务与结果都还在(`/result` 仍可读)。
    OriginalEvicted,
    JobNotFinished,
    NotCancellable,
    ModelsMissing,
    ModelsCorrupt,
    DownloadsDisabled,
    /// §7.5:`POST /api/engine/reload` 请求的 provider 无法生效。
    ///
    /// `reason` 是库侧 `resolve_execution_providers` 的**原文**("…is not compiled in;
    /// rebuild with `--features directml-provider`"),启动期与运行期因此措辞一致。
    ProviderRejected {
        provider: String,
        reason: String,
    },
    /// §6.5:下载前的**任务级**磁盘空间预检。**生产者:M2**(`submit_download`)。
    ///
    /// 它带两个数值(需求与可用),因为"空间不足"这条错误的价值全在这两个数上;
    /// 库内部对**每个文件**的同类预检走 [`DownloadError::InsufficientSpace`]
    /// (507,`detail` 字段名相同),两者是同一事实在不同粒度上的判定。
    InsufficientDiskSpace {
        required_bytes: u64,
        available_bytes: u64,
    },
    /// §4.2:`POST /api/models/download` 的 `set_id` 在模型集里不存在。
    ///
    /// **M2 新增的变体**(§11.1 的清单里没有能产生它的项):§4.2 的请求体只带 `set_id`,
    /// 而"未知集合"必须有可定位的答复、**绝不**回落到 `sets[0]`。404 与"请求体不合法"的
    /// 400 是两件事,因此不共用 `bad_request`。`detail` 里给出请求的 id 与已知集合。
    ModelSetNotFound {
        set_id: String,
        known: Vec<String>,
    },
    /// **M4 新增的变体**:`POST /api/evaluate` 的评估请求无法执行。
    ///
    /// `code` 复用 §11.1 已有的 `bad_request`(400),但 `detail.reason` 必须给出**可定位**
    /// 的原因:清单读不出来、不是 `rapidstruct evaluate` 的格式、用例数超过 `--max-eval-cases`、
    /// 或某一张图路径解析不到。这些都不是"服务器内部错误",也不该压成一句 `bad_request`
    /// 的默认文案(用户需要知道改哪一个输入)。
    Evaluation {
        reason: String,
    },
    /// §11.1 的 422。当前由 `Ocr(RapidOcrError::InvalidImage | InvalidInput | Decode)`
    /// 分类产生(保留库侧原文),因此该变体本身没有构造点。
    #[allow(dead_code)]
    UnsupportedInput,
    EngineUnavailable {
        reason: String,
    },
    Download(DownloadError),
    Ocr(RapidOcrError),
    Internal,
}

/// `RapidOcrError` 的分类结果:状态码 + `code` + 机器可读 kind + 是否把错误文本
/// 作为 `reason` 放进响应体。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct OcrErrorClass {
    status: u16,
    code: &'static str,
    kind: &'static str,
    reason_is_message: bool,
}

/// `RapidOcrError` → (状态码, `code`, kind) 的**唯一**映射点(§11.1)。
///
/// 逐变体映射,无任何字符串匹配:
///
/// | 库错误 | 状态码 | `code` | 理由 |
/// | --- | --- | --- | --- |
/// | `Config` | 500 | `internal` | 配置在启动期已校验(§7.6 第 2 步),请求期再出现即内部不一致 |
/// | `ModelResolve` | 409 | `models_missing` | 模型文件解析不到 |
/// | `FileNotFound` | 409 | `models_missing` | 同上(模型文件缺失) |
/// | `Download` | 见 [`DownloadErrorMapping`] | 见 [`DownloadErrorMapping`] | 库侧下载器(§6.1)的分类被原样沿用,不降级成字符串 |
/// | `InvalidImage` | 422 | `unsupported_input` | 图片无法解码/超限 |
/// | `InvalidInput` | 422 | `unsupported_input` | 输入不满足契约 |
/// | `Decode` | 422 | `unsupported_input` | 识别结果解码失败(输入导致) |
/// | `Tokenizer` | 409 | `models_corrupt` | 分词器数据是模型集的一部分,失败即模型资产问题 |
/// | `UnsupportedProvider` | 503 | `engine_unavailable` | provider 不可用,必须带 reason(§7.6) |
/// | `UnsupportedBackend` | 503 | `engine_unavailable` | 同上(运行时后端不可用) |
/// | `Io` | 500 | `internal` | 本机 I/O 故障 |
/// | `Yaml` | 500 | `internal` | 配置解析在启动期已完成 |
/// | `Reqwest` | 502 | `download_failed` | 网络栈失败 |
/// | `HashMismatch` | 409 | `models_corrupt` | 文件存在但哈希不符 |
fn classify_ocr_error(error: &RapidOcrError) -> OcrErrorClass {
    match error {
        RapidOcrError::Config(_) => OcrErrorClass {
            status: 500,
            code: "internal",
            kind: "config",
            reason_is_message: false,
        },
        RapidOcrError::ModelResolve(_) => OcrErrorClass {
            status: 409,
            code: "models_missing",
            kind: "model_resolve",
            reason_is_message: false,
        },
        RapidOcrError::FileNotFound(_) => OcrErrorClass {
            status: 409,
            code: "models_missing",
            kind: "file_not_found",
            reason_is_message: false,
        },
        // 库侧下载失败:分类信息不丢——状态码/`code`/`kind` 由**同一套**下载映射给出
        // (例如 ReadTimeout → 504 `download_timeout` / kind `read_timeout`),
        // 而不是被压成 502 + 一个笼统的字符串。
        RapidOcrError::Download(error) => OcrErrorClass {
            status: error.status_code(),
            code: error.code(),
            kind: error.kind(),
            reason_is_message: false,
        },
        RapidOcrError::InvalidImage(_) => OcrErrorClass {
            status: 422,
            code: "unsupported_input",
            kind: "invalid_image",
            reason_is_message: false,
        },
        RapidOcrError::InvalidInput(_) => OcrErrorClass {
            status: 422,
            code: "unsupported_input",
            kind: "invalid_input",
            reason_is_message: false,
        },
        RapidOcrError::Decode(_) => OcrErrorClass {
            status: 422,
            code: "unsupported_input",
            kind: "decode",
            reason_is_message: false,
        },
        RapidOcrError::Tokenizer(_) => OcrErrorClass {
            status: 409,
            code: "models_corrupt",
            kind: "tokenizer",
            reason_is_message: false,
        },
        RapidOcrError::UnsupportedProvider(_) => OcrErrorClass {
            status: 503,
            code: "engine_unavailable",
            kind: "unsupported_provider",
            reason_is_message: true,
        },
        RapidOcrError::UnsupportedBackend(_) => OcrErrorClass {
            status: 503,
            code: "engine_unavailable",
            kind: "unsupported_backend",
            reason_is_message: true,
        },
        RapidOcrError::Io(_) => OcrErrorClass {
            status: 500,
            code: "internal",
            kind: "io",
            reason_is_message: false,
        },
        RapidOcrError::Yaml(_) => OcrErrorClass {
            status: 500,
            code: "internal",
            kind: "yaml",
            reason_is_message: false,
        },
        RapidOcrError::Reqwest(_) => OcrErrorClass {
            status: 502,
            code: "download_failed",
            kind: "reqwest",
            reason_is_message: false,
        },
        RapidOcrError::HashMismatch { .. } => OcrErrorClass {
            status: 409,
            code: "models_corrupt",
            kind: "hash_mismatch",
            reason_is_message: false,
        },
    }
}

impl ServeError {
    /// HTTP 状态码。
    pub fn status_code(&self) -> u16 {
        match self {
            Self::BadRequest => 400,
            Self::Unauthorized => 401,
            Self::BadOrigin | Self::DownloadsDisabled => 403,
            Self::RequestTimeout => 408,
            Self::JobNotFound => 404,
            Self::ModelSetNotFound { .. } => 404,
            Self::JobNotFinished
            | Self::NotCancellable
            | Self::ModelsMissing
            | Self::ModelsCorrupt => 409,
            Self::JobEvicted | Self::OriginalEvicted => 410,
            Self::PayloadTooLarge | Self::ResultTooLarge | Self::ExportTooLarge { .. } => 413,
            Self::BadHost => 421,
            Self::UnsupportedInput => 422,
            Self::Busy | Self::EngineUnavailable { .. } => 503,
            Self::InsufficientDiskSpace { .. } => 507,
            Self::ProviderRejected { .. } | Self::Evaluation { .. } => 400,
            Self::Download(error) => error.status_code(),
            Self::Ocr(error) => classify_ocr_error(error).status,
            Self::Internal => 500,
        }
    }

    /// 机器可读的 `code`(§11.1 的字符串,逐字一致)。
    pub fn code(&self) -> &'static str {
        match self {
            Self::BadRequest => "bad_request",
            Self::Unauthorized => "unauthorized",
            Self::BadOrigin => "bad_origin",
            Self::DownloadsDisabled => "downloads_disabled",
            Self::RequestTimeout => "request_timeout",
            Self::JobNotFound => "job_not_found",
            Self::ModelSetNotFound { .. } => "model_set_not_found",
            Self::JobNotFinished => "job_not_finished",
            Self::JobEvicted => "job_evicted",
            Self::OriginalEvicted => "original_evicted",
            Self::NotCancellable => "not_cancellable",
            Self::ModelsMissing => "models_missing",
            Self::ModelsCorrupt => "models_corrupt",
            Self::PayloadTooLarge => "payload_too_large",
            Self::ResultTooLarge => "result_too_large",
            Self::ExportTooLarge { .. } => "export_too_large",
            Self::BadHost => "bad_host",
            Self::UnsupportedInput => "unsupported_input",
            Self::Busy => "busy",
            Self::EngineUnavailable { .. } => "engine_unavailable",
            Self::InsufficientDiskSpace { .. } => "insufficient_disk_space",
            Self::ProviderRejected { .. } | Self::Evaluation { .. } => "bad_request",
            Self::Download(error) => error.code(),
            Self::Ocr(error) => classify_ocr_error(error).code,
            Self::Internal => "internal",
        }
    }

    /// 人类可读说明(不含 `code`,便于前端任意排版)。
    pub fn message(&self) -> String {
        match self {
            Self::BadRequest => "the request is malformed".to_string(),
            Self::Unauthorized => "missing or invalid X-RapidStructure-Token".to_string(),
            Self::BadOrigin => {
                "state-changing requests must carry an Origin equal to this server's origin"
                    .to_string()
            }
            Self::DownloadsDisabled => {
                "model downloads are disabled; restart with --allow-download to enable them"
                    .to_string()
            }
            Self::RequestTimeout => {
                "the request body did not arrive within the read timeout".to_string()
            }
            Self::JobNotFound => "no such job".to_string(),
            Self::JobNotFinished => "the job has not finished yet".to_string(),
            Self::JobEvicted => "the job result has been evicted".to_string(),
            Self::OriginalEvicted => {
                "the retained copy of the original image has been released; the annotated image \
                 can no longer be regenerated for this job (the result itself is still \
                 available), upload the image again to annotate it"
                    .to_string()
            }
            Self::NotCancellable => {
                "the job cannot be cancelled; only a job still queued can be cancelled".to_string()
            }
            Self::ModelsMissing => "the model set is incomplete".to_string(),
            Self::ModelsCorrupt => "a model file is present but its content is wrong".to_string(),
            Self::PayloadTooLarge => "the request body exceeds the configured limit".to_string(),
            Self::ResultTooLarge => {
                "the serialized result exceeds the configured limit".to_string()
            }
            Self::ExportTooLarge {
                limit_bytes,
                observed_bytes,
                annotated,
            } => format!(
                "the exported document would be {observed_bytes} bytes, over the \
                 --max-export-mb limit of {limit_bytes} bytes; download the annotated PNG from \
                 {annotated} instead (it is not subject to the export budget)"
            ),
            Self::ProviderRejected { provider, reason } => {
                format!("the requested provider `{provider}` cannot be used: {reason}")
            }
            Self::Evaluation { reason } => {
                format!("the evaluation request was rejected: {reason}")
            }
            Self::BadHost => {
                "the Host header does not belong to this server (possible DNS rebinding)"
                    .to_string()
            }
            Self::UnsupportedInput => {
                "the input image cannot be decoded or exceeds the limits".to_string()
            }
            Self::Busy => "the request queue is full; retry later".to_string(),
            Self::EngineUnavailable { reason } => {
                format!("the OCR engine is not available: {reason}")
            }
            Self::InsufficientDiskSpace {
                required_bytes,
                available_bytes,
            } => format!(
                "insufficient disk space for the download: {required_bytes} bytes required, \
                 {available_bytes} bytes available"
            ),
            Self::ModelSetNotFound { set_id, known } => format!(
                "no model set with id `{set_id}` exists in this model directory (known sets: {})",
                if known.is_empty() {
                    "<none>".to_string()
                } else {
                    known.join(", ")
                }
            ),
            Self::Download(error) => error.message(),
            Self::Ocr(error) => error.to_string(),
            Self::Internal => "internal error".to_string(),
        }
    }

    /// `detail` 的单行文本(流动日志用,M5):`null` 时是空串,否则是紧凑 JSON。
    ///
    /// 与 `detail` 同源(不重算),因此日志里的 `detail=` 与响应体里的 `detail` 一定是
    /// 同一个值——用户照着日志去找响应不会看到第二种说法。
    pub fn detail_text(&self) -> String {
        let detail = self.detail();
        if detail.is_null() {
            return String::new();
        }
        serde_json::to_string(&detail).unwrap_or_else(|_| detail.to_string())
    }

    /// `detail` 载荷。没有额外信息时是 `null`(键仍然存在)。
    pub fn detail(&self) -> serde_json::Value {
        match self {
            Self::EngineUnavailable { reason } => serde_json::json!({ "reason": reason }),
            // 两个数值都给出(§6.5)——字段名与库侧 `DownloadError::InsufficientSpace`
            // 的 `detail` 逐字相同,客户端不必区分"同步预检"与"任务内预检"。
            Self::InsufficientDiskSpace {
                required_bytes,
                available_bytes,
            } => serde_json::json!({
                "required_bytes": required_bytes,
                "available_bytes": available_bytes,
            }),
            Self::ModelSetNotFound { set_id, known } => serde_json::json!({
                "set_id": set_id,
                "known_sets": known,
            }),
            // §9.5:超限的导出必须**指向**仍然可用的 `annotated.png`(它就是那份图,
            // 只是不内嵌进文档),否则用户拿到的是一条死路。
            Self::ExportTooLarge {
                limit_bytes,
                observed_bytes,
                annotated,
            } => serde_json::json!({
                "limit_bytes": limit_bytes,
                "observed_bytes": observed_bytes,
                "annotated": annotated,
            }),
            Self::ProviderRejected { provider, reason } => serde_json::json!({
                "provider": provider,
                "reason": reason,
            }),
            Self::Evaluation { reason } => serde_json::json!({ "reason": reason }),
            Self::Download(error) => error.detail(),
            Self::Ocr(error) => {
                let class = classify_ocr_error(error);
                let text = error.to_string();
                let mut detail = serde_json::Map::new();
                detail.insert(
                    "kind".to_string(),
                    serde_json::Value::String(class.kind.to_string()),
                );
                if class.reason_is_message {
                    // §7.6:engine_unavailable 必须带 reason。
                    detail.insert(
                        "reason".to_string(),
                        serde_json::Value::String(text.clone()),
                    );
                }
                detail.insert("error".to_string(), serde_json::Value::String(text));
                serde_json::Value::Object(detail)
            }
            _ => serde_json::Value::Null,
        }
    }

    /// 完整的响应体(三键固定顺序:`code` → `message` → `detail`)。
    pub fn body(&self) -> ErrorBody {
        ErrorBody {
            code: self.code(),
            message: self.message(),
            detail: self.detail(),
        }
    }

    /// 响应体的 JSON 文本。
    pub fn render_body(&self) -> String {
        serde_json::to_string(&self.body()).expect("ErrorBody serialization cannot fail")
    }

    /// `/api/ocr` 的准入结论 → 结果(§7.6)。
    ///
    /// `Run` 与 `Queue` 都是 `Ok(())`:`Loading`/`Rebuilding` 期间新任务**排队而不失败**。
    /// `ModelsMissing` → 409;`Unavailable` → 503 `engine_unavailable{reason}`。
    pub fn from_ocr_admission(admission: OcrAdmission) -> Result<(), Self> {
        match admission {
            OcrAdmission::Run | OcrAdmission::Queue { .. } => Ok(()),
            OcrAdmission::ModelsMissing { .. } => Err(Self::ModelsMissing),
            OcrAdmission::Unavailable { reason } => Err(Self::EngineUnavailable { reason }),
        }
    }
}

impl std::fmt::Display for ServeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.code(), self.message())
    }
}

impl std::error::Error for ServeError {}

impl From<RapidOcrError> for ServeError {
    /// 保留原始错误:分类只在 [`classify_ocr_error`] 一处完成,
    /// 因此这里不做任何"预翻译",也不会丢失库侧的诊断文本。
    fn from(error: RapidOcrError) -> Self {
        Self::Ocr(error)
    }
}

impl From<DownloadError> for ServeError {
    fn from(error: DownloadError) -> Self {
        Self::Download(error)
    }
}

/// JSON 错误体:`{ "code", "message", "detail" }`。
#[derive(Debug, Clone, Serialize)]
pub struct ErrorBody {
    /// 机器可读错误码(§11.1)。
    pub code: &'static str,
    /// 人类可读说明。
    pub message: String,
    /// 结构化附加信息;没有时为 `null`。
    pub detail: serde_json::Value,
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use rapid_structure_rs::RapidOcrError;

    use super::{DownloadError, DownloadErrorMapping, ServeError};

    /// 每个变体的 (状态码, code) 都被逐项锁住。
    #[test]
    fn every_variant_has_the_documented_status_and_code() {
        let cases: [(ServeError, u16, &str); 22] = [
            (ServeError::BadRequest, 400, "bad_request"),
            (ServeError::Unauthorized, 401, "unauthorized"),
            (ServeError::BadOrigin, 403, "bad_origin"),
            (ServeError::DownloadsDisabled, 403, "downloads_disabled"),
            (ServeError::RequestTimeout, 408, "request_timeout"),
            (ServeError::JobNotFound, 404, "job_not_found"),
            (
                ServeError::ModelSetNotFound {
                    set_id: "nope".to_string(),
                    known: vec!["test-set".to_string()],
                },
                404,
                "model_set_not_found",
            ),
            (ServeError::JobNotFinished, 409, "job_not_finished"),
            (ServeError::JobEvicted, 410, "job_evicted"),
            (ServeError::OriginalEvicted, 410, "original_evicted"),
            (ServeError::NotCancellable, 409, "not_cancellable"),
            (ServeError::ModelsMissing, 409, "models_missing"),
            (ServeError::ModelsCorrupt, 409, "models_corrupt"),
            (ServeError::PayloadTooLarge, 413, "payload_too_large"),
            (ServeError::ResultTooLarge, 413, "result_too_large"),
            (
                ServeError::ExportTooLarge {
                    limit_bytes: 1024,
                    observed_bytes: 4096,
                    annotated: "/api/jobs/job-0/annotated.png".to_string(),
                },
                413,
                "export_too_large",
            ),
            (
                ServeError::ProviderRejected {
                    provider: "directml".to_string(),
                    reason: "not compiled in".to_string(),
                },
                400,
                "bad_request",
            ),
            (ServeError::BadHost, 421, "bad_host"),
            (ServeError::UnsupportedInput, 422, "unsupported_input"),
            (ServeError::Busy, 503, "busy"),
            (
                ServeError::EngineUnavailable {
                    reason: "no EP".to_string(),
                },
                503,
                "engine_unavailable",
            ),
            (
                ServeError::InsufficientDiskSpace {
                    required_bytes: 1000,
                    available_bytes: 10,
                },
                507,
                "insufficient_disk_space",
            ),
        ];
        for (error, status, code) in cases {
            assert_eq!(error.status_code(), status, "error: {error}");
            assert_eq!(error.code(), code, "error: {error}");
            assert!(!error.message().is_empty(), "error: {error}");
        }
        assert_eq!(ServeError::Internal.status_code(), 500);
        assert_eq!(ServeError::Internal.code(), "internal");
    }

    /// 每个下载错误类都有确定的状态码与 code(§6.1 的十类;类型来自库里)。
    #[test]
    fn every_download_error_maps_to_a_documented_status_and_code() {
        let cases: [(DownloadError, u16, &str, &str); 10] = [
            (
                DownloadError::SchemeRejected {
                    scheme: "http".to_string(),
                },
                502,
                "download_failed",
                "scheme",
            ),
            (
                DownloadError::RedirectRejected {
                    location: Some("https://evil.example/m.onnx".to_string()),
                },
                502,
                "download_failed",
                "redirect",
            ),
            (
                DownloadError::HostRejected {
                    host: "evil.example".to_string(),
                },
                502,
                "download_failed",
                "host",
            ),
            (
                DownloadError::TooLarge {
                    limit_bytes: 1024,
                    observed_bytes: Some(2048),
                },
                413,
                "payload_too_large",
                "too_large",
            ),
            (
                DownloadError::Network {
                    detail: "connection reset".to_string(),
                },
                502,
                "download_failed",
                "network",
            ),
            (
                DownloadError::ConnectTimeout { timeout_ms: 10_000 },
                504,
                "download_timeout",
                "connect_timeout",
            ),
            (
                DownloadError::ReadTimeout { timeout_ms: 30_000 },
                504,
                "download_timeout",
                "read_timeout",
            ),
            (
                DownloadError::InsufficientSpace {
                    required_bytes: 100,
                    available_bytes: 1,
                },
                507,
                "insufficient_disk_space",
                "insufficient_space",
            ),
            (
                DownloadError::HashMismatch {
                    expected: "aa".to_string(),
                    actual: "bb".to_string(),
                },
                502,
                "download_failed",
                "hash_mismatch",
            ),
            (
                DownloadError::Cancelled,
                409,
                "download_cancelled",
                "cancelled",
            ),
        ];
        for (download, status, code, kind) in cases {
            assert_eq!(download.status_code(), status, "download: {download}");
            assert_eq!(download.code(), code, "download: {download}");
            assert_eq!(download.kind(), kind, "download: {download}");
            let error = ServeError::Download(download);
            assert_eq!(error.status_code(), status, "error: {error}");
            assert_eq!(error.code(), code, "error: {error}");
            assert_eq!(error.detail()["kind"], kind);
            assert!(!error.message().is_empty(), "error: {error}");
        }
    }

    /// `DownloadError` 的具体数值必须原样进 `detail`(便于定位"超了多少""差多少空间")。
    #[test]
    fn download_details_carry_the_numbers() {
        let error = ServeError::Download(DownloadError::TooLarge {
            limit_bytes: 1024,
            observed_bytes: Some(4096),
        });
        let detail = error.detail();
        assert_eq!(detail["limit_bytes"], 1024);
        assert_eq!(detail["observed_bytes"], 4096);

        let error = ServeError::Download(DownloadError::InsufficientSpace {
            required_bytes: 600_000_000,
            available_bytes: 12_345,
        });
        let detail = error.detail();
        assert_eq!(detail["required_bytes"], 600_000_000u64);
        assert_eq!(detail["available_bytes"], 12_345);

        let error = ServeError::Download(DownloadError::HashMismatch {
            expected: "aa".to_string(),
            actual: "bb".to_string(),
        });
        assert_eq!(error.detail()["expected"], "aa");
        assert_eq!(error.detail()["actual"], "bb");
        assert!(error.message().contains("SHA-256"), "{}", error.message());

        // 同步(任务级)磁盘预检:与库侧任务内预检**同一组字段名**。
        let error = ServeError::InsufficientDiskSpace {
            required_bytes: 600_000_000,
            available_bytes: 12_345,
        };
        let detail = error.detail();
        assert_eq!(error.status_code(), 507);
        assert_eq!(detail["required_bytes"], 600_000_000u64);
        assert_eq!(detail["available_bytes"], 12_345);
        assert!(error.message().contains("600000000"), "{}", error.message());
        assert!(error.message().contains("12345"), "{}", error.message());

        // 未知集合:请求的 id 与已知集合都给出(可定位,而不是一句 404)。
        let error = ServeError::ModelSetNotFound {
            set_id: "nope".to_string(),
            known: vec!["PP-OCRv6".to_string()],
        };
        assert_eq!(error.status_code(), 404);
        assert_eq!(error.detail()["set_id"], "nope");
        assert_eq!(error.detail()["known_sets"][0], "PP-OCRv6");
        assert!(error.message().contains("nope"), "{}", error.message());
        assert!(error.message().contains("PP-OCRv6"), "{}", error.message());

        // §9.5:超限的导出给出**两个数值**,并指向仍然可用的 annotated.png——
        // 错误信息本身必须能回答"那我还能拿到什么"。
        let error = ServeError::ExportTooLarge {
            limit_bytes: 33_554_432,
            observed_bytes: 40_000_000,
            annotated: "/api/jobs/job-0/annotated.png".to_string(),
        };
        let detail = error.detail();
        assert_eq!(error.status_code(), 413);
        assert_eq!(error.code(), "export_too_large");
        assert_eq!(detail["limit_bytes"], 33_554_432u64);
        assert_eq!(detail["observed_bytes"], 40_000_000u64);
        assert_eq!(detail["annotated"], "/api/jobs/job-0/annotated.png");
        assert!(
            error.message().contains("annotated.png"),
            "{}",
            error.message()
        );
        assert!(error.message().contains("33554432"), "{}", error.message());
        assert!(error.message().contains("40000000"), "{}", error.message());

        // §7.5:运行期 provider 拒绝保留库侧原文(启动期与运行期同一句措辞)。
        let error = ServeError::ProviderRejected {
            provider: "directml".to_string(),
            reason: "execution provider directml is not compiled in; rebuild with \
                     `--features directml-provider`"
                .to_string(),
        };
        assert_eq!(error.status_code(), 400);
        assert_eq!(error.code(), "bad_request");
        assert_eq!(error.detail()["provider"], "directml");
        assert!(
            error.detail()["reason"]
                .as_str()
                .is_some_and(|reason| reason.contains("--features directml-provider")),
            "{:?}",
            error.detail()
        );
    }

    /// `RapidOcrError` 的每一个变体都在同一张表里映射(无字符串匹配)。
    #[test]
    fn every_rapid_structure_error_variant_is_mapped() {
        let http = reqwest::blocking::Client::new()
            .get("http://127.0.0.1:99999/")
            .send()
            .expect_err("an out-of-range port must fail before any connection");
        let cases: [(RapidOcrError, u16, &str, &str); 15] = [
            (
                RapidOcrError::Config("bad".to_string()),
                500,
                "internal",
                "config",
            ),
            (
                RapidOcrError::ModelResolve("missing".to_string()),
                409,
                "models_missing",
                "model_resolve",
            ),
            (
                RapidOcrError::FileNotFound(PathBuf::from("det.onnx")),
                409,
                "models_missing",
                "file_not_found",
            ),
            // 库侧下载失败被原样分类:状态码/`code`/`kind` 来自同一套下载映射
            // (`Network` 是"传输失败/非 2xx"这一类,仍是 502 `download_failed`)。
            (
                RapidOcrError::Download(DownloadError::Network {
                    detail: "boom".to_string(),
                }),
                502,
                "download_failed",
                "network",
            ),
            // 超时不降级:连接/读取超时是 504 `download_timeout`,而不是笼统的下载失败。
            (
                RapidOcrError::Download(DownloadError::ReadTimeout { timeout_ms: 30_000 }),
                504,
                "download_timeout",
                "read_timeout",
            ),
            (
                RapidOcrError::InvalidImage("truncated".to_string()),
                422,
                "unsupported_input",
                "invalid_image",
            ),
            (
                RapidOcrError::InvalidInput("huge".to_string()),
                422,
                "unsupported_input",
                "invalid_input",
            ),
            (
                RapidOcrError::Decode("bad".to_string()),
                422,
                "unsupported_input",
                "decode",
            ),
            (
                RapidOcrError::Tokenizer("bad".to_string()),
                409,
                "models_corrupt",
                "tokenizer",
            ),
            (
                RapidOcrError::UnsupportedProvider("not compiled in".to_string()),
                503,
                "engine_unavailable",
                "unsupported_provider",
            ),
            (
                RapidOcrError::UnsupportedBackend("no backend".to_string()),
                503,
                "engine_unavailable",
                "unsupported_backend",
            ),
            (
                RapidOcrError::Io(std::io::Error::other("io")),
                500,
                "internal",
                "io",
            ),
            (
                RapidOcrError::Yaml(serde_yaml::from_str::<u32>("nope").unwrap_err()),
                500,
                "internal",
                "yaml",
            ),
            (
                RapidOcrError::Reqwest(http),
                502,
                "download_failed",
                "reqwest",
            ),
            (
                RapidOcrError::HashMismatch {
                    path: PathBuf::from("det.onnx"),
                    expected: "aa".to_string(),
                    actual: "bb".to_string(),
                },
                409,
                "models_corrupt",
                "hash_mismatch",
            ),
        ];
        for (library_error, status, code, kind) in cases {
            let error = ServeError::from(library_error);
            assert_eq!(error.status_code(), status, "error: {error}");
            assert_eq!(error.code(), code, "error: {error}");
            assert_eq!(error.detail()["kind"], kind, "error: {error}");
        }
    }

    /// §7.6:provider 不可用必须在 OCR 路径上表现为 `engine_unavailable` **且带 reason**。
    #[test]
    fn engine_unavailable_always_carries_a_reason() {
        let error = ServeError::EngineUnavailable {
            reason: "DirectML is unavailable".to_string(),
        };
        assert_eq!(error.detail()["reason"], "DirectML is unavailable");
        assert!(error.message().contains("DirectML is unavailable"));

        let error = ServeError::from(RapidOcrError::UnsupportedProvider(
            "CUDA provider support is not compiled in".to_string(),
        ));
        assert_eq!(error.code(), "engine_unavailable");
        assert_eq!(
            error.detail()["reason"],
            "unsupported provider for v1: CUDA provider support is not compiled in"
        );
    }

    /// 响应体形状固定:三个键始终存在。
    #[test]
    fn error_body_always_has_code_message_and_detail_keys() {
        let bodies = [
            ServeError::BadRequest,
            ServeError::JobEvicted,
            ServeError::Internal,
            ServeError::EngineUnavailable {
                reason: "r".to_string(),
            },
            ServeError::Download(DownloadError::Cancelled),
            ServeError::from(RapidOcrError::InvalidImage("truncated".to_string())),
        ];
        for error in bodies {
            let text = error.render_body();
            let value: serde_json::Value =
                serde_json::from_str(&text).expect("the error body must be valid JSON");
            let object = value.as_object().expect("the body must be an object");
            assert_eq!(
                object.len(),
                3,
                "the body must have exactly code/message/detail: {text}"
            );
            assert_eq!(value["code"], error.code(), "body: {text}");
            assert_eq!(value["message"], error.message(), "body: {text}");
            assert!(object.contains_key("detail"), "body: {text}");
            // 键顺序固定为 code, message, detail(前端/日志可依赖)。
            let code_at = text.find("\"code\"").expect("code key");
            let message_at = text.find("\"message\"").expect("message key");
            let detail_at = text.find("\"detail\"").expect("detail key");
            assert!(
                code_at < message_at && message_at < detail_at,
                "body: {text}"
            );
        }

        let body = ServeError::JobNotFound.body();
        assert_eq!(body.code, "job_not_found");
        assert_eq!(body.message, "no such job");
        assert!(body.detail.is_null(), "unit variants carry `null` detail");
    }

    #[test]
    fn display_includes_the_code_and_the_message() {
        let error = ServeError::Busy;
        let text = error.to_string();
        assert!(text.starts_with("busy: "), "{text}");
        assert!(text.contains("retry later"), "{text}");
    }
}