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/// Converge the `agent:queue` opening-tag `auto` attribute to `want_auto`.
280///
281/// A content patch replaces only a component's body, so it cannot add or remove
282/// the `auto` attribute on the `<!-- agent:queue auto -->` opening tag. Editor
283/// plugins call this to converge a live route-owned buffer's queue tag to the
284/// committed inactive shape after a queue halt (`#adoc-queue-ipc-buffer-divergence`),
285/// pairing with the `queue_active` frontmatter merge that the same convergence
286/// patch carries. Returns the (possibly unchanged) document via [`FfiPatchResult`];
287/// an absent `queue` component or an already-converged tag returns the input
288/// unchanged so the editor's normal no-op path applies.
289///
290/// `want_auto` is a C int (nonzero = ensure `auto`, zero = strip `auto`) rather
291/// than a C `bool` so the JNA/FFI boundary stays on the reliable 32-bit-int ABI.
292///
293/// # Safety
294///
295/// `doc` must be a valid, NUL-terminated UTF-8 string.
296#[unsafe(no_mangle)]
297pub unsafe extern "C" fn agent_doc_converge_queue_auto(
298    doc: *const c_char,
299    want_auto: std::os::raw::c_int,
300) -> FfiPatchResult {
301    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
302        Ok(s) => s,
303        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
304    };
305    let converged = component::converge_queue_auto(doc_str, want_auto != 0)
306        .unwrap_or_else(|| doc_str.to_string());
307    FfiPatchResult {
308        text: CString::new(converged).unwrap_or_default().into_raw(),
309        error: ptr::null_mut(),
310    }
311}
312
313/// Normalize/fail-close template structure before editor-visible IPC writes.
314///
315/// Safe duplicate scaffold shells are repaired. Ambiguous duplicate scaffold
316/// content, conversation text outside exchange, or malformed component shape
317/// returns an error so editor plugins can refuse to mutate the visible buffer.
318///
319/// # Safety
320///
321/// `doc` must be a valid, NUL-terminated UTF-8 string.
322#[unsafe(no_mangle)]
323pub unsafe extern "C" fn agent_doc_normalize_template_structure(
324    doc: *const c_char,
325) -> FfiPatchResult {
326    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
327        Ok(s) => s,
328        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
329    };
330
331    ffi_patch_from_result(template::normalize_editor_visible_template_structure(
332        doc_str,
333    ))
334}
335
336/// Result of [`agent_doc_crdt_merge`].
337#[repr(C)]
338pub struct FfiMergeResult {
339    /// Merged document text, or null on error. Free with [`agent_doc_free_string`].
340    pub text: *mut c_char,
341    /// Updated CRDT state bytes (caller must copy). Null on error.
342    pub state: *mut u8,
343    /// Length of `state` in bytes.
344    pub state_len: usize,
345    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
346    pub error: *mut c_char,
347}
348
349/// 3-way CRDT merge over opaque state bytes.
350///
351/// Returns the merged text plus updated CRDT state for persistence.
352///
353/// # Safety
354///
355/// - `ours` and `theirs` must be valid, NUL-terminated UTF-8.
356/// - If `base_state` is non-null, `base_state_len` bytes must be readable from it.
357/// - The caller must free `text` and `error` with [`agent_doc_free_string`].
358/// - The caller must free `state` with [`agent_doc_free_state`].
359#[unsafe(no_mangle)]
360pub unsafe extern "C" fn agent_doc_crdt_merge(
361    base_state: *const u8,
362    base_state_len: usize,
363    ours: *const c_char,
364    theirs: *const c_char,
365) -> FfiMergeResult {
366    let make_err = |msg: &str| FfiMergeResult {
367        text: ptr::null_mut(),
368        state: ptr::null_mut(),
369        state_len: 0,
370        error: CString::new(msg).unwrap_or_default().into_raw(),
371    };
372
373    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
374        Ok(s) => s,
375        Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
376    };
377    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
378        Ok(s) => s,
379        Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
380    };
381
382    let base = if base_state.is_null() {
383        None
384    } else {
385        Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
386    };
387
388    match crdt::merge_by_component(base, ours_str, theirs_str) {
389        Ok(merged_text) => {
390            // Encode the merged state for persistence
391            let doc = crdt::CrdtDoc::from_text(&merged_text);
392            let state_bytes = doc.encode_state();
393            let state_len = state_bytes.len();
394            let state_ptr = {
395                let mut boxed = state_bytes.into_boxed_slice();
396                let ptr = boxed.as_mut_ptr();
397                std::mem::forget(boxed);
398                ptr
399            };
400
401            FfiMergeResult {
402                text: CString::new(merged_text).unwrap_or_default().into_raw(),
403                state: state_ptr,
404                state_len,
405                error: ptr::null_mut(),
406            }
407        }
408        Err(e) => make_err(&format!("{e}")),
409    }
410}
411
412/// Text-based CRDT 3-way merge. Simpler interface than [`agent_doc_crdt_merge`].
413///
414/// All three parameters are plain UTF-8 text (not CRDT state bytes).
415/// Returns the conflict-free merged text. On any error, falls back to `ours`.
416///
417/// Intended for editor plugin use (replaces `git merge-file` in `PromptPoller`).
418///
419/// # Safety
420///
421/// `base`, `ours`, and `theirs` must be valid, NUL-terminated UTF-8.
422/// The caller must free the returned pointer with [`agent_doc_free_string`].
423#[unsafe(no_mangle)]
424pub unsafe extern "C" fn agent_doc_merge_crdt(
425    base: *const c_char,
426    ours: *const c_char,
427    theirs: *const c_char,
428) -> *mut c_char {
429    let base_str = match unsafe { CStr::from_ptr(base) }.to_str() {
430        Ok(s) => s,
431        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
432    };
433    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
434        Ok(s) => s,
435        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
436    };
437    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
438        Ok(s) => s,
439        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
440    };
441
442    // Encode base text as CRDT state for proper 3-way merge
443    let base_doc = crdt::CrdtDoc::from_text(base_str);
444    let base_state = base_doc.encode_state();
445
446    let merged = crdt::merge_by_component(Some(&base_state), ours_str, theirs_str)
447        .unwrap_or_else(|_| ours_str.to_string());
448    CString::new(merged).unwrap_or_default().into_raw()
449}
450
451/// Reposition boundary marker to end of exchange component.
452///
453/// Removes all existing boundary markers from the document, strips transient
454/// heading-level ` (HEAD)` suffixes, and inserts a single fresh boundary at the
455/// end of the exchange component. Returns the document unchanged if no exchange
456/// component exists.
457///
458/// # Safety
459///
460/// `doc` must be a valid, NUL-terminated UTF-8 string.
461#[unsafe(no_mangle)]
462pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
463    doc: *const c_char,
464) -> FfiPatchResult {
465    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
466        Ok(s) => s,
467        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
468    };
469
470    let result = template::reposition_boundary_to_end_clean(doc_str);
471    ffi_patch_from_result(normalize_editor_visible_result(result))
472}
473
474/// Reposition boundary marker to end of exchange component using an explicit ID.
475///
476/// This is used by post-commit editor refresh so the live buffer can be
477/// normalized back to the exact boundary marker already committed in `HEAD`
478/// rather than generating a fresh boundary-only local diff.
479///
480/// # Safety
481///
482/// `doc` and `boundary_id` must be valid, NUL-terminated UTF-8 strings.
483#[unsafe(no_mangle)]
484pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_with_id(
485    doc: *const c_char,
486    boundary_id: *const c_char,
487) -> FfiPatchResult {
488    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
489        Ok(s) => s,
490        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
491    };
492    let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
493        Ok(s) => s,
494        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
495    };
496
497    let result = template::reposition_boundary_to_end_clean_with_id(doc_str, Some(boundary_id_str));
498    ffi_patch_from_result(normalize_editor_visible_result(result))
499}
500
501/// Reposition boundary marker to end of exchange, preserving `(HEAD)` markers.
502///
503/// Used for post-commit working-tree cleanup where `(HEAD)` annotations should
504/// remain visible to the user. The committed blob and snapshot use the `_clean`
505/// variant; the working tree and editor buffer use this variant.
506///
507/// # Safety
508///
509/// `doc` must be a valid, NUL-terminated UTF-8 string.
510#[unsafe(no_mangle)]
511pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head(
512    doc: *const c_char,
513) -> FfiPatchResult {
514    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
515        Ok(s) => s,
516        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
517    };
518
519    let result = template::reposition_boundary_to_end_preserve_head(doc_str);
520    ffi_patch_from_result(normalize_editor_visible_result(result))
521}
522
523/// Reposition boundary using an explicit ID, preserving `(HEAD)` markers.
524///
525/// # Safety
526///
527/// `doc` and `boundary_id` must be valid, NUL-terminated UTF-8 strings.
528#[unsafe(no_mangle)]
529pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head_with_id(
530    doc: *const c_char,
531    boundary_id: *const c_char,
532) -> FfiPatchResult {
533    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
534        Ok(s) => s,
535        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
536    };
537    let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
538        Ok(s) => s,
539        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
540    };
541
542    let result =
543        template::reposition_boundary_to_end_preserve_head_with_id(doc_str, Some(boundary_id_str));
544    ffi_patch_from_result(normalize_editor_visible_result(result))
545}
546
547/// Apply a patch to a document component.
548///
549/// `mode` must be one of: `"replace"`, `"append"`, `"prepend"`.
550///
551/// # Safety
552///
553/// All string pointers must be valid, NUL-terminated UTF-8.
554#[unsafe(no_mangle)]
555pub unsafe extern "C" fn agent_doc_apply_patch(
556    doc: *const c_char,
557    component_name: *const c_char,
558    content: *const c_char,
559    mode: *const c_char,
560) -> FfiPatchResult {
561    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
562        Ok(s) => s,
563        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
564    };
565    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
566        Ok(s) => s,
567        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
568    };
569    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
570        Ok(s) => s,
571        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
572    };
573    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
574        Ok(s) => s,
575        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
576    };
577
578    // Build a patch block and apply it
579    let patch = template::PatchBlock::new(name, patch_content);
580
581    // Use mode overrides to force the specified mode
582    let mut overrides = HashMap::new();
583    overrides.insert(name.to_string(), mode_str.to_string());
584
585    // Editor FFI applies to in-memory content with explicit overrides — no
586    // backing file, so summary is None and component/max_lines configs are empty.
587    ffi_patch_from_result(
588        template::apply_patches_with_overrides_pure(
589            doc_str,
590            &[patch],
591            "",
592            None,
593            &HashMap::new(),
594            &HashMap::new(),
595            &overrides,
596        )
597        .and_then(normalize_editor_visible_result),
598    )
599}
600
601/// Apply a component patch with cursor-aware ordering for append mode.
602///
603/// When `mode` is `"append"` and `caret_offset >= 0`, the content is inserted
604/// at the line boundary before the caret position (if the caret is inside the
605/// component). This ensures agent responses appear above where the user is typing.
606///
607/// Pass `caret_offset = -1` for normal behavior (identical to `agent_doc_apply_patch`).
608///
609/// # Safety
610///
611/// All pointers must be valid, non-null, NUL-terminated UTF-8.
612#[unsafe(no_mangle)]
613pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
614    doc: *const c_char,
615    component_name: *const c_char,
616    content: *const c_char,
617    mode: *const c_char,
618    caret_offset: i32,
619) -> FfiPatchResult {
620    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
621        Ok(s) => s,
622        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
623    };
624    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
625        Ok(s) => s,
626        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
627    };
628    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
629        Ok(s) => s,
630        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
631    };
632    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
633        Ok(s) => s,
634        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
635    };
636
637    // If append mode with a valid caret, use cursor-aware insertion
638    if mode_str == "append" && caret_offset >= 0 {
639        let components = match component::parse(doc_str) {
640            Ok(c) => c,
641            Err(e) => return ffi_patch_err(&format!("{e}")),
642        };
643        if let Some(comp) = components.iter().find(|c| c.name == name) {
644            let result =
645                comp.append_with_caret(doc_str, patch_content, Some(caret_offset as usize));
646            return ffi_patch_from_result(normalize_editor_visible_result(result));
647        }
648    }
649
650    // Fall back to normal apply_patch behavior
651    let patch = template::PatchBlock::new(name, patch_content);
652    let mut overrides = HashMap::new();
653    overrides.insert(name.to_string(), mode_str.to_string());
654    ffi_patch_from_result(
655        template::apply_patches_with_overrides_pure(
656            doc_str,
657            &[patch],
658            "",
659            None,
660            &HashMap::new(),
661            &HashMap::new(),
662            &overrides,
663        )
664        .and_then(normalize_editor_visible_result),
665    )
666}
667
668/// Apply a component patch using a boundary marker for insertion point.
669///
670/// When `mode` is `"append"` and `boundary_id` is provided, the content is
671/// inserted at the boundary marker position (replacing the marker). This ensures
672/// agent responses appear after the prompt that triggered them, even if the user
673/// has typed new text below.
674///
675/// Falls back to normal patch application if the boundary is not found.
676///
677/// # Safety
678///
679/// All pointers must be valid, non-null, NUL-terminated UTF-8.
680#[unsafe(no_mangle)]
681pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
682    doc: *const c_char,
683    component_name: *const c_char,
684    content: *const c_char,
685    mode: *const c_char,
686    boundary_id: *const c_char,
687) -> FfiPatchResult {
688    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
689        Ok(s) => s,
690        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
691    };
692    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
693        Ok(s) => s,
694        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
695    };
696    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
697        Ok(s) => s,
698        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
699    };
700    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
701        Ok(s) => s,
702        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
703    };
704    let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
705        Ok(s) => s,
706        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
707    };
708
709    // Use boundary-aware insertion for append mode
710    if mode_str == "append" && !bid.is_empty() {
711        let components = match component::parse(doc_str) {
712            Ok(c) => c,
713            Err(e) => return ffi_patch_err(&format!("{e}")),
714        };
715        if let Some(comp) = components.iter().find(|c| c.name == name) {
716            let result = comp.append_with_boundary(doc_str, patch_content, bid);
717            let result = if name == "exchange" {
718                template::annotate_exchange_headings_against_baseline(&result, doc_str)
719            } else {
720                result
721            };
722            return ffi_patch_from_result(normalize_editor_visible_result(result));
723        }
724    }
725
726    // Fall back to normal apply_patch behavior
727    let patch = template::PatchBlock::new(name, patch_content);
728    let mut overrides = HashMap::new();
729    overrides.insert(name.to_string(), mode_str.to_string());
730    ffi_patch_from_result(
731        template::apply_patches_with_overrides_pure(
732            doc_str,
733            &[patch],
734            "",
735            None,
736            &HashMap::new(),
737            &HashMap::new(),
738            &overrides,
739        )
740        .and_then(normalize_editor_visible_result),
741    )
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use std::ffi::{CStr, CString};
748
749    #[test]
750    fn apply_patch_append_preserves_leading_code_fence_after_prompt_fence() {
751        let doc = concat!(
752            "<!-- agent:exchange patch=append -->\n",
753            "❯ show fenced prompt\n",
754            "```\n",
755            "prompt body\n",
756            "```\n",
757            "<!-- /agent:exchange -->\n",
758        );
759        let c_doc = CString::new(doc).unwrap();
760        let c_name = CString::new("exchange").unwrap();
761        let c_content = CString::new("```\nresponse body\n```\n").unwrap();
762        let c_mode = CString::new("append").unwrap();
763
764        let result = unsafe {
765            agent_doc_apply_patch(
766                c_doc.as_ptr(),
767                c_name.as_ptr(),
768                c_content.as_ptr(),
769                c_mode.as_ptr(),
770            )
771        };
772
773        assert!(result.error.is_null());
774        assert!(!result.text.is_null());
775        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
776        assert_eq!(
777            text.matches("```").count(),
778            4,
779            "native IPC append must keep prompt and response fences:\n{text}"
780        );
781        assert!(
782            text.contains("```\n```\nresponse body\n```"),
783            "native IPC append stripped the response opening fence:\n{text}"
784        );
785        unsafe { agent_doc_free_string(result.text) };
786    }
787}