merman 0.8.0

Parity-focused, headless Rust implementation of Mermaid for parsing, layout, and rendering.
Documentation
#![cfg(all(
    feature = "svg",
    feature = "system-clock",
    feature = "system-timezone",
    feature = "system-random",
    feature = "system-timing",
    unix
))]

use std::process::Command;

use merman::{
    Engine, OperationControl, ParseOptions, RenderOutput, RenderRequest, RenderSemanticModel,
    Renderer, SvgEnvironment, SvgRequest, runtime::RuntimePolicy, time::CivilDate,
};
#[cfg(feature = "ascii")]
use merman::{
    ascii::{AsciiRenderOptions, AsciiRenderer, AsciiResourcePolicy},
    diagrams::gantt::{GanttDiagramRenderModel, GanttRenderTask},
};

const CHILD_PROCESS: &str = "MERMAN_DST_TEST_CHILD";
const PROVENANCE_CHILD: &str = "MERMAN_TZ_PROVENANCE_CHILD";
const SOURCE: &str = "gantt\ndateFormat YYYY-MM-DD\nsection Winter\nTask: task, 2026-01-15, 1d";
const GAP_SOURCE: &str =
    "gantt\ndateFormat YYYY-MM-DD HH:mm\nsection Gap\nTask: task, 2026-03-08 02:30, 1h";

#[test]
fn system_timezone_resolves_target_date_dst_in_parse_and_layout() {
    if let Some(time_zone) = std::env::var_os(PROVENANCE_CHILD) {
        emit_timezone_provenance(&time_zone.to_string_lossy());
        return;
    }
    if std::env::var_os(CHILD_PROCESS).is_some() {
        assert_new_york_winter_semantics();
        return;
    }

    let status = Command::new(std::env::current_exe().expect("current test executable"))
        .arg("--exact")
        .arg("system_timezone_resolves_target_date_dst_in_parse_and_layout")
        .arg("--nocapture")
        .env(CHILD_PROCESS, "1")
        .env("TZ", "America/New_York")
        .status()
        .expect("spawn isolated timezone test");
    assert!(status.success(), "isolated timezone test failed: {status}");
}

fn assert_new_york_winter_semantics() {
    let runtime_policy = RuntimePolicy::try_native()
        .expect("native runtime policy")
        .with_fixed_unix_millis(1_784_390_400_000);
    #[cfg(feature = "ascii")]
    assert_gantt_fold_identity(runtime_policy.clone());

    let winter_midnight = runtime_policy
        .local_time_zone()
        .resolve_local(
            CivilDate::new(2026, 1, 15)
                .expect("valid winter date")
                .at_midnight(),
        )
        .expect("resolve New York winter midnight");
    assert_eq!(winter_midnight.timestamp_millis(), 1_768_453_200_000);
    assert_eq!(winter_midnight.offset().minutes(), -5 * 60);

    let environment = SvgEnvironment::deterministic();
    let renderer = Renderer::new()
        .with_runtime_policy(runtime_policy.clone())
        .with_parse_options(ParseOptions::strict());

    let parsed = Engine::new()
        .with_runtime_policy(runtime_policy.clone())
        .parse_diagram_for_render_model_sync(SOURCE, ParseOptions::strict())
        .expect("parse Gantt")
        .expect("detect Gantt");
    let RenderSemanticModel::Gantt(model) = parsed.model() else {
        panic!("expected Gantt semantic model");
    };
    assert_eq!(model.tasks[0].start_ms, 1_768_453_200_000);

    let gap = Engine::new()
        .with_runtime_policy(runtime_policy)
        .parse_diagram_for_render_model_sync(GAP_SOURCE, ParseOptions::strict())
        .expect("parse DST gap Gantt")
        .expect("detect DST gap Gantt");
    let RenderSemanticModel::Gantt(gap_model) = gap.model() else {
        panic!("expected Gantt semantic model");
    };
    assert_eq!(
        gap_model.tasks[0].start_ms, 1_772_955_000_000,
        "02:30 in the New York spring gap must normalize to 03:30 EDT"
    );

    let layout = render_layout(&renderer, environment.clone(), SOURCE);
    assert_eq!(
        layout
            .layout()
            .pointer("/layout/GanttDiagram/tasks/0/start_ms"),
        Some(&serde_json::json!(1_768_453_200_000_i64))
    );

    let gap_layout = render_layout(&renderer, environment, GAP_SOURCE);
    assert_eq!(
        gap_layout
            .layout()
            .pointer("/layout/GanttDiagram/tasks/0/start_ms"),
        Some(&serde_json::json!(1_772_955_000_000_i64))
    );

    assert_timezone_rules_are_captured_and_reported();
}

