Skip to main content

sz_rust_addons_loader/
resource.rs

1//! 插件 RESTful 资源路由注入(Phase 10.3.5)
2//!
3//! 借鉴 Rails 资源路由 + ThinkPHP 6 多应用机制。
4//!
5//! ## ThinkPHP 6 资源路由对齐
6//!
7//! ThinkPHP 6 `Route::resource($rule, $route)` 生成 7 个 RESTful 路由
8//! (源码:`vendor/topthink/framework/src/think/Route.php` 第 39-47 行 `$rest` 默认数组):
9//!
10//! | 动作 | HTTP 方法 | URL 后缀 | 控制器方法 |
11//! |------|-----------|----------|-----------|
12//! | index | GET | `''` | index |
13//! | create | GET | `/create` | create |
14//! | edit | GET | `/<id>/edit` | edit |
15//! | read | GET | `/<id>` | read |
16//! | save | POST | `''` | save |
17//! | update | PUT | `/<id>` | update |
18//! | delete | DELETE | `/<id>` | delete |
19//!
20//! ## addon 路由前缀注入(Phase 10.3.5 核心)
21//!
22//! 插件内 `Route::resource` 自动注入 addon 路由前缀:
23//!
24//! - 普通资源:`/blog` → `/addons/<addon>/<controller>`
25//! - 嵌套资源:`blog.comment` → `/addons/<addon>/blog/<blog_id>/comment`
26//!
27//! ## PHP 项目使用情况
28//!
29//! 调研结论:PHP 项目业务代码 0 处使用 `Route::resource`,addons 0 处使用,
30//! 项目使用控制器自动路由机制(URL 直接映射 `模块/控制器/方法`)。
31//! 本模块为 Rust 侧扩展能力,对齐 ThinkPHP 6 资源路由机制作为框架能力。
32
33use std::fmt;
34
35use crate::error::{AddonLoaderError, AddonLoaderResult};
36
37/// RESTful 动作枚举(对齐 ThinkPHP 6 `$rest` 默认数组的 7 个键)
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum ResourceAction {
40    /// 列表页(GET,URL 后缀 `''`)
41    Index,
42    /// 新建表单页(GET,URL 后缀 `/create`)
43    Create,
44    /// 编辑表单页(GET,URL 后缀 `/<id>/edit`)
45    Edit,
46    /// 单个资源详情(GET,URL 后缀 `/<id>`)
47    Read,
48    /// 新建保存(POST,URL 后缀 `''`)
49    Save,
50    /// 更新(PUT,URL 后缀 `/<id>`)
51    Update,
52    /// 删除(DELETE,URL 后缀 `/<id>`)
53    Delete,
54}
55
56impl ResourceAction {
57    /// 返回动作名称(对齐 ThinkPHP 6 `$rest` 数组的键名)
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            ResourceAction::Index => "index",
61            ResourceAction::Create => "create",
62            ResourceAction::Edit => "edit",
63            ResourceAction::Read => "read",
64            ResourceAction::Save => "save",
65            ResourceAction::Update => "update",
66            ResourceAction::Delete => "delete",
67        }
68    }
69
70    /// 返回对应的 HTTP 方法
71    pub fn http_method(&self) -> HttpMethod {
72        match self {
73            ResourceAction::Index
74            | ResourceAction::Create
75            | ResourceAction::Edit
76            | ResourceAction::Read => HttpMethod::Get,
77            ResourceAction::Save => HttpMethod::Post,
78            ResourceAction::Update => HttpMethod::Put,
79            ResourceAction::Delete => HttpMethod::Delete,
80        }
81    }
82
83    /// 返回 URL 后缀(对齐 ThinkPHP 6 `$rest` 数组的第二元素)
84    pub fn url_suffix(&self) -> &'static str {
85        match self {
86            ResourceAction::Index | ResourceAction::Save => "",
87            ResourceAction::Create => "/create",
88            ResourceAction::Edit => "/<id>/edit",
89            ResourceAction::Read | ResourceAction::Update | ResourceAction::Delete => "/<id>",
90        }
91    }
92
93    /// 返回控制器方法名(对齐 ThinkPHP 6 `$rest` 数组的第三元素)
94    pub fn controller_method(&self) -> &'static str {
95        self.as_str()
96    }
97
98    /// 返回全部 7 个动作(按 ThinkPHP 6 `$rest` 数组顺序)
99    pub fn all() -> &'static [ResourceAction] {
100        &[
101            ResourceAction::Index,
102            ResourceAction::Create,
103            ResourceAction::Edit,
104            ResourceAction::Read,
105            ResourceAction::Save,
106            ResourceAction::Update,
107            ResourceAction::Delete,
108        ]
109    }
110
111    /// 从字符串解析动作名(便捷方法,返回 Option)
112    ///
113    /// 注:对齐 `std::str::FromStr` trait 的语义,但保留 Option 返回类型以便测试使用。
114    pub fn parse_name(name: &str) -> Option<ResourceAction> {
115        match name {
116            "index" => Some(ResourceAction::Index),
117            "create" => Some(ResourceAction::Create),
118            "edit" => Some(ResourceAction::Edit),
119            "read" => Some(ResourceAction::Read),
120            "save" => Some(ResourceAction::Save),
121            "update" => Some(ResourceAction::Update),
122            "delete" => Some(ResourceAction::Delete),
123            _ => None,
124        }
125    }
126}
127
128/// 实现 `std::str::FromStr` trait(对齐 Rust 标准库约定)
129impl std::str::FromStr for ResourceAction {
130    type Err = String;
131
132    fn from_str(s: &str) -> Result<Self, Self::Err> {
133        Self::parse_name(s).ok_or_else(|| format!("unknown ResourceAction: {}", s))
134    }
135}
136
137/// HTTP 方法
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum HttpMethod {
140    Get,
141    Post,
142    Put,
143    Delete,
144    Patch,
145}
146
147impl HttpMethod {
148    /// 返回 HTTP 方法名(大写)
149    pub fn as_str(&self) -> &'static str {
150        match self {
151            HttpMethod::Get => "GET",
152            HttpMethod::Post => "POST",
153            HttpMethod::Put => "PUT",
154            HttpMethod::Delete => "DELETE",
155            HttpMethod::Patch => "PATCH",
156        }
157    }
158}
159
160impl fmt::Display for HttpMethod {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        write!(f, "{}", self.as_str())
163    }
164}
165
166/// 资源路由条目(单个生成的路由)
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ResourceRouteEntry {
169    /// 动作名
170    pub action: ResourceAction,
171    /// HTTP 方法
172    pub method: HttpMethod,
173    /// 完整 URL(含 addon 前缀)
174    pub url: String,
175    /// 控制器方法名
176    pub controller_method: String,
177}
178
179/// 资源路由选项(only/except 过滤,对齐 ThinkPHP 6 `Resource::only()` / `except()`)
180#[derive(Debug, Clone, Default)]
181pub struct ResourceOptions {
182    /// 仅生成指定的动作(对齐 `->only([...])`)
183    pub only: Vec<ResourceAction>,
184    /// 排除指定的动作(对齐 `->except([...])`)
185    pub except: Vec<ResourceAction>,
186}
187
188impl ResourceOptions {
189    /// 创建空选项(生成全部 7 条路由)
190    pub fn new() -> Self {
191        Self::default()
192    }
193
194    /// 设置 only 过滤
195    pub fn with_only(mut self, only: Vec<ResourceAction>) -> Self {
196        self.only = only;
197        self
198    }
199
200    /// 设置 except 过滤
201    pub fn with_except(mut self, except: Vec<ResourceAction>) -> Self {
202        self.except = except;
203        self
204    }
205
206    /// 判断指定动作是否应该生成
207    ///
208    /// 对齐 ThinkPHP 6 `Resource.php` 第 106-109 行的过滤逻辑:
209    /// `if (isset($option['only']) && !in_array($key, $option['only'])) continue;`
210    /// `if (isset($option['except']) && in_array($key, $option['except'])) continue;`
211    pub fn should_include(&self, action: ResourceAction) -> bool {
212        if !self.only.is_empty() && !self.only.contains(&action) {
213            return false;
214        }
215        if self.except.contains(&action) {
216            return false;
217        }
218        true
219    }
220}
221
222/// 生成的资源路由集合(Phase 10.3.5 主交付物)
223#[derive(Debug, Clone)]
224pub struct ResourceRoute {
225    /// 插件名
226    pub addon: String,
227    /// 控制器名(原始形式,可能含点号表示嵌套)
228    pub controller: String,
229    /// 基础 URL(含 addon 前缀,如 `/addons/operate/blog`)
230    pub base_url: String,
231    /// 生成的路由条目列表
232    pub entries: Vec<ResourceRouteEntry>,
233}
234
235impl ResourceRoute {
236    /// 获取指定动作的路由条目
237    pub fn get(&self, action: ResourceAction) -> Option<&ResourceRouteEntry> {
238        self.entries.iter().find(|e| e.action == action)
239    }
240
241    /// 返回生成的路由数量
242    pub fn len(&self) -> usize {
243        self.entries.len()
244    }
245
246    /// 是否为空
247    pub fn is_empty(&self) -> bool {
248        self.entries.is_empty()
249    }
250}
251
252/// 构建资源路由(Phase 10.3.5 主入口)
253///
254/// 借鉴 Rails 资源路由 + ThinkPHP 6 多应用机制,
255/// 自动注入 addon 路由前缀 `/addons/<addon>/<controller>`。
256///
257/// ## 参数
258///
259/// - `addon`:插件名(如 `"operate"`)
260/// - `controller`:控制器名(如 `"Blog"`),支持点号嵌套(如 `"blog.comment"`)
261/// - `options`:过滤选项(only/except)
262///
263/// ## 嵌套资源(对齐 ThinkPHP 6 `Resource.php` 第 89-100 行)
264///
265/// - `blog.comment` → 基础 URL `/addons/<addon>/blog/<blog_id>/comment`
266/// - 父级资源 ID 参数名默认为 `{name}_id`(对齐 PHP `$option['var'][$val] ?? $val . '_id'`)
267///
268/// ## 生成规则(对齐 ThinkPHP 6 `$rest` 默认数组)
269///
270/// 假设调用 `build_resource_routes("operate", "Blog", ResourceOptions::new())`:
271///
272/// | 动作 | HTTP 方法 | URL | 控制器方法 |
273/// |------|-----------|-----|-----------|
274/// | index | GET | `/addons/operate/Blog` | index |
275/// | create | GET | `/addons/operate/Blog/create` | create |
276/// | edit | GET | `/addons/operate/Blog/<id>/edit` | edit |
277/// | read | GET | `/addons/operate/Blog/<id>` | read |
278/// | save | POST | `/addons/operate/Blog` | save |
279/// | update | PUT | `/addons/operate/Blog/<id>` | update |
280/// | delete | DELETE | `/addons/operate/Blog/<id>` | delete |
281///
282/// ## 错误
283///
284/// - `RouteParse`:addon 或 controller 为空
285pub fn build_resource_routes(
286    addon: &str,
287    controller: &str,
288    options: &ResourceOptions,
289) -> AddonLoaderResult<ResourceRoute> {
290    if addon.is_empty() {
291        return Err(AddonLoaderError::RouteParse {
292            url: String::new(),
293            reason: "addon cannot be empty for resource route".to_string(),
294        });
295    }
296    if controller.is_empty() {
297        return Err(AddonLoaderError::RouteParse {
298            url: String::new(),
299            reason: "controller cannot be empty for resource route".to_string(),
300        });
301    }
302
303    // 构建基础 URL(自动注入 addon 前缀 + 处理嵌套资源)
304    let base_url = build_resource_base_url(addon, controller);
305
306    // 生成 7 条路由(对齐 ThinkPHP 6 `$rest` 默认数组)
307    let mut entries = Vec::new();
308    for &action in ResourceAction::all() {
309        if !options.should_include(action) {
310            continue;
311        }
312        let url = format!("{}{}", base_url, action.url_suffix());
313        entries.push(ResourceRouteEntry {
314            action,
315            method: action.http_method(),
316            url,
317            controller_method: action.controller_method().to_string(),
318        });
319    }
320
321    Ok(ResourceRoute {
322        addon: addon.to_string(),
323        controller: controller.to_string(),
324        base_url,
325        entries,
326    })
327}
328
329/// 构建资源路由基础 URL(对齐 ThinkPHP 6 嵌套资源处理)
330///
331/// ## 嵌套资源处理(对齐 `Resource.php` 第 89-100 行)
332///
333/// ```php
334/// if (strpos($rule, '.')) {
335///     $array = explode('.', $rule);
336///     $last  = array_pop($array);
337///     $item  = [];
338///     foreach ($array as $val) {
339///         $item[] = $val . '/<' . ($option['var'][$val] ?? $val . '_id') . '>';
340///     }
341///     $rule = implode('/', $item) . '/' . $last;
342/// }
343/// ```
344///
345/// ## 示例
346///
347/// - `build_resource_base_url("operate", "Blog")` → `/addons/operate/Blog`
348/// - `build_resource_base_url("operate", "blog.comment")` → `/addons/operate/blog/<blog_id>/comment`
349/// - `build_resource_base_url("operate", "blog.post.comment")` → `/addons/operate/blog/<blog_id>/post/<post_id>/comment`
350fn build_resource_base_url(addon: &str, controller: &str) -> String {
351    if !controller.contains('.') {
352        return format!("/addons/{}/{}", addon, controller);
353    }
354
355    // 嵌套资源:按点号拆分,末段为资源名,前段为父级资源
356    let mut parts: Vec<&str> = controller.split('.').collect();
357    let last = parts.pop().unwrap_or(controller);
358
359    // 对齐 PHP:父级资源段格式为 `{val}/<{val}_id>`
360    let mut segments: Vec<String> = Vec::new();
361    for val in &parts {
362        segments.push(format!("{}/<{}_id>", val, val));
363    }
364    segments.push(last.to_string());
365
366    format!("/addons/{}/{}", addon, segments.join("/"))
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    // ==================== ResourceAction 测试 ====================
374
375    #[test]
376    fn test_resource_action_as_str() {
377        assert_eq!(ResourceAction::Index.as_str(), "index");
378        assert_eq!(ResourceAction::Create.as_str(), "create");
379        assert_eq!(ResourceAction::Edit.as_str(), "edit");
380        assert_eq!(ResourceAction::Read.as_str(), "read");
381        assert_eq!(ResourceAction::Save.as_str(), "save");
382        assert_eq!(ResourceAction::Update.as_str(), "update");
383        assert_eq!(ResourceAction::Delete.as_str(), "delete");
384    }
385
386    #[test]
387    fn test_resource_action_http_method() {
388        assert_eq!(ResourceAction::Index.http_method(), HttpMethod::Get);
389        assert_eq!(ResourceAction::Create.http_method(), HttpMethod::Get);
390        assert_eq!(ResourceAction::Edit.http_method(), HttpMethod::Get);
391        assert_eq!(ResourceAction::Read.http_method(), HttpMethod::Get);
392        assert_eq!(ResourceAction::Save.http_method(), HttpMethod::Post);
393        assert_eq!(ResourceAction::Update.http_method(), HttpMethod::Put);
394        assert_eq!(ResourceAction::Delete.http_method(), HttpMethod::Delete);
395    }
396
397    #[test]
398    fn test_resource_action_url_suffix() {
399        assert_eq!(ResourceAction::Index.url_suffix(), "");
400        assert_eq!(ResourceAction::Create.url_suffix(), "/create");
401        assert_eq!(ResourceAction::Edit.url_suffix(), "/<id>/edit");
402        assert_eq!(ResourceAction::Read.url_suffix(), "/<id>");
403        assert_eq!(ResourceAction::Save.url_suffix(), "");
404        assert_eq!(ResourceAction::Update.url_suffix(), "/<id>");
405        assert_eq!(ResourceAction::Delete.url_suffix(), "/<id>");
406    }
407
408    #[test]
409    fn test_resource_action_controller_method() {
410        assert_eq!(ResourceAction::Index.controller_method(), "index");
411        assert_eq!(ResourceAction::Save.controller_method(), "save");
412        assert_eq!(ResourceAction::Delete.controller_method(), "delete");
413    }
414
415    #[test]
416    fn test_resource_action_all_returns_seven() {
417        let all = ResourceAction::all();
418        assert_eq!(all.len(), 7);
419        assert_eq!(all[0], ResourceAction::Index);
420        assert_eq!(all[1], ResourceAction::Create);
421        assert_eq!(all[2], ResourceAction::Edit);
422        assert_eq!(all[3], ResourceAction::Read);
423        assert_eq!(all[4], ResourceAction::Save);
424        assert_eq!(all[5], ResourceAction::Update);
425        assert_eq!(all[6], ResourceAction::Delete);
426    }
427
428    #[test]
429    fn test_resource_action_parse_name_valid() {
430        assert_eq!(
431            ResourceAction::parse_name("index"),
432            Some(ResourceAction::Index)
433        );
434        assert_eq!(
435            ResourceAction::parse_name("create"),
436            Some(ResourceAction::Create)
437        );
438        assert_eq!(
439            ResourceAction::parse_name("edit"),
440            Some(ResourceAction::Edit)
441        );
442        assert_eq!(
443            ResourceAction::parse_name("read"),
444            Some(ResourceAction::Read)
445        );
446        assert_eq!(
447            ResourceAction::parse_name("save"),
448            Some(ResourceAction::Save)
449        );
450        assert_eq!(
451            ResourceAction::parse_name("update"),
452            Some(ResourceAction::Update)
453        );
454        assert_eq!(
455            ResourceAction::parse_name("delete"),
456            Some(ResourceAction::Delete)
457        );
458    }
459
460    #[test]
461    fn test_resource_action_parse_name_invalid() {
462        assert_eq!(ResourceAction::parse_name("unknown"), None);
463        assert_eq!(ResourceAction::parse_name(""), None);
464        assert_eq!(ResourceAction::parse_name("INDEX"), None);
465    }
466
467    #[test]
468    fn test_resource_action_from_str_trait() {
469        // 验证 std::str::FromStr trait 实现
470        use std::str::FromStr;
471        assert_eq!(ResourceAction::from_str("index"), Ok(ResourceAction::Index));
472        assert_eq!(
473            ResourceAction::from_str("delete"),
474            Ok(ResourceAction::Delete)
475        );
476        assert!(ResourceAction::from_str("unknown").is_err());
477        assert!(ResourceAction::from_str("").is_err());
478    }
479
480    // ==================== HttpMethod 测试 ====================
481
482    #[test]
483    fn test_http_method_as_str() {
484        assert_eq!(HttpMethod::Get.as_str(), "GET");
485        assert_eq!(HttpMethod::Post.as_str(), "POST");
486        assert_eq!(HttpMethod::Put.as_str(), "PUT");
487        assert_eq!(HttpMethod::Delete.as_str(), "DELETE");
488        assert_eq!(HttpMethod::Patch.as_str(), "PATCH");
489    }
490
491    #[test]
492    fn test_http_method_display() {
493        assert_eq!(format!("{}", HttpMethod::Get), "GET");
494        assert_eq!(format!("{}", HttpMethod::Post), "POST");
495        assert_eq!(format!("{}", HttpMethod::Put), "PUT");
496        assert_eq!(format!("{}", HttpMethod::Delete), "DELETE");
497        assert_eq!(format!("{}", HttpMethod::Patch), "PATCH");
498    }
499
500    // ==================== ResourceOptions 测试 ====================
501
502    #[test]
503    fn test_resource_options_new() {
504        let opts = ResourceOptions::new();
505        assert!(opts.only.is_empty());
506        assert!(opts.except.is_empty());
507    }
508
509    #[test]
510    fn test_resource_options_with_only() {
511        let opts =
512            ResourceOptions::new().with_only(vec![ResourceAction::Index, ResourceAction::Read]);
513        assert_eq!(opts.only.len(), 2);
514        assert!(opts.except.is_empty());
515    }
516
517    #[test]
518    fn test_resource_options_with_except() {
519        let opts = ResourceOptions::new().with_except(vec![ResourceAction::Delete]);
520        assert!(opts.only.is_empty());
521        assert_eq!(opts.except.len(), 1);
522    }
523
524    #[test]
525    fn test_resource_options_with_only_and_except() {
526        let opts = ResourceOptions::new()
527            .with_only(vec![
528                ResourceAction::Index,
529                ResourceAction::Read,
530                ResourceAction::Delete,
531            ])
532            .with_except(vec![ResourceAction::Delete]);
533        assert_eq!(opts.only.len(), 3);
534        assert_eq!(opts.except.len(), 1);
535    }
536
537    #[test]
538    fn test_should_include_no_filter() {
539        let opts = ResourceOptions::new();
540        for &action in ResourceAction::all() {
541            assert!(
542                opts.should_include(action),
543                "{:?} should be included",
544                action
545            );
546        }
547    }
548
549    #[test]
550    fn test_should_include_only_filter() {
551        let opts =
552            ResourceOptions::new().with_only(vec![ResourceAction::Index, ResourceAction::Read]);
553        assert!(opts.should_include(ResourceAction::Index));
554        assert!(opts.should_include(ResourceAction::Read));
555        assert!(!opts.should_include(ResourceAction::Create));
556        assert!(!opts.should_include(ResourceAction::Edit));
557        assert!(!opts.should_include(ResourceAction::Save));
558        assert!(!opts.should_include(ResourceAction::Update));
559        assert!(!opts.should_include(ResourceAction::Delete));
560    }
561
562    #[test]
563    fn test_should_include_except_filter() {
564        let opts = ResourceOptions::new()
565            .with_except(vec![ResourceAction::Delete, ResourceAction::Create]);
566        assert!(opts.should_include(ResourceAction::Index));
567        assert!(!opts.should_include(ResourceAction::Create));
568        assert!(opts.should_include(ResourceAction::Edit));
569        assert!(opts.should_include(ResourceAction::Read));
570        assert!(opts.should_include(ResourceAction::Save));
571        assert!(opts.should_include(ResourceAction::Update));
572        assert!(!opts.should_include(ResourceAction::Delete));
573    }
574
575    #[test]
576    fn test_should_include_only_and_except_combined() {
577        // only 包含 Delete,但 except 也排除 Delete → Delete 被排除
578        let opts = ResourceOptions::new()
579            .with_only(vec![
580                ResourceAction::Index,
581                ResourceAction::Read,
582                ResourceAction::Delete,
583            ])
584            .with_except(vec![ResourceAction::Delete]);
585        assert!(opts.should_include(ResourceAction::Index));
586        assert!(opts.should_include(ResourceAction::Read));
587        assert!(!opts.should_include(ResourceAction::Delete));
588        assert!(!opts.should_include(ResourceAction::Create));
589    }
590
591    #[test]
592    fn test_should_include_empty_only_includes_all() {
593        // only 为空时不做 only 过滤(对齐 PHP `isset($option['only'])` 判断)
594        let opts = ResourceOptions::new().with_only(vec![]);
595        for &action in ResourceAction::all() {
596            assert!(opts.should_include(action));
597        }
598    }
599
600    // ==================== build_resource_base_url 测试 ====================
601
602    #[test]
603    fn test_build_resource_base_url_simple() {
604        let url = build_resource_base_url("operate", "Blog");
605        assert_eq!(url, "/addons/operate/Blog");
606    }
607
608    #[test]
609    fn test_build_resource_base_url_nested_two_levels() {
610        let url = build_resource_base_url("operate", "blog.comment");
611        assert_eq!(url, "/addons/operate/blog/<blog_id>/comment");
612    }
613
614    #[test]
615    fn test_build_resource_base_url_nested_three_levels() {
616        let url = build_resource_base_url("operate", "blog.post.comment");
617        assert_eq!(url, "/addons/operate/blog/<blog_id>/post/<post_id>/comment");
618    }
619
620    #[test]
621    fn test_build_resource_base_url_nested_four_levels() {
622        let url = build_resource_base_url("operate", "a.b.c.d");
623        assert_eq!(url, "/addons/operate/a/<a_id>/b/<b_id>/c/<c_id>/d");
624    }
625
626    // ==================== build_resource_routes 测试 ====================
627
628    #[test]
629    fn test_build_resource_routes_default_seven() {
630        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
631        assert_eq!(route.addon, "operate");
632        assert_eq!(route.controller, "Blog");
633        assert_eq!(route.base_url, "/addons/operate/Blog");
634        assert_eq!(route.len(), 7);
635        assert!(!route.is_empty());
636    }
637
638    #[test]
639    fn test_build_resource_routes_index_entry() {
640        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
641        let entry = route.get(ResourceAction::Index).unwrap();
642        assert_eq!(entry.action, ResourceAction::Index);
643        assert_eq!(entry.method, HttpMethod::Get);
644        assert_eq!(entry.url, "/addons/operate/Blog");
645        assert_eq!(entry.controller_method, "index");
646    }
647
648    #[test]
649    fn test_build_resource_routes_create_entry() {
650        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
651        let entry = route.get(ResourceAction::Create).unwrap();
652        assert_eq!(entry.action, ResourceAction::Create);
653        assert_eq!(entry.method, HttpMethod::Get);
654        assert_eq!(entry.url, "/addons/operate/Blog/create");
655        assert_eq!(entry.controller_method, "create");
656    }
657
658    #[test]
659    fn test_build_resource_routes_edit_entry() {
660        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
661        let entry = route.get(ResourceAction::Edit).unwrap();
662        assert_eq!(entry.action, ResourceAction::Edit);
663        assert_eq!(entry.method, HttpMethod::Get);
664        assert_eq!(entry.url, "/addons/operate/Blog/<id>/edit");
665        assert_eq!(entry.controller_method, "edit");
666    }
667
668    #[test]
669    fn test_build_resource_routes_read_entry() {
670        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
671        let entry = route.get(ResourceAction::Read).unwrap();
672        assert_eq!(entry.action, ResourceAction::Read);
673        assert_eq!(entry.method, HttpMethod::Get);
674        assert_eq!(entry.url, "/addons/operate/Blog/<id>");
675        assert_eq!(entry.controller_method, "read");
676    }
677
678    #[test]
679    fn test_build_resource_routes_save_entry() {
680        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
681        let entry = route.get(ResourceAction::Save).unwrap();
682        assert_eq!(entry.action, ResourceAction::Save);
683        assert_eq!(entry.method, HttpMethod::Post);
684        assert_eq!(entry.url, "/addons/operate/Blog");
685        assert_eq!(entry.controller_method, "save");
686    }
687
688    #[test]
689    fn test_build_resource_routes_update_entry() {
690        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
691        let entry = route.get(ResourceAction::Update).unwrap();
692        assert_eq!(entry.action, ResourceAction::Update);
693        assert_eq!(entry.method, HttpMethod::Put);
694        assert_eq!(entry.url, "/addons/operate/Blog/<id>");
695        assert_eq!(entry.controller_method, "update");
696    }
697
698    #[test]
699    fn test_build_resource_routes_delete_entry() {
700        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
701        let entry = route.get(ResourceAction::Delete).unwrap();
702        assert_eq!(entry.action, ResourceAction::Delete);
703        assert_eq!(entry.method, HttpMethod::Delete);
704        assert_eq!(entry.url, "/addons/operate/Blog/<id>");
705        assert_eq!(entry.controller_method, "delete");
706    }
707
708    #[test]
709    fn test_build_resource_routes_all_urls() {
710        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
711        let urls: Vec<&str> = route.entries.iter().map(|e| e.url.as_str()).collect();
712        assert_eq!(
713            urls,
714            vec![
715                "/addons/operate/Blog",
716                "/addons/operate/Blog/create",
717                "/addons/operate/Blog/<id>/edit",
718                "/addons/operate/Blog/<id>",
719                "/addons/operate/Blog",
720                "/addons/operate/Blog/<id>",
721                "/addons/operate/Blog/<id>",
722            ]
723        );
724    }
725
726    #[test]
727    fn test_build_resource_routes_all_methods() {
728        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
729        let methods: Vec<HttpMethod> = route.entries.iter().map(|e| e.method).collect();
730        assert_eq!(
731            methods,
732            vec![
733                HttpMethod::Get,
734                HttpMethod::Get,
735                HttpMethod::Get,
736                HttpMethod::Get,
737                HttpMethod::Post,
738                HttpMethod::Put,
739                HttpMethod::Delete,
740            ]
741        );
742    }
743
744    #[test]
745    fn test_build_resource_routes_only_index_read() {
746        let opts =
747            ResourceOptions::new().with_only(vec![ResourceAction::Index, ResourceAction::Read]);
748        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
749        assert_eq!(route.len(), 2);
750        assert!(route.get(ResourceAction::Index).is_some());
751        assert!(route.get(ResourceAction::Read).is_some());
752        assert!(route.get(ResourceAction::Create).is_none());
753        assert!(route.get(ResourceAction::Edit).is_none());
754        assert!(route.get(ResourceAction::Save).is_none());
755        assert!(route.get(ResourceAction::Update).is_none());
756        assert!(route.get(ResourceAction::Delete).is_none());
757    }
758
759    #[test]
760    fn test_build_resource_routes_except_delete() {
761        let opts = ResourceOptions::new().with_except(vec![ResourceAction::Delete]);
762        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
763        assert_eq!(route.len(), 6);
764        assert!(route.get(ResourceAction::Delete).is_none());
765        assert!(route.get(ResourceAction::Index).is_some());
766    }
767
768    #[test]
769    fn test_build_resource_routes_only_create_save() {
770        let opts =
771            ResourceOptions::new().with_only(vec![ResourceAction::Create, ResourceAction::Save]);
772        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
773        assert_eq!(route.len(), 2);
774        // 顺序按 only 指定?不对,按 $rest 默认数组顺序
775        assert_eq!(route.entries[0].action, ResourceAction::Create);
776        assert_eq!(route.entries[1].action, ResourceAction::Save);
777    }
778
779    #[test]
780    fn test_build_resource_routes_except_all() {
781        let opts = ResourceOptions::new().with_except(ResourceAction::all().to_vec());
782        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
783        assert_eq!(route.len(), 0);
784        assert!(route.is_empty());
785    }
786
787    #[test]
788    fn test_build_resource_routes_only_empty() {
789        let opts = ResourceOptions::new().with_only(vec![]);
790        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
791        assert_eq!(route.len(), 7);
792    }
793
794    #[test]
795    fn test_build_resource_routes_nested_resource() {
796        let route =
797            build_resource_routes("operate", "blog.comment", &ResourceOptions::new()).unwrap();
798        assert_eq!(route.base_url, "/addons/operate/blog/<blog_id>/comment");
799        assert_eq!(route.len(), 7);
800
801        let index = route.get(ResourceAction::Index).unwrap();
802        assert_eq!(index.url, "/addons/operate/blog/<blog_id>/comment");
803
804        let read = route.get(ResourceAction::Read).unwrap();
805        assert_eq!(read.url, "/addons/operate/blog/<blog_id>/comment/<id>");
806
807        let create = route.get(ResourceAction::Create).unwrap();
808        assert_eq!(create.url, "/addons/operate/blog/<blog_id>/comment/create");
809
810        let edit = route.get(ResourceAction::Edit).unwrap();
811        assert_eq!(edit.url, "/addons/operate/blog/<blog_id>/comment/<id>/edit");
812    }
813
814    #[test]
815    fn test_build_resource_routes_nested_three_levels() {
816        let route =
817            build_resource_routes("operate", "blog.post.comment", &ResourceOptions::new()).unwrap();
818        assert_eq!(
819            route.base_url,
820            "/addons/operate/blog/<blog_id>/post/<post_id>/comment"
821        );
822
823        let read = route.get(ResourceAction::Read).unwrap();
824        assert_eq!(
825            read.url,
826            "/addons/operate/blog/<blog_id>/post/<post_id>/comment/<id>"
827        );
828    }
829
830    #[test]
831    fn test_build_resource_routes_different_addon() {
832        let route = build_resource_routes("cashier", "Order", &ResourceOptions::new()).unwrap();
833        assert_eq!(route.base_url, "/addons/cashier/Order");
834        assert_eq!(route.addon, "cashier");
835        assert_eq!(route.controller, "Order");
836    }
837
838    #[test]
839    fn test_build_resource_routes_empty_addon() {
840        let result = build_resource_routes("", "Blog", &ResourceOptions::new());
841        assert!(result.is_err());
842        match result {
843            Err(AddonLoaderError::RouteParse { reason, .. }) => {
844                assert!(reason.contains("addon cannot be empty"));
845            }
846            _ => panic!("expected RouteParse error"),
847        }
848    }
849
850    #[test]
851    fn test_build_resource_routes_empty_controller() {
852        let result = build_resource_routes("operate", "", &ResourceOptions::new());
853        assert!(result.is_err());
854        match result {
855            Err(AddonLoaderError::RouteParse { reason, .. }) => {
856                assert!(reason.contains("controller cannot be empty"));
857            }
858            _ => panic!("expected RouteParse error"),
859        }
860    }
861
862    #[test]
863    fn test_build_resource_routes_get_returns_none_for_excluded() {
864        let opts = ResourceOptions::new().with_only(vec![ResourceAction::Index]);
865        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
866        assert!(route.get(ResourceAction::Index).is_some());
867        assert!(route.get(ResourceAction::Read).is_none());
868    }
869
870    #[test]
871    fn test_build_resource_routes_entries_order() {
872        // 验证生成顺序对齐 ThinkPHP 6 $rest 默认数组顺序
873        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
874        let actions: Vec<ResourceAction> = route.entries.iter().map(|e| e.action).collect();
875        assert_eq!(
876            actions,
877            vec![
878                ResourceAction::Index,
879                ResourceAction::Create,
880                ResourceAction::Edit,
881                ResourceAction::Read,
882                ResourceAction::Save,
883                ResourceAction::Update,
884                ResourceAction::Delete,
885            ]
886        );
887    }
888
889    // ==================== ResourceRoute 测试 ====================
890
891    #[test]
892    fn test_resource_route_len() {
893        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
894        assert_eq!(route.len(), 7);
895    }
896
897    #[test]
898    fn test_resource_route_is_empty_false() {
899        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
900        assert!(!route.is_empty());
901    }
902
903    #[test]
904    fn test_resource_route_is_empty_true() {
905        let opts = ResourceOptions::new().with_except(ResourceAction::all().to_vec());
906        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
907        assert!(route.is_empty());
908    }
909
910    #[test]
911    fn test_resource_route_get_existing() {
912        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
913        let entry = route.get(ResourceAction::Save).unwrap();
914        assert_eq!(entry.method, HttpMethod::Post);
915    }
916
917    #[test]
918    fn test_resource_route_get_nonexistent() {
919        let opts = ResourceOptions::new().with_only(vec![ResourceAction::Index]);
920        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
921        assert!(route.get(ResourceAction::Save).is_none());
922    }
923
924    // ==================== R5 ThinkPHP 6 行为对齐测试 ====================
925
926    #[test]
927    fn test_r5_thinkphp_rest_default_mapping() {
928        // 对齐 ThinkPHP 6 `$rest` 默认数组(Route.php 第 39-47 行)
929        // 验证 7 个动作的 HTTP 方法 + URL 后缀 + 控制器方法名
930        let expected: &[(ResourceAction, HttpMethod, &str, &str)] = &[
931            (ResourceAction::Index, HttpMethod::Get, "", "index"),
932            (ResourceAction::Create, HttpMethod::Get, "/create", "create"),
933            (ResourceAction::Edit, HttpMethod::Get, "/<id>/edit", "edit"),
934            (ResourceAction::Read, HttpMethod::Get, "/<id>", "read"),
935            (ResourceAction::Save, HttpMethod::Post, "", "save"),
936            (ResourceAction::Update, HttpMethod::Put, "/<id>", "update"),
937            (
938                ResourceAction::Delete,
939                HttpMethod::Delete,
940                "/<id>",
941                "delete",
942            ),
943        ];
944
945        for &(action, method, suffix, ctrl_method) in expected {
946            assert_eq!(
947                action.http_method(),
948                method,
949                "{:?} HTTP method mismatch",
950                action
951            );
952            assert_eq!(
953                action.url_suffix(),
954                suffix,
955                "{:?} URL suffix mismatch",
956                action
957            );
958            assert_eq!(
959                action.controller_method(),
960                ctrl_method,
961                "{:?} controller method mismatch",
962                action
963            );
964        }
965    }
966
967    #[test]
968    fn test_r5_thinkphp_resource_seven_routes() {
969        // 对齐 ThinkPHP 6 `Route::resource('blog', 'Blog')` 生成 7 条路由
970        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
971        assert_eq!(route.len(), 7, "ThinkPHP 6 resource must generate 7 routes");
972    }
973
974    #[test]
975    fn test_r5_thinkphp_only_filter() {
976        // 对齐 ThinkPHP 6 `Resource::only(['index', 'read'])` 过滤逻辑
977        let opts =
978            ResourceOptions::new().with_only(vec![ResourceAction::Index, ResourceAction::Read]);
979        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
980        assert_eq!(route.len(), 2);
981    }
982
983    #[test]
984    fn test_r5_thinkphp_except_filter() {
985        // 对齐 ThinkPHP 6 `Resource::except(['delete'])` 过滤逻辑
986        let opts = ResourceOptions::new().with_except(vec![ResourceAction::Delete]);
987        let route = build_resource_routes("operate", "Blog", &opts).unwrap();
988        assert_eq!(route.len(), 6);
989    }
990
991    #[test]
992    fn test_r5_thinkphp_nested_resource() {
993        // 对齐 ThinkPHP 6 `Route::resource('blog.comment', 'Comment')` 嵌套资源
994        // 父级资源 ID 参数名默认为 `{name}_id`
995        let route =
996            build_resource_routes("operate", "blog.comment", &ResourceOptions::new()).unwrap();
997        assert_eq!(route.base_url, "/addons/operate/blog/<blog_id>/comment");
998    }
999
1000    #[test]
1001    fn test_r5_thinkphp_no_add_action() {
1002        // 对齐 ThinkPHP 6 默认映射中没有 `add` 动作
1003        // 新建资源使用 `save`(POST 到根 URL)
1004        assert!(ResourceAction::parse_name("add").is_none());
1005        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
1006        assert!(route.get(ResourceAction::Save).is_some());
1007        // 确认 save 是 POST 方法
1008        assert_eq!(
1009            route.get(ResourceAction::Save).unwrap().method,
1010            HttpMethod::Post
1011        );
1012    }
1013
1014    #[test]
1015    fn test_r5_thinkphp_update_uses_put_not_patch() {
1016        // 对齐 ThinkPHP 6 默认 `$rest` 数组中 update 使用 PUT 方法(不是 PATCH)
1017        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
1018        let update = route.get(ResourceAction::Update).unwrap();
1019        assert_eq!(update.method, HttpMethod::Put);
1020    }
1021
1022    #[test]
1023    fn test_r5_thinkphp_index_and_save_share_url() {
1024        // 对齐 ThinkPHP 6:index(GET)和 save(POST)共享相同的 URL 后缀 `''`
1025        // 通过 HTTP 方法区分
1026        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
1027        let index = route.get(ResourceAction::Index).unwrap();
1028        let save = route.get(ResourceAction::Save).unwrap();
1029        assert_eq!(index.url, save.url);
1030        assert_ne!(index.method, save.method);
1031    }
1032
1033    #[test]
1034    fn test_r5_thinkphp_read_update_delete_share_url() {
1035        // 对齐 ThinkPHP 6:read(GET)、update(PUT)、delete(DELETE)共享 URL 后缀 `/<id>`
1036        // 通过 HTTP 方法区分
1037        let route = build_resource_routes("operate", "Blog", &ResourceOptions::new()).unwrap();
1038        let read = route.get(ResourceAction::Read).unwrap();
1039        let update = route.get(ResourceAction::Update).unwrap();
1040        let delete = route.get(ResourceAction::Delete).unwrap();
1041        assert_eq!(read.url, update.url);
1042        assert_eq!(read.url, delete.url);
1043        assert_ne!(read.method, update.method);
1044        assert_ne!(read.method, delete.method);
1045        assert_ne!(update.method, delete.method);
1046    }
1047
1048    #[test]
1049    fn test_r5_addon_prefix_injection() {
1050        // Phase 10.3.5 核心:自动注入 addon 路由前缀
1051        let route = build_resource_routes("cashier", "Order", &ResourceOptions::new()).unwrap();
1052        assert!(route.base_url.starts_with("/addons/cashier/"));
1053        for entry in &route.entries {
1054            assert!(entry.url.starts_with("/addons/cashier/Order"));
1055        }
1056    }
1057
1058    #[test]
1059    fn test_r5_addon_prefix_injection_nested() {
1060        // 嵌套资源也注入 addon 前缀
1061        let route =
1062            build_resource_routes("operate", "blog.comment", &ResourceOptions::new()).unwrap();
1063        assert!(route.base_url.starts_with("/addons/operate/"));
1064        for entry in &route.entries {
1065            assert!(entry.url.starts_with("/addons/operate/blog/"));
1066        }
1067    }
1068}