1use crate::{MetaConfig, RuntimeConfig};
13use serde::{Deserialize, Serialize};
14use std::path::PathBuf;
15
16pub const PLUGIN_PROTOCOL_VERSION: &str = "1.0";
20
21#[derive(Debug, Serialize, Deserialize)]
23#[serde(tag = "type")]
24pub enum PluginRequest {
25 GetInfo,
27 RegisterCommands,
29 HandleCommand {
31 command: String,
32 args: Vec<String>,
33 config: Box<RuntimeConfigDto>,
34 },
35}
36
37#[derive(Debug, Serialize, Deserialize)]
39#[serde(tag = "type")]
40pub enum PluginResponse {
41 Info {
42 name: String,
43 version: String,
44 experimental: bool,
45 #[serde(default)]
49 protocol_version: Option<String>,
50 },
51 Commands {
52 commands: Vec<CommandInfo>,
53 },
54 Success {
55 message: Option<String>,
56 },
57 Error {
58 message: String,
59 },
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CommandInfo {
66 pub name: String,
67 pub about: String,
68 pub subcommands: Vec<CommandInfo>,
69 pub args: Vec<ArgInfo>,
70}
71
72impl CommandInfo {
73 pub fn new(name: impl Into<String>, about: impl Into<String>) -> Self {
75 CommandInfo {
76 name: name.into(),
77 about: about.into(),
78 subcommands: Vec::new(),
79 args: Vec::new(),
80 }
81 }
82
83 pub fn arg(mut self, arg: ArgInfo) -> Self {
85 self.args.push(arg);
86 self
87 }
88
89 pub fn subcommand(mut self, sub: CommandInfo) -> Self {
91 self.subcommands.push(sub);
92 self
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ArgInfo {
99 pub name: String,
100 pub help: String,
101 pub required: bool,
102}
103
104impl ArgInfo {
105 pub fn new(name: impl Into<String>, help: impl Into<String>, required: bool) -> Self {
106 ArgInfo {
107 name: name.into(),
108 help: help.into(),
109 required,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct RuntimeConfigDto {
120 pub meta_config: MetaConfig,
121 pub working_dir: PathBuf,
122 pub meta_file_path: Option<PathBuf>,
123 pub experimental: bool,
124}
125
126impl From<&RuntimeConfig> for RuntimeConfigDto {
127 fn from(config: &RuntimeConfig) -> Self {
128 RuntimeConfigDto {
129 meta_config: config.meta_config.clone(),
130 working_dir: config.working_dir.clone(),
131 meta_file_path: config.meta_file_path.clone(),
132 experimental: config.experimental,
133 }
134 }
135}
136
137impl From<RuntimeConfigDto> for RuntimeConfig {
138 fn from(dto: RuntimeConfigDto) -> Self {
139 RuntimeConfig {
140 meta_config: dto.meta_config,
141 working_dir: dto.working_dir,
142 meta_file_path: dto.meta_file_path,
143 experimental: dto.experimental,
144 non_interactive: None,
145 }
146 }
147}
148
149pub fn check_protocol_version(reported: Option<&str>) -> anyhow::Result<()> {
153 let reported = reported.ok_or_else(|| {
154 anyhow::anyhow!(
155 "Plugin does not declare a protocol_version. This metarepo speaks v{}; rebuild the plugin against the latest metarepo-plugin-sdk.",
156 PLUGIN_PROTOCOL_VERSION
157 )
158 })?;
159
160 let (their_major, _) = split_major_minor(reported).map_err(|_| {
161 anyhow::anyhow!(
162 "Plugin reported an unparseable protocol_version '{}'. Expected something like '{}'.",
163 reported,
164 PLUGIN_PROTOCOL_VERSION
165 )
166 })?;
167 let (our_major, _) = split_major_minor(PLUGIN_PROTOCOL_VERSION).unwrap();
168
169 if their_major != our_major {
170 return Err(anyhow::anyhow!(
171 "Plugin reports protocol v{} but this metarepo supports v{}. Rebuild the plugin against a compatible metarepo-plugin-sdk.",
172 reported,
173 PLUGIN_PROTOCOL_VERSION
174 ));
175 }
176 Ok(())
177}
178
179fn split_major_minor(s: &str) -> std::result::Result<(u32, u32), std::num::ParseIntError> {
180 let mut parts = s.splitn(2, '.');
181 let major: u32 = parts.next().unwrap_or("").parse()?;
182 let minor: u32 = parts.next().unwrap_or("0").parse()?;
183 Ok((major, minor))
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn request_serialization_roundtrips() {
192 let request = PluginRequest::GetInfo;
193 let json = serde_json::to_string(&request).unwrap();
194 assert!(json.contains("GetInfo"));
195 }
196
197 #[test]
198 fn response_deserialization_legacy_missing_protocol_version() {
199 let json = r#"{"type":"Info","name":"test","version":"1.0.0","experimental":false}"#;
200 let response: PluginResponse = serde_json::from_str(json).unwrap();
201 match response {
202 PluginResponse::Info {
203 protocol_version, ..
204 } => assert!(protocol_version.is_none()),
205 _ => panic!("expected Info variant"),
206 }
207 }
208
209 #[test]
210 fn response_deserialization_with_protocol_version() {
211 let json = r#"{"type":"Info","name":"test","version":"1.0.0","experimental":false,"protocol_version":"1.0"}"#;
212 let response: PluginResponse = serde_json::from_str(json).unwrap();
213 match response {
214 PluginResponse::Info {
215 protocol_version, ..
216 } => assert_eq!(protocol_version.as_deref(), Some("1.0")),
217 _ => panic!("expected Info variant"),
218 }
219 }
220
221 #[test]
222 fn check_protocol_version_accepts_same_major() {
223 assert!(check_protocol_version(Some("1.0")).is_ok());
224 assert!(check_protocol_version(Some("1.5")).is_ok());
225 }
226
227 #[test]
228 fn check_protocol_version_rejects_missing() {
229 let err = check_protocol_version(None).unwrap_err();
230 let msg = err.to_string();
231 assert!(msg.contains("does not declare"));
232 assert!(msg.contains(PLUGIN_PROTOCOL_VERSION));
233 }
234
235 #[test]
236 fn check_protocol_version_rejects_different_major() {
237 let err = check_protocol_version(Some("2.0")).unwrap_err();
238 let msg = err.to_string();
239 assert!(msg.contains("v2.0"));
240 assert!(msg.contains(PLUGIN_PROTOCOL_VERSION));
241 }
242
243 #[test]
244 fn check_protocol_version_rejects_garbage() {
245 let err = check_protocol_version(Some("not-a-version")).unwrap_err();
246 assert!(err.to_string().contains("unparseable"));
247 }
248
249 #[test]
250 fn runtime_config_dto_roundtrips() {
251 let config = RuntimeConfig {
252 meta_config: MetaConfig::default(),
253 working_dir: PathBuf::from("/tmp"),
254 meta_file_path: None,
255 experimental: false,
256 non_interactive: None,
257 };
258 let dto: RuntimeConfigDto = (&config).into();
259 assert_eq!(dto.working_dir, config.working_dir);
260 assert_eq!(dto.experimental, config.experimental);
261 let back: RuntimeConfig = dto.into();
262 assert_eq!(back.working_dir, config.working_dir);
263 }
264}