Skip to main content

harmont_cli/plugin/
manifest.rs

1//! Validates plugin manifests as they're loaded.
2
3// Pedantic nags suppressed scope-wide:
4// - `missing_errors_doc`: the only fn returning Result is
5//   `validate_standalone`, whose errors are typed as `ManifestError`
6//   and each variant carries its own message.
7// - `implicit_hasher`: `available_host_fns` is intentionally typed
8//   `&HashSet<&str>` (default hasher) — the registry constructs it
9//   that way; generalising over hashers buys nothing.
10// - `collapsible_if`: keeping the inner `if` separate from the outer
11//   `match` makes the validation rules easier to read one-per-line.
12// - `single_match_else` style: see same rationale.
13#![allow(clippy::missing_errors_doc)]
14#![allow(clippy::implicit_hasher)]
15#![allow(clippy::collapsible_if)]
16#![allow(clippy::collapsible_match)]
17// The first doc paragraph explains both what `validate_standalone` does
18// and what it deliberately leaves to the registry; splitting that
19// across paragraphs would scatter the contract.
20#![allow(clippy::too_long_first_doc_paragraph)]
21// `["hm_log"].into_iter().collect()` keeps the visual shape of the
22// broader case (the same pattern adds N host fns when needed); the
23// `iter_on_single_items` rewrite would hide that.
24#![allow(clippy::iter_on_single_items)]
25
26use std::collections::HashSet;
27
28use hm_plugin_protocol::{Capability, HM_PLUGIN_API_VERSION, PluginManifest};
29use thiserror::Error;
30
31#[derive(Debug, Error)]
32pub enum ManifestError {
33    #[error("plugin '{name}': api_version mismatch (plugin: {found}, host: {expected})")]
34    ApiVersion {
35        name: String,
36        found: u32,
37        expected: u32,
38    },
39    #[error("plugin '{name}': required host fn '{fn_name}' is not available in this hm build")]
40    MissingHostFn { name: String, fn_name: String },
41    #[error("plugin '{name}': declared no capabilities")]
42    NoCapabilities { name: String },
43    #[error("plugin '{name}': StepExecutorSpec.runner '{runner}' is empty or contains whitespace")]
44    BadRunnerName { name: String, runner: String },
45    #[error("plugin '{name}': declared the same subcommand verb twice ('{verb}')")]
46    DuplicateSubcommandVerb { name: String, verb: String },
47}
48
49/// Returns Ok(()) iff `manifest` passes every check we can do
50/// statically (i.e. without consulting other plugins). Cross-plugin
51/// conflicts (e.g. two plugins both claim `runner: "docker"`) are
52/// caught by [`super::registry`].
53pub fn validate_standalone(
54    manifest: &PluginManifest,
55    available_host_fns: &HashSet<&str>,
56) -> Result<(), ManifestError> {
57    if manifest.api_version != HM_PLUGIN_API_VERSION {
58        return Err(ManifestError::ApiVersion {
59            name: manifest.name.clone(),
60            found: manifest.api_version,
61            expected: HM_PLUGIN_API_VERSION,
62        });
63    }
64    for fn_name in &manifest.required_host_fns {
65        if !available_host_fns.contains(fn_name.as_str()) {
66            return Err(ManifestError::MissingHostFn {
67                name: manifest.name.clone(),
68                fn_name: fn_name.clone(),
69            });
70        }
71    }
72    if manifest.capabilities.is_empty() {
73        return Err(ManifestError::NoCapabilities {
74            name: manifest.name.clone(),
75        });
76    }
77    let mut seen_verbs: HashSet<&str> = HashSet::new();
78    for cap in &manifest.capabilities {
79        match cap {
80            Capability::StepExecutor(s) => {
81                if s.runner.trim().is_empty() || s.runner.chars().any(char::is_whitespace) {
82                    return Err(ManifestError::BadRunnerName {
83                        name: manifest.name.clone(),
84                        runner: s.runner.clone(),
85                    });
86                }
87            }
88            Capability::Subcommand(s) => {
89                if !seen_verbs.insert(s.verb.as_str()) {
90                    return Err(ManifestError::DuplicateSubcommandVerb {
91                        name: manifest.name.clone(),
92                        verb: s.verb.clone(),
93                    });
94                }
95            }
96            _ => {}
97        }
98    }
99    Ok(())
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use hm_plugin_protocol::{Capability, StepExecutorSpec};
106    use semver::Version;
107
108    fn host_fns() -> HashSet<&'static str> {
109        ["hm_log"].into_iter().collect()
110    }
111
112    #[test]
113    fn rejects_wrong_api_version() {
114        let m = PluginManifest {
115            api_version: 999,
116            name: "p".into(),
117            version: Version::new(0, 1, 0),
118            description: "x".into(),
119            capabilities: vec![Capability::StepExecutor(StepExecutorSpec {
120                runner: "a".into(),
121                default: false,
122                step_schema: None,
123            })],
124            required_host_fns: vec![],
125            config_schema: None,
126            allowed_hosts: vec![],
127        };
128        assert!(matches!(
129            validate_standalone(&m, &host_fns()),
130            Err(ManifestError::ApiVersion { .. })
131        ));
132    }
133
134    #[test]
135    fn rejects_missing_host_fn() {
136        let m = PluginManifest {
137            api_version: HM_PLUGIN_API_VERSION,
138            name: "p".into(),
139            version: Version::new(0, 1, 0),
140            description: "x".into(),
141            capabilities: vec![Capability::StepExecutor(StepExecutorSpec {
142                runner: "a".into(),
143                default: false,
144                step_schema: None,
145            })],
146            required_host_fns: vec!["hm_quantum_teleport".into()],
147            config_schema: None,
148            allowed_hosts: vec![],
149        };
150        assert!(matches!(
151            validate_standalone(&m, &host_fns()),
152            Err(ManifestError::MissingHostFn { fn_name, .. }) if fn_name == "hm_quantum_teleport"
153        ));
154    }
155
156    #[test]
157    fn accepts_minimal_valid_manifest() {
158        let m = PluginManifest {
159            api_version: HM_PLUGIN_API_VERSION,
160            name: "p".into(),
161            version: Version::new(0, 1, 0),
162            description: "x".into(),
163            capabilities: vec![Capability::StepExecutor(StepExecutorSpec {
164                runner: "a".into(),
165                default: false,
166                step_schema: None,
167            })],
168            required_host_fns: vec!["hm_log".into()],
169            config_schema: None,
170            allowed_hosts: vec![],
171        };
172        assert!(validate_standalone(&m, &host_fns()).is_ok());
173    }
174}