kglite_c/graph.rs
1//! `KgliteGraph` opaque handle — load_file, save_graph, free.
2//!
3//! Wraps `Arc<kglite::api::DirGraph>` so the C side can hold a
4//! cheap reference-counted snapshot. Session creation takes
5//! ownership of the handle (the underlying Arc moves into the
6//! Session); callers do NOT free the graph after handing it to
7//! [`kglite_session_new`](crate::kglite_session_new).
8
9use crate::status::KgliteStatusCode;
10use crate::strings::alloc_c_string;
11use kglite::api::{load_file, save_graph, DirGraph};
12use std::ffi::{c_char, CStr};
13use std::sync::Arc;
14
15/// Opaque handle for a knowledge graph. The C-side caller only
16/// ever sees `KgliteGraph*`; allocation, deallocation, and field
17/// access happen inside `kglite-c`.
18///
19/// cbindgen sees the `#[repr(C)]` empty struct and renders only a
20/// forward declaration in `kglite.h`. The actual state lives in
21/// the private [`GraphState`] sidecar: every `*mut KgliteGraph`
22/// the C side holds is really a `*mut GraphState` cast through
23/// the opaque facade.
24#[repr(C)]
25pub struct KgliteGraph {
26 _opaque: [u8; 0],
27 // Prevent C-side stack allocation: the !Send/!Sync marker isn't
28 // visible across the C ABI but stops downstream Rust callers
29 // from accidentally constructing one by value. (The real state
30 // is in GraphState; this struct is never instantiated.)
31 _marker: core::marker::PhantomData<(*mut u8, core::marker::PhantomPinned)>,
32}
33
34/// Private state backing a [`KgliteGraph`] handle. Never named at
35/// the C ABI surface — the C side only knows `KgliteGraph*`. We
36/// `Box::into_raw` a `GraphState`, cast the pointer to
37/// `*mut KgliteGraph`, and reverse the cast on free / use.
38pub(crate) struct GraphState {
39 pub(crate) inner: Arc<DirGraph>,
40}
41
42impl GraphState {
43 /// Allocate a new opaque handle wrapping `arc`.
44 pub(crate) fn into_handle(arc: Arc<DirGraph>) -> *mut KgliteGraph {
45 let boxed = Box::new(GraphState { inner: arc });
46 Box::into_raw(boxed).cast::<KgliteGraph>()
47 }
48
49 /// Mutably borrow the state behind a non-null handle. Caller
50 /// must uphold the C-ABI contract — the handle is valid, not
51 /// yet freed, and exclusively borrowed for the call. (A
52 /// `&mut` variant is the only borrower we need today: the
53 /// only read-only operation against a `GraphState` is
54 /// snapshot-taking, which we do by handing the graph to
55 /// `Session::from_arc` and moving ownership out via the Box.)
56 pub(crate) unsafe fn from_handle_mut<'a>(handle: *mut KgliteGraph) -> &'a mut GraphState {
57 unsafe { &mut *handle.cast::<GraphState>() }
58 }
59
60 /// Free a handle. Idempotent on null.
61 pub(crate) unsafe fn free_handle(handle: *mut KgliteGraph) {
62 if handle.is_null() {
63 return;
64 }
65 let _ = unsafe { Box::from_raw(handle.cast::<GraphState>()) };
66 }
67}
68
69/// Load a knowledge graph from disk. Accepts `.kgl` files
70/// (single-file mmap format) and directories (disk-backed CSR
71/// layout) — the loader picks the right path based on what's at
72/// `path`.
73///
74/// # Arguments
75///
76/// - `path` (in, borrowed): UTF-8 file path, null-terminated.
77/// - `out_graph` (out, owned): set to the loaded graph handle on
78/// success; caller must free via [`kglite_graph_free`]. Set to
79/// null on failure.
80/// - `out_error_msg` (out, owned): set to an owned error message
81/// on failure; caller must free via
82/// [`kglite_free_string`](crate::kglite_free_string). Set to
83/// null on success.
84///
85/// # Errors
86///
87/// - `KGLITE_ERR_NULL_POINTER` — `path` or `out_graph` is null
88/// - `KGLITE_ERR_INVALID_UTF8` — `path` isn't valid UTF-8
89/// - `KGLITE_ERR_FILE_NOT_FOUND` — `path` doesn't exist
90/// - `KGLITE_ERR_FILE_FORMAT` — file isn't a valid `.kgl` /
91/// disk-graph directory
92/// - `KGLITE_ERR_FILE_IO` — I/O failure during read
93///
94/// # Safety
95///
96/// `path` must point to a null-terminated UTF-8 string.
97/// `out_graph` must be a valid writable pointer to a
98/// `*mut KgliteGraph` slot. `out_error_msg` may be null (the
99/// caller doesn't care about the message); otherwise it must
100/// point to a valid writable `*const c_char` slot.
101#[no_mangle]
102pub unsafe extern "C" fn kglite_load_file(
103 path: *const c_char,
104 out_graph: *mut *mut KgliteGraph,
105 out_error_msg: *mut *const c_char,
106) -> KgliteStatusCode {
107 if path.is_null() || out_graph.is_null() {
108 return KgliteStatusCode::NullPointer;
109 }
110 let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
111 Ok(s) => s,
112 Err(_) => return KgliteStatusCode::InvalidUtf8,
113 };
114 match load_file(path_str) {
115 Ok(arc) => {
116 unsafe {
117 *out_graph = GraphState::into_handle(arc);
118 }
119 if !out_error_msg.is_null() {
120 unsafe {
121 *out_error_msg = std::ptr::null();
122 }
123 }
124 KgliteStatusCode::Ok
125 }
126 Err(io_err) => {
127 unsafe {
128 *out_graph = std::ptr::null_mut();
129 }
130 let (code, message) = classify_io_error(&io_err);
131 if !out_error_msg.is_null() {
132 unsafe {
133 *out_error_msg = alloc_c_string(&message);
134 }
135 }
136 code
137 }
138 }
139}
140
141/// Map a `std::io::Error` from `load_file` to a `KgliteStatusCode`
142/// plus a human-readable message. `load_file` returns `io::Error`
143/// regardless of the underlying cause; we sniff the `kind` to
144/// pick the right C-side code.
145fn classify_io_error(err: &std::io::Error) -> (KgliteStatusCode, String) {
146 let code = match err.kind() {
147 std::io::ErrorKind::NotFound => KgliteStatusCode::FileNotFound,
148 std::io::ErrorKind::InvalidData => KgliteStatusCode::FileFormat,
149 _ => KgliteStatusCode::FileIo,
150 };
151 (code, err.to_string())
152}
153
154/// Save a knowledge graph to disk. The on-disk format depends on
155/// the underlying storage mode — in-memory and mapped graphs
156/// produce a `.kgl` single-file; disk-backed graphs produce / fill
157/// a directory.
158///
159/// # Arguments
160///
161/// - `graph` (in, borrowed): the graph to save.
162/// - `path` (in, borrowed): UTF-8 destination path,
163/// null-terminated.
164/// - `out_error_msg` (out, owned): set to an owned error message
165/// on failure; caller must free via
166/// [`kglite_free_string`](crate::kglite_free_string). Set to
167/// null on success.
168///
169/// # Errors
170///
171/// - `KGLITE_ERR_NULL_POINTER` — `graph` or `path` is null
172/// - `KGLITE_ERR_INVALID_UTF8` — `path` isn't valid UTF-8
173/// - `KGLITE_ERR_FILE_IO` — write failed
174///
175/// # Safety
176///
177/// `graph` must be a valid `*mut KgliteGraph` previously returned
178/// by a `kglite_*` function and not yet freed. `path` must be a
179/// null-terminated UTF-8 string.
180#[no_mangle]
181pub unsafe extern "C" fn kglite_save_graph(
182 graph: *mut KgliteGraph,
183 path: *const c_char,
184 out_error_msg: *mut *const c_char,
185) -> KgliteStatusCode {
186 if graph.is_null() || path.is_null() {
187 return KgliteStatusCode::NullPointer;
188 }
189 let path_str = match unsafe { CStr::from_ptr(path) }.to_str() {
190 Ok(s) => s,
191 Err(_) => return KgliteStatusCode::InvalidUtf8,
192 };
193 // Safety: caller's responsibility per the function's safety
194 // doc — graph must be a valid handle. We take a transient
195 // &mut to its inner Arc (save_graph needs &mut Arc).
196 let state = unsafe { GraphState::from_handle_mut(graph) };
197 match save_graph(&mut state.inner, path_str) {
198 Ok(()) => {
199 if !out_error_msg.is_null() {
200 unsafe {
201 *out_error_msg = std::ptr::null();
202 }
203 }
204 KgliteStatusCode::Ok
205 }
206 Err(msg) => {
207 if !out_error_msg.is_null() {
208 unsafe {
209 *out_error_msg = alloc_c_string(&msg);
210 }
211 }
212 KgliteStatusCode::FileIo
213 }
214 }
215}
216
217/// Free a graph handle. Idempotent on null (no-op).
218///
219/// # Safety
220///
221/// `graph` must be either null or a pointer previously returned by
222/// [`kglite_load_file`] (or any future `kglite_*` function that
223/// returns a `*mut KgliteGraph`) and not yet freed. Calling twice
224/// on the same pointer is UB.
225///
226/// **Do NOT free** a graph handle that has been handed to
227/// [`kglite_session_new`](crate::kglite_session_new) — the session
228/// takes ownership and frees on its own teardown.
229#[no_mangle]
230pub unsafe extern "C" fn kglite_graph_free(graph: *mut KgliteGraph) {
231 // Safety: caller's responsibility per the function's safety doc.
232 unsafe { GraphState::free_handle(graph) };
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use std::ffi::CString;
239
240 #[test]
241 fn load_nonexistent_file_returns_file_not_found() {
242 let path = CString::new("/tmp/__kglite_c_does_not_exist__.kgl").unwrap();
243 let mut graph: *mut KgliteGraph = std::ptr::null_mut();
244 let mut err: *const c_char = std::ptr::null();
245 let rc =
246 unsafe { kglite_load_file(path.as_ptr(), &mut graph as *mut _, &mut err as *mut _) };
247 assert_eq!(rc, KgliteStatusCode::FileNotFound);
248 assert!(graph.is_null());
249 assert!(!err.is_null());
250 unsafe { crate::kglite_free_string(err) };
251 }
252
253 #[test]
254 fn load_null_path_returns_null_pointer() {
255 let mut graph: *mut KgliteGraph = std::ptr::null_mut();
256 let mut err: *const c_char = std::ptr::null();
257 let rc =
258 unsafe { kglite_load_file(std::ptr::null(), &mut graph as *mut _, &mut err as *mut _) };
259 assert_eq!(rc, KgliteStatusCode::NullPointer);
260 }
261
262 #[test]
263 fn graph_free_is_null_safe() {
264 unsafe { kglite_graph_free(std::ptr::null_mut()) };
265 }
266}