use std::ffi::{CStr, CString, c_char};
use std::ptr;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::component;
use crate::crdt;
use crate::frontmatter;
use crate::template;
static SYNC_LOCKED: AtomicBool = AtomicBool::new(false);
static SYNC_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[repr(C)]
pub struct FfiComponentList {
pub json: *mut c_char,
pub count: usize,
}
#[repr(C)]
pub struct FfiPatchResult {
pub text: *mut c_char,
pub error: *mut c_char,
}
#[repr(C)]
pub struct FfiMergeResult {
pub text: *mut c_char,
pub state: *mut u8,
pub state_len: usize,
pub error: *mut c_char,
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_parse_components(doc: *const c_char) -> FfiComponentList {
let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
Ok(s) => s,
Err(_) => {
return FfiComponentList {
json: ptr::null_mut(),
count: 0,
};
}
};
let components = match component::parse(doc_str) {
Ok(c) => c,
Err(_) => {
return FfiComponentList {
json: ptr::null_mut(),
count: 0,
};
}
};
let count = components.len();
let json_items: Vec<serde_json::Value> = components
.iter()
.map(|c| {
serde_json::json!({
"name": c.name,
"attrs": c.attrs,
"open_start": c.open_start,
"open_end": c.open_end,
"close_start": c.close_start,
"close_end": c.close_end,
"content": c.content(doc_str),
})
})
.collect();
let json_str = serde_json::to_string(&json_items).unwrap_or_default();
let c_json = CString::new(json_str).unwrap_or_default();
FfiComponentList {
json: c_json.into_raw(),
count,
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_apply_patch(
doc: *const c_char,
component_name: *const c_char,
content: *const c_char,
mode: *const c_char,
) -> FfiPatchResult {
let make_err = |msg: &str| FfiPatchResult {
text: ptr::null_mut(),
error: CString::new(msg).unwrap_or_default().into_raw(),
};
let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
};
let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
};
let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
};
let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
};
let patch = template::PatchBlock {
name: name.to_string(),
content: patch_content.to_string(),
};
let mut overrides = std::collections::HashMap::new();
overrides.insert(name.to_string(), mode_str.to_string());
let dummy_path = std::path::Path::new("/dev/null");
match template::apply_patches_with_overrides(
doc_str,
&[patch],
"",
dummy_path,
&overrides,
) {
Ok(result) => FfiPatchResult {
text: CString::new(result).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
},
Err(e) => make_err(&format!("{e}")),
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_apply_patch_with_caret(
doc: *const c_char,
component_name: *const c_char,
content: *const c_char,
mode: *const c_char,
caret_offset: i32,
) -> FfiPatchResult {
let make_err = |msg: &str| FfiPatchResult {
text: ptr::null_mut(),
error: CString::new(msg).unwrap_or_default().into_raw(),
};
let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
};
let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
};
let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
};
let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
};
if mode_str == "append" && caret_offset >= 0 {
let components = match component::parse(doc_str) {
Ok(c) => c,
Err(e) => return make_err(&format!("{e}")),
};
if let Some(comp) = components.iter().find(|c| c.name == name) {
let result = comp.append_with_caret(
doc_str,
patch_content,
Some(caret_offset as usize),
);
return FfiPatchResult {
text: CString::new(result).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
};
}
}
let patch = template::PatchBlock {
name: name.to_string(),
content: patch_content.to_string(),
};
let mut overrides = std::collections::HashMap::new();
overrides.insert(name.to_string(), mode_str.to_string());
let dummy_path = std::path::Path::new("/dev/null");
match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
Ok(result) => FfiPatchResult {
text: CString::new(result).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
},
Err(e) => make_err(&format!("{e}")),
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_apply_patch_with_boundary(
doc: *const c_char,
component_name: *const c_char,
content: *const c_char,
mode: *const c_char,
boundary_id: *const c_char,
) -> FfiPatchResult {
let make_err = |msg: &str| FfiPatchResult {
text: ptr::null_mut(),
error: CString::new(msg).unwrap_or_default().into_raw(),
};
let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
};
let name = match unsafe { CStr::from_ptr(component_name) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid component name UTF-8: {e}")),
};
let patch_content = match unsafe { CStr::from_ptr(content) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid content UTF-8: {e}")),
};
let mode_str = match unsafe { CStr::from_ptr(mode) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid mode UTF-8: {e}")),
};
let bid = match unsafe { CStr::from_ptr(boundary_id) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid boundary_id UTF-8: {e}")),
};
if mode_str == "append" && !bid.is_empty() {
let components = match component::parse(doc_str) {
Ok(c) => c,
Err(e) => return make_err(&format!("{e}")),
};
if let Some(comp) = components.iter().find(|c| c.name == name) {
let result = comp.append_with_boundary(doc_str, patch_content, bid);
return FfiPatchResult {
text: CString::new(result).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
};
}
}
let patch = template::PatchBlock {
name: name.to_string(),
content: patch_content.to_string(),
};
let mut overrides = std::collections::HashMap::new();
overrides.insert(name.to_string(), mode_str.to_string());
let dummy_path = std::path::Path::new("/dev/null");
match template::apply_patches_with_overrides(doc_str, &[patch], "", dummy_path, &overrides) {
Ok(result) => FfiPatchResult {
text: CString::new(result).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
},
Err(e) => make_err(&format!("{e}")),
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_crdt_merge(
base_state: *const u8,
base_state_len: usize,
ours: *const c_char,
theirs: *const c_char,
) -> FfiMergeResult {
let make_err = |msg: &str| FfiMergeResult {
text: ptr::null_mut(),
state: ptr::null_mut(),
state_len: 0,
error: CString::new(msg).unwrap_or_default().into_raw(),
};
let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid ours UTF-8: {e}")),
};
let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid theirs UTF-8: {e}")),
};
let base = if base_state.is_null() {
None
} else {
Some(unsafe { std::slice::from_raw_parts(base_state, base_state_len) })
};
match crdt::merge(base, ours_str, theirs_str) {
Ok(merged_text) => {
let doc = crdt::CrdtDoc::from_text(&merged_text);
let state_bytes = doc.encode_state();
let state_len = state_bytes.len();
let state_ptr = {
let mut boxed = state_bytes.into_boxed_slice();
let ptr = boxed.as_mut_ptr();
std::mem::forget(boxed);
ptr
};
FfiMergeResult {
text: CString::new(merged_text).unwrap_or_default().into_raw(),
state: state_ptr,
state_len,
error: ptr::null_mut(),
}
}
Err(e) => make_err(&format!("{e}")),
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_merge_frontmatter(
doc: *const c_char,
yaml_fields: *const c_char,
) -> FfiPatchResult {
let make_err = |msg: &str| FfiPatchResult {
text: ptr::null_mut(),
error: CString::new(msg).unwrap_or_default().into_raw(),
};
let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
};
let fields_str = match unsafe { CStr::from_ptr(yaml_fields) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid yaml_fields UTF-8: {e}")),
};
match frontmatter::merge_fields(doc_str, fields_str) {
Ok(result) => FfiPatchResult {
text: CString::new(result).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
},
Err(e) => make_err(&format!("{e}")),
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_reposition_boundary_to_end(
doc: *const c_char,
) -> FfiPatchResult {
let make_err = |msg: &str| FfiPatchResult {
text: ptr::null_mut(),
error: CString::new(msg).unwrap_or_default().into_raw(),
};
let doc_str = match unsafe { CStr::from_ptr(doc) }.to_str() {
Ok(s) => s,
Err(e) => return make_err(&format!("invalid doc UTF-8: {e}")),
};
let result = template::reposition_boundary_to_end(doc_str);
FfiPatchResult {
text: CString::new(result).unwrap_or_default().into_raw(),
error: ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_document_changed(file_path: *const c_char) {
if let Ok(path) = unsafe { CStr::from_ptr(file_path) }.to_str() {
crate::debounce::document_changed(path);
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_is_tracked(file_path: *const c_char) -> bool {
let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
Ok(s) => s,
Err(_) => return false,
};
crate::debounce::is_tracked(path)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_await_idle(
file_path: *const c_char,
debounce_ms: i64,
timeout_ms: i64,
) -> bool {
let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
Ok(s) => s,
Err(_) => return true, };
crate::debounce::await_idle(path, debounce_ms as u64, timeout_ms as u64)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_set_status(
file_path: *const c_char,
status: *const c_char,
) {
let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
Ok(s) => s,
Err(_) => return,
};
let st = match unsafe { CStr::from_ptr(status) }.to_str() {
Ok(s) => s,
Err(_) => return,
};
crate::debounce::set_status(path, st);
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_get_status(file_path: *const c_char) -> *mut c_char {
let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
Ok(s) => s,
Err(_) => return CString::new("idle").unwrap().into_raw(),
};
let status = crate::debounce::get_status(path);
CString::new(status).unwrap_or_else(|_| CString::new("idle").unwrap()).into_raw()
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_is_busy(file_path: *const c_char) -> bool {
let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
Ok(s) => s,
Err(_) => return false,
};
crate::debounce::is_busy(path)
}
#[unsafe(no_mangle)]
pub extern "C" fn agent_doc_sync_try_lock() -> bool {
SYNC_LOCKED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_ok()
}
#[unsafe(no_mangle)]
pub extern "C" fn agent_doc_sync_unlock() {
SYNC_LOCKED.store(false, Ordering::SeqCst);
}
#[unsafe(no_mangle)]
pub extern "C" fn agent_doc_sync_bump_generation() -> u64 {
SYNC_GENERATION.fetch_add(1, Ordering::SeqCst) + 1
}
#[unsafe(no_mangle)]
pub extern "C" fn agent_doc_sync_check_generation(generation: u64) -> bool {
SYNC_GENERATION.load(Ordering::SeqCst) == generation
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_start_ipc_listener(
project_root: *const c_char,
callback: extern "C" fn(message: *const c_char) -> bool,
) -> bool {
let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
Ok(s) => s.to_string(),
Err(_) => return false,
};
let root_path = std::path::PathBuf::from(&root_str);
std::thread::spawn(move || {
let result = crate::ipc_socket::start_listener(&root_path, move |msg| {
let c_msg = match CString::new(msg) {
Ok(c) => c,
Err(_) => return Some(r#"{"type":"ack","status":"error"}"#.to_string()),
};
let success = callback(c_msg.as_ptr());
if success {
Some(r#"{"type":"ack","status":"ok"}"#.to_string())
} else {
Some(r#"{"type":"ack","status":"error"}"#.to_string())
}
});
if let Err(e) = result {
eprintln!("[ffi] IPC listener error: {}", e);
}
});
true
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_stop_ipc_listener(
project_root: *const c_char,
) {
let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
Ok(s) => s,
Err(_) => return,
};
let sock = crate::ipc_socket::socket_path(std::path::Path::new(root_str));
let _ = std::fs::remove_file(&sock);
}
#[unsafe(no_mangle)]
pub extern "C" fn agent_doc_version() -> *mut c_char {
CString::new(env!("CARGO_PKG_VERSION")).unwrap().into_raw()
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_free_string(ptr: *mut c_char) {
if !ptr.is_null() {
drop(unsafe { CString::from_raw(ptr) });
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_free_state(ptr: *mut u8, len: usize) {
if !ptr.is_null() {
drop(unsafe { Vec::from_raw_parts(ptr, len, len) });
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_components_roundtrip() {
let doc = "before\n<!-- agent:status -->\nhello\n<!-- /agent:status -->\nafter\n";
let c_doc = CString::new(doc).unwrap();
let result = unsafe { agent_doc_parse_components(c_doc.as_ptr()) };
assert_eq!(result.count, 1);
assert!(!result.json.is_null());
let json_str = unsafe { CStr::from_ptr(result.json) }.to_str().unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap();
assert_eq!(parsed[0]["name"], "status");
assert_eq!(parsed[0]["content"], "hello\n");
unsafe { agent_doc_free_string(result.json) };
}
#[test]
fn apply_patch_replace() {
let doc = "<!-- agent:output -->\nold\n<!-- /agent:output -->\n";
let c_doc = CString::new(doc).unwrap();
let c_name = CString::new("output").unwrap();
let c_content = CString::new("new content\n").unwrap();
let c_mode = CString::new("replace").unwrap();
let result = unsafe {
agent_doc_apply_patch(c_doc.as_ptr(), c_name.as_ptr(), c_content.as_ptr(), c_mode.as_ptr())
};
assert!(result.error.is_null());
assert!(!result.text.is_null());
let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
assert!(text.contains("new content"));
assert!(!text.contains("old"));
unsafe { agent_doc_free_string(result.text) };
}
#[test]
fn merge_frontmatter_adds_field() {
let doc = "---\nagent_doc_session: abc\n---\nBody\n";
let fields = "model: opus";
let c_doc = CString::new(doc).unwrap();
let c_fields = CString::new(fields).unwrap();
let result = unsafe {
agent_doc_merge_frontmatter(c_doc.as_ptr(), c_fields.as_ptr())
};
assert!(result.error.is_null());
assert!(!result.text.is_null());
let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
assert!(text.contains("model: opus"));
assert!(text.contains("agent_doc_session: abc"));
assert!(text.contains("Body"));
unsafe { agent_doc_free_string(result.text) };
}
#[test]
fn reposition_boundary_removes_stale() {
let doc = "<!-- agent:exchange patch=append -->\ntext\n<!-- agent:boundary:aaaa1111 -->\nmore\n<!-- agent:boundary:bbbb2222 -->\n<!-- /agent:exchange -->\n";
let c_doc = CString::new(doc).unwrap();
let result = unsafe { agent_doc_reposition_boundary_to_end(c_doc.as_ptr()) };
assert!(result.error.is_null());
assert!(!result.text.is_null());
let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
let boundary_count = text.matches("<!-- agent:boundary:").count();
assert_eq!(boundary_count, 1, "should have exactly 1 boundary, got {}", boundary_count);
assert!(text.contains("more\n<!-- agent:boundary:"));
assert!(text.contains(" -->\n<!-- /agent:exchange -->"));
unsafe { agent_doc_free_string(result.text) };
}
#[test]
fn crdt_merge_no_base() {
let c_ours = CString::new("hello world").unwrap();
let c_theirs = CString::new("hello world").unwrap();
let result = unsafe {
agent_doc_crdt_merge(ptr::null(), 0, c_ours.as_ptr(), c_theirs.as_ptr())
};
assert!(result.error.is_null());
assert!(!result.text.is_null());
let text = unsafe { CStr::from_ptr(result.text) }.to_str().unwrap();
assert_eq!(text, "hello world");
unsafe {
agent_doc_free_string(result.text);
agent_doc_free_state(result.state, result.state_len);
};
}
}