From a38f5036e83afcab98aedbf6e384b6502d200dca Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 19 Sep 2024 00:18:49 -0600 Subject: [PATCH] Add badge support --- Cargo.lock | 46 ++++++++++++++++- Cargo.toml | 1 + src/handlers/badge.rs | 114 +++++++++++++++++++++++++++++++++++++++++ src/handlers/mod.rs | 1 + src/handlers/report.rs | 94 +++++++++++++++++++++++++++++---- 5 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 src/handlers/badge.rs diff --git a/Cargo.lock b/Cargo.lock index f27d42d..8b4d066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,6 +47,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +dependencies = [ + "memchr", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -352,6 +361,29 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "badge-maker" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d1c1803a02b28fbbf44a1498fd1043655b0da3bcd315437fcb7776302b2917c" +dependencies = [ + "aho-corasick 0.7.20", + "base64 0.13.1", + "bincode", + "itoa", + "lazy_static", + "regex", + "seahash", + "serde", + "thiserror", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -389,6 +421,15 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit_field" version = "0.10.2" @@ -918,6 +959,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "badge-maker", "blake3", "bytes", "chrono", @@ -3365,7 +3407,7 @@ version = "1.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" dependencies = [ - "aho-corasick", + "aho-corasick 1.1.3", "memchr", "regex-automata 0.4.7", "regex-syntax 0.8.4", @@ -3386,7 +3428,7 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ - "aho-corasick", + "aho-corasick 1.1.3", "memchr", "regex-syntax 0.8.4", ] diff --git a/Cargo.toml b/Cargo.toml index e7fceae..8b65c78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = "1.0" axum = "0.7" +badge-maker = "0.3" blake3 = "1.5" bytes = "1.7" chrono = "0.4" diff --git a/src/handlers/badge.rs b/src/handlers/badge.rs new file mode 100644 index 0000000..a5aa302 --- /dev/null +++ b/src/handlers/badge.rs @@ -0,0 +1,114 @@ +use std::io::Cursor; + +use anyhow::{anyhow, Context, Result}; +use image::{buffer::ConvertBuffer, ImageFormat, RgbImage, RgbaImage}; +use objdiff_core::bindings::report::{Measures, ReportCategory}; +use resvg::{ + tiny_skia::{PixmapMut, Transform}, + usvg::{Options, Tree}, +}; +use serde::{Deserialize, Serialize}; + +use crate::models::ReportFile; + +#[derive(Deserialize, Default, Clone)] +pub struct ShieldParams { + label: Option, + label_color: Option, + color: Option, + style: Option, + measure: Option, +} + +#[derive(Serialize, Clone)] +pub struct ShieldResponse { + schema_version: u32, + label: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + label_color: Option, +} + +pub fn render( + report: &ReportFile, + measures: &Measures, + current_category: Option<&ReportCategory>, + params: &ShieldParams, +) -> Result { + let label = if let Some(label) = params.label.clone() { + label + } else if let Some(category) = current_category { + category.name.clone() + } else { + report.project.short_name().to_string() + }; + let message = if let Some(measure) = ¶ms.measure { + match measure.as_str() { + "code" => format!("{:.2}%", measures.matched_code_percent), + "data" => format!("{:.2}%", measures.matched_data_percent), + "functions" => format!("{:.2}%", measures.matched_functions_percent), + "complete_code" => format!("{:.2}%", measures.complete_code_percent), + "complete_data" => format!("{:.2}%", measures.complete_data_percent), + _ => return Err(anyhow!("Unknown measure")), + } + } else { + format!("{:.2}%", measures.matched_code_percent) + }; + Ok(ShieldResponse { + schema_version: 1, + label, + message, + color: Some(params.color.clone().unwrap_or_else(|| "informational".to_string())), + style: params.style.clone(), + label_color: params.label_color.clone(), + }) +} + +pub fn render_svg( + report: &ReportFile, + measures: &Measures, + current_category: Option<&ReportCategory>, + params: &ShieldParams, +) -> Result { + let response = render(report, measures, current_category, params)?; + let mut builder = badge_maker::BadgeBuilder::new(); + builder.label(&response.label).message(&response.message); + if let Some(color) = &response.color { + builder.color_parse(color); + } + if let Some(style) = &response.style { + builder.style_parse(style); + } + if let Some(label_color) = &response.label_color { + builder.label_color_parse(label_color); + } + let badge = builder.build()?; + Ok(badge.svg()) +} + +pub fn render_image( + report: &ReportFile, + measures: &Measures, + current_category: Option<&ReportCategory>, + params: &ShieldParams, + format: ImageFormat, +) -> Result> { + let svg = render_svg(report, measures, current_category, params)?; + let opt = Options::default(); + let tree = Tree::from_str(&svg, &opt).context("Failed to parse SVG")?; + let rect = tree.root().abs_layer_bounding_box(); + let w = rect.width() as u32; + let h = rect.height() as u32; + let mut image = RgbaImage::new(w, h); + let mut pixmap = PixmapMut::from_bytes(image.as_mut(), w, h) + .ok_or_else(|| anyhow!("Failed to create pixmap"))?; + resvg::render(&tree, Transform::identity(), &mut pixmap); + let mut bytes = Vec::new(); + let image: RgbImage = image.convert(); + image.write_to(&mut Cursor::new(&mut bytes), format)?; + Ok(bytes) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 42cdec3..eea8bc1 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -14,6 +14,7 @@ use prost::Message; use crate::AppState; +mod badge; mod css; mod graph; mod js; diff --git a/src/handlers/report.rs b/src/handlers/report.rs index 3abc13c..c3e5dcd 100644 --- a/src/handlers/report.rs +++ b/src/handlers/report.rs @@ -13,9 +13,8 @@ use objdiff_core::bindings::report::{Measures, ReportCategory}; use serde::{Deserialize, Serialize}; use url::Url; -use super::{graph::layout_units, parse_accept, AppError, FullUri, Protobuf, PROTOBUF}; +use super::{badge, graph, parse_accept, AppError, FullUri, Protobuf, PROTOBUF}; use crate::{ - handlers::graph::{render_image, render_svg, unit_color}, models::{Project, ProjectInfo, ReportFile}, templates::render, util::UrlExt, @@ -35,9 +34,12 @@ const DEFAULT_IMAGE_HEIGHT: u32 = 475; #[derive(Deserialize)] pub struct ReportQuery { + mode: Option, category: Option, w: Option, h: Option, + #[serde(flatten)] + shield: badge::ShieldParams, } impl ReportQuery { @@ -203,17 +205,36 @@ pub async fn get_report( return Err(AppError::Status(StatusCode::NOT_FOUND)); }; - let (w, h) = query.size(); - let (measures, current_category, units) = apply_category(&report, &query)?; + if let Some(mode) = query.mode.as_deref() { + match mode.to_ascii_lowercase().as_str() { + "shield" => mode_shield(report, query, headers, ext), + "report" => mode_report(report, project_info, &state, uri, query, headers, start, ext), + _ => Err(AppError::Status(StatusCode::BAD_REQUEST)), + } + } else { + mode_report(report, project_info, &state, uri, query, headers, start, ext) + } +} +fn mode_report( + report: ReportFile, + project_info: ProjectInfo, + state: &AppState, + uri: Uri, + query: ReportQuery, + headers: HeaderMap, + start: Instant, + ext: Option, +) -> Result { + let (measures, current_category, units) = apply_category(&report, &query)?; let acceptable = if let Some(ext) = ext { vec![match ext.to_ascii_lowercase().as_str() { "json" => mime::APPLICATION_JSON, - "binpb" | "proto" => Mime::from_str("application/x-protobuf").unwrap(), + "binpb" | "proto" => Mime::from_str("application/x-protobuf")?, "svg" => mime::IMAGE_SVG, _ => { if let Some(format) = ImageFormat::from_extension(ext) { - Mime::from_str(format.to_mime_type()).unwrap() + Mime::from_str(format.to_mime_type())? } else { return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)); } @@ -246,7 +267,8 @@ pub async fn get_report( } else if mime.type_() == mime::APPLICATION && mime.subtype() == PROTOBUF { return Ok(Protobuf(report.report).into_response()); } else if mime.type_() == mime::IMAGE && mime.subtype() == mime::SVG { - let svg = render_svg(&units, w, h, &state)?; + let (w, h) = query.size(); + let svg = graph::render_svg(&units, w, h, &state)?; return Ok(([(header::CONTENT_TYPE, mime::IMAGE_SVG.as_ref())], svg).into_response()); } else if mime.type_() == mime::IMAGE { let format = if mime.subtype() == mime::STAR { @@ -256,7 +278,59 @@ pub async fn get_report( ImageFormat::from_mime_type(mime.essence_str()) .ok_or_else(|| AppError::Status(StatusCode::NOT_ACCEPTABLE))? }; - let data = render_image(&units, w, h, &state, format)?; + let (w, h) = query.size(); + let data = graph::render_image(&units, w, h, &state, format)?; + return Ok(([(header::CONTENT_TYPE, format.to_mime_type())], data).into_response()); + } + } + Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)) +} + +fn mode_shield( + report: ReportFile, + query: ReportQuery, + headers: HeaderMap, + ext: Option, +) -> Result { + let (measures, current_category, _units) = apply_category(&report, &query)?; + let acceptable = if let Some(ext) = ext { + vec![match ext.to_ascii_lowercase().as_str() { + "json" => mime::APPLICATION_JSON, + "svg" => mime::IMAGE_SVG, + _ => { + if let Some(format) = ImageFormat::from_extension(ext) { + Mime::from_str(format.to_mime_type())? + } else { + return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)); + } + } + }] + } else { + if !headers.contains_key(header::ACCEPT) { + return Ok(Json(report.report).into_response()); + } + parse_accept(&headers) + }; + for mime in acceptable { + if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR) + || (mime.type_() == mime::IMAGE && mime.subtype() == mime::SVG) + || (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML) + { + let data = badge::render_svg(&report, measures, current_category, &query.shield)?; + return Ok(([(header::CONTENT_TYPE, mime::IMAGE_SVG.as_ref())], data).into_response()); + } else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON { + let data = badge::render(&report, measures, current_category, &query.shield)?; + return Ok(Json(data).into_response()); + } else if mime.type_() == mime::IMAGE { + let format = if mime.subtype() == mime::STAR { + // Default to PNG + ImageFormat::Png + } else { + ImageFormat::from_mime_type(mime.essence_str()) + .ok_or_else(|| AppError::Status(StatusCode::NOT_ACCEPTABLE))? + }; + let data = + badge::render_image(&report, measures, current_category, &query.shield, format)?; return Ok(([(header::CONTENT_TYPE, format.to_mime_type())], data).into_response()); } } @@ -296,7 +370,7 @@ fn apply_category<'a>( } let (w, h) = query.size(); let aspect = w as f32 / h as f32; - let units = layout_units(&report.report, w, h, |item| { + let units = graph::layout_units(&report.report, w, h, |item| { if let Some(category_id) = &category_id_filter { item.metadata .as_ref() @@ -321,7 +395,7 @@ fn apply_category<'a>( ReportTemplateUnit { name: &unit.name, fuzzy_match_percent: match_percent, - color: unit_color(match_percent), + color: graph::unit_color(match_percent), x: bounds.x, y: bounds.y, w: bounds.w,