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;
22use std::sync::{Mutex, OnceLock};
23
24use crate::crdt_sync::ReplicaState;
25use crate::{component, crdt, frontmatter, syntax, template};
26
27/// Free a string returned by an `agent_doc_*` function.
28///
29/// # Safety
30///
31/// `ptr` must have been returned by an `agent_doc_*` function, or be null.
32#[unsafe(no_mangle)]
33pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
34    if !ptr.is_null() {
35        drop(unsafe { CString::from_raw(ptr) });
36    }
37}
38
39/// Free a state buffer returned by an `agent_doc_*` function (e.g.
40/// `agent_doc_crdt_merge`).
41///
42/// # Safety
43///
44/// `ptr` and `len` must match a state buffer returned by an
45/// `agent_doc_*` function, or `ptr` must be null.
46#[unsafe(no_mangle)]
47pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
48    if !ptr.is_null() {
49        drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
50    }
51}
52
53/// Serialized component info returned by [`agent_doc_parse_components`].
54#[repr(C)]
55pub struct FfiComponentList {
56    /// JSON-encoded array of components. Free with [`agent_doc_free_string`].
57    pub json: *mut c_char,
58    /// Number of components parsed (convenience — also available in the JSON).
59    pub count: usize,
60}
61
62/// Result of a patch-style FFI function (`agent_doc_apply_patch`,
63/// `agent_doc_merge_frontmatter`, `agent_doc_normalize_template_structure`,
64/// the `agent_doc_reposition_boundary_*` family, …).
65#[repr(C)]
66pub struct FfiPatchResult {
67    /// The patched document text, or null on error. Free with [`agent_doc_free_string`].
68    pub text: *mut c_char,
69    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
70    pub error: *mut c_char,
71}
72
73/// Build a successful [`FfiPatchResult`] from patched document text.
74pub fn ffi_patch_ok(text: String) -> FfiPatchResult {
75    FfiPatchResult {
76        text: CString::new(text).unwrap_or_default().into_raw(),
77        error: ptr::null_mut(),
78    }
79}
80
81/// Build an error [`FfiPatchResult`] from a message.
82pub fn ffi_patch_err(msg: &str) -> FfiPatchResult {
83    FfiPatchResult {
84        text: ptr::null_mut(),
85        error: CString::new(msg).unwrap_or_default().into_raw(),
86    }
87}
88
89/// Convert an `anyhow::Result<String>` into an [`FfiPatchResult`].
90pub fn ffi_patch_from_result(result: anyhow::Result<String>) -> FfiPatchResult {
91    match result {
92        Ok(text) => ffi_patch_ok(text),
93        Err(e) => ffi_patch_err(&format!("{e:#}")),
94    }
95}
96
97/// Run the editor-visible template-structure normalization pass over patched text.
98pub fn normalize_editor_visible_result(text: String) -> anyhow::Result<String> {
99    template::normalize_editor_visible_template_structure(&text)
100}
101
102/// Map UTF-8 byte offsets to UTF-16 code-unit offsets in a single pass.
103///
104/// Editor range APIs (JetBrains, VS Code) consume UTF-16 offsets, while the
105/// document parser works in UTF-8 byte offsets. The `targets` are byte offsets
106/// to translate; the returned map keys them to their UTF-16 positions.
107fn utf8_offsets_to_utf16_offsets(doc: &str, offsets: &[usize]) -> HashMap<usize, usize> {
108    let mut targets = offsets.to_vec();
109    targets.sort_unstable();
110    targets.dedup();
111
112    let mut mapped = HashMap::with_capacity(targets.len());
113    let mut target_idx = 0usize;
114    let mut utf8_offset = 0usize;
115    let mut utf16_offset = 0usize;
116
117    while target_idx < targets.len() && targets[target_idx] == 0 {
118        mapped.insert(0, 0);
119        target_idx += 1;
120    }
121
122    for ch in doc.chars() {
123        let next_utf8 = utf8_offset + ch.len_utf8();
124        while target_idx < targets.len() && targets[target_idx] < next_utf8 {
125            mapped.insert(targets[target_idx], utf16_offset);
126            target_idx += 1;
127        }
128
129        utf8_offset = next_utf8;
130        utf16_offset += ch.len_utf16();
131
132        while target_idx < targets.len() && targets[target_idx] == utf8_offset {
133            mapped.insert(targets[target_idx], utf16_offset);
134            target_idx += 1;
135        }
136    }
137
138    while target_idx < targets.len() {
139        mapped.insert(targets[target_idx], utf16_offset);
140        target_idx += 1;
141    }
142
143    mapped
144}
145
146/// Parse components from a document.
147///
148/// Returns a [`FfiComponentList`] with a JSON-encoded array of components.
149/// Each component object has: `name`, `attrs`, `open_start`, `open_end`,
150/// `close_start`, `close_end`, `content`.
151///
152/// # Safety
153///
154/// `doc` must be a valid, NUL-terminated UTF-8 string.
155#[unsafe(no_mangle)]
156pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
157    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
158        Ok(s) => s,
159        Err(_) => {
160            return FfiComponentList {
161                json: ptr::null_mut(),
162                count: 0,
163            };
164        }
165    };
166
167    let components = match component::parse(doc_str) {
168        Ok(c) => c,
169        Err(_) => {
170            return FfiComponentList {
171                json: ptr::null_mut(),
172                count: 0,
173            };
174        }
175    };
176
177    let count = components.len();
178
179    // Serialize to JSON with content included
180    let json_items: Vec<serde_json::Value> = components
181        .iter()
182        .map(|c| {
183            serde_json::json!({
184                "name": c.name,
185                "attrs": c.attrs,
186                "open_start": c.open_start,
187                "open_end": c.open_end,
188                "close_start": c.close_start,
189                "close_end": c.close_end,
190                "content": c.content(doc_str),
191            })
192        })
193        .collect();
194
195    let json_str = serde_json::to_string(&json_items).unwrap_or_default();
196    let c_json = CString::new(json_str).unwrap_or_default();
197
198    FfiComponentList {
199        json: c_json.into_raw(),
200        count,
201    }
202}
203
204/// Collect editor-facing visual token ranges from a markdown document.
205///
206/// The returned JSON array contains `{ kind, start, end }` objects, where
207/// offsets are UTF-16 document positions suitable for JetBrains and VS Code
208/// range APIs.
209///
210/// # Safety
211///
212/// `doc` must be a valid, NUL-terminated UTF-8 string.
213#[unsafe(no_mangle)]
214pub unsafe extern "C" fn agent_doc_visual_tokens_json(doc: *const c_char) -> *mut c_char {
215    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
216        Ok(s) => s,
217        Err(_) => return ptr::null_mut(),
218    };
219    let tokens = syntax::collect_visual_tokens(doc_str);
220    let offsets: Vec<usize> = tokens
221        .iter()
222        .flat_map(|token| [token.start, token.end])
223        .collect();
224    let utf16_offsets = utf8_offsets_to_utf16_offsets(doc_str, &offsets);
225    let editor_tokens: Vec<_> = tokens
226        .iter()
227        .map(|token| {
228            serde_json::json!({
229                "kind": token.kind,
230                "start": utf16_offsets[&token.start],
231                "end": utf16_offsets[&token.end],
232            })
233        })
234        .collect();
235    let json = match serde_json::to_string(&editor_tokens) {
236        Ok(json) => json,
237        Err(_) => return ptr::null_mut(),
238    };
239    match CString::new(json) {
240        Ok(value) => value.into_raw(),
241        Err(_) => ptr::null_mut(),
242    }
243}
244
245/// Merge YAML key/value pairs into a document's frontmatter.
246///
247/// `yaml_fields` is a YAML string of fields to merge (additive — never removes keys).
248/// Returns the updated document content via [`FfiPatchResult`].
249///
250/// # Safety
251///
252/// All string pointers must be valid, NUL-terminated UTF-8.
253#[unsafe(no_mangle)]
254pub unsafe extern "C" fn agent_doc_merge_frontmatter(
255    doc: *const c_char,
256    yaml_fields: *const c_char,
257) -> FfiPatchResult {
258    let make_err = |msg: &str| FfiPatchResult {
259        text: ptr::null_mut(),
260        error: CString::new(msg).unwrap_or_default().into_raw(),
261    };
262
263    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
264        Ok(s) => s,
265        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
266    };
267    let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
268        Ok(s) => s,
269        Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
270    };
271
272    match frontmatter::merge_fields(doc_str, fields_str) {
273        Ok(result) => FfiPatchResult {
274            text: CString::new(result).unwrap_or_default().into_raw(),
275            error: ptr::null_mut(),
276        },
277        Err(e) => make_err(&format!("{e}")),
278    }
279}
280
281/// Converge the `agent:queue` opening-tag `auto` attribute to `want_auto`.
282///
283/// A content patch replaces only a component's body, so it cannot add or remove
284/// the `auto` attribute on the `<!-- agent:queue auto -->` opening tag. Editor
285/// plugins call this to converge a live route-owned buffer's queue tag to the
286/// committed inactive shape after a queue halt (`#adoc-queue-ipc-buffer-divergence`),
287/// pairing with the `queue_active` frontmatter merge that the same convergence
288/// patch carries. Returns the (possibly unchanged) document via [`FfiPatchResult`];
289/// an absent `queue` component or an already-converged tag returns the input
290/// unchanged so the editor's normal no-op path applies.
291///
292/// `want_auto` is a C int (nonzero = ensure `auto`, zero = strip `auto`) rather
293/// than a C `bool` so the JNA/FFI boundary stays on the reliable 32-bit-int ABI.
294///
295/// # Safety
296///
297/// `doc` must be a valid, NUL-terminated UTF-8 string.
298#[unsafe(no_mangle)]
299pub unsafe extern "C" fn agent_doc_converge_queue_auto(
300    doc: *const c_char,
301    want_auto: std::os::raw::c_int,
302) -> FfiPatchResult {
303    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
304        Ok(s) => s,
305        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
306    };
307    let converged = component::converge_queue_auto(doc_str, want_auto != 0)
308        .unwrap_or_else(|| doc_str.to_string());
309    FfiPatchResult {
310        text: CString::new(converged).unwrap_or_default().into_raw(),
311        error: ptr::null_mut(),
312    }
313}
314
315/// Normalize/fail-close template structure before editor-visible IPC writes.
316///
317/// Safe duplicate scaffold shells are repaired. Ambiguous duplicate scaffold
318/// content, conversation text outside exchange, or malformed component shape
319/// returns an error so editor plugins can refuse to mutate the visible buffer.
320///
321/// # Safety
322///
323/// `doc` must be a valid, NUL-terminated UTF-8 string.
324#[unsafe(no_mangle)]
325pub unsafe extern "C" fn agent_doc_normalize_template_structure(
326    doc: *const c_char,
327) -> FfiPatchResult {
328    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
329        Ok(s) => s,
330        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
331    };
332
333    ffi_patch_from_result(template::normalize_editor_visible_template_structure(
334        doc_str,
335    ))
336}
337
338/// Result of [`agent_doc_crdt_merge`].
339#[repr(C)]
340pub struct FfiMergeResult {
341    /// Merged document text, or null on error. Free with [`agent_doc_free_string`].
342    pub text: *mut c_char,
343    /// Updated CRDT state bytes (caller must copy). Null on error.
344    pub state: *mut u8,
345    /// Length of `state` in bytes.
346    pub state_len: usize,
347    /// Error message if `text` is null. Free with [`agent_doc_free_string`].
348    pub error: *mut c_char,
349}
350
351/// 3-way CRDT merge over opaque state bytes.
352///
353/// Returns the merged text plus updated CRDT state for persistence.
354///
355/// # Safety
356///
357/// - `ours` and `theirs` must be valid, NUL-terminated UTF-8.
358/// - If `base_state` is non-null, `base_state_len` bytes must be readable from it.
359/// - The caller must free `text` and `error` with [`agent_doc_free_string`].
360/// - The caller must free `state` with [`agent_doc_free_state`].
361#[unsafe(no_mangle)]
362pub unsafe extern "C" fn agent_doc_crdt_merge(
363    base_state: *const u8,
364    base_state_len: usize,
365    ours: *const c_char,
366    theirs: *const c_char,
367) -> FfiMergeResult {
368    let make_err = |msg: &str| FfiMergeResult {
369        text: ptr::null_mut(),
370        state: ptr::null_mut(),
371        state_len: 0,
372        error: CString::new(msg).unwrap_or_default().into_raw(),
373    };
374
375    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
376        Ok(s) => s,
377        Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
378    };
379    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
380        Ok(s) => s,
381        Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
382    };
383
384    let base = if base_state.is_null() {
385        None
386    } else {
387        Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
388    };
389
390    match crdt::merge_by_component(base, ours_str, theirs_str) {
391        Ok(merged_text) => {
392            // Encode the merged state for persistence
393            let doc = crdt::CrdtDoc::from_text(&merged_text);
394            let state_bytes = doc.encode_state();
395            let state_len = state_bytes.len();
396            let state_ptr = {
397                let mut boxed = state_bytes.into_boxed_slice();
398                let ptr = boxed.as_mut_ptr();
399                std::mem::forget(boxed);
400                ptr
401            };
402
403            FfiMergeResult {
404                text: CString::new(merged_text).unwrap_or_default().into_raw(),
405                state: state_ptr,
406                state_len,
407                error: ptr::null_mut(),
408            }
409        }
410        Err(e) => make_err(&format!("{e}")),
411    }
412}
413
414// ---------------------------------------------------------------------------
415// Editor-as-replica FFI (`#crdtauth2`) — the cdylib hosts a per-editor yrs replica.
416//
417// FFI-first per the Shared Foundation pattern: the CRDT node (a durable
418// [`crate::crdt_sync::ReplicaState`]) lives in the shared library; thin editor
419// plugins are bindings that forward local Document deltas (`apply_local`) and
420// apply remote updates (`apply_update`), exchanging state vectors / updates with
421// the supervisor's canonical replica. Each replica is keyed by a caller-chosen
422// `replica_id` (also its yrs client id, so distinct replicas order concurrent
423// inserts deterministically). Authority gating (sync only under a multi-replica
424// authority, `#crdtauth1sv`) lives in the orchestration layer over this transport.
425// ---------------------------------------------------------------------------
426
427/// Process-wide registry of cdylib-hosted CRDT replicas, keyed by `replica_id`.
428static REPLICA_REGISTRY: OnceLock<Mutex<HashMap<u64, ReplicaState>>> = OnceLock::new();
429
430fn replica_registry() -> &'static Mutex<HashMap<u64, ReplicaState>> {
431    REPLICA_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
432}
433
434/// Leak `bytes` to the C caller (freed with [`agent_doc_free_state`]); writes the
435/// length to `out_len` when non-null.
436fn leak_state(bytes: Vec<u8>, out_len: *mut usize) -> *mut u8 {
437    let len = bytes.len();
438    let mut boxed = bytes.into_boxed_slice();
439    let ptr = boxed.as_mut_ptr();
440    std::mem::forget(boxed);
441    if !out_len.is_null() {
442        unsafe { *out_len = len };
443    }
444    ptr
445}
446
447/// The null byte-buffer result: null pointer with `*out_len = 0`.
448fn null_state(out_len: *mut usize) -> *mut u8 {
449    if !out_len.is_null() {
450        unsafe { *out_len = 0 };
451    }
452    ptr::null_mut()
453}
454
455/// Open (or reset) the cdylib-hosted CRDT replica `replica_id`, optionally
456/// bootstrapping it from a previously encoded state (`init_state` / `init_len`;
457/// pass null / 0 for a fresh empty replica). `replica_id` is also the yrs client
458/// id, so distinct live replicas MUST use distinct ids.
459///
460/// Returns 0 on success, -1 on a poisoned registry, -2 on an invalid init state.
461///
462/// # Safety
463/// If `init_state` is non-null, `init_len` bytes must be readable from it.
464#[unsafe(no_mangle)]
465pub unsafe extern "C" fn agent_doc_replica_open(
466    replica_id: u64,
467    init_state: *const u8,
468    init_len: usize,
469) -> i32 {
470    let replica = if init_state.is_null() || init_len == 0 {
471        ReplicaState::new(replica_id)
472    } else {
473        let bytes = unsafe { std::slice::from_raw_parts(init_state, init_len) };
474        match ReplicaState::from_encoded(replica_id, bytes) {
475            Ok(r) => r,
476            Err(_) => return -2,
477        }
478    };
479    match replica_registry().lock() {
480        Ok(mut reg) => {
481            reg.insert(replica_id, replica);
482            0
483        }
484        Err(_) => -1,
485    }
486}
487
488/// Apply a local edit to replica `replica_id` (the editor forwarding a local
489/// `Document` delta): delete `delete_len` chars at `offset`, then insert
490/// `insert`. Returns 0 ok, -1 poison, -2 bad UTF-8, -3 replica not open.
491///
492/// # Safety
493/// `insert` must be a valid NUL-terminated UTF-8 string (may be empty), or null.
494#[unsafe(no_mangle)]
495pub unsafe extern "C" fn agent_doc_replica_apply_local(
496    replica_id: u64,
497    offset: u32,
498    delete_len: u32,
499    insert: *const c_char,
500) -> i32 {
501    let insert_str = if insert.is_null() {
502        ""
503    } else {
504        match unsafe { CStr::from_ptr(insert) }.to_str() {
505            Ok(s) => s,
506            Err(_) => return -2,
507        }
508    };
509    match replica_registry().lock() {
510        Ok(reg) => match reg.get(&replica_id) {
511            Some(replica) => {
512                replica.apply_local_edit(offset, delete_len, insert_str);
513                0
514            }
515            None => -3,
516        },
517        Err(_) => -1,
518    }
519}
520
521/// The current text of replica `replica_id`, or null if not open / poisoned.
522/// Free with [`agent_doc_free_string`].
523///
524/// # Safety
525/// Always safe to call (no pointer arguments). The returned pointer, when
526/// non-null, must be freed exactly once with [`agent_doc_free_string`].
527#[unsafe(no_mangle)]
528pub unsafe extern "C" fn agent_doc_replica_text(replica_id: u64) -> *mut c_char {
529    match replica_registry().lock() {
530        Ok(reg) => match reg.get(&replica_id) {
531            Some(replica) => CString::new(replica.text()).unwrap_or_default().into_raw(),
532            None => ptr::null_mut(),
533        },
534        Err(_) => ptr::null_mut(),
535    }
536}
537
538/// The encoded state vector of replica `replica_id` (the compact causal summary a
539/// replica announces to a peer). Writes the length to `out_len`. Returns null
540/// (with `*out_len = 0`) if not open / poisoned. Free with [`agent_doc_free_state`].
541///
542/// # Safety
543/// `out_len` must be a valid writable `usize` pointer, or null.
544#[unsafe(no_mangle)]
545pub unsafe extern "C" fn agent_doc_replica_state_vector(
546    replica_id: u64,
547    out_len: *mut usize,
548) -> *mut u8 {
549    match replica_registry().lock() {
550        Ok(reg) => match reg.get(&replica_id) {
551            Some(replica) => leak_state(replica.state_vector(), out_len),
552            None => null_state(out_len),
553        },
554        Err(_) => null_state(out_len),
555    }
556}
557
558/// The incremental update replica `replica_id` should send a peer whose state
559/// vector is `their_sv` — only the ops that peer is missing (a delta, not a
560/// snapshot). Writes length to `out_len`. Returns null on not-open / bad-sv /
561/// poison. Free with [`agent_doc_free_state`].
562///
563/// # Safety
564/// `their_sv` must point to `their_sv_len` readable bytes; `out_len` writable or null.
565#[unsafe(no_mangle)]
566pub unsafe extern "C" fn agent_doc_replica_diff(
567    replica_id: u64,
568    their_sv: *const u8,
569    their_sv_len: usize,
570    out_len: *mut usize,
571) -> *mut u8 {
572    if their_sv.is_null() {
573        return null_state(out_len);
574    }
575    let sv = unsafe { std::slice::from_raw_parts(their_sv, their_sv_len) };
576    match replica_registry().lock() {
577        Ok(reg) => match reg.get(&replica_id) {
578            Some(replica) => match replica.diff(sv) {
579                Ok(update) => leak_state(update, out_len),
580                Err(_) => null_state(out_len),
581            },
582            None => null_state(out_len),
583        },
584        Err(_) => null_state(out_len),
585    }
586}
587
588/// Apply a remote update to replica `replica_id` (the editor applying a peer's
589/// ops — idempotent and yrs causal-buffered). Returns 0 ok, -1 poison, -2 bad
590/// update bytes, -3 replica not open.
591///
592/// # Safety
593/// `update` must point to `update_len` readable bytes, or be null.
594#[unsafe(no_mangle)]
595pub unsafe extern "C" fn agent_doc_replica_apply_update(
596    replica_id: u64,
597    update: *const u8,
598    update_len: usize,
599) -> i32 {
600    if update.is_null() {
601        return -2;
602    }
603    let bytes = unsafe { std::slice::from_raw_parts(update, update_len) };
604    match replica_registry().lock() {
605        Ok(reg) => match reg.get(&replica_id) {
606            Some(replica) => match replica.apply_update(bytes) {
607                Ok(()) => 0,
608                Err(_) => -2,
609            },
610            None => -3,
611        },
612        Err(_) => -1,
613    }
614}
615
616/// The full encoded state of replica `replica_id` (a durable checkpoint, or the
617/// snapshot a peer needs on first contact). Writes length to `out_len`. Returns
618/// null if not open / poisoned. Free with [`agent_doc_free_state`].
619///
620/// # Safety
621/// `out_len` must be writable or null.
622#[unsafe(no_mangle)]
623pub unsafe extern "C" fn agent_doc_replica_encode_state(
624    replica_id: u64,
625    out_len: *mut usize,
626) -> *mut u8 {
627    match replica_registry().lock() {
628        Ok(reg) => match reg.get(&replica_id) {
629            Some(replica) => leak_state(replica.encode_state(), out_len),
630            None => null_state(out_len),
631        },
632        Err(_) => null_state(out_len),
633    }
634}
635
636/// Close (drop) replica `replica_id`. Returns 0 if it was open, -1 on poison,
637/// -3 if it was not open.
638///
639/// # Safety
640/// Always safe to call (no pointer arguments).
641#[unsafe(no_mangle)]
642pub unsafe extern "C" fn agent_doc_replica_close(replica_id: u64) -> i32 {
643    match replica_registry().lock() {
644        Ok(mut reg) => {
645            if reg.remove(&replica_id).is_some() {
646                0
647            } else {
648                -3
649            }
650        }
651        Err(_) => -1,
652    }
653}
654
655/// Persist replica `replica_id` to a local file for crash safety (`#crdtauth4`,
656/// disk demotion / plan phase 6). Each FFI node writes its OWN replica's encoded
657/// state through this so a plugin/IDE crash mid-lag does not lose un-synced ops.
658///
659/// The file is a **write-through durable recovery projection only** — it is read
660/// back by [`agent_doc_replica_recover`] on restart, never the coordination
661/// medium. The write is atomic (temp file + rename) so a crash mid-write cannot
662/// truncate the projection.
663///
664/// Returns 0 on success, -1 on poison, -2 on a bad path / IO error, -3 if the
665/// replica is not open.
666///
667/// # Safety
668/// `path` must be a valid NUL-terminated UTF-8 string.
669#[unsafe(no_mangle)]
670pub unsafe extern "C" fn agent_doc_replica_persist(replica_id: u64, path: *const c_char) -> i32 {
671    if path.is_null() {
672        return -2;
673    }
674    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
675        Ok(s) => s,
676        Err(_) => return -2,
677    };
678    let state = match replica_registry().lock() {
679        Ok(reg) => match reg.get(&replica_id) {
680            Some(replica) => replica.encode_state(),
681            None => return -3,
682        },
683        Err(_) => return -1,
684    };
685    match atomic_write_bytes(std::path::Path::new(path_str), &state) {
686        Ok(()) => 0,
687        Err(_) => -2,
688    }
689}
690
691/// Recover (open) replica `replica_id` from a local durable recovery projection
692/// written by [`agent_doc_replica_persist`] (`#crdtauth4`, disk demotion / plan
693/// phase 6). On restart the node rebuilds its in-memory replica from disk; live
694/// peers re-sync any newer ops via the normal state-vector exchange afterward, so
695/// the recovered projection is a starting point, not authority.
696///
697/// Returns 0 on success, -1 on poison, -2 on a bad path / IO / decode error.
698///
699/// # Safety
700/// `path` must be a valid NUL-terminated UTF-8 string.
701#[unsafe(no_mangle)]
702pub unsafe extern "C" fn agent_doc_replica_recover(replica_id: u64, path: *const c_char) -> i32 {
703    if path.is_null() {
704        return -2;
705    }
706    let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
707        Ok(s) => s,
708        Err(_) => return -2,
709    };
710    let bytes = match std::fs::read(path_str) {
711        Ok(b) => b,
712        Err(_) => return -2,
713    };
714    let replica = match ReplicaState::from_encoded(replica_id, &bytes) {
715        Ok(r) => r,
716        Err(_) => return -2,
717    };
718    match replica_registry().lock() {
719        Ok(mut reg) => {
720            reg.insert(replica_id, replica);
721            0
722        }
723        Err(_) => -1,
724    }
725}
726
727/// Atomically write `bytes` to `path` (sibling temp file + rename) so a crash
728/// mid-write cannot truncate a recovery projection.
729fn atomic_write_bytes(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
730    use std::io::Write;
731    if let Some(parent) = path.parent()
732        && !parent.as_os_str().is_empty()
733    {
734        std::fs::create_dir_all(parent)?;
735    }
736    let pid = std::process::id();
737    let tmp_path = match path.extension().and_then(|e| e.to_str()) {
738        Some(ext) => path.with_extension(format!("{ext}.tmp.{pid}")),
739        None => path.with_extension(format!("tmp.{pid}")),
740    };
741    {
742        let mut f = std::fs::File::create(&tmp_path)?;
743        f.write_all(bytes)?;
744        f.flush()?;
745    }
746    std::fs::rename(&tmp_path, path)?;
747    Ok(())
748}
749
750/// Text-based CRDT 3-way merge. Simpler interface than [`agent_doc_crdt_merge`].
751///
752/// All three parameters are plain UTF-8 text (not CRDT state bytes).
753/// Returns the conflict-free merged text. On any error, falls back to `ours`.
754///
755/// Intended for editor plugin use (replaces `git merge-file` in `PromptPoller`).
756///
757/// # Safety
758///
759/// `base`, `ours`, and `theirs` must be valid, NUL-terminated UTF-8.
760/// The caller must free the returned pointer with [`agent_doc_free_string`].
761#[unsafe(no_mangle)]
762pub unsafe extern "C" fn agent_doc_merge_crdt(
763    base: *const c_char,
764    ours: *const c_char,
765    theirs: *const c_char,
766) -> *mut c_char {
767    let base_str = match unsafe { CStr::from_ptr(base) }.to_str() {
768        Ok(s) => s,
769        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
770    };
771    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
772        Ok(s) => s,
773        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
774    };
775    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
776        Ok(s) => s,
777        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
778    };
779
780    // Encode base text as CRDT state for proper 3-way merge
781    let base_doc = crdt::CrdtDoc::from_text(base_str);
782    let base_state = base_doc.encode_state();
783
784    let merged = crdt::merge_by_component(Some(&base_state), ours_str, theirs_str)
785        .unwrap_or_else(|_| ours_str.to_string());
786    CString::new(merged).unwrap_or_default().into_raw()
787}
788
789/// Reposition boundary marker to end of exchange component.
790///
791/// Removes all existing boundary markers from the document, strips transient
792/// heading-level ` (HEAD)` suffixes, and inserts a single fresh boundary at the
793/// end of the exchange component. Returns the document unchanged if no exchange
794/// component exists.
795///
796/// # Safety
797///
798/// `doc` must be a valid, NUL-terminated UTF-8 string.
799#[unsafe(no_mangle)]
800pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
801    doc: *const c_char,
802) -> FfiPatchResult {
803    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
804        Ok(s) => s,
805        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
806    };
807
808    let result = template::reposition_boundary_to_end_clean(doc_str);
809    ffi_patch_from_result(normalize_editor_visible_result(result))
810}
811
812/// Reposition boundary marker to end of exchange component using an explicit ID.
813///
814/// This is used by post-commit editor refresh so the live buffer can be
815/// normalized back to the exact boundary marker already committed in `HEAD`
816/// rather than generating a fresh boundary-only local diff.
817///
818/// # Safety
819///
820/// `doc` and `boundary_id` must be valid, NUL-terminated UTF-8 strings.
821#[unsafe(no_mangle)]
822pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_with_id(
823    doc: *const c_char,
824    boundary_id: *const c_char,
825) -> FfiPatchResult {
826    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
827        Ok(s) => s,
828        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
829    };
830    let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
831        Ok(s) => s,
832        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
833    };
834
835    let result = template::reposition_boundary_to_end_clean_with_id(doc_str, Some(boundary_id_str));
836    ffi_patch_from_result(normalize_editor_visible_result(result))
837}
838
839/// Reposition boundary marker to end of exchange, preserving `(HEAD)` markers.
840///
841/// Used for post-commit working-tree cleanup where `(HEAD)` annotations should
842/// remain visible to the user. The committed blob and snapshot use the `_clean`
843/// variant; the working tree and editor buffer use this variant.
844///
845/// # Safety
846///
847/// `doc` must be a valid, NUL-terminated UTF-8 string.
848#[unsafe(no_mangle)]
849pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head(
850    doc: *const c_char,
851) -> FfiPatchResult {
852    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
853        Ok(s) => s,
854        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
855    };
856
857    let result = template::reposition_boundary_to_end_preserve_head(doc_str);
858    ffi_patch_from_result(normalize_editor_visible_result(result))
859}
860
861/// Reposition boundary using an explicit ID, preserving `(HEAD)` markers.
862///
863/// # Safety
864///
865/// `doc` and `boundary_id` must be valid, NUL-terminated UTF-8 strings.
866#[unsafe(no_mangle)]
867pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head_with_id(
868    doc: *const c_char,
869    boundary_id: *const c_char,
870) -> FfiPatchResult {
871    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
872        Ok(s) => s,
873        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
874    };
875    let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
876        Ok(s) => s,
877        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
878    };
879
880    let result =
881        template::reposition_boundary_to_end_preserve_head_with_id(doc_str, Some(boundary_id_str));
882    ffi_patch_from_result(normalize_editor_visible_result(result))
883}
884
885/// Apply a patch to a document component.
886///
887/// `mode` must be one of: `"replace"`, `"append"`, `"prepend"`.
888///
889/// # Safety
890///
891/// All string pointers must be valid, NUL-terminated UTF-8.
892#[unsafe(no_mangle)]
893pub unsafe extern "C" fn agent_doc_apply_patch(
894    doc: *const c_char,
895    component_name: *const c_char,
896    content: *const c_char,
897    mode: *const c_char,
898) -> FfiPatchResult {
899    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
900        Ok(s) => s,
901        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
902    };
903    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
904        Ok(s) => s,
905        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
906    };
907    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
908        Ok(s) => s,
909        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
910    };
911    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
912        Ok(s) => s,
913        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
914    };
915
916    // Build a patch block and apply it
917    let patch = template::PatchBlock::new(name, patch_content);
918
919    // Use mode overrides to force the specified mode
920    let mut overrides = HashMap::new();
921    overrides.insert(name.to_string(), mode_str.to_string());
922
923    // Editor FFI applies to in-memory content with explicit overrides — no
924    // backing file, so summary is None and component/max_lines configs are empty.
925    ffi_patch_from_result(
926        template::apply_patches_with_overrides_pure(
927            doc_str,
928            &[patch],
929            "",
930            None,
931            &HashMap::new(),
932            &HashMap::new(),
933            &overrides,
934        )
935        .and_then(normalize_editor_visible_result),
936    )
937}
938
939/// Apply a component patch with cursor-aware ordering for append mode.
940///
941/// When `mode` is `"append"` and `caret_offset >= 0`, the content is inserted
942/// at the line boundary before the caret position (if the caret is inside the
943/// component). This ensures agent responses appear above where the user is typing.
944///
945/// Pass `caret_offset = -1` for normal behavior (identical to `agent_doc_apply_patch`).
946///
947/// # Safety
948///
949/// All pointers must be valid, non-null, NUL-terminated UTF-8.
950#[unsafe(no_mangle)]
951pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
952    doc: *const c_char,
953    component_name: *const c_char,
954    content: *const c_char,
955    mode: *const c_char,
956    caret_offset: i32,
957) -> FfiPatchResult {
958    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
959        Ok(s) => s,
960        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
961    };
962    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
963        Ok(s) => s,
964        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
965    };
966    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
967        Ok(s) => s,
968        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
969    };
970    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
971        Ok(s) => s,
972        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
973    };
974
975    // If append mode with a valid caret, use cursor-aware insertion
976    if mode_str == "append" && caret_offset >= 0 {
977        let components = match component::parse(doc_str) {
978            Ok(c) => c,
979            Err(e) => return ffi_patch_err(&format!("{e}")),
980        };
981        if let Some(comp) = components.iter().find(|c| c.name == name) {
982            let result =
983                comp.append_with_caret(doc_str, patch_content, Some(caret_offset as usize));
984            return ffi_patch_from_result(normalize_editor_visible_result(result));
985        }
986    }
987
988    // Fall back to normal apply_patch behavior
989    let patch = template::PatchBlock::new(name, patch_content);
990    let mut overrides = HashMap::new();
991    overrides.insert(name.to_string(), mode_str.to_string());
992    ffi_patch_from_result(
993        template::apply_patches_with_overrides_pure(
994            doc_str,
995            &[patch],
996            "",
997            None,
998            &HashMap::new(),
999            &HashMap::new(),
1000            &overrides,
1001        )
1002        .and_then(normalize_editor_visible_result),
1003    )
1004}
1005
1006/// Apply a component patch using a boundary marker for insertion point.
1007///
1008/// When `mode` is `"append"` and `boundary_id` is provided, the content is
1009/// inserted at the boundary marker position (replacing the marker). This ensures
1010/// agent responses appear after the prompt that triggered them, even if the user
1011/// has typed new text below.
1012///
1013/// Falls back to normal patch application if the boundary is not found.
1014///
1015/// # Safety
1016///
1017/// All pointers must be valid, non-null, NUL-terminated UTF-8.
1018#[unsafe(no_mangle)]
1019pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
1020    doc: *const c_char,
1021    component_name: *const c_char,
1022    content: *const c_char,
1023    mode: *const c_char,
1024    boundary_id: *const c_char,
1025) -> FfiPatchResult {
1026    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
1027        Ok(s) => s,
1028        Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
1029    };
1030    let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
1031        Ok(s) => s,
1032        Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
1033    };
1034    let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
1035        Ok(s) => s,
1036        Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
1037    };
1038    let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
1039        Ok(s) => s,
1040        Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
1041    };
1042    let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
1043        Ok(s) => s,
1044        Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
1045    };
1046
1047    // Use boundary-aware insertion for append mode
1048    if mode_str == "append" && !bid.is_empty() {
1049        let components = match component::parse(doc_str) {
1050            Ok(c) => c,
1051            Err(e) => return ffi_patch_err(&format!("{e}")),
1052        };
1053        if let Some(comp) = components.iter().find(|c| c.name == name) {
1054            let result = comp.append_with_boundary(doc_str, patch_content, bid);
1055            let result = if name == "exchange" {
1056                template::annotate_exchange_headings_against_baseline(&result, doc_str)
1057            } else {
1058                result
1059            };
1060            return ffi_patch_from_result(normalize_editor_visible_result(result));
1061        }
1062    }
1063
1064    // Fall back to normal apply_patch behavior
1065    let patch = template::PatchBlock::new(name, patch_content);
1066    let mut overrides = HashMap::new();
1067    overrides.insert(name.to_string(), mode_str.to_string());
1068    ffi_patch_from_result(
1069        template::apply_patches_with_overrides_pure(
1070            doc_str,
1071            &[patch],
1072            "",
1073            None,
1074            &HashMap::new(),
1075            &HashMap::new(),
1076            &overrides,
1077        )
1078        .and_then(normalize_editor_visible_result),
1079    )
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use std::ffi::{CStr, CString};
1086
1087    #[test]
1088    fn apply_patch_append_preserves_leading_code_fence_after_prompt_fence() {
1089        let doc = concat!(
1090            "<!-- agent:exchange patch=append -->\n",
1091            "❯ show fenced prompt\n",
1092            "```\n",
1093            "prompt body\n",
1094            "```\n",
1095            "<!-- /agent:exchange -->\n",
1096        );
1097        let c_doc = CString::new(doc).unwrap();
1098        let c_name = CString::new("exchange").unwrap();
1099        let c_content = CString::new("```\nresponse body\n```\n").unwrap();
1100        let c_mode = CString::new("append").unwrap();
1101
1102        let result = unsafe {
1103            agent_doc_apply_patch(
1104                c_doc.as_ptr(),
1105                c_name.as_ptr(),
1106                c_content.as_ptr(),
1107                c_mode.as_ptr(),
1108            )
1109        };
1110
1111        assert!(result.error.is_null());
1112        assert!(!result.text.is_null());
1113        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
1114        assert_eq!(
1115            text.matches("```").count(),
1116            4,
1117            "native IPC append must keep prompt and response fences:\n{text}"
1118        );
1119        assert!(
1120            text.contains("```\n```\nresponse body\n```"),
1121            "native IPC append stripped the response opening fence:\n{text}"
1122        );
1123        unsafe { agent_doc_free_string(result.text) };
1124    }
1125
1126    #[test]
1127    fn replica_ffi_round_trip_converges_via_state_vector_exchange() {
1128        use std::ffi::CString;
1129
1130        let a: u64 = 0xA11CE;
1131        let b: u64 = 0xB0B;
1132        assert_eq!(unsafe { agent_doc_replica_open(a, std::ptr::null(), 0) }, 0);
1133        assert_eq!(unsafe { agent_doc_replica_open(b, std::ptr::null(), 0) }, 0);
1134
1135        // Each replica forwards a local Document delta (concurrent, non-overlapping).
1136        let ins_a = CString::new("alpha").unwrap();
1137        let ins_b = CString::new("beta").unwrap();
1138        assert_eq!(
1139            unsafe { agent_doc_replica_apply_local(a, 0, 0, ins_a.as_ptr()) },
1140            0
1141        );
1142        assert_eq!(
1143            unsafe { agent_doc_replica_apply_local(b, 0, 0, ins_b.as_ptr()) },
1144            0
1145        );
1146
1147        // Exchange state vectors → deltas (only the missing ops), then apply.
1148        let mut b_sv_len: usize = 0;
1149        let b_sv = unsafe { agent_doc_replica_state_vector(b, &mut b_sv_len) };
1150        assert!(!b_sv.is_null());
1151        let mut a_to_b_len: usize = 0;
1152        let a_to_b = unsafe { agent_doc_replica_diff(a, b_sv, b_sv_len, &mut a_to_b_len) };
1153        assert!(!a_to_b.is_null());
1154
1155        let mut a_sv_len: usize = 0;
1156        let a_sv = unsafe { agent_doc_replica_state_vector(a, &mut a_sv_len) };
1157        let mut b_to_a_len: usize = 0;
1158        let b_to_a = unsafe { agent_doc_replica_diff(b, a_sv, a_sv_len, &mut b_to_a_len) };
1159
1160        assert_eq!(
1161            unsafe { agent_doc_replica_apply_update(b, a_to_b, a_to_b_len) },
1162            0
1163        );
1164        assert_eq!(
1165            unsafe { agent_doc_replica_apply_update(a, b_to_a, b_to_a_len) },
1166            0
1167        );
1168
1169        let ta = unsafe { agent_doc_replica_text(a) };
1170        let tb = unsafe { agent_doc_replica_text(b) };
1171        let sa = unsafe { CStr::from_ptr(ta) }.to_str().unwrap().to_string();
1172        let sb = unsafe { CStr::from_ptr(tb) }.to_str().unwrap().to_string();
1173        assert_eq!(sa, sb, "replicas converge after FFI state-vector exchange");
1174        assert!(sa.contains("alpha") && sa.contains("beta"));
1175
1176        // Re-applying a known update over the FFI is idempotent.
1177        assert_eq!(
1178            unsafe { agent_doc_replica_apply_update(b, a_to_b, a_to_b_len) },
1179            0
1180        );
1181        let tb2 = unsafe { agent_doc_replica_text(b) };
1182        let sb2 = unsafe { CStr::from_ptr(tb2) }.to_str().unwrap().to_string();
1183        assert_eq!(sb2, sb, "re-applying a known update via FFI is idempotent");
1184
1185        unsafe {
1186            agent_doc_free_state(b_sv, b_sv_len);
1187            agent_doc_free_state(a_sv, a_sv_len);
1188            agent_doc_free_state(a_to_b, a_to_b_len);
1189            agent_doc_free_state(b_to_a, b_to_a_len);
1190            agent_doc_free_string(ta);
1191            agent_doc_free_string(tb);
1192            agent_doc_free_string(tb2);
1193        }
1194
1195        assert_eq!(unsafe { agent_doc_replica_close(a) }, 0);
1196        assert_eq!(unsafe { agent_doc_replica_close(b) }, 0);
1197        // Closing an already-closed replica reports not-open.
1198        assert_eq!(unsafe { agent_doc_replica_close(a) }, -3);
1199    }
1200
1201    #[test]
1202    fn replica_ffi_reports_unopened_replica() {
1203        let id: u64 = 0xDEAD_BEEF;
1204        let ins = std::ffi::CString::new("x").unwrap();
1205        assert_eq!(
1206            unsafe { agent_doc_replica_apply_local(id, 0, 0, ins.as_ptr()) },
1207            -3,
1208            "apply_local on an unopened replica reports not-open"
1209        );
1210        // A null update is rejected before the lookup.
1211        assert_eq!(
1212            unsafe { agent_doc_replica_apply_update(id, std::ptr::null(), 0) },
1213            -2
1214        );
1215        assert!(unsafe { agent_doc_replica_text(id) }.is_null());
1216        let mut len: usize = 123;
1217        assert!(unsafe { agent_doc_replica_state_vector(id, &mut len) }.is_null());
1218        assert_eq!(len, 0, "null byte result sets out_len to 0");
1219        assert_eq!(unsafe { agent_doc_replica_close(id) }, -3);
1220    }
1221
1222    #[test]
1223    fn replica_ffi_persist_then_recover_round_trips_disk_projection() {
1224        // Disk demotion (#crdtauth4): each FFI node persists its OWN replica to a
1225        // local recovery projection and recovers it on restart.
1226        let dir = tempfile::tempdir().unwrap();
1227        let path = dir.path().join("node.yrs");
1228        let path_c = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
1229
1230        let src: u64 = 0x5151;
1231        assert_eq!(
1232            unsafe { agent_doc_replica_open(src, std::ptr::null(), 0) },
1233            0
1234        );
1235        let ins = std::ffi::CString::new("crash-safe").unwrap();
1236        assert_eq!(
1237            unsafe { agent_doc_replica_apply_local(src, 0, 0, ins.as_ptr()) },
1238            0
1239        );
1240        // Persist the node's own replica.
1241        assert_eq!(
1242            unsafe { agent_doc_replica_persist(src, path_c.as_ptr()) },
1243            0,
1244            "persist writes the recovery projection"
1245        );
1246        assert!(path.exists());
1247        assert_eq!(unsafe { agent_doc_replica_close(src) }, 0);
1248
1249        // Recover into a fresh replica id (a restarted node) from disk.
1250        let recovered: u64 = 0x5252;
1251        assert_eq!(
1252            unsafe { agent_doc_replica_recover(recovered, path_c.as_ptr()) },
1253            0,
1254            "recover rebuilds the in-memory replica from disk"
1255        );
1256        let t = unsafe { agent_doc_replica_text(recovered) };
1257        assert!(!t.is_null());
1258        let text = unsafe { CStr::from_ptr(t) }.to_str().unwrap().to_string();
1259        unsafe { agent_doc_free_string(t) };
1260        assert_eq!(text, "crash-safe");
1261        assert_eq!(unsafe { agent_doc_replica_close(recovered) }, 0);
1262    }
1263
1264    #[test]
1265    fn replica_ffi_persist_recover_reject_bad_args() {
1266        let id: u64 = 0x6363;
1267        // persist on an unopened replica → not-open.
1268        let p = std::ffi::CString::new("/tmp/agent-doc-nope.yrs").unwrap();
1269        assert_eq!(unsafe { agent_doc_replica_persist(id, p.as_ptr()) }, -3);
1270        // null path → bad arg.
1271        assert_eq!(
1272            unsafe { agent_doc_replica_persist(id, std::ptr::null()) },
1273            -2
1274        );
1275        // recover from a missing file → bad arg / IO.
1276        let missing = std::ffi::CString::new("/nonexistent/dir/agent-doc-missing.yrs").unwrap();
1277        assert_eq!(
1278            unsafe { agent_doc_replica_recover(id, missing.as_ptr()) },
1279            -2
1280        );
1281    }
1282}