agent-doc-core 0.34.43

Pure document data layer for agent-doc (component parsing, frontmatter, template, CRDT, pending, diff classification, model tier)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
//! C-ABI exports for FFI consumers (editor plugins, Python bindings).
//!
//! Pure subset of the FFI surface: functions that depend only on
//! `agent-doc-core` types and need no orchestration-layer state. The full
//! editor-plugin FFI lives in `agent_doc::ffi` (main crate), which
//! re-exports the symbols defined here via `pub use agent_doc_core::ffi::*`.
//!
//! Wave 5 / `#k9e1` of `#adcr` — proof-of-concept relocation. Adding more
//! pure functions to this module is tracked under follow-up sub-tasks of
//! `#k9e1`. See `tasks/agent-doc/plan-agent-doc-core-extraction.md`.
//!
//! `#k9e1-ffi-simple` (`#epv5`) relocated the four simplest pure FFI
//! functions (`agent_doc_parse_components`, `agent_doc_visual_tokens_json`,
//! `agent_doc_merge_frontmatter`, `agent_doc_normalize_template_structure`)
//! plus the shared `FfiPatchResult` C-ABI type and its constructor helpers,
//! which the boundary/apply-patch surfaces (`#vb8h` / `#e130`) also depend on.

use std::collections::HashMap;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::ptr;

use crate::{component, crdt, frontmatter, syntax, template};

/// Free a string returned by an `agent_doc_*` function.
///
/// # Safety
///
/// `ptr` must have been returned by an `agent_doc_*` function, or be null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
    if !ptr.is_null() {
        drop(unsafe { CString::from_raw(ptr) });
    }
}

/// Free a state buffer returned by an `agent_doc_*` function (e.g.
/// `agent_doc_crdt_merge`).
///
/// # Safety
///
/// `ptr` and `len` must match a state buffer returned by an
/// `agent_doc_*` function, or `ptr` must be null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
    if !ptr.is_null() {
        drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
    }
}

/// Serialized component info returned by [`agent_doc_parse_components`].
#[repr(C)]
pub struct FfiComponentList {
    /// JSON-encoded array of components. Free with [`agent_doc_free_string`].
    pub json: *mut c_char,
    /// Number of components parsed (convenience — also available in the JSON).
    pub count: usize,
}

/// Result of a patch-style FFI function (`agent_doc_apply_patch`,
/// `agent_doc_merge_frontmatter`, `agent_doc_normalize_template_structure`,
/// the `agent_doc_reposition_boundary_*` family, …).
#[repr(C)]
pub struct FfiPatchResult {
    /// The patched document text, or null on error. Free with [`agent_doc_free_string`].
    pub text: *mut c_char,
    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
    pub error: *mut c_char,
}

/// Build a successful [`FfiPatchResult`] from patched document text.
pub fn ffi_patch_ok(text: String) -> FfiPatchResult {
    FfiPatchResult {
        text: CString::new(text).unwrap_or_default().into_raw(),
        error: ptr::null_mut(),
    }
}

/// Build an error [`FfiPatchResult`] from a message.
pub fn ffi_patch_err(msg: &str) -> FfiPatchResult {
    FfiPatchResult {
        text: ptr::null_mut(),
        error: CString::new(msg).unwrap_or_default().into_raw(),
    }
}

/// Convert an `anyhow::Result<String>` into an [`FfiPatchResult`].
pub fn ffi_patch_from_result(result: anyhow::Result<String>) -> FfiPatchResult {
    match result {
        Ok(text) => ffi_patch_ok(text),
        Err(e) => ffi_patch_err(&format!("{e:#}")),
    }
}

/// Run the editor-visible template-structure normalization pass over patched text.
pub fn normalize_editor_visible_result(text: String) -> anyhow::Result<String> {
    template::normalize_editor_visible_template_structure(&text)
}

