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::{ConfigSetting, 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.
19///
20/// 1.1 added the optional `GetSettings`/`Settings` exchange; it is additive and
21/// backward compatible — a 1.0 plugin simply doesn't answer it and the host
22/// treats that as "no declared settings".
23///
24/// 1.2 added the optional `help_description` field on [`CommandInfo`]; it is
25/// additive and backward compatible — older plugins omit it (deserializes to
26/// `None`) and the host renders no `Description:` section for that command.
27pub const PLUGIN_PROTOCOL_VERSION: &str = "1.2";
28
29/// A request sent from the host to a plugin subprocess.
30#[derive(Debug, Serialize, Deserialize)]
31#[serde(tag = "type")]
32pub enum PluginRequest {
33    /// Ask the plugin to identify itself (name, version, protocol).
34    GetInfo,
35    /// Ask the plugin for its command tree.
36    RegisterCommands,
37    /// Ask the plugin to declare its configurable settings (protocol 1.1+).
38    /// Older plugins don't recognize this and reply with an error, which the
39    /// host treats as "no settings".
40    GetSettings,
41    /// Ask the plugin to execute a command.
42    HandleCommand {
43        command: String,
44        args: Vec<String>,
45        config: Box<RuntimeConfigDto>,
46    },
47}
48
49/// A response sent from a plugin subprocess back to the host.
50#[derive(Debug, Serialize, Deserialize)]
51#[serde(tag = "type")]
52pub enum PluginResponse {
53    Info {
54        name: String,
55        version: String,
56        experimental: bool,
57        /// Wire-protocol version the plugin implements (e.g. "1.0"). Optional in
58        /// the deserialized form so the host can detect legacy plugins that
59        /// predate v1 and surface a useful error instead of a parse failure.
60        #[serde(default)]
61        protocol_version: Option<String>,
62    },
63    Commands {
64        commands: Vec<CommandInfo>,
65    },
66    /// The plugin's declared configuration settings (protocol 1.1+).
67    Settings {
68        settings: Vec<ConfigSetting>,
69    },
70    Success {
71        message: Option<String>,
72    },
73    Error {
74        message: String,
75    },
76}
77
78/// Declarative description of a command (and its subcommands/args) that a plugin
79/// exposes. The host rebuilds clap commands from this over the wire.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct CommandInfo {
82    pub name: String,
83    pub about: String,
84    /// Optional long, man-page-style help body rendered as a `Description:`
85    /// section on `--help`. Added in protocol 1.2; older plugins omit it.
86    #[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    /// Create a leaf command with no args or subcommands.
94    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    /// Set a long, man-page-style help description (rendered on `--help`).
105    pub fn help_description(mut self, text: impl Into<String>) -> Self {
106        self.help_description = Some(text.into());
107        self
108    }
109
110    /// Add a positional/required argument (builder style).
111    pub fn arg(mut self, arg: ArgInfo) -> Self {
112        self.args.push(arg);
113        self
114    }
115
116    /// Add a nested subcommand (builder style).
117    pub fn subcommand(mut self, sub: CommandInfo) -> Self {
118        self.subcommands.push(sub);
119        self
120    }
121}
122
123/// Declarative description of a single command argument.
124#[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/// Serializable snapshot of [`RuntimeConfig`] passed to a plugin over the wire.
142///
143/// This intentionally omits host-only fields (e.g. `non_interactive`) that have
144/// no meaning in a subprocess; they default when reconstructed.
145#[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    /// Whether the user requested whole-workspace scope (`--workspace`/`-w`).
152    /// Defaults to `false` so older hosts/plugins remain compatible.
153    #[serde(default)]
154    pub scope_workspace: bool,
155}
156
157impl RuntimeConfigDto {
158    /// Typed access to a plugin's own config block, mirroring
159    /// [`crate::RuntimeConfig::plugin_config`] so external plugins read their
160    /// settings exactly the way in-process ones do.
161    pub fn plugin_config<T: serde::de::DeserializeOwned>(&self, name: &str) -> Option<T> {
162        self.meta_config.plugin_settings(name)
163    }
164
165    /// Resolve the project keys an external plugin should operate on, applying
166    /// the same directory-aware rules as the host. See [`crate::scoped_keys`].
167    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
203/// Verify that a plugin's reported `protocol_version` is compatible with this
204/// build. Same major version = compatible (additive minor changes remain
205/// backwards-compatible). Missing or mismatched major = rejected.
206pub 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}