roboticus_plugin_sdk/
loader.rs1use std::path::{Path, PathBuf};
2
3use tracing::{debug, warn};
4
5use roboticus_core::Result;
6
7use crate::manifest::PluginManifest;
8
9#[derive(Debug, Clone)]
10pub struct DiscoveredPlugin {
11 pub manifest: PluginManifest,
12 pub dir: PathBuf,
13}
14
15pub fn discover_plugins(plugins_dir: &Path) -> Result<Vec<DiscoveredPlugin>> {
16 if !plugins_dir.exists() {
17 debug!(dir = %plugins_dir.display(), "plugins directory does not exist");
18 return Ok(Vec::new());
19 }
20
21 let mut discovered = Vec::new();
22
23 let entries = std::fs::read_dir(plugins_dir)?;
24
25 for entry in entries {
26 let entry = entry?;
27 let path = entry.path();
28
29 if !path.is_dir() {
30 continue;
31 }
32
33 let manifest_path = path.join("plugin.toml");
34 if !manifest_path.exists() {
35 debug!(dir = %path.display(), "skipping directory without plugin.toml");
36 continue;
37 }
38
39 match PluginManifest::from_file(&manifest_path) {
40 Ok(manifest) => {
41 debug!(name = %manifest.name, version = %manifest.version, "discovered plugin");
42 discovered.push(DiscoveredPlugin {
43 manifest,
44 dir: path,
45 });
46 }
47 Err(e) => {
48 warn!(path = %manifest_path.display(), error = %e, "failed to parse plugin manifest");
49 }
50 }
51 }
52
53 discovered.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
54 Ok(discovered)
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 #[test]
62 fn discover_missing_dir() {
63 let result = discover_plugins(Path::new("/nonexistent/plugins"));
64 assert!(result.is_ok());
65 assert!(result.unwrap().is_empty());
66 }
67
68 #[test]
69 fn discover_empty_dir() {
70 let dir = tempfile::tempdir().unwrap();
71 let result = discover_plugins(dir.path()).unwrap();
72 assert!(result.is_empty());
73 }
74
75 #[test]
76 fn discover_valid_plugin() {
77 let dir = tempfile::tempdir().unwrap();
78 let plugin_dir = dir.path().join("my-plugin");
79 std::fs::create_dir(&plugin_dir).unwrap();
80 std::fs::write(
81 plugin_dir.join("plugin.toml"),
82 r#"
83name = "my-plugin"
84version = "1.0.0"
85description = "Test plugin"
86"#,
87 )
88 .unwrap();
89
90 let result = discover_plugins(dir.path()).unwrap();
91 assert_eq!(result.len(), 1);
92 assert_eq!(result[0].manifest.name, "my-plugin");
93 assert_eq!(result[0].dir, plugin_dir);
94 }
95
96 #[test]
97 fn discover_skips_files() {
98 let dir = tempfile::tempdir().unwrap();
99 std::fs::write(dir.path().join("not-a-dir.txt"), "hello").unwrap();
100 let result = discover_plugins(dir.path()).unwrap();
101 assert!(result.is_empty());
102 }
103
104 #[test]
105 fn discover_skips_dir_without_manifest() {
106 let dir = tempfile::tempdir().unwrap();
107 std::fs::create_dir(dir.path().join("no-manifest")).unwrap();
108 let result = discover_plugins(dir.path()).unwrap();
109 assert!(result.is_empty());
110 }
111
112 #[test]
113 fn discover_skips_invalid_manifest() {
114 let dir = tempfile::tempdir().unwrap();
115 let plugin_dir = dir.path().join("bad");
116 std::fs::create_dir(&plugin_dir).unwrap();
117 std::fs::write(plugin_dir.join("plugin.toml"), "[[[[invalid").unwrap();
118 let result = discover_plugins(dir.path()).unwrap();
119 assert!(result.is_empty());
120 }
121
122 #[test]
123 fn discover_sorted_by_name() {
124 let dir = tempfile::tempdir().unwrap();
125 for name in ["charlie", "alpha", "bravo"] {
126 let p = dir.path().join(name);
127 std::fs::create_dir(&p).unwrap();
128 std::fs::write(
129 p.join("plugin.toml"),
130 format!("name = \"{name}\"\nversion = \"1.0.0\"\n"),
131 )
132 .unwrap();
133 }
134 let result = discover_plugins(dir.path()).unwrap();
135 let names: Vec<_> = result.iter().map(|p| p.manifest.name.as_str()).collect();
136 assert_eq!(names, vec!["alpha", "bravo", "charlie"]);
137 }
138}