Skip to main content

metarepo_core/
protocol.rs

1//! Wire protocol (v1) for communication between the metarepo host and external
2//! plugins running as subprocesses.
3//!
4//! The host writes a newline-delimited JSON [`PluginRequest`] to the plugin's
5//! stdin and reads a single newline-delimited JSON [`PluginResponse`] back from
6//! its stdout. These types are the canonical definition of that format; both the
7//! host (`metarepo`) and the plugin-author SDK (`metarepo-plugin-sdk`) depend on
8//! them so the wire format is defined exactly once.
9//!
10//! See `docs/PLUGIN_PROTOCOL_V1.md` for the full specification.
11
12use crate::{MetaConfig, RuntimeConfig};
13use serde::{Deserialize, Serialize};
14use std::path::PathBuf;
15
16/// Wire-format protocol version this build speaks. Plugins must report a
17/// matching major version in their [`PluginResponse::Info`] or the host refuses
18/// to load them.
19pub const PLUGIN_PROTOCOL_VERSION: &str = "1.0";
20
21/// A request sent from the host to a plugin subprocess.
22#[derive(Debug, Serialize, Deserialize)]
23#[serde(tag = "type")]
24pub enum PluginRequest {
25    /// Ask the plugin to identify itself (name, version, protocol).
26    GetInfo,
27    /// Ask the plugin for its command tree.
28    RegisterCommands,
29    /// Ask the plugin to execute a command.
30    HandleCommand {
31        command: String,
32        args: Vec<String>,
33        config: Box<RuntimeConfigDto>,
34    },
35}
36
37/// A response sent from a plugin subprocess back to the host.
38#[derive(Debug, Serialize, Deserialize)]
39#[serde(tag = "type")]
40pub enum PluginResponse {
41    Info {
42        name: String,
43        version: String,
44        experimental: bool,
45        /// Wire-protocol version the plugin implements (e.g. "1.0"). Optional in
46        /// the deserialized form so the host can detect legacy plugins that
47        /// predate v1 and surface a useful error instead of a parse failure.
48        #[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/// Declarative description of a command (and its subcommands/args) that a plugin
63/// exposes. The host rebuilds clap commands from this over the wire.
64#[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    /// Create a leaf command with no args or subcommands.
74    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    /// Add a positional/required argument (builder style).
84    pub fn arg(mut self, arg: ArgInfo) -> Self {
85        self.args.push(arg);
86        self
87    }
88
89    /// Add a nested subcommand (builder style).
90    pub fn subcommand(mut self, sub: CommandInfo) -> Self {
91        self.subcommands.push(sub);
92        self
93    }
94}
95
96/// Declarative description of a single command argument.
97#[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/// Serializable snapshot of [`RuntimeConfig`] passed to a plugin over the wire.
115///
116/// This intentionally omits host-only fields (e.g. `non_interactive`) that have
117/// no meaning in a subprocess; they default when reconstructed.
118#[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
149/// Verify that a plugin's reported `protocol_version` is compatible with this
150/// build. Same major version = compatible (additive minor changes remain
151/// backwards-compatible). Missing or mismatched major = rejected.
152pub 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}