1use std::collections::HashMap;
19use std::ffi::{CStr, CString};
20use std::os::raw::c_char;
21use std::ptr;
22
23use crate::{component, crdt, frontmatter, syntax, template};
24
25#[unsafe(no_mangle)]
31pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
32 if !ptr.is_null() {
33 drop(unsafe { CString::from_raw(ptr) });
34 }
35}
36
37#[unsafe(no_mangle)]
45pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
46 if !ptr.is_null() {
47 drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
48 }
49}
50
51#[repr(C)]
53pub struct FfiComponentList {
54 pub json: *mut c_char,
56 pub count: usize,
58}
59
60#[repr(C)]
64pub struct FfiPatchResult {
65 pub text: *mut c_char,
67 pub error: *mut c_char,
69}
70
71pub fn ffi_patch_ok(text: String) -> FfiPatchResult {
73 FfiPatchResult {
74 text: CString::new(text).unwrap_or_default().into_raw(),
75 error: ptr::null_mut(),
76 }
77}
78
79pub fn ffi_patch_err(msg: &str) -> FfiPatchResult {
81 FfiPatchResult {
82 text: ptr::null_mut(),
83 error: CString::new(msg).unwrap_or_default().into_raw(),
84 }
85}
86
87pub fn ffi_patch_from_result(result: anyhow::Result<String>) -> FfiPatchResult {
89 match result {
90 Ok(text) => ffi_patch_ok(text),
91 Err(e) => ffi_patch_err(&format!("{e:#}")),
92 }
93}
94
95pub fn normalize_editor_visible_result(text: String) -> anyhow::Result<String> {
97 template::normalize_editor_visible_template_structure(&text)
98}
99
100fn utf8_offsets_to_utf16_offsets(doc: &str, offsets: &[usize]) -> HashMap<usize, usize> {
106 let mut targets = offsets.to_vec();
107 targets.sort_unstable();
108 targets.dedup();
109
110 let mut mapped = HashMap::with_capacity(targets.len());
111 let mut target_idx = 0usize;
112 let mut utf8_offset = 0usize;
113 let mut utf16_offset = 0usize;
114
115 while target_idx < targets.len() && targets[target_idx] == 0 {
116 mapped.insert(0, 0);
117 target_idx += 1;
118 }
119
120 for ch in doc.chars() {
121 let next_utf8 = utf8_offset + ch.len_utf8();
122 while target_idx < targets.len() && targets[target_idx] < next_utf8 {
123 mapped.insert(targets[target_idx], utf16_offset);
124 target_idx += 1;
125 }
126
127 utf8_offset = next_utf8;
128 utf16_offset += ch.len_utf16();
129
130 while target_idx < targets.len() && targets[target_idx] == utf8_offset {
131 mapped.insert(targets[target_idx], utf16_offset);
132 target_idx += 1;
133 }
134 }
135
136 while target_idx < targets.len() {
137 mapped.insert(targets[target_idx], utf16_offset);
138 target_idx += 1;
139 }
140
141 mapped
142}
143
144#[unsafe(no_mangle)]
154pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
155 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
156 Ok(s) => s,
157 Err(_) => {
158 return FfiComponentList {
159 json: ptr::null_mut(),
160 count: 0,
161 };
162 }
163 };
164
165 let components = match component::parse(doc_str) {
166 Ok(c) => c,
167 Err(_) => {
168 return FfiComponentList {
169 json: ptr::null_mut(),
170 count: 0,
171 };
172 }
173 };
174
175 let count = components.len();
176
177 let json_items: Vec<serde_json::Value> = components
179 .iter()
180 .map(|c| {
181 serde_json::json!({
182 "name": c.name,
183 "attrs": c.attrs,
184 "open_start": c.open_start,
185 "open_end": c.open_end,
186 "close_start": c.close_start,
187 "close_end": c.close_end,
188 "content": c.content(doc_str),
189 })
190 })
191 .collect();
192
193 let json_str = serde_json::to_string(&json_items).unwrap_or_default();
194 let c_json = CString::new(json_str).unwrap_or_default();
195
196 FfiComponentList {
197 json: c_json.into_raw(),
198 count,
199 }
200}
201
202#[unsafe(no_mangle)]
212pub unsafe extern "C" fn agent_doc_visual_tokens_json(doc: *const c_char) -> *mut c_char {
213 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
214 Ok(s) => s,
215 Err(_) => return ptr::null_mut(),
216 };
217 let tokens = syntax::collect_visual_tokens(doc_str);
218 let offsets: Vec<usize> = tokens
219 .iter()
220 .flat_map(|token| [token.start, token.end])
221 .collect();
222 let utf16_offsets = utf8_offsets_to_utf16_offsets(doc_str, &offsets);
223 let editor_tokens: Vec<_> = tokens
224 .iter()
225 .map(|token| {
226 serde_json::json!({
227 "kind": token.kind,
228 "start": utf16_offsets[&token.start],
229 "end": utf16_offsets[&token.end],
230 })
231 })
232 .collect();
233 let json = match serde_json::to_string(&editor_tokens) {
234 Ok(json) => json,
235 Err(_) => return ptr::null_mut(),
236 };
237 match CString::new(json) {
238 Ok(value) => value.into_raw(),
239 Err(_) => ptr::null_mut(),
240 }
241}
242
243#[unsafe(no_mangle)]
252pub unsafe extern "C" fn agent_doc_merge_frontmatter(
253 doc: *const c_char,
254 yaml_fields: *const c_char,
255) -> FfiPatchResult {
256 let make_err = |msg: &str| FfiPatchResult {
257 text: ptr::null_mut(),
258 error: CString::new(msg).unwrap_or_default().into_raw(),
259 };
260
261 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
262 Ok(s) => s,
263 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
264 };
265 let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
266 Ok(s) => s,
267 Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
268 };
269
270 match frontmatter::merge_fields(doc_str, fields_str) {
271 Ok(result) => FfiPatchResult {
272 text: CString::new(result).unwrap_or_default().into_raw(),
273 error: ptr::null_mut(),
274 },
275 Err(e) => make_err(&format!("{e}")),
276 }
277}
278
279#[unsafe(no_mangle)]
297pub unsafe extern "C" fn agent_doc_converge_queue_auto(
298 doc: *const c_char,
299 want_auto: std::os::raw::c_int,
300) -> FfiPatchResult {
301 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
302 Ok(s) => s,
303 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
304 };
305 let converged = component::converge_queue_auto(doc_str, want_auto != 0)
306 .unwrap_or_else(|| doc_str.to_string());
307 FfiPatchResult {
308 text: CString::new(converged).unwrap_or_default().into_raw(),
309 error: ptr::null_mut(),
310 }
311}
312
313#[unsafe(no_mangle)]
323pub unsafe extern "C" fn agent_doc_normalize_template_structure(
324 doc: *const c_char,
325) -> FfiPatchResult {
326 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
327 Ok(s) => s,
328 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
329 };
330
331 ffi_patch_from_result(template::normalize_editor_visible_template_structure(
332 doc_str,
333 ))
334}
335
336#[repr(C)]
338pub struct FfiMergeResult {
339 pub text: *mut c_char,
341 pub state: *mut u8,
343 pub state_len: usize,
345 pub error: *mut c_char,
347}
348
349#[unsafe(no_mangle)]
360pub unsafe extern "C" fn agent_doc_crdt_merge(
361 base_state: *const u8,
362 base_state_len: usize,
363 ours: *const c_char,
364 theirs: *const c_char,
365) -> FfiMergeResult {
366 let make_err = |msg: &str| FfiMergeResult {
367 text: ptr::null_mut(),
368 state: ptr::null_mut(),
369 state_len: 0,
370 error: CString::new(msg).unwrap_or_default().into_raw(),
371 };
372
373 let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
374 Ok(s) => s,
375 Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
376 };
377 let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
378 Ok(s) => s,
379 Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
380 };
381
382 let base = if base_state.is_null() {
383 None
384 } else {
385 Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
386 };
387
388 match crdt::merge_by_component(base, ours_str, theirs_str) {
389 Ok(merged_text) => {
390 let doc = crdt::CrdtDoc::from_text(&merged_text);
392 let state_bytes = doc.encode_state();
393 let state_len = state_bytes.len();
394 let state_ptr = {
395 let mut boxed = state_bytes.into_boxed_slice();
396 let ptr = boxed.as_mut_ptr();
397 std::mem::forget(boxed);
398 ptr
399 };
400
401 FfiMergeResult {
402 text: CString::new(merged_text).unwrap_or_default().into_raw(),
403 state: state_ptr,
404 state_len,
405 error: ptr::null_mut(),
406 }
407 }
408 Err(e) => make_err(&format!("{e}")),
409 }
410}
411
412#[unsafe(no_mangle)]
424pub unsafe extern "C" fn agent_doc_merge_crdt(
425 base: *const c_char,
426 ours: *const c_char,
427 theirs: *const c_char,
428) -> *mut c_char {
429 let base_str = match unsafe { CStr::from_ptr(base) }.to_str() {
430 Ok(s) => s,
431 Err(_) => return CString::new("").unwrap_or_default().into_raw(),
432 };
433 let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
434 Ok(s) => s,
435 Err(_) => return CString::new("").unwrap_or_default().into_raw(),
436 };
437 let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
438 Ok(s) => s,
439 Err(_) => return CString::new("").unwrap_or_default().into_raw(),
440 };
441
442 let base_doc = crdt::CrdtDoc::from_text(base_str);
444 let base_state = base_doc.encode_state();
445
446 let merged = crdt::merge_by_component(Some(&base_state), ours_str, theirs_str)
447 .unwrap_or_else(|_| ours_str.to_string());
448 CString::new(merged).unwrap_or_default().into_raw()
449}
450
451#[unsafe(no_mangle)]
462pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
463 doc: *const c_char,
464) -> FfiPatchResult {
465 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
466 Ok(s) => s,
467 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
468 };
469
470 let result = template::reposition_boundary_to_end_clean(doc_str);
471 ffi_patch_from_result(normalize_editor_visible_result(result))
472}
473
474#[unsafe(no_mangle)]
484pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_with_id(
485 doc: *const c_char,
486 boundary_id: *const c_char,
487) -> FfiPatchResult {
488 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
489 Ok(s) => s,
490 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
491 };
492 let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
493 Ok(s) => s,
494 Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
495 };
496
497 let result = template::reposition_boundary_to_end_clean_with_id(doc_str, Some(boundary_id_str));
498 ffi_patch_from_result(normalize_editor_visible_result(result))
499}
500
501#[unsafe(no_mangle)]
511pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head(
512 doc: *const c_char,
513) -> FfiPatchResult {
514 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
515 Ok(s) => s,
516 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
517 };
518
519 let result = template::reposition_boundary_to_end_preserve_head(doc_str);
520 ffi_patch_from_result(normalize_editor_visible_result(result))
521}
522
523#[unsafe(no_mangle)]
529pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end_preserve_head_with_id(
530 doc: *const c_char,
531 boundary_id: *const c_char,
532) -> FfiPatchResult {
533 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
534 Ok(s) => s,
535 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
536 };
537 let boundary_id_str = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
538 Ok(s) => s,
539 Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
540 };
541
542 let result =
543 template::reposition_boundary_to_end_preserve_head_with_id(doc_str, Some(boundary_id_str));
544 ffi_patch_from_result(normalize_editor_visible_result(result))
545}
546
547#[unsafe(no_mangle)]
555pub unsafe extern "C" fn agent_doc_apply_patch(
556 doc: *const c_char,
557 component_name: *const c_char,
558 content: *const c_char,
559 mode: *const c_char,
560) -> FfiPatchResult {
561 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
562 Ok(s) => s,
563 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
564 };
565 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
566 Ok(s) => s,
567 Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
568 };
569 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
570 Ok(s) => s,
571 Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
572 };
573 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
574 Ok(s) => s,
575 Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
576 };
577
578 let patch = template::PatchBlock::new(name, patch_content);
580
581 let mut overrides = HashMap::new();
583 overrides.insert(name.to_string(), mode_str.to_string());
584
585 ffi_patch_from_result(
588 template::apply_patches_with_overrides_pure(
589 doc_str,
590 &[patch],
591 "",
592 None,
593 &HashMap::new(),
594 &HashMap::new(),
595 &overrides,
596 )
597 .and_then(normalize_editor_visible_result),
598 )
599}
600
601#[unsafe(no_mangle)]
613pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
614 doc: *const c_char,
615 component_name: *const c_char,
616 content: *const c_char,
617 mode: *const c_char,
618 caret_offset: i32,
619) -> FfiPatchResult {
620 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
621 Ok(s) => s,
622 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
623 };
624 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
625 Ok(s) => s,
626 Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
627 };
628 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
629 Ok(s) => s,
630 Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
631 };
632 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
633 Ok(s) => s,
634 Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
635 };
636
637 if mode_str == "append" && caret_offset >= 0 {
639 let components = match component::parse(doc_str) {
640 Ok(c) => c,
641 Err(e) => return ffi_patch_err(&format!("{e}")),
642 };
643 if let Some(comp) = components.iter().find(|c| c.name == name) {
644 let result =
645 comp.append_with_caret(doc_str, patch_content, Some(caret_offset as usize));
646 return ffi_patch_from_result(normalize_editor_visible_result(result));
647 }
648 }
649
650 let patch = template::PatchBlock::new(name, patch_content);
652 let mut overrides = HashMap::new();
653 overrides.insert(name.to_string(), mode_str.to_string());
654 ffi_patch_from_result(
655 template::apply_patches_with_overrides_pure(
656 doc_str,
657 &[patch],
658 "",
659 None,
660 &HashMap::new(),
661 &HashMap::new(),
662 &overrides,
663 )
664 .and_then(normalize_editor_visible_result),
665 )
666}
667
668#[unsafe(no_mangle)]
681pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
682 doc: *const c_char,
683 component_name: *const c_char,
684 content: *const c_char,
685 mode: *const c_char,
686 boundary_id: *const c_char,
687) -> FfiPatchResult {
688 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
689 Ok(s) => s,
690 Err(e) => return ffi_patch_err(&format!("invalid doc UTF-8: {e}")),
691 };
692 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
693 Ok(s) => s,
694 Err(e) => return ffi_patch_err(&format!("invalid component name UTF-8: {e}")),
695 };
696 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
697 Ok(s) => s,
698 Err(e) => return ffi_patch_err(&format!("invalid content UTF-8: {e}")),
699 };
700 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
701 Ok(s) => s,
702 Err(e) => return ffi_patch_err(&format!("invalid mode UTF-8: {e}")),
703 };
704 let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
705 Ok(s) => s,
706 Err(e) => return ffi_patch_err(&format!("invalid boundary_id UTF-8: {e}")),
707 };
708
709 if mode_str == "append" && !bid.is_empty() {
711 let components = match component::parse(doc_str) {
712 Ok(c) => c,
713 Err(e) => return ffi_patch_err(&format!("{e}")),
714 };
715 if let Some(comp) = components.iter().find(|c| c.name == name) {
716 let result = comp.append_with_boundary(doc_str, patch_content, bid);
717 let result = if name == "exchange" {
718 template::annotate_exchange_headings_against_baseline(&result, doc_str)
719 } else {
720 result
721 };
722 return ffi_patch_from_result(normalize_editor_visible_result(result));
723 }
724 }
725
726 let patch = template::PatchBlock::new(name, patch_content);
728 let mut overrides = HashMap::new();
729 overrides.insert(name.to_string(), mode_str.to_string());
730 ffi_patch_from_result(
731 template::apply_patches_with_overrides_pure(
732 doc_str,
733 &[patch],
734 "",
735 None,
736 &HashMap::new(),
737 &HashMap::new(),
738 &overrides,
739 )
740 .and_then(normalize_editor_visible_result),
741 )
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747 use std::ffi::{CStr, CString};
748
749 #[test]
750 fn apply_patch_append_preserves_leading_code_fence_after_prompt_fence() {
751 let doc = concat!(
752 "<!-- agent:exchange patch=append -->\n",
753 "❯ show fenced prompt\n",
754 "```\n",
755 "prompt body\n",
756 "```\n",
757 "<!-- /agent:exchange -->\n",
758 );
759 let c_doc = CString::new(doc).unwrap();
760 let c_name = CString::new("exchange").unwrap();
761 let c_content = CString::new("```\nresponse body\n```\n").unwrap();
762 let c_mode = CString::new("append").unwrap();
763
764 let result = unsafe {
765 agent_doc_apply_patch(
766 c_doc.as_ptr(),
767 c_name.as_ptr(),
768 c_content.as_ptr(),
769 c_mode.as_ptr(),
770 )
771 };
772
773 assert!(result.error.is_null());
774 assert!(!result.text.is_null());
775 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
776 assert_eq!(
777 text.matches("```").count(),
778 4,
779 "native IPC append must keep prompt and response fences:\n{text}"
780 );
781 assert!(
782 text.contains("```\n```\nresponse body\n```"),
783 "native IPC append stripped the response opening fence:\n{text}"
784 );
785 unsafe { agent_doc_free_string(result.text) };
786 }
787}