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::new(name, patch_content);
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::new(name, patch_content);
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::new(name, patch_content);
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_crdt(
base: *const c_char,
ours: *const c_char,
theirs: *const c_char,
) -> *mut c_char {
let base_str = match unsafe { CStr::from_ptr(base) }.to_str() {
Ok(s) => s,
Err(_) => return CString::new("").unwrap_or_default().into_raw(),
};
let ours_str = match unsafe { CStr::from_ptr(ours) }.to_str() {
Ok(s) => s,
Err(_) => return CString::new("").unwrap_or_default().into_raw(),
};
let theirs_str = match unsafe { CStr::from_ptr(theirs) }.to_str() {
Ok(s) => s,
Err(_) => return CString::new("").unwrap_or_default().into_raw(),
};
let base_doc = crdt::CrdtDoc::from_text(base_str);
let base_state = base_doc.encode_state();
let merged = crdt::merge(Some(&base_state), ours_str, theirs_str)
.unwrap_or_else(|_| ours_str.to_string());
CString::new(merged).unwrap_or_default().into_raw()
}
#[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 extern "C" fn agent_doc_tracked_count() -> u32 {
crate::debounce::tracked_count() as u32
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_is_idle(
file_path: *const c_char,
debounce_ms: i64,
) -> bool {
let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
Ok(s) => s,
Err(_) => return true, };
let in_process_idle = crate::debounce::is_idle(path, debounce_ms as u64);
if !in_process_idle {
return false;
}
if !crate::debounce::is_tracked(path) {
return !crate::debounce::is_typing_via_file(path, debounce_ms as u64);
}
true
}
#[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, };
if !crate::debounce::is_tracked(path) {
return crate::debounce::await_idle_via_file(
path,
debounce_ms as u64,
timeout_ms as u64,
);
}
crate::debounce::await_idle(path, debounce_ms as u64, timeout_ms as u64)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_is_typing_via_file(
file_path: *const c_char,
debounce_ms: i64,
) -> bool {
let path = match unsafe { CStr::from_ptr(file_path) }.to_str() {
Ok(s) => s,
Err(_) => return false,
};
crate::debounce::is_typing_via_file(path, debounce_ms as u64)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_await_idle_via_file(
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_via_file(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));
if let Err(e) = std::fs::remove_file(&sock)
&& e.kind() != std::io::ErrorKind::NotFound
{
eprintln!("[ffi] failed to remove socket {:?}: {}", sock, e);
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_write_ack_content(
project_root: *const c_char,
patch_id: *const c_char,
content: *const c_char,
) -> bool {
let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
Ok(s) => s,
Err(_) => return false,
};
let patch_id_str = match unsafe { CStr::from_ptr(patch_id) }.to_str() {
Ok(s) => s,
Err(_) => return false,
};
let content_str = match unsafe { CStr::from_ptr(content) }.to_str() {
Ok(s) => s,
Err(_) => return false,
};
let ack_dir = std::path::Path::new(root_str).join(".agent-doc/ack-content");
if let Err(e) = std::fs::create_dir_all(&ack_dir) {
eprintln!("[ffi] agent_doc_write_ack_content: mkdir error: {e}");
return false;
}
let sidecar = ack_dir.join(format!("{patch_id_str}.md"));
match std::fs::write(&sidecar, content_str) {
Ok(_) => {
eprintln!("[ffi] ack_content written: {} bytes for patch_id {}",
content_str.len(), &patch_id_str[..patch_id_str.len().min(8)]);
true
}
Err(e) => {
eprintln!("[ffi] agent_doc_write_ack_content: write error: {e}");
false
}
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_is_claimed_by_force_disk(
project_root: *const c_char,
patch_id: *const c_char,
) -> bool {
let root_str = match unsafe { CStr::from_ptr(project_root) }.to_str() {
Ok(s) => s,
Err(_) => return false,
};
let patch_id_str = match unsafe { CStr::from_ptr(patch_id) }.to_str() {
Ok(s) => s,
Err(_) => return false,
};
let sentinel = std::path::Path::new(root_str)
.join(".agent-doc/claimed-patches")
.join(patch_id_str);
if sentinel.exists() {
eprintln!("[ffi] patch_id {} claimed by force-disk — skipping apply",
&patch_id_str[..patch_id_str.len().min(8)]);
let _ = std::fs::remove_file(&sentinel);
true
} else {
false
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn agent_doc_commit(file_path: *const c_char) -> bool {
if file_path.is_null() {
return false;
}
let path_str = match unsafe { CStr::from_ptr(file_path) }.to_str() {
Ok(s) => s,
Err(_) => return false,
};
let path = std::path::Path::new(path_str);
ffi_git_commit(path)
}
fn ffi_git_commit(file: &std::path::Path) -> bool {
let parent = file.parent().unwrap_or(file);
let git_root_out = std::process::Command::new("git")
.current_dir(parent)
.args(["rev-parse", "--show-toplevel"])
.output();
let git_root = match git_root_out {
Ok(o) if o.status.success() => std::path::PathBuf::from(
String::from_utf8_lossy(&o.stdout).trim().to_string()
),
_ => return false,
};
let doc_name = file.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let msg = format!("agent-doc({}): {}", doc_name, secs);
let add_ok = std::process::Command::new("git")
.current_dir(&git_root)
.args(["add", &file.to_string_lossy()])
.status()
.map(|s| s.success())
.unwrap_or(false);
if !add_ok {
eprintln!("[ffi] agent_doc_commit: git add failed for {}", file.display());
return false;
}
std::process::Command::new("git")
.current_dir(&git_root)
.args(["commit", "-m", &msg, "--no-verify"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[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 is_idle_untracked_returns_true() {
let path = CString::new("/tmp/ffi-test-untracked-file.md").unwrap();
let result = unsafe { agent_doc_is_idle(path.as_ptr(), 500) };
assert!(result, "untracked file should report idle");
}
#[test]
fn is_idle_after_change_returns_false() {
let path = CString::new("/tmp/ffi-test-just-changed.md").unwrap();
unsafe { agent_doc_document_changed(path.as_ptr()) };
let result = unsafe { agent_doc_is_idle(path.as_ptr(), 2000) };
assert!(!result, "file changed <2s ago should not be idle with 2000ms window");
}
#[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);
};
}
}
#[cfg(test)]
mod ack_content_tests {
use super::*;
use std::ffi::CString;
use tempfile::TempDir;
#[test]
fn test_write_ack_content_creates_file() {
let tmp = TempDir::new().unwrap();
let project_root = CString::new(tmp.path().to_str().unwrap()).unwrap();
let patch_id = CString::new("test-patch-id-123").unwrap();
let content = CString::new("hello world").unwrap();
let result = unsafe {
agent_doc_write_ack_content(
project_root.as_ptr(),
patch_id.as_ptr(),
content.as_ptr(),
)
};
assert!(result, "should return true on success");
let sidecar = tmp.path().join(".agent-doc/ack-content/test-patch-id-123.md");
assert!(sidecar.exists(), "sidecar file should exist at {:?}", sidecar);
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), "hello world");
}
#[test]
fn test_is_claimed_by_force_disk_present() {
let tmp = TempDir::new().unwrap();
let claimed_dir = tmp.path().join(".agent-doc/claimed-patches");
std::fs::create_dir_all(&claimed_dir).unwrap();
std::fs::write(claimed_dir.join("test-patch-456"), "").unwrap();
let project_root = CString::new(tmp.path().to_str().unwrap()).unwrap();
let patch_id = CString::new("test-patch-456").unwrap();
let claimed = unsafe { agent_doc_is_claimed_by_force_disk(project_root.as_ptr(), patch_id.as_ptr()) };
assert!(claimed, "should return true when sentinel exists");
assert!(!claimed_dir.join("test-patch-456").exists(), "sentinel should be deleted after check");
}
#[test]
fn test_is_claimed_by_force_disk_absent() {
let tmp = TempDir::new().unwrap();
let project_root = CString::new(tmp.path().to_str().unwrap()).unwrap();
let patch_id = CString::new("nonexistent-patch").unwrap();
let claimed = unsafe { agent_doc_is_claimed_by_force_disk(project_root.as_ptr(), patch_id.as_ptr()) };
assert!(!claimed, "should return false when sentinel absent");
}
#[test]
fn agent_doc_commit_returns_false_for_null() {
let result = unsafe { agent_doc_commit(std::ptr::null()) };
assert!(!result, "null path should return false");
}
#[test]
fn ffi_git_commit_commits_staged_file() {
use std::process::Command;
let dir = TempDir::new().unwrap();
let root = dir.path();
Command::new("git").current_dir(root).args(["init"]).output().unwrap();
Command::new("git").current_dir(root).args(["config", "user.email", "test@test.com"]).output().unwrap();
Command::new("git").current_dir(root).args(["config", "user.name", "Test"]).output().unwrap();
let readme = root.join("README.md");
std::fs::write(&readme, "# test\n").unwrap();
Command::new("git").current_dir(root).args(["add", "README.md"]).output().unwrap();
Command::new("git").current_dir(root).args(["commit", "-m", "initial", "--no-verify"]).output().unwrap();
let doc = root.join("session.md");
std::fs::write(&doc, "# content\n").unwrap();
let ok = ffi_git_commit(&doc);
assert!(ok, "ffi_git_commit should succeed for a valid git repo");
let log = Command::new("git")
.current_dir(root)
.args(["log", "--oneline", "-2"])
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(
log_str.contains("agent-doc(session):"),
"git log should contain agent-doc commit, got:\n{log_str}"
);
}
}