Skip to main content

merman_render/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Headless layout + rendering for Mermaid diagrams.
4//!
5//! This crate consumes `merman-core`'s semantic models and produces:
6//! - a layout JSON (geometry + routes)
7//! - Mermaid-like SVG output with DOM parity checks against upstream baselines
8//!
9//! This low-level crate has no default diagram families. Select `diagram-*` features or
10//! `all-diagrams` explicitly; optional layout engines and math remain independent selectors.
11//! Family-owned typed layout APIs are conditional on those selections. Use
12//! [`family::supports_diagram_type`] to inspect this renderer's local handler availability:
13//! another dependency may enable additional core parsers without enabling their renderers.
14
15#[cfg(feature = "layout-cytoscape")]
16#[cfg(feature = "diagram-architecture")]
17pub mod architecture;
18#[cfg(all(feature = "layout-cytoscape", feature = "diagram-architecture"))]
19pub(crate) mod architecture_metrics;
20#[cfg(feature = "diagram-block")]
21pub mod block;
22#[cfg(feature = "diagram-c4")]
23pub mod c4;
24#[cfg(feature = "diagram-xychart")]
25mod chart_palette;
26#[cfg(feature = "diagram-class")]
27pub mod class;
28#[cfg_attr(
29    not(feature = "all-diagrams"),
30    allow(
31        dead_code,
32        unused_imports,
33        reason = "Shared rendering utilities have different callers in each diagram selection."
34    )
35)]
36mod config;
37#[cfg(feature = "diagram-cynefin")]
38pub mod cynefin;
39#[cfg(any(
40    feature = "diagram-agentflow",
41    feature = "diagram-flowchart",
42    feature = "diagram-swimlane",
43    feature = "diagram-state"
44))]
45mod dagre;
46#[cfg(feature = "layout-elk")]
47#[cfg_attr(
48    not(feature = "all-diagrams"),
49    allow(
50        dead_code,
51        unused_imports,
52        reason = "Shared rendering utilities have different callers in each diagram selection."
53    )
54)]
55mod elk_adapter;
56#[cfg_attr(
57    not(feature = "all-diagrams"),
58    allow(
59        dead_code,
60        unused_imports,
61        reason = "Shared rendering utilities have different callers in each diagram selection."
62    )
63)]
64mod elk_edge_geometry;
65#[cfg(feature = "layout-elk")]
66#[cfg_attr(
67    not(feature = "all-diagrams"),
68    allow(
69        dead_code,
70        unused_imports,
71        reason = "Shared rendering utilities have different callers in each diagram selection."
72    )
73)]
74mod elk_feedback_edges;
75#[cfg_attr(
76    not(feature = "all-diagrams"),
77    allow(
78        dead_code,
79        unused_imports,
80        reason = "Shared rendering utilities have different callers in each diagram selection."
81    )
82)]
83mod elk_geometry;
84#[cfg(feature = "layout-elk")]
85#[cfg_attr(
86    not(feature = "all-diagrams"),
87    allow(
88        dead_code,
89        unused_imports,
90        reason = "Shared rendering utilities have different callers in each diagram selection."
91    )
92)]
93mod elk_hierarchy;
94#[cfg(feature = "layout-elk")]
95#[cfg_attr(
96    not(feature = "all-diagrams"),
97    allow(
98        dead_code,
99        unused_imports,
100        reason = "Shared rendering utilities have different callers in each diagram selection."
101    )
102)]
103mod elk_options;
104#[cfg_attr(
105    not(feature = "all-diagrams"),
106    allow(
107        dead_code,
108        unused_imports,
109        reason = "Shared rendering utilities have different callers in each diagram selection."
110    )
111)]
112mod elk_terminal_jogs;
113#[cfg_attr(
114    not(feature = "all-diagrams"),
115    allow(
116        dead_code,
117        unused_imports,
118        reason = "Shared rendering utilities have different callers in each diagram selection."
119    )
120)]
121mod entities;
122pub mod environment;
123#[cfg(feature = "diagram-er")]
124pub mod er;
125pub mod error;
126#[cfg(feature = "diagram-event-modeling")]
127pub mod eventmodeling;
128pub mod family;
129#[cfg(any(
130    feature = "diagram-flowchart",
131    feature = "diagram-swimlane",
132    feature = "diagram-agentflow"
133))]
134pub mod flowchart;
135#[cfg(feature = "diagram-gantt")]
136pub mod gantt;
137mod generated;
138#[cfg(feature = "diagram-git-graph")]
139pub mod gitgraph;
140#[cfg(any(
141    feature = "diagram-agentflow",
142    feature = "diagram-class",
143    feature = "diagram-flowchart",
144    feature = "diagram-swimlane",
145    feature = "diagram-mindmap"
146))]
147#[cfg_attr(
148    not(feature = "all-diagrams"),
149    allow(
150        dead_code,
151        unused_imports,
152        reason = "Shared rendering utilities have different callers in each diagram selection."
153    )
154)]
155mod graph_label;
156#[cfg(feature = "diagram-info")]
157pub mod info;
158#[cfg(feature = "diagram-ishikawa")]
159pub mod ishikawa;
160#[cfg(feature = "diagram-journey")]
161pub mod journey;
162#[cfg(feature = "diagram-kanban")]
163pub mod kanban;
164#[cfg_attr(
165    not(feature = "all-diagrams"),
166    allow(
167        dead_code,
168        unused_imports,
169        reason = "Shared rendering utilities have different callers in each diagram selection."
170    )
171)]
172mod layout_backend;
173#[cfg_attr(
174    not(feature = "all-diagrams"),
175    allow(
176        dead_code,
177        unused_imports,
178        reason = "Shared rendering utilities have different callers in each diagram selection."
179    )
180)]
181mod layout_work;
182#[cfg_attr(
183    not(feature = "all-diagrams"),
184    allow(
185        dead_code,
186        unused_imports,
187        reason = "Shared rendering utilities have different callers in each diagram selection."
188    )
189)]
190pub mod math;
191#[cfg_attr(
192    not(feature = "all-diagrams"),
193    allow(
194        dead_code,
195        unused_imports,
196        reason = "Shared rendering utilities have different callers in each diagram selection."
197    )
198)]
199mod mermaid_style;
200#[cfg(feature = "diagram-mindmap")]
201pub mod mindmap;
202pub mod model;
203#[cfg(feature = "diagram-packet")]
204pub mod packet;
205#[cfg(feature = "diagram-pie")]
206pub mod pie;
207pub mod presentation;
208#[cfg(feature = "diagram-quadrant-chart")]
209pub mod quadrantchart;
210#[cfg(feature = "diagram-radar")]
211pub mod radar;
212#[cfg(feature = "diagram-railroad")]
213pub mod railroad;
214#[cfg(feature = "diagram-requirement")]
215pub mod requirement;
216pub mod resources;
217#[cfg(feature = "diagram-sankey")]
218pub mod sankey;
219#[cfg(feature = "diagram-sequence")]
220pub mod sequence;
221#[cfg(feature = "diagram-state")]
222pub mod state;
223pub mod svg;
224#[cfg(any(
225    feature = "diagram-flowchart",
226    feature = "diagram-swimlane",
227    feature = "diagram-mindmap"
228))]
229pub mod swimlane;
230#[cfg_attr(
231    not(feature = "all-diagrams"),
232    allow(
233        dead_code,
234        unused_imports,
235        reason = "Shared rendering utilities have different callers in each diagram selection."
236    )
237)]
238pub mod text;
239#[cfg_attr(
240    not(feature = "all-diagrams"),
241    allow(
242        dead_code,
243        unused_imports,
244        reason = "Shared rendering utilities have different callers in each diagram selection."
245    )
246)]
247mod theme;
248#[cfg(feature = "diagram-timeline")]
249pub mod timeline;
250#[cfg(feature = "diagram-tree-view")]
251pub mod tree_view;
252#[cfg(feature = "diagram-treemap")]
253pub mod treemap;
254
255#[cfg(feature = "diagram-usecase")]
256pub mod usecase;
257#[cfg(feature = "diagram-venn")]
258pub mod venn;
259#[cfg(feature = "diagram-wardley")]
260pub mod wardley;
261#[cfg_attr(
262    not(feature = "all-diagrams"),
263    allow(
264        dead_code,
265        unused_imports,
266        reason = "Shared rendering utilities have different callers in each diagram selection."
267    )
268)]
269mod xml;
270#[cfg(feature = "diagram-xychart")]
271pub mod xychart;
272#[cfg(feature = "diagram-zenuml")]
273pub mod zenuml;
274
275/// Reports whether the Cytoscape-derived layout backend is present in this compiled renderer.
276pub const fn layout_cytoscape_available() -> bool {
277    cfg!(feature = "layout-cytoscape")
278}
279
280/// Reports whether the ELK layout backend is present in this compiled renderer.
281pub const fn layout_elk_available() -> bool {
282    cfg!(feature = "layout-elk")
283}
284
285/// Reports whether the built-in math renderer is present in this compiled renderer.
286pub const fn math_available() -> bool {
287    cfg!(feature = "math")
288}
289
290/// Optional renderer capabilities that a typed diagram operation may require.
291#[non_exhaustive]
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
293pub enum RenderCapability {
294    LayoutCytoscape,
295    LayoutElk,
296    Math,
297}
298
299impl RenderCapability {
300    const fn bit(self) -> u8 {
301        match self {
302            Self::LayoutCytoscape => 1 << 0,
303            Self::LayoutElk => 1 << 1,
304            Self::Math => 1 << 2,
305        }
306    }
307
308    pub const fn id(self) -> &'static str {
309        match self {
310            Self::LayoutCytoscape => "layout-cytoscape",
311            Self::LayoutElk => "layout-elk",
312            Self::Math => "math",
313        }
314    }
315}
316
317const ALL_RENDER_CAPABILITY_BITS: u8 = RenderCapability::LayoutCytoscape.bit()
318    | RenderCapability::LayoutElk.bit()
319    | RenderCapability::Math.bit();
320
321/// Operation-level permission for optional renderer capabilities.
322///
323/// The policy does not claim that a backend is compiled or that a service is installed. Effective
324/// availability requires both this permission and the concrete backend/service. The unrestricted
325/// default preserves the direct Rust renderer API, while artifact facades can project their exact
326/// owner-selected capability contract without depending on binding-specific types.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub struct RenderCapabilityPolicy {
329    allowed_bits: u8,
330}
331
332impl RenderCapabilityPolicy {
333    /// Allows every optional renderer capability that is otherwise available.
334    pub const fn unrestricted() -> Self {
335        Self {
336            allowed_bits: ALL_RENDER_CAPABILITY_BITS,
337        }
338    }
339
340    /// Denies every optional renderer capability until explicitly allowed.
341    pub const fn deny_all() -> Self {
342        Self { allowed_bits: 0 }
343    }
344
345    /// Allows one optional renderer capability.
346    #[must_use]
347    pub const fn with_allowed(mut self, capability: RenderCapability) -> Self {
348        self.allowed_bits |= capability.bit();
349        self
350    }
351
352    /// Reports whether this policy permits the capability.
353    pub const fn allows(self, capability: RenderCapability) -> bool {
354        self.allowed_bits & capability.bit() != 0
355    }
356}
357
358impl Default for RenderCapabilityPolicy {
359    fn default() -> Self {
360        Self::unrestricted()
361    }
362}
363
364impl std::fmt::Display for RenderCapability {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        f.write_str(self.id())
367    }
368}
369
370use crate::environment::{RenderSession, RoutedTextMeasurer, TextMeasurementPhase};
371use merman_core::OperationPhase;
372#[cfg(any(
373    feature = "diagram-flowchart",
374    feature = "diagram-swimlane",
375    feature = "diagram-agentflow"
376))]
377use merman_core::diagrams::flowchart::FlowchartModel;
378#[cfg(feature = "diagram-class")]
379use merman_core::models::class_diagram::ClassDiagram;
380
381pub use resources::{
382    CLI_DEFAULT_RESOURCE_PROFILE, GENERAL_BINDING_DEFAULT_RESOURCE_PROFILE, RenderResourceLimitId,
383    RenderResourcePolicy, RenderResourceProfile, RenderResourceProfileDescriptor,
384    ResourceLimitCause, ResourceLimitDescriptor, ResourceLimitExceeded, ResourceLimitId,
385    ResourceLimitOverride, ResourceLimitOverrideError, ResourceLimitPhase,
386    resource_limit_descriptors, resource_profile_descriptors,
387};
388
389#[derive(Debug, thiserror::Error)]
390#[non_exhaustive]
391pub enum Error {
392    #[error(transparent)]
393    Cancelled(#[from] merman_core::OperationCancelled),
394    #[error("unsupported diagram type for layout: {diagram_type}")]
395    UnsupportedDiagram { diagram_type: String },
396    #[error("render session lacks capability `{capability}` required by diagram `{diagram_type}`")]
397    MissingCapability {
398        capability: RenderCapability,
399        diagram_type: String,
400    },
401    #[error("invalid semantic model: {message}")]
402    InvalidModel { message: String },
403    #[error(
404        "custom JSON model `{model_name}` from {provenance:?} cannot render diagram type `{diagram_type}`"
405    )]
406    NonRenderableCustomModel {
407        diagram_type: String,
408        model_name: String,
409        provenance: merman_core::CustomJsonProvenance,
410    },
411    #[error("SVG postprocessor `{pass}` failed: {message}")]
412    SvgPostprocess { pass: String, message: String },
413    #[error("external icon output is invalid: {message}")]
414    InvalidIconOutput { message: String },
415    #[error("icon rendering failed internally: {message}")]
416    IconProcessing { message: String },
417    #[error(transparent)]
418    ResourceLimitExceeded(#[from] ResourceLimitExceeded),
419    #[error(transparent)]
420    OperationResourceTerminal(merman_core::OperationLedgerError),
421    #[error(transparent)]
422    Color(#[from] merman_core::theme_color::ColorError),
423    #[error("semantic model JSON error: {0}")]
424    Json(#[from] serde_json::Error),
425    #[error(transparent)]
426    OperationTimingUnavailable(#[from] merman_core::runtime::OperationTimingUnavailable),
427}
428
429pub type Result<T> = std::result::Result<T, Error>;
430
431impl From<dugong::LayoutError> for Error {
432    fn from(error: dugong::LayoutError) -> Self {
433        Self::InvalidModel {
434            message: error.to_string(),
435        }
436    }
437}
438
439impl From<crate::resources::OperationWorkError> for Error {
440    fn from(error: crate::resources::OperationWorkError) -> Self {
441        match error {
442            crate::resources::OperationWorkError::Cancelled(error) => Self::Cancelled(error),
443            crate::resources::OperationWorkError::ResourceLimitExceeded(error) => {
444                Self::ResourceLimitExceeded(error)
445            }
446            crate::resources::OperationWorkError::ForeignResourceTerminal(error) => {
447                Self::OperationResourceTerminal(error)
448            }
449        }
450    }
451}
452
453impl Error {
454    pub fn svg_postprocess(pass: impl Into<String>, message: impl Into<String>) -> Self {
455        Self::SvgPostprocess {
456            pass: pass.into(),
457            message: message.into(),
458        }
459    }
460
461    #[cfg_attr(
462        not(feature = "all-diagrams"),
463        allow(
464            dead_code,
465            reason = "Shared operation support has different consumers in each diagram selection."
466        )
467    )]
468    pub(crate) fn invalid_icon_output(message: impl Into<String>) -> Self {
469        Self::InvalidIconOutput {
470            message: message.into(),
471        }
472    }
473
474    #[cfg_attr(
475        not(feature = "all-diagrams"),
476        allow(
477            dead_code,
478            reason = "Shared operation support has different consumers in each diagram selection."
479        )
480    )]
481    pub(crate) fn icon_processing(message: impl Into<String>) -> Self {
482        Self::IconProcessing {
483            message: message.into(),
484        }
485    }
486
487    pub const fn missing_capability(&self) -> Option<RenderCapability> {
488        match self {
489            Self::MissingCapability { capability, .. } => Some(*capability),
490            _ => None,
491        }
492    }
493}
494
495/// Host-provided geometry available to family layout algorithms.
496///
497/// This models the element that owns diagram layout, not a browser page viewport and not the
498/// final SVG viewport emitted after layout.
499#[derive(Debug, Clone)]
500#[non_exhaustive]
501pub struct LayoutOptions {
502    /// Width of the host layout container in CSS pixels.
503    ///
504    /// Families whose Mermaid renderer reads DOM-available width use this value.
505    pub container_width: f64,
506    /// Height of the host layout container in CSS pixels.
507    pub container_height: f64,
508    /// Browser `screen.availWidth` in CSS pixels when the host has a screen environment.
509    ///
510    /// Mermaid's C4 renderer uses the available screen width rather than its container width.
511    /// `None` keeps headless rendering deterministic by falling back to `container_width`.
512    pub screen_available_width: Option<f64>,
513}
514
515impl Default for LayoutOptions {
516    fn default() -> Self {
517        Self {
518            container_width: 800.0,
519            container_height: 600.0,
520            screen_available_width: None,
521        }
522    }
523}
524
525impl LayoutOptions {
526    /// Returns geometry defaults suitable for headless SVG rendering.
527    pub fn headless_svg_defaults() -> Self {
528        Self::default()
529    }
530
531    /// Sets the host layout container dimensions in CSS pixels.
532    pub fn with_container_size(mut self, width: f64, height: f64) -> Self {
533        self.container_width = width;
534        self.container_height = height;
535        self
536    }
537
538    /// Supplies the browser `screen.availWidth` observed by the host.
539    pub fn with_screen_available_width(mut self, width: f64) -> Self {
540        self.screen_available_width = Some(width);
541        self
542    }
543}
544
545#[cfg_attr(
546    not(feature = "all-diagrams"),
547    allow(
548        dead_code,
549        reason = "Shared operation support has different consumers in each diagram selection."
550    )
551)]
552pub(crate) struct LayoutExecution<'a> {
553    request: &'a LayoutOptions,
554    session: &'a RenderSession,
555    text_measurer: RoutedTextMeasurer<'a>,
556}
557
558#[cfg_attr(
559    not(feature = "all-diagrams"),
560    allow(
561        dead_code,
562        reason = "Shared operation support has different consumers in each diagram selection."
563    )
564)]
565impl<'a> LayoutExecution<'a> {
566    pub(crate) fn new(request: &'a LayoutOptions, session: &'a RenderSession) -> Self {
567        Self {
568            request,
569            session,
570            text_measurer: session
571                .controlled_text_measurer(TextMeasurementPhase::Layout, OperationPhase::Layout),
572        }
573    }
574
575    pub(crate) fn text_measurer(&self) -> &dyn crate::text::TextMeasurer {
576        &self.text_measurer
577    }
578
579    pub(crate) fn math_renderer(&self) -> Option<&(dyn crate::math::MathRenderer + Send + Sync)> {
580        self.session.math_renderer()
581    }
582
583    pub(crate) fn work_meter(&self) -> std::sync::Arc<crate::resources::OperationWorkMeter> {
584        std::sync::Arc::clone(self.session.work_meter())
585    }
586
587    pub(crate) fn work_meter_ref(&self) -> &crate::resources::OperationWorkMeter {
588        self.session.work_meter().as_ref()
589    }
590
591    pub(crate) fn local_time_zone(&self) -> &merman_core::time::LocalTimeZone {
592        self.session.local_time_zone()
593    }
594
595    #[cfg(feature = "layout-cytoscape")]
596    pub(crate) fn operation_seed(&self) -> u64 {
597        self.session.render_seed().get()
598    }
599
600    #[cfg(feature = "layout-elk")]
601    pub(crate) fn elk_operation_seed(&self) -> merman_layout_elk::ElkOperationSeed {
602        // The ELK source port applies its own stable ELK-specific domain and graph-path
603        // derivation. This token merely keeps every ELK random boundary tied to one immutable
604        // render operation.
605        merman_layout_elk::ElkOperationSeed::from_operation_seed(self.session.render_seed())
606    }
607}
608
609impl std::ops::Deref for LayoutExecution<'_> {
610    type Target = LayoutOptions;
611
612    fn deref(&self) -> &Self::Target {
613        self.request
614    }
615}
616
617#[cfg(feature = "diagram-class")]
618pub(crate) fn layout_class_typed_by_engine(
619    model: &ClassDiagram,
620    effective_config: &merman_core::MermaidConfig,
621    options: &LayoutExecution<'_>,
622) -> Result<model::ClassDiagramLayout> {
623    options
624        .work_meter_ref()
625        .preflight_class_complexity(model, OperationPhase::Layout)?;
626    let mut work_control = layout_work::OperationLayoutWorkControl::new(options.work_meter());
627    let preparation_work = class::class_layout_work_units(model, &work_control)?;
628    work_control.charge_adapter(preparation_work)?;
629    let selection = layout_backend::resolve_graph_layout(effective_config.as_value());
630    selection.validate_rootless_graph()?;
631    #[cfg(feature = "layout-elk")]
632    if selection.backend == layout_backend::GraphLayoutBackend::Elk {
633        return class::layout_class_diagram_elk_typed_with_config_and_operation_seed(
634            model,
635            effective_config,
636            options.text_measurer(),
637            options.math_renderer(),
638            options.elk_operation_seed(),
639            &mut work_control,
640        );
641    }
642    class::layout_class_diagram_typed_with_config(
643        model,
644        effective_config,
645        options.text_measurer(),
646        options.math_renderer(),
647        &mut work_control,
648    )
649}
650
651#[cfg(all(test, feature = "layout-elk"))]
652#[cfg(any(
653    feature = "diagram-flowchart",
654    feature = "diagram-swimlane",
655    feature = "diagram-agentflow"
656))]
657pub(crate) fn layout_flowchart_typed_by_engine(
658    model: &FlowchartModel,
659    effective_config: &merman_core::MermaidConfig,
660    options: &LayoutExecution<'_>,
661) -> Result<model::FlowchartLayout> {
662    layout_flowchart_typed_with_render_labels_by_engine(
663        model,
664        &merman_core::diagrams::flowchart::FlowchartRenderContext::default(),
665        effective_config,
666        options,
667        None,
668    )
669}
670
671#[cfg(any(
672    feature = "diagram-flowchart",
673    feature = "diagram-swimlane",
674    feature = "diagram-agentflow"
675))]
676pub(crate) fn layout_flowchart_typed_with_render_labels_by_engine(
677    model: &FlowchartModel,
678    render_label_sources: &merman_core::diagrams::flowchart::FlowchartRenderContext,
679    effective_config: &merman_core::MermaidConfig,
680    options: &LayoutExecution<'_>,
681    svg_label_sidecar: Option<&flowchart::FlowchartSvgLabelSidecarBuilder>,
682) -> Result<model::FlowchartLayout> {
683    options.session.checkpoint(OperationPhase::Layout)?;
684    let selection = layout_backend::resolve_graph_layout(effective_config.as_value());
685    selection.validate_rootless_graph()?;
686    #[cfg(feature = "layout-elk")]
687    if selection.backend == layout_backend::GraphLayoutBackend::Elk {
688        return flowchart::elk::layout_flowchart_elk_typed_with_render_labels_and_operation_seed(
689            model,
690            render_label_sources,
691            effective_config,
692            flowchart::elk::FlowchartElkLayoutExecution::new(
693                options.text_measurer(),
694                options.math_renderer(),
695                options.elk_operation_seed(),
696                svg_label_sidecar,
697                options.work_meter(),
698            ),
699        );
700    }
701
702    flowchart::layout_flowchart_typed_with_render_labels_and_work_meter_and_svg_label_sidecar(
703        model,
704        render_label_sources,
705        effective_config,
706        options.text_measurer(),
707        options.math_renderer(),
708        svg_label_sidecar,
709        options.work_meter(),
710    )
711}
712
713#[cfg(all(test, feature = "all-diagrams"))]
714mod tests {
715    use super::*;
716    #[cfg(feature = "layout-elk")]
717    use merman_core::ParsedDiagramRender;
718    #[cfg(feature = "layout-elk")]
719    use merman_core::RenderSemanticModel;
720    use merman_core::{Engine, ParseOptions};
721
722    #[test]
723    fn render_capability_ids_and_error_accessor_are_stable() {
724        for (capability, stable_id) in [
725            (RenderCapability::LayoutCytoscape, "layout-cytoscape"),
726            (RenderCapability::LayoutElk, "layout-elk"),
727            (RenderCapability::Math, "math"),
728        ] {
729            assert_eq!(capability.id(), stable_id);
730            assert_eq!(capability.to_string(), stable_id);
731
732            let error = Error::MissingCapability {
733                capability,
734                diagram_type: "contract-test".to_string(),
735            };
736            assert_eq!(error.missing_capability(), Some(capability));
737            assert_eq!(
738                error.to_string(),
739                format!(
740                    "render session lacks capability `{stable_id}` required by diagram `contract-test`"
741                )
742            );
743        }
744    }
745
746    #[test]
747    fn render_capability_policy_is_an_explicit_allow_mask() {
748        let denied = RenderCapabilityPolicy::deny_all();
749        for capability in [
750            RenderCapability::LayoutCytoscape,
751            RenderCapability::LayoutElk,
752            RenderCapability::Math,
753        ] {
754            assert!(!denied.allows(capability));
755            assert!(
756                RenderCapabilityPolicy::unrestricted().allows(capability),
757                "unrestricted policy denied {}",
758                capability.id()
759            );
760        }
761
762        let math_only = denied.with_allowed(RenderCapability::Math);
763        assert!(math_only.allows(RenderCapability::Math));
764        assert!(!math_only.allows(RenderCapability::LayoutCytoscape));
765        assert!(!math_only.allows(RenderCapability::LayoutElk));
766    }
767
768    #[cfg(feature = "layout-elk")]
769    fn flowchart_layout(
770        parsed: &ParsedDiagramRender,
771        options: &LayoutOptions,
772        session: &RenderSession,
773    ) -> model::FlowchartLayout {
774        let RenderSemanticModel::Flowchart(model) = parsed.model() else {
775            panic!("expected flowchart render model");
776        };
777        layout_flowchart_typed_by_engine(
778            model,
779            &parsed.metadata().effective_config,
780            &LayoutExecution::new(options, session),
781        )
782        .expect("flowchart layout")
783    }
784
785    #[cfg(feature = "layout-elk")]
786    fn class_layout(
787        parsed: &ParsedDiagramRender,
788        options: &LayoutOptions,
789        session: &RenderSession,
790    ) -> model::ClassDiagramLayout {
791        let RenderSemanticModel::Class(model) = parsed.model() else {
792            panic!("expected class render model");
793        };
794        layout_class_typed_by_engine(
795            model,
796            &parsed.metadata().effective_config,
797            &LayoutExecution::new(options, session),
798        )
799        .expect("class layout")
800    }
801
802    fn render_source(
803        source: &str,
804        layout_options: &LayoutOptions,
805        svg_options: &crate::svg::SvgRenderOptions,
806    ) -> String {
807        let parsed = Engine::new()
808            .parse_diagram_for_render_model_sync(source, ParseOptions::strict())
809            .expect("parse")
810            .expect("diagram");
811        let session = crate::environment::RenderEnvironment::deterministic()
812            .begin_session()
813            .unwrap();
814        crate::family::prepare(parsed, layout_options, session)
815            .expect("prepare")
816            .render_svg(svg_options, &crate::svg::SvgDebugOptions::default())
817            .expect("render")
818            .svg()
819            .to_owned()
820    }
821
822    #[cfg(feature = "layout-elk")]
823    #[test]
824    fn elk_operation_seed_is_captured_once_per_render_operation() {
825        fn capture(seed: u64) -> merman_layout_elk::ElkOperationSeed {
826            let session = crate::environment::RenderEnvironment::deterministic()
827                .with_runtime_policy(
828                    merman_core::runtime::RuntimePolicy::deterministic().with_fixed_seed(seed),
829                )
830                .begin_session()
831                .expect("render session");
832            LayoutExecution::new(&LayoutOptions::default(), &session).elk_operation_seed()
833        }
834
835        assert_eq!(capture(17), capture(17));
836        assert_ne!(capture(17), capture(18));
837    }
838
839    #[cfg(feature = "layout-elk")]
840    #[test]
841    fn render_model_dispatch_accepts_diagram_type_aliases() {
842        let session = crate::environment::RenderEnvironment::deterministic()
843            .begin_session()
844            .unwrap();
845        let parsed = Engine::new()
846            .parse_diagram_for_render_model_with_type_sync(
847                "flowchart-elk",
848                "flowchart-elk TD\nA-->B;",
849                ParseOptions::strict(),
850            )
851            .unwrap()
852            .unwrap();
853
854        let artifact = crate::family::prepare(parsed, &LayoutOptions::default(), session).unwrap();
855        assert_eq!(
856            artifact.family_kind(),
857            crate::family::RenderFamilyKind::Flowchart
858        );
859    }
860
861    #[cfg(feature = "layout-elk")]
862    #[test]
863    fn render_model_dispatch_uses_elk_for_scoped_flowchart_layout() {
864        let session = crate::environment::RenderEnvironment::deterministic()
865            .begin_session()
866            .unwrap();
867        let parsed = Engine::new()
868            .parse_diagram_for_render_model_sync(
869                r#"---
870config:
871  flowchart:
872    layout: elk
873---
874flowchart TD
875A-->B
876"#,
877                ParseOptions::strict(),
878            )
879            .unwrap()
880            .unwrap();
881
882        assert_eq!(parsed.metadata().diagram_type, "flowchart-v2");
883        let layout = flowchart_layout(&parsed, &LayoutOptions::default(), &session);
884        assert!(layout.uses_elk_adapter_dom);
885        let a = layout.nodes.iter().find(|node| node.id == "A").unwrap();
886        let b = layout.nodes.iter().find(|node| node.id == "B").unwrap();
887        assert!(b.y > a.y);
888    }
889
890    #[cfg(feature = "layout-elk")]
891    #[test]
892    fn render_model_dispatch_rejects_flowchart_over_node_resource_limit() {
893        let parsed = Engine::new()
894            .parse_diagram_for_render_model_with_type_sync(
895                "flowchart-elk",
896                "flowchart-elk TD\nA-->B;",
897                ParseOptions::strict(),
898            )
899            .unwrap()
900            .unwrap();
901        let options = LayoutOptions::default();
902        let session = crate::environment::RenderEnvironment::deterministic()
903            .with_resource_policy(
904                RenderResourcePolicy::unbounded_for_trusted_input()
905                    .with_limit(ResourceLimitId::MaxModelItems, 1)
906                    .unwrap(),
907            )
908            .begin_session()
909            .unwrap();
910
911        let err = match crate::family::prepare(parsed, &options, session) {
912            Err(error) => error,
913            Ok(_) => panic!("expected resource limit error"),
914        };
915
916        let Error::ResourceLimitExceeded(limit) = err else {
917            panic!("expected resource limit error");
918        };
919        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
920        assert_eq!(limit.limit, "max_model_items");
921    }
922
923    #[test]
924    fn render_model_dispatch_rejects_flowchart_dagre_work_during_its_first_owner_phase() {
925        let parsed = Engine::new()
926            .parse_diagram_for_render_model_sync(
927                "flowchart TD\nsubgraph Cluster\nA\nend\nA-->B",
928                ParseOptions::strict(),
929            )
930            .unwrap()
931            .unwrap();
932        let session = crate::environment::RenderEnvironment::deterministic()
933            .with_resource_policy(
934                RenderResourcePolicy::unbounded_for_trusted_input()
935                    .with_limit(ResourceLimitId::MaxLayoutWorkUnits, 1)
936                    .unwrap(),
937            )
938            .begin_session()
939            .unwrap();
940
941        let err = match crate::family::prepare(parsed, &LayoutOptions::default(), session) {
942            Err(error) => error,
943            Ok(_) => panic!("expected flowchart layout work resource limit"),
944        };
945
946        let Error::ResourceLimitExceeded(limit) = err else {
947            panic!("expected resource limit error");
948        };
949        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
950        assert_eq!(limit.limit, "max_layout_work_units");
951        assert!(limit.actual > 1);
952    }
953
954    fn assert_class_layout_work_limit(source: &str) {
955        let parsed = Engine::new()
956            .parse_diagram_for_render_model_sync(source, ParseOptions::strict())
957            .unwrap()
958            .expect("class source should produce a render model");
959        let session = crate::environment::RenderEnvironment::deterministic()
960            .with_resource_policy(
961                RenderResourcePolicy::unbounded_for_trusted_input()
962                    .with_limit(ResourceLimitId::MaxLayoutWorkUnits, 1)
963                    .unwrap(),
964            )
965            .begin_session()
966            .unwrap();
967
968        let error = match crate::family::prepare(parsed, &LayoutOptions::default(), session) {
969            Err(error) => error,
970            Ok(_) => panic!("Class layout unexpectedly bypassed the work budget"),
971        };
972        let Error::ResourceLimitExceeded(limit) = error else {
973            panic!("expected Class layout work resource limit error");
974        };
975        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
976        assert_eq!(limit.limit, "max_layout_work_units");
977        assert!(limit.actual > 1);
978        assert_eq!(limit.max, 1);
979    }
980
981    #[test]
982    fn dagre_class_layout_honors_the_public_work_budget() {
983        assert_class_layout_work_limit("classDiagram\nA --> B\nA --> C\nA --> D\nB --> C\nC --> D");
984    }
985
986    #[cfg(feature = "layout-elk")]
987    #[test]
988    fn elk_class_layout_honors_the_public_work_budget() {
989        assert_class_layout_work_limit(
990            r#"---
991config:
992  layout: elk
993---
994classDiagram
995A --> B
996A --> C
997A --> D
998B --> C
999C --> D"#,
1000        );
1001    }
1002
1003    #[cfg(feature = "layout-elk")]
1004    #[test]
1005    fn render_model_dispatch_uses_elk_for_class_layout_config() {
1006        let session = crate::environment::RenderEnvironment::deterministic()
1007            .begin_session()
1008            .unwrap();
1009        let parsed = Engine::new()
1010            .parse_diagram_for_render_model_sync(
1011                r#"---
1012config:
1013  layout: elk
1014---
1015classDiagram
1016direction LR
1017Animal <|-- Duck
1018"#,
1019                ParseOptions::strict(),
1020            )
1021            .unwrap()
1022            .unwrap();
1023
1024        assert_eq!(parsed.metadata().diagram_type, "classDiagram");
1025        let layout = class_layout(&parsed, &LayoutOptions::default(), &session);
1026        let animal = layout
1027            .nodes
1028            .iter()
1029            .find(|node| node.id == "Animal")
1030            .unwrap();
1031        let duck = layout.nodes.iter().find(|node| node.id == "Duck").unwrap();
1032        assert!(
1033            duck.x > animal.x,
1034            "ELK LR class layout should place Duck to the right of Animal; Animal={}, Duck={}",
1035            animal.x,
1036            duck.x
1037        );
1038    }
1039
1040    #[cfg(feature = "layout-elk")]
1041    #[test]
1042    fn render_model_dispatch_rejects_class_over_node_resource_limit() {
1043        let parsed = Engine::new()
1044            .parse_diagram_for_render_model_sync(
1045                "classDiagram\nAnimal <|-- Duck",
1046                ParseOptions::strict(),
1047            )
1048            .unwrap()
1049            .unwrap();
1050        let options = LayoutOptions::default();
1051        let session = crate::environment::RenderEnvironment::deterministic()
1052            .with_resource_policy(
1053                RenderResourcePolicy::unbounded_for_trusted_input()
1054                    .with_limit(ResourceLimitId::MaxModelItems, 1)
1055                    .unwrap(),
1056            )
1057            .begin_session()
1058            .unwrap();
1059
1060        let err = match crate::family::prepare(parsed, &options, session) {
1061            Err(error) => error,
1062            Ok(_) => panic!("expected resource limit error"),
1063        };
1064
1065        let Error::ResourceLimitExceeded(limit) = err else {
1066            panic!("expected resource limit error");
1067        };
1068        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
1069        assert_eq!(limit.limit, "max_model_items");
1070    }
1071
1072    #[cfg(feature = "layout-elk")]
1073    #[test]
1074    fn typed_dispatch_rejects_flowchart_over_edge_resource_limit() {
1075        let parsed = Engine::new()
1076            .parse_diagram_for_render_model_sync(
1077                "flowchart TD\nA-->B\nB-->C\nC-->D",
1078                ParseOptions::strict(),
1079            )
1080            .unwrap()
1081            .unwrap();
1082        let options = LayoutOptions::default();
1083        let session = crate::environment::RenderEnvironment::deterministic()
1084            .with_resource_policy(
1085                RenderResourcePolicy::unbounded_for_trusted_input()
1086                    .with_limit(ResourceLimitId::MaxModelItems, 2)
1087                    .unwrap(),
1088            )
1089            .begin_session()
1090            .unwrap();
1091
1092        let err = match crate::family::prepare(parsed, &options, session) {
1093            Err(error) => error,
1094            Ok(_) => panic!("expected resource limit error"),
1095        };
1096
1097        let Error::ResourceLimitExceeded(limit) = err else {
1098            panic!("expected resource limit error");
1099        };
1100        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
1101        assert_eq!(limit.limit, "max_model_items");
1102    }
1103
1104    #[cfg(feature = "layout-elk")]
1105    #[test]
1106    fn typed_dispatch_rejects_class_over_edge_resource_limit() {
1107        let parsed = Engine::new()
1108            .parse_diagram_for_render_model_sync(
1109                "classDiagram\nAnimal <|-- Duck\nDuck <|-- Mallard",
1110                ParseOptions::strict(),
1111            )
1112            .unwrap()
1113            .unwrap();
1114        let options = LayoutOptions::default();
1115        let session = crate::environment::RenderEnvironment::deterministic()
1116            .with_resource_policy(
1117                RenderResourcePolicy::unbounded_for_trusted_input()
1118                    .with_limit(ResourceLimitId::MaxModelItems, 1)
1119                    .unwrap(),
1120            )
1121            .begin_session()
1122            .unwrap();
1123
1124        let err = match crate::family::prepare(parsed, &options, session) {
1125            Err(error) => error,
1126            Ok(_) => panic!("expected resource limit error"),
1127        };
1128
1129        let Error::ResourceLimitExceeded(limit) = err else {
1130            panic!("expected resource limit error");
1131        };
1132        assert_eq!(limit.phase, ResourceLimitPhase::LayoutModel);
1133        assert_eq!(limit.limit, "max_model_items");
1134    }
1135
1136    #[cfg(feature = "layout-elk")]
1137    #[test]
1138    fn canonical_svg_preserves_flowchart_elk_roledescription() {
1139        let svg = render_source(
1140            "flowchart-elk TD\nA-->B;",
1141            &LayoutOptions::default(),
1142            &crate::svg::SvgRenderOptions {
1143                diagram_id: Some("elk-smoke".to_string()),
1144                ..Default::default()
1145            },
1146        );
1147
1148        assert!(svg.contains(r#"aria-roledescription="flowchart-elk""#));
1149        assert!(svg.contains("elk-smoke_flowchart-elk-pointEnd"));
1150        assert!(!svg.contains(r#"aria-roledescription="flowchart-v2""#));
1151        assert!(svg.contains(r#"<g class="root""#));
1152
1153        let marker_pos = svg
1154            .find(r#"<g><marker id="elk-smoke_flowchart-elk-pointEnd""#)
1155            .expect("ELK marker group");
1156        let root_pos = svg.find(r#"<g class="root">"#).expect("ELK root group");
1157        let clusters_pos = svg
1158            .find(r#"<g class="clusters"/>"#)
1159            .expect("ELK clusters group");
1160        let edges_pos = svg
1161            .find(r#"<g class="edges edgePaths">"#)
1162            .expect("ELK edge paths group");
1163        let labels_pos = svg
1164            .find(r#"<g class="edgeLabels">"#)
1165            .expect("ELK edge labels group");
1166        let nodes_pos = svg.find(r#"<g class="nodes">"#).expect("ELK nodes group");
1167        let defs_pos = svg
1168            .find(r#"<defs><filter id="elk-smoke-drop-shadow""#)
1169            .expect("ELK shadow defs");
1170
1171        assert!(marker_pos < root_pos);
1172        assert!(root_pos < clusters_pos);
1173        assert!(clusters_pos < edges_pos);
1174        assert!(edges_pos < labels_pos);
1175        assert!(labels_pos < nodes_pos);
1176        assert!(nodes_pos < defs_pos);
1177    }
1178
1179    #[cfg(feature = "layout-elk")]
1180    #[test]
1181    fn canonical_svg_uses_elk_adapter_dom_for_flowchart_layout_elk() {
1182        let svg = render_source(
1183            r#"---
1184config:
1185  layout: elk
1186---
1187flowchart LR
1188A{A} --> B & C
1189"#,
1190            &LayoutOptions::default(),
1191            &crate::svg::SvgRenderOptions {
1192                diagram_id: Some("layout-elk-smoke".to_string()),
1193                ..Default::default()
1194            },
1195        );
1196
1197        assert!(svg.contains(r#"aria-roledescription="flowchart-v2""#));
1198        assert!(svg.contains("layout-elk-smoke_flowchart-v2-pointEnd"));
1199        assert!(svg.contains(r#"<g class="root""#));
1200
1201        let marker_pos = svg
1202            .find(r#"<g><marker id="layout-elk-smoke_flowchart-v2-pointEnd""#)
1203            .expect("ELK marker group");
1204        let root_pos = svg.find(r#"<g class="root">"#).expect("ELK root group");
1205        let clusters_pos = svg
1206            .find(r#"<g class="clusters"/>"#)
1207            .expect("ELK clusters group");
1208        let edges_pos = svg
1209            .find(r#"<g class="edges edgePaths">"#)
1210            .expect("ELK edge paths group");
1211        let labels_pos = svg
1212            .find(r#"<g class="edgeLabels">"#)
1213            .expect("ELK edge labels group");
1214        let nodes_pos = svg.find(r#"<g class="nodes">"#).expect("ELK nodes group");
1215        let defs_pos = svg
1216            .find(r#"<defs><filter id="layout-elk-smoke-drop-shadow""#)
1217            .expect("ELK shadow defs");
1218
1219        assert!(marker_pos < root_pos);
1220        assert!(root_pos < clusters_pos);
1221        assert!(clusters_pos < edges_pos);
1222        assert!(edges_pos < labels_pos);
1223        assert!(labels_pos < nodes_pos);
1224        assert!(nodes_pos < defs_pos);
1225    }
1226
1227    #[cfg(feature = "layout-elk")]
1228    #[test]
1229    fn canonical_svg_uses_right_angle_edges_for_flowchart_elk() {
1230        let svg = render_source(
1231            "flowchart-elk LR\nA --> B\nA --> C",
1232            &LayoutOptions::default(),
1233            &crate::svg::SvgRenderOptions::default(),
1234        );
1235
1236        let path = edge_path_chunk(&svg, "L_A_B_0");
1237        let d = edge_path_d(path);
1238        assert!(
1239            d.contains('L') && !d.contains('C'),
1240            "expected ELK edges to use right-angle paths without smooth curves by default: {d}"
1241        );
1242    }
1243
1244    #[cfg(feature = "layout-elk")]
1245    #[test]
1246    fn canonical_svg_keeps_source_ported_elk_rect_edge_boundary_points() {
1247        let svg = render_source(
1248            r#"---
1249config:
1250  htmlLabels: true
1251  flowchart:
1252    htmlLabels: true
1253  securityLevel: loose
1254---
1255flowchart-elk LR
1256id1(Start)-->id2(Stop)
1257"#,
1258            &LayoutOptions::default(),
1259            &crate::svg::SvgRenderOptions::default(),
1260        );
1261
1262        let path = edge_path_chunk(&svg, "L_id1_id2_0");
1263        let d = edge_path_d(path);
1264        assert!(
1265            !d.contains('Q'),
1266            "straight ELK roundedRect edge should not gain a rounded corner: {d}"
1267        );
1268        let points = edge_data_points(path);
1269        assert_eq!(
1270            points.len(),
1271            2,
1272            "unexpected ELK edge data-points: {points:?}"
1273        );
1274        assert_eq!(points[0].1, points[1].1);
1275        assert!(
1276            points[0].0 < points[1].0,
1277            "ELK edge must travel left to right: {points:?}"
1278        );
1279        assert!(
1280            (points[1].0 - points[0].0 - 40.0).abs() < 1.0e-9,
1281            "ELK straight edge must retain the source-backed node span: {points:?}"
1282        );
1283    }
1284
1285    #[cfg(feature = "layout-elk")]
1286    #[test]
1287    fn canonical_svg_keeps_source_ported_elk_self_loop_edges() {
1288        let svg = render_source(
1289            "flowchart-elk TD\nA --> A",
1290            &LayoutOptions::default(),
1291            &crate::svg::SvgRenderOptions::default(),
1292        );
1293
1294        let path = edge_path_chunk(&svg, "L_A_A_0");
1295        let d = edge_path_d(path);
1296        assert!(
1297            d.contains('Q'),
1298            "ELK self-loop path should be rendered from the source-backed edge: {d}"
1299        );
1300        let points = edge_data_points(path);
1301        assert_eq!(
1302            points.len(),
1303            4,
1304            "unexpected ELK self-loop data-points: {points:?}"
1305        );
1306        assert!(
1307            !svg.contains("A---A---1") && !svg.contains("cyclic-special"),
1308            "ELK renderer must not reuse Dagre self-loop helper nodes: {svg}"
1309        );
1310        assert_eq!(edge_attr_value(path, "data-id"), "L_A_A_0");
1311    }
1312
1313    #[test]
1314    fn graph_layout_unregistered_loader_falls_back_to_dagre() {
1315        let mut requests = vec!["unknown", "ELK", "elk.layered", "elk.radial"];
1316        if !cfg!(feature = "layout-elk") {
1317            requests.extend([
1318                "elk",
1319                "elk.stress",
1320                "elk.force",
1321                "elk.mrtree",
1322                "elk.sporeOverlap",
1323                "elk.box",
1324                "elk.rectpacking",
1325            ]);
1326        }
1327        for body in [
1328            "flowchart TD\nA[Alpha] --> B[Beta]\nB --> C[Gamma]",
1329            "classDiagram\nAnimal <|-- Duck",
1330            "erDiagram\nCUSTOMER ||--o{ ORDER : places",
1331        ] {
1332            let render = |layout: &str| {
1333                let source = format!("---\nconfig:\n  layout: {layout}\n---\n{body}\n");
1334                let parsed = Engine::new()
1335                    .parse_diagram_for_render_model_sync(&source, ParseOptions::strict())
1336                    .unwrap()
1337                    .unwrap();
1338                let session = crate::environment::RenderEnvironment::deterministic()
1339                    .begin_session()
1340                    .unwrap();
1341                let plan = crate::family::plan_render(&parsed, &session).unwrap();
1342                assert!(
1343                    !plan
1344                        .required_capabilities()
1345                        .contains(&RenderCapability::LayoutElk)
1346                );
1347                assert!(plan.missing_capabilities().is_empty());
1348                let artifact = crate::family::prepare(parsed, &LayoutOptions::default(), session)
1349                    .expect("unregistered loader falls back before capability admission");
1350                let geometry = artifact.layout_json().unwrap()["layout"].clone();
1351                let svg = artifact
1352                    .render_svg(
1353                        &crate::svg::SvgRenderOptions {
1354                            diagram_id: Some("fallback-test".into()),
1355                            ..Default::default()
1356                        },
1357                        &crate::svg::SvgDebugOptions::default(),
1358                    )
1359                    .unwrap()
1360                    .svg()
1361                    .to_owned();
1362                (geometry, svg)
1363            };
1364            let expected = render("dagre");
1365            for request in &requests {
1366                assert_eq!(render(request), expected, "{request}: {body}");
1367            }
1368        }
1369    }
1370
1371    #[cfg(not(feature = "layout-elk"))]
1372    #[test]
1373    fn flowchart_elk_alias_uses_dagre_geometry_when_loader_is_absent() {
1374        let parsed = Engine::new()
1375            .parse_diagram_for_render_model_with_type_sync(
1376                "flowchart-elk",
1377                "flowchart-elk TD\nA-->B;",
1378                ParseOptions::strict(),
1379            )
1380            .unwrap()
1381            .unwrap();
1382        let session = crate::environment::RenderEnvironment::deterministic()
1383            .begin_session()
1384            .unwrap();
1385        let artifact = crate::family::prepare(parsed, &LayoutOptions::default(), session).unwrap();
1386        let geometry = artifact.layout_json().unwrap()["layout"].clone();
1387        let dagre_parsed = Engine::new()
1388            .parse_diagram_for_render_model_sync(
1389                "---\nconfig:\n  layout: dagre\n---\nflowchart TD\nA-->B;",
1390                ParseOptions::strict(),
1391            )
1392            .unwrap()
1393            .unwrap();
1394        let dagre_session = crate::environment::RenderEnvironment::deterministic()
1395            .begin_session()
1396            .unwrap();
1397        let dagre_artifact =
1398            crate::family::prepare(dagre_parsed, &LayoutOptions::default(), dagre_session).unwrap();
1399        assert_eq!(geometry, dagre_artifact.layout_json().unwrap()["layout"]);
1400        let svg = artifact
1401            .render_svg(
1402                &crate::svg::SvgRenderOptions::default(),
1403                &crate::svg::SvgDebugOptions::default(),
1404            )
1405            .unwrap()
1406            .svg()
1407            .to_owned();
1408        assert!(svg.contains("edgePath"), "{svg}");
1409    }
1410
1411    #[test]
1412    fn registered_cose_graph_failure_is_not_replaced_by_dagre() {
1413        for body in [
1414            "flowchart LR\nA-->B\nA-->C",
1415            "agentflow-beta\nA-->B",
1416            "usecase-beta\nactor A\nA --> B",
1417            "classDiagram\nA --> B",
1418            "stateDiagram-v2\nA --> B",
1419            "erDiagram\nA ||--o{ B : relates",
1420            "requirementDiagram\nrequirement r {\nid: 1\ntext: Demo\nrisk: low\nverifymethod: test\n}",
1421        ] {
1422            for layout in ["cose-bilkent", "unknown-layout-probe"] {
1423                let source = format!("---\nconfig:\n  layout: {layout}\n---\n{body}\n");
1424                let parsed = Engine::new()
1425                    .parse_diagram_for_render_model_sync(&source, ParseOptions::strict())
1426                    .unwrap()
1427                    .unwrap();
1428                let session = crate::environment::RenderEnvironment::deterministic()
1429                    .begin_session()
1430                    .unwrap();
1431                let result = crate::family::prepare(parsed, &LayoutOptions::default(), session);
1432                if layout == "cose-bilkent" && cfg!(feature = "layout-cytoscape") {
1433                    assert!(
1434                        matches!(result, Err(Error::InvalidModel { message }) if message.contains("Root node is required")),
1435                        "{body}"
1436                    );
1437                } else {
1438                    result.unwrap_or_else(|error| panic!("{body}: {layout}: {error}"));
1439                }
1440            }
1441        }
1442    }
1443
1444    #[cfg(feature = "layout-cytoscape")]
1445    #[test]
1446    fn registered_cose_host_denial_precedes_missing_root_failure() {
1447        let source = "---\nconfig:\n  layout: cose-bilkent\n---\nusecase-beta\nactor A\nA --> B\n";
1448        let parsed = Engine::new()
1449            .parse_diagram_for_render_model_sync(source, ParseOptions::strict())
1450            .unwrap()
1451            .unwrap();
1452        let session = crate::environment::RenderEnvironment::deterministic()
1453            .with_capability_policy(RenderCapabilityPolicy::deny_all())
1454            .begin_session()
1455            .unwrap();
1456        let plan = crate::family::plan_render(&parsed, &session).unwrap();
1457        assert_eq!(
1458            plan.missing_capabilities(),
1459            &[RenderCapability::LayoutCytoscape]
1460        );
1461        assert!(matches!(
1462            crate::family::prepare(parsed, &LayoutOptions::default(), session),
1463            Err(Error::MissingCapability {
1464                capability: RenderCapability::LayoutCytoscape,
1465                ..
1466            })
1467        ));
1468    }
1469
1470    #[cfg(feature = "layout-elk")]
1471    #[test]
1472    fn registered_elk_denied_by_host_policy_does_not_fall_back() {
1473        for body in [
1474            "flowchart TD\nA-->B",
1475            "classDiagram\nAnimal <|-- Duck",
1476            "erDiagram\nCUSTOMER ||--o{ ORDER : places",
1477        ] {
1478            for layout in [
1479                "elk",
1480                "elk.stress",
1481                "elk.force",
1482                "elk.mrtree",
1483                "elk.sporeOverlap",
1484                "elk.box",
1485                "elk.rectpacking",
1486            ] {
1487                let source = format!("---\nconfig:\n  layout: {layout}\n---\n{body}\n");
1488                let parsed = Engine::new()
1489                    .parse_diagram_for_render_model_sync(&source, ParseOptions::strict())
1490                    .unwrap()
1491                    .unwrap();
1492                let session = crate::environment::RenderEnvironment::deterministic()
1493                    .with_capability_policy(RenderCapabilityPolicy::deny_all())
1494                    .begin_session()
1495                    .unwrap();
1496                let plan = crate::family::plan_render(&parsed, &session).unwrap();
1497                assert_eq!(plan.missing_capabilities(), &[RenderCapability::LayoutElk]);
1498                assert!(matches!(
1499                    crate::family::prepare(parsed, &LayoutOptions::default(), session),
1500                    Err(Error::MissingCapability {
1501                        capability: RenderCapability::LayoutElk,
1502                        ..
1503                    })
1504                ));
1505            }
1506        }
1507    }
1508
1509    #[test]
1510    fn render_model_dispatch_renders_cynefin_svg() {
1511        let source = r#"cynefin-beta
1512  title Team Practices
1513  accTitle: Cynefin map
1514  accDescr: Practice movement
1515  complex
1516    "Pair programming"
1517  complicated
1518    "Architecture review"
1519  complex --> complicated : "Pattern emerges"
1520"#;
1521        let svg = render_source(
1522            source,
1523            &LayoutOptions::default(),
1524            &crate::svg::SvgRenderOptions {
1525                diagram_id: Some("cynefin-test".to_string()),
1526                ..Default::default()
1527            },
1528        );
1529
1530        assert!(svg.contains(r#"aria-roledescription="cynefin""#));
1531        assert!(svg.contains(r#"<g class="cynefin-backgrounds">"#));
1532        assert!(svg.contains(r#"class="cynefinDomain""#));
1533        assert!(svg.contains(r#"class="cynefinBoundary""#));
1534        assert!(svg.contains(r#"class="cynefinCliff""#));
1535        assert!(svg.contains(r#"class="cynefinItem""#));
1536        assert!(svg.contains("Pair programming"));
1537        assert!(svg.contains(r#"class="cynefinArrowLine""#));
1538        assert!(svg.contains("Pattern emerges"));
1539        assert!(svg.contains(r#"<title id="chart-title-cynefin-test">Cynefin map</title>"#));
1540        assert!(svg.contains(r#"<desc id="chart-desc-cynefin-test">Practice movement</desc>"#));
1541        assert!(svg.contains("#cynefin-test .cynefinDomain{stroke:none;}"));
1542        assert_eq!(svg.matches("<title").count(), 2, "{svg}");
1543        assert_eq!(svg.matches("<desc").count(), 2, "{svg}");
1544
1545        let scoped_title = svg
1546            .find(r#"<title id="chart-title-cynefin-test">"#)
1547            .expect("scoped accessibility title");
1548        let scoped_descr = svg
1549            .find(r#"<desc id="chart-desc-cynefin-test">"#)
1550            .expect("scoped accessibility description");
1551        let style = svg.find("<style>").expect("style");
1552        let framework_group = svg.find("<g/>").expect("Mermaid framework group");
1553        let renderer_title = svg
1554            .find("<title>Cynefin map</title>")
1555            .expect("renderer accessibility title");
1556        let renderer_descr = svg
1557            .find("<desc>Practice movement</desc>")
1558            .expect("renderer accessibility description");
1559        let root_group = svg
1560            .find(r#"<g transform="translate("#)
1561            .expect("cynefin root group");
1562        let defs = svg.find("<defs>").expect("transition marker defs");
1563
1564        assert!(scoped_title < scoped_descr, "{svg}");
1565        assert!(scoped_descr < style, "{svg}");
1566        assert!(style < framework_group, "{svg}");
1567        assert!(framework_group < renderer_title, "{svg}");
1568        assert!(renderer_title < renderer_descr, "{svg}");
1569        assert!(renderer_descr < root_group, "{svg}");
1570        assert!(root_group < defs, "{svg}");
1571    }
1572
1573    #[test]
1574    fn render_model_dispatch_keeps_whitespace_cynefin_transition_labels() {
1575        let source = r#"cynefin-beta
1576  complex
1577  complicated
1578  complex --> complicated : "   "
1579"#;
1580        let svg = render_source(
1581            source,
1582            &LayoutOptions::default(),
1583            &crate::svg::SvgRenderOptions {
1584                diagram_id: Some("cynefin-whitespace".to_string()),
1585                ..Default::default()
1586            },
1587        );
1588
1589        assert!(
1590            svg.contains(r#"class="cynefinArrowLabel""#),
1591            "a whitespace-only label is truthy in JavaScript and must emit a text node: {svg}"
1592        );
1593    }
1594
1595    #[test]
1596    fn render_model_dispatch_renders_railroad_svg() {
1597        let source = r#"railroad-beta
1598accTitle: Railroad grammar
1599accDescr: Expression grammar
1600expr = sequence(nonterminal("term"), optional(special("guard")), zeroOrMore(terminal("+"))) ;
1601"#;
1602        let svg = render_source(
1603            source,
1604            &LayoutOptions::default(),
1605            &crate::svg::SvgRenderOptions {
1606                diagram_id: Some("railroad-test".to_string()),
1607                ..Default::default()
1608            },
1609        );
1610
1611        assert!(svg.contains(r#"aria-roledescription="railroad""#));
1612        assert!(svg.contains(r#"class="railroad-diagram""#));
1613        assert!(svg.contains(r#"class="railroad-rule""#));
1614        assert!(svg.contains(r#"class="railroad-rule-name""#));
1615        assert!(svg.contains(r#"class="railroad-nonterminal""#));
1616        assert!(svg.contains(r#"class="railroad-special""#));
1617        assert!(svg.contains(r#"class="railroad-terminal""#));
1618        assert!(svg.contains(r#"class="railroad-line""#));
1619        assert!(svg.contains("term"));
1620        assert!(svg.contains("? guard ?"));
1621        assert!(svg.contains("+"));
1622        assert!(svg.contains(r#"<title id="chart-title-railroad-test">Railroad grammar</title>"#));
1623        assert!(svg.contains(r#"<desc id="chart-desc-railroad-test">Expression grammar</desc>"#));
1624        assert!(
1625            svg.contains("</style><g/><g class=\"railroad-rule\""),
1626            "{svg}"
1627        );
1628    }
1629
1630    #[cfg(feature = "layout-elk")]
1631    fn edge_path_chunk<'a>(svg: &'a str, edge_id: &str) -> &'a str {
1632        let id_attr = format!(r#"id="merman-{edge_id}""#);
1633        let id_start = svg.find(&id_attr).expect("edge id");
1634        let path_start = svg[..id_start].rfind("<path ").expect("edge path start");
1635        let path_end = svg[id_start..].find("/>").expect("edge path end") + id_start;
1636        &svg[path_start..path_end]
1637    }
1638
1639    #[cfg(feature = "layout-elk")]
1640    fn edge_path_d(path: &str) -> &str {
1641        let d_start = path.find(r#"d=""#).expect("edge path d") + r#"d=""#.len();
1642        let d_end = path[d_start..].find('"').expect("edge path d end") + d_start;
1643        &path[d_start..d_end]
1644    }
1645
1646    #[cfg(feature = "layout-elk")]
1647    fn edge_attr_value<'a>(path: &'a str, attr: &str) -> &'a str {
1648        let needle = format!(r#"{attr}=""#);
1649        let start = path.find(&needle).expect("edge attr") + needle.len();
1650        let end = path[start..].find('"').expect("edge attr end") + start;
1651        &path[start..end]
1652    }
1653
1654    #[cfg(feature = "layout-elk")]
1655    fn edge_data_points(path: &str) -> Vec<(f64, f64)> {
1656        use base64::Engine as _;
1657
1658        let b64 = edge_attr_value(path, "data-points");
1659        let bytes = base64::engine::general_purpose::STANDARD
1660            .decode(b64.as_bytes())
1661            .expect("data-points base64");
1662        let json: serde_json::Value =
1663            serde_json::from_slice(&bytes).expect("data-points JSON payload");
1664        json.as_array()
1665            .expect("data-points array")
1666            .iter()
1667            .map(|point| {
1668                (
1669                    point.get("x").and_then(serde_json::Value::as_f64).unwrap(),
1670                    point.get("y").and_then(serde_json::Value::as_f64).unwrap(),
1671                )
1672            })
1673            .collect()
1674    }
1675}
1676
1677#[cfg(feature = "diagram-class")]
1678pub use resources::ClassComplexity;
1679
1680#[cfg(any(
1681    feature = "diagram-flowchart",
1682    feature = "diagram-swimlane",
1683    feature = "diagram-agentflow"
1684))]
1685pub use resources::FlowchartComplexity;
1686
1687#[cfg(feature = "diagram-mindmap")]
1688pub use resources::MindmapComplexity;
1689
1690#[cfg(feature = "diagram-sequence")]
1691pub use resources::SequenceComplexity;
1692
1693#[cfg(feature = "diagram-zenuml")]
1694pub use resources::ZenumlComplexity;