1#![deny(unsafe_op_in_unsafe_fn)]
2
3use 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#[unsafe(no_mangle)]
57pub extern "C" fn merman_abi_version() -> u32 {
58 MERMAN_ABI_VERSION
59}
60
61#[unsafe(no_mangle)]
63pub extern "C" fn merman_package_version() -> *const c_char {
64 PACKAGE_VERSION.as_ptr().cast()
65}
66
67#[unsafe(no_mangle)]
69pub extern "C" fn merman_buffer_struct_size() -> usize {
70 std::mem::size_of::<MermanBuffer>()
71}
72
73#[unsafe(no_mangle)]
75pub extern "C" fn merman_result_struct_size() -> usize {
76 std::mem::size_of::<MermanResult>()
77}
78
79#[unsafe(no_mangle)]
81pub extern "C" fn merman_engine_result_struct_size() -> usize {
82 std::mem::size_of::<MermanEngineResult>()
83}
84
85#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[unsafe(no_mangle)]
333pub extern "C" fn merman_supported_diagrams_json() -> MermanResult {
334 ffi_result(merman_bindings_core::supported_diagrams_json)
335}
336
337#[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#[unsafe(no_mangle)]
345pub extern "C" fn merman_supported_themes_json() -> MermanResult {
346 ffi_result(merman_bindings_core::supported_themes_json)
347}
348
349#[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#[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}