feat: dreport-service + dreport-ffi + nuget packages
Some checks failed
CI / rust (push) Failing after 40s
CI / frontend (push) Failing after 1m53s
CI / wasm (push) Successful in 1m45s
CI / publish-crates (push) Has been skipped
CI / publish-npm (push) Has been skipped

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:
2026-04-28 16:19:47 +03:00
parent 92583141c9
commit 2db5929e39
44 changed files with 3377 additions and 252 deletions

17
dreport-ffi/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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"
);
}