/// Map UTF-8 byte offsets to UTF-16 code-unit offsets in a single pass.
///
/// Editor range APIs (JetBrains, VS Code) consume UTF-16 offsets, while the
/// document parser works in UTF-8 byte offsets. The `targets` are byte offsets
/// to translate; the returned map keys them to their UTF-16 positions.
fn utf8_offsets_to_utf16_offsets(doc: &str, offsets: &[usize]) -> HashMap<usize, usize> {
    let mut targets = offsets.to_vec();
    targets.sort_unstable();
    targets.dedup();

    let mut mapped = HashMap::with_capacity(targets.len());
    let mut target_idx = 0usize;
    let mut utf8_offset = 0usize;
    let mut utf16_offset = 0usize;

    while target_idx < targets.len() && targets[target_idx] == 0 {
        mapped.insert(0, 0);
        target_idx += 1;
    }

    for ch in doc.chars() {
        let next_utf8 = utf8_offset + ch.len_utf8();
        while target_idx < targets.len() && targets[target_idx] < next_utf8 {
            mapped.insert(targets[target_idx], utf16_offset);
            target_idx += 1;
        }

        utf8_offset = next_utf8;
        utf16_offset += ch.len_utf16();

        while target_idx < targets.len() && targets[target_idx] == utf8_offset {
            mapped.insert(targets[target_idx], utf16_offset);
            target_idx += 1;
        }
    }

    while target_idx < targets.len() {
        mapped.insert(targets[target_idx], utf16_offset);
        target_idx += 1;
    }

    mapped
}

/// Parse components from a document.
///
/// Returns a [`FfiComponentList`] with a JSON-encoded array of components.
/// Each component object has: `name`, `attrs`, `open_start`, `open_end`,
/// `close_start`, `close_end`, `content`.
///
/// # Safety
///
/// `doc` must be a valid, NUL-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(_) => {
            return FfiComponentList {
                json: ptr::null_mut(),
                count: 0,
            };
        }
    };

    let components = match component::parse(doc_str) {
        Ok(c) => c,
        Err(_) => {
            return FfiComponentList {
                json: ptr::null_mut(),
                count: 0,
            };
        }
    };

    let count = components.len();

    // Serialize to JSON with content included
    let json_items: Vec<serde_json::Value> = components
        .iter()
        .map(|c| {
            serde_json::json!({
                "name": c.name,
                "attrs": c.attrs,
                "open_start": c.open_start,
                "open_end": c.open_end,
                "close_start": c.close_start,
                "close_end": c.close_end,
                "content": c.content(doc_str),
            })
        })
        .collect();

    let json_str = serde_json::to_string(&json_items).unwrap_or_default();
    let c_json = CString::new(json_str).unwrap_or_default();

    FfiComponentList {
        json: c_json.into_raw(),
        count,
    }
}

/// Collect editor-facing visual token ranges from a markdown document.
///
/// The returned JSON array contains `{ kind, start, end }` objects, where
/// offsets are UTF-16 document positions suitable for JetBrains and VS Code
/// range APIs.
///
/// # Safety
///
/// `doc` must be a valid, NUL-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_visual_tokens_json(doc: *const c_char) -> *mut c_char {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(_) => return ptr::null_mut(),
    };
    let tokens = syntax::collect_visual_tokens(doc_str);
    let offsets: Vec<usize> = tokens
        .iter()
        .flat_map(|token| [token.start, token.end])
        .collect();
    let utf16_offsets = utf8_offsets_to_utf16_offsets(doc_str, &offsets);
    let editor_tokens: Vec<_> = tokens
        .iter()
        .map(|token| {
            serde_json::json!({
                "kind": token.kind,
                "start": utf16_offsets[&token.start],
                "end": utf16_offsets[&token.end],
            })
        })
        .collect();
    let json = match serde_json::to_string(&editor_tokens) {
        Ok(json) => json,
        Err(_) => return ptr::null_mut(),
    };
    match CString::new(json) {
        Ok(value) => value.into_raw(),
        Err(_) => ptr::null_mut(),
    }
}

/// Merge YAML key/value pairs into a document's frontmatter.
///
/// `yaml_fields` is a YAML string of fields to merge (additive — never removes keys).
/// Returns the updated document content via [`FfiPatchResult`].
///
/// # Safety
///
/// All string pointers must be valid, NUL-terminated UTF-8.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_merge_frontmatter(
    doc: *const c_char,
    yaml_fields: *const c_char,
) -> FfiPatchResult {
    let make_err = |msg: &str| FfiPatchResult {
        text: ptr::null_mut(),
        error: CString::new(msg).unwrap_or_default().into_raw(),
    };

    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
    };
    let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
        Ok(s) => s,
        Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
    };

    match frontmatter::merge_fields(doc_str, fields_str) {
        Ok(result) => FfiPatchResult {
            text: CString::new(result).unwrap_or_default().into_raw(),
            error: ptr::null_mut(),
        },
        Err(e) => make_err(&format!("{e}")),
    }
}

