Skip to main content

harmont_cli/plugin/
registry.rs

1//! Discovers `.wasm` plugins under the user and project plugin dirs,
2//! validates each manifest, and builds a capability index used by
3//! the dispatcher.
4
5// Pedantic-bucket nags accepted at module scope:
6// - `missing_errors_doc`: every fallible fn returns `anyhow::Result`
7//   with rich `with_context` messages.
8// - `needless_pass_by_value`: `RegistryConfig` is intentionally moved
9//   into `load` so callers can't reuse a config they expected to
10//   consume.
11// - `collapsible_if`: the nested `if s.default { … }` reads more clearly
12//   one rule per line.
13#![allow(clippy::missing_errors_doc)]
14#![allow(clippy::needless_pass_by_value)]
15#![allow(clippy::collapsible_if)]
16
17use std::collections::{BTreeMap, HashSet};
18use std::path::PathBuf;
19use std::sync::Arc;
20
21use anyhow::{Context, Result};
22use hm_plugin_protocol::{Capability, PluginManifest};
23
24use super::host::LoadedPlugin;
25use super::host_fns::HOST_FN_NAMES;
26use super::manifest::{ManifestError, validate_standalone};
27use super::paths;
28use crate::error::HmError;
29
30#[derive(Debug, Default)]
31pub struct RegistryConfig {
32    /// If `false`, skip discovery and only registers explicitly added
33    /// plugins. Used by integration tests.
34    pub auto_discover: bool,
35    /// Extra plugin paths to load (in addition to discovery). Used by
36    /// tests to load fixture plugins.
37    pub extra_paths: Vec<PathBuf>,
38    /// Embedded plugin bytes — registered first, before disk plugins.
39    /// Plan 2 onward stuffs `docker.wasm`, etc. in here.
40    pub embedded: Vec<(&'static str, &'static [u8])>,
41    /// Per-runner instance pool size override. Keyed by `runner` name.
42    /// Defaults to 1 when a runner isn't present here. The orchestrator
43    /// sets this to `parallelism` for the default-runner plugin so
44    /// concurrent chains stop serialising on a single plugin instance.
45    pub pool_sizes: BTreeMap<String, usize>,
46}
47
48#[derive(Debug)]
49pub struct PluginRegistry {
50    plugins: Vec<Arc<LoadedPlugin>>,
51    pub subcommand_index: BTreeMap<String, usize>,
52    pub runner_index: BTreeMap<String, usize>,
53    pub output_formatter_index: BTreeMap<String, usize>,
54    pub default_runner: Option<usize>,
55}
56
57impl PluginRegistry {
58    pub fn load(config: RegistryConfig) -> Result<Self> {
59        let host_fns: HashSet<&str> = HOST_FN_NAMES.iter().copied().collect();
60        let mut plugins: Vec<Arc<LoadedPlugin>> = Vec::new();
61
62        // Chicken-and-egg: we'd need the manifest to know if a plugin
63        // is a step executor before sizing its pool. Resolve by using
64        // the max pool size across all declared runners — the
65        // semaphore guarantees we never exceed it, and non-step
66        // plugins simply never grow past their single pre-allocated
67        // instance.
68        let max_instances = config
69            .pool_sizes
70            .values()
71            .copied()
72            .max()
73            .unwrap_or(1)
74            .max(1);
75
76        for (name, bytes) in &config.embedded {
77            let p = LoadedPlugin::from_bytes(bytes, max_instances)
78                .with_context(|| format!("embedded plugin '{name}'"))?;
79            validate(&p.manifest, &host_fns)?;
80            plugins.push(Arc::new(p));
81        }
82
83        if config.auto_discover {
84            for dir in [paths::user_plugins_dir(), paths::project_plugins_dir()]
85                .into_iter()
86                .flatten()
87            {
88                if !dir.is_dir() {
89                    continue;
90                }
91                let entries =
92                    std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))?;
93                for ent in entries {
94                    let Ok(ent) = ent else { continue };
95                    let path = ent.path();
96                    if path.extension().and_then(|s| s.to_str()) != Some("wasm") {
97                        continue;
98                    }
99                    let p = LoadedPlugin::from_file(path.clone(), max_instances)
100                        .with_context(|| format!("load {}", path.display()))?;
101                    validate(&p.manifest, &host_fns)?;
102                    plugins.push(Arc::new(p));
103                }
104            }
105        }
106
107        for path in &config.extra_paths {
108            let p = LoadedPlugin::from_file(path.clone(), max_instances)
109                .with_context(|| format!("load {}", path.display()))?;
110            validate(&p.manifest, &host_fns)?;
111            plugins.push(Arc::new(p));
112        }
113
114        let mut me = Self {
115            plugins,
116            subcommand_index: BTreeMap::new(),
117            runner_index: BTreeMap::new(),
118            output_formatter_index: BTreeMap::new(),
119            default_runner: None,
120        };
121        me.index_capabilities()?;
122        Ok(me)
123    }
124
125    fn index_capabilities(&mut self) -> Result<()> {
126        for (i, p) in self.plugins.iter().enumerate() {
127            for cap in &p.manifest.capabilities {
128                match cap {
129                    Capability::Subcommand(s) => {
130                        if let Some(other) = self.subcommand_index.insert(s.verb.clone(), i) {
131                            return Err(HmError::PluginConflict {
132                                verb: s.verb.clone(),
133                                plugin_a: self.plugins[other].manifest.name.clone(),
134                                plugin_b: p.manifest.name.clone(),
135                            }
136                            .into());
137                        }
138                    }
139                    Capability::StepExecutor(s) => {
140                        if let Some(other) = self.runner_index.insert(s.runner.clone(), i) {
141                            return Err(HmError::PluginConflict {
142                                verb: format!("runner:{}", s.runner),
143                                plugin_a: self.plugins[other].manifest.name.clone(),
144                                plugin_b: p.manifest.name.clone(),
145                            }
146                            .into());
147                        }
148                        if s.default {
149                            if let Some(other) = self.default_runner.replace(i) {
150                                return Err(HmError::PluginConflict {
151                                    verb: "default-runner".into(),
152                                    plugin_a: self.plugins[other].manifest.name.clone(),
153                                    plugin_b: p.manifest.name.clone(),
154                                }
155                                .into());
156                            }
157                        }
158                    }
159                    Capability::OutputFormatter(s) => {
160                        if let Some(other) = self.output_formatter_index.insert(s.name.clone(), i) {
161                            return Err(HmError::PluginConflict {
162                                verb: format!("format:{}", s.name),
163                                plugin_a: self.plugins[other].manifest.name.clone(),
164                                plugin_b: p.manifest.name.clone(),
165                            }
166                            .into());
167                        }
168                    }
169                    Capability::LifecycleHook(_) => {
170                        // Hooks can stack; no conflict possible.
171                    }
172                }
173            }
174        }
175        Ok(())
176    }
177
178    pub fn manifests(&self) -> impl Iterator<Item = &PluginManifest> {
179        self.plugins.iter().map(|p| &p.manifest)
180    }
181
182    /// Return a cheap clone of the plugin at `idx`. Callers should
183    /// drop any registry-level lock they hold before awaiting on the
184    /// returned plugin — the per-plugin pool is what serialises
185    /// concurrent calls, not the registry.
186    #[must_use]
187    pub fn get(&self, idx: usize) -> Option<Arc<LoadedPlugin>> {
188        self.plugins.get(idx).cloned()
189    }
190
191    /// Returns the runner name of the plugin marked `default: true` at
192    /// registration time, if any. Used by the scheduler to resolve
193    /// steps that don't declare a `runner` field.
194    #[must_use]
195    pub fn default_runner_name(&self) -> Option<&str> {
196        let idx = self.default_runner?;
197        self.runner_index
198            .iter()
199            .find_map(|(name, &i)| (i == idx).then_some(name.as_str()))
200    }
201}
202
203fn validate(m: &PluginManifest, host_fns: &HashSet<&str>) -> Result<()> {
204    validate_standalone(m, host_fns).map_err(|e| match e {
205        ManifestError::ApiVersion {
206            name,
207            found,
208            expected,
209        } => HmError::PluginManifest {
210            name,
211            expected_api: expected,
212            found_api: found,
213        }
214        .into(),
215        ManifestError::MissingHostFn { name, fn_name } => HmError::PluginMissingHostFn {
216            name,
217            fn_name,
218            min_hm_version: semver::Version::new(0, 0, 0),
219        }
220        .into(),
221        ManifestError::NoCapabilities { ref name }
222        | ManifestError::BadRunnerName { ref name, .. }
223        | ManifestError::DuplicateSubcommandVerb { ref name, .. } => HmError::PluginLoad {
224            name: name.clone(),
225            path: std::path::PathBuf::new(),
226            reason: e.to_string(),
227            doc_url: "https://harmont.dev/docs/plugins/manifest",
228        }
229        .into(),
230    })
231}