Skip to main content

agent_doc/
ffi.rs

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