/// Converge the `agent:queue` opening-tag `auto` attribute to `want_auto`.
///
/// A content patch replaces only a component's body, so it cannot add or remove
/// the `auto` attribute on the `<!-- agent:queue auto -->` opening tag. Editor
/// plugins call this to converge a live route-owned buffer's queue tag to the
/// committed inactive shape after a queue halt (`#adoc-queue-ipc-buffer-divergence`),
/// pairing with the `queue_active` frontmatter merge that the same convergence
/// patch carries. Returns the (possibly unchanged) document via [`FfiPatchResult`];
/// an absent `queue` component or an already-converged tag returns the input
/// unchanged so the editor's normal no-op path applies.
///
/// `want_auto` is a C int (nonzero = ensure `auto`, zero = strip `auto`) rather
/// than a C `bool` so the JNA/FFI boundary stays on the reliable 32-bit-int ABI.
///
/// # Safety
///
/// `doc` must be a valid, NUL-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_converge_queue_auto(
    doc: *const c_char,
    want_auto: std::os::raw::c_int,
) -> FfiPatchResult {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
    };
    let converged = component::converge_queue_auto(doc_str, want_auto != 0)
        .unwrap_or_else(|| doc_str.to_string());
    FfiPatchResult {
        text: CString::new(converged).unwrap_or_default().into_raw(),
        error: ptr::null_mut(),
    }
}

/// Normalize/fail-close template structure before editor-visible IPC writes.
///
/// Safe duplicate scaffold shells are repaired. Ambiguous duplicate scaffold
/// content, conversation text outside exchange, or malformed component shape
/// returns an error so editor plugins can refuse to mutate the visible buffer.
///
/// # Safety
///
/// `doc` must be a valid, NUL-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_normalize_template_structure(
    doc: *const c_char,
) -> FfiPatchResult {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
    };

    ffi_patch_from_result(template::normalize_editor_visible_template_structure(
        doc_str,
    ))
}

/// Result of [`agent_doc_crdt_merge`].
#[repr(C)]
pub struct FfiMergeResult {
    /// Merged document text, or null on error. Free with [`agent_doc_free_string`].
    pub text: *mut c_char,
    /// Updated CRDT state bytes (caller must copy). Null on error.
    pub state: *mut u8,
    /// Length of `state` in bytes.
    pub state_len: usize,
    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
    pub error: *mut c_char,
}

/// 3-way CRDT merge over opaque state bytes.
///
/// Returns the merged text plus updated CRDT state for persistence.
///
/// # Safety
///
/// - `ours` and `theirs` must be valid, NUL-terminated UTF-8.
/// - If `base_state` is non-null, `base_state_len` bytes must be readable from it.
/// - The caller must free `text` and `error` with [`agent_doc_free_string`].
/// - The caller must free `state` with [`agent_doc_free_state`].
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_crdt_merge(
    base_state: *const u8,
    base_state_len: usize,
    ours: *const c_char,
    theirs: *const c_char,
) -> FfiMergeResult {
    let make_err = |msg: &str| FfiMergeResult {
        text: ptr::null_mut(),
        state: ptr::null_mut(),
        state_len: 0,
        error: CString::new(msg).unwrap_or_default().into_raw(),
    };

    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
        Ok(s) => s,
        Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
    };
    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
        Ok(s) => s,
        Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
    };

    let base = if base_state.is_null() {
        None
    } else {
        Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
    };

    match crdt::merge_by_component(base, ours_str, theirs_str) {
        Ok(merged_text) => {
            // Encode the merged state for persistence
            let doc = crdt::CrdtDoc::from_text(&merged_text);
            let state_bytes = doc.encode_state();
            let state_len = state_bytes.len();
            let state_ptr = {
                let mut boxed = state_bytes.into_boxed_slice();
                let ptr = boxed.as_mut_ptr();
                std::mem::forget(boxed);
                ptr
            };

            FfiMergeResult {
                text: CString::new(merged_text).unwrap_or_default().into_raw(),
                state: state_ptr,
                state_len,
                error: ptr::null_mut(),
            }
        }
        Err(e) => make_err(&format!("{e}")),
    }
}

