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)]
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#[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#[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#[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#[unsafe(no_mangle)]
536pub extern "C" fn agent_doc_tracked_count() -> u32 {
537 crate::debounce::tracked_count() as u32
538}
539
540#[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, };
561 let in_process_idle = crate::debounce::is_idle(path, debounce_ms as u64);
562 if !in_process_idle {
563 return false;
564 }
565 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#[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, };
592 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#[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#[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, };
648 crate::debounce::await_idle_via_file(path, debounce_ms as u64, timeout_ms as u64)
649}
650
651#[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#[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#[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#[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#[unsafe(no_mangle)]
725pub extern "C" fn agent_doc_sync_unlock() {
726 SYNC_LOCKED.store(false, Ordering::SeqCst);
727}
728
729#[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#[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#[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 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#[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#[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#[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#[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 let boundary_count = text.matches("<!-- agent:boundary:").count();
917 assert_eq!(boundary_count, 1, "should have exactly 1 boundary, got {}", boundary_count);
918 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}