Skip to main content

sz_rust_addons_loader/
lib.rs

1//! # SZ-Rust Addons Loader — 插件加载器
2//!
3//! 对标 PHP `zzstudio/think-addons` 的 Rust 实现,提供插件发现/清单解析/注册/路由解析能力。
4//!
5//! ## PHP 对齐
6//!
7//! | PHP 模块 | Rust 模块 | 子任务 |
8//! |----------|----------|--------|
9//! | `think\addons\Service` | `loader::AddonLoader` | 10.1 |
10//! | `think\Addons::getInfo()` | `manifest::parse_manifest` | 10.1 |
11//! | `spl_autoload_register`(helper.php) | `autoload::AddonAutoload` | 10.1 |
12//! | 隐式插件状态管理 | `registry::AddonRegistry` | 10.2 |
13//! | `think\addons\Route::execute` | `route::parse_route` | 10.3 |
14//! | `think\Route::resource` | `resource::build_resource_routes` | 10.3.5 |
15//!
16//! ## 模块结构
17//!
18//! ```text
19//! sz-rust-addons-loader/
20//! ├── Cargo.toml
21//! └── src/
22//!     ├── lib.rs          # 主入口(重导出)
23//!     ├── error.rs        # 错误类型
24//!     ├── loader.rs       # AddonLoader(对齐 Service.php)
25//!     ├── manifest.rs     # 清单解析(对齐 Addons::getInfo)
26//!     ├── autoload.rs     # 自动加载(对齐 spl_autoload_register)
27//!     ├── registry.rs     # 注册中心(对齐插件状态管理)
28//!     ├── route.rs        # 路由解析(对齐 Route::execute)
29//!     └── resource.rs     # 资源路由注入(对齐 Route::resource)
30//! ```
31//!
32//! ## 用法
33//!
34//! ```rust,ignore
35//! use sz_rust_addons_loader::loader::AddonLoader;
36//!
37//! let loader = AddonLoader::new("/path/to/addons");
38//! let errors = loader.register().unwrap();
39//! // errors 包含解析失败的插件错误(不中断整体扫描)
40//!
41//! let route = loader.parse_route("/addons/operate/Order/index").unwrap();
42//! assert_eq!(route.addon, "operate");
43//! assert_eq!(route.controller, "Order");
44//! assert_eq!(route.action, "index");
45//! ```
46//!
47//! ## 不支持(与 PHP 差异)
48//!
49//! - **不实际加载 PHP 文件**:Rust 无法直接执行 PHP,仅解析 `$info` 数组
50//! - **不实现钩子自动扫描**:PHP 通过反射收集 Plugin.php 非基类方法作为钩子,Rust 侧需调用方显式注册
51//! - **不实现 `service.ini` 容器绑定**:Rust 侧无容器概念,由调用方根据需要处理
52//! - **不实现 `_empty` 兜底方法**:对齐 PHP `is_callable([$instance, '_empty'])`,但需调用方实现
53
54#![forbid(unsafe_code)]
55#![warn(missing_docs)]
56
57pub mod autoload;
58pub mod error;
59pub mod loader;
60pub mod manifest;
61pub mod registry;
62pub mod resource;
63pub mod route;
64
65// 重导出核心类型,对齐 PHP `think\addons` 命名空间
66pub use autoload::AddonAutoload;
67pub use error::{AddonLoaderError, AddonLoaderResult};
68pub use loader::AddonLoader;
69pub use manifest::AddonManifest;
70pub use registry::AddonRegistry;
71pub use resource::{
72    build_resource_routes, HttpMethod, ResourceAction, ResourceOptions, ResourceRoute,
73    ResourceRouteEntry,
74};
75pub use route::AddonRoute;
76
77#[cfg(test)]
78mod integration_tests {
79    use super::*;
80    use std::fs;
81
82    /// 端到端集成测试:完整生命周期
83    ///
84    /// 验证 AddonLoader 的完整流程:
85    /// 1. 创建临时插件目录
86    /// 2. 编写 Plugin.php(含 $info 数组)
87    /// 3. 创建控制器文件
88    /// 4. register() 扫描并注册
89    /// 5. parse_route() 解析 URL
90    /// 6. 验证路由信息正确
91    #[test]
92    fn test_end_to_end_lifecycle() {
93        let tmp = tempfile::tempdir().expect("create tempdir");
94        let addons_path = tmp.path().join("addons");
95
96        // 创建 operate 插件
97        let operate_dir = addons_path.join("operate");
98        fs::create_dir_all(&operate_dir).unwrap();
99        fs::write(
100            operate_dir.join("Plugin.php"),
101            r#"<?php
102namespace addons\operate;
103use think\Addons;
104class Plugin extends Addons {
105    public $info = [
106        'name' => 'operate',
107        'title' => '运营管理',
108        'identifier' => 'operate.addon',
109        'icon' => 'fa-cog',
110        'author' => 'sz',
111        'version' => '1.0.0',
112        'admin' => 'operate/index/index',
113        'status' => 1,
114    ];
115    public function install() {}
116    public function uninstall() {}
117}
118"#,
119        )
120        .unwrap();
121
122        // 创建 operate/controller/Order.php
123        let controller_dir = operate_dir.join("controller");
124        fs::create_dir_all(&controller_dir).unwrap();
125        fs::write(controller_dir.join("Order.php"), "<?php // stub").unwrap();
126
127        // 创建 operate/controller/admin/Order.php
128        let admin_dir = controller_dir.join("admin");
129        fs::create_dir_all(&admin_dir).unwrap();
130        fs::write(admin_dir.join("Order.php"), "<?php // stub").unwrap();
131
132        // 创建 disabled 插件(status=0)
133        let disabled_dir = addons_path.join("disabled");
134        fs::create_dir_all(&disabled_dir).unwrap();
135        fs::write(
136            disabled_dir.join("Plugin.php"),
137            r#"
138public $info = [
139    'name' => 'disabled',
140    'status' => 0,
141];
142"#,
143        )
144        .unwrap();
145
146        // 1. 创建加载器
147        let loader = AddonLoader::new(&addons_path);
148        assert_eq!(loader.count(), 0);
149
150        // 2. 注册所有插件
151        let errors = loader.register().unwrap();
152        assert!(errors.is_empty(), "register errors: {:?}", errors);
153        assert_eq!(loader.count(), 2);
154
155        // 3. 验证 operate 启用、disabled 禁用
156        assert!(loader.is_enabled("operate").unwrap());
157        assert!(!loader.is_enabled("disabled").unwrap());
158
159        // 4. 验证清单字段
160        let manifest = loader.get_manifest("operate").unwrap();
161        assert_eq!(manifest.name, "operate");
162        assert_eq!(manifest.title, "运营管理");
163        assert_eq!(manifest.version, "1.0.0");
164        assert!(manifest.is_enabled());
165
166        // 5. 解析简单路由
167        let route = loader.parse_route("/addons/operate/Order/index").unwrap();
168        assert_eq!(route.addon, "operate");
169        assert_eq!(route.controller, "Order");
170        assert_eq!(route.action, "index");
171        assert_eq!(route.controller_class, "addons\\operate\\controller\\Order");
172        assert!(route.controller_file.is_some());
173        assert!(route.controller_file.unwrap().exists());
174
175        // 6. 解析多级控制器路由(点号分隔)
176        let route2 = loader
177            .parse_route("/addons/operate/admin.Order/index")
178            .unwrap();
179        assert_eq!(route2.controller, "admin.Order");
180        assert_eq!(
181            route2.controller_class,
182            "addons\\operate\\controller\\admin\\Order"
183        );
184        assert!(route2.controller_file.is_some());
185
186        // 7. 禁用插件应抛 AddonDisabled
187        let result = loader.parse_route("/addons/disabled/Order/index");
188        assert!(matches!(result, Err(AddonLoaderError::AddonDisabled(_))));
189
190        // 8. 不存在的插件应抛 AddonNotFound
191        let result = loader.parse_route("/addons/ghost/Order/index");
192        assert!(matches!(result, Err(AddonLoaderError::AddonNotFound(_))));
193    }
194
195    /// 测试混合启用/禁用插件列表
196    #[test]
197    fn test_mixed_enabled_disabled_addons() {
198        let tmp = tempfile::tempdir().unwrap();
199        let addons_path = tmp.path().join("addons");
200
201        for (name, status) in [
202            ("enabled1", 1),
203            ("enabled2", 1),
204            ("disabled1", 0),
205            ("disabled2", 0),
206        ] {
207            let dir = addons_path.join(name);
208            fs::create_dir_all(&dir).unwrap();
209            fs::write(
210                dir.join("Plugin.php"),
211                format!(
212                    r#"
213public $info = [
214    'name' => '{}',
215    'status' => {},
216];
217"#,
218                    name, status
219                ),
220            )
221            .unwrap();
222        }
223
224        let loader = AddonLoader::new(&addons_path);
225        loader.register().unwrap();
226
227        assert_eq!(loader.count(), 4);
228        assert_eq!(loader.enabled_addons().len(), 2);
229        assert_eq!(loader.disabled_addons().len(), 2);
230        assert_eq!(
231            loader.names(),
232            vec!["disabled1", "disabled2", "enabled1", "enabled2"]
233        );
234    }
235
236    /// 测试动态状态切换
237    #[test]
238    fn test_dynamic_status_toggle() {
239        let tmp = tempfile::tempdir().unwrap();
240        let addons_path = tmp.path().join("addons");
241        let dir = addons_path.join("operate");
242        fs::create_dir_all(&dir).unwrap();
243        fs::write(
244            dir.join("Plugin.php"),
245            r#"
246public $info = [
247    'name' => 'operate',
248    'status' => 1,
249];
250"#,
251        )
252        .unwrap();
253
254        let loader = AddonLoader::new(&addons_path);
255        loader.register().unwrap();
256
257        assert!(loader.is_enabled("operate").unwrap());
258
259        // 动态禁用
260        loader.registry().set_enabled("operate", false).unwrap();
261        assert!(!loader.is_enabled("operate").unwrap());
262
263        // 动态启用
264        loader.registry().set_enabled("operate", true).unwrap();
265        assert!(loader.is_enabled("operate").unwrap());
266    }
267
268    /// 测试 autoload 与 registry 协作
269    #[test]
270    fn test_autoload_registry_collaboration() {
271        let tmp = tempfile::tempdir().unwrap();
272        let addons_path = tmp.path().join("addons");
273        let operate_dir = addons_path.join("operate");
274        fs::create_dir_all(&operate_dir).unwrap();
275        fs::write(operate_dir.join("Plugin.php"), "<?php // stub").unwrap();
276        fs::write(
277            operate_dir.join("Plugin.php"),
278            r#"
279public $info = [
280    'name' => 'operate',
281    'status' => 1,
282];
283"#,
284        )
285        .unwrap();
286
287        let controller_dir = operate_dir.join("controller");
288        fs::create_dir_all(&controller_dir).unwrap();
289        fs::write(controller_dir.join("Order.php"), "<?php // stub").unwrap();
290
291        let loader = AddonLoader::new(&addons_path);
292        loader.register().unwrap();
293
294        // 通过 autoload 解析类路径
295        let path = loader
296            .resolve_class("addons\\operate\\controller\\Order")
297            .unwrap();
298        assert!(path.is_some());
299        assert!(path.unwrap().exists());
300
301        // 通过 autoload 解析插件入口类
302        let plugin_path = loader.resolve_plugin("operate").unwrap();
303        assert!(plugin_path.is_some());
304        assert!(plugin_path.unwrap().exists());
305    }
306
307    /// 测试多个插件目录扫描
308    #[test]
309    fn test_multiple_addons_scan() {
310        let tmp = tempfile::tempdir().unwrap();
311        let addons_path = tmp.path().join("addons");
312
313        for name in &["operate", "cashier", "food", "erp"] {
314            let dir = addons_path.join(name);
315            fs::create_dir_all(&dir).unwrap();
316            fs::write(
317                dir.join("Plugin.php"),
318                format!(
319                    r#"
320public $info = [
321    'name' => '{}',
322    'status' => 1,
323];
324"#,
325                    name
326                ),
327            )
328            .unwrap();
329        }
330
331        let loader = AddonLoader::new(&addons_path);
332        let errors = loader.register().unwrap();
333        assert!(errors.is_empty());
334        assert_eq!(loader.count(), 4);
335        assert_eq!(loader.names(), vec!["cashier", "erp", "food", "operate"]);
336    }
337
338    /// 测试部分插件损坏时整体扫描不中断
339    #[test]
340    fn test_partial_failure_continues_scan() {
341        let tmp = tempfile::tempdir().unwrap();
342        let addons_path = tmp.path().join("addons");
343
344        // 好插件
345        let good_dir = addons_path.join("good");
346        fs::create_dir_all(&good_dir).unwrap();
347        fs::write(
348            good_dir.join("Plugin.php"),
349            r#"
350public $info = [
351    'name' => 'good',
352    'status' => 1,
353];
354"#,
355        )
356        .unwrap();
357
358        // 坏插件(无 $info 数组)
359        let bad_dir = addons_path.join("bad");
360        fs::create_dir_all(&bad_dir).unwrap();
361        fs::write(bad_dir.join("Plugin.php"), "<?php class Plugin {}").unwrap();
362
363        let loader = AddonLoader::new(&addons_path);
364        let errors = loader.register().unwrap();
365
366        // bad 插件应记录错误
367        assert!(!errors.is_empty());
368        // good 插件应正常注册
369        assert_eq!(loader.count(), 1);
370        assert!(loader.exists("good"));
371        assert!(!loader.exists("bad"));
372    }
373
374    /// 测试空插件目录
375    #[test]
376    fn test_empty_addons_directory() {
377        let tmp = tempfile::tempdir().unwrap();
378        let addons_path = tmp.path().join("addons");
379        fs::create_dir_all(&addons_path).unwrap();
380
381        let loader = AddonLoader::new(&addons_path);
382        let errors = loader.register().unwrap();
383        assert!(errors.is_empty());
384        assert_eq!(loader.count(), 0);
385    }
386}