#[cfg(feature = "ascii")]
fn assert_gantt_fold_identity(runtime_policy: RuntimePolicy) {
    const EARLIER_MS: i64 = 1_793_511_000_000;
    const LATER_MS: i64 = 1_793_514_600_000;

    let mut model = GanttDiagramRenderModel::default();
    model.sections.push("Fold".to_string());
    model.tasks = vec![
        GanttRenderTask {
            id: "earlier".to_string(),
            task: "Earlier instant".to_string(),
            section: "Fold".to_string(),
            order: 0,
            start_ms: EARLIER_MS,
            end_ms: EARLIER_MS,
            ..GanttRenderTask::default()
        },
        GanttRenderTask {
            id: "later".to_string(),
            task: "Later instant".to_string(),
            section: "Fold".to_string(),
            order: 1,
            start_ms: LATER_MS,
            end_ms: LATER_MS,
            ..GanttRenderTask::default()
        },
    ];

    let context = runtime_policy
        .begin_operation()
        .expect("capture New York operation context");
    let rendered = AsciiRenderer::new(AsciiRenderOptions::ascii())
        .expect("ASCII options should validate")
        .render_model(
            &RenderSemanticModel::Gantt(model),
            &OperationControl::new(),
            &context,
            AsciiResourcePolicy::default(),
        )
        .expect("render repeated New York local time");

    assert!(
        rendered.contains("range=2026-11-01T01:30:00.000 -> 2026-11-01T01:30:00.000"),
        "the compatible earlier instant should keep the ordinary local display:\n{rendered}"
    );
    assert!(
        rendered.contains("range=2026-11-01T01:30:00.000-05:00 -> 2026-11-01T01:30:00.000-05:00"),
        "the later repeated instant must disclose its actual offset:\n{rendered}"
    );
}

fn render_layout(
    renderer: &Renderer,
    environment: SvgEnvironment,
    source: &str,
) -> merman::SvgLayoutOutput {
    let output = renderer
        .render(RenderRequest::layout_json(
            source,
            OperationControl::new(),
            SvgRequest {
                environment,
                ..Default::default()
            },
        ))
        .expect("layout Gantt");
    let RenderOutput::LayoutJson(Some(layout)) = output else {
        panic!("detect Gantt layout");
    };
    layout
}

fn assert_timezone_rules_are_captured_and_reported() {
    let denver = capture_timezone_provenance("America/Denver");
    let phoenix = capture_timezone_provenance("America/Phoenix");

    assert_eq!(denver.0, phoenix.0, "winter snapshots must be identical");
    assert_ne!(denver.1, phoenix.1, "rule reports must be distinguishable");
    assert_eq!(denver.2, -6 * 60 * 60);
    assert_eq!(phoenix.2, -7 * 60 * 60);
}

fn capture_timezone_provenance(time_zone: &str) -> (String, String, i32) {
    let output = Command::new(std::env::current_exe().expect("current test executable"))
        .arg("--exact")
        .arg("system_timezone_resolves_target_date_dst_in_parse_and_layout")
        .arg("--nocapture")
        .env(PROVENANCE_CHILD, time_zone)
        .env("TZ", time_zone)
        .output()
        .expect("spawn isolated timezone provenance test");
    assert!(
        output.status.success(),
        "timezone provenance child failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    let line = stdout
        .lines()
        .find(|line| line.starts_with("MERMAN_TIME_ZONE_EVIDENCE|"))
        .unwrap_or_else(|| panic!("missing timezone evidence in child output: {stdout}"));
    let mut fields = line.split('|');
    assert_eq!(fields.next(), Some("MERMAN_TIME_ZONE_EVIDENCE"));
    assert_eq!(fields.next(), Some(time_zone));
    let snapshot = fields.next().expect("snapshot evidence").to_string();
    let rules = fields.next().expect("rules evidence").to_string();
    let summer_offset = fields
        .next()
        .expect("summer offset evidence")
        .parse()
        .expect("numeric summer offset");
    (snapshot, rules, summer_offset)
}

fn emit_timezone_provenance(expected_identifier: &str) {
    let runtime_policy = RuntimePolicy::try_native()
        .expect("native runtime policy")
        .with_fixed_unix_millis(1_768_478_400_000);
    let environment = SvgEnvironment::deterministic();
    let renderer = Renderer::new().with_runtime_policy(runtime_policy);
    let output = renderer
        .render(RenderRequest::svg(
            SOURCE,
            OperationControl::new(),
            SvgRequest {
                environment,
                ..Default::default()
            },
        ))
        .expect("capture timezone evidence");
    let RenderOutput::Svg(Some(svg)) = output else {
        panic!("detect Gantt SVG");
    };
    let evidence = svg.evidence();
    assert_eq!(evidence.local_time_zone().identifier(), expected_identifier);
    let summer = CivilDate::new(2026, 7, 15)
        .expect("valid summer date")
        .at_midnight();
    let summer_offset = evidence
        .operation_context()
        .local_time_zone()
        .resolve_local(summer)
        .expect("summer midnight")
        .offset()
        .seconds();
    let captured_offset = evidence
        .operation_context()
        .local_time_zone()
        .at_instant(evidence.unix_millis())
        .expect("captured local instant")
        .offset()
        .minutes();
    println!(
        "MERMAN_TIME_ZONE_EVIDENCE|{}|{}:{}:{}|{}|{}",
        evidence.local_time_zone().identifier(),
        evidence.unix_millis(),
        evidence.local_date(),
        captured_offset,
        evidence
            .local_time_zone()
            .rules_sha256()
            .expect("system rules digest"),
        summer_offset
    );
}