use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{AddonLoaderError, AddonLoaderResult};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AddonManifest {
pub name: String,
pub title: String,
pub identifier: String,
pub icon: String,
pub author: String,
pub version: String,
pub admin: String,
pub status: i64,
#[serde(skip)]
pub addon_path: PathBuf,
}
impl AddonManifest {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
title: String::new(),
identifier: String::new(),
icon: String::new(),
author: String::new(),
version: String::new(),
admin: String::new(),
status: 0,
addon_path: PathBuf::new(),
}
}
pub fn is_enabled(&self) -> bool {
self.status != 0
}
pub fn plugin_file(&self) -> PathBuf {
self.addon_path.join("Plugin.php")
}
pub fn info_ini_file(&self) -> PathBuf {
self.addon_path.join("info.ini")
}
pub fn config_php_file(&self) -> PathBuf {
self.addon_path.join("config.php")
}
pub fn service_ini_file(&self) -> PathBuf {
self.addon_path.join("service.ini")
}
pub fn view_dir(&self) -> PathBuf {
self.addon_path.join("view")
}
pub fn controller_dir(&self) -> PathBuf {
self.addon_path.join("controller")
}
pub fn model_dir(&self) -> PathBuf {
self.addon_path.join("model")
}
}
#[tracing::instrument]
pub fn parse_manifest(addon_path: &Path) -> AddonLoaderResult<AddonManifest> {
let plugin_file = addon_path.join("Plugin.php");
if !plugin_file.exists() {
return Err(AddonLoaderError::ManifestParse {
addon: addon_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("<unknown>")
.to_string(),
reason: format!("Plugin.php not found in {}", addon_path.display()),
});
}
let plugin_content =
std::fs::read_to_string(&plugin_file).map_err(|e| AddonLoaderError::ReadFile {
path: plugin_file.display().to_string(),
source: e,
})?;
let mut info =
parse_php_info_array(&plugin_content).ok_or_else(|| AddonLoaderError::ManifestParse {
addon: addon_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("<unknown>")
.to_string(),
reason: "$info array not found or malformed in Plugin.php".to_string(),
})?;
let info_ini_path = addon_path.join("info.ini");
if info_ini_path.exists() {
let ini_content =
std::fs::read_to_string(&info_ini_path).map_err(|e| AddonLoaderError::ReadFile {
path: info_ini_path.display().to_string(),
source: e,
})?;
let ini_map = parse_simple_ini(&ini_content);
for (key, value) in ini_map {
info.insert(key, value);
}
}
let name = addon_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let manifest = build_manifest_from_info(&name, addon_path.to_path_buf(), info)?;
Ok(manifest)
}
fn build_manifest_from_info(
fallback_name: &str,
addon_path: PathBuf,
info: BTreeMap<String, PhpValue>,
) -> AddonLoaderResult<AddonManifest> {
let get_string =
|key: &str| -> String { info.get(key).map(|v| v.as_string()).unwrap_or_default() };
let get_int = |key: &str| -> i64 { info.get(key).map(|v| v.as_int()).unwrap_or(0) };
let status = get_int("status");
Ok(AddonManifest {
name: get_string("name").if_empty(fallback_name),
title: get_string("title"),
identifier: get_string("identifier"),
icon: get_string("icon"),
author: get_string("author"),
version: get_string("version"),
admin: get_string("admin"),
status,
addon_path,
})
}
#[derive(Debug, Clone, PartialEq)]
enum PhpValue {
Str(String),
Int(i64),
Bool(bool),
}
impl PhpValue {
fn as_string(&self) -> String {
match self {
PhpValue::Str(s) => s.clone(),
PhpValue::Int(i) => i.to_string(),
PhpValue::Bool(b) => {
if *b {
"1".to_string()
} else {
"".to_string()
}
}
}
}
fn as_int(&self) -> i64 {
match self {
PhpValue::Str(s) => s.parse().unwrap_or(0),
PhpValue::Int(i) => *i,
PhpValue::Bool(b) => {
if *b {
1
} else {
0
}
}
}
}
}
fn parse_php_info_array(content: &str) -> Option<BTreeMap<String, PhpValue>> {
let info_regex = regex::Regex::new(r#"\$info\s*=\s*\["#).ok()?;
let cap = info_regex.find(content)?;
let array_start = cap.end() - 1;
let bytes = content.as_bytes();
let mut depth = 0i32;
let mut array_end = None;
let mut in_string = false;
let mut string_char = b'\0';
let mut escape = false;
for (i, &c) in bytes.iter().enumerate().skip(array_start) {
if escape {
escape = false;
continue;
}
if in_string {
if c == b'\\' {
escape = true;
} else if c == string_char {
in_string = false;
}
continue;
}
match c {
b'\'' | b'"' => {
in_string = true;
string_char = c;
}
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
array_end = Some(i);
break;
}
}
_ => {}
}
}
let array_end = array_end?;
let array_body = &content[array_start + 1..array_end];
let mut map = BTreeMap::new();
parse_php_array_body(array_body, &mut map);
Some(map)
}
fn parse_php_array_body(body: &str, map: &mut BTreeMap<String, PhpValue>) {
let mut chars = body.chars().peekable();
let mut current_key: Option<String> = None;
let mut buffer = String::new();
while let Some(&c) = chars.peek() {
match c {
' ' | '\t' | '\n' | '\r' | ',' => {
chars.next();
}
'\'' | '"' => {
let quote = c;
chars.next(); let mut value = String::new();
let mut escaped = false;
while let Some(&cc) = chars.peek() {
if escaped {
match cc {
'n' => value.push('\n'),
't' => value.push('\t'),
'r' => value.push('\r'),
'\\' => value.push('\\'),
'\'' => value.push('\''),
'"' => value.push('"'),
_ => value.push(cc),
}
escaped = false;
chars.next();
continue;
}
if cc == '\\' {
escaped = true;
chars.next();
continue;
}
if cc == quote {
chars.next();
break;
}
value.push(cc);
chars.next();
}
skip_whitespace(&mut chars);
if chars.peek() == Some(&'=') {
chars.next();
if chars.peek() == Some(&'>') {
chars.next();
current_key = Some(value);
}
} else {
if let Some(key) = current_key.take() {
map.insert(key, PhpValue::Str(value));
}
}
}
'0'..='9' | '-' => {
let mut num = String::new();
while let Some(&cc) = chars.peek() {
if cc.is_ascii_digit() || cc == '-' || cc == '+' {
num.push(cc);
chars.next();
} else {
break;
}
}
if let Ok(n) = num.parse::<i64>() {
if let Some(key) = current_key.take() {
map.insert(key, PhpValue::Int(n));
}
}
}
't' | 'f' | 'n' => {
let mut word = String::new();
while let Some(&cc) = chars.peek() {
if cc.is_alphabetic() {
word.push(cc);
chars.next();
} else {
break;
}
}
let value = match word.as_str() {
"true" => PhpValue::Bool(true),
"false" => PhpValue::Bool(false),
"null" => PhpValue::Str(String::new()),
_ => {
continue;
}
};
if let Some(key) = current_key.take() {
map.insert(key, value);
}
}
_ if c.is_alphabetic() || c == '_' => {
let mut word = String::new();
while let Some(&cc) = chars.peek() {
if cc.is_alphanumeric() || cc == '_' {
word.push(cc);
chars.next();
} else {
break;
}
}
buffer.push_str(&word);
}
_ => {
chars.next();
}
}
}
let _ = buffer; }
fn skip_whitespace<I: Iterator<Item = char>>(iter: &mut std::iter::Peekable<I>) {
while let Some(&c) = iter.peek() {
if c.is_whitespace() {
iter.next();
} else {
break;
}
}
}
fn parse_simple_ini(content: &str) -> BTreeMap<String, PhpValue> {
let mut map = BTreeMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if let Some(eq_pos) = line.find('=') {
let key = line[..eq_pos].trim().to_string();
let raw_value = line[eq_pos + 1..].trim();
let value = if (raw_value.starts_with('"') && raw_value.ends_with('"'))
|| (raw_value.starts_with('\'') && raw_value.ends_with('\''))
{
PhpValue::Str(raw_value[1..raw_value.len() - 1].to_string())
} else if raw_value == "true" {
PhpValue::Bool(true)
} else if raw_value == "false" {
PhpValue::Bool(false)
} else if let Ok(n) = raw_value.parse::<i64>() {
PhpValue::Int(n)
} else {
PhpValue::Str(raw_value.to_string())
};
map.insert(key, value);
}
}
map
}
trait IfEmpty {
fn if_empty(self, fallback: &str) -> Self;
}
impl IfEmpty for String {
fn if_empty(self, fallback: &str) -> Self {
if self.is_empty() {
fallback.to_string()
} else {
self
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn make_test_plugin_php(content: &str) -> tempfile::NamedTempFile {
let mut file = tempfile::Builder::new()
.suffix(".php")
.tempfile()
.expect("create temp file");
file.write_all(content.as_bytes()).expect("write content");
file
}
#[test]
fn test_addon_manifest_new() {
let manifest = AddonManifest::new("operate");
assert_eq!(manifest.name, "operate");
assert_eq!(manifest.title, "");
assert_eq!(manifest.status, 0);
assert!(!manifest.is_enabled());
}
#[test]
fn test_is_enabled_status_zero() {
let mut manifest = AddonManifest::new("test");
manifest.status = 0;
assert!(!manifest.is_enabled());
}
#[test]
fn test_is_enabled_status_one() {
let mut manifest = AddonManifest::new("test");
manifest.status = 1;
assert!(manifest.is_enabled());
}
#[test]
fn test_is_enabled_status_two() {
let mut manifest = AddonManifest::new("test");
manifest.status = 2;
assert!(manifest.is_enabled());
}
#[test]
fn test_plugin_file_path() {
let mut manifest = AddonManifest::new("operate");
manifest.addon_path = PathBuf::from("/addons/operate");
assert_eq!(
manifest.plugin_file(),
PathBuf::from("/addons/operate/Plugin.php")
);
}
#[test]
fn test_info_ini_file_path() {
let mut manifest = AddonManifest::new("operate");
manifest.addon_path = PathBuf::from("/addons/operate");
assert_eq!(
manifest.info_ini_file(),
PathBuf::from("/addons/operate/info.ini")
);
}
#[test]
fn test_config_php_file_path() {
let mut manifest = AddonManifest::new("operate");
manifest.addon_path = PathBuf::from("/addons/operate");
assert_eq!(
manifest.config_php_file(),
PathBuf::from("/addons/operate/config.php")
);
}
#[test]
fn test_service_ini_file_path() {
let mut manifest = AddonManifest::new("operate");
manifest.addon_path = PathBuf::from("/addons/operate");
assert_eq!(
manifest.service_ini_file(),
PathBuf::from("/addons/operate/service.ini")
);
}
#[test]
fn test_view_dir_path() {
let mut manifest = AddonManifest::new("operate");
manifest.addon_path = PathBuf::from("/addons/operate");
assert_eq!(manifest.view_dir(), PathBuf::from("/addons/operate/view"));
}
#[test]
fn test_controller_dir_path() {
let mut manifest = AddonManifest::new("operate");
manifest.addon_path = PathBuf::from("/addons/operate");
assert_eq!(
manifest.controller_dir(),
PathBuf::from("/addons/operate/controller")
);
}
#[test]
fn test_model_dir_path() {
let mut manifest = AddonManifest::new("operate");
manifest.addon_path = PathBuf::from("/addons/operate");
assert_eq!(manifest.model_dir(), PathBuf::from("/addons/operate/model"));
}
#[test]
fn test_php_value_string_conversion() {
let s = PhpValue::Str("hello".to_string());
assert_eq!(s.as_string(), "hello");
assert_eq!(s.as_int(), 0);
let i = PhpValue::Int(42);
assert_eq!(i.as_string(), "42");
assert_eq!(i.as_int(), 42);
let b = PhpValue::Bool(true);
assert_eq!(b.as_string(), "1");
assert_eq!(b.as_int(), 1);
let b2 = PhpValue::Bool(false);
assert_eq!(b2.as_string(), "");
assert_eq!(b2.as_int(), 0);
}
#[test]
fn test_parse_php_info_array_basic() {
let php = r#"<?php
namespace addons\operate;
use think\Addons;
class Plugin extends Addons {
public $info = [
'name' => 'operate',
'title' => '运营管理',
'status' => 1,
];
public function install() {}
public function uninstall() {}
}
"#;
let info = parse_php_info_array(php);
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.get("name").unwrap().as_string(), "operate");
assert_eq!(info.get("title").unwrap().as_string(), "运营管理");
assert_eq!(info.get("status").unwrap().as_int(), 1);
}
#[test]
fn test_parse_php_info_array_double_quotes() {
let php = r#"
public $info = [
"name" => "test",
"version" => "1.0.0",
];
"#;
let info = parse_php_info_array(php);
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.get("name").unwrap().as_string(), "test");
assert_eq!(info.get("version").unwrap().as_string(), "1.0.0");
}
#[test]
fn test_parse_php_info_array_no_info() {
let php = r#"<?php
namespace addons\test;
class Plugin {
public function install() {}
}
"#;
assert!(parse_php_info_array(php).is_none());
}
#[test]
fn test_parse_php_info_array_with_bool() {
let php = r#"
public $info = [
'enabled' => true,
'debug' => false,
];
"#;
let info = parse_php_info_array(php);
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.get("enabled").unwrap().as_string(), "1");
assert_eq!(info.get("debug").unwrap().as_string(), "");
}
#[test]
fn test_parse_php_info_array_negative_int() {
let php = r#"
public $info = [
'order' => -5,
];
"#;
let info = parse_php_info_array(php);
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.get("order").unwrap().as_int(), -5);
}
#[test]
fn test_parse_simple_ini_basic() {
let ini = r#"
name = operate
title = "运营管理"
status = 1
# 注释
; 分号注释
"#;
let map = parse_simple_ini(ini);
assert_eq!(map.get("name").unwrap().as_string(), "operate");
assert_eq!(map.get("title").unwrap().as_string(), "运营管理");
assert_eq!(map.get("status").unwrap().as_int(), 1);
}
#[test]
fn test_parse_simple_ini_bool() {
let ini = "enabled = true\ndebug = false";
let map = parse_simple_ini(ini);
assert_eq!(map.get("enabled").unwrap().as_string(), "1");
assert_eq!(map.get("debug").unwrap().as_string(), "");
}
#[test]
fn test_parse_simple_ini_empty() {
let map = parse_simple_ini("");
assert!(map.is_empty());
}
#[test]
fn test_parse_manifest_missing_plugin_file() {
let tmp = tempfile::tempdir().expect("create tempdir");
let result = parse_manifest(tmp.path());
assert!(result.is_err());
match result.unwrap_err() {
AddonLoaderError::ManifestParse { .. } => {}
other => panic!("expected ManifestParse, got {:?}", other),
}
}
#[test]
fn test_parse_manifest_valid_plugin() {
let tmp = tempfile::tempdir().expect("create tempdir");
let plugin_path = tmp.path().join("Plugin.php");
let php_content = r#"<?php
namespace addons\operate;
use think\Addons;
class Plugin extends Addons {
public $info = [
'name' => 'operate',
'title' => '运营管理',
'identifier' => 'operate.addon',
'icon' => 'fa-cog',
'author' => 'sz',
'version' => '1.0.0',
'admin' => 'operate/index/index',
'status' => 1,
];
public function install() {}
public function uninstall() {}
}
"#;
std::fs::write(&plugin_path, php_content).expect("write Plugin.php");
let result = parse_manifest(tmp.path());
assert!(result.is_ok());
let manifest = result.unwrap();
assert_eq!(manifest.name, "operate");
assert_eq!(manifest.title, "运营管理");
assert_eq!(manifest.identifier, "operate.addon");
assert_eq!(manifest.icon, "fa-cog");
assert_eq!(manifest.author, "sz");
assert_eq!(manifest.version, "1.0.0");
assert_eq!(manifest.admin, "operate/index/index");
assert_eq!(manifest.status, 1);
assert!(manifest.is_enabled());
}
#[test]
fn test_parse_manifest_disabled_status() {
let tmp = tempfile::tempdir().expect("create tempdir");
let plugin_path = tmp.path().join("Plugin.php");
let php_content = r#"
public $info = [
'name' => 'disabled',
'status' => 0,
];
"#;
std::fs::write(&plugin_path, php_content).expect("write Plugin.php");
let result = parse_manifest(tmp.path());
assert!(result.is_ok());
let manifest = result.unwrap();
assert_eq!(manifest.name, "disabled");
assert!(!manifest.is_enabled());
}
#[test]
fn test_parse_manifest_with_info_ini_merge() {
let tmp = tempfile::tempdir().expect("create tempdir");
let plugin_path = tmp.path().join("Plugin.php");
let php_content = r#"
public $info = [
'name' => 'operate',
'version' => '1.0.0',
];
"#;
std::fs::write(&plugin_path, php_content).expect("write Plugin.php");
let info_ini = tmp.path().join("info.ini");
std::fs::write(&info_ini, "version = 2.0.0\nauthor = sz").expect("write info.ini");
let result = parse_manifest(tmp.path());
assert!(result.is_ok());
let manifest = result.unwrap();
assert_eq!(manifest.version, "2.0.0"); assert_eq!(manifest.author, "sz"); }
#[test]
fn test_parse_manifest_malformed_info() {
let tmp = tempfile::tempdir().expect("create tempdir");
let plugin_path = tmp.path().join("Plugin.php");
let php_content = "<?php class Plugin {}";
std::fs::write(&plugin_path, php_content).expect("write Plugin.php");
let result = parse_manifest(tmp.path());
assert!(result.is_err());
}
#[test]
fn test_if_empty_trait() {
assert_eq!("hello".to_string().if_empty("fallback"), "hello");
assert_eq!("".to_string().if_empty("fallback"), "fallback");
}
#[test]
fn test_skip_whitespace() {
let mut iter = " hello".chars().peekable();
skip_whitespace(&mut iter);
assert_eq!(iter.peek(), Some(&'h'));
}
#[test]
fn test_make_test_plugin_php() {
let file = make_test_plugin_php("<?php echo 'hi';");
let content = std::fs::read_to_string(file.path()).unwrap();
assert!(content.contains("echo"));
}
#[test]
fn test_manifest_serde() {
let manifest = AddonManifest::new("test");
let json = serde_json::to_string(&manifest).unwrap();
let deserialized: AddonManifest = serde_json::from_str(&json).unwrap();
assert_eq!(manifest, deserialized);
}
#[test]
fn test_manifest_clone_eq() {
let m1 = AddonManifest::new("test");
let m2 = m1.clone();
assert_eq!(m1, m2);
}
}