Skip to main content

metarepo_plugin_sdk/
lib.rs

1//! # metarepo-plugin-sdk
2//!
3//! Author a metarepo external plugin by implementing one trait and calling
4//! [`serve`]. The SDK owns the v1 stdio wire protocol — request framing,
5//! parsing, dispatch, error handling, and the protocol-version handshake — so
6//! plugin code never touches stdin/stdout or JSON directly.
7//!
8//! ```no_run
9//! use metarepo_plugin_sdk::{serve, CommandInfo, Plugin, RuntimeConfigDto};
10//!
11//! struct Hello;
12//!
13//! impl Plugin for Hello {
14//!     fn name(&self) -> &str { "hello" }
15//!     fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
16//!
17//!     fn commands(&self) -> Vec<CommandInfo> {
18//!         vec![CommandInfo::new("hello", "Print a greeting")]
19//!     }
20//!
21//!     fn handle(
22//!         &self,
23//!         _command: &str,
24//!         _args: &[String],
25//!         _config: &RuntimeConfigDto,
26//!     ) -> anyhow::Result<Option<String>> {
27//!         Ok(Some("hello from a plugin".to_string()))
28//!     }
29//! }
30//!
31//! fn main() -> anyhow::Result<()> {
32//!     serve(Hello)
33//! }
34//! ```
35
36use std::io::{BufRead, Write};
37
38// Re-export the wire types so plugin authors depend only on the SDK. These are
39// also the names used internally by `serve_io`/`dispatch` below.
40pub use metarepo_core::protocol::{
41    ArgInfo, CommandInfo, PluginRequest, PluginResponse, RuntimeConfigDto, PLUGIN_PROTOCOL_VERSION,
42};
43
44/// A metarepo plugin. Implement this trait and pass an instance to [`serve`].
45///
46/// This is the subprocess-oriented analogue of the host's `MetaPlugin` trait:
47/// commands are declared as data ([`CommandInfo`]) rather than built from clap,
48/// and the runtime config arrives as a serialized [`RuntimeConfigDto`] snapshot
49/// instead of a borrowed host value.
50pub trait Plugin {
51    /// The command name this plugin registers (e.g. `"hello"` for `meta hello`).
52    fn name(&self) -> &str;
53
54    /// The plugin's own version string (typically `env!("CARGO_PKG_VERSION")`).
55    fn version(&self) -> &str;
56
57    /// Whether this plugin is experimental. Defaults to `false`.
58    fn is_experimental(&self) -> bool {
59        false
60    }
61
62    /// The command tree this plugin exposes. The host rebuilds clap commands
63    /// from this for `meta --help` and argument routing.
64    fn commands(&self) -> Vec<CommandInfo>;
65
66    /// Execute an invocation. `command` is the top-level command name and
67    /// `args` are the positional arguments the host parsed. Return an optional
68    /// message to print on success, or an error to report failure.
69    fn handle(
70        &self,
71        command: &str,
72        args: &[String],
73        config: &RuntimeConfigDto,
74    ) -> anyhow::Result<Option<String>>;
75}
76
77/// Run the plugin against the process stdin/stdout.
78///
79/// This blocks, serving requests until stdin reaches EOF (the host closes the
80/// pipe). Call it from `main`.
81pub fn serve<P: Plugin>(plugin: P) -> anyhow::Result<()> {
82    let stdin = std::io::stdin();
83    let stdout = std::io::stdout();
84    serve_io(&plugin, stdin.lock(), stdout.lock())
85}
86
87/// Run the request loop against arbitrary reader/writer streams.
88///
89/// Exposed for testing with in-memory buffers; [`serve`] wraps this with the
90/// process std streams.
91pub fn serve_io<P, R, W>(plugin: &P, reader: R, mut writer: W) -> anyhow::Result<()>
92where
93    P: Plugin,
94    R: BufRead,
95    W: Write,
96{
97    for line in reader.lines() {
98        let line = line?;
99        if line.trim().is_empty() {
100            continue;
101        }
102
103        let response = match serde_json::from_str::<PluginRequest>(&line) {
104            Ok(request) => dispatch(plugin, request),
105            Err(e) => PluginResponse::Error {
106                message: format!("Failed to parse request: {e}"),
107            },
108        };
109
110        writeln!(writer, "{}", serde_json::to_string(&response)?)?;
111        writer.flush()?;
112    }
113
114    Ok(())
115}
116
117/// Map a single request to its response using the plugin's trait methods.
118fn dispatch<P: Plugin>(plugin: &P, request: PluginRequest) -> PluginResponse {
119    match request {
120        PluginRequest::GetInfo => PluginResponse::Info {
121            name: plugin.name().to_string(),
122            version: plugin.version().to_string(),
123            experimental: plugin.is_experimental(),
124            protocol_version: Some(PLUGIN_PROTOCOL_VERSION.to_string()),
125        },
126        PluginRequest::RegisterCommands => PluginResponse::Commands {
127            commands: plugin.commands(),
128        },
129        PluginRequest::HandleCommand {
130            command,
131            args,
132            config,
133        } => match plugin.handle(&command, &args, &config) {
134            Ok(message) => PluginResponse::Success { message },
135            Err(e) => PluginResponse::Error {
136                message: e.to_string(),
137            },
138        },
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    struct TestPlugin;
147
148    impl Plugin for TestPlugin {
149        fn name(&self) -> &str {
150            "test"
151        }
152        fn version(&self) -> &str {
153            "9.9.9"
154        }
155        fn commands(&self) -> Vec<CommandInfo> {
156            vec![CommandInfo::new("test", "A test command").arg(ArgInfo::new(
157                "name",
158                "Name to greet",
159                true,
160            ))]
161        }
162        fn handle(
163            &self,
164            command: &str,
165            args: &[String],
166            _config: &RuntimeConfigDto,
167        ) -> anyhow::Result<Option<String>> {
168            if command == "boom" {
169                anyhow::bail!("explicit failure");
170            }
171            Ok(Some(format!("handled {command} with {args:?}")))
172        }
173    }
174
175    /// Drive serve_io with a sequence of request lines, return the response lines.
176    fn run(input: &str) -> Vec<String> {
177        let mut out = Vec::new();
178        serve_io(&TestPlugin, input.as_bytes(), &mut out).unwrap();
179        String::from_utf8(out)
180            .unwrap()
181            .lines()
182            .map(|s| s.to_string())
183            .collect()
184    }
185
186    #[test]
187    fn get_info_reports_name_version_and_protocol() {
188        let lines = run(r#"{"type":"GetInfo"}"#);
189        assert_eq!(lines.len(), 1);
190        let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
191        match resp {
192            PluginResponse::Info {
193                name,
194                version,
195                experimental,
196                protocol_version,
197            } => {
198                assert_eq!(name, "test");
199                assert_eq!(version, "9.9.9");
200                assert!(!experimental);
201                assert_eq!(protocol_version.as_deref(), Some(PLUGIN_PROTOCOL_VERSION));
202            }
203            _ => panic!("expected Info"),
204        }
205    }
206
207    #[test]
208    fn register_commands_returns_declared_tree() {
209        let lines = run(r#"{"type":"RegisterCommands"}"#);
210        let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
211        match resp {
212            PluginResponse::Commands { commands } => {
213                assert_eq!(commands.len(), 1);
214                assert_eq!(commands[0].name, "test");
215                assert_eq!(commands[0].args.len(), 1);
216                assert_eq!(commands[0].args[0].name, "name");
217            }
218            _ => panic!("expected Commands"),
219        }
220    }
221
222    #[test]
223    fn handle_command_success_carries_message() {
224        let req = r#"{"type":"HandleCommand","command":"greet","args":["world"],"config":{"meta_config":{"projects":{}},"working_dir":"/tmp","meta_file_path":null,"experimental":false}}"#;
225        let lines = run(req);
226        let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
227        match resp {
228            PluginResponse::Success { message } => {
229                assert!(message.unwrap().contains("handled greet"));
230            }
231            _ => panic!("expected Success"),
232        }
233    }
234
235    #[test]
236    fn handle_command_error_is_reported() {
237        let req = r#"{"type":"HandleCommand","command":"boom","args":[],"config":{"meta_config":{"projects":{}},"working_dir":"/tmp","meta_file_path":null,"experimental":false}}"#;
238        let lines = run(req);
239        let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
240        match resp {
241            PluginResponse::Error { message } => assert!(message.contains("explicit failure")),
242            _ => panic!("expected Error"),
243        }
244    }
245
246    #[test]
247    fn malformed_request_yields_error_not_panic() {
248        let lines = run("not json at all");
249        let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
250        match resp {
251            PluginResponse::Error { message } => assert!(message.contains("Failed to parse")),
252            _ => panic!("expected Error"),
253        }
254    }
255
256    #[test]
257    fn blank_lines_are_skipped_and_multiple_requests_served() {
258        let lines = run("\n{\"type\":\"GetInfo\"}\n\n{\"type\":\"GetInfo\"}\n");
259        assert_eq!(lines.len(), 2);
260    }
261}