Skip to main content

appctl_plugin_sdk/
ffi.rs

1//! Stable C ABI for dynamic `appctl` sync plugins.
2//!
3//! The host (`appctl` binary) loads a `cdylib` built against this crate,
4//! calls `appctl_plugin_register`, and receives a [`PluginManifest`] containing
5//! a [`PluginVtable`] of extern "C" function pointers.
6//!
7//! JSON is used as the wire format between host and plugin so the ABI does
8//! not depend on matching Rust toolchains or serde versions.
9
10use serde::{Deserialize, Serialize};
11use std::os::raw::{c_char, c_int};
12
13/// Bumped whenever the vtable shape changes. Plugins must refuse to load
14/// if the host reports a different value.
15pub const SDK_ABI_VERSION: u32 = 1;
16
17/// Static metadata returned by every plugin.
18#[repr(C)]
19pub struct PluginManifest {
20    /// Must equal [`SDK_ABI_VERSION`]; checked by the host.
21    pub abi_version: u32,
22    /// Null-terminated UTF-8 name, e.g. `"airtable"`.
23    pub name: *const c_char,
24    /// Null-terminated UTF-8 semver.
25    pub version: *const c_char,
26    /// Null-terminated UTF-8 human description.
27    pub description: *const c_char,
28    /// Vtable with the plugin's operations.
29    pub vtable: PluginVtable,
30}
31
32// SAFETY: all pointers are to 'static CStrs owned by the plugin image.
33unsafe impl Send for PluginManifest {}
34unsafe impl Sync for PluginManifest {}
35
36/// Function pointer table. Every function takes and returns heap-allocated
37/// null-terminated UTF-8 JSON strings the host must free via `free_string`.
38#[repr(C)]
39pub struct PluginVtable {
40    /// Introspect the target.
41    ///
42    /// `input_json` is a UTF-8 JSON document matching [`crate::SyncInput`].
43    /// On success `*out_json` points to a plugin-owned JSON string of a
44    /// [`crate::schema::Schema`]; on failure `*out_json` is an error message.
45    /// Returns 0 on success, non-zero on error.
46    pub introspect:
47        unsafe extern "C" fn(input_json: *const c_char, out_json: *mut *mut c_char) -> c_int,
48
49    /// Free a string previously returned by this plugin.
50    pub free_string: unsafe extern "C" fn(ptr: *mut c_char),
51}
52
53/// JSON envelope returned by the plugin's introspect function on success.
54#[derive(Debug, Serialize, Deserialize)]
55pub struct IntrospectResponse {
56    pub schema: crate::schema::Schema,
57}
58
59/// Declare a plugin. Generates the `appctl_plugin_register` entrypoint plus
60/// the `introspect` / `free_string` wrappers from a user-provided async
61/// function of the form `async fn(SyncInput) -> anyhow::Result<Schema>`.
62///
63/// ```ignore
64/// use appctl_plugin_sdk::prelude::*;
65///
66/// async fn my_introspect(_input: SyncInput) -> Result<Schema> { todo!() }
67///
68/// declare_plugin! {
69///     name: "airtable",
70///     version: "0.1.0",
71///     description: "Sync an Airtable base",
72///     introspect: my_introspect,
73/// }
74/// ```
75#[macro_export]
76macro_rules! declare_plugin {
77    (
78        name: $name:expr,
79        version: $version:expr,
80        description: $desc:expr,
81        introspect: $introspect:path $(,)?
82    ) => {
83        const _APPCTL_PLUGIN_NAME: &[u8] = concat!($name, "\0").as_bytes();
84        const _APPCTL_PLUGIN_VERSION: &[u8] = concat!($version, "\0").as_bytes();
85        const _APPCTL_PLUGIN_DESC: &[u8] = concat!($desc, "\0").as_bytes();
86
87        #[unsafe(no_mangle)]
88        pub unsafe extern "C" fn appctl_plugin_register() -> *const $crate::ffi::PluginManifest {
89            static MANIFEST: $crate::ffi::PluginManifest = $crate::ffi::PluginManifest {
90                abi_version: $crate::ffi::SDK_ABI_VERSION,
91                name: _APPCTL_PLUGIN_NAME.as_ptr() as *const ::std::os::raw::c_char,
92                version: _APPCTL_PLUGIN_VERSION.as_ptr() as *const ::std::os::raw::c_char,
93                description: _APPCTL_PLUGIN_DESC.as_ptr() as *const ::std::os::raw::c_char,
94                vtable: $crate::ffi::PluginVtable {
95                    introspect: _appctl_plugin_introspect_cabi,
96                    free_string: _appctl_plugin_free_string_cabi,
97                },
98            };
99            &MANIFEST as *const _
100        }
101
102        unsafe extern "C" fn _appctl_plugin_introspect_cabi(
103            input_json: *const ::std::os::raw::c_char,
104            out_json: *mut *mut ::std::os::raw::c_char,
105        ) -> ::std::os::raw::c_int {
106            let input_str = if input_json.is_null() {
107                "{}".to_string()
108            } else {
109                unsafe { ::std::ffi::CStr::from_ptr(input_json) }
110                    .to_string_lossy()
111                    .into_owned()
112            };
113
114            let res: ::std::result::Result<$crate::schema::Schema, ::anyhow::Error> = (|| {
115                let input: $crate::SyncInput = ::serde_json::from_str(&input_str)?;
116                let rt = ::tokio::runtime::Builder::new_current_thread()
117                    .enable_all()
118                    .build()?;
119                rt.block_on($introspect(input))
120            })();
121
122            let (code, payload) = match res {
123                Ok(schema) => {
124                    let env = $crate::ffi::IntrospectResponse { schema };
125                    match ::serde_json::to_string(&env) {
126                        Ok(json) => (0, json),
127                        Err(err) => (1, format!("{{\"error\":\"serialize failed: {}\"}}", err)),
128                    }
129                }
130                Err(err) => (
131                    1,
132                    format!(
133                        "{{\"error\":{}}}",
134                        ::serde_json::to_string(&err.to_string())
135                            .unwrap_or_else(|_| "\"unknown\"".into())
136                    ),
137                ),
138            };
139
140            match ::std::ffi::CString::new(payload) {
141                Ok(cstr) => unsafe {
142                    *out_json = cstr.into_raw();
143                    code
144                },
145                Err(_) => unsafe {
146                    *out_json = ::std::ptr::null_mut();
147                    2
148                },
149            }
150        }
151
152        unsafe extern "C" fn _appctl_plugin_free_string_cabi(ptr: *mut ::std::os::raw::c_char) {
153            if !ptr.is_null() {
154                unsafe {
155                    drop(::std::ffi::CString::from_raw(ptr));
156                }
157            }
158        }
159    };
160}
161
162/// Used by the host to recover a string returned from a plugin.
163///
164/// # Safety
165///
166/// `ptr` must have been returned by the corresponding plugin's
167/// `introspect` function and freed using its `free_string` entry.
168pub unsafe fn cstr_to_string(ptr: *const c_char) -> Option<String> {
169    if ptr.is_null() {
170        None
171    } else {
172        Some(
173            unsafe { std::ffi::CStr::from_ptr(ptr) }
174                .to_string_lossy()
175                .into_owned(),
176        )
177    }
178}