1use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45
46use serde::{Deserialize, Serialize};
47
48use crate::error::{AddonLoaderError, AddonLoaderResult};
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct AddonManifest {
53 pub name: String,
55 pub title: String,
57 pub identifier: String,
59 pub icon: String,
61 pub author: String,
63 pub version: String,
65 pub admin: String,
67 pub status: i64,
69 #[serde(skip)]
71 pub addon_path: PathBuf,
72}
73
74impl AddonManifest {
75 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 pub fn is_enabled(&self) -> bool {
92 self.status != 0
93 }
94
95 pub fn plugin_file(&self) -> PathBuf {
97 self.addon_path.join("Plugin.php")
98 }
99
100 pub fn info_ini_file(&self) -> PathBuf {
102 self.addon_path.join("info.ini")
103 }
104
105 pub fn config_php_file(&self) -> PathBuf {
107 self.addon_path.join("config.php")
108 }
109
110 pub fn service_ini_file(&self) -> PathBuf {
112 self.addon_path.join("service.ini")
113 }
114
115 pub fn view_dir(&self) -> PathBuf {
117 self.addon_path.join("view")
118 }
119
120 pub fn controller_dir(&self) -> PathBuf {
122 self.addon_path.join("controller")
123 }
124
125 pub fn model_dir(&self) -> PathBuf {
127 self.addon_path.join("model")
128 }
129}
130
131#[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 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 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 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
207fn 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 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#[derive(Debug, Clone, PartialEq)]
235enum PhpValue {
236 Str(String),
238 Int(i64),
240 Bool(bool),
242}
243
244impl PhpValue {
245 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 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
276fn parse_php_info_array(content: &str) -> Option<BTreeMap<String, PhpValue>> {
295 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; 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 let mut map = BTreeMap::new();
345 parse_php_array_body(array_body, &mut map);
346 Some(map)
347}
348
349fn 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 ' ' | '\t' | '\n' | '\r' | ',' => {
359 chars.next();
360 }
361 '\'' | '"' => {
363 let quote = c;
364 chars.next(); 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 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 '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 '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 continue;
444 }
445 };
446 if let Some(key) = current_key.take() {
447 map.insert(key, value);
448 }
449 }
450 _ 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; }
471
472fn 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
483fn 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
531trait 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 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 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"); assert_eq!(manifest.author, "sz"); }
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}