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 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 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 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 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
204fn 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 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#[derive(Debug, Clone, PartialEq)]
232enum PhpValue {
233 Str(String),
235 Int(i64),
237 Bool(bool),
239}
240
241impl PhpValue {
242 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 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
273fn parse_php_info_array(content: &str) -> Option<BTreeMap<String, PhpValue>> {
292 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; 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 let mut map = BTreeMap::new();
342 parse_php_array_body(array_body, &mut map);
343 Some(map)
344}
345
346fn 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 ' ' | '\t' | '\n' | '\r' | ',' => {
356 chars.next();
357 }
358 '\'' | '"' => {
360 let quote = c;
361 chars.next(); 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 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 '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 '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 continue;
441 }
442 };
443 if let Some(key) = current_key.take() {
444 map.insert(key, value);
445 }
446 }
447 _ 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; }
468
469fn 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
480fn 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
528trait 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 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 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"); assert_eq!(manifest.author, "sz"); }
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}