use clap::{Args as ClapArgs, Parser, Subcommand, ValueEnum, ValueHint};
use merman::render::FlowchartElkBackend as RenderFlowchartElkBackend;
#[derive(Debug, Parser)]
#[command(
name = "merman-cli",
version,
subcommand_precedence_over_arg = true,
override_usage = "merman-cli [OPTIONS] [INPUT]\n merman-cli <COMMAND> [ARGS]",
about = "Headless Mermaid renderer compatible with common mmdc workflows.",
long_about = "Headless Mermaid renderer compatible with common mmdc workflows.\n\n\
Top-level usage functionally mirrors common mmdc workflows:\n merman-cli -i input.mmd -o output.svg\n merman-cli -i input.mmd -o output.png -t dark -b transparent\n\n\
Developer subcommands expose merman internals:\n merman-cli parse --pretty input.mmd\n merman-cli layout --pretty input.mmd\n merman-cli render --format unicode input.mmd"
)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Option<Command>,
#[command(flatten)]
pub(crate) export: ExportArgs,
#[arg(value_name = "INPUT", value_hint = ValueHint::FilePath)]
pub(crate) input: Option<String>,
}
#[derive(Debug, Subcommand)]
#[allow(clippy::large_enum_variant)]
pub(crate) enum Command {
Detect(DetectArgs),
Parse(ParseArgs),
Layout(LayoutArgs),
Render(RenderArgs),
Completion(CompletionArgs),
}
#[derive(Debug, ClapArgs)]
pub(crate) struct DetectArgs {
#[arg(value_name = "INPUT", value_hint = ValueHint::FilePath)]
pub(crate) input: Option<String>,
#[command(flatten)]
pub(crate) parse: ParseCliArgs,
}
#[derive(Debug, ClapArgs)]
pub(crate) struct ParseArgs {
#[arg(value_name = "INPUT", value_hint = ValueHint::FilePath)]
pub(crate) input: Option<String>,
#[arg(long)]
pub(crate) pretty: bool,
#[arg(long, alias = "with-meta")]
pub(crate) meta: bool,
#[command(flatten)]
pub(crate) parse: ParseCliArgs,
}
#[derive(Debug, ClapArgs)]
pub(crate) struct LayoutArgs {
#[arg(value_name = "INPUT", value_hint = ValueHint::FilePath)]
pub(crate) input: Option<String>,
#[arg(long)]
pub(crate) pretty: bool,
#[command(flatten)]
pub(crate) parse: ParseCliArgs,
#[command(flatten)]
pub(crate) render: RenderCliArgs,
}
#[derive(Debug, ClapArgs)]
pub(crate) struct RenderArgs {
#[arg(value_name = "INPUT", value_hint = ValueHint::FilePath)]
pub(crate) input: Option<String>,
#[command(flatten)]
pub(crate) export: RenderExportArgs,
}
#[derive(Debug, ClapArgs)]
pub(crate) struct CompletionArgs {
#[arg(value_enum)]
pub(crate) shell: clap_complete::Shell,
}
#[derive(Debug, Clone, ClapArgs, Default)]
pub(crate) struct ParseCliArgs {
#[arg(long = "suppress-errors", help_heading = "Mermaid configuration")]
pub(crate) suppress_errors: bool,
#[arg(
short = 'c',
long = "configFile",
alias = "config-file",
value_hint = ValueHint::FilePath,
help_heading = "Mermaid configuration"
)]
pub(crate) config_file: Option<String>,
#[arg(short = 't', long, help_heading = "Mermaid configuration")]
pub(crate) theme: Option<String>,
#[arg(
long = "fixed-today",
value_parser = parse_naive_date,
help_heading = "Deterministic rendering"
)]
pub(crate) fixed_today: Option<chrono::NaiveDate>,
#[arg(
long = "fixed-local-offset-minutes",
value_parser = parse_fixed_local_offset_minutes,
help_heading = "Deterministic rendering"
)]
pub(crate) fixed_local_offset_minutes: Option<i32>,
}
#[derive(Debug, Clone, ClapArgs)]
pub(crate) struct RenderCliArgs {
#[arg(
long = "text-measurer",
value_enum,
default_value_t = TextMeasurerKind::Vendored,
help_heading = "Rust renderer controls"
)]
pub(crate) text_measurer: TextMeasurerKind,
#[arg(
long = "math-renderer",
value_enum,
default_value_t = MathRendererKind::None,
help_heading = "Rust renderer controls"
)]
pub(crate) math_renderer: MathRendererKind,
#[arg(
long = "flowchart-elk-backend",
value_enum,
default_value_t = FlowchartElkBackend::SourcePorted,
help_heading = "Rust renderer controls"
)]
pub(crate) flowchart_elk_backend: FlowchartElkBackend,
#[arg(
short = 'w',
long = "width",
alias = "viewport-width",
value_parser = parse_positive_f64,
help_heading = "Rust renderer controls"
)]
pub(crate) width: Option<f64>,
#[arg(
short = 'H',
long = "height",
alias = "viewport-height",
value_parser = parse_positive_f64,
help_heading = "Rust renderer controls"
)]
pub(crate) height: Option<f64>,
#[arg(
short = 'I',
long = "svgId",
alias = "svg-id",
alias = "id",
help_heading = "Rust renderer controls"
)]
pub(crate) svg_id: Option<String>,
#[arg(long = "hand-drawn-seed", help_heading = "Deterministic rendering")]
pub(crate) hand_drawn_seed: Option<u64>,
#[arg(
long = "resource-profile",
value_enum,
default_value_t = ResourceProfile::TrustedNative,
help_heading = "Rust renderer controls"
)]
pub(crate) resource_profile: ResourceProfile,
}
impl Default for RenderCliArgs {
fn default() -> Self {
Self {
text_measurer: TextMeasurerKind::Vendored,
math_renderer: MathRendererKind::None,
flowchart_elk_backend: FlowchartElkBackend::SourcePorted,
width: None,
height: None,
svg_id: None,
hand_drawn_seed: None,
resource_profile: ResourceProfile::TrustedNative,
}
}
}
#[derive(Debug, Clone, ClapArgs, Default)]
pub(crate) struct ExportArgs {
#[arg(
short = 'i',
long = "input",
value_name = "INPUT",
value_hint = ValueHint::FilePath,
help_heading = "mmdc-compatible export"
)]
pub(crate) input_file: Option<String>,
#[arg(
short = 'o',
long = "output",
alias = "out",
value_name = "OUTPUT",
value_hint = ValueHint::FilePath,
help_heading = "mmdc-compatible export"
)]
pub(crate) output: Option<String>,
#[arg(
short = 'a',
long = "artefacts",
alias = "artifacts",
value_hint = ValueHint::DirPath,
help_heading = "Markdown batch export"
)]
pub(crate) artefacts: Option<String>,
#[arg(
short = 'j',
long = "jobs",
value_parser = parse_positive_usize,
help_heading = "Markdown batch export"
)]
pub(crate) jobs: Option<usize>,
#[arg(
short = 'e',
long = "outputFormat",
alias = "output-format",
visible_alias = "format",
value_enum,
help_heading = "mmdc-compatible export"
)]
pub(crate) output_format: Option<RenderFormat>,
#[arg(
short = 'b',
long = "backgroundColor",
alias = "background-color",
alias = "background",
help_heading = "Raster and PDF export"
)]
pub(crate) background_color: Option<String>,
#[arg(
short = 'C',
long = "cssFile",
alias = "css-file",
value_hint = ValueHint::FilePath,
help_heading = "Mermaid configuration"
)]
pub(crate) css_file: Option<String>,
#[arg(
short = 'f',
long = "pdfFit",
alias = "pdf-fit",
help_heading = "Raster and PDF export"
)]
pub(crate) pdf_fit: bool,
#[arg(short = 'q', long = "quiet", help_heading = "mmdc-compatible export")]
pub(crate) quiet: bool,
#[arg(
short = 'p',
long = "puppeteerConfigFile",
alias = "puppeteer-config-file",
value_hint = ValueHint::FilePath,
help_heading = "Accepted browser compatibility flags"
)]
pub(crate) puppeteer_config_file: Option<String>,
#[command(flatten)]
pub(crate) raster: RasterCliArgs,
#[command(flatten)]
pub(crate) icons: IconCliArgs,
#[command(flatten)]
pub(crate) parse: ParseCliArgs,
#[command(flatten)]
pub(crate) render: RenderCliArgs,
#[command(flatten)]
pub(crate) text: TextOutputCliArgs,
}
#[derive(Debug, Clone, ClapArgs, Default)]
pub(crate) struct RenderExportArgs {
#[arg(
short = 'i',
long = "input",
value_name = "INPUT",
value_hint = ValueHint::FilePath,
help_heading = "Render input and output"
)]
pub(crate) input_file: Option<String>,
#[arg(
short = 'o',
long = "output",
alias = "out",
value_name = "OUTPUT",
value_hint = ValueHint::FilePath,
help_heading = "Render input and output"
)]
pub(crate) output: Option<String>,
#[arg(
short = 'e',
long = "outputFormat",
alias = "output-format",
visible_alias = "format",
value_enum,
help_heading = "Render input and output"
)]
pub(crate) output_format: Option<RenderFormat>,
#[arg(
short = 'b',
long = "backgroundColor",
alias = "background-color",
alias = "background",
help_heading = "Raster and PDF export"
)]
pub(crate) background_color: Option<String>,
#[arg(
short = 'C',
long = "cssFile",
alias = "css-file",
value_hint = ValueHint::FilePath,
help_heading = "Mermaid configuration"
)]
pub(crate) css_file: Option<String>,
#[arg(short = 'q', long = "quiet", help_heading = "Render input and output")]
pub(crate) quiet: bool,
#[command(flatten)]
pub(crate) raster: RasterCliArgs,
#[command(flatten)]
pub(crate) icons: IconCliArgs,
#[command(flatten)]
pub(crate) parse: ParseCliArgs,
#[command(flatten)]
pub(crate) render: RenderCliArgs,
#[command(flatten)]
pub(crate) text: TextOutputCliArgs,
}
#[derive(Debug, Clone, ClapArgs, Default)]
pub(crate) struct RasterCliArgs {
#[arg(
short = 's',
long = "scale",
value_parser = parse_positive_f32,
help_heading = "Raster and PDF export"
)]
pub(crate) scale: Option<f32>,
#[arg(
long = "raster-fit-width",
value_parser = parse_positive_u32,
help_heading = "Raster and PDF export"
)]
pub(crate) raster_fit_width: Option<u32>,
#[arg(
long = "raster-fit-height",
value_parser = parse_positive_u32,
help_heading = "Raster and PDF export"
)]
pub(crate) raster_fit_height: Option<u32>,
#[arg(
long = "raster-max-width",
value_parser = parse_positive_u32,
help_heading = "Raster and PDF export"
)]
pub(crate) raster_max_width: Option<u32>,
#[arg(
long = "raster-max-height",
value_parser = parse_positive_u32,
help_heading = "Raster and PDF export"
)]
pub(crate) raster_max_height: Option<u32>,
#[arg(
long = "raster-max-pixels",
value_parser = parse_positive_u64,
help_heading = "Raster and PDF export"
)]
pub(crate) raster_max_pixels: Option<u64>,
#[arg(
long = "raster-unbounded",
conflicts_with_all = ["raster_max_width", "raster_max_height", "raster_max_pixels"],
help_heading = "Raster and PDF export"
)]
pub(crate) raster_unbounded: bool,
}
#[derive(Debug, Clone, ClapArgs, Default)]
pub(crate) struct IconCliArgs {
#[arg(long = "iconPacks", num_args = 1.., help_heading = "Icon packs")]
pub(crate) icon_packs: Vec<String>,
#[arg(
long = "iconPacksNamesAndUrls",
num_args = 1..,
help_heading = "Icon packs"
)]
pub(crate) icon_packs_names_and_urls: Vec<String>,
}
#[derive(Debug, Clone, ClapArgs, Default)]
pub(crate) struct TextOutputCliArgs {
#[arg(long = "sequence-mirror-actors", help_heading = "Text output")]
pub(crate) sequence_mirror_actors: bool,
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub(crate) enum TextMeasurerKind {
Deterministic,
#[default]
Vendored,
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub(crate) enum MathRendererKind {
#[default]
None,
Ratex,
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub(crate) enum FlowchartElkBackend {
Compat,
#[default]
SourcePorted,
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub(crate) enum ResourceProfile {
Interactive,
TypstPackage,
#[default]
TrustedNative,
UnboundedForTrustedInput,
}
impl From<ResourceProfile> for merman::render::RenderResourceProfile {
fn from(value: ResourceProfile) -> Self {
match value {
ResourceProfile::Interactive => Self::Interactive,
ResourceProfile::TypstPackage => Self::TypstPackage,
ResourceProfile::TrustedNative => Self::TrustedNative,
ResourceProfile::UnboundedForTrustedInput => Self::UnboundedForTrustedInput,
}
}
}
impl From<FlowchartElkBackend> for RenderFlowchartElkBackend {
fn from(value: FlowchartElkBackend) -> Self {
match value {
FlowchartElkBackend::Compat => Self::Compat,
FlowchartElkBackend::SourcePorted => Self::SourcePorted,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
pub(crate) enum RenderFormat {
#[default]
Svg,
Ascii,
Unicode,
Png,
#[value(name = "jpg", alias = "jpeg")]
Jpeg,
Pdf,
}
impl RenderFormat {
pub(crate) fn extension(self) -> &'static str {
match self {
RenderFormat::Svg => "svg",
RenderFormat::Ascii | RenderFormat::Unicode => "txt",
RenderFormat::Png => "png",
RenderFormat::Jpeg => "jpg",
RenderFormat::Pdf => "pdf",
}
}
pub(crate) fn is_raster(self) -> bool {
matches!(
self,
RenderFormat::Png | RenderFormat::Jpeg | RenderFormat::Pdf
)
}
pub(crate) fn is_text(self) -> bool {
matches!(self, RenderFormat::Ascii | RenderFormat::Unicode)
}
}
fn parse_positive_usize(value: &str) -> Result<usize, String> {
let parsed = value
.parse::<usize>()
.map_err(|_| "expected a positive integer".to_string())?;
if parsed == 0 {
return Err("expected a positive integer".to_string());
}
Ok(parsed)
}
fn parse_positive_u32(value: &str) -> Result<u32, String> {
let parsed = value
.parse::<u32>()
.map_err(|_| "expected a positive integer".to_string())?;
if parsed == 0 {
return Err("expected a positive integer".to_string());
}
Ok(parsed)
}
fn parse_positive_u64(value: &str) -> Result<u64, String> {
let parsed = value
.parse::<u64>()
.map_err(|_| "expected a positive integer".to_string())?;
if parsed == 0 {
return Err("expected a positive integer".to_string());
}
Ok(parsed)
}
fn parse_positive_f32(value: &str) -> Result<f32, String> {
let parsed = value
.parse::<f32>()
.map_err(|_| "expected a positive number".to_string())?;
if !(parsed.is_finite() && parsed > 0.0) {
return Err("expected a positive number".to_string());
}
Ok(parsed)
}
fn parse_positive_f64(value: &str) -> Result<f64, String> {
let parsed = value
.parse::<f64>()
.map_err(|_| "expected a positive number".to_string())?;
if !(parsed.is_finite() && parsed > 0.0) {
return Err("expected a positive number".to_string());
}
Ok(parsed)
}
fn parse_naive_date(value: &str) -> Result<chrono::NaiveDate, String> {
chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d")
.map_err(|_| "expected a date in YYYY-MM-DD format".to_string())
}
fn parse_fixed_local_offset_minutes(value: &str) -> Result<i32, String> {
let parsed = value
.parse::<i32>()
.map_err(|_| "expected a timezone offset in minutes".to_string())?;
let Some(seconds) = parsed.checked_mul(60) else {
return Err("expected a timezone offset in minutes between -1439 and 1439".to_string());
};
if chrono::FixedOffset::east_opt(seconds).is_none() {
return Err("expected a timezone offset in minutes between -1439 and 1439".to_string());
}
Ok(parsed)
}