1use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use crate::plugin::types::{
12 CommandMetadata, LoadedPlugin, PluginComponent, PluginError, PluginManifest,
13};
14
15fn validate_git_url(url: &str) -> Result<String, PluginError> {
17 if url.starts_with("git@") {
19 return Ok(url.to_string());
20 }
21
22 if let Ok(parsed) = url::Url::parse(url) {
24 let scheme = parsed.scheme();
25 if ["https", "http", "file"].contains(&scheme) {
26 return Ok(url.to_string());
27 }
28 }
29
30 Err(PluginError::GenericError {
31 source: "plugin_loader".to_string(),
32 plugin: None,
33 error: format!("Invalid git URL: {}", url),
34 })
35}
36
37#[allow(dead_code)]
39async fn path_exists(path: &Path) -> bool {
40 path.exists()
41}
42
43#[allow(dead_code)]
45fn validate_manifest(manifest: &PluginManifest) -> Result<(), Vec<String>> {
46 let mut errors = Vec::new();
47
48 if manifest.name.is_empty() {
50 errors.push("Plugin name is required".to_string());
51 }
52
53 if manifest.name.contains(' ') {
55 errors.push(format!(
56 "Plugin name '{}' should not contain spaces. Use kebab-case.",
57 manifest.name
58 ));
59 }
60
61 if let Some(ref commands) = manifest.commands {
63 match commands {
68 serde_json::Value::String(_) => {}
69 serde_json::Value::Array(arr) => {
70 for item in arr {
71 if !item.is_string() {
72 errors.push("Commands array must contain strings".to_string());
73 }
74 }
75 }
76 serde_json::Value::Object(obj) => {
77 for (cmd_name, metadata) in obj {
78 if let serde_json::Value::Object(meta) = metadata {
79 if let Some(source) = meta.get("source") {
81 if !source.is_string() {
82 errors.push(format!(
83 "Command '{}' source must be a string",
84 cmd_name
85 ));
86 }
87 }
88 if let Some(content) = meta.get("content") {
89 if !content.is_string() {
90 errors.push(format!(
91 "Command '{}' content must be a string",
92 cmd_name
93 ));
94 }
95 }
96 }
97 }
98 }
99 _ => {
100 errors.push("Commands must be a string, array, or object".to_string());
101 }
102 }
103 }
104
105 if let Some(ref skills) = manifest.skills {
107 match skills {
108 serde_json::Value::String(_) => {}
109 serde_json::Value::Array(arr) => {
110 for item in arr {
111 if !item.is_string() {
112 errors.push("Skills array must contain strings".to_string());
113 }
114 }
115 }
116 _ => {
117 errors.push("Skills must be a string or array".to_string());
118 }
119 }
120 }
121
122 if errors.is_empty() {
123 Ok(())
124 } else {
125 Err(errors)
126 }
127}
128
129fn validate_manifest_schema(manifest: &PluginManifest) -> Result<(), String> {
131 if manifest.name.is_empty() {
133 return Err("name is required".to_string());
134 }
135
136 if let Some(ref commands) = manifest.commands {
138 let valid = match commands {
139 serde_json::Value::String(_) => true,
140 serde_json::Value::Array(arr) => arr.iter().all(|v| v.is_string()),
141 serde_json::Value::Object(obj) => obj.values().all(|v| {
142 if let serde_json::Value::Object(meta) = v {
143 meta.contains_key("source") || meta.contains_key("content")
144 } else {
145 false
146 }
147 }),
148 _ => false,
149 };
150 if !valid {
151 return Err(
152 "commands must be a string, array, or object with source/content fields"
153 .to_string(),
154 );
155 }
156 }
157
158 if let Some(ref agents) = manifest.agents {
160 let valid = match agents {
161 serde_json::Value::String(_) => true,
162 serde_json::Value::Array(arr) => arr.iter().all(|v| v.is_string()),
163 _ => false,
164 };
165 if !valid {
166 return Err("agents must be a string or array".to_string());
167 }
168 }
169
170 if let Some(ref skills) = manifest.skills {
172 let valid = match skills {
173 serde_json::Value::String(_) => true,
174 serde_json::Value::Array(arr) => arr.iter().all(|v| v.is_string()),
175 _ => false,
176 };
177 if !valid {
178 return Err("skills must be a string or array".to_string());
179 }
180 }
181
182 if let Some(ref hooks) = manifest.hooks {
184 if !hooks.is_object() {
185 return Err("hooks must be an object".to_string());
186 }
187 }
188
189 if let Some(ref output_styles) = manifest.output_styles {
191 let valid = match output_styles {
192 serde_json::Value::String(_) => true,
193 serde_json::Value::Array(arr) => arr.iter().all(|v| v.is_string()),
194 _ => false,
195 };
196 if !valid {
197 return Err("output_styles must be a string or array".to_string());
198 }
199 }
200
201 Ok(())
202}
203
204pub fn load_plugin_manifest(manifest_path: &Path) -> Result<PluginManifest, PluginError> {
206 if !manifest_path.exists() {
207 return Err(PluginError::PathNotFound {
208 source: "plugin_loader".to_string(),
209 plugin: None,
210 path: manifest_path.display().to_string(),
211 component: PluginComponent::Commands,
212 });
213 }
214
215 let content =
216 fs::read_to_string(manifest_path).map_err(|e| PluginError::ManifestParseError {
217 source: "plugin_loader".to_string(),
218 plugin: None,
219 manifest_path: manifest_path.display().to_string(),
220 parse_error: e.to_string(),
221 })?;
222
223 let parsed: serde_json::Value =
224 serde_json::from_str(&content).map_err(|e| PluginError::ManifestParseError {
225 source: "plugin_loader".to_string(),
226 plugin: None,
227 manifest_path: manifest_path.display().to_string(),
228 parse_error: e.to_string(),
229 })?;
230
231 let manifest: PluginManifest =
233 serde_json::from_value(parsed).map_err(|e| PluginError::ManifestParseError {
234 source: "plugin_loader".to_string(),
235 plugin: None,
236 manifest_path: manifest_path.display().to_string(),
237 parse_error: e.to_string(),
238 })?;
239
240 validate_manifest_schema(&manifest).map_err(|err| PluginError::ManifestValidationError {
242 source: "plugin_loader".to_string(),
243 plugin: Some(manifest.name.clone()),
244 manifest_path: manifest_path.display().to_string(),
245 validation_errors: vec![err],
246 })?;
247
248 Ok(manifest)
249}
250
251pub fn load_plugin_manifest_or_default(
253 manifest_path: &Path,
254 plugin_name: &str,
255 source: &str,
256) -> PluginManifest {
257 match load_plugin_manifest(manifest_path) {
258 Ok(manifest) => manifest,
259 Err(_) => PluginManifest {
260 name: plugin_name.to_string(),
261 version: None,
262 description: Some(format!("Plugin from {}", source)),
263 author: None,
264 homepage: None,
265 repository: None,
266 license: None,
267 keywords: None,
268 dependencies: None,
269 commands: None,
270 agents: None,
271 skills: None,
272 hooks: None,
273 output_styles: None,
274 channels: None,
275 mcp_servers: None,
276 lsp_servers: None,
277 settings: None,
278 user_config: None,
279 },
280 }
281}
282
283pub async fn git_clone(
285 git_url: &str,
286 target_path: &Path,
287 branch: Option<&str>,
288 sha: Option<&str>,
289) -> Result<(), PluginError> {
290 let validated_url = validate_git_url(git_url)?;
291
292 if let Some(parent) = target_path.parent() {
294 fs::create_dir_all(parent).map_err(|e| PluginError::GenericError {
295 source: "plugin_loader".to_string(),
296 plugin: None,
297 error: format!("Failed to create parent directory: {}", e),
298 })?;
299 }
300
301 let mut args = vec![
303 "clone".to_string(),
304 "--depth".to_string(),
305 "1".to_string(),
306 "--recurse-submodules".to_string(),
307 "--shallow-submodules".to_string(),
308 ];
309
310 if let Some(branch) = branch {
312 args.push("--branch".to_string());
313 args.push(branch.to_string());
314 }
315
316 if sha.is_some() {
318 args.push("--no-checkout".to_string());
319 }
320
321 args.push(validated_url);
322 args.push(target_path.display().to_string());
323
324 let output =
326 Command::new("git")
327 .args(&args)
328 .output()
329 .map_err(|e| PluginError::GenericError {
330 source: "plugin_loader".to_string(),
331 plugin: None,
332 error: format!("Failed to execute git: {}", e),
333 })?;
334
335 if !output.status.success() {
336 let stderr = String::from_utf8_lossy(&output.stderr);
337 return Err(PluginError::GenericError {
338 source: "plugin_loader".to_string(),
339 plugin: None,
340 error: format!("Git clone failed: {}", stderr),
341 });
342 }
343
344 if let Some(sha) = sha {
346 let fetch_result = Command::new("git")
348 .args(&["fetch", "--depth", "1", "origin", sha])
349 .current_dir(target_path)
350 .output();
351
352 let fetch_success = match fetch_result {
353 Ok(output) => output.status.success(),
354 Err(_) => false,
355 };
356
357 if !fetch_success {
358 let _ = Command::new("git")
360 .args(&["fetch", "--unshallow"])
361 .current_dir(target_path)
362 .output();
363 }
364
365 let checkout_output = Command::new("git")
367 .args(&["checkout", sha])
368 .current_dir(target_path)
369 .output()
370 .map_err(|e| PluginError::GenericError {
371 source: "plugin_loader".to_string(),
372 plugin: None,
373 error: format!("Failed to checkout commit: {}", e),
374 })?;
375
376 if !checkout_output.status.success() {
377 let stderr = String::from_utf8_lossy(&checkout_output.stderr);
378 return Err(PluginError::GenericError {
379 source: "plugin_loader".to_string(),
380 plugin: None,
381 error: format!("Failed to checkout commit {}: {}", sha, stderr),
382 });
383 }
384 }
385
386 Ok(())
387}
388
389pub async fn install_from_npm(
391 package_name: &str,
392 target_path: &Path,
393 version: Option<&str>,
394) -> Result<(), PluginError> {
395 let package_spec = match version {
397 Some(v) => format!("{}@{}", package_name, v),
398 None => package_name.to_string(),
399 };
400
401 if let Some(parent) = target_path.parent() {
403 fs::create_dir_all(parent).map_err(|e| PluginError::GenericError {
404 source: "plugin_loader".to_string(),
405 plugin: Some(package_name.to_string()),
406 error: format!("Failed to create parent directory: {}", e),
407 })?;
408 }
409
410 let install_result = Command::new("npm")
412 .args(&[
413 "install",
414 &package_spec,
415 "--prefix",
416 &target_path.display().to_string(),
417 ])
418 .output()
419 .map_err(|e| PluginError::GenericError {
420 source: "plugin_loader".to_string(),
421 plugin: Some(package_name.to_string()),
422 error: format!("Failed to execute npm: {}", e),
423 })?;
424
425 if !install_result.status.success() {
426 let stderr = String::from_utf8_lossy(&install_result.stderr);
427 return Err(PluginError::GenericError {
428 source: "plugin_loader".to_string(),
429 plugin: Some(package_name.to_string()),
430 error: format!("npm install failed: {}", stderr),
431 });
432 }
433
434 let node_modules_path = target_path.join("node_modules").join(package_name);
436 if !node_modules_path.exists() {
437 return Err(PluginError::GenericError {
438 source: "plugin_loader".to_string(),
439 plugin: Some(package_name.to_string()),
440 error: format!("Package not found in node_modules: {}", package_name),
441 });
442 }
443
444 Ok(())
445}
446
447#[allow(dead_code)]
449fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<(), PluginError> {
450 if !src.exists() {
451 return Err(PluginError::PathNotFound {
452 source: "plugin_loader".to_string(),
453 plugin: None,
454 path: src.display().to_string(),
455 component: PluginComponent::Commands,
456 });
457 }
458
459 fs::create_dir_all(dest).map_err(|e| PluginError::GenericError {
461 source: "plugin_loader".to_string(),
462 plugin: None,
463 error: format!("Failed to create destination directory: {}", e),
464 })?;
465
466 for entry in fs::read_dir(src).map_err(|e| PluginError::GenericError {
468 source: "plugin_loader".to_string(),
469 plugin: None,
470 error: format!("Failed to read source directory: {}", e),
471 })? {
472 let entry = entry.map_err(|e| PluginError::GenericError {
473 source: "plugin_loader".to_string(),
474 plugin: None,
475 error: format!("Failed to read directory entry: {}", e),
476 })?;
477
478 let src_path = entry.path();
479 let dest_path = dest.join(entry.file_name());
480
481 if src_path.is_dir() {
482 copy_dir_recursive(&src_path, &dest_path)?;
483 } else {
484 fs::copy(&src_path, &dest_path).map_err(|e| PluginError::GenericError {
485 source: "plugin_loader".to_string(),
486 plugin: None,
487 error: format!("Failed to copy file: {}", e),
488 })?;
489 }
490 }
491
492 Ok(())
493}
494
495#[allow(dead_code)]
497fn validate_plugin_paths(
498 paths: &[String],
499 plugin_path: &Path,
500 _plugin_name: &str,
501 _source: &str,
502 _component: PluginComponent,
503) -> Vec<(String, bool)> {
504 paths
505 .iter()
506 .map(|rel_path| {
507 let full_path = plugin_path.join(rel_path);
508 (rel_path.clone(), full_path.exists())
509 })
510 .collect()
511}
512
513pub async fn create_plugin_from_path(
515 plugin_path: &Path,
516 source: &str,
517 enabled: bool,
518 fallback_name: &str,
519) -> Result<LoadedPlugin, PluginError> {
520 let possible_manifest_paths = vec![
523 plugin_path.join(".ai-plugin").join("plugin.json"),
524 plugin_path.join("plugin.json"),
525 plugin_path.join("claude_plugin.json"),
526 ];
527
528 let mut manifest: Option<PluginManifest> = None;
529 for manifest_path in &possible_manifest_paths {
530 if manifest_path.exists() {
531 match load_plugin_manifest(manifest_path) {
532 Ok(m) => {
533 manifest = Some(m);
534 break;
535 }
536 Err(e) => {
537 return Err(e);
538 }
539 }
540 }
541 }
542
543 let manifest = manifest.unwrap_or_else(|| PluginManifest {
545 name: fallback_name.to_string(),
546 version: None,
547 description: Some(format!("Plugin from {}", source)),
548 author: None,
549 homepage: None,
550 repository: None,
551 license: None,
552 keywords: None,
553 dependencies: None,
554 commands: None,
555 agents: None,
556 skills: None,
557 hooks: None,
558 output_styles: None,
559 channels: None,
560 mcp_servers: None,
561 lsp_servers: None,
562 settings: None,
563 user_config: None,
564 });
565
566 let mut plugin = LoadedPlugin {
568 name: manifest.name.clone(),
569 manifest: manifest.clone(),
570 path: plugin_path.display().to_string(),
571 source: source.to_string(),
572 repository: source.to_string(),
573 enabled: Some(enabled),
574 is_builtin: None,
575 sha: None,
576 commands_path: None,
577 commands_paths: None,
578 commands_metadata: None,
579 agents_path: None,
580 agents_paths: None,
581 skills_path: None,
582 skills_paths: None,
583 output_styles_path: None,
584 output_styles_paths: None,
585 hooks_config: None,
586 mcp_servers: None,
587 lsp_servers: None,
588 settings: None,
589 };
590
591 let commands_dir = plugin_path.join("commands");
593 let agents_dir = plugin_path.join("agents");
594 let skills_dir = plugin_path.join("skills");
595 let output_styles_dir = plugin_path.join("output-styles");
596
597 if commands_dir.exists() {
599 plugin.commands_path = Some(commands_dir.display().to_string());
600 }
601
602 if agents_dir.exists() {
603 plugin.agents_path = Some(agents_dir.display().to_string());
604 }
605
606 if skills_dir.exists() {
607 plugin.skills_path = Some(skills_dir.display().to_string());
608 }
609
610 if output_styles_dir.exists() {
611 plugin.output_styles_path = Some(output_styles_dir.display().to_string());
612 }
613
614 if let Some(ref commands) = manifest.commands {
616 let cmd_paths: Vec<String> = match commands {
617 serde_json::Value::String(s) => vec![s.clone()],
618 serde_json::Value::Array(arr) => arr
619 .iter()
620 .filter_map(|v| v.as_str().map(String::from))
621 .collect(),
622 serde_json::Value::Object(obj) => {
623 let mut paths: Vec<String> = Vec::new();
625 let mut metadata_map: HashMap<String, CommandMetadata> = HashMap::new();
626
627 for (cmd_name, metadata) in obj {
628 if let serde_json::Value::Object(meta) = metadata {
629 if let Some(source) = meta.get("source").and_then(|v| v.as_str()) {
630 paths.push(source.to_string());
631 }
632
633 let meta_obj = CommandMetadata {
635 source: meta
636 .get("source")
637 .and_then(|v| v.as_str().map(String::from)),
638 content: meta
639 .get("content")
640 .and_then(|v| v.as_str().map(String::from)),
641 description: meta
642 .get("description")
643 .and_then(|v| v.as_str().map(String::from)),
644 argument_hint: meta
645 .get("argumentHint")
646 .and_then(|v| v.as_str().map(String::from)),
647 model: meta.get("model").and_then(|v| v.as_str().map(String::from)),
648 allowed_tools: meta.get("allowedTools").and_then(|v| {
649 v.as_array().map(|arr| {
650 arr.iter()
651 .filter_map(|item| item.as_str().map(String::from))
652 .collect()
653 })
654 }),
655 };
656 metadata_map.insert(cmd_name.clone(), meta_obj);
657 }
658 }
659
660 plugin.commands_metadata = Some(metadata_map);
661 paths
662 }
663 _ => vec![],
664 };
665
666 if !cmd_paths.is_empty() {
668 let validated: Vec<String> = cmd_paths
669 .iter()
670 .filter(|p| plugin_path.join(p).exists())
671 .map(|p| plugin_path.join(p).display().to_string())
672 .collect();
673
674 if !validated.is_empty() {
675 plugin.commands_paths = Some(validated);
676 }
677 }
678 }
679
680 if let Some(ref agents) = manifest.agents {
682 let agent_paths: Vec<String> = match agents {
683 serde_json::Value::String(s) => vec![s.clone()],
684 serde_json::Value::Array(arr) => arr
685 .iter()
686 .filter_map(|v| v.as_str().map(String::from))
687 .collect(),
688 _ => vec![],
689 };
690
691 if !agent_paths.is_empty() {
692 let validated: Vec<String> = agent_paths
693 .iter()
694 .filter(|p| plugin_path.join(p).exists())
695 .map(|p| plugin_path.join(p).display().to_string())
696 .collect();
697
698 if !validated.is_empty() {
699 plugin.agents_paths = Some(validated);
700 }
701 }
702 }
703
704 if let Some(ref skills) = manifest.skills {
706 let skill_paths: Vec<String> = match skills {
707 serde_json::Value::String(s) => vec![s.clone()],
708 serde_json::Value::Array(arr) => arr
709 .iter()
710 .filter_map(|v| v.as_str().map(String::from))
711 .collect(),
712 _ => vec![],
713 };
714
715 if !skill_paths.is_empty() {
716 let validated: Vec<String> = skill_paths
717 .iter()
718 .filter(|p| plugin_path.join(p).exists())
719 .map(|p| plugin_path.join(p).display().to_string())
720 .collect();
721
722 if !validated.is_empty() {
723 plugin.skills_paths = Some(validated);
724 }
725 }
726 }
727
728 if let Some(ref output_styles) = manifest.output_styles {
730 let style_paths: Vec<String> = match output_styles {
731 serde_json::Value::String(s) => vec![s.clone()],
732 serde_json::Value::Array(arr) => arr
733 .iter()
734 .filter_map(|v| v.as_str().map(String::from))
735 .collect(),
736 _ => vec![],
737 };
738
739 if !style_paths.is_empty() {
740 let validated: Vec<String> = style_paths
741 .iter()
742 .filter(|p| plugin_path.join(p).exists())
743 .map(|p| plugin_path.join(p).display().to_string())
744 .collect();
745
746 if !validated.is_empty() {
747 plugin.output_styles_paths = Some(validated);
748 }
749 }
750 }
751
752 let hooks_path = plugin_path.join("hooks").join("hooks.json");
754 if hooks_path.exists() {
755 match fs::read_to_string(&hooks_path) {
756 Ok(content) => {
757 if let Ok(hooks_config) = serde_json::from_str::<serde_json::Value>(&content) {
758 plugin.hooks_config = Some(hooks_config);
759 }
760 }
761 Err(_) => {}
762 }
763 }
764
765 Ok(plugin)
766}
767
768pub async fn load_plugin(path: &Path) -> Result<LoadedPlugin, PluginError> {
781 let path_str = path.display().to_string();
782
783 let (plugin_path, source, plugin_name) = if path.is_dir() {
785 let name = path
787 .file_name()
788 .and_then(|n| n.to_str())
789 .unwrap_or("unknown")
790 .to_string();
791 (path.to_path_buf(), path_str.clone(), name)
792 } else if path_str.starts_with("git@")
793 || path_str.starts_with("https://")
794 || path_str.starts_with("http://")
795 || path_str.starts_with("file://")
796 {
797 let temp_dir = std::env::temp_dir().join(format!(
799 "plugin_{}",
800 std::time::SystemTime::now()
801 .duration_since(std::time::UNIX_EPOCH)
802 .unwrap()
803 .as_millis()
804 ));
805
806 git_clone(&path_str, &temp_dir, None, None).await?;
807
808 let name = path
810 .file_stem()
811 .and_then(|n| n.to_str())
812 .unwrap_or("git-plugin")
813 .to_string();
814
815 (temp_dir, path_str.clone(), name)
816 } else if !path_str.contains('/') && !path_str.contains('\\') {
817 let temp_dir = std::env::temp_dir().join(format!(
819 "npm_plugin_{}",
820 std::time::SystemTime::now()
821 .duration_since(std::time::UNIX_EPOCH)
822 .unwrap()
823 .as_millis()
824 ));
825
826 install_from_npm(&path_str, &temp_dir, None).await?;
827
828 (temp_dir, format!("npm:{}", path_str), path_str.clone())
829 } else {
830 return Err(PluginError::GenericError {
831 source: "plugin_loader".to_string(),
832 plugin: None,
833 error: format!("Invalid plugin path: {}", path_str),
834 });
835 };
836
837 create_plugin_from_path(&plugin_path, &source, true, &plugin_name).await
839}
840
841pub async fn load_plugins_from_dir(dir: &Path) -> Vec<LoadedPlugin> {
851 let mut plugins = Vec::new();
852
853 if !dir.exists() || !dir.is_dir() {
854 return plugins;
855 }
856
857 let entries = match fs::read_dir(dir) {
859 Ok(entries) => entries,
860 Err(_) => return plugins,
861 };
862
863 for entry in entries.flatten() {
864 let path = entry.path();
865 if path.is_dir() {
866 match load_plugin(&path).await {
867 Ok(plugin) => plugins.push(plugin),
868 Err(e) => {
869 eprintln!("Failed to load plugin from {}: {:?}", path.display(), e);
871 }
872 }
873 }
874 }
875
876 plugins
877}
878
879pub async fn load_plugins_from_sources(sources: &[PathBuf]) -> Vec<LoadedPlugin> {
887 let mut plugins = Vec::new();
888
889 for source in sources {
890 match load_plugin(source).await {
891 Ok(plugin) => plugins.push(plugin),
892 Err(e) => {
893 eprintln!("Failed to load plugin from {}: {:?}", source.display(), e);
894 }
895 }
896 }
897
898 plugins
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904 use std::fs;
905 use tempfile::TempDir;
906
907 #[test]
908 fn test_validate_git_url_https() {
909 let result = validate_git_url("https://github.com/user/repo.git");
910 assert!(result.is_ok());
911 }
912
913 #[test]
914 fn test_validate_git_url_ssh() {
915 let result = validate_git_url("git@github.com:user/repo.git");
916 assert!(result.is_ok());
917 }
918
919 #[test]
920 fn test_validate_git_url_invalid() {
921 let result = validate_git_url("ftp://github.com/user/repo.git");
922 assert!(result.is_err());
923 }
924
925 #[test]
926 fn test_validate_manifest_schema_valid() {
927 let manifest = PluginManifest {
928 name: "test-plugin".to_string(),
929 version: Some("1.0.0".to_string()),
930 description: Some("A test plugin".to_string()),
931 author: None,
932 homepage: None,
933 repository: None,
934 license: None,
935 keywords: None,
936 dependencies: None,
937 commands: None,
938 agents: None,
939 skills: None,
940 hooks: None,
941 output_styles: None,
942 channels: None,
943 mcp_servers: None,
944 lsp_servers: None,
945 settings: None,
946 user_config: None,
947 };
948
949 let result = validate_manifest_schema(&manifest);
950 assert!(result.is_ok());
951 }
952
953 #[test]
954 fn test_validate_manifest_schema_empty_name() {
955 let manifest = PluginManifest {
956 name: "".to_string(),
957 version: Some("1.0.0".to_string()),
958 description: Some("A test plugin".to_string()),
959 author: None,
960 homepage: None,
961 repository: None,
962 license: None,
963 keywords: None,
964 dependencies: None,
965 commands: None,
966 agents: None,
967 skills: None,
968 hooks: None,
969 output_styles: None,
970 channels: None,
971 mcp_servers: None,
972 lsp_servers: None,
973 settings: None,
974 user_config: None,
975 };
976
977 let result = validate_manifest_schema(&manifest);
978 assert!(result.is_err());
979 }
980
981 #[tokio::test]
982 async fn test_load_plugin_manifest_from_file() {
983 let temp_dir = TempDir::new().unwrap();
985 let manifest_path = temp_dir.path().join("plugin.json");
986
987 let manifest_content = r#"{
988 "name": "test-plugin",
989 "version": "1.0.0",
990 "description": "A test plugin"
991 }"#;
992
993 fs::write(&manifest_path, manifest_content).unwrap();
994
995 let result = load_plugin_manifest(&manifest_path);
996 assert!(result.is_ok());
997 let manifest = result.unwrap();
998 assert_eq!(manifest.name, "test-plugin");
999 assert_eq!(manifest.version, Some("1.0.0".to_string()));
1000 }
1001
1002 #[tokio::test]
1003 async fn test_load_plugin_manifest_not_found() {
1004 let temp_dir = TempDir::new().unwrap();
1005 let manifest_path = temp_dir.path().join("nonexistent.json");
1006
1007 let result = load_plugin_manifest(&manifest_path);
1008 assert!(result.is_err());
1009 }
1010
1011 #[tokio::test]
1012 async fn test_create_plugin_from_path_with_manifest() {
1013 let temp_dir = TempDir::new().unwrap();
1015 let plugin_dir = temp_dir.path();
1016
1017 let manifest_content = r#"{
1019 "name": "my-test-plugin",
1020 "version": "1.0.0",
1021 "description": "A test plugin"
1022 }"#;
1023 fs::write(plugin_dir.join("plugin.json"), manifest_content).unwrap();
1024
1025 fs::create_dir(plugin_dir.join("commands")).unwrap();
1027 fs::write(
1028 plugin_dir.join("commands").join("test.md"),
1029 "# Test Command",
1030 )
1031 .unwrap();
1032
1033 let result = create_plugin_from_path(plugin_dir, "test", true, "fallback").await;
1034 assert!(result.is_ok());
1035
1036 let plugin = result.unwrap();
1037 assert_eq!(plugin.name, "my-test-plugin");
1038 assert!(plugin.commands_path.is_some());
1039 }
1040
1041 #[tokio::test]
1042 async fn test_load_plugins_from_dir_empty() {
1043 let temp_dir = TempDir::new().unwrap();
1044 let plugins = load_plugins_from_dir(temp_dir.path()).await;
1045 assert!(plugins.is_empty());
1046 }
1047
1048 #[tokio::test]
1049 async fn test_load_plugins_from_dir_with_plugins() {
1050 let temp_dir = TempDir::new().unwrap();
1052 let plugins_dir = temp_dir.path();
1053
1054 let plugin1_dir = plugins_dir.join("plugin1");
1056 fs::create_dir(&plugin1_dir).unwrap();
1057 fs::write(plugin1_dir.join("plugin.json"), r#"{"name": "plugin1"}"#).unwrap();
1058
1059 let plugin2_dir = plugins_dir.join("plugin2");
1061 fs::create_dir(&plugin2_dir).unwrap();
1062 fs::write(plugin2_dir.join("plugin.json"), r#"{"name": "plugin2"}"#).unwrap();
1063
1064 let plugins = load_plugins_from_dir(plugins_dir).await;
1065 assert_eq!(plugins.len(), 2);
1066 }
1067}