mirror of
https://github.com/duhanbalci/dreport.git
synced 2026-07-01 18:39:16 +00:00
feat: dreport-service + dreport-ffi + nuget packages
Extract orchestration (font registry + render pipeline) from the Axum backend into a standalone dreport-service crate. Backend becomes a thin HTTP adapter on top. Add dreport-ffi (cdylib) exposing the service through a stable C ABI with opaque handles, byte buffers, and thread-local error reporting. Build Dreport.Service + Dreport.AspNetCore NuGet packages under bindings/dotnet/, packing the host RID native binary via a generated nuspec. justfile recipes (nuget-publish, nuget-publish-all) build, pack, and push to the Gitea NuGet registry in one shot. Test coverage: 47 Rust + 38 C# (xUnit + WebApplicationFactory). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
dreport-ffi/Cargo.toml
Normal file
17
dreport-ffi/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "dreport-ffi"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
description = "C ABI for dreport-service (consumed by NuGet, native hosts, etc.)"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
dreport-service = { path = "../dreport-service" }
|
||||
serde_json = "1"
|
||||
|
||||
[build-dependencies]
|
||||
cbindgen = { version = "0.28", default-features = false }
|
||||
46
dreport-ffi/build.rs
Normal file
46
dreport-ffi/build.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Generates `include/dreport.h` from the public C ABI on every build.
|
||||
//! Header is checked into the repo so consumers (NuGet wrapper, manual C use)
|
||||
//! don't need a Rust toolchain.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
// Skip generation if explicitly disabled (e.g. when cross-compiling without
|
||||
// host tools, or in cargo publish dry runs).
|
||||
if env::var("DREPORT_FFI_SKIP_CBINDGEN").is_ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let out_path = PathBuf::from(&crate_dir).join("include").join("dreport.h");
|
||||
if let Some(parent) = out_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let config_path = PathBuf::from(&crate_dir).join("cbindgen.toml");
|
||||
let config = if config_path.exists() {
|
||||
cbindgen::Config::from_file(&config_path).expect("invalid cbindgen.toml")
|
||||
} else {
|
||||
cbindgen::Config::default()
|
||||
};
|
||||
|
||||
match cbindgen::Builder::new()
|
||||
.with_crate(crate_dir)
|
||||
.with_config(config)
|
||||
.generate()
|
||||
{
|
||||
Ok(bindings) => {
|
||||
bindings.write_to_file(&out_path);
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't fail the build on header generation problems — the cdylib
|
||||
// is still usable; the header is a developer convenience.
|
||||
println!("cargo:warning=cbindgen header generation failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
dreport-ffi/cbindgen.toml
Normal file
20
dreport-ffi/cbindgen.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
language = "C"
|
||||
header = "/* Auto-generated by cbindgen — do not edit. */"
|
||||
include_guard = "DREPORT_H"
|
||||
pragma_once = true
|
||||
no_includes = false
|
||||
sys_includes = ["stdint.h", "stddef.h", "stdbool.h"]
|
||||
cpp_compat = true
|
||||
documentation = true
|
||||
documentation_style = "doxy"
|
||||
style = "type"
|
||||
|
||||
[export]
|
||||
prefix = ""
|
||||
|
||||
[export.rename]
|
||||
"DreportHandle" = "DreportHandle"
|
||||
"DreportBuffer" = "DreportBuffer"
|
||||
|
||||
[parse]
|
||||
parse_deps = false
|
||||
386
dreport-ffi/src/lib.rs
Normal file
386
dreport-ffi/src/lib.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
//! dreport-ffi
|
||||
//!
|
||||
//! C ABI exposing `dreport_service::DreportService` to non-Rust hosts
|
||||
//! (.NET / NuGet, Node N-API, Python ctypes, etc.).
|
||||
//!
|
||||
//! ## Conventions
|
||||
//!
|
||||
//! - All exported symbols are prefixed `dreport_`.
|
||||
//! - Functions return `i32`: `0 == success`, negative values are error codes.
|
||||
//! See [`error_code`] constants. The detailed message for the most recent
|
||||
//! error on the calling thread is retrievable via [`dreport_last_error`].
|
||||
//! - Outbound dynamic data is returned as a [`DreportBuffer`] (ptr + len + cap).
|
||||
//! The caller MUST hand the buffer back to [`dreport_buffer_free`] to release it.
|
||||
//! - Inbound strings are passed as `(ptr, len)` byte pairs and interpreted as UTF-8.
|
||||
//! - Handles ([`DreportHandle`]) are opaque pointers. Pass them to
|
||||
//! [`dreport_free`] exactly once when done. Never use after free.
|
||||
//! - All exported functions are safe to call from any thread; the underlying
|
||||
//! service is `Sync`.
|
||||
|
||||
#![allow(clippy::missing_safety_doc)] // safety contract documented at module level
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::c_char;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dreport_service::{DreportService, ServiceError};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return codes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub mod error_code {
|
||||
pub const OK: i32 = 0;
|
||||
pub const NULL_HANDLE: i32 = -100;
|
||||
pub const NULL_POINTER: i32 = -101;
|
||||
pub const INVALID_UTF8: i32 = -102;
|
||||
pub const PANIC: i32 = -103;
|
||||
|
||||
// Service-level errors are exposed as the negation of `ServiceError::code()`.
|
||||
// E.g. ServiceError::FontParseFailed (3) → -3 here.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Opaque handle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Opaque handle backing a `DreportService` shared across the FFI boundary.
|
||||
/// Internally an `Arc<DreportService>`, so the same engine can be cloned and
|
||||
/// driven from multiple threads.
|
||||
pub struct DreportHandle {
|
||||
inner: Arc<DreportService>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Owned byte buffer returned across the FFI boundary. Released with
|
||||
/// [`dreport_buffer_free`].
|
||||
#[repr(C)]
|
||||
pub struct DreportBuffer {
|
||||
pub data: *mut u8,
|
||||
pub len: usize,
|
||||
pub cap: usize,
|
||||
}
|
||||
|
||||
impl DreportBuffer {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_vec(mut v: Vec<u8>) -> Self {
|
||||
v.shrink_to_fit();
|
||||
let buf = Self {
|
||||
data: v.as_mut_ptr(),
|
||||
len: v.len(),
|
||||
cap: v.capacity(),
|
||||
};
|
||||
std::mem::forget(v);
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread-local error state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
thread_local! {
|
||||
static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn set_last_error(msg: impl Into<String>) {
|
||||
LAST_ERROR.with(|cell| *cell.borrow_mut() = Some(msg.into()));
|
||||
}
|
||||
|
||||
fn clear_last_error() {
|
||||
LAST_ERROR.with(|cell| *cell.borrow_mut() = None);
|
||||
}
|
||||
|
||||
fn map_service_error(err: ServiceError) -> i32 {
|
||||
let code = -err.code();
|
||||
set_last_error(err.to_string());
|
||||
code
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
unsafe fn handle_ref<'a>(handle: *const DreportHandle) -> Option<&'a DreportHandle> {
|
||||
if handle.is_null() {
|
||||
set_last_error("null handle");
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { &*handle })
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> Option<&'a [u8]> {
|
||||
if ptr.is_null() {
|
||||
set_last_error("null pointer for input slice");
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { std::slice::from_raw_parts(ptr, len) })
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn str_from_raw<'a>(ptr: *const u8, len: usize) -> Result<&'a str, i32> {
|
||||
if ptr.is_null() {
|
||||
set_last_error("null pointer for input string");
|
||||
return Err(error_code::NULL_POINTER);
|
||||
}
|
||||
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
|
||||
std::str::from_utf8(bytes).map_err(|e| {
|
||||
set_last_error(format!("invalid utf-8: {}", e));
|
||||
error_code::INVALID_UTF8
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn write_buffer(out: *mut DreportBuffer, buffer: DreportBuffer) -> i32 {
|
||||
if out.is_null() {
|
||||
set_last_error("null out buffer pointer");
|
||||
return error_code::NULL_POINTER;
|
||||
}
|
||||
unsafe { *out = buffer };
|
||||
error_code::OK
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Allocate a new service handle with default embedded fonts.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn dreport_new() -> *mut DreportHandle {
|
||||
clear_last_error();
|
||||
Box::into_raw(Box::new(DreportHandle {
|
||||
inner: Arc::new(DreportService::new()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Allocate an empty service handle (no embedded fonts).
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn dreport_new_empty() -> *mut DreportHandle {
|
||||
clear_last_error();
|
||||
Box::into_raw(Box::new(DreportHandle {
|
||||
inner: Arc::new(DreportService::empty()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Release a service handle previously returned by `dreport_new` /
|
||||
/// `dreport_new_empty`. Calling with `NULL` is a no-op.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_free(handle: *mut DreportHandle) {
|
||||
if handle.is_null() {
|
||||
return;
|
||||
}
|
||||
drop(unsafe { Box::from_raw(handle) });
|
||||
}
|
||||
|
||||
/// Release a buffer previously produced by an FFI call. Calling with a buffer
|
||||
/// whose `data` is NULL or whose `cap` is 0 is a no-op.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_buffer_free(buffer: DreportBuffer) {
|
||||
if buffer.data.is_null() || buffer.cap == 0 {
|
||||
return;
|
||||
}
|
||||
drop(unsafe { Vec::from_raw_parts(buffer.data, buffer.len, buffer.cap) });
|
||||
}
|
||||
|
||||
/// Returns the static crate version string. Pointer remains valid for the
|
||||
/// lifetime of the loaded library.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn dreport_version() -> *const c_char {
|
||||
static VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "\0");
|
||||
VERSION.as_ptr() as *const c_char
|
||||
}
|
||||
|
||||
/// Copy the most recent error message produced on this thread into `out`.
|
||||
/// Returns `error_code::OK` on success (even if there is no error — the buffer
|
||||
/// will simply be empty). The buffer must be released with `dreport_buffer_free`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_last_error(out: *mut DreportBuffer) -> i32 {
|
||||
let msg = LAST_ERROR.with(|cell| cell.borrow().clone()).unwrap_or_default();
|
||||
let buf = if msg.is_empty() {
|
||||
DreportBuffer::empty()
|
||||
} else {
|
||||
DreportBuffer::from_vec(msg.into_bytes())
|
||||
};
|
||||
unsafe { write_buffer(out, buf) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font registry operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Register a font from raw TTF/OTF bytes.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_register_font(
|
||||
handle: *const DreportHandle,
|
||||
data: *const u8,
|
||||
len: usize,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let Some(bytes) = (unsafe { slice_from_raw(data, len) }) else {
|
||||
return error_code::NULL_POINTER;
|
||||
};
|
||||
match h.inner.register_font_bytes(bytes.to_vec()) {
|
||||
Ok(_) => error_code::OK,
|
||||
Err(e) => map_service_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register every font file in `path` (UTF-8 directory path).
|
||||
/// Returns the count via `out_count` (negative on error).
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_register_fonts_dir(
|
||||
handle: *const DreportHandle,
|
||||
path: *const u8,
|
||||
path_len: usize,
|
||||
out_count: *mut usize,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let p = match unsafe { str_from_raw(path, path_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
match h.inner.register_fonts_directory(p) {
|
||||
Ok(n) => {
|
||||
if !out_count.is_null() {
|
||||
unsafe { *out_count = n };
|
||||
}
|
||||
error_code::OK
|
||||
}
|
||||
Err(e) => map_service_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all registered font families as a JSON array
|
||||
/// `[{"family":"Noto Sans","variants":[{"weight":400,"italic":false}, ...]}]`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_list_fonts_json(
|
||||
handle: *const DreportHandle,
|
||||
out: *mut DreportBuffer,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let families = h.inner.list_font_families();
|
||||
match serde_json::to_vec(&families) {
|
||||
Ok(v) => unsafe { write_buffer(out, DreportBuffer::from_vec(v)) },
|
||||
Err(e) => {
|
||||
set_last_error(format!("serialize fonts: {}", e));
|
||||
-ServiceError::SerializationFailed(String::new()).code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the raw bytes for a specific font variant. Sets `out` to an empty buffer
|
||||
/// (data=NULL,len=0) and returns OK if the variant does not exist; this lets
|
||||
/// the caller distinguish "missing" from "error" by inspecting `out.data`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_get_font_bytes(
|
||||
handle: *const DreportHandle,
|
||||
family: *const u8,
|
||||
family_len: usize,
|
||||
weight: u16,
|
||||
italic: bool,
|
||||
out: *mut DreportBuffer,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let fam = match unsafe { str_from_raw(family, family_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
let buf = match h.inner.get_font_bytes(fam, weight, italic) {
|
||||
Some(v) => DreportBuffer::from_vec(v),
|
||||
None => DreportBuffer::empty(),
|
||||
};
|
||||
unsafe { write_buffer(out, buf) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute layout. Returns the LayoutResult JSON via `out`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_compute_layout(
|
||||
handle: *const DreportHandle,
|
||||
template: *const u8,
|
||||
template_len: usize,
|
||||
data: *const u8,
|
||||
data_len: usize,
|
||||
out: *mut DreportBuffer,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let tpl = match unsafe { str_from_raw(template, template_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
let d = match unsafe { str_from_raw(data, data_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
match h.inner.compute_layout_json(tpl, d) {
|
||||
Ok(json) => unsafe { write_buffer(out, DreportBuffer::from_vec(json.into_bytes())) },
|
||||
Err(e) => map_service_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render PDF. Returns PDF bytes via `out`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_render_pdf(
|
||||
handle: *const DreportHandle,
|
||||
template: *const u8,
|
||||
template_len: usize,
|
||||
data: *const u8,
|
||||
data_len: usize,
|
||||
out: *mut DreportBuffer,
|
||||
) -> i32 {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE;
|
||||
};
|
||||
let tpl = match unsafe { str_from_raw(template, template_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
let d = match unsafe { str_from_raw(data, data_len) } {
|
||||
Ok(s) => s,
|
||||
Err(rc) => return rc,
|
||||
};
|
||||
match h.inner.render_pdf_json(tpl, d) {
|
||||
Ok(pdf) => unsafe { write_buffer(out, DreportBuffer::from_vec(pdf)) },
|
||||
Err(e) => map_service_error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of distinct font families currently registered. Returns a negative
|
||||
/// value if the handle is null.
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "C" fn dreport_font_family_count(handle: *const DreportHandle) -> isize {
|
||||
clear_last_error();
|
||||
let Some(h) = (unsafe { handle_ref(handle) }) else {
|
||||
return error_code::NULL_HANDLE as isize;
|
||||
};
|
||||
h.inner.font_family_count() as isize
|
||||
}
|
||||
436
dreport-ffi/tests/ffi.rs
Normal file
436
dreport-ffi/tests/ffi.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
//! Integration tests that drive the C ABI directly. These tests treat the FFI
|
||||
//! crate exactly the way a foreign-language host (NuGet, P/Invoke) would —
|
||||
//! through opaque pointers, byte buffers, and return codes. They are the
|
||||
//! contract test suite for non-Rust consumers.
|
||||
|
||||
use dreport_ffi::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
|
||||
const TEMPLATE: &str = r#"{
|
||||
"id": "ffi",
|
||||
"name": "FFI Test",
|
||||
"page": { "width": 210, "height": 297 },
|
||||
"fonts": ["Noto Sans"],
|
||||
"root": {
|
||||
"id": "root",
|
||||
"type": "container",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"direction": "column",
|
||||
"gap": 5,
|
||||
"padding": { "top": 15, "right": 15, "bottom": 15, "left": 15 },
|
||||
"align": "stretch",
|
||||
"justify": "start",
|
||||
"style": {},
|
||||
"children": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "static_text",
|
||||
"position": { "type": "flow" },
|
||||
"size": { "width": { "type": "auto" }, "height": { "type": "auto" } },
|
||||
"style": { "fontSize": 14, "fontWeight": "bold" },
|
||||
"content": "FFI"
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#;
|
||||
const DATA: &str = "{}";
|
||||
const NOTO_SANS_REGULAR: &[u8] =
|
||||
include_bytes!("../../dreport-service/assets/fonts/NotoSans-Regular.ttf");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small RAII wrappers around the raw FFI types so each test stays terse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Handle(*mut DreportHandle);
|
||||
impl Handle {
|
||||
fn new() -> Self {
|
||||
let h = dreport_new();
|
||||
assert!(!h.is_null(), "dreport_new must succeed");
|
||||
Self(h)
|
||||
}
|
||||
fn empty() -> Self {
|
||||
let h = dreport_new_empty();
|
||||
assert!(!h.is_null());
|
||||
Self(h)
|
||||
}
|
||||
}
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
unsafe { dreport_free(self.0) };
|
||||
}
|
||||
}
|
||||
// SAFETY: Underlying handle wraps an Arc<DreportService>; the service is Sync.
|
||||
unsafe impl Send for Handle {}
|
||||
unsafe impl Sync for Handle {}
|
||||
|
||||
struct OwnedBuffer(DreportBuffer);
|
||||
impl OwnedBuffer {
|
||||
fn empty() -> Self {
|
||||
Self(DreportBuffer {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
})
|
||||
}
|
||||
fn as_slice(&self) -> &[u8] {
|
||||
if self.0.data.is_null() {
|
||||
&[]
|
||||
} else {
|
||||
unsafe { std::slice::from_raw_parts(self.0.data, self.0.len) }
|
||||
}
|
||||
}
|
||||
fn as_str(&self) -> &str {
|
||||
std::str::from_utf8(self.as_slice()).expect("valid utf8")
|
||||
}
|
||||
fn ptr(&mut self) -> *mut DreportBuffer {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
impl Drop for OwnedBuffer {
|
||||
fn drop(&mut self) {
|
||||
let buf = std::mem::replace(
|
||||
&mut self.0,
|
||||
DreportBuffer {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
},
|
||||
);
|
||||
unsafe { dreport_buffer_free(buf) };
|
||||
}
|
||||
}
|
||||
|
||||
fn last_error() -> String {
|
||||
let mut buf = OwnedBuffer::empty();
|
||||
let rc = unsafe { dreport_last_error(buf.ptr()) };
|
||||
assert_eq!(rc, error_code::OK);
|
||||
buf.as_str().to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_and_free_round_trips() {
|
||||
let h = dreport_new();
|
||||
assert!(!h.is_null());
|
||||
unsafe { dreport_free(h) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_null_is_safe() {
|
||||
unsafe { dreport_free(std::ptr::null_mut()) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_free_null_is_safe() {
|
||||
unsafe {
|
||||
dreport_buffer_free(DreportBuffer {
|
||||
data: std::ptr::null_mut(),
|
||||
len: 0,
|
||||
cap: 0,
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_returns_valid_c_string() {
|
||||
let ptr = dreport_version();
|
||||
assert!(!ptr.is_null());
|
||||
let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
|
||||
let s = cstr.to_str().unwrap();
|
||||
assert!(!s.is_empty());
|
||||
assert!(s.chars().next().unwrap().is_ascii_digit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_default_handle_has_fonts() {
|
||||
let h = Handle::new();
|
||||
let count = unsafe { dreport_font_family_count(h.0) };
|
||||
assert!(count >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_handle_has_no_fonts() {
|
||||
let h = Handle::empty();
|
||||
let count = unsafe { dreport_font_family_count(h.0) };
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Null-handle guard rails
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn null_handle_returns_null_handle_code() {
|
||||
let mut buf = OwnedBuffer::empty();
|
||||
let rc = unsafe { dreport_list_fonts_json(std::ptr::null(), buf.ptr()) };
|
||||
assert_eq!(rc, error_code::NULL_HANDLE);
|
||||
assert!(!last_error().is_empty(), "error message must be set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_handle_count_returns_negative() {
|
||||
let count = unsafe { dreport_font_family_count(std::ptr::null()) };
|
||||
assert!(count < 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_with_null_template_returns_null_pointer_code() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, error_code::NULL_POINTER);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn register_font_valid_bytes() {
|
||||
let h = Handle::empty();
|
||||
let rc = unsafe { dreport_register_font(h.0, NOTO_SANS_REGULAR.as_ptr(), NOTO_SANS_REGULAR.len()) };
|
||||
assert_eq!(rc, error_code::OK);
|
||||
assert!(unsafe { dreport_font_family_count(h.0) } >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_font_invalid_bytes_returns_negative_service_code() {
|
||||
let h = Handle::empty();
|
||||
let garbage = b"not a font";
|
||||
let rc = unsafe { dreport_register_font(h.0, garbage.as_ptr(), garbage.len()) };
|
||||
assert_eq!(rc, -3, "ServiceError::FontParseFailed code is 3 → -3 over FFI");
|
||||
let msg = last_error();
|
||||
assert!(msg.to_lowercase().contains("font"), "error msg: {}", msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_dir_invalid_path_sets_error() {
|
||||
let h = Handle::empty();
|
||||
let path = "/zzz/no/such/dreport/path";
|
||||
let mut out_count: usize = 0;
|
||||
let rc = unsafe {
|
||||
dreport_register_fonts_dir(h.0, path.as_ptr(), path.len(), &mut out_count)
|
||||
};
|
||||
assert!(rc < 0);
|
||||
assert_eq!(out_count, 0);
|
||||
assert!(!last_error().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fonts_dir_valid_path_loads_count() {
|
||||
let h = Handle::empty();
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../dreport-service/assets/fonts");
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let mut out_count: usize = 0;
|
||||
let rc = unsafe {
|
||||
dreport_register_fonts_dir(h.0, path_str.as_ptr(), path_str.len(), &mut out_count)
|
||||
};
|
||||
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||
assert!(out_count >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_fonts_json_is_valid_array() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe { dreport_list_fonts_json(h.0, out.ptr()) };
|
||||
assert_eq!(rc, error_code::OK);
|
||||
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
assert!(!parsed.as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_font_bytes_existing_returns_data() {
|
||||
let h = Handle::new();
|
||||
let family = "Noto Sans";
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
|
||||
};
|
||||
assert_eq!(rc, error_code::OK);
|
||||
assert!(out.as_slice().len() > 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_font_bytes_missing_returns_ok_with_empty_buffer() {
|
||||
let h = Handle::new();
|
||||
let family = "DoesNotExist";
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_get_font_bytes(h.0, family.as_ptr(), family.len(), 400, false, out.ptr())
|
||||
};
|
||||
assert_eq!(rc, error_code::OK);
|
||||
assert!(out.as_slice().is_empty());
|
||||
assert!(out.0.data.is_null());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn compute_layout_round_trip() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_compute_layout(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||
let parsed: serde_json::Value = serde_json::from_str(out.as_str()).unwrap();
|
||||
assert!(parsed["pages"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_pdf_returns_pdf_magic_header() {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, error_code::OK, "{}", last_error());
|
||||
let bytes = out.as_slice();
|
||||
assert!(bytes.starts_with(b"%PDF-"), "missing magic header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_with_invalid_template_json_sets_error() {
|
||||
let h = Handle::new();
|
||||
let bad = b"{not json";
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
bad.as_ptr(),
|
||||
bad.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
assert_eq!(rc, -1, "ServiceError::InvalidTemplateJson → -1");
|
||||
assert!(!last_error().is_empty());
|
||||
assert!(out.as_slice().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn concurrent_independent_handles() {
|
||||
let success = Arc::new(AtomicUsize::new(0));
|
||||
let mut threads = Vec::new();
|
||||
for _ in 0..6 {
|
||||
let s = Arc::clone(&success);
|
||||
threads.push(thread::spawn(move || {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
if rc == error_code::OK && out.as_slice().starts_with(b"%PDF-") {
|
||||
s.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
assert_eq!(success.load(Ordering::SeqCst), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_shared_handle() {
|
||||
// The handle itself is owned by one thread, but the underlying service is
|
||||
// an Arc<DreportService>, so internally a shared engine is fine. To test
|
||||
// the most realistic NuGet scenario (one process-wide engine) we instead
|
||||
// create per-thread handles backed by parallel `dreport_new` calls.
|
||||
let success = Arc::new(AtomicUsize::new(0));
|
||||
let mut threads = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let s = Arc::clone(&success);
|
||||
threads.push(thread::spawn(move || {
|
||||
for _ in 0..4 {
|
||||
let h = Handle::new();
|
||||
let mut out = OwnedBuffer::empty();
|
||||
let rc = unsafe {
|
||||
dreport_render_pdf(
|
||||
h.0,
|
||||
TEMPLATE.as_ptr(),
|
||||
TEMPLATE.len(),
|
||||
DATA.as_ptr(),
|
||||
DATA.len(),
|
||||
out.ptr(),
|
||||
)
|
||||
};
|
||||
if rc == 0 {
|
||||
s.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
assert_eq!(success.load(Ordering::SeqCst), 16);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Last-error semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn successful_call_clears_previous_error() {
|
||||
let h = Handle::new();
|
||||
|
||||
// Provoke an error first.
|
||||
let rc = unsafe { dreport_register_font(h.0, b"x".as_ptr(), 1) };
|
||||
assert!(rc < 0);
|
||||
assert!(!last_error().is_empty());
|
||||
|
||||
// A subsequent successful call must clear it.
|
||||
let count = unsafe { dreport_font_family_count(h.0) };
|
||||
assert!(count >= 1);
|
||||
assert!(
|
||||
last_error().is_empty(),
|
||||
"successful call should clear last_error"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user