1use std::collections::BTreeMap;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5
6use cymule_core::{ReconciliationResolution, WorldOutcome};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::{RuntimeError, RuntimeResult};
11
12pub const PLUGIN_VERSION: &str = "cymule.plugin/1";
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct PluginOperation {
19 pub implementation_revision: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct PluginEffect {
27 pub implementation_revision: String,
29 pub can_reconcile: bool,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct PluginManifest {
37 pub plugin_version: String,
39 pub implementation_id: String,
41 #[serde(default)]
43 pub components: BTreeMap<String, PluginOperation>,
44 #[serde(default)]
46 pub effects: BTreeMap<String, PluginEffect>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(tag = "type", rename_all = "snake_case")]
52pub enum PluginRequest {
53 Describe,
55 Call {
57 component: String,
59 input: Value,
61 },
62 PrepareEffect {
64 operation: String,
66 intent_id: String,
68 input: Value,
70 },
71 DispatchEffect {
73 operation: String,
75 intent_id: String,
77 input: Value,
79 },
80 ReconcileEffect {
82 operation: String,
84 intent_id: String,
86 input: Value,
88 },
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94pub enum PluginResponse {
95 Manifest {
97 manifest: PluginManifest,
99 },
100 CallResult {
102 value: Value,
104 },
105 Prepared,
107 EffectResult {
109 outcome: WorldOutcome,
111 #[serde(default)]
113 value: Option<Value>,
114 },
115 ReconciliationResult {
117 resolution: ReconciliationResolution,
119 #[serde(default)]
121 value: Option<Value>,
122 },
123 Error {
125 code: String,
127 message: String,
129 },
130}
131
132pub trait PluginHost {
134 fn invoke(&mut self, request: PluginRequest) -> RuntimeResult<PluginResponse>;
136
137 fn describe(&mut self) -> RuntimeResult<PluginManifest> {
139 match self.invoke(PluginRequest::Describe)? {
140 PluginResponse::Manifest { manifest } => {
141 if manifest.plugin_version != PLUGIN_VERSION {
142 return Err(RuntimeError::Plugin(format!(
143 "unsupported plugin version {:?}",
144 manifest.plugin_version
145 )));
146 }
147 if manifest.implementation_id.is_empty() {
148 return Err(RuntimeError::Plugin(
149 "plugin implementation_id is empty".to_owned(),
150 ));
151 }
152 Ok(manifest)
153 }
154 response => Err(RuntimeError::Plugin(format!(
155 "describe returned unexpected response {response:?}"
156 ))),
157 }
158 }
159}
160
161#[derive(Debug, Clone)]
163pub struct ProcessPlugin {
164 executable: PathBuf,
165}
166
167impl ProcessPlugin {
168 pub fn new(executable: impl AsRef<Path>) -> Self {
170 Self {
171 executable: executable.as_ref().to_path_buf(),
172 }
173 }
174}
175
176impl PluginHost for ProcessPlugin {
177 fn invoke(&mut self, request: PluginRequest) -> RuntimeResult<PluginResponse> {
178 let mut child = Command::new(&self.executable)
179 .stdin(Stdio::piped())
180 .stdout(Stdio::piped())
181 .stderr(Stdio::piped())
182 .spawn()
183 .map_err(|error| {
184 RuntimeError::Io(format!(
185 "failed to start plugin {}: {error}",
186 self.executable.display()
187 ))
188 })?;
189 let input = serde_json::to_vec(&request)?;
190 child
191 .stdin
192 .take()
193 .ok_or_else(|| RuntimeError::Io("plugin stdin was not captured".to_owned()))?
194 .write_all(&input)?;
195 let output = child.wait_with_output()?;
196 if !output.status.success() {
197 return Err(RuntimeError::Plugin(format!(
198 "plugin {} exited with {}: {}",
199 self.executable.display(),
200 output.status,
201 String::from_utf8_lossy(&output.stderr).trim()
202 )));
203 }
204 let response: PluginResponse = serde_json::from_slice(&output.stdout)?;
205 if let PluginResponse::Error { code, message } = &response {
206 return Err(RuntimeError::Plugin(format!("{code}: {message}")));
207 }
208 Ok(response)
209 }
210}