Add badge support

This commit is contained in:
Luke Street
2024-09-19 00:18:49 -06:00
parent 9aa82494fc
commit a38f5036e8
5 changed files with 244 additions and 12 deletions
Generated
+44 -2
View File
@@ -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",
]
+1
View File
@@ -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"
+114
View File
@@ -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<String>,
label_color: Option<String>,
color: Option<String>,
style: Option<String>,
measure: Option<String>,
}
#[derive(Serialize, Clone)]
pub struct ShieldResponse {
schema_version: u32,
label: String,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
label_color: Option<String>,
}
pub fn render(
report: &ReportFile,
measures: &Measures,
current_category: Option<&ReportCategory>,
params: &ShieldParams,
) -> Result<ShieldResponse> {
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) = &params.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<String> {
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<Vec<u8>> {
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)
}
+1
View File
@@ -14,6 +14,7 @@ use prost::Message;
use crate::AppState;
mod badge;
mod css;
mod graph;
mod js;
+84 -10
View File
@@ -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<String>,
category: Option<String>,
w: Option<u32>,
h: Option<u32>,
#[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<String>,
) -> Result<Response, AppError> {
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<String>,
) -> Result<Response, AppError> {
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,