use std::io::{Read, Write};
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser)]
#[command(name = "fulgur-chart", version)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Render(RenderArgs),
Schema(SchemaArgs),
Inspect(InspectArgs),
}
#[derive(Parser)]
struct SchemaArgs {
#[arg(long, default_value = "chartjs")]
dsl: String,
}
#[derive(Parser)]
struct InspectArgs {
spec: String,
#[arg(short, long, default_value = "-")]
output: String,
#[arg(long)]
dsl: Option<String>,
#[arg(long)]
width: Option<f64>,
#[arg(long)]
height: Option<f64>,
#[arg(long)]
font: Option<String>,
}
#[derive(Parser)]
struct RenderArgs {
#[arg(num_args = 1..)]
spec: Vec<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(long)]
out_dir: Option<String>,
#[arg(long, value_enum)]
format: Option<Format>,
#[arg(long)]
width: Option<f64>,
#[arg(long)]
height: Option<f64>,
#[arg(long)]
strict: bool,
#[arg(long)]
dsl: Option<String>,
#[arg(long, default_value_t = 1.0)]
scale: f32,
#[arg(long)]
font: Option<String>,
}
#[derive(Clone, ValueEnum)]
enum Format {
Svg,
Png,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Render(args) => run_render(args),
Command::Schema(args) => run_schema(args),
Command::Inspect(args) => run_inspect(args),
}
}
fn run_render(args: RenderArgs) {
if let Some(dsl) = &args.dsl {
if dsl != "chartjs" && dsl != "vegalite" {
eprintln!("error: unsupported DSL '{dsl}' (supported: chartjs, vegalite)");
std::process::exit(1);
}
}
let font_bytes: Option<Vec<u8>> = match &args.font {
Some(path) => match std::fs::read(path) {
Ok(b) => Some(b),
Err(e) => {
eprintln!("error: failed to read font '{path}': {e}");
std::process::exit(1);
}
},
None => None,
};
match &args.out_dir {
None => run_single(&args, &font_bytes),
Some(out_dir) => run_batch(&args, out_dir, &font_bytes),
}
}
fn run_single(args: &RenderArgs, font_bytes: &Option<Vec<u8>>) {
if args.spec.is_empty() {
eprintln!("error: no input spec provided");
std::process::exit(1);
}
if args.spec.len() > 1 {
eprintln!("error: multiple input specs require --out-dir");
std::process::exit(1);
}
let spec_path = &args.spec[0];
let output = match &args.output {
Some(o) => o,
None => {
eprintln!("error: --output (-o) is required for single-spec mode");
std::process::exit(1);
}
};
let json = match read_spec(spec_path) {
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to read input: {e}");
std::process::exit(1);
}
};
let format = args.format.clone().unwrap_or_else(|| detect_format(output));
let bytes = match render_one(&json, args, &format, font_bytes) {
Ok(b) => b,
Err((code, msg)) => {
eprintln!("{msg}");
std::process::exit(code);
}
};
if let Err(e) = write_output(output, &bytes) {
eprintln!("error: write failed: {e}");
std::process::exit(3);
}
}
fn run_batch(args: &RenderArgs, out_dir: &str, font_bytes: &Option<Vec<u8>>) {
if args.output.is_some() {
eprintln!("error: --out-dir and --output cannot be used together");
std::process::exit(1);
}
let format = args.format.clone().unwrap_or(Format::Svg);
let ext = match format {
Format::Svg => "svg",
Format::Png => "png",
};
let mut seen_stems: Vec<String> = Vec::new();
let mut outputs: Vec<(std::path::PathBuf, Vec<u8>)> = Vec::new();
for spec_path in &args.spec {
if spec_path == "-" {
eprintln!("error: stdin ('-') is not supported in batch mode");
std::process::exit(1);
}
let stem = match std::path::Path::new(spec_path).file_stem() {
Some(s) => s.to_string_lossy().into_owned(),
None => {
eprintln!("error: cannot determine output stem for '{spec_path}'");
std::process::exit(1);
}
};
if seen_stems.contains(&stem) {
eprintln!("error: output name conflict: multiple inputs would produce '{stem}.{ext}'");
std::process::exit(1);
}
seen_stems.push(stem.clone());
let json = match std::fs::read_to_string(spec_path) {
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to read '{spec_path}': {e}");
std::process::exit(1);
}
};
let bytes = match render_one(&json, args, &format, font_bytes) {
Ok(b) => b,
Err((code, msg)) => {
eprintln!("{spec_path}: {msg}");
std::process::exit(code);
}
};
let out_path = std::path::Path::new(out_dir).join(format!("{stem}.{ext}"));
outputs.push((out_path, bytes));
}
if let Err(e) = std::fs::create_dir_all(out_dir) {
eprintln!("error: failed to create output directory '{out_dir}': {e}");
std::process::exit(3);
}
for (out_path, _) in &outputs {
if out_path.exists() && !out_path.is_file() {
eprintln!("error: output path is not a file: {}", out_path.display());
std::process::exit(3);
}
}
for (out_path, bytes) in &outputs {
if let Err(e) = std::fs::write(out_path, bytes) {
eprintln!("error: write failed '{}': {e}", out_path.display());
std::process::exit(3);
}
}
}
fn parse_spec(json: &str, dsl: &str, strict: bool) -> Result<fulgur_chart::ir::ChartSpec, String> {
match dsl {
"vegalite" => fulgur_chart::frontend::vegalite::parse(json, strict),
_ => fulgur_chart::frontend::chartjs::parse(json, strict), }
}
fn render_one(
json: &str,
args: &RenderArgs,
format: &Format,
font_bytes: &Option<Vec<u8>>,
) -> Result<Vec<u8>, (i32, String)> {
let dsl: &str = match &args.dsl {
Some(d) => d.as_str(),
None => detect_dsl(json).map_err(|e| (1, e))?,
};
let mut spec_ir =
parse_spec(json, dsl, false).map_err(|e| (1, format!("error: parse failed: {e}")))?;
if args.strict {
parse_spec(json, dsl, true).map_err(|e| (2, format!("error: strict violation: {e}")))?;
}
if let Some(w) = args.width {
spec_ir.width = w;
}
if let Some(h) = args.height {
spec_ir.height = h;
}
fulgur_chart::guard::validate_spec(&spec_ir, &fulgur_chart::guard::InputLimits::default())
.map_err(|e| (1, format!("error: {e}")))?;
match format {
Format::Svg => {
let svg = match font_bytes {
Some(bytes) => fulgur_chart::render::render_chart_with_font(&spec_ir, bytes)
.map_err(|e| (1, format!("error: render failed: {e}")))?,
None => fulgur_chart::render::render_chart(&spec_ir),
};
Ok(svg.into_bytes())
}
Format::Png => {
let fb = font_bytes
.as_deref()
.unwrap_or(fulgur_chart::font::DEFAULT_FONT);
fulgur_chart::raster_direct::render_chart_to_png(&spec_ir, args.scale, fb)
.map_err(|e| (3, format!("error: PNG conversion failed: {e}")))
}
}
}
fn read_spec(path: &str) -> std::io::Result<String> {
if path == "-" {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
} else {
std::fs::read_to_string(path)
}
}
fn write_output(path: &str, bytes: &[u8]) -> std::io::Result<()> {
if path == "-" {
let mut out = std::io::stdout();
out.write_all(bytes)?;
out.flush()?;
Ok(())
} else {
std::fs::write(path, bytes)
}
}
fn detect_format(output: &str) -> Format {
if output != "-"
&& std::path::Path::new(output)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("png"))
{
Format::Png
} else {
Format::Svg
}
}
#[derive(serde::Deserialize)]
struct DslDetector {
mark: Option<serde::de::IgnoredAny>,
#[serde(rename = "type")]
r#type: Option<serde::de::IgnoredAny>,
}
fn detect_dsl(json: &str) -> Result<&'static str, String> {
let d: DslDetector =
serde_json::from_str(json).map_err(|e| format!("error: invalid JSON: {e}"))?;
if d.mark.is_some() {
return Ok("vegalite");
}
if d.r#type.is_some() {
return Ok("chartjs");
}
Err("error: cannot auto-detect DSL: specify --dsl chartjs or --dsl vegalite".to_string())
}
fn run_schema(args: SchemaArgs) {
let json = match args.dsl.as_str() {
"chartjs" => {
let schema = schemars::schema_for!(fulgur_chart::schema::ChartJsSpec);
serde_json::to_string_pretty(&schema).expect("schema serialization failed")
}
"vegalite" => {
let schema = schemars::schema_for!(fulgur_chart::schema::VegaLiteSpec);
serde_json::to_string_pretty(&schema).expect("schema serialization failed")
}
other => {
eprintln!("error: unsupported DSL '{other}' (supported: chartjs, vegalite)");
std::process::exit(1);
}
};
println!("{json}");
}
fn run_inspect(args: InspectArgs) {
let json = match read_spec(&args.spec) {
Ok(s) => s,
Err(e) => {
eprintln!("error: failed to read input: {e}");
std::process::exit(1);
}
};
let dsl: String = match &args.dsl {
Some(d) => {
if d != "chartjs" && d != "vegalite" {
eprintln!("error: unsupported DSL '{d}' (supported: chartjs, vegalite)");
std::process::exit(1);
}
d.clone()
}
None => match detect_dsl(&json) {
Ok(d) => d.to_string(),
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
},
};
let mut spec_ir = match parse_spec(&json, &dsl, false) {
Ok(s) => s,
Err(e) => {
eprintln!("error: parse failed: {e}");
std::process::exit(1);
}
};
if let Some(w) = args.width {
spec_ir.width = w;
}
if let Some(h) = args.height {
spec_ir.height = h;
}
if let Err(e) =
fulgur_chart::guard::validate_spec(&spec_ir, &fulgur_chart::guard::InputLimits::default())
{
eprintln!("error: {e}");
std::process::exit(1);
}
let font_bytes: std::borrow::Cow<'static, [u8]> = match &args.font {
Some(path) => match std::fs::read(path) {
Ok(b) => std::borrow::Cow::Owned(b),
Err(e) => {
eprintln!("error: failed to read font '{path}': {e}");
std::process::exit(1);
}
},
None => std::borrow::Cow::Borrowed(fulgur_chart::font::DEFAULT_FONT),
};
let measurer = match fulgur_chart::text::TextMeasurer::new(&font_bytes) {
Ok(m) => m,
Err(e) => {
eprintln!("error: font load failed: {e}");
std::process::exit(1);
}
};
let model = fulgur_chart::model::build_model(&spec_ir, &measurer);
let out = serde_json::to_string_pretty(&model).expect("model serialization failed");
if let Err(e) = write_output(&args.output, out.as_bytes()) {
eprintln!("error: write failed: {e}");
std::process::exit(3);
}
}
#[cfg(test)]
mod detect_dsl_tests {
use super::detect_dsl;
#[test]
fn type_key_detects_chartjs() {
assert_eq!(
detect_dsl(r#"{"type":"bar","data":{}}"#).unwrap(),
"chartjs"
);
}
#[test]
fn mark_key_detects_vegalite() {
assert_eq!(
detect_dsl(r#"{"mark":"bar","data":{"values":[]}}"#).unwrap(),
"vegalite"
);
}
#[test]
fn mark_takes_priority_over_type() {
assert_eq!(
detect_dsl(r#"{"mark":"bar","type":"x"}"#).unwrap(),
"vegalite"
);
}
#[test]
fn no_known_key_is_err() {
assert!(detect_dsl(r#"{"labels":[]}"#).is_err());
}
#[test]
fn invalid_json_is_err() {
assert!(detect_dsl("not json").is_err());
}
}