1use 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
65static SYNC_LOCKED: AtomicBool = AtomicBool::new(false);
67
68static SYNC_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
70
71#[repr(C)]
73pub struct FfiComponentList {
74 pub json: *mut c_char,
76 pub count: usize,
78}
79
80#[repr(C)]
82pub struct FfiPatchResult {
83 pub text: *mut c_char,
85 pub error: *mut c_char,
87}
88
89#[repr(C)]
91pub struct FfiMergeResult {
92 pub text: *mut c_char,
94 pub state: *mut u8,
96 pub state_len: usize,
98 pub error: *mut c_char,
100}
101
102#[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 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#[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 let patch = template::PatchBlock::new(name, patch_content);
198
199 let mut overrides = std::collections::HashMap::new();
201 overrides.insert(name.to_string(), mode_str.to_string());
202
203 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#[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 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 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#[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 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 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#[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 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#[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 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#[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#[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#[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#[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#[unsafe(no_mangle)]
575pub extern "C" fn agent_doc_tracked_count() -> u32 {
576 crate::debounce::tracked_count() as u32
577}
578
579#[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, };
600 let in_process_idle = crate::debounce::is_idle(path, debounce_ms as u64);
601 if !in_process_idle {
602 return false;
603 }
604 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#[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, };
631 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#[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#[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, };
687 crate::debounce::await_idle_via_file(path, debounce_ms as u64, timeout_ms as u64)
688}
689
690#[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#[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#[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#[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#[unsafe(no_mangle)]
764pub extern "C" fn agent_doc_sync_unlock() {
765 SYNC_LOCKED.store(false, Ordering::SeqCst);
766}
767
768#[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#[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#[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 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#[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#[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#[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#[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#[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#[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 let boundary_count = text.matches("<!-- agent:boundary:").count();
1041 assert_eq!(boundary_count, 1, "should have exactly 1 boundary, got {}", boundary_count);
1042 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}