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