1use serde::{Deserialize, Serialize};
13
14use crate::traits::RendererDescriptor;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct PluginDescription {
21 pub sdk_version: String,
22 #[serde(default)]
23 pub renderers: Vec<RendererDescriptor>,
24}
25
26#[derive(Debug, Serialize, Deserialize)]
29pub struct RenderRequest {
30 pub changesets: Vec<crate::ir::Changeset>,
35 pub config: serde_json::Value,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39#[serde(tag = "status")]
40pub enum RenderResponse {
41 #[serde(rename = "ok")]
42 Ok { output: String },
43 #[serde(rename = "error")]
44 Error { message: String },
45}
46
47#[macro_export]
67macro_rules! export_plugin {
68 (@out_descs $($out:ty),*) => {{
69 vec![
70 $($crate::Renderer::descriptor(
71 &<$out as ::std::default::Default>::default(),
72 )),*
73 ]
74 }};
75
76 (@renderer_fns $($out:ty),+) => {
77 #[no_mangle]
78 pub unsafe extern "C" fn _binoc_renderer_render(
79 index: u32,
80 request: *const ::std::ffi::c_char,
81 ) -> *mut ::std::ffi::c_char {
82 let response = ::std::panic::catch_unwind(|| {
83 let request_str = ::std::ffi::CStr::from_ptr(request)
84 .to_str()
85 .expect("binoc SDK: valid UTF-8 request");
86 let req: $crate::plugin_abi::RenderRequest =
87 $crate::_reexport::serde_json::from_str(request_str)
88 .expect("binoc SDK: deserialize RenderRequest");
89 let renderers: Vec<Box<dyn $crate::Renderer>> =
90 vec![$(Box::new(<$out as ::std::default::Default>::default())),+];
91 let out = &renderers[index as usize];
92 match $crate::Renderer::render(out.as_ref(), &req.changesets, &req.config) {
93 Ok(output) => $crate::plugin_abi::RenderResponse::Ok { output },
94 Err(e) => $crate::plugin_abi::RenderResponse::Error {
95 message: e.to_string(),
96 },
97 }
98 });
99 let response = match response {
100 Ok(r) => r,
101 Err(_) => $crate::plugin_abi::RenderResponse::Error {
102 message: "plugin panicked".to_string(),
103 },
104 };
105 let json = $crate::_reexport::serde_json::to_string(&response)
106 .expect("binoc SDK: serialize render response");
107 ::std::ffi::CString::new(json)
108 .expect("binoc SDK: CString from JSON")
109 .into_raw()
110 }
111 };
112
113 (
114 module: $module_name:ident,
115 renderers: [$($out:ty),+ $(,)?] $(,)?
116 ) => {
117 #[no_mangle]
118 pub extern "C" fn _binoc_plugin_describe() -> *mut ::std::ffi::c_char {
119 let desc = $crate::plugin_abi::PluginDescription {
120 sdk_version: $crate::SDK_VERSION.to_string(),
121 renderers: $crate::export_plugin!(@out_descs $($out),+),
122 };
123 let json = $crate::_reexport::serde_json::to_string(&desc)
124 .expect("binoc SDK: serialize plugin description");
125 ::std::ffi::CString::new(json)
126 .expect("binoc SDK: CString from JSON")
127 .into_raw()
128 }
129
130 #[no_mangle]
131 pub unsafe extern "C" fn _binoc_free_string(s: *mut ::std::ffi::c_char) {
132 if !s.is_null() {
133 drop(::std::ffi::CString::from_raw(s));
134 }
135 }
136
137 $crate::export_plugin!(@renderer_fns $($out),+);
138
139 #[cfg(feature = "python")]
140 #[::pyo3::pymodule]
141 fn $module_name(_m: &::pyo3::Bound<'_, ::pyo3::types::PyModule>) -> ::pyo3::PyResult<()> {
142 Ok(())
143 }
144 };
145}
146
147#[cfg(test)]
148mod tests {
149 use std::ffi::{c_char, CStr, CString};
150
151 use serde_json::json;
152
153 use crate::{
154 correspondence::Edit, BinocResult, Changeset, DiffNode, Renderer, RendererDescriptor, Side,
155 Source,
156 };
157
158 #[derive(Default)]
159 struct EchoRenderer;
160
161 impl Renderer for EchoRenderer {
162 fn descriptor(&self) -> RendererDescriptor {
163 RendererDescriptor::new("test.echo", "echo")
164 }
165
166 fn render(
167 &self,
168 changesets: &[Changeset],
169 config: &serde_json::Value,
170 ) -> BinocResult<String> {
171 let root = changesets
172 .first()
173 .and_then(|changeset| changeset.root.as_ref())
174 .expect("test request has a root node");
175 let source = root.sources.first().expect("test request has a source");
176 let edits = root
177 .details
178 .get("edits")
179 .and_then(|value| value.as_array())
180 .expect("test request has edits");
181 let edit_verb = edits
182 .first()
183 .and_then(|edit| edit.get("verb"))
184 .and_then(|verb| verb.as_str())
185 .expect("test request edit has verb");
186
187 Ok(json!({
188 "action": root.action,
189 "item_type": root.item_type,
190 "tag": root.tags.iter().next(),
191 "source_evidence": source.evidence,
192 "source_action": source.action,
193 "edit_verb": edit_verb,
194 "config_seen": config["mode"],
195 })
196 .to_string())
197 }
198 }
199
200 crate::export_plugin! {
201 module: abi_test_plugin,
202 renderers: [EchoRenderer],
203 }
204
205 unsafe fn take_owned_abi_string(ptr: *mut c_char) -> String {
206 assert!(!ptr.is_null());
207 let value = unsafe { CStr::from_ptr(ptr) }
208 .to_str()
209 .expect("ABI string is UTF-8")
210 .to_string();
211 unsafe { _binoc_free_string(ptr) };
212 value
213 }
214
215 #[test]
216 fn renderer_abi_preserves_open_ir_vocabulary() {
217 let description_json = unsafe { take_owned_abi_string(_binoc_plugin_describe()) };
218 let description: crate::plugin_abi::PluginDescription =
219 serde_json::from_str(&description_json).expect("plugin description");
220 assert_eq!(description.sdk_version, crate::SDK_VERSION);
221 assert_eq!(description.renderers.len(), 1);
222 assert_eq!(description.renderers[0].name, "test.echo");
223
224 let edit = Edit::new(
225 "third_party.frobnicate",
226 json!({ "mode": "unknown-to-host" }),
227 );
228 let node = DiffNode::new(
229 "third_party.rebalance",
230 "third_party.dataset",
231 "current/data.bin",
232 )
233 .with_tag("third_party.semantic")
234 .with_source(
235 Source::new("previous/data.bin", Side::From)
236 .with_evidence("third_party.pair.bespoke")
237 .with_action("third_party.source_action"),
238 )
239 .with_detail("edits", json!([edit]));
240 let request = crate::plugin_abi::RenderRequest {
241 changesets: vec![Changeset::new("left", "right", Some(node))],
242 config: json!({ "mode": "parity" }),
243 };
244 let request_json = serde_json::to_string(&request).expect("request JSON");
245 let request_cstring = CString::new(request_json).expect("request has no nul");
246
247 let response_json =
248 unsafe { take_owned_abi_string(_binoc_renderer_render(0, request_cstring.as_ptr())) };
249 let response: crate::plugin_abi::RenderResponse =
250 serde_json::from_str(&response_json).expect("render response");
251 let crate::plugin_abi::RenderResponse::Ok { output } = response else {
252 panic!("renderer ABI returned an error");
253 };
254 let output: serde_json::Value = serde_json::from_str(&output).expect("renderer output");
255
256 assert_eq!(output["action"], "third_party.rebalance");
257 assert_eq!(output["item_type"], "third_party.dataset");
258 assert_eq!(output["tag"], "third_party.semantic");
259 assert_eq!(output["source_evidence"], "third_party.pair.bespoke");
260 assert_eq!(output["source_action"], "third_party.source_action");
261 assert_eq!(output["edit_verb"], "third_party.frobnicate");
262 assert_eq!(output["config_seen"], "parity");
263 }
264}