use anyhow::{bail, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagramStyle {
Default,
Editorial,
Monochrome,
Technical,
}
pub const VALID_STYLE_NAMES: [&str; 4] = ["default", "editorial", "monochrome", "technical"];
impl DiagramStyle {
pub fn parse(name: &str) -> Result<Self> {
match name {
"default" => Ok(Self::Default),
"editorial" => Ok(Self::Editorial),
"monochrome" => Ok(Self::Monochrome),
"technical" => Ok(Self::Technical),
other => bail!(
"Unknown diagram style '{other}' — valid styles are: {}",
VALID_STYLE_NAMES.join(", ")
),
}
}
fn palette(self) -> Option<Palette> {
match self {
Self::Default => None,
Self::Editorial => Some(Palette {
background: "#FFFFFF",
foreground: "#1B1B1A",
neutral: "#54514C",
neutral_light: "#F5F5F3",
accent: Some("#B2362C"),
monospace: false,
}),
Self::Monochrome => Some(Palette {
background: "#FFFFFF",
foreground: "#1A1A1A",
neutral: "#808080",
neutral_light: "#E8E8E8",
accent: None,
monospace: false,
}),
Self::Technical => Some(Palette {
background: "#FFFFFF",
foreground: "#1A1A1A",
neutral: "#555555",
neutral_light: "#FFFFFF",
accent: None,
monospace: true,
}),
}
}
}
struct Palette {
background: &'static str,
foreground: &'static str,
neutral: &'static str,
neutral_light: &'static str,
accent: Option<&'static str>,
monospace: bool,
}
impl Palette {
fn accent_or_neutral(&self) -> &'static str {
self.accent.unwrap_or(self.neutral)
}
}
pub fn mermaid_options(style: DiagramStyle) -> mermaid_rs_renderer::RenderOptions {
let Some(p) = style.palette() else {
return mermaid_rs_renderer::RenderOptions::default();
};
let accent = p.accent_or_neutral();
let font_family = if p.monospace {
"'Courier New', 'DejaVu Sans Mono', ui-monospace, monospace".to_string()
} else {
"'Helvetica Neue', Helvetica, Arial, sans-serif".to_string()
};
let pie_colors: [String; 12] = {
let cycle = [
p.background,
accent,
p.neutral,
p.neutral_light,
p.foreground,
];
std::array::from_fn(|i| cycle[i % cycle.len()].to_string())
};
let theme = mermaid_rs_renderer::Theme {
font_family,
font_size: 14.0,
primary_color: p.neutral_light.to_string(),
primary_text_color: p.foreground.to_string(),
primary_border_color: p.neutral.to_string(),
line_color: accent.to_string(),
secondary_color: p.neutral_light.to_string(),
tertiary_color: p.background.to_string(),
edge_label_background: p.background.to_string(),
cluster_background: p.neutral_light.to_string(),
cluster_border: p.neutral.to_string(),
background: p.background.to_string(),
sequence_actor_fill: p.neutral_light.to_string(),
sequence_actor_border: p.neutral.to_string(),
sequence_actor_line: p.neutral.to_string(),
sequence_note_fill: p.neutral_light.to_string(),
sequence_note_border: p.neutral.to_string(),
sequence_activation_fill: p.neutral_light.to_string(),
sequence_activation_border: p.neutral.to_string(),
text_color: p.foreground.to_string(),
git_colors: [
p.foreground,
accent,
p.neutral,
p.neutral_light,
p.background,
p.foreground,
accent,
p.neutral,
]
.map(|s| s.to_string()),
git_inv_colors: [p.background; 8].map(|s| s.to_string()),
git_branch_label_colors: [p.foreground; 8].map(|s| s.to_string()),
git_commit_label_color: p.foreground.to_string(),
git_commit_label_background: p.background.to_string(),
git_tag_label_color: p.foreground.to_string(),
git_tag_label_background: p.neutral_light.to_string(),
git_tag_label_border: p.neutral.to_string(),
pie_colors,
pie_title_text_size: 20.0,
pie_title_text_color: p.foreground.to_string(),
pie_section_text_size: 14.0,
pie_section_text_color: p.foreground.to_string(),
pie_legend_text_size: 14.0,
pie_legend_text_color: p.foreground.to_string(),
pie_stroke_color: p.foreground.to_string(),
pie_stroke_width: 1.0,
pie_outer_stroke_width: 1.0,
pie_outer_stroke_color: p.neutral.to_string(),
pie_opacity: 1.0,
};
let mut layout = mermaid_rs_renderer::LayoutConfig::default();
if matches!(style, DiagramStyle::Editorial) {
layout.node_spacing *= 1.4;
layout.rank_spacing *= 1.4;
}
mermaid_rs_renderer::RenderOptions { theme, layout }
}
pub fn d2_prefix(style: DiagramStyle) -> String {
let Some(p) = style.palette() else {
return String::new();
};
let accent = p.accent_or_neutral();
let theme_id = if p.monospace { 301 } else { 0 };
format!(
"vars: {{\n d2-config: {{\n theme-id: {theme_id}\n theme-overrides: {{\n N1: \"{fg}\"\n N2: \"{neu}\"\n N3: \"{neu}\"\n N4: \"{neul}\"\n N5: \"{neul}\"\n N6: \"{neul}\"\n N7: \"{bg}\"\n B1: \"{fg}\"\n B2: \"{acc}\"\n B3: \"{neu}\"\n B4: \"{neul}\"\n B5: \"{neul}\"\n B6: \"{bg}\"\n AA2: \"{acc}\"\n AA4: \"{neul}\"\n AA5: \"{bg}\"\n AB4: \"{neul}\"\n AB5: \"{bg}\"\n }}\n }}\n}}\n",
fg = p.foreground,
neu = p.neutral,
neul = p.neutral_light,
bg = p.background,
acc = accent,
)
}
fn graphviz_attr_statements(style: DiagramStyle) -> String {
let Some(p) = style.palette() else {
return String::new();
};
let accent = p.accent_or_neutral();
format!(
"node [style=filled, fillcolor=\"{}\", color=\"{}\", fontsize=12];\nedge [color=\"{}\"];\n",
p.neutral_light, accent, accent
)
}
pub fn graphviz_inject(src: &str, style: DiagramStyle) -> String {
let stmt = graphviz_attr_statements(style);
if stmt.is_empty() {
return src.to_string();
}
match src.find('{') {
Some(idx) => {
let mut out = String::with_capacity(src.len() + stmt.len() + 1);
out.push_str(&src[..=idx]);
out.push('\n');
out.push_str(&stmt);
out.push_str(&src[idx + 1..]);
out
}
None => src.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_all_four_names() {
assert_eq!(
DiagramStyle::parse("default").unwrap(),
DiagramStyle::Default
);
assert_eq!(
DiagramStyle::parse("editorial").unwrap(),
DiagramStyle::Editorial
);
assert_eq!(
DiagramStyle::parse("monochrome").unwrap(),
DiagramStyle::Monochrome
);
assert_eq!(
DiagramStyle::parse("technical").unwrap(),
DiagramStyle::Technical
);
}
#[test]
fn parse_unknown_name_lists_valid_names() {
let err = DiagramStyle::parse("editoral").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("editoral"), "message: {msg}");
for name in VALID_STYLE_NAMES {
assert!(msg.contains(name), "message missing '{name}': {msg}");
}
}
#[test]
fn parse_rejects_old_mono_name() {
let err = DiagramStyle::parse("mono").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("monochrome"), "message: {msg}");
}
#[test]
fn parse_rejects_blueprint_name() {
assert!(DiagramStyle::parse("blueprint").is_err());
}
#[test]
fn mermaid_default_style_is_untouched_default_options() {
let opts = mermaid_options(DiagramStyle::Default);
let base = mermaid_rs_renderer::RenderOptions::default();
assert_eq!(opts.theme.background, base.theme.background);
assert_eq!(opts.theme.primary_color, base.theme.primary_color);
assert_eq!(opts.layout.node_spacing, base.layout.node_spacing);
}
#[test]
fn mermaid_monochrome_style_has_no_accent() {
let opts = mermaid_options(DiagramStyle::Monochrome);
assert_eq!(opts.theme.primary_border_color, opts.theme.line_color);
}
#[test]
fn d2_default_style_has_no_prefix() {
assert_eq!(d2_prefix(DiagramStyle::Default), "");
}
#[test]
fn d2_editorial_style_prefix_contains_theme_overrides() {
let prefix = d2_prefix(DiagramStyle::Editorial);
assert!(prefix.contains("theme-overrides"));
let accent = DiagramStyle::Editorial.palette().unwrap().accent.unwrap();
assert!(prefix.contains(accent));
}
#[test]
fn graphviz_default_style_leaves_source_untouched() {
let src = "digraph G { A -> B }";
assert_eq!(graphviz_inject(src, DiagramStyle::Default), src);
}
#[test]
fn graphviz_styled_source_injects_after_opening_brace() {
let src = "digraph G { A -> B }";
let injected = graphviz_inject(src, DiagramStyle::Editorial);
assert!(injected.contains("node [style=filled"));
assert!(injected.contains("edge [color="));
assert!(injected.trim_end().ends_with("}"));
}
}