Skip to main content

merman_bindings_core/
common.rs

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}