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