1use super::manifest::ToolManifest;
22use super::subprocess::SubprocessTool;
23use anyhow::Result;
24use std::fs;
25use std::path::{Path, PathBuf};
26use zeroclaw_api::tool::Tool;
27
28pub struct LoadedPlugin {
30 pub name: String,
32 pub version: String,
34 pub tool: Box<dyn Tool>,
36}
37
38pub fn scan_plugin_dir() -> Vec<LoadedPlugin> {
44 let tools_dir = match plugin_tools_dir() {
45 Ok(p) => p,
46 Err(e) => {
47 ::zeroclaw_log::record!(
48 WARN,
49 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
50 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
51 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
52 "cannot resolve plugin tools dir"
53 );
54 return Vec::new();
55 }
56 };
57
58 if !tools_dir.exists() {
60 if let Err(e) = fs::create_dir_all(&tools_dir) {
61 ::zeroclaw_log::record!(
62 WARN,
63 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
64 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
65 &format!(
66 "[registry] could not create {:?}: {}",
67 tools_dir.display().to_string(),
68 e
69 )
70 );
71 return Vec::new();
72 }
73 ::zeroclaw_log::record!(
74 INFO,
75 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
76 &format!(
77 "[registry] created plugin directory: {}",
78 tools_dir.display().to_string()
79 )
80 );
81 }
82
83 println!(
84 "[registry] scanning {}...",
85 match dirs_home().as_deref().filter(|s| !s.is_empty()) {
86 Some(home) => tools_dir
87 .to_str()
88 .unwrap_or("~/.zeroclaw/tools")
89 .replace(home, "~"),
90 None => tools_dir
91 .to_str()
92 .unwrap_or("~/.zeroclaw/tools")
93 .to_string(),
94 }
95 );
96
97 let mut plugins = Vec::new();
98
99 let entries = match fs::read_dir(&tools_dir) {
100 Ok(e) => e,
101 Err(e) => {
102 ::zeroclaw_log::record!(
103 WARN,
104 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
105 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
106 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
107 "cannot read tools dir"
108 );
109 return Vec::new();
110 }
111 };
112
113 for entry in entries {
114 let entry = match entry {
115 Ok(e) => e,
116 Err(e) => {
117 ::zeroclaw_log::record!(
118 WARN,
119 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
120 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
121 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
122 "skipping unreadable dir entry"
123 );
124 continue;
125 }
126 };
127
128 let plugin_dir = entry.path();
129
130 if !plugin_dir.is_dir() {
132 continue;
133 }
134
135 let manifest_path = plugin_dir.join("tool.toml");
136
137 if !manifest_path.exists() {
138 ::zeroclaw_log::record!(
139 DEBUG,
140 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
141 &format!(
142 "[registry] no tool.toml in {:?} — skipping",
143 plugin_dir.file_name().unwrap_or_default()
144 )
145 );
146 continue;
147 }
148
149 match load_one_plugin(&plugin_dir, &manifest_path) {
150 Ok(plugin) => plugins.push(plugin),
151 Err(e) => {
152 ::zeroclaw_log::record!(
153 WARN,
154 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
155 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
156 &format!(
157 "[registry] skipping plugin in {:?}: {}",
158 plugin_dir.file_name().unwrap_or_default(),
159 e
160 )
161 );
162 }
163 }
164 }
165
166 plugins
167}
168
169fn load_one_plugin(plugin_dir: &Path, manifest_path: &Path) -> Result<LoadedPlugin> {
173 let raw = fs::read_to_string(manifest_path).map_err(|e| {
174 ::zeroclaw_log::record!(
175 WARN,
176 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
177 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
178 .with_attrs(::serde_json::json!({
179 "manifest_path": manifest_path.display().to_string(),
180 "error": format!("{}", e),
181 })),
182 "hardware plugin manifest unreadable"
183 );
184 anyhow::Error::msg(format!("cannot read tool.toml: {e}"))
185 })?;
186
187 let manifest: ToolManifest = toml::from_str(&raw).map_err(|e| {
188 ::zeroclaw_log::record!(
189 WARN,
190 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
191 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
192 .with_attrs(::serde_json::json!({
193 "manifest_path": manifest_path.display().to_string(),
194 "error": format!("{}", e),
195 })),
196 "hardware plugin manifest failed to parse"
197 );
198 anyhow::Error::msg(format!("TOML parse error in tool.toml: {e}"))
199 })?;
200
201 if manifest.tool.name.trim().is_empty() {
203 anyhow::bail!("manifest missing [tool] name");
204 }
205 if manifest.tool.description.trim().is_empty() {
206 anyhow::bail!("manifest missing [tool] description");
207 }
208 if manifest.exec.binary.trim().is_empty() {
209 anyhow::bail!("manifest missing [exec] binary");
210 }
211
212 let canonical_plugin_dir = plugin_dir.canonicalize().map_err(|e| {
214 ::zeroclaw_log::record!(
215 WARN,
216 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
217 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
218 .with_attrs(::serde_json::json!({
219 "plugin_dir": plugin_dir.display().to_string(),
220 "error": format!("{}", e),
221 })),
222 "cannot canonicalize plugin dir"
223 );
224 anyhow::Error::msg(format!(
225 "cannot canonicalize plugin dir {}: {e}",
226 plugin_dir.display()
227 ))
228 })?;
229 let raw_binary_path = plugin_dir.join(&manifest.exec.binary);
230 if !raw_binary_path.exists() {
231 anyhow::bail!(
232 "manifest exec binary not found: {}",
233 raw_binary_path.display()
234 );
235 }
236 let binary_path = raw_binary_path.canonicalize().map_err(|e| {
237 ::zeroclaw_log::record!(
238 WARN,
239 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
240 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
241 .with_attrs(::serde_json::json!({
242 "binary_path": raw_binary_path.display().to_string(),
243 "error": format!("{}", e),
244 })),
245 "cannot canonicalize plugin binary path"
246 );
247 anyhow::Error::msg(format!(
248 "cannot canonicalize binary path {}: {e}",
249 raw_binary_path.display()
250 ))
251 })?;
252 if !binary_path.starts_with(&canonical_plugin_dir) {
253 anyhow::bail!(
254 "manifest exec binary escapes plugin directory: {} is not under {}",
255 binary_path.display().to_string(),
256 canonical_plugin_dir.display()
257 );
258 }
259 if !binary_path.is_file() {
260 anyhow::bail!(
261 "manifest exec binary is not a regular file: {}",
262 binary_path.display()
263 );
264 }
265
266 let name = manifest.tool.name.clone();
267 let version = manifest.tool.version.clone();
268 let tool: Box<dyn Tool> = Box::new(SubprocessTool::new(manifest, binary_path));
269
270 Ok(LoadedPlugin {
271 name,
272 version,
273 tool,
274 })
275}
276
277pub fn plugin_tools_dir() -> Result<PathBuf> {
279 use directories::BaseDirs;
280 let base = BaseDirs::new().ok_or_else(|| {
281 ::zeroclaw_log::record!(
282 ERROR,
283 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
284 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
285 "cannot determine the user home directory"
286 );
287 anyhow::Error::msg("cannot determine the user home directory")
288 })?;
289 Ok(base.home_dir().join(".zeroclaw").join("tools"))
290}
291
292fn dirs_home() -> Option<String> {
294 use directories::BaseDirs;
295 BaseDirs::new().map(|b| b.home_dir().to_string_lossy().into_owned())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use std::fs;
302
303 fn write_valid_manifest(dir: &Path) {
304 let toml = r#"
305[tool]
306name = "test_plugin"
307version = "1.0.0"
308description = "A deterministic test plugin"
309
310[exec]
311binary = "tool.sh"
312
313[[parameters]]
314name = "device"
315type = "string"
316description = "Device alias"
317required = true
318"#;
319 fs::write(dir.join("tool.toml"), toml).unwrap();
320 fs::write(
322 dir.join("tool.sh"),
323 "#!/bin/sh\necho '{\"success\":true,\"output\":\"ok\",\"error\":null}'\n",
324 )
325 .unwrap();
326 }
327
328 #[test]
329 fn load_one_plugin_succeeds_for_valid_manifest() {
330 let dir = tempfile::tempdir().unwrap();
331 write_valid_manifest(dir.path());
332
333 let manifest_path = dir.path().join("tool.toml");
334 let plugin = load_one_plugin(dir.path(), &manifest_path).unwrap();
335
336 assert_eq!(plugin.name, "test_plugin");
337 assert_eq!(plugin.version, "1.0.0");
338 assert_eq!(plugin.tool.name(), "test_plugin");
339 }
340
341 #[test]
342 fn load_one_plugin_fails_on_missing_name() {
343 let dir = tempfile::tempdir().unwrap();
344 let toml = r#"
345[tool]
346name = ""
347version = "1.0.0"
348description = "Missing name test"
349
350[exec]
351binary = "tool.sh"
352"#;
353 fs::write(dir.path().join("tool.toml"), toml).unwrap();
354
355 let result = load_one_plugin(dir.path(), &dir.path().join("tool.toml"));
356 match result {
357 Err(e) => assert!(e.to_string().contains("name"), "unexpected error: {}", e),
358 Ok(_) => panic!("expected an error for missing name"),
359 }
360 }
361
362 #[test]
363 fn load_one_plugin_fails_on_parse_error() {
364 let dir = tempfile::tempdir().unwrap();
365 fs::write(dir.path().join("tool.toml"), "not valid toml {{{{").unwrap();
366
367 let result = load_one_plugin(dir.path(), &dir.path().join("tool.toml"));
368 match result {
369 Err(e) => assert!(
370 e.to_string().contains("TOML parse error"),
371 "unexpected error: {}",
372 e
373 ),
374 Ok(_) => panic!("expected a parse error"),
375 }
376 }
377
378 #[test]
379 fn scan_plugin_dir_skips_broken_manifests_without_panicking() {
380 let dir = tempfile::tempdir().unwrap();
384
385 let p1 = dir.path().join("good");
387 fs::create_dir_all(&p1).unwrap();
388 write_valid_manifest(&p1);
389
390 let p2 = dir.path().join("bad");
392 fs::create_dir_all(&p2).unwrap();
393 fs::write(p2.join("tool.toml"), "{{broken").unwrap();
394
395 let good = load_one_plugin(&p1, &p1.join("tool.toml"));
397 let bad = load_one_plugin(&p2, &p2.join("tool.toml"));
398
399 assert!(good.is_ok(), "good plugin should load");
400 assert!(bad.is_err(), "bad plugin should error, not panic");
401 }
402
403 #[test]
404 fn plugin_tools_dir_returns_path_ending_in_zeroclaw_tools() {
405 let path = plugin_tools_dir().expect("should resolve");
406 let display = path.to_string_lossy();
407 let expected = std::path::Path::new(".zeroclaw").join("tools");
408 assert!(path.ends_with(&expected), "unexpected path: {}", display);
409 }
410}