/// Text-based CRDT 3-way merge. Simpler interface than [`agent_doc_crdt_merge`].
///
/// All three parameters are plain UTF-8 text (not CRDT state bytes).
/// Returns the conflict-free merged text. On any error, falls back to `ours`.
///
/// Intended for editor plugin use (replaces `git merge-file` in `PromptPoller`).
///
/// # Safety
///
/// `base`, `ours`, and `theirs` must be valid, NUL-terminated UTF-8.
/// The caller must free the returned pointer with [`agent_doc_free_string`].
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_merge_crdt(
    base: *const c_char,
    ours: *const c_char,
    theirs: *const c_char,
) -> *mut c_char {
    let base_str = match unsafe { CStr::from_ptr(base) }.to_str() {
        Ok(s) => s,
        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
    };
    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
        Ok(s) => s,
        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
    };
    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
        Ok(s) => s,
        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
    };

    // Encode base text as CRDT state for proper 3-way merge
    let base_doc = crdt::CrdtDoc::from_text(base_str);
    let base_state = base_doc.encode_state();

    let merged = crdt::merge_by_component(Some(&base_state), ours_str, theirs_str)
        .unwrap_or_else(|_| ours_str.to_string());
    CString::new(merged).unwrap_or_default().into_raw()
}

/// Reposition boundary marker to end of exchange component.
///
/// Removes all existing boundary markers from the document, strips transient
/// heading-level ` (HEAD)` suffixes, and inserts a single fresh boundary at the
/// end of the exchange component. Returns the document unchanged if no exchange
/// component exists.
///
/// # Safety
///
/// `doc` must be a valid, NUL-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
    doc: *const c_char,
) -> FfiPatchResult {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
    };

    let result = template::reposition_boundary_to_end_clean(doc_str);
    ffi_patch_from_result(normalize_editor_visible_result(result))
}

/// Reposition boundary marker to end of exchange component using an explicit ID.
///
/// This is used by post-commit editor refresh so the live buffer can be
/// normalized back to the exact boundary marker already committed in `HEAD`
/// rather than generating a fresh boundary-only local diff.
///
/// # Safety
///
/// `doc` and `boundary_id` must be valid, NUL-terminated UTF-8 strings.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_with_id(
    doc: *const c_char,
    boundary_id: *const c_char,
) -> FfiPatchResult {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
    };
    let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
    };

    let result = template::reposition_boundary_to_end_clean_with_id(doc_str, Some(boundary_id_str));
    ffi_patch_from_result(normalize_editor_visible_result(result))
}

/// Reposition boundary marker to end of exchange, preserving `(HEAD)` markers.
///
/// Used for post-commit working-tree cleanup where `(HEAD)` annotations should
/// remain visible to the user. The committed blob and snapshot use the `_clean`
/// variant; the working tree and editor buffer use this variant.
///
/// # Safety
///
/// `doc` must be a valid, NUL-terminated UTF-8 string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head(
    doc: *const c_char,
) -> FfiPatchResult {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
    };

    let result = template::reposition_boundary_to_end_preserve_head(doc_str);
    ffi_patch_from_result(normalize_editor_visible_result(result))
}

/// Reposition boundary using an explicit ID, preserving `(HEAD)` markers.
///
/// # Safety
///
/// `doc` and `boundary_id` must be valid, NUL-terminated UTF-8 strings.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head_with_id(
    doc: *const c_char,
    boundary_id: *const c_char,
) -> FfiPatchResult {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
    };
    let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
    };

    let result =
        template::reposition_boundary_to_end_preserve_head_with_id(doc_str, Some(boundary_id_str));
    ffi_patch_from_result(normalize_editor_visible_result(result))
}

/// Apply a patch to a document component.
///
/// `mode` must be one of: `"replace"`, `"append"`, `"prepend"`.
///
/// # Safety
///
/// All string pointers must be valid, NUL-terminated UTF-8.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_apply_patch(
    doc: *const c_char,
    component_name: *const c_char,
    content: *const c_char,
    mode: *const c_char,
) -> FfiPatchResult {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
    };
    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
    };
    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
    };
    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
    };

    // Build a patch block and apply it
    let patch = template::PatchBlock::new(name, patch_content);

    // Use mode overrides to force the specified mode
    let mut overrides = HashMap::new();
    overrides.insert(name.to_string(), mode_str.to_string());

    // Editor FFI applies to in-memory content with explicit overrides — no
    // backing file, so summary is None and component/max_lines configs are empty.
    ffi_patch_from_result(
        template::apply_patches_with_overrides_pure(
            doc_str,
            &[patch],
            "",
            None,
            &HashMap::new(),
            &HashMap::new(),
            &overrides,
        )
        .and_then(normalize_editor_visible_result),
    )
}

