1#[cfg(any(feature = "render", feature = "ascii"))]
2use serde::Deserialize;
3use serde::Serialize;
4
5#[repr(i32)]
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum BindingStatus {
8 Ok = 0,
9 InvalidArgument = 1,
10 Utf8Error = 2,
11 OptionsJsonError = 3,
12 NoDiagram = 4,
13 ParseError = 5,
14 RenderError = 6,
15 UnsupportedFormat = 7,
16 Panic = 8,
17 InternalError = 9,
18}
19
20impl BindingStatus {
21 pub const fn code(self) -> i32 {
22 self as i32
23 }
24
25 pub const fn code_name(self) -> &'static str {
26 match self {
27 Self::Ok => "MERMAN_OK",
28 Self::InvalidArgument => "MERMAN_INVALID_ARGUMENT",
29 Self::Utf8Error => "MERMAN_UTF8_ERROR",
30 Self::OptionsJsonError => "MERMAN_OPTIONS_JSON_ERROR",
31 Self::NoDiagram => "MERMAN_NO_DIAGRAM",
32 Self::ParseError => "MERMAN_PARSE_ERROR",
33 Self::RenderError => "MERMAN_RENDER_ERROR",
34 Self::UnsupportedFormat => "MERMAN_UNSUPPORTED_FORMAT",
35 Self::Panic => "MERMAN_PANIC",
36 Self::InternalError => "MERMAN_INTERNAL_ERROR",
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct BindingError {
43 status: BindingStatus,
44 message: String,
45}
46
47impl BindingError {
48 pub fn new(status: BindingStatus, message: impl Into<String>) -> Self {
49 Self {
50 status,
51 message: message.into(),
52 }
53 }
54
55 pub const fn status(&self) -> BindingStatus {
56 self.status
57 }
58
59 pub fn message(&self) -> &str {
60 &self.message
61 }
62}
63
64#[derive(Debug, Serialize)]
65struct ErrorPayload<'a> {
66 version: u32,
67 ok: bool,
68 code: i32,
69 code_name: &'a str,
70 message: &'a str,
71}
72
73#[derive(Debug, Serialize)]
74struct ValidationPayload<'a> {
75 valid: bool,
76 error: Option<String>,
77 code: i32,
78 code_name: &'a str,
79}
80
81#[cfg(any(feature = "render", feature = "ascii"))]
82#[derive(Debug, Default, Deserialize)]
83pub(crate) struct BindingOptions {
84 #[allow(dead_code)]
85 pub(crate) version: Option<u32>,
86 pub(crate) fixed_today: Option<String>,
87 pub(crate) fixed_local_offset_minutes: Option<i32>,
88 #[cfg(feature = "render")]
89 pub(crate) host_theme: Option<HostThemeOptionsJson>,
90 pub(crate) site_config: Option<serde_json::Value>,
91 pub(crate) parse: Option<ParseOptionsJson>,
92 #[cfg(feature = "render")]
93 pub(crate) layout: Option<LayoutOptionsJson>,
94 #[cfg(feature = "render")]
95 pub(crate) svg: Option<SvgOptionsJson>,
96}
97
98#[cfg(any(feature = "render", feature = "ascii"))]
99#[derive(Debug, Default, Deserialize)]
100pub(crate) struct ParseOptionsJson {
101 pub(crate) suppress_errors: Option<bool>,
102}
103
104#[cfg(feature = "render")]
105#[derive(Debug, Default, Deserialize)]
106pub(crate) struct LayoutOptionsJson {
107 pub(crate) viewport_width: Option<f64>,
108 pub(crate) viewport_height: Option<f64>,
109 pub(crate) text_measurer: Option<String>,
110 pub(crate) math_renderer: Option<String>,
111}
112
113#[cfg(feature = "render")]
114#[derive(Debug, Default, Deserialize)]
115pub(crate) struct SvgOptionsJson {
116 pub(crate) diagram_id: Option<String>,
117 pub(crate) pipeline: Option<String>,
118 pub(crate) scoped_css: Option<String>,
119 pub(crate) css_override_policy: Option<String>,
120 pub(crate) root_background_color: Option<String>,
121 pub(crate) drop_native_duplicate_fallbacks: Option<bool>,
122}
123
124#[cfg(feature = "render")]
125#[derive(Debug, Default, Deserialize)]
126pub(crate) struct HostThemeOptionsJson {
127 pub(crate) preset: Option<String>,
128 pub(crate) appearance: Option<String>,
129 pub(crate) font_family: Option<String>,
130 pub(crate) font_size: Option<String>,
131 pub(crate) roles: Option<HostThemeRolesJson>,
132 pub(crate) series_palette: Option<Vec<String>>,
133 pub(crate) output: Option<HostThemeOutputJson>,
134 #[serde(default, alias = "themeVariables")]
135 pub(crate) theme_variables: Option<serde_json::Map<String, serde_json::Value>>,
136 pub(crate) site_config: Option<serde_json::Value>,
137}
138
139#[cfg(feature = "render")]
140#[derive(Debug, Default, Deserialize)]
141pub(crate) struct HostThemeRolesJson {
142 pub(crate) canvas: Option<String>,
143 pub(crate) surface: Option<String>,
144 pub(crate) surface_alt: Option<String>,
145 pub(crate) surface_muted: Option<String>,
146 pub(crate) text: Option<String>,
147 pub(crate) subtle_text: Option<String>,
148 pub(crate) border: Option<String>,
149 pub(crate) line: Option<String>,
150 pub(crate) edge_label_background: Option<String>,
151 pub(crate) cluster_background: Option<String>,
152 pub(crate) cluster_border: Option<String>,
153 pub(crate) note_background: Option<String>,
154 pub(crate) note_border: Option<String>,
155 pub(crate) note_text: Option<String>,
156 pub(crate) actor_background: Option<String>,
157 pub(crate) actor_border: Option<String>,
158 pub(crate) actor_text: Option<String>,
159 pub(crate) activation_background: Option<String>,
160 pub(crate) activation_border: Option<String>,
161 pub(crate) error: Option<String>,
162 pub(crate) warning: Option<String>,
163 pub(crate) success: Option<String>,
164}
165
166#[cfg(feature = "render")]
167#[derive(Debug, Default, Deserialize)]
168pub(crate) struct HostThemeOutputJson {
169 pub(crate) pipeline: Option<String>,
170 pub(crate) css_override_policy: Option<String>,
171 pub(crate) root_background: Option<String>,
172 pub(crate) drop_native_duplicate_fallbacks: Option<bool>,
173 pub(crate) scoped_css: Option<String>,
174}
175
176pub fn error_payload_json_bytes(status: BindingStatus, message: &str) -> Vec<u8> {
177 let payload = ErrorPayload {
178 version: 1,
179 ok: false,
180 code: status.code(),
181 code_name: status.code_name(),
182 message,
183 };
184 serde_json::to_vec(&payload).unwrap_or_else(|_| {
185 format!(
186 r#"{{"version":1,"ok":false,"code":{},"code_name":"{}","message":"internal error payload serialization failed"}}"#,
187 BindingStatus::InternalError.code(),
188 BindingStatus::InternalError.code_name()
189 )
190 .into_bytes()
191 })
192}
193
194pub(crate) fn validation_payload_json(
195 result: Result<(), BindingError>,
196) -> Result<Vec<u8>, BindingError> {
197 let payload = match result {
198 Ok(()) => ValidationPayload {
199 valid: true,
200 error: None,
201 code: BindingStatus::Ok.code(),
202 code_name: BindingStatus::Ok.code_name(),
203 },
204 Err(error) => ValidationPayload {
205 valid: false,
206 error: Some(error.message().to_string()),
207 code: error.status().code(),
208 code_name: error.status().code_name(),
209 },
210 };
211 serde_json::to_vec(&payload).map_err(internal_json_error)
212}
213
214#[cfg(any(feature = "render", feature = "ascii"))]
215pub(crate) fn parse_options(bytes: &[u8]) -> Result<BindingOptions, BindingError> {
216 if bytes.is_empty() {
217 return Ok(BindingOptions::default());
218 }
219 let text = std::str::from_utf8(bytes).map_err(|err| {
220 BindingError::new(
221 BindingStatus::Utf8Error,
222 format!("invalid options_json UTF-8: {err}"),
223 )
224 })?;
225 serde_json::from_str(text).map_err(|err| {
226 BindingError::new(
227 BindingStatus::OptionsJsonError,
228 format!("invalid options_json: {err}"),
229 )
230 })
231}
232
233#[cfg(any(feature = "render", feature = "ascii"))]
234pub(crate) fn source_text(bytes: &[u8]) -> Result<&str, BindingError> {
235 let source = std::str::from_utf8(bytes).map_err(|err| {
236 BindingError::new(
237 BindingStatus::Utf8Error,
238 format!("invalid source UTF-8: {err}"),
239 )
240 })?;
241 if source.trim().is_empty() {
242 return Err(no_diagram_error());
243 }
244 Ok(source)
245}
246
247#[cfg(any(feature = "render", feature = "ascii"))]
248pub(crate) fn binding_site_config(
249 options: &BindingOptions,
250) -> Result<Option<merman::MermaidConfig>, BindingError> {
251 let Some(site_config) = options.site_config.as_ref() else {
252 return Ok(None);
253 };
254 if !site_config.is_object() {
255 return Err(BindingError::new(
256 BindingStatus::InvalidArgument,
257 "site_config must be a JSON object",
258 ));
259 }
260 Ok(Some(merman::MermaidConfig::from_value(site_config.clone())))
261}
262
263#[cfg(any(feature = "render", feature = "ascii"))]
264pub(crate) fn binding_fixed_today(
265 options: &BindingOptions,
266) -> Result<Option<chrono::NaiveDate>, BindingError> {
267 let Some(today) = options.fixed_today.as_deref() else {
268 return Ok(None);
269 };
270 chrono::NaiveDate::parse_from_str(today, "%Y-%m-%d")
271 .map(Some)
272 .map_err(|_| {
273 BindingError::new(
274 BindingStatus::InvalidArgument,
275 "fixed_today must be a date in YYYY-MM-DD format",
276 )
277 })
278}
279
280#[cfg(any(feature = "render", feature = "ascii"))]
281pub(crate) fn binding_fixed_local_offset_minutes(
282 options: &BindingOptions,
283) -> Result<Option<i32>, BindingError> {
284 let Some(offset_minutes) = options.fixed_local_offset_minutes else {
285 return Ok(None);
286 };
287 let valid = offset_minutes
288 .checked_mul(60)
289 .and_then(chrono::FixedOffset::east_opt)
290 .is_some();
291 if !valid {
292 return Err(BindingError::new(
293 BindingStatus::InvalidArgument,
294 "fixed_local_offset_minutes must be between -1439 and 1439",
295 ));
296 }
297 Ok(Some(offset_minutes))
298}
299
300#[cfg(any(feature = "render", feature = "ascii"))]
301pub(crate) fn no_diagram_error() -> BindingError {
302 BindingError::new(BindingStatus::NoDiagram, "no Mermaid diagram detected")
303}
304
305pub(crate) fn internal_json_error(err: serde_json::Error) -> BindingError {
306 BindingError::new(
307 BindingStatus::InternalError,
308 format!("failed to serialize JSON output: {err}"),
309 )
310}
311
312#[cfg(feature = "render")]
313pub(crate) fn finite_positive(value: f64, name: &'static str) -> Result<f64, BindingError> {
314 if value.is_finite() && value > 0.0 {
315 Ok(value)
316 } else {
317 Err(BindingError::new(
318 BindingStatus::InvalidArgument,
319 format!("{name} must be a finite positive number"),
320 ))
321 }
322}
323
324#[cfg(feature = "render")]
325pub(crate) fn normalize_option(value: &str) -> String {
326 value.trim().to_ascii_lowercase()
327}
328
329#[cfg(feature = "render")]
330pub(crate) fn css_declaration_value(value: &str, name: &str) -> Result<String, BindingError> {
331 let trimmed = value.trim();
332 if trimmed.is_empty() {
333 return Err(BindingError::new(
334 BindingStatus::InvalidArgument,
335 format!("{name} must be a non-empty CSS value"),
336 ));
337 }
338
339 let invalid = trimmed
340 .chars()
341 .any(|ch| ch.is_control() || matches!(ch, ';' | '"' | '\'' | '<' | '>' | '{' | '}'));
342 if invalid {
343 return Err(BindingError::new(
344 BindingStatus::InvalidArgument,
345 format!("{name} must be a single CSS declaration value"),
346 ));
347 }
348
349 Ok(trimmed.to_string())
350}
351
352#[cfg(any(not(feature = "render"), not(feature = "ascii")))]
353pub(crate) fn feature_required_error(operation: &str, feature: &str) -> BindingError {
354 BindingError::new(
355 BindingStatus::UnsupportedFormat,
356 format!("{operation} requires the {feature} feature"),
357 )
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use serde_json::Value;
364
365 #[test]
366 fn error_payload_json_uses_public_code_names() {
367 let payload = error_payload_json_bytes(BindingStatus::RenderError, "failed");
368 let json: Value = serde_json::from_slice(&payload).unwrap();
369
370 assert_eq!(json["code"], BindingStatus::RenderError.code());
371 assert_eq!(json["code_name"], BindingStatus::RenderError.code_name());
372 assert_eq!(json["message"], "failed");
373 }
374}