Skip to main content

merman_uniffi/
lib.rs

1#![forbid(unsafe_code)]
2
3//! UniFFI bindings for `merman`.
4//!
5//! This crate exposes an idiomatic generated-binding surface over `merman-bindings-core`. It does
6//! not replace the canonical C ABI in `merman-ffi`.
7
8use merman_bindings_core::{BindingError, BindingStatus};
9use serde_json::Value;
10use std::sync::{Arc, OnceLock};
11
12pub const MERMAN_UNIFFI_ABI_VERSION: u32 = 1;
13
14static SUPPORTED_DIAGRAMS: OnceLock<Vec<String>> = OnceLock::new();
15static ASCII_SUPPORTED_DIAGRAMS: OnceLock<Vec<String>> = OnceLock::new();
16static SUPPORTED_THEMES: OnceLock<Vec<String>> = OnceLock::new();
17static SUPPORTED_HOST_THEME_PRESETS: OnceLock<Vec<String>> = OnceLock::new();
18
19#[derive(Debug, thiserror::Error, uniffi::Error)]
20pub enum MermanError {
21    #[error("{code_name}: {message}")]
22    Binding {
23        code: i32,
24        code_name: String,
25        message: String,
26    },
27}
28
29impl MermanError {
30    pub fn from_binding(error: BindingError) -> Self {
31        let status = error.status();
32        Self::Binding {
33            code: status.code(),
34            code_name: status.code_name().to_string(),
35            message: error.message().to_string(),
36        }
37    }
38
39    fn internal(message: impl Into<String>) -> Self {
40        let status = BindingStatus::InternalError;
41        Self::Binding {
42            code: status.code(),
43            code_name: status.code_name().to_string(),
44            message: message.into(),
45        }
46    }
47}
48
49#[derive(Debug, Default, uniffi::Object)]
50pub struct MermanEngine;
51
52#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
53pub struct MermanValidationResult {
54    pub valid: bool,
55    pub error: Option<String>,
56    pub code: i32,
57    pub code_name: String,
58}
59
60#[uniffi::export]
61impl MermanEngine {
62    #[uniffi::constructor]
63    pub fn new() -> Arc<Self> {
64        Arc::new(Self)
65    }
66
67    pub fn abi_version(&self) -> u32 {
68        MERMAN_UNIFFI_ABI_VERSION
69    }
70
71    pub fn package_version(&self) -> String {
72        env!("CARGO_PKG_VERSION").to_string()
73    }
74
75    pub fn render_svg(
76        &self,
77        source: String,
78        options_json: Option<String>,
79    ) -> Result<String, MermanError> {
80        string_output(merman_bindings_core::render_svg(
81            source.as_bytes(),
82            options_bytes(options_json.as_deref()),
83        ))
84    }
85
86    pub fn render_ascii(
87        &self,
88        source: String,
89        options_json: Option<String>,
90    ) -> Result<String, MermanError> {
91        string_output(merman_bindings_core::render_ascii(
92            source.as_bytes(),
93            options_bytes(options_json.as_deref()),
94        ))
95    }
96
97    pub fn parse_json(
98        &self,
99        source: String,
100        options_json: Option<String>,
101    ) -> Result<String, MermanError> {
102        string_output(merman_bindings_core::parse_json(
103            source.as_bytes(),
104            options_bytes(options_json.as_deref()),
105        ))
106    }
107
108    pub fn layout_json(
109        &self,
110        source: String,
111        options_json: Option<String>,
112    ) -> Result<String, MermanError> {
113        string_output(merman_bindings_core::layout_json(
114            source.as_bytes(),
115            options_bytes(options_json.as_deref()),
116        ))
117    }
118
119    pub fn validate(
120        &self,
121        source: String,
122        options_json: Option<String>,
123    ) -> Result<MermanValidationResult, MermanError> {
124        validation_output(merman_bindings_core::validate_json(
125            source.as_bytes(),
126            options_bytes(options_json.as_deref()),
127        ))
128    }
129
130    pub fn supported_diagrams(&self) -> Vec<String> {
131        cached_string_vec(
132            &SUPPORTED_DIAGRAMS,
133            merman_bindings_core::supported_diagrams,
134        )
135    }
136
137    pub fn ascii_supported_diagrams(&self) -> Vec<String> {
138        cached_string_vec(
139            &ASCII_SUPPORTED_DIAGRAMS,
140            merman_bindings_core::ascii_supported_diagrams,
141        )
142    }
143
144    pub fn supported_themes(&self) -> Vec<String> {
145        cached_string_vec(&SUPPORTED_THEMES, merman_bindings_core::supported_themes)
146    }
147
148    pub fn supported_host_theme_presets(&self) -> Vec<String> {
149        cached_string_vec(
150            &SUPPORTED_HOST_THEME_PRESETS,
151            merman_bindings_core::supported_host_theme_presets,
152        )
153    }
154}
155
156fn options_bytes(options_json: Option<&str>) -> &[u8] {
157    options_json.unwrap_or_default().as_bytes()
158}
159
160fn string_output(result: Result<Vec<u8>, BindingError>) -> Result<String, MermanError> {
161    let bytes = result.map_err(MermanError::from_binding)?;
162    String::from_utf8(bytes)
163        .map_err(|err| MermanError::internal(format!("binding output was not UTF-8: {err}")))
164}
165
166fn validation_output(
167    result: Result<Vec<u8>, BindingError>,
168) -> Result<MermanValidationResult, MermanError> {
169    let bytes = result.map_err(MermanError::from_binding)?;
170    let value: Value = serde_json::from_slice(&bytes)
171        .map_err(|err| MermanError::internal(format!("validation JSON decode failed: {err}")))?;
172    let object = value
173        .as_object()
174        .ok_or_else(|| MermanError::internal("validation JSON was not an object"))?;
175    let valid = object
176        .get("valid")
177        .and_then(Value::as_bool)
178        .ok_or_else(|| MermanError::internal("validation JSON missing valid"))?;
179    let code = object
180        .get("code")
181        .and_then(Value::as_i64)
182        .ok_or_else(|| MermanError::internal("validation JSON missing code"))?;
183    let code_name = object
184        .get("code_name")
185        .and_then(Value::as_str)
186        .ok_or_else(|| MermanError::internal("validation JSON missing code_name"))?;
187    let error = object
188        .get("error")
189        .and_then(Value::as_str)
190        .map(str::to_string);
191
192    Ok(MermanValidationResult {
193        valid,
194        error,
195        code: code as i32,
196        code_name: code_name.to_string(),
197    })
198}
199
200fn string_vec(values: &[&str]) -> Vec<String> {
201    values.iter().map(|value| (*value).to_string()).collect()
202}
203
204fn cached_string_vec(
205    cache: &OnceLock<Vec<String>>,
206    values: fn() -> &'static [&'static str],
207) -> Vec<String> {
208    cache.get_or_init(|| string_vec(values())).clone()
209}
210
211uniffi::setup_scaffolding!();
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde_json::Value;
217
218    fn engine() -> Arc<MermanEngine> {
219        MermanEngine::new()
220    }
221
222    #[test]
223    fn engine_renders_svg() {
224        let svg = engine()
225            .render_svg("flowchart TD\nA[Hello] --> B[World]".to_string(), None)
226            .unwrap();
227
228        assert!(svg.contains("<svg"));
229        assert!(svg.contains("Hello"));
230        assert!(svg.contains("World"));
231    }
232
233    #[test]
234    fn engine_exposes_versions() {
235        let engine = engine();
236
237        assert_eq!(engine.abi_version(), MERMAN_UNIFFI_ABI_VERSION);
238        assert_eq!(engine.package_version(), env!("CARGO_PKG_VERSION"));
239    }
240
241    #[test]
242    fn engine_accepts_options_json() {
243        let svg = engine()
244            .render_svg(
245                "flowchart TD\nA[Hello]".to_string(),
246                Some(
247                    r#"{
248                        "layout": { "text_measurer": "deterministic" },
249                        "svg": { "diagram_id": "uniffi diagram", "pipeline": "readable" }
250                    }"#
251                    .to_string(),
252                ),
253            )
254            .unwrap();
255
256        assert!(svg.contains("id=\"uniffi-diagram\""));
257        assert!(svg.contains("data-merman-foreignobject"));
258    }
259
260    #[test]
261    fn engine_renders_ascii() {
262        let text = engine()
263            .render_ascii("flowchart TD\nA[Hello] --> B[World]".to_string(), None)
264            .unwrap();
265
266        assert!(text.contains("Hello"));
267        assert!(text.contains("World"));
268    }
269
270    #[test]
271    fn engine_returns_semantic_json() {
272        let json: Value = serde_json::from_str(
273            &engine()
274                .parse_json("flowchart TD\nA[Hello] --> B[World]".to_string(), None)
275                .unwrap(),
276        )
277        .unwrap();
278
279        assert_eq!(
280            json.get("type").and_then(Value::as_str),
281            Some("flowchart-v2")
282        );
283    }
284
285    #[test]
286    fn engine_returns_layout_json() {
287        let json: Value = serde_json::from_str(
288            &engine()
289                .layout_json("flowchart TD\nA[Hello] --> B[World]".to_string(), None)
290                .unwrap(),
291        )
292        .unwrap();
293
294        assert!(json.get("meta").is_some());
295        assert!(json.get("layout").is_some());
296    }
297
298    #[test]
299    fn engine_validates_source() {
300        let result = engine()
301            .validate("flowchart TD\nA[Hello]".to_string(), None)
302            .unwrap();
303
304        assert!(result.valid);
305        assert_eq!(result.code_name, BindingStatus::Ok.code_name());
306
307        let result = engine().validate("".to_string(), None).unwrap();
308        assert!(!result.valid);
309        assert_eq!(result.code_name, BindingStatus::NoDiagram.code_name());
310        assert!(result.error.unwrap().contains("no Mermaid diagram"));
311    }
312
313    #[test]
314    fn engine_exposes_metadata() {
315        let engine = engine();
316
317        assert!(
318            engine
319                .supported_diagrams()
320                .contains(&"flowchart".to_string())
321        );
322        assert!(
323            engine
324                .ascii_supported_diagrams()
325                .contains(&"sequence".to_string())
326        );
327        assert!(engine.supported_themes().contains(&"default".to_string()));
328        assert!(
329            engine
330                .supported_host_theme_presets()
331                .contains(&"one-dark".to_string())
332        );
333    }
334
335    #[test]
336    fn engine_error_preserves_binding_status() {
337        let err = engine()
338            .render_svg("flowchart TD\nA".to_string(), Some("{".to_string()))
339            .unwrap_err();
340
341        let MermanError::Binding {
342            code,
343            code_name,
344            message,
345        } = err;
346        assert_eq!(code, BindingStatus::OptionsJsonError.code());
347        assert_eq!(code_name, BindingStatus::OptionsJsonError.code_name());
348        assert!(message.contains("invalid options_json"));
349    }
350}