use crate::{ConfigSetting, MetaConfig, RuntimeConfig};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const PLUGIN_PROTOCOL_VERSION: &str = "1.2";
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PluginRequest {
GetInfo,
RegisterCommands,
GetSettings,
HandleCommand {
command: String,
args: Vec<String>,
config: Box<RuntimeConfigDto>,
},
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PluginResponse {
Info {
name: String,
version: String,
experimental: bool,
#[serde(default)]
protocol_version: Option<String>,
},
Commands {
commands: Vec<CommandInfo>,
},
Settings {
settings: Vec<ConfigSetting>,
},
Success {
message: Option<String>,
},
Error {
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandInfo {
pub name: String,
pub about: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help_description: Option<String>,
pub subcommands: Vec<CommandInfo>,
pub args: Vec<ArgInfo>,
}
impl CommandInfo {
pub fn new(name: impl Into<String>, about: impl Into<String>) -> Self {
CommandInfo {
name: name.into(),
about: about.into(),
help_description: None,
subcommands: Vec::new(),
args: Vec::new(),
}
}
pub fn help_description(mut self, text: impl Into<String>) -> Self {
self.help_description = Some(text.into());
self
}
pub fn arg(mut self, arg: ArgInfo) -> Self {
self.args.push(arg);
self
}
pub fn subcommand(mut self, sub: CommandInfo) -> Self {
self.subcommands.push(sub);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArgInfo {
pub name: String,
pub help: String,
pub required: bool,
}
impl ArgInfo {
pub fn new(name: impl Into<String>, help: impl Into<String>, required: bool) -> Self {
ArgInfo {
name: name.into(),
help: help.into(),
required,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeConfigDto {
pub meta_config: MetaConfig,
pub working_dir: PathBuf,
pub meta_file_path: Option<PathBuf>,
pub experimental: bool,
#[serde(default)]
pub scope_workspace: bool,
}
impl RuntimeConfigDto {
pub fn plugin_config<T: serde::de::DeserializeOwned>(&self, name: &str) -> Option<T> {
self.meta_config.plugin_settings(name)
}
pub fn scoped_project_keys(&self) -> Vec<String> {
crate::scoped_keys(
&self.meta_config,
&self.working_dir,
self.meta_file_path.as_deref(),
self.scope_workspace,
)
}
}
impl From<&RuntimeConfig> for RuntimeConfigDto {
fn from(config: &RuntimeConfig) -> Self {
RuntimeConfigDto {
meta_config: config.meta_config.clone(),
working_dir: config.working_dir.clone(),
meta_file_path: config.meta_file_path.clone(),
experimental: config.experimental,
scope_workspace: config.scope_workspace,
}
}
}
impl From<RuntimeConfigDto> for RuntimeConfig {
fn from(dto: RuntimeConfigDto) -> Self {
RuntimeConfig {
meta_config: dto.meta_config,
working_dir: dto.working_dir,
meta_file_path: dto.meta_file_path,
experimental: dto.experimental,
non_interactive: None,
scope_workspace: dto.scope_workspace,
settings_catalog: Vec::new(),
}
}
}
pub fn check_protocol_version(reported: Option<&str>) -> anyhow::Result<()> {
let reported = reported.ok_or_else(|| {
anyhow::anyhow!(
"Plugin does not declare a protocol_version. This metarepo speaks v{}; rebuild the plugin against the latest metarepo-plugin-sdk.",
PLUGIN_PROTOCOL_VERSION
)
})?;
let (their_major, _) = split_major_minor(reported).map_err(|_| {
anyhow::anyhow!(
"Plugin reported an unparseable protocol_version '{}'. Expected something like '{}'.",
reported,
PLUGIN_PROTOCOL_VERSION
)
})?;
let (our_major, _) = split_major_minor(PLUGIN_PROTOCOL_VERSION).unwrap();
if their_major != our_major {
return Err(anyhow::anyhow!(
"Plugin reports protocol v{} but this metarepo supports v{}. Rebuild the plugin against a compatible metarepo-plugin-sdk.",
reported,
PLUGIN_PROTOCOL_VERSION
));
}
Ok(())
}
fn split_major_minor(s: &str) -> std::result::Result<(u32, u32), std::num::ParseIntError> {
let mut parts = s.splitn(2, '.');
let major: u32 = parts.next().unwrap_or("").parse()?;
let minor: u32 = parts.next().unwrap_or("0").parse()?;
Ok((major, minor))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_serialization_roundtrips() {
let request = PluginRequest::GetInfo;
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("GetInfo"));
}
#[test]
fn response_deserialization_legacy_missing_protocol_version() {
let json = r#"{"type":"Info","name":"test","version":"1.0.0","experimental":false}"#;
let response: PluginResponse = serde_json::from_str(json).unwrap();
match response {
PluginResponse::Info {
protocol_version, ..
} => assert!(protocol_version.is_none()),
_ => panic!("expected Info variant"),
}
}
#[test]
fn response_deserialization_with_protocol_version() {
let json = r#"{"type":"Info","name":"test","version":"1.0.0","experimental":false,"protocol_version":"1.0"}"#;
let response: PluginResponse = serde_json::from_str(json).unwrap();
match response {
PluginResponse::Info {
protocol_version, ..
} => assert_eq!(protocol_version.as_deref(), Some("1.0")),
_ => panic!("expected Info variant"),
}
}
#[test]
fn check_protocol_version_accepts_same_major() {
assert!(check_protocol_version(Some("1.0")).is_ok());
assert!(check_protocol_version(Some("1.5")).is_ok());
}
#[test]
fn check_protocol_version_rejects_missing() {
let err = check_protocol_version(None).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("does not declare"));
assert!(msg.contains(PLUGIN_PROTOCOL_VERSION));
}
#[test]
fn check_protocol_version_rejects_different_major() {
let err = check_protocol_version(Some("2.0")).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("v2.0"));
assert!(msg.contains(PLUGIN_PROTOCOL_VERSION));
}
#[test]
fn check_protocol_version_rejects_garbage() {
let err = check_protocol_version(Some("not-a-version")).unwrap_err();
assert!(err.to_string().contains("unparseable"));
}
#[test]
fn runtime_config_dto_roundtrips() {
let config = RuntimeConfig {
meta_config: MetaConfig::default(),
working_dir: PathBuf::from("/tmp"),
meta_file_path: None,
experimental: false,
non_interactive: None,
scope_workspace: false,
settings_catalog: Vec::new(),
};
let dto: RuntimeConfigDto = (&config).into();
assert_eq!(dto.working_dir, config.working_dir);
assert_eq!(dto.experimental, config.experimental);
let back: RuntimeConfig = dto.into();
assert_eq!(back.working_dir, config.working_dir);
}
}