archunit/metrics/reporting/
exporter.rsuse std::{
collections::BTreeMap,
ffi::OsString,
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
common::{ArchUnitError, TechnicalError, UserError},
metrics::{MetricMeasurement, MetricSubject},
};
use super::MetricsExportOptions;
pub type MetricsReportData = BTreeMap<String, String>;
pub const DEFAULT_METRICS_CSS: &str = r#"
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { max-width: 1080px; margin: 0 auto; padding: 2rem; }
h1 { margin-bottom: .25rem; }
.timestamp { color: #666; margin-top: 0; }
table { border-collapse: collapse; margin-top: 1.5rem; width: 100%; }
th, td { border: 1px solid #bbb; padding: .6rem .8rem; text-align: left; }
th { background: rgba(127, 127, 127, .14); }
tr:nth-child(even) { background: rgba(127, 127, 127, .07); }
footer { color: #666; font-size: .85rem; margin-top: 2rem; }
"#;
#[derive(Debug, Clone, Copy, Default)]
pub struct MetricsExporter;
impl MetricsExporter {
pub fn render_html(data: &MetricsReportData) -> Result<String, ArchUnitError> {
Self::render_html_with(data, &MetricsExportOptions::default())
}
pub fn render_html_with(
data: &MetricsReportData,
options: &MetricsExportOptions,
) -> Result<String, ArchUnitError> {
validate_options(options)?;
let title = escape_html(options.title());
let css = neutralize_style_end(options.custom_css().unwrap_or(DEFAULT_METRICS_CSS));
let timestamp = if options.includes_timestamp() {
format!(
"<p class=\"timestamp\">Generated: {}</p>",
current_utc_timestamp()?
)
} else {
String::new()
};
let rows = render_rows(data);
Ok(format!(
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>{title}</title>\n<style>{css}</style>\n</head>\n<body>\n<main>\n<h1>{title}</h1>\n{timestamp}\n<table>\n<thead><tr><th>Metric</th><th>Value</th></tr></thead>\n<tbody>{rows}</tbody>\n</table>\n</main>\n<footer>Generated by ArchUnitRust</footer>\n</body>\n</html>\n"
))
}
pub fn export_as_html(
data: &MetricsReportData,
output_path: impl AsRef<Path>,
) -> Result<PathBuf, ArchUnitError> {
Self::export_as_html_with(data, output_path, &MetricsExportOptions::default())
}
pub fn export_as_html_with(
data: &MetricsReportData,
output_path: impl AsRef<Path>,
options: &MetricsExportOptions,
) -> Result<PathBuf, ArchUnitError> {
let path = Self::validate_export(output_path.as_ref(), options)?;
let html = Self::render_html_with(data, options)?;
write_html(&path, &html)?;
Ok(path)
}
#[must_use]
pub fn data_from_measurements(measurements: &[MetricMeasurement]) -> MetricsReportData {
measurements
.iter()
.map(|measurement| {
(
format!(
"{} [{}]",
measurement.metric_name(),
report_identifier(measurement.subject())
),
measurement.value().to_string(),
)
})
.collect()
}
pub(crate) fn validate_export(
output_path: &Path,
options: &MetricsExportOptions,
) -> Result<PathBuf, ArchUnitError> {
validate_options(options)?;
resolve_html_path(output_path)
}
pub(crate) fn export_to_validated_path(
data: &MetricsReportData,
path: PathBuf,
options: &MetricsExportOptions,
) -> Result<PathBuf, ArchUnitError> {
let html = Self::render_html_with(data, options)?;
write_html(&path, &html)?;
Ok(path)
}
}
fn validate_options(options: &MetricsExportOptions) -> Result<(), ArchUnitError> {
if options.title().trim().is_empty() {
return Err(ArchUnitError::from(UserError::new(
"the metrics report title must not be empty",
)));
}
if options
.custom_css()
.is_some_and(|css| css.trim().is_empty())
{
return Err(ArchUnitError::from(UserError::new(
"custom metrics report CSS must not be empty",
)));
}
Ok(())
}
fn resolve_html_path(path: &Path) -> Result<PathBuf, ArchUnitError> {
if path.as_os_str().is_empty() || path.file_name().is_none() {
return Err(ArchUnitError::from(UserError::new(
"the metrics report output path must name a file",
)));
}
if path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("html"))
{
return Ok(path.to_path_buf());
}
let mut resolved = OsString::from(path.as_os_str());
resolved.push(".html");
Ok(PathBuf::from(resolved))
}
fn write_html(path: &Path, html: &str) -> Result<(), ArchUnitError> {
if let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
fs::create_dir_all(parent).map_err(|source| {
ArchUnitError::from(TechnicalError::with_source(
format!(
"could not create metrics report directory {}",
parent.display()
),
source,
))
})?;
}
fs::write(path, html.as_bytes()).map_err(|source| {
ArchUnitError::from(TechnicalError::with_source(
format!("could not write metrics report {}", path.display()),
source,
))
})
}
fn render_rows(data: &MetricsReportData) -> String {
if data.is_empty() {
return "<tr><td colspan=\"2\">No metric data.</td></tr>".to_owned();
}
data.iter()
.map(|(name, value)| {
format!(
"<tr><td>{}</td><td>{}</td></tr>",
escape_html(name),
escape_html(value)
)
})
.collect()
}
fn report_identifier(subject: &MetricSubject) -> String {
match subject {
MetricSubject::Type(type_info) => {
format!("{}:{}", type_info.file_path(), type_info.name())
}
MetricSubject::File(file) => file.path().to_owned(),
MetricSubject::Distance(info) => info.identifier().to_owned(),
}
}
fn escape_html(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
_ => escaped.push(character),
}
}
escaped
}
fn neutralize_style_end(css: &str) -> String {
let lowercase = css.to_ascii_lowercase();
let mut safe = String::with_capacity(css.len());
let mut cursor = 0;
while let Some(relative) = lowercase[cursor..].find("</style") {
let index = cursor + relative;
safe.push_str(&css[cursor..index]);
safe.push_str("<\\/style");
cursor = index + "</style".len();
}
safe.push_str(&css[cursor..]);
safe
}
fn current_utc_timestamp() -> Result<String, ArchUnitError> {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|source| {
ArchUnitError::from(TechnicalError::with_source(
"could not obtain a UTC metrics report timestamp",
source,
))
})?
.as_secs();
Ok(format_utc_timestamp(seconds))
}
fn format_utc_timestamp(seconds: u64) -> String {
let days = (seconds / 86_400) as i64;
let day_seconds = seconds % 86_400;
let (year, month, day) = civil_date_from_unix_days(days);
let hour = day_seconds / 3_600;
let minute = day_seconds % 3_600 / 60;
let second = day_seconds % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_date_from_unix_days(days: i64) -> (i64, i64, i64) {
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_part = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_part + 2) / 5 + 1;
let month = month_part + if month_part < 10 { 3 } else { -9 };
year += i64::from(month <= 2);
(year, month, day)
}
#[cfg(test)]
mod tests {
use std::{fs, process, time::SystemTime};
use super::{MetricsExporter, MetricsReportData, format_utc_timestamp};
use crate::{common::ArchUnitError, metrics::MetricsExportOptions};
fn options() -> MetricsExportOptions {
MetricsExportOptions::new()
.with_title("Team <Metrics>")
.with_timestamp(false)
}
#[test]
fn renders_sorted_escaped_rows_and_a_self_contained_document() {
let data = MetricsReportData::from([
("zeta".to_owned(), "<strong>7</strong>".to_owned()),
("<script>alert(1)</script>".to_owned(), "2 & 3".to_owned()),
]);
let html =
MetricsExporter::render_html_with(&data, &options()).expect("valid data should render");
assert!(html.starts_with("<!DOCTYPE html>\n<html lang=\"en\">"));
assert!(html.contains("<meta charset=\"utf-8\">"));
assert!(html.contains("<title>Team <Metrics></title>"));
assert!(html.contains("<script>alert(1)</script>"));
assert!(html.contains("<strong>7</strong>"));
assert!(html.contains("2 & 3"));
assert!(!html.contains("<script>alert(1)</script>"));
assert!(!html.contains("Generated:"));
assert!(html.contains("Generated by ArchUnitRust"));
assert!(
html.find("<script>").expect("first row") < html.find("zeta").expect("last row")
);
}
#[test]
fn neutralizes_custom_css_style_termination_and_renders_empty_data() {
let options =
options().with_custom_css("body { color: red; } </StYlE><script>bad()</script>");
let html = MetricsExporter::render_html_with(&MetricsReportData::new(), &options)
.expect("custom CSS should render safely");
assert!(html.contains("body { color: red; } <\\/style>"));
assert!(!html.contains("</StYlE><script>"));
assert!(html.contains("No metric data."));
}
#[test]
fn formats_utc_calendar_boundaries_without_a_time_dependency() {
assert_eq!(format_utc_timestamp(0), "1970-01-01T00:00:00Z");
assert_eq!(format_utc_timestamp(951_782_400), "2000-02-29T00:00:00Z");
}
#[test]
fn creates_nested_directories_appends_extension_and_writes_exact_utf8() {
let nonce = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock should follow the Unix epoch")
.as_nanos();
let root =
std::env::temp_dir().join(format!("archunit-metrics-export-{}-{nonce}", process::id()));
let requested = root.join("nested/report");
let data = MetricsReportData::from([("Cohesion".to_owned(), "0.2".to_owned())]);
let written = MetricsExporter::export_as_html_with(&data, &requested, &options())
.expect("report should be written");
let bytes = fs::read(&written).expect("written report should be readable");
let rendered = MetricsExporter::render_html_with(&data, &options())
.expect("the same report should render");
assert_eq!(written, requested.with_file_name("report.html"));
assert_eq!(bytes, rendered.as_bytes());
fs::remove_dir_all(root).expect("temporary export should be removable");
}
#[test]
fn invalid_options_and_output_paths_are_user_errors() {
let data = MetricsReportData::new();
let cases = [
MetricsExporter::render_html_with(&data, &MetricsExportOptions::new().with_title(" ")),
MetricsExporter::render_html_with(
&data,
&MetricsExportOptions::new().with_custom_css(""),
),
MetricsExporter::export_as_html_with(&data, "", &options()).map(|_| String::new()),
];
for result in cases {
assert!(matches!(result, Err(ArchUnitError::User(_))));
}
}
}