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/// Apply a component patch using a boundary marker for insertion point.
252///
253/// When `mode` is `"append"` and `boundary_id` is provided, the content is
254/// inserted at the boundary marker position (replacing the marker). This ensures
255/// agent responses appear after the prompt that triggered them, even if the user
256/// has typed new text below.
257///
258/// Falls back to normal patch application if the boundary is not found.
259///
260/// # Safety
261///
262/// All pointers must be valid, non-null, NUL-terminated UTF-8.
263#[unsafe(no_mangle)]
264pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
265    doc: *const c_char,
266    component_name: *const c_char,
267    content: *const c_char,
268    mode: *const c_char,
269    boundary_id: *const c_char,
270) -> FfiPatchResult {
271    let make_err = |msg: &str| FfiPatchResult {
272        text: ptr::null_mut(),
273        error: CString::new(msg).unwrap_or_default().into_raw(),
274    };
275
276    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
277        Ok(s) => s,
278        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
279    };
280    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
281        Ok(s) => s,
282        Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
283    };
284    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
285        Ok(s) => s,
286        Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
287    };
288    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
289        Ok(s) => s,
290        Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
291    };
292    let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
293        Ok(s) => s,
294        Err(e) => return make_err(&format!("invalid boundary_id UTF-8: {e}")),
295    };
296
297    // Use boundary-aware insertion for append mode
298    if mode_str == "append" && !bid.is_empty() {
299        let components = match component::parse(doc_str) {
300            Ok(c) => c,
301            Err(e) => return make_err(&format!("{e}")),
302        };
303        if let Some(comp) = components.iter().find(|c| c.name == name) {
304            let result = comp.append_with_boundary(doc_str, patch_content, bid);
305            return FfiPatchResult {
306                text: CString::new(result).unwrap_or_default().into_raw(),
307                error: ptr::null_mut(),
308            };
309        }
310    }
311
312    // Fall back to normal apply_patch behavior
313    let patch = template::PatchBlock {
314        name: name.to_string(),
315        content: patch_content.to_string(),
316    };
317    let mut overrides = std::collections::HashMap::new();
318    overrides.insert(name.to_string(), mode_str.to_string());
319    let dummy_path = std::path::Path::new("/dev/null");
320    match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
321        Ok(result) => FfiPatchResult {
322            text: CString::new(result).unwrap_or_default().into_raw(),
323            error: ptr::null_mut(),
324        },
325        Err(e) => make_err(&format!("{e}")),
326    }
327}
328
329/// CRDT merge (3-way conflict-free).
330///
331/// `base_state` may be null (first merge). `base_state_len` is ignored when null.
332///
333/// # Safety
334///
335/// - `ours` and `theirs` must be valid, NUL-terminated UTF-8.
336/// - If `base_state` is non-null, `base_state_len` bytes must be readable from it.
337/// - The caller must free `text` and `error` with [`agent_doc_free_string`].
338/// - The caller must free `state` with [`agent_doc_free_state`].
339#[unsafe(no_mangle)]
340pub unsafe extern "C" fn agent_doc_crdt_merge(
341    base_state: *const u8,
342    base_state_len: usize,
343    ours: *const c_char,
344    theirs: *const c_char,
345) -> FfiMergeResult {
346    let make_err = |msg: &str| FfiMergeResult {
347        text: ptr::null_mut(),
348        state: ptr::null_mut(),
349        state_len: 0,
350        error: CString::new(msg).unwrap_or_default().into_raw(),
351    };
352
353    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
354        Ok(s) => s,
355        Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
356    };
357    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
358        Ok(s) => s,
359        Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
360    };
361
362    let base = if base_state.is_null() {
363        None
364    } else {
365        Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
366    };
367
368    match crdt::merge(base, ours_str, theirs_str) {
369        Ok(merged_text) => {
370            // Encode the merged state for persistence
371            let doc = crdt::CrdtDoc::from_text(&merged_text);
372            let state_bytes = doc.encode_state();
373            let state_len = state_bytes.len();
374            let state_ptr = {
375                let mut boxed = state_bytes.into_boxed_slice();
376                let ptr = boxed.as_mut_ptr();
377                std::mem::forget(boxed);
378                ptr
379            };
380
381            FfiMergeResult {
382                text: CString::new(merged_text).unwrap_or_default().into_raw(),
383                state: state_ptr,
384                state_len,
385                error: ptr::null_mut(),
386            }
387        }
388        Err(e) => make_err(&format!("{e}")),
389    }
390}
391
392/// Merge YAML key/value pairs into a document's frontmatter.
393///
394/// `yaml_fields` is a YAML string of fields to merge (additive — never removes keys).
395/// Returns the updated document content via [`FfiPatchResult`].
396///
397/// # Safety
398///
399/// All string pointers must be valid, NUL-terminated UTF-8.
400#[unsafe(no_mangle)]
401pub unsafe extern "C" fn agent_doc_merge_frontmatter(
402    doc: *const c_char,
403    yaml_fields: *const c_char,
404) -> FfiPatchResult {
405    let make_err = |msg: &str| FfiPatchResult {
406        text: ptr::null_mut(),
407        error: CString::new(msg).unwrap_or_default().into_raw(),
408    };
409
410    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
411        Ok(s) => s,
412        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
413    };
414    let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
415        Ok(s) => s,
416        Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
417    };
418
419    match frontmatter::merge_fields(doc_str, fields_str) {
420        Ok(result) => FfiPatchResult {
421            text: CString::new(result).unwrap_or_default().into_raw(),
422            error: ptr::null_mut(),
423        },
424        Err(e) => make_err(&format!("{e}")),
425    }
426}
427
428/// Reposition boundary marker to end of exchange component.
429///
430/// Removes all existing boundary markers from the document and inserts a single
431/// fresh one at the end of the exchange component. Returns the document unchanged
432/// if no exchange component exists.
433///
434/// # Safety
435///
436/// `doc` must be a valid, NUL-terminated UTF-8 string.
437#[unsafe(no_mangle)]
438pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
439    doc: *const c_char,
440) -> FfiPatchResult {
441    let make_err = |msg: &str| FfiPatchResult {
442        text: ptr::null_mut(),
443        error: CString::new(msg).unwrap_or_default().into_raw(),
444    };
445
446    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
447        Ok(s) => s,
448        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
449    };
450
451    let result = template::reposition_boundary_to_end(doc_str);
452    FfiPatchResult {
453        text: CString::new(result).unwrap_or_default().into_raw(),
454        error: ptr::null_mut(),
455    }
456}
457
458/// Record a document change event for debounce tracking.
459///
460/// Plugins call this on every document modification (typing, paste, undo).
461/// Used by [`agent_doc_await_idle`] to determine if the user is still editing.
462///
463/// # Safety
464///
465/// `file_path` must be a valid, NUL-terminated UTF-8 string.
466#[unsafe(no_mangle)]
467pub unsafe extern "C" fn agent_doc_document_changed(file_path: *const c_char) {
468    if let Ok(path) = unsafe { CStr::from_ptr(file_path) }.to_str() {
469        crate::debounce::document_changed(path);
470    }
471}
472
473/// Block until the document has been idle for `debounce_ms`, or `timeout_ms` expires.
474///
475/// Returns `true` if idle was reached (safe to run), `false` if timed out.
476/// If no changes have been recorded for this file, returns `true` immediately.
477///
478/// # Safety
479///
480/// `file_path` must be a valid, NUL-terminated UTF-8 string.
481#[unsafe(no_mangle)]
482pub unsafe extern "C" fn agent_doc_await_idle(
483    file_path: *const c_char,
484    debounce_ms: i64,
485    timeout_ms: i64,
486) -> bool {
487    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
488        Ok(s) => s,
489        Err(_) => return true, // Invalid path — don't block
490    };
491    crate::debounce::await_idle(path, debounce_ms as u64, timeout_ms as u64)
492}
493
494/// Free a string returned by any `agent_doc_*` function.
495///
496/// # Safety
497///
498/// `ptr` must have been returned by an `agent_doc_*` function, or be null.
499#[unsafe(no_mangle)]
500pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
501    if !ptr.is_null() {
502        drop(unsafe { CString::from_raw(ptr) });
503    }
504}
505
506/// Free a state buffer returned by [`agent_doc_crdt_merge`].
507///
508/// # Safety
509///
510/// `ptr` and `len` must match a state buffer returned by `agent_doc_crdt_merge`, or `ptr` must be null.
511#[unsafe(no_mangle)]
512pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
513    if !ptr.is_null() {
514        drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn parse_components_roundtrip() {
524        let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
525        let c_doc = CString::new(doc).unwrap();
526        let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
527        assert_eq!(result.count, 1);
528        assert!(!result.json.is_null());
529        let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
530        let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
531        assert_eq!(parsed[0]["name"], "status");
532        assert_eq!(parsed[0]["content"], "hello\n");
533        unsafe { agent_doc_free_string(result.json) };
534    }
535
536    #[test]
537    fn apply_patch_replace() {
538        let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
539        let c_doc = CString::new(doc).unwrap();
540        let c_name = CString::new("output").unwrap();
541        let c_content = CString::new("new content\n").unwrap();
542        let c_mode = CString::new("replace").unwrap();
543        let result = unsafe {
544            agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
545        };
546        assert!(result.error.is_null());
547        assert!(!result.text.is_null());
548        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
549        assert!(text.contains("new content"));
550        assert!(!text.contains("old"));
551        unsafe { agent_doc_free_string(result.text) };
552    }
553
554    #[test]
555    fn merge_frontmatter_adds_field() {
556        let doc = "---\nagent_doc_session: abc\n---\nBody\n";
557        let fields = "model: opus";
558        let c_doc = CString::new(doc).unwrap();
559        let c_fields = CString::new(fields).unwrap();
560        let result = unsafe {
561            agent_doc_merge_frontmatter(c_doc.as_ptr(), c_fields.as_ptr())
562        };
563        assert!(result.error.is_null());
564        assert!(!result.text.is_null());
565        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
566        assert!(text.contains("model: opus"));
567        assert!(text.contains("agent_doc_session: abc"));
568        assert!(text.contains("Body"));
569        unsafe { agent_doc_free_string(result.text) };
570    }
571
572    #[test]
573    fn reposition_boundary_removes_stale() {
574        let doc = "<!-- agent:exchange patch=append -->\ntext\n<!-- agent:boundary:aaaa1111 -->\nmore\n<!-- agent:boundary:bbbb2222 -->\n<!-- /agent:exchange -->\n";
575        let c_doc = CString::new(doc).unwrap();
576        let result = unsafe { agent_doc_reposition_boundary_to_end(c_doc.as_ptr()) };
577        assert!(result.error.is_null());
578        assert!(!result.text.is_null());
579        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
580        // Should have exactly one boundary marker at the end
581        let boundary_count = text.matches("<!-- agent:boundary:").count();
582        assert_eq!(boundary_count, 1, "should have exactly 1 boundary, got {}", boundary_count);
583        // The boundary should be just before the close tag
584        assert!(text.contains("more\n<!-- agent:boundary:"));
585        assert!(text.contains(" -->\n<!-- /agent:exchange -->"));
586        unsafe { agent_doc_free_string(result.text) };
587    }
588
589    #[test]
590    fn crdt_merge_no_base() {
591        let c_ours = CString::new("hello world").unwrap();
592        let c_theirs = CString::new("hello world").unwrap();
593        let result = unsafe {
594            agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
595        };
596        assert!(result.error.is_null());
597        assert!(!result.text.is_null());
598        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
599        assert_eq!(text, "hello world");
600        unsafe {
601            agent_doc_free_string(result.text);
602            agent_doc_free_state(result.state, result.state_len);
603        };
604    }
605}