Skip to main content

sz_rust_addons_loader/
manifest.rs

1//! 插件清单解析
2//!
3//! ## PHP 对齐
4//!
5//! 对齐 PHP `think\Addons::getInfo()` 的行为:
6//!
7//! ```php
8//! // vendor/zzstudio/think-addons/src/Addons.php:85-110
9//! final public function getInfo(): array
10//! {
11//!     // 1. 先读缓存
12//!     $info = Config::get($this->addon_info, []);
13//!     if (empty($info)) {
14//!         // 2. 合并子类 $info 属性与 info.ini 文件
15//!         $info = $this->info;
16//!         $info_file = $this->addon_path . 'info.ini';
17//!         if (is_file($info_file)) {
18//!             $info = array_merge($info, parse_ini_file($info_file, true, INI_SCANNER_TYPED));
19//!         }
20//!     }
21//!     // 3. 自动注入 url 字段
22//!     $_info['url'] = addons_url();
23//!     Config::set($info, $this->addon_info);
24//!     return $info;
25//! }
26//! ```
27//!
28//! ## Plugin.php 清单字段
29//!
30//! 对齐 PHP 插件入口文件的 `$info` 数组:
31//!
32//! | 字段 | 类型 | 说明 |
33//! |------|------|------|
34//! | `name` | string | 插件标识(与目录名一致) |
35//! | `title` | string | 插件标题 |
36//! | `identifier` | string | 插件唯一标识符 |
37//! | `icon` | string | 图标路径 |
38//! | `author` | string | 作者 |
39//! | `version` | string | 版本号 |
40//! | `admin` | string | 后台管理 URL |
41//! | `status` | int | 状态(1=启用,0=禁用) |
42
43use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45
46use serde::{Deserialize, Serialize};
47
48use crate::error::{AddonLoaderError, AddonLoaderResult};
49
50/// 插件清单信息(对齐 PHP `think\Addons::getInfo()` 返回的 `$info` 数组)
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct AddonManifest {
53    /// 插件标识(对齐 `$info['name']`)
54    pub name: String,
55    /// 插件标题(对齐 `$info['title']`)
56    pub title: String,
57    /// 唯一标识符(对齐 `$info['identifier']`)
58    pub identifier: String,
59    /// 图标路径(对齐 `$info['icon']`)
60    pub icon: String,
61    /// 作者(对齐 `$info['author']`)
62    pub author: String,
63    /// 版本号(对齐 `$info['version']`)
64    pub version: String,
65    /// 后台管理 URL(对齐 `$info['admin']`)
66    pub admin: String,
67    /// 状态:1=启用,0=禁用(对齐 `$info['status']`,被 `Route::execute` 检查)
68    pub status: i64,
69    /// 插件目录绝对路径(Rust 侧额外字段,用于后续加载文件)
70    #[serde(skip)]
71    pub addon_path: PathBuf,
72}
73
74impl AddonManifest {
75    /// 创建空清单(用于测试)
76    pub fn new(name: impl Into<String>) -> Self {
77        Self {
78            name: name.into(),
79            title: String::new(),
80            identifier: String::new(),
81            icon: String::new(),
82            author: String::new(),
83            version: String::new(),
84            admin: String::new(),
85            status: 0,
86            addon_path: PathBuf::new(),
87        }
88    }
89
90    /// 判断插件是否启用(对齐 PHP `Route::execute` 中 `if (!$info['status'])` 检查)
91    pub fn is_enabled(&self) -> bool {
92        self.status != 0
93    }
94
95    /// 获取插件入口文件路径(对齐 PHP `ucfirst($name) . '.php'`,但 Rust 侧统一使用 `Plugin.php`)
96    pub fn plugin_file(&self) -> PathBuf {
97        self.addon_path.join("Plugin.php")
98    }
99
100    /// 获取 info.ini 文件路径(对齐 PHP `info.ini`)
101    pub fn info_ini_file(&self) -> PathBuf {
102        self.addon_path.join("info.ini")
103    }
104
105    /// 获取 config.php 文件路径(对齐 PHP `config.php`)
106    pub fn config_php_file(&self) -> PathBuf {
107        self.addon_path.join("config.php")
108    }
109
110    /// 获取 service.ini 文件路径(对齐 PHP `service.ini`)
111    pub fn service_ini_file(&self) -> PathBuf {
112        self.addon_path.join("service.ini")
113    }
114
115    /// 获取视图目录路径(对齐 PHP `{addon_path}/view/`)
116    pub fn view_dir(&self) -> PathBuf {
117        self.addon_path.join("view")
118    }
119
120    /// 获取控制器目录路径(对齐 PHP `{addon_path}/controller/`)
121    pub fn controller_dir(&self) -> PathBuf {
122        self.addon_path.join("controller")
123    }
124
125    /// 获取模型目录路径(对齐 PHP `{addon_path}/model/`)
126    pub fn model_dir(&self) -> PathBuf {
127        self.addon_path.join("model")
128    }
129}
130
131/// 从插件目录解析清单(对齐 PHP `getInfo()` 流程)
132///
133/// ## 解析顺序(对齐 PHP)
134///
135/// 1. 读取 `Plugin.php`,提取 `$info` 数组(对齐子类 `$info` 属性)
136/// 2. 读取 `info.ini`(若存在),合并覆盖(对齐 `array_merge`)
137/// 3. 设置 `addon_path` 为插件目录绝对路径
138///
139/// ## PHP Plugin.php 解析
140///
141/// 由于 Rust 无法直接执行 PHP,本函数通过简单的字符串扫描解析 `$info = [...]` 数组,
142/// 支持字符串值(单引号/双引号)和整数值。
143///
144/// ## 错误
145///
146/// - `ManifestParse`:Plugin.php 不存在或 `$info` 数组格式错误
147/// - `ReadFile`:文件读取失败
148#[tracing::instrument]
149pub fn parse_manifest(addon_path: &Path) -> AddonLoaderResult<AddonManifest> {
150    let plugin_file = addon_path.join("Plugin.php");
151    if !plugin_file.exists() {
152        return Err(AddonLoaderError::ManifestParse {
153            addon: addon_path
154                .file_name()
155                .and_then(|n| n.to_str())
156                .unwrap_or("<unknown>")
157                .to_string(),
158            reason: format!("Plugin.php not found in {}", addon_path.display()),
159        });
160    }
161
162    let plugin_content =
163        std::fs::read_to_string(&plugin_file).map_err(|e| AddonLoaderError::ReadFile {
164            path: plugin_file.display().to_string(),
165            source: e,
166        })?;
167
168    // 解析 Plugin.php 中的 $info 数组
169    let mut info =
170        parse_php_info_array(&plugin_content).ok_or_else(|| AddonLoaderError::ManifestParse {
171            addon: addon_path
172                .file_name()
173                .and_then(|n| n.to_str())
174                .unwrap_or("<unknown>")
175                .to_string(),
176            reason: "$info array not found or malformed in Plugin.php".to_string(),
177        })?;
178
179    // 合并 info.ini(若存在)
180    let info_ini_path = addon_path.join("info.ini");
181    if info_ini_path.exists() {
182        let ini_content =
183            std::fs::read_to_string(&info_ini_path).map_err(|e| AddonLoaderError::ReadFile {
184                path: info_ini_path.display().to_string(),
185                source: e,
186            })?;
187        let ini_map = parse_simple_ini(&ini_content);
188        for (key, value) in ini_map {
189            info.insert(key, value);
190        }
191    }
192
193    // 构建清单(对齐 PHP getInfo 返回字段)
194    let name = addon_path
195        .file_name()
196        .and_then(|n| n.to_str())
197        .unwrap_or_default()
198        .to_string();
199    let manifest = build_manifest_from_info(&name, addon_path.to_path_buf(), info)?;
200
201    Ok(manifest)
202}
203
204/// 构建 AddonManifest 从 info map(内部辅助函数)
205fn build_manifest_from_info(
206    fallback_name: &str,
207    addon_path: PathBuf,
208    info: BTreeMap<String, PhpValue>,
209) -> AddonLoaderResult<AddonManifest> {
210    let get_string =
211        |key: &str| -> String { info.get(key).map(|v| v.as_string()).unwrap_or_default() };
212    let get_int = |key: &str| -> i64 { info.get(key).map(|v| v.as_int()).unwrap_or(0) };
213
214    // 对齐 PHP `Route::execute` 中的 status 检查:假值会抛 500
215    let status = get_int("status");
216
217    Ok(AddonManifest {
218        name: get_string("name").if_empty(fallback_name),
219        title: get_string("title"),
220        identifier: get_string("identifier"),
221        icon: get_string("icon"),
222        author: get_string("author"),
223        version: get_string("version"),
224        admin: get_string("admin"),
225        status,
226        addon_path,
227    })
228}
229
230/// 简单的 PHP 值类型(用于解析 $info 数组)
231#[derive(Debug, Clone, PartialEq)]
232enum PhpValue {
233    /// 字符串值(单引号/双引号)
234    Str(String),
235    /// 整数值
236    Int(i64),
237    /// 布尔值
238    Bool(bool),
239}
240
241impl PhpValue {
242    /// 转字符串(对齐 PHP 字符串上下文转换)
243    fn as_string(&self) -> String {
244        match self {
245            PhpValue::Str(s) => s.clone(),
246            PhpValue::Int(i) => i.to_string(),
247            PhpValue::Bool(b) => {
248                if *b {
249                    "1".to_string()
250                } else {
251                    "".to_string()
252                }
253            }
254        }
255    }
256
257    /// 转整数(对齐 PHP 整数上下文转换)
258    fn as_int(&self) -> i64 {
259        match self {
260            PhpValue::Str(s) => s.parse().unwrap_or(0),
261            PhpValue::Int(i) => *i,
262            PhpValue::Bool(b) => {
263                if *b {
264                    1
265                } else {
266                    0
267                }
268            }
269        }
270    }
271}
272
273/// 解析 Plugin.php 中的 `$info = [...]` 数组(对齐 PHP 子类 `$info` 属性)
274///
275/// ## 支持语法
276///
277/// ```php
278/// public $info = [
279///     'name' => 'operate',
280///     'title' => '运营管理',
281///     'status' => 1,
282/// ];
283/// ```
284///
285/// ## 解析策略
286///
287/// 1. 使用正则匹配 `$info\s*=\s*\[` 找到数组起始位置
288/// 2. 从起始括号开始扫描,平衡括号匹配找到数组结束位置
289/// 3. 逐行解析 `key => value` 对
290/// 4. 支持字符串(单引号/双引号)、整数、布尔值
291fn parse_php_info_array(content: &str) -> Option<BTreeMap<String, PhpValue>> {
292    // 查找 $info = [ 的位置
293    let info_regex = regex::Regex::new(r#"\$info\s*=\s*\["#).ok()?;
294    let cap = info_regex.find(content)?;
295    let array_start = cap.end() - 1; // 指向 '['
296
297    // 平衡括号扫描找到匹配的 ']'
298    let bytes = content.as_bytes();
299    let mut depth = 0i32;
300    let mut array_end = None;
301    let mut in_string = false;
302    let mut string_char = b'\0';
303    let mut escape = false;
304
305    for (i, &c) in bytes.iter().enumerate().skip(array_start) {
306        if escape {
307            escape = false;
308            continue;
309        }
310
311        if in_string {
312            if c == b'\\' {
313                escape = true;
314            } else if c == string_char {
315                in_string = false;
316            }
317            continue;
318        }
319
320        match c {
321            b'\'' | b'"' => {
322                in_string = true;
323                string_char = c;
324            }
325            b'[' => depth += 1,
326            b']' => {
327                depth -= 1;
328                if depth == 0 {
329                    array_end = Some(i);
330                    break;
331                }
332            }
333            _ => {}
334        }
335    }
336
337    let array_end = array_end?;
338    let array_body = &content[array_start + 1..array_end];
339
340    // 逐行解析 key => value
341    let mut map = BTreeMap::new();
342    parse_php_array_body(array_body, &mut map);
343    Some(map)
344}
345
346/// 解析 PHP 数组体(`key => value, key => value,` 格式)
347fn parse_php_array_body(body: &str, map: &mut BTreeMap<String, PhpValue>) {
348    let mut chars = body.chars().peekable();
349    let mut current_key: Option<String> = None;
350    let mut buffer = String::new();
351
352    while let Some(&c) = chars.peek() {
353        match c {
354            // 跳过空白和注释
355            ' ' | '\t' | '\n' | '\r' | ',' => {
356                chars.next();
357            }
358            // 字符串键或值
359            '\'' | '"' => {
360                let quote = c;
361                chars.next(); // 消费引号
362                let mut value = String::new();
363                let mut escaped = false;
364                while let Some(&cc) = chars.peek() {
365                    if escaped {
366                        match cc {
367                            'n' => value.push('\n'),
368                            't' => value.push('\t'),
369                            'r' => value.push('\r'),
370                            '\\' => value.push('\\'),
371                            '\'' => value.push('\''),
372                            '"' => value.push('"'),
373                            _ => value.push(cc),
374                        }
375                        escaped = false;
376                        chars.next();
377                        continue;
378                    }
379                    if cc == '\\' {
380                        escaped = true;
381                        chars.next();
382                        continue;
383                    }
384                    if cc == quote {
385                        chars.next();
386                        break;
387                    }
388                    value.push(cc);
389                    chars.next();
390                }
391
392                // 检查后面是否跟着 =>
393                skip_whitespace(&mut chars);
394                if chars.peek() == Some(&'=') {
395                    chars.next();
396                    if chars.peek() == Some(&'>') {
397                        chars.next();
398                        current_key = Some(value);
399                    }
400                } else {
401                    if let Some(key) = current_key.take() {
402                        map.insert(key, PhpValue::Str(value));
403                    }
404                }
405            }
406            // 数字
407            '0'..='9' | '-' => {
408                let mut num = String::new();
409                while let Some(&cc) = chars.peek() {
410                    if cc.is_ascii_digit() || cc == '-' || cc == '+' {
411                        num.push(cc);
412                        chars.next();
413                    } else {
414                        break;
415                    }
416                }
417                if let Ok(n) = num.parse::<i64>() {
418                    if let Some(key) = current_key.take() {
419                        map.insert(key, PhpValue::Int(n));
420                    }
421                }
422            }
423            // true/false/null
424            't' | 'f' | 'n' => {
425                let mut word = String::new();
426                while let Some(&cc) = chars.peek() {
427                    if cc.is_alphabetic() {
428                        word.push(cc);
429                        chars.next();
430                    } else {
431                        break;
432                    }
433                }
434                let value = match word.as_str() {
435                    "true" => PhpValue::Bool(true),
436                    "false" => PhpValue::Bool(false),
437                    "null" => PhpValue::Str(String::new()),
438                    _ => {
439                        // 未知单词,跳过
440                        continue;
441                    }
442                };
443                if let Some(key) = current_key.take() {
444                    map.insert(key, value);
445                }
446            }
447            // 标识符(可能是类常量或常量名)
448            _ if c.is_alphabetic() || c == '_' => {
449                let mut word = String::new();
450                while let Some(&cc) = chars.peek() {
451                    if cc.is_alphanumeric() || cc == '_' {
452                        word.push(cc);
453                        chars.next();
454                    } else {
455                        break;
456                    }
457                }
458                buffer.push_str(&word);
459            }
460            _ => {
461                chars.next();
462            }
463        }
464    }
465
466    let _ = buffer; // 避免 unused 警告
467}
468
469/// 跳过空白字符
470fn skip_whitespace<I: Iterator<Item = char>>(iter: &mut std::iter::Peekable<I>) {
471    while let Some(&c) = iter.peek() {
472        if c.is_whitespace() {
473            iter.next();
474        } else {
475            break;
476        }
477    }
478}
479
480/// 解析简单 INI 文件(对齐 PHP `parse_ini_file` 的扁平化行为)
481///
482/// ## 支持语法
483///
484/// ```ini
485/// name = operate
486/// title = "运营管理"
487/// status = 1
488/// ```
489///
490/// ## 不支持
491///
492/// - 分区(`[section]`)— 简化处理,仅返回扁平 key-value
493/// - 转义序列(除引号包裹的字符串外)
494fn parse_simple_ini(content: &str) -> BTreeMap<String, PhpValue> {
495    let mut map = BTreeMap::new();
496
497    for line in content.lines() {
498        let line = line.trim();
499        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
500            continue;
501        }
502
503        if let Some(eq_pos) = line.find('=') {
504            let key = line[..eq_pos].trim().to_string();
505            let raw_value = line[eq_pos + 1..].trim();
506
507            let value = if (raw_value.starts_with('"') && raw_value.ends_with('"'))
508                || (raw_value.starts_with('\'') && raw_value.ends_with('\''))
509            {
510                PhpValue::Str(raw_value[1..raw_value.len() - 1].to_string())
511            } else if raw_value == "true" {
512                PhpValue::Bool(true)
513            } else if raw_value == "false" {
514                PhpValue::Bool(false)
515            } else if let Ok(n) = raw_value.parse::<i64>() {
516                PhpValue::Int(n)
517            } else {
518                PhpValue::Str(raw_value.to_string())
519            };
520
521            map.insert(key, value);
522        }
523    }
524
525    map
526}
527
528/// String 扩展:空字符串时使用 fallback
529trait IfEmpty {
530    fn if_empty(self, fallback: &str) -> Self;
531}
532
533impl IfEmpty for String {
534    fn if_empty(self, fallback: &str) -> Self {
535        if self.is_empty() {
536            fallback.to_string()
537        } else {
538            self
539        }
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use std::io::Write;
547
548    /// 创建临时 PHP Plugin.php 文件用于测试
549    fn make_test_plugin_php(content: &str) -> tempfile::NamedTempFile {
550        let mut file = tempfile::Builder::new()
551            .suffix(".php")
552            .tempfile()
553            .expect("create temp file");
554        file.write_all(content.as_bytes()).expect("write content");
555        file
556    }
557
558    #[test]
559    fn test_addon_manifest_new() {
560        let manifest = AddonManifest::new("operate");
561        assert_eq!(manifest.name, "operate");
562        assert_eq!(manifest.title, "");
563        assert_eq!(manifest.status, 0);
564        assert!(!manifest.is_enabled());
565    }
566
567    #[test]
568    fn test_is_enabled_status_zero() {
569        let mut manifest = AddonManifest::new("test");
570        manifest.status = 0;
571        assert!(!manifest.is_enabled());
572    }
573
574    #[test]
575    fn test_is_enabled_status_one() {
576        let mut manifest = AddonManifest::new("test");
577        manifest.status = 1;
578        assert!(manifest.is_enabled());
579    }
580
581    #[test]
582    fn test_is_enabled_status_two() {
583        let mut manifest = AddonManifest::new("test");
584        manifest.status = 2;
585        assert!(manifest.is_enabled());
586    }
587
588    #[test]
589    fn test_plugin_file_path() {
590        let mut manifest = AddonManifest::new("operate");
591        manifest.addon_path = PathBuf::from("/addons/operate");
592        assert_eq!(
593            manifest.plugin_file(),
594            PathBuf::from("/addons/operate/Plugin.php")
595        );
596    }
597
598    #[test]
599    fn test_info_ini_file_path() {
600        let mut manifest = AddonManifest::new("operate");
601        manifest.addon_path = PathBuf::from("/addons/operate");
602        assert_eq!(
603            manifest.info_ini_file(),
604            PathBuf::from("/addons/operate/info.ini")
605        );
606    }
607
608    #[test]
609    fn test_config_php_file_path() {
610        let mut manifest = AddonManifest::new("operate");
611        manifest.addon_path = PathBuf::from("/addons/operate");
612        assert_eq!(
613            manifest.config_php_file(),
614            PathBuf::from("/addons/operate/config.php")
615        );
616    }
617
618    #[test]
619    fn test_service_ini_file_path() {
620        let mut manifest = AddonManifest::new("operate");
621        manifest.addon_path = PathBuf::from("/addons/operate");
622        assert_eq!(
623            manifest.service_ini_file(),
624            PathBuf::from("/addons/operate/service.ini")
625        );
626    }
627
628    #[test]
629    fn test_view_dir_path() {
630        let mut manifest = AddonManifest::new("operate");
631        manifest.addon_path = PathBuf::from("/addons/operate");
632        assert_eq!(manifest.view_dir(), PathBuf::from("/addons/operate/view"));
633    }
634
635    #[test]
636    fn test_controller_dir_path() {
637        let mut manifest = AddonManifest::new("operate");
638        manifest.addon_path = PathBuf::from("/addons/operate");
639        assert_eq!(
640            manifest.controller_dir(),
641            PathBuf::from("/addons/operate/controller")
642        );
643    }
644
645    #[test]
646    fn test_model_dir_path() {
647        let mut manifest = AddonManifest::new("operate");
648        manifest.addon_path = PathBuf::from("/addons/operate");
649        assert_eq!(manifest.model_dir(), PathBuf::from("/addons/operate/model"));
650    }
651
652    #[test]
653    fn test_php_value_string_conversion() {
654        let s = PhpValue::Str("hello".to_string());
655        assert_eq!(s.as_string(), "hello");
656        assert_eq!(s.as_int(), 0);
657
658        let i = PhpValue::Int(42);
659        assert_eq!(i.as_string(), "42");
660        assert_eq!(i.as_int(), 42);
661
662        let b = PhpValue::Bool(true);
663        assert_eq!(b.as_string(), "1");
664        assert_eq!(b.as_int(), 1);
665
666        let b2 = PhpValue::Bool(false);
667        assert_eq!(b2.as_string(), "");
668        assert_eq!(b2.as_int(), 0);
669    }
670
671    #[test]
672    fn test_parse_php_info_array_basic() {
673        let php = r#"<?php
674namespace addons\operate;
675use think\Addons;
676class Plugin extends Addons {
677    public $info = [
678        'name' => 'operate',
679        'title' => '运营管理',
680        'status' => 1,
681    ];
682    public function install() {}
683    public function uninstall() {}
684}
685"#;
686        let info = parse_php_info_array(php);
687        assert!(info.is_some());
688        let info = info.unwrap();
689        assert_eq!(info.get("name").unwrap().as_string(), "operate");
690        assert_eq!(info.get("title").unwrap().as_string(), "运营管理");
691        assert_eq!(info.get("status").unwrap().as_int(), 1);
692    }
693
694    #[test]
695    fn test_parse_php_info_array_double_quotes() {
696        let php = r#"
697public $info = [
698    "name" => "test",
699    "version" => "1.0.0",
700];
701"#;
702        let info = parse_php_info_array(php);
703        assert!(info.is_some());
704        let info = info.unwrap();
705        assert_eq!(info.get("name").unwrap().as_string(), "test");
706        assert_eq!(info.get("version").unwrap().as_string(), "1.0.0");
707    }
708
709    #[test]
710    fn test_parse_php_info_array_no_info() {
711        let php = r#"<?php
712namespace addons\test;
713class Plugin {
714    public function install() {}
715}
716"#;
717        assert!(parse_php_info_array(php).is_none());
718    }
719
720    #[test]
721    fn test_parse_php_info_array_with_bool() {
722        let php = r#"
723public $info = [
724    'enabled' => true,
725    'debug' => false,
726];
727"#;
728        let info = parse_php_info_array(php);
729        assert!(info.is_some());
730        let info = info.unwrap();
731        assert_eq!(info.get("enabled").unwrap().as_string(), "1");
732        assert_eq!(info.get("debug").unwrap().as_string(), "");
733    }
734
735    #[test]
736    fn test_parse_php_info_array_negative_int() {
737        let php = r#"
738public $info = [
739    'order' => -5,
740];
741"#;
742        let info = parse_php_info_array(php);
743        assert!(info.is_some());
744        let info = info.unwrap();
745        assert_eq!(info.get("order").unwrap().as_int(), -5);
746    }
747
748    #[test]
749    fn test_parse_simple_ini_basic() {
750        let ini = r#"
751name = operate
752title = "运营管理"
753status = 1
754# 注释
755; 分号注释
756"#;
757        let map = parse_simple_ini(ini);
758        assert_eq!(map.get("name").unwrap().as_string(), "operate");
759        assert_eq!(map.get("title").unwrap().as_string(), "运营管理");
760        assert_eq!(map.get("status").unwrap().as_int(), 1);
761    }
762
763    #[test]
764    fn test_parse_simple_ini_bool() {
765        let ini = "enabled = true\ndebug = false";
766        let map = parse_simple_ini(ini);
767        assert_eq!(map.get("enabled").unwrap().as_string(), "1");
768        assert_eq!(map.get("debug").unwrap().as_string(), "");
769    }
770
771    #[test]
772    fn test_parse_simple_ini_empty() {
773        let map = parse_simple_ini("");
774        assert!(map.is_empty());
775    }
776
777    #[test]
778    fn test_parse_manifest_missing_plugin_file() {
779        let tmp = tempfile::tempdir().expect("create tempdir");
780        let result = parse_manifest(tmp.path());
781        assert!(result.is_err());
782        match result.unwrap_err() {
783            AddonLoaderError::ManifestParse { .. } => {}
784            other => panic!("expected ManifestParse, got {:?}", other),
785        }
786    }
787
788    #[test]
789    fn test_parse_manifest_valid_plugin() {
790        let tmp = tempfile::tempdir().expect("create tempdir");
791        let plugin_path = tmp.path().join("Plugin.php");
792        let php_content = r#"<?php
793namespace addons\operate;
794use think\Addons;
795class Plugin extends Addons {
796    public $info = [
797        'name' => 'operate',
798        'title' => '运营管理',
799        'identifier' => 'operate.addon',
800        'icon' => 'fa-cog',
801        'author' => 'sz',
802        'version' => '1.0.0',
803        'admin' => 'operate/index/index',
804        'status' => 1,
805    ];
806    public function install() {}
807    public function uninstall() {}
808}
809"#;
810        std::fs::write(&plugin_path, php_content).expect("write Plugin.php");
811
812        let result = parse_manifest(tmp.path());
813        assert!(result.is_ok());
814        let manifest = result.unwrap();
815        assert_eq!(manifest.name, "operate");
816        assert_eq!(manifest.title, "运营管理");
817        assert_eq!(manifest.identifier, "operate.addon");
818        assert_eq!(manifest.icon, "fa-cog");
819        assert_eq!(manifest.author, "sz");
820        assert_eq!(manifest.version, "1.0.0");
821        assert_eq!(manifest.admin, "operate/index/index");
822        assert_eq!(manifest.status, 1);
823        assert!(manifest.is_enabled());
824    }
825
826    #[test]
827    fn test_parse_manifest_disabled_status() {
828        let tmp = tempfile::tempdir().expect("create tempdir");
829        let plugin_path = tmp.path().join("Plugin.php");
830        let php_content = r#"
831public $info = [
832    'name' => 'disabled',
833    'status' => 0,
834];
835"#;
836        std::fs::write(&plugin_path, php_content).expect("write Plugin.php");
837
838        let result = parse_manifest(tmp.path());
839        assert!(result.is_ok());
840        let manifest = result.unwrap();
841        assert_eq!(manifest.name, "disabled");
842        assert!(!manifest.is_enabled());
843    }
844
845    #[test]
846    fn test_parse_manifest_with_info_ini_merge() {
847        let tmp = tempfile::tempdir().expect("create tempdir");
848        let plugin_path = tmp.path().join("Plugin.php");
849        let php_content = r#"
850public $info = [
851    'name' => 'operate',
852    'version' => '1.0.0',
853];
854"#;
855        std::fs::write(&plugin_path, php_content).expect("write Plugin.php");
856
857        // info.ini 覆盖 version
858        let info_ini = tmp.path().join("info.ini");
859        std::fs::write(&info_ini, "version = 2.0.0\nauthor = sz").expect("write info.ini");
860
861        let result = parse_manifest(tmp.path());
862        assert!(result.is_ok());
863        let manifest = result.unwrap();
864        assert_eq!(manifest.version, "2.0.0"); // 被 ini 覆盖
865        assert_eq!(manifest.author, "sz"); // 来自 ini
866    }
867
868    #[test]
869    fn test_parse_manifest_malformed_info() {
870        let tmp = tempfile::tempdir().expect("create tempdir");
871        let plugin_path = tmp.path().join("Plugin.php");
872        let php_content = "<?php class Plugin {}";
873        std::fs::write(&plugin_path, php_content).expect("write Plugin.php");
874
875        let result = parse_manifest(tmp.path());
876        assert!(result.is_err());
877    }
878
879    #[test]
880    fn test_if_empty_trait() {
881        assert_eq!("hello".to_string().if_empty("fallback"), "hello");
882        assert_eq!("".to_string().if_empty("fallback"), "fallback");
883    }
884
885    #[test]
886    fn test_skip_whitespace() {
887        let mut iter = "  hello".chars().peekable();
888        skip_whitespace(&mut iter);
889        assert_eq!(iter.peek(), Some(&'h'));
890    }
891
892    #[test]
893    fn test_make_test_plugin_php() {
894        let file = make_test_plugin_php("<?php echo 'hi';");
895        let content = std::fs::read_to_string(file.path()).unwrap();
896        assert!(content.contains("echo"));
897    }
898
899    #[test]
900    fn test_manifest_serde() {
901        let manifest = AddonManifest::new("test");
902        let json = serde_json::to_string(&manifest).unwrap();
903        let deserialized: AddonManifest = serde_json::from_str(&json).unwrap();
904        assert_eq!(manifest, deserialized);
905    }
906
907    #[test]
908    fn test_manifest_clone_eq() {
909        let m1 = AddonManifest::new("test");
910        let m2 = m1.clone();
911        assert_eq!(m1, m2);
912    }
913}