use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use cymule_core::{ReconciliationResolution, WorldOutcome};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{RuntimeError, RuntimeResult};
pub const PLUGIN_VERSION: &str = "cymule.plugin/1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginOperation {
pub implementation_revision: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginEffect {
pub implementation_revision: String,
pub can_reconcile: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginManifest {
pub plugin_version: String,
pub implementation_id: String,
#[serde(default)]
pub components: BTreeMap<String, PluginOperation>,
#[serde(default)]
pub effects: BTreeMap<String, PluginEffect>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginRequest {
Describe,
Call {
component: String,
input: Value,
},
PrepareEffect {
operation: String,
intent_id: String,
input: Value,
},
DispatchEffect {
operation: String,
intent_id: String,
input: Value,
},
ReconcileEffect {
operation: String,
intent_id: String,
input: Value,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginResponse {
Manifest {
manifest: PluginManifest,
},
CallResult {
value: Value,
},
Prepared,
EffectResult {
outcome: WorldOutcome,
#[serde(default)]
value: Option<Value>,
},
ReconciliationResult {
resolution: ReconciliationResolution,
#[serde(default)]
value: Option<Value>,
},
Error {
code: String,
message: String,
},
}
pub trait PluginHost {
fn invoke(&mut self, request: PluginRequest) -> RuntimeResult<PluginResponse>;
fn describe(&mut self) -> RuntimeResult<PluginManifest> {
match self.invoke(PluginRequest::Describe)? {
PluginResponse::Manifest { manifest } => {
if manifest.plugin_version != PLUGIN_VERSION {
return Err(RuntimeError::Plugin(format!(
"unsupported plugin version {:?}",
manifest.plugin_version
)));
}
if manifest.implementation_id.is_empty() {
return Err(RuntimeError::Plugin(
"plugin implementation_id is empty".to_owned(),
));
}
Ok(manifest)
}
response => Err(RuntimeError::Plugin(format!(
"describe returned unexpected response {response:?}"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct ProcessPlugin {
executable: PathBuf,
}
impl ProcessPlugin {
pub fn new(executable: impl AsRef<Path>) -> Self {
Self {
executable: executable.as_ref().to_path_buf(),
}
}
}
impl PluginHost for ProcessPlugin {
fn invoke(&mut self, request: PluginRequest) -> RuntimeResult<PluginResponse> {
let mut child = Command::new(&self.executable)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| {
RuntimeError::Io(format!(
"failed to start plugin {}: {error}",
self.executable.display()
))
})?;
let input = serde_json::to_vec(&request)?;
child
.stdin
.take()
.ok_or_else(|| RuntimeError::Io("plugin stdin was not captured".to_owned()))?
.write_all(&input)?;
let output = child.wait_with_output()?;
if !output.status.success() {
return Err(RuntimeError::Plugin(format!(
"plugin {} exited with {}: {}",
self.executable.display(),
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let response: PluginResponse = serde_json::from_slice(&output.stdout)?;
if let PluginResponse::Error { code, message } = &response {
return Err(RuntimeError::Plugin(format!("{code}: {message}")));
}
Ok(response)
}
}