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/// Text-based CRDT 3-way merge. Simpler interface than [`agent_doc_crdt_merge`].
434///
435/// All three parameters are plain UTF-8 text (not CRDT state bytes).
436/// Returns the conflict-free merged text. On any error, falls back to `ours`.
437///
438/// Intended for editor plugin use (replaces `git merge-file` in `PromptPoller`).
439///
440/// # Safety
441///
442/// `base`, `ours`, and `theirs` must be valid, NUL-terminated UTF-8.
443/// The caller must free the returned pointer with [`agent_doc_free_string`].
444#[unsafe(no_mangle)]
445pub unsafe extern "C" fn agent_doc_merge_crdt(
446    base: *const c_char,
447    ours: *const c_char,
448    theirs: *const c_char,
449) -> *mut c_char {
450    let base_str = match unsafe { CStr::from_ptr(base) }.to_str() {
451        Ok(s) => s,
452        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
453    };
454    let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
455        Ok(s) => s,
456        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
457    };
458    let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
459        Ok(s) => s,
460        Err(_) => return CString::new("").unwrap_or_default().into_raw(),
461    };
462
463    // Encode base text as CRDT state for proper 3-way merge
464    let base_doc = crdt::CrdtDoc::from_text(base_str);
465    let base_state = base_doc.encode_state();
466
467    let merged = crdt::merge(Some(&base_state), ours_str, theirs_str)
468        .unwrap_or_else(|_| ours_str.to_string());
469    CString::new(merged).unwrap_or_default().into_raw()
470}
471
472/// Merge YAML key/value pairs into a document's frontmatter.
473///
474/// `yaml_fields` is a YAML string of fields to merge (additive — never removes keys).
475/// Returns the updated document content via [`FfiPatchResult`].
476///
477/// # Safety
478///
479/// All string pointers must be valid, NUL-terminated UTF-8.
480#[unsafe(no_mangle)]
481pub unsafe extern "C" fn agent_doc_merge_frontmatter(
482    doc: *const c_char,
483    yaml_fields: *const c_char,
484) -> FfiPatchResult {
485    let make_err = |msg: &str| FfiPatchResult {
486        text: ptr::null_mut(),
487        error: CString::new(msg).unwrap_or_default().into_raw(),
488    };
489
490    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
491        Ok(s) => s,
492        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
493    };
494    let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
495        Ok(s) => s,
496        Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
497    };
498
499    match frontmatter::merge_fields(doc_str, fields_str) {
500        Ok(result) => FfiPatchResult {
501            text: CString::new(result).unwrap_or_default().into_raw(),
502            error: ptr::null_mut(),
503        },
504        Err(e) => make_err(&format!("{e}")),
505    }
506}
507
508/// Reposition boundary marker to end of exchange component.
509///
510/// Removes all existing boundary markers from the document and inserts a single
511/// fresh one at the end of the exchange component. Returns the document unchanged
512/// if no exchange component exists.
513///
514/// # Safety
515///
516/// `doc` must be a valid, NUL-terminated UTF-8 string.
517#[unsafe(no_mangle)]
518pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
519    doc: *const c_char,
520) -> FfiPatchResult {
521    let make_err = |msg: &str| FfiPatchResult {
522        text: ptr::null_mut(),
523        error: CString::new(msg).unwrap_or_default().into_raw(),
524    };
525
526    let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
527        Ok(s) => s,
528        Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
529    };
530
531    let result = template::reposition_boundary_to_end(doc_str);
532    FfiPatchResult {
533        text: CString::new(result).unwrap_or_default().into_raw(),
534        error: ptr::null_mut(),
535    }
536}
537
538/// Record a document change event for debounce tracking.
539///
540/// Plugins call this on every document modification (typing, paste, undo).
541/// Used by [`agent_doc_await_idle`] to determine if the user is still editing.
542///
543/// # Safety
544///
545/// `file_path` must be a valid, NUL-terminated UTF-8 string.
546#[unsafe(no_mangle)]
547pub unsafe extern "C" fn agent_doc_document_changed(file_path: *const c_char) {
548    if let Ok(path) = unsafe { CStr::from_ptr(file_path) }.to_str() {
549        crate::debounce::document_changed(path);
550    }
551}
552
553/// Check if the document has been tracked (at least one `document_changed` call recorded).
554///
555/// Returns `true` if the file has been tracked, `false` if never seen.
556/// Plugins use this to decide whether `await_idle` results are trustworthy:
557/// an untracked file returns idle=true from `await_idle`, but that's because
558/// no changes were recorded, not because the user isn't typing.
559///
560/// # Safety
561///
562/// `file_path` must be a valid, NUL-terminated UTF-8 string.
563#[unsafe(no_mangle)]
564pub unsafe extern "C" fn agent_doc_is_tracked(file_path: *const c_char) -> bool {
565    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
566        Ok(s) => s,
567        Err(_) => return false,
568    };
569    crate::debounce::is_tracked(path)
570}
571
572/// Return the number of files tracked in the debounce state.
573/// Used by IDE plugins for state diagnostics.
574#[unsafe(no_mangle)]
575pub extern "C" fn agent_doc_tracked_count() -> u32 {
576    crate::debounce::tracked_count() as u32
577}
578
579/// Non-blocking idle check — returns `true` if no `document_changed` event
580/// within `debounce_ms`.
581///
582/// Used by IDE plugins to defer IPC operations (boundary repositioning, patch
583/// application) while the user is actively typing.  Unlike `await_idle`, this
584/// returns immediately.
585///
586/// For untracked files (no `document_changed` ever called), returns `true`.
587///
588/// # Safety
589///
590/// `file_path` must be a valid, NUL-terminated UTF-8 string.
591#[unsafe(no_mangle)]
592pub unsafe extern "C" fn agent_doc_is_idle(
593    file_path: *const c_char,
594    debounce_ms: i64,
595) -> bool {
596    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
597        Ok(s) => s,
598        Err(_) => return true, // Invalid path — don't block callers
599    };
600    let in_process_idle = crate::debounce::is_idle(path, debounce_ms as u64);
601    if !in_process_idle {
602        return false;
603    }
604    // In-process says idle. If the file was never tracked in this process (e.g., after
605    // plugin restart), also check the file-based indicator so cross-process typing state
606    // from another plugin instance isn't silently lost.
607    if !crate::debounce::is_tracked(path) {
608        return !crate::debounce::is_typing_via_file(path, debounce_ms as u64);
609    }
610    true
611}
612
613/// Block until the document has been idle for `debounce_ms`, or `timeout_ms` expires.
614///
615/// Returns `true` if idle was reached (safe to run), `false` if timed out.
616/// If no changes have been recorded for this file, returns `true` immediately.
617///
618/// # Safety
619///
620/// `file_path` must be a valid, NUL-terminated UTF-8 string.
621#[unsafe(no_mangle)]
622pub unsafe extern "C" fn agent_doc_await_idle(
623    file_path: *const c_char,
624    debounce_ms: i64,
625    timeout_ms: i64,
626) -> bool {
627    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
628        Ok(s) => s,
629        Err(_) => return true, // Invalid path — don't block
630    };
631    // When the file is untracked in-process (e.g., after plugin restart), bridge to
632    // file-based indicator so cross-process typing state isn't silently ignored.
633    if !crate::debounce::is_tracked(path) {
634        return crate::debounce::await_idle_via_file(
635            path,
636            debounce_ms as u64,
637            timeout_ms as u64,
638        );
639    }
640    crate::debounce::await_idle(path, debounce_ms as u64, timeout_ms as u64)
641}
642
643/// Check if a plugin in another process has typed recently (cross-process).
644///
645/// Reads the file-based typing indicator written by `agent_doc_document_changed`.
646/// Returns `true` if the indicator exists and was updated within `debounce_ms`.
647/// Returns `false` if no indicator file exists (plugin not active or no edits).
648///
649/// This is the cross-process complement to `agent_doc_is_idle`, which only
650/// works within the same process. Use this from CLI tools that run separately
651/// from the editor plugin.
652///
653/// # Safety
654///
655/// `file_path` must be a valid, NUL-terminated UTF-8 string.
656#[unsafe(no_mangle)]
657pub unsafe extern "C" fn agent_doc_is_typing_via_file(
658    file_path: *const c_char,
659    debounce_ms: i64,
660) -> bool {
661    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
662        Ok(s) => s,
663        Err(_) => return false,
664    };
665    crate::debounce::is_typing_via_file(path, debounce_ms as u64)
666}
667
668/// Block until the file-based typing indicator shows idle, or timeout expires.
669///
670/// Used by CLI tools to wait for an editor plugin (separate process) to stop
671/// typing before running an agent. Returns `true` if idle was reached, `false`
672/// if `timeout_ms` expired first.
673///
674/// # Safety
675///
676/// `file_path` must be a valid, NUL-terminated UTF-8 string.
677#[unsafe(no_mangle)]
678pub unsafe extern "C" fn agent_doc_await_idle_via_file(
679    file_path: *const c_char,
680    debounce_ms: i64,
681    timeout_ms: i64,
682) -> bool {
683    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
684        Ok(s) => s,
685        Err(_) => return true, // Invalid path — don't block
686    };
687    crate::debounce::await_idle_via_file(path, debounce_ms as u64, timeout_ms as u64)
688}
689
690/// Set the response status for a file (Option B: in-process).
691///
692/// Status values: "generating", "writing", "routing", "idle"
693/// Also writes a file-based signal (Option A: cross-process).
694///
695/// # Safety
696///
697/// `file_path` and `status` must be valid, NUL-terminated UTF-8 strings.
698#[unsafe(no_mangle)]
699pub unsafe extern "C" fn agent_doc_set_status(
700    file_path: *const c_char,
701    status: *const c_char,
702) {
703    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
704        Ok(s) => s,
705        Err(_) => return,
706    };
707    let st = match unsafe { CStr::from_ptr(status) }.to_str() {
708        Ok(s) => s,
709        Err(_) => return,
710    };
711    crate::debounce::set_status(path, st);
712}
713
714/// Get the response status for a file (Option B: in-process).
715///
716/// Returns a NUL-terminated string: "generating", "writing", "routing", or "idle".
717/// Caller must free with `agent_doc_free_string`.
718///
719/// # Safety
720///
721/// `file_path` must be a valid, NUL-terminated UTF-8 string.
722#[unsafe(no_mangle)]
723pub unsafe extern "C" fn agent_doc_get_status(file_path: *const c_char) -> *mut c_char {
724    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
725        Ok(s) => s,
726        Err(_) => return CString::new("idle").unwrap().into_raw(),
727    };
728    let status = crate::debounce::get_status(path);
729    CString::new(status).unwrap_or_else(|_| CString::new("idle").unwrap()).into_raw()
730}
731
732/// Check if any operation is in progress for a file (Option B: in-process).
733///
734/// Returns `true` if status is NOT "idle". Plugins should skip route
735/// operations when this returns `true` to prevent cascading.
736///
737/// # Safety
738///
739/// `file_path` must be a valid, NUL-terminated UTF-8 string.
740#[unsafe(no_mangle)]
741pub unsafe extern "C" fn agent_doc_is_busy(file_path: *const c_char) -> bool {
742    let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
743        Ok(s) => s,
744        Err(_) => return false,
745    };
746    crate::debounce::is_busy(path)
747}
748
749/// Try to acquire the sync lock. Returns `true` if acquired, `false` if already held.
750///
751/// Editors call this before triggering `agent-doc sync`. If it returns `false`,
752/// skip the sync (another sync is in progress). Call `agent_doc_sync_unlock()`
753/// when the sync completes.
754///
755/// This is a cross-editor shared lock — prevents concurrent syncs from IntelliJ
756/// and VS Code plugins simultaneously.
757#[unsafe(no_mangle)]
758pub extern "C" fn agent_doc_sync_try_lock() -> bool {
759    SYNC_LOCKED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_ok()
760}
761
762/// Release the sync lock acquired by `agent_doc_sync_try_lock()`.
763#[unsafe(no_mangle)]
764pub extern "C" fn agent_doc_sync_unlock() {
765    SYNC_LOCKED.store(false, Ordering::SeqCst);
766}
767
768/// Bump the sync debounce generation. Returns the new generation number.
769///
770/// Editors call this when a layout change is detected. After a delay (e.g., 500ms),
771/// they call `agent_doc_sync_check_generation(gen)` — if it returns `true`, the
772/// generation is still current and the sync should proceed. If `false`, a newer
773/// event superseded this one.
774#[unsafe(no_mangle)]
775pub extern "C" fn agent_doc_sync_bump_generation() -> u64 {
776    SYNC_GENERATION.fetch_add(1, Ordering::SeqCst) + 1
777}
778
779/// Check if a generation is still current. Returns `true` if `generation` matches the
780/// latest generation (no newer events have been scheduled).
781#[unsafe(no_mangle)]
782pub extern "C" fn agent_doc_sync_check_generation(generation: u64) -> bool {
783    SYNC_GENERATION.load(Ordering::SeqCst) == generation
784}
785
786/// Start the IPC socket listener on a background thread.
787///
788/// The plugin calls this on project open to start listening for socket IPC
789/// messages from the CLI. The callback receives each JSON message as a
790/// read-only NUL-terminated string (do NOT free it) and returns `true` if
791/// the message was handled successfully, `false` on error. The listener
792/// generates the ack response internally based on the return value.
793///
794/// Returns `true` if the listener was started, `false` on error.
795///
796/// # Safety
797///
798/// - `project_root` must be a valid, NUL-terminated UTF-8 string.
799/// - `callback` must be a valid function pointer that remains valid for the
800///   lifetime of the listener thread. The message pointer is borrowed — the
801///   callback must NOT free it or hold a reference after returning.
802#[unsafe(no_mangle)]
803pub unsafe extern "C" fn agent_doc_start_ipc_listener(
804    project_root: *const c_char,
805    callback: extern "C" fn(message: *const c_char) -> bool,
806) -> bool {
807    let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
808        Ok(s) => s.to_string(),
809        Err(_) => return false,
810    };
811    let root_path = std::path::PathBuf::from(&root_str);
812
813    std::thread::spawn(move || {
814        let result = crate::ipc_socket::start_listener(&root_path, move |msg| {
815            // Lend the message to the callback (no ownership transfer)
816            let c_msg = match CString::new(msg) {
817                Ok(c) => c,
818                Err(_) => return Some(r#"{"type":"ack","status":"error"}"#.to_string()),
819            };
820            let success = callback(c_msg.as_ptr());
821            if success {
822                Some(r#"{"type":"ack","status":"ok"}"#.to_string())
823            } else {
824                Some(r#"{"type":"ack","status":"error"}"#.to_string())
825            }
826        });
827        if let Err(e) = result {
828            eprintln!("[ffi] IPC listener error: {}", e);
829        }
830    });
831
832    true
833}
834
835/// Stop the IPC socket listener by removing the socket file.
836///
837/// The listener thread will exit on its next accept() call when the socket
838/// is removed. Call this on project close / plugin disposal.
839///
840/// # Safety
841///
842/// `project_root` must be a valid, NUL-terminated UTF-8 string.
843#[unsafe(no_mangle)]
844pub unsafe extern "C" fn agent_doc_stop_ipc_listener(
845    project_root: *const c_char,
846) {
847    let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
848        Ok(s) => s,
849        Err(_) => return,
850    };
851    let sock = crate::ipc_socket::socket_path(std::path::Path::new(root_str));
852    if let Err(e) = std::fs::remove_file(&sock)
853        && e.kind() != std::io::ErrorKind::NotFound
854    {
855        eprintln!("[ffi] failed to remove socket {:?}: {}", sock, e);
856    }
857}
858
859/// Write the final applied document content to the ack-content sidecar file.
860///
861/// The binary reads this after receiving IPC ACK to use as snapshot content,
862/// eliminating the 200ms sleep + re-read. If the plugin doesn't call this,
863/// the binary falls back to the 200ms sleep heuristic.
864///
865/// Sidecar path: `<project_root>/.agent-doc/ack-content/<patch_id>.md`
866///
867/// # Safety
868///
869/// All three pointers must be valid, NUL-terminated UTF-8 strings.
870#[unsafe(no_mangle)]
871pub unsafe extern "C" fn agent_doc_write_ack_content(
872    project_root: *const c_char,
873    patch_id: *const c_char,
874    content: *const c_char,
875) -> bool {
876    let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
877        Ok(s) => s,
878        Err(_) => return false,
879    };
880    let patch_id_str = match unsafe { CStr::from_ptr(patch_id) }.to_str() {
881        Ok(s) => s,
882        Err(_) => return false,
883    };
884    let content_str = match unsafe { CStr::from_ptr(content) }.to_str() {
885        Ok(s) => s,
886        Err(_) => return false,
887    };
888
889    let ack_dir = std::path::Path::new(root_str).join(".agent-doc/ack-content");
890    if let Err(e) = std::fs::create_dir_all(&ack_dir) {
891        eprintln!("[ffi] agent_doc_write_ack_content: mkdir error: {e}");
892        return false;
893    }
894
895    let sidecar = ack_dir.join(format!("{patch_id_str}.md"));
896    match std::fs::write(&sidecar, content_str) {
897        Ok(_) => {
898            eprintln!("[ffi] ack_content written: {} bytes for patch_id {}",
899                content_str.len(), &patch_id_str[..patch_id_str.len().min(8)]);
900            true
901        }
902        Err(e) => {
903            eprintln!("[ffi] agent_doc_write_ack_content: write error: {e}");
904            false
905        }
906    }
907}
908
909/// Check if --force-disk claimed this patch by writing a sentinel file.
910/// Returns true if the sentinel `.agent-doc/claimed-patches/<patch_id>` exists.
911/// Deletes the sentinel on success (one-time use).
912///
913/// # Safety
914///
915/// Both pointers must be valid, NUL-terminated UTF-8 strings.
916#[unsafe(no_mangle)]
917pub unsafe extern "C" fn agent_doc_is_claimed_by_force_disk(
918    project_root: *const c_char,
919    patch_id: *const c_char,
920) -> bool {
921    let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
922        Ok(s) => s,
923        Err(_) => return false,
924    };
925    let patch_id_str = match unsafe { CStr::from_ptr(patch_id) }.to_str() {
926        Ok(s) => s,
927        Err(_) => return false,
928    };
929
930    let sentinel = std::path::Path::new(root_str)
931        .join(".agent-doc/claimed-patches")
932        .join(patch_id_str);
933
934    if sentinel.exists() {
935        eprintln!("[ffi] patch_id {} claimed by force-disk — skipping apply",
936            &patch_id_str[..patch_id_str.len().min(8)]);
937        let _ = std::fs::remove_file(&sentinel);
938        true
939    } else {
940        false
941    }
942}
943
944/// Get the agent-doc library version.
945///
946/// Returns a NUL-terminated string like "0.26.1".
947/// Caller must free with `agent_doc_free_string`.
948#[unsafe(no_mangle)]
949pub extern "C" fn agent_doc_version() -> *mut c_char {
950    CString::new(env!("CARGO_PKG_VERSION")).unwrap().into_raw()
951}
952
953/// Free a string returned by any `agent_doc_*` function.
954///
955/// # Safety
956///
957/// `ptr` must have been returned by an `agent_doc_*` function, or be null.
958#[unsafe(no_mangle)]
959pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
960    if !ptr.is_null() {
961        drop(unsafe { CString::from_raw(ptr) });
962    }
963}
964
965/// Free a state buffer returned by [`agent_doc_crdt_merge`].
966///
967/// # Safety
968///
969/// `ptr` and `len` must match a state buffer returned by `agent_doc_crdt_merge`, or `ptr` must be null.
970#[unsafe(no_mangle)]
971pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
972    if !ptr.is_null() {
973        drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
974    }
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    #[test]
982    fn parse_components_roundtrip() {
983        let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
984        let c_doc = CString::new(doc).unwrap();
985        let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
986        assert_eq!(result.count, 1);
987        assert!(!result.json.is_null());
988        let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
989        let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
990        assert_eq!(parsed[0]["name"], "status");
991        assert_eq!(parsed[0]["content"], "hello\n");
992        unsafe { agent_doc_free_string(result.json) };
993    }
994
995    #[test]
996    fn apply_patch_replace() {
997        let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
998        let c_doc = CString::new(doc).unwrap();
999        let c_name = CString::new("output").unwrap();
1000        let c_content = CString::new("new content\n").unwrap();
1001        let c_mode = CString::new("replace").unwrap();
1002        let result = unsafe {
1003            agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
1004        };
1005        assert!(result.error.is_null());
1006        assert!(!result.text.is_null());
1007        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
1008        assert!(text.contains("new content"));
1009        assert!(!text.contains("old"));
1010        unsafe { agent_doc_free_string(result.text) };
1011    }
1012
1013    #[test]
1014    fn merge_frontmatter_adds_field() {
1015        let doc = "---\nagent_doc_session: abc\n---\nBody\n";
1016        let fields = "model: opus";
1017        let c_doc = CString::new(doc).unwrap();
1018        let c_fields = CString::new(fields).unwrap();
1019        let result = unsafe {
1020            agent_doc_merge_frontmatter(c_doc.as_ptr(), c_fields.as_ptr())
1021        };
1022        assert!(result.error.is_null());
1023        assert!(!result.text.is_null());
1024        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
1025        assert!(text.contains("model: opus"));
1026        assert!(text.contains("agent_doc_session: abc"));
1027        assert!(text.contains("Body"));
1028        unsafe { agent_doc_free_string(result.text) };
1029    }
1030
1031    #[test]
1032    fn reposition_boundary_removes_stale() {
1033        let doc = "<!-- agent:exchange patch=append -->\ntext\n<!-- agent:boundary:aaaa1111 -->\nmore\n<!-- agent:boundary:bbbb2222 -->\n<!-- /agent:exchange -->\n";
1034        let c_doc = CString::new(doc).unwrap();
1035        let result = unsafe { agent_doc_reposition_boundary_to_end(c_doc.as_ptr()) };
1036        assert!(result.error.is_null());
1037        assert!(!result.text.is_null());
1038        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
1039        // Should have exactly one boundary marker at the end
1040        let boundary_count = text.matches("<!-- agent:boundary:").count();
1041        assert_eq!(boundary_count, 1, "should have exactly 1 boundary, got {}", boundary_count);
1042        // The boundary should be just before the close tag
1043        assert!(text.contains("more\n<!-- agent:boundary:"));
1044        assert!(text.contains(" -->\n<!-- /agent:exchange -->"));
1045        unsafe { agent_doc_free_string(result.text) };
1046    }
1047
1048    #[test]
1049    fn is_idle_untracked_returns_true() {
1050        let path = CString::new("/tmp/ffi-test-untracked-file.md").unwrap();
1051        let result = unsafe { agent_doc_is_idle(path.as_ptr(), 500) };
1052        assert!(result, "untracked file should report idle");
1053    }
1054
1055    #[test]
1056    fn is_idle_after_change_returns_false() {
1057        let path = CString::new("/tmp/ffi-test-just-changed.md").unwrap();
1058        unsafe { agent_doc_document_changed(path.as_ptr()) };
1059        let result = unsafe { agent_doc_is_idle(path.as_ptr(), 2000) };
1060        assert!(!result, "file changed <2s ago should not be idle with 2000ms window");
1061    }
1062
1063    #[test]
1064    fn crdt_merge_no_base() {
1065        let c_ours = CString::new("hello world").unwrap();
1066        let c_theirs = CString::new("hello world").unwrap();
1067        let result = unsafe {
1068            agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
1069        };
1070        assert!(result.error.is_null());
1071        assert!(!result.text.is_null());
1072        let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
1073        assert_eq!(text, "hello world");
1074        unsafe {
1075            agent_doc_free_string(result.text);
1076            agent_doc_free_state(result.state, result.state_len);
1077        };
1078    }
1079}
1080
1081#[cfg(test)]
1082mod ack_content_tests {
1083    use super::*;
1084    use std::ffi::CString;
1085    use tempfile::TempDir;
1086
1087    #[test]
1088    fn test_write_ack_content_creates_file() {
1089        let tmp = TempDir::new().unwrap();
1090        let project_root = CString::new(tmp.path().to_str().unwrap()).unwrap();
1091        let patch_id = CString::new("test-patch-id-123").unwrap();
1092        let content = CString::new("hello world").unwrap();
1093
1094        let result = unsafe {
1095            agent_doc_write_ack_content(
1096                project_root.as_ptr(),
1097                patch_id.as_ptr(),
1098                content.as_ptr(),
1099            )
1100        };
1101        assert!(result, "should return true on success");
1102
1103        let sidecar = tmp.path().join(".agent-doc/ack-content/test-patch-id-123.md");
1104        assert!(sidecar.exists(), "sidecar file should exist at {:?}", sidecar);
1105        assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), "hello world");
1106    }
1107
1108    #[test]
1109    fn test_is_claimed_by_force_disk_present() {
1110        let tmp = TempDir::new().unwrap();
1111        let claimed_dir = tmp.path().join(".agent-doc/claimed-patches");
1112        std::fs::create_dir_all(&claimed_dir).unwrap();
1113        std::fs::write(claimed_dir.join("test-patch-456"), "").unwrap();
1114
1115        let project_root = CString::new(tmp.path().to_str().unwrap()).unwrap();
1116        let patch_id = CString::new("test-patch-456").unwrap();
1117
1118        let claimed = unsafe { agent_doc_is_claimed_by_force_disk(project_root.as_ptr(), patch_id.as_ptr()) };
1119        assert!(claimed, "should return true when sentinel exists");
1120        assert!(!claimed_dir.join("test-patch-456").exists(), "sentinel should be deleted after check");
1121    }
1122
1123    #[test]
1124    fn test_is_claimed_by_force_disk_absent() {
1125        let tmp = TempDir::new().unwrap();
1126        let project_root = CString::new(tmp.path().to_str().unwrap()).unwrap();
1127        let patch_id = CString::new("nonexistent-patch").unwrap();
1128
1129        let claimed = unsafe { agent_doc_is_claimed_by_force_disk(project_root.as_ptr(), patch_id.as_ptr()) };
1130        assert!(!claimed, "should return false when sentinel absent");
1131    }
1132}