#![allow(clippy::missing_safety_doc)]
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
use std::ptr;
use std::cell::RefCell;
use std::path::PathBuf;
use std::collections::HashMap;
use crate::storage::Database;
use crate::storage::mvcc::IsolationLevel;
use crate::cli::Shell;
use crate::error::OdbError;
use crate::vector::VectorStore;
use crate::timeseries::TimeSeriesEngine;
use crate::graph::GraphEngine;
use crate::streaming::StreamEngine;
use crate::storage::ram::RamStore;
struct SubscriptionState {
topic: String,
consumer_group: Option<String>,
current_offset: u64,
}
pub struct OdbHandle {
db: Database,
db_path: PathBuf,
vector: Option<VectorStore>,
timeseries: Option<TimeSeriesEngine>,
graph: Option<GraphEngine>,
streaming: Option<StreamEngine>,
ram: Option<RamStore>,
auto_create_tables: bool,
subscriptions: HashMap<u64, SubscriptionState>,
sub_counter: u64,
}
impl OdbHandle {
fn new(db: Database, path: PathBuf) -> Self {
let auto = db.auto_create_tables();
Self {
db,
db_path: path,
vector: None,
timeseries: None,
graph: None,
streaming: None,
ram: None,
auto_create_tables: auto,
subscriptions: HashMap::new(),
sub_counter: 0,
}
}
fn engine_dir(&self) -> PathBuf {
self.db_path.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
fn vector_store(&mut self) -> &mut VectorStore {
if self.vector.is_none() {
let dir = self.engine_dir().join("_vector");
std::fs::create_dir_all(&dir).ok();
self.vector = Some(VectorStore::new(&dir));
}
self.vector.as_mut().unwrap()
}
fn timeseries_engine(&mut self) -> &mut TimeSeriesEngine {
if self.timeseries.is_none() {
let dir = self.engine_dir().join("_timeseries");
std::fs::create_dir_all(&dir).ok();
self.timeseries = Some(TimeSeriesEngine::new(&dir));
}
self.timeseries.as_mut().unwrap()
}
fn graph_engine(&mut self) -> &mut GraphEngine {
if self.graph.is_none() {
let dir = self.engine_dir().join("_graph");
std::fs::create_dir_all(&dir).ok();
self.graph = Some(GraphEngine::new(&dir));
}
self.graph.as_mut().unwrap()
}
fn stream_engine(&mut self) -> &mut StreamEngine {
if self.streaming.is_none() {
let dir = self.engine_dir().join("_streaming");
std::fs::create_dir_all(&dir).ok();
self.streaming = Some(StreamEngine::new(&dir));
}
self.streaming.as_mut().unwrap()
}
}
thread_local! {
static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
static LAST_ERROR_JSON: RefCell<Option<CString>> = const { RefCell::new(None) };
}
fn set_error(msg: &str) {
let cs = CString::new(msg).unwrap_or_else(|_| CString::new("(error message contained null byte)").unwrap());
LAST_ERROR.with(|e| *e.borrow_mut() = Some(cs));
let json = format!(
r#"{{"code":"ODB-FFI-099","message":{},"context":"","suggestions":[],"doc_link":"https://overdrive-db.com/docs/errors/ODB-FFI-099"}}"#,
serde_json::to_string(msg).unwrap_or_else(|_| format!("\"{}\"", msg))
);
let cs_json = CString::new(json).unwrap_or_else(|_| CString::new("{}").unwrap());
LAST_ERROR_JSON.with(|e| *e.borrow_mut() = Some(cs_json));
}
fn set_odb_error(err: &OdbError) {
let plain = err.to_string();
let cs = CString::new(plain).unwrap_or_else(|_| CString::new("error").unwrap());
LAST_ERROR.with(|e| *e.borrow_mut() = Some(cs));
let json = err.to_json();
let cs_json = CString::new(json).unwrap_or_else(|_| CString::new("{}").unwrap());
LAST_ERROR_JSON.with(|e| *e.borrow_mut() = Some(cs_json));
}
fn clear_error() {
LAST_ERROR.with(|e| *e.borrow_mut() = None);
LAST_ERROR_JSON.with(|e| *e.borrow_mut() = None);
}
unsafe fn c_str_to_string(ptr: *const c_char) -> Option<String> {
if ptr.is_null() {
return None;
}
Some(CStr::from_ptr(ptr).to_string_lossy().into_owned())
}
fn alloc_c_string(s: &str) -> *mut c_char {
CString::new(s)
.unwrap_or_else(|_| CString::new("").unwrap())
.into_raw()
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_open(path: *const c_char) -> *mut OdbHandle {
clear_error();
let path_str = match c_str_to_string(path) {
Some(p) => p,
None => { set_error("overdrive_open: path is null"); return ptr::null_mut(); }
};
let db = if std::path::Path::new(&path_str).exists() {
Database::open(&path_str)
} else {
Database::create(&path_str)
};
match db {
Ok(db) => Box::into_raw(Box::new(OdbHandle::new(db, PathBuf::from(&path_str)))),
Err(e) => { set_odb_error(&e.to_odb_error()); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_close(handle: *mut OdbHandle) {
if !handle.is_null() {
drop(Box::from_raw(handle));
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_sync(handle: *mut OdbHandle) {
if handle.is_null() { return; }
let _ = (*handle).db.sync();
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_table(handle: *mut OdbHandle, name: *const c_char) -> i32 {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name = match c_str_to_string(name) {
Some(n) => n,
None => { set_error("overdrive_create_table: null name"); return -1; }
};
match (*handle).db.create_table(&name) {
Ok(()) => 0,
Err(e) => { set_odb_error(&e.to_odb_error()); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_drop_table(handle: *mut OdbHandle, name: *const c_char) -> i32 {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name = match c_str_to_string(name) {
Some(n) => n,
None => { set_error("overdrive_drop_table: null name"); return -1; }
};
match (*handle).db.drop_table(&name) {
Ok(()) => 0,
Err(e) => { set_odb_error(&e.to_odb_error()); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_list_tables(handle: *mut OdbHandle) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let tables = (*handle).db.list_tables();
let json = serde_json::to_string(&tables).unwrap_or_else(|_| "[]".to_string());
alloc_c_string(&json)
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_table_exists(handle: *mut OdbHandle, name: *const c_char) -> i32 {
if handle.is_null() { return -1; }
let name = match c_str_to_string(name) {
Some(n) => n,
None => return -1,
};
let tables = (*handle).db.list_tables();
if tables.contains(&name) { 1 } else { 0 }
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_insert(
handle: *mut OdbHandle,
table: *const c_char,
json: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let table = match c_str_to_string(table) { Some(t) => t, None => { set_error("null table"); return ptr::null_mut(); } };
let json_str = match c_str_to_string(json) { Some(j) => j, None => { set_error("null json"); return ptr::null_mut(); } };
let value: serde_json::Value = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(e) => { set_error(&format!("JSON parse error: {}", e)); return ptr::null_mut(); }
};
match (*handle).db.insert_json(&table, &value) {
Ok(id) => alloc_c_string(&id),
Err(e) => { set_odb_error(&e.to_odb_error()); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_get(
handle: *mut OdbHandle,
table: *const c_char,
id: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { return ptr::null_mut(); }
let table = match c_str_to_string(table) { Some(t) => t, None => return ptr::null_mut() };
let id = match c_str_to_string(id) { Some(i) => i, None => return ptr::null_mut() };
match (*handle).db.get_json(&table, &id) {
Ok(Some(val)) => alloc_c_string(&val.to_string()),
Ok(None) => ptr::null_mut(),
Err(e) => { set_odb_error(&e.to_odb_error()); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_update(
handle: *mut OdbHandle,
table: *const c_char,
id: *const c_char,
json: *const c_char,
) -> i32 {
clear_error();
if handle.is_null() { return -1; }
let table = match c_str_to_string(table) { Some(t) => t, None => return -1 };
let id = match c_str_to_string(id) { Some(i) => i, None => return -1 };
let json_str = match c_str_to_string(json) { Some(j) => j, None => return -1 };
let value: serde_json::Value = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(e) => { set_error(&format!("JSON parse error: {}", e)); return -1; }
};
match (*handle).db.update(&table, &id, &value) {
Ok(true) => 1,
Ok(false) => 0,
Err(e) => { set_odb_error(&e.to_odb_error()); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_delete(
handle: *mut OdbHandle,
table: *const c_char,
id: *const c_char,
) -> i32 {
clear_error();
if handle.is_null() { return -1; }
let table = match c_str_to_string(table) { Some(t) => t, None => return -1 };
let id = match c_str_to_string(id) { Some(i) => i, None => return -1 };
match (*handle).db.delete(&table, id.as_bytes()) {
Ok(deleted) => if deleted { 1 } else { 0 },
Err(e) => { set_odb_error(&e.to_odb_error()); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_count(handle: *mut OdbHandle, table: *const c_char) -> i32 {
clear_error();
if handle.is_null() { return -1; }
let table = match c_str_to_string(table) { Some(t) => t, None => return -1 };
match (*handle).db.count(&table) {
Ok(n) => n as i32,
Err(e) => { set_odb_error(&e.to_odb_error()); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_query(
handle: *mut OdbHandle,
sql: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let sql = match c_str_to_string(sql) { Some(s) => s, None => return ptr::null_mut() };
let h = &*handle;
let db_dir = h.db_path.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| ".".to_string());
let mut shell = Shell::new(&db_dir);
if let Some(stem) = h.db_path.file_stem().and_then(|s| s.to_str()) {
let _ = shell.parse_and_execute(&format!("USE {};", stem));
}
match shell.parse_and_execute(&sql) {
Ok(output) => {
let json = serde_json::json!({ "result": output, "ok": true });
alloc_c_string(&json.to_string())
}
Err(e) => {
set_error(&e.to_string());
let json = serde_json::json!({ "result": e.to_string(), "ok": false });
alloc_c_string(&json.to_string())
}
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_search(
handle: *mut OdbHandle,
_table: *const c_char, text: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { return ptr::null_mut(); }
let text = match c_str_to_string(text) { Some(t) => t, None => return ptr::null_mut() };
let results = (*handle).db.search_text(&text);
let json = serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string());
alloc_c_string(&json)
}
#[no_mangle]
pub extern "C" fn overdrive_last_error() -> *const c_char {
LAST_ERROR.with(|e| {
match &*e.borrow() {
Some(cs) => cs.as_ptr(),
None => ptr::null(),
}
})
}
#[no_mangle]
pub extern "C" fn overdrive_last_error_json() -> *const c_char {
LAST_ERROR_JSON.with(|e| {
match &*e.borrow() {
Some(cs) => cs.as_ptr(),
None => ptr::null(),
}
})
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_free_string(ptr: *mut c_char) {
if !ptr.is_null() {
drop(CString::from_raw(ptr));
}
}
#[no_mangle]
pub extern "C" fn overdrive_version() -> *const c_char {
c"1.4.4".as_ptr()
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_begin_transaction(handle: *mut OdbHandle, isolation_level: i32) -> u64 {
clear_error();
if handle.is_null() { set_error("null handle"); return 0; }
let isolation = match isolation_level {
0 => IsolationLevel::ReadUncommitted,
2 => IsolationLevel::RepeatableRead,
3 => IsolationLevel::Serializable,
_ => IsolationLevel::ReadCommitted,
};
match (*handle).db.begin_transaction(isolation) {
Ok(txn) => txn.id,
Err(e) => { set_odb_error(&e.to_odb_error()); 0 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_commit_transaction(handle: *mut OdbHandle, txn_id: u64) -> i32 {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
match (*handle).db.commit_transaction(txn_id) {
Ok(()) => 0,
Err(e) => { set_odb_error(&e.to_odb_error()); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_abort_transaction(handle: *mut OdbHandle, txn_id: u64) -> i32 {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
match (*handle).db.abort_transaction(txn_id) {
Ok(()) => 0,
Err(e) => { set_odb_error(&e.to_odb_error()); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_verify_integrity(handle: *mut OdbHandle) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let tables = (*handle).db.list_tables();
let mut issues: Vec<String> = Vec::new();
let mut total_records: usize = 0;
for table in &tables {
match (*handle).db.count(table) {
Ok(n) => total_records += n,
Err(e) => issues.push(format!("table '{}': {}", table, e)),
}
}
let report = serde_json::json!({
"valid": issues.is_empty(),
"pages_checked": total_records,
"tables_verified": tables.len(),
"issues": issues,
});
alloc_c_string(&report.to_string())
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_open_with_engine(
path: *const c_char,
engine: *const c_char,
options_json: *const c_char,
) -> *mut OdbHandle {
clear_error();
let path_str = match c_str_to_string(path) {
Some(p) => p,
None => { set_error("overdrive_open_with_engine: path is null"); return ptr::null_mut(); }
};
let engine_str = c_str_to_string(engine).unwrap_or_else(|| "Disk".to_string());
let mut password: Option<String> = None;
let mut auto_create = true;
if let Some(opts_str) = c_str_to_string(options_json) {
if let Ok(opts) = serde_json::from_str::<serde_json::Value>(&opts_str) {
if let Some(pwd) = opts.get("password").and_then(|v| v.as_str()) {
password = Some(pwd.to_string());
}
if let Some(ac) = opts.get("auto_create_tables").and_then(|v| v.as_bool()) {
auto_create = ac;
}
}
}
let db_result = if std::path::Path::new(&path_str).exists() {
Database::open_with_password(&path_str, password.as_deref())
} else {
match password {
Some(ref pwd) => Database::create_with_password(&path_str, pwd),
None => Database::create(&path_str),
}
};
match db_result {
Ok(db) => {
let mut handle = OdbHandle::new(db, PathBuf::from(&path_str));
handle.auto_create_tables = auto_create;
if engine_str == "RAM" {
let name = std::path::Path::new(&path_str)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("ram_db")
.to_string();
handle.ram = Some(RamStore::new(&name));
}
Box::into_raw(Box::new(handle))
}
Err(e) => { set_odb_error(&e.to_odb_error()); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_get_engine_type(handle: *mut OdbHandle) -> *mut c_char {
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let engine = if (*handle).ram.is_some() {
"RAM"
} else if (*handle).vector.is_some() {
"Vector"
} else if (*handle).timeseries.is_some() {
"Time-Series"
} else if (*handle).graph.is_some() {
"Graph"
} else if (*handle).streaming.is_some() {
"Streaming"
} else {
"Disk"
};
alloc_c_string(engine)
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_table_with_engine(
handle: *mut OdbHandle,
table_name: *const c_char,
engine: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name = match c_str_to_string(table_name) {
Some(n) => n,
None => { set_error("null table_name"); return -1; }
};
let engine_str = c_str_to_string(engine).unwrap_or_else(|| "Disk".to_string());
match engine_str.as_str() {
"RAM" => {
let ram = (*handle).ram.get_or_insert_with(|| RamStore::new("default"));
match ram.create_table(&name) {
Ok(()) => 0,
Err(e) => { set_error(&e.to_string()); -1 }
}
}
_ => {
match (*handle).db.create_table(&name) {
Ok(()) => 0,
Err(e) => { set_odb_error(&e.to_odb_error()); -1 }
}
}
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_ram_db(
path: *const c_char,
max_memory_bytes: u64,
) -> *mut OdbHandle {
clear_error();
let path_str = match c_str_to_string(path) {
Some(p) => p,
None => { set_error("overdrive_create_ram_db: path is null"); return ptr::null_mut(); }
};
let db_result = if std::path::Path::new(&path_str).exists() {
Database::open(&path_str)
} else {
Database::create(&path_str)
};
match db_result {
Ok(db) => {
let name = std::path::Path::new(&path_str)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("ram_db")
.to_string();
let ram = if max_memory_bytes > 0 {
RamStore::with_memory_limit(&name, max_memory_bytes as usize)
} else {
RamStore::new(&name)
};
let mut handle = OdbHandle::new(db, PathBuf::from(&path_str));
handle.ram = Some(ram);
Box::into_raw(Box::new(handle))
}
Err(e) => { set_odb_error(&e.to_odb_error()); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_ram_table(
handle: *mut OdbHandle,
table_name: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name = match c_str_to_string(table_name) {
Some(n) => n,
None => { set_error("null table_name"); return -1; }
};
let ram = (*handle).ram.get_or_insert_with(|| RamStore::new("default"));
match ram.create_table(&name) {
Ok(()) => 0,
Err(e) => { set_error(&e.to_string()); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_snapshot(
handle: *mut OdbHandle,
snapshot_path: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let snap_path = match c_str_to_string(snapshot_path) {
Some(p) => p,
None => { set_error("null snapshot_path"); return -1; }
};
match &(*handle).ram {
Some(ram) => match ram.snapshot(&snap_path) {
Ok(_) => 0,
Err(e) => { set_error(&e.to_string()); -1 }
},
None => { set_error("No RAM store on this handle"); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_restore(
handle: *mut OdbHandle,
snapshot_path: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let snap_path = match c_str_to_string(snapshot_path) {
Some(p) => p,
None => { set_error("null snapshot_path"); return -1; }
};
match &mut (*handle).ram {
Some(ram) => match ram.restore(&snap_path) {
Ok(_) => 0,
Err(e) => { set_error(&e.to_string()); -1 }
},
None => { set_error("No RAM store on this handle"); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_memory_usage(handle: *mut OdbHandle) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
match &(*handle).ram {
Some(ram) => {
let stats = ram.stats();
let bytes = stats.memory_bytes as u64;
let mb = bytes as f64 / (1024.0 * 1024.0);
let json = match stats.max_memory_bytes {
Some(limit) => {
let limit_bytes = limit as u64;
let percent = if limit_bytes > 0 {
Some((bytes as f64 / limit_bytes as f64) * 100.0)
} else {
None
};
serde_json::json!({
"bytes": bytes,
"mb": (mb * 10.0).round() / 10.0,
"limit_bytes": limit_bytes,
"percent": percent,
})
}
None => {
serde_json::json!({
"bytes": bytes,
"mb": (mb * 10.0).round() / 10.0,
"limit_bytes": serde_json::Value::Null,
"percent": serde_json::Value::Null,
})
}
};
alloc_c_string(&json.to_string())
}
None => { set_error("No RAM store on this handle"); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_set_memory_limit(
handle: *mut OdbHandle,
max_bytes: u64,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
match &mut (*handle).ram {
Some(ram) => {
ram.set_memory_limit(max_bytes as usize);
0
}
None => { set_error("No RAM store on this handle"); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_timeseries(
handle: *mut OdbHandle,
name: *const c_char,
ttl_seconds: u64,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name_str = match c_str_to_string(name) {
Some(n) => n,
None => { set_error("null name"); return -1; }
};
let ttl_str = ttl_seconds.to_string();
let ts = (*handle).timeseries_engine();
match ts.create(&name_str, &ttl_str) {
Ok(()) => 0,
Err(e) => { set_error(&e); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_insert_measurement(
handle: *mut OdbHandle,
timeseries: *const c_char,
measurement_json: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let series_name = match c_str_to_string(timeseries) {
Some(n) => n,
None => { set_error("null timeseries"); return -1; }
};
let json_str = match c_str_to_string(measurement_json) {
Some(j) => j,
None => { set_error("null measurement_json"); return -1; }
};
let val: serde_json::Value = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(e) => { set_error(&format!("JSON parse error: {}", e)); return -1; }
};
let obj = match val.as_object() {
Some(o) => o,
None => { set_error("measurement_json must be a JSON object"); return -1; }
};
let mut parts: Vec<String> = Vec::new();
for (k, v) in obj {
if k == "timestamp" || k == "ts" {
if let Some(ts) = v.as_i64() {
parts.push(format!("ts={}", ts * 1_000_000_000i64));
}
} else if let Some(n) = v.as_f64() {
parts.push(format!("{}={}", k, n));
} else if let Some(s) = v.as_str() {
parts.push(format!("{}=\"{}\"", k, s));
}
}
if parts.is_empty() {
set_error("measurement_json must contain at least one field");
return -1;
}
let assignments = parts.join(", ");
let ts = (*handle).timeseries_engine();
match ts.insert(&series_name, &assignments) {
Ok(()) => 0,
Err(e) => { set_error(&e); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_query_timeseries(
handle: *mut OdbHandle,
timeseries: *const c_char,
start_ts: i64,
end_ts: i64,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let series_name = match c_str_to_string(timeseries) {
Some(n) => n,
None => { set_error("null timeseries"); return ptr::null_mut(); }
};
let from_ns = start_ts.saturating_mul(1_000_000_000);
let to_ns = end_ts.saturating_mul(1_000_000_000);
let ts = (*handle).timeseries_engine();
let measurements = ts.query_range(&series_name, from_ns, to_ns);
let result: Vec<serde_json::Value> = measurements.into_iter().map(|m| {
let mut obj = serde_json::Map::new();
obj.insert("timestamp".to_string(), serde_json::json!(m.timestamp_ns / 1_000_000_000));
obj.insert("timestamp_ns".to_string(), serde_json::json!(m.timestamp_ns));
for (k, v) in &m.fields {
obj.insert(k.clone(), serde_json::json!(v));
}
for (k, v) in &m.tags {
obj.insert(k.clone(), serde_json::json!(v));
}
serde_json::Value::Object(obj)
}).collect();
let json = serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string());
alloc_c_string(&json)
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_aggregate_timeseries(
handle: *mut OdbHandle,
timeseries: *const c_char,
start_ts: i64,
end_ts: i64,
window_sec: i64,
aggregation: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let series_name = match c_str_to_string(timeseries) {
Some(n) => n,
None => { set_error("null timeseries"); return ptr::null_mut(); }
};
let agg_str = c_str_to_string(aggregation).unwrap_or_else(|| "avg".to_string());
let agg_func = match crate::timeseries::query::AggFunc::from_str(&agg_str) {
Some(f) => f,
None => { set_error(&format!("Unknown aggregation '{}'. Use: avg, sum, min, max, count", agg_str)); return ptr::null_mut(); }
};
let from_ns = start_ts.saturating_mul(1_000_000_000);
let to_ns = end_ts.saturating_mul(1_000_000_000);
let window_ns = window_sec.saturating_mul(1_000_000_000);
if window_ns <= 0 {
set_error("window_sec must be > 0");
return ptr::null_mut();
}
let q = crate::timeseries::query::WindowQuery {
series: series_name,
field: "value".to_string(), agg: agg_func,
from_ns,
to_ns,
window_ns,
group_by: None,
};
let ts = (*handle).timeseries_engine();
let buckets = ts.window_query(q);
let result: Vec<serde_json::Value> = buckets.into_iter().map(|b| {
serde_json::json!({
"window_start": b.window_start_ns / 1_000_000_000,
"window_end": b.window_end_ns / 1_000_000_000,
"window_start_ns": b.window_start_ns,
"window_end_ns": b.window_end_ns,
"value": b.value,
"count": b.count,
})
}).collect();
let json = serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string());
alloc_c_string(&json)
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_drop_timeseries(
handle: *mut OdbHandle,
name: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name_str = match c_str_to_string(name) {
Some(n) => n,
None => { set_error("null name"); return -1; }
};
let ts = (*handle).timeseries_engine();
if ts.drop_series(&name_str) {
0
} else {
set_error(&format!("Time-series '{}' not found", name_str));
-1
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_list_timeseries(handle: *mut OdbHandle) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let ts = (*handle).timeseries_engine();
let list = ts.list();
let result: Vec<serde_json::Value> = list.into_iter().map(|def| {
serde_json::json!({
"name": def.name,
"ttl_seconds": def.ttl_seconds,
"created_at": def.created_at,
})
}).collect();
let json = serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string());
alloc_c_string(&json)
}
#[cfg(test)]
mod timeseries_ffi_tests {
use super::*;
use std::ffi::CString;
fn open_temp_db() -> (*mut OdbHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let handle = unsafe { overdrive_open(path_cstr.as_ptr()) };
assert!(!handle.is_null(), "Failed to open temp db");
(handle, dir)
}
#[test]
fn test_create_timeseries_success() {
let (handle, _dir) = open_temp_db();
let name = CString::new("cpu_usage").unwrap();
let rc = unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 86400) };
assert_eq!(rc, 0, "create_timeseries should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_timeseries_no_ttl() {
let (handle, _dir) = open_temp_db();
let name = CString::new("forever_series").unwrap();
let rc = unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 0) };
assert_eq!(rc, 0, "create_timeseries with ttl=0 should succeed");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_timeseries_null_handle() {
let name = CString::new("x").unwrap();
let rc = unsafe { overdrive_create_timeseries(ptr::null_mut(), name.as_ptr(), 0) };
assert_eq!(rc, -1);
}
#[test]
fn test_insert_measurement_success() {
let (handle, _dir) = open_temp_db();
let name = CString::new("temp").unwrap();
unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 0) };
let meas = CString::new(r#"{"value": 23.5}"#).unwrap();
let rc = unsafe { overdrive_insert_measurement(handle, name.as_ptr(), meas.as_ptr()) };
assert_eq!(rc, 0, "insert_measurement should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_insert_measurement_with_timestamp() {
let (handle, _dir) = open_temp_db();
let name = CString::new("pressure").unwrap();
unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 0) };
let meas = CString::new(r#"{"value": 101.3, "timestamp": 1700000000}"#).unwrap();
let rc = unsafe { overdrive_insert_measurement(handle, name.as_ptr(), meas.as_ptr()) };
assert_eq!(rc, 0);
unsafe { overdrive_close(handle) };
}
#[test]
fn test_insert_measurement_nonexistent_series() {
let (handle, _dir) = open_temp_db();
let name = CString::new("ghost").unwrap();
let meas = CString::new(r#"{"value": 1.0}"#).unwrap();
let rc = unsafe { overdrive_insert_measurement(handle, name.as_ptr(), meas.as_ptr()) };
assert_eq!(rc, -1, "insert into non-existent series should fail");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_query_timeseries_returns_data() {
let (handle, _dir) = open_temp_db();
let name = CString::new("humidity").unwrap();
unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 0) };
let meas = CString::new(r#"{"value": 55.0}"#).unwrap();
unsafe { overdrive_insert_measurement(handle, name.as_ptr(), meas.as_ptr()) };
let now = chrono::Utc::now().timestamp();
let result_ptr = unsafe {
overdrive_query_timeseries(handle, name.as_ptr(), now - 60, now + 60)
};
assert!(!result_ptr.is_null(), "query_timeseries should return a JSON string");
let s = unsafe { CStr::from_ptr(result_ptr).to_string_lossy().into_owned() };
let parsed: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
let arr = parsed.as_array().expect("array");
assert_eq!(arr.len(), 1, "should return 1 measurement");
assert!(arr[0].get("value").is_some(), "measurement should have 'value' field");
assert!(arr[0].get("timestamp").is_some(), "measurement should have 'timestamp' field");
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_query_timeseries_empty_range() {
let (handle, _dir) = open_temp_db();
let name = CString::new("empty_range").unwrap();
unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 0) };
let result_ptr = unsafe {
overdrive_query_timeseries(handle, name.as_ptr(), 0, 1)
};
assert!(!result_ptr.is_null());
let s = unsafe { CStr::from_ptr(result_ptr).to_string_lossy().into_owned() };
assert_eq!(s, "[]");
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_aggregate_timeseries_avg() {
let (handle, _dir) = open_temp_db();
let name = CString::new("agg_test").unwrap();
unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 0) };
for v in [10.0f64, 20.0f64] {
let meas = CString::new(format!(r#"{{"value": {}}}"#, v)).unwrap();
unsafe { overdrive_insert_measurement(handle, name.as_ptr(), meas.as_ptr()) };
}
let now = chrono::Utc::now().timestamp();
let agg = CString::new("avg").unwrap();
let result_ptr = unsafe {
overdrive_aggregate_timeseries(handle, name.as_ptr(), now - 60, now + 60, 120, agg.as_ptr())
};
assert!(!result_ptr.is_null(), "aggregate_timeseries should return JSON");
let s = unsafe { CStr::from_ptr(result_ptr).to_string_lossy().into_owned() };
let parsed: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
let arr = parsed.as_array().expect("array");
assert!(!arr.is_empty(), "should have at least one bucket");
let bucket = &arr[0];
assert!(bucket.get("value").is_some(), "bucket should have 'value'");
assert!(bucket.get("window_start").is_some(), "bucket should have 'window_start'");
assert!(bucket.get("count").is_some(), "bucket should have 'count'");
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_aggregate_timeseries_invalid_agg() {
let (handle, _dir) = open_temp_db();
let name = CString::new("bad_agg").unwrap();
unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 0) };
let now = chrono::Utc::now().timestamp();
let agg = CString::new("median").unwrap(); let result_ptr = unsafe {
overdrive_aggregate_timeseries(handle, name.as_ptr(), now - 60, now + 60, 60, agg.as_ptr())
};
assert!(result_ptr.is_null(), "aggregate with unknown function should return NULL");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_drop_timeseries_success() {
let (handle, _dir) = open_temp_db();
let name = CString::new("to_drop").unwrap();
unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 0) };
let rc = unsafe { overdrive_drop_timeseries(handle, name.as_ptr()) };
assert_eq!(rc, 0, "drop_timeseries should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_drop_timeseries_not_found() {
let (handle, _dir) = open_temp_db();
let name = CString::new("nonexistent").unwrap();
let rc = unsafe { overdrive_drop_timeseries(handle, name.as_ptr()) };
assert_eq!(rc, -1, "drop_timeseries should return -1 when not found");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_list_timeseries_empty() {
let (handle, _dir) = open_temp_db();
let result_ptr = unsafe { overdrive_list_timeseries(handle) };
assert!(!result_ptr.is_null());
let s = unsafe { CStr::from_ptr(result_ptr).to_string_lossy().into_owned() };
let parsed: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
assert!(parsed.is_array());
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_list_timeseries_after_create() {
let (handle, _dir) = open_temp_db();
for series in ["s1", "s2", "s3"] {
let name = CString::new(series).unwrap();
unsafe { overdrive_create_timeseries(handle, name.as_ptr(), 3600) };
}
let result_ptr = unsafe { overdrive_list_timeseries(handle) };
assert!(!result_ptr.is_null());
let s = unsafe { CStr::from_ptr(result_ptr).to_string_lossy().into_owned() };
let parsed: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
let arr = parsed.as_array().expect("array");
assert_eq!(arr.len(), 3, "should list 3 time-series");
let first = &arr[0];
assert!(first.get("name").is_some());
assert!(first.get("ttl_seconds").is_some());
assert!(first.get("created_at").is_some());
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_vector_index(
handle: *mut OdbHandle,
table: *const c_char,
_field: *const c_char,
dimensions: u32,
metric: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let table_str = match c_str_to_string(table) {
Some(t) => t,
None => { set_error("null table"); return -1; }
};
let metric_str = c_str_to_string(metric).unwrap_or_else(|| "cosine".to_string());
let vs = (*handle).vector_store();
match vs.create_collection(&table_str, dimensions as usize, &metric_str) {
Ok(()) => 0,
Err(e) => { set_error(&e); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_insert_vector(
handle: *mut OdbHandle,
table: *const c_char,
json: *const c_char,
embedding_json: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let table_str = match c_str_to_string(table) {
Some(t) => t,
None => { set_error("null table"); return ptr::null_mut(); }
};
let json_str = match c_str_to_string(json) {
Some(j) => j,
None => { set_error("null json"); return ptr::null_mut(); }
};
let emb_str = match c_str_to_string(embedding_json) {
Some(e) => e,
None => { set_error("null embedding_json"); return ptr::null_mut(); }
};
let meta: serde_json::Value = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(e) => { set_error(&format!("JSON parse error: {}", e)); return ptr::null_mut(); }
};
let vector = match crate::vector::parse_vector(&emb_str) {
Ok(v) => v,
Err(e) => { set_error(&e); return ptr::null_mut(); }
};
let id = meta.get("_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}_vec_{}", table_str, chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
let vs = (*handle).vector_store();
match vs.insert(&table_str, &id, vector, Some(meta)) {
Ok(()) => alloc_c_string(&id),
Err(e) => { set_error(&e); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_vector_search(
handle: *mut OdbHandle,
table: *const c_char,
query_vector_json: *const c_char,
limit: u32,
metric: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let table_str = match c_str_to_string(table) {
Some(t) => t,
None => { set_error("null table"); return ptr::null_mut(); }
};
let qvec_str = match c_str_to_string(query_vector_json) {
Some(q) => q,
None => { set_error("null query_vector_json"); return ptr::null_mut(); }
};
let metric_opt = c_str_to_string(metric);
let query_vec = match crate::vector::parse_vector(&qvec_str) {
Ok(v) => v,
Err(e) => { set_error(&e); return ptr::null_mut(); }
};
let k = if limit == 0 { 10 } else { limit as usize };
let vs = (*handle).vector_store();
match vs.search(&table_str, &query_vec, metric_opt.as_deref(), k) {
Ok(results) => {
let mapped: Vec<serde_json::Value> = results.into_iter().map(|r| {
serde_json::json!({
"id": r.id,
"score": r.distance,
"metadata": r.metadata,
})
}).collect();
let json = serde_json::to_string(&mapped).unwrap_or_else(|_| "[]".to_string());
alloc_c_string(&json)
}
Err(e) => { set_error(&e); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_drop_vector_index(
handle: *mut OdbHandle,
table: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let table_str = match c_str_to_string(table) {
Some(t) => t,
None => { set_error("null table"); return -1; }
};
let vs = (*handle).vector_store();
if vs.drop_collection(&table_str) { 0 } else { set_error("Vector index not found"); -1 }
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_list_vector_indexes(handle: *mut OdbHandle) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let vs = (*handle).vector_store();
let list = vs.list();
let json = serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string());
alloc_c_string(&json)
}
#[cfg(test)]
mod vector_ffi_tests {
use super::*;
use std::ffi::CString;
fn open_temp_db() -> (*mut OdbHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let handle = unsafe { overdrive_open(path_cstr.as_ptr()) };
assert!(!handle.is_null(), "Failed to open temp db");
(handle, dir)
}
#[test]
fn test_create_vector_index_success() {
let (handle, _dir) = open_temp_db();
let table = CString::new("embeddings").unwrap();
let field = CString::new("vec").unwrap();
let metric = CString::new("cosine").unwrap();
let rc = unsafe { overdrive_create_vector_index(handle, table.as_ptr(), field.as_ptr(), 3, metric.as_ptr()) };
assert_eq!(rc, 0, "create_vector_index should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_vector_index_invalid_metric() {
let (handle, _dir) = open_temp_db();
let table = CString::new("bad_metric_table").unwrap();
let field = CString::new("vec").unwrap();
let metric = CString::new("manhattan").unwrap(); let rc = unsafe { overdrive_create_vector_index(handle, table.as_ptr(), field.as_ptr(), 3, metric.as_ptr()) };
assert_eq!(rc, -1, "create_vector_index should return -1 for unknown metric");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_vector_index_null_handle() {
let table = CString::new("t").unwrap();
let field = CString::new("f").unwrap();
let metric = CString::new("cosine").unwrap();
let rc = unsafe { overdrive_create_vector_index(ptr::null_mut(), table.as_ptr(), field.as_ptr(), 3, metric.as_ptr()) };
assert_eq!(rc, -1);
}
#[test]
fn test_insert_vector_success() {
let (handle, _dir) = open_temp_db();
let table = CString::new("docs").unwrap();
let field = CString::new("emb").unwrap();
let metric = CString::new("cosine").unwrap();
unsafe { overdrive_create_vector_index(handle, table.as_ptr(), field.as_ptr(), 3, metric.as_ptr()) };
let json = CString::new(r#"{"_id":"doc1","title":"hello"}"#).unwrap();
let emb = CString::new("[1.0, 0.0, 0.0]").unwrap();
let id_ptr = unsafe { overdrive_insert_vector(handle, table.as_ptr(), json.as_ptr(), emb.as_ptr()) };
assert!(!id_ptr.is_null(), "insert_vector should return an ID");
let id_str = unsafe { CStr::from_ptr(id_ptr).to_string_lossy().into_owned() };
assert_eq!(id_str, "doc1");
unsafe { overdrive_free_string(id_ptr); overdrive_close(handle) };
}
#[test]
fn test_insert_vector_auto_id() {
let (handle, _dir) = open_temp_db();
let table = CString::new("autoid_docs").unwrap();
let field = CString::new("emb").unwrap();
let metric = CString::new("euclidean").unwrap();
unsafe { overdrive_create_vector_index(handle, table.as_ptr(), field.as_ptr(), 2, metric.as_ptr()) };
let json = CString::new(r#"{"title":"auto"}"#).unwrap();
let emb = CString::new("[0.5, 0.5]").unwrap();
let id_ptr = unsafe { overdrive_insert_vector(handle, table.as_ptr(), json.as_ptr(), emb.as_ptr()) };
assert!(!id_ptr.is_null(), "insert_vector should return an auto-generated ID");
unsafe { overdrive_free_string(id_ptr); overdrive_close(handle) };
}
#[test]
fn test_insert_vector_wrong_dimensions() {
let (handle, _dir) = open_temp_db();
let table = CString::new("dim_check").unwrap();
let field = CString::new("emb").unwrap();
let metric = CString::new("cosine").unwrap();
unsafe { overdrive_create_vector_index(handle, table.as_ptr(), field.as_ptr(), 3, metric.as_ptr()) };
let json = CString::new(r#"{"_id":"x"}"#).unwrap();
let emb = CString::new("[1.0, 0.0]").unwrap(); let id_ptr = unsafe { overdrive_insert_vector(handle, table.as_ptr(), json.as_ptr(), emb.as_ptr()) };
assert!(id_ptr.is_null(), "insert_vector should fail on dimension mismatch");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_vector_search_returns_results() {
let (handle, _dir) = open_temp_db();
let table = CString::new("search_test").unwrap();
let field = CString::new("emb").unwrap();
let metric = CString::new("cosine").unwrap();
unsafe { overdrive_create_vector_index(handle, table.as_ptr(), field.as_ptr(), 3, metric.as_ptr()) };
for (id, vec) in [("a", "[1.0,0.0,0.0]"), ("b", "[0.0,1.0,0.0]"), ("c", "[0.9,0.1,0.0]")] {
let json = CString::new(format!(r#"{{"_id":"{}"}}"#, id)).unwrap();
let emb = CString::new(vec).unwrap();
unsafe { overdrive_insert_vector(handle, table.as_ptr(), json.as_ptr(), emb.as_ptr()) };
}
let query = CString::new("[1.0,0.0,0.0]").unwrap();
let metric_null: *const c_char = ptr::null();
let result_ptr = unsafe { overdrive_vector_search(handle, table.as_ptr(), query.as_ptr(), 2, metric_null) };
assert!(!result_ptr.is_null(), "vector_search should return results");
let result_str = unsafe { CStr::from_ptr(result_ptr).to_string_lossy().into_owned() };
let parsed: serde_json::Value = serde_json::from_str(&result_str).expect("valid JSON");
let arr = parsed.as_array().expect("array");
assert!(!arr.is_empty(), "should have at least one result");
let first = &arr[0];
assert!(first.get("id").is_some(), "result should have 'id'");
assert!(first.get("score").is_some(), "result should have 'score'");
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_vector_search_with_metric_override() {
let (handle, _dir) = open_temp_db();
let table = CString::new("metric_override").unwrap();
let field = CString::new("emb").unwrap();
let metric = CString::new("cosine").unwrap();
unsafe { overdrive_create_vector_index(handle, table.as_ptr(), field.as_ptr(), 3, metric.as_ptr()) };
let json = CString::new(r#"{"_id":"v1"}"#).unwrap();
let emb = CString::new("[1.0,0.0,0.0]").unwrap();
unsafe { overdrive_insert_vector(handle, table.as_ptr(), json.as_ptr(), emb.as_ptr()) };
let query = CString::new("[1.0,0.0,0.0]").unwrap();
let dot_metric = CString::new("dot").unwrap();
let result_ptr = unsafe { overdrive_vector_search(handle, table.as_ptr(), query.as_ptr(), 1, dot_metric.as_ptr()) };
assert!(!result_ptr.is_null());
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_drop_vector_index_success() {
let (handle, _dir) = open_temp_db();
let table = CString::new("to_drop").unwrap();
let field = CString::new("emb").unwrap();
let metric = CString::new("cosine").unwrap();
unsafe { overdrive_create_vector_index(handle, table.as_ptr(), field.as_ptr(), 3, metric.as_ptr()) };
let rc = unsafe { overdrive_drop_vector_index(handle, table.as_ptr()) };
assert_eq!(rc, 0, "drop_vector_index should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_drop_vector_index_not_found() {
let (handle, _dir) = open_temp_db();
let table = CString::new("nonexistent").unwrap();
let rc = unsafe { overdrive_drop_vector_index(handle, table.as_ptr()) };
assert_eq!(rc, -1, "drop_vector_index should return -1 when index not found");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_list_vector_indexes_empty() {
let (handle, _dir) = open_temp_db();
let result_ptr = unsafe { overdrive_list_vector_indexes(handle) };
assert!(!result_ptr.is_null());
let s = unsafe { CStr::from_ptr(result_ptr).to_string_lossy().into_owned() };
let parsed: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
assert!(parsed.is_array());
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_list_vector_indexes_after_create() {
let (handle, _dir) = open_temp_db();
for name in ["idx1", "idx2"] {
let table = CString::new(name).unwrap();
let field = CString::new("emb").unwrap();
let metric = CString::new("cosine").unwrap();
unsafe { overdrive_create_vector_index(handle, table.as_ptr(), field.as_ptr(), 4, metric.as_ptr()) };
}
let result_ptr = unsafe { overdrive_list_vector_indexes(handle) };
assert!(!result_ptr.is_null());
let s = unsafe { CStr::from_ptr(result_ptr).to_string_lossy().into_owned() };
let parsed: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
let arr = parsed.as_array().expect("array");
assert_eq!(arr.len(), 2, "should list 2 indexes");
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_node_type(
handle: *mut OdbHandle,
type_name: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name = match c_str_to_string(type_name) {
Some(n) => n,
None => { set_error("null type_name"); return -1; }
};
let graph = (*handle).graph_engine();
match graph.create_node_type(&name) {
Ok(()) => 0,
Err(e) => { set_error(&e); -1 }
}
}
#[cfg(test)]
mod graph_ffi_tests {
use super::*;
use std::ffi::CString;
fn open_temp_db() -> (*mut OdbHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let handle = unsafe { overdrive_open(path_cstr.as_ptr()) };
assert!(!handle.is_null(), "Failed to open temp db");
(handle, dir)
}
#[test]
fn test_create_node_type_success() {
let (handle, _dir) = open_temp_db();
let type_name = CString::new("Person").unwrap();
let rc = unsafe { overdrive_create_node_type(handle, type_name.as_ptr()) };
assert_eq!(rc, 0, "create_node_type should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_node_type_duplicate_returns_error() {
let (handle, _dir) = open_temp_db();
let type_name = CString::new("Product").unwrap();
let rc1 = unsafe { overdrive_create_node_type(handle, type_name.as_ptr()) };
assert_eq!(rc1, 0, "first create should succeed");
let rc2 = unsafe { overdrive_create_node_type(handle, type_name.as_ptr()) };
assert_eq!(rc2, -1, "duplicate create_node_type should return -1");
let err_ptr = overdrive_last_error();
assert!(!err_ptr.is_null(), "last error should be set on duplicate");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_node_type_null_handle() {
let type_name = CString::new("Node").unwrap();
let rc = unsafe { overdrive_create_node_type(ptr::null_mut(), type_name.as_ptr()) };
assert_eq!(rc, -1, "null handle should return -1");
}
#[test]
fn test_create_node_type_null_name() {
let (handle, _dir) = open_temp_db();
let rc = unsafe { overdrive_create_node_type(handle, ptr::null()) };
assert_eq!(rc, -1, "null type_name should return -1");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_multiple_node_types() {
let (handle, _dir) = open_temp_db();
for name in ["Person", "Company", "Location"] {
let type_name = CString::new(name).unwrap();
let rc = unsafe { overdrive_create_node_type(handle, type_name.as_ptr()) };
assert_eq!(rc, 0, "create_node_type('{}') should succeed", name);
}
unsafe { overdrive_close(handle) };
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_edge_type(
handle: *mut OdbHandle,
type_name: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name = match c_str_to_string(type_name) {
Some(n) => n,
None => { set_error("null type_name"); return -1; }
};
let graph = (*handle).graph_engine();
match graph.create_edge_type(&name) {
Ok(()) => 0,
Err(e) => { set_error(&e); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_node(
handle: *mut OdbHandle,
type_name: *const c_char,
properties_json: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let type_str = match c_str_to_string(type_name) {
Some(s) => s,
None => { set_error("null type_name"); return ptr::null_mut(); }
};
let props_str = match c_str_to_string(properties_json) {
Some(s) => s,
None => { set_error("null properties_json"); return ptr::null_mut(); }
};
let props: serde_json::Value = match serde_json::from_str(&props_str) {
Ok(v) => v,
Err(e) => { set_error(&format!("invalid JSON: {}", e)); return ptr::null_mut(); }
};
let graph = (*handle).graph_engine();
match graph.create_node(&type_str, props) {
Ok(id) => alloc_c_string(&id),
Err(e) => { set_error(&e); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_edge(
handle: *mut OdbHandle,
type_name: *const c_char,
from_node_id: *const c_char,
to_node_id: *const c_char,
properties_json: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let type_str = match c_str_to_string(type_name) {
Some(s) => s,
None => { set_error("null type_name"); return ptr::null_mut(); }
};
let from_str = match c_str_to_string(from_node_id) {
Some(s) => s,
None => { set_error("null from_node_id"); return ptr::null_mut(); }
};
let to_str = match c_str_to_string(to_node_id) {
Some(s) => s,
None => { set_error("null to_node_id"); return ptr::null_mut(); }
};
let props_str = match c_str_to_string(properties_json) {
Some(s) => s,
None => { set_error("null properties_json"); return ptr::null_mut(); }
};
let props: serde_json::Value = match serde_json::from_str(&props_str) {
Ok(v) => v,
Err(e) => { set_error(&format!("invalid JSON: {}", e)); return ptr::null_mut(); }
};
let graph = (*handle).graph_engine();
match graph.create_edge(&type_str, &from_str, &to_str, props) {
Ok(id) => alloc_c_string(&id),
Err(e) => { set_error(&e); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_graph_traverse(
handle: *mut OdbHandle,
match_query: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let query_str = match c_str_to_string(match_query) {
Some(s) => s,
None => { set_error("null match_query"); return ptr::null_mut(); }
};
let parsed = match crate::graph::query::parse_match(&query_str) {
Ok(q) => q,
Err(e) => { set_error(&format!("parse error: {}", e)); return ptr::null_mut(); }
};
let graph = (*handle).graph_engine();
let results = graph.match_query(parsed);
match serde_json::to_string(&results) {
Ok(json) => alloc_c_string(&json),
Err(e) => { set_error(&format!("serialization error: {}", e)); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_shortest_path(
handle: *mut OdbHandle,
from_node_id: *const c_char,
to_node_id: *const c_char,
edge_type: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let from_str = match c_str_to_string(from_node_id) {
Some(s) => s,
None => { set_error("null from_node_id"); return ptr::null_mut(); }
};
let to_str = match c_str_to_string(to_node_id) {
Some(s) => s,
None => { set_error("null to_node_id"); return ptr::null_mut(); }
};
let edge_filter = c_str_to_string(edge_type)
.filter(|s| !s.is_empty());
let graph = (*handle).graph_engine();
match graph.shortest_path(&from_str, &to_str, edge_filter.as_deref()) {
Some(path) => match serde_json::to_string(&path) {
Ok(json) => alloc_c_string(&json),
Err(e) => { set_error(&format!("serialization error: {}", e)); ptr::null_mut() }
},
None => {
set_error(&format!("no path from '{}' to '{}'", from_str, to_str));
ptr::null_mut()
}
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_delete_node(
handle: *mut OdbHandle,
node_id: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let id = match c_str_to_string(node_id) {
Some(s) => s,
None => { set_error("null node_id"); return -1; }
};
let graph = (*handle).graph_engine();
if graph.delete_node(&id) { 0 } else {
set_error(&format!("node '{}' not found", id));
-1
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_list_nodes(
handle: *mut OdbHandle,
type_name: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let type_filter = c_str_to_string(type_name)
.filter(|s| !s.is_empty());
let graph = (*handle).graph_engine();
let nodes = graph.list_nodes(type_filter.as_deref());
match serde_json::to_string(&nodes) {
Ok(json) => alloc_c_string(&json),
Err(e) => { set_error(&format!("serialization error: {}", e)); ptr::null_mut() }
}
}
#[cfg(test)]
mod graph_ffi_extended_tests {
use super::*;
use std::ffi::CString;
fn open_temp_db() -> (*mut OdbHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let handle = unsafe { overdrive_open(path_cstr.as_ptr()) };
assert!(!handle.is_null(), "Failed to open temp db");
(handle, dir)
}
#[test]
fn test_create_edge_type_success() {
let (handle, _dir) = open_temp_db();
let type_name = CString::new("KNOWS").unwrap();
let rc = unsafe { overdrive_create_edge_type(handle, type_name.as_ptr()) };
assert_eq!(rc, 0);
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_edge_type_duplicate_returns_error() {
let (handle, _dir) = open_temp_db();
let type_name = CString::new("FOLLOWS").unwrap();
assert_eq!(unsafe { overdrive_create_edge_type(handle, type_name.as_ptr()) }, 0);
assert_eq!(unsafe { overdrive_create_edge_type(handle, type_name.as_ptr()) }, -1);
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_edge_type_null_handle() {
let type_name = CString::new("EDGE").unwrap();
let rc = unsafe { overdrive_create_edge_type(ptr::null_mut(), type_name.as_ptr()) };
assert_eq!(rc, -1);
}
#[test]
fn test_create_node_returns_id() {
let (handle, _dir) = open_temp_db();
let type_name = CString::new("Person").unwrap();
let props = CString::new(r#"{"name":"Alice","age":30}"#).unwrap();
let id_ptr = unsafe { overdrive_create_node(handle, type_name.as_ptr(), props.as_ptr()) };
assert!(!id_ptr.is_null(), "create_node should return a node ID");
let id = unsafe { std::ffi::CStr::from_ptr(id_ptr).to_string_lossy().to_string() };
assert!(id.starts_with("node_"), "ID should start with 'node_', got: {}", id);
unsafe { overdrive_free_string(id_ptr); overdrive_close(handle) };
}
#[test]
fn test_create_node_invalid_json() {
let (handle, _dir) = open_temp_db();
let type_name = CString::new("Person").unwrap();
let bad_json = CString::new("not-json").unwrap();
let id_ptr = unsafe { overdrive_create_node(handle, type_name.as_ptr(), bad_json.as_ptr()) };
assert!(id_ptr.is_null(), "invalid JSON should return null");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_node_null_handle() {
let type_name = CString::new("Person").unwrap();
let props = CString::new("{}").unwrap();
let id_ptr = unsafe { overdrive_create_node(ptr::null_mut(), type_name.as_ptr(), props.as_ptr()) };
assert!(id_ptr.is_null());
}
#[test]
fn test_create_edge_returns_id() {
let (handle, _dir) = open_temp_db();
let ptype = CString::new("Person").unwrap();
let props = CString::new("{}").unwrap();
let n1 = unsafe { overdrive_create_node(handle, ptype.as_ptr(), props.as_ptr()) };
let n2 = unsafe { overdrive_create_node(handle, ptype.as_ptr(), props.as_ptr()) };
assert!(!n1.is_null() && !n2.is_null());
let etype = CString::new("KNOWS").unwrap();
let eprops = CString::new(r#"{"since":2020}"#).unwrap();
let edge_id = unsafe { overdrive_create_edge(handle, etype.as_ptr(), n1, n2, eprops.as_ptr()) };
assert!(!edge_id.is_null(), "create_edge should return an edge ID");
let id_str = unsafe { std::ffi::CStr::from_ptr(edge_id).to_string_lossy().to_string() };
assert!(id_str.starts_with("edge_"), "edge ID should start with 'edge_', got: {}", id_str);
unsafe { overdrive_free_string(n1); overdrive_free_string(n2); overdrive_free_string(edge_id); overdrive_close(handle) };
}
#[test]
fn test_create_edge_missing_node_returns_null() {
let (handle, _dir) = open_temp_db();
let etype = CString::new("KNOWS").unwrap();
let from = CString::new("node_999").unwrap();
let to = CString::new("node_998").unwrap();
let eprops = CString::new("{}").unwrap();
let edge_id = unsafe { overdrive_create_edge(handle, etype.as_ptr(), from.as_ptr(), to.as_ptr(), eprops.as_ptr()) };
assert!(edge_id.is_null(), "edge to non-existent nodes should return null");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_graph_traverse_returns_json_array() {
let (handle, _dir) = open_temp_db();
let ptype = CString::new("Person").unwrap();
unsafe { overdrive_create_node_type(handle, ptype.as_ptr()) };
let knows = CString::new("KNOWS").unwrap();
unsafe { overdrive_create_edge_type(handle, knows.as_ptr()) };
let props_alice = CString::new(r#"{"name":"Alice"}"#).unwrap();
let props_bob = CString::new(r#"{"name":"Bob"}"#).unwrap();
let ptype2 = CString::new("Person").unwrap();
let n1 = unsafe { overdrive_create_node(handle, ptype2.as_ptr(), props_alice.as_ptr()) };
let n2 = unsafe { overdrive_create_node(handle, ptype2.as_ptr(), props_bob.as_ptr()) };
let etype = CString::new("KNOWS").unwrap();
let eprops = CString::new("{}").unwrap();
unsafe { overdrive_create_edge(handle, etype.as_ptr(), n1, n2, eprops.as_ptr()) };
let query = CString::new(r#"MATCH (p:Person)-[r:KNOWS]->(q:Person) RETURN p,r,q"#).unwrap();
let result_ptr = unsafe { overdrive_graph_traverse(handle, query.as_ptr()) };
assert!(!result_ptr.is_null(), "traverse should return JSON");
let json_str = unsafe { std::ffi::CStr::from_ptr(result_ptr).to_string_lossy().to_string() };
assert!(json_str.starts_with('['), "result should be a JSON array");
unsafe { overdrive_free_string(n1); overdrive_free_string(n2); overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_graph_traverse_null_handle() {
let query = CString::new("MATCH (p:Person) RETURN p").unwrap();
let result = unsafe { overdrive_graph_traverse(ptr::null_mut(), query.as_ptr()) };
assert!(result.is_null());
}
#[test]
fn test_shortest_path_found() {
let (handle, _dir) = open_temp_db();
let ptype = CString::new("Person").unwrap();
let props = CString::new("{}").unwrap();
let n1 = unsafe { overdrive_create_node(handle, ptype.as_ptr(), props.as_ptr()) };
let ptype2 = CString::new("Person").unwrap();
let n2 = unsafe { overdrive_create_node(handle, ptype2.as_ptr(), props.as_ptr()) };
let etype = CString::new("KNOWS").unwrap();
let eprops = CString::new("{}").unwrap();
unsafe { overdrive_create_edge(handle, etype.as_ptr(), n1, n2, eprops.as_ptr()) };
let edge_filter = CString::new("KNOWS").unwrap();
let path_ptr = unsafe { overdrive_shortest_path(handle, n1, n2, edge_filter.as_ptr()) };
assert!(!path_ptr.is_null(), "shortest_path should find a path");
let json_str = unsafe { std::ffi::CStr::from_ptr(path_ptr).to_string_lossy().to_string() };
assert!(json_str.contains("total_hops"), "result should contain total_hops");
unsafe { overdrive_free_string(n1); overdrive_free_string(n2); overdrive_free_string(path_ptr); overdrive_close(handle) };
}
#[test]
fn test_shortest_path_not_found() {
let (handle, _dir) = open_temp_db();
let ptype = CString::new("Person").unwrap();
let props = CString::new("{}").unwrap();
let n1 = unsafe { overdrive_create_node(handle, ptype.as_ptr(), props.as_ptr()) };
let ptype2 = CString::new("Person").unwrap();
let n2 = unsafe { overdrive_create_node(handle, ptype2.as_ptr(), props.as_ptr()) };
let edge_filter = CString::new("").unwrap();
let path_ptr = unsafe { overdrive_shortest_path(handle, n1, n2, edge_filter.as_ptr()) };
assert!(path_ptr.is_null(), "no path should return null");
unsafe { overdrive_free_string(n1); overdrive_free_string(n2); overdrive_close(handle) };
}
#[test]
fn test_delete_node_success() {
let (handle, _dir) = open_temp_db();
let ptype = CString::new("Person").unwrap();
let props = CString::new("{}").unwrap();
let node_id = unsafe { overdrive_create_node(handle, ptype.as_ptr(), props.as_ptr()) };
assert!(!node_id.is_null());
let rc = unsafe { overdrive_delete_node(handle, node_id) };
assert_eq!(rc, 0, "delete_node should return 0 on success");
unsafe { overdrive_free_string(node_id); overdrive_close(handle) };
}
#[test]
fn test_delete_node_not_found() {
let (handle, _dir) = open_temp_db();
let node_id = CString::new("node_9999").unwrap();
let rc = unsafe { overdrive_delete_node(handle, node_id.as_ptr()) };
assert_eq!(rc, -1, "deleting non-existent node should return -1");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_delete_node_removes_edges() {
let (handle, _dir) = open_temp_db();
let ptype = CString::new("Person").unwrap();
let props = CString::new("{}").unwrap();
let n1 = unsafe { overdrive_create_node(handle, ptype.as_ptr(), props.as_ptr()) };
let ptype2 = CString::new("Person").unwrap();
let n2 = unsafe { overdrive_create_node(handle, ptype2.as_ptr(), props.as_ptr()) };
let etype = CString::new("KNOWS").unwrap();
let eprops = CString::new("{}").unwrap();
unsafe { overdrive_create_edge(handle, etype.as_ptr(), n1, n2, eprops.as_ptr()) };
let rc = unsafe { overdrive_delete_node(handle, n1) };
assert_eq!(rc, 0);
unsafe { overdrive_free_string(n1); overdrive_free_string(n2); overdrive_close(handle) };
}
#[test]
fn test_list_nodes_empty() {
let (handle, _dir) = open_temp_db();
let type_filter = CString::new("").unwrap();
let result_ptr = unsafe { overdrive_list_nodes(handle, type_filter.as_ptr()) };
assert!(!result_ptr.is_null());
let json_str = unsafe { std::ffi::CStr::from_ptr(result_ptr).to_string_lossy().to_string() };
assert_eq!(json_str, "[]", "empty graph should return empty array");
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_list_nodes_with_type_filter() {
let (handle, _dir) = open_temp_db();
let ptype = CString::new("Person").unwrap();
let ctype = CString::new("Company").unwrap();
let props = CString::new("{}").unwrap();
unsafe { overdrive_create_node(handle, ptype.as_ptr(), props.as_ptr()) };
unsafe { overdrive_create_node(handle, ctype.as_ptr(), props.as_ptr()) };
let filter = CString::new("Person").unwrap();
let result_ptr = unsafe { overdrive_list_nodes(handle, filter.as_ptr()) };
assert!(!result_ptr.is_null());
let json_str = unsafe { std::ffi::CStr::from_ptr(result_ptr).to_string_lossy().to_string() };
let nodes: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(nodes.as_array().unwrap().len(), 1, "should only return Person nodes");
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_list_nodes_null_handle() {
let filter = CString::new("").unwrap();
let result = unsafe { overdrive_list_nodes(ptr::null_mut(), filter.as_ptr()) };
assert!(result.is_null());
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_create_topic(
handle: *mut OdbHandle,
topic_name: *const c_char,
partitions: u32,
retention_seconds: u64,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name = match c_str_to_string(topic_name) {
Some(n) => n,
None => { set_error("null topic_name"); return -1; }
};
let retention_str = if retention_seconds == 0 {
"0".to_string()
} else {
retention_seconds.to_string()
};
let engine = (*handle).stream_engine();
match engine.create(&name, partitions as usize, &retention_str) {
Ok(()) => 0,
Err(e) => { set_error(&e); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_publish(
handle: *mut OdbHandle,
topic_name: *const c_char,
message_json: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let name = match c_str_to_string(topic_name) {
Some(n) => n,
None => { set_error("null topic_name"); return ptr::null_mut(); }
};
let msg_str = match c_str_to_string(message_json) {
Some(m) => m,
None => { set_error("null message_json"); return ptr::null_mut(); }
};
let payload: serde_json::Value = match serde_json::from_str(&msg_str) {
Ok(v) => v,
Err(e) => { set_error(&format!("JSON parse error: {}", e)); return ptr::null_mut(); }
};
let engine = (*handle).stream_engine();
match engine.publish(&name, payload) {
Ok(offset) => {
let json = serde_json::json!({ "offset": offset });
alloc_c_string(&json.to_string())
}
Err(e) => { set_error(&e); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_subscribe(
handle: *mut OdbHandle,
topic_name: *const c_char,
consumer_group: *const c_char,
offset_mode: *const c_char,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let name = match c_str_to_string(topic_name) {
Some(n) => n,
None => { set_error("null topic_name"); return ptr::null_mut(); }
};
let group = c_str_to_string(consumer_group).filter(|s| !s.is_empty());
let mode = c_str_to_string(offset_mode).unwrap_or_else(|| "latest".to_string());
let start_offset = if mode.eq_ignore_ascii_case("earliest") {
Some(0u64)
} else {
None };
let h = &mut *handle;
h.sub_counter += 1;
let sub_id = h.sub_counter;
h.subscriptions.insert(sub_id, SubscriptionState {
topic: name,
consumer_group: group,
current_offset: start_offset.unwrap_or(u64::MAX), });
let json = serde_json::json!({ "subscription_id": sub_id });
alloc_c_string(&json.to_string())
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_poll(
handle: *mut OdbHandle,
subscription_id: u64,
max_messages: u32,
_timeout_ms: u32,
) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let h = &mut *handle;
let (topic, group, current_offset) = match h.subscriptions.get(&subscription_id) {
Some(s) => (s.topic.clone(), s.consumer_group.clone(), s.current_offset),
None => {
set_error(&format!("subscription {} not found", subscription_id));
return ptr::null_mut();
}
};
let limit = if max_messages == 0 { 100 } else { max_messages as usize };
let from_offset = if current_offset == u64::MAX {
None } else {
Some(current_offset)
};
let engine = h.stream_engine();
let results = match engine.subscribe(&topic, from_offset, group.as_deref(), limit) {
Ok(r) => r,
Err(e) => { set_error(&e); return ptr::null_mut(); }
};
if let Some(last) = results.last() {
let next_offset = last.offset + 1;
if let Some(sub) = h.subscriptions.get_mut(&subscription_id) {
sub.current_offset = next_offset;
}
} else if current_offset == u64::MAX {
if let Some(sub) = h.subscriptions.get_mut(&subscription_id) {
sub.current_offset = 0;
}
}
let json_arr: Vec<serde_json::Value> = results.into_iter().map(|r| {
serde_json::json!({
"offset": r.offset,
"timestamp_ms": r.timestamp_ms,
"payload": r.payload,
})
}).collect();
let json = serde_json::to_string(&json_arr).unwrap_or_else(|_| "[]".to_string());
alloc_c_string(&json)
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_commit_offset(
handle: *mut OdbHandle,
topic_name: *const c_char,
consumer_group: *const c_char,
offset: u64,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let topic = match c_str_to_string(topic_name) {
Some(n) => n,
None => { set_error("null topic_name"); return -1; }
};
let group = match c_str_to_string(consumer_group).filter(|s| !s.is_empty()) {
Some(g) => g,
None => { set_error("consumer_group must not be null or empty"); return -1; }
};
let engine = (*handle).stream_engine();
match engine.subscribe(&topic, Some(offset), Some(&group), 0) {
Ok(_) => 0,
Err(e) => { set_error(&e); -1 }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_unsubscribe(
handle: *mut OdbHandle,
subscription_id: u64,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let h = &mut *handle;
if h.subscriptions.remove(&subscription_id).is_some() {
0
} else {
set_error(&format!("subscription {} not found", subscription_id));
-1
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_drop_topic(
handle: *mut OdbHandle,
topic_name: *const c_char,
) -> c_int {
clear_error();
if handle.is_null() { set_error("null handle"); return -1; }
let name = match c_str_to_string(topic_name) {
Some(n) => n,
None => { set_error("null topic_name"); return -1; }
};
let engine = (*handle).stream_engine();
if engine.drop_stream(&name) {
0
} else {
set_error(&format!("topic '{}' not found", name));
-1
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_list_topics(handle: *mut OdbHandle) -> *mut c_char {
clear_error();
if handle.is_null() { set_error("null handle"); return ptr::null_mut(); }
let engine = (*handle).stream_engine();
let defs = engine.list();
let result: Vec<serde_json::Value> = defs.into_iter().map(|d| {
serde_json::json!({
"name": d.name,
"partitions": d.partitions,
"retention_seconds": d.retention_seconds,
"created_at": d.created_at,
})
}).collect();
let json = serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string());
alloc_c_string(&json)
}
#[cfg(test)]
mod streaming_ffi_tests {
use super::*;
use std::ffi::CString;
fn open_temp_db() -> (*mut OdbHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let handle = unsafe { overdrive_open(path_cstr.as_ptr()) };
assert!(!handle.is_null(), "Failed to open temp db");
(handle, dir)
}
#[test]
fn test_create_topic_success() {
let (handle, _dir) = open_temp_db();
let name = CString::new("orders").unwrap();
let rc = unsafe { overdrive_create_topic(handle, name.as_ptr(), 4, 86400) };
assert_eq!(rc, 0, "create_topic should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_topic_no_retention() {
let (handle, _dir) = open_temp_db();
let name = CString::new("forever_topic").unwrap();
let rc = unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) };
assert_eq!(rc, 0, "create_topic with retention=0 should succeed");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_topic_duplicate_returns_error() {
let (handle, _dir) = open_temp_db();
let name = CString::new("dup_topic").unwrap();
assert_eq!(unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) }, 0);
assert_eq!(unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) }, -1,
"duplicate create_topic should return -1");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_create_topic_null_handle() {
let name = CString::new("t").unwrap();
let rc = unsafe { overdrive_create_topic(ptr::null_mut(), name.as_ptr(), 1, 0) };
assert_eq!(rc, -1);
}
#[test]
fn test_publish_returns_offset() {
let (handle, _dir) = open_temp_db();
let name = CString::new("events").unwrap();
unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) };
let msg = CString::new(r#"{"type":"click","user":1}"#).unwrap();
let result_ptr = unsafe { overdrive_publish(handle, name.as_ptr(), msg.as_ptr()) };
assert!(!result_ptr.is_null(), "publish should return JSON with offset");
let s = unsafe { std::ffi::CStr::from_ptr(result_ptr).to_string_lossy().to_string() };
let v: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
assert!(v.get("offset").is_some(), "result should have 'offset' field");
assert_eq!(v["offset"], 0, "first message offset should be 0");
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_publish_increments_offset() {
let (handle, _dir) = open_temp_db();
let name = CString::new("inc_events").unwrap();
unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) };
for expected_offset in 0u64..3 {
let msg = CString::new(format!(r#"{{"n":{}}}"#, expected_offset)).unwrap();
let ptr = unsafe { overdrive_publish(handle, name.as_ptr(), msg.as_ptr()) };
assert!(!ptr.is_null());
let s = unsafe { std::ffi::CStr::from_ptr(ptr).to_string_lossy().to_string() };
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["offset"], expected_offset);
unsafe { overdrive_free_string(ptr) };
}
unsafe { overdrive_close(handle) };
}
#[test]
fn test_publish_nonexistent_topic_returns_null() {
let (handle, _dir) = open_temp_db();
let name = CString::new("ghost").unwrap();
let msg = CString::new(r#"{"x":1}"#).unwrap();
let ptr = unsafe { overdrive_publish(handle, name.as_ptr(), msg.as_ptr()) };
assert!(ptr.is_null(), "publish to non-existent topic should return NULL");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_subscribe_returns_subscription_id() {
let (handle, _dir) = open_temp_db();
let name = CString::new("sub_topic").unwrap();
unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) };
let group = CString::new("my_group").unwrap();
let mode = CString::new("earliest").unwrap();
let ptr = unsafe { overdrive_subscribe(handle, name.as_ptr(), group.as_ptr(), mode.as_ptr()) };
assert!(!ptr.is_null(), "subscribe should return JSON with subscription_id");
let s = unsafe { std::ffi::CStr::from_ptr(ptr).to_string_lossy().to_string() };
let v: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
assert!(v.get("subscription_id").is_some(), "result should have 'subscription_id'");
assert!(v["subscription_id"].as_u64().unwrap() > 0);
unsafe { overdrive_free_string(ptr); overdrive_close(handle) };
}
#[test]
fn test_subscribe_null_handle() {
let name = CString::new("t").unwrap();
let ptr = unsafe { overdrive_subscribe(ptr::null_mut(), name.as_ptr(), ptr::null(), ptr::null()) };
assert!(ptr.is_null());
}
#[test]
fn test_poll_returns_messages() {
let (handle, _dir) = open_temp_db();
let name = CString::new("poll_topic").unwrap();
unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) };
for i in 0..2 {
let msg = CString::new(format!(r#"{{"i":{}}}"#, i)).unwrap();
let p = unsafe { overdrive_publish(handle, name.as_ptr(), msg.as_ptr()) };
unsafe { overdrive_free_string(p) };
}
let mode = CString::new("earliest").unwrap();
let sub_ptr = unsafe { overdrive_subscribe(handle, name.as_ptr(), ptr::null(), mode.as_ptr()) };
let sub_str = unsafe { std::ffi::CStr::from_ptr(sub_ptr).to_string_lossy().to_string() };
let sub_v: serde_json::Value = serde_json::from_str(&sub_str).unwrap();
let sub_id = sub_v["subscription_id"].as_u64().unwrap();
unsafe { overdrive_free_string(sub_ptr) };
let result_ptr = unsafe { overdrive_poll(handle, sub_id, 10, 0) };
assert!(!result_ptr.is_null(), "poll should return JSON array");
let s = unsafe { std::ffi::CStr::from_ptr(result_ptr).to_string_lossy().to_string() };
let arr: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
let msgs = arr.as_array().expect("array");
assert_eq!(msgs.len(), 2, "should receive 2 messages");
assert!(msgs[0].get("offset").is_some());
assert!(msgs[0].get("payload").is_some());
unsafe { overdrive_free_string(result_ptr); overdrive_close(handle) };
}
#[test]
fn test_poll_invalid_subscription_returns_null() {
let (handle, _dir) = open_temp_db();
let ptr = unsafe { overdrive_poll(handle, 9999, 10, 0) };
assert!(ptr.is_null(), "poll with invalid sub_id should return NULL");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_commit_offset_success() {
let (handle, _dir) = open_temp_db();
let name = CString::new("commit_topic").unwrap();
unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) };
let msg = CString::new(r#"{"v":1}"#).unwrap();
let p = unsafe { overdrive_publish(handle, name.as_ptr(), msg.as_ptr()) };
unsafe { overdrive_free_string(p) };
let group = CString::new("grp1").unwrap();
let rc = unsafe { overdrive_commit_offset(handle, name.as_ptr(), group.as_ptr(), 1) };
assert_eq!(rc, 0, "commit_offset should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_commit_offset_null_group_returns_error() {
let (handle, _dir) = open_temp_db();
let name = CString::new("t").unwrap();
unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) };
let rc = unsafe { overdrive_commit_offset(handle, name.as_ptr(), ptr::null(), 0) };
assert_eq!(rc, -1, "null consumer_group should return -1");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_unsubscribe_success() {
let (handle, _dir) = open_temp_db();
let name = CString::new("unsub_topic").unwrap();
unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) };
let mode = CString::new("latest").unwrap();
let sub_ptr = unsafe { overdrive_subscribe(handle, name.as_ptr(), ptr::null(), mode.as_ptr()) };
let sub_str = unsafe { std::ffi::CStr::from_ptr(sub_ptr).to_string_lossy().to_string() };
let sub_v: serde_json::Value = serde_json::from_str(&sub_str).unwrap();
let sub_id = sub_v["subscription_id"].as_u64().unwrap();
unsafe { overdrive_free_string(sub_ptr) };
let rc = unsafe { overdrive_unsubscribe(handle, sub_id) };
assert_eq!(rc, 0, "unsubscribe should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_unsubscribe_not_found() {
let (handle, _dir) = open_temp_db();
let rc = unsafe { overdrive_unsubscribe(handle, 9999) };
assert_eq!(rc, -1, "unsubscribe with unknown id should return -1");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_drop_topic_success() {
let (handle, _dir) = open_temp_db();
let name = CString::new("drop_me").unwrap();
unsafe { overdrive_create_topic(handle, name.as_ptr(), 1, 0) };
let rc = unsafe { overdrive_drop_topic(handle, name.as_ptr()) };
assert_eq!(rc, 0, "drop_topic should return 0 on success");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_drop_topic_not_found() {
let (handle, _dir) = open_temp_db();
let name = CString::new("ghost_topic").unwrap();
let rc = unsafe { overdrive_drop_topic(handle, name.as_ptr()) };
assert_eq!(rc, -1, "drop_topic should return -1 when not found");
unsafe { overdrive_close(handle) };
}
#[test]
fn test_list_topics_empty() {
let (handle, _dir) = open_temp_db();
let ptr = unsafe { overdrive_list_topics(handle) };
assert!(!ptr.is_null());
let s = unsafe { std::ffi::CStr::from_ptr(ptr).to_string_lossy().to_string() };
let v: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
assert!(v.is_array());
assert_eq!(v.as_array().unwrap().len(), 0);
unsafe { overdrive_free_string(ptr); overdrive_close(handle) };
}
#[test]
fn test_list_topics_after_create() {
let (handle, _dir) = open_temp_db();
for topic in ["alpha", "beta", "gamma"] {
let n = CString::new(topic).unwrap();
unsafe { overdrive_create_topic(handle, n.as_ptr(), 2, 3600) };
}
let ptr = unsafe { overdrive_list_topics(handle) };
assert!(!ptr.is_null());
let s = unsafe { std::ffi::CStr::from_ptr(ptr).to_string_lossy().to_string() };
let v: serde_json::Value = serde_json::from_str(&s).expect("valid JSON");
let arr = v.as_array().expect("array");
assert_eq!(arr.len(), 3, "should list 3 topics");
let first = &arr[0];
assert!(first.get("name").is_some());
assert!(first.get("partitions").is_some());
assert!(first.get("retention_seconds").is_some());
assert!(first.get("created_at").is_some());
unsafe { overdrive_free_string(ptr); overdrive_close(handle) };
}
#[test]
fn test_list_topics_null_handle() {
let ptr = unsafe { overdrive_list_topics(ptr::null_mut()) };
assert!(ptr.is_null());
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_open_with_password(
path: *const c_char,
password: *const c_char,
) -> *mut OdbHandle {
clear_error();
let path_str = match c_str_to_string(path) {
Some(p) => p,
None => { set_error("overdrive_open_with_password: path is null"); return ptr::null_mut(); }
};
let pwd_opt = c_str_to_string(password);
let db_result = if std::path::Path::new(&path_str).exists() {
Database::open_with_password(&path_str, pwd_opt.as_deref())
} else {
match pwd_opt.as_deref() {
Some(pwd) => Database::create_with_password(&path_str, pwd),
None => Database::create(&path_str),
}
};
match db_result {
Ok(db) => Box::into_raw(Box::new(OdbHandle::new(db, PathBuf::from(&path_str)))),
Err(e) => { set_odb_error(&e.to_odb_error()); ptr::null_mut() }
}
}
#[no_mangle]
pub unsafe extern "C" fn overdrive_set_auto_create_tables(
handle: *mut OdbHandle,
enabled: c_int,
) -> c_int {
if handle.is_null() { set_error("overdrive_set_auto_create_tables: null handle"); return -1; }
let flag = enabled != 0;
(*handle).auto_create_tables = flag;
(*handle).db.set_auto_create_tables(flag);
0
}
#[no_mangle]
pub extern "C" fn overdrive_get_error_details() -> *const c_char {
LAST_ERROR_JSON.with(|e| {
match &*e.borrow() {
Some(cs) => cs.as_ptr(),
None => ptr::null(),
}
})
}
struct FileIntegrityStats {
size: u64,
modified: i64,
page_count: u64,
}
fn verify_file_integrity(path: &str) -> std::result::Result<FileIntegrityStats, String> {
use std::io::Read;
use crc32fast::Hasher as Crc32Hasher;
use sha2::{Sha256, Digest};
const PAGE_SIZE: usize = 4096;
const MAGIC_BYTES: [u8; 4] = [0x42, 0x44, 0x56, 0x4F];
let metadata = std::fs::metadata(path)
.map_err(|e| format!("Cannot access file: {}", e))?;
let file_size = metadata.len();
if file_size == 0 {
return Err("File is empty".to_string());
}
if file_size % PAGE_SIZE as u64 != 0 {
return Err(format!(
"File size {} is not a multiple of page size {}",
file_size, PAGE_SIZE
));
}
let page_count = file_size / PAGE_SIZE as u64;
let modified = metadata
.modified()
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
})
.unwrap_or(0);
let mut file = std::fs::File::open(path)
.map_err(|e| format!("Cannot open file: {}", e))?;
let mut data = Vec::with_capacity(file_size as usize);
file.read_to_end(&mut data)
.map_err(|e| format!("Cannot read file: {}", e))?;
if data.len() < 4 {
return Err("File too small to contain magic number".to_string());
}
let magic_ok = data[0..4] == MAGIC_BYTES
|| &data[0..4] == b"ODBD";
if !magic_ok {
return Err(format!(
"Invalid magic number: {:02X}{:02X}{:02X}{:02X} (expected OVDB/ODBD)",
data[0], data[1], data[2], data[3]
));
}
for page_num in 1..page_count as usize {
let offset = page_num * PAGE_SIZE;
let page = &data[offset..offset + PAGE_SIZE];
if page.iter().all(|&b| b == 0) {
continue;
}
let payload = &page[..PAGE_SIZE - 4];
let stored_crc = u32::from_le_bytes([
page[PAGE_SIZE - 4],
page[PAGE_SIZE - 3],
page[PAGE_SIZE - 2],
page[PAGE_SIZE - 1],
]);
if stored_crc != 0 {
let mut hasher = Crc32Hasher::new();
hasher.update(payload);
let computed = hasher.finalize();
if computed != stored_crc {
return Err(format!(
"Page {} CRC32 mismatch: stored={:#010X} computed={:#010X}",
page_num, stored_crc, computed
));
}
}
}
let mut prev_hash: Option<[u8; 32]> = None;
for page_num in 0..page_count as usize {
let offset = page_num * PAGE_SIZE;
let page = &data[offset..offset + PAGE_SIZE];
if page.iter().all(|&b| b == 0) {
prev_hash = None;
continue;
}
if let Some(expected) = prev_hash {
if page.len() >= 36 {
let stored: [u8; 32] = page[4..36].try_into().unwrap_or([0u8; 32]);
let all_zero = stored.iter().all(|&b| b == 0);
if !all_zero && stored != expected {
return Err(format!(
"Page {} hash chain broken: stored hash does not match hash of page {}",
page_num,
page_num - 1
));
}
}
}
let mut hasher = Sha256::new();
hasher.update(&page[0..4]);
if page.len() > 36 {
hasher.update(&page[36..]);
}
let hash: [u8; 32] = hasher.finalize().into();
prev_hash = Some(hash);
}
Ok(FileIntegrityStats {
size: file_size,
modified,
page_count,
})
}
#[no_mangle]
pub extern "C" fn overdrive_watchdog(path: *const c_char) -> *mut c_char {
let path_str = if path.is_null() {
return alloc_c_string(
r#"{"file_path":"","file_size_bytes":0,"last_modified":0,"integrity_status":"missing","corruption_details":"null path argument","page_count":0,"magic_valid":false}"#
);
} else {
unsafe { CStr::from_ptr(path) }.to_string_lossy().into_owned()
};
if !std::path::Path::new(&path_str).exists() {
let report = serde_json::json!({
"file_path": path_str,
"file_size_bytes": 0,
"last_modified": 0,
"integrity_status": "missing",
"corruption_details": serde_json::Value::Null,
"page_count": 0,
"magic_valid": false
});
return alloc_c_string(&report.to_string());
}
match verify_file_integrity(&path_str) {
Ok(stats) => {
let report = serde_json::json!({
"file_path": path_str,
"file_size_bytes": stats.size,
"last_modified": stats.modified,
"integrity_status": "valid",
"corruption_details": serde_json::Value::Null,
"page_count": stats.page_count,
"magic_valid": true
});
alloc_c_string(&report.to_string())
}
Err(e) => {
let (size, modified) = std::fs::metadata(&path_str)
.map(|m| {
let sz = m.len();
let ts = m.modified()
.map(|t| t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0))
.unwrap_or(0);
(sz, ts)
})
.unwrap_or((0, 0));
let report = serde_json::json!({
"file_path": path_str,
"file_size_bytes": size,
"last_modified": modified,
"integrity_status": "corrupted",
"corruption_details": e,
"page_count": 0,
"magic_valid": false
});
alloc_c_string(&report.to_string())
}
}
}
#[cfg(test)]
mod watchdog_ffi_tests {
use super::*;
use std::ffi::{CStr, CString};
use std::io::Write;
fn open_temp_db() -> (*mut OdbHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let handle = unsafe { overdrive_open(path_cstr.as_ptr()) };
assert!(!handle.is_null(), "Failed to open temp db");
(handle, dir)
}
fn parse_report(ptr: *mut c_char) -> serde_json::Value {
assert!(!ptr.is_null(), "watchdog returned NULL");
let s = unsafe { CStr::from_ptr(ptr).to_string_lossy().into_owned() };
unsafe { overdrive_free_string(ptr) };
serde_json::from_str(&s).expect("watchdog result should be valid JSON")
}
#[test]
fn test_watchdog_valid_db_returns_correct_shape() {
let (handle, dir) = open_temp_db();
unsafe { overdrive_close(handle) };
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let report = parse_report(overdrive_watchdog(path_cstr.as_ptr()));
assert!(report.get("file_path").is_some(), "missing file_path");
assert!(report.get("file_size_bytes").is_some(), "missing file_size_bytes");
assert!(report.get("last_modified").is_some(), "missing last_modified");
assert!(report.get("integrity_status").is_some(), "missing integrity_status");
assert!(report.get("corruption_details").is_some(), "missing corruption_details");
assert!(report.get("page_count").is_some(), "missing page_count");
assert!(report.get("magic_valid").is_some(), "missing magic_valid");
}
#[test]
fn test_watchdog_missing_file() {
let path_cstr = CString::new("/tmp/nonexistent_overdrive_test_xyz.odb").unwrap();
let report = parse_report(overdrive_watchdog(path_cstr.as_ptr()));
assert_eq!(report["integrity_status"], "missing");
assert_eq!(report["file_size_bytes"], 0);
assert_eq!(report["page_count"], 0);
assert_eq!(report["magic_valid"], false);
}
#[test]
fn test_watchdog_valid_db_has_size_and_timestamp() {
let (handle, dir) = open_temp_db();
unsafe { overdrive_close(handle) };
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let report = parse_report(overdrive_watchdog(path_cstr.as_ptr()));
assert!(report["file_size_bytes"].as_u64().unwrap_or(0) > 0, "file_size_bytes should be > 0");
assert!(report["last_modified"].as_i64().unwrap_or(0) > 0, "last_modified should be a Unix timestamp");
assert!(report["page_count"].as_u64().unwrap_or(0) > 0, "page_count should be > 0");
}
#[test]
fn test_watchdog_corrupted_magic() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("bad_magic.odb");
let mut f = std::fs::File::create(&db_path).unwrap();
let mut page = vec![0u8; 4096];
page[0..4].copy_from_slice(b"BAAD"); f.write_all(&page).unwrap();
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let report = parse_report(overdrive_watchdog(path_cstr.as_ptr()));
assert_eq!(report["integrity_status"], "corrupted", "wrong magic should be corrupted");
assert_eq!(report["magic_valid"], false);
assert!(report["corruption_details"].as_str().is_some(), "corruption_details should be a string");
}
#[test]
fn test_watchdog_valid_magic() {
let (handle, dir) = open_temp_db();
unsafe { overdrive_close(handle) };
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let report = parse_report(overdrive_watchdog(path_cstr.as_ptr()));
assert_eq!(report["magic_valid"], true, "valid DB should have magic_valid=true");
assert_eq!(report["integrity_status"], "valid");
}
#[test]
fn test_watchdog_null_path() {
let report = parse_report(overdrive_watchdog(std::ptr::null()));
assert_eq!(report["integrity_status"], "missing");
}
#[test]
fn test_watchdog_performance() {
let (handle, dir) = open_temp_db();
unsafe { overdrive_close(handle) };
let db_path = dir.path().join("test.odb");
let path_cstr = CString::new(db_path.to_str().unwrap()).unwrap();
let start = std::time::Instant::now();
let ptr = overdrive_watchdog(path_cstr.as_ptr());
let elapsed = start.elapsed();
unsafe { overdrive_free_string(ptr) };
assert!(
elapsed.as_millis() < 100,
"watchdog should complete in < 100ms, took {}ms",
elapsed.as_millis()
);
}
}