1use std::ffi::{CStr, CString, c_char};
49use std::ptr;
50
51use crate::component;
52use crate::crdt;
53use crate::frontmatter;
54use crate::template;
55
56#[repr(C)]
58pub struct FfiComponentList {
59 pub json: *mut c_char,
61 pub count: usize,
63}
64
65#[repr(C)]
67pub struct FfiPatchResult {
68 pub text: *mut c_char,
70 pub error: *mut c_char,
72}
73
74#[repr(C)]
76pub struct FfiMergeResult {
77 pub text: *mut c_char,
79 pub state: *mut u8,
81 pub state_len: usize,
83 pub error: *mut c_char,
85}
86
87#[unsafe(no_mangle)]
97pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
98 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
99 Ok(s) => s,
100 Err(_) => {
101 return FfiComponentList {
102 json: ptr::null_mut(),
103 count: 0,
104 };
105 }
106 };
107
108 let components = match component::parse(doc_str) {
109 Ok(c) => c,
110 Err(_) => {
111 return FfiComponentList {
112 json: ptr::null_mut(),
113 count: 0,
114 };
115 }
116 };
117
118 let count = components.len();
119
120 let json_items: Vec<serde_json::Value> = components
122 .iter()
123 .map(|c| {
124 serde_json::json!({
125 "name": c.name,
126 "attrs": c.attrs,
127 "open_start": c.open_start,
128 "open_end": c.open_end,
129 "close_start": c.close_start,
130 "close_end": c.close_end,
131 "content": c.content(doc_str),
132 })
133 })
134 .collect();
135
136 let json_str = serde_json::to_string(&json_items).unwrap_or_default();
137 let c_json = CString::new(json_str).unwrap_or_default();
138
139 FfiComponentList {
140 json: c_json.into_raw(),
141 count,
142 }
143}
144
145#[unsafe(no_mangle)]
153pub unsafe extern "C" fn agent_doc_apply_patch(
154 doc: *const c_char,
155 component_name: *const c_char,
156 content: *const c_char,
157 mode: *const c_char,
158) -> FfiPatchResult {
159 let make_err = |msg: &str| FfiPatchResult {
160 text: ptr::null_mut(),
161 error: CString::new(msg).unwrap_or_default().into_raw(),
162 };
163
164 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
165 Ok(s) => s,
166 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
167 };
168 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
169 Ok(s) => s,
170 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
171 };
172 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
173 Ok(s) => s,
174 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
175 };
176 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
177 Ok(s) => s,
178 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
179 };
180
181 let patch = template::PatchBlock {
183 name: name.to_string(),
184 content: patch_content.to_string(),
185 };
186
187 let mut overrides = std::collections::HashMap::new();
189 overrides.insert(name.to_string(), mode_str.to_string());
190
191 let dummy_path = std::path::Path::new("/dev/null");
194 match template::apply_patches_with_overrides(
195 doc_str,
196 &[patch],
197 "",
198 dummy_path,
199 &overrides,
200 ) {
201 Ok(result) => FfiPatchResult {
202 text: CString::new(result).unwrap_or_default().into_raw(),
203 error: ptr::null_mut(),
204 },
205 Err(e) => make_err(&format!("{e}")),
206 }
207}
208
209#[unsafe(no_mangle)]
221pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
222 doc: *const c_char,
223 component_name: *const c_char,
224 content: *const c_char,
225 mode: *const c_char,
226 caret_offset: i32,
227) -> FfiPatchResult {
228 let make_err = |msg: &str| FfiPatchResult {
229 text: ptr::null_mut(),
230 error: CString::new(msg).unwrap_or_default().into_raw(),
231 };
232
233 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
234 Ok(s) => s,
235 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
236 };
237 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
238 Ok(s) => s,
239 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
240 };
241 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
242 Ok(s) => s,
243 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
244 };
245 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
246 Ok(s) => s,
247 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
248 };
249
250 if mode_str == "append" && caret_offset >= 0 {
252 let components = match component::parse(doc_str) {
253 Ok(c) => c,
254 Err(e) => return make_err(&format!("{e}")),
255 };
256 if let Some(comp) = components.iter().find(|c| c.name == name) {
257 let result = comp.append_with_caret(
258 doc_str,
259 patch_content,
260 Some(caret_offset as usize),
261 );
262 return FfiPatchResult {
263 text: CString::new(result).unwrap_or_default().into_raw(),
264 error: ptr::null_mut(),
265 };
266 }
267 }
268
269 let patch = template::PatchBlock {
271 name: name.to_string(),
272 content: patch_content.to_string(),
273 };
274 let mut overrides = std::collections::HashMap::new();
275 overrides.insert(name.to_string(), mode_str.to_string());
276 let dummy_path = std::path::Path::new("/dev/null");
277 match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
278 Ok(result) => FfiPatchResult {
279 text: CString::new(result).unwrap_or_default().into_raw(),
280 error: ptr::null_mut(),
281 },
282 Err(e) => make_err(&format!("{e}")),
283 }
284}
285
286#[unsafe(no_mangle)]
299pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
300 doc: *const c_char,
301 component_name: *const c_char,
302 content: *const c_char,
303 mode: *const c_char,
304 boundary_id: *const c_char,
305) -> FfiPatchResult {
306 let make_err = |msg: &str| FfiPatchResult {
307 text: ptr::null_mut(),
308 error: CString::new(msg).unwrap_or_default().into_raw(),
309 };
310
311 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
312 Ok(s) => s,
313 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
314 };
315 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
316 Ok(s) => s,
317 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
318 };
319 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
320 Ok(s) => s,
321 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
322 };
323 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
324 Ok(s) => s,
325 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
326 };
327 let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
328 Ok(s) => s,
329 Err(e) => return make_err(&format!("invalid boundary_id UTF-8: {e}")),
330 };
331
332 if mode_str == "append" && !bid.is_empty() {
334 let components = match component::parse(doc_str) {
335 Ok(c) => c,
336 Err(e) => return make_err(&format!("{e}")),
337 };
338 if let Some(comp) = components.iter().find(|c| c.name == name) {
339 let result = comp.append_with_boundary(doc_str, patch_content, bid);
340 return FfiPatchResult {
341 text: CString::new(result).unwrap_or_default().into_raw(),
342 error: ptr::null_mut(),
343 };
344 }
345 }
346
347 let patch = template::PatchBlock {
349 name: name.to_string(),
350 content: patch_content.to_string(),
351 };
352 let mut overrides = std::collections::HashMap::new();
353 overrides.insert(name.to_string(), mode_str.to_string());
354 let dummy_path = std::path::Path::new("/dev/null");
355 match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
356 Ok(result) => FfiPatchResult {
357 text: CString::new(result).unwrap_or_default().into_raw(),
358 error: ptr::null_mut(),
359 },
360 Err(e) => make_err(&format!("{e}")),
361 }
362}
363
364#[unsafe(no_mangle)]
375pub unsafe extern "C" fn agent_doc_crdt_merge(
376 base_state: *const u8,
377 base_state_len: usize,
378 ours: *const c_char,
379 theirs: *const c_char,
380) -> FfiMergeResult {
381 let make_err = |msg: &str| FfiMergeResult {
382 text: ptr::null_mut(),
383 state: ptr::null_mut(),
384 state_len: 0,
385 error: CString::new(msg).unwrap_or_default().into_raw(),
386 };
387
388 let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
389 Ok(s) => s,
390 Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
391 };
392 let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
393 Ok(s) => s,
394 Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
395 };
396
397 let base = if base_state.is_null() {
398 None
399 } else {
400 Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
401 };
402
403 match crdt::merge(base, ours_str, theirs_str) {
404 Ok(merged_text) => {
405 let doc = crdt::CrdtDoc::from_text(&merged_text);
407 let state_bytes = doc.encode_state();
408 let state_len = state_bytes.len();
409 let state_ptr = {
410 let mut boxed = state_bytes.into_boxed_slice();
411 let ptr = boxed.as_mut_ptr();
412 std::mem::forget(boxed);
413 ptr
414 };
415
416 FfiMergeResult {
417 text: CString::new(merged_text).unwrap_or_default().into_raw(),
418 state: state_ptr,
419 state_len,
420 error: ptr::null_mut(),
421 }
422 }
423 Err(e) => make_err(&format!("{e}")),
424 }
425}
426
427#[unsafe(no_mangle)]
436pub unsafe extern "C" fn agent_doc_merge_frontmatter(
437 doc: *const c_char,
438 yaml_fields: *const c_char,
439) -> FfiPatchResult {
440 let make_err = |msg: &str| FfiPatchResult {
441 text: ptr::null_mut(),
442 error: CString::new(msg).unwrap_or_default().into_raw(),
443 };
444
445 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
446 Ok(s) => s,
447 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
448 };
449 let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
450 Ok(s) => s,
451 Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
452 };
453
454 match frontmatter::merge_fields(doc_str, fields_str) {
455 Ok(result) => FfiPatchResult {
456 text: CString::new(result).unwrap_or_default().into_raw(),
457 error: ptr::null_mut(),
458 },
459 Err(e) => make_err(&format!("{e}")),
460 }
461}
462
463#[unsafe(no_mangle)]
473pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
474 doc: *const c_char,
475) -> FfiPatchResult {
476 let make_err = |msg: &str| FfiPatchResult {
477 text: ptr::null_mut(),
478 error: CString::new(msg).unwrap_or_default().into_raw(),
479 };
480
481 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
482 Ok(s) => s,
483 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
484 };
485
486 let result = template::reposition_boundary_to_end(doc_str);
487 FfiPatchResult {
488 text: CString::new(result).unwrap_or_default().into_raw(),
489 error: ptr::null_mut(),
490 }
491}
492
493#[unsafe(no_mangle)]
502pub unsafe extern "C" fn agent_doc_document_changed(file_path: *const c_char) {
503 if let Ok(path) = unsafe { CStr::from_ptr(file_path) }.to_str() {
504 crate::debounce::document_changed(path);
505 }
506}
507
508#[unsafe(no_mangle)]
519pub unsafe extern "C" fn agent_doc_is_tracked(file_path: *const c_char) -> bool {
520 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
521 Ok(s) => s,
522 Err(_) => return false,
523 };
524 crate::debounce::is_tracked(path)
525}
526
527#[unsafe(no_mangle)]
536pub unsafe extern "C" fn agent_doc_await_idle(
537 file_path: *const c_char,
538 debounce_ms: i64,
539 timeout_ms: i64,
540) -> bool {
541 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
542 Ok(s) => s,
543 Err(_) => return true, };
545 crate::debounce::await_idle(path, debounce_ms as u64, timeout_ms as u64)
546}
547
548#[unsafe(no_mangle)]
557pub unsafe extern "C" fn agent_doc_set_status(
558 file_path: *const c_char,
559 status: *const c_char,
560) {
561 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
562 Ok(s) => s,
563 Err(_) => return,
564 };
565 let st = match unsafe { CStr::from_ptr(status) }.to_str() {
566 Ok(s) => s,
567 Err(_) => return,
568 };
569 crate::debounce::set_status(path, st);
570}
571
572#[unsafe(no_mangle)]
581pub unsafe extern "C" fn agent_doc_get_status(file_path: *const c_char) -> *mut c_char {
582 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
583 Ok(s) => s,
584 Err(_) => return CString::new("idle").unwrap().into_raw(),
585 };
586 let status = crate::debounce::get_status(path);
587 CString::new(status).unwrap_or_else(|_| CString::new("idle").unwrap()).into_raw()
588}
589
590#[unsafe(no_mangle)]
599pub unsafe extern "C" fn agent_doc_is_busy(file_path: *const c_char) -> bool {
600 let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
601 Ok(s) => s,
602 Err(_) => return false,
603 };
604 crate::debounce::is_busy(path)
605}
606
607#[unsafe(no_mangle)]
612pub extern "C" fn agent_doc_version() -> *mut c_char {
613 CString::new(env!("CARGO_PKG_VERSION")).unwrap().into_raw()
614}
615
616#[unsafe(no_mangle)]
622pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
623 if !ptr.is_null() {
624 drop(unsafe { CString::from_raw(ptr) });
625 }
626}
627
628#[unsafe(no_mangle)]
634pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
635 if !ptr.is_null() {
636 drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
645 fn parse_components_roundtrip() {
646 let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
647 let c_doc = CString::new(doc).unwrap();
648 let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
649 assert_eq!(result.count, 1);
650 assert!(!result.json.is_null());
651 let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
652 let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
653 assert_eq!(parsed[0]["name"], "status");
654 assert_eq!(parsed[0]["content"], "hello\n");
655 unsafe { agent_doc_free_string(result.json) };
656 }
657
658 #[test]
659 fn apply_patch_replace() {
660 let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
661 let c_doc = CString::new(doc).unwrap();
662 let c_name = CString::new("output").unwrap();
663 let c_content = CString::new("new content\n").unwrap();
664 let c_mode = CString::new("replace").unwrap();
665 let result = unsafe {
666 agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
667 };
668 assert!(result.error.is_null());
669 assert!(!result.text.is_null());
670 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
671 assert!(text.contains("new content"));
672 assert!(!text.contains("old"));
673 unsafe { agent_doc_free_string(result.text) };
674 }
675
676 #[test]
677 fn merge_frontmatter_adds_field() {
678 let doc = "---\nagent_doc_session: abc\n---\nBody\n";
679 let fields = "model: opus";
680 let c_doc = CString::new(doc).unwrap();
681 let c_fields = CString::new(fields).unwrap();
682 let result = unsafe {
683 agent_doc_merge_frontmatter(c_doc.as_ptr(), c_fields.as_ptr())
684 };
685 assert!(result.error.is_null());
686 assert!(!result.text.is_null());
687 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
688 assert!(text.contains("model: opus"));
689 assert!(text.contains("agent_doc_session: abc"));
690 assert!(text.contains("Body"));
691 unsafe { agent_doc_free_string(result.text) };
692 }
693
694 #[test]
695 fn reposition_boundary_removes_stale() {
696 let doc = "<!-- agent:exchange patch=append -->\ntext\n<!-- agent:boundary:aaaa1111 -->\nmore\n<!-- agent:boundary:bbbb2222 -->\n<!-- /agent:exchange -->\n";
697 let c_doc = CString::new(doc).unwrap();
698 let result = unsafe { agent_doc_reposition_boundary_to_end(c_doc.as_ptr()) };
699 assert!(result.error.is_null());
700 assert!(!result.text.is_null());
701 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
702 let boundary_count = text.matches("<!-- agent:boundary:").count();
704 assert_eq!(boundary_count, 1, "should have exactly 1 boundary, got {}", boundary_count);
705 assert!(text.contains("more\n<!-- agent:boundary:"));
707 assert!(text.contains(" -->\n<!-- /agent:exchange -->"));
708 unsafe { agent_doc_free_string(result.text) };
709 }
710
711 #[test]
712 fn crdt_merge_no_base() {
713 let c_ours = CString::new("hello world").unwrap();
714 let c_theirs = CString::new("hello world").unwrap();
715 let result = unsafe {
716 agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
717 };
718 assert!(result.error.is_null());
719 assert!(!result.text.is_null());
720 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
721 assert_eq!(text, "hello world");
722 unsafe {
723 agent_doc_free_string(result.text);
724 agent_doc_free_state(result.state, result.state_len);
725 };
726 }
727}