1use std::collections::HashMap;
19use std::ffi::{CStr, CString};
20use std::os::raw::c_char;
21use std::ptr;
22use std::sync::{Mutex, OnceLock};
23
24use crate::crdt_sync::ReplicaState;
25use crate::{component, crdt, frontmatter, syntax, template};
26
27#[unsafe(no_mangle)]
33pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
34 if !ptr.is_null() {
35 drop(unsafe { CString::from_raw(ptr) });
36 }
37}
38
39#[unsafe(no_mangle)]
47pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
48 if !ptr.is_null() {
49 drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
50 }
51}
52
53#[repr(C)]
55pub struct FfiComponentList {
56 pub json: *mut c_char,
58 pub count: usize,
60}
61
62#[repr(C)]
66pub struct FfiPatchResult {
67 pub text: *mut c_char,
69 pub error: *mut c_char,
71}
72
73pub fn ffi_patch_ok(text: String) -> FfiPatchResult {
75 FfiPatchResult {
76 text: CString::new(text).unwrap_or_default().into_raw(),
77 error: ptr::null_mut(),
78 }
79}
80
81pub fn ffi_patch_err(msg: &str) -> FfiPatchResult {
83 FfiPatchResult {
84 text: ptr::null_mut(),
85 error: CString::new(msg).unwrap_or_default().into_raw(),
86 }
87}
88
89pub fn ffi_patch_from_result(result: anyhow::Result<String>) -> FfiPatchResult {
91 match result {
92 Ok(text) => ffi_patch_ok(text),
93 Err(e) => ffi_patch_err(&format!("{e:#}")),
94 }
95}
96
97pub fn normalize_editor_visible_result(text: String) -> anyhow::Result<String> {
99 template::normalize_editor_visible_template_structure(&text)
100}
101
102fn utf8_offsets_to_utf16_offsets(doc: &str, offsets: &[usize]) -> HashMap<usize, usize> {
108 let mut targets = offsets.to_vec();
109 targets.sort_unstable();
110 targets.dedup();
111
112 let mut mapped = HashMap::with_capacity(targets.len());
113 let mut target_idx = 0usize;
114 let mut utf8_offset = 0usize;
115 let mut utf16_offset = 0usize;
116
117 while target_idx < targets.len() && targets[target_idx] == 0 {
118 mapped.insert(0, 0);
119 target_idx += 1;
120 }
121
122 for ch in doc.chars() {
123 let next_utf8 = utf8_offset + ch.len_utf8();
124 while target_idx < targets.len() && targets[target_idx] < next_utf8 {
125 mapped.insert(targets[target_idx], utf16_offset);
126 target_idx += 1;
127 }
128
129 utf8_offset = next_utf8;
130 utf16_offset += ch.len_utf16();
131
132 while target_idx < targets.len() && targets[target_idx] == utf8_offset {
133 mapped.insert(targets[target_idx], utf16_offset);
134 target_idx += 1;
135 }
136 }
137
138 while target_idx < targets.len() {
139 mapped.insert(targets[target_idx], utf16_offset);
140 target_idx += 1;
141 }
142
143 mapped
144}
145
146#[unsafe(no_mangle)]
156pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
157 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
158 Ok(s) => s,
159 Err(_) => {
160 return FfiComponentList {
161 json: ptr::null_mut(),
162 count: 0,
163 };
164 }
165 };
166
167 let components = match component::parse(doc_str) {
168 Ok(c) => c,
169 Err(_) => {
170 return FfiComponentList {
171 json: ptr::null_mut(),
172 count: 0,
173 };
174 }
175 };
176
177 let count = components.len();
178
179 let json_items: Vec<serde_json::Value> = components
181 .iter()
182 .map(|c| {
183 serde_json::json!({
184 "name": c.name,
185 "attrs": c.attrs,
186 "open_start": c.open_start,
187 "open_end": c.open_end,
188 "close_start": c.close_start,
189 "close_end": c.close_end,
190 "content": c.content(doc_str),
191 })
192 })
193 .collect();
194
195 let json_str = serde_json::to_string(&json_items).unwrap_or_default();
196 let c_json = CString::new(json_str).unwrap_or_default();
197
198 FfiComponentList {
199 json: c_json.into_raw(),
200 count,
201 }
202}
203
204#[unsafe(no_mangle)]
214pub unsafe extern "C" fn agent_doc_visual_tokens_json(doc: *const c_char) -> *mut c_char {
215 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
216 Ok(s) => s,
217 Err(_) => return ptr::null_mut(),
218 };
219 let tokens = syntax::collect_visual_tokens(doc_str);
220 let offsets: Vec<usize> = tokens
221 .iter()
222 .flat_map(|token| [token.start, token.end])
223 .collect();
224 let utf16_offsets = utf8_offsets_to_utf16_offsets(doc_str, &offsets);
225 let editor_tokens: Vec<_> = tokens
226 .iter()
227 .map(|token| {
228 serde_json::json!({
229 "kind": token.kind,
230 "start": utf16_offsets[&token.start],
231 "end": utf16_offsets[&token.end],
232 })
233 })
234 .collect();
235 let json = match serde_json::to_string(&editor_tokens) {
236 Ok(json) => json,
237 Err(_) => return ptr::null_mut(),
238 };
239 match CString::new(json) {
240 Ok(value) => value.into_raw(),
241 Err(_) => ptr::null_mut(),
242 }
243}
244
245#[unsafe(no_mangle)]
254pub unsafe extern "C" fn agent_doc_merge_frontmatter(
255 doc: *const c_char,
256 yaml_fields: *const c_char,
257) -> FfiPatchResult {
258 let make_err = |msg: &str| FfiPatchResult {
259 text: ptr::null_mut(),
260 error: CString::new(msg).unwrap_or_default().into_raw(),
261 };
262
263 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
264 Ok(s) => s,
265 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
266 };
267 let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
268 Ok(s) => s,
269 Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
270 };
271
272 match frontmatter::merge_fields(doc_str, fields_str) {
273 Ok(result) => FfiPatchResult {
274 text: CString::new(result).unwrap_or_default().into_raw(),
275 error: ptr::null_mut(),
276 },
277 Err(e) => make_err(&format!("{e}")),
278 }
279}
280
281#[unsafe(no_mangle)]
299pub unsafe extern "C" fn agent_doc_converge_queue_auto(
300 doc: *const c_char,
301 want_auto: std::os::raw::c_int,
302) -> FfiPatchResult {
303 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
304 Ok(s) => s,
305 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
306 };
307 let converged = component::converge_queue_auto(doc_str, want_auto != 0)
308 .unwrap_or_else(|| doc_str.to_string());
309 FfiPatchResult {
310 text: CString::new(converged).unwrap_or_default().into_raw(),
311 error: ptr::null_mut(),
312 }
313}
314
315#[unsafe(no_mangle)]
325pub unsafe extern "C" fn agent_doc_normalize_template_structure(
326 doc: *const c_char,
327) -> FfiPatchResult {
328 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
329 Ok(s) => s,
330 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
331 };
332
333 ffi_patch_from_result(template::normalize_editor_visible_template_structure(
334 doc_str,
335 ))
336}
337
338#[repr(C)]
340pub struct FfiMergeResult {
341 pub text: *mut c_char,
343 pub state: *mut u8,
345 pub state_len: usize,
347 pub error: *mut c_char,
349}
350
351#[unsafe(no_mangle)]
362pub unsafe extern "C" fn agent_doc_crdt_merge(
363 base_state: *const u8,
364 base_state_len: usize,
365 ours: *const c_char,
366 theirs: *const c_char,
367) -> FfiMergeResult {
368 let make_err = |msg: &str| FfiMergeResult {
369 text: ptr::null_mut(),
370 state: ptr::null_mut(),
371 state_len: 0,
372 error: CString::new(msg).unwrap_or_default().into_raw(),
373 };
374
375 let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
376 Ok(s) => s,
377 Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
378 };
379 let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
380 Ok(s) => s,
381 Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
382 };
383
384 let base = if base_state.is_null() {
385 None
386 } else {
387 Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
388 };
389
390 match crdt::merge_by_component(base, ours_str, theirs_str) {
391 Ok(merged_text) => {
392 let doc = crdt::CrdtDoc::from_text(&merged_text);
394 let state_bytes = doc.encode_state();
395 let state_len = state_bytes.len();
396 let state_ptr = {
397 let mut boxed = state_bytes.into_boxed_slice();
398 let ptr = boxed.as_mut_ptr();
399 std::mem::forget(boxed);
400 ptr
401 };
402
403 FfiMergeResult {
404 text: CString::new(merged_text).unwrap_or_default().into_raw(),
405 state: state_ptr,
406 state_len,
407 error: ptr::null_mut(),
408 }
409 }
410 Err(e) => make_err(&format!("{e}")),
411 }
412}
413
414static REPLICA_REGISTRY: OnceLock<Mutex<HashMap<u64, ReplicaState>>> = OnceLock::new();
429
430fn replica_registry() -> &'static Mutex<HashMap<u64, ReplicaState>> {
431 REPLICA_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
432}
433
434fn leak_state(bytes: Vec<u8>, out_len: *mut usize) -> *mut u8 {
437 let len = bytes.len();
438 let mut boxed = bytes.into_boxed_slice();
439 let ptr = boxed.as_mut_ptr();
440 std::mem::forget(boxed);
441 if !out_len.is_null() {
442 unsafe { *out_len = len };
443 }
444 ptr
445}
446
447fn null_state(out_len: *mut usize) -> *mut u8 {
449 if !out_len.is_null() {
450 unsafe { *out_len = 0 };
451 }
452 ptr::null_mut()
453}
454
455#[unsafe(no_mangle)]
465pub unsafe extern "C" fn agent_doc_replica_open(
466 replica_id: u64,
467 init_state: *const u8,
468 init_len: usize,
469) -> i32 {
470 let replica = if init_state.is_null() || init_len == 0 {
471 ReplicaState::new(replica_id)
472 } else {
473 let bytes = unsafe { std::slice::from_raw_parts(init_state, init_len) };
474 match ReplicaState::from_encoded(replica_id, bytes) {
475 Ok(r) => r,
476 Err(_) => return -2,
477 }
478 };
479 match replica_registry().lock() {
480 Ok(mut reg) => {
481 reg.insert(replica_id, replica);
482 0
483 }
484 Err(_) => -1,
485 }
486}
487
488#[unsafe(no_mangle)]
495pub unsafe extern "C" fn agent_doc_replica_apply_local(
496 replica_id: u64,
497 offset: u32,
498 delete_len: u32,
499 insert: *const c_char,
500) -> i32 {
501 let insert_str = if insert.is_null() {
502 ""
503 } else {
504 match unsafe { CStr::from_ptr(insert) }.to_str() {
505 Ok(s) => s,
506 Err(_) => return -2,
507 }
508 };
509 match replica_registry().lock() {
510 Ok(reg) => match reg.get(&replica_id) {
511 Some(replica) => {
512 replica.apply_local_edit(offset, delete_len, insert_str);
513 0
514 }
515 None => -3,
516 },
517 Err(_) => -1,
518 }
519}
520
521#[unsafe(no_mangle)]
528pub unsafe extern "C" fn agent_doc_replica_text(replica_id: u64) -> *mut c_char {
529 match replica_registry().lock() {
530 Ok(reg) => match reg.get(&replica_id) {
531 Some(replica) => CString::new(replica.text()).unwrap_or_default().into_raw(),
532 None => ptr::null_mut(),
533 },
534 Err(_) => ptr::null_mut(),
535 }
536}
537
538#[unsafe(no_mangle)]
545pub unsafe extern "C" fn agent_doc_replica_state_vector(
546 replica_id: u64,
547 out_len: *mut usize,
548) -> *mut u8 {
549 match replica_registry().lock() {
550 Ok(reg) => match reg.get(&replica_id) {
551 Some(replica) => leak_state(replica.state_vector(), out_len),
552 None => null_state(out_len),
553 },
554 Err(_) => null_state(out_len),
555 }
556}
557
558#[unsafe(no_mangle)]
566pub unsafe extern "C" fn agent_doc_replica_diff(
567 replica_id: u64,
568 their_sv: *const u8,
569 their_sv_len: usize,
570 out_len: *mut usize,
571) -> *mut u8 {
572 if their_sv.is_null() {
573 return null_state(out_len);
574 }
575 let sv = unsafe { std::slice::from_raw_parts(their_sv, their_sv_len) };
576 match replica_registry().lock() {
577 Ok(reg) => match reg.get(&replica_id) {
578 Some(replica) => match replica.diff(sv) {
579 Ok(update) => leak_state(update, out_len),
580 Err(_) => null_state(out_len),
581 },
582 None => null_state(out_len),
583 },
584 Err(_) => null_state(out_len),
585 }
586}
587
588#[unsafe(no_mangle)]
595pub unsafe extern "C" fn agent_doc_replica_apply_update(
596 replica_id: u64,
597 update: *const u8,
598 update_len: usize,
599) -> i32 {
600 if update.is_null() {
601 return -2;
602 }
603 let bytes = unsafe { std::slice::from_raw_parts(update, update_len) };
604 match replica_registry().lock() {
605 Ok(reg) => match reg.get(&replica_id) {
606 Some(replica) => match replica.apply_update(bytes) {
607 Ok(()) => 0,
608 Err(_) => -2,
609 },
610 None => -3,
611 },
612 Err(_) => -1,
613 }
614}
615
616#[unsafe(no_mangle)]
623pub unsafe extern "C" fn agent_doc_replica_encode_state(
624 replica_id: u64,
625 out_len: *mut usize,
626) -> *mut u8 {
627 match replica_registry().lock() {
628 Ok(reg) => match reg.get(&replica_id) {
629 Some(replica) => leak_state(replica.encode_state(), out_len),
630 None => null_state(out_len),
631 },
632 Err(_) => null_state(out_len),
633 }
634}
635
636#[unsafe(no_mangle)]
642pub unsafe extern "C" fn agent_doc_replica_close(replica_id: u64) -> i32 {
643 match replica_registry().lock() {
644 Ok(mut reg) => {
645 if reg.remove(&replica_id).is_some() {
646 0
647 } else {
648 -3
649 }
650 }
651 Err(_) => -1,
652 }
653}
654
655#[unsafe(no_mangle)]
670pub unsafe extern "C" fn agent_doc_replica_persist(replica_id: u64, path: *const c_char) -> i32 {
671 if path.is_null() {
672 return -2;
673 }
674 let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
675 Ok(s) => s,
676 Err(_) => return -2,
677 };
678 let state = match replica_registry().lock() {
679 Ok(reg) => match reg.get(&replica_id) {
680 Some(replica) => replica.encode_state(),
681 None => return -3,
682 },
683 Err(_) => return -1,
684 };
685 match atomic_write_bytes(std::path::Path::new(path_str), &state) {
686 Ok(()) => 0,
687 Err(_) => -2,
688 }
689}
690
691#[unsafe(no_mangle)]
702pub unsafe extern "C" fn agent_doc_replica_recover(replica_id: u64, path: *const c_char) -> i32 {
703 if path.is_null() {
704 return -2;
705 }
706 let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
707 Ok(s) => s,
708 Err(_) => return -2,
709 };
710 let bytes = match std::fs::read(path_str) {
711 Ok(b) => b,
712 Err(_) => return -2,
713 };
714 let replica = match ReplicaState::from_encoded(replica_id, &bytes) {
715 Ok(r) => r,
716 Err(_) => return -2,
717 };
718 match replica_registry().lock() {
719 Ok(mut reg) => {
720 reg.insert(replica_id, replica);
721 0
722 }
723 Err(_) => -1,
724 }
725}
726
727fn atomic_write_bytes(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
730 use std::io::Write;
731 if let Some(parent) = path.parent()
732 && !parent.as_os_str().is_empty()
733 {
734 std::fs::create_dir_all(parent)?;
735 }
736 let pid = std::process::id();
737 let tmp_path = match path.extension().and_then(|e| e.to_str()) {
738 Some(ext) => path.with_extension(format!("{ext}.tmp.{pid}")),
739 None => path.with_extension(format!("tmp.{pid}")),
740 };
741 {
742 let mut f = std::fs::File::create(&tmp_path)?;
743 f.write_all(bytes)?;
744 f.flush()?;
745 }
746 std::fs::rename(&tmp_path, path)?;
747 Ok(())
748}
749
750#[unsafe(no_mangle)]
762pub unsafe extern "C" fn agent_doc_merge_crdt(
763 base: *const c_char,
764 ours: *const c_char,
765 theirs: *const c_char,
766) -> *mut c_char {
767 let base_str = match unsafe { CStr::from_ptr(base) }.to_str() {
768 Ok(s) => s,
769 Err(_) => return CString::new("").unwrap_or_default().into_raw(),
770 };
771 let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
772 Ok(s) => s,
773 Err(_) => return CString::new("").unwrap_or_default().into_raw(),
774 };
775 let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
776 Ok(s) => s,
777 Err(_) => return CString::new("").unwrap_or_default().into_raw(),
778 };
779
780 let base_doc = crdt::CrdtDoc::from_text(base_str);
782 let base_state = base_doc.encode_state();
783
784 let merged = crdt::merge_by_component(Some(&base_state), ours_str, theirs_str)
785 .unwrap_or_else(|_| ours_str.to_string());
786 CString::new(merged).unwrap_or_default().into_raw()
787}
788
789#[unsafe(no_mangle)]
800pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
801 doc: *const c_char,
802) -> FfiPatchResult {
803 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
804 Ok(s) => s,
805 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
806 };
807
808 let result = template::reposition_boundary_to_end_clean(doc_str);
809 ffi_patch_from_result(normalize_editor_visible_result(result))
810}
811
812#[unsafe(no_mangle)]
822pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_with_id(
823 doc: *const c_char,
824 boundary_id: *const c_char,
825) -> FfiPatchResult {
826 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
827 Ok(s) => s,
828 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
829 };
830 let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
831 Ok(s) => s,
832 Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
833 };
834
835 let result = template::reposition_boundary_to_end_clean_with_id(doc_str, Some(boundary_id_str));
836 ffi_patch_from_result(normalize_editor_visible_result(result))
837}
838
839#[unsafe(no_mangle)]
849pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head(
850 doc: *const c_char,
851) -> FfiPatchResult {
852 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
853 Ok(s) => s,
854 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
855 };
856
857 let result = template::reposition_boundary_to_end_preserve_head(doc_str);
858 ffi_patch_from_result(normalize_editor_visible_result(result))
859}
860
861#[unsafe(no_mangle)]
867pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head_with_id(
868 doc: *const c_char,
869 boundary_id: *const c_char,
870) -> FfiPatchResult {
871 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
872 Ok(s) => s,
873 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
874 };
875 let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
876 Ok(s) => s,
877 Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
878 };
879
880 let result =
881 template::reposition_boundary_to_end_preserve_head_with_id(doc_str, Some(boundary_id_str));
882 ffi_patch_from_result(normalize_editor_visible_result(result))
883}
884
885#[unsafe(no_mangle)]
893pub unsafe extern "C" fn agent_doc_apply_patch(
894 doc: *const c_char,
895 component_name: *const c_char,
896 content: *const c_char,
897 mode: *const c_char,
898) -> FfiPatchResult {
899 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
900 Ok(s) => s,
901 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
902 };
903 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
904 Ok(s) => s,
905 Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
906 };
907 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
908 Ok(s) => s,
909 Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
910 };
911 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
912 Ok(s) => s,
913 Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
914 };
915
916 let patch = template::PatchBlock::new(name, patch_content);
918
919 let mut overrides = HashMap::new();
921 overrides.insert(name.to_string(), mode_str.to_string());
922
923 ffi_patch_from_result(
926 template::apply_patches_with_overrides_pure(
927 doc_str,
928 &[patch],
929 "",
930 None,
931 &HashMap::new(),
932 &HashMap::new(),
933 &overrides,
934 )
935 .and_then(normalize_editor_visible_result),
936 )
937}
938
939#[unsafe(no_mangle)]
951pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
952 doc: *const c_char,
953 component_name: *const c_char,
954 content: *const c_char,
955 mode: *const c_char,
956 caret_offset: i32,
957) -> FfiPatchResult {
958 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
959 Ok(s) => s,
960 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
961 };
962 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
963 Ok(s) => s,
964 Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
965 };
966 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
967 Ok(s) => s,
968 Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
969 };
970 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
971 Ok(s) => s,
972 Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
973 };
974
975 if mode_str == "append" && caret_offset >= 0 {
977 let components = match component::parse(doc_str) {
978 Ok(c) => c,
979 Err(e) => return ffi_patch_err(&format!("{e}")),
980 };
981 if let Some(comp) = components.iter().find(|c| c.name == name) {
982 let result =
983 comp.append_with_caret(doc_str, patch_content, Some(caret_offset as usize));
984 return ffi_patch_from_result(normalize_editor_visible_result(result));
985 }
986 }
987
988 let patch = template::PatchBlock::new(name, patch_content);
990 let mut overrides = HashMap::new();
991 overrides.insert(name.to_string(), mode_str.to_string());
992 ffi_patch_from_result(
993 template::apply_patches_with_overrides_pure(
994 doc_str,
995 &[patch],
996 "",
997 None,
998 &HashMap::new(),
999 &HashMap::new(),
1000 &overrides,
1001 )
1002 .and_then(normalize_editor_visible_result),
1003 )
1004}
1005
1006#[unsafe(no_mangle)]
1019pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
1020 doc: *const c_char,
1021 component_name: *const c_char,
1022 content: *const c_char,
1023 mode: *const c_char,
1024 boundary_id: *const c_char,
1025) -> FfiPatchResult {
1026 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
1027 Ok(s) => s,
1028 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
1029 };
1030 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
1031 Ok(s) => s,
1032 Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
1033 };
1034 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
1035 Ok(s) => s,
1036 Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
1037 };
1038 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
1039 Ok(s) => s,
1040 Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
1041 };
1042 let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
1043 Ok(s) => s,
1044 Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
1045 };
1046
1047 if mode_str == "append" && !bid.is_empty() {
1049 let components = match component::parse(doc_str) {
1050 Ok(c) => c,
1051 Err(e) => return ffi_patch_err(&format!("{e}")),
1052 };
1053 if let Some(comp) = components.iter().find(|c| c.name == name) {
1054 let result = comp.append_with_boundary(doc_str, patch_content, bid);
1055 let result = if name == "exchange" {
1056 template::annotate_exchange_headings_against_baseline(&result, doc_str)
1057 } else {
1058 result
1059 };
1060 return ffi_patch_from_result(normalize_editor_visible_result(result));
1061 }
1062 }
1063
1064 let patch = template::PatchBlock::new(name, patch_content);
1066 let mut overrides = HashMap::new();
1067 overrides.insert(name.to_string(), mode_str.to_string());
1068 ffi_patch_from_result(
1069 template::apply_patches_with_overrides_pure(
1070 doc_str,
1071 &[patch],
1072 "",
1073 None,
1074 &HashMap::new(),
1075 &HashMap::new(),
1076 &overrides,
1077 )
1078 .and_then(normalize_editor_visible_result),
1079 )
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085 use std::ffi::{CStr, CString};
1086
1087 #[test]
1088 fn apply_patch_append_preserves_leading_code_fence_after_prompt_fence() {
1089 let doc = concat!(
1090 "<!-- agent:exchange patch=append -->\n",
1091 "❯ show fenced prompt\n",
1092 "```\n",
1093 "prompt body\n",
1094 "```\n",
1095 "<!-- /agent:exchange -->\n",
1096 );
1097 let c_doc = CString::new(doc).unwrap();
1098 let c_name = CString::new("exchange").unwrap();
1099 let c_content = CString::new("```\nresponse body\n```\n").unwrap();
1100 let c_mode = CString::new("append").unwrap();
1101
1102 let result = unsafe {
1103 agent_doc_apply_patch(
1104 c_doc.as_ptr(),
1105 c_name.as_ptr(),
1106 c_content.as_ptr(),
1107 c_mode.as_ptr(),
1108 )
1109 };
1110
1111 assert!(result.error.is_null());
1112 assert!(!result.text.is_null());
1113 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
1114 assert_eq!(
1115 text.matches("```").count(),
1116 4,
1117 "native IPC append must keep prompt and response fences:\n{text}"
1118 );
1119 assert!(
1120 text.contains("```\n```\nresponse body\n```"),
1121 "native IPC append stripped the response opening fence:\n{text}"
1122 );
1123 unsafe { agent_doc_free_string(result.text) };
1124 }
1125
1126 #[test]
1127 fn replica_ffi_round_trip_converges_via_state_vector_exchange() {
1128 use std::ffi::CString;
1129
1130 let a: u64 = 0xA11CE;
1131 let b: u64 = 0xB0B;
1132 assert_eq!(unsafe { agent_doc_replica_open(a, std::ptr::null(), 0) }, 0);
1133 assert_eq!(unsafe { agent_doc_replica_open(b, std::ptr::null(), 0) }, 0);
1134
1135 let ins_a = CString::new("alpha").unwrap();
1137 let ins_b = CString::new("beta").unwrap();
1138 assert_eq!(
1139 unsafe { agent_doc_replica_apply_local(a, 0, 0, ins_a.as_ptr()) },
1140 0
1141 );
1142 assert_eq!(
1143 unsafe { agent_doc_replica_apply_local(b, 0, 0, ins_b.as_ptr()) },
1144 0
1145 );
1146
1147 let mut b_sv_len: usize = 0;
1149 let b_sv = unsafe { agent_doc_replica_state_vector(b, &mut b_sv_len) };
1150 assert!(!b_sv.is_null());
1151 let mut a_to_b_len: usize = 0;
1152 let a_to_b = unsafe { agent_doc_replica_diff(a, b_sv, b_sv_len, &mut a_to_b_len) };
1153 assert!(!a_to_b.is_null());
1154
1155 let mut a_sv_len: usize = 0;
1156 let a_sv = unsafe { agent_doc_replica_state_vector(a, &mut a_sv_len) };
1157 let mut b_to_a_len: usize = 0;
1158 let b_to_a = unsafe { agent_doc_replica_diff(b, a_sv, a_sv_len, &mut b_to_a_len) };
1159
1160 assert_eq!(
1161 unsafe { agent_doc_replica_apply_update(b, a_to_b, a_to_b_len) },
1162 0
1163 );
1164 assert_eq!(
1165 unsafe { agent_doc_replica_apply_update(a, b_to_a, b_to_a_len) },
1166 0
1167 );
1168
1169 let ta = unsafe { agent_doc_replica_text(a) };
1170 let tb = unsafe { agent_doc_replica_text(b) };
1171 let sa = unsafe { CStr::from_ptr(ta) }.to_str().unwrap().to_string();
1172 let sb = unsafe { CStr::from_ptr(tb) }.to_str().unwrap().to_string();
1173 assert_eq!(sa, sb, "replicas converge after FFI state-vector exchange");
1174 assert!(sa.contains("alpha") && sa.contains("beta"));
1175
1176 assert_eq!(
1178 unsafe { agent_doc_replica_apply_update(b, a_to_b, a_to_b_len) },
1179 0
1180 );
1181 let tb2 = unsafe { agent_doc_replica_text(b) };
1182 let sb2 = unsafe { CStr::from_ptr(tb2) }.to_str().unwrap().to_string();
1183 assert_eq!(sb2, sb, "re-applying a known update via FFI is idempotent");
1184
1185 unsafe {
1186 agent_doc_free_state(b_sv, b_sv_len);
1187 agent_doc_free_state(a_sv, a_sv_len);
1188 agent_doc_free_state(a_to_b, a_to_b_len);
1189 agent_doc_free_state(b_to_a, b_to_a_len);
1190 agent_doc_free_string(ta);
1191 agent_doc_free_string(tb);
1192 agent_doc_free_string(tb2);
1193 }
1194
1195 assert_eq!(unsafe { agent_doc_replica_close(a) }, 0);
1196 assert_eq!(unsafe { agent_doc_replica_close(b) }, 0);
1197 assert_eq!(unsafe { agent_doc_replica_close(a) }, -3);
1199 }
1200
1201 #[test]
1202 fn replica_ffi_reports_unopened_replica() {
1203 let id: u64 = 0xDEAD_BEEF;
1204 let ins = std::ffi::CString::new("x").unwrap();
1205 assert_eq!(
1206 unsafe { agent_doc_replica_apply_local(id, 0, 0, ins.as_ptr()) },
1207 -3,
1208 "apply_local on an unopened replica reports not-open"
1209 );
1210 assert_eq!(
1212 unsafe { agent_doc_replica_apply_update(id, std::ptr::null(), 0) },
1213 -2
1214 );
1215 assert!(unsafe { agent_doc_replica_text(id) }.is_null());
1216 let mut len: usize = 123;
1217 assert!(unsafe { agent_doc_replica_state_vector(id, &mut len) }.is_null());
1218 assert_eq!(len, 0, "null byte result sets out_len to 0");
1219 assert_eq!(unsafe { agent_doc_replica_close(id) }, -3);
1220 }
1221
1222 #[test]
1223 fn replica_ffi_persist_then_recover_round_trips_disk_projection() {
1224 let dir = tempfile::tempdir().unwrap();
1227 let path = dir.path().join("node.yrs");
1228 let path_c = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
1229
1230 let src: u64 = 0x5151;
1231 assert_eq!(
1232 unsafe { agent_doc_replica_open(src, std::ptr::null(), 0) },
1233 0
1234 );
1235 let ins = std::ffi::CString::new("crash-safe").unwrap();
1236 assert_eq!(
1237 unsafe { agent_doc_replica_apply_local(src, 0, 0, ins.as_ptr()) },
1238 0
1239 );
1240 assert_eq!(
1242 unsafe { agent_doc_replica_persist(src, path_c.as_ptr()) },
1243 0,
1244 "persist writes the recovery projection"
1245 );
1246 assert!(path.exists());
1247 assert_eq!(unsafe { agent_doc_replica_close(src) }, 0);
1248
1249 let recovered: u64 = 0x5252;
1251 assert_eq!(
1252 unsafe { agent_doc_replica_recover(recovered, path_c.as_ptr()) },
1253 0,
1254 "recover rebuilds the in-memory replica from disk"
1255 );
1256 let t = unsafe { agent_doc_replica_text(recovered) };
1257 assert!(!t.is_null());
1258 let text = unsafe { CStr::from_ptr(t) }.to_str().unwrap().to_string();
1259 unsafe { agent_doc_free_string(t) };
1260 assert_eq!(text, "crash-safe");
1261 assert_eq!(unsafe { agent_doc_replica_close(recovered) }, 0);
1262 }
1263
1264 #[test]
1265 fn replica_ffi_persist_recover_reject_bad_args() {
1266 let id: u64 = 0x6363;
1267 let p = std::ffi::CString::new("/tmp/agent-doc-nope.yrs").unwrap();
1269 assert_eq!(unsafe { agent_doc_replica_persist(id, p.as_ptr()) }, -3);
1270 assert_eq!(
1272 unsafe { agent_doc_replica_persist(id, std::ptr::null()) },
1273 -2
1274 );
1275 let missing = std::ffi::CString::new("/nonexistent/dir/agent-doc-missing.yrs").unwrap();
1277 assert_eq!(
1278 unsafe { agent_doc_replica_recover(id, missing.as_ptr()) },
1279 -2
1280 );
1281 }
1282}