// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
#![allow(clippy::multiple_crate_versions)]
use std::collections::BTreeMap;
use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use askama::Template;
use chrono::{DateTime, FixedOffset, Utc};
use sloc_core::{AnalysisRun, FileRecord};
// Embed logo images at compile time so every generated HTML report is fully
// self-contained. Server-relative paths like /images/logo/... break when the
// HTML is rendered by Chrome via file:// (PDF export) or opened from disk.
static LOGO_TEXT_PNG: &[u8] = include_bytes!("../assets/logo/logo-text.png");
static SMALL_LOGO_PNG: &[u8] = include_bytes!("../assets/logo/small-logo.png");
fn png_data_uri(bytes: &[u8]) -> String {
format!("data:image/png;base64,{}", base64_encode(bytes))
}
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = u32::from(chunk[0]);
let b1 = if chunk.len() > 1 {
u32::from(chunk[1])
} else {
0
};
let b2 = if chunk.len() > 2 {
u32::from(chunk[2])
} else {
0
};
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(CHARS[((n >> 18) & 63) as usize] as char);
out.push(CHARS[((n >> 12) & 63) as usize] as char);
out.push(if chunk.len() > 1 {
CHARS[((n >> 6) & 63) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
CHARS[(n & 63) as usize] as char
} else {
'='
});
}
out
}
/// Render a full standalone HTML report for the given analysis run.
///
/// # Errors
///
/// Returns an error if template rendering or configuration serialization fails.
pub fn render_html(run: &AnalysisRun) -> Result<String> {
render_html_inner(run, false)
}
/// Render an embedded sub-report HTML fragment for the given analysis run.
///
/// # Errors
///
/// Returns an error if template rendering or configuration serialization fails.
pub fn render_sub_report_html(run: &AnalysisRun) -> Result<String> {
render_html_inner(run, true)
}
fn render_html_inner(run: &AnalysisRun, is_sub_report: bool) -> Result<String> {
let config_json = serde_json::to_string_pretty(&run.effective_configuration)
.context("failed to serialize effective configuration")?;
let warning_summary_rows = summarize_warnings(&run.warnings);
let warning_opportunity_rows = build_support_opportunities(&run.warnings);
let logo_text_uri = png_data_uri(LOGO_TEXT_PNG);
let small_logo_uri = png_data_uri(SMALL_LOGO_PNG);
let template = ReportTemplate {
// Empty nonce for disk-saved reports; patch_html_nonce replaces it
// with the request nonce when serving from the web server.
nonce: String::new(),
title: run.effective_configuration.reporting.report_title.clone(),
browser_title: format!(
"Oxide-SLOC | {}",
run.effective_configuration.reporting.report_title
),
generated_display: format!("{} (PST)", to_pst_display(run.tool.timestamp_utc)),
scan_performed_by: format!(
"{} / {}",
run.environment.initiator_username, run.environment.initiator_hostname
),
scan_time_pst: to_pst_display(run.tool.timestamp_utc),
tool_version: run.tool.version.clone(),
is_sub_report,
run,
language_rows: run
.totals_by_language
.iter()
.map(|row| LanguageRow {
language: row.language.display_name().to_string(),
files: row.files,
total_physical_lines: row.total_physical_lines,
code_lines: row.code_lines,
comment_lines: row.comment_lines,
blank_lines: row.blank_lines,
mixed_lines_separate: row.mixed_lines_separate,
functions: row.functions,
classes: row.classes,
variables: row.variables,
imports: row.imports,
})
.collect(),
file_rows: run.per_file_records.iter().map(file_row_view).collect(),
skipped_rows: run.skipped_file_records.iter().map(file_row_view).collect(),
config_json,
lang_chart_json: {
let entries: Vec<String> = run
.totals_by_language
.iter()
.take(12)
.map(|l| {
let name = l
.language
.display_name()
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!(
r#"{{"lang":"{}","code":{},"comments":{},"blanks":{}}}"#,
name, l.code_lines, l.comment_lines, l.blank_lines,
)
})
.collect();
format!("[{}]", entries.join(","))
},
has_run_warnings: !run.warnings.is_empty(),
warning_count: run.warnings.len(),
warning_summary_rows,
warning_opportunity_rows,
warning_console_preview: build_warning_console_preview(&run.warnings, 12),
warning_console_full: build_warning_console(&run.warnings),
warning_preview_truncated: run.warnings.len() > 12,
logo_text_uri,
small_logo_uri,
};
template.render().context("failed to render HTML report")
}
/// Render an HTML report and write it to `output_path`.
///
/// # Errors
///
/// Returns an error if rendering fails or the file cannot be written.
pub fn write_html(run: &AnalysisRun, output_path: &Path) -> Result<()> {
let html = render_html(run)?;
fs::write(output_path, html)
.with_context(|| format!("failed to write HTML report to {}", output_path.display()))
}
/// Build the argument list for the browser process.
fn build_browser_args<'a>(
headless_flag: &'a str,
user_data_arg: &'a str,
print_to_pdf_arg: &'a str,
file_url: &'a str,
no_sandbox: bool,
) -> Vec<&'a str> {
let mut args: Vec<&str> = vec![
headless_flag,
"--disable-gpu",
"--disable-extensions",
"--disable-background-networking",
"--disable-sync",
"--no-first-run",
"--no-default-browser-check",
"--disable-default-apps",
"--hide-scrollbars",
"--mute-audio",
"--print-to-pdf-no-header",
"--no-pdf-header-footer",
"--run-all-compositor-stages-before-draw",
"--virtual-time-budget=8000",
"--force-device-scale-factor=1",
user_data_arg,
print_to_pdf_arg,
file_url,
];
if no_sandbox {
args.push("--no-sandbox");
}
args
}
/// Poll for the PDF file to reach a stable non-zero size.
/// Returns `true` if stable, `false` if not yet ready.
fn poll_pdf_stable(pdf_path: &Path, last_size: &mut Option<u64>, stable_polls: &mut u32) -> bool {
let Ok(meta) = fs::metadata(pdf_path) else {
return false;
};
let size = meta.len();
if size == 0 {
return false;
}
if *last_size == Some(size) {
*stable_polls += 1;
} else {
*last_size = Some(size);
*stable_polls = 0;
}
*stable_polls >= 3
}
/// Handle browser exit status, returning Ok if PDF was produced or an error otherwise.
fn handle_browser_exit(
status: std::process::ExitStatus,
headless_flag: &str,
absolute_pdf: &Path,
) -> Result<()> {
eprintln!(
"[oxide-sloc][pdf] {} exit = {:?}",
headless_flag,
status.code()
);
if status.success() && absolute_pdf.exists() {
return Ok(());
}
if status.success() {
anyhow::bail!("browser exited successfully but PDF file was not created");
}
anyhow::bail!(
"browser exited with status {} while generating PDF",
status
.code()
.map_or_else(|| "unknown".into(), |code| code.to_string())
);
}
/// Wait loop: poll until the PDF is stable, the browser exits, or we time out.
fn wait_for_pdf_stable(
child: &mut std::process::Child,
browser_display: &std::path::Display<'_>,
headless_flag: &str,
absolute_pdf: &Path,
) -> Result<()> {
let started = std::time::Instant::now();
let mut last_size: Option<u64> = None;
let mut stable_polls: u32 = 0;
loop {
if poll_pdf_stable(absolute_pdf, &mut last_size, &mut stable_polls) {
let size = last_size.unwrap_or(0);
eprintln!("[oxide-sloc][pdf] file ready at {size} bytes");
let _ = child.kill();
let _ = child.wait();
return Ok(());
}
if let Some(status) = child
.try_wait()
.with_context(|| format!("failed while waiting for {browser_display}"))?
{
return handle_browser_exit(status, headless_flag, absolute_pdf);
}
if started.elapsed() > std::time::Duration::from_secs(45) {
let _ = child.kill();
let _ = child.wait();
if let Ok(meta) = fs::metadata(absolute_pdf) {
if meta.len() > 0 {
eprintln!(
"[oxide-sloc][pdf] timeout reached but PDF exists at {} bytes",
meta.len()
);
return Ok(());
}
}
anyhow::bail!("browser timed out while generating PDF");
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
}
/// Launch a headless Chromium-based browser to print `html_path` as a PDF to `pdf_path`.
///
/// # Errors
///
/// Returns an error if no supported browser is found, the browser process fails to start,
/// or the PDF file is not produced within the timeout.
#[allow(clippy::too_many_lines)]
pub fn write_pdf_from_html(html_path: &Path, pdf_path: &Path) -> Result<()> {
// NOSONAR
eprintln!("[oxide-sloc][pdf] starting");
let browser = discover_browser().context(
"no supported Chromium-based browser found; set SLOC_BROWSER/BROWSER or install Chrome, Chromium, Edge, Brave, Vivaldi, or Opera",
)?;
eprintln!("[oxide-sloc][pdf] browser = {}", browser.display());
let absolute_html = html_path
.canonicalize()
.with_context(|| format!("failed to canonicalize {}", html_path.display()))?;
eprintln!("[oxide-sloc][pdf] html = {}", absolute_html.display());
let absolute_pdf = if pdf_path.is_absolute() {
pdf_path.to_path_buf()
} else {
std::env::current_dir()
.context("failed to resolve current working directory")?
.join(pdf_path)
};
eprintln!("[oxide-sloc][pdf] pdf = {}", absolute_pdf.display());
if let Some(parent) = absolute_pdf.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create PDF output directory {}", parent.display())
})?;
}
let html_for_url = PathBuf::from(
absolute_html
.to_string_lossy()
.trim_start_matches(r"\\?\")
.to_string(),
);
let file_url = file_url(&html_for_url);
eprintln!("[oxide-sloc][pdf] url = {file_url}");
let html_parent = absolute_html
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let profile_dir_handle = tempfile::Builder::new()
.prefix(&format!("oxide-sloc-pdf-{}-", std::process::id()))
.tempdir()
.context("failed to create temporary browser profile directory")?;
let profile_dir = profile_dir_handle.path().to_path_buf();
eprintln!("[oxide-sloc][pdf] profile = {}", profile_dir.display());
// Chrome's --print-to-pdf silently fails when the output path exceeds ~260 characters
// (Windows MAX_PATH). Write to a short path inside the profile dir (which is always in
// %TEMP%) and rename to the final destination afterward.
let chrome_pdf_path = profile_dir.join("output.pdf");
// --no-sandbox is required in Docker (and other rootless environments) where
// the Linux kernel namespacing that Chrome's sandbox relies on is unavailable.
// It is NOT enabled by default because it disables security isolation.
// Set SLOC_BROWSER_NOSANDBOX=1 when running inside a container.
let no_sandbox = std::env::var("SLOC_BROWSER_NOSANDBOX").as_deref() == Ok("1");
if no_sandbox {
eprintln!("[oxide-sloc][pdf] --no-sandbox enabled via SLOC_BROWSER_NOSANDBOX=1");
}
let run_once = |headless_flag: &str| -> Result<()> {
eprintln!("[oxide-sloc][pdf] launching {headless_flag}");
if chrome_pdf_path.exists() {
let _ = fs::remove_file(&chrome_pdf_path);
}
let user_data_arg = format!("--user-data-dir={}", profile_dir.display());
let print_to_pdf_arg = format!("--print-to-pdf={}", chrome_pdf_path.display());
let args = build_browser_args(
headless_flag,
&user_data_arg,
&print_to_pdf_arg,
&file_url,
no_sandbox,
);
let mut child = Command::new(&browser)
.current_dir(&html_parent)
.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| format!("failed to launch browser {}", browser.display()))?;
wait_for_pdf_stable(
&mut child,
&browser.display(),
headless_flag,
&chrome_pdf_path,
)
};
let result = run_once("--headless=old").or_else(|err| {
eprintln!("[oxide-sloc][pdf] --headless=old failed ({err}), trying --headless");
run_once("--headless")
});
if let Err(err) = &result {
eprintln!("[oxide-sloc][pdf] --headless failed: {err}");
}
result?;
fs::rename(&chrome_pdf_path, &absolute_pdf)
.with_context(|| format!("failed to move generated PDF to {}", absolute_pdf.display()))?;
eprintln!("[oxide-sloc][pdf] done");
Ok(())
}
fn normalize_browser_env_path(raw: &str) -> PathBuf {
let trimmed = raw.trim();
#[cfg(windows)]
{
let bytes = trimmed.as_bytes();
if bytes.len() >= 3
&& bytes[0] == b'/'
&& bytes[2] == b'/'
&& bytes[1].is_ascii_alphabetic()
{
let drive = (bytes[1] as char).to_ascii_uppercase();
let rest = &trimmed[3..];
return PathBuf::from(format!("{drive}:/{rest}"));
}
}
PathBuf::from(trimmed)
}
fn discover_browser() -> Option<PathBuf> {
for var_name in ["SLOC_BROWSER", "BROWSER"] {
if let Ok(path) = std::env::var(var_name) {
let candidate = normalize_browser_env_path(&path);
if candidate.is_file() {
return Some(candidate);
}
}
}
let names = [
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"microsoft-edge",
"msedge",
"brave",
"brave-browser",
"vivaldi",
"opera",
"opera-stable",
];
for name in names {
if let Some(path) = which_in_path(name) {
return Some(path);
}
}
#[cfg(windows)]
{
for candidate in windows_browser_candidates() {
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
#[cfg(windows)]
fn windows_browser_candidates() -> Vec<PathBuf> {
let mut paths = Vec::new();
let program_files = std::env::var_os("ProgramFiles");
let program_files_x86 = std::env::var_os("ProgramFiles(x86)");
let local_app_data = std::env::var_os("LocalAppData");
for base in [program_files, program_files_x86].into_iter().flatten() {
let base = PathBuf::from(base);
paths.push(base.join("Google/Chrome/Application/chrome.exe"));
paths.push(base.join("Microsoft/Edge/Application/msedge.exe"));
paths.push(base.join("BraveSoftware/Brave-Browser/Application/brave.exe"));
paths.push(base.join("Vivaldi/Application/vivaldi.exe"));
paths.push(base.join("Opera/launcher.exe"));
paths.push(base.join("Opera GX/launcher.exe"));
}
if let Some(base) = local_app_data {
let base = PathBuf::from(base);
paths.push(base.join("Google/Chrome/Application/chrome.exe"));
paths.push(base.join("Microsoft/Edge/Application/msedge.exe"));
paths.push(base.join("BraveSoftware/Brave-Browser/Application/brave.exe"));
paths.push(base.join("Vivaldi/Application/vivaldi.exe"));
paths.push(base.join("Programs/Opera/launcher.exe"));
paths.push(base.join("Programs/Opera GX/launcher.exe"));
}
paths
}
fn which_in_path(exe: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(exe);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
{
let candidate = dir.join(format!("{exe}.exe"));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
fn file_url(path: &Path) -> String {
let raw = path.to_string_lossy().replace('\\', "/");
let normalized = if raw.starts_with('/') {
raw
} else {
format!("/{raw}")
};
let mut encoded = String::with_capacity(normalized.len() + 8);
for byte in normalized.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' | b':' => {
encoded.push(byte as char);
}
_ => {
let _ = write!(encoded, "%{byte:02X}");
}
}
}
format!("file://{encoded}")
}
fn file_row_view(file: &FileRecord) -> FileRow {
FileRow {
relative_path: file.relative_path.clone(),
language: file.language.map_or_else(
|| "-".into(),
|language| language.display_name().to_string(),
),
total_physical_lines: file.raw_line_categories.total_physical_lines,
code_lines: file.effective_counts.code_lines,
comment_lines: file.effective_counts.comment_lines,
blank_lines: file.effective_counts.blank_lines,
mixed_lines_separate: file.effective_counts.mixed_lines_separate,
functions: file.raw_line_categories.functions,
classes: file.raw_line_categories.classes,
variables: file.raw_line_categories.variables,
imports: file.raw_line_categories.imports,
status: format!("{:?}", file.status),
status_class: format!("{:?}", file.status).to_ascii_lowercase(),
warnings: if file.warnings.is_empty() {
String::new()
} else {
file.warnings.join("; ")
},
}
}
fn to_pst_display(dt: DateTime<Utc>) -> String {
// PST = UTC−8 fixed offset (no DST adjustment)
let pst = FixedOffset::west_opt(8 * 3600).expect("valid PST offset");
dt.with_timezone(&pst)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
}
fn build_warning_console(warnings: &[String]) -> String {
if warnings.is_empty() {
return "No top-level warnings.".to_string();
}
warnings
.iter()
.enumerate()
.map(|(index, warning)| {
format!(
"[{index:03}] {warning}",
index = index + 1,
warning = warning
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn build_warning_console_preview(warnings: &[String], limit: usize) -> String {
if warnings.is_empty() {
return "No top-level warnings.".to_string();
}
warnings
.iter()
.take(limit)
.enumerate()
.map(|(index, warning)| {
format!(
"[{index:03}] {warning}",
index = index + 1,
warning = warning
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn summarize_warnings(warnings: &[String]) -> Vec<WarningSummaryRow> {
let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
for warning in warnings {
let key = if warning.contains("unsupported or undetected language") {
"Unsupported or undetected text formats"
} else if warning.contains("file exceeded max_file_size_bytes") {
"Large files skipped by size limit"
} else if warning.contains("binary file skipped by default") {
"Binary assets skipped"
} else if warning.contains("minified file skipped by policy") {
"Minified files skipped by policy"
} else if warning.contains("vendor file skipped by policy") {
"Vendor files skipped by policy"
} else if warning.contains("best effort") || warning.contains("unclosed string literal") {
"Best-effort parse results"
} else {
"Other warnings"
};
*counts.entry(key).or_default() += 1;
}
counts
.into_iter()
.map(|(label, count)| {
let (tone_class, detail) = match label {
"Unsupported or undetected text formats" => (
"tone-neutral",
"These are usually docs, manifests, templates, or formats that have not been promoted into first-class analyzers yet.",
),
"Large files skipped by size limit" => (
"tone-warn",
"Artifacts and archives larger than the configured cap were skipped intentionally to keep runs fast and predictable.",
),
"Binary assets skipped" => (
"tone-neutral",
"Binary bundles are excluded from source counting unless you explicitly opt into them.",
),
"Minified files skipped by policy" => (
"tone-warn",
"Generated and minified assets are being filtered out to avoid inflating code totals.",
),
"Vendor files skipped by policy" => (
"tone-neutral",
"Vendored third-party code is being excluded so the report stays focused on repository-owned source.",
),
"Best-effort parse results" => (
"tone-danger",
"These files were analyzed, but the parser hit malformed or ambiguous content and fell back to a best-effort count.",
),
_ => (
"tone-danger",
"Warnings in this bucket need manual review because they do not match one of the common policy-based skip reasons.",
),
};
WarningSummaryRow {
label: label.to_string(),
count,
tone_class: tone_class.to_string(),
detail: detail.to_string(),
}
})
.collect()
}
/// Classify an unsupported-language warning path into a named bucket.
fn classify_unsupported_path(path: &str) -> &'static str {
let ext_lc = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
if ext_lc == "md"
|| path.ends_with("README")
|| path.ends_with("README.md")
|| path.ends_with("LICENSE")
{
"Documentation / text"
} else if ext_lc == "json" || path.ends_with(".spdx.json") || path.ends_with("devkit.json") {
"JSON manifests and config"
} else if ext_lc == "toml"
|| path.ends_with("MANIFEST.in")
|| path.ends_with("requirements.txt")
{
"Project metadata and packaging"
} else if ext_lc == "html" {
"HTML templates"
} else if ext_lc == "txt" {
"Plain text assets"
} else if ext_lc.is_empty() {
"Extensionless or custom text files"
} else {
"Other unsupported text formats"
}
}
/// Map a bucket label to its recommendation string.
fn bucket_recommendation(label: &str) -> String {
match label {
"Documentation / text" => "Add a docs/text classification path so README, LICENSE, and markdown stop appearing as source-language misses.".to_string(),
"JSON manifests and config" => "Promote JSON manifests into a metadata bucket or add a light-weight JSON analyzer if you want them counted separately.".to_string(),
"Project metadata and packaging" => "Treat TOML, MANIFEST.in, and requirements files as metadata so they become intentional non-source records instead of generic warnings.".to_string(),
"HTML templates" => "Add HTML/template detection for web views and server-rendered pages to reduce unsupported-template noise.".to_string(),
"Plain text assets" => "Classify text asset placeholders as plain text or ignore them by policy.".to_string(),
_ => "Review this bucket and either map it to an existing metadata class or create a dedicated analyzer when it truly represents source.".to_string(),
}
}
fn build_support_opportunities(warnings: &[String]) -> Vec<WarningOpportunityRow> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for warning in warnings {
if !warning.contains("unsupported or undetected language") {
continue;
}
let path = warning
.split_once(':')
.map(|(path, _)| path.trim())
.unwrap_or_default();
if path.is_empty() {
continue;
}
let bucket = classify_unsupported_path(path);
*counts.entry(bucket.to_string()).or_default() += 1;
}
let mut rows = counts.into_iter().collect::<Vec<_>>();
rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
rows.into_iter()
.map(|(label, count)| {
let recommendation = bucket_recommendation(&label);
WarningOpportunityRow {
label,
count,
recommendation,
}
})
.collect()
}
#[derive(Debug, Clone)]
struct LanguageRow {
language: String,
files: u64,
total_physical_lines: u64,
code_lines: u64,
comment_lines: u64,
blank_lines: u64,
mixed_lines_separate: u64,
functions: u64,
classes: u64,
variables: u64,
imports: u64,
}
#[derive(Debug, Clone)]
struct FileRow {
relative_path: String,
language: String,
total_physical_lines: u64,
code_lines: u64,
comment_lines: u64,
blank_lines: u64,
mixed_lines_separate: u64,
functions: u64,
classes: u64,
variables: u64,
imports: u64,
status: String,
status_class: String,
warnings: String,
}
#[derive(Debug, Clone)]
struct WarningSummaryRow {
label: String,
count: usize,
tone_class: String,
detail: String,
}
#[derive(Debug, Clone)]
struct WarningOpportunityRow {
label: String,
count: usize,
recommendation: String,
}
#[derive(Template)]
#[template(
source = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ browser_title }}</title>
<link rel="icon" href="{{ small_logo_uri }}" type="image/png" />
<style nonce="{{ nonce }}">
:root {
--radius: 18px;
--bg: #f5efe8;
--surface: rgba(255,255,255,0.82);
--surface-2: #fbf7f2;
--surface-3: #efe6dc;
--line: #e6d0bf;
--line-strong: #dcb89f;
--text: #43342d;
--muted: #7b675b;
--muted-2: #a08777;
--nav: #b85d33;
--nav-2: #7a371b;
--accent: #6f9bff;
--accent-2: #4a78ee;
--oxide: #d37a4c;
--oxide-2: #b35428;
--shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
--shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
--good-bg: #e8f5ed;
--good-text: #1a8f47;
--warn-bg: #fff4dc;
--warn-text: #9a6d00;
--danger-bg: #fdebec;
--danger-text: #cc4b4b;
--info-bg: #eef3ff;
--info-text: #4467d8;
}
body.dark-theme {
--bg: #1b1511;
--surface: #261c17;
--surface-2: #2d221d;
--surface-3: #372922;
--line: #524238;
--line-strong: #6c5649;
--text: #f5ece6;
--muted: #c7b7aa;
--muted-2: #aa9485;
--nav: #b85d33;
--nav-2: #7a371b;
--accent: #6f9bff;
--accent-2: #4a78ee;
--oxide: #d37a4c;
--oxide-2: #b35428;
--shadow: 0 18px 42px rgba(0,0,0,0.28);
--shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
--good-bg: #163927;
--good-text: #8fe2a8;
--warn-bg: #3c2d11;
--warn-text: #f3cb75;
--danger-bg: #3d1f1f;
--danger-text: #ff9f9f;
--info-bg: #1c2847;
--info-text: #a9c1ff;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
.top-nav { position: sticky; top: 0; z-index: 30; background: linear-gradient(180deg, var(--nav), var(--nav-2)); border-bottom: 1px solid rgba(255,255,255,0.12); box-shadow: 0 4px 14px rgba(0,0,0,0.18); }
.top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: flex; align-items: center; position: relative; }
.brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; flex: 0 0 auto; }
.brand-logo { width: 42px; height: 46px; object-fit: contain; flex: 0 0 auto; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.22)); }
.background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
.background-watermarks img { position: absolute; opacity: 0.15; filter: blur(0.3px); user-select: none; max-width: none; }
.brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
.brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
.brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
.nav-project-slot { position: absolute; left: 50%; transform: translateX(-50%); pointer-events: none; }
.nav-project-pill, .nav-pill, .theme-toggle, .header-button {
display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 14px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); color: #fff; background: rgba(255,255,255,0.10); font-size: 12px; font-weight: 700; box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
}
.nav-project-pill { pointer-events: auto; max-width: 280px; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
.nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; }
.nav-status { display:flex; align-items:center; justify-content:flex-end; gap:10px; flex-wrap:wrap; margin-left: auto; }
.theme-toggle, .header-button { cursor:pointer; background: rgba(255,255,255,0.08); text-decoration:none; }
.theme-toggle { width: 38px; justify-content:center; padding:0; }
.nav-dropdown-wrap { position: relative; padding-bottom: 6px; }
.nav-dropdown-trigger { }
.nav-dropdown-menu { display: none; position: absolute; top: 100%; right: 0; background: var(--nav-2); border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; min-width: 140px; padding: 6px; z-index: 50; box-shadow: 0 8px 24px rgba(0,0,0,0.28); }
.nav-dropdown-wrap:hover .nav-dropdown-menu, .nav-dropdown-wrap:focus-within .nav-dropdown-menu { display: flex; flex-direction: column; gap: 2px; }
.nav-dropdown-item { display: block; width: 100%; padding: 8px 12px; border: none; border-radius: 7px; background: transparent; color: #fff; font-size: 13px; font-weight: 700; text-align: left; cursor: pointer; }
.nav-dropdown-item:hover { background: rgba(255,255,255,0.12); }
.theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
.theme-toggle .icon-sun { display:none; }
body.dark-theme .theme-toggle .icon-sun { display:block; }
body.dark-theme .theme-toggle .icon-moon { display:none; }
.page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
.summary-grid { display:grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap:14px; }
.panel, .metric, .warning-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
.panel { padding: 20px; }
.metric { padding: 18px 18px 28px; position: relative; cursor: help; transition: transform 0.15s ease, box-shadow 0.15s ease; min-height: 96px; }
.metric:hover { transform: translateY(-3px); box-shadow: var(--shadow-strong); }
.metric-label, .section-kicker { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); }
.metric-value { margin-top: 8px; }
.metric-big { display:block; font-size: 30px; font-weight: 800; color: var(--text); line-height: 1.15; letter-spacing: -0.02em; }
.metric-exact { position: absolute; bottom: 9px; right: 13px; font-size: 11px; font-weight: 600; color: var(--muted-2); font-family: ui-monospace, monospace; }
.metric-tooltip { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 8px 12px; border-radius: 10px; font-size: 12px; font-weight: 500; line-height: 1.45; white-space: normal; max-width: 220px; text-align: center; pointer-events: none; opacity: 0; transition: opacity 0.18s ease; z-index: 100; box-shadow: 0 4px 14px rgba(0,0,0,0.22); }
.metric-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--text); }
.metric:hover .metric-tooltip { opacity: 1; }
.hero { padding: 24px 24px 20px; margin-bottom: 18px; background: linear-gradient(150deg, rgba(111,155,255,0.06) 0%, transparent 55%), var(--surface); border-top: 3px solid var(--accent); }
.hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
.hero h1 { margin:0 0 8px; font-size: 28px; letter-spacing: -0.04em; }
.run-id-row { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:10px; margin-top:16px; }
@media(max-width:960px) { .run-id-row { grid-template-columns: 1fr 1fr; } }
@media(max-width:560px) { .run-id-row { grid-template-columns: 1fr; } }
.run-id-chip { display:flex; flex-direction:column; gap:5px; padding:12px 14px; border-radius:10px; background:var(--surface-2); border:1px solid var(--line); border-left:3px solid var(--accent); color:var(--text); }
.run-id-chip.muted-chip { border-left-color:var(--line-strong); }
.run-id-chip-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.1em; color:var(--accent); }
.run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
.run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
.subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
.meta { display:flex; flex-wrap:wrap; align-items:center; gap:0; margin:14px 0 20px; padding:10px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); }
.meta-chip { display:inline-flex; align-items:center; gap:5px; padding:0 14px; font-size:13px; font-weight:500; color:var(--muted); border-right:1px solid var(--line); line-height:1.8; }
.meta-chip:first-child { padding-left:0; }
.meta-chip:last-child { border-right:none; }
.meta-chip b { color:var(--text); font-weight:700; }
.soft-chip { display:inline-flex; align-items:center; min-height:32px; padding:0 12px; border-radius:999px; border:1px solid var(--line); background:var(--surface-2); color:var(--text); font-size:13px; font-weight:700; }
.toolbar { display:flex; flex-wrap:wrap; justify-content:space-between; gap: 12px; align-items: center; margin-bottom: 16px; }
.toolbar-left { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.search { min-width: 280px; padding: 10px 12px; border-radius: 10px; border:1px solid var(--line-strong); background: var(--surface-2); color:var(--text); }
.pill-row { display:flex; gap:8px; flex-wrap:wrap; }
.pill { padding: 6px 10px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
.pill.good { background: var(--good-bg); color: var(--good-text); }
.pill.info { background: var(--info-bg); color: var(--info-text); }
.export-group { display:flex; gap:6px; align-items:center; }
.export-btn { display:inline-flex; align-items:center; gap:5px; padding:6px 12px; border-radius:8px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--text); font-size:12px; font-weight:700; cursor:pointer; white-space:nowrap; }
.export-btn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
.table-shell { border: 1px solid var(--line); border-radius: 16px; overflow: auto; background: var(--surface-2); max-height: 900px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { text-align: left; padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 800; background: var(--surface-2); cursor: pointer; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
/* Per-file detail table — fixed layout keeps all columns visible */
.table-resizable { table-layout: fixed; }
.table-resizable th { position: sticky; top: 0; z-index: 2; overflow: hidden; white-space: nowrap; min-width: 52px; }
.table-resizable td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#skipped-table th, #skipped-table td { padding: 7px 10px; }
/* Column resize handle */
.col-resize-handle { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 10; }
.col-resize-handle:hover, .col-resize-handle.dragging { background: rgba(211,122,76,0.3); }
#per-file-table { min-width: 1200px; }
/* Numeric columns get more breathing room */
#per-file-table th, #per-file-table td { padding: 11px 14px; }
/* File column stays pinned so it never scrolls out of view */
#per-file-table th:first-child { position: sticky; top: 0; left: 0; z-index: 3; min-width: 200px; background: var(--surface-2); padding: 11px 10px 11px 14px; }
#per-file-table td:first-child { position: sticky; left: 0; z-index: 1; background: var(--surface-2); padding: 11px 10px 11px 14px; }
#per-file-table tbody tr:hover td:first-child { background: rgba(255,247,238,0.6); }
body.dark-theme #per-file-table tbody tr:hover td:first-child { background: rgba(255,255,255,0.03); }
.num-col { text-align: right !important; }
tbody tr:hover { background: rgba(255, 247, 238, 0.6); }
body.dark-theme tbody tr:hover { background: rgba(255,255,255,0.03); }
tr:last-child td { border-bottom: none; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.small { color: var(--muted); font-size: 13px; }
.status-tag { display:inline-flex; align-items:center; padding: 4px 8px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
.status-analyzedexact { background: var(--good-bg); color: var(--good-text); border-color: rgba(28,135,70,0.18); }
.status-analyzedbesteffort, .status-skippedbypolicy { background: var(--warn-bg); color: var(--warn-text); border-color: rgba(146,96,0,0.18); }
.status-skippedunsupported, .status-skippedbinary { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(179,59,59,0.18); }
.stack { display:grid; gap:22px; }
.report-stack { display:grid; gap: 18px; align-items:start; }
pre { background: var(--surface-2); border: 1px solid var(--line); border-radius: 16px; padding: 16px; overflow: auto; font-size: 12px; color: var(--text); }
.warn-list { margin: 0; padding-left: 18px; line-height: 1.6; }
.sort-indicator { color: var(--muted-2); font-size: 11px; margin-left: 6px; }
.warning-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.warning-card { padding: 10px 12px; }
.warning-card h3 { margin: 0 0 4px; font-size: 12px; font-weight: 700; }
.warning-card .count { font-size: 16px; font-weight: 800; margin-bottom: 4px; }
.tone-neutral .count { color: var(--text); }
.tone-warn .count { color: var(--warn-text); }
.tone-danger .count { color: var(--danger-text); }
.support-note { color: var(--muted); font-size: 11px; line-height: 1.45; }
.support-table th { cursor: default; }
details { border: 1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
summary { cursor: pointer; padding: 14px 16px; font-weight: 700; }
details > div { padding: 0 16px 16px; }
.warning-console { margin: 0; padding: 14px 16px; border-radius: 12px; border:1px solid var(--line); background: #16120f; color: #d4f0d0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; line-height: 1.55; max-height: 260px; overflow: auto; }
.warning-console-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top: 12px; }
.warning-console.hidden { display:none; }
@media (max-width: 1200px) {
.summary-grid, .warning-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 960px) {
.top-nav-inner { grid-template-columns: 1fr; }
.nav-project-slot, .nav-status { justify-content:flex-start; }
.summary-grid, .warning-grid, .report-stack { grid-template-columns: 1fr; }
.hero-top { flex-direction: column; }
.search { min-width: 100%; width: 100%; }
}
/* ── Print & PDF export ──────────────────────────────────────────── */
@page {
size: A4 landscape;
margin: 0.4in 0.45in;
}
@media print {
*, *::before, *::after {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
html, body {
background: #f5efe8 !important;
min-height: auto !important;
width: 100% !important;
}
/* Hide interactive UI-chrome; keep section heading text visible */
.top-nav, .hero-actions,
.background-watermarks,
.header-button, .theme-toggle,
.nav-dropdown-wrap, .config-actions,
.warnings-show-link, .warning-console-actions,
.toolbar .pill-row, .toolbar .export-group,
input[type="search"], button { display: none !important; }
/* Show toolbar as a plain block so h2 headings are visible */
.toolbar { display: block !important; margin-bottom: 8px !important; }
.toolbar-left { display: block !important; }
/* Remove page-level layout constraints */
.page {
max-width: none !important;
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
.panel, .hero, .section,
.saved-report-shell, .saved-panel, .report-shell, .stack {
max-width: none !important;
width: 100% !important;
box-shadow: none !important;
border: 1px solid #ddd !important;
border-radius: 10px !important;
margin-bottom: 10px !important;
}
/* Force grids to their full-width column counts regardless of viewport */
.summary-grid {
display: grid !important;
grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
gap: 10px !important;
}
.warning-grid {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 8px !important;
}
.report-stack {
display: grid !important;
gap: 12px !important;
align-items: start !important;
}
/* Metric cards */
.metric {
box-shadow: none !important;
border: 1px solid #e0d0c0 !important;
border-radius: 8px !important;
break-inside: avoid !important;
padding: 10px 12px 22px !important;
min-height: 0 !important;
}
.metric-big { font-size: 20px !important; }
.metric-exact { font-size: 9px !important; bottom: 5px !important; right: 8px !important; }
.metric-label { font-size: 10px !important; }
/* Page break control — small atomic cards stay together; large panels
and tables flow freely so they never force blank pages. */
.metric, .warning-card, .run-id-chip { break-inside: avoid !important; }
.hero, .panel, .stack { break-inside: auto !important; }
section { break-inside: auto !important; }
/* Keep the summary grid on the same page as the hero header when possible */
.summary-grid { break-before: avoid !important; }
/* Section headings never orphan at the bottom of a page */
h2, h3 { break-after: avoid !important; orphans: 3; widows: 3; }
/* Keep the first few rows of a table with the header */
thead { break-after: avoid !important; }
/* Language charts — table layout is inherently side-by-side */
#lang-overview-charts table { display: inline-table !important; }
#lang-overview-charts td { vertical-align: top !important; }
/* Tables */
.table-shell {
max-height: none !important;
overflow: visible !important;
width: 100% !important;
break-inside: auto !important;
}
table {
width: 100% !important;
table-layout: auto !important;
font-size: 10px !important;
border-collapse: collapse !important;
orphans: 4 !important;
widows: 4 !important;
}
/* Remove the screen-layout min-width so tables scale to page width */
#per-file-table, #skipped-table { min-width: 0 !important; }
/* Release sticky column positioning (not meaningful on paper) */
#per-file-table th:first-child,
#per-file-table td:first-child { position: static !important; }
thead { display: table-header-group; }
tr { break-inside: avoid !important; }
th {
position: static !important;
font-size: 9px !important;
padding: 5px 8px !important;
background: rgba(211,122,76,0.12) !important;
white-space: normal !important;
}
td {
white-space: normal !important;
overflow-wrap: anywhere !important;
word-break: break-word !important;
padding: 5px 8px !important;
font-size: 10px !important;
border-bottom: 1px solid #e8d8c8 !important;
}
pre, code {
white-space: pre-wrap !important;
overflow-wrap: anywhere !important;
word-break: break-word !important;
font-size: 9px !important;
max-height: none !important;
}
.warning-card {
box-shadow: none !important;
border: 1px solid #ddd !important;
break-inside: avoid !important;
padding: 10px !important;
}
.hero-top { flex-direction: row !important; }
.run-id-row { flex-wrap: wrap !important; gap: 4px !important; }
.run-id-chip { font-size: 9px !important; padding: 4px 8px !important; border-left-width: 2px !important; }
.meta { flex-wrap: wrap !important; gap: 0 !important; padding: 4px 0 !important; border-top: 1px solid #ccc !important; border-bottom: 1px solid #ccc !important; }
.meta-chip { font-size: 9px !important; padding: 0 8px !important; border-right: 1px solid #ccc !important; }
.meta-chip:first-child { padding-left: 0 !important; }
.meta-chip:last-child { border-right: none !important; }
.report-footer {
border-top: 1px solid #ccc !important;
margin-top: 12px !important;
font-size: 10px !important;
}
/* Keep warning consoles collapsed in print — they are too long and
create blank pages when expanded. Show the summary label only. */
details { border: 1px solid #ddd !important; border-radius: 8px !important; }
details > summary { display: block !important; font-size: 10px !important; }
details > div { display: none !important; }
.warning-console { display: none !important; }
.warning-console-actions { display: none !important; }
/* Pill badges */
.pill { font-size: 9px !important; padding: 2px 6px !important; min-height: auto !important; }
/* Support opportunities table */
.support-table td:first-child { font-weight: 600; font-size: 10px !important; }
}
.warnings-show-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(111, 144, 255, 0.35);
background: #eef3ff;
color: #2f5fe3 !important;
font-weight: 800;
text-decoration: none;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.45);
}
body.dark-theme .warnings-show-link {
background: #1c2847;
color: #a9c1ff !important;
border-color: rgba(169, 193, 255, 0.32);
}
.effective-config-note {
margin: 8px 0 14px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.config-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 10px; }
.config-actions { display: flex; gap: 8px; flex-shrink: 0; margin-top: 4px; }
.config-pre { background: #16120f; color: #d4f0d0; border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; overflow: auto; resize: vertical; max-height: 320px; min-height: 100px; white-space: pre; }
body.dark-theme .config-pre { background: #0e0c0a; color: #b8f0b8; }
.page {
position: relative;
z-index: 1;
}
.report-footer { margin-top: 32px; padding: 14px 24px; border-top: 1px solid var(--line); text-align: center; color: var(--muted); font-size: 12px; font-weight: 600; }
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
<img src="{{ logo_text_uri }}" alt="" />
</div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/" onclick="if(location.protocol==='file:'){event.preventDefault();}">
<img class="brand-logo" src="{{ small_logo_uri }}" alt="OxideSLOC logo" />
<div class="brand-copy">
<div class="brand-title">OxideSLOC Local analysis workbench</div>
<div class="brand-subtitle">Saved HTML report</div>
</div>
</a>
<div class="nav-project-slot">
<div class="nav-project-pill"><span class="nav-project-label">Report </span><span class="nav-project-value">{{ title }}</span></div>
</div>
<div class="nav-status">
<span class="nav-pill">Saved artifact</span>
<button type="button" class="header-button" data-copy-link>Copy link</button>
<button type="button" class="header-button" data-share-report>Share</button>
<div class="nav-dropdown-wrap">
<button type="button" class="header-button nav-dropdown-trigger" aria-haspopup="true">Export ▾</button>
<div class="nav-dropdown-menu">
<button type="button" class="nav-dropdown-item" onclick="exportReportCsv()">Export CSV</button>
<button type="button" class="nav-dropdown-item" onclick="exportReportXls()">Export Excel</button>
</div>
</div>
<a id="nav-view-pdf-btn" href="/runs/{{ run.tool.run_id }}/pdf" target="_blank" rel="noopener" class="header-button" style="text-decoration:none;">View PDF</a>
<button type="button" class="header-button" data-print-report>Save / Print</button>
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Toggle theme" title="Toggle theme">
<svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
<svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
</button>
</div>
</div>
</div>
<div class="page">
<section class="hero panel">
<div class="hero-top">
<div>
<div class="section-kicker">Saved report artifact</div>
<h1>{{ title }}</h1>
<div class="run-id-row">
<span class="run-id-chip" title="{{ run.tool.run_id }}">
<span class="run-id-chip-label">Run ID</span>
<span class="run-id-chip-value">{{ run.tool.run_id }}</span>
</span>
{% if let Some(long_commit) = run.git_commit_long %}
<span class="run-id-chip" title="{{ long_commit }}">
<span class="run-id-chip-label">Git Commit</span>
<span class="run-id-chip-value">{{ long_commit }}</span>
</span>
{% else %}
<span class="run-id-chip muted-chip">
<span class="run-id-chip-label">Git Commit</span>
<span class="run-id-chip-value">Not detected</span>
</span>
{% endif %}
{% if let Some(branch) = run.git_branch %}
<span class="run-id-chip" title="{{ branch }}">
<span class="run-id-chip-label">Branch</span>
<span class="run-id-chip-value">{{ branch }}</span>
</span>
{% else %}
<span class="run-id-chip muted-chip">
<span class="run-id-chip-label">Branch</span>
<span class="run-id-chip-value">Not detected</span>
</span>
{% endif %}
{% if let Some(author) = run.git_commit_author %}
<span class="run-id-chip" title="{{ author }}">
<span class="run-id-chip-label">Last Commit By</span>
<span class="run-id-chip-value">{{ author }}</span>
</span>
{% else %}
<span class="run-id-chip muted-chip">
<span class="run-id-chip-label">Last Commit By</span>
<span class="run-id-chip-value">Not detected</span>
</span>
{% endif %}
</div>
</div>
</div>
<div class="meta">
<span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
<span class="meta-chip">Scanned <b>{{ scan_time_pst }} PST</b></span>
<span class="meta-chip">Generated <b>{{ generated_display }}</b></span>
<span class="meta-chip">OS <b>{{ run.environment.operating_system }} / {{ run.environment.architecture }}</b></span>
<span class="meta-chip">Files analyzed <b>{{ run.summary_totals.files_analyzed }}</b></span>
<span class="meta-chip">Files skipped <b>{{ run.summary_totals.files_skipped }}</b></span>
</div>
<div class="summary-grid">
<div class="metric" data-metric-value="{{ run.summary_totals.total_physical_lines }}"><div class="metric-tooltip">Total lines across all analyzed files, including code, comments, and blank lines.</div><div class="metric-label">Physical lines</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
<div class="metric" data-metric-value="{{ run.summary_totals.code_lines }}"><div class="metric-tooltip">Lines containing executable source code, excluding comments and blanks.</div><div class="metric-label">Code</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
<div class="metric" data-metric-value="{{ run.summary_totals.comment_lines }}"><div class="metric-tooltip">Lines consisting entirely of comments or inline documentation.</div><div class="metric-label">Comments</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
<div class="metric" data-metric-value="{{ run.summary_totals.blank_lines }}"><div class="metric-tooltip">Empty or whitespace-only lines used for readability and spacing.</div><div class="metric-label">Blank</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
<div class="metric" data-metric-value="{{ run.summary_totals.mixed_lines_separate }}"><div class="metric-tooltip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div><div class="metric-label">Mixed separate</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
<div class="metric" data-metric-value="{{ run.summary_totals.functions }}"><div class="metric-tooltip">Best-effort count of function/method definitions detected across all source files.</div><div class="metric-label">Functions</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
<div class="metric" data-metric-value="{{ run.summary_totals.classes }}"><div class="metric-tooltip">Best-effort count of class, struct, interface, and type definitions.</div><div class="metric-label">Classes / Types</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
<div class="metric" data-metric-value="{{ run.summary_totals.variables }}"><div class="metric-tooltip">Best-effort count of variable and constant declarations.</div><div class="metric-label">Variables</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
<div class="metric" data-metric-value="{{ run.summary_totals.imports }}"><div class="metric-tooltip">Best-effort count of import, include, and module-use statements.</div><div class="metric-label">Imports</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
<div class="metric" data-metric-density><div class="metric-tooltip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div><div class="metric-label">Code density</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
</div>
</section>
<div class="report-stack">
<section class="panel stack">
<div>
<div class="toolbar"><div class="toolbar-left"><h2>Language breakdown</h2></div><div class="pill-row"><span class="pill good">Click any column header to sort</span></div></div>
<div id="lang-overview-charts" style="margin:0 0 16px;"></div>
<div class="table-shell">
<table data-sort-table>
<thead>
<tr>
<th data-sort-type="text">Language</th>
<th data-sort-type="number">Files</th>
<th data-sort-type="number">Physical</th>
<th data-sort-type="number">Code</th>
<th data-sort-type="number">Comments</th>
<th data-sort-type="number">Blank</th>
<th data-sort-type="number">Mixed separate</th>
<th data-sort-type="number">Functions</th>
<th data-sort-type="number">Classes</th>
<th data-sort-type="number">Variables</th>
<th data-sort-type="number">Imports</th>
</tr>
</thead>
<tbody>
{% for row in language_rows %}
<tr>
<td>{{ row.language }}</td>
<td>{{ row.files }}</td>
<td>{{ row.total_physical_lines }}</td>
<td>{{ row.code_lines }}</td>
<td>{{ row.comment_lines }}</td>
<td>{{ row.blank_lines }}</td>
<td>{{ row.mixed_lines_separate }}</td>
<td>{{ row.functions }}</td>
<td>{{ row.classes }}</td>
<td>{{ row.variables }}</td>
<td>{{ row.imports }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
<section class="panel stack">
<div class="toolbar"><div class="toolbar-left"><h2>Per-file detail</h2><input class="search" type="search" placeholder="Filter files, languages, status, warnings..." data-table-filter="per-file-table" /></div><div class="pill-row"><span class="pill good">Counts shown as analyzed by the selected policy</span><div class="export-group"><button class="export-btn" onclick="resetPerFileTable()" title="Reset scroll and column layout">↻ Reset</button><button class="export-btn" onclick="exportReportCsv()">↓ CSV</button><button class="export-btn" onclick="exportReportXls()">↓ Excel</button></div></div></div>
<div class="table-shell">
<table id="per-file-table" data-sort-table class="table-resizable">
<colgroup>
<col><col style="width:100px"><col style="width:90px"><col style="width:72px">
<col style="width:90px"><col style="width:72px"><col style="width:76px">
<col style="width:90px"><col style="width:76px"><col style="width:90px"><col style="width:76px">
</colgroup>
<thead>
<tr>
<th data-sort-type="text">File<div class="col-resize-handle"></div></th>
<th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
<th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
<th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
<th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
<th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
<th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
<th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
<th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
<th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
<th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
</tr>
</thead>
<tbody>
{% for row in file_rows %}
<tr>
<td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
<td>{{ row.language }}</td>
<td class="num-col">{{ row.total_physical_lines }}</td>
<td class="num-col">{{ row.code_lines }}</td>
<td class="num-col">{{ row.comment_lines }}</td>
<td class="num-col">{{ row.blank_lines }}</td>
<td class="num-col">{{ row.mixed_lines_separate }}</td>
<td class="num-col">{{ row.functions }}</td>
<td class="num-col">{{ row.classes }}</td>
<td class="num-col">{{ row.variables }}</td>
<td class="num-col">{{ row.imports }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel stack">
<div class="toolbar"><div class="toolbar-left"><h2>Skipped files</h2><input class="search" type="search" placeholder="Filter skipped files, reasons, warnings..." data-table-filter="skipped-table" /></div></div>
<div class="table-shell" style="margin-top:6px;max-height:441px;">
<table id="skipped-table" data-sort-table class="table-resizable">
<thead>
<tr>
<th data-sort-type="text" style="width:55%">File</th>
<th data-sort-type="text" style="width:18%">Status</th>
<th data-sort-type="text">Warnings</th>
</tr>
</thead>
<tbody>
{% for row in skipped_rows %}
<tr>
<td class="mono">{{ row.relative_path }}</td>
<td><span class="status-tag status-{{ row.status_class }}">{{ row.status }}</span></td>
<td class="small">{{ row.warnings }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel stack">
<div>
<div class="toolbar">
<div class="toolbar-left"><h2>Diagnostics & Configuration</h2></div>
{% if !is_sub_report && has_run_warnings %}<div class="pill-row"><span class="pill info" style="font-size:11px;min-height:26px;">{{ warning_count }} total warnings</span></div>{% endif %}
</div>
<p class="effective-config-note">Warning summary, support improvement opportunities, raw diagnostic output, and the exact configuration in effect for this scan.</p>
</div>
{% if !is_sub_report %}
<div>
<h3 style="margin:0 0 10px;">Warnings overview</h3>
{% if !has_run_warnings %}
<div class="pill good">No top-level warnings.</div>
{% else %}
<div class="warning-grid">
{% for row in warning_summary_rows %}
<div class="warning-card {{ row.tone_class }}">
<h3>{{ row.label }}</h3>
<div class="count">{{ row.count }}</div>
<div class="support-note">{{ row.detail }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div>
<h3 style="margin:0 0 6px;">High-value support opportunities</h3>
<p class="support-note">Groups the noisy unsupported warnings into the next format buckets most worth classifying or supporting in the analysis core.</p>
{% if warning_opportunity_rows.is_empty() %}
<div class="pill good">No unsupported text-format buckets detected.</div>
{% else %}
<div class="table-shell">
<table class="support-table">
<thead>
<tr><th>Opportunity</th><th>Count</th><th>Recommended next move</th></tr>
</thead>
<tbody>
{% for row in warning_opportunity_rows %}
<tr>
<td>{{ row.label }}</td>
<td>{{ row.count }}</td>
<td class="small">{{ row.recommendation }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
<div>
<details>
<summary>Detailed run warnings ({{ warning_count }})</summary>
<div>
<p style="font-size:13px;color:var(--muted);margin:0 0 10px;">These are the raw warning messages emitted during the scan — file-level parse issues, encoding fallbacks, binary detections, and unsupported-language notices. High counts typically indicate large numbers of non-code assets (JSON configs, docs, lockfiles) in the target directory.</p>
{% if !has_run_warnings %}
<div class="pill good">No top-level warnings.</div>
{% else %}
<pre class="warning-console" id="warning-console-preview">{{ warning_console_preview }}</pre>
{% if warning_preview_truncated %}
<div class="warning-console-actions">
<button type="button" class="header-button" data-expand-warnings class="warnings-show-link">Show all warnings</button>
</div>
<pre class="warning-console hidden" id="warning-console-full">{{ warning_console_full }}</pre>
{% endif %}
{% endif %}
</div>
</details>
</div>
{% endif %}
<div>
<div class="config-header">
<div>
<h3 style="margin:0 0 4px;">Effective configuration</h3>
<p style="margin:0;font-size:13px;color:var(--muted);">The merged, fully-resolved configuration snapshot used for this scan — includes all CLI overrides applied on top of the base config file. Use this to replay the exact run or verify what settings were active.</p>
</div>
<div class="config-actions">
<button type="button" class="header-button" data-copy-config>Copy</button>
<button type="button" class="header-button" data-download-config>Download</button>
</div>
</div>
<pre class="config-pre" id="config-json-block">{{ config_json }}</pre>
</div>
</section>
</div>
</div>
<script nonce="{{ nonce }}">
// Hide "View PDF" button when the report is opened as a local file (not from web server)
(function () {
var pdfBtn = document.getElementById('nav-view-pdf-btn');
if (pdfBtn && window.location.protocol === 'file:') {
pdfBtn.style.display = 'none';
}
})();
(function () {
var body = document.body;
var storageKey = 'oxide-sloc-theme';
var themeToggle = document.querySelector('[data-theme-toggle]');
var copyLinkButtons = Array.prototype.slice.call(document.querySelectorAll('[data-copy-link]'));
var shareButtons = Array.prototype.slice.call(document.querySelectorAll('[data-share-report]'));
var printButtons = Array.prototype.slice.call(document.querySelectorAll('[data-print-report]'));
var expandWarningsButton = document.querySelector('[data-expand-warnings]');
function applyTheme(theme) {
body.classList.toggle('dark-theme', theme === 'dark');
}
function currentTheme() {
return body.classList.contains('dark-theme') ? 'dark' : 'light';
}
try {
var saved = localStorage.getItem(storageKey);
if (saved === 'dark' || saved === 'light') {
applyTheme(saved);
}
} catch (e) {}
if (themeToggle) {
themeToggle.addEventListener('click', function () {
var next = currentTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
try { localStorage.setItem(storageKey, next); } catch (e) {}
});
}
function copyText(value) {
if (!value) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(value).catch(function () {});
}
}
copyLinkButtons.forEach(function (button) {
button.addEventListener('click', function () {
copyText(window.location.href);
});
});
shareButtons.forEach(function (button) {
button.addEventListener('click', function () {
if (navigator.share) {
navigator.share({ title: document.title, url: window.location.href }).catch(function () {});
} else {
copyText(window.location.href);
}
});
});
printButtons.forEach(function (button) {
button.addEventListener('click', function () {
window.print();
});
});
if (expandWarningsButton) {
expandWarningsButton.addEventListener('click', function () {
var preview = document.getElementById('warning-console-preview');
var full = document.getElementById('warning-console-full');
if (preview) preview.classList.add('hidden');
if (full) full.classList.remove('hidden');
expandWarningsButton.classList.add('hidden');
});
}
var copyConfigBtn = document.querySelector('[data-copy-config]');
var downloadConfigBtn = document.querySelector('[data-download-config]');
var configBlock = document.getElementById('config-json-block');
if (copyConfigBtn && configBlock) {
copyConfigBtn.addEventListener('click', function () {
copyText(configBlock.textContent);
copyConfigBtn.textContent = 'Copied!';
setTimeout(function () { copyConfigBtn.textContent = 'Copy'; }, 1600);
});
}
if (downloadConfigBtn && configBlock) {
downloadConfigBtn.addEventListener('click', function () {
var blob = new Blob([configBlock.textContent], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'effective-config.json';
document.body.appendChild(a); a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 200);
});
}
function detectType(value) {
return /^-?\d+(?:\.\d+)?$/.test(value.trim()) ? parseFloat(value) : value.toLowerCase();
}
document.querySelectorAll('[data-sort-table]').forEach(function (table) {
var headers = Array.prototype.slice.call(table.querySelectorAll('th'));
headers.forEach(function (th, idx) {
var direction = 1;
var marker = document.createElement('span');
marker.className = 'sort-indicator';
marker.textContent = ' ↕';
th.appendChild(marker);
th.addEventListener('click', function (e) {
if (e.target.closest && e.target.closest('.col-resize-handle')) return;
var tbody = table.tBodies[0];
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function (a, b) {
var av = detectType(a.children[idx].innerText || a.children[idx].textContent || '');
var bv = detectType(b.children[idx].innerText || b.children[idx].textContent || '');
if (av < bv) return -1 * direction;
if (av > bv) return 1 * direction;
return 0;
});
rows.forEach(function (row) { tbody.appendChild(row); });
direction = direction * -1;
});
});
});
// ── Column resize for per-file-table ─────────────────────────────────────
(function() {
var table = document.getElementById('per-file-table');
if (!table) return;
var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
var ths = Array.prototype.slice.call(table.querySelectorAll('thead th'));
ths.forEach(function(th, i) {
var handle = th.querySelector('.col-resize-handle');
if (!handle || !cols[i]) return;
var startX, startW;
handle.addEventListener('mousedown', function(e) {
e.stopPropagation(); e.preventDefault();
startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
handle.classList.add('dragging');
function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
})();
document.querySelectorAll('[data-table-filter]').forEach(function (input) {
var table = document.getElementById(input.getAttribute('data-table-filter'));
if (!table) return;
input.addEventListener('input', function () {
var q = input.value.toLowerCase();
Array.prototype.slice.call(table.tBodies[0].rows).forEach(function (row) {
var text = row.innerText.toLowerCase();
row.style.display = text.indexOf(q) >= 0 ? '' : 'none';
});
});
});
})();
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
var placed = [];
function tooClose(t, l) {
for (var i = 0; i < placed.length; i++) {
var dt = Math.abs(placed[i][0] - t);
var dl = Math.abs(placed[i][1] - l);
if (dt < 18 && dl < 18) return true;
}
return false;
}
function pick(leftBias) {
for (var attempt = 0; attempt < 40; attempt++) {
var t = Math.random() * 90;
var l = leftBias ? Math.random() * 50 : 40 + Math.random() * 55;
if (!tooClose(t, l)) { placed.push([t, l]); return [t, l]; }
}
var fb = [Math.random() * 90, Math.random() * 95];
placed.push(fb);
return fb;
}
var half = Math.floor(wms.length / 2);
wms.forEach(function (img, i) {
var pos = pick(i < half);
var sz = Math.floor(Math.random() * 80 + 110);
var rot = (Math.random() * 360).toFixed(1);
var op = (Math.random() * 0.07 + 0.10).toFixed(2);
img.style.cssText = 'width:' + sz + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
});
})();
// ── Metric number formatting ─────────────────────────────────────────────
(function () {
function fmtBig(n) {
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
return n.toLocaleString();
}
function fmtExact(n) { return n.toLocaleString(); }
document.querySelectorAll('[data-metric-value]').forEach(function (el) {
var n = parseInt(el.getAttribute('data-metric-value'), 10);
if (isNaN(n)) return;
var big = el.querySelector('.metric-big');
var exact = el.querySelector('.metric-exact');
if (big) big.textContent = fmtBig(n);
if (exact) exact.textContent = n >= 1e4 ? fmtExact(n) : '';
});
var densityCard = document.querySelector('[data-metric-density]');
if (densityCard) {
var phys = 0, code = 0;
document.querySelectorAll('[data-metric-value]').forEach(function (el) {
var lbl = el.querySelector('.metric-label');
if (!lbl) return;
var t = lbl.textContent.trim().toLowerCase();
var v = parseInt(el.getAttribute('data-metric-value'), 10) || 0;
if (t === 'physical lines') phys = v;
if (t === 'code') code = v;
});
var pct = phys > 0 ? (code / phys * 100) : 0;
var big = densityCard.querySelector('.metric-big');
var exact = densityCard.querySelector('.metric-exact');
if (big) big.textContent = pct.toFixed(1) + '%';
if (exact) exact.textContent = '';
}
})();
// ── Export helpers ────────────────────────────────────────────────────────
function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
function slocDownload(data,name,mime){var b=new Blob([data],{type:mime});var u=URL.createObjectURL(b);var a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(u);},200);}
function slocCsv(fname,hdrs,rows){slocDownload([hdrs.map(slocEscCsv).join(',')].concat(rows.map(function(r){return r.map(slocEscCsv).join(',');})).join('\r\n'),fname,'text/csv;charset=utf-8;');}
function slocXls(fname,sheet,hdrs,rows){var enc=new TextEncoder();var CT=[];for(var _n=0;_n<256;_n++){var _c=_n;for(var _k=0;_k<8;_k++)_c=_c&1?0xEDB88320^(_c>>>1):_c>>>1;CT[_n]=_c;}function crc32(d){var v=0xFFFFFFFF;for(var i=0;i<d.length;i++)v=CT[(v^d[i])&0xFF]^(v>>>8);return(v^0xFFFFFFFF)>>>0;}function u2(n){return[n&0xFF,(n>>8)&0xFF];}function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}var ss=[],si={};function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}function colRef(c,r){var s='',n=c+1;while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s+r;}var rx='<row r="1">';hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});rx+='</row>';rows.forEach(function(row,ri){var rn=ri+2;rx+='<row r="'+rn+'">';row.forEach(function(cell,c){var ref=colRef(c,rn);var num=c>=2&&cell!==''&&cell!=null&&!isNaN(Number(cell));rx+=num?'<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>':'<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';});rx+='</row>';});var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="'+sns+'" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';var wsh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData></worksheet>';var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><sz val="11"/><b/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="3"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment horizontal="right"/></xf></cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';var F={'[Content_Types].xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="'+pns+'content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>','_rels/.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>','xl/workbook.xml':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets><sheet name="'+xe(sheet)+'" sheetId="1" r:id="rId1"/></sheets></workbook>','xl/_rels/workbook.xml.rels':'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships"><Relationship Id="rId1" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="'+ons+'relationships/styles" Target="styles.xml"/><Relationship Id="rId3" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>','xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':wsh};var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml'];var zparts=[],zcds=[],zoff=0,znf=0;order.forEach(function(name){var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);var lha=[0x50,0x4B,0x03,0x04,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0]);var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);var cda=[0x50,0x4B,0x01,0x02,0x14,0,0x14,0,0,0,0,0,0,0,0,0].concat(u4(cr)).concat(u4(sz)).concat(u4(sz)).concat(u2(nb.length)).concat([0,0,0,0,0,0,0,0,0,0,0,0]).concat(u4(zoff));var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);zoff+=entry.length;znf++;});var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);var ea=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});zout.set(new Uint8Array(ea),zpos);slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');}
window.resetPerFileTable = function() {
var tbl = document.getElementById('per-file-table');
if (!tbl) return;
var shell = tbl.closest('.table-shell');
if (shell) shell.scrollLeft = 0;
Array.prototype.slice.call(tbl.querySelectorAll('th')).forEach(function(th) { th.style.width = ''; });
Array.prototype.slice.call(tbl.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
};
var _rh=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed Separate','Functions','Classes','Variables','Imports'];
function getReportExportRows(){var r=[];document.querySelectorAll('#per-file-table tbody tr').forEach(function(tr){var tds=tr.querySelectorAll('td');if(tds.length<11)return;r.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim(),tds[3].textContent.trim(),tds[4].textContent.trim(),tds[5].textContent.trim(),tds[6].textContent.trim(),tds[7].textContent.trim(),tds[8].textContent.trim(),tds[9].textContent.trim(),tds[10].textContent.trim()]);});return r;}
window.exportReportCsv=function(){slocCsv('report-per-file.csv',_rh,getReportExportRows());};
window.exportReportXls=function(){slocXls('report-per-file.xlsx','Per-File Detail',_rh,getReportExportRows());};
// ── Language overview charts ─────────────────────────────────────────────
(function(){
var D={{ lang_chart_json|safe }};
if(!D||!D.length)return;
var el=document.getElementById('lang-overview-charts');
if(!el)return;
var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
function fmt(n){return Number(n).toLocaleString();}
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
function px(n){return Math.round(n);}
// Floating tooltip (adapts to light/dark theme via CSS variables)
var tt=document.createElement('div');
tt.style.cssText='display:none;position:fixed;pointer-events:none;background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:9px 13px;font-family:'+FONT+';font-size:12px;line-height:1.6;box-shadow:0 4px 18px rgba(0,0,0,0.15);z-index:9999;max-width:240px;color:var(--text);';
document.body.appendChild(tt);
function showTT(e,html){tt.innerHTML=html;tt.style.display='block';moveTT(e);}
function moveTT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;tt.style.left=x+'px';tt.style.top=y+'px';}
function hideTT(){tt.style.display='none';}
// Code lines donut — larger radius and legend area for legibility
var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
var cx=120,cy=120,Ro=100,Ri=54,DW=420,DH=Math.max(270,24+D.length*22);
// overflow:visible lets scale(1.06) hover expand beyond the SVG bounds
var ds='<svg viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;overflow:visible;" xmlns="http://www.w3.org/2000/svg">';
if(D.length===1){
// Single language: full-circle arc paths can degenerate in some renderers; use a stroked circle instead
var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
ds+='<circle class="donut-seg" data-lang="'+esc(D[0].lang)+'" data-code="'+D[0].code+'" data-pct="100" cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+COLS[0]+'" stroke-width="'+rsw+'" style="cursor:pointer;"/>';
} else {
var ang=-Math.PI/2;
D.forEach(function(d,i){
var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
var pct=Math.round(d.code/tot*100);
ds+='<path class="donut-seg" data-lang="'+esc(d.lang)+'" data-code="'+d.code+'" data-pct="'+pct+'" d="M'+px(x1)+','+px(y1)+' A'+Ro+','+Ro+' 0 '+(sw>Math.PI?1:0)+',1 '+px(x2)+','+px(y2)+' L'+px(xi1)+','+px(yi1)+' A'+Ri+','+Ri+' 0 '+(sw>Math.PI?1:0)+',0 '+px(xi2)+','+px(yi2)+' Z" fill="'+(COLS[i%COLS.length])+'" stroke="white" stroke-width="2" style="cursor:pointer;"/>';
ang+=sw;
});
}
ds+='<text x="'+cx+'" y="'+(cy-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="22" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
ds+='<text x="'+cx+'" y="'+(cy+16)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
D.forEach(function(d,i){
var ly=12+i*22;
if(ly+16>DH)return;
ds+='<rect x="'+(cx+Ro+14)+'" y="'+ly+'" width="13" height="13" fill="'+(COLS[i%COLS.length])+'" rx="3"/>';
ds+='<text x="'+(cx+Ro+32)+'" y="'+(ly+11)+'" font-family="'+FONT+'" font-size="12" fill="#43342d">'+esc(d.lang)+'</text>';
});
ds+='</svg>';
// Per-language stacked bar — wider bars and labels for legibility
var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
var LW=104,BW=280,rHb=30,bH=22,SH=D.length*rHb+36;
var bs='<svg viewBox="0 0 '+(LW+BW+62)+' '+SH+'" width="'+(LW+BW+62)+'" height="'+SH+'" style="display:block;overflow:visible;" xmlns="http://www.w3.org/2000/svg">';
D.forEach(function(d,i){
var y=10+i*rHb,x=LW;
var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
bs+='<g class="bar-row" data-lang="'+esc(d.lang)+'" data-code="'+d.code+'" data-comments="'+d.comments+'" data-blanks="'+d.blanks+'">';
bs+='<rect class="bar-hover-bg" x="0" y="'+y+'" width="'+(LW+BW+62)+'" height="'+rHb+'" fill="transparent" rx="3"/>';
bs+='<text x="'+(LW-6)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d">'+esc(d.lang)+'</text>';
bs+='<g class="bar-anim" data-bar-x="'+LW+'">';
if(cW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'" rx="2"/>';x+=cW;
if(cmW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'" rx="2"/>';x+=cmW;
if(blW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'" rx="2"/>';
bs+='</g>';
bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(d.code+d.comments+d.blanks)+'</text>';
bs+='</g>';
});
var ly=SH-16;
bs+='<rect x="'+LW+'" y="'+ly+'" width="10" height="10" fill="'+OX+'"/><text x="'+(LW+14)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>';
bs+='<rect x="'+(LW+56)+'" y="'+ly+'" width="10" height="10" fill="'+GN+'"/><text x="'+(LW+70)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>';
bs+='<rect x="'+(LW+158)+'" y="'+ly+'" width="10" height="10" fill="'+GY+'"/><text x="'+(LW+172)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>';
bs+='</svg>';
var lbl='font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;text-align:center;';
el.innerHTML='<div style="overflow-x:auto;padding:6px 0;">'+
'<div style="display:flex;flex-wrap:nowrap;justify-content:center;align-items:flex-start;gap:64px;padding:0 24px;">'+
'<div style="flex-shrink:0;text-align:center;"><p style="'+lbl+'">Code Lines by Language</p>'+ds+'</div>'+
'<div style="flex-shrink:0;text-align:center;"><p style="'+lbl+'">Line Mix per Language</p>'+bs+'</div>'+
'</div>'+
'</div>';
// Donut segment: entry fade-in + hover scale/brighten
el.querySelectorAll('.donut-seg').forEach(function(seg,i){
// transform-box:view-box makes transform-origin relative to the SVG viewBox,
// so scale() expands each segment outward from the donut centre (cx,cy)
seg.style.transformBox='view-box';
seg.style.transformOrigin=cx+'px '+cy+'px';
seg.style.opacity='0';
setTimeout(function(){
seg.style.transition='opacity 0.3s ease,transform 0.15s ease,filter 0.15s ease';
seg.style.opacity='1';
},60+i*55);
setTimeout(function(){
seg.style.transition='transform 0.15s ease,filter 0.15s ease';
},60+i*55+380);
seg.addEventListener('mouseover',function(e){
this.style.transform='scale(1.06)';
this.style.filter='brightness(1.18)';
showTT(e,'<strong style="display:block;font-size:13px;margin-bottom:3px;">'+this.dataset.lang+'</strong>'+fmt(Number(this.dataset.code))+' code lines<br><span style="opacity:0.65">('+this.dataset.pct+'% of total)</span>');
});
seg.addEventListener('mouseout',function(){
this.style.transform='';
this.style.filter='';
hideTT();
});
seg.addEventListener('mousemove',moveTT);
});
// Bar row: entry grow-from-left + hover highlight + tooltip
el.querySelectorAll('.bar-anim').forEach(function(g,i){
// transform-origin at the left edge of the bar area so scaleX grows rightward
g.style.transformBox='view-box';
g.style.transformOrigin=g.dataset.barX+'px 0px';
g.style.transform='scaleX(0)';
setTimeout(function(){
g.style.transition='transform 0.45s cubic-bezier(0.25,0.46,0.45,0.94)';
g.style.transform='scaleX(1)';
},80+i*55);
setTimeout(function(){
g.style.transition='';
},80+i*55+500);
});
el.querySelectorAll('.bar-row').forEach(function(row){
var bg=row.querySelector('.bar-hover-bg');
var segs=row.querySelectorAll('.bar-anim rect');
row.style.cursor='default';
row.addEventListener('mouseover',function(e){
if(bg)bg.setAttribute('fill','rgba(184,93,51,0.07)');
segs.forEach(function(r){r.style.filter='brightness(1.12)';});
showTT(e,
'<strong style="display:block;font-size:13px;margin-bottom:4px;">'+row.dataset.lang+'</strong>'+
'<span style="color:'+OX+'">■</span> Code: <strong>'+fmt(Number(row.dataset.code))+'</strong><br>'+
'<span style="color:'+GN+'">■</span> Comments: <strong>'+fmt(Number(row.dataset.comments))+'</strong><br>'+
'<span style="color:'+GY+'">■</span> Blanks: <strong>'+fmt(Number(row.dataset.blanks))+'</strong>'
);
});
row.addEventListener('mouseout',function(){
if(bg)bg.setAttribute('fill','transparent');
segs.forEach(function(r){r.style.filter='';});
hideTT();
});
row.addEventListener('mousemove',moveTT);
});
})();
</script>
<footer class="report-footer">oxide-sloc v{{ tool_version }}</footer>
</body>
</html>"##,
ext = "html"
)]
struct ReportTemplate<'a> {
nonce: String,
title: String,
browser_title: String,
generated_display: String,
scan_performed_by: String,
scan_time_pst: String,
tool_version: String,
is_sub_report: bool,
run: &'a AnalysisRun,
language_rows: Vec<LanguageRow>,
file_rows: Vec<FileRow>,
skipped_rows: Vec<FileRow>,
config_json: String,
lang_chart_json: String,
has_run_warnings: bool,
warning_count: usize,
warning_summary_rows: Vec<WarningSummaryRow>,
warning_opportunity_rows: Vec<WarningOpportunityRow>,
warning_console_preview: String,
warning_console_full: String,
warning_preview_truncated: bool,
logo_text_uri: String,
small_logo_uri: String,
}
// ─────────────────────────────────────────────────────────────────────────────
// CSV export
// ─────────────────────────────────────────────────────────────────────────────
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
/// Write a two-section CSV: language summary followed by per-file detail.
///
/// # Errors
///
/// Returns an error if the file cannot be written.
pub fn write_csv(run: &AnalysisRun, path: &Path) -> Result<()> {
let mut out = String::new();
// ── Section 1: Summary ──────────────────────────────────────────────────
out.push_str("# Summary\r\n");
out.push_str("Metric,Value\r\n");
let _ = write!(out, "Run ID,{}\r\n", csv_escape(&run.tool.run_id));
let _ = write!(
out,
"Timestamp,{}\r\n",
csv_escape(
&run.tool
.timestamp_utc
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string()
)
);
let _ = write!(
out,
"Report Title,{}\r\n",
csv_escape(&run.effective_configuration.reporting.report_title)
);
let _ = write!(
out,
"Files Analyzed,{}\r\n",
run.summary_totals.files_analyzed
);
let _ = write!(
out,
"Files Skipped,{}\r\n",
run.summary_totals.files_skipped
);
let _ = write!(
out,
"Physical Lines,{}\r\n",
run.summary_totals.total_physical_lines
);
let _ = write!(out, "Code Lines,{}\r\n", run.summary_totals.code_lines);
let _ = write!(
out,
"Comment Lines,{}\r\n",
run.summary_totals.comment_lines
);
let _ = write!(out, "Blank Lines,{}\r\n", run.summary_totals.blank_lines);
let _ = write!(
out,
"Mixed Lines (separate),{}\r\n",
run.summary_totals.mixed_lines_separate
);
// ── Section 2: Language breakdown ───────────────────────────────────────
out.push_str("\r\n# By Language\r\n");
out.push_str(
"Language,Files,Physical Lines,Code Lines,Comment Lines,Blank Lines,Mixed Lines\r\n",
);
for lang in &run.totals_by_language {
let _ = write!(
out,
"{},{},{},{},{},{},{}\r\n",
csv_escape(lang.language.display_name()),
lang.files,
lang.total_physical_lines,
lang.code_lines,
lang.comment_lines,
lang.blank_lines,
lang.mixed_lines_separate,
);
}
// ── Section 3: Per-file detail (if present) ─────────────────────────────
if !run.per_file_records.is_empty() {
out.push_str("\r\n# Per File\r\n");
out.push_str(
"Path,Language,Size (bytes),Code Lines,Comment Lines,Blank Lines,Physical Lines,Generated,Minified,Vendor\r\n",
);
for rec in &run.per_file_records {
let _ = write!(
out,
"{},{},{},{},{},{},{},{},{},{}\r\n",
csv_escape(&rec.relative_path),
csv_escape(
&rec.language
.map(|l| l.display_name().to_string())
.unwrap_or_default()
),
rec.size_bytes,
rec.effective_counts.code_lines,
rec.effective_counts.comment_lines,
rec.effective_counts.blank_lines,
rec.raw_line_categories.total_physical_lines,
rec.generated,
rec.minified,
rec.vendor,
);
}
}
fs::write(path, out).with_context(|| format!("failed to write CSV to {}", path.display()))
}
/// Write a diff/delta as CSV.
///
/// # Errors
///
/// Returns an error if the file cannot be written.
pub fn write_diff_csv(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
let s = &cmp.summary;
let mut out = String::new();
out.push_str("# Diff Summary\r\n");
out.push_str("Metric,Value\r\n");
let _ = write!(out, "Baseline Run,{}\r\n", csv_escape(&s.baseline_run_id));
let _ = write!(out, "Current Run,{}\r\n", csv_escape(&s.current_run_id));
let _ = write!(out, "Files Added,{}\r\n", cmp.files_added);
let _ = write!(out, "Files Removed,{}\r\n", cmp.files_removed);
let _ = write!(out, "Files Modified,{}\r\n", cmp.files_modified);
let _ = write!(out, "Files Unchanged,{}\r\n", cmp.files_unchanged);
let _ = write!(out, "Code Δ,{}\r\n", s.code_lines_delta);
let _ = write!(out, "Comment Δ,{}\r\n", s.comment_lines_delta);
let _ = write!(out, "Blank Δ,{}\r\n", s.blank_lines_delta);
let _ = write!(out, "Total Δ,{}\r\n", s.total_lines_delta);
out.push_str("\r\n# File Deltas\r\n");
out.push_str("Status,Path,Language,Baseline Code,Current Code,Code Δ,Baseline Comment,Current Comment,Comment Δ,Baseline Blank,Current Blank,Blank Δ,Total Δ\r\n");
for f in &cmp.file_deltas {
let status = match f.status {
sloc_core::FileChangeStatus::Added => "Added",
sloc_core::FileChangeStatus::Removed => "Removed",
sloc_core::FileChangeStatus::Modified => "Modified",
sloc_core::FileChangeStatus::Unchanged => "Unchanged",
};
let _ = write!(
out,
"{},{},{},{},{},{},{},{},{},{},{},{},{}\r\n",
status,
csv_escape(&f.relative_path),
csv_escape(f.language.as_deref().unwrap_or("")),
f.baseline_code,
f.current_code,
f.code_delta,
f.baseline_comment,
f.current_comment,
f.comment_delta,
f.baseline_blank,
f.current_blank,
f.blank_delta,
f.total_delta,
);
}
fs::write(path, out).with_context(|| format!("failed to write diff CSV to {}", path.display()))
}
// ─────────────────────────────────────────────────────────────────────────────
// XLSX export — self-contained, no external crates required.
//
// An .xlsx file is a ZIP archive containing a set of XML files. We write the
// ZIP with the STORE (uncompressed) method so we only need a CRC-32 routine
// and straightforward byte-level framing — both implemented inline below.
// ─────────────────────────────────────────────────────────────────────────────
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xffff_ffff;
for &b in data {
crc ^= u32::from(b);
for _ in 0..8 {
crc = if crc & 1 == 0 {
crc >> 1
} else {
(crc >> 1) ^ 0xedb8_8320
};
}
}
!crc
}
struct ZipEntry {
name: Vec<u8>,
data: Vec<u8>,
crc: u32,
offset: u32,
}
#[allow(clippy::cast_possible_truncation)] // deliberate ZIP format construction: sizes are bounded by caller
fn zip_add(entries: &mut Vec<ZipEntry>, buf: &mut Vec<u8>, name: &str, data: Vec<u8>) {
let crc = crc32(&data);
let offset = buf.len() as u32;
let name_bytes = name.as_bytes().to_vec();
let size = data.len() as u32;
// Local file header (signature 0x04034b50)
buf.extend_from_slice(&0x0403_4b50_u32.to_le_bytes());
buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
buf.extend_from_slice(&0u16.to_le_bytes()); // flags
buf.extend_from_slice(&0u16.to_le_bytes()); // compression: STORE
buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
buf.extend_from_slice(&crc.to_le_bytes());
buf.extend_from_slice(&size.to_le_bytes()); // compressed size
buf.extend_from_slice(&size.to_le_bytes()); // uncompressed size
buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); // extra field length
buf.extend_from_slice(&name_bytes);
buf.extend_from_slice(&data);
entries.push(ZipEntry {
name: name_bytes,
data,
crc,
offset,
});
}
#[allow(clippy::cast_possible_truncation)] // deliberate ZIP format construction: sizes are bounded by ZIP spec limits
fn zip_finish(mut buf: Vec<u8>, entries: &[ZipEntry]) -> Vec<u8> {
let central_start = buf.len() as u32;
for e in entries {
let size = e.data.len() as u32;
buf.extend_from_slice(&0x0201_4b50_u32.to_le_bytes()); // central dir sig
buf.extend_from_slice(&20u16.to_le_bytes()); // version made by
buf.extend_from_slice(&20u16.to_le_bytes()); // version needed
buf.extend_from_slice(&0u16.to_le_bytes()); // flags
buf.extend_from_slice(&0u16.to_le_bytes()); // compression: STORE
buf.extend_from_slice(&0u16.to_le_bytes()); // mod time
buf.extend_from_slice(&0u16.to_le_bytes()); // mod date
buf.extend_from_slice(&e.crc.to_le_bytes());
buf.extend_from_slice(&size.to_le_bytes());
buf.extend_from_slice(&size.to_le_bytes());
buf.extend_from_slice(&(e.name.len() as u16).to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); // extra
buf.extend_from_slice(&0u16.to_le_bytes()); // comment
buf.extend_from_slice(&0u16.to_le_bytes()); // disk start
buf.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
buf.extend_from_slice(&0u32.to_le_bytes()); // external attrs
buf.extend_from_slice(&e.offset.to_le_bytes());
buf.extend_from_slice(&e.name);
}
let central_size = buf.len() as u32 - central_start;
let n = entries.len() as u16;
// End of central directory record
buf.extend_from_slice(&0x0605_4b50_u32.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); // disk number
buf.extend_from_slice(&0u16.to_le_bytes()); // disk with central dir
buf.extend_from_slice(&n.to_le_bytes()); // entries on this disk
buf.extend_from_slice(&n.to_le_bytes()); // total entries
buf.extend_from_slice(¢ral_size.to_le_bytes());
buf.extend_from_slice(¢ral_start.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes()); // comment length
buf
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
/// Build a worksheet XML with the given header row and data rows.
/// String cells use `t="inlineStr"` (no shared-strings table needed).
/// Numeric cells use plain `<v>`.
fn build_sheet(headers: &[&str], rows: &[Vec<String>], style_header: bool) -> Vec<u8> {
#[allow(clippy::cast_possible_truncation)] // n % 26 is always in 0..=25, fits in u8
fn col_name(idx: usize) -> String {
// Convert 0-based column index to Excel column letters (A, B, … Z, AA, …)
let mut n = idx + 1;
let mut s = String::new();
while n > 0 {
n -= 1;
s.insert(0, char::from(b'A' + (n % 26) as u8));
n /= 26;
}
s
}
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
xml.push_str(
"<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n",
);
xml.push_str("<sheetData>\n");
// Header row
xml.push_str("<row r=\"1\">");
for (ci, &h) in headers.iter().enumerate() {
let cell_ref = format!("{}1", col_name(ci));
let style = if style_header { " s=\"1\"" } else { "" };
let _ = write!(
xml,
"<c r=\"{}\" t=\"inlineStr\"{style}><is><t>{}</t></is></c>",
cell_ref,
xml_escape(h)
);
}
xml.push_str("</row>\n");
// Data rows
for (ri, row) in rows.iter().enumerate() {
let row_num = ri + 2;
let _ = write!(xml, "<row r=\"{row_num}\">");
for (ci, cell) in row.iter().enumerate() {
let cell_ref = format!("{}{}", col_name(ci), row_num);
// Try to detect if the value is purely numeric
if cell.parse::<f64>().is_ok() && !cell.is_empty() {
let _ = write!(xml, "<c r=\"{cell_ref}\"><v>{}</v></c>", xml_escape(cell));
} else {
let _ = write!(
xml,
"<c r=\"{cell_ref}\" t=\"inlineStr\"><is><t>{}</t></is></c>",
xml_escape(cell)
);
}
}
xml.push_str("</row>\n");
}
xml.push_str("</sheetData>\n</worksheet>");
xml.into_bytes()
}
type SheetDef<'a> = (&'a str, &'a [&'a str], Vec<Vec<String>>);
fn build_xlsx_archive(sheets: &[SheetDef<'_>]) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
let mut entries: Vec<ZipEntry> = Vec::new();
// ── [Content_Types].xml ─────────────────────────────────────────────────
let mut ct = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
ct.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n");
ct.push_str(" <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n");
ct.push_str(" <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n");
ct.push_str(" <Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>\n");
ct.push_str(" <Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>\n");
for (i, _) in sheets.iter().enumerate() {
let _ = writeln!(
ct,
" <Override PartName=\"/xl/worksheets/sheet{}.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>",
i + 1
);
}
ct.push_str("</Types>");
zip_add(
&mut entries,
&mut buf,
"[Content_Types].xml",
ct.into_bytes(),
);
// ── _rels/.rels ─────────────────────────────────────────────────────────
let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n\
<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/>\n\
</Relationships>";
zip_add(
&mut entries,
&mut buf,
"_rels/.rels",
rels.as_bytes().to_vec(),
);
// ── xl/workbook.xml ──────────────────────────────────────────────────────
let mut wb = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
wb.push_str("<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\n");
wb.push_str(" <sheets>\n");
for (i, (name, _, _)) in sheets.iter().enumerate() {
let _ = writeln!(
wb,
" <sheet name=\"{}\" sheetId=\"{}\" r:id=\"rId{}\"/>",
xml_escape(name),
i + 1,
i + 1
);
}
wb.push_str(" </sheets>\n</workbook>");
zip_add(&mut entries, &mut buf, "xl/workbook.xml", wb.into_bytes());
// ── xl/_rels/workbook.xml.rels ───────────────────────────────────────────
let mut wbr = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
wbr.push_str(
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n",
);
for (i, _) in sheets.iter().enumerate() {
let _ = writeln!(
wbr,
" <Relationship Id=\"rId{}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet{}.xml\"/>",
i + 1, i + 1
);
}
let _ = writeln!(
wbr,
" <Relationship Id=\"rId{}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>",
sheets.len() + 1
);
wbr.push_str("</Relationships>");
zip_add(
&mut entries,
&mut buf,
"xl/_rels/workbook.xml.rels",
wbr.into_bytes(),
);
// ── xl/styles.xml (minimal: normal + bold-header) ───────────────────────
let styles = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n\
<fonts count=\"2\">\n\
<font><sz val=\"11\"/><name val=\"Calibri\"/></font>\n\
<font><b/><sz val=\"11\"/><name val=\"Calibri\"/></font>\n\
</fonts>\n\
<fills count=\"2\">\n\
<fill><patternFill patternType=\"none\"/></fill>\n\
<fill><patternFill patternType=\"gray125\"/></fill>\n\
</fills>\n\
<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>\n\
<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>\n\
<cellXfs count=\"2\">\n\
<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>\n\
<xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/>\n\
</cellXfs>\n\
</styleSheet>";
zip_add(
&mut entries,
&mut buf,
"xl/styles.xml",
styles.as_bytes().to_vec(),
);
// ── worksheets ───────────────────────────────────────────────────────────
for (i, (_, headers, rows)) in sheets.iter().enumerate() {
let sheet_xml = build_sheet(headers, rows, true);
let name = format!("xl/worksheets/sheet{}.xml", i + 1);
zip_add(&mut entries, &mut buf, &name, sheet_xml);
}
zip_finish(buf, &entries)
}
/// Write an analysis run as a multi-sheet Excel workbook.
///
/// # Errors
///
/// Returns an error if the file cannot be written.
#[allow(clippy::too_many_lines)]
pub fn write_xlsx(run: &AnalysisRun, path: &Path) -> Result<()> {
// Sheet 1 — Summary
let summary_rows: Vec<Vec<String>> = vec![
vec!["Run ID".into(), run.tool.run_id.clone()],
vec![
"Timestamp".into(),
run.tool
.timestamp_utc
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
],
vec![
"Report Title".into(),
run.effective_configuration.reporting.report_title.clone(),
],
vec![
"Files Analyzed".into(),
run.summary_totals.files_analyzed.to_string(),
],
vec![
"Files Skipped".into(),
run.summary_totals.files_skipped.to_string(),
],
vec![
"Physical Lines".into(),
run.summary_totals.total_physical_lines.to_string(),
],
vec![
"Code Lines".into(),
run.summary_totals.code_lines.to_string(),
],
vec![
"Comment Lines".into(),
run.summary_totals.comment_lines.to_string(),
],
vec![
"Blank Lines".into(),
run.summary_totals.blank_lines.to_string(),
],
vec![
"Mixed Lines (separate)".into(),
run.summary_totals.mixed_lines_separate.to_string(),
],
];
// Sheet 2 — By Language
let lang_rows: Vec<Vec<String>> = run
.totals_by_language
.iter()
.map(|l| {
vec![
l.language.display_name().to_string(),
l.files.to_string(),
l.total_physical_lines.to_string(),
l.code_lines.to_string(),
l.comment_lines.to_string(),
l.blank_lines.to_string(),
l.mixed_lines_separate.to_string(),
]
})
.collect();
// Sheet 3 — Per File
let file_rows: Vec<Vec<String>> = run
.per_file_records
.iter()
.map(|r| {
vec![
r.relative_path.clone(),
r.language
.map(|l| l.display_name().to_string())
.unwrap_or_default(),
r.size_bytes.to_string(),
r.effective_counts.code_lines.to_string(),
r.effective_counts.comment_lines.to_string(),
r.effective_counts.blank_lines.to_string(),
r.raw_line_categories.total_physical_lines.to_string(),
r.generated.to_string(),
r.minified.to_string(),
r.vendor.to_string(),
]
})
.collect();
// Sheet 4 — Skipped Files
let skipped_rows: Vec<Vec<String>> = run
.skipped_file_records
.iter()
.map(|r| {
vec![
r.relative_path.clone(),
format!("{:?}", r.status),
r.size_bytes.to_string(),
]
})
.collect();
let summary_hdrs: &[&str] = &["Metric", "Value"];
let lang_hdrs: &[&str] = &[
"Language",
"Files",
"Physical Lines",
"Code Lines",
"Comments",
"Blank",
"Mixed",
];
let file_hdrs: &[&str] = &[
"Path",
"Language",
"Size (bytes)",
"Code Lines",
"Comments",
"Blank Lines",
"Physical Lines",
"Generated",
"Minified",
"Vendor",
];
let skipped_hdrs: &[&str] = &["Path", "Status", "Size (bytes)"];
let sheets: Vec<SheetDef<'_>> = vec![
("Summary", summary_hdrs, summary_rows),
("By Language", lang_hdrs, lang_rows),
("Per File", file_hdrs, file_rows),
("Skipped", skipped_hdrs, skipped_rows),
];
let bytes = build_xlsx_archive(&sheets);
fs::write(path, bytes).with_context(|| format!("failed to write XLSX to {}", path.display()))
}
/// Write a diff comparison as an Excel workbook.
///
/// # Errors
///
/// Returns an error if the file cannot be written.
pub fn write_diff_xlsx(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
let s = &cmp.summary;
let summary_rows: Vec<Vec<String>> = vec![
vec!["Baseline Run".into(), s.baseline_run_id.clone()],
vec!["Current Run".into(), s.current_run_id.clone()],
vec!["Files Added".into(), cmp.files_added.to_string()],
vec!["Files Removed".into(), cmp.files_removed.to_string()],
vec!["Files Modified".into(), cmp.files_modified.to_string()],
vec!["Files Unchanged".into(), cmp.files_unchanged.to_string()],
vec!["Code Δ".into(), s.code_lines_delta.to_string()],
vec!["Comment Δ".into(), s.comment_lines_delta.to_string()],
vec!["Blank Δ".into(), s.blank_lines_delta.to_string()],
vec!["Total Δ".into(), s.total_lines_delta.to_string()],
];
let delta_rows: Vec<Vec<String>> = cmp
.file_deltas
.iter()
.map(|f| {
let status = match f.status {
sloc_core::FileChangeStatus::Added => "Added",
sloc_core::FileChangeStatus::Removed => "Removed",
sloc_core::FileChangeStatus::Modified => "Modified",
sloc_core::FileChangeStatus::Unchanged => "Unchanged",
};
vec![
status.to_string(),
f.relative_path.clone(),
f.language.clone().unwrap_or_default(),
f.baseline_code.to_string(),
f.current_code.to_string(),
f.code_delta.to_string(),
f.baseline_comment.to_string(),
f.current_comment.to_string(),
f.comment_delta.to_string(),
f.total_delta.to_string(),
]
})
.collect();
let summary_hdrs: &[&str] = &["Metric", "Value"];
let delta_hdrs: &[&str] = &[
"Status",
"Path",
"Language",
"Baseline Code",
"Current Code",
"Code Δ",
"Baseline Comment",
"Current Comment",
"Comment Δ",
"Total Δ",
];
let sheets: Vec<SheetDef<'_>> = vec![
("Diff Summary", summary_hdrs, summary_rows),
("File Deltas", delta_hdrs, delta_rows),
];
let bytes = build_xlsx_archive(&sheets);
fs::write(path, bytes)
.with_context(|| format!("failed to write diff XLSX to {}", path.display()))
}