#![allow(clippy::not_unsafe_ptr_arg_deref)]
pub mod catalog;
pub mod config_loader;
pub mod hooks;
pub mod marker;
pub mod prefetch;
pub mod resolve;
pub mod session;
pub mod stage2;
pub mod state;
pub mod struct_extract;
#[cfg(feature = "proxy")]
pub mod config;
#[cfg(feature = "proxy")]
pub mod proxy;
#[cfg(feature = "proxy")]
pub mod retrieve;
#[cfg(feature = "proxy")]
pub mod setup;
use std::{
collections::HashMap,
ffi::{CStr, CString},
os::raw::c_char,
sync::{Arc, Mutex},
};
use headroom_core::transforms;
use state::AphroditeState;
const MAX_CONTENT:usize = 16 * 1024 * 1024;
static HANDLES:Mutex<Option<HashMap<usize, Arc<Mutex<AphroditeState>>>>> = Mutex::new(None);
static NEXT_ID:Mutex<usize> = Mutex::new(1);
fn handles() -> std::sync::MutexGuard<'static, Option<HashMap<usize, Arc<Mutex<AphroditeState>>>>> {
let mut g = HANDLES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if g.is_none() {
*g = Some(HashMap::new());
}
g
}
fn get_handle(hid:usize) -> Option<Arc<Mutex<AphroditeState>>> {
handles().as_ref().and_then(|m| m.get(&hid)).cloned()
}
fn alloc_handle(state:AphroditeState) -> usize {
let mut id = NEXT_ID.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let hid = *id;
*id = id.wrapping_add(1); handles().as_mut().unwrap().insert(hid, Arc::new(Mutex::new(state)));
hid
}
fn with_state<T>(hid:usize, f:impl FnOnce(&mut AphroditeState) -> T) -> Result<T, String> {
let session = match get_handle(hid) {
Some(s) => s,
None => return Err(format!("invalid handle: {}", hid)),
};
let mut state = session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut state)))
.map_err(|_| "internal error: hook panicked".to_string())
}
fn to_json_error(msg:&str) -> *mut c_char {
let json = serde_json::json!({"error": msg}).to_string();
CString::new(json).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut())
}
fn guarded(f:impl FnOnce() -> *mut c_char + std::panic::UnwindSafe) -> *mut c_char {
std::panic::catch_unwind(f).unwrap_or_else(|_| to_json_error("internal error: panicked"))
}
fn to_json_ok(v:&serde_json::Value) -> *mut c_char {
CString::new(v.to_string())
.map(|c| c.into_raw())
.unwrap_or(std::ptr::null_mut())
}
unsafe fn cstr(ptr:*const c_char) -> Option<String> {
if ptr.is_null() {
return None;
}
Some(CStr::from_ptr(ptr).to_string_lossy().into_owned())
}
fn check_content(content:&str) -> Result<(), &'static str> {
if content.is_empty() {
return Err("empty content");
}
if content.len() > MAX_CONTENT {
return Err("content exceeds 16MB limit");
}
if content.contains('\0') {
return Err("content contains null bytes");
}
Ok(())
}
#[no_mangle]
pub extern "C" fn aphrodite_version() -> *mut c_char { CString::new(env!("CARGO_PKG_VERSION")).unwrap().into_raw() }
#[no_mangle]
pub extern "C" fn aphrodite_free_string(s:*mut c_char) {
if !s.is_null() {
unsafe {
let _ = CString::from_raw(s);
}
}
}
#[no_mangle]
pub extern "C" fn aphrodite_hooks() -> *mut c_char {
CString::new(
serde_json::json!([
"on_session_start",
"transform_tool_result",
"transform_terminal_output",
"pre_llm_call",
"post_llm_call"
])
.to_string(),
)
.unwrap()
.into_raw()
}
#[no_mangle]
pub extern "C" fn aphrodite_init(config_path:*const c_char) -> *mut c_char {
let path = unsafe { cstr(config_path) }.unwrap_or_default();
let mut state = AphroditeState::default();
if !path.is_empty() {
if let Ok(s) = std::fs::read_to_string(path.as_str()) {
if let Ok(t) = s.parse::<toml::Table>() {
if let Some(c) = t.get("compression").and_then(|v| v.as_table()) {
if let Some(v) = c.get("context_engine").and_then(|v| v.as_bool()) {
state.context_engine_enabled = v;
}
if let Some(v) = c.get("engine_threshold_pct").and_then(|v| v.as_integer()) {
state.engine_threshold_pct = v as u64;
}
if let Some(v) = c.get("tool_threshold").and_then(|v| v.as_integer()) {
state.tool_threshold = v as usize;
}
if let Some(v) = c.get("terminal_threshold").and_then(|v| v.as_integer()) {
state.terminal_threshold = v as usize;
}
}
}
}
}
CString::new(alloc_handle(state).to_string()).unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn aphrodite_destroy(handle:*const c_char) {
if let Ok(hid) = unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
handles().as_mut().and_then(|m| m.remove(&hid));
}
}
#[no_mangle]
pub extern "C" fn aphrodite_classify(content:*const c_char) -> *mut c_char {
let c = match unsafe { cstr(content) } {
Some(s) => s,
None => return to_json_error("null content"),
};
guarded(std::panic::AssertUnwindSafe(move || {
if let Err(e) = check_content(&c) {
return to_json_error(e);
}
let ct = transforms::detect(&c);
to_json_ok(&serde_json::json!({"type":ct.as_str(),"lines":c.lines().count(),"bytes":c.len()}))
}))
}
#[no_mangle]
pub extern "C" fn aphrodite_call_hook(_hook:*const c_char, _args:*const c_char) -> *mut c_char {
to_json_error("aphrodite_call_hook is stateless and deprecated; use aphrodite_init + aphrodite_dispatch")
}
macro_rules! stateful {
($name:ident, |$s:ident, $($arg:ident : $ty:ty),*| $body:expr) => {
#[no_mangle] pub extern "C" fn $name(handle: *const c_char, $($arg: *const c_char),*) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() { Ok(id) => id, Err(_) => return to_json_error("invalid handle") };
$(let $arg = unsafe { cstr($arg) }.unwrap_or_default();)*
match with_state(hid, |$s| $body) {
Ok(v) => to_json_ok(&v),
Err(e) => to_json_error(&e),
}
}
};
}
stateful!(aphrodite_compress, |s, content:*const c_char, hint:*const c_char| {
if content.is_empty() {
return serde_json::json!({"error":"empty"});
}
if content.len() > MAX_CONTENT {
return serde_json::json!({"error":"content exceeds 16MB limit"});
}
let ct = transforms::detect(&content);
let t = if hint.is_empty() || hint == "text" {
ct.as_str().to_string()
} else {
hint.to_string()
};
let hash = headroom_core::ccr::compute_key(content.as_bytes());
s.inline_store_put(hash.clone(), content.to_string());
let preview = crate::build_preview(&t, &content);
let marker = marker::ccr_marker(&hash, &t, content.len(), &preview, None, None, None);
s.record_marker(state::MarkerEntry {
hash:hash.clone(),
ccr_type:t.clone(),
size:content.len(),
preview:preview.clone(),
turn:s.turn_counter,
center:None,
meta:None,
});
serde_json::json!({"hash":hash,"type":t,"size":content.len(),"preview":preview,"marker":marker})
});
#[no_mangle]
pub extern "C" fn aphrodite_retrieve(handle:*const c_char, hash:*const c_char) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
let hash = unsafe { cstr(hash) }.unwrap_or_default();
guarded(std::panic::AssertUnwindSafe(move || {
let session = match get_handle(hid) {
Some(s) => s,
None => return to_json_error(&format!("invalid handle: {}", hid)),
};
let mut s = session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
match s.inline_store_get(&hash) {
Some(content) => CString::new(content.replace('\0', "")).unwrap().into_raw(),
None => to_json_error(&format!("hash not found: {}", hash)),
}
}))
}
stateful!(aphrodite_transform, |s, content:*const c_char, tool:*const c_char| {
if content.len() > MAX_CONTENT {
return serde_json::json!({"error":"content exceeds 16MB limit"});
}
hooks::transform_tool_result(s, &content, &tool)
});
stateful!(aphrodite_terminal, |s, content:*const c_char| {
if content.len() > MAX_CONTENT {
return serde_json::json!({"error":"content exceeds 16MB limit"});
}
hooks::transform_terminal_output(s, &content)
});
#[no_mangle]
pub extern "C" fn aphrodite_session_start(handle:*const c_char) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
match with_state(hid, session::on_session_start) {
Ok(v) => to_json_ok(&v),
Err(e) => to_json_error(&e),
}
}
#[no_mangle]
pub extern "C" fn aphrodite_catalog(handle:*const c_char, mode:*const c_char) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
let m = unsafe { cstr(mode) }.unwrap_or_default();
match get_handle(hid) {
Some(session) => {
let s = session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
to_json_ok(&crate::catalog::build_catalog(&s, &m))
},
None => to_json_error(&format!("invalid handle: {}", hid)),
}
}
#[no_mangle]
pub extern "C" fn aphrodite_stats(handle:*const c_char) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
match get_handle(hid) {
Some(session) => {
let s = session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
to_json_ok(&serde_json::json!({
"version":env!("CARGO_PKG_VERSION"),"inline_entries":s.inline_store.len(),
"markers":s.recent_markers.len(),"turn":s.turn_counter,
"engine_enabled":s.context_engine_enabled,"threshold_pct":s.engine_threshold_pct,
"tool_threshold":s.tool_threshold,"terminal_threshold":s.terminal_threshold,
}))
},
None => to_json_error(&format!("invalid handle: {}", hid)),
}
}
#[no_mangle]
pub extern "C" fn aphrodite_reload(handle:*const c_char, path:*const c_char) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
let p = unsafe { cstr(path) }.unwrap_or_default();
let context_engine_enabled = if !p.is_empty() {
std::fs::read_to_string(p.as_str())
.ok()
.and_then(|t| t.parse::<toml::Table>().ok())
.and_then(|tbl| tbl.get("compression").and_then(|v| v.as_table()).cloned())
.and_then(|c| c.get("context_engine").and_then(|v| v.as_bool()))
} else {
None
};
match with_state(hid, |s| {
if let Some(v) = context_engine_enabled {
s.context_engine_enabled = v;
}
serde_json::json!({"status":"ok"})
}) {
Ok(v) => to_json_ok(&v),
Err(e) => to_json_error(&e),
}
}
#[no_mangle]
pub extern "C" fn aphrodite_search(handle:*const c_char, query:*const c_char) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
let q = unsafe { cstr(query) }.unwrap_or_default().to_lowercase();
match get_handle(hid) {
Some(session) => {
let s = session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let results:Vec<serde_json::Value> = s
.recent_markers
.iter()
.filter(|m| m.preview.to_lowercase().contains(&q) || m.ccr_type.to_lowercase().contains(&q))
.take(20)
.map(|m| serde_json::json!({"hash":&m.hash,"type":m.ccr_type,"size":m.size,"preview":m.preview}))
.collect();
to_json_ok(&serde_json::json!({"total":results.len(),"results":results}))
},
None => to_json_error(&format!("invalid handle: {}", hid)),
}
}
#[no_mangle]
pub extern "C" fn aphrodite_config_get(handle:*const c_char, key:*const c_char) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
let k = unsafe { cstr(key) }.unwrap_or_default();
match get_handle(hid) {
Some(session) => {
let s = session.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let v = match k.as_ref() {
"model" => s.model.clone(),
"api_url" => s.api_url.clone(),
"engine_threshold_pct" => s.engine_threshold_pct.to_string(),
"tool_threshold" => s.tool_threshold.to_string(),
"context_engine_enabled" => s.context_engine_enabled.to_string(),
_ => return to_json_error(&format!("unknown key: {}", k)),
};
CString::new(v.replace('\0', "")).unwrap().into_raw()
},
None => to_json_error(&format!("invalid handle: {}", hid)),
}
}
#[no_mangle]
pub extern "C" fn aphrodite_config_set(handle:*const c_char, key:*const c_char, value:*const c_char) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
let k = unsafe { cstr(key) }.unwrap_or_default();
let v = unsafe { cstr(value) }.unwrap_or_default();
match with_state(hid, |s| {
match k.as_ref() {
"model" => s.model = v.to_string(),
"engine_threshold_pct" => {
if let Ok(n) = v.parse() {
s.engine_threshold_pct = n;
}
},
"tool_threshold" => {
if let Ok(n) = v.parse() {
s.tool_threshold = n;
}
},
"context_engine_enabled" => s.context_engine_enabled = v == "true" || v == "1",
_ => {},
}
serde_json::json!({"status":"ok"})
}) {
Ok(v) => to_json_ok(&v),
Err(e) => to_json_error(&e),
}
}
pub mod preview;
pub use preview::{build_preview, detect_type};
#[no_mangle]
pub extern "C" fn aphrodite_dispatch(
handle:*const c_char,
hook_name:*const c_char,
args_json:*const c_char,
) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
let name = unsafe { cstr(hook_name) }.unwrap_or_default();
let args_str = unsafe { cstr(args_json) }.unwrap_or_default();
let args:serde_json::Value = match serde_json::from_str(&args_str) {
Ok(v) => v,
Err(e) => return to_json_error(&format!("invalid args: {}", e)),
};
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
if content.len() > MAX_CONTENT {
return to_json_error("content exceeds 16MB limit");
}
let tool = args.get("tool_name").and_then(|v| v.as_str()).unwrap_or("unknown");
let prefetch_outcomes = std::cell::RefCell::new(if name == "prefetch" {
let paths = args.get("paths").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let path_strings:Vec<String> = paths.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
Some(crate::prefetch::read_paths(&path_strings))
} else {
None
});
let result = match with_state(hid, |s| {
match name.as_ref() {
"on_session_start" | "session_start" => hooks::on_session_start(s),
"transform_tool_result" => hooks::transform_tool_result(s, content, tool),
"transform_terminal_output" => hooks::transform_terminal_output(s, content),
"pre_llm_call" => hooks::pre_llm_call(s),
"post_llm_call" => hooks::post_llm_call(s),
"catalog" => {
let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("full");
crate::catalog::build_catalog(s, mode)
},
"stats" => {
serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"inline_entries": s.inline_store.len(),
"markers": s.recent_markers.len(),
"turn": s.turn_counter,
"engine_enabled": s.context_engine_enabled,
})
},
"search" => {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
let type_filter = args.get("type").and_then(|v| v.as_str());
let results:Vec<serde_json::Value> = s
.recent_markers
.iter()
.filter(|m| {
let matches_query = query.is_empty()
|| m.preview.to_lowercase().contains(&query)
|| m.ccr_type.to_lowercase().contains(&query);
let matches_type = type_filter.is_none_or(|t| m.ccr_type == t);
matches_query && matches_type
})
.take(20)
.map(|m| serde_json::json!({"hash":&m.hash,"type":m.ccr_type,"size":m.size,"preview":m.preview}))
.collect();
serde_json::json!({"total":results.len(),"results":results})
},
"diff" => {
let turns:Vec<serde_json::Value> = s
.conv_index
.iter()
.map(
|(turn, (hash, summary, size))| serde_json::json!({"turn":turn,"hash":hash,"summary":summary,"size":size}),
)
.collect();
serde_json::json!({"turns":turns,"total":turns.len()})
},
"files" => {
let files:Vec<serde_json::Value> = s
.referenced_files
.iter()
.map(|(path, tool)| serde_json::json!({"path":path,"tool":tool}))
.collect();
serde_json::json!({"files":files,"total":files.len()})
},
"classify" => {
let ct = headroom_core::transforms::detect(content);
serde_json::json!({"type":ct.as_str(),"lines":content.lines().count(),"bytes":content.len()})
},
"prefetch" => {
let outcomes = prefetch_outcomes
.borrow_mut()
.take()
.expect("prefetch_outcomes set when name == \"prefetch\"");
crate::prefetch::insert_outcomes(s, outcomes)
},
_ => serde_json::json!({"error": format!("unknown hook: {}", name)}),
}
}) {
Ok(v) => to_json_ok(&v),
Err(e) => to_json_error(&e),
};
result
}
#[no_mangle]
pub extern "C" fn aphrodite_filter_lines(content:*const c_char, query:*const c_char) -> *mut c_char {
let c = match unsafe { cstr(content) } {
Some(s) => s,
None => return to_json_error("null content"),
};
let q = unsafe { cstr(query) }.unwrap_or_default();
guarded(std::panic::AssertUnwindSafe(move || {
let filtered = crate::resolve::filter_lines(&c, &q);
CString::new(filtered.replace('\0', "")).unwrap().into_raw()
}))
}
#[no_mangle]
pub extern "C" fn aphrodite_resolve(handle:*const c_char, hash:*const c_char) -> *mut c_char {
let hid = match unsafe { cstr(handle) }.unwrap_or_default().parse::<usize>() {
Ok(id) => id,
Err(_) => return to_json_error("invalid handle"),
};
let h = unsafe { cstr(hash) }.unwrap_or_default();
match with_state(hid, |s| {
match crate::resolve::expand(s, &h) {
Some(content) => serde_json::json!({"found":true,"content":content}),
None => serde_json::json!({"found":false}),
}
}) {
Ok(v) => to_json_ok(&v),
Err(e) => to_json_error(&e),
}
}
#[no_mangle]
pub extern "C" fn aphrodite_preview(content:*const c_char, ccr_type:*const c_char) -> *mut c_char {
let c = match unsafe { cstr(content) } {
Some(s) => s,
None => return to_json_error("null content"),
};
let t = unsafe { cstr(ccr_type) }.unwrap_or_default();
guarded(std::panic::AssertUnwindSafe(move || {
let preview = crate::build_preview(&t, &c);
CString::new(preview.replace('\0', "")).unwrap().into_raw()
}))
}
#[no_mangle]
pub extern "C" fn aphrodite_stage2(content:*const c_char, ccr_type:*const c_char) -> *mut c_char {
let c = match unsafe { cstr(content) } {
Some(s) => s,
None => return to_json_error("null content"),
};
let t = unsafe { cstr(ccr_type) }.unwrap_or_default();
guarded(std::panic::AssertUnwindSafe(move || {
match crate::stage2::compress_stage2(&c, &t) {
Some(reduced) => CString::new(reduced.replace('\0', "")).unwrap().into_raw(),
None => to_json_error("no reduction possible"),
}
}))
}
#[no_mangle]
pub extern "C" fn aphrodite_struct_extract(content:*const c_char, language:*const c_char) -> *mut c_char {
let c = match unsafe { cstr(content) } {
Some(s) => s,
None => return to_json_error("null content"),
};
let lang = unsafe { cstr(language) }.unwrap_or_default();
guarded(std::panic::AssertUnwindSafe(move || {
let result = crate::struct_extract::extract_code_structure(&c, &lang);
to_json_ok(&serde_json::json!(result))
}))
}
#[cfg(test)]
mod ffi_tests {
use super::*;
#[test]
fn test_build_preview_terminal_surfaces_exit_code() {
let preview = build_preview("terminal", "running tests\nall good\nexit code: 1\n");
assert!(preview.starts_with("[terminal:"));
assert!(
preview.contains("exit code: 1"),
"preview should surface the exit code line: {preview}"
);
}
#[test]
fn test_build_preview_terminal_falls_back_to_last_line() {
let preview = build_preview("terminal", "line one\nline two\nlast line here\n");
assert!(preview.starts_with("[terminal:"));
assert!(
preview.contains("last line here"),
"preview should fall back to the last non-empty line: {preview}"
);
}
fn cs(s:&str) -> CString { CString::new(s).unwrap() }
unsafe fn take(ptr:*mut c_char) -> String {
assert!(!ptr.is_null(), "expected non-null C string");
let s = CStr::from_ptr(ptr).to_string_lossy().into_owned();
aphrodite_free_string(ptr);
s
}
#[test]
fn with_state_does_not_serialize_across_different_handles() {
let h1:usize = unsafe { take(aphrodite_init(std::ptr::null())) }.parse().unwrap();
let h2:usize = unsafe { take(aphrodite_init(std::ptr::null())) }.parse().unwrap();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let b1 = barrier.clone();
let t1 = std::thread::spawn(move || {
with_state(h1, |_s| {
b1.wait();
std::thread::sleep(std::time::Duration::from_millis(300));
})
.unwrap();
});
barrier.wait();
let start = std::time::Instant::now();
with_state(h2, |_s| {}).unwrap();
let elapsed = start.elapsed();
t1.join().unwrap();
handles().as_mut().and_then(|m| m.remove(&h1));
handles().as_mut().and_then(|m| m.remove(&h2));
assert!(
elapsed < std::time::Duration::from_millis(150),
"handle 2's call should not block behind handle 1's long-held lock, took {elapsed:?}"
);
}
#[test]
fn init_with_null_config_yields_parseable_handle() {
let h = unsafe { take(aphrodite_init(std::ptr::null())) };
assert!(h.parse::<usize>().is_ok(), "handle {:?} should parse as usize", h);
aphrodite_destroy(cs(&h).as_ptr());
}
#[test]
fn compress_retrieve_destroy_roundtrip() {
let h = unsafe { take(aphrodite_init(std::ptr::null())) };
let handle = cs(&h);
let compress_json = unsafe {
take(aphrodite_compress(
handle.as_ptr(),
cs("fn main() {}").as_ptr(),
cs("text").as_ptr(),
))
};
let v:serde_json::Value = serde_json::from_str(&compress_json).unwrap();
let hash = v["hash"].as_str().unwrap().to_string();
assert!(!hash.is_empty());
let retrieved = unsafe { take(aphrodite_retrieve(handle.as_ptr(), cs(&hash).as_ptr())) };
assert_eq!(retrieved, "fn main() {}");
aphrodite_destroy(handle.as_ptr());
let after = unsafe { take(aphrodite_stats(handle.as_ptr())) };
assert!(after.contains("invalid handle"));
}
#[test]
fn retrieve_unknown_hash_returns_error_json() {
let h = unsafe { take(aphrodite_init(std::ptr::null())) };
let handle = cs(&h);
let out = unsafe { take(aphrodite_retrieve(handle.as_ptr(), cs("deadbeef00000000").as_ptr())) };
let v:serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(v["error"].as_str().unwrap().contains("hash not found"));
aphrodite_destroy(handle.as_ptr());
}
#[test]
fn invalid_handles_are_rejected_without_crashing() {
for bad in ["999999", "abc", ""] {
let handle = cs(bad);
for out in [
unsafe { take(aphrodite_stats(handle.as_ptr())) },
unsafe { take(aphrodite_catalog(handle.as_ptr(), cs("full").as_ptr())) },
unsafe { take(aphrodite_search(handle.as_ptr(), cs("q").as_ptr())) },
unsafe { take(aphrodite_config_get(handle.as_ptr(), cs("model").as_ptr())) },
unsafe { take(aphrodite_session_start(handle.as_ptr())) },
unsafe { take(aphrodite_compress(handle.as_ptr(), cs("x").as_ptr(), cs("text").as_ptr())) },
unsafe { take(aphrodite_retrieve(handle.as_ptr(), cs("x").as_ptr())) },
] {
assert!(
out.contains("invalid handle"),
"expected invalid-handle error for {:?}, got {:?}",
bad,
out
);
}
aphrodite_destroy(handle.as_ptr());
}
}
#[test]
fn classify_null_content_returns_error() {
let out = unsafe { take(aphrodite_classify(std::ptr::null())) };
let v:serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["error"].as_str().unwrap(), "null content");
}
#[test]
fn dispatch_known_and_unknown_hooks() {
let h = unsafe { take(aphrodite_init(std::ptr::null())) };
let handle = cs(&h);
let ok = unsafe {
take(aphrodite_dispatch(
handle.as_ptr(),
cs("session_start").as_ptr(),
cs("{}").as_ptr(),
))
};
assert!(serde_json::from_str::<serde_json::Value>(&ok).is_ok());
let unknown = unsafe {
take(aphrodite_dispatch(
handle.as_ptr(),
cs("not_a_real_hook").as_ptr(),
cs("{}").as_ptr(),
))
};
let v:serde_json::Value = serde_json::from_str(&unknown).unwrap();
assert!(v["error"].as_str().unwrap().contains("unknown hook"));
aphrodite_destroy(handle.as_ptr());
}
#[test]
fn free_string_null_is_a_no_op() { aphrodite_free_string(std::ptr::null_mut()); }
#[test]
fn oversize_content_is_rejected() {
let h = unsafe { take(aphrodite_init(std::ptr::null())) };
let handle = cs(&h);
let big = "a".repeat(MAX_CONTENT + 1);
let out = unsafe { take(aphrodite_compress(handle.as_ptr(), cs(&big).as_ptr(), cs("text").as_ptr())) };
let v:serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(v["error"].as_str().unwrap().contains("16MB"));
aphrodite_destroy(handle.as_ptr());
}
#[test]
fn guarded_helper_converts_panic_to_error_json() {
let ptr = guarded(|| panic!("deliberate test panic"));
let out = unsafe { take(ptr) };
let v:serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(v["error"].as_str().unwrap().contains("panicked"));
}
#[test]
fn retrieve_tolerates_interior_nul_in_dispatched_content() {
let h = unsafe { take(aphrodite_init(std::ptr::null())) };
let handle = cs(&h);
let _ = unsafe {
take(aphrodite_config_set(
handle.as_ptr(),
cs("tool_threshold").as_ptr(),
cs("0").as_ptr(),
))
};
let args = cs(r#"{"content":"x\u0000y","tool_name":"t"}"#);
let dispatched = unsafe {
take(aphrodite_dispatch(
handle.as_ptr(),
cs("transform_tool_result").as_ptr(),
args.as_ptr(),
))
};
let v:serde_json::Value = serde_json::from_str(&dispatched).unwrap();
let hash = v["hash"].as_str().expect("compression should have produced a hash").to_string();
let retrieved = unsafe { take(aphrodite_retrieve(handle.as_ptr(), cs(&hash).as_ptr())) };
assert_eq!(retrieved, "xy"); aphrodite_destroy(handle.as_ptr());
}
}