metarepo_plugin_sdk/
lib.rs1use std::io::{BufRead, Write};
37
38pub use metarepo_core::protocol::{
41 ArgInfo, CommandInfo, PluginRequest, PluginResponse, RuntimeConfigDto, PLUGIN_PROTOCOL_VERSION,
42};
43
44pub trait Plugin {
51 fn name(&self) -> &str;
53
54 fn version(&self) -> &str;
56
57 fn is_experimental(&self) -> bool {
59 false
60 }
61
62 fn commands(&self) -> Vec<CommandInfo>;
65
66 fn handle(
70 &self,
71 command: &str,
72 args: &[String],
73 config: &RuntimeConfigDto,
74 ) -> anyhow::Result<Option<String>>;
75}
76
77pub 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
87pub 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
117fn 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 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}