/// Apply a component patch with cursor-aware ordering for append mode.
///
/// When `mode` is `"append"` and `caret_offset >= 0`, the content is inserted
/// at the line boundary before the caret position (if the caret is inside the
/// component). This ensures agent responses appear above where the user is typing.
///
/// Pass `caret_offset = -1` for normal behavior (identical to `agent_doc_apply_patch`).
///
/// # Safety
///
/// All pointers must be valid, non-null, NUL-terminated UTF-8.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
    doc: *const c_char,
    component_name: *const c_char,
    content: *const c_char,
    mode: *const c_char,
    caret_offset: i32,
) -> FfiPatchResult {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
    };
    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
    };
    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
    };
    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
    };

    // If append mode with a valid caret, use cursor-aware insertion
    if mode_str == "append" && caret_offset >= 0 {
        let components = match component::parse(doc_str) {
            Ok(c) => c,
            Err(e) => return ffi_patch_err(&format!("{e}")),
        };
        if let Some(comp) = components.iter().find(|c| c.name == name) {
            let result =
                comp.append_with_caret(doc_str, patch_content, Some(caret_offset as usize));
            return ffi_patch_from_result(normalize_editor_visible_result(result));
        }
    }

    // Fall back to normal apply_patch behavior
    let patch = template::PatchBlock::new(name, patch_content);
    let mut overrides = HashMap::new();
    overrides.insert(name.to_string(), mode_str.to_string());
    ffi_patch_from_result(
        template::apply_patches_with_overrides_pure(
            doc_str,
            &[patch],
            "",
            None,
            &HashMap::new(),
            &HashMap::new(),
            &overrides,
        )
        .and_then(normalize_editor_visible_result),
    )
}

/// Apply a component patch using a boundary marker for insertion point.
///
/// When `mode` is `"append"` and `boundary_id` is provided, the content is
/// inserted at the boundary marker position (replacing the marker). This ensures
/// agent responses appear after the prompt that triggered them, even if the user
/// has typed new text below.
///
/// Falls back to normal patch application if the boundary is not found.
///
/// # Safety
///
/// All pointers must be valid, non-null, NUL-terminated UTF-8.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
    doc: *const c_char,
    component_name: *const c_char,
    content: *const c_char,
    mode: *const c_char,
    boundary_id: *const c_char,
) -> FfiPatchResult {
    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
    };
    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
    };
    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
    };
    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
    };
    let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
        Ok(s) => s,
        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
    };

    // Use boundary-aware insertion for append mode
    if mode_str == "append" && !bid.is_empty() {
        let components = match component::parse(doc_str) {
            Ok(c) => c,
            Err(e) => return ffi_patch_err(&format!("{e}")),
        };
        if let Some(comp) = components.iter().find(|c| c.name == name) {
            let result = comp.append_with_boundary(doc_str, patch_content, bid);
            let result = if name == "exchange" {
                template::annotate_exchange_headings_against_baseline(&result, doc_str)
            } else {
                result
            };
            return ffi_patch_from_result(normalize_editor_visible_result(result));
        }
    }

    // Fall back to normal apply_patch behavior
    let patch = template::PatchBlock::new(name, patch_content);
    let mut overrides = HashMap::new();
    overrides.insert(name.to_string(), mode_str.to_string());
    ffi_patch_from_result(
        template::apply_patches_with_overrides_pure(
            doc_str,
            &[patch],
            "",
            None,
            &HashMap::new(),
            &HashMap::new(),
            &overrides,
        )
        .and_then(normalize_editor_visible_result),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::ffi::{CStr, CString};

    #[test]
    fn apply_patch_append_preserves_leading_code_fence_after_prompt_fence() {
        let doc = concat!(
            "<!-- agent:exchange patch=append -->\n",
            "❯ show fenced prompt\n",
            "```\n",
            "prompt body\n",
            "```\n",
            "<!-- /agent:exchange -->\n",
        );
        let c_doc = CString::new(doc).unwrap();
        let c_name = CString::new("exchange").unwrap();
        let c_content = CString::new("```\nresponse body\n```\n").unwrap();
        let c_mode = CString::new("append").unwrap();

        let result = unsafe {
            agent_doc_apply_patch(
                c_doc.as_ptr(),
                c_name.as_ptr(),
                c_content.as_ptr(),
                c_mode.as_ptr(),
            )
        };

        assert!(result.error.is_null());
        assert!(!result.text.is_null());
        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
        assert_eq!(
            text.matches("```").count(),
            4,
            "native IPC append must keep prompt and response fences:\n{text}"
        );
        assert!(
            text.contains("```\n```\nresponse body\n```"),
            "native IPC append stripped the response opening fence:\n{text}"
        );
        unsafe { agent_doc_free_string(result.text) };
    }
}