1#![forbid(unsafe_code)]
2
3use 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}