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