1use std::ffi::{CStr, CString, c_char};
14use std::ptr;
15
16use crate::component;
17use crate::crdt;
18use crate::frontmatter;
19use crate::template;
20
21#[repr(C)]
23pub struct FfiComponentList {
24 pub json: *mut c_char,
26 pub count: usize,
28}
29
30#[repr(C)]
32pub struct FfiPatchResult {
33 pub text: *mut c_char,
35 pub error: *mut c_char,
37}
38
39#[repr(C)]
41pub struct FfiMergeResult {
42 pub text: *mut c_char,
44 pub state: *mut u8,
46 pub state_len: usize,
48 pub error: *mut c_char,
50}
51
52#[unsafe(no_mangle)]
62pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
63 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
64 Ok(s) => s,
65 Err(_) => {
66 return FfiComponentList {
67 json: ptr::null_mut(),
68 count: 0,
69 };
70 }
71 };
72
73 let components = match component::parse(doc_str) {
74 Ok(c) => c,
75 Err(_) => {
76 return FfiComponentList {
77 json: ptr::null_mut(),
78 count: 0,
79 };
80 }
81 };
82
83 let count = components.len();
84
85 let json_items: Vec<serde_json::Value> = components
87 .iter()
88 .map(|c| {
89 serde_json::json!({
90 "name": c.name,
91 "attrs": c.attrs,
92 "open_start": c.open_start,
93 "open_end": c.open_end,
94 "close_start": c.close_start,
95 "close_end": c.close_end,
96 "content": c.content(doc_str),
97 })
98 })
99 .collect();
100
101 let json_str = serde_json::to_string(&json_items).unwrap_or_default();
102 let c_json = CString::new(json_str).unwrap_or_default();
103
104 FfiComponentList {
105 json: c_json.into_raw(),
106 count,
107 }
108}
109
110#[unsafe(no_mangle)]
118pub unsafe extern "C" fn agent_doc_apply_patch(
119 doc: *const c_char,
120 component_name: *const c_char,
121 content: *const c_char,
122 mode: *const c_char,
123) -> FfiPatchResult {
124 let make_err = |msg: &str| FfiPatchResult {
125 text: ptr::null_mut(),
126 error: CString::new(msg).unwrap_or_default().into_raw(),
127 };
128
129 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
130 Ok(s) => s,
131 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
132 };
133 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
134 Ok(s) => s,
135 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
136 };
137 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
138 Ok(s) => s,
139 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
140 };
141 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
142 Ok(s) => s,
143 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
144 };
145
146 let patch = template::PatchBlock {
148 name: name.to_string(),
149 content: patch_content.to_string(),
150 };
151
152 let mut overrides = std::collections::HashMap::new();
154 overrides.insert(name.to_string(), mode_str.to_string());
155
156 let dummy_path = std::path::Path::new("/dev/null");
159 match template::apply_patches_with_overrides(
160 doc_str,
161 &[patch],
162 "",
163 dummy_path,
164 &overrides,
165 ) {
166 Ok(result) => FfiPatchResult {
167 text: CString::new(result).unwrap_or_default().into_raw(),
168 error: ptr::null_mut(),
169 },
170 Err(e) => make_err(&format!("{e}")),
171 }
172}
173
174#[unsafe(no_mangle)]
186pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
187 doc: *const c_char,
188 component_name: *const c_char,
189 content: *const c_char,
190 mode: *const c_char,
191 caret_offset: i32,
192) -> FfiPatchResult {
193 let make_err = |msg: &str| FfiPatchResult {
194 text: ptr::null_mut(),
195 error: CString::new(msg).unwrap_or_default().into_raw(),
196 };
197
198 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
199 Ok(s) => s,
200 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
201 };
202 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
203 Ok(s) => s,
204 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
205 };
206 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
207 Ok(s) => s,
208 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
209 };
210 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
211 Ok(s) => s,
212 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
213 };
214
215 if mode_str == "append" && caret_offset >= 0 {
217 let components = match component::parse(doc_str) {
218 Ok(c) => c,
219 Err(e) => return make_err(&format!("{e}")),
220 };
221 if let Some(comp) = components.iter().find(|c| c.name == name) {
222 let result = comp.append_with_caret(
223 doc_str,
224 patch_content,
225 Some(caret_offset as usize),
226 );
227 return FfiPatchResult {
228 text: CString::new(result).unwrap_or_default().into_raw(),
229 error: ptr::null_mut(),
230 };
231 }
232 }
233
234 let patch = template::PatchBlock {
236 name: name.to_string(),
237 content: patch_content.to_string(),
238 };
239 let mut overrides = std::collections::HashMap::new();
240 overrides.insert(name.to_string(), mode_str.to_string());
241 let dummy_path = std::path::Path::new("/dev/null");
242 match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
243 Ok(result) => FfiPatchResult {
244 text: CString::new(result).unwrap_or_default().into_raw(),
245 error: ptr::null_mut(),
246 },
247 Err(e) => make_err(&format!("{e}")),
248 }
249}
250
251#[unsafe(no_mangle)]
264pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
265 doc: *const c_char,
266 component_name: *const c_char,
267 content: *const c_char,
268 mode: *const c_char,
269 boundary_id: *const c_char,
270) -> FfiPatchResult {
271 let make_err = |msg: &str| FfiPatchResult {
272 text: ptr::null_mut(),
273 error: CString::new(msg).unwrap_or_default().into_raw(),
274 };
275
276 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
277 Ok(s) => s,
278 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
279 };
280 let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
281 Ok(s) => s,
282 Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
283 };
284 let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
285 Ok(s) => s,
286 Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
287 };
288 let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
289 Ok(s) => s,
290 Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
291 };
292 let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
293 Ok(s) => s,
294 Err(e) => return make_err(&format!("invalid boundary_id UTF-8: {e}")),
295 };
296
297 if mode_str == "append" && !bid.is_empty() {
299 let components = match component::parse(doc_str) {
300 Ok(c) => c,
301 Err(e) => return make_err(&format!("{e}")),
302 };
303 if let Some(comp) = components.iter().find(|c| c.name == name) {
304 let result = comp.append_with_boundary(doc_str, patch_content, bid);
305 return FfiPatchResult {
306 text: CString::new(result).unwrap_or_default().into_raw(),
307 error: ptr::null_mut(),
308 };
309 }
310 }
311
312 let patch = template::PatchBlock {
314 name: name.to_string(),
315 content: patch_content.to_string(),
316 };
317 let mut overrides = std::collections::HashMap::new();
318 overrides.insert(name.to_string(), mode_str.to_string());
319 let dummy_path = std::path::Path::new("/dev/null");
320 match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
321 Ok(result) => FfiPatchResult {
322 text: CString::new(result).unwrap_or_default().into_raw(),
323 error: ptr::null_mut(),
324 },
325 Err(e) => make_err(&format!("{e}")),
326 }
327}
328
329#[unsafe(no_mangle)]
340pub unsafe extern "C" fn agent_doc_crdt_merge(
341 base_state: *const u8,
342 base_state_len: usize,
343 ours: *const c_char,
344 theirs: *const c_char,
345) -> FfiMergeResult {
346 let make_err = |msg: &str| FfiMergeResult {
347 text: ptr::null_mut(),
348 state: ptr::null_mut(),
349 state_len: 0,
350 error: CString::new(msg).unwrap_or_default().into_raw(),
351 };
352
353 let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
354 Ok(s) => s,
355 Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
356 };
357 let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
358 Ok(s) => s,
359 Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
360 };
361
362 let base = if base_state.is_null() {
363 None
364 } else {
365 Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
366 };
367
368 match crdt::merge(base, ours_str, theirs_str) {
369 Ok(merged_text) => {
370 let doc = crdt::CrdtDoc::from_text(&merged_text);
372 let state_bytes = doc.encode_state();
373 let state_len = state_bytes.len();
374 let state_ptr = {
375 let mut boxed = state_bytes.into_boxed_slice();
376 let ptr = boxed.as_mut_ptr();
377 std::mem::forget(boxed);
378 ptr
379 };
380
381 FfiMergeResult {
382 text: CString::new(merged_text).unwrap_or_default().into_raw(),
383 state: state_ptr,
384 state_len,
385 error: ptr::null_mut(),
386 }
387 }
388 Err(e) => make_err(&format!("{e}")),
389 }
390}
391
392#[unsafe(no_mangle)]
401pub unsafe extern "C" fn agent_doc_merge_frontmatter(
402 doc: *const c_char,
403 yaml_fields: *const c_char,
404) -> FfiPatchResult {
405 let make_err = |msg: &str| FfiPatchResult {
406 text: ptr::null_mut(),
407 error: CString::new(msg).unwrap_or_default().into_raw(),
408 };
409
410 let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
411 Ok(s) => s,
412 Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
413 };
414 let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
415 Ok(s) => s,
416 Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
417 };
418
419 match frontmatter::merge_fields(doc_str, fields_str) {
420 Ok(result) => FfiPatchResult {
421 text: CString::new(result).unwrap_or_default().into_raw(),
422 error: ptr::null_mut(),
423 },
424 Err(e) => make_err(&format!("{e}")),
425 }
426}
427
428#[unsafe(no_mangle)]
434pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
435 if !ptr.is_null() {
436 drop(unsafe { CString::from_raw(ptr) });
437 }
438}
439
440#[unsafe(no_mangle)]
446pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
447 if !ptr.is_null() {
448 drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn parse_components_roundtrip() {
458 let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
459 let c_doc = CString::new(doc).unwrap();
460 let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
461 assert_eq!(result.count, 1);
462 assert!(!result.json.is_null());
463 let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
464 let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
465 assert_eq!(parsed[0]["name"], "status");
466 assert_eq!(parsed[0]["content"], "hello\n");
467 unsafe { agent_doc_free_string(result.json) };
468 }
469
470 #[test]
471 fn apply_patch_replace() {
472 let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
473 let c_doc = CString::new(doc).unwrap();
474 let c_name = CString::new("output").unwrap();
475 let c_content = CString::new("new content\n").unwrap();
476 let c_mode = CString::new("replace").unwrap();
477 let result = unsafe {
478 agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
479 };
480 assert!(result.error.is_null());
481 assert!(!result.text.is_null());
482 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
483 assert!(text.contains("new content"));
484 assert!(!text.contains("old"));
485 unsafe { agent_doc_free_string(result.text) };
486 }
487
488 #[test]
489 fn merge_frontmatter_adds_field() {
490 let doc = "---\nagent_doc_session: abc\n---\nBody\n";
491 let fields = "model: opus";
492 let c_doc = CString::new(doc).unwrap();
493 let c_fields = CString::new(fields).unwrap();
494 let result = unsafe {
495 agent_doc_merge_frontmatter(c_doc.as_ptr(), c_fields.as_ptr())
496 };
497 assert!(result.error.is_null());
498 assert!(!result.text.is_null());
499 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
500 assert!(text.contains("model: opus"));
501 assert!(text.contains("agent_doc_session: abc"));
502 assert!(text.contains("Body"));
503 unsafe { agent_doc_free_string(result.text) };
504 }
505
506 #[test]
507 fn crdt_merge_no_base() {
508 let c_ours = CString::new("hello world").unwrap();
509 let c_theirs = CString::new("hello world").unwrap();
510 let result = unsafe {
511 agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
512 };
513 assert!(result.error.is_null());
514 assert!(!result.text.is_null());
515 let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
516 assert_eq!(text, "hello world");
517 unsafe {
518 agent_doc_free_string(result.text);
519 agent_doc_free_state(result.state, result.state_len);
520 };
521 }
522}