use std::path::Path;
use slopguard_core::finding::Finding;
use slopguard_core::rule::{Language, Severity};
pub(super) const MAX_SNIPPET_CHARS: usize = 2000;
pub(super) fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
pub(super) fn utc_timestamp(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719_468;
let shifted = if z >= 0 { z } else { z - 146_096 };
let era = shifted / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mth = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if mth <= 2 { y + 1 } else { y };
format!("{year:04}-{mth:02}-{d:02} {h:02}:{m:02}:{s:02} UTC")
}
pub(super) fn severity_class(label: &str) -> &'static str {
match label {
"error" => "error",
"warning" => "warning",
_ => "",
}
}
pub(super) fn bars(rows: &[(&str, usize)], class_of: impl Fn(&str) -> &'static str) -> String {
let max = rows.iter().map(|(_, count)| *count).max().unwrap_or(0);
let mut out = String::with_capacity(rows.len() * 200);
for &(label, count) in rows {
let pct = count * 100 / max.max(1);
let class = class_of(label);
let escaped = escape_html(label);
out.push_str(&format!(
"<div class=\"bar-row\">\
<span class=\"bar-label\">{escaped}</span>\
<span class=\"bar-track\">\
<span class=\"bar {class}\" style=\"width:{pct}%\"></span>\
</span>\
<span class=\"bar-count\">{count}</span>\
</div>"
));
}
out
}
pub(super) fn options(rows: &[(&str, usize)]) -> String {
let mut out = String::new();
for &(label, count) in rows.iter().filter(|(_, count)| *count > 0) {
let escaped = escape_html(label);
let option = format!("<option value=\"{escaped}\">{escaped} ({count})</option>");
out.push_str(&option);
}
out
}
fn severity_rank(severity: &Severity) -> u8 {
match severity {
Severity::Error => 0,
Severity::Warning => 1,
}
}
fn sort_key(f: &Finding) -> (u8, &Path, usize, usize) {
(
severity_rank(&f.severity),
f.file.as_path(),
f.line,
f.column,
)
}
pub(super) fn sort_findings(findings: &[Finding]) -> Vec<&Finding> {
let mut sorted: Vec<&Finding> = findings.iter().collect();
sorted.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)));
sorted
}
fn language_slug(path: &Path) -> &'static str {
match crate::language_for_path(path) {
Some(Language::Rust) => "rust",
Some(Language::TypeScript) => "typescript",
None => "other",
}
}
fn truncate_snippet(text: &str) -> String {
if text.chars().count() > MAX_SNIPPET_CHARS {
let mut kept: String = text.chars().take(MAX_SNIPPET_CHARS).collect();
kept.push_str("\n...");
kept
} else {
text.to_string()
}
}
pub(super) fn rows(findings: &[&Finding]) -> String {
if findings.is_empty() {
return "<tr class=\"empty\"><td colspan=\"4\">No findings.</td></tr>".to_string();
}
let mut out = String::with_capacity(findings.len() * 512);
for f in findings {
let severity = f.severity.to_string();
let category = f.category.to_string();
let language = language_slug(&f.file);
let file = escape_html(&f.file.display().to_string());
let escalated_badge = if f.escalated {
"<span class=\"esc\">escalated</span>"
} else {
""
};
out.push_str(&format!(
"<tr data-severity=\"{severity}\" data-category=\"{category}\" \
data-language=\"{language}\" data-file=\"{file}\" \
data-escalated=\"{escalated}\">\
<td><span class=\"sev {severity}\">{severity}</span>{escalated_badge}</td>\
<td><code>{rule}</code></td>\
<td class=\"loc\">{file}:{line}:{column}</td>\
<td>{message}</td></tr>",
rule = escape_html(f.rule_id.as_str()),
line = f.line,
column = f.column,
message = escape_html(&f.message),
escalated = f.escalated
));
out.push_str(&format!(
"<tr class=\"detail\"><td colspan=\"4\"><pre><code>{snippet}</code></pre>",
snippet = escape_html(&truncate_snippet(&f.matched_text))
));
if let Some(note) = &f.note {
out.push_str(&format!("<p class=\"note\">{}</p>", escape_html(note)));
}
if let Some(fix) = &f.fix {
out.push_str(&format!("<p class=\"fix\">{}</p>", escape_html(fix)));
}
out.push_str("</td></tr>");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_angle_brackets_and_ampersands() {
let out = escape_html("<a href=\"x\">&'");
assert_eq!(out, "<a href="x">&'");
assert!(
!out.contains("&amp;"),
"the ampersand should be escaped exactly once, got: {out}"
);
}
#[test]
fn utc_timestamp_epoch() {
assert_eq!(utc_timestamp(0), "1970-01-01 00:00:00 UTC");
}
#[test]
fn utc_timestamp_known_date() {
assert_eq!(utc_timestamp(1_700_000_000), "2023-11-14 22:13:20 UTC");
}
#[test]
fn utc_timestamp_leap_day() {
assert_eq!(utc_timestamp(1_709_164_800), "2024-02-29 00:00:00 UTC");
}
#[test]
fn language_slug_maps_known_extensions() {
assert_eq!(language_slug(Path::new("a.rs")), "rust");
assert_eq!(language_slug(Path::new("a.tsx")), "typescript");
assert_eq!(language_slug(Path::new("a.py")), "other");
}
}