use std::collections::BTreeMap;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use chrono::Utc;
use printpdf::lopdf::{
Dictionary as LoDict, Document as LoDoc, Object as LoObj, ObjectId as LoObjectId,
};
use printpdf::{
BuiltinFont, Color, Image, ImageTransform, ImageXObject, IndirectFontRef, Mm, PdfDocument,
PdfDocumentReference, PdfLayerIndex, PdfLayerReference, PdfPageIndex, Rect, Rgb,
};
use sha2::{Digest, Sha256};
use tracing::warn;
use crate::config::branding::{hex_to_pdf_rgb, BrandingConfig, DEFAULT_FONT_FAMILY};
use crate::error::{ActionError, RepoLensError};
use crate::rules::results::{AuditResults, Finding, Severity};
const PAGE_WIDTH_MM: f32 = 210.0;
const PAGE_HEIGHT_MM: f32 = 297.0;
const MARGIN_LEFT_MM: f32 = 18.0;
const MARGIN_RIGHT_MM: f32 = 18.0;
const MARGIN_TOP_MM: f32 = 22.0;
const MARGIN_BOTTOM_MM: f32 = 22.0;
const COLOR_CRITICAL: &str = "#D73A49";
const COLOR_WARNING: &str = "#FB8500";
const COLOR_INFO: &str = "#0366D6";
const LARGE_REPORT_THRESHOLD: usize = 5_000;
const MAX_CATEGORY_BODY_FINDINGS: usize = 200;
const CELL_TRUNCATE_LEN: usize = 247;
const CELL_WRAP_AT: usize = 80;
pub struct PdfReport {
detailed: bool,
branding: BrandingConfig,
}
impl PdfReport {
pub fn new(detailed: bool) -> Self {
Self {
detailed,
branding: BrandingConfig::defaults(),
}
}
pub fn with_branding(mut self, mut branding: BrandingConfig) -> Self {
branding.validate_and_apply_defaults();
self.branding = branding;
self
}
pub fn render_to_file(
&self,
results: &AuditResults,
output: &Path,
) -> Result<(), RepoLensError> {
let bytes = self.render_to_bytes(results)?;
let mut file = BufWriter::new(File::create(output).map_err(|e| {
RepoLensError::Action(ActionError::FileWrite {
path: output.display().to_string(),
source: e,
})
})?);
file.write_all(&bytes).map_err(|e| {
RepoLensError::Action(ActionError::FileWrite {
path: output.display().to_string(),
source: e,
})
})?;
file.flush().map_err(|e| {
RepoLensError::Action(ActionError::FileWrite {
path: output.display().to_string(),
source: e,
})
})?;
Ok(())
}
pub fn render_to_bytes(&self, results: &AuditResults) -> Result<Vec<u8>, RepoLensError> {
let title = format!("RepoLens Audit — {}", results.repository_name);
let (doc, page0, layer0) =
PdfDocument::new(&title, Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), "Cover");
let doc = doc
.with_author("RepoLens")
.with_creator(format!("RepoLens v{}", env!("CARGO_PKG_VERSION")))
.with_producer("RepoLens PDF renderer (printpdf)")
.with_subject(format!("Audit report for {}", results.repository_name));
let fonts = Fonts::load(
&doc,
self.branding
.font_family
.as_deref()
.unwrap_or(DEFAULT_FONT_FAMILY),
)?;
let palette = Palette::from_branding(&self.branding);
let config_hash = compute_config_hash(results, &self.branding);
let layout = Layout::new();
let categories = collect_categories(results);
let plan = ReportPlan::build(results, &categories, self.detailed);
let mut pages = Pages::new(doc, page0, layer0);
self.draw_cover(&pages, &fonts, &palette, &layout, results, &config_hash);
let toc_index = pages.add_page("Table of contents");
self.draw_toc(&pages.layer(toc_index), &fonts, &palette, &layout, &plan);
let mut toc_entries: Vec<TocEntry> = Vec::new();
let summary_idx = pages.add_page("Summary");
toc_entries.push(TocEntry::new("Summary", summary_idx));
self.draw_summary(
&pages.layer(summary_idx),
&fonts,
&palette,
&layout,
results,
);
self.draw_header_footer(&pages.layer(summary_idx), &fonts, &palette, &layout);
for category in &categories {
let entry_idx = pages.add_page(&format!("Category: {}", category));
toc_entries.push(TocEntry::new(format!("Category: {category}"), entry_idx));
self.draw_category_section(
&mut pages, entry_idx, &fonts, &palette, &layout, results, category, &plan,
);
}
let annex_idx = pages.add_page("Annexes");
toc_entries.push(TocEntry::new("Annexes", annex_idx));
self.draw_annexes(
&mut pages,
annex_idx,
&fonts,
&palette,
&layout,
results,
&config_hash,
&plan,
);
let link_specs = self.draw_toc_entries(
&pages.layer(toc_index),
&fonts,
&palette,
&layout,
toc_index,
&toc_entries,
);
let bytes = pages.into_bytes()?;
let bytes = add_internal_links(bytes, &link_specs)?;
Ok(bytes)
}
fn draw_cover(
&self,
pages: &Pages,
fonts: &Fonts,
palette: &Palette,
layout: &Layout,
results: &AuditResults,
config_hash: &str,
) {
let layer = pages.layer(pages.first());
if let Some(ref logo) = self.branding.logo_path {
match decode_logo(logo) {
Ok(image) => {
let (w_pt, h_pt) = clamp_logo_dimensions(
image.image.width.0 as f32,
image.image.height.0 as f32,
);
let center_x_mm = PAGE_WIDTH_MM / 2.0 - pt_to_mm(w_pt) / 2.0;
let top_y_pt = mm_to_pt(PAGE_HEIGHT_MM) - 60.0 - h_pt;
let scale_x = w_pt / image.image.width.0 as f32;
let scale_y = h_pt / image.image.height.0 as f32;
image.add_to_layer(
layer.clone(),
ImageTransform {
translate_x: Some(Mm(center_x_mm)),
translate_y: Some(Mm(pt_to_mm(top_y_pt))),
scale_x: Some(scale_x),
scale_y: Some(scale_y),
..Default::default()
},
);
}
Err(e) => {
warn!("branding: failed to decode logo: {e}");
}
}
}
layer.set_fill_color(palette.primary.clone());
let title = results.repository_name.clone();
let title_y_mm = PAGE_HEIGHT_MM - 110.0;
layer.use_text(
title.clone(),
24.0,
Mm(layout.center_x(estimate_text_width_mm(&title, 24.0))),
Mm(title_y_mm),
&fonts.bold,
);
layer.set_fill_color(palette.secondary.clone());
let subtitle = self
.branding
.cover_subtitle
.clone()
.unwrap_or_else(|| format!("Preset: {}", results.preset));
layer.use_text(
subtitle.clone(),
14.0,
Mm(layout.center_x(estimate_text_width_mm(&subtitle, 14.0))),
Mm(title_y_mm - 12.0),
&fonts.regular,
);
layer.set_fill_color(palette.text.clone());
let date = Utc::now().format("%Y-%m-%d").to_string();
let lines = [
format!("Generated: {date}"),
format!("RepoLens version: {}", env!("CARGO_PKG_VERSION")),
format!("Config hash: {}", &config_hash[..16]),
];
for (i, line) in lines.iter().enumerate() {
layer.use_text(
line.as_str(),
10.0,
Mm(layout.left_margin),
Mm(title_y_mm - 36.0 - (i as f32) * 6.0),
&fonts.regular,
);
}
layer.set_fill_color(palette.primary.clone());
layer.add_rect(
Rect::new(Mm(0.0), Mm(0.0), Mm(PAGE_WIDTH_MM), Mm(18.0))
.with_mode(printpdf::path::PaintMode::Fill),
);
layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
let band_text = format!("RepoLens v{} • {}", env!("CARGO_PKG_VERSION"), date);
layer.use_text(
band_text,
10.0,
Mm(layout.left_margin),
Mm(7.0),
&fonts.bold,
);
}
fn draw_toc(
&self,
layer: &PdfLayerReference,
fonts: &Fonts,
palette: &Palette,
layout: &Layout,
_plan: &ReportPlan,
) {
layer.set_fill_color(palette.primary.clone());
layer.use_text(
"Table of Contents",
18.0,
Mm(layout.left_margin),
Mm(layout.content_top_y),
&fonts.bold,
);
self.draw_header_footer(layer, fonts, palette, layout);
}
fn draw_toc_entries(
&self,
layer: &PdfLayerReference,
fonts: &Fonts,
palette: &Palette,
layout: &Layout,
toc_page: PageRef,
entries: &[TocEntry],
) -> Vec<InternalLinkSpec> {
layer.set_fill_color(palette.text.clone());
let start_y = layout.content_top_y - 14.0;
let mut links: Vec<InternalLinkSpec> = Vec::with_capacity(entries.len());
let toc_human = human_page(toc_page) as u32;
for (i, entry) in entries.iter().enumerate() {
let y = start_y - (i as f32) * 7.0;
layer.use_text(
format!("{}. {}", i + 1, entry.label),
11.0,
Mm(layout.left_margin),
Mm(y),
&fonts.regular,
);
layer.use_text(
human_page(entry.target_page).to_string(),
11.0,
Mm(PAGE_WIDTH_MM - layout.right_margin - 10.0),
Mm(y),
&fonts.regular,
);
let x_lo_pt = mm_to_pt(layout.left_margin);
let x_hi_pt = mm_to_pt(PAGE_WIDTH_MM - layout.right_margin);
let y_lo_pt = mm_to_pt(y - 1.5);
let y_hi_pt = mm_to_pt(y + 5.0);
links.push(InternalLinkSpec {
toc_page_human: toc_human,
target_page_human: human_page(entry.target_page) as u32,
rect_pt: (x_lo_pt, y_lo_pt, x_hi_pt, y_hi_pt),
});
}
links
}
fn draw_summary(
&self,
layer: &PdfLayerReference,
fonts: &Fonts,
palette: &Palette,
layout: &Layout,
results: &AuditResults,
) {
layer.set_fill_color(palette.primary.clone());
layer.use_text(
"Summary",
18.0,
Mm(layout.left_margin),
Mm(layout.content_top_y),
&fonts.bold,
);
let counts = [
(
"Critical",
results.count_by_severity(Severity::Critical),
palette.critical.clone(),
),
(
"Warning",
results.count_by_severity(Severity::Warning),
palette.warning.clone(),
),
(
"Info",
results.count_by_severity(Severity::Info),
palette.info.clone(),
),
];
let mut y = layout.content_top_y - 16.0;
for (label, count, color) in &counts {
layer.set_fill_color(color.clone());
layer.use_text(
format!("{label}: {count}"),
14.0,
Mm(layout.left_margin),
Mm(y),
&fonts.bold,
);
y -= 9.0;
}
layer.set_fill_color(palette.text.clone());
layer.use_text(
"Top 10 Critical Findings",
13.0,
Mm(layout.left_margin),
Mm(y - 8.0),
&fonts.bold,
);
y -= 16.0;
let critical: Vec<&Finding> = results
.findings_by_severity(Severity::Critical)
.take(10)
.collect();
if critical.is_empty() {
layer.use_text(
"No critical findings.",
10.0,
Mm(layout.left_margin),
Mm(y),
&fonts.regular,
);
} else {
for (i, finding) in critical.iter().enumerate() {
let line = truncate_cell(&format!(
"{}. {} — {}",
i + 1,
finding.rule_id,
finding.message
));
layer.use_text(line, 10.0, Mm(layout.left_margin), Mm(y), &fonts.regular);
y -= 6.0;
if y < layout.content_bottom_y {
break;
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_category_section(
&self,
pages: &mut Pages,
first_page: PageRef,
fonts: &Fonts,
palette: &Palette,
layout: &Layout,
results: &AuditResults,
category: &str,
plan: &ReportPlan,
) {
let layer = pages.layer(first_page);
let findings: Vec<&Finding> = results.findings_by_category(category).collect();
let count = findings.len();
layer.set_fill_color(palette.primary.clone());
layer.use_text(
format!("Category: {category}"),
18.0,
Mm(layout.left_margin),
Mm(layout.content_top_y),
&fonts.bold,
);
layer.set_fill_color(palette.text.clone());
layer.use_text(
format!("{count} finding(s)"),
11.0,
Mm(layout.left_margin),
Mm(layout.content_top_y - 7.0),
&fonts.regular,
);
self.draw_header_footer(&layer, fonts, palette, layout);
let aggregate_info = plan.aggregate_info;
let body_findings: Vec<&Finding> = findings
.iter()
.copied()
.filter(|f| !(aggregate_info && f.severity == Severity::Info))
.take(MAX_CATEGORY_BODY_FINDINGS)
.collect();
let column_x = [
layout.left_margin,
layout.left_margin + 60.0,
layout.left_margin + 95.0,
layout.left_margin + 120.0,
];
let header_y = layout.content_top_y - 16.0;
layer.set_fill_color(palette.secondary.clone());
for (i, label) in ["Path", "Line", "Severity", "Message"].iter().enumerate() {
layer.use_text(*label, 10.0, Mm(column_x[i]), Mm(header_y), &fonts.bold);
}
let mut current_layer = layer;
let mut y = header_y - 7.0;
for finding in body_findings {
if y < layout.content_bottom_y + 10.0 {
let next = pages.add_page(&format!("{category} (cont.)"));
current_layer = pages.layer(next);
self.draw_header_footer(¤t_layer, fonts, palette, layout);
y = layout.content_top_y;
}
let (path, line) = split_location(finding.location.as_deref());
let severity = severity_label(finding.severity);
let severity_color = severity_color(finding.severity, palette);
let message = wrap_long(&truncate_cell(&finding.message));
current_layer.set_fill_color(palette.text.clone());
current_layer.use_text(
truncate_cell(&path),
9.0,
Mm(column_x[0]),
Mm(y),
&fonts.regular,
);
current_layer.use_text(line, 9.0, Mm(column_x[1]), Mm(y), &fonts.regular);
current_layer.set_fill_color(severity_color);
current_layer.use_text(severity, 9.0, Mm(column_x[2]), Mm(y), &fonts.bold);
current_layer.set_fill_color(palette.text.clone());
for (i, msg_line) in message.lines().enumerate() {
current_layer.use_text(
msg_line,
9.0,
Mm(column_x[3]),
Mm(y - (i as f32) * 4.5),
&fonts.regular,
);
}
let consumed = (message.lines().count().max(1) as f32) * 4.5;
y -= consumed.max(5.5);
}
if aggregate_info {
let info_count = findings
.iter()
.filter(|f| f.severity == Severity::Info)
.count();
if info_count > 0 {
if y < layout.content_bottom_y + 10.0 {
let next = pages.add_page(&format!("{category} (cont.)"));
current_layer = pages.layer(next);
self.draw_header_footer(¤t_layer, fonts, palette, layout);
y = layout.content_top_y;
}
current_layer.set_fill_color(palette.info.clone());
current_layer.use_text(
format!("(+ {info_count} info finding(s) aggregated; see Annexes)"),
9.0,
Mm(layout.left_margin),
Mm(y - 4.0),
&fonts.regular,
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_annexes(
&self,
pages: &mut Pages,
first_page: PageRef,
fonts: &Fonts,
palette: &Palette,
layout: &Layout,
results: &AuditResults,
config_hash: &str,
plan: &ReportPlan,
) {
let layer = pages.layer(first_page);
layer.set_fill_color(palette.primary.clone());
layer.use_text(
"Annexes",
18.0,
Mm(layout.left_margin),
Mm(layout.content_top_y),
&fonts.bold,
);
self.draw_header_footer(&layer, fonts, palette, layout);
layer.set_fill_color(palette.text.clone());
let mut y = layout.content_top_y - 12.0;
let info = [
format!("Repository: {}", results.repository_name),
format!("Preset: {}", results.preset),
format!("Total findings: {}", results.findings().len()),
format!("Generated: {}", Utc::now().format("%Y-%m-%d %H:%M:%S UTC")),
format!("RepoLens version: {}", env!("CARGO_PKG_VERSION")),
format!("Config hash: {config_hash}"),
];
for line in info {
layer.use_text(line, 10.0, Mm(layout.left_margin), Mm(y), &fonts.regular);
y -= 6.0;
}
y -= 4.0;
layer.set_fill_color(palette.secondary.clone());
layer.use_text(
"Applied branding configuration",
12.0,
Mm(layout.left_margin),
Mm(y),
&fonts.bold,
);
layer.set_fill_color(palette.text.clone());
y -= 6.0;
let branding_dump = render_branding_dump(&self.branding);
for line in branding_dump.lines() {
if y < layout.content_bottom_y + 6.0 {
let next = pages.add_page("Annexes (cont.)");
let l2 = pages.layer(next);
self.draw_header_footer(&l2, fonts, palette, layout);
l2.set_fill_color(palette.text.clone());
y = layout.content_top_y;
l2.use_text(line, 9.0, Mm(layout.left_margin), Mm(y), &fonts.regular);
} else {
layer.use_text(line, 9.0, Mm(layout.left_margin), Mm(y), &fonts.regular);
}
y -= 4.5;
}
if plan.aggregate_info {
let next = pages.add_page("Annexes — Info findings");
let l = pages.layer(next);
self.draw_header_footer(&l, fonts, palette, layout);
l.set_fill_color(palette.info.clone());
l.use_text(
"Info findings (aggregated)",
14.0,
Mm(layout.left_margin),
Mm(layout.content_top_y),
&fonts.bold,
);
l.set_fill_color(palette.text.clone());
let mut yy = layout.content_top_y - 10.0;
let info_by_cat = aggregate_info_findings(results);
for (cat, count) in info_by_cat {
if yy < layout.content_bottom_y {
break;
}
l.use_text(
format!("{cat}: {count}"),
10.0,
Mm(layout.left_margin),
Mm(yy),
&fonts.regular,
);
yy -= 5.5;
}
}
let next = pages.add_page("Annexes — Rules");
let l = pages.layer(next);
self.draw_header_footer(&l, fonts, palette, layout);
l.set_fill_color(palette.secondary.clone());
l.use_text(
"Rules referenced in this report",
14.0,
Mm(layout.left_margin),
Mm(layout.content_top_y),
&fonts.bold,
);
l.set_fill_color(palette.text.clone());
let mut yy = layout.content_top_y - 10.0;
let mut rules: Vec<String> = results
.findings()
.iter()
.map(|f| format!("{} ({})", f.rule_id, f.category))
.collect();
rules.sort();
rules.dedup();
let mut current = l;
for rule in rules {
if yy < layout.content_bottom_y {
let next = pages.add_page("Annexes — Rules (cont.)");
current = pages.layer(next);
self.draw_header_footer(¤t, fonts, palette, layout);
current.set_fill_color(palette.text.clone());
yy = layout.content_top_y;
}
current.use_text(rule, 9.0, Mm(layout.left_margin), Mm(yy), &fonts.regular);
yy -= 4.5;
}
}
fn draw_header_footer(
&self,
layer: &PdfLayerReference,
fonts: &Fonts,
palette: &Palette,
layout: &Layout,
) {
if let Some(ref header) = self.branding.header_text {
if !header.trim().is_empty() {
layer.set_fill_color(palette.secondary.clone());
layer.use_text(
header.as_str(),
9.0,
Mm(layout.left_margin),
Mm(PAGE_HEIGHT_MM - 10.0),
&fonts.regular,
);
}
}
if let Some(ref footer) = self.branding.footer_text {
if !footer.trim().is_empty() {
layer.set_fill_color(palette.secondary.clone());
layer.use_text(
footer.as_str(),
9.0,
Mm(layout.left_margin),
Mm(8.0),
&fonts.regular,
);
}
}
}
}
struct Layout {
left_margin: f32,
right_margin: f32,
content_top_y: f32,
content_bottom_y: f32,
}
impl Layout {
fn new() -> Self {
Self {
left_margin: MARGIN_LEFT_MM,
right_margin: MARGIN_RIGHT_MM,
content_top_y: PAGE_HEIGHT_MM - MARGIN_TOP_MM,
content_bottom_y: MARGIN_BOTTOM_MM,
}
}
fn center_x(&self, content_width_mm: f32) -> f32 {
(PAGE_WIDTH_MM - content_width_mm) / 2.0
}
}
struct Fonts {
regular: IndirectFontRef,
bold: IndirectFontRef,
}
impl Fonts {
fn load(doc: &PdfDocumentReference, family: &str) -> Result<Self, RepoLensError> {
let (regular_kind, bold_kind) = match resolve_builtin_family(family) {
Some(pair) => pair,
None => {
warn!(
"branding: font family {family:?} is not a built-in PDF font, falling back to {DEFAULT_FONT_FAMILY}"
);
(BuiltinFont::Helvetica, BuiltinFont::HelveticaBold)
}
};
let regular = doc.add_builtin_font(regular_kind).map_err(font_err)?;
let bold = doc.add_builtin_font(bold_kind).map_err(font_err)?;
Ok(Self { regular, bold })
}
}
fn font_err(e: printpdf::Error) -> RepoLensError {
RepoLensError::Action(ActionError::ExecutionFailed {
message: format!("printpdf font: {e}"),
})
}
fn resolve_builtin_family(family: &str) -> Option<(BuiltinFont, BuiltinFont)> {
match family.to_ascii_lowercase().as_str() {
"helvetica" | "" => Some((BuiltinFont::Helvetica, BuiltinFont::HelveticaBold)),
"times" | "times-roman" | "times new roman" => {
Some((BuiltinFont::TimesRoman, BuiltinFont::TimesBold))
}
"courier" => Some((BuiltinFont::Courier, BuiltinFont::CourierBold)),
_ => None,
}
}
#[derive(Clone)]
struct Palette {
primary: Color,
secondary: Color,
text: Color,
critical: Color,
warning: Color,
info: Color,
}
impl Palette {
fn from_branding(branding: &BrandingConfig) -> Self {
Self {
primary: hex_color(branding.primary_color.as_deref().unwrap_or("#0052CC")),
secondary: hex_color(branding.secondary_color.as_deref().unwrap_or("#172B4D")),
text: hex_color(branding.text_color.as_deref().unwrap_or("#000000")),
critical: hex_color(COLOR_CRITICAL),
warning: hex_color(COLOR_WARNING),
info: hex_color(COLOR_INFO),
}
}
}
fn hex_color(hex: &str) -> Color {
let (r, g, b) = hex_to_pdf_rgb(hex).unwrap_or((0.0, 0.0, 0.0));
Color::Rgb(Rgb::new(r, g, b, None))
}
fn severity_color(sev: Severity, palette: &Palette) -> Color {
match sev {
Severity::Critical => palette.critical.clone(),
Severity::Warning => palette.warning.clone(),
Severity::Info => palette.info.clone(),
}
}
fn severity_label(sev: Severity) -> &'static str {
match sev {
Severity::Critical => "CRITICAL",
Severity::Warning => "WARNING",
Severity::Info => "INFO",
}
}
#[derive(Clone, Copy)]
struct PageRef(usize);
struct Pages {
doc: PdfDocumentReference,
pages: Vec<(PdfPageIndex, PdfLayerIndex)>,
}
impl Pages {
fn new(
doc: PdfDocumentReference,
first_page: PdfPageIndex,
first_layer: PdfLayerIndex,
) -> Self {
Self {
doc,
pages: vec![(first_page, first_layer)],
}
}
fn first(&self) -> PageRef {
PageRef(0)
}
fn add_page(&mut self, layer_name: &str) -> PageRef {
let (page, layer) = self
.doc
.add_page(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), layer_name);
self.pages.push((page, layer));
PageRef(self.pages.len() - 1)
}
fn layer(&self, page: PageRef) -> PdfLayerReference {
let (p, l) = self.pages[page.0];
self.doc.get_page(p).get_layer(l)
}
fn into_bytes(self) -> Result<Vec<u8>, RepoLensError> {
self.doc.save_to_bytes().map_err(|e| {
RepoLensError::Action(ActionError::ExecutionFailed {
message: format!("printpdf save: {e}"),
})
})
}
}
fn human_page(page: PageRef) -> usize {
page.0 + 1
}
struct ReportPlan {
aggregate_info: bool,
}
impl ReportPlan {
fn build(results: &AuditResults, _categories: &[String], _detailed: bool) -> Self {
Self {
aggregate_info: results.findings().len() > LARGE_REPORT_THRESHOLD,
}
}
}
fn collect_categories(results: &AuditResults) -> Vec<String> {
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
for f in results.findings() {
seen.insert(f.category.clone(), ());
}
seen.into_keys().collect()
}
fn aggregate_info_findings(results: &AuditResults) -> Vec<(String, usize)> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for f in results.findings() {
if f.severity == Severity::Info {
*counts.entry(f.category.clone()).or_insert(0) += 1;
}
}
counts.into_iter().collect()
}
fn split_location(loc: Option<&str>) -> (String, String) {
match loc {
None => ("-".to_string(), "-".to_string()),
Some(s) => match s.rsplit_once(':') {
Some((path, line)) if line.chars().all(|c| c.is_ascii_digit()) => {
(path.to_string(), line.to_string())
}
_ => (s.to_string(), "-".to_string()),
},
}
}
fn wrap_long(text: &str) -> String {
if text.chars().count() <= CELL_WRAP_AT {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut current = 0usize;
for ch in text.chars() {
out.push(ch);
current += 1;
if current >= CELL_WRAP_AT && matches!(ch, '/' | '_' | '-' | '.') {
out.push('\n');
current = 0;
}
}
out
}
fn truncate_cell(text: &str) -> String {
if text.chars().count() <= CELL_TRUNCATE_LEN + 3 {
return text.to_string();
}
let mut out: String = text.chars().take(CELL_TRUNCATE_LEN).collect();
out.push('…');
out
}
fn render_branding_dump(b: &BrandingConfig) -> String {
fn opt(label: &str, v: Option<&str>) -> String {
format!("{} = {}", label, v.unwrap_or("(default)"))
}
let mut out = String::new();
out.push_str("[branding]\n");
out.push_str(&format!(
"logo_path = {}\n",
b.logo_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(none)".to_string())
));
out.push_str(&opt("primary_color ", b.primary_color.as_deref()));
out.push('\n');
out.push_str(&opt("secondary_color ", b.secondary_color.as_deref()));
out.push('\n');
out.push_str(&opt("text_color ", b.text_color.as_deref()));
out.push('\n');
out.push_str(&opt("font_family ", b.font_family.as_deref()));
out.push('\n');
out.push_str(&opt("header_text ", b.header_text.as_deref()));
out.push('\n');
out.push_str(&opt("footer_text ", b.footer_text.as_deref()));
out.push('\n');
out.push_str(&opt("cover_subtitle ", b.cover_subtitle.as_deref()));
out.push('\n');
out
}
fn compute_config_hash(results: &AuditResults, branding: &BrandingConfig) -> String {
let mut hasher = Sha256::new();
hasher.update(results.preset.as_bytes());
hasher.update(b"|");
hasher.update(results.repository_name.as_bytes());
hasher.update(b"|");
hasher.update(branding.primary_color.as_deref().unwrap_or("").as_bytes());
hasher.update(branding.secondary_color.as_deref().unwrap_or("").as_bytes());
hasher.update(branding.text_color.as_deref().unwrap_or("").as_bytes());
hasher.update(branding.font_family.as_deref().unwrap_or("").as_bytes());
format!("{:x}", hasher.finalize())
}
fn estimate_text_width_mm(text: &str, font_size_pt: f32) -> f32 {
let avg_glyph_pt = font_size_pt * 0.5;
pt_to_mm(text.chars().count() as f32 * avg_glyph_pt)
}
fn pt_to_mm(pt: f32) -> f32 {
pt / 2.834_645_7
}
fn mm_to_pt(mm: f32) -> f32 {
mm * 2.834_645_7
}
fn clamp_logo_dimensions(width_pt: f32, height_pt: f32) -> (f32, f32) {
const MAX_W: f32 = 200.0;
const MAX_H: f32 = 80.0;
let scale = (MAX_W / width_pt.max(1.0))
.min(MAX_H / height_pt.max(1.0))
.min(1.0);
(width_pt * scale, height_pt * scale)
}
fn decode_logo(path: &Path) -> Result<Image, String> {
let raw = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let img = image::load_from_memory(&raw).map_err(|e| format!("decode: {e}"))?;
Ok(Image::from(ImageXObject::from_dynamic_image(&img)))
}
struct TocEntry {
label: String,
target_page: PageRef,
}
impl TocEntry {
fn new(label: impl Into<String>, target_page: PageRef) -> Self {
Self {
label: label.into(),
target_page,
}
}
}
struct InternalLinkSpec {
toc_page_human: u32,
target_page_human: u32,
rect_pt: (f32, f32, f32, f32),
}
fn add_internal_links(
pdf_bytes: Vec<u8>,
links: &[InternalLinkSpec],
) -> Result<Vec<u8>, RepoLensError> {
if links.is_empty() {
return Ok(pdf_bytes);
}
let mut doc = LoDoc::load_mem(&pdf_bytes).map_err(|e| {
RepoLensError::Action(ActionError::ExecutionFailed {
message: format!("post-process pdf load: {e}"),
})
})?;
let pages = doc.get_pages();
let mut by_toc: BTreeMap<u32, Vec<LoObjectId>> = BTreeMap::new();
for link in links {
let Some(target_id) = pages.get(&link.target_page_human).copied() else {
continue;
};
let dest = LoObj::Array(vec![
LoObj::Reference(target_id),
LoObj::Name(b"Fit".to_vec()),
]);
let action = LoObj::Dictionary(LoDict::from_iter(vec![
("S", LoObj::Name(b"GoTo".to_vec())),
("D", dest),
]));
let (x_lo, y_lo, x_hi, y_hi) = link.rect_pt;
let annot_dict = LoDict::from_iter(vec![
("Type", LoObj::Name(b"Annot".to_vec())),
("Subtype", LoObj::Name(b"Link".to_vec())),
(
"Rect",
LoObj::Array(vec![
LoObj::Real(x_lo),
LoObj::Real(y_lo),
LoObj::Real(x_hi),
LoObj::Real(y_hi),
]),
),
(
"Border",
LoObj::Array(vec![
LoObj::Integer(0),
LoObj::Integer(0),
LoObj::Integer(0),
]),
),
("A", action),
]);
let annot_id = doc.add_object(LoObj::Dictionary(annot_dict));
by_toc
.entry(link.toc_page_human)
.or_default()
.push(annot_id);
}
for (toc_human, annot_ids) in by_toc {
let Some(toc_id) = pages.get(&toc_human).copied() else {
continue;
};
let page_dict = doc
.get_object_mut(toc_id)
.and_then(|o| o.as_dict_mut())
.map_err(|e| {
RepoLensError::Action(ActionError::ExecutionFailed {
message: format!("post-process toc page dict: {e}"),
})
})?;
let mut existing: Vec<LoObj> = page_dict
.get(b"Annots")
.ok()
.and_then(|o| o.as_array().ok())
.cloned()
.unwrap_or_default();
for id in annot_ids {
existing.push(LoObj::Reference(id));
}
page_dict.set("Annots", LoObj::Array(existing));
}
let mut buf: Vec<u8> = Vec::new();
doc.save_to(&mut buf).map_err(|e| {
RepoLensError::Action(ActionError::ExecutionFailed {
message: format!("post-process pdf save: {e}"),
})
})?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(rule: &str, category: &str, severity: Severity) -> Finding {
Finding::new(rule, category, severity, "msg")
}
#[test]
fn severity_label_maps_each_variant() {
assert_eq!(severity_label(Severity::Critical), "CRITICAL");
assert_eq!(severity_label(Severity::Warning), "WARNING");
assert_eq!(severity_label(Severity::Info), "INFO");
}
#[test]
fn human_page_starts_at_one() {
assert_eq!(human_page(PageRef(0)), 1);
assert_eq!(human_page(PageRef(7)), 8);
}
#[test]
fn split_location_handles_path_with_line() {
let (path, line) = split_location(Some("src/main.rs:42"));
assert_eq!(path, "src/main.rs");
assert_eq!(line, "42");
}
#[test]
fn split_location_returns_dash_for_none() {
assert_eq!(split_location(None), ("-".to_string(), "-".to_string()));
}
#[test]
fn split_location_treats_non_numeric_suffix_as_path() {
let (path, line) = split_location(Some("a/b:branch"));
assert_eq!(path, "a/b:branch");
assert_eq!(line, "-");
}
#[test]
fn wrap_long_passes_through_short_strings() {
assert_eq!(wrap_long("short"), "short");
}
#[test]
fn wrap_long_inserts_breaks_after_separators() {
let big = "a".repeat(CELL_WRAP_AT) + "/" + &"b".repeat(10);
let wrapped = wrap_long(&big);
assert!(wrapped.contains('\n'), "expected a wrap, got {wrapped:?}");
}
#[test]
fn truncate_cell_keeps_short_strings() {
assert_eq!(truncate_cell("short"), "short");
}
#[test]
fn truncate_cell_appends_ellipsis_for_long_strings() {
let long = "x".repeat(CELL_TRUNCATE_LEN + 50);
let truncated = truncate_cell(&long);
assert!(truncated.ends_with('…'));
assert_eq!(truncated.chars().count(), CELL_TRUNCATE_LEN + 1);
}
#[test]
fn pt_mm_round_trips_within_epsilon() {
let pt = mm_to_pt(50.0);
let mm = pt_to_mm(pt);
assert!((mm - 50.0).abs() < 1e-3, "round trip drift: {mm}");
}
#[test]
fn clamp_logo_dimensions_scales_uniformly() {
let (w, h) = clamp_logo_dimensions(400.0, 100.0);
assert!((w - 200.0).abs() < 0.01);
assert!((h - 50.0).abs() < 0.01);
}
#[test]
fn clamp_logo_dimensions_keeps_small_logos_unchanged() {
let (w, h) = clamp_logo_dimensions(50.0, 20.0);
assert!((w - 50.0).abs() < 0.01);
assert!((h - 20.0).abs() < 0.01);
}
#[test]
fn estimate_text_width_mm_grows_with_length() {
let short = estimate_text_width_mm("hi", 12.0);
let long = estimate_text_width_mm("hello, world!", 12.0);
assert!(long > short);
}
#[test]
fn collect_categories_returns_sorted_unique() {
let mut r = AuditResults::new("repo", "opensource");
r.add_finding(finding("R1", "security", Severity::Critical));
r.add_finding(finding("R2", "docs", Severity::Warning));
r.add_finding(finding("R3", "security", Severity::Info));
let cats = collect_categories(&r);
assert_eq!(cats, vec!["docs".to_string(), "security".to_string()]);
}
#[test]
fn aggregate_info_findings_only_counts_info() {
let mut r = AuditResults::new("repo", "opensource");
r.add_finding(finding("R1", "docs", Severity::Info));
r.add_finding(finding("R2", "docs", Severity::Info));
r.add_finding(finding("R3", "security", Severity::Critical));
let agg = aggregate_info_findings(&r);
assert_eq!(agg, vec![("docs".to_string(), 2)]);
}
#[test]
fn resolve_builtin_family_known_aliases() {
assert!(resolve_builtin_family("Helvetica").is_some());
assert!(resolve_builtin_family("HELVETICA").is_some());
assert!(resolve_builtin_family("Times").is_some());
assert!(resolve_builtin_family("times-roman").is_some());
assert!(resolve_builtin_family("Times New Roman").is_some());
assert!(resolve_builtin_family("Courier").is_some());
assert!(resolve_builtin_family("").is_some());
}
#[test]
fn resolve_builtin_family_unknown_returns_none() {
assert!(resolve_builtin_family("Comic Sans").is_none());
}
#[test]
fn render_branding_dump_emits_section_header_and_keys() {
let b = BrandingConfig::defaults();
let dump = render_branding_dump(&b);
assert!(dump.starts_with("[branding]"));
assert!(dump.contains("logo_path"));
assert!(dump.contains("primary_color"));
assert!(dump.contains("font_family"));
assert!(dump.contains("header_text"));
}
#[test]
fn compute_config_hash_changes_when_branding_changes() {
let r = AuditResults::new("repo", "opensource");
let mut b1 = BrandingConfig::defaults();
let h1 = compute_config_hash(&r, &b1);
b1.primary_color = Some("#FF00FF".to_string());
let h2 = compute_config_hash(&r, &b1);
assert_ne!(h1, h2);
}
#[test]
fn compute_config_hash_is_deterministic() {
let r = AuditResults::new("repo", "opensource");
let b = BrandingConfig::defaults();
assert_eq!(compute_config_hash(&r, &b), compute_config_hash(&r, &b));
}
#[test]
fn palette_uses_branding_colors() {
let mut b = BrandingConfig::defaults();
b.primary_color = Some("#0052CC".to_string());
let palette = Palette::from_branding(&b);
let _ = severity_color(Severity::Critical, &palette);
let _ = severity_color(Severity::Warning, &palette);
let _ = severity_color(Severity::Info, &palette);
}
#[test]
fn toc_entry_new_stores_label_and_target() {
let entry = TocEntry::new("Cover", PageRef(3));
assert_eq!(entry.label, "Cover");
assert_eq!(entry.target_page.0, 3);
}
}