use std::io::{BufRead, Write};
pub use metarepo_core::protocol::{
ArgInfo, CommandInfo, PluginRequest, PluginResponse, RuntimeConfigDto, PLUGIN_PROTOCOL_VERSION,
};
pub trait Plugin {
fn name(&self) -> &str;
fn version(&self) -> &str;
fn is_experimental(&self) -> bool {
false
}
fn commands(&self) -> Vec<CommandInfo>;
fn handle(
&self,
command: &str,
args: &[String],
config: &RuntimeConfigDto,
) -> anyhow::Result<Option<String>>;
}
pub fn serve<P: Plugin>(plugin: P) -> anyhow::Result<()> {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
serve_io(&plugin, stdin.lock(), stdout.lock())
}
pub fn serve_io<P, R, W>(plugin: &P, reader: R, mut writer: W) -> anyhow::Result<()>
where
P: Plugin,
R: BufRead,
W: Write,
{
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let response = match serde_json::from_str::<PluginRequest>(&line) {
Ok(request) => dispatch(plugin, request),
Err(e) => PluginResponse::Error {
message: format!("Failed to parse request: {e}"),
},
};
writeln!(writer, "{}", serde_json::to_string(&response)?)?;
writer.flush()?;
}
Ok(())
}
fn dispatch<P: Plugin>(plugin: &P, request: PluginRequest) -> PluginResponse {
match request {
PluginRequest::GetInfo => PluginResponse::Info {
name: plugin.name().to_string(),
version: plugin.version().to_string(),
experimental: plugin.is_experimental(),
protocol_version: Some(PLUGIN_PROTOCOL_VERSION.to_string()),
},
PluginRequest::RegisterCommands => PluginResponse::Commands {
commands: plugin.commands(),
},
PluginRequest::HandleCommand {
command,
args,
config,
} => match plugin.handle(&command, &args, &config) {
Ok(message) => PluginResponse::Success { message },
Err(e) => PluginResponse::Error {
message: e.to_string(),
},
},
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestPlugin;
impl Plugin for TestPlugin {
fn name(&self) -> &str {
"test"
}
fn version(&self) -> &str {
"9.9.9"
}
fn commands(&self) -> Vec<CommandInfo> {
vec![CommandInfo::new("test", "A test command").arg(ArgInfo::new(
"name",
"Name to greet",
true,
))]
}
fn handle(
&self,
command: &str,
args: &[String],
_config: &RuntimeConfigDto,
) -> anyhow::Result<Option<String>> {
if command == "boom" {
anyhow::bail!("explicit failure");
}
Ok(Some(format!("handled {command} with {args:?}")))
}
}
fn run(input: &str) -> Vec<String> {
let mut out = Vec::new();
serve_io(&TestPlugin, input.as_bytes(), &mut out).unwrap();
String::from_utf8(out)
.unwrap()
.lines()
.map(|s| s.to_string())
.collect()
}
#[test]
fn get_info_reports_name_version_and_protocol() {
let lines = run(r#"{"type":"GetInfo"}"#);
assert_eq!(lines.len(), 1);
let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
match resp {
PluginResponse::Info {
name,
version,
experimental,
protocol_version,
} => {
assert_eq!(name, "test");
assert_eq!(version, "9.9.9");
assert!(!experimental);
assert_eq!(protocol_version.as_deref(), Some(PLUGIN_PROTOCOL_VERSION));
}
_ => panic!("expected Info"),
}
}
#[test]
fn register_commands_returns_declared_tree() {
let lines = run(r#"{"type":"RegisterCommands"}"#);
let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
match resp {
PluginResponse::Commands { commands } => {
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, "test");
assert_eq!(commands[0].args.len(), 1);
assert_eq!(commands[0].args[0].name, "name");
}
_ => panic!("expected Commands"),
}
}
#[test]
fn handle_command_success_carries_message() {
let req = r#"{"type":"HandleCommand","command":"greet","args":["world"],"config":{"meta_config":{"projects":{}},"working_dir":"/tmp","meta_file_path":null,"experimental":false}}"#;
let lines = run(req);
let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
match resp {
PluginResponse::Success { message } => {
assert!(message.unwrap().contains("handled greet"));
}
_ => panic!("expected Success"),
}
}
#[test]
fn handle_command_error_is_reported() {
let req = r#"{"type":"HandleCommand","command":"boom","args":[],"config":{"meta_config":{"projects":{}},"working_dir":"/tmp","meta_file_path":null,"experimental":false}}"#;
let lines = run(req);
let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
match resp {
PluginResponse::Error { message } => assert!(message.contains("explicit failure")),
_ => panic!("expected Error"),
}
}
#[test]
fn malformed_request_yields_error_not_panic() {
let lines = run("not json at all");
let resp: PluginResponse = serde_json::from_str(&lines[0]).unwrap();
match resp {
PluginResponse::Error { message } => assert!(message.contains("Failed to parse")),
_ => panic!("expected Error"),
}
}
#[test]
fn blank_lines_are_skipped_and_multiple_requests_served() {
let lines = run("\n{\"type\":\"GetInfo\"}\n\n{\"type\":\"GetInfo\"}\n");
assert_eq!(lines.len(), 2);
}
}