1use std::ffi::{CStr, CString, c_char};
52use std::ptr;
53use std::sync::atomic::{AtomicBool, Ordering};
54
55use crate::component;
56use crate::crdt;
57use crate::frontmatter;
58use crate::template;
59
60static SYNC_LOCKED: AtomicBool = AtomicBool::new(false);
62
63static SYNC_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
65
66#[repr(C)]
68pub struct FfiComponentList {
69 pub json: *mut c_char,
71 pub count: usize,
73}
74
75#[repr(C)]
77pub struct FfiPatchResult {
78 pub text: *mut c_char,
80 pub error: *mut c_char,
82}
83
84#[repr(C)]
86pub struct FfiMergeResult {
87 pub text: *mut c_char,
89 pub state: *mut u8,
91 pub state_len: usize,
93 pub error: *mut c_char,
95}
96
97#[unsafe(no_mangle)]
107pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
108 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
109 Ok(s) => s,
110 Err(_) => {
111 return FfiComponentList {
112 json: ptr::null_mut(),
113 count: 0,
114 };
115 }
116 };
117
118 let components = match component::parse(doc_str) {
119 Ok(c) => c,
120 Err(_) => {
121 return FfiComponentList {
122 json: ptr::null_mut(),
123 count: 0,
124 };
125 }
126 };
127
128 let count = components.len();
129
130 let json_items: Vec<serde_json::Value> = components
132 .iter()
133 .map(|c| {
134 serde_json::json!({
135 "name": c.name,
136 "attrs": c.attrs,
137 "open_start": c.open_start,
138 "open_end": c.open_end,
139 "close_start": c.close_start,
140 "close_end": c.close_end,
141 "content": c.content(doc_str),
142 })
143 })
144 .collect();
145
146 let json_str = serde_json::to_string(&json_items).unwrap_or_default();
147 let c_json = CString::new(json_str).unwrap_or_default();
148
149 FfiComponentList {
150 json: c_json.into_raw(),
151 count,
152 }
153}
154
155#[unsafe(no_mangle)]
163pub unsafe extern "C" fn agent_doc_apply_patch(
164 doc: *const c_char,
165 component_name: *const c_char,
166 content: *const c_char,
167 mode: *const c_char,
168) -> FfiPatchResult {
169 let make_err = |msg: &str| FfiPatchResult {
170 text: ptr::null_mut(),
171 error: CString::new(msg).unwrap_or_default().into_raw(),
172 };
173
174 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
175 Ok(s) => s,
176 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
177 };
178 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
179 Ok(s) => s,
180 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
181 };
182 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
183 Ok(s) => s,
184 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
185 };
186 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
187 Ok(s) => s,
188 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
189 };
190
191 let patch = template::PatchBlock {
193 name: name.to_string(),
194 content: patch_content.to_string(),
195 };
196
197 let mut overrides = std::collections::HashMap::new();
199 overrides.insert(name.to_string(), mode_str.to_string());
200
201 let dummy_path = std::path::Path::new("/dev/null");
204 match template::apply_patches_with_overrides(
205 doc_str,
206 &[patch],
207 "",
208 dummy_path,
209 &overrides,
210 ) {
211 Ok(result) => FfiPatchResult {
212 text: CString::new(result).unwrap_or_default().into_raw(),
213 error: ptr::null_mut(),
214 },
215 Err(e) => make_err(&format!("{e}")),
216 }
217}
218
219#[unsafe(no_mangle)]
231pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
232 doc: *const c_char,
233 component_name: *const c_char,
234 content: *const c_char,
235 mode: *const c_char,
236 caret_offset: i32,
237) -> FfiPatchResult {
238 let make_err = |msg: &str| FfiPatchResult {
239 text: ptr::null_mut(),
240 error: CString::new(msg).unwrap_or_default().into_raw(),
241 };
242
243 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
244 Ok(s) => s,
245 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
246 };
247 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
248 Ok(s) => s,
249 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
250 };
251 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
252 Ok(s) => s,
253 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
254 };
255 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
256 Ok(s) => s,
257 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
258 };
259
260 if mode_str == "append" && caret_offset >= 0 {
262 let components = match component::parse(doc_str) {
263 Ok(c) => c,
264 Err(e) => return make_err(&format!("{e}")),
265 };
266 if let Some(comp) = components.iter().find(|c| c.name == name) {
267 let result = comp.append_with_caret(
268 doc_str,
269 patch_content,
270 Some(caret_offset as usize),
271 );
272 return FfiPatchResult {
273 text: CString::new(result).unwrap_or_default().into_raw(),
274 error: ptr::null_mut(),
275 };
276 }
277 }
278
279 let patch = template::PatchBlock {
281 name: name.to_string(),
282 content: patch_content.to_string(),
283 };
284 let mut overrides = std::collections::HashMap::new();
285 overrides.insert(name.to_string(), mode_str.to_string());
286 let dummy_path = std::path::Path::new("/dev/null");
287 match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
288 Ok(result) => FfiPatchResult {
289 text: CString::new(result).unwrap_or_default().into_raw(),
290 error: ptr::null_mut(),
291 },
292 Err(e) => make_err(&format!("{e}")),
293 }
294}
295
296#[unsafe(no_mangle)]
309pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
310 doc: *const c_char,
311 component_name: *const c_char,
312 content: *const c_char,
313 mode: *const c_char,
314 boundary_id: *const c_char,
315) -> FfiPatchResult {
316 let make_err = |msg: &str| FfiPatchResult {
317 text: ptr::null_mut(),
318 error: CString::new(msg).unwrap_or_default().into_raw(),
319 };
320
321 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
322 Ok(s) => s,
323 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
324 };
325 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
326 Ok(s) => s,
327 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
328 };
329 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
330 Ok(s) => s,
331 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
332 };
333 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
334 Ok(s) => s,
335 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
336 };
337 let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
338 Ok(s) => s,
339 Err(e) => return make_err(&format!("invalid boundary_id UTF-8: {e}")),
340 };
341
342 if mode_str == "append" && !bid.is_empty() {
344 let components = match component::parse(doc_str) {
345 Ok(c) => c,
346 Err(e) => return make_err(&format!("{e}")),
347 };
348 if let Some(comp) = components.iter().find(|c| c.name == name) {
349 let result = comp.append_with_boundary(doc_str, patch_content, bid);
350 return FfiPatchResult {
351 text: CString::new(result).unwrap_or_default().into_raw(),
352 error: ptr::null_mut(),
353 };
354 }
355 }
356
357 let patch = template::PatchBlock {
359 name: name.to_string(),
360 content: patch_content.to_string(),
361 };
362 let mut overrides = std::collections::HashMap::new();
363 overrides.insert(name.to_string(), mode_str.to_string());
364 let dummy_path = std::path::Path::new("/dev/null");
365 match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
366 Ok(result) => FfiPatchResult {
367 text: CString::new(result).unwrap_or_default().into_raw(),
368 error: ptr::null_mut(),
369 },
370 Err(e) => make_err(&format!("{e}")),
371 }
372}
373
374#[unsafe(no_mangle)]
385pub unsafe extern "C" fn agent_doc_crdt_merge(
386 base_state: *const u8,
387 base_state_len: usize,
388 ours: *const c_char,
389 theirs: *const c_char,
390) -> FfiMergeResult {
391 let make_err = |msg: &str| FfiMergeResult {
392 text: ptr::null_mut(),
393 state: ptr::null_mut(),
394 state_len: 0,
395 error: CString::new(msg).unwrap_or_default().into_raw(),
396 };
397
398 let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
399 Ok(s) => s,
400 Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
401 };
402 let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
403 Ok(s) => s,
404 Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
405 };
406
407 let base = if base_state.is_null() {
408 None
409 } else {
410 Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
411 };
412
413 match crdt::merge(base, ours_str, theirs_str) {
414 Ok(merged_text) => {
415 let doc = crdt::CrdtDoc::from_text(&merged_text);
417 let state_bytes = doc.encode_state();
418 let state_len = state_bytes.len();
419 let state_ptr = {
420 let mut boxed = state_bytes.into_boxed_slice();
421 let ptr = boxed.as_mut_ptr();
422 std::mem::forget(boxed);
423 ptr
424 };
425
426 FfiMergeResult {
427 text: CString::new(merged_text).unwrap_or_default().into_raw(),
428 state: state_ptr,
429 state_len,
430 error: ptr::null_mut(),
431 }
432 }
433 Err(e) => make_err(&format!("{e}")),
434 }
435}
436
437#[unsafe(no_mangle)]
446pub unsafe extern "C" fn agent_doc_merge_frontmatter(
447 doc: *const c_char,
448 yaml_fields: *const c_char,
449) -> FfiPatchResult {
450 let make_err = |msg: &str| FfiPatchResult {
451 text: ptr::null_mut(),
452 error: CString::new(msg).unwrap_or_default().into_raw(),
453 };
454
455 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
456 Ok(s) => s,
457 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
458 };
459 let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
460 Ok(s) => s,
461 Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
462 };
463
464 match frontmatter::merge_fields(doc_str, fields_str) {
465 Ok(result) => FfiPatchResult {
466 text: CString::new(result).unwrap_or_default().into_raw(),
467 error: ptr::null_mut(),
468 },
469 Err(e) => make_err(&format!("{e}")),
470 }
471}
472
473#[unsafe(no_mangle)]
483pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
484 doc: *const c_char,
485) -> FfiPatchResult {
486 let make_err = |msg: &str| FfiPatchResult {
487 text: ptr::null_mut(),
488 error: CString::new(msg).unwrap_or_default().into_raw(),
489 };
490
491 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
492 Ok(s) => s,
493 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
494 };
495
496 let result = template::reposition_boundary_to_end(doc_str);
497 FfiPatchResult {
498 text: CString::new(result).unwrap_or_default().into_raw(),
499 error: ptr::null_mut(),
500 }
501}
502
503#[unsafe(no_mangle)]
512pub unsafe extern "C" fn agent_doc_document_changed(file_path: *const c_char) {
513 if let Ok(path) = unsafe { CStr::from_ptr(file_path) }.to_str() {
514 crate::debounce::document_changed(path);
515 }
516}
517
518#[unsafe(no_mangle)]
529pub unsafe extern "C" fn agent_doc_is_tracked(file_path: *const c_char) -> bool {
530 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
531 Ok(s) => s,
532 Err(_) => return false,
533 };
534 crate::debounce::is_tracked(path)
535}
536
537#[unsafe(no_mangle)]
550pub unsafe extern "C" fn agent_doc_is_idle(
551 file_path: *const c_char,
552 debounce_ms: i64,
553) -> bool {
554 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
555 Ok(s) => s,
556 Err(_) => return true, };
558 crate::debounce::is_idle(path, debounce_ms as u64)
559}
560
561#[unsafe(no_mangle)]
570pub unsafe extern "C" fn agent_doc_await_idle(
571 file_path: *const c_char,
572 debounce_ms: i64,
573 timeout_ms: i64,
574) -> bool {
575 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
576 Ok(s) => s,
577 Err(_) => return true, };
579 crate::debounce::await_idle(path, debounce_ms as u64, timeout_ms as u64)
580}
581
582#[unsafe(no_mangle)]
591pub unsafe extern "C" fn agent_doc_set_status(
592 file_path: *const c_char,
593 status: *const c_char,
594) {
595 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
596 Ok(s) => s,
597 Err(_) => return,
598 };
599 let st = match unsafe { CStr::from_ptr(status) }.to_str() {
600 Ok(s) => s,
601 Err(_) => return,
602 };
603 crate::debounce::set_status(path, st);
604}
605
606#[unsafe(no_mangle)]
615pub unsafe extern "C" fn agent_doc_get_status(file_path: *const c_char) -> *mut c_char {
616 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
617 Ok(s) => s,
618 Err(_) => return CString::new("idle").unwrap().into_raw(),
619 };
620 let status = crate::debounce::get_status(path);
621 CString::new(status).unwrap_or_else(|_| CString::new("idle").unwrap()).into_raw()
622}
623
624#[unsafe(no_mangle)]
633pub unsafe extern "C" fn agent_doc_is_busy(file_path: *const c_char) -> bool {
634 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
635 Ok(s) => s,
636 Err(_) => return false,
637 };
638 crate::debounce::is_busy(path)
639}
640
641#[unsafe(no_mangle)]
650pub extern "C" fn agent_doc_sync_try_lock() -> bool {
651 SYNC_LOCKED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_ok()
652}
653
654#[unsafe(no_mangle)]
656pub extern "C" fn agent_doc_sync_unlock() {
657 SYNC_LOCKED.store(false, Ordering::SeqCst);
658}
659
660#[unsafe(no_mangle)]
667pub extern "C" fn agent_doc_sync_bump_generation() -> u64 {
668 SYNC_GENERATION.fetch_add(1, Ordering::SeqCst) + 1
669}
670
671#[unsafe(no_mangle)]
674pub extern "C" fn agent_doc_sync_check_generation(generation: u64) -> bool {
675 SYNC_GENERATION.load(Ordering::SeqCst) == generation
676}
677
678#[unsafe(no_mangle)]
695pub unsafe extern "C" fn agent_doc_start_ipc_listener(
696 project_root: *const c_char,
697 callback: extern "C" fn(message: *const c_char) -> bool,
698) -> bool {
699 let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
700 Ok(s) => s.to_string(),
701 Err(_) => return false,
702 };
703 let root_path = std::path::PathBuf::from(&root_str);
704
705 std::thread::spawn(move || {
706 let result = crate::ipc_socket::start_listener(&root_path, move |msg| {
707 let c_msg = match CString::new(msg) {
709 Ok(c) => c,
710 Err(_) => return Some(r#"{"type":"ack","status":"error"}"#.to_string()),
711 };
712 let success = callback(c_msg.as_ptr());
713 if success {
714 Some(r#"{"type":"ack","status":"ok"}"#.to_string())
715 } else {
716 Some(r#"{"type":"ack","status":"error"}"#.to_string())
717 }
718 });
719 if let Err(e) = result {
720 eprintln!("[ffi] IPC listener error: {}", e);
721 }
722 });
723
724 true
725}
726
727#[unsafe(no_mangle)]
736pub unsafe extern "C" fn agent_doc_stop_ipc_listener(
737 project_root: *const c_char,
738) {
739 let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
740 Ok(s) => s,
741 Err(_) => return,
742 };
743 let sock = crate::ipc_socket::socket_path(std::path::Path::new(root_str));
744 let _ = std::fs::remove_file(&sock);
745}
746
747#[unsafe(no_mangle)]
752pub extern "C" fn agent_doc_version() -> *mut c_char {
753 CString::new(env!("CARGO_PKG_VERSION")).unwrap().into_raw()
754}
755
756#[unsafe(no_mangle)]
762pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
763 if !ptr.is_null() {
764 drop(unsafe { CString::from_raw(ptr) });
765 }
766}
767
768#[unsafe(no_mangle)]
774pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
775 if !ptr.is_null() {
776 drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 fn parse_components_roundtrip() {
786 let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
787 let c_doc = CString::new(doc).unwrap();
788 let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
789 assert_eq!(result.count, 1);
790 assert!(!result.json.is_null());
791 let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
792 let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
793 assert_eq!(parsed[0]["name"], "status");
794 assert_eq!(parsed[0]["content"], "hello\n");
795 unsafe { agent_doc_free_string(result.json) };
796 }
797
798 #[test]
799 fn apply_patch_replace() {
800 let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
801 let c_doc = CString::new(doc).unwrap();
802 let c_name = CString::new("output").unwrap();
803 let c_content = CString::new("new content\n").unwrap();
804 let c_mode = CString::new("replace").unwrap();
805 let result = unsafe {
806 agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
807 };
808 assert!(result.error.is_null());
809 assert!(!result.text.is_null());
810 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
811 assert!(text.contains("new content"));
812 assert!(!text.contains("old"));
813 unsafe { agent_doc_free_string(result.text) };
814 }
815
816 #[test]
817 fn merge_frontmatter_adds_field() {
818 let doc = "---\nagent_doc_session: abc\n---\nBody\n";
819 let fields = "model: opus";
820 let c_doc = CString::new(doc).unwrap();
821 let c_fields = CString::new(fields).unwrap();
822 let result = unsafe {
823 agent_doc_merge_frontmatter(c_doc.as_ptr(), c_fields.as_ptr())
824 };
825 assert!(result.error.is_null());
826 assert!(!result.text.is_null());
827 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
828 assert!(text.contains("model: opus"));
829 assert!(text.contains("agent_doc_session: abc"));
830 assert!(text.contains("Body"));
831 unsafe { agent_doc_free_string(result.text) };
832 }
833
834 #[test]
835 fn reposition_boundary_removes_stale() {
836 let doc = "<!-- agent:exchange patch=append -->\ntext\n<!-- agent:boundary:aaaa1111 -->\nmore\n<!-- agent:boundary:bbbb2222 -->\n<!-- /agent:exchange -->\n";
837 let c_doc = CString::new(doc).unwrap();
838 let result = unsafe { agent_doc_reposition_boundary_to_end(c_doc.as_ptr()) };
839 assert!(result.error.is_null());
840 assert!(!result.text.is_null());
841 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
842 let boundary_count = text.matches("<!-- agent:boundary:").count();
844 assert_eq!(boundary_count, 1, "should have exactly 1 boundary, got {}", boundary_count);
845 assert!(text.contains("more\n<!-- agent:boundary:"));
847 assert!(text.contains(" -->\n<!-- /agent:exchange -->"));
848 unsafe { agent_doc_free_string(result.text) };
849 }
850
851 #[test]
852 fn is_idle_untracked_returns_true() {
853 let path = CString::new("/tmp/ffi-test-untracked-file.md").unwrap();
854 let result = unsafe { agent_doc_is_idle(path.as_ptr(), 500) };
855 assert!(result, "untracked file should report idle");
856 }
857
858 #[test]
859 fn is_idle_after_change_returns_false() {
860 let path = CString::new("/tmp/ffi-test-just-changed.md").unwrap();
861 unsafe { agent_doc_document_changed(path.as_ptr()) };
862 let result = unsafe { agent_doc_is_idle(path.as_ptr(), 2000) };
863 assert!(!result, "file changed <2s ago should not be idle with 2000ms window");
864 }
865
866 #[test]
867 fn crdt_merge_no_base() {
868 let c_ours = CString::new("hello world").unwrap();
869 let c_theirs = CString::new("hello world").unwrap();
870 let result = unsafe {
871 agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
872 };
873 assert!(result.error.is_null());
874 assert!(!result.text.is_null());
875 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
876 assert_eq!(text, "hello world");
877 unsafe {
878 agent_doc_free_string(result.text);
879 agent_doc_free_state(result.state, result.state_len);
880 };
881 }
882}