1use std::ffi::{CStr, CString, c_char};
49use std::ptr;
50use std::sync::atomic::{AtomicBool, Ordering};
51
52use crate::component;
53use crate::crdt;
54use crate::frontmatter;
55use crate::template;
56
57static SYNC_LOCKED: AtomicBool = AtomicBool::new(false);
59
60static SYNC_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
62
63#[repr(C)]
65pub struct FfiComponentList {
66 pub json: *mut c_char,
68 pub count: usize,
70}
71
72#[repr(C)]
74pub struct FfiPatchResult {
75 pub text: *mut c_char,
77 pub error: *mut c_char,
79}
80
81#[repr(C)]
83pub struct FfiMergeResult {
84 pub text: *mut c_char,
86 pub state: *mut u8,
88 pub state_len: usize,
90 pub error: *mut c_char,
92}
93
94#[unsafe(no_mangle)]
104pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
105 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
106 Ok(s) => s,
107 Err(_) => {
108 return FfiComponentList {
109 json: ptr::null_mut(),
110 count: 0,
111 };
112 }
113 };
114
115 let components = match component::parse(doc_str) {
116 Ok(c) => c,
117 Err(_) => {
118 return FfiComponentList {
119 json: ptr::null_mut(),
120 count: 0,
121 };
122 }
123 };
124
125 let count = components.len();
126
127 let json_items: Vec<serde_json::Value> = components
129 .iter()
130 .map(|c| {
131 serde_json::json!({
132 "name": c.name,
133 "attrs": c.attrs,
134 "open_start": c.open_start,
135 "open_end": c.open_end,
136 "close_start": c.close_start,
137 "close_end": c.close_end,
138 "content": c.content(doc_str),
139 })
140 })
141 .collect();
142
143 let json_str = serde_json::to_string(&json_items).unwrap_or_default();
144 let c_json = CString::new(json_str).unwrap_or_default();
145
146 FfiComponentList {
147 json: c_json.into_raw(),
148 count,
149 }
150}
151
152#[unsafe(no_mangle)]
160pub unsafe extern "C" fn agent_doc_apply_patch(
161 doc: *const c_char,
162 component_name: *const c_char,
163 content: *const c_char,
164 mode: *const c_char,
165) -> FfiPatchResult {
166 let make_err = |msg: &str| FfiPatchResult {
167 text: ptr::null_mut(),
168 error: CString::new(msg).unwrap_or_default().into_raw(),
169 };
170
171 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
172 Ok(s) => s,
173 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
174 };
175 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
176 Ok(s) => s,
177 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
178 };
179 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
180 Ok(s) => s,
181 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
182 };
183 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
184 Ok(s) => s,
185 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
186 };
187
188 let patch = template::PatchBlock {
190 name: name.to_string(),
191 content: patch_content.to_string(),
192 };
193
194 let mut overrides = std::collections::HashMap::new();
196 overrides.insert(name.to_string(), mode_str.to_string());
197
198 let dummy_path = std::path::Path::new("/dev/null");
201 match template::apply_patches_with_overrides(
202 doc_str,
203 &[patch],
204 "",
205 dummy_path,
206 &overrides,
207 ) {
208 Ok(result) => FfiPatchResult {
209 text: CString::new(result).unwrap_or_default().into_raw(),
210 error: ptr::null_mut(),
211 },
212 Err(e) => make_err(&format!("{e}")),
213 }
214}
215
216#[unsafe(no_mangle)]
228pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
229 doc: *const c_char,
230 component_name: *const c_char,
231 content: *const c_char,
232 mode: *const c_char,
233 caret_offset: i32,
234) -> FfiPatchResult {
235 let make_err = |msg: &str| FfiPatchResult {
236 text: ptr::null_mut(),
237 error: CString::new(msg).unwrap_or_default().into_raw(),
238 };
239
240 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
241 Ok(s) => s,
242 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
243 };
244 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
245 Ok(s) => s,
246 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
247 };
248 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
249 Ok(s) => s,
250 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
251 };
252 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
253 Ok(s) => s,
254 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
255 };
256
257 if mode_str == "append" && caret_offset >= 0 {
259 let components = match component::parse(doc_str) {
260 Ok(c) => c,
261 Err(e) => return make_err(&format!("{e}")),
262 };
263 if let Some(comp) = components.iter().find(|c| c.name == name) {
264 let result = comp.append_with_caret(
265 doc_str,
266 patch_content,
267 Some(caret_offset as usize),
268 );
269 return FfiPatchResult {
270 text: CString::new(result).unwrap_or_default().into_raw(),
271 error: ptr::null_mut(),
272 };
273 }
274 }
275
276 let patch = template::PatchBlock {
278 name: name.to_string(),
279 content: patch_content.to_string(),
280 };
281 let mut overrides = std::collections::HashMap::new();
282 overrides.insert(name.to_string(), mode_str.to_string());
283 let dummy_path = std::path::Path::new("/dev/null");
284 match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
285 Ok(result) => FfiPatchResult {
286 text: CString::new(result).unwrap_or_default().into_raw(),
287 error: ptr::null_mut(),
288 },
289 Err(e) => make_err(&format!("{e}")),
290 }
291}
292
293#[unsafe(no_mangle)]
306pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
307 doc: *const c_char,
308 component_name: *const c_char,
309 content: *const c_char,
310 mode: *const c_char,
311 boundary_id: *const c_char,
312) -> FfiPatchResult {
313 let make_err = |msg: &str| FfiPatchResult {
314 text: ptr::null_mut(),
315 error: CString::new(msg).unwrap_or_default().into_raw(),
316 };
317
318 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
319 Ok(s) => s,
320 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
321 };
322 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
323 Ok(s) => s,
324 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
325 };
326 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
327 Ok(s) => s,
328 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
329 };
330 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
331 Ok(s) => s,
332 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
333 };
334 let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
335 Ok(s) => s,
336 Err(e) => return make_err(&format!("invalid boundary_id UTF-8: {e}")),
337 };
338
339 if mode_str == "append" && !bid.is_empty() {
341 let components = match component::parse(doc_str) {
342 Ok(c) => c,
343 Err(e) => return make_err(&format!("{e}")),
344 };
345 if let Some(comp) = components.iter().find(|c| c.name == name) {
346 let result = comp.append_with_boundary(doc_str, patch_content, bid);
347 return FfiPatchResult {
348 text: CString::new(result).unwrap_or_default().into_raw(),
349 error: ptr::null_mut(),
350 };
351 }
352 }
353
354 let patch = template::PatchBlock {
356 name: name.to_string(),
357 content: patch_content.to_string(),
358 };
359 let mut overrides = std::collections::HashMap::new();
360 overrides.insert(name.to_string(), mode_str.to_string());
361 let dummy_path = std::path::Path::new("/dev/null");
362 match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
363 Ok(result) => FfiPatchResult {
364 text: CString::new(result).unwrap_or_default().into_raw(),
365 error: ptr::null_mut(),
366 },
367 Err(e) => make_err(&format!("{e}")),
368 }
369}
370
371#[unsafe(no_mangle)]
382pub unsafe extern "C" fn agent_doc_crdt_merge(
383 base_state: *const u8,
384 base_state_len: usize,
385 ours: *const c_char,
386 theirs: *const c_char,
387) -> FfiMergeResult {
388 let make_err = |msg: &str| FfiMergeResult {
389 text: ptr::null_mut(),
390 state: ptr::null_mut(),
391 state_len: 0,
392 error: CString::new(msg).unwrap_or_default().into_raw(),
393 };
394
395 let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
396 Ok(s) => s,
397 Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
398 };
399 let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
400 Ok(s) => s,
401 Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
402 };
403
404 let base = if base_state.is_null() {
405 None
406 } else {
407 Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
408 };
409
410 match crdt::merge(base, ours_str, theirs_str) {
411 Ok(merged_text) => {
412 let doc = crdt::CrdtDoc::from_text(&merged_text);
414 let state_bytes = doc.encode_state();
415 let state_len = state_bytes.len();
416 let state_ptr = {
417 let mut boxed = state_bytes.into_boxed_slice();
418 let ptr = boxed.as_mut_ptr();
419 std::mem::forget(boxed);
420 ptr
421 };
422
423 FfiMergeResult {
424 text: CString::new(merged_text).unwrap_or_default().into_raw(),
425 state: state_ptr,
426 state_len,
427 error: ptr::null_mut(),
428 }
429 }
430 Err(e) => make_err(&format!("{e}")),
431 }
432}
433
434#[unsafe(no_mangle)]
443pub unsafe extern "C" fn agent_doc_merge_frontmatter(
444 doc: *const c_char,
445 yaml_fields: *const c_char,
446) -> FfiPatchResult {
447 let make_err = |msg: &str| FfiPatchResult {
448 text: ptr::null_mut(),
449 error: CString::new(msg).unwrap_or_default().into_raw(),
450 };
451
452 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
453 Ok(s) => s,
454 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
455 };
456 let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
457 Ok(s) => s,
458 Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
459 };
460
461 match frontmatter::merge_fields(doc_str, fields_str) {
462 Ok(result) => FfiPatchResult {
463 text: CString::new(result).unwrap_or_default().into_raw(),
464 error: ptr::null_mut(),
465 },
466 Err(e) => make_err(&format!("{e}")),
467 }
468}
469
470#[unsafe(no_mangle)]
480pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
481 doc: *const c_char,
482) -> FfiPatchResult {
483 let make_err = |msg: &str| FfiPatchResult {
484 text: ptr::null_mut(),
485 error: CString::new(msg).unwrap_or_default().into_raw(),
486 };
487
488 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
489 Ok(s) => s,
490 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
491 };
492
493 let result = template::reposition_boundary_to_end(doc_str);
494 FfiPatchResult {
495 text: CString::new(result).unwrap_or_default().into_raw(),
496 error: ptr::null_mut(),
497 }
498}
499
500#[unsafe(no_mangle)]
509pub unsafe extern "C" fn agent_doc_document_changed(file_path: *const c_char) {
510 if let Ok(path) = unsafe { CStr::from_ptr(file_path) }.to_str() {
511 crate::debounce::document_changed(path);
512 }
513}
514
515#[unsafe(no_mangle)]
526pub unsafe extern "C" fn agent_doc_is_tracked(file_path: *const c_char) -> bool {
527 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
528 Ok(s) => s,
529 Err(_) => return false,
530 };
531 crate::debounce::is_tracked(path)
532}
533
534#[unsafe(no_mangle)]
543pub unsafe extern "C" fn agent_doc_await_idle(
544 file_path: *const c_char,
545 debounce_ms: i64,
546 timeout_ms: i64,
547) -> bool {
548 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
549 Ok(s) => s,
550 Err(_) => return true, };
552 crate::debounce::await_idle(path, debounce_ms as u64, timeout_ms as u64)
553}
554
555#[unsafe(no_mangle)]
564pub unsafe extern "C" fn agent_doc_set_status(
565 file_path: *const c_char,
566 status: *const c_char,
567) {
568 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
569 Ok(s) => s,
570 Err(_) => return,
571 };
572 let st = match unsafe { CStr::from_ptr(status) }.to_str() {
573 Ok(s) => s,
574 Err(_) => return,
575 };
576 crate::debounce::set_status(path, st);
577}
578
579#[unsafe(no_mangle)]
588pub unsafe extern "C" fn agent_doc_get_status(file_path: *const c_char) -> *mut c_char {
589 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
590 Ok(s) => s,
591 Err(_) => return CString::new("idle").unwrap().into_raw(),
592 };
593 let status = crate::debounce::get_status(path);
594 CString::new(status).unwrap_or_else(|_| CString::new("idle").unwrap()).into_raw()
595}
596
597#[unsafe(no_mangle)]
606pub unsafe extern "C" fn agent_doc_is_busy(file_path: *const c_char) -> bool {
607 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
608 Ok(s) => s,
609 Err(_) => return false,
610 };
611 crate::debounce::is_busy(path)
612}
613
614#[unsafe(no_mangle)]
623pub extern "C" fn agent_doc_sync_try_lock() -> bool {
624 SYNC_LOCKED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_ok()
625}
626
627#[unsafe(no_mangle)]
629pub extern "C" fn agent_doc_sync_unlock() {
630 SYNC_LOCKED.store(false, Ordering::SeqCst);
631}
632
633#[unsafe(no_mangle)]
640pub extern "C" fn agent_doc_sync_bump_generation() -> u64 {
641 SYNC_GENERATION.fetch_add(1, Ordering::SeqCst) + 1
642}
643
644#[unsafe(no_mangle)]
647pub extern "C" fn agent_doc_sync_check_generation(generation: u64) -> bool {
648 SYNC_GENERATION.load(Ordering::SeqCst) == generation
649}
650
651#[unsafe(no_mangle)]
656pub extern "C" fn agent_doc_version() -> *mut c_char {
657 CString::new(env!("CARGO_PKG_VERSION")).unwrap().into_raw()
658}
659
660#[unsafe(no_mangle)]
666pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
667 if !ptr.is_null() {
668 drop(unsafe { CString::from_raw(ptr) });
669 }
670}
671
672#[unsafe(no_mangle)]
678pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
679 if !ptr.is_null() {
680 drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
681 }
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 #[test]
689 fn parse_components_roundtrip() {
690 let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
691 let c_doc = CString::new(doc).unwrap();
692 let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
693 assert_eq!(result.count, 1);
694 assert!(!result.json.is_null());
695 let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
696 let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
697 assert_eq!(parsed[0]["name"], "status");
698 assert_eq!(parsed[0]["content"], "hello\n");
699 unsafe { agent_doc_free_string(result.json) };
700 }
701
702 #[test]
703 fn apply_patch_replace() {
704 let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
705 let c_doc = CString::new(doc).unwrap();
706 let c_name = CString::new("output").unwrap();
707 let c_content = CString::new("new content\n").unwrap();
708 let c_mode = CString::new("replace").unwrap();
709 let result = unsafe {
710 agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
711 };
712 assert!(result.error.is_null());
713 assert!(!result.text.is_null());
714 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
715 assert!(text.contains("new content"));
716 assert!(!text.contains("old"));
717 unsafe { agent_doc_free_string(result.text) };
718 }
719
720 #[test]
721 fn merge_frontmatter_adds_field() {
722 let doc = "---\nagent_doc_session: abc\n---\nBody\n";
723 let fields = "model: opus";
724 let c_doc = CString::new(doc).unwrap();
725 let c_fields = CString::new(fields).unwrap();
726 let result = unsafe {
727 agent_doc_merge_frontmatter(c_doc.as_ptr(), c_fields.as_ptr())
728 };
729 assert!(result.error.is_null());
730 assert!(!result.text.is_null());
731 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
732 assert!(text.contains("model: opus"));
733 assert!(text.contains("agent_doc_session: abc"));
734 assert!(text.contains("Body"));
735 unsafe { agent_doc_free_string(result.text) };
736 }
737
738 #[test]
739 fn reposition_boundary_removes_stale() {
740 let doc = "<!-- agent:exchange patch=append -->\ntext\n<!-- agent:boundary:aaaa1111 -->\nmore\n<!-- agent:boundary:bbbb2222 -->\n<!-- /agent:exchange -->\n";
741 let c_doc = CString::new(doc).unwrap();
742 let result = unsafe { agent_doc_reposition_boundary_to_end(c_doc.as_ptr()) };
743 assert!(result.error.is_null());
744 assert!(!result.text.is_null());
745 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
746 let boundary_count = text.matches("<!-- agent:boundary:").count();
748 assert_eq!(boundary_count, 1, "should have exactly 1 boundary, got {}", boundary_count);
749 assert!(text.contains("more\n<!-- agent:boundary:"));
751 assert!(text.contains(" -->\n<!-- /agent:exchange -->"));
752 unsafe { agent_doc_free_string(result.text) };
753 }
754
755 #[test]
756 fn crdt_merge_no_base() {
757 let c_ours = CString::new("hello world").unwrap();
758 let c_theirs = CString::new("hello world").unwrap();
759 let result = unsafe {
760 agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
761 };
762 assert!(result.error.is_null());
763 assert!(!result.text.is_null());
764 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
765 assert_eq!(text, "hello world");
766 unsafe {
767 agent_doc_free_string(result.text);
768 agent_doc_free_state(result.state, result.state_len);
769 };
770 }
771}