Skip to main content

agent_doc_core/
ffi.rs

1//! C-ABI exports for FFI consumers (editor plugins, Python bindings).
2//!
3//! Pure subset of the FFI surface: functions that depend only on
4//! `agent-doc-core` types and need no orchestration-layer state. The full
5//! editor-plugin FFI lives in `agent_doc::ffi` (main crate), which
6//! re-exports the symbols defined here via `pub use agent_doc_core::ffi::*`.
7//!
8//! Wave 5 / `#k9e1` of `#adcr` — proof-of-concept relocation. Adding more
9//! pure functions to this module is tracked under follow-up sub-tasks of
10//! `#k9e1`. See `tasks/agent-doc/plan-agent-doc-core-extraction.md`.
11//!
12//! `#k9e1-ffi-simple` (`#epv5`) relocated the four simplest pure FFI
13//! functions (`agent_doc_parse_components`, `agent_doc_visual_tokens_json`,
14//! `agent_doc_merge_frontmatter`, `agent_doc_normalize_template_structure`)
15//! plus the shared `FfiPatchResult` C-ABI type and its constructor helpers,
16//! which the boundary/apply-patch surfaces (`#vb8h` / `#e130`) also depend on.
17
18use std::collections::HashMap;
19use std::ffi::{CStr, CString};
20use std::os::raw::c_char;
21use std::ptr;
22
23use crate::{component, crdt, frontmatter, syntax, template};
24
25/// Free a string returned by an `agent_doc_*` function.
26///
27/// # Safety
28///
29/// `ptr` must have been returned by an `agent_doc_*` function, or be null.
30#[unsafe(no_mangle)]
31pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
32    if !ptr.is_null() {
33        drop(unsafe { CString::from_raw(ptr) });
34    }
35}
36
37/// Free a state buffer returned by an `agent_doc_*` function (e.g.
38/// `agent_doc_crdt_merge`).
39///
40/// # Safety
41///
42/// `ptr` and `len` must match a state buffer returned by an
43/// `agent_doc_*` function, or `ptr` must be null.
44#[unsafe(no_mangle)]
45pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
46    if !ptr.is_null() {
47        drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
48    }
49}
50
51/// Serialized component info returned by [`agent_doc_parse_components`].
52#[repr(C)]
53pub struct FfiComponentList {
54    /// JSON-encoded array of components. Free with [`agent_doc_free_string`].
55    pub json: *mut c_char,
56    /// Number of components parsed (convenience — also available in the JSON).
57    pub count: usize,
58}
59
60/// Result of a patch-style FFI function (`agent_doc_apply_patch`,
61/// `agent_doc_merge_frontmatter`, `agent_doc_normalize_template_structure`,
62/// the `agent_doc_reposition_boundary_*` family, …).
63#[repr(C)]
64pub struct FfiPatchResult {
65    /// The patched document text, or null on error. Free with [`agent_doc_free_string`].
66    pub text: *mut c_char,
67    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
68    pub error: *mut c_char,
69}
70
71/// Build a successful [`FfiPatchResult`] from patched document text.
72pub fn ffi_patch_ok(text: String) -> FfiPatchResult {
73    FfiPatchResult {
74        text: CString::new(text).unwrap_or_default().into_raw(),
75        error: ptr::null_mut(),
76    }
77}
78
79/// Build an error [`FfiPatchResult`] from a message.
80pub fn ffi_patch_err(msg: &str) -> FfiPatchResult {
81    FfiPatchResult {
82        text: ptr::null_mut(),
83        error: CString::new(msg).unwrap_or_default().into_raw(),
84    }
85}
86
87/// Convert an `anyhow::Result<String>` into an [`FfiPatchResult`].
88pub fn ffi_patch_from_result(result: anyhow::Result<String>) -> FfiPatchResult {
89    match result {
90        Ok(text) => ffi_patch_ok(text),
91        Err(e) => ffi_patch_err(&format!("{e:#}")),
92    }
93}
94
95/// Run the editor-visible template-structure normalization pass over patched text.
96pub fn normalize_editor_visible_result(text: String) -> anyhow::Result<String> {
97    template::normalize_editor_visible_template_structure(&text)
98}
99
100/// Map UTF-8 byte offsets to UTF-16 code-unit offsets in a single pass.
101///
102/// Editor range APIs (JetBrains, VS Code) consume UTF-16 offsets, while the
103/// document parser works in UTF-8 byte offsets. The `targets` are byte offsets
104/// to translate; the returned map keys them to their UTF-16 positions.
105fn utf8_offsets_to_utf16_offsets(doc: &str, offsets: &[usize]) -> HashMap<usize, usize> {
106    let mut targets = offsets.to_vec();
107    targets.sort_unstable();
108    targets.dedup();
109
110    let mut mapped = HashMap::with_capacity(targets.len());
111    let mut target_idx = 0usize;
112    let mut utf8_offset = 0usize;
113    let mut utf16_offset = 0usize;
114
115    while target_idx < targets.len() && targets[target_idx] == 0 {
116        mapped.insert(0, 0);
117        target_idx += 1;
118    }
119
120    for ch in doc.chars() {
121        let next_utf8 = utf8_offset + ch.len_utf8();
122        while target_idx < targets.len() && targets[target_idx] < next_utf8 {
123            mapped.insert(targets[target_idx], utf16_offset);
124            target_idx += 1;
125        }
126
127        utf8_offset = next_utf8;
128        utf16_offset += ch.len_utf16();
129
130        while target_idx < targets.len() && targets[target_idx] == utf8_offset {
131            mapped.insert(targets[target_idx], utf16_offset);
132            target_idx += 1;
133        }
134    }
135
136    while target_idx < targets.len() {
137        mapped.insert(targets[target_idx], utf16_offset);
138        target_idx += 1;
139    }
140
141    mapped
142}
143
144/// Parse components from a document.
145///
146/// Returns a [`FfiComponentList`] with a JSON-encoded array of components.
147/// Each component object has: `name`, `attrs`, `open_start`, `open_end`,
148/// `close_start`, `close_end`, `content`.
149///
150/// # Safety
151///
152/// `doc` must be a valid, NUL-terminated UTF-8 string.
153#[unsafe(no_mangle)]
154pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
155    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
156        Ok(s) => s,
157        Err(_) => {
158            return FfiComponentList {
159                json: ptr::null_mut(),
160                count: 0,
161            };
162        }
163    };
164
165    let components = match component::parse(doc_str) {
166        Ok(c) => c,
167        Err(_) => {
168            return FfiComponentList {
169                json: ptr::null_mut(),
170                count: 0,
171            };
172        }
173    };
174
175    let count = components.len();
176
177    // Serialize to JSON with content included
178    let json_items: Vec<serde_json::Value> = components
179        .iter()
180        .map(|c| {
181            serde_json::json!({
182                "name": c.name,
183                "attrs": c.attrs,
184                "open_start": c.open_start,
185                "open_end": c.open_end,
186                "close_start": c.close_start,
187                "close_end": c.close_end,
188                "content": c.content(doc_str),
189            })
190        })
191        .collect();
192
193    let json_str = serde_json::to_string(&json_items).unwrap_or_default();
194    let c_json = CString::new(json_str).unwrap_or_default();
195
196    FfiComponentList {
197        json: c_json.into_raw(),
198        count,
199    }
200}
201
202/// Collect editor-facing visual token ranges from a markdown document.
203///
204/// The returned JSON array contains `{ kind, start, end }` objects, where
205/// offsets are UTF-16 document positions suitable for JetBrains and VS Code
206/// range APIs.
207///
208/// # Safety
209///
210/// `doc` must be a valid, NUL-terminated UTF-8 string.
211#[unsafe(no_mangle)]
212pub unsafe extern "C" fn agent_doc_visual_tokens_json(doc: *const c_char) -> *mut c_char {
213    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
214        Ok(s) => s,
215        Err(_) => return ptr::null_mut(),
216    };
217    let tokens = syntax::collect_visual_tokens(doc_str);
218    let offsets: Vec<usize> = tokens
219        .iter()
220        .flat_map(|token| [token.start, token.end])
221        .collect();
222    let utf16_offsets = utf8_offsets_to_utf16_offsets(doc_str, &offsets);
223    let editor_tokens: Vec<_> = tokens
224        .iter()
225        .map(|token| {
226            serde_json::json!({
227                "kind": token.kind,
228                "start": utf16_offsets[&token.start],
229                "end": utf16_offsets[&token.end],
230            })
231        })
232        .collect();
233    let json = match serde_json::to_string(&editor_tokens) {
234        Ok(json) => json,
235        Err(_) => return ptr::null_mut(),
236    };
237    match CString::new(json) {
238        Ok(value) => value.into_raw(),
239        Err(_) => ptr::null_mut(),
240    }
241}
242
243/// Merge YAML key/value pairs into a document's frontmatter.
244///
245/// `yaml_fields` is a YAML string of fields to merge (additive — never removes keys).
246/// Returns the updated document content via [`FfiPatchResult`].
247///
248/// # Safety
249///
250/// All string pointers must be valid, NUL-terminated UTF-8.
251#[unsafe(no_mangle)]
252pub unsafe extern "C" fn agent_doc_merge_frontmatter(
253    doc: *const c_char,
254    yaml_fields: *const c_char,
255) -> FfiPatchResult {
256    let make_err = |msg: &str| FfiPatchResult {
257        text: ptr::null_mut(),
258        error: CString::new(msg).unwrap_or_default().into_raw(),
259    };
260
261    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
262        Ok(s) => s,
263        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
264    };
265    let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
266        Ok(s) => s,
267        Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
268    };
269
270    match frontmatter::merge_fields(doc_str, fields_str) {
271        Ok(result) => FfiPatchResult {
272            text: CString::new(result).unwrap_or_default().into_raw(),
273            error: ptr::null_mut(),
274        },
275        Err(e) => make_err(&format!("{e}")),
276    }
277}
278
279/// Normalize/fail-close template structure before editor-visible IPC writes.
280///
281/// Safe duplicate scaffold shells are repaired. Ambiguous duplicate scaffold
282/// content, conversation text outside exchange, or malformed component shape
283/// returns an error so editor plugins can refuse to mutate the visible buffer.
284///
285/// # Safety
286///
287/// `doc` must be a valid, NUL-terminated UTF-8 string.
288#[unsafe(no_mangle)]
289pub unsafe extern "C" fn agent_doc_normalize_template_structure(
290    doc: *const c_char,
291) -> FfiPatchResult {
292    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
293        Ok(s) => s,
294        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
295    };
296
297    ffi_patch_from_result(template::normalize_editor_visible_template_structure(
298        doc_str,
299    ))
300}
301
302/// Result of [`agent_doc_crdt_merge`].
303#[repr(C)]
304pub struct FfiMergeResult {
305    /// Merged document text, or null on error. Free with [`agent_doc_free_string`].
306    pub text: *mut c_char,
307    /// Updated CRDT state bytes (caller must copy). Null on error.
308    pub state: *mut u8,
309    /// Length of `state` in bytes.
310    pub state_len: usize,
311    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
312    pub error: *mut c_char,
313}
314
315/// 3-way CRDT merge over opaque state bytes.
316///
317/// Returns the merged text plus updated CRDT state for persistence.
318///
319/// # Safety
320///
321/// - `ours` and `theirs` must be valid, NUL-terminated UTF-8.
322/// - If `base_state` is non-null, `base_state_len` bytes must be readable from it.
323/// - The caller must free `text` and `error` with [`agent_doc_free_string`].
324/// - The caller must free `state` with [`agent_doc_free_state`].
325#[unsafe(no_mangle)]
326pub unsafe extern "C" fn agent_doc_crdt_merge(
327    base_state: *const u8,
328    base_state_len: usize,
329    ours: *const c_char,
330    theirs: *const c_char,
331) -> FfiMergeResult {
332    let make_err = |msg: &str| FfiMergeResult {
333        text: ptr::null_mut(),
334        state: ptr::null_mut(),
335        state_len: 0,
336        error: CString::new(msg).unwrap_or_default().into_raw(),
337    };
338
339    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
340        Ok(s) => s,
341        Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
342    };
343    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
344        Ok(s) => s,
345        Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
346    };
347
348    let base = if base_state.is_null() {
349        None
350    } else {
351        Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
352    };
353
354    match crdt::merge(base, ours_str, theirs_str) {
355        Ok(merged_text) => {
356            // Encode the merged state for persistence
357            let doc = crdt::CrdtDoc::from_text(&merged_text);
358            let state_bytes = doc.encode_state();
359            let state_len = state_bytes.len();
360            let state_ptr = {
361                let mut boxed = state_bytes.into_boxed_slice();
362                let ptr = boxed.as_mut_ptr();
363                std::mem::forget(boxed);
364                ptr
365            };
366
367            FfiMergeResult {
368                text: CString::new(merged_text).unwrap_or_default().into_raw(),
369                state: state_ptr,
370                state_len,
371                error: ptr::null_mut(),
372            }
373        }
374        Err(e) => make_err(&format!("{e}")),
375    }
376}
377
378/// Text-based CRDT 3-way merge. Simpler interface than [`agent_doc_crdt_merge`].
379///
380/// All three parameters are plain UTF-8 text (not CRDT state bytes).
381/// Returns the conflict-free merged text. On any error, falls back to `ours`.
382///
383/// Intended for editor plugin use (replaces `git merge-file` in `PromptPoller`).
384///
385/// # Safety
386///
387/// `base`, `ours`, and `theirs` must be valid, NUL-terminated UTF-8.
388/// The caller must free the returned pointer with [`agent_doc_free_string`].
389#[unsafe(no_mangle)]
390pub unsafe extern "C" fn agent_doc_merge_crdt(
391    base: *const c_char,
392    ours: *const c_char,
393    theirs: *const c_char,
394) -> *mut c_char {
395    let base_str = match unsafe { CStr::from_ptr(base) }.to_str() {
396        Ok(s) => s,
397        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
398    };
399    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
400        Ok(s) => s,
401        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
402    };
403    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
404        Ok(s) => s,
405        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
406    };
407
408    // Encode base text as CRDT state for proper 3-way merge
409    let base_doc = crdt::CrdtDoc::from_text(base_str);
410    let base_state = base_doc.encode_state();
411
412    let merged = crdt::merge(Some(&base_state), ours_str, theirs_str)
413        .unwrap_or_else(|_| ours_str.to_string());
414    CString::new(merged).unwrap_or_default().into_raw()
415}
416
417/// Reposition boundary marker to end of exchange component.
418///
419/// Removes all existing boundary markers from the document, strips transient
420/// heading-level ` (HEAD)` suffixes, and inserts a single fresh boundary at the
421/// end of the exchange component. Returns the document unchanged if no exchange
422/// component exists.
423///
424/// # Safety
425///
426/// `doc` must be a valid, NUL-terminated UTF-8 string.
427#[unsafe(no_mangle)]
428pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
429    doc: *const c_char,
430) -> FfiPatchResult {
431    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
432        Ok(s) => s,
433        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
434    };
435
436    let result = template::reposition_boundary_to_end_clean(doc_str);
437    ffi_patch_from_result(normalize_editor_visible_result(result))
438}
439
440/// Reposition boundary marker to end of exchange component using an explicit ID.
441///
442/// This is used by post-commit editor refresh so the live buffer can be
443/// normalized back to the exact boundary marker already committed in `HEAD`
444/// rather than generating a fresh boundary-only local diff.
445///
446/// # Safety
447///
448/// `doc` and `boundary_id` must be valid, NUL-terminated UTF-8 strings.
449#[unsafe(no_mangle)]
450pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_with_id(
451    doc: *const c_char,
452    boundary_id: *const c_char,
453) -> FfiPatchResult {
454    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
455        Ok(s) => s,
456        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
457    };
458    let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
459        Ok(s) => s,
460        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
461    };
462
463    let result = template::reposition_boundary_to_end_clean_with_id(doc_str, Some(boundary_id_str));
464    ffi_patch_from_result(normalize_editor_visible_result(result))
465}
466
467/// Reposition boundary marker to end of exchange, preserving `(HEAD)` markers.
468///
469/// Used for post-commit working-tree cleanup where `(HEAD)` annotations should
470/// remain visible to the user. The committed blob and snapshot use the `_clean`
471/// variant; the working tree and editor buffer use this variant.
472///
473/// # Safety
474///
475/// `doc` must be a valid, NUL-terminated UTF-8 string.
476#[unsafe(no_mangle)]
477pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head(
478    doc: *const c_char,
479) -> FfiPatchResult {
480    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
481        Ok(s) => s,
482        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
483    };
484
485    let result = template::reposition_boundary_to_end_preserve_head(doc_str);
486    ffi_patch_from_result(normalize_editor_visible_result(result))
487}
488
489/// Reposition boundary using an explicit ID, preserving `(HEAD)` markers.
490///
491/// # Safety
492///
493/// `doc` and `boundary_id` must be valid, NUL-terminated UTF-8 strings.
494#[unsafe(no_mangle)]
495pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head_with_id(
496    doc: *const c_char,
497    boundary_id: *const c_char,
498) -> FfiPatchResult {
499    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
500        Ok(s) => s,
501        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
502    };
503    let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
504        Ok(s) => s,
505        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
506    };
507
508    let result =
509        template::reposition_boundary_to_end_preserve_head_with_id(doc_str, Some(boundary_id_str));
510    ffi_patch_from_result(normalize_editor_visible_result(result))
511}
512
513/// Apply a patch to a document component.
514///
515/// `mode` must be one of: `"replace"`, `"append"`, `"prepend"`.
516///
517/// # Safety
518///
519/// All string pointers must be valid, NUL-terminated UTF-8.
520#[unsafe(no_mangle)]
521pub unsafe extern "C" fn agent_doc_apply_patch(
522    doc: *const c_char,
523    component_name: *const c_char,
524    content: *const c_char,
525    mode: *const c_char,
526) -> FfiPatchResult {
527    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
528        Ok(s) => s,
529        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
530    };
531    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
532        Ok(s) => s,
533        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
534    };
535    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
536        Ok(s) => s,
537        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
538    };
539    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
540        Ok(s) => s,
541        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
542    };
543
544    // Build a patch block and apply it
545    let patch = template::PatchBlock::new(name, patch_content);
546
547    // Use mode overrides to force the specified mode
548    let mut overrides = HashMap::new();
549    overrides.insert(name.to_string(), mode_str.to_string());
550
551    // Editor FFI applies to in-memory content with explicit overrides — no
552    // backing file, so summary is None and component/max_lines configs are empty.
553    ffi_patch_from_result(
554        template::apply_patches_with_overrides_pure(
555            doc_str,
556            &[patch],
557            "",
558            None,
559            &HashMap::new(),
560            &HashMap::new(),
561            &overrides,
562        )
563        .and_then(normalize_editor_visible_result),
564    )
565}
566
567/// Apply a component patch with cursor-aware ordering for append mode.
568///
569/// When `mode` is `"append"` and `caret_offset >= 0`, the content is inserted
570/// at the line boundary before the caret position (if the caret is inside the
571/// component). This ensures agent responses appear above where the user is typing.
572///
573/// Pass `caret_offset = -1` for normal behavior (identical to `agent_doc_apply_patch`).
574///
575/// # Safety
576///
577/// All pointers must be valid, non-null, NUL-terminated UTF-8.
578#[unsafe(no_mangle)]
579pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
580    doc: *const c_char,
581    component_name: *const c_char,
582    content: *const c_char,
583    mode: *const c_char,
584    caret_offset: i32,
585) -> FfiPatchResult {
586    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
587        Ok(s) => s,
588        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
589    };
590    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
591        Ok(s) => s,
592        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
593    };
594    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
595        Ok(s) => s,
596        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
597    };
598    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
599        Ok(s) => s,
600        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
601    };
602
603    // If append mode with a valid caret, use cursor-aware insertion
604    if mode_str == "append" && caret_offset >= 0 {
605        let components = match component::parse(doc_str) {
606            Ok(c) => c,
607            Err(e) => return ffi_patch_err(&format!("{e}")),
608        };
609        if let Some(comp) = components.iter().find(|c| c.name == name) {
610            let result =
611                comp.append_with_caret(doc_str, patch_content, Some(caret_offset as usize));
612            return ffi_patch_from_result(normalize_editor_visible_result(result));
613        }
614    }
615
616    // Fall back to normal apply_patch behavior
617    let patch = template::PatchBlock::new(name, patch_content);
618    let mut overrides = HashMap::new();
619    overrides.insert(name.to_string(), mode_str.to_string());
620    ffi_patch_from_result(
621        template::apply_patches_with_overrides_pure(
622            doc_str,
623            &[patch],
624            "",
625            None,
626            &HashMap::new(),
627            &HashMap::new(),
628            &overrides,
629        )
630        .and_then(normalize_editor_visible_result),
631    )
632}
633
634/// Apply a component patch using a boundary marker for insertion point.
635///
636/// When `mode` is `"append"` and `boundary_id` is provided, the content is
637/// inserted at the boundary marker position (replacing the marker). This ensures
638/// agent responses appear after the prompt that triggered them, even if the user
639/// has typed new text below.
640///
641/// Falls back to normal patch application if the boundary is not found.
642///
643/// # Safety
644///
645/// All pointers must be valid, non-null, NUL-terminated UTF-8.
646#[unsafe(no_mangle)]
647pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
648    doc: *const c_char,
649    component_name: *const c_char,
650    content: *const c_char,
651    mode: *const c_char,
652    boundary_id: *const c_char,
653) -> FfiPatchResult {
654    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
655        Ok(s) => s,
656        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
657    };
658    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
659        Ok(s) => s,
660        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
661    };
662    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
663        Ok(s) => s,
664        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
665    };
666    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
667        Ok(s) => s,
668        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
669    };
670    let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
671        Ok(s) => s,
672        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
673    };
674
675    // Use boundary-aware insertion for append mode
676    if mode_str == "append" && !bid.is_empty() {
677        let components = match component::parse(doc_str) {
678            Ok(c) => c,
679            Err(e) => return ffi_patch_err(&format!("{e}")),
680        };
681        if let Some(comp) = components.iter().find(|c| c.name == name) {
682            let result = comp.append_with_boundary(doc_str, patch_content, bid);
683            let result = if name == "exchange" {
684                template::annotate_exchange_headings_against_baseline(&result, doc_str)
685            } else {
686                result
687            };
688            return ffi_patch_from_result(normalize_editor_visible_result(result));
689        }
690    }
691
692    // Fall back to normal apply_patch behavior
693    let patch = template::PatchBlock::new(name, patch_content);
694    let mut overrides = HashMap::new();
695    overrides.insert(name.to_string(), mode_str.to_string());
696    ffi_patch_from_result(
697        template::apply_patches_with_overrides_pure(
698            doc_str,
699            &[patch],
700            "",
701            None,
702            &HashMap::new(),
703            &HashMap::new(),
704            &overrides,
705        )
706        .and_then(normalize_editor_visible_result),
707    )
708}