Skip to main content

binoc_sdk/
plugin_abi.rs

1//! C-ABI stable protocol for native renderer plugins.
2//!
3//! Plugins compiled as separate cdylibs expose `#[no_mangle] extern "C"`
4//! functions. The host loads them via `libloading` and calls them with
5//! JSON-serialized requests/responses, avoiding Rust ABI compatibility
6//! requirements.
7//!
8//! As of CFM-27b, renderers are the only graduated stable ABI family. Rule
9//! families remain in-process until their trait shapes and vocabularies satisfy
10//! the graduation signal recorded in the tiered plugin surface ADR.
11
12use serde::{Deserialize, Serialize};
13
14use crate::traits::RendererDescriptor;
15
16// ── Plugin description ─────────────────────────────────────────────
17
18/// Top-level plugin description returned by `_binoc_plugin_describe`.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct PluginDescription {
21    pub sdk_version: String,
22    #[serde(default)]
23    pub renderers: Vec<RendererDescriptor>,
24}
25
26// ── Renderer wire types ───────────────────────────────────────────
27
28#[derive(Debug, Serialize, Deserialize)]
29pub struct RenderRequest {
30    /// Complete projected changesets. Renderer-facing vocabularies inside the
31    /// IR remain open strings: actions, item types, tags, source evidence,
32    /// detail-block kinds, global-claim verbs, and edit verbs must flow through
33    /// the renderer ABI unchanged.
34    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// ── export_plugin! macro ───────────────────────────────────────────
48
49/// Export a renderer plugin pack.
50///
51/// Generates:
52///
53/// - `_binoc_plugin_describe`
54/// - `_binoc_free_string`
55/// - `_binoc_renderer_render`
56/// - an empty `#[pymodule]` when the `python` feature is active
57///
58/// # Example
59///
60/// ```ignore
61/// export_plugin! {
62///     module: my_plugin,
63///     renderers: [MyRenderer],
64/// }
65/// ```
66#[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}