Skip to main content

kronroe_ios/
ffi.rs

1use chrono::Utc;
2use kronroe::TemporalGraph;
3use std::cell::RefCell;
4use std::ffi::{c_char, CStr, CString};
5use std::ptr;
6
7pub struct KronroeGraphHandle {
8    graph: TemporalGraph,
9}
10
11thread_local! {
12    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
13}
14
15fn set_last_error(msg: String) {
16    // Strip null bytes so CString::new never fails — a null byte in an error
17    // message would otherwise silently drop the entire error (CString::new
18    // returns Err, .ok() yields None, and the caller sees "no error").
19    let sanitized = msg.replace('\0', "\\0");
20    LAST_ERROR.with(|cell| {
21        *cell.borrow_mut() = CString::new(sanitized).ok();
22    });
23}
24
25fn clear_last_error() {
26    LAST_ERROR.with(|cell| {
27        *cell.borrow_mut() = None;
28    });
29}
30
31fn cstr_to_string(ptr: *const c_char, field: &str) -> Result<String, String> {
32    if ptr.is_null() {
33        return Err(format!("{field} is null"));
34    }
35    let s = unsafe { CStr::from_ptr(ptr) }
36        .to_str()
37        .map_err(|_| format!("{field} is not valid UTF-8"))?;
38    Ok(s.to_string())
39}
40
41#[no_mangle]
42/// Create an in-memory Kronroe graph handle (no file I/O).
43///
44/// Ideal for simulator testing and ephemeral workloads.
45/// Returns NULL on error (inspect `kronroe_last_error_message`).
46pub extern "C" fn kronroe_graph_open_in_memory() -> *mut KronroeGraphHandle {
47    clear_last_error();
48    match TemporalGraph::open_in_memory() {
49        Ok(graph) => Box::into_raw(Box::new(KronroeGraphHandle { graph })),
50        Err(err) => {
51            set_last_error(err.to_string());
52            ptr::null_mut()
53        }
54    }
55}
56
57#[no_mangle]
58/// Open/create a Kronroe graph handle.
59///
60/// # Safety
61/// `path` must be a valid, NUL-terminated UTF-8 C string pointer.
62pub unsafe extern "C" fn kronroe_graph_open(path: *const c_char) -> *mut KronroeGraphHandle {
63    clear_last_error();
64    let path = match cstr_to_string(path, "path") {
65        Ok(v) => v,
66        Err(e) => {
67            set_last_error(e);
68            return ptr::null_mut();
69        }
70    };
71
72    match TemporalGraph::open(&path) {
73        Ok(graph) => Box::into_raw(Box::new(KronroeGraphHandle { graph })),
74        Err(err) => {
75            set_last_error(err.to_string());
76            ptr::null_mut()
77        }
78    }
79}
80
81#[no_mangle]
82/// Close and free a graph handle.
83///
84/// # Safety
85/// `handle` must be either NULL or a pointer returned from `kronroe_graph_open`
86/// that has not already been closed.
87pub unsafe extern "C" fn kronroe_graph_close(handle: *mut KronroeGraphHandle) {
88    if handle.is_null() {
89        return;
90    }
91    unsafe {
92        drop(Box::from_raw(handle));
93    }
94}
95
96#[no_mangle]
97/// Assert a text fact on the graph.
98///
99/// # Safety
100/// `handle` must be a valid graph handle pointer.
101/// `subject`, `predicate`, and `object` must be valid NUL-terminated UTF-8 C strings.
102pub unsafe extern "C" fn kronroe_graph_assert_text(
103    handle: *mut KronroeGraphHandle,
104    subject: *const c_char,
105    predicate: *const c_char,
106    object: *const c_char,
107) -> bool {
108    clear_last_error();
109    if handle.is_null() {
110        set_last_error("graph handle is null".to_string());
111        return false;
112    }
113
114    let subject = match cstr_to_string(subject, "subject") {
115        Ok(v) => v,
116        Err(e) => {
117            set_last_error(e);
118            return false;
119        }
120    };
121    let predicate = match cstr_to_string(predicate, "predicate") {
122        Ok(v) => v,
123        Err(e) => {
124            set_last_error(e);
125            return false;
126        }
127    };
128    let object = match cstr_to_string(object, "object") {
129        Ok(v) => v,
130        Err(e) => {
131            set_last_error(e);
132            return false;
133        }
134    };
135
136    let graph = unsafe { &*handle };
137    match graph
138        .graph
139        .assert_fact(&subject, &predicate, object, Utc::now())
140    {
141        Ok(_) => true,
142        Err(err) => {
143            set_last_error(err.to_string());
144            false
145        }
146    }
147}
148
149#[no_mangle]
150/// Return all facts about an entity as a newly allocated JSON C string.
151///
152/// # Safety
153/// `handle` must be a valid graph handle pointer.
154/// `entity` must be a valid NUL-terminated UTF-8 C string.
155/// The returned pointer must be released with `kronroe_string_free`.
156pub unsafe extern "C" fn kronroe_graph_facts_about_json(
157    handle: *mut KronroeGraphHandle,
158    entity: *const c_char,
159) -> *mut c_char {
160    clear_last_error();
161    if handle.is_null() {
162        set_last_error("graph handle is null".to_string());
163        return ptr::null_mut();
164    }
165    let entity = match cstr_to_string(entity, "entity") {
166        Ok(v) => v,
167        Err(e) => {
168            set_last_error(e);
169            return ptr::null_mut();
170        }
171    };
172    let graph = unsafe { &*handle };
173
174    match graph.graph.all_facts_about(&entity) {
175        Ok(facts) => match serde_json::to_string(&facts) {
176            Ok(s) => match CString::new(s) {
177                Ok(cs) => cs.into_raw(),
178                Err(_) => {
179                    set_last_error("failed to encode facts JSON".to_string());
180                    ptr::null_mut()
181                }
182            },
183            Err(err) => {
184                set_last_error(err.to_string());
185                ptr::null_mut()
186            }
187        },
188        Err(err) => {
189            set_last_error(err.to_string());
190            ptr::null_mut()
191        }
192    }
193}
194
195#[no_mangle]
196/// Return the last error message as a newly allocated C string.
197///
198/// Returns NULL if no error is set.
199///
200/// # Safety
201/// The returned pointer must be freed with `kronroe_string_free` when no
202/// longer needed. Unlike the previous implementation, this returns an
203/// independent allocation — the pointer remains valid even after subsequent
204/// Kronroe calls that clear or overwrite the internal error state.
205pub extern "C" fn kronroe_last_error_message() -> *mut c_char {
206    LAST_ERROR.with(|cell| match cell.borrow().as_ref() {
207        Some(msg) => {
208            // Clone so the caller owns the allocation independently
209            // of the thread-local lifetime.
210            msg.clone().into_raw()
211        }
212        None => ptr::null_mut(),
213    })
214}
215
216#[no_mangle]
217/// Free a C string returned by Kronroe FFI.
218///
219/// # Safety
220/// `ptr` must be either NULL or a pointer previously returned by
221/// `kronroe_graph_facts_about_json` that has not yet been freed.
222pub unsafe extern "C" fn kronroe_string_free(ptr: *mut c_char) {
223    if ptr.is_null() {
224        return;
225    }
226    unsafe {
227        drop(CString::from_raw(ptr));
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use std::time::{SystemTime, UNIX_EPOCH};
235
236    fn c(s: &str) -> CString {
237        CString::new(s).expect("test CString")
238    }
239
240    fn unique_db_path() -> String {
241        let nanos = SystemTime::now()
242            .duration_since(UNIX_EPOCH)
243            .expect("clock")
244            .as_nanos();
245        let mut p = std::env::temp_dir();
246        p.push(format!("kronroe-ios-ffi-{nanos}.kronroe"));
247        p.to_string_lossy().to_string()
248    }
249
250    #[test]
251    fn ffi_open_assert_query_roundtrip_file_backed() {
252        let path = c(&unique_db_path());
253        let subject = c("Freya");
254        let predicate = c("attends");
255        let object = c("Sunrise Primary");
256        let entity = c("Freya");
257
258        let handle = unsafe { kronroe_graph_open(path.as_ptr()) };
259        assert!(!handle.is_null(), "open should return a valid handle");
260
261        let ok = unsafe {
262            kronroe_graph_assert_text(
263                handle,
264                subject.as_ptr(),
265                predicate.as_ptr(),
266                object.as_ptr(),
267            )
268        };
269        assert!(ok, "assert should succeed");
270
271        let json_ptr = unsafe { kronroe_graph_facts_about_json(handle, entity.as_ptr()) };
272        assert!(!json_ptr.is_null(), "facts query should return JSON");
273
274        let json = unsafe { CStr::from_ptr(json_ptr) }
275            .to_str()
276            .expect("valid utf8");
277        let facts: serde_json::Value = serde_json::from_str(json).expect("valid json");
278        let arr = facts.as_array().expect("json array");
279        assert_eq!(arr.len(), 1);
280        assert_eq!(arr[0]["subject"], "Freya");
281        assert_eq!(arr[0]["predicate"], "attends");
282        assert_eq!(arr[0]["object"]["value"], "Sunrise Primary");
283
284        unsafe {
285            kronroe_string_free(json_ptr);
286            kronroe_graph_close(handle);
287        }
288    }
289
290    #[test]
291    fn ffi_open_in_memory_assert_query_roundtrip() {
292        let subject = c("alice");
293        let predicate = c("works_at");
294        let object = c("Acme");
295        let entity = c("alice");
296
297        let handle = kronroe_graph_open_in_memory();
298        assert!(
299            !handle.is_null(),
300            "open_in_memory should return a valid handle"
301        );
302
303        let ok = unsafe {
304            kronroe_graph_assert_text(
305                handle,
306                subject.as_ptr(),
307                predicate.as_ptr(),
308                object.as_ptr(),
309            )
310        };
311        assert!(ok, "assert should succeed");
312
313        let json_ptr = unsafe { kronroe_graph_facts_about_json(handle, entity.as_ptr()) };
314        assert!(!json_ptr.is_null(), "facts query should return JSON");
315
316        let json = unsafe { CStr::from_ptr(json_ptr) }
317            .to_str()
318            .expect("valid utf8");
319        let facts: serde_json::Value = serde_json::from_str(json).expect("valid json");
320        let arr = facts.as_array().expect("json array");
321        assert_eq!(arr.len(), 1);
322
323        unsafe {
324            kronroe_string_free(json_ptr);
325            kronroe_graph_close(handle);
326        }
327    }
328
329    #[test]
330    fn ffi_failure_path_null_handle_assert_sets_error() {
331        let subject = c("alice");
332        let predicate = c("works_at");
333        let object = c("Acme");
334
335        let ok = unsafe {
336            kronroe_graph_assert_text(
337                std::ptr::null_mut(),
338                subject.as_ptr(),
339                predicate.as_ptr(),
340                object.as_ptr(),
341            )
342        };
343        assert!(!ok, "assert should fail with null handle");
344
345        let msg_ptr = kronroe_last_error_message();
346        assert!(!msg_ptr.is_null(), "error message should be set");
347        let msg = unsafe { CStr::from_ptr(msg_ptr) }
348            .to_str()
349            .expect("valid utf8");
350        assert!(
351            msg.contains("graph handle is null"),
352            "expected null-handle error, got: {msg}"
353        );
354        unsafe { kronroe_string_free(msg_ptr) };
355    }
356
357    #[test]
358    fn ffi_last_error_sanitizes_null_bytes() {
359        clear_last_error();
360        set_last_error("broken\0message".to_string());
361
362        let msg_ptr = kronroe_last_error_message();
363        assert!(!msg_ptr.is_null(), "error message should be present");
364        let msg = unsafe { CStr::from_ptr(msg_ptr) }
365            .to_str()
366            .expect("valid utf8");
367        assert_eq!(msg, "broken\\0message");
368
369        unsafe { kronroe_string_free(msg_ptr) };
370    }
371}