Skip to main content

merman_ffi/
lib.rs

1#![deny(unsafe_op_in_unsafe_fn)]
2
3//! C ABI exports for embedding `merman` in non-Rust hosts.
4//!
5//! This crate is the only place where the public FFI boundary owns unsafe code. The core
6//! parser/render crates and shared binding facade remain safe Rust APIs.
7
8use merman_bindings_core::{BindingEngine, BindingError, BindingStatus, error_payload_json_bytes};
9use std::ffi::c_char;
10use std::panic::{AssertUnwindSafe, catch_unwind};
11use std::ptr;
12
13#[cfg(target_os = "android")]
14mod android_jni;
15
16pub const MERMAN_ABI_VERSION: u32 = 1;
17
18const PACKAGE_VERSION: &[u8] = concat!(env!("CARGO_PKG_VERSION"), "\0").as_bytes();
19
20#[repr(C)]
21#[derive(Debug, Clone, Copy)]
22pub struct MermanBuffer {
23    pub data: *mut u8,
24    pub len: usize,
25}
26
27impl MermanBuffer {
28    const fn empty() -> Self {
29        Self {
30            data: ptr::null_mut(),
31            len: 0,
32        }
33    }
34}
35
36#[repr(C)]
37#[derive(Debug, Clone, Copy)]
38pub struct MermanResult {
39    pub code: i32,
40    pub data: MermanBuffer,
41}
42
43pub struct MermanEngine {
44    inner: BindingEngine,
45}
46
47#[repr(C)]
48#[derive(Debug, Clone, Copy)]
49pub struct MermanEngineResult {
50    pub code: i32,
51    pub engine: *mut MermanEngine,
52    pub data: MermanBuffer,
53}
54
55/// Return the C ABI protocol version implemented by this library.
56#[unsafe(no_mangle)]
57pub extern "C" fn merman_abi_version() -> u32 {
58    MERMAN_ABI_VERSION
59}
60
61/// Return the `merman-ffi` crate package version as a static C string.
62#[unsafe(no_mangle)]
63pub extern "C" fn merman_package_version() -> *const c_char {
64    PACKAGE_VERSION.as_ptr().cast()
65}
66
67/// Return the Rust-side size of `MermanBuffer`.
68#[unsafe(no_mangle)]
69pub extern "C" fn merman_buffer_struct_size() -> usize {
70    std::mem::size_of::<MermanBuffer>()
71}
72
73/// Return the Rust-side size of `MermanResult`.
74#[unsafe(no_mangle)]
75pub extern "C" fn merman_result_struct_size() -> usize {
76    std::mem::size_of::<MermanResult>()
77}
78
79/// Return the Rust-side size of `MermanEngineResult`.
80#[unsafe(no_mangle)]
81pub extern "C" fn merman_engine_result_struct_size() -> usize {
82    std::mem::size_of::<MermanEngineResult>()
83}
84
85/// Create a reusable engine for repeated calls with the same options.
86///
87/// # Safety
88///
89/// - `options_json` may be null only when `options_len == 0`.
90/// - Non-null pointers must be valid for reads of `options_len` bytes for the duration of the call.
91/// - A returned non-null engine must be released with `merman_engine_free`.
92#[unsafe(no_mangle)]
93pub unsafe extern "C" fn merman_engine_new(
94    options_json: *const u8,
95    options_len: usize,
96) -> MermanEngineResult {
97    ffi_engine_result(|| unsafe { engine_new_impl(options_json, options_len) })
98}
99
100/// Free an engine returned by `merman_engine_new`.
101///
102/// Passing null is a no-op.
103///
104/// # Safety
105///
106/// Non-null engines must have been returned by this crate and must not be freed more than once.
107/// Callers must not free an engine while another thread is using it.
108#[unsafe(no_mangle)]
109pub unsafe extern "C" fn merman_engine_free(engine: *mut MermanEngine) {
110    if engine.is_null() {
111        return;
112    }
113    unsafe {
114        drop(Box::from_raw(engine));
115    }
116}
117
118/// Render Mermaid source to SVG bytes using a reusable engine.
119///
120/// # Safety
121///
122/// - `engine` must be a live pointer returned by `merman_engine_new`.
123/// - `source` may be null only when `source_len == 0`.
124/// - Non-null source pointers must be valid for reads of `source_len` bytes.
125/// - Returned non-empty buffers must be released with `merman_buffer_free`.
126#[unsafe(no_mangle)]
127pub unsafe extern "C" fn merman_engine_render_svg(
128    engine: *const MermanEngine,
129    source: *const u8,
130    source_len: usize,
131) -> MermanResult {
132    ffi_result(|| unsafe {
133        ffi_engine_source_call(engine, source, source_len, BindingEngine::render_svg)
134    })
135}
136
137/// Render Mermaid source to Unicode ASCII-art text using a reusable engine.
138///
139/// # Safety
140///
141/// Safety rules are identical to `merman_engine_render_svg`.
142#[unsafe(no_mangle)]
143pub unsafe extern "C" fn merman_engine_render_ascii(
144    engine: *const MermanEngine,
145    source: *const u8,
146    source_len: usize,
147) -> MermanResult {
148    ffi_result(|| unsafe {
149        ffi_engine_source_call(engine, source, source_len, BindingEngine::render_ascii)
150    })
151}
152
153/// Parse Mermaid source to semantic JSON bytes using a reusable engine.
154///
155/// # Safety
156///
157/// Safety rules are identical to `merman_engine_render_svg`.
158#[unsafe(no_mangle)]
159pub unsafe extern "C" fn merman_engine_parse_json(
160    engine: *const MermanEngine,
161    source: *const u8,
162    source_len: usize,
163) -> MermanResult {
164    ffi_result(|| unsafe {
165        ffi_engine_source_call(engine, source, source_len, BindingEngine::parse_json)
166    })
167}
168
169/// Layout Mermaid source to layout JSON bytes using a reusable engine.
170///
171/// # Safety
172///
173/// Safety rules are identical to `merman_engine_render_svg`.
174#[unsafe(no_mangle)]
175pub unsafe extern "C" fn merman_engine_layout_json(
176    engine: *const MermanEngine,
177    source: *const u8,
178    source_len: usize,
179) -> MermanResult {
180    ffi_result(|| unsafe {
181        ffi_engine_source_call(engine, source, source_len, BindingEngine::layout_json)
182    })
183}
184
185/// Validate Mermaid source using a reusable engine.
186///
187/// # Safety
188///
189/// Safety rules are identical to `merman_engine_render_svg`.
190#[unsafe(no_mangle)]
191pub unsafe extern "C" fn merman_engine_validate_json(
192    engine: *const MermanEngine,
193    source: *const u8,
194    source_len: usize,
195) -> MermanResult {
196    ffi_result(|| unsafe {
197        ffi_engine_source_call(engine, source, source_len, BindingEngine::validate_json)
198    })
199}
200
201/// Render Mermaid source to SVG bytes.
202///
203/// # Safety
204///
205/// - `source` may be null only when `source_len == 0`.
206/// - `options_json` may be null only when `options_len == 0`.
207/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
208/// - Returned non-empty buffers must be released with `merman_buffer_free`.
209#[unsafe(no_mangle)]
210pub unsafe extern "C" fn merman_render_svg(
211    source: *const u8,
212    source_len: usize,
213    options_json: *const u8,
214    options_len: usize,
215) -> MermanResult {
216    ffi_result(|| unsafe {
217        ffi_source_options_call(
218            source,
219            source_len,
220            options_json,
221            options_len,
222            merman_bindings_core::render_svg,
223        )
224    })
225}
226
227/// Render Mermaid source to Unicode ASCII-art text.
228///
229/// # Safety
230///
231/// - `source` may be null only when `source_len == 0`.
232/// - `options_json` may be null only when `options_len == 0`.
233/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
234/// - Returned non-empty buffers must be released with `merman_buffer_free`.
235#[unsafe(no_mangle)]
236pub unsafe extern "C" fn merman_render_ascii(
237    source: *const u8,
238    source_len: usize,
239    options_json: *const u8,
240    options_len: usize,
241) -> MermanResult {
242    ffi_result(|| unsafe {
243        ffi_source_options_call(
244            source,
245            source_len,
246            options_json,
247            options_len,
248            merman_bindings_core::render_ascii,
249        )
250    })
251}
252
253/// Parse Mermaid source to semantic JSON bytes.
254///
255/// # Safety
256///
257/// - `source` may be null only when `source_len == 0`.
258/// - `options_json` may be null only when `options_len == 0`.
259/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
260/// - Returned non-empty buffers must be released with `merman_buffer_free`.
261#[unsafe(no_mangle)]
262pub unsafe extern "C" fn merman_parse_json(
263    source: *const u8,
264    source_len: usize,
265    options_json: *const u8,
266    options_len: usize,
267) -> MermanResult {
268    ffi_result(|| unsafe {
269        ffi_source_options_call(
270            source,
271            source_len,
272            options_json,
273            options_len,
274            merman_bindings_core::parse_json,
275        )
276    })
277}
278
279/// Layout Mermaid source to layout JSON bytes.
280///
281/// # Safety
282///
283/// - `source` may be null only when `source_len == 0`.
284/// - `options_json` may be null only when `options_len == 0`.
285/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
286/// - Returned non-empty buffers must be released with `merman_buffer_free`.
287#[unsafe(no_mangle)]
288pub unsafe extern "C" fn merman_layout_json(
289    source: *const u8,
290    source_len: usize,
291    options_json: *const u8,
292    options_len: usize,
293) -> MermanResult {
294    ffi_result(|| unsafe {
295        ffi_source_options_call(
296            source,
297            source_len,
298            options_json,
299            options_len,
300            merman_bindings_core::layout_json,
301        )
302    })
303}
304
305/// Validate Mermaid source and return a JSON validation payload.
306///
307/// # Safety
308///
309/// - `source` may be null only when `source_len == 0`.
310/// - `options_json` may be null only when `options_len == 0`.
311/// - Non-null pointers must be valid for reads of their paired length for the duration of the call.
312/// - Returned non-empty buffers must be released with `merman_buffer_free`.
313#[unsafe(no_mangle)]
314pub unsafe extern "C" fn merman_validate_json(
315    source: *const u8,
316    source_len: usize,
317    options_json: *const u8,
318    options_len: usize,
319) -> MermanResult {
320    ffi_result(|| unsafe {
321        ffi_source_options_call(
322            source,
323            source_len,
324            options_json,
325            options_len,
326            merman_bindings_core::validate_json,
327        )
328    })
329}
330
331/// Return supported diagram type metadata as a JSON string array.
332#[unsafe(no_mangle)]
333pub extern "C" fn merman_supported_diagrams_json() -> MermanResult {
334    ffi_result(merman_bindings_core::supported_diagrams_json)
335}
336
337/// Return ASCII-supported diagram type metadata as a JSON string array.
338#[unsafe(no_mangle)]
339pub extern "C" fn merman_ascii_supported_diagrams_json() -> MermanResult {
340    ffi_result(merman_bindings_core::ascii_supported_diagrams_json)
341}
342
343/// Return supported theme metadata as a JSON string array.
344#[unsafe(no_mangle)]
345pub extern "C" fn merman_supported_themes_json() -> MermanResult {
346    ffi_result(merman_bindings_core::supported_themes_json)
347}
348
349/// Return supported host/editor theme preset metadata as a JSON string array.
350#[unsafe(no_mangle)]
351pub extern "C" fn merman_supported_host_theme_presets_json() -> MermanResult {
352    ffi_result(merman_bindings_core::supported_host_theme_presets_json)
353}
354
355/// Free a buffer returned by this crate.
356///
357/// Passing a null buffer is a no-op.
358///
359/// # Safety
360///
361/// Non-null buffers must have been returned by this crate and must not be freed more than once.
362#[unsafe(no_mangle)]
363pub unsafe extern "C" fn merman_buffer_free(buffer: MermanBuffer) {
364    if buffer.data.is_null() || buffer.len == 0 {
365        return;
366    }
367
368    let raw = ptr::slice_from_raw_parts_mut(buffer.data, buffer.len);
369    unsafe {
370        drop(Box::from_raw(raw));
371    }
372}
373
374fn ffi_result<F>(f: F) -> MermanResult
375where
376    F: FnOnce() -> Result<Vec<u8>, BindingError>,
377{
378    match catch_unwind(AssertUnwindSafe(f)) {
379        Ok(Ok(bytes)) => MermanResult {
380            code: BindingStatus::Ok.code(),
381            data: buffer_from_vec(bytes),
382        },
383        Ok(Err(err)) => error_result(err.status(), err.message()),
384        Err(_) => error_result(BindingStatus::Panic, "panic caught at merman FFI boundary"),
385    }
386}
387
388fn ffi_engine_result<F>(f: F) -> MermanEngineResult
389where
390    F: FnOnce() -> Result<BindingEngine, BindingError>,
391{
392    match catch_unwind(AssertUnwindSafe(f)) {
393        Ok(Ok(inner)) => MermanEngineResult {
394            code: BindingStatus::Ok.code(),
395            engine: Box::into_raw(Box::new(MermanEngine { inner })),
396            data: MermanBuffer::empty(),
397        },
398        Ok(Err(err)) => MermanEngineResult {
399            code: err.status().code(),
400            engine: ptr::null_mut(),
401            data: buffer_from_vec(error_payload_json_bytes(err.status(), err.message())),
402        },
403        Err(_) => MermanEngineResult {
404            code: BindingStatus::Panic.code(),
405            engine: ptr::null_mut(),
406            data: buffer_from_vec(error_payload_json_bytes(
407                BindingStatus::Panic,
408                "panic caught at merman FFI boundary",
409            )),
410        },
411    }
412}
413
414unsafe fn engine_new_impl(
415    options_json: *const u8,
416    options_len: usize,
417) -> Result<BindingEngine, BindingError> {
418    let options_bytes = unsafe { raw_bytes(options_json, options_len, "options_json")? };
419    BindingEngine::new(options_bytes)
420}
421
422unsafe fn engine_ref<'a>(engine: *const MermanEngine) -> Result<&'a MermanEngine, BindingError> {
423    if engine.is_null() {
424        return Err(BindingError::new(
425            BindingStatus::InvalidArgument,
426            "engine pointer is null",
427        ));
428    }
429    Ok(unsafe { &*engine })
430}
431
432unsafe fn ffi_engine_source_call<F>(
433    engine: *const MermanEngine,
434    source: *const u8,
435    source_len: usize,
436    f: F,
437) -> Result<Vec<u8>, BindingError>
438where
439    F: FnOnce(&BindingEngine, &[u8]) -> Result<Vec<u8>, BindingError>,
440{
441    let engine = unsafe { engine_ref(engine)? };
442    let source_bytes = unsafe { raw_bytes(source, source_len, "source")? };
443    f(&engine.inner, source_bytes)
444}
445
446unsafe fn ffi_source_options_call<F>(
447    source: *const u8,
448    source_len: usize,
449    options_json: *const u8,
450    options_len: usize,
451    f: F,
452) -> Result<Vec<u8>, BindingError>
453where
454    F: FnOnce(&[u8], &[u8]) -> Result<Vec<u8>, BindingError>,
455{
456    let request = unsafe {
457        FfiSourceOptionsRequest::from_raw(source, source_len, options_json, options_len)?
458    };
459    f(request.source, request.options_json)
460}
461
462struct FfiSourceOptionsRequest<'a> {
463    source: &'a [u8],
464    options_json: &'a [u8],
465}
466
467impl<'a> FfiSourceOptionsRequest<'a> {
468    unsafe fn from_raw(
469        source: *const u8,
470        source_len: usize,
471        options_json: *const u8,
472        options_len: usize,
473    ) -> Result<Self, BindingError> {
474        let source = unsafe { raw_bytes(source, source_len, "source")? };
475        let options_json = unsafe { raw_bytes(options_json, options_len, "options_json")? };
476        Ok(Self {
477            source,
478            options_json,
479        })
480    }
481}
482
483unsafe fn raw_bytes<'a>(
484    data: *const u8,
485    len: usize,
486    name: &'static str,
487) -> Result<&'a [u8], BindingError> {
488    if data.is_null() {
489        if len == 0 {
490            return Ok(&[]);
491        }
492        return Err(BindingError::new(
493            BindingStatus::InvalidArgument,
494            format!("{name} pointer is null but length is {len}"),
495        ));
496    }
497
498    if len == 0 {
499        return Ok(&[]);
500    }
501
502    Ok(unsafe { std::slice::from_raw_parts(data, len) })
503}
504
505fn buffer_from_vec(bytes: Vec<u8>) -> MermanBuffer {
506    if bytes.is_empty() {
507        return MermanBuffer::empty();
508    }
509    let mut boxed = bytes.into_boxed_slice();
510    let buffer = MermanBuffer {
511        data: boxed.as_mut_ptr(),
512        len: boxed.len(),
513    };
514    std::mem::forget(boxed);
515    buffer
516}
517
518fn error_result(status: BindingStatus, message: &str) -> MermanResult {
519    MermanResult {
520        code: status.code(),
521        data: buffer_from_vec(error_payload_json_bytes(status, message)),
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use serde_json::Value;
529    use std::ffi::CStr;
530
531    fn call_render(source: &[u8], options: &[u8]) -> MermanResult {
532        unsafe {
533            merman_render_svg(
534                source.as_ptr(),
535                source.len(),
536                options.as_ptr(),
537                options.len(),
538            )
539        }
540    }
541
542    fn call_render_ascii(source: &[u8], options: &[u8]) -> MermanResult {
543        unsafe {
544            merman_render_ascii(
545                source.as_ptr(),
546                source.len(),
547                options.as_ptr(),
548                options.len(),
549            )
550        }
551    }
552
553    fn call_parse(source: &[u8], options: &[u8]) -> MermanResult {
554        unsafe {
555            merman_parse_json(
556                source.as_ptr(),
557                source.len(),
558                options.as_ptr(),
559                options.len(),
560            )
561        }
562    }
563
564    fn call_validate(source: &[u8], options: &[u8]) -> MermanResult {
565        unsafe {
566            merman_validate_json(
567                source.as_ptr(),
568                source.len(),
569                options.as_ptr(),
570                options.len(),
571            )
572        }
573    }
574
575    fn call_layout(source: &[u8], options: &[u8]) -> MermanResult {
576        unsafe {
577            merman_layout_json(
578                source.as_ptr(),
579                source.len(),
580                options.as_ptr(),
581                options.len(),
582            )
583        }
584    }
585
586    fn call_engine(options: &[u8]) -> MermanEngineResult {
587        unsafe { merman_engine_new(options.as_ptr(), options.len()) }
588    }
589
590    fn call_engine_render(engine: *const MermanEngine, source: &[u8]) -> MermanResult {
591        unsafe { merman_engine_render_svg(engine, source.as_ptr(), source.len()) }
592    }
593
594    fn take_buffer(buffer: MermanBuffer) -> Vec<u8> {
595        if buffer.data.is_null() || buffer.len == 0 {
596            return Vec::new();
597        }
598        let bytes = unsafe { std::slice::from_raw_parts(buffer.data, buffer.len).to_vec() };
599        unsafe { merman_buffer_free(buffer) };
600        bytes
601    }
602
603    fn take_text(buffer: MermanBuffer) -> String {
604        String::from_utf8(take_buffer(buffer)).expect("FFI output should be UTF-8")
605    }
606
607    fn take_error(result: MermanResult) -> Value {
608        serde_json::from_str(&take_text(result.data)).expect("error payload should be JSON")
609    }
610
611    fn expect_render_feature_error(result: MermanResult) {
612        assert_eq!(result.code, BindingStatus::UnsupportedFormat.code());
613        let error = take_error(result);
614        assert_eq!(
615            error["code_name"],
616            BindingStatus::UnsupportedFormat.code_name()
617        );
618        assert!(
619            error["message"]
620                .as_str()
621                .unwrap()
622                .contains("render feature")
623        );
624    }
625
626    #[test]
627    fn abi_introspection_reports_contract_values() {
628        assert_eq!(merman_abi_version(), MERMAN_ABI_VERSION);
629        assert_eq!(
630            merman_buffer_struct_size(),
631            std::mem::size_of::<MermanBuffer>()
632        );
633        assert_eq!(
634            merman_result_struct_size(),
635            std::mem::size_of::<MermanResult>()
636        );
637
638        let version = unsafe { CStr::from_ptr(merman_package_version()) };
639        assert_eq!(version.to_str().unwrap(), env!("CARGO_PKG_VERSION"));
640    }
641
642    #[test]
643    fn render_svg_returns_svg_for_flowchart() {
644        let result = call_render(b"flowchart TD\nA[Hello] --> B[World]", b"");
645
646        if cfg!(feature = "render") {
647            assert_eq!(result.code, BindingStatus::Ok.code());
648            let svg = take_text(result.data);
649            assert!(svg.contains("<svg"));
650            assert!(svg.contains("Hello"));
651            assert!(svg.contains("World"));
652        } else {
653            expect_render_feature_error(result);
654        }
655    }
656
657    #[test]
658    fn render_svg_accepts_options_json() {
659        let options = br#"{
660            "layout": { "text_measurer": "deterministic", "viewport_width": 640, "viewport_height": 480 },
661            "svg": { "diagram_id": "ffi diagram", "pipeline": "readable" }
662        }"#;
663        let result = call_render(b"flowchart TD\nA[Hello]", options);
664
665        if cfg!(feature = "render") {
666            assert_eq!(result.code, BindingStatus::Ok.code());
667            let svg = take_text(result.data);
668            assert!(svg.contains("id=\"ffi-diagram\""));
669            assert!(svg.contains("data-merman-foreignobject"));
670        } else {
671            expect_render_feature_error(result);
672        }
673    }
674
675    #[test]
676    fn render_ascii_returns_text_or_feature_error() {
677        let result = call_render_ascii(b"flowchart TD\nA[Hello] --> B[World]", b"");
678
679        if cfg!(feature = "ascii") {
680            assert_eq!(result.code, BindingStatus::Ok.code());
681            let text = take_text(result.data);
682            assert!(text.contains("Hello"));
683            assert!(text.contains("World"));
684        } else {
685            assert_eq!(result.code, BindingStatus::UnsupportedFormat.code());
686            let error = take_error(result);
687            assert_eq!(
688                error["code_name"],
689                BindingStatus::UnsupportedFormat.code_name()
690            );
691        }
692    }
693
694    #[test]
695    fn parse_json_returns_semantic_model() {
696        let result = call_parse(b"flowchart TD\nA[Hello] --> B[World]", b"");
697
698        if cfg!(feature = "render") {
699            assert_eq!(result.code, BindingStatus::Ok.code());
700            let json: Value = serde_json::from_str(&take_text(result.data)).unwrap();
701            assert!(json.is_object());
702            assert_eq!(
703                json.get("type").and_then(Value::as_str),
704                Some("flowchart-v2")
705            );
706            assert!(json.get("nodes").and_then(Value::as_array).is_some());
707            assert!(json.get("edges").and_then(Value::as_array).is_some());
708        } else {
709            expect_render_feature_error(result);
710        }
711    }
712
713    #[test]
714    fn layout_json_returns_layouted_diagram() {
715        let result = call_layout(b"flowchart TD\nA[Hello] --> B[World]", b"");
716
717        if cfg!(feature = "render") {
718            assert_eq!(result.code, BindingStatus::Ok.code());
719            let json: Value = serde_json::from_str(&take_text(result.data)).unwrap();
720            assert!(json.get("meta").is_some());
721            assert!(json.get("layout").is_some());
722        } else {
723            expect_render_feature_error(result);
724        }
725    }
726
727    #[test]
728    fn validate_json_returns_status_payload() {
729        let valid = call_validate(b"flowchart TD\nA[Hello]", b"");
730        assert_eq!(valid.code, BindingStatus::Ok.code());
731        let json: Value = serde_json::from_str(&take_text(valid.data)).unwrap();
732        if cfg!(feature = "render") {
733            assert_eq!(json["valid"], true);
734            assert_eq!(json["code_name"], BindingStatus::Ok.code_name());
735        } else {
736            assert_eq!(json["valid"], false);
737            assert_eq!(
738                json["code_name"],
739                BindingStatus::UnsupportedFormat.code_name()
740            );
741        }
742
743        let invalid = call_validate(b"", b"");
744        assert_eq!(invalid.code, BindingStatus::Ok.code());
745        let json: Value = serde_json::from_str(&take_text(invalid.data)).unwrap();
746        assert_eq!(json["valid"], false);
747        if cfg!(feature = "render") {
748            assert_eq!(json["code_name"], BindingStatus::NoDiagram.code_name());
749        } else {
750            assert_eq!(
751                json["code_name"],
752                BindingStatus::UnsupportedFormat.code_name()
753            );
754        }
755    }
756
757    #[test]
758    fn metadata_entry_points_return_json_arrays() {
759        let diagrams = merman_supported_diagrams_json();
760        let ascii_diagrams = merman_ascii_supported_diagrams_json();
761        let themes = merman_supported_themes_json();
762        let host_theme_presets = merman_supported_host_theme_presets_json();
763
764        assert_eq!(diagrams.code, BindingStatus::Ok.code());
765        assert_eq!(ascii_diagrams.code, BindingStatus::Ok.code());
766        assert_eq!(themes.code, BindingStatus::Ok.code());
767        assert_eq!(host_theme_presets.code, BindingStatus::Ok.code());
768
769        let diagrams: Value = serde_json::from_str(&take_text(diagrams.data)).unwrap();
770        let ascii_diagrams: Value = serde_json::from_str(&take_text(ascii_diagrams.data)).unwrap();
771        let themes: Value = serde_json::from_str(&take_text(themes.data)).unwrap();
772        let host_theme_presets: Value =
773            serde_json::from_str(&take_text(host_theme_presets.data)).unwrap();
774
775        assert!(
776            diagrams
777                .as_array()
778                .unwrap()
779                .contains(&Value::String("flowchart".to_string()))
780        );
781        assert!(ascii_diagrams.is_array());
782        assert!(
783            themes
784                .as_array()
785                .unwrap()
786                .contains(&Value::String("default".to_string()))
787        );
788        assert!(host_theme_presets.is_array());
789        if cfg!(feature = "render") {
790            assert!(
791                host_theme_presets
792                    .as_array()
793                    .unwrap()
794                    .contains(&Value::String("one-dark".to_string()))
795            );
796        }
797    }
798
799    #[test]
800    fn parse_json_uses_same_error_payload() {
801        let result = call_parse(&[0xff], b"");
802
803        if cfg!(feature = "render") {
804            assert_eq!(result.code, BindingStatus::Utf8Error.code());
805            let error = take_error(result);
806            assert_eq!(error["code_name"], BindingStatus::Utf8Error.code_name());
807        } else {
808            expect_render_feature_error(result);
809        }
810    }
811
812    #[test]
813    fn null_source_with_nonzero_len_returns_invalid_argument() {
814        let result = unsafe { merman_render_svg(ptr::null(), 1, ptr::null(), 0) };
815
816        assert_eq!(result.code, BindingStatus::InvalidArgument.code());
817        let error = take_error(result);
818        assert_eq!(
819            error["code_name"],
820            BindingStatus::InvalidArgument.code_name()
821        );
822    }
823
824    #[test]
825    fn invalid_source_utf8_returns_utf8_error() {
826        let result = call_render(&[0xff], b"");
827
828        if cfg!(feature = "render") {
829            assert_eq!(result.code, BindingStatus::Utf8Error.code());
830            let error = take_error(result);
831            assert_eq!(error["code_name"], BindingStatus::Utf8Error.code_name());
832        } else {
833            expect_render_feature_error(result);
834        }
835    }
836
837    #[test]
838    fn empty_source_returns_no_diagram() {
839        let result = unsafe { merman_render_svg(ptr::null(), 0, ptr::null(), 0) };
840
841        if cfg!(feature = "render") {
842            assert_eq!(result.code, BindingStatus::NoDiagram.code());
843            let error = take_error(result);
844            assert_eq!(error["code_name"], BindingStatus::NoDiagram.code_name());
845        } else {
846            expect_render_feature_error(result);
847        }
848    }
849
850    #[test]
851    fn invalid_options_json_returns_options_json_error() {
852        let result = call_render(b"flowchart TD\nA", b"{");
853
854        if cfg!(feature = "render") {
855            assert_eq!(result.code, BindingStatus::OptionsJsonError.code());
856            let error = take_error(result);
857            assert_eq!(
858                error["code_name"],
859                BindingStatus::OptionsJsonError.code_name()
860            );
861        } else {
862            expect_render_feature_error(result);
863        }
864    }
865
866    #[test]
867    fn unsupported_ratex_without_feature_returns_unsupported_format() {
868        let result = call_render(
869            b"flowchart TD\nA[Hello]",
870            br#"{ "layout": { "math_renderer": "ratex" } }"#,
871        );
872
873        if cfg!(feature = "ratex-math") {
874            assert_eq!(result.code, BindingStatus::Ok.code());
875            unsafe { merman_buffer_free(result.data) };
876        } else {
877            assert_eq!(result.code, BindingStatus::UnsupportedFormat.code());
878            let error = take_error(result);
879            assert_eq!(
880                error["code_name"],
881                BindingStatus::UnsupportedFormat.code_name()
882            );
883        }
884    }
885
886    #[test]
887    fn buffer_free_accepts_null_buffer() {
888        unsafe { merman_buffer_free(MermanBuffer::empty()) };
889    }
890
891    #[test]
892    fn ffi_result_catches_panic() {
893        let result = ffi_result(|| -> Result<Vec<u8>, BindingError> { panic!("boom") });
894
895        assert_eq!(result.code, BindingStatus::Panic.code());
896        let error = take_error(result);
897        assert_eq!(error["code_name"], BindingStatus::Panic.code_name());
898    }
899
900    #[test]
901    fn ffi_source_options_request_decodes_source_and_null_options() {
902        let source = b"flowchart TD\nA[Hello]";
903        let request = unsafe {
904            FfiSourceOptionsRequest::from_raw(source.as_ptr(), source.len(), ptr::null(), 0)
905        }
906        .unwrap();
907
908        assert_eq!(request.source, source);
909        assert!(request.options_json.is_empty());
910    }
911
912    #[test]
913    fn ffi_engine_source_call_decodes_engine_and_source() {
914        let engine = MermanEngine {
915            inner: BindingEngine::new(b"").unwrap(),
916        };
917        let source = b"flowchart TD\nA[Hello]";
918        let output = unsafe {
919            ffi_engine_source_call(&engine, source.as_ptr(), source.len(), |_engine, source| {
920                Ok(source.to_vec())
921            })
922        }
923        .unwrap();
924
925        assert_eq!(output, source);
926    }
927
928    #[test]
929    fn reusable_engine_renders_with_cached_options() {
930        let options = br#"{
931            "layout": { "text_measurer": "deterministic" },
932            "svg": { "diagram_id": "ffi engine", "pipeline": "readable" }
933        }"#;
934        let engine = call_engine(options);
935        assert_eq!(engine.code, BindingStatus::Ok.code());
936        assert!(!engine.engine.is_null());
937        assert!(engine.data.data.is_null());
938
939        let result = call_engine_render(engine.engine, b"flowchart TD\nA[Hello]");
940        if cfg!(feature = "render") {
941            assert_eq!(result.code, BindingStatus::Ok.code());
942            let svg = take_text(result.data);
943            assert!(svg.contains("id=\"ffi-engine\""));
944            assert!(svg.contains("data-merman-foreignobject"));
945        } else {
946            expect_render_feature_error(result);
947        }
948
949        unsafe { merman_engine_free(engine.engine) };
950    }
951
952    #[test]
953    fn reusable_engine_reports_invalid_options_json() {
954        let engine = call_engine(b"{");
955
956        if cfg!(any(feature = "render", feature = "ascii")) {
957            assert_eq!(engine.code, BindingStatus::OptionsJsonError.code());
958            assert!(engine.engine.is_null());
959            let error: Value = serde_json::from_str(&take_text(engine.data)).unwrap();
960            assert_eq!(
961                error["code_name"],
962                BindingStatus::OptionsJsonError.code_name()
963            );
964        } else {
965            assert_eq!(engine.code, BindingStatus::Ok.code());
966            unsafe { merman_engine_free(engine.engine) };
967        }
968    }
969
970    #[test]
971    fn reusable_engine_rejects_null_engine() {
972        let result = unsafe {
973            merman_engine_render_svg(
974                ptr::null(),
975                b"flowchart TD\nA".as_ptr(),
976                b"flowchart TD\nA".len(),
977            )
978        };
979
980        assert_eq!(result.code, BindingStatus::InvalidArgument.code());
981        let error = take_error(result);
982        assert_eq!(
983            error["code_name"],
984            BindingStatus::InvalidArgument.code_name()
985        );
986        assert!(error["message"].as_str().unwrap().contains("engine"));
987    }
988
989    #[test]
990    fn engine_result_struct_size_is_reported() {
991        assert_eq!(
992            merman_engine_result_struct_size(),
993            std::mem::size_of::<MermanEngineResult>()
994        );
995    }
996
997    #[test]
998    fn reusable_engine_can_render_concurrently_through_c_abi() {
999        let engine = call_engine(b"");
1000        assert_eq!(engine.code, BindingStatus::Ok.code());
1001        assert!(!engine.engine.is_null());
1002        let engine_addr = engine.engine as usize;
1003
1004        let mut handles = Vec::new();
1005        for _ in 0..8 {
1006            handles.push(std::thread::spawn(move || {
1007                let engine = engine_addr as *const MermanEngine;
1008                for _ in 0..8 {
1009                    let result = call_engine_render(engine, b"flowchart TD\nA[Hello] --> B[World]");
1010                    if cfg!(feature = "render") {
1011                        assert_eq!(result.code, BindingStatus::Ok.code());
1012                        let svg = take_text(result.data);
1013                        assert!(svg.contains("<svg"));
1014                    } else {
1015                        expect_render_feature_error(result);
1016                    }
1017                }
1018            }));
1019        }
1020
1021        for handle in handles {
1022            handle.join().unwrap();
1023        }
1024
1025        unsafe { merman_engine_free(engine.engine) };
1026    }
1027}