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/// Apply a component patch with cursor-aware ordering for append mode.
175///
176/// When `mode` is `"append"` and `caret_offset >= 0`, the content is inserted
177/// at the line boundary before the caret position (if the caret is inside the
178/// component). This ensures agent responses appear above where the user is typing.
179///
180/// Pass `caret_offset = -1` for normal behavior (identical to `agent_doc_apply_patch`).
181///
182/// # Safety
183///
184/// All pointers must be valid, non-null, NUL-terminated UTF-8.
185#[unsafe(no_mangle)]
186pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
187    doc: *const c_char,
188    component_name: *const c_char,
189    content: *const c_char,
190    mode: *const c_char,
191    caret_offset: i32,
192) -> FfiPatchResult {
193    let make_err = |msg: &str| FfiPatchResult {
194        text: ptr::null_mut(),
195        error: CString::new(msg).unwrap_or_default().into_raw(),
196    };
197
198    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
199        Ok(s) => s,
200        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
201    };
202    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
203        Ok(s) => s,
204        Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
205    };
206    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
207        Ok(s) => s,
208        Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
209    };
210    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
211        Ok(s) => s,
212        Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
213    };
214
215    // If append mode with a valid caret, use cursor-aware insertion
216    if mode_str == "append" && caret_offset >= 0 {
217        let components = match component::parse(doc_str) {
218            Ok(c) => c,
219            Err(e) => return make_err(&format!("{e}")),
220        };
221        if let Some(comp) = components.iter().find(|c| c.name == name) {
222            let result = comp.append_with_caret(
223                doc_str,
224                patch_content,
225                Some(caret_offset as usize),
226            );
227            return FfiPatchResult {
228                text: CString::new(result).unwrap_or_default().into_raw(),
229                error: ptr::null_mut(),
230            };
231        }
232    }
233
234    // Fall back to normal apply_patch behavior
235    let patch = template::PatchBlock {
236        name: name.to_string(),
237        content: patch_content.to_string(),
238    };
239    let mut overrides = std::collections::HashMap::new();
240    overrides.insert(name.to_string(), mode_str.to_string());
241    let dummy_path = std::path::Path::new("/dev/null");
242    match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
243        Ok(result) => FfiPatchResult {
244            text: CString::new(result).unwrap_or_default().into_raw(),
245            error: ptr::null_mut(),
246        },
247        Err(e) => make_err(&format!("{e}")),
248    }
249}
250
251/// CRDT merge (3-way conflict-free).
252///
253/// `base_state` may be null (first merge). `base_state_len` is ignored when null.
254///
255/// # Safety
256///
257/// - `ours` and `theirs` must be valid, NUL-terminated UTF-8.
258/// - If `base_state` is non-null, `base_state_len` bytes must be readable from it.
259/// - The caller must free `text` and `error` with [`agent_doc_free_string`].
260/// - The caller must free `state` with [`agent_doc_free_state`].
261#[unsafe(no_mangle)]
262pub unsafe extern "C" fn agent_doc_crdt_merge(
263    base_state: *const u8,
264    base_state_len: usize,
265    ours: *const c_char,
266    theirs: *const c_char,
267) -> FfiMergeResult {
268    let make_err = |msg: &str| FfiMergeResult {
269        text: ptr::null_mut(),
270        state: ptr::null_mut(),
271        state_len: 0,
272        error: CString::new(msg).unwrap_or_default().into_raw(),
273    };
274
275    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
276        Ok(s) => s,
277        Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
278    };
279    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
280        Ok(s) => s,
281        Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
282    };
283
284    let base = if base_state.is_null() {
285        None
286    } else {
287        Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
288    };
289
290    match crdt::merge(base, ours_str, theirs_str) {
291        Ok(merged_text) => {
292            // Encode the merged state for persistence
293            let doc = crdt::CrdtDoc::from_text(&merged_text);
294            let state_bytes = doc.encode_state();
295            let state_len = state_bytes.len();
296            let state_ptr = {
297                let mut boxed = state_bytes.into_boxed_slice();
298                let ptr = boxed.as_mut_ptr();
299                std::mem::forget(boxed);
300                ptr
301            };
302
303            FfiMergeResult {
304                text: CString::new(merged_text).unwrap_or_default().into_raw(),
305                state: state_ptr,
306                state_len,
307                error: ptr::null_mut(),
308            }
309        }
310        Err(e) => make_err(&format!("{e}")),
311    }
312}
313
314/// Merge YAML key/value pairs into a document's frontmatter.
315///
316/// `yaml_fields` is a YAML string of fields to merge (additive — never removes keys).
317/// Returns the updated document content via [`FfiPatchResult`].
318///
319/// # Safety
320///
321/// All string pointers must be valid, NUL-terminated UTF-8.
322#[unsafe(no_mangle)]
323pub unsafe extern "C" fn agent_doc_merge_frontmatter(
324    doc: *const c_char,
325    yaml_fields: *const c_char,
326) -> FfiPatchResult {
327    let make_err = |msg: &str| FfiPatchResult {
328        text: ptr::null_mut(),
329        error: CString::new(msg).unwrap_or_default().into_raw(),
330    };
331
332    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
333        Ok(s) => s,
334        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
335    };
336    let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
337        Ok(s) => s,
338        Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
339    };
340
341    match frontmatter::merge_fields(doc_str, fields_str) {
342        Ok(result) => FfiPatchResult {
343            text: CString::new(result).unwrap_or_default().into_raw(),
344            error: ptr::null_mut(),
345        },
346        Err(e) => make_err(&format!("{e}")),
347    }
348}
349
350/// Free a string returned by any `agent_doc_*` function.
351///
352/// # Safety
353///
354/// `ptr` must have been returned by an `agent_doc_*` function, or be null.
355#[unsafe(no_mangle)]
356pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
357    if !ptr.is_null() {
358        drop(unsafe { CString::from_raw(ptr) });
359    }
360}
361
362/// Free a state buffer returned by [`agent_doc_crdt_merge`].
363///
364/// # Safety
365///
366/// `ptr` and `len` must match a state buffer returned by `agent_doc_crdt_merge`, or `ptr` must be null.
367#[unsafe(no_mangle)]
368pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
369    if !ptr.is_null() {
370        drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn parse_components_roundtrip() {
380        let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
381        let c_doc = CString::new(doc).unwrap();
382        let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
383        assert_eq!(result.count, 1);
384        assert!(!result.json.is_null());
385        let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
386        let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
387        assert_eq!(parsed[0]["name"], "status");
388        assert_eq!(parsed[0]["content"], "hello\n");
389        unsafe { agent_doc_free_string(result.json) };
390    }
391
392    #[test]
393    fn apply_patch_replace() {
394        let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
395        let c_doc = CString::new(doc).unwrap();
396        let c_name = CString::new("output").unwrap();
397        let c_content = CString::new("new content\n").unwrap();
398        let c_mode = CString::new("replace").unwrap();
399        let result = unsafe {
400            agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
401        };
402        assert!(result.error.is_null());
403        assert!(!result.text.is_null());
404        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
405        assert!(text.contains("new content"));
406        assert!(!text.contains("old"));
407        unsafe { agent_doc_free_string(result.text) };
408    }
409
410    #[test]
411    fn merge_frontmatter_adds_field() {
412        let doc = "---\nagent_doc_session: abc\n---\nBody\n";
413        let fields = "model: opus";
414        let c_doc = CString::new(doc).unwrap();
415        let c_fields = CString::new(fields).unwrap();
416        let result = unsafe {
417            agent_doc_merge_frontmatter(c_doc.as_ptr(), c_fields.as_ptr())
418        };
419        assert!(result.error.is_null());
420        assert!(!result.text.is_null());
421        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
422        assert!(text.contains("model: opus"));
423        assert!(text.contains("agent_doc_session: abc"));
424        assert!(text.contains("Body"));
425        unsafe { agent_doc_free_string(result.text) };
426    }
427
428    #[test]
429    fn crdt_merge_no_base() {
430        let c_ours = CString::new("hello world").unwrap();
431        let c_theirs = CString::new("hello world").unwrap();
432        let result = unsafe {
433            agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
434        };
435        assert!(result.error.is_null());
436        assert!(!result.text.is_null());
437        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
438        assert_eq!(text, "hello world");
439        unsafe {
440            agent_doc_free_string(result.text);
441            agent_doc_free_state(result.state, result.state_len);
442        };
443    }
444}