Skip to main content

agent_doc/
ffi.rs

1//! C ABI exports for editor plugin native bindings.
2//!
3//! Provides a minimal FFI surface for component parsing, patch application,
4//! and CRDT merge — the operations currently duplicated in Kotlin and TypeScript.
5//!
6//! # Safety
7//!
8//! All `extern "C"` functions accept raw pointers. Callers must ensure:
9//! - String pointers are valid, non-null, NUL-terminated UTF-8
10//! - Returned pointers are freed with [`agent_doc_free_string`]
11//! - Byte-buffer pointers (`*const u8`) have the specified length
12
13use std::ffi::{CStr, CString, c_char};
14use std::ptr;
15
16use crate::component;
17use crate::crdt;
18use crate::template;
19
20/// Serialized component info returned by [`agent_doc_parse_components`].
21#[repr(C)]
22pub struct FfiComponentList {
23    /// JSON-encoded array of components. Free with [`agent_doc_free_string`].
24    pub json: *mut c_char,
25    /// Number of components parsed (convenience — also available in the JSON).
26    pub count: usize,
27}
28
29/// Result of [`agent_doc_apply_patch`].
30#[repr(C)]
31pub struct FfiPatchResult {
32    /// The patched document text, or null on error. Free with [`agent_doc_free_string`].
33    pub text: *mut c_char,
34    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
35    pub error: *mut c_char,
36}
37
38/// Result of [`agent_doc_crdt_merge`].
39#[repr(C)]
40pub struct FfiMergeResult {
41    /// Merged document text, or null on error. Free with [`agent_doc_free_string`].
42    pub text: *mut c_char,
43    /// Updated CRDT state bytes (caller must copy). Null on error.
44    pub state: *mut u8,
45    /// Length of `state` in bytes.
46    pub state_len: usize,
47    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
48    pub error: *mut c_char,
49}
50
51/// Parse components from a document.
52///
53/// Returns a [`FfiComponentList`] with a JSON-encoded array of components.
54/// Each component object has: `name`, `attrs`, `open_start`, `open_end`,
55/// `close_start`, `close_end`, `content`.
56///
57/// # Safety
58///
59/// `doc` must be a valid, NUL-terminated UTF-8 string.
60#[unsafe(no_mangle)]
61pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
62    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
63        Ok(s) => s,
64        Err(_) => {
65            return FfiComponentList {
66                json: ptr::null_mut(),
67                count: 0,
68            };
69        }
70    };
71
72    let components = match component::parse(doc_str) {
73        Ok(c) => c,
74        Err(_) => {
75            return FfiComponentList {
76                json: ptr::null_mut(),
77                count: 0,
78            };
79        }
80    };
81
82    let count = components.len();
83
84    // Serialize to JSON with content included
85    let json_items: Vec<serde_json::Value> = components
86        .iter()
87        .map(|c| {
88            serde_json::json!({
89                "name": c.name,
90                "attrs": c.attrs,
91                "open_start": c.open_start,
92                "open_end": c.open_end,
93                "close_start": c.close_start,
94                "close_end": c.close_end,
95                "content": c.content(doc_str),
96            })
97        })
98        .collect();
99
100    let json_str = serde_json::to_string(&json_items).unwrap_or_default();
101    let c_json = CString::new(json_str).unwrap_or_default();
102
103    FfiComponentList {
104        json: c_json.into_raw(),
105        count,
106    }
107}
108
109/// Apply a patch to a document component.
110///
111/// `mode` must be one of: `"replace"`, `"append"`, `"prepend"`.
112///
113/// # Safety
114///
115/// All string pointers must be valid, NUL-terminated UTF-8.
116#[unsafe(no_mangle)]
117pub unsafe extern "C" fn agent_doc_apply_patch(
118    doc: *const c_char,
119    component_name: *const c_char,
120    content: *const c_char,
121    mode: *const c_char,
122) -> FfiPatchResult {
123    let make_err = |msg: &str| FfiPatchResult {
124        text: ptr::null_mut(),
125        error: CString::new(msg).unwrap_or_default().into_raw(),
126    };
127
128    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
129        Ok(s) => s,
130        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
131    };
132    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
133        Ok(s) => s,
134        Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
135    };
136    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
137        Ok(s) => s,
138        Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
139    };
140    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
141        Ok(s) => s,
142        Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
143    };
144
145    // Build a patch block and apply it
146    let patch = template::PatchBlock {
147        name: name.to_string(),
148        content: patch_content.to_string(),
149    };
150
151    // Use mode overrides to force the specified mode
152    let mut overrides = std::collections::HashMap::new();
153    overrides.insert(name.to_string(), mode_str.to_string());
154
155    // apply_patches_with_overrides needs a file path for config lookup — use a dummy
156    // since we're providing explicit overrides
157    let dummy_path = std::path::Path::new("/dev/null");
158    match template::apply_patches_with_overrides(
159        doc_str,
160        &[patch],
161        "",
162        dummy_path,
163        &overrides,
164    ) {
165        Ok(result) => FfiPatchResult {
166            text: CString::new(result).unwrap_or_default().into_raw(),
167            error: ptr::null_mut(),
168        },
169        Err(e) => make_err(&format!("{e}")),
170    }
171}
172
173/// CRDT merge (3-way conflict-free).
174///
175/// `base_state` may be null (first merge). `base_state_len` is ignored when null.
176///
177/// # Safety
178///
179/// - `ours` and `theirs` must be valid, NUL-terminated UTF-8.
180/// - If `base_state` is non-null, `base_state_len` bytes must be readable from it.
181/// - The caller must free `text` and `error` with [`agent_doc_free_string`].
182/// - The caller must free `state` with [`agent_doc_free_state`].
183#[unsafe(no_mangle)]
184pub unsafe extern "C" fn agent_doc_crdt_merge(
185    base_state: *const u8,
186    base_state_len: usize,
187    ours: *const c_char,
188    theirs: *const c_char,
189) -> FfiMergeResult {
190    let make_err = |msg: &str| FfiMergeResult {
191        text: ptr::null_mut(),
192        state: ptr::null_mut(),
193        state_len: 0,
194        error: CString::new(msg).unwrap_or_default().into_raw(),
195    };
196
197    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
198        Ok(s) => s,
199        Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
200    };
201    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
202        Ok(s) => s,
203        Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
204    };
205
206    let base = if base_state.is_null() {
207        None
208    } else {
209        Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
210    };
211
212    match crdt::merge(base, ours_str, theirs_str) {
213        Ok(merged_text) => {
214            // Encode the merged state for persistence
215            let doc = crdt::CrdtDoc::from_text(&merged_text);
216            let state_bytes = doc.encode_state();
217            let state_len = state_bytes.len();
218            let state_ptr = {
219                let mut boxed = state_bytes.into_boxed_slice();
220                let ptr = boxed.as_mut_ptr();
221                std::mem::forget(boxed);
222                ptr
223            };
224
225            FfiMergeResult {
226                text: CString::new(merged_text).unwrap_or_default().into_raw(),
227                state: state_ptr,
228                state_len,
229                error: ptr::null_mut(),
230            }
231        }
232        Err(e) => make_err(&format!("{e}")),
233    }
234}
235
236/// Free a string returned by any `agent_doc_*` function.
237///
238/// # Safety
239///
240/// `ptr` must have been returned by an `agent_doc_*` function, or be null.
241#[unsafe(no_mangle)]
242pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
243    if !ptr.is_null() {
244        drop(unsafe { CString::from_raw(ptr) });
245    }
246}
247
248/// Free a state buffer returned by [`agent_doc_crdt_merge`].
249///
250/// # Safety
251///
252/// `ptr` and `len` must match a state buffer returned by `agent_doc_crdt_merge`, or `ptr` must be null.
253#[unsafe(no_mangle)]
254pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
255    if !ptr.is_null() {
256        drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn parse_components_roundtrip() {
266        let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
267        let c_doc = CString::new(doc).unwrap();
268        let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
269        assert_eq!(result.count, 1);
270        assert!(!result.json.is_null());
271        let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
272        let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
273        assert_eq!(parsed[0]["name"], "status");
274        assert_eq!(parsed[0]["content"], "hello\n");
275        unsafe { agent_doc_free_string(result.json) };
276    }
277
278    #[test]
279    fn apply_patch_replace() {
280        let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
281        let c_doc = CString::new(doc).unwrap();
282        let c_name = CString::new("output").unwrap();
283        let c_content = CString::new("new content\n").unwrap();
284        let c_mode = CString::new("replace").unwrap();
285        let result = unsafe {
286            agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
287        };
288        assert!(result.error.is_null());
289        assert!(!result.text.is_null());
290        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
291        assert!(text.contains("new content"));
292        assert!(!text.contains("old"));
293        unsafe { agent_doc_free_string(result.text) };
294    }
295
296    #[test]
297    fn crdt_merge_no_base() {
298        let c_ours = CString::new("hello world").unwrap();
299        let c_theirs = CString::new("hello world").unwrap();
300        let result = unsafe {
301            agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
302        };
303        assert!(result.error.is_null());
304        assert!(!result.text.is_null());
305        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
306        assert_eq!(text, "hello world");
307        unsafe {
308            agent_doc_free_string(result.text);
309            agent_doc_free_state(result.state, result.state_len);
310        };
311    }
312}