Skip to main content

agent_doc/
ffi.rs

1//! # Module: ffi
2//!
3//! ## Spec
4//! - Exports a C ABI (`extern "C"`) surface consumed by the JetBrains plugin via JNA and the VS
5//!   Code extension via Node native addons, eliminating duplicated parsing/merge logic in Kotlin
6//!   and TypeScript.
7//! - `agent_doc_parse_components(doc)`: parses all `<!-- agent:name -->` components and returns a
8//!   JSON-encoded array with fields `name`, `attrs`, `open_start`, `open_end`, `close_start`,
9//!   `close_end`, `content`.
10//! - `agent_doc_apply_patch(doc, component_name, content, mode)`: applies a patch to a named
11//!   component using `replace`, `append`, or `prepend` mode.
12//! - `agent_doc_apply_patch_with_caret(…, caret_offset)`: caret-aware append — inserts content at
13//!   the line boundary before the caret when `mode == "append"` and `caret_offset >= 0`.  Falls
14//!   back to `apply_patch` otherwise.
15//! - `agent_doc_apply_patch_with_boundary(…, boundary_id)`: boundary-marker-aware append — inserts
16//!   content at the named boundary marker position, then falls back to `apply_patch` if the marker
17//!   is not found.
18//! - `agent_doc_crdt_merge(base_state, base_state_len, ours, theirs)`: 3-way CRDT merge; `base_state`
19//!   may be null for the first merge.  Returns merged text and updated opaque CRDT state bytes.
20//! - `agent_doc_merge_frontmatter(doc, yaml_fields)`: merges YAML key/value pairs into the
21//!   document's frontmatter additively (never removes keys).
22//! - `agent_doc_reposition_boundary_to_end(doc)`: removes all existing boundary markers and inserts
23//!   a single fresh one at the end of the `exchange` component.
24//! - `agent_doc_document_changed(file_path)`: records a change event for debounce tracking.
25//! - `agent_doc_is_tracked(file_path)`: returns whether at least one change event has been recorded
26//!   for the file.
27//! - `agent_doc_await_idle(file_path, debounce_ms, timeout_ms)`: blocks until the document has been
28//!   idle for `debounce_ms`, or `timeout_ms` expires.  Returns `true` on idle, `false` on timeout.
29//! - `agent_doc_free_string(ptr)` / `agent_doc_free_state(ptr, len)`: free memory returned by any
30//!   `agent_doc_*` function.  Must be called for every non-null pointer.
31//!
32//! ## Agentic Contracts
33//! - All string parameters must be valid, non-null, NUL-terminated UTF-8; violation is UB.
34//! - Every non-null `text` or `error` pointer in a result struct must be freed exactly once with
35//!   `agent_doc_free_string`; CRDT `state` pointers must be freed with `agent_doc_free_state`.
36//! - On parse/apply errors, `text` (or `json`) is null and `error` holds a message; callers must
37//!   check nullability before use.
38//! - `agent_doc_await_idle` returning `false` means the timeout expired — the caller must not
39//!   proceed with the agent run.
40//!
41//! ## Evals
42//! - parse_components_roundtrip: single `agent:status` component → JSON count=1, content="hello\n"
43//! - apply_patch_replace: replace mode on `agent:output` → new content present, old content absent
44//! - merge_frontmatter_adds_field: add `model: opus` to existing frontmatter → both keys present, body unchanged
45//! - reposition_boundary_removes_stale: two boundary markers in exchange → exactly one marker at end
46//! - crdt_merge_no_base: identical `ours`/`theirs` with null base → merged text equals input
47
48use std::ffi::{CStr, CString, c_char};
49use std::ptr;
50
51use crate::component;
52use crate::crdt;
53use crate::frontmatter;
54use crate::template;
55
56/// Serialized component info returned by [`agent_doc_parse_components`].
57#[repr(C)]
58pub struct FfiComponentList {
59    /// JSON-encoded array of components. Free with [`agent_doc_free_string`].
60    pub json: *mut c_char,
61    /// Number of components parsed (convenience — also available in the JSON).
62    pub count: usize,
63}
64
65/// Result of [`agent_doc_apply_patch`].
66#[repr(C)]
67pub struct FfiPatchResult {
68    /// The patched document text, or null on error. Free with [`agent_doc_free_string`].
69    pub text: *mut c_char,
70    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
71    pub error: *mut c_char,
72}
73
74/// Result of [`agent_doc_crdt_merge`].
75#[repr(C)]
76pub struct FfiMergeResult {
77    /// Merged document text, or null on error. Free with [`agent_doc_free_string`].
78    pub text: *mut c_char,
79    /// Updated CRDT state bytes (caller must copy). Null on error.
80    pub state: *mut u8,
81    /// Length of `state` in bytes.
82    pub state_len: usize,
83    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
84    pub error: *mut c_char,
85}
86
87/// Parse components from a document.
88///
89/// Returns a [`FfiComponentList`] with a JSON-encoded array of components.
90/// Each component object has: `name`, `attrs`, `open_start`, `open_end`,
91/// `close_start`, `close_end`, `content`.
92///
93/// # Safety
94///
95/// `doc` must be a valid, NUL-terminated UTF-8 string.
96#[unsafe(no_mangle)]
97pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
98    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
99        Ok(s) => s,
100        Err(_) => {
101            return FfiComponentList {
102                json: ptr::null_mut(),
103                count: 0,
104            };
105        }
106    };
107
108    let components = match component::parse(doc_str) {
109        Ok(c) => c,
110        Err(_) => {
111            return FfiComponentList {
112                json: ptr::null_mut(),
113                count: 0,
114            };
115        }
116    };
117
118    let count = components.len();
119
120    // Serialize to JSON with content included
121    let json_items: Vec<serde_json::Value> = components
122        .iter()
123        .map(|c| {
124            serde_json::json!({
125                "name": c.name,
126                "attrs": c.attrs,
127                "open_start": c.open_start,
128                "open_end": c.open_end,
129                "close_start": c.close_start,
130                "close_end": c.close_end,
131                "content": c.content(doc_str),
132            })
133        })
134        .collect();
135
136    let json_str = serde_json::to_string(&json_items).unwrap_or_default();
137    let c_json = CString::new(json_str).unwrap_or_default();
138
139    FfiComponentList {
140        json: c_json.into_raw(),
141        count,
142    }
143}
144
145/// Apply a patch to a document component.
146///
147/// `mode` must be one of: `"replace"`, `"append"`, `"prepend"`.
148///
149/// # Safety
150///
151/// All string pointers must be valid, NUL-terminated UTF-8.
152#[unsafe(no_mangle)]
153pub unsafe extern "C" fn agent_doc_apply_patch(
154    doc: *const c_char,
155    component_name: *const c_char,
156    content: *const c_char,
157    mode: *const c_char,
158) -> FfiPatchResult {
159    let make_err = |msg: &str| FfiPatchResult {
160        text: ptr::null_mut(),
161        error: CString::new(msg).unwrap_or_default().into_raw(),
162    };
163
164    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
165        Ok(s) => s,
166        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
167    };
168    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
169        Ok(s) => s,
170        Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
171    };
172    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
173        Ok(s) => s,
174        Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
175    };
176    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
177        Ok(s) => s,
178        Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
179    };
180
181    // Build a patch block and apply it
182    let patch = template::PatchBlock {
183        name: name.to_string(),
184        content: patch_content.to_string(),
185    };
186
187    // Use mode overrides to force the specified mode
188    let mut overrides = std::collections::HashMap::new();
189    overrides.insert(name.to_string(), mode_str.to_string());
190
191    // apply_patches_with_overrides needs a file path for config lookup — use a dummy
192    // since we're providing explicit overrides
193    let dummy_path = std::path::Path::new("/dev/null");
194    match template::apply_patches_with_overrides(
195        doc_str,
196        &[patch],
197        "",
198        dummy_path,
199        &overrides,
200    ) {
201        Ok(result) => FfiPatchResult {
202            text: CString::new(result).unwrap_or_default().into_raw(),
203            error: ptr::null_mut(),
204        },
205        Err(e) => make_err(&format!("{e}")),
206    }
207}
208
209/// Apply a component patch with cursor-aware ordering for append mode.
210///
211/// When `mode` is `"append"` and `caret_offset >= 0`, the content is inserted
212/// at the line boundary before the caret position (if the caret is inside the
213/// component). This ensures agent responses appear above where the user is typing.
214///
215/// Pass `caret_offset = -1` for normal behavior (identical to `agent_doc_apply_patch`).
216///
217/// # Safety
218///
219/// All pointers must be valid, non-null, NUL-terminated UTF-8.
220#[unsafe(no_mangle)]
221pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
222    doc: *const c_char,
223    component_name: *const c_char,
224    content: *const c_char,
225    mode: *const c_char,
226    caret_offset: i32,
227) -> FfiPatchResult {
228    let make_err = |msg: &str| FfiPatchResult {
229        text: ptr::null_mut(),
230        error: CString::new(msg).unwrap_or_default().into_raw(),
231    };
232
233    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
234        Ok(s) => s,
235        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
236    };
237    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
238        Ok(s) => s,
239        Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
240    };
241    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
242        Ok(s) => s,
243        Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
244    };
245    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
246        Ok(s) => s,
247        Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
248    };
249
250    // If append mode with a valid caret, use cursor-aware insertion
251    if mode_str == "append" && caret_offset >= 0 {
252        let components = match component::parse(doc_str) {
253            Ok(c) => c,
254            Err(e) => return make_err(&format!("{e}")),
255        };
256        if let Some(comp) = components.iter().find(|c| c.name == name) {
257            let result = comp.append_with_caret(
258                doc_str,
259                patch_content,
260                Some(caret_offset as usize),
261            );
262            return FfiPatchResult {
263                text: CString::new(result).unwrap_or_default().into_raw(),
264                error: ptr::null_mut(),
265            };
266        }
267    }
268
269    // Fall back to normal apply_patch behavior
270    let patch = template::PatchBlock {
271        name: name.to_string(),
272        content: patch_content.to_string(),
273    };
274    let mut overrides = std::collections::HashMap::new();
275    overrides.insert(name.to_string(), mode_str.to_string());
276    let dummy_path = std::path::Path::new("/dev/null");
277    match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
278        Ok(result) => FfiPatchResult {
279            text: CString::new(result).unwrap_or_default().into_raw(),
280            error: ptr::null_mut(),
281        },
282        Err(e) => make_err(&format!("{e}")),
283    }
284}
285
286/// Apply a component patch using a boundary marker for insertion point.
287///
288/// When `mode` is `"append"` and `boundary_id` is provided, the content is
289/// inserted at the boundary marker position (replacing the marker). This ensures
290/// agent responses appear after the prompt that triggered them, even if the user
291/// has typed new text below.
292///
293/// Falls back to normal patch application if the boundary is not found.
294///
295/// # Safety
296///
297/// All pointers must be valid, non-null, NUL-terminated UTF-8.
298#[unsafe(no_mangle)]
299pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
300    doc: *const c_char,
301    component_name: *const c_char,
302    content: *const c_char,
303    mode: *const c_char,
304    boundary_id: *const c_char,
305) -> FfiPatchResult {
306    let make_err = |msg: &str| FfiPatchResult {
307        text: ptr::null_mut(),
308        error: CString::new(msg).unwrap_or_default().into_raw(),
309    };
310
311    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
312        Ok(s) => s,
313        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
314    };
315    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
316        Ok(s) => s,
317        Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
318    };
319    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
320        Ok(s) => s,
321        Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
322    };
323    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
324        Ok(s) => s,
325        Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
326    };
327    let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
328        Ok(s) => s,
329        Err(e) => return make_err(&format!("invalid boundary_id UTF-8: {e}")),
330    };
331
332    // Use boundary-aware insertion for append mode
333    if mode_str == "append" && !bid.is_empty() {
334        let components = match component::parse(doc_str) {
335            Ok(c) => c,
336            Err(e) => return make_err(&format!("{e}")),
337        };
338        if let Some(comp) = components.iter().find(|c| c.name == name) {
339            let result = comp.append_with_boundary(doc_str, patch_content, bid);
340            return FfiPatchResult {
341                text: CString::new(result).unwrap_or_default().into_raw(),
342                error: ptr::null_mut(),
343            };
344        }
345    }
346
347    // Fall back to normal apply_patch behavior
348    let patch = template::PatchBlock {
349        name: name.to_string(),
350        content: patch_content.to_string(),
351    };
352    let mut overrides = std::collections::HashMap::new();
353    overrides.insert(name.to_string(), mode_str.to_string());
354    let dummy_path = std::path::Path::new("/dev/null");
355    match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
356        Ok(result) => FfiPatchResult {
357            text: CString::new(result).unwrap_or_default().into_raw(),
358            error: ptr::null_mut(),
359        },
360        Err(e) => make_err(&format!("{e}")),
361    }
362}
363
364/// CRDT merge (3-way conflict-free).
365///
366/// `base_state` may be null (first merge). `base_state_len` is ignored when null.
367///
368/// # Safety
369///
370/// - `ours` and `theirs` must be valid, NUL-terminated UTF-8.
371/// - If `base_state` is non-null, `base_state_len` bytes must be readable from it.
372/// - The caller must free `text` and `error` with [`agent_doc_free_string`].
373/// - The caller must free `state` with [`agent_doc_free_state`].
374#[unsafe(no_mangle)]
375pub unsafe extern "C" fn agent_doc_crdt_merge(
376    base_state: *const u8,
377    base_state_len: usize,
378    ours: *const c_char,
379    theirs: *const c_char,
380) -> FfiMergeResult {
381    let make_err = |msg: &str| FfiMergeResult {
382        text: ptr::null_mut(),
383        state: ptr::null_mut(),
384        state_len: 0,
385        error: CString::new(msg).unwrap_or_default().into_raw(),
386    };
387
388    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
389        Ok(s) => s,
390        Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
391    };
392    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
393        Ok(s) => s,
394        Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
395    };
396
397    let base = if base_state.is_null() {
398        None
399    } else {
400        Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
401    };
402
403    match crdt::merge(base, ours_str, theirs_str) {
404        Ok(merged_text) => {
405            // Encode the merged state for persistence
406            let doc = crdt::CrdtDoc::from_text(&merged_text);
407            let state_bytes = doc.encode_state();
408            let state_len = state_bytes.len();
409            let state_ptr = {
410                let mut boxed = state_bytes.into_boxed_slice();
411                let ptr = boxed.as_mut_ptr();
412                std::mem::forget(boxed);
413                ptr
414            };
415
416            FfiMergeResult {
417                text: CString::new(merged_text).unwrap_or_default().into_raw(),
418                state: state_ptr,
419                state_len,
420                error: ptr::null_mut(),
421            }
422        }
423        Err(e) => make_err(&format!("{e}")),
424    }
425}
426
427/// Merge YAML key/value pairs into a document's frontmatter.
428///
429/// `yaml_fields` is a YAML string of fields to merge (additive — never removes keys).
430/// Returns the updated document content via [`FfiPatchResult`].
431///
432/// # Safety
433///
434/// All string pointers must be valid, NUL-terminated UTF-8.
435#[unsafe(no_mangle)]
436pub unsafe extern "C" fn agent_doc_merge_frontmatter(
437    doc: *const c_char,
438    yaml_fields: *const c_char,
439) -> FfiPatchResult {
440    let make_err = |msg: &str| FfiPatchResult {
441        text: ptr::null_mut(),
442        error: CString::new(msg).unwrap_or_default().into_raw(),
443    };
444
445    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
446        Ok(s) => s,
447        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
448    };
449    let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
450        Ok(s) => s,
451        Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
452    };
453
454    match frontmatter::merge_fields(doc_str, fields_str) {
455        Ok(result) => FfiPatchResult {
456            text: CString::new(result).unwrap_or_default().into_raw(),
457            error: ptr::null_mut(),
458        },
459        Err(e) => make_err(&format!("{e}")),
460    }
461}
462
463/// Reposition boundary marker to end of exchange component.
464///
465/// Removes all existing boundary markers from the document and inserts a single
466/// fresh one at the end of the exchange component. Returns the document unchanged
467/// if no exchange component exists.
468///
469/// # Safety
470///
471/// `doc` must be a valid, NUL-terminated UTF-8 string.
472#[unsafe(no_mangle)]
473pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
474    doc: *const c_char,
475) -> FfiPatchResult {
476    let make_err = |msg: &str| FfiPatchResult {
477        text: ptr::null_mut(),
478        error: CString::new(msg).unwrap_or_default().into_raw(),
479    };
480
481    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
482        Ok(s) => s,
483        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
484    };
485
486    let result = template::reposition_boundary_to_end(doc_str);
487    FfiPatchResult {
488        text: CString::new(result).unwrap_or_default().into_raw(),
489        error: ptr::null_mut(),
490    }
491}
492
493/// Record a document change event for debounce tracking.
494///
495/// Plugins call this on every document modification (typing, paste, undo).
496/// Used by [`agent_doc_await_idle`] to determine if the user is still editing.
497///
498/// # Safety
499///
500/// `file_path` must be a valid, NUL-terminated UTF-8 string.
501#[unsafe(no_mangle)]
502pub unsafe extern "C" fn agent_doc_document_changed(file_path: *const c_char) {
503    if let Ok(path) = unsafe { CStr::from_ptr(file_path) }.to_str() {
504        crate::debounce::document_changed(path);
505    }
506}
507
508/// Check if the document has been tracked (at least one `document_changed` call recorded).
509///
510/// Returns `true` if the file has been tracked, `false` if never seen.
511/// Plugins use this to decide whether `await_idle` results are trustworthy:
512/// an untracked file returns idle=true from `await_idle`, but that's because
513/// no changes were recorded, not because the user isn't typing.
514///
515/// # Safety
516///
517/// `file_path` must be a valid, NUL-terminated UTF-8 string.
518#[unsafe(no_mangle)]
519pub unsafe extern "C" fn agent_doc_is_tracked(file_path: *const c_char) -> bool {
520    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
521        Ok(s) => s,
522        Err(_) => return false,
523    };
524    crate::debounce::is_tracked(path)
525}
526
527/// Block until the document has been idle for `debounce_ms`, or `timeout_ms` expires.
528///
529/// Returns `true` if idle was reached (safe to run), `false` if timed out.
530/// If no changes have been recorded for this file, returns `true` immediately.
531///
532/// # Safety
533///
534/// `file_path` must be a valid, NUL-terminated UTF-8 string.
535#[unsafe(no_mangle)]
536pub unsafe extern "C" fn agent_doc_await_idle(
537    file_path: *const c_char,
538    debounce_ms: i64,
539    timeout_ms: i64,
540) -> bool {
541    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
542        Ok(s) => s,
543        Err(_) => return true, // Invalid path — don't block
544    };
545    crate::debounce::await_idle(path, debounce_ms as u64, timeout_ms as u64)
546}
547
548/// Set the response status for a file (Option B: in-process).
549///
550/// Status values: "generating", "writing", "routing", "idle"
551/// Also writes a file-based signal (Option A: cross-process).
552///
553/// # Safety
554///
555/// `file_path` and `status` must be valid, NUL-terminated UTF-8 strings.
556#[unsafe(no_mangle)]
557pub unsafe extern "C" fn agent_doc_set_status(
558    file_path: *const c_char,
559    status: *const c_char,
560) {
561    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
562        Ok(s) => s,
563        Err(_) => return,
564    };
565    let st = match unsafe { CStr::from_ptr(status) }.to_str() {
566        Ok(s) => s,
567        Err(_) => return,
568    };
569    crate::debounce::set_status(path, st);
570}
571
572/// Get the response status for a file (Option B: in-process).
573///
574/// Returns a NUL-terminated string: "generating", "writing", "routing", or "idle".
575/// Caller must free with `agent_doc_free_string`.
576///
577/// # Safety
578///
579/// `file_path` must be a valid, NUL-terminated UTF-8 string.
580#[unsafe(no_mangle)]
581pub unsafe extern "C" fn agent_doc_get_status(file_path: *const c_char) -> *mut c_char {
582    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
583        Ok(s) => s,
584        Err(_) => return CString::new("idle").unwrap().into_raw(),
585    };
586    let status = crate::debounce::get_status(path);
587    CString::new(status).unwrap_or_else(|_| CString::new("idle").unwrap()).into_raw()
588}
589
590/// Check if any operation is in progress for a file (Option B: in-process).
591///
592/// Returns `true` if status is NOT "idle". Plugins should skip route
593/// operations when this returns `true` to prevent cascading.
594///
595/// # Safety
596///
597/// `file_path` must be a valid, NUL-terminated UTF-8 string.
598#[unsafe(no_mangle)]
599pub unsafe extern "C" fn agent_doc_is_busy(file_path: *const c_char) -> bool {
600    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
601        Ok(s) => s,
602        Err(_) => return false,
603    };
604    crate::debounce::is_busy(path)
605}
606
607/// Get the agent-doc library version.
608///
609/// Returns a NUL-terminated string like "0.26.1".
610/// Caller must free with `agent_doc_free_string`.
611#[unsafe(no_mangle)]
612pub extern "C" fn agent_doc_version() -> *mut c_char {
613    CString::new(env!("CARGO_PKG_VERSION")).unwrap().into_raw()
614}
615
616/// Free a string returned by any `agent_doc_*` function.
617///
618/// # Safety
619///
620/// `ptr` must have been returned by an `agent_doc_*` function, or be null.
621#[unsafe(no_mangle)]
622pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
623    if !ptr.is_null() {
624        drop(unsafe { CString::from_raw(ptr) });
625    }
626}
627
628/// Free a state buffer returned by [`agent_doc_crdt_merge`].
629///
630/// # Safety
631///
632/// `ptr` and `len` must match a state buffer returned by `agent_doc_crdt_merge`, or `ptr` must be null.
633#[unsafe(no_mangle)]
634pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
635    if !ptr.is_null() {
636        drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    #[test]
645    fn parse_components_roundtrip() {
646        let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
647        let c_doc = CString::new(doc).unwrap();
648        let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
649        assert_eq!(result.count, 1);
650        assert!(!result.json.is_null());
651        let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
652        let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
653        assert_eq!(parsed[0]["name"], "status");
654        assert_eq!(parsed[0]["content"], "hello\n");
655        unsafe { agent_doc_free_string(result.json) };
656    }
657
658    #[test]
659    fn apply_patch_replace() {
660        let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
661        let c_doc = CString::new(doc).unwrap();
662        let c_name = CString::new("output").unwrap();
663        let c_content = CString::new("new content\n").unwrap();
664        let c_mode = CString::new("replace").unwrap();
665        let result = unsafe {
666            agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
667        };
668        assert!(result.error.is_null());
669        assert!(!result.text.is_null());
670        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
671        assert!(text.contains("new content"));
672        assert!(!text.contains("old"));
673        unsafe { agent_doc_free_string(result.text) };
674    }
675
676    #[test]
677    fn merge_frontmatter_adds_field() {
678        let doc = "---\nagent_doc_session: abc\n---\nBody\n";
679        let fields = "model: opus";
680        let c_doc = CString::new(doc).unwrap();
681        let c_fields = CString::new(fields).unwrap();
682        let result = unsafe {
683            agent_doc_merge_frontmatter(c_doc.as_ptr(), c_fields.as_ptr())
684        };
685        assert!(result.error.is_null());
686        assert!(!result.text.is_null());
687        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
688        assert!(text.contains("model: opus"));
689        assert!(text.contains("agent_doc_session: abc"));
690        assert!(text.contains("Body"));
691        unsafe { agent_doc_free_string(result.text) };
692    }
693
694    #[test]
695    fn reposition_boundary_removes_stale() {
696        let doc = "<!-- agent:exchange patch=append -->\ntext\n<!-- agent:boundary:aaaa1111 -->\nmore\n<!-- agent:boundary:bbbb2222 -->\n<!-- /agent:exchange -->\n";
697        let c_doc = CString::new(doc).unwrap();
698        let result = unsafe { agent_doc_reposition_boundary_to_end(c_doc.as_ptr()) };
699        assert!(result.error.is_null());
700        assert!(!result.text.is_null());
701        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
702        // Should have exactly one boundary marker at the end
703        let boundary_count = text.matches("<!-- agent:boundary:").count();
704        assert_eq!(boundary_count, 1, "should have exactly 1 boundary, got {}", boundary_count);
705        // The boundary should be just before the close tag
706        assert!(text.contains("more\n<!-- agent:boundary:"));
707        assert!(text.contains(" -->\n<!-- /agent:exchange -->"));
708        unsafe { agent_doc_free_string(result.text) };
709    }
710
711    #[test]
712    fn crdt_merge_no_base() {
713        let c_ours = CString::new("hello world").unwrap();
714        let c_theirs = CString::new("hello world").unwrap();
715        let result = unsafe {
716            agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
717        };
718        assert!(result.error.is_null());
719        assert!(!result.text.is_null());
720        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
721        assert_eq!(text, "hello world");
722        unsafe {
723            agent_doc_free_string(result.text);
724            agent_doc_free_state(result.state, result.state_len);
725        };
726    }
727}