1use crate::{ConfigSetting, MetaConfig, RuntimeConfig};
13use serde::{Deserialize, Serialize};
14use std::path::PathBuf;
15
16pub const PLUGIN_PROTOCOL_VERSION: &str = "1.2";
28
29#[derive(Debug, Serialize, Deserialize)]
31#[serde(tag = "type")]
32pub enum PluginRequest {
33 GetInfo,
35 RegisterCommands,
37 GetSettings,
41 HandleCommand {
43 command: String,
44 args: Vec<String>,
45 config: Box<RuntimeConfigDto>,
46 },
47}
48
49#[derive(Debug, Serialize, Deserialize)]
51#[serde(tag = "type")]
52pub enum PluginResponse {
53 Info {
54 name: String,
55 version: String,
56 experimental: bool,
57 #[serde(default)]
61 protocol_version: Option<String>,
62 },
63 Commands {
64 commands: Vec<CommandInfo>,
65 },
66 Settings {
68 settings: Vec<ConfigSetting>,
69 },
70 Success {
71 message: Option<String>,
72 },
73 Error {
74 message: String,
75 },
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct CommandInfo {
82 pub name: String,
83 pub about: String,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub help_description: Option<String>,
88 pub subcommands: Vec<CommandInfo>,
89 pub args: Vec<ArgInfo>,
90}
91
92impl CommandInfo {
93 pub fn new(name: impl Into<String>, about: impl Into<String>) -> Self {
95 CommandInfo {
96 name: name.into(),
97 about: about.into(),
98 help_description: None,
99 subcommands: Vec::new(),
100 args: Vec::new(),
101 }
102 }
103
104 pub fn help_description(mut self, text: impl Into<String>) -> Self {
106 self.help_description = Some(text.into());
107 self
108 }
109
110 pub fn arg(mut self, arg: ArgInfo) -> Self {
112 self.args.push(arg);
113 self
114 }
115
116 pub fn subcommand(mut self, sub: CommandInfo) -> Self {
118 self.subcommands.push(sub);
119 self
120 }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ArgInfo {
126 pub name: String,
127 pub help: String,
128 pub required: bool,
129}
130
131impl ArgInfo {
132 pub fn new(name: impl Into<String>, help: impl Into<String>, required: bool) -> Self {
133 ArgInfo {
134 name: name.into(),
135 help: help.into(),
136 required,
137 }
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct RuntimeConfigDto {
147 pub meta_config: MetaConfig,
148 pub working_dir: PathBuf,
149 pub meta_file_path: Option<PathBuf>,
150 pub experimental: bool,
151 #[serde(default)]
154 pub scope_workspace: bool,
155}
156
157impl RuntimeConfigDto {
158 pub fn plugin_config<T: serde::de::DeserializeOwned>(&self, name: &str) -> Option<T> {
162 self.meta_config.plugin_settings(name)
163 }
164
165 pub fn scoped_project_keys(&self) -> Vec<String> {
168 crate::scoped_keys(
169 &self.meta_config,
170 &self.working_dir,
171 self.meta_file_path.as_deref(),
172 self.scope_workspace,
173 )
174 }
175}
176
177impl From<&RuntimeConfig> for RuntimeConfigDto {
178 fn from(config: &RuntimeConfig) -> Self {
179 RuntimeConfigDto {
180 meta_config: config.meta_config.clone(),
181 working_dir: config.working_dir.clone(),
182 meta_file_path: config.meta_file_path.clone(),
183 experimental: config.experimental,
184 scope_workspace: config.scope_workspace,
185 }
186 }
187}
188
189impl From<RuntimeConfigDto> for RuntimeConfig {
190 fn from(dto: RuntimeConfigDto) -> Self {
191 RuntimeConfig {
192 meta_config: dto.meta_config,
193 working_dir: dto.working_dir,
194 meta_file_path: dto.meta_file_path,
195 experimental: dto.experimental,
196 non_interactive: None,
197 scope_workspace: dto.scope_workspace,
198 settings_catalog: Vec::new(),
199 }
200 }
201}
202
203pub fn check_protocol_version(reported: Option<&str>) -> anyhow::Result<()> {
207 let reported = reported.ok_or_else(|| {
208 anyhow::anyhow!(
209 "Plugin does not declare a protocol_version. This metarepo speaks v{}; rebuild the plugin against the latest metarepo-plugin-sdk.",
210 PLUGIN_PROTOCOL_VERSION
211 )
212 })?;
213
214 let (their_major, _) = split_major_minor(reported).map_err(|_| {
215 anyhow::anyhow!(
216 "Plugin reported an unparseable protocol_version '{}'. Expected something like '{}'.",
217 reported,
218 PLUGIN_PROTOCOL_VERSION
219 )
220 })?;
221 let (our_major, _) = split_major_minor(PLUGIN_PROTOCOL_VERSION).unwrap();
222
223 if their_major != our_major {
224 return Err(anyhow::anyhow!(
225 "Plugin reports protocol v{} but this metarepo supports v{}. Rebuild the plugin against a compatible metarepo-plugin-sdk.",
226 reported,
227 PLUGIN_PROTOCOL_VERSION
228 ));
229 }
230 Ok(())
231}
232
233fn split_major_minor(s: &str) -> std::result::Result<(u32, u32), std::num::ParseIntError> {
234 let mut parts = s.splitn(2, '.');
235 let major: u32 = parts.next().unwrap_or("").parse()?;
236 let minor: u32 = parts.next().unwrap_or("0").parse()?;
237 Ok((major, minor))
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn request_serialization_roundtrips() {
246 let request = PluginRequest::GetInfo;
247 let json = serde_json::to_string(&request).unwrap();
248 assert!(json.contains("GetInfo"));
249 }
250
251 #[test]
252 fn response_deserialization_legacy_missing_protocol_version() {
253 let json = r#"{"type":"Info","name":"test","version":"1.0.0","experimental":false}"#;
254 let response: PluginResponse = serde_json::from_str(json).unwrap();
255 match response {
256 PluginResponse::Info {
257 protocol_version, ..
258 } => assert!(protocol_version.is_none()),
259 _ => panic!("expected Info variant"),
260 }
261 }
262
263 #[test]
264 fn response_deserialization_with_protocol_version() {
265 let json = r#"{"type":"Info","name":"test","version":"1.0.0","experimental":false,"protocol_version":"1.0"}"#;
266 let response: PluginResponse = serde_json::from_str(json).unwrap();
267 match response {
268 PluginResponse::Info {
269 protocol_version, ..
270 } => assert_eq!(protocol_version.as_deref(), Some("1.0")),
271 _ => panic!("expected Info variant"),
272 }
273 }
274
275 #[test]
276 fn check_protocol_version_accepts_same_major() {
277 assert!(check_protocol_version(Some("1.0")).is_ok());
278 assert!(check_protocol_version(Some("1.5")).is_ok());
279 }
280
281 #[test]
282 fn check_protocol_version_rejects_missing() {
283 let err = check_protocol_version(None).unwrap_err();
284 let msg = err.to_string();
285 assert!(msg.contains("does not declare"));
286 assert!(msg.contains(PLUGIN_PROTOCOL_VERSION));
287 }
288
289 #[test]
290 fn check_protocol_version_rejects_different_major() {
291 let err = check_protocol_version(Some("2.0")).unwrap_err();
292 let msg = err.to_string();
293 assert!(msg.contains("v2.0"));
294 assert!(msg.contains(PLUGIN_PROTOCOL_VERSION));
295 }
296
297 #[test]
298 fn check_protocol_version_rejects_garbage() {
299 let err = check_protocol_version(Some("not-a-version")).unwrap_err();
300 assert!(err.to_string().contains("unparseable"));
301 }
302
303 #[test]
304 fn runtime_config_dto_roundtrips() {
305 let config = RuntimeConfig {
306 meta_config: MetaConfig::default(),
307 working_dir: PathBuf::from("/tmp"),
308 meta_file_path: None,
309 experimental: false,
310 non_interactive: None,
311 scope_workspace: false,
312 settings_catalog: Vec::new(),
313 };
314 let dto: RuntimeConfigDto = (&config).into();
315 assert_eq!(dto.working_dir, config.working_dir);
316 assert_eq!(dto.experimental, config.experimental);
317 let back: RuntimeConfig = dto.into();
318 assert_eq!(back.working_dir, config.working_dir);
319 }
320}