Skip to main content

zeroclaw_hardware/
loader.rs

1//! Plugin manifest loader — scans `~/.zeroclaw/tools/` at startup.
2//!
3//! Layout expected on disk:
4//! ```text
5//! ~/.zeroclaw/tools/
6//! ├── i2c_scan/
7//! │   ├── tool.toml
8//! │   └── i2c_scan.py
9//! └── pwm_set/
10//!     ├── tool.toml
11//!     └── pwm_set
12//! ```
13//!
14//! Rules:
15//! - The directory is **created** if it does not exist.
16//! - Each subdirectory is scanned for a `tool.toml`.
17//! - Manifests that fail to parse or validate are **skipped with a warning**;
18//!   they must not crash startup.
19//! - Non-directory entries at the top level are silently ignored.
20
21use super::manifest::ToolManifest;
22use super::subprocess::SubprocessTool;
23use anyhow::Result;
24use std::fs;
25use std::path::{Path, PathBuf};
26use zeroclaw_api::tool::Tool;
27
28/// A successfully loaded plugin, ready for registration.
29pub struct LoadedPlugin {
30    /// Tool name from the manifest (unique key in `ToolRegistry`).
31    pub name: String,
32    /// Semantic version string from the manifest.
33    pub version: String,
34    /// The constructed tool, boxed for dynamic dispatch.
35    pub tool: Box<dyn Tool>,
36}
37
38/// Scan `~/.zeroclaw/tools/` and return all valid plugins.
39///
40/// - Creates the directory if absent.
41/// - Skips broken manifests with a `tracing::warn!` — does not propagate errors.
42/// - Returns an empty `Vec` when no plugins are installed.
43pub 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    // Create the directory tree if it is missing.
59    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        // Only descend into subdirectories.
131        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
169/// Parse and validate a single plugin directory.
170///
171/// Returns `Err` on any validation failure so the caller can log and continue.
172fn 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    // Validate required fields — fail fast with a descriptive error.
202    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    // Validate binary path: must exist, be a regular file, and reside within plugin_dir.
213    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
277/// Return the path `~/.zeroclaw/tools/` using the `directories` crate.
278pub 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
292/// Best-effort home dir string for display purposes only.
293fn 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        // Write a dummy binary (content doesn't matter for manifest loading).
321        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        // We can't redirect scan_plugin_dir to an arbitrary directory (it
381        // always uses ~/.zeroclaw/tools), but we can verify load_one_plugin
382        // behaviour under broken input without affecting the real directory.
383        let dir = tempfile::tempdir().unwrap();
384
385        // Plugin 1: valid
386        let p1 = dir.path().join("good");
387        fs::create_dir_all(&p1).unwrap();
388        write_valid_manifest(&p1);
389
390        // Plugin 2: broken TOML
391        let p2 = dir.path().join("bad");
392        fs::create_dir_all(&p2).unwrap();
393        fs::write(p2.join("tool.toml"), "{{broken").unwrap();
394
395        // Load manually to simulate what scan_plugin_dir does.
396        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}