harmont_cli/plugin/
manifest.rs1#![allow(clippy::missing_errors_doc)]
14#![allow(clippy::implicit_hasher)]
15#![allow(clippy::collapsible_if)]
16#![allow(clippy::collapsible_match)]
17#![allow(clippy::too_long_first_doc_paragraph)]
21#![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
49pub 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}