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