1#![allow(clippy::multiple_crate_versions)]
4
5mod pdf_compat;
6
7use std::collections::BTreeMap;
8use std::fmt::Write as FmtWrite;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use askama::Template;
14use chrono::{DateTime, FixedOffset, Utc};
15use sloc_core::{AnalysisRun, CocomoMode, FileRecord, StyleSummary, SummaryTotals};
16
17static LOGO_TEXT_PNG: &[u8] = include_bytes!("../assets/logo/logo-text.png");
21static SMALL_LOGO_PNG: &[u8] = include_bytes!("../assets/logo/small-logo.png");
22static CHART_JS: &str = include_str!("../assets/chart.min.js");
23
24fn png_data_uri(bytes: &[u8]) -> String {
25 format!("data:image/png;base64,{}", base64_encode(bytes))
26}
27
28fn base64_encode(data: &[u8]) -> String {
29 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
30 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
31 for chunk in data.chunks(3) {
32 let b0 = u32::from(chunk[0]);
33 let b1 = if chunk.len() > 1 {
34 u32::from(chunk[1])
35 } else {
36 0
37 };
38 let b2 = if chunk.len() > 2 {
39 u32::from(chunk[2])
40 } else {
41 0
42 };
43 let n = (b0 << 16) | (b1 << 8) | b2;
44 out.push(CHARS[((n >> 18) & 63) as usize] as char);
45 out.push(CHARS[((n >> 12) & 63) as usize] as char);
46 out.push(if chunk.len() > 1 {
47 CHARS[((n >> 6) & 63) as usize] as char
48 } else {
49 '='
50 });
51 out.push(if chunk.len() > 2 {
52 CHARS[(n & 63) as usize] as char
53 } else {
54 '='
55 });
56 }
57 out
58}
59
60fn normalize_remote_url(remote_url: &str) -> Option<String> {
64 let url = remote_url.trim();
65 if let Some(rest) = url.strip_prefix("git@") {
66 let (host, path) = rest.split_once(':')?;
67 let path = path.trim_end_matches(".git");
68 return Some(format!("https://{host}/{path}"));
69 }
70 if url.starts_with("https://") || url.starts_with("http://") {
71 return Some(url.trim_end_matches(".git").to_string());
72 }
73 None
74}
75
76pub(crate) fn derive_commit_url(remote_url: &str, sha: &str) -> Option<String> {
78 let base = normalize_remote_url(remote_url)?;
79 let lower = base.to_lowercase();
80 if lower.contains("bitbucket.org") {
81 Some(format!("{base}/commits/{sha}"))
82 } else if lower.contains("gitlab.") {
83 Some(format!("{base}/-/commit/{sha}"))
84 } else {
85 Some(format!("{base}/commit/{sha}"))
86 }
87}
88
89pub(crate) fn derive_branch_url(remote_url: &str, branch: &str) -> Option<String> {
91 let base = normalize_remote_url(remote_url)?;
92 let lower = base.to_lowercase();
93 if lower.contains("bitbucket.org") {
94 Some(format!("{base}/branch/{branch}"))
95 } else if lower.contains("gitlab.") {
96 Some(format!("{base}/-/tree/{branch}"))
97 } else {
98 Some(format!("{base}/tree/{branch}"))
99 }
100}
101
102pub struct ReportDeltaContext {
105 pub delta_code_added: i64,
107 pub delta_code_removed: i64,
109 pub delta_unmodified_lines: i64,
111 pub delta_files_added: usize,
113 pub delta_files_removed: usize,
115 pub delta_files_modified: usize,
117 pub delta_files_unchanged: usize,
119 pub prev_code_lines: u64,
121 pub prev_scan_count: usize,
123 pub prev_scan_label: String,
125 pub prev_run_id: Option<String>,
127 pub current_run_id: Option<String>,
129}
130
131pub fn render_html(run: &AnalysisRun) -> Result<String> {
137 render_html_inner(run, false, None, None)
138}
139
140pub fn render_html_with_delta(
150 run: &AnalysisRun,
151 delta: Option<&ReportDeltaContext>,
152) -> Result<String> {
153 render_html_inner(run, false, None, delta)
154}
155
156pub fn render_sub_report_html(run: &AnalysisRun, pdf_url: Option<&str>) -> Result<String> {
162 render_html_inner(run, true, pdf_url, None)
163}
164
165fn load_custom_logo(path: &std::path::Path) -> Option<String> {
166 let bytes = std::fs::read(path).ok()?;
167 let ext = path
168 .extension()
169 .and_then(|e| e.to_str())
170 .unwrap_or("")
171 .to_ascii_lowercase();
172 let mime = if ext == "svg" {
173 "image/svg+xml"
174 } else {
175 "image/png"
176 };
177 Some(format!("data:{mime};base64,{}", base64_encode(&bytes)))
178}
179
180fn json_escape(s: &str) -> String {
184 s.replace('\\', "\\\\").replace('"', "\\\"")
185}
186
187fn build_lang_chart_json(run: &AnalysisRun) -> String {
188 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
189 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
190 let entries: Vec<String> = langs
191 .into_iter()
192 .take(12)
193 .map(|l| {
194 format!(
195 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"tests":{},"files":{}}}"#,
196 json_escape(l.language.display_name()),
197 l.code_lines, l.comment_lines, l.blank_lines, l.total_physical_lines,
198 l.functions, l.classes, l.variables, l.imports,
199 l.test_count, l.files,
200 )
201 })
202 .collect();
203 format!("[{}]", entries.join(","))
204}
205
206fn build_submodule_chart_json(run: &AnalysisRun) -> String {
207 let entries: Vec<String> = run
208 .submodule_summaries
209 .iter()
210 .map(|s| {
211 format!(
212 r#"{{"name":"{}","path":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
213 json_escape(&s.name), json_escape(&s.relative_path),
214 s.code_lines, s.comment_lines, s.blank_lines,
215 s.total_physical_lines, s.files_analyzed,
216 )
217 })
218 .collect();
219 format!("[{}]", entries.join(","))
220}
221
222fn build_scatter_chart_json(run: &AnalysisRun) -> String {
223 let entries: Vec<String> = run
224 .totals_by_language
225 .iter()
226 .map(|l| {
227 format!(
228 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
229 json_escape(l.language.display_name()),
230 l.files,
231 l.code_lines,
232 l.total_physical_lines,
233 )
234 })
235 .collect();
236 format!("[{}]", entries.join(","))
237}
238
239fn build_semantic_chart_json(run: &AnalysisRun) -> String {
240 let entries: Vec<String> = run
241 .totals_by_language
242 .iter()
243 .filter(|l| l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0)
244 .map(|l| {
245 format!(
246 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
247 json_escape(l.language.display_name()),
248 l.functions, l.classes, l.variables, l.imports, l.test_count,
249 )
250 })
251 .collect();
252 format!("[{}]", entries.join(","))
253}
254
255fn build_file_size_histogram_json(run: &AnalysisRun) -> String {
256 let labels = [
258 ("Tiny (<50)", 0u64, 49u64),
259 ("Small (50-199)", 50, 199),
260 ("Medium (200-499)", 200, 499),
261 ("Large (500-999)", 500, 999),
262 ("Huge (>=1000)", 1000, u64::MAX),
263 ];
264 let mut counts = [0u64; 5];
265 for f in &run.per_file_records {
266 let cl = f.effective_counts.code_lines;
267 for (i, &(_, lo, hi)) in labels.iter().enumerate() {
268 if cl >= lo && cl <= hi {
269 counts[i] += 1;
270 break;
271 }
272 }
273 }
274 let entries: Vec<String> = labels
275 .iter()
276 .zip(counts.iter())
277 .map(|((label, _, _), count)| {
278 format!(r#"{{"label":"{}","count":{}}}"#, json_escape(label), count)
279 })
280 .collect();
281 format!("[{}]", entries.join(","))
282}
283
284fn build_style_chart_json(summary: &StyleSummary) -> String {
287 let groups: Vec<String> = summary
288 .by_language
289 .iter()
290 .map(|grp| {
291 let guides: Vec<String> = grp
292 .guide_avg_scores
293 .iter()
294 .map(|(name, score)| {
295 format!(r#"{{"guide":"{}","score":{}}}"#, json_escape(name), score)
296 })
297 .collect();
298 format!(
299 r#"{{"family":"{}","files":{},"indent":"{}","dominant":"{}","score":{},"guides":[{}]}}"#,
300 json_escape(&grp.language_family),
301 grp.files_count,
302 json_escape(&grp.common_indent_style),
303 json_escape(&grp.dominant_guide),
304 grp.dominant_score_pct,
305 guides.join(","),
306 )
307 })
308 .collect();
309 format!("[{}]", groups.join(","))
310}
311
312fn build_style_file_json(run: &AnalysisRun) -> String {
314 let entries: Vec<String> = run
315 .per_file_records
316 .iter()
317 .filter_map(|f| {
318 let s = f.style_analysis.as_ref()?;
319 let sigs: Vec<String> = s
321 .signals
322 .iter()
323 .take(3)
324 .map(|sig| {
325 format!(
326 r#"{{"k":"{}","v":"{}"}}"#,
327 json_escape(&sig.name),
328 json_escape(&sig.value),
329 )
330 })
331 .collect();
332 Some(format!(
333 r#"{{"path":"{}","lang":"{}","family":"{}","indent":"{}","guide":"{}","score":{},"signals":[{}]}}"#,
334 json_escape(&f.relative_path),
335 json_escape(f.language.map_or("\u{2014}", |l| l.display_name())),
336 json_escape(&s.language_family),
337 json_escape(s.indent_style.display()),
338 json_escape(&s.dominant_guide),
339 s.dominant_score_pct,
340 sigs.join(","),
341 ))
342 })
343 .take(500)
344 .collect();
345 format!("[{}]", entries.join(","))
346}
347
348#[allow(clippy::cast_precision_loss)]
352fn coverage_pct_str(hit: u64, found: u64) -> String {
353 if found > 0 {
354 format!("{:.1}", hit as f64 / found as f64 * 100.0)
355 } else {
356 String::new()
357 }
358}
359
360#[allow(clippy::cast_precision_loss)]
362fn coverage_class(hit: u64, found: u64) -> String {
363 if found > 0 {
364 let pct = hit as f64 / found as f64 * 100.0;
365 if pct >= 80.0 {
366 "good"
367 } else if pct >= 60.0 {
368 "warn"
369 } else {
370 "danger"
371 }
372 } else {
373 "muted"
374 }
375 .to_string()
376}
377
378#[allow(clippy::cast_precision_loss)]
380fn format_test_density(code_lines: u64, test_count: u64) -> String {
381 if code_lines > 0 && test_count > 0 {
382 format!("{:.1}", test_count as f64 / code_lines as f64 * 1000.0)
383 } else {
384 String::from("0.0")
385 }
386}
387
388fn group_thousands(s: &str) -> String {
394 let (sign, rest) = match s.as_bytes().first() {
395 Some(b'-') => ("-", &s[1..]),
396 Some(b'+') => ("+", &s[1..]),
397 _ => ("", s),
398 };
399 let (int_part, frac_part) = match rest.split_once('.') {
400 Some((i, f)) => (i, Some(f)),
401 None => (rest, None),
402 };
403 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
404 return s.to_string();
405 }
406 let bytes = int_part.as_bytes();
407 let len = bytes.len();
408 let mut grouped = String::with_capacity(len + len / 3);
409 for (i, &b) in bytes.iter().enumerate() {
410 if i > 0 && (len - i).is_multiple_of(3) {
411 grouped.push(',');
412 }
413 grouped.push(b as char);
414 }
415 frac_part.map_or_else(
416 || format!("{sign}{grouped}"),
417 |f| format!("{sign}{grouped}.{f}"),
418 )
419}
420
421mod filters {
423 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
426 use askama::{Result, Values};
427
428 #[askama::filter_fn]
430 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
431 Ok(super::group_thousands(&value.to_string()))
432 }
433}
434
435#[allow(clippy::too_many_lines)] fn render_html_inner(
439 run: &AnalysisRun,
440 is_sub_report: bool,
441 pdf_url: Option<&str>,
442 delta_ctx: Option<&ReportDeltaContext>,
443) -> Result<String> {
444 let config_json = serde_json::to_string_pretty(&run.effective_configuration)
445 .context("failed to serialize effective configuration")?;
446
447 let warning_summary_rows = summarize_warnings(&run.warnings);
448 let warning_opportunity_rows = build_support_opportunities(&run.warnings);
449
450 let logo_text_uri = png_data_uri(LOGO_TEXT_PNG);
451 let small_logo_uri = png_data_uri(SMALL_LOGO_PNG);
452
453 let rep = &run.effective_configuration.reporting;
454 let custom_logo_uri = rep.logo_path.as_deref().and_then(load_custom_logo);
455 let company_name = rep.company_name.clone();
456 let accent_hex = rep.accent_color.clone();
457 let report_header_footer = rep.report_header_footer.clone();
458
459 let totals = &run.summary_totals;
460
461 let hotspot_rows = build_hotspot_rows(run, 200);
464
465 let template = ReportTemplate {
466 nonce: String::new(),
469 title: rep.report_title.clone(),
470 browser_title: format!("Oxide-SLOC | {}", rep.report_title),
471 scan_performed_by: run.environment.ci_name.clone().unwrap_or_else(|| {
472 format!(
473 "{} / {}",
474 run.environment.initiator_username, run.environment.initiator_hostname
475 )
476 }),
477 scan_time_pst: to_pst_display(run.tool.timestamp_utc),
478 tool_version: run.tool.version.clone(),
479 is_sub_report,
480 run,
481 language_rows: run
482 .totals_by_language
483 .iter()
484 .map(|row| LanguageRow {
485 language: row.language.display_name().to_string(),
486 files: row.files,
487 total_physical_lines: row.total_physical_lines,
488 code_lines: row.code_lines,
489 comment_lines: row.comment_lines,
490 blank_lines: row.blank_lines,
491 mixed_lines_separate: row.mixed_lines_separate,
492 functions: row.functions,
493 classes: row.classes,
494 variables: row.variables,
495 imports: row.imports,
496 test_count: row.test_count,
497 test_assertion_count: row.test_assertion_count,
498 test_suite_count: row.test_suite_count,
499 test_density_str: if row.code_lines > 0 {
500 #[allow(clippy::cast_precision_loss)]
502 let density = row.test_count as f64 / row.code_lines as f64 * 1000.0;
503 format!("{density:.1}")
504 } else {
505 "—".to_string()
506 },
507 })
508 .collect(),
509 file_rows: run.per_file_records.iter().map(file_row_view).collect(),
510 skipped_rows: run.skipped_file_records.iter().map(file_row_view).collect(),
511 config_json,
512 lang_chart_json: build_lang_chart_json(run),
513 submodule_chart_json: build_submodule_chart_json(run),
514 scatter_chart_json: build_scatter_chart_json(run),
515 semantic_chart_json: build_semantic_chart_json(run),
516 file_size_histogram_json: build_file_size_histogram_json(run),
517 has_submodule_data: !run.submodule_summaries.is_empty(),
518 has_semantic_data: run
519 .totals_by_language
520 .iter()
521 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
522 has_coverage_data: run.per_file_records.iter().any(|f| f.coverage.is_some()),
523 has_fn_coverage: totals.coverage_functions_found > 0,
524 has_branch_coverage: totals.coverage_branches_found > 0,
525 test_files_count: run
526 .per_file_records
527 .iter()
528 .filter(|f| f.raw_line_categories.test_count > 0)
529 .count() as u64,
530 test_assertion_count: totals.test_assertion_count,
531 test_suite_count: totals.test_suite_count,
532 test_density: format_test_density(totals.code_lines, totals.test_count),
533 most_tested_lang: run
534 .totals_by_language
535 .iter()
536 .filter(|l| l.test_count > 0)
537 .max_by_key(|l| l.test_count)
538 .map_or_else(
539 || "\u{2014}".to_string(),
540 |l| l.language.display_name().to_string(),
541 ),
542 langs_with_tests: run
543 .totals_by_language
544 .iter()
545 .filter(|l| l.test_count > 0)
546 .count(),
547 cov_line_pct: coverage_pct_str(totals.coverage_lines_hit, totals.coverage_lines_found),
548 cov_fn_pct: coverage_pct_str(
549 totals.coverage_functions_hit,
550 totals.coverage_functions_found,
551 ),
552 cov_branch_pct: coverage_pct_str(
553 totals.coverage_branches_hit,
554 totals.coverage_branches_found,
555 ),
556 cov_line_class: coverage_class(totals.coverage_lines_hit, totals.coverage_lines_found),
557 cov_fn_class: coverage_class(
558 totals.coverage_functions_hit,
559 totals.coverage_functions_found,
560 ),
561 cov_branch_class: coverage_class(
562 totals.coverage_branches_hit,
563 totals.coverage_branches_found,
564 ),
565 has_run_warnings: !run.warnings.is_empty(),
566 warning_count: run.warnings.len(),
567 warning_summary_rows,
568 warning_opportunity_rows,
569 warning_console_full: build_warning_console(&run.warnings),
570 logo_text_uri,
571 small_logo_uri,
572 custom_logo_uri,
573 company_name,
574 accent_hex,
575 report_header_footer,
576 chart_js: CHART_JS,
577 run_id_short: run
578 .tool
579 .run_id
580 .split('-')
581 .next_back()
582 .unwrap_or(&run.tool.run_id)
583 .chars()
584 .take(7)
585 .collect(),
586 standalone_pdf_url: pdf_url.map(str::to_string),
587 has_style_data: run.style_summary.is_some(),
588 style_lang_count: run
589 .style_summary
590 .as_ref()
591 .map_or(0, |ss| ss.by_language.len()),
592 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
593 style_chart_json: run
594 .style_summary
595 .as_ref()
596 .map(build_style_chart_json)
597 .unwrap_or_default(),
598 style_file_json: if run.style_summary.is_some() {
599 build_style_file_json(run)
600 } else {
601 String::new()
602 },
603 style_summary: run.style_summary.clone(),
604 has_delta: delta_ctx.is_some(),
605 delta_code_added: delta_ctx.map_or(0, |d| d.delta_code_added),
606 delta_code_removed: delta_ctx.map_or(0, |d| d.delta_code_removed),
607 delta_unmodified_lines: delta_ctx.map_or(0, |d| d.delta_unmodified_lines),
608 delta_files_added: delta_ctx.map_or(0, |d| d.delta_files_added),
609 delta_files_removed: delta_ctx.map_or(0, |d| d.delta_files_removed),
610 delta_files_modified: delta_ctx.map_or(0, |d| d.delta_files_modified),
611 delta_files_unchanged: delta_ctx.map_or(0, |d| d.delta_files_unchanged),
612 prev_code_lines: delta_ctx.map_or(0, |d| d.prev_code_lines),
613 prev_scan_count: delta_ctx.map_or(0, |d| d.prev_scan_count),
614 prev_scan_label: delta_ctx
615 .map(|d| d.prev_scan_label.clone())
616 .unwrap_or_default(),
617 prev_run_id: delta_ctx
618 .and_then(|d| d.prev_run_id.clone())
619 .unwrap_or_default(),
620 git_commit_url: run
621 .git_remote_url
622 .as_deref()
623 .zip(run.git_commit_long.as_deref())
624 .and_then(|(remote, sha)| derive_commit_url(remote, sha)),
625 git_branch_url: run
626 .git_remote_url
627 .as_deref()
628 .zip(run.git_branch.as_deref())
629 .and_then(|(remote, branch)| derive_branch_url(remote, branch)),
630 has_cocomo: run.cocomo.is_some(),
631 cocomo_effort_str: run
632 .cocomo
633 .as_ref()
634 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
635 cocomo_duration_str: run
636 .cocomo
637 .as_ref()
638 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
639 cocomo_staff_str: run
640 .cocomo
641 .as_ref()
642 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
643 cocomo_ksloc_str: run
644 .cocomo
645 .as_ref()
646 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
647 cocomo_mode_label: run
648 .cocomo
649 .as_ref()
650 .map_or_else(|| "Organic".to_string(), |c| {
651 match c.mode {
652 CocomoMode::Organic => "Organic",
653 CocomoMode::SemiDetached => "Semi-detached",
654 CocomoMode::Embedded => "Embedded",
655 }
656 .to_string()
657 }),
658 cocomo_mode_tooltip: run
659 .cocomo
660 .as_ref()
661 .map_or(String::new(), |c| match c.mode {
662 CocomoMode::Organic => "Organic: A small team working on a well-understood \
663 project in a familiar environment with minimal external constraints. \
664 Suited for internal tools, utilities, and projects with stable requirements. \
665 Effort = 2.4 \u{00D7} KSLOC^1.05.",
666 CocomoMode::SemiDetached => "Semi-detached: A mixed team with varying levels of \
667 experience tackling a project with moderate novelty and some rigid constraints. \
668 Typical for compilers, transaction systems, and batch processors. \
669 Effort = 3.0 \u{00D7} KSLOC^1.12.",
670 CocomoMode::Embedded => "Embedded: Tight hardware, software, or operational \
671 constraints requiring significant innovation and deep integration work. \
672 Typical for real-time control systems and safety-critical software. \
673 Effort = 3.6 \u{00D7} KSLOC^1.20.",
674 }.to_string()),
675 uloc: run.uloc,
676 dryness_pct_str: run
677 .dryness_pct
678 .map_or(String::new(), |d| format!("{d:.1}")),
679 duplicate_group_count: run.duplicate_groups.len(),
680 has_hotspots: !hotspot_rows.is_empty(),
681 hotspot_rows,
682 };
683
684 template.render().context("failed to render HTML report")
685}
686
687struct HotspotRow {
689 path: String,
690 code_lines: u64,
691 commit_count: u32,
692 last_commit_date: String,
693 score: u64,
694}
695
696fn build_hotspot_rows(run: &AnalysisRun, limit: usize) -> Vec<HotspotRow> {
702 let mut rows: Vec<HotspotRow> = run
703 .per_file_records
704 .iter()
705 .filter_map(|r| {
706 let commits = r.commit_count?;
707 let code = r.effective_counts.code_lines;
708 Some(HotspotRow {
709 path: r.relative_path.clone(),
710 code_lines: code,
711 commit_count: commits,
712 last_commit_date: r.last_commit_date.as_deref().map_or_else(String::new, |d| {
714 d.split('T').next().unwrap_or(d).to_string()
715 }),
716 score: code.saturating_mul(u64::from(commits)),
717 })
718 })
719 .collect();
720 rows.sort_by(|a, b| {
721 b.score
722 .cmp(&a.score)
723 .then(b.commit_count.cmp(&a.commit_count))
724 });
725 rows.truncate(limit);
726 rows
727}
728
729pub fn write_html(run: &AnalysisRun, output_path: &Path) -> Result<()> {
735 let html = render_html_inner(run, false, None, None)?;
736 fs::write(output_path, html)
737 .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
738}
739
740pub fn write_html_with_pdf_link(
750 run: &AnalysisRun,
751 output_path: &Path,
752 pdf_path: Option<&Path>,
753) -> Result<()> {
754 let pdf_relative = pdf_path.and_then(|pdf| {
755 let html_dir = output_path.parent()?;
756 let pdf_dir = pdf.parent()?;
757 if html_dir == pdf_dir {
758 pdf.file_name().map(|n| n.to_string_lossy().into_owned())
759 } else {
760 None
761 }
762 });
763 let html = render_html_inner(run, false, pdf_relative.as_deref(), None)?;
764 fs::write(output_path, html)
765 .with_context(|| format!("failed to write HTML report to {}", output_path.display()))
766}
767
768fn launch_cdp_browser(
774 browser_path: std::path::PathBuf,
775 no_sandbox: bool,
776) -> Result<headless_chrome::Browser> {
777 use headless_chrome::{Browser, LaunchOptions};
778
779 if no_sandbox {
780 return Browser::new(LaunchOptions {
781 headless: true,
782 path: Some(browser_path),
783 window_size: Some((1122, 794)),
784 sandbox: false,
785 ..Default::default()
786 })
787 .context("failed to launch browser via CDP (no-sandbox)");
788 }
789
790 Browser::new(LaunchOptions {
793 headless: true,
794 path: Some(browser_path),
795 window_size: Some((1122, 794)),
796 sandbox: true,
797 ..Default::default()
798 })
799 .map_err(|e| {
800 anyhow::anyhow!(
801 "Browser launch failed with sandbox enabled: {e:#}\n\
802 If running in a container without user namespaces (e.g. Docker with cap_drop:ALL), \
803 set SLOC_BROWSER_NOSANDBOX=1 to opt into --no-sandbox mode."
804 )
805 })
806}
807
808fn report_chart_error_if_any(tab: &headless_chrome::Tab) {
810 let Ok(e) = tab.evaluate("window.oxSlocChartError||''", false) else {
811 return;
812 };
813 let Some(serde_json::Value::String(msg)) = e.value else {
814 return;
815 };
816 if !msg.is_empty() {
817 eprintln!("[oxide-sloc][pdf] chart JS error (charts may be missing): {msg}");
818 }
819}
820
821fn wait_for_charts_ready(tab: &headless_chrome::Tab) {
823 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
824 let mut last_cdp_err: Option<String> = None;
825 loop {
826 match tab.evaluate("!!window.oxSlocChartsReady", false) {
827 Ok(r) => {
828 last_cdp_err = None;
829 if matches!(r.value, Some(serde_json::Value::Bool(true))) {
830 report_chart_error_if_any(tab);
831 return;
832 }
833 }
834 Err(e) => {
835 let msg = format!("{e:#}");
836 if last_cdp_err.as_deref() != Some(&msg) {
837 eprintln!("[oxide-sloc][pdf] CDP evaluate error (will retry): {msg}");
838 last_cdp_err = Some(msg);
839 }
840 }
841 }
842 if std::time::Instant::now() >= deadline {
843 report_chart_error_if_any(tab);
844 break;
845 }
846 std::thread::sleep(std::time::Duration::from_millis(250));
847 }
848}
849
850fn extract_banner_text(tab: &headless_chrome::Tab) -> Option<String> {
852 let result = tab
853 .evaluate(
854 "(function(){\
855 var el=document.querySelector('.report-id-banner');\
856 return el?el.textContent.trim():null;\
857 })()",
858 false,
859 )
860 .ok()?;
861 match result.value? {
862 serde_json::Value::String(s) if !s.is_empty() => Some(s),
863 _ => None,
864 }
865}
866
867fn extract_pdf_template(tab: &headless_chrome::Tab, id: &str) -> Option<String> {
872 let js = format!(
874 "(function(){{var el=document.getElementById('{id}');return el?el.innerHTML:null;}})()"
875 );
876 let result = tab.evaluate(&js, false).ok()?;
877 match result.value? {
878 serde_json::Value::String(s) if !s.trim().is_empty() => Some(s),
879 _ => None,
880 }
881}
882
883fn write_pdf_via_cdp(html_path: &Path, output_path: &Path) -> Result<()> {
889 use headless_chrome::types::PrintToPdfOptions;
890
891 let browser_path = discover_browser().context(
892 "no supported Chromium-based browser found; \
893 set SLOC_BROWSER/BROWSER or install Chrome, Chromium, Edge, Brave, Vivaldi, or Opera",
894 )?;
895 eprintln!("[oxide-sloc][pdf] browser = {}", browser_path.display());
896
897 let no_sandbox = std::env::var("SLOC_BROWSER_NOSANDBOX").as_deref() == Ok("1");
898 if no_sandbox {
899 eprintln!("[oxide-sloc][pdf] --no-sandbox enabled via SLOC_BROWSER_NOSANDBOX=1");
900 }
901
902 let browser = launch_cdp_browser(browser_path, no_sandbox)?;
903 let tab = browser.new_tab().context("failed to open browser tab")?;
904 tab.set_default_timeout(std::time::Duration::from_secs(90));
909
910 let html_for_url = PathBuf::from(
911 html_path
912 .to_string_lossy()
913 .trim_start_matches(r"\\?\")
914 .to_string(),
915 );
916 let url = file_url(&html_for_url);
917 eprintln!("[oxide-sloc][pdf] url = {url}");
918
919 tab.navigate_to(&url)
920 .context("failed to navigate browser to HTML file")?;
921 tab.wait_until_navigated()
922 .context("browser navigation did not complete")?;
923
924 wait_for_charts_ready(&tab);
925
926 let banner_text = extract_banner_text(&tab);
930
931 if let Some(ref t) = banner_text {
932 eprintln!("[oxide-sloc][pdf] report banner detected: {t}");
933 }
934
935 let has_banner = banner_text.is_some();
936
937 let native_header = if has_banner {
943 None
944 } else {
945 extract_pdf_template(&tab, "pdf-native-header")
946 };
947 let native_footer = if has_banner {
948 None
949 } else {
950 extract_pdf_template(&tab, "pdf-native-footer")
951 };
952 let has_native_header = native_header.is_some();
953 let has_native_footer = native_footer.is_some();
954
955 let make_banner_tmpl = |text: &str| -> String {
958 let escaped = text
959 .replace('&', "&")
960 .replace('<', "<")
961 .replace('>', ">")
962 .replace('"', """);
963 format!(
964 r#"<div style="font-size:10px;width:100%;text-align:center;\
965color:#fff;background:#b35428;padding:5px 0;\
966font-family:sans-serif;font-weight:700;letter-spacing:0.05em;\
967-webkit-print-color-adjust:exact;print-color-adjust:exact;">{escaped}</div>"#
968 )
969 };
970
971 let (header_tmpl, footer_tmpl): (Option<String>, Option<String>) = if has_banner {
975 let t = banner_text.as_deref().unwrap_or_default();
976 (Some(make_banner_tmpl(t)), Some(make_banner_tmpl(t)))
977 } else if has_native_header || has_native_footer {
978 let empty = || "<span></span>".to_string();
979 (
980 Some(native_header.unwrap_or_else(empty)),
981 Some(native_footer.unwrap_or_else(empty)),
982 )
983 } else {
984 (None, None)
985 };
986
987 let display_header_footer = has_banner || has_native_header || has_native_footer;
988 let margin_top = if has_banner {
991 0.35
992 } else if has_native_header {
993 0.55
994 } else {
995 0.0
996 };
997 let margin_bottom = if has_banner {
998 0.25
999 } else if has_native_footer {
1000 0.42
1001 } else {
1002 0.0
1003 };
1004
1005 let pdf_bytes = tab
1006 .print_to_pdf(Some(PrintToPdfOptions {
1007 landscape: Some(true),
1008 print_background: Some(true),
1009 scale: Some(0.97),
1010 paper_width: Some(11.69), paper_height: Some(8.27), margin_top: Some(margin_top),
1013 margin_bottom: Some(margin_bottom),
1014 margin_left: Some(0.0),
1015 margin_right: Some(0.0),
1016 prefer_css_page_size: Some(false),
1017 display_header_footer: if display_header_footer {
1018 Some(true)
1019 } else {
1020 None
1021 },
1022 header_template: header_tmpl,
1023 footer_template: footer_tmpl,
1024 ..Default::default()
1025 }))
1026 .context("browser failed to generate PDF")?;
1027
1028 fs::write(output_path, &pdf_bytes)
1029 .with_context(|| format!("failed to write PDF to {}", output_path.display()))?;
1030
1031 eprintln!("[oxide-sloc][pdf] wrote {} bytes", pdf_bytes.len());
1032 Ok(())
1033}
1034
1035fn discover_wkhtmltopdf() -> Option<PathBuf> {
1045 if let Some(p) = which_in_path("wkhtmltopdf") {
1046 return Some(p);
1047 }
1048
1049 #[cfg(windows)]
1050 {
1051 for var in ["ProgramFiles", "ProgramFiles(x86)"] {
1052 if let Ok(base) = std::env::var(var) {
1053 let candidate = PathBuf::from(base)
1054 .join("wkhtmltopdf")
1055 .join("bin")
1056 .join("wkhtmltopdf.exe");
1057 if candidate.is_file() {
1058 return Some(candidate);
1059 }
1060 }
1061 }
1062 }
1063
1064 #[cfg(not(windows))]
1065 for p in [
1066 "/usr/bin/wkhtmltopdf",
1067 "/usr/local/bin/wkhtmltopdf",
1068 "/opt/wkhtmltopdf/bin/wkhtmltopdf",
1069 "/snap/bin/wkhtmltopdf",
1070 ] {
1071 let candidate = PathBuf::from(p);
1072 if candidate.is_file() {
1073 return Some(candidate);
1074 }
1075 }
1076
1077 None
1078}
1079
1080fn write_pdf_via_wkhtmltopdf(html_path: &Path, pdf_path: &Path) -> Result<()> {
1087 eprintln!("[oxide-sloc][pdf] trying wkhtmltopdf fallback");
1088
1089 let exe = discover_wkhtmltopdf().context(
1090 "wkhtmltopdf not found. \
1091 Linux: install via 'dnf install wkhtmltopdf' or 'apt install wkhtmltopdf'. \
1092 Windows: install the MSI from https://wkhtmltopdf.org/downloads.html. \
1093 Alternatively, set SLOC_BROWSER to a Chromium-based browser executable.",
1094 )?;
1095 eprintln!("[oxide-sloc][pdf] wkhtmltopdf = {}", exe.display());
1096
1097 let html_normalized = PathBuf::from(
1099 html_path
1100 .to_string_lossy()
1101 .trim_start_matches(r"\\?\")
1102 .to_string(),
1103 );
1104 let html_url = file_url(&html_normalized);
1106 eprintln!("[oxide-sloc][pdf] wkhtmltopdf url = {html_url}");
1107
1108 let pdf_str = pdf_path
1109 .to_str()
1110 .context("PDF output path contains non-UTF-8 characters")?;
1111
1112 let output = std::process::Command::new(&exe)
1113 .args([
1114 "--enable-javascript",
1115 "--javascript-delay",
1116 "2000",
1117 "--quiet",
1118 "--orientation",
1119 "Landscape",
1120 "--page-size",
1121 "A4",
1122 "--margin-top",
1123 "9",
1124 "--margin-bottom",
1125 "9",
1126 "--margin-left",
1127 "13",
1128 "--margin-right",
1129 "13",
1130 "--print-media-type",
1131 &html_url,
1132 pdf_str,
1133 ])
1134 .output()
1135 .with_context(|| format!("failed to launch wkhtmltopdf at {}", exe.display()))?;
1136
1137 if !output.status.success() {
1138 let stderr = String::from_utf8_lossy(&output.stderr);
1139 anyhow::bail!("wkhtmltopdf exited with {}: {stderr}", output.status);
1140 }
1141
1142 if !pdf_path.exists() {
1143 anyhow::bail!(
1144 "wkhtmltopdf exited successfully but {} was not created",
1145 pdf_path.display()
1146 );
1147 }
1148
1149 eprintln!("[oxide-sloc][pdf] wkhtmltopdf wrote {}", pdf_path.display());
1150 Ok(())
1151}
1152
1153struct PdfCtx<'a> {
1154 layer: &'a crate::pdf_compat::PdfLayerReference,
1155 font_reg: &'a crate::pdf_compat::IndirectFontRef,
1156 font_bold: &'a crate::pdf_compat::IndirectFontRef,
1157 w: f32,
1158 margin: f32,
1159 row_h: f32,
1160 tbl_hdr_h: f32,
1161}
1162
1163#[derive(Clone, Copy)]
1166struct PdfPageDims {
1167 w: f32,
1168 h: f32,
1169 margin: f32,
1170 footer_h: f32,
1171 row_h: f32,
1172 tbl_hdr_h: f32,
1173}
1174
1175#[allow(
1176 clippy::cast_precision_loss,
1177 clippy::suboptimal_flops,
1178 clippy::too_many_lines
1179)]
1180fn runtime_mode_display(mode: &str) -> &str {
1181 match mode {
1182 "serve" => "Web UI",
1183 "analyze" => "CLI",
1184 "git-scan" => "Git Scan",
1185 "git-compare" => "Git Compare",
1186 "watch" => "Watch",
1187 other => other,
1188 }
1189}
1190
1191fn pdf_render_page1_header(
1192 ctx: &PdfCtx<'_>,
1193 run: &AnalysisRun,
1194 ts: &str,
1195 title: &str,
1196 h: f32,
1197 hdr_h: f32,
1198 banner: Option<&str>,
1199) -> f32 {
1200 use crate::pdf_compat::{Color, Mm, Rgb};
1201 let hdr_y = h - hdr_h;
1202 pdf_fill_rect(
1203 ctx.layer,
1204 0.0,
1205 hdr_y,
1206 ctx.w,
1207 hdr_h,
1208 Rgb::new(0.098, 0.11, 0.15, None),
1209 );
1210 ctx.layer
1211 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1212 ctx.layer.use_text(
1213 "oxide-sloc",
1214 13.0,
1215 Mm(ctx.margin),
1216 Mm(hdr_y + 4.5),
1217 ctx.font_bold,
1218 );
1219 ctx.layer
1220 .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
1221 ctx.layer.use_text(
1222 "Code Metrics Report",
1223 9.5,
1224 Mm(54.0),
1225 Mm(hdr_y + 5.0),
1226 ctx.font_reg,
1227 );
1228 ctx.layer.use_text(
1229 pdf_safe_str(ts),
1230 8.0,
1231 Mm(ctx.w - 70.0),
1232 Mm(hdr_y + 5.0),
1233 ctx.font_reg,
1234 );
1235 if let Some(text) = banner {
1237 let safe = pdf_trunc(&pdf_safe_str(text), 40);
1238 #[allow(
1242 clippy::cast_precision_loss,
1243 reason = "small bounded char count; sub-mm layout offset"
1244 )]
1245 let text_x = (safe.len() as f32).mul_add(-0.97, ctx.w / 2.0).max(95.0);
1246 ctx.layer
1247 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1248 ctx.layer
1249 .use_text(safe, 9.0, Mm(text_x), Mm(hdr_y + 4.5), ctx.font_bold);
1250 }
1251 let title_text_y = hdr_y - 5.5;
1252 ctx.layer
1253 .set_fill_color(Color::Rgb(Rgb::new(0.098, 0.11, 0.15, None)));
1254 ctx.layer.use_text(
1255 pdf_trunc(&pdf_safe_str(title), 55),
1256 9.5,
1257 Mm(ctx.margin),
1258 Mm(title_text_y),
1259 ctx.font_bold,
1260 );
1261 let roots_text_y = title_text_y - 5.0;
1262 let roots: String = run
1264 .input_roots
1265 .iter()
1266 .map(|r| pdf_safe_str(r))
1267 .collect::<Vec<_>>()
1268 .join(" ");
1269 ctx.layer
1270 .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
1271 ctx.layer.use_text(
1272 pdf_trunc(&roots, 85),
1273 6.5,
1274 Mm(ctx.margin),
1275 Mm(roots_text_y),
1276 ctx.font_reg,
1277 );
1278 pdf_render_page1_gitbox(ctx, run, title_text_y, roots_text_y);
1280 roots_text_y
1281}
1282
1283fn pdf_render_page1_gitbox(
1285 ctx: &PdfCtx<'_>,
1286 run: &AnalysisRun,
1287 title_text_y: f32,
1288 roots_text_y: f32,
1289) {
1290 use crate::pdf_compat::{Color, Mm, Rgb};
1291 let mut git_parts: Vec<String> = vec![];
1292 if let Some(ref b) = run.git_branch {
1293 git_parts.push(format!("Branch: {}", pdf_safe_str(b)));
1294 }
1295 if let Some(ref c) = run.git_commit_short {
1296 git_parts.push(format!("Commit: {}", pdf_safe_str(c)));
1297 }
1298 if let Some(ref t) = run.git_nearest_tag {
1299 git_parts.push(format!("Tag: {}", pdf_safe_str(t)));
1300 }
1301 let git_str = pdf_trunc(&git_parts.join(" \u{00B7} "), 70);
1302
1303 let initiator = run
1304 .environment
1305 .ci_name
1306 .as_deref()
1307 .unwrap_or(run.environment.initiator_username.as_str());
1308 let mode_label = runtime_mode_display(&run.environment.runtime_mode);
1309 let env_str = format!(
1310 "OS: {} / {} \u{00B7} User: {} \u{00B7} Host: {} \u{00B7} Source: {}",
1311 pdf_safe_str(&run.environment.operating_system),
1312 pdf_safe_str(&run.environment.architecture),
1313 pdf_safe_str(initiator),
1314 pdf_safe_str(&run.environment.initiator_hostname),
1315 mode_label,
1316 );
1317 let env_trunc = pdf_trunc(&env_str, 100);
1318
1319 let right_anchor = ctx.w - ctx.margin - 6.0;
1321 let git_w = helvetica_width_mm(&git_str, 7.5, true);
1324 let env_w = helvetica_width_mm(&env_trunc, 6.5, false);
1325 let max_w = git_w.max(env_w);
1326
1327 let pad_h: f32 = 3.5;
1329 let pad_v: f32 = 1.8;
1330 let box_left = (right_anchor - max_w - pad_h).max(ctx.w / 2.0 - pad_h);
1331 let box_right = right_anchor + pad_h;
1332 let box_width = box_right - box_left;
1333 let box_bot = roots_text_y - pad_v;
1334 let box_top = title_text_y + pad_v + 1.5;
1335 let box_height = box_top - box_bot;
1336 pdf_fill_rect(
1337 ctx.layer,
1338 box_left - 0.6,
1339 box_bot - 0.6,
1340 box_width + 1.2,
1341 box_height + 1.2,
1342 Rgb::new(0.80, 0.75, 0.68, None),
1343 );
1344 pdf_fill_rect(
1345 ctx.layer,
1346 box_left,
1347 box_bot,
1348 box_width,
1349 box_height,
1350 Rgb::new(0.97, 0.95, 0.92, None),
1351 );
1352
1353 if !git_str.is_empty() {
1355 let git_x = (right_anchor - git_w).max(box_left + 2.0);
1356 ctx.layer
1357 .set_fill_color(Color::Rgb(Rgb::new(0.25, 0.42, 0.25, None)));
1358 ctx.layer.use_text(
1359 git_str.as_str(),
1360 7.5,
1361 Mm(git_x),
1362 Mm(title_text_y),
1363 ctx.font_bold,
1364 );
1365 }
1366 let env_x = (right_anchor - env_w).max(box_left + 2.0);
1368 ctx.layer
1369 .set_fill_color(Color::Rgb(Rgb::new(0.38, 0.38, 0.38, None)));
1370 ctx.layer.use_text(
1371 env_trunc.as_str(),
1372 6.5,
1373 Mm(env_x),
1374 Mm(roots_text_y),
1375 ctx.font_reg,
1376 );
1377}
1378
1379#[allow(clippy::cast_precision_loss)]
1380fn pdf_render_summary_chips(ctx: &PdfCtx<'_>, run: &AnalysisRun, roots_text_y: f32) -> f32 {
1381 use crate::pdf_compat::{Color, Mm, Rgb};
1382 let tot = &run.summary_totals;
1383 let chip_gap: f32 = 5.0;
1384 let chip_w = 3.0f32.mul_add(-chip_gap, 2.0f32.mul_add(-ctx.margin, ctx.w)) / 4.0;
1385 let chip_h: f32 = 17.0;
1386 let row1_bot = roots_text_y - 4.0 - chip_h;
1387 let row1: [(&str, u64); 4] = [
1388 ("Code Lines", tot.code_lines),
1389 ("Comment Lines", tot.comment_lines),
1390 ("Blank Lines", tot.blank_lines),
1391 ("Physical Lines", tot.total_physical_lines),
1392 ];
1393 for (i, (label, value)) in row1.iter().enumerate() {
1394 let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1395 pdf_fill_rect(
1396 ctx.layer,
1397 cx,
1398 row1_bot,
1399 chip_w,
1400 chip_h,
1401 Rgb::new(0.945, 0.925, 0.90, None),
1402 );
1403 ctx.layer
1404 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
1405 ctx.layer.use_text(
1407 pdf_fmt_full(*value),
1408 13.0,
1409 Mm(cx + 4.0),
1410 Mm(row1_bot + 9.0),
1411 ctx.font_bold,
1412 );
1413 ctx.layer
1414 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
1415 ctx.layer.use_text(
1416 pdf_safe_str(label),
1417 6.5,
1418 Mm(cx + 4.0),
1419 Mm(row1_bot + 3.0),
1420 ctx.font_reg,
1421 );
1422 }
1423 let row2_bot = row1_bot - 3.0 - chip_h;
1424 let row2_4th = if tot.test_count > 0 {
1425 ("Test Methods", tot.test_count)
1426 } else if tot.classes > 0 {
1427 ("Classes", tot.classes)
1428 } else {
1429 ("Mixed Lines", tot.mixed_lines_separate)
1430 };
1431 let row2: [(&str, u64); 4] = [
1432 ("Files Analyzed", tot.files_analyzed),
1433 ("Files Skipped", tot.files_skipped),
1434 ("Functions", tot.functions),
1435 row2_4th,
1436 ];
1437 for (i, (label, value)) in row2.iter().enumerate() {
1438 let cx = (i as f32).mul_add(chip_w + chip_gap, ctx.margin);
1439 pdf_fill_rect(
1440 ctx.layer,
1441 cx,
1442 row2_bot,
1443 chip_w,
1444 chip_h,
1445 Rgb::new(0.91, 0.92, 0.96, None),
1446 );
1447 ctx.layer
1448 .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.25, 0.55, None)));
1449 ctx.layer.use_text(
1450 pdf_fmt_full(*value),
1451 13.0,
1452 Mm(cx + 4.0),
1453 Mm(row2_bot + 9.0),
1454 ctx.font_bold,
1455 );
1456 ctx.layer
1457 .set_fill_color(Color::Rgb(Rgb::new(0.35, 0.35, 0.45, None)));
1458 ctx.layer.use_text(
1459 pdf_safe_str(label),
1460 6.5,
1461 Mm(cx + 4.0),
1462 Mm(row2_bot + 3.0),
1463 ctx.font_reg,
1464 );
1465 }
1466 row2_bot
1467}
1468
1469#[allow(clippy::cast_precision_loss)]
1470fn pdf_info_parts_stats(tot: &SummaryTotals) -> Vec<String> {
1471 let total = tot.total_physical_lines.max(1) as f64;
1472 let code_pct = tot.code_lines as f64 / total * 100.0;
1473 let cmt_pct = tot.comment_lines as f64 / total * 100.0;
1474 let blank_pct = tot.blank_lines as f64 / total * 100.0;
1475 let mixed_pct = tot.mixed_lines_separate as f64 / total * 100.0;
1476 let mut parts = vec![
1477 format!(
1478 "Code: {code_pct:.1}% ({} lines)",
1479 pdf_fmt_full(tot.code_lines)
1480 ),
1481 format!(
1482 "Comments: {cmt_pct:.1}% ({} lines)",
1483 pdf_fmt_full(tot.comment_lines)
1484 ),
1485 format!(
1486 "Blank: {blank_pct:.1}% ({} lines)",
1487 pdf_fmt_full(tot.blank_lines)
1488 ),
1489 ];
1490 if tot.functions > 0 {
1491 parts.push(format!("Functions: {}", pdf_fmt_full(tot.functions)));
1492 }
1493 if tot.mixed_lines_separate > 0 {
1494 parts.push(format!(
1495 "Mixed: {mixed_pct:.1}% ({} lines)",
1496 pdf_fmt_full(tot.mixed_lines_separate)
1497 ));
1498 }
1499 if tot.imports > 0 {
1500 parts.push(format!("Imports: {}", pdf_fmt_full(tot.imports)));
1501 }
1502 if tot.variables > 0 {
1503 parts.push(format!("Variables: {}", pdf_fmt_full(tot.variables)));
1504 }
1505 if tot.classes > 0 {
1506 parts.push(format!("Classes: {}", pdf_fmt_full(tot.classes)));
1507 }
1508 parts
1509}
1510
1511fn pdf_info_parts_git(run: &AnalysisRun) -> Vec<String> {
1512 let mut parts: Vec<String> = Vec::new();
1513 if let Some(ref b) = run.git_branch {
1514 parts.push(format!("Branch: {}", pdf_safe_str(b)));
1515 }
1516 if let Some(ref c) = run.git_commit_short {
1517 parts.push(format!("Commit: {}", pdf_safe_str(c)));
1518 }
1519 if let Some(ref t) = run.git_nearest_tag {
1520 parts.push(format!("Tag: {}", pdf_safe_str(t)));
1521 }
1522 if let Some(ref a) = run.git_commit_author {
1523 parts.push(format!("Author: {}", pdf_safe_str(a)));
1524 }
1525 if let Some(ref d) = run.git_commit_date {
1526 parts.push(format!("Commit Date: {}", fmt_commit_date_pt(d)));
1527 }
1528 parts
1529}
1530
1531#[allow(clippy::cast_precision_loss)]
1532fn pdf_info_parts_tests(tot: &SummaryTotals) -> Vec<String> {
1533 let mut tc: Vec<String> = Vec::new();
1534 if tot.test_count > 0 {
1535 tc.push(format!("Tests: {}", pdf_fmt_full(tot.test_count)));
1536 }
1537 if tot.test_assertion_count > 0 {
1538 tc.push(format!(
1539 "Assertions: {}",
1540 pdf_fmt_full(tot.test_assertion_count)
1541 ));
1542 }
1543 if tot.test_suite_count > 0 {
1544 tc.push(format!("Suites: {}", pdf_fmt_full(tot.test_suite_count)));
1545 }
1546 if tot.coverage_lines_found > 0 {
1547 tc.push(format!(
1548 "Line Cov: {:.1}% ({}/{})",
1549 tot.coverage_lines_hit as f64 / tot.coverage_lines_found as f64 * 100.0,
1550 pdf_fmt_full(tot.coverage_lines_hit),
1551 pdf_fmt_full(tot.coverage_lines_found)
1552 ));
1553 }
1554 if tot.coverage_functions_found > 0 {
1555 tc.push(format!(
1556 "Func Cov: {:.1}%",
1557 tot.coverage_functions_hit as f64 / tot.coverage_functions_found as f64 * 100.0
1558 ));
1559 }
1560 if tot.coverage_branches_found > 0 {
1561 tc.push(format!(
1562 "Branch Cov: {:.1}%",
1563 tot.coverage_branches_hit as f64 / tot.coverage_branches_found as f64 * 100.0
1564 ));
1565 }
1566 tc
1567}
1568
1569fn pdf_info_emit_line(
1574 ctx: &PdfCtx<'_>,
1575 mut y: f32,
1576 r: f32,
1577 g: f32,
1578 b: f32,
1579 parts: &[String],
1580) -> f32 {
1581 use crate::pdf_compat::{Color, Mm, Rgb};
1582 if parts.is_empty() {
1583 return y;
1584 }
1585 const SEP: &str = " | ";
1586 let usable = ctx.w - 2.0 * ctx.margin;
1587 ctx.layer
1588 .set_fill_color(Color::Rgb(Rgb::new(r, g, b, None)));
1589 let mut line = String::new();
1590 for part in parts {
1591 let candidate = if line.is_empty() {
1592 part.clone()
1593 } else {
1594 format!("{line}{SEP}{part}")
1595 };
1596 if !line.is_empty() && helvetica_width_mm(&candidate, 7.0, false) > usable {
1598 ctx.layer
1599 .use_text(line.as_str(), 7.0, Mm(ctx.margin), Mm(y), ctx.font_reg);
1600 y -= 5.0;
1601 line.clone_from(part);
1602 } else {
1603 line = candidate;
1604 }
1605 }
1606 ctx.layer
1607 .use_text(line.as_str(), 7.0, Mm(ctx.margin), Mm(y), ctx.font_reg);
1608 y - 5.0
1609}
1610
1611fn pdf_render_info_lines(ctx: &PdfCtx<'_>, run: &AnalysisRun, row2_bot: f32) -> f32 {
1612 let tot = &run.summary_totals;
1613 let mut y = row2_bot - 6.5;
1614 let stats = pdf_info_parts_stats(tot);
1615 y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.15, &stats);
1616 let git = pdf_info_parts_git(run);
1617 if !git.is_empty() {
1618 y = pdf_info_emit_line(ctx, y, 0.10, 0.35, 0.15, &git);
1619 }
1620 let tests = pdf_info_parts_tests(tot);
1621 if !tests.is_empty() {
1622 y = pdf_info_emit_line(ctx, y, 0.15, 0.15, 0.50, &tests);
1623 }
1624 y
1625}
1626
1627#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1628fn pdf_table_render_section(
1629 ctx: &PdfCtx<'_>,
1630 x: f32,
1631 top: f32,
1632 w: f32,
1633 lbl_frac: f32,
1634 title: &str,
1635 rows: &[(&str, String)],
1636) {
1637 use crate::pdf_compat::{Color, Mm, Rgb};
1638 pdf_fill_rect(
1639 ctx.layer,
1640 x,
1641 top - ctx.tbl_hdr_h,
1642 w,
1643 ctx.tbl_hdr_h,
1644 Rgb::new(0.098, 0.11, 0.15, None),
1645 );
1646 ctx.layer
1647 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1648 ctx.layer.use_text(
1649 title,
1650 7.0,
1651 Mm(x + 2.0),
1652 Mm(top - ctx.tbl_hdr_h + 1.5),
1653 ctx.font_bold,
1654 );
1655 let y = top - ctx.tbl_hdr_h;
1656 for (ri, (lbl, val)) in rows.iter().enumerate() {
1657 let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, y);
1658 let bg = if ri % 2 == 0 {
1659 Rgb::new(0.975, 0.965, 0.95, None)
1660 } else {
1661 Rgb::new(1.0, 1.0, 1.0, None)
1662 };
1663 pdf_fill_rect(ctx.layer, x, ry, w, ctx.row_h, bg);
1664 ctx.layer
1665 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
1666 ctx.layer
1667 .use_text(*lbl, 6.5, Mm(x + 2.0), Mm(ry + 1.5), ctx.font_reg);
1668 let is_dash = val == "--";
1669 let val_rgb = if is_dash {
1670 Rgb::new(0.55, 0.55, 0.55, None)
1671 } else {
1672 Rgb::new(0.12, 0.12, 0.12, None)
1673 };
1674 let val_font = if is_dash { ctx.font_reg } else { ctx.font_bold };
1675 ctx.layer.set_fill_color(Color::Rgb(val_rgb));
1676 ctx.layer.use_text(
1677 val.as_str(),
1678 6.5,
1679 Mm(x + w * lbl_frac + 2.0),
1680 Mm(ry + 1.5),
1681 val_font,
1682 );
1683 }
1684}
1685
1686#[allow(
1687 clippy::cast_precision_loss,
1688 clippy::suboptimal_flops,
1689 clippy::similar_names
1690)]
1691fn pdf_render_metric_tables(ctx: &PdfCtx<'_>, run: &AnalysisRun, tbl_top: f32) {
1692 let tot = &run.summary_totals;
1693 let half_w = (2.0f32.mul_add(-ctx.margin, ctx.w) - 4.0) / 2.0;
1694 let left_x = ctx.margin;
1695 let right_x = ctx.margin + half_w + 4.0;
1696 let lbl_frac: f32 = 0.68;
1697
1698 let files_rows: [(&str, String); 4] = [
1699 ("Files analyzed", pdf_fmt_full(tot.files_analyzed)),
1700 ("Files skipped", pdf_fmt_full(tot.files_skipped)),
1701 ("Files modified", "--".to_string()),
1702 ("Files unchanged", "--".to_string()),
1703 ];
1704 pdf_table_render_section(ctx, left_x, tbl_top, half_w, lbl_frac, "FILES", &files_rows);
1705
1706 let lc_rows: [(&str, String); 5] = [
1707 ("Physical lines", pdf_fmt_full(tot.total_physical_lines)),
1708 ("Code lines", pdf_fmt_full(tot.code_lines)),
1709 ("Comment lines", pdf_fmt_full(tot.comment_lines)),
1710 ("Blank lines", pdf_fmt_full(tot.blank_lines)),
1711 ("Mixed (separate)", pdf_fmt_full(tot.mixed_lines_separate)),
1712 ];
1713 let lc_top = tbl_top - ctx.tbl_hdr_h - (files_rows.len() as f32).mul_add(ctx.row_h, 3.0);
1714 pdf_table_render_section(
1715 ctx,
1716 left_x,
1717 lc_top,
1718 half_w,
1719 lbl_frac,
1720 "LINE COUNTS",
1721 &lc_rows,
1722 );
1723
1724 let cs_rows: [(&str, String); 4] = [
1725 ("Functions", pdf_fmt_full(tot.functions)),
1726 ("Classes / Types", pdf_fmt_full(tot.classes)),
1727 ("Variables", pdf_fmt_full(tot.variables)),
1728 ("Imports", pdf_fmt_full(tot.imports)),
1729 ];
1730 pdf_table_render_section(
1731 ctx,
1732 right_x,
1733 tbl_top,
1734 half_w,
1735 lbl_frac,
1736 "CODE STRUCTURE",
1737 &cs_rows,
1738 );
1739
1740 let lcs_rows: [(&str, String); 4] = [
1741 ("Lines added", "--".to_string()),
1742 ("Lines removed", "--".to_string()),
1743 ("Lines modified (net)", "--".to_string()),
1744 ("Lines unmodified", "--".to_string()),
1745 ];
1746 let lcs_top = tbl_top - ctx.tbl_hdr_h - (cs_rows.len() as f32).mul_add(ctx.row_h, 3.0);
1747 pdf_table_render_section(
1748 ctx,
1749 right_x,
1750 lcs_top,
1751 half_w,
1752 lbl_frac,
1753 "LINE CHANGE SUMMARY",
1754 &lcs_rows,
1755 );
1756}
1757
1758fn pdf_tc_title_bar(ctx: &PdfCtx<'_>, label: &str, y: f32) -> f32 {
1761 use crate::pdf_compat::{Color, Mm, Rgb};
1762 let tbl_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
1763 pdf_fill_rect(
1764 ctx.layer,
1765 ctx.margin,
1766 y - ctx.tbl_hdr_h,
1767 tbl_w,
1768 ctx.tbl_hdr_h,
1769 Rgb::new(0.098, 0.11, 0.15, None),
1770 );
1771 ctx.layer
1772 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
1773 ctx.layer.use_text(
1774 label,
1775 7.0,
1776 Mm(ctx.margin + 2.0),
1777 Mm(y - ctx.tbl_hdr_h + 1.5),
1778 ctx.font_bold,
1779 );
1780 y - ctx.tbl_hdr_h
1781}
1782
1783fn pdf_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
1785 use crate::pdf_compat::Rgb;
1786 if ri.is_multiple_of(2) {
1787 Rgb::new(0.975, 0.965, 0.95, None)
1788 } else {
1789 Rgb::new(1.0, 1.0, 1.0, None)
1790 }
1791}
1792
1793fn pdf_sub_sum(
1795 sub: &sloc_core::SubmoduleSummary,
1796 f: impl Fn(&sloc_core::LanguageSummary) -> u64,
1797) -> u64 {
1798 sub.language_summaries.iter().map(f).sum()
1799}
1800
1801#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1803fn pdf_tc_stat_boxes(ctx: &PdfCtx<'_>, run: &AnalysisRun, has_cov: bool, mut y: f32) -> f32 {
1804 use crate::pdf_compat::{Color, Mm, Rgb};
1805 let gap: f32 = 4.0;
1806 let box_h: f32 = 15.0;
1807 let box_w = (ctx.w - 2.0 * ctx.margin - 3.0 * gap) / 4.0;
1808 let line_cov_str = if has_cov {
1809 let pct = run.summary_totals.coverage_lines_hit as f64
1810 / run.summary_totals.coverage_lines_found as f64
1811 * 100.0;
1812 format!("{pct:.1}%")
1813 } else {
1814 "\u{2014}".to_string()
1815 };
1816 let box_vals: [String; 4] = [
1817 pdf_fmt_full(run.summary_totals.test_count),
1818 pdf_fmt_full(run.summary_totals.test_assertion_count),
1819 pdf_fmt_full(run.summary_totals.test_suite_count),
1820 line_cov_str,
1821 ];
1822 let box_labels: [&str; 4] = [
1823 "Test Functions",
1824 "Test Assertions",
1825 "Test Suites",
1826 "Line Coverage",
1827 ];
1828 for (i, (label, val)) in box_labels.iter().zip(box_vals.iter()).enumerate() {
1829 let bx = ctx.margin + i as f32 * (box_w + gap);
1830 let by = y - box_h;
1831 pdf_fill_rect(
1832 ctx.layer,
1833 bx,
1834 by,
1835 box_w,
1836 box_h,
1837 Rgb::new(0.97, 0.96, 0.94, None),
1838 );
1839 ctx.layer
1840 .set_fill_color(Color::Rgb(Rgb::new(0.60, 0.40, 0.22, None)));
1841 ctx.layer
1842 .use_text(val.as_str(), 9.5, Mm(bx + 3.0), Mm(by + 7.5), ctx.font_bold);
1843 ctx.layer
1844 .set_fill_color(Color::Rgb(Rgb::new(0.50, 0.44, 0.40, None)));
1845 ctx.layer
1846 .use_text(*label, 5.5, Mm(bx + 3.0), Mm(by + 2.0), ctx.font_reg);
1847 }
1848 y -= box_h + 4.0;
1849 y
1850}
1851
1852#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1854fn pdf_tc_submodules(ctx: &PdfCtx<'_>, run: &AnalysisRun, footer_h: f32, mut y: f32) -> f32 {
1855 use crate::pdf_compat::{Color, Mm, Rgb};
1856 let subs = &run.submodule_summaries;
1857 if subs.is_empty() {
1858 return y;
1859 }
1860 let margin = ctx.margin;
1861 let row_h = ctx.row_h;
1862 let tbl_w = ctx.w - 2.0 * margin;
1863
1864 let col_name = tbl_w * 0.40;
1865 let rem = tbl_w - col_name;
1866 let col_files = rem * 0.15;
1867 let col_code = rem * 0.20;
1868 let col_tests = rem * 0.20;
1869 let col_assert = rem * 0.20;
1870
1871 let cx_files = margin + col_name;
1872 let cx_code = cx_files + col_files;
1873 let cx_tests = cx_code + col_code;
1874 let cx_assert = cx_tests + col_tests;
1875 let cx_cov = cx_assert + col_assert;
1876
1877 y = pdf_tc_title_bar(ctx, "SUBMODULES", y);
1878
1879 pdf_fill_rect(
1880 ctx.layer,
1881 margin,
1882 y - row_h,
1883 tbl_w,
1884 row_h,
1885 Rgb::new(0.25, 0.27, 0.32, None),
1886 );
1887 ctx.layer
1888 .set_fill_color(Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)));
1889 for (lbl, x) in &[
1890 ("Submodule", margin + 2.0),
1891 ("Files", cx_files + 2.0),
1892 ("Code Lines", cx_code + 2.0),
1893 ("Test Functions", cx_tests + 2.0),
1894 ("Assertions", cx_assert + 2.0),
1895 ("Line Coverage %", cx_cov + 2.0),
1896 ] {
1897 ctx.layer
1898 .use_text(*lbl, 5.5, Mm(*x), Mm(y - row_h + 1.5), ctx.font_bold);
1899 }
1900 y -= row_h;
1901
1902 for (ri, sub) in subs.iter().enumerate() {
1903 if y < footer_h + row_h {
1904 break;
1905 }
1906 let sub_tests = pdf_sub_sum(sub, |l| l.test_count);
1907 let sub_assert = pdf_sub_sum(sub, |l| l.test_assertion_count);
1908 let sub_cov_hit = pdf_sub_sum(sub, |l| l.coverage_lines_hit);
1909 let sub_cov_found = pdf_sub_sum(sub, |l| l.coverage_lines_found);
1910 let sub_cov_str = if sub_cov_found > 0 {
1911 format!("{:.1}%", sub_cov_hit as f64 / sub_cov_found as f64 * 100.0)
1912 } else {
1913 "\u{2014}".to_string()
1914 };
1915
1916 let ry = y - row_h;
1917 pdf_fill_rect(ctx.layer, margin, ry, tbl_w, row_h, pdf_row_bg(ri));
1918 ctx.layer
1919 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
1920 ctx.layer.use_text(
1921 pdf_trunc(&pdf_safe_str(&sub.name), 40),
1922 5.5,
1923 Mm(margin + 2.0),
1924 Mm(ry + 1.5),
1925 ctx.font_bold,
1926 );
1927 for (val, x) in &[
1928 (pdf_fmt_full(sub.files_analyzed), cx_files + 2.0),
1929 (pdf_fmt_full(sub.code_lines), cx_code + 2.0),
1930 (pdf_fmt_full(sub_tests), cx_tests + 2.0),
1931 (pdf_fmt_full(sub_assert), cx_assert + 2.0),
1932 (sub_cov_str, cx_cov + 2.0),
1933 ] {
1934 ctx.layer
1935 .use_text(val.as_str(), 5.5, Mm(*x), Mm(ry + 1.5), ctx.font_reg);
1936 }
1937 y -= row_h;
1938 }
1939 y - 3.0
1940}
1941
1942#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
1944fn pdf_tc_gauges(ctx: &PdfCtx<'_>, run: &AnalysisRun, mut y: f32) -> f32 {
1945 use crate::pdf_compat::{Color, Mm, Rgb};
1946 let margin = ctx.margin;
1947 let gap: f32 = 4.0;
1948 let gauges: &[(&str, u64, u64)] = &[
1949 (
1950 "Line Coverage",
1951 run.summary_totals.coverage_lines_hit,
1952 run.summary_totals.coverage_lines_found,
1953 ),
1954 (
1955 "Function Coverage",
1956 run.summary_totals.coverage_functions_hit,
1957 run.summary_totals.coverage_functions_found,
1958 ),
1959 (
1960 "Branch Coverage",
1961 run.summary_totals.coverage_branches_hit,
1962 run.summary_totals.coverage_branches_found,
1963 ),
1964 ];
1965 let visible: Vec<_> = gauges.iter().filter(|(_, _, found)| *found > 0).collect();
1966 if visible.is_empty() {
1967 return y;
1968 }
1969 let count = visible.len() as f32;
1970 let gauge_h: f32 = 14.0;
1971 let gauge_w = (ctx.w - 2.0 * margin - (count - 1.0) * gap) / count;
1972 for (gi, (label, hit, found)) in visible.iter().enumerate() {
1973 let gx = margin + gi as f32 * (gauge_w + gap);
1974 let pct = *hit as f64 / *found as f64 * 100.0;
1975 let pct_str = format!("{pct:.1}%");
1976 #[allow(
1979 clippy::cast_possible_truncation,
1980 reason = "0..=100 percentage to f32 bar width"
1981 )]
1982 let bar_fill = (gauge_w - 6.0) * (pct as f32 / 100.0);
1983 let gy = y - gauge_h;
1984 pdf_fill_rect(
1985 ctx.layer,
1986 gx,
1987 gy,
1988 gauge_w,
1989 gauge_h,
1990 Rgb::new(0.975, 0.965, 0.95, None),
1991 );
1992 ctx.layer
1993 .set_fill_color(Color::Rgb(Rgb::new(0.15, 0.15, 0.15, None)));
1994 ctx.layer
1995 .use_text(*label, 6.0, Mm(gx + 2.0), Mm(gy + 9.5), ctx.font_bold);
1996 ctx.layer
1997 .set_fill_color(Color::Rgb(Rgb::new(0.20, 0.55, 0.35, None)));
1998 ctx.layer
1999 .use_text(&pct_str, 8.0, Mm(gx + 2.0), Mm(gy + 4.5), ctx.font_bold);
2000 pdf_fill_rect(
2001 ctx.layer,
2002 gx + 3.0,
2003 gy + 1.5,
2004 gauge_w - 6.0,
2005 2.5,
2006 Rgb::new(0.86, 0.84, 0.80, None),
2007 );
2008 if bar_fill > 0.0 {
2009 pdf_fill_rect(
2010 ctx.layer,
2011 gx + 3.0,
2012 gy + 1.5,
2013 bar_fill,
2014 2.5,
2015 Rgb::new(0.20, 0.55, 0.35, None),
2016 );
2017 }
2018 }
2019 y -= gauge_h + 5.0;
2020 y
2021}
2022
2023struct CovCols {
2025 has_fn_cov: bool,
2026 has_br_cov: bool,
2027 col_fn_w: f32,
2028 hdr_x2: f32,
2029}
2030
2031fn pdf_tc_per_file_header(
2033 ctx: &PdfCtx<'_>,
2034 has_fn_cov: bool,
2035 has_br_cov: bool,
2036 col_fn_w: f32,
2037 y: f32,
2038) -> (f32, CovCols) {
2039 use crate::pdf_compat::{Color, Mm, Rgb};
2040 let margin = ctx.margin;
2041 let col_br_w: f32 = if has_br_cov { 22.0 } else { 0.0 };
2042 let col_file_w = 2.0_f32.mul_add(-margin, ctx.w) - 22.0 - col_fn_w - col_br_w;
2043 let hdr_x2 = margin + col_file_w;
2044
2045 let y = pdf_tc_title_bar(ctx, "PER-FILE COVERAGE", y - 3.0);
2046 ctx.layer
2047 .set_fill_color(Color::Rgb(Rgb::new(0.55, 0.55, 0.55, None)));
2048 ctx.layer
2049 .use_text("Line%", 5.5, Mm(hdr_x2 + 2.0), Mm(y - 3.5), ctx.font_bold);
2050 if has_fn_cov {
2051 ctx.layer
2052 .use_text("Fn%", 5.5, Mm(hdr_x2 + 24.0), Mm(y - 3.5), ctx.font_bold);
2053 }
2054 if has_br_cov {
2055 ctx.layer.use_text(
2056 "Br%",
2057 5.5,
2058 Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2059 Mm(y - 3.5),
2060 ctx.font_bold,
2061 );
2062 }
2063 (
2064 y - ctx.row_h,
2065 CovCols {
2066 has_fn_cov,
2067 has_br_cov,
2068 col_fn_w,
2069 hdr_x2,
2070 },
2071 )
2072}
2073
2074fn pdf_tc_per_file_row(ctx: &PdfCtx<'_>, file: &FileRecord, ri: usize, cols: &CovCols, ry: f32) {
2076 use crate::pdf_compat::{Color, Mm, Rgb};
2077 let (has_fn_cov, has_br_cov, col_fn_w, hdr_x2) =
2078 (cols.has_fn_cov, cols.has_br_cov, cols.col_fn_w, cols.hdr_x2);
2079 let Some(cov) = file.coverage.as_ref() else {
2080 return;
2081 };
2082 pdf_fill_rect(
2083 ctx.layer,
2084 ctx.margin,
2085 ry,
2086 2.0_f32.mul_add(-ctx.margin, ctx.w),
2087 ctx.row_h,
2088 pdf_row_bg(ri),
2089 );
2090 ctx.layer
2091 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2092 let fname = pdf_trunc(
2093 &pdf_safe_str(
2094 std::path::Path::new(&file.relative_path)
2095 .file_name()
2096 .and_then(|n| n.to_str())
2097 .unwrap_or(&file.relative_path),
2098 ),
2099 52,
2100 );
2101 ctx.layer.use_text(
2102 &fname,
2103 5.5,
2104 Mm(ctx.margin + 2.0),
2105 Mm(ry + 1.5),
2106 ctx.font_reg,
2107 );
2108 ctx.layer
2109 .set_fill_color(Color::Rgb(Rgb::new(0.10, 0.42, 0.25, None)));
2110 ctx.layer.use_text(
2111 format!("{:.1}%", cov.line_pct()),
2112 5.5,
2113 Mm(hdr_x2 + 2.0),
2114 Mm(ry + 1.5),
2115 ctx.font_bold,
2116 );
2117 if has_fn_cov && cov.functions_found > 0 {
2118 ctx.layer.use_text(
2119 format!("{:.1}%", cov.function_pct()),
2120 5.5,
2121 Mm(hdr_x2 + 24.0),
2122 Mm(ry + 1.5),
2123 ctx.font_bold,
2124 );
2125 }
2126 if has_br_cov && cov.branches_found > 0 {
2127 ctx.layer.use_text(
2128 format!("{:.1}%", cov.branch_pct()),
2129 5.5,
2130 Mm(hdr_x2 + 22.0 + col_fn_w + 2.0),
2131 Mm(ry + 1.5),
2132 ctx.font_bold,
2133 );
2134 }
2135}
2136
2137fn pdf_tc_per_file(
2139 ctx: &PdfCtx<'_>,
2140 run: &AnalysisRun,
2141 footer_h: f32,
2142 has_fn_cov: bool,
2143 has_br_cov: bool,
2144 mut y: f32,
2145) -> f32 {
2146 let cov_files: Vec<_> = run
2147 .per_file_records
2148 .iter()
2149 .filter(|r| r.coverage.is_some())
2150 .collect();
2151 if cov_files.is_empty() {
2152 return y;
2153 }
2154 let col_fn_w: f32 = if has_fn_cov { 22.0 } else { 0.0 };
2155 let (rows_start, cols) = pdf_tc_per_file_header(ctx, has_fn_cov, has_br_cov, col_fn_w, y);
2156 y = rows_start;
2157 for (ri, file) in cov_files.iter().enumerate() {
2158 if y < footer_h + ctx.row_h {
2159 break;
2160 }
2161 let ry = y - ctx.row_h;
2162 pdf_tc_per_file_row(ctx, file, ri, &cols, ry);
2163 y -= ctx.row_h;
2164 }
2165 y
2166}
2167
2168fn pdf_tc_no_coverage_note(ctx: &PdfCtx<'_>, mut y: f32) -> f32 {
2170 use crate::pdf_compat::{Color, Mm, Rgb};
2171 let margin = ctx.margin;
2172 let note_h: f32 = 12.0;
2173 pdf_fill_rect(
2174 ctx.layer,
2175 margin,
2176 y - note_h,
2177 2.0_f32.mul_add(-margin, ctx.w),
2178 note_h,
2179 Rgb::new(0.96, 0.95, 0.93, None),
2180 );
2181 ctx.layer
2182 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.40, 0.37, None)));
2183 ctx.layer.use_text(
2184 "No code coverage data detected.",
2185 7.0,
2186 Mm(margin + 4.0),
2187 Mm(y - note_h + 7.0),
2188 ctx.font_bold,
2189 );
2190 ctx.layer.use_text(
2191 "Re-run with --lcov-path <file.info> to see per-file line, function, and branch coverage.",
2192 6.0,
2193 Mm(margin + 4.0),
2194 Mm(y - note_h + 2.5),
2195 ctx.font_reg,
2196 );
2197 y -= note_h;
2198 y
2199}
2200
2201fn pdf_render_tc_inline(ctx: &PdfCtx<'_>, run: &AnalysisRun, y_start: f32, footer_h: f32) -> f32 {
2204 let has_cov = run.summary_totals.coverage_lines_found > 0;
2205 let has_fn_cov = run.summary_totals.coverage_functions_found > 0;
2206 let has_br_cov = run.summary_totals.coverage_branches_found > 0;
2207
2208 let mut y = pdf_tc_title_bar(ctx, "TESTS & COVERAGE", y_start) - 4.0;
2209 y = pdf_tc_stat_boxes(ctx, run, has_cov, y);
2210 y = pdf_tc_submodules(ctx, run, footer_h, y);
2211
2212 if has_cov {
2213 y = pdf_tc_gauges(ctx, run, y);
2214 y = pdf_tc_per_file(ctx, run, footer_h, has_fn_cov, has_br_cov, y);
2215 } else {
2216 y = pdf_tc_no_coverage_note(ctx, y);
2217 }
2218 y
2219}
2220
2221fn pdf_page_header_meta(run: &AnalysisRun) -> String {
2224 let mut parts = vec![format!(
2225 "Run ID: {}",
2226 pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2227 )];
2228 if let Some(ref c) = run.git_commit_short {
2229 parts.push(format!("Commit: {}", pdf_safe_str(c)));
2230 }
2231 parts.push(to_pt_hhmm(run.tool.timestamp_utc));
2232 parts.join(" \u{00B7} ")
2233}
2234
2235fn pdf_draw_header_meta(
2239 layer: &crate::pdf_compat::PdfLayerReference,
2240 font: &crate::pdf_compat::IndirectFontRef,
2241 w: f32,
2242 margin: f32,
2243 baseline_y: f32,
2244 text: &str,
2245) {
2246 use crate::pdf_compat::{Color, Mm, Rgb};
2247 let tw = helvetica_width_mm(text, 6.5, false);
2248 let x = (w - margin - tw).max(margin + 60.0);
2249 layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2250 layer.use_text(text, 6.5, Mm(x), Mm(baseline_y), font);
2251}
2252
2253fn pdf_page_mini_header(ctx: &PdfCtx<'_>, h: f32, hdr_h: f32, title: &str, run: &AnalysisRun) {
2257 use crate::pdf_compat::{Color, Mm, Rgb};
2258 pdf_fill_rect(
2259 ctx.layer,
2260 0.0,
2261 h - hdr_h,
2262 ctx.w,
2263 hdr_h,
2264 Rgb::new(0.098, 0.11, 0.15, None),
2265 );
2266 ctx.layer
2267 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2268 ctx.layer.use_text(
2269 "oxide-sloc",
2270 9.0,
2271 Mm(ctx.margin),
2272 Mm(h - 5.5),
2273 ctx.font_bold,
2274 );
2275 ctx.layer
2276 .set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2277 ctx.layer.use_text(
2278 pdf_trunc(&pdf_safe_str(title), 45),
2279 7.5,
2280 Mm(46.0),
2281 Mm(h - 5.5),
2282 ctx.font_reg,
2283 );
2284 pdf_draw_header_meta(
2285 ctx.layer,
2286 ctx.font_reg,
2287 ctx.w,
2288 ctx.margin,
2289 h - 5.5,
2290 &pdf_page_header_meta(run),
2291 );
2292}
2293
2294fn pdf_page_footer_band(ctx: &PdfCtx<'_>, footer_h: f32, version: &str) {
2297 use crate::pdf_compat::{Color, Mm, Rgb};
2298 pdf_fill_rect(
2299 ctx.layer,
2300 0.0,
2301 0.0,
2302 ctx.w,
2303 footer_h,
2304 Rgb::new(0.93, 0.91, 0.87, None),
2305 );
2306 ctx.layer
2307 .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2308 ctx.layer.use_text(
2309 format!("oxide-sloc v{version} \u{00b7} AGPL-3.0-or-later"),
2310 6.5,
2311 Mm(ctx.margin),
2312 Mm(3.0),
2313 ctx.font_reg,
2314 );
2315}
2316
2317#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)]
2320fn pdf_render_tests_coverage_page(
2321 doc: &crate::pdf_compat::PdfDocumentReference,
2322 font_reg: &crate::pdf_compat::IndirectFontRef,
2323 font_bold: &crate::pdf_compat::IndirectFontRef,
2324 run: &AnalysisRun,
2325 w: f32,
2326 h: f32,
2327 margin: f32,
2328 footer_h: f32,
2329 title: &str,
2330 version: &str,
2331) -> (
2332 crate::pdf_compat::PdfPageIndex,
2333 crate::pdf_compat::PdfLayerIndex,
2334 f32,
2335) {
2336 use crate::pdf_compat::Mm;
2337 const HDR_H: f32 = 8.0;
2338
2339 let (tc_page, tc_layer_idx) = doc.add_page(Mm(w), Mm(h), "Tests & Coverage");
2340 let layer = doc.get_page(tc_page).get_layer(tc_layer_idx);
2341 let ctx = PdfCtx {
2342 layer: &layer,
2343 font_reg,
2344 font_bold,
2345 w,
2346 margin,
2347 row_h: 5.5,
2348 tbl_hdr_h: 6.0,
2349 };
2350
2351 pdf_page_mini_header(&ctx, h, HDR_H, title, run);
2352
2353 let tc_bottom = pdf_render_tc_inline(&ctx, run, h - HDR_H - 4.0, footer_h);
2355
2356 pdf_page_footer_band(&ctx, footer_h, version);
2357
2358 (tc_page, tc_layer_idx, tc_bottom - 3.0)
2359}
2360
2361#[allow(clippy::cast_precision_loss, clippy::suboptimal_flops)]
2362fn pdf_render_page1_footer(
2363 ctx: &PdfCtx<'_>,
2364 run: &AnalysisRun,
2365 footer_h: f32,
2366 version: &str,
2367 banner: Option<&str>,
2368) {
2369 use crate::pdf_compat::{Color, Mm, Rgb};
2370 pdf_fill_rect(
2371 ctx.layer,
2372 0.0,
2373 0.0,
2374 ctx.w,
2375 footer_h,
2376 Rgb::new(0.93, 0.91, 0.87, None),
2377 );
2378 ctx.layer
2379 .set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2380 ctx.layer.use_text(
2382 format!("oxide-sloc v{version} | AGPL-3.0-or-later"),
2383 6.5,
2384 Mm(ctx.margin),
2385 Mm(3.0),
2386 ctx.font_reg,
2387 );
2388 let right_text = format!(
2390 "github.com/oxide-sloc/oxide-sloc | Run ID: {}",
2391 pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2392 );
2393 let right_x = (ctx.w - ctx.margin - right_text.len() as f32 * 1.27).max(ctx.margin + 80.0);
2394 ctx.layer
2395 .use_text(right_text, 6.5, Mm(right_x), Mm(3.0), ctx.font_reg);
2396 if let Some(text) = banner {
2398 let safe = pdf_trunc(&pdf_safe_str(text), 40);
2399 let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(ctx.margin + 50.0);
2401 ctx.layer
2402 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2403 ctx.layer
2404 .use_text(safe, 9.0, Mm(text_x), Mm(2.6), ctx.font_bold);
2405 }
2406}
2407
2408fn per_file_row_bg(ri: usize) -> crate::pdf_compat::Rgb {
2409 if ri.is_multiple_of(2) {
2410 crate::pdf_compat::Rgb::new(0.975, 0.965, 0.95, None)
2411 } else {
2412 crate::pdf_compat::Rgb::new(1.0, 1.0, 1.0, None)
2413 }
2414}
2415
2416const PDF_PERFILE_HDR_H: f32 = 8.0;
2418const PDF_PERFILE_SUB_H: f32 = 5.5;
2419const PDF_PERFILE_TABLE_GAP: f32 = 3.0;
2422
2423struct PdfPerFileCtx<'a> {
2426 doc: &'a crate::pdf_compat::PdfDocumentReference,
2427 font_reg: &'a crate::pdf_compat::IndirectFontRef,
2428 font_bold: &'a crate::pdf_compat::IndirectFontRef,
2429 w: f32,
2430 h: f32,
2431 margin: f32,
2432}
2433
2434fn pdf_perfile_page_slice(
2436 page_idx: usize,
2437 use_continuation: bool,
2438 has_first_page: bool,
2439 fp_rows: usize,
2440 rows_per_page: usize,
2441 total_files: usize,
2442) -> (usize, usize) {
2443 if use_continuation {
2444 (0, fp_rows.min(total_files))
2445 } else if has_first_page {
2446 let s = fp_rows + (page_idx - 1) * rows_per_page;
2447 (s, (s + rows_per_page).min(total_files))
2448 } else {
2449 let s = page_idx * rows_per_page;
2450 (s, (s + rows_per_page).min(total_files))
2451 }
2452}
2453
2454#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2460fn pdf_draw_perfile_header(
2461 ctx: &PdfPerFileCtx<'_>,
2462 use_continuation: bool,
2463 first_page: Option<(
2464 crate::pdf_compat::PdfPageIndex,
2465 crate::pdf_compat::PdfLayerIndex,
2466 f32,
2467 )>,
2468 page_idx: usize,
2469 page_count: usize,
2470 banner: Option<&str>,
2471 meta: &str,
2472) -> (crate::pdf_compat::PdfLayerReference, f32) {
2473 use crate::pdf_compat::{Color, Mm, Rgb};
2474 if use_continuation {
2475 let (fp_page, fp_layer_idx, fp_top) = first_page.unwrap();
2476 let layer = ctx.doc.get_page(fp_page).get_layer(fp_layer_idx);
2477 (layer, fp_top - PDF_PERFILE_SUB_H)
2478 } else {
2479 let (pf_page, pf_layer_idx) = ctx.doc.add_page(Mm(ctx.w), Mm(ctx.h), "Content");
2480 let layer = ctx.doc.get_page(pf_page).get_layer(pf_layer_idx);
2481 let hdr_top = ctx.h - PDF_PERFILE_HDR_H;
2482 pdf_fill_rect(
2483 &layer,
2484 0.0,
2485 hdr_top,
2486 ctx.w,
2487 PDF_PERFILE_HDR_H,
2488 Rgb::new(0.098, 0.11, 0.15, None),
2489 );
2490 layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2491 layer.use_text(
2492 "oxide-sloc",
2493 9.0,
2494 Mm(ctx.margin),
2495 Mm(hdr_top + 2.5),
2496 ctx.font_bold,
2497 );
2498 layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2499 layer.use_text(
2500 "Per-File Detail",
2501 8.0,
2502 Mm(46.0),
2503 Mm(hdr_top + 2.5),
2504 ctx.font_reg,
2505 );
2506 let right = format!(
2508 "{meta} \u{00B7} Page {} of {}",
2509 page_idx + 2,
2510 page_count + 1
2511 );
2512 let right_w = helvetica_width_mm(&right, 6.5, false);
2513 let right_x = (ctx.w - ctx.margin - right_w).max(ctx.margin + 60.0);
2514 layer.use_text(right, 6.5, Mm(right_x), Mm(hdr_top + 2.5), ctx.font_reg);
2515 if let Some(text) = banner {
2516 let safe = pdf_trunc(&pdf_safe_str(text), 40);
2517 let text_x = (ctx.w / 2.0 - safe.len() as f32 * 0.97).max(80.0);
2518 layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2519 layer.use_text(safe, 9.0, Mm(text_x), Mm(hdr_top + 2.5), ctx.font_bold);
2520 }
2521 (layer, hdr_top - PDF_PERFILE_TABLE_GAP - PDF_PERFILE_SUB_H)
2523 }
2524}
2525
2526#[allow(clippy::suboptimal_flops, clippy::cast_precision_loss)]
2528fn pdf_draw_perfile_rows(
2529 ctx: &PdfCtx<'_>,
2530 records: &[FileRecord],
2531 col_x: &[f32; 13],
2532 pf_tbl_top: f32,
2533) {
2534 use crate::pdf_compat::{Color, Mm, Rgb};
2535 for (ri, rec) in records.iter().enumerate() {
2536 let ry = ((ri + 1) as f32).mul_add(-ctx.row_h, pf_tbl_top - ctx.tbl_hdr_h);
2537 let bg = per_file_row_bg(ri);
2538 pdf_fill_rect(
2539 ctx.layer,
2540 ctx.margin,
2541 ry,
2542 2.0f32.mul_add(-ctx.margin, ctx.w),
2543 ctx.row_h,
2544 bg,
2545 );
2546 ctx.layer
2547 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2548 let file_str = pdf_safe_str(&rec.relative_path);
2549 let lang_str = rec
2550 .language
2551 .as_ref()
2552 .map_or_else(|| "--".to_string(), |l| l.display_name().to_string());
2553 let raw = &rec.raw_line_categories;
2554 let eff = &rec.effective_counts;
2555 let cells = [
2556 pdf_trunc_end(&file_str, 110),
2557 lang_str,
2558 pdf_fmt_full(raw.total_physical_lines),
2559 pdf_fmt_full(eff.code_lines),
2560 pdf_fmt_full(eff.comment_lines),
2561 pdf_fmt_full(eff.blank_lines),
2562 pdf_fmt_full(eff.mixed_lines_separate),
2563 pdf_fmt_full(raw.functions),
2564 pdf_fmt_full(raw.classes),
2565 pdf_fmt_full(raw.variables),
2566 pdf_fmt_full(raw.imports),
2567 pdf_fmt_full(raw.test_count),
2568 pdf_fmt_full(raw.test_assertion_count),
2569 ];
2570 for (ci, cell) in cells.iter().enumerate() {
2571 ctx.layer.use_text(
2572 cell.clone(),
2573 5.5,
2574 Mm(col_x[ci] + 0.5),
2575 Mm(ry + 1.0),
2576 ctx.font_reg,
2577 );
2578 }
2579 }
2580}
2581
2582#[allow(
2584 clippy::cast_precision_loss,
2585 clippy::cast_possible_truncation,
2586 clippy::cast_sign_loss,
2587 clippy::too_many_arguments,
2588 clippy::too_many_lines,
2589 clippy::suboptimal_flops
2590)]
2591fn pdf_render_per_file_pages(
2592 doc: &crate::pdf_compat::PdfDocumentReference,
2593 font_reg: &crate::pdf_compat::IndirectFontRef,
2594 font_bold: &crate::pdf_compat::IndirectFontRef,
2595 run: &AnalysisRun,
2596 w: f32,
2597 h: f32,
2598 margin: f32,
2599 footer_h: f32,
2600 row_h: f32,
2601 tbl_hdr_h: f32,
2602 title: &str,
2603 ts: &str,
2604 version: &str,
2605 banner: Option<&str>,
2606 first_page: Option<(
2609 crate::pdf_compat::PdfPageIndex,
2610 crate::pdf_compat::PdfLayerIndex,
2611 f32,
2612 )>,
2613) {
2614 use crate::pdf_compat::{Color, Mm, Rgb};
2615 let col_x: [f32; 13] = [
2619 10.0, 146.0, 160.0, 172.0, 182.0, 195.0, 205.0, 215.0, 228.0, 239.0, 252.0, 263.0, 273.0,
2620 ];
2621 let col_labels: [&str; 13] = [
2622 "File",
2623 "Language",
2624 "Physical",
2625 "Code",
2626 "Comments",
2627 "Blank",
2628 "Mixed",
2629 "Functions",
2630 "Classes",
2631 "Variables",
2632 "Imports",
2633 "Tests",
2634 "Assertions",
2635 ];
2636 let rows_per_page =
2637 ((h - PDF_PERFILE_HDR_H - PDF_PERFILE_SUB_H - PDF_PERFILE_TABLE_GAP - tbl_hdr_h - footer_h)
2638 / row_h)
2639 .floor() as usize;
2640 let total_files = run.per_file_records.len();
2641
2642 let fp_rows = match first_page {
2644 Some((_, _, fp_top)) => ((fp_top - PDF_PERFILE_SUB_H - tbl_hdr_h - footer_h) / row_h)
2645 .floor()
2646 .max(0.0) as usize,
2647 None => rows_per_page,
2648 };
2649 let page_count = if first_page.is_some() {
2650 1 + total_files.saturating_sub(fp_rows).div_ceil(rows_per_page)
2651 } else {
2652 total_files.div_ceil(rows_per_page)
2653 };
2654 let pf_ctx = PdfPerFileCtx {
2655 doc,
2656 font_reg,
2657 font_bold,
2658 w,
2659 h,
2660 margin,
2661 };
2662 let header_meta = pdf_page_header_meta(run);
2663
2664 for page_idx in 0..page_count {
2665 let use_continuation = page_idx == 0 && first_page.is_some();
2666 let (pf_layer, sub_top) = pdf_draw_perfile_header(
2667 &pf_ctx,
2668 use_continuation,
2669 first_page,
2670 page_idx,
2671 page_count,
2672 banner,
2673 &header_meta,
2674 );
2675
2676 pdf_fill_rect(
2678 &pf_layer,
2679 margin,
2680 sub_top,
2681 w - 2.0 * margin,
2682 PDF_PERFILE_SUB_H,
2683 Rgb::new(0.098, 0.11, 0.15, None),
2684 );
2685 pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2686 pf_layer.use_text(
2687 "PER-FILE DETAIL",
2688 7.0,
2689 Mm(margin + 2.0),
2690 Mm(sub_top + 1.5),
2691 font_bold,
2692 );
2693 if use_continuation {
2694 let right = format!(
2696 "{} | {} files | {ts}",
2697 pdf_trunc(title, 30),
2698 total_files
2699 );
2700 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2701 let right_x = (w - margin - right.len() as f32 * 1.05).max(margin + 80.0);
2702 pf_layer.use_text(right, 5.5, Mm(right_x), Mm(sub_top + 1.5), font_reg);
2703 } else {
2704 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
2705 pf_layer.use_text(
2706 pdf_trunc(title, 45),
2707 5.5,
2708 Mm(margin + 60.0),
2709 Mm(sub_top + 1.5),
2710 font_reg,
2711 );
2712 pf_layer.use_text(
2713 format!("{total_files} files | {ts}"),
2714 5.5,
2715 Mm(w - margin - 55.0),
2716 Mm(sub_top + 1.5),
2717 font_reg,
2718 );
2719 }
2720
2721 let pf_tbl_top = sub_top;
2722 pdf_fill_rect(
2723 &pf_layer,
2724 margin,
2725 pf_tbl_top - tbl_hdr_h,
2726 2.0f32.mul_add(-margin, w),
2727 tbl_hdr_h,
2728 Rgb::new(0.098, 0.11, 0.15, None),
2729 );
2730 pf_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2731 for (i, lbl) in col_labels.iter().enumerate() {
2732 pf_layer.use_text(
2733 *lbl,
2734 5.0,
2735 Mm(col_x[i] + 0.5),
2736 Mm(pf_tbl_top - tbl_hdr_h + 1.5),
2737 font_bold,
2738 );
2739 }
2740
2741 let (start, end) = pdf_perfile_page_slice(
2742 page_idx,
2743 use_continuation,
2744 first_page.is_some(),
2745 fp_rows,
2746 rows_per_page,
2747 total_files,
2748 );
2749 let row_ctx = PdfCtx {
2750 layer: &pf_layer,
2751 font_reg,
2752 font_bold,
2753 w,
2754 margin,
2755 row_h,
2756 tbl_hdr_h,
2757 };
2758 pdf_draw_perfile_rows(
2759 &row_ctx,
2760 &run.per_file_records[start..end],
2761 &col_x,
2762 pf_tbl_top,
2763 );
2764
2765 pdf_fill_rect(
2767 &pf_layer,
2768 0.0,
2769 0.0,
2770 w,
2771 footer_h,
2772 Rgb::new(0.93, 0.91, 0.87, None),
2773 );
2774 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
2775 pf_layer.use_text(
2776 format!("oxide-sloc v{version} | AGPL-3.0-or-later"),
2777 6.5,
2778 Mm(margin),
2779 Mm(3.0),
2780 font_reg,
2781 );
2782 let right_text = format!(
2783 "github.com/oxide-sloc/oxide-sloc | Run ID: {}",
2784 pdf_safe_str(&run.tool.run_id[..run.tool.run_id.len().min(20)])
2785 );
2786 let right_x = (w - margin - right_text.len() as f32 * 1.27).max(margin + 80.0);
2787 pf_layer.use_text(right_text, 6.5, Mm(right_x), Mm(3.0), font_reg);
2788 if let Some(text) = banner {
2790 let safe = pdf_trunc(&pdf_safe_str(text), 40);
2791 let text_x = (w / 2.0 - safe.len() as f32 * 0.97).max(margin + 50.0);
2792 pf_layer.set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2793 pf_layer.use_text(safe, 9.0, Mm(text_x), Mm(2.6), font_bold);
2794 }
2795 }
2796}
2797
2798fn pdf_section_header_bar(
2802 ctx: &PdfCtx<'_>,
2803 usable_w: f32,
2804 section_top: f32,
2805 hdr_h: f32,
2806 title: &str,
2807) {
2808 use crate::pdf_compat::{Color, Mm, Rgb};
2809 pdf_fill_rect(
2810 ctx.layer,
2811 ctx.margin,
2812 section_top - hdr_h,
2813 usable_w,
2814 hdr_h,
2815 Rgb::new(0.098, 0.11, 0.15, None),
2816 );
2817 ctx.layer
2818 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2819 ctx.layer.use_text(
2820 title,
2821 7.0,
2822 Mm(ctx.margin + 2.0),
2823 Mm(section_top - hdr_h + 1.5),
2824 ctx.font_bold,
2825 );
2826}
2827
2828#[allow(
2834 clippy::cast_precision_loss,
2835 clippy::cast_possible_truncation,
2836 clippy::too_many_lines,
2837 clippy::suboptimal_flops
2838)]
2839fn pdf_render_style_section(ctx: &PdfCtx<'_>, ss: &StyleSummary, section_top: f32) -> f32 {
2840 use crate::pdf_compat::{Color, Mm, Rgb};
2841 const HDR_H: f32 = 5.5;
2842 const CHIP_H: f32 = 11.0;
2843 const CHIP_GAP: f32 = 4.0;
2844 const ROW_H: f32 = 5.0;
2845 const TBL_HDR_H: f32 = 5.0;
2846 const GAP: f32 = 2.5;
2847
2848 let usable_w = ctx.w - 2.0 * ctx.margin;
2849 let chip_w = (usable_w - 3.0 * CHIP_GAP) / 4.0;
2850
2851 pdf_section_header_bar(ctx, usable_w, section_top, HDR_H, "CODE STYLE ANALYSIS");
2853 let col_label = format!("{}-Col", ss.col_threshold);
2854 ctx.layer
2855 .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
2856 ctx.layer.use_text(
2857 "Lexical heuristics",
2858 5.5,
2859 Mm(ctx.w - ctx.margin - 26.0),
2860 Mm(section_top - HDR_H + 1.5),
2861 ctx.font_reg,
2862 );
2863
2864 let chips_bot = section_top - HDR_H - GAP - CHIP_H;
2866 let chip_data: [(&str, String); 4] = [
2867 ("Files Analyzed", ss.files_analyzed.to_string()),
2868 ("Language Groups", ss.by_language.len().to_string()),
2869 ("Common Indent", ss.common_indent_style.clone()),
2870 (&col_label, format!("{}%", ss.line_col_compliant_pct)),
2871 ];
2872 for (i, (label, value)) in chip_data.iter().enumerate() {
2873 let cx = (i as f32).mul_add(chip_w + CHIP_GAP, ctx.margin);
2874 pdf_fill_rect(
2875 ctx.layer,
2876 cx,
2877 chips_bot,
2878 chip_w,
2879 CHIP_H,
2880 Rgb::new(0.945, 0.925, 0.90, None),
2881 );
2882 ctx.layer
2883 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2884 ctx.layer.use_text(
2885 pdf_trunc(value, 16),
2886 10.0,
2887 Mm(cx + 3.0),
2888 Mm(chips_bot + 5.5),
2889 ctx.font_bold,
2890 );
2891 ctx.layer
2892 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
2893 ctx.layer.use_text(
2894 pdf_safe_str(label),
2895 5.5,
2896 Mm(cx + 3.0),
2897 Mm(chips_bot + 1.5),
2898 ctx.font_reg,
2899 );
2900 }
2901
2902 if ss.by_language.is_empty() {
2904 return chips_bot;
2905 }
2906 let tbl_top = chips_bot - GAP;
2907
2908 let col_w = [0.28_f32, 0.08, 0.36, 0.14, 0.14];
2910 let col_x: Vec<f32> = col_w
2911 .iter()
2912 .scan(ctx.margin, |acc, &w| {
2913 let x = *acc;
2914 *acc += w * usable_w;
2915 Some(x)
2916 })
2917 .collect();
2918 let headers = ["Language Family", "Files", "Top Guide", "Score", &col_label];
2919
2920 pdf_fill_rect(
2921 ctx.layer,
2922 ctx.margin,
2923 tbl_top - TBL_HDR_H,
2924 usable_w,
2925 TBL_HDR_H,
2926 Rgb::new(0.098, 0.11, 0.15, None),
2927 );
2928 ctx.layer
2929 .set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
2930 for (hi, hdr) in headers.iter().enumerate() {
2931 ctx.layer.use_text(
2932 pdf_safe_str(hdr),
2933 5.5,
2934 Mm(col_x[hi] + 2.0),
2935 Mm(tbl_top - TBL_HDR_H + 1.5),
2936 ctx.font_bold,
2937 );
2938 }
2939
2940 let mut row_y = tbl_top - TBL_HDR_H;
2941 for (ri, grp) in ss.by_language.iter().take(5).enumerate() {
2942 let ry = row_y - ROW_H;
2943 let bg = if ri % 2 == 0 {
2944 Rgb::new(0.975, 0.965, 0.95, None)
2945 } else {
2946 Rgb::new(1.0, 1.0, 1.0, None)
2947 };
2948 pdf_fill_rect(ctx.layer, ctx.margin, ry, usable_w, ROW_H, bg);
2949 ctx.layer
2950 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2951 let cells = [
2952 pdf_trunc(&grp.language_family, 26),
2953 grp.files_count.to_string(),
2954 pdf_trunc(&grp.dominant_guide, 28),
2955 format!("{}%", grp.dominant_score_pct),
2956 format!("{}%", grp.line_col_compliant_pct),
2957 ];
2958 for (ci, cell) in cells.iter().enumerate() {
2959 let is_score = ci == 3 || ci == 4;
2960 if is_score && cell != "--" {
2961 ctx.layer
2962 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
2963 } else {
2964 ctx.layer
2965 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
2966 }
2967 ctx.layer.use_text(
2968 pdf_safe_str(cell),
2969 6.0,
2970 Mm(col_x[ci] + 2.0),
2971 Mm(ry + 1.5),
2972 ctx.font_reg,
2973 );
2974 }
2975 row_y = ry;
2976 }
2977
2978 row_y
2979}
2980
2981#[allow(clippy::cast_precision_loss)]
2984fn pdf_render_cocomo_section(ctx: &PdfCtx<'_>, run: &AnalysisRun, section_top: f32) -> f32 {
2985 use crate::pdf_compat::{Color, Mm, Rgb};
2986 const HDR_H: f32 = 5.5;
2987 const ROW_H: f32 = 13.0; const NOTE_H: f32 = 2.0; const GAP: f32 = 5.0; let Some(ref c) = run.cocomo else {
2992 return section_top;
2993 };
2994
2995 let mode_label = match c.mode {
2996 CocomoMode::Organic => "Organic",
2997 CocomoMode::SemiDetached => "Semi-detached",
2998 CocomoMode::Embedded => "Embedded",
2999 };
3000 let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3001
3002 pdf_section_header_bar(
3004 ctx,
3005 usable_w,
3006 section_top,
3007 HDR_H,
3008 "CONSTRUCTIVE COST MODEL (COCOMO I) ESTIMATE",
3009 );
3010 ctx.layer
3011 .set_fill_color(Color::Rgb(Rgb::new(0.85, 0.65, 0.35, None)));
3012 let mode_display = format!("{mode_label} mode");
3013 ctx.layer.use_text(
3014 mode_display.as_str(),
3015 5.5,
3016 Mm(ctx.w - ctx.margin - 28.0),
3017 Mm(section_top - HDR_H + 1.5),
3018 ctx.font_reg,
3019 );
3020
3021 let col_w = usable_w / 4.0;
3023 let row_y = section_top - HDR_H - ROW_H;
3024 let data: [(&str, String); 4] = [
3025 ("Person-months", format!("{:.2}", c.effort_person_months)),
3026 ("Schedule (months)", format!("{:.2}", c.duration_months)),
3027 ("Avg. Team Size", format!("{:.2}", c.avg_staff)),
3028 ("Input KSLOC", format!("{:.2}K", c.ksloc)),
3029 ];
3030 for (i, (label, value)) in data.iter().enumerate() {
3031 let cx = (i as f32).mul_add(col_w, ctx.margin);
3032 let bg = if i % 2 == 0 {
3033 Rgb::new(0.975, 0.965, 0.95, None)
3034 } else {
3035 Rgb::new(1.0, 1.0, 1.0, None)
3036 };
3037 pdf_fill_rect(ctx.layer, cx, row_y, col_w, ROW_H, bg);
3038 ctx.layer
3039 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3040 ctx.layer
3041 .use_text(*label, 5.5, Mm(cx + 2.0), Mm(row_y + 9.0), ctx.font_reg);
3042 ctx.layer
3043 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3044 ctx.layer.use_text(
3045 value.as_str(),
3046 10.0,
3047 Mm(cx + 2.0),
3048 Mm(row_y + 2.5),
3049 ctx.font_bold,
3050 );
3051 }
3052
3053 let note_y = row_y - GAP;
3055 ctx.layer
3056 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3057 ctx.layer.use_text(
3058 "COCOMO I (Boehm, 1981): algorithmic model converting SLOC into effort, schedule, and team-size estimates. \
3059 Ballpark figures only - actual outcomes vary with team experience and domain complexity.",
3060 5.5,
3061 Mm(ctx.margin),
3062 Mm(note_y),
3063 ctx.font_reg,
3064 );
3065
3066 note_y - NOTE_H
3067}
3068
3069fn pdf_text_right(ctx: &PdfCtx<'_>, text: &str, pt: f32, x_right: f32, y: f32, bold: bool) {
3072 use crate::pdf_compat::Mm;
3073 let font = if bold { ctx.font_bold } else { ctx.font_reg };
3074 let w = helvetica_width_mm(text, pt, bold);
3075 ctx.layer.use_text(text, pt, Mm(x_right - w), Mm(y), font);
3076}
3077
3078fn pdf_fit_path(path: &str, budget_mm: f32, pt: f32) -> String {
3081 if helvetica_width_mm(path, pt, false) <= budget_mm {
3082 return path.to_string();
3083 }
3084 let mut chars: Vec<char> = path.chars().collect();
3085 while !chars.is_empty() {
3086 chars.remove(0);
3087 let candidate: String = format!("...{}", chars.iter().collect::<String>());
3088 if helvetica_width_mm(&candidate, pt, false) <= budget_mm {
3089 return candidate;
3090 }
3091 }
3092 "...".to_string()
3093}
3094
3095fn pdf_render_hotspots_section(ctx: &PdfCtx<'_>, rows: &[HotspotRow], section_top: f32) -> f32 {
3099 use crate::pdf_compat::{Color, Mm, Rgb};
3100 const HDR_H: f32 = 5.5;
3101 const COLHDR_H: f32 = 5.0;
3102 const ROW_H: f32 = 5.2;
3103 const NOTE_GAP: f32 = 4.0;
3104
3105 let usable_w = 2.0_f32.mul_add(-ctx.margin, ctx.w);
3106
3107 pdf_section_header_bar(
3109 ctx,
3110 usable_w,
3111 section_top,
3112 HDR_H,
3113 "GIT HOTSPOTS (CODE LINES x RECENT COMMITS)",
3114 );
3115
3116 let col_last_r = ctx.w - ctx.margin;
3118 let col_score_r = col_last_r - 32.0;
3119 let col_commits_r = col_score_r - 33.0;
3120 let col_code_r = col_commits_r - 32.0;
3121 let file_x = ctx.margin + 2.0;
3122 let file_budget = (col_code_r - 26.0) - file_x;
3123
3124 let chdr_y = section_top - HDR_H - COLHDR_H;
3126 pdf_fill_rect(
3127 ctx.layer,
3128 ctx.margin,
3129 chdr_y,
3130 usable_w,
3131 COLHDR_H,
3132 Rgb::new(0.90, 0.88, 0.84, None),
3133 );
3134 ctx.layer
3135 .set_fill_color(Color::Rgb(Rgb::new(0.30, 0.30, 0.30, None)));
3136 ctx.layer
3137 .use_text("File", 6.0, Mm(file_x), Mm(chdr_y + 1.4), ctx.font_bold);
3138 pdf_text_right(ctx, "Code lines", 6.0, col_code_r, chdr_y + 1.4, true);
3139 pdf_text_right(ctx, "Commits", 6.0, col_commits_r, chdr_y + 1.4, true);
3140 pdf_text_right(ctx, "Hotspot score", 6.0, col_score_r, chdr_y + 1.4, true);
3141 pdf_text_right(ctx, "Last changed", 6.0, col_last_r, chdr_y + 1.4, true);
3142
3143 let mut y = chdr_y;
3145 for (ri, hrow) in rows.iter().enumerate() {
3146 y -= ROW_H;
3147 let bg = if ri.is_multiple_of(2) {
3148 Rgb::new(0.975, 0.965, 0.95, None)
3149 } else {
3150 Rgb::new(1.0, 1.0, 1.0, None)
3151 };
3152 pdf_fill_rect(ctx.layer, ctx.margin, y, usable_w, ROW_H, bg);
3153 ctx.layer
3155 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3156 let path = pdf_fit_path(&pdf_safe_str(&hrow.path), file_budget, 6.0);
3157 ctx.layer
3158 .use_text(path, 6.0, Mm(file_x), Mm(y + 1.4), ctx.font_reg);
3159 ctx.layer
3161 .set_fill_color(Color::Rgb(Rgb::new(0.12, 0.12, 0.12, None)));
3162 pdf_text_right(
3163 ctx,
3164 &group_thousands(&hrow.code_lines.to_string()),
3165 6.0,
3166 col_code_r,
3167 y + 1.4,
3168 false,
3169 );
3170 pdf_text_right(
3171 ctx,
3172 &hrow.commit_count.to_string(),
3173 6.0,
3174 col_commits_r,
3175 y + 1.4,
3176 false,
3177 );
3178 ctx.layer
3180 .set_fill_color(Color::Rgb(Rgb::new(0.7, 0.33, 0.16, None)));
3181 pdf_text_right(
3182 ctx,
3183 &group_thousands(&hrow.score.to_string()),
3184 6.0,
3185 col_score_r,
3186 y + 1.4,
3187 true,
3188 );
3189 ctx.layer
3190 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3191 pdf_text_right(ctx, &hrow.last_commit_date, 6.0, col_last_r, y + 1.4, false);
3192 }
3193
3194 let note_y = y - NOTE_GAP;
3196 ctx.layer
3197 .set_fill_color(Color::Rgb(Rgb::new(0.45, 0.45, 0.45, None)));
3198 ctx.layer.use_text(
3199 "Files ranked by code lines x commits over the configured git activity window. \
3200 Distinct from the Compare page's scan-to-scan churn rate.",
3201 5.5,
3202 Mm(ctx.margin),
3203 Mm(note_y + 1.0),
3204 ctx.font_reg,
3205 );
3206
3207 note_y
3208}
3209
3210#[allow(clippy::too_many_arguments)]
3213fn pdf_render_hotspots_page(
3214 doc: &crate::pdf_compat::PdfDocumentReference,
3215 font_reg: &crate::pdf_compat::IndirectFontRef,
3216 font_bold: &crate::pdf_compat::IndirectFontRef,
3217 run: &AnalysisRun,
3218 rows: &[HotspotRow],
3219 w: f32,
3220 h: f32,
3221 margin: f32,
3222 footer_h: f32,
3223 title: &str,
3224 version: &str,
3225) -> (
3226 crate::pdf_compat::PdfPageIndex,
3227 crate::pdf_compat::PdfLayerIndex,
3228 f32,
3229) {
3230 use crate::pdf_compat::Mm;
3231 const HDR_H: f32 = 8.0;
3232
3233 let (page, layer_idx) = doc.add_page(Mm(w), Mm(h), "Git Hotspots");
3234 let layer = doc.get_page(page).get_layer(layer_idx);
3235 let ctx = PdfCtx {
3236 layer: &layer,
3237 font_reg,
3238 font_bold,
3239 w,
3240 margin,
3241 row_h: 5.5,
3242 tbl_hdr_h: 6.0,
3243 };
3244
3245 pdf_page_mini_header(&ctx, h, HDR_H, title, run);
3246
3247 let bottom = pdf_render_hotspots_section(&ctx, rows, h - HDR_H - 4.0);
3248
3249 pdf_page_footer_band(&ctx, footer_h, version);
3250
3251 (page, layer_idx, bottom - 3.0)
3252}
3253
3254#[allow(clippy::too_many_arguments)]
3263fn measure_terminal_tc_page_height(
3264 run: &AnalysisRun,
3265 w: f32,
3266 h_full: f32,
3267 margin: f32,
3268 footer_h: f32,
3269 row_h: f32,
3270 tbl_hdr_h: f32,
3271 with_cocomo: bool,
3272) -> f32 {
3273 use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3274 let measure = || -> Option<f32> {
3275 let (doc, page, layer_idx) = PdfDocument::new("measure", Mm(w), Mm(h_full), "m");
3276 let font_reg = doc.add_builtin_font(BuiltinFont::Helvetica).ok()?;
3277 let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold).ok()?;
3278 let layer = doc.get_page(page).get_layer(layer_idx);
3279 let ctx = PdfCtx {
3280 layer: &layer,
3281 font_reg: &font_reg,
3282 font_bold: &font_bold,
3283 w,
3284 margin,
3285 row_h,
3286 tbl_hdr_h,
3287 };
3288 let content_bottom = if with_cocomo {
3291 let cocomo_bottom = pdf_render_cocomo_section(&ctx, run, h_full - 8.0 - 6.0);
3292 pdf_render_tc_inline(&ctx, run, cocomo_bottom - 2.0, footer_h)
3293 } else {
3294 pdf_render_tc_inline(&ctx, run, h_full - 8.0 - 4.0, footer_h)
3295 };
3296 let pad = 4.0;
3298 Some((h_full - content_bottom + footer_h + pad).clamp(60.0, h_full))
3299 };
3300 measure().unwrap_or(h_full)
3301}
3302
3303#[allow(
3309 clippy::cast_precision_loss,
3310 clippy::cast_possible_truncation,
3311 clippy::cast_sign_loss,
3312 clippy::too_many_arguments
3313)]
3314fn pdf_render_cocomo_or_tc_page(
3315 doc: &crate::pdf_compat::PdfDocumentReference,
3316 font_reg: &crate::pdf_compat::IndirectFontRef,
3317 font_bold: &crate::pdf_compat::IndirectFontRef,
3318 run: &AnalysisRun,
3319 dims: PdfPageDims,
3320 title: &str,
3321 version: &str,
3322 cocomo_fits_page1: bool,
3323 trim_page: bool,
3324) -> (
3325 crate::pdf_compat::PdfPageIndex,
3326 crate::pdf_compat::PdfLayerIndex,
3327 f32,
3328) {
3329 use crate::pdf_compat::{Color, Mm, Mm as PdfMm, Rgb};
3330 let PdfPageDims {
3331 w,
3332 h,
3333 margin,
3334 footer_h,
3335 row_h,
3336 tbl_hdr_h,
3337 } = dims;
3338
3339 if run.cocomo.is_none() || cocomo_fits_page1 {
3341 let page_h = if trim_page {
3342 measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, false)
3343 } else {
3344 h
3345 };
3346 return pdf_render_tests_coverage_page(
3347 doc, font_reg, font_bold, run, w, page_h, margin, footer_h, title, version,
3348 );
3349 }
3350
3351 let page_h = if trim_page {
3352 measure_terminal_tc_page_height(run, w, h, margin, footer_h, row_h, tbl_hdr_h, true)
3353 } else {
3354 h
3355 };
3356 let (c2_page, c2_layer_idx) = doc.add_page(Mm(w), Mm(page_h), "Content");
3357 let c2_layer = doc.get_page(c2_page).get_layer(c2_layer_idx);
3358 let c2_ctx = PdfCtx {
3359 layer: &c2_layer,
3360 font_reg,
3361 font_bold,
3362 w,
3363 margin,
3364 row_h,
3365 tbl_hdr_h,
3366 };
3367 pdf_fill_rect(
3369 &c2_layer,
3370 0.0,
3371 page_h - 8.0,
3372 w,
3373 8.0,
3374 Rgb::new(0.098, 0.11, 0.15, None),
3375 );
3376 c2_layer.set_fill_color(Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)));
3377 c2_layer.use_text(
3378 "oxide-sloc",
3379 9.0,
3380 PdfMm(margin),
3381 PdfMm(page_h - 5.5),
3382 font_bold,
3383 );
3384 c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.72, 0.72, 0.72, None)));
3385 c2_layer.use_text(
3386 pdf_trunc(&pdf_safe_str(title), 45),
3387 7.5,
3388 PdfMm(46.0),
3389 PdfMm(page_h - 5.5),
3390 font_reg,
3391 );
3392 pdf_draw_header_meta(
3393 &c2_layer,
3394 font_reg,
3395 w,
3396 margin,
3397 page_h - 5.5,
3398 &pdf_page_header_meta(run),
3399 );
3400 let cocomo_bottom = pdf_render_cocomo_section(&c2_ctx, run, page_h - 8.0 - 6.0);
3401 let tc_bottom = pdf_render_tc_inline(&c2_ctx, run, cocomo_bottom - 2.0, footer_h);
3403 pdf_fill_rect(
3405 &c2_layer,
3406 0.0,
3407 0.0,
3408 w,
3409 footer_h,
3410 Rgb::new(0.93, 0.91, 0.87, None),
3411 );
3412 c2_layer.set_fill_color(Color::Rgb(Rgb::new(0.4, 0.4, 0.4, None)));
3413 c2_layer.use_text(
3414 format!("oxide-sloc v{version} | AGPL-3.0-or-later"),
3415 6.5,
3416 PdfMm(margin),
3417 PdfMm(3.0),
3418 font_reg,
3419 );
3420 (c2_page, c2_layer_idx, tc_bottom - 3.0)
3422}
3423
3424#[allow(
3434 clippy::cast_precision_loss,
3435 clippy::cast_possible_truncation,
3436 clippy::cast_sign_loss,
3437 clippy::too_many_lines
3438)]
3439pub fn write_pdf_from_run(run: &AnalysisRun, pdf_path: &Path) -> Result<()> {
3440 use crate::pdf_compat::{BuiltinFont, Mm, PdfDocument};
3441 use std::fs::File;
3442 use std::io::BufWriter;
3443
3444 const W: f32 = 297.0;
3445 const H: f32 = 210.0;
3446 const MARGIN: f32 = 10.0;
3447 const FOOTER_H: f32 = 10.0;
3448 const HDR_H: f32 = 13.5;
3449 const ROW_H: f32 = 5.5;
3450 const TBL_HDR_H: f32 = 6.0;
3451
3452 if let Some(parent) = pdf_path.parent() {
3453 fs::create_dir_all(parent)
3454 .with_context(|| format!("failed to create PDF directory {}", parent.display()))?;
3455 }
3456
3457 let title = pdf_safe_str(&run.effective_configuration.reporting.report_title);
3458 let ts = to_pt_hhmm(run.tool.timestamp_utc);
3459 let version = env!("CARGO_PKG_VERSION");
3460 let banner = run
3461 .effective_configuration
3462 .reporting
3463 .report_header_footer
3464 .as_deref();
3465
3466 let (doc, page1, layer1) =
3467 PdfDocument::new(format!("oxide-sloc: {title}"), Mm(W), Mm(H), "Content");
3468 let font_reg = doc
3469 .add_builtin_font(BuiltinFont::Helvetica)
3470 .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
3471 let font_bold = doc
3472 .add_builtin_font(BuiltinFont::HelveticaBold)
3473 .map_err(|e| anyhow::anyhow!("printpdf font error: {e}"))?;
3474 let layer = doc.get_page(page1).get_layer(layer1);
3475
3476 let ctx = PdfCtx {
3477 layer: &layer,
3478 font_reg: &font_reg,
3479 font_bold: &font_bold,
3480 w: W,
3481 margin: MARGIN,
3482 row_h: ROW_H,
3483 tbl_hdr_h: TBL_HDR_H,
3484 };
3485 let roots_text_y = pdf_render_page1_header(&ctx, run, &ts, &title, H, HDR_H, banner);
3486 let row2_bot = pdf_render_summary_chips(&ctx, run, roots_text_y);
3487 let info_y = pdf_render_info_lines(&ctx, run, row2_bot);
3488 let tbl_top = info_y - 4.0;
3489 pdf_render_metric_tables(&ctx, run, tbl_top);
3490 let after_tables_y = tbl_top - 64.5 - 4.0;
3493 let after_style_y = run.style_summary.as_ref().map_or(after_tables_y, |ss| {
3494 if after_tables_y > FOOTER_H + 12.0 {
3495 pdf_render_style_section(&ctx, ss, after_tables_y)
3496 } else {
3497 after_tables_y
3498 }
3499 });
3500 let cocomo_fits_page1 = run.cocomo.is_some() && (after_style_y - 3.0) > FOOTER_H + 32.0;
3503 if cocomo_fits_page1 {
3504 pdf_render_cocomo_section(&ctx, run, after_style_y - 3.0);
3505 }
3506 pdf_render_page1_footer(&ctx, run, FOOTER_H, version, banner);
3507
3508 let hotspot_rows = build_hotspot_rows(run, 15);
3513 let has_per_file = !run.per_file_records.is_empty();
3514 let tc_page_gets_per_file = hotspot_rows.is_empty() && has_per_file;
3515 let trim_tc_page = !tc_page_gets_per_file;
3516
3517 let page_dims = PdfPageDims {
3521 w: W,
3522 h: H,
3523 margin: MARGIN,
3524 footer_h: FOOTER_H,
3525 row_h: ROW_H,
3526 tbl_hdr_h: TBL_HDR_H,
3527 };
3528 let cocomo_page_ctx = pdf_render_cocomo_or_tc_page(
3529 &doc,
3530 &font_reg,
3531 &font_bold,
3532 run,
3533 page_dims,
3534 &title,
3535 version,
3536 cocomo_fits_page1,
3537 trim_tc_page,
3538 );
3539
3540 let per_file_start = if hotspot_rows.is_empty() {
3547 Some(cocomo_page_ctx)
3548 } else {
3549 Some(pdf_render_hotspots_page(
3550 &doc,
3551 &font_reg,
3552 &font_bold,
3553 run,
3554 &hotspot_rows,
3555 W,
3556 H,
3557 MARGIN,
3558 FOOTER_H,
3559 &title,
3560 version,
3561 ))
3562 };
3563
3564 if !run.per_file_records.is_empty() {
3565 pdf_render_per_file_pages(
3567 &doc,
3568 &font_reg,
3569 &font_bold,
3570 run,
3571 W,
3572 H,
3573 MARGIN,
3574 FOOTER_H,
3575 ROW_H,
3576 TBL_HDR_H,
3577 &title,
3578 &ts,
3579 version,
3580 banner,
3581 per_file_start,
3582 );
3583 }
3584
3585 doc.save(&mut BufWriter::new(File::create(pdf_path).with_context(
3586 || format!("cannot create PDF at {}", pdf_path.display()),
3587 )?))
3588 .map_err(|e| anyhow::anyhow!("printpdf save error: {e}"))?;
3589
3590 Ok(())
3591}
3592
3593const HELVETICA_WIDTHS: &[(char, u32, u32)] = &[
3601 (' ', 278, 278),
3602 ('!', 333, 278),
3603 ('"', 474, 355),
3604 ('#', 556, 556),
3605 ('$', 556, 556),
3606 ('%', 889, 889),
3607 ('&', 722, 667),
3608 ('\'', 278, 222),
3609 ('(', 333, 333),
3610 (')', 333, 333),
3611 ('*', 389, 389),
3612 ('+', 584, 584),
3613 (',', 278, 278),
3614 ('-', 333, 333),
3615 ('.', 278, 278),
3616 ('/', 278, 278),
3617 (':', 333, 278),
3618 (';', 333, 278),
3619 ('<', 584, 584),
3620 ('=', 584, 584),
3621 ('>', 584, 584),
3622 ('?', 556, 472),
3623 ('@', 975, 1015),
3624 ('A', 722, 667),
3625 ('B', 722, 667),
3626 ('C', 722, 722),
3627 ('D', 722, 722),
3628 ('E', 667, 667),
3629 ('F', 611, 611),
3630 ('G', 778, 778),
3631 ('H', 722, 722),
3632 ('I', 278, 278),
3633 ('J', 556, 500),
3634 ('K', 722, 667),
3635 ('L', 611, 556),
3636 ('M', 833, 833),
3637 ('N', 722, 722),
3638 ('O', 778, 778),
3639 ('P', 667, 667),
3640 ('Q', 778, 778),
3641 ('R', 722, 722),
3642 ('S', 667, 667),
3643 ('T', 611, 611),
3644 ('U', 722, 722),
3645 ('V', 667, 667),
3646 ('W', 944, 944),
3647 ('X', 667, 667),
3648 ('Y', 611, 611),
3649 ('Z', 611, 611),
3650 ('[', 333, 278),
3651 ('\\', 278, 278),
3652 (']', 333, 278),
3653 ('^', 584, 469),
3654 ('_', 556, 556),
3655 ('`', 278, 222),
3656 ('a', 556, 556),
3657 ('b', 611, 556),
3658 ('c', 556, 500),
3659 ('d', 611, 556),
3660 ('e', 556, 556),
3661 ('f', 333, 278),
3662 ('g', 611, 556),
3663 ('h', 611, 556),
3664 ('i', 278, 222),
3665 ('j', 278, 222),
3666 ('k', 556, 500),
3667 ('l', 278, 222),
3668 ('m', 889, 833),
3669 ('n', 611, 556),
3670 ('o', 611, 556),
3671 ('p', 611, 556),
3672 ('q', 611, 556),
3673 ('r', 389, 333),
3674 ('s', 556, 500),
3675 ('t', 333, 278),
3676 ('u', 611, 556),
3677 ('v', 556, 500),
3678 ('w', 778, 722),
3679 ('x', 556, 500),
3680 ('y', 556, 500),
3681 ('z', 500, 500),
3682 ('\u{00B7}', 278, 278), ];
3684
3685fn helvetica_advance(ch: char, bold: bool) -> u32 {
3689 if ch.is_ascii_digit() {
3690 return 556;
3691 }
3692 for &(glyph, bold_w, regular_w) in HELVETICA_WIDTHS {
3693 if glyph == ch {
3694 return if bold { bold_w } else { regular_w };
3695 }
3696 }
3697 if bold {
3698 556
3699 } else {
3700 500
3701 }
3702}
3703
3704#[allow(
3710 clippy::cast_precision_loss,
3711 reason = "bounded glyph-unit sum to mm width"
3712)]
3713fn helvetica_width_mm(text: &str, pt: f32, bold: bool) -> f32 {
3714 let units: u32 = text.chars().map(|ch| helvetica_advance(ch, bold)).sum();
3715 units as f32 * pt * (25.4 / 72.0) / 1000.0
3717}
3718
3719fn pdf_fill_rect(
3720 layer: &crate::pdf_compat::PdfLayerReference,
3721 x: f32,
3722 y: f32,
3723 w: f32,
3724 h: f32,
3725 color: crate::pdf_compat::Rgb,
3726) {
3727 layer.fill_rect(x, y, w, h, color);
3728}
3729
3730fn pdf_safe_str(s: &str) -> String {
3731 let mut out = String::with_capacity(s.len());
3732 for c in s.chars() {
3733 match c {
3734 '\u{2014}' | '\u{2013}' => out.push_str(" - "), '\u{2026}' => out.push_str("..."), '\u{2018}' | '\u{2019}' => out.push('\''), '\u{201C}' | '\u{201D}' => out.push('"'), '\u{00B7}' | '\u{2022}' => out.push('-'), '\u{00A0}' => out.push(' '), c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
3742 _ => {} }
3744 }
3745 out
3746}
3747
3748fn pdf_trunc(s: &str, max: usize) -> String {
3749 if s.len() <= max {
3750 s.to_string()
3751 } else {
3752 format!("{}...", &s[..max.saturating_sub(3)])
3753 }
3754}
3755
3756fn pdf_trunc_end(s: &str, max: usize) -> String {
3759 if s.len() <= max {
3760 s.to_string()
3761 } else {
3762 format!("...{}", &s[s.len() - max.saturating_sub(3)..])
3763 }
3764}
3765
3766fn pdf_fmt_full(n: u64) -> String {
3767 let s = n.to_string();
3769 let mut out = String::with_capacity(s.len() + s.len() / 3);
3770 for (i, ch) in s.chars().rev().enumerate() {
3771 if i > 0 && i % 3 == 0 {
3772 out.push(',');
3773 }
3774 out.push(ch);
3775 }
3776 out.chars().rev().collect()
3777}
3778
3779pub fn write_pdf_from_html(html_path: &Path, pdf_path: &Path) -> Result<()> {
3789 eprintln!("[oxide-sloc][pdf] starting");
3790
3791 let absolute_html = html_path
3792 .canonicalize()
3793 .with_context(|| format!("failed to canonicalize {}", html_path.display()))?;
3794 eprintln!(
3796 "[oxide-sloc][pdf] html = {}",
3797 absolute_html.to_string_lossy().trim_start_matches(r"\\?\")
3798 );
3799
3800 let absolute_pdf = if pdf_path.is_absolute() {
3801 pdf_path.to_path_buf()
3802 } else {
3803 std::env::current_dir()
3804 .context("failed to resolve current working directory")?
3805 .join(pdf_path)
3806 };
3807 eprintln!("[oxide-sloc][pdf] pdf = {}", absolute_pdf.display());
3808
3809 if let Some(parent) = absolute_pdf.parent() {
3810 fs::create_dir_all(parent).with_context(|| {
3811 format!("failed to create PDF output directory {}", parent.display())
3812 })?;
3813 }
3814
3815 match write_pdf_via_cdp(&absolute_html, &absolute_pdf) {
3816 Ok(()) => {}
3817 Err(cdp_err) => {
3818 eprintln!("[oxide-sloc][pdf] CDP failed ({cdp_err:#}), trying wkhtmltopdf fallback");
3819 write_pdf_via_wkhtmltopdf(&absolute_html, &absolute_pdf).with_context(|| {
3820 format!(
3821 "PDF generation failed via both CDP ({cdp_err:#}) and wkhtmltopdf. \
3822 Install a Chromium-based browser (Chrome, Edge, Brave) or wkhtmltopdf \
3823 on the server, or set SLOC_BROWSER to the browser executable path."
3824 )
3825 })?;
3826 }
3827 }
3828
3829 eprintln!("[oxide-sloc][pdf] done");
3830 Ok(())
3831}
3832
3833fn normalize_browser_env_path(raw: &str) -> PathBuf {
3834 let trimmed = raw.trim();
3835 #[cfg(windows)]
3836 {
3837 let bytes = trimmed.as_bytes();
3838 if bytes.len() >= 3
3839 && bytes[0] == b'/'
3840 && bytes[2] == b'/'
3841 && bytes[1].is_ascii_alphabetic()
3842 {
3843 let drive = (bytes[1] as char).to_ascii_uppercase();
3844 let rest = &trimmed[3..];
3845 return PathBuf::from(format!("{drive}:/{rest}"));
3846 }
3847 }
3848 PathBuf::from(trimmed)
3849}
3850
3851fn discover_browser_from_env() -> Option<PathBuf> {
3852 for var_name in ["SLOC_BROWSER", "BROWSER"] {
3853 if let Ok(path) = std::env::var(var_name) {
3854 let candidate = normalize_browser_env_path(&path);
3855 if candidate.is_file() {
3856 return Some(candidate);
3857 }
3858 }
3859 }
3860 None
3861}
3862
3863fn discover_browser() -> Option<PathBuf> {
3864 if let Some(p) = discover_browser_from_env() {
3865 return Some(p);
3866 }
3867
3868 let names = [
3869 "chromium",
3870 "chromium-browser",
3871 "google-chrome",
3872 "google-chrome-stable",
3873 "microsoft-edge",
3874 "msedge",
3875 "brave",
3876 "brave-browser",
3877 "vivaldi",
3878 "opera",
3879 "opera-stable",
3880 ];
3881
3882 for name in names {
3883 if let Some(path) = which_in_path(name) {
3884 return Some(path);
3885 }
3886 }
3887
3888 #[cfg(windows)]
3889 {
3890 for candidate in windows_browser_candidates() {
3891 if candidate.is_file() {
3892 return Some(candidate);
3893 }
3894 }
3895 }
3896
3897 #[cfg(not(windows))]
3900 {
3901 for candidate in linux_browser_candidates() {
3902 if candidate.is_file() {
3903 return Some(candidate);
3904 }
3905 }
3906
3907 if let Some(path) = which_subprocess(&[
3910 "chromium-browser",
3911 "chromium",
3912 "google-chrome",
3913 "google-chrome-stable",
3914 "microsoft-edge",
3915 "brave-browser",
3916 ]) {
3917 return Some(path);
3918 }
3919 }
3920
3921 None
3922}
3923
3924#[cfg(windows)]
3928fn push_chromium_app_browsers(paths: &mut Vec<PathBuf>, base: &Path) {
3929 paths.push(
3930 base.join("Google")
3931 .join("Chrome")
3932 .join("Application")
3933 .join("chrome.exe"),
3934 );
3935 paths.push(
3936 base.join("Microsoft")
3937 .join("Edge")
3938 .join("Application")
3939 .join("msedge.exe"),
3940 );
3941 paths.push(
3942 base.join("BraveSoftware")
3943 .join("Brave-Browser")
3944 .join("Application")
3945 .join("brave.exe"),
3946 );
3947 paths.push(base.join("Vivaldi").join("Application").join("vivaldi.exe"));
3948}
3949
3950#[cfg(windows)]
3951fn windows_browser_candidates() -> Vec<PathBuf> {
3952 let mut paths = Vec::new();
3953
3954 let program_files = std::env::var_os("ProgramFiles");
3955 let program_files_x86 = std::env::var_os("ProgramFiles(x86)");
3956 let local_app_data = std::env::var_os("LocalAppData");
3957
3958 for base in [program_files, program_files_x86].into_iter().flatten() {
3959 let base = PathBuf::from(base);
3960 push_chromium_app_browsers(&mut paths, &base);
3961 paths.push(base.join("Opera").join("launcher.exe"));
3962 paths.push(base.join("Opera GX").join("launcher.exe"));
3963 }
3964
3965 if let Some(base) = local_app_data {
3966 let base = PathBuf::from(base);
3967 push_chromium_app_browsers(&mut paths, &base);
3968 paths.push(base.join("Programs").join("Opera").join("launcher.exe"));
3969 paths.push(base.join("Programs").join("Opera GX").join("launcher.exe"));
3970 }
3971
3972 paths
3973}
3974
3975#[cfg(not(windows))]
3976fn linux_browser_candidates() -> Vec<PathBuf> {
3977 vec![
3978 PathBuf::from("/snap/bin/chromium"),
3980 PathBuf::from("/snap/bin/chromium-browser"),
3981 PathBuf::from("/usr/bin/chromium"),
3983 PathBuf::from("/usr/bin/chromium-browser"),
3984 PathBuf::from("/usr/bin/google-chrome"),
3985 PathBuf::from("/usr/bin/google-chrome-stable"),
3986 PathBuf::from("/usr/bin/microsoft-edge"),
3987 PathBuf::from("/usr/bin/microsoft-edge-stable"),
3988 PathBuf::from("/usr/bin/brave-browser"),
3989 PathBuf::from("/usr/bin/brave-browser-stable"),
3990 PathBuf::from("/usr/lib/chromium-browser/chromium-browser"),
3992 PathBuf::from("/usr/lib/chromium/chromium"),
3993 PathBuf::from("/usr/lib/chromium/chrome"),
3994 PathBuf::from("/opt/google/chrome/google-chrome"),
3996 PathBuf::from("/opt/google/chrome-beta/google-chrome"),
3997 PathBuf::from("/opt/google/chrome-unstable/google-chrome"),
3998 PathBuf::from("/usr/local/bin/chromium"),
4000 PathBuf::from("/usr/local/bin/chromium-browser"),
4001 PathBuf::from("/usr/local/bin/google-chrome"),
4002 PathBuf::from("/var/lib/flatpak/exports/bin/org.chromium.Chromium"),
4004 PathBuf::from("/usr/share/flatpak/exports/bin/org.chromium.Chromium"),
4005 ]
4006}
4007
4008#[cfg(not(windows))]
4011fn which_subprocess(names: &[&str]) -> Option<PathBuf> {
4012 for name in names {
4013 if let Ok(out) = std::process::Command::new("which").arg(name).output() {
4014 if out.status.success() {
4015 let s = String::from_utf8_lossy(&out.stdout);
4016 let path = PathBuf::from(s.trim());
4017 if path.is_file() {
4018 return Some(path);
4019 }
4020 }
4021 }
4022 }
4023 None
4024}
4025
4026fn which_in_path(exe: &str) -> Option<PathBuf> {
4027 let path_var = std::env::var_os("PATH")?;
4028 for dir in std::env::split_paths(&path_var) {
4029 let candidate = dir.join(exe);
4030 if candidate.is_file() {
4031 return Some(candidate);
4032 }
4033 #[cfg(windows)]
4034 {
4035 let candidate = dir.join(format!("{exe}.exe"));
4036 if candidate.is_file() {
4037 return Some(candidate);
4038 }
4039 }
4040 }
4041 None
4042}
4043
4044fn file_url(path: &Path) -> String {
4045 let raw = path.to_string_lossy().replace('\\', "/");
4046 let normalized = if raw.starts_with('/') {
4047 raw
4048 } else {
4049 format!("/{raw}")
4050 };
4051
4052 let mut encoded = String::with_capacity(normalized.len() + 8);
4053 for byte in normalized.bytes() {
4054 match byte {
4055 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' | b':' => {
4056 encoded.push(byte as char);
4057 }
4058 _ => {
4059 let _ = write!(encoded, "%{byte:02X}");
4060 }
4061 }
4062 }
4063
4064 format!("file://{encoded}")
4065}
4066
4067fn file_row_view(file: &FileRecord) -> FileRow {
4068 FileRow {
4069 relative_path: file.relative_path.clone(),
4070 language: file.language.map_or_else(
4071 || "-".into(),
4072 |language| language.display_name().to_string(),
4073 ),
4074 total_physical_lines: file.raw_line_categories.total_physical_lines,
4075 code_lines: file.effective_counts.code_lines,
4076 comment_lines: file.effective_counts.comment_lines,
4077 blank_lines: file.effective_counts.blank_lines,
4078 mixed_lines_separate: file.effective_counts.mixed_lines_separate,
4079 functions: file.raw_line_categories.functions,
4080 classes: file.raw_line_categories.classes,
4081 variables: file.raw_line_categories.variables,
4082 imports: file.raw_line_categories.imports,
4083 test_count: file.raw_line_categories.test_count,
4084 test_assertion_count: file.raw_line_categories.test_assertion_count,
4085 test_suite_count: file.raw_line_categories.test_suite_count,
4086 line_cov_pct: file
4087 .coverage
4088 .as_ref()
4089 .map(|c| format!("{:.1}", c.line_pct()))
4090 .unwrap_or_default(),
4091 fn_cov_pct: file
4092 .coverage
4093 .as_ref()
4094 .filter(|c| c.functions_found > 0)
4095 .map(|c| format!("{:.1}", c.function_pct()))
4096 .unwrap_or_default(),
4097 branch_cov_pct: file
4098 .coverage
4099 .as_ref()
4100 .filter(|c| c.branches_found > 0)
4101 .map(|c| format!("{:.1}", c.branch_pct()))
4102 .unwrap_or_default(),
4103 cov_lines_detail: file.coverage.as_ref().map_or_else(String::new, |c| {
4104 format!("{}/{}", c.lines_hit, c.lines_found)
4105 }),
4106 status: format!("{:?}", file.status),
4107 status_class: format!("{:?}", file.status).to_ascii_lowercase(),
4108 warnings: if file.warnings.is_empty() {
4109 String::new()
4110 } else {
4111 file.warnings.join("; ")
4112 },
4113 }
4114}
4115
4116fn is_pacific_dst_report(dt: DateTime<Utc>) -> bool {
4117 use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Weekday};
4118 let year = dt.year();
4119 let nth_sun = |month: u32, n: u32, hour: u32| {
4120 let mut count = 0u32;
4121 let mut day = 1u32;
4122 loop {
4123 let d = NaiveDate::from_ymd_opt(year, month, day).expect("valid");
4124 if d.weekday() == Weekday::Sun {
4125 count += 1;
4126 if count == n {
4127 return Utc.from_utc_datetime(
4128 &d.and_time(NaiveTime::from_hms_opt(hour, 0, 0).expect("valid")),
4129 );
4130 }
4131 }
4132 day += 1;
4133 }
4134 };
4135 let dst_start = nth_sun(3, 2, 10);
4136 let dst_end = nth_sun(11, 1, 9);
4137 dt >= dst_start && dt < dst_end
4138}
4139
4140fn to_pst_display(dt: DateTime<Utc>) -> String {
4141 let (offset, label) = if is_pacific_dst_report(dt) {
4142 (
4143 FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4144 "PDT",
4145 )
4146 } else {
4147 (
4148 FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4149 "PST",
4150 )
4151 };
4152 format!(
4153 "{} {label}",
4154 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
4155 )
4156}
4157
4158fn to_pt_hhmm(dt: DateTime<Utc>) -> String {
4160 let (offset, label) = if is_pacific_dst_report(dt) {
4161 (
4162 FixedOffset::west_opt(7 * 3600).expect("valid PDT offset"),
4163 "PDT",
4164 )
4165 } else {
4166 (
4167 FixedOffset::west_opt(8 * 3600).expect("valid PST offset"),
4168 "PST",
4169 )
4170 };
4171 format!(
4172 "{} {label}",
4173 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M")
4174 )
4175}
4176
4177fn fmt_commit_date_pt(s: &str) -> String {
4180 use chrono::DateTime as ChronoDateTime;
4181 ChronoDateTime::parse_from_rfc3339(s).map_or_else(
4182 |_| s.replace('T', " "),
4183 |dt| to_pt_hhmm(dt.with_timezone(&Utc)),
4184 )
4185}
4186
4187fn build_warning_console(warnings: &[String]) -> String {
4188 if warnings.is_empty() {
4189 return "No top-level warnings.".to_string();
4190 }
4191
4192 warnings
4193 .iter()
4194 .enumerate()
4195 .map(|(index, warning)| {
4196 format!(
4197 "[{index:03}] {warning}",
4198 index = index + 1,
4199 warning = warning
4200 )
4201 })
4202 .collect::<Vec<_>>()
4203 .join("\n")
4204}
4205
4206fn summarize_warnings(warnings: &[String]) -> Vec<WarningSummaryRow> {
4207 let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
4208 for warning in warnings {
4209 let key = if warning.contains("unsupported or undetected language") {
4210 "Unsupported or undetected text formats"
4211 } else if warning.contains("file exceeded max_file_size_bytes") {
4212 "Large files skipped by size limit"
4213 } else if warning.contains("binary file skipped by default") {
4214 "Binary assets skipped"
4215 } else if warning.contains("minified file skipped by policy") {
4216 "Minified files skipped by policy"
4217 } else if warning.contains("vendor file skipped by policy") {
4218 "Vendor files skipped by policy"
4219 } else if warning.contains("best effort") || warning.contains("unclosed string literal") {
4220 "Best-effort parse results"
4221 } else {
4222 "Other warnings"
4223 };
4224 *counts.entry(key).or_default() += 1;
4225 }
4226
4227 counts
4228 .into_iter()
4229 .map(|(label, count)| {
4230 let (tone_class, detail) = match label {
4231 "Unsupported or undetected text formats" => (
4232 "tone-neutral",
4233 "These are usually docs, manifests, templates, or formats that have not been promoted into first-class analyzers yet.",
4234 ),
4235 "Large files skipped by size limit" => (
4236 "tone-warn",
4237 "Artifacts and archives larger than the configured cap were skipped intentionally to keep runs fast and predictable.",
4238 ),
4239 "Binary assets skipped" => (
4240 "tone-neutral",
4241 "Binary bundles are excluded from source counting unless you explicitly opt into them.",
4242 ),
4243 "Minified files skipped by policy" => (
4244 "tone-warn",
4245 "Generated and minified assets are being filtered out to avoid inflating code totals.",
4246 ),
4247 "Vendor files skipped by policy" => (
4248 "tone-neutral",
4249 "Vendored third-party code is being excluded so the report stays focused on repository-owned source.",
4250 ),
4251 "Best-effort parse results" => (
4252 "tone-danger",
4253 "These files were analyzed, but the parser hit malformed or ambiguous content and fell back to a best-effort count.",
4254 ),
4255 _ => (
4256 "tone-danger",
4257 "Warnings in this bucket need manual review because they do not match one of the common policy-based skip reasons.",
4258 ),
4259 };
4260
4261 WarningSummaryRow {
4262 label: label.to_string(),
4263 count,
4264 tone_class: tone_class.to_string(),
4265 detail: detail.to_string(),
4266 }
4267 })
4268 .collect()
4269}
4270
4271fn classify_unsupported_path(path: &str) -> &'static str {
4273 let ext_lc = Path::new(path)
4274 .extension()
4275 .and_then(|e| e.to_str())
4276 .map(str::to_ascii_lowercase)
4277 .unwrap_or_default();
4278
4279 if ext_lc == "md"
4280 || path.ends_with("README")
4281 || path.ends_with("README.md")
4282 || path.ends_with("LICENSE")
4283 {
4284 "Documentation / text"
4285 } else if ext_lc == "json" || path.ends_with(".spdx.json") || path.ends_with("devkit.json") {
4286 "JSON manifests and config"
4287 } else if ext_lc == "toml"
4288 || path.ends_with("MANIFEST.in")
4289 || path.ends_with("requirements.txt")
4290 {
4291 "Project metadata and packaging"
4292 } else if ext_lc == "html" {
4293 "HTML templates"
4294 } else if ext_lc == "txt" {
4295 "Plain text assets"
4296 } else if ext_lc.is_empty() {
4297 "Extensionless or custom text files"
4298 } else {
4299 "Other unsupported text formats"
4300 }
4301}
4302
4303fn bucket_recommendation(label: &str) -> String {
4305 match label {
4306 "Documentation / text" => "These files are documentation, not source code. Add a docs/text exclude glob (e.g. **/*.md) or mark them as non-source in your config so they stop generating warnings.".to_string(),
4307 "JSON manifests and config" => "JSON config and manifest files are not source code. Exclude them with an ignore glob (e.g. **/*.json) or add them to excluded_directories so they are silently skipped.".to_string(),
4308 "Project metadata and packaging" => "TOML, requirements.txt, and MANIFEST.in files describe package metadata — not source. Add an exclude glob such as **/*.toml or list them in excluded_directories to suppress these warnings.".to_string(),
4309 "HTML templates" => "HTML is already a supported language. These files may have unusual extensions or be in a directory that is being excluded. Check that the files have a .html extension and are not inside an excluded path.".to_string(),
4310 "Plain text assets" => "Plain .txt files are not analyzed by default. If they contain source, rename them with a source extension. Otherwise add **/*.txt to your exclude globs.".to_string(),
4311 "Extensionless or custom text files" => "Files without an extension cannot be auto-detected. Add a shebang line (e.g. #!/usr/bin/env python3) so oxide-sloc can identify the language, or add an explicit language override in your config.".to_string(),
4312 _ => "Files in this group were not recognized. Check the file extensions and either add an exclude glob to silence the warning or open an issue to request analyzer support.".to_string(),
4313 }
4314}
4315
4316fn bucket_description(label: &str) -> String {
4318 match label {
4319 "Documentation / text" => {
4320 "README, LICENSE, and markdown files — not source code.".to_string()
4321 }
4322 "JSON manifests and config" => {
4323 "JSON configuration or manifest files (package.json, lockfiles, etc.).".to_string()
4324 }
4325 "Project metadata and packaging" => {
4326 "TOML, requirements.txt, and MANIFEST.in files that describe package metadata."
4327 .to_string()
4328 }
4329 "HTML templates" => {
4330 "HTML files not covered by the built-in HTML analyzer (unexpected extension or path)."
4331 .to_string()
4332 }
4333 "Plain text assets" => "Plain .txt files that have no analyzable structure.".to_string(),
4334 "Extensionless or custom text files" => {
4335 "Files with no extension that could not be language-detected.".to_string()
4336 }
4337 _ => "Files with an unrecognized extension or format.".to_string(),
4338 }
4339}
4340
4341fn build_support_opportunities(warnings: &[String]) -> Vec<WarningOpportunityRow> {
4342 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
4343 let mut examples: BTreeMap<String, Vec<String>> = BTreeMap::new();
4344
4345 for warning in warnings {
4346 if !warning.contains("unsupported or undetected language") {
4347 continue;
4348 }
4349
4350 let path = warning
4351 .split_once(':')
4352 .map(|(path, _)| path.trim())
4353 .unwrap_or_default();
4354 if path.is_empty() {
4355 continue;
4356 }
4357
4358 let bucket = classify_unsupported_path(path);
4359 *counts.entry(bucket.to_string()).or_default() += 1;
4360
4361 let ex = examples.entry(bucket.to_string()).or_default();
4362 if ex.len() < 3 {
4363 let basename = Path::new(path)
4364 .file_name()
4365 .and_then(|n| n.to_str())
4366 .unwrap_or(path)
4367 .to_string();
4368 if !ex.contains(&basename) {
4369 ex.push(basename);
4370 }
4371 }
4372 }
4373
4374 let mut rows = counts.into_iter().collect::<Vec<_>>();
4375 rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4376
4377 rows.into_iter()
4378 .map(|(label, count)| {
4379 let recommendation = bucket_recommendation(&label);
4380 let bucket_description = bucket_description(&label);
4381 let example_files = examples.remove(&label).unwrap_or_default();
4382 WarningOpportunityRow {
4383 label,
4384 count,
4385 recommendation,
4386 example_files,
4387 bucket_description,
4388 }
4389 })
4390 .collect()
4391}
4392
4393#[derive(Debug, Clone)]
4394struct LanguageRow {
4395 language: String,
4396 files: u64,
4397 total_physical_lines: u64,
4398 code_lines: u64,
4399 comment_lines: u64,
4400 blank_lines: u64,
4401 mixed_lines_separate: u64,
4402 functions: u64,
4403 classes: u64,
4404 variables: u64,
4405 imports: u64,
4406 test_count: u64,
4407 test_assertion_count: u64,
4408 test_suite_count: u64,
4409 test_density_str: String,
4410}
4411
4412#[derive(Debug, Clone)]
4413struct FileRow {
4414 relative_path: String,
4415 language: String,
4416 total_physical_lines: u64,
4417 code_lines: u64,
4418 comment_lines: u64,
4419 blank_lines: u64,
4420 mixed_lines_separate: u64,
4421 functions: u64,
4422 classes: u64,
4423 variables: u64,
4424 imports: u64,
4425 test_count: u64,
4426 test_assertion_count: u64,
4427 test_suite_count: u64,
4428 line_cov_pct: String,
4430 fn_cov_pct: String,
4432 branch_cov_pct: String,
4434 cov_lines_detail: String,
4436 status: String,
4437 status_class: String,
4438 warnings: String,
4439}
4440
4441#[derive(Debug, Clone)]
4442struct WarningSummaryRow {
4443 label: String,
4444 count: usize,
4445 tone_class: String,
4446 detail: String,
4447}
4448
4449#[derive(Debug, Clone)]
4450struct WarningOpportunityRow {
4451 label: String,
4452 count: usize,
4453 recommendation: String,
4454 example_files: Vec<String>,
4456 bucket_description: String,
4458}
4459
4460#[derive(Template)]
4461#[template(
4462 source = r##"<!doctype html>
4463<html lang="en">
4464<head>
4465 <meta charset="utf-8" />
4466 <meta name="viewport" content="width=device-width, initial-scale=1" />
4467 <title>{{ browser_title }}</title>
4468 <link rel="icon" href="{{ small_logo_uri }}" type="image/png" />
4469 <style nonce="{{ nonce }}">
4470 :root {
4471 --radius: 18px;
4472 --bg: #f5efe8;
4473 --surface: rgba(255,255,255,0.82);
4474 --surface-2: #fbf7f2;
4475 --surface-3: #efe6dc;
4476 --line: #e6d0bf;
4477 --line-strong: #dcb89f;
4478 --text: #43342d;
4479 --muted: #7b675b;
4480 --muted-2: #a08777;
4481 --nav: #b85d33;
4482 --nav-2: #7a371b;
4483 --accent: #6f9bff;
4484 --accent-2: #4a78ee;
4485 --oxide: #d37a4c;
4486 --oxide-2: #b35428;
4487 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
4488 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
4489 --good-bg: #e8f5ed;
4490 --good-text: #1a8f47;
4491 --warn-bg: #fff4dc;
4492 --warn-text: #9a6d00;
4493 --danger-bg: #fdebec;
4494 --danger-text: #cc4b4b;
4495 --info-bg: #f2f6ff;
4496 --info-text: #4467d8;
4497 }
4498 {% if let Some(hex) = accent_hex %}
4499 :root, body.dark-theme { --accent: {{ hex }}; --accent-2: {{ hex }}; }
4500 {% endif %}
4501 body.dark-theme {
4502 --bg: #1b1511;
4503 --surface: #261c17;
4504 --surface-2: #2d221d;
4505 --surface-3: #372922;
4506 --line: #524238;
4507 --line-strong: #6c5649;
4508 --text: #f5ece6;
4509 --muted: #c7b7aa;
4510 --muted-2: #aa9485;
4511 --nav: #b85d33;
4512 --nav-2: #7a371b;
4513 --accent: #6f9bff;
4514 --accent-2: #4a78ee;
4515 --oxide: #d37a4c;
4516 --oxide-2: #b35428;
4517 --shadow: 0 18px 42px rgba(0,0,0,0.28);
4518 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
4519 --good-bg: #163927;
4520 --good-text: #8fe2a8;
4521 --warn-bg: #3c2d11;
4522 --warn-text: #f3cb75;
4523 --danger-bg: #3d1f1f;
4524 --danger-text: #ff9f9f;
4525 --info-bg: #202e55;
4526 --info-text: #a9c1ff;
4527 }
4528 * { box-sizing: border-box; }
4529 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); }
4530 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; }
4531 .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); }
4532 .top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 14px; }
4533 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; flex: 0 0 auto; }
4534 .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)); }
4535 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4536 .background-watermarks img { position: absolute; opacity: 0.15; filter: blur(0.3px); user-select: none; max-width: none; }
4537 .code-particles { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
4538 .code-particle { position: absolute; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; font-weight: 600; color: var(--oxide); opacity: 0; white-space: nowrap; user-select: none; animation: floatCode linear infinite; }
4539 @keyframes floatCode { 0% { opacity: 0; transform: translateY(0) rotate(var(--rot)); } 10% { opacity: var(--op); } 85% { opacity: var(--op); } 100% { opacity: 0; transform: translateY(-200px) rotate(var(--rot)); } }
4540 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
4541 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; letter-spacing: -0.01em; }
4542 .brand-subtitle { color: rgba(255,255,255,0.72); font-size: 11px; line-height: 1.2; margin-top: 2px; letter-spacing: 0.01em; }
4543 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
4544 .nav-project-pill, .nav-pill, .theme-toggle, .header-button {
4545 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.08); font-size: 12px; font-weight: 700; box-shadow: none;
4546 }
4547 .nav-project-pill { pointer-events: auto; width: 100%; max-width: 300px; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
4548 .nav-project-label { color: rgba(255,255,255,0.72); text-transform: uppercase; letter-spacing: 0.09em; font-size: 10px; font-weight: 800; }
4549 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size: 13px; }
4550 .nav-status { display:flex; align-items:center; justify-content:flex-end; gap:10px; flex-wrap:nowrap; min-width:0; }
4551 @media (max-width: 1400px) { .nav-status { gap: 6px; } .header-button, .theme-toggle { padding: 0 10px; } }
4552 @media (max-width: 1150px) { .nav-status { gap: 4px; } .header-button, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } }
4553 .theme-toggle, .header-button { cursor:pointer; background: rgba(255,255,255,0.08); text-decoration:none; transition: background 0.15s ease, transform 0.15s ease; }
4554 .theme-toggle:hover, .header-button:hover { background: rgba(255,255,255,0.18); transform: translateY(-1px); }
4555 .theme-toggle { width: 38px; justify-content:center; padding:0; }
4556 .nav-dropdown-wrap { position: relative; }
4557 .nav-dropdown-wrap::after { content: ''; position: absolute; left: 0; right: 0; bottom: -6px; height: 6px; }
4558 .nav-dropdown-trigger { }
4559 .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); }
4560 .nav-dropdown-wrap:hover .nav-dropdown-menu, .nav-dropdown-wrap:focus-within .nav-dropdown-menu { display: flex; flex-direction: column; gap: 2px; }
4561 .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; }
4562 .nav-dropdown-item:hover { background: rgba(255,255,255,0.12); }
4563 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
4564 .theme-toggle .icon-sun { display:none; }
4565 body.dark-theme .theme-toggle .icon-sun { display:block; }
4566 body.dark-theme .theme-toggle .icon-moon { display:none; }
4567 .settings-modal{position:fixed;z-index:9999;background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
4568 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
4569 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
4570 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
4571 .settings-close:hover{color:var(--text);background:var(--surface-2);}
4572 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
4573 .settings-modal-body{padding:14px 16px 16px;}
4574 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
4575 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
4576 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
4577 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
4578 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
4579 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
4580 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
4581 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
4582 .tz-select:focus{border-color:var(--oxide);}
4583 .page { max-width: 1720px; margin: 0 auto; padding: 32px 24px 40px; }
4584 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
4585 /* Uniform two-row card strip. JS pads the card count to even (revealing a
4586 reserve card when odd) and sets the column count to n/2, so the cards form
4587 exactly two full rows with every column aligned and every card the same
4588 width — no oversized card, no empty trailing cell. */
4589 .summary-grid { display:grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap:10px; align-items:stretch; }
4590 .panel, .metric, .warning-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
4591 .panel { padding: 20px; }
4592 .metric { padding: 11px 12px 20px; position: relative; cursor: help; transition: transform 0.15s ease, box-shadow 0.15s ease; min-height: 70px; }
4593 .metric:hover { transform: translateY(-3px); box-shadow: var(--shadow-strong); }
4594 .metric-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
4595 .section-kicker { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted-2); }
4596 .metric-value { margin-top: 6px; }
4597 .metric-big { display:block; font-size: 20px; font-weight: 900; color: var(--oxide); line-height: 1.15; letter-spacing: -0.02em; }
4598 .metric-exact { position: absolute; bottom: 6px; right: 10px; font-size: 12px; font-weight: 600; color: var(--muted); font-family: ui-monospace, monospace; }
4599 .metric-tooltip { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%) translateY(7px); background: var(--text); color: var(--bg); padding: 10px 14px; border-radius: 10px; font-size: 12px; font-weight: 500; line-height: 1.55; white-space: normal; max-width: 340px; min-width: 200px; text-align: left; pointer-events: none; opacity: 0; transition: opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index: 100; box-shadow: 0 4px 18px rgba(0,0,0,0.25); }
4600 .metric-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--text); }
4601 .metric:hover .metric-tooltip { opacity: 1; transform: translateX(-50%) translateY(0); }
4602 .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); }
4603 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; }
4604 .hero h1 { margin:0 0 8px; font-size: 28px; letter-spacing: -0.04em; }
4605 .run-id-row { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:10px; margin-top:16px; }
4606 @media(max-width:960px) { .run-id-row { grid-template-columns: 1fr 1fr; } }
4607 @media(max-width:560px) { .run-id-row { grid-template-columns: 1fr; } }
4608 .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); position:relative; cursor:default; transition:transform 0.18s ease, box-shadow 0.18s ease; }
4609 .run-id-chip[data-copy] { cursor:pointer; }
4610 a.run-id-chip-link { text-decoration:none; cursor:pointer; }
4611 a.run-id-chip-link:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; border-left-color:var(--oxide); }
4612 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
4613 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
4614 .run-id-chip-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.1em; color:var(--accent); display:flex; align-items:center; gap:4px; }
4615 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
4616 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
4617 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
4618 .submodule-state-badge { display:inline-block; font-size:10px; font-style:italic; font-weight:600; color:var(--accent-2); background:rgba(100,130,220,0.10); border:1px solid rgba(100,130,220,0.22); border-radius:4px; padding:1px 6px; letter-spacing:0.03em; }
4619 body.dark-theme .submodule-state-badge { color:var(--accent); background:rgba(111,155,255,0.13); border-color:rgba(111,155,255,0.28); }
4620 .chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
4621 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4622 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
4623 a.run-id-chip-link:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
4624 .chip-copy-icon { display:inline-block; margin-left:5px; font-size:10px; opacity:0.55; vertical-align:middle; }
4625 .chip-label-icon { display:inline-block; vertical-align:middle; margin-right:3px; margin-top:-1px; opacity:0.8; }
4626 .chip-popout-icon { display:inline-block; vertical-align:middle; margin-left:4px; opacity:0.6; flex-shrink:0; }
4627 .run-id-short-badge { font-family:ui-monospace,monospace; font-size:13px; font-weight:700; color:var(--muted); background:var(--surface-2); border:1px solid var(--line); border-radius:6px; padding:2px 8px; letter-spacing:0.04em; white-space:nowrap; vertical-align:middle; }
4628 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
4629 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
4630 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
4631 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
4632 .subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
4633 .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); width:100%; }
4634 .meta-chip { flex:1; display:inline-flex; align-items:center; justify-content:center; gap:5px; padding:0 10px; font-size:13px; font-weight:500; color:var(--muted); border-right:1px solid var(--line); line-height:1.8; }
4635 .meta-chip:last-child { border-right:none; }
4636 .meta-chip b { color:var(--text); font-weight:700; }
4637 .prev-scan-banner { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px 20px; margin:0 0 20px; display:flex; flex-direction:column; gap:12px; width:100%; box-sizing:border-box; box-shadow:0 4px 16px rgba(77,44,20,0.10); }
4638 body.dark-theme .prev-scan-banner { box-shadow:0 4px 16px rgba(0,0,0,0.3); }
4639 .prev-scan-banner-empty { flex-direction:row; align-items:center; gap:8px; font-size:13px; color:var(--muted); font-style:italic; }
4640 .prev-scan-banner-top { display:flex; flex-direction:column; gap:4px; }
4641 .prev-scan-meta { display:flex; align-items:center; gap:7px; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); flex-wrap:wrap; }
4642 .prev-scan-ts { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
4643 .prev-scan-count { font-weight:500; text-transform:none; letter-spacing:0; color:var(--muted); }
4644 .prev-scan-summary { font-size:13px; font-weight:600; color:var(--text); }
4645 .prev-scan-summary b { font-weight:900; }
4646 .delta-neutral-text { color:var(--muted); }
4647 .delta-up { color:#2a6846; }
4648 .delta-down { color:#b23030; }
4649 body.dark-theme .delta-up { color:#5aba8a; }
4650 body.dark-theme .delta-down { color:#e07070; }
4651 .delta-card-row { display:grid; grid-template-columns:repeat(7,1fr); gap:12px; width:100%; }
4652 @media(max-width:1000px){ .delta-card-row { grid-template-columns:repeat(4,1fr); } }
4653 @media(max-width:540px){ .delta-card-row { grid-template-columns:repeat(2,1fr); } }
4654 .delta-card-inline { display:flex; flex-direction:column; gap:4px; padding:14px 16px; border-radius:12px; border:1px solid var(--line); background:var(--surface); position:relative; cursor:default; transition:transform .2s ease, box-shadow .2s ease; box-shadow:0 2px 8px rgba(77,44,20,0.07); }
4655 .delta-card-inline:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.22); z-index:10; }
4656 body.dark-theme .delta-card-inline { box-shadow:0 2px 8px rgba(0,0,0,0.2); }
4657 body.dark-theme .delta-card-inline:hover { box-shadow:0 12px 32px rgba(0,0,0,0.55); }
4658 .delta-card-val { font-size:20px; font-weight:900; color:var(--oxide); line-height:1.2; }
4659 .delta-card-val.pos { color:#2a6846; }
4660 .delta-card-val.neg { color:#b23030; }
4661 .delta-card-val.mod { color:#7a5a10; }
4662 body.dark-theme .delta-card-val.pos { color:#5aba8a; }
4663 body.dark-theme .delta-card-val.neg { color:#e07070; }
4664 body.dark-theme .delta-card-val.mod { color:#d4a843; }
4665 .delta-card-lbl { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
4666 .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:7px 12px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 12px rgba(0,0,0,0.18); }
4667 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4668 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4669 .delta-panel-link { display:inline-flex; align-items:center; gap:6px; padding:8px 16px; border-radius:10px; border:1px solid var(--line); background:var(--surface); color:var(--text); font-size:12px; font-weight:600; text-decoration:none; transition:background .15s, border-color .15s, color .15s, transform .15s, box-shadow .15s; box-shadow:0 2px 6px rgba(77,44,20,0.08); }
4670 .delta-panel-link:hover { background:var(--oxide); color:#fff; border-color:var(--oxide); transform:translateY(-2px); box-shadow:0 6px 18px rgba(77,44,20,0.25); }
4671 body.dark-theme .delta-panel-link { box-shadow:0 2px 6px rgba(0,0,0,0.2); }
4672 body.dark-theme .delta-panel-link:hover { box-shadow:0 6px 18px rgba(0,0,0,0.45); }
4673 .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; }
4674 .toolbar { display:flex; flex-wrap:wrap; justify-content:space-between; gap: 12px; align-items: center; margin-bottom: 16px; }
4675 .toolbar-left { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
4676 .search { min-width: 280px; padding: 10px 12px; border-radius: 10px; border:1px solid var(--line-strong); background: var(--surface-2); color:var(--text); }
4677 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
4678 .pill { padding: 6px 10px; border-radius: 999px; border:1px solid var(--line); background: var(--surface-2); font-size: 12px; font-weight: 700; }
4679 .pill.good { background: var(--good-bg); color: var(--good-text); }
4680 .pill.info { background: var(--info-bg); color: var(--info-text); }
4681 .export-group { display:flex; gap:6px; align-items:center; }
4682 .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; }
4683 .export-btn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
4684 .page-size-row { display:flex; align-items:center; gap:6px; }
4685 .page-size-label { font-size:13px; color:var(--muted); font-weight:600; }
4686 .page-size-select { padding:5px 10px; border-radius:8px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--text); font-size:13px; font-weight:700; cursor:pointer; }
4687 .page-count-label { font-size:12px; color:var(--muted); white-space:nowrap; }
4688 .pagination-bar { display:flex; align-items:center; justify-content:center; gap:14px; padding:10px 0 2px; }
4689 .pager-btn { padding:6px 16px; border-radius:8px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--text); font-size:13px; font-weight:700; cursor:pointer; transition:background .15s,color .15s; }
4690 .pager-btn:hover:not(:disabled) { background:var(--accent); color:#fff; border-color:var(--accent); }
4691 .pager-btn:disabled { opacity:.4; cursor:default; }
4692 .pager-info { font-size:13px; color:var(--muted); font-weight:600; min-width:120px; text-align:center; }
4693 .pager-edge { font-size:12px; padding:5px 10px; }
4694 .pager-jump-wrap { font-size:13px; color:var(--muted); font-weight:600; display:flex; align-items:center; gap:5px; white-space:nowrap; }
4695 .pager-jump { width:52px; padding:3px 5px; border-radius:6px; border:1px solid var(--line-strong); background:var(--surface); color:var(--text); font-size:13px; font-weight:700; text-align:center; -moz-appearance:textfield; }
4696 .pager-jump::-webkit-inner-spin-button,.pager-jump::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
4697 .table-shell { border: 1px solid var(--line); border-radius: 16px; overflow: auto; background: var(--surface-2); max-height: 900px; }
4698 /* Clip wrapper: hides the scrollbar track that hangs 8px past the right edge */
4699 .table-shell-clip { overflow: hidden !important; max-height: none !important; }
4700 /* Skipped-files scroll pane: auto so no phantom space when content is short */
4701 #skipped-shell { overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: var(--line-strong) var(--surface-2); }
4702 #per-file-table tbody tr:last-child td, #skipped-table tbody tr:last-child td { border-bottom: none; }
4703 #skipped-shell::-webkit-scrollbar { width: 8px; }
4704 #skipped-shell::-webkit-scrollbar-track { background: var(--surface-2); }
4705 #skipped-shell::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 4px; }
4706 table { width: 100%; border-collapse: collapse; font-size: 14px; }
4707 th, td { text-align: left; padding: 11px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
4708 th { color: var(--muted); font-weight: 800; background: var(--surface-2); cursor: pointer; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
4709 /* Per-file detail table — auto layout so File column sizes to content */
4710 .table-resizable { table-layout: auto; }
4711 .table-resizable th { position: sticky; top: 0; z-index: 2; overflow: hidden; white-space: nowrap; min-width: 52px; }
4712 .table-resizable td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4713 .table-resizable td.mono { overflow: visible; text-overflow: unset; white-space: nowrap; }
4714 #skipped-table { table-layout: fixed; width: 100%; }
4715 #skipped-table th, #skipped-table td { padding: 7px 8px; }
4716 #skipped-table td:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4717 /* Hotspots table — overflow visible so header tooltips can escape the cell */
4718 #hotspots-table th { overflow: visible; }
4719 .hs-hint { color: var(--muted); font-style: italic; }
4720 /* Column-header explainer tooltip (shared visual language with .stat-chip-tip) */
4721 .col-tip { position: absolute; top: calc(100% + 9px); left: 0; z-index: 60; width: max-content; max-width: 270px;
4722 background: var(--text); color: var(--bg); padding: 9px 12px; border-radius: 9px;
4723 font-size: 11.5px; font-weight: 500; line-height: 1.5; letter-spacing: normal; text-transform: none;
4724 white-space: normal; text-align: left; box-shadow: 0 10px 30px rgba(0,0,0,0.22);
4725 opacity: 0; pointer-events: none; transition: opacity .18s ease; }
4726 .col-tip.col-tip-r { left: auto; right: 0; }
4727 .col-tip strong { color: var(--bg); }
4728 .col-tip::after { content: ''; position: absolute; bottom: 100%; left: 16px;
4729 border: 6px solid transparent; border-bottom-color: var(--text); }
4730 .col-tip.col-tip-r::after { left: auto; right: 16px; }
4731 #hotspots-table th:hover .col-tip { opacity: 1; }
4732 /* Column resize handle */
4733 .col-resize-handle { position: absolute; top: 0; right: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 10; }
4734 .col-resize-handle:hover, .col-resize-handle.dragging { background: rgba(211,122,76,0.3); }
4735 #per-file-table { table-layout: fixed; width: 100%; min-width: 0; }
4736 #per-file-table th, #per-file-table td { padding: 8px 6px; }
4737 /* File column: pinned, truncates long paths */
4738 #per-file-table th:first-child { position: sticky; top: 0; left: 0; z-index: 3; width: 26%; background: var(--surface-2); padding: 8px 6px; overflow: hidden; text-overflow: ellipsis; }
4739 #per-file-table td:first-child { position: sticky; left: 0; z-index: 1; background: var(--surface-2); padding: 8px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4740 #per-file-table th:nth-child(2) { width: 6%; }
4741 /* 12 numeric columns share the remaining 68%: 26+6+12×5.67≈98% total */
4742 #per-file-table th:nth-child(n+3) { width: 5.67%; }
4743 /* Override mono class overflow so file paths truncate */
4744 #per-file-table td.mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4745 #per-file-table tbody tr:hover td:first-child { background: rgba(255,247,238,0.6); }
4746 body.dark-theme #per-file-table tbody tr:hover td:first-child { background: rgba(255,255,255,0.03); }
4747 /* Language breakdown: auto layout with resizable columns — headers size to content */
4748 #lang-breakdown-table { width: 100%; min-width: 760px; }
4749 #lang-breakdown-table th, #lang-breakdown-table td { padding: 8px 6px; font-size: 13px; }
4750 /* Skipped table: extend truncation to all cells, not just the first column */
4751 #skipped-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; }
4752 /* Support opportunities table: fixed layout so Category/Count columns don't grow */
4753 .support-table { table-layout: fixed; width: 100%; }
4754 .support-table th:first-child { width: 20%; }
4755 .support-table th:nth-child(2) { width: 6%; }
4756 .support-table th:nth-child(3) { width: 24%; }
4757 .support-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
4758 /* Description and example columns must wrap — content can be long */
4759 .support-table td:nth-child(3) { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; line-height: 1.45; }
4760 .support-table td:last-child { white-space: normal; overflow: visible; text-overflow: unset; max-width: none; }
4761 .support-example-file { display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; padding: 1px 6px; background: var(--surface); border: 1px solid var(--line); border-radius: 4px; margin-bottom: 2px; word-break: break-all; }
4762 .support-recommendation { color: var(--muted); font-size: 11px; margin: 6px 0 0; line-height: 1.5; }
4763 .num-col { text-align: right !important; }
4764 tbody tr:hover { background: rgba(255, 247, 238, 0.6); }
4765 body.dark-theme tbody tr:hover { background: rgba(255,255,255,0.03); }
4766 tr:last-child td { border-bottom: none; }
4767 .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
4768 .small { color: var(--muted); font-size: 13px; }
4769 .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; }
4770 .status-analyzedexact { background: var(--good-bg); color: var(--good-text); border-color: rgba(28,135,70,0.18); }
4771 .status-analyzedbesteffort, .status-skippedbypolicy { background: var(--warn-bg); color: var(--warn-text); border-color: rgba(146,96,0,0.18); }
4772 .status-skippedunsupported, .status-skippedbinary { background: var(--danger-bg); color: var(--danger-text); border-color: rgba(179,59,59,0.18); }
4773 .stack { display:grid; gap:22px; }
4774 .summary-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
4775 @media(max-width:800px) { .summary-strip { grid-template-columns:repeat(2,1fr) !important; } }
4776 .test-density-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; padding:10px 16px; border-radius:10px; background:var(--surface-2); border:1px solid var(--line); }
4777 .test-density-num { font-size:22px; font-weight:900; color:var(--oxide); line-height:1; }
4778 .test-density-meta { display:flex; flex-direction:column; gap:2px; }
4779 .test-density-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
4780 .test-density-sub { font-size:11px; color:var(--muted-2); }
4781 .test-density-badge { margin-left:auto; padding:4px 12px; border-radius:999px; font-size:12px; font-weight:700; }
4782 .test-density-badge.good { background:var(--good-bg); color:var(--good-text); }
4783 .test-density-badge.warn { background:var(--warn-bg); color:var(--warn-text); }
4784 .test-density-badge.danger { background:var(--danger-bg); color:var(--danger-text); }
4785 .info-callout { display:flex; align-items:flex-start; gap:10px; margin-top:14px; padding:11px 14px; border-radius:10px; background:var(--info-bg); border:1px solid rgba(68,103,216,0.18); color:var(--info-text); font-size:13px; line-height:1.5; }
4786 .info-callout-icon { flex:0 0 auto; font-size:15px; margin-top:1px; }
4787 .info-callout code { background:rgba(68,103,216,0.12); border-radius:4px; padding:1px 5px; font-size:12px; }
4788 body.dark-theme .info-callout { background:rgba(100,130,255,0.09); border-color:rgba(100,130,255,0.22); }
4789 .empty-state-row td { text-align:center; padding:20px; color:var(--muted-2); font-size:13px; font-style:italic; }
4790 .cov-gauge-row { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin-bottom:18px; }
4791 @media(max-width:700px) { .cov-gauge-row { grid-template-columns:1fr; } }
4792 .cov-gauge-card { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:18px 20px; display:flex; flex-direction:column; gap:8px; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); min-width:0; }
4793 .cov-gauge-card:hover { transform:translateY(-3px); box-shadow:0 10px 28px rgba(77,44,20,0.15); }
4794 .cov-gauge-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); }
4795 .cov-gauge-val { font-size:32px; font-weight:900; line-height:1; }
4796 .cov-gauge-track { height:8px; border-radius:4px; background:var(--line); overflow:hidden; }
4797 .cov-gauge-fill { height:100%; border-radius:4px; transition:width .5s ease; }
4798 .cov-gauge-sub { font-size:11px; color:var(--muted); }
4799 .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); }
4800 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
4801 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
4802 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-top:4px; }
4803 .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; font-weight:500; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
4804 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
4805 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
4806 .stat-chip-exact { position:absolute; bottom:6px; right:10px; font-size:12px; font-weight:600; color:var(--muted); font-variant-numeric:tabular-nums; line-height:1; }
4807 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
4808 .cocomo-mode-tip { position:absolute; top:calc(100% + 8px); left:0; transform:translateY(-7px); background:var(--text); color:var(--bg); padding:9px 13px; border-radius:8px; font-size:11px; font-weight:500; line-height:1.55; white-space:normal; max-width:300px; min-width:180px; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:300; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
4809 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
4810 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
4811 .report-stack { display:grid; gap: 18px; align-items:start; }
4812 pre { background: var(--surface-2); border: 1px solid var(--line); border-radius: 16px; padding: 16px; overflow: auto; font-size: 12px; color: var(--text); }
4813 .warn-list { margin: 0; padding-left: 18px; line-height: 1.6; }
4814 .sort-indicator { color: var(--muted-2); font-size: 11px; margin-left: 6px; }
4815 .warning-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
4816 .warning-card { padding: 10px 12px; }
4817 .warning-card h3 { margin: 0 0 4px; font-size: 12px; font-weight: 700; }
4818 .warning-card .count { font-size: 16px; font-weight: 800; margin-bottom: 4px; }
4819 .tone-neutral .count { color: var(--text); }
4820 .tone-warn .count { color: var(--warn-text); }
4821 .tone-danger .count { color: var(--danger-text); }
4822 .tone-neutral .warning-count { color: var(--oxide); }
4823 .tone-warn .warning-count { color: var(--warn-text); }
4824 .tone-danger .warning-count { color: var(--danger-text); }
4825 .support-note { color: var(--muted); font-size: 11px; line-height: 1.45; }
4826 .support-table th { cursor: default; }
4827 details { border: 1px solid var(--line); border-radius: 14px; background: var(--surface-2); }
4828 summary { cursor: pointer; padding: 14px 16px; font-weight: 700; }
4829 details > div { padding: 0 16px 16px; }
4830 .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; }
4831 .warning-console-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top: 12px; }
4832 .warning-console.hidden { display:none; }
4833 @media (max-width: 1200px) {
4834 .warning-grid { grid-template-columns: 1fr 1fr; }
4835 }
4836 @media (max-width: 960px) {
4837 .top-nav-inner { grid-template-columns: 1fr; }
4838 .nav-project-slot, .nav-status { justify-content:flex-start; }
4839 .warning-grid, .report-stack { grid-template-columns: 1fr; }
4840 .hero-top { flex-direction: column; }
4841 .search { min-width: 100%; width: 100%; }
4842 }
4843 @media (max-width: 640px) {
4844 .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
4845 }
4846 /* ── Report header / footer identification banner ─────────────────── */
4847 .report-id-banner { background: var(--nav); color: #fff; font-size: 11px; font-weight: 700; letter-spacing: 0.05em; display: flex; align-items: center; justify-content: center; height: 27px; padding: 0 16px; position: fixed; top: 0; left: 0; right: 0; z-index: 32; }
4848 .report-id-footer-banner { background: var(--nav); color: #fff; font-size: 11px; font-weight: 700; letter-spacing: 0.05em; display: flex; align-items: center; justify-content: center; height: 27px; padding: 0 16px; position: fixed; bottom: 0; left: 0; right: 0; z-index: 32; }
4849 body.has-report-banner .top-nav { top: 27px; }
4850 body.has-report-banner { padding-bottom: 27px; }
4851 /* ── Print & PDF export ──────────────────────────────────────────── */
4852 @page { size: A4 landscape; margin: 0.35in 0.5in; }
4853
4854 @media print {
4855 *, *::before, *::after {
4856 -webkit-print-color-adjust: exact !important;
4857 print-color-adjust: exact !important;
4858 box-sizing: border-box !important;
4859 }
4860
4861 html, body {
4862 background: #f5efe8 !important;
4863 min-height: auto !important;
4864 width: 100% !important;
4865 }
4866
4867 /* Report id banner — fixed position repeats the banner on every printed page */
4868 .report-id-banner { display: flex !important; align-items: center !important; justify-content: center !important; position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; padding: 3px 12px !important; font-size: 10px !important; background: #3d3d3d !important; color: #fff !important; z-index: 9999 !important; }
4869 .report-id-footer-banner { display: flex !important; align-items: center !important; justify-content: center !important; position: fixed !important; bottom: 0 !important; left: 0 !important; right: 0 !important; padding: 3px 12px !important; font-size: 10px !important; margin-top: 0 !important; background: #3d3d3d !important; color: #fff !important; z-index: 9999 !important; }
4870 body.has-report-banner .top-nav { top: 0 !important; }
4871 body.has-report-banner { padding-bottom: 0 !important; }
4872 /* Hide interactive UI-chrome; keep section heading text visible */
4873 .top-nav, .hero-actions,
4874 .background-watermarks, #code-particles,
4875 .header-button, .theme-toggle,
4876 .nav-dropdown-wrap, .config-actions,
4877 .warnings-show-link, .warning-console-actions,
4878 .toolbar .pill-row, .toolbar .export-group,
4879 input[type="search"], button { display: none !important; }
4880 /* Show toolbar as a plain block so h2 headings are visible */
4881 .toolbar { display: block !important; margin-bottom: 8px !important; }
4882 .toolbar-left { display: block !important; }
4883
4884 /* Remove page-level layout constraints */
4885 .page {
4886 max-width: none !important;
4887 width: 100% !important;
4888 padding: 0 !important;
4889 margin: 0 !important;
4890 }
4891
4892 .panel, .hero, .section,
4893 .saved-report-shell, .saved-panel, .report-shell, .stack {
4894 max-width: none !important;
4895 width: 100% !important;
4896 box-shadow: none !important;
4897 border: 1px solid #ddd !important;
4898 border-radius: 10px !important;
4899 margin-bottom: 10px !important;
4900 overflow: visible !important;
4901 }
4902
4903 /* Force grids to their full-width column counts regardless of viewport */
4904 .summary-grid {
4905 display: grid !important;
4906 grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
4907 gap: 10px !important;
4908 }
4909
4910 .warning-grid {
4911 display: grid !important;
4912 grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
4913 gap: 8px !important;
4914 }
4915
4916 .report-stack {
4917 display: grid !important;
4918 gap: 12px !important;
4919 align-items: start !important;
4920 }
4921
4922 /* Metric cards */
4923 .metric {
4924 box-shadow: none !important;
4925 border: 1px solid #e0d0c0 !important;
4926 border-radius: 8px !important;
4927 break-inside: avoid !important;
4928 padding: 10px 12px 22px !important;
4929 min-height: 0 !important;
4930 }
4931
4932 .metric-big { font-size: 20px !important; }
4933 .metric-exact { font-size: 10px !important; bottom: 5px !important; right: 8px !important; }
4934 .metric-label { font-size: 10px !important; }
4935
4936 /* Page break control — small atomic cards stay together; large panels
4937 and tables flow freely so they never force blank pages. */
4938 .metric, .warning-card, .run-id-chip { break-inside: avoid !important; }
4939 .hero, .panel, .stack { break-inside: auto !important; }
4940 section { break-inside: auto !important; }
4941 /* Keep each chart panel whole — browser moves it to the next page rather than
4942 slicing through the middle of a canvas. */
4943 .chart-section { break-inside: avoid !important; }
4944 /* Keep the summary grid on the same page as the hero header when possible */
4945 .summary-grid { break-before: avoid !important; }
4946 /* Section headings never orphan at the bottom of a page */
4947 h2, h3 { break-after: avoid !important; orphans: 3; widows: 3; }
4948 /* Keep the first few rows of a table with the header */
4949 thead { break-after: avoid !important; }
4950
4951 /* Language charts — table layout is inherently side-by-side */
4952 #lang-overview-charts table { display: inline-table !important; }
4953 #lang-overview-charts td { vertical-align: top !important; }
4954
4955 /* Tables */
4956 .table-shell {
4957 max-height: none !important;
4958 overflow: visible !important;
4959 width: 100% !important;
4960 break-inside: auto !important;
4961 }
4962
4963 table {
4964 width: 100% !important;
4965 table-layout: auto !important;
4966 font-size: 10px !important;
4967 border-collapse: collapse !important;
4968 orphans: 4 !important;
4969 widows: 4 !important;
4970 }
4971
4972 /* Remove the screen-layout min-width so tables scale to page width */
4973 #per-file-table, #skipped-table { min-width: 0 !important; }
4974 /* Release sticky column positioning (not meaningful on paper) */
4975 #per-file-table th:first-child,
4976 #per-file-table td:first-child { position: static !important; }
4977 /* Show ALL rows — JS pagination hides rows via inline style; !important overrides it */
4978 #per-file-table tbody tr, #skipped-table tbody tr, #hotspots-table tbody tr { display: table-row !important; }
4979 /* Hide pagination controls — not interactive in PDF */
4980 .page-size-row, .pagination-bar { display: none !important; }
4981 /* Header tooltips and the interaction hint are screen-only */
4982 .col-tip { display: none !important; }
4983 .hs-hint { display: none !important; }
4984
4985 thead { display: table-header-group; }
4986 tr { break-inside: avoid !important; }
4987
4988 th {
4989 position: relative !important;
4990 font-size: 9px !important;
4991 font-weight: 700 !important;
4992 color: #333 !important;
4993 padding: 5px 8px !important;
4994 background: rgba(211,122,76,0.18) !important;
4995 white-space: normal !important;
4996 }
4997 /* Resize handles are screen-only — hide them in print */
4998 .col-resize-handle { display: none !important; }
4999 /* Sort indicators are redundant on paper */
5000 .sort-indicator { display: none !important; }
5001
5002 td {
5003 white-space: normal !important;
5004 overflow-wrap: anywhere !important;
5005 word-break: break-word !important;
5006 padding: 5px 8px !important;
5007 font-size: 10px !important;
5008 border-bottom: 1px solid #e8d8c8 !important;
5009 }
5010
5011 pre, code {
5012 white-space: pre-wrap !important;
5013 overflow-wrap: anywhere !important;
5014 word-break: break-word !important;
5015 font-size: 9px !important;
5016 max-height: none !important;
5017 }
5018
5019 .warning-card {
5020 box-shadow: none !important;
5021 border: 1px solid #ddd !important;
5022 break-inside: avoid !important;
5023 padding: 10px !important;
5024 }
5025
5026 .hero-top { flex-direction: row !important; }
5027
5028 .run-id-row { flex-wrap: wrap !important; gap: 4px !important; }
5029 .run-id-chip { font-size: 9px !important; padding: 4px 8px !important; border-left-width: 2px !important; }
5030 .meta { flex-wrap: wrap !important; gap: 0 !important; padding: 4px 0 !important; border-top: 1px solid #ccc !important; border-bottom: 1px solid #ccc !important; width: 100% !important; }
5031 .meta-chip { flex: 1 !important; justify-content: center !important; font-size: 9px !important; padding: 0 8px !important; border-right: 1px solid #ccc !important; }
5032 .meta-chip:last-child { border-right: none !important; }
5033
5034 .report-footer {
5035 border-top: 1px solid #ccc !important;
5036 margin-top: 12px !important;
5037 font-size: 10px !important;
5038 }
5039
5040 /* Collapse all <details> in print except the warnings block */
5041 details { border: 1px solid #ddd !important; border-radius: 8px !important; }
5042 details > summary { display: block !important; font-size: 10px !important; }
5043 details > div { display: none !important; }
5044 .warning-console { display: none !important; }
5045 .warning-console-actions { display: none !important; }
5046 /* Always expand the run-warnings details in PDF */
5047 details.warnings-details > div { display: block !important; }
5048 details.warnings-details .warning-console {
5049 display: block !important;
5050 max-height: none !important;
5051 overflow: visible !important;
5052 font-size: 8px !important;
5053 white-space: pre-wrap !important;
5054 word-break: break-all !important;
5055 }
5056 details.warnings-details .code-block-toolbar { display: none !important; }
5057
5058 /* Pill badges */
5059 .pill { font-size: 9px !important; padding: 2px 6px !important; min-height: auto !important; }
5060
5061 /* Support opportunities table */
5062 .support-table td:first-child { font-weight: 600; font-size: 10px !important; }
5063
5064 /* Hide canvas-based interactive chart sections; replaced by pre-rendered variants */
5065 .chart-section { display: none !important; }
5066 .charts-grid { display: none !important; }
5067 /* Pre-rendered chart variants — no forced page break; flow naturally after hero section */
5068 .pdf-variants-root { display: block !important; padding: 0 !important; }
5069 .pdf-variant-group { break-inside: auto !important; margin-bottom: 8px !important; background: #faf6f0 !important; border: 1px solid #e0d0c0 !important; border-radius: 10px !important; padding: 10px 12px !important; }
5070 .pdf-variant-group-title { break-after: avoid !important; font-size: 12px !important; font-weight: 800 !important; color: #3d2d26 !important; margin: 0 0 6px !important; padding-bottom: 4px !important; border-bottom: 2px solid #d37a4c !important; }
5071 .pdf-variant-grid { display: grid !important; grid-template-columns: 1fr 1fr !important; gap: 6px !important; }
5072 /* Single-column chart (scatter, etc.) — centre and constrain width in print */
5073 .pdf-variant-grid.single-col { grid-template-columns: 1fr !important; }
5074 .pdf-variant-grid.single-col .pdf-variant-panel { max-width: 62% !important; margin: 0 auto !important; }
5075 .pdf-variant-panel { break-inside: avoid !important; }
5076 .pdf-variant-label { font-size: 9px !important; font-weight: 700 !important; text-transform: uppercase !important; letter-spacing: .06em !important; color: #7b675b !important; margin: 0 0 2px !important; }
5077 .pdf-variant-img { width: 100% !important; height: auto !important; display: block !important; border-radius: 5px !important; border: 1px solid #ddd !important; }
5078 }
5079
5080
5081 .warnings-show-link {
5082 display: inline-flex;
5083 align-items: center;
5084 gap: 8px;
5085 padding: 8px 12px;
5086 border-radius: 10px;
5087 border: 1px solid rgba(111, 144, 255, 0.35);
5088 background: #eef3ff;
5089 color: #2f5fe3 !important;
5090 font-weight: 800;
5091 text-decoration: none;
5092 box-shadow: inset 0 1px 0 rgba(255,255,255,0.45);
5093 }
5094
5095 body.dark-theme .warnings-show-link {
5096 background: #1c2847;
5097 color: #a9c1ff !important;
5098 border-color: rgba(169, 193, 255, 0.32);
5099 }
5100
5101 .effective-config-note {
5102 margin: 8px 0 0;
5103 color: var(--muted);
5104 font-size: 14px;
5105 line-height: 1.6;
5106 }
5107 .config-actions { display: flex; gap: 8px; flex-shrink: 0; }
5108 .config-pre-wrap { position: relative; }
5109 .config-pre { margin: 0; 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; }
5110 body.dark-theme .config-pre { background: #0e0c0a; color: #b8f0b8; }
5111 .code-block-toolbar { display:flex; justify-content:flex-end; margin-bottom:6px; }
5112 .code-copy-btn { display:inline-flex; align-items:center; gap:5px; background: var(--surface-2); border: 1px solid var(--line-strong); color: var(--muted); border-radius: 8px; padding: 5px 12px; font-size: 12px; font-weight: 700; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; white-space: nowrap; }
5113 .code-copy-btn:hover { background: rgba(184,93,51,0.08); color: var(--oxide-2); border-color: rgba(184,93,51,0.30); }
5114 body.dark-theme .code-copy-btn { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.18); color: rgba(255,255,255,0.75); }
5115 body.dark-theme .code-copy-btn:hover { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.40); color: var(--oxide); }
5116
5117
5118 .page {
5119 position: relative;
5120 z-index: 1;
5121 }
5122 .report-footer { margin-top: 16px; padding: 14px 24px; border-top: 1px solid var(--line); text-align: center; color: var(--muted); font-size: 12px; font-weight: 600; }
5123
5124 /* ── Chart controls & containers ───────────────────────────────────── */
5125 .chart-section { }
5126 .chart-controls { display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
5127 .chart-controls label { font-size:13px; font-weight:700; color:var(--muted); display:flex; align-items:center; gap:6px; }
5128 .chart-select { background:var(--surface-2); border:1px solid var(--line-strong); border-radius:8px; padding:5px 10px; color:var(--text); font-size:13px; font-weight:600; cursor:pointer; outline:none; appearance:auto; }
5129 .chart-select:focus { border-color:var(--accent); }
5130 .chart-expand-btn { background:none; border:1px solid var(--line-strong); border-radius:6px; cursor:pointer; color:var(--muted); padding:4px 10px; font-size:13px; line-height:1; transition:background .13s,color .13s; }
5131 .chart-expand-btn:hover { background:var(--surface-2); color:var(--text); }
5132 .chart-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.55); z-index:9999; display:flex; align-items:center; justify-content:center; padding:24px; box-sizing:border-box; }
5133 .chart-modal { background:var(--bg); border-radius:16px; padding:24px 28px; max-width:1000px; width:100%; max-height:88vh; overflow-y:auto; position:relative; box-shadow:0 24px 80px rgba(0,0,0,0.3); }
5134 .chart-modal-title { font-size:15px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; color:var(--text); margin:0 0 2px; display:block; }
5135 .chart-modal-subtitle { font-size:13px; font-weight:600; color:var(--muted); margin:0 0 16px; display:block; letter-spacing:.02em; }
5136 .chart-modal-close { position:absolute; top:14px; right:18px; background:none; border:none; font-size:22px; cursor:pointer; color:var(--text); line-height:1; padding:0; }
5137 .chart-modal-close:hover { opacity:.7; }
5138 .chart-modal-header { display:flex; align-items:center; gap:12px; flex-wrap:nowrap; margin:0 0 16px; padding-right:44px; }
5139 .chart-modal-header .chart-modal-title { flex:1 1 auto; margin:0; min-width:0; }
5140 body.dark-theme .chart-modal { background:var(--surface); }
5141 .chart-container { width:100%; overflow:visible; }
5142 .charts-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
5143 .charts-grid > .panel { margin:0; min-width:0; display:flex; flex-direction:column; }
5144 .charts-grid .chart-section > div { display:flex; flex-direction:column; flex:1; }
5145 .charts-grid .chart-container { flex:1; min-height:180px; }
5146 .chart-pre { min-height:72px; }
5147 @media (max-width:820px) { .charts-grid { grid-template-columns:1fr; } }
5148 .r-lang-overview { display:flex; gap:40px; align-items:center; justify-content:center; flex-wrap:wrap; padding:8px 0 16px; }
5149 .r-lang-overview-cell { display:flex; flex-direction:column; align-items:center; gap:8px; flex:1 1 280px; max-width:480px; }
5150 .r-lang-overview-cell p { margin:0; font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); text-align:center; }
5151 .r-lang-overview svg { display:block; max-width:100%; height:auto; }
5152 .rchit { cursor:pointer; transition:opacity .17s,filter .17s,transform .17s; transform-box:fill-box; transform-origin:center center; }
5153 .rchit:hover { filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18)); transform:scale(1.05); }
5154 .lang-bar-row { cursor:pointer; transition:transform .2s cubic-bezier(.34,1.56,.64,1); }
5155 .lang-bar-row:hover { transform:translateY(-2px); }
5156 .lang-bar-row .rchit:hover { filter:none; transform:none; }
5157 .lang-bar-row:hover .rchit { filter:brightness(1.12); transform:scaleY(1.22); }
5158 #r-tt { display:none; position:fixed; background:rgba(15,10,6,.95); color:#fff; border-radius:10px; padding:8px 13px; font-size:12px; line-height:1.5; pointer-events:none; z-index:10001; box-shadow:0 4px 20px rgba(0,0,0,.32); border:1px solid rgba(255,255,255,.1); max-width:240px; white-space:nowrap; }
5159 .chart-tab-bar { display:flex; gap:6px; margin-bottom:12px; flex-wrap:wrap; }
5160 .chart-tab { padding:5px 16px; border-radius:999px; border:1px solid var(--line-strong); background:var(--surface-2); color:var(--muted); font-size:12px; font-weight:700; cursor:pointer; transition:background 0.12s,color 0.12s,border-color 0.12s; }
5161 .chart-tab:hover { background:var(--surface-3); color:var(--text); }
5162 .chart-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
5163 .chart-locked-card { display:none; padding:20px 24px; border-radius:14px; background:var(--info-bg); border:1px solid rgba(111,144,255,0.28); color:var(--info-text); font-size:14px; line-height:1.6; }
5164 .chart-locked-card a { color:var(--accent-2); font-weight:700; }
5165 .chart-locked-card h3 { margin:0 0 6px; font-size:15px; }
5166
5167 /* Print: hide interactive controls; keep SVGs; show locked card for history mode */
5168 @media print {
5169 .chart-controls, .chart-tab-bar { display:none !important; }
5170 /* Single-column grid: each chart gets full page width, renders shorter, fits on one page */
5171 .charts-grid { grid-template-columns: 1fr !important; gap: 10px !important; }
5172 /* Cap canvas height so a single chart never overflows a landscape page */
5173 canvas { max-width: 100% !important; max-height: 280px !important; }
5174 .chart-container { width: 100% !important; overflow: visible !important; }
5175 .chart-container svg { max-height:300px !important; }
5176 /* chart-locked-card: do NOT force display:block — let JS-set visibility carry
5177 into print (hidden in normal mode; visible only when history mode is active).
5178 When it does show, use readable dark text instead of accent blue. */
5179 .chart-locked-card { background:#f0f0f0 !important; border:1px solid #bbb !important; color:#333 !important; font-size:11px !important; padding:10px 14px !important; border-radius:8px !important; }
5180 .chart-locked-card h3 { color:#222 !important; font-size:13px !important; }
5181 .chart-locked-card a, .chart-locked-card strong { color:#1a4fa0 !important; }
5182 .chart-locked-card code { background:rgba(0,0,0,0.08) !important; padding:1px 4px !important; border-radius:3px !important; }
5183 }
5184
5185 /* PDF-only chart variants container — hidden on screen, rendered in print */
5186 .pdf-variants-root{display:none;}
5187 .pdf-variant-group{margin-bottom:12px;background:#faf6f0;border:1px solid #e0d0c0;border-radius:10px;padding:12px 14px;}
5188 .pdf-variant-group-title{font-size:15px;font-weight:800;color:#3d2d26;margin:0 0 8px;padding-bottom:5px;border-bottom:2px solid #d37a4c;}
5189 .pdf-variant-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
5190 /* Solo charts (one per row) — constrained width, centred */
5191 .pdf-variant-grid.single-col{grid-template-columns:1fr;}
5192 .pdf-variant-grid.single-col .pdf-variant-panel{max-width:62%;margin:0 auto;}
5193 .pdf-variant-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#7b675b;margin:0 0 3px;}
5194 .pdf-variant-img{width:100%;height:auto;display:block;border-radius:6px;border:1px solid #ddd;}
5195
5196 #rpt-loading-overlay{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;overflow:hidden;transition:opacity .6s cubic-bezier(.4,0,.2,1);background:radial-gradient(125% 125% at 50% 0%,#fbf4ec 0%,#f4ebe0 45%,#ecdfd0 100%);}
5197 #rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
5198 /* Drifting color blobs — transform/opacity only (GPU composited, no per-frame repaint) */
5199 .rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
5200 .rpt-blob-a{width:48vw;height:48vw;left:-10vw;top:-12vw;background:radial-gradient(circle,#e8932f,transparent 64%);animation:rpt-drift-a 17s ease-in-out infinite;}
5201 .rpt-blob-b{width:42vw;height:42vw;right:-8vw;bottom:-10vw;background:radial-gradient(circle,#d3621a,transparent 64%);animation:rpt-drift-b 21s ease-in-out infinite;}
5202 .rpt-blob-c{width:34vw;height:34vw;right:20vw;top:-8vw;background:radial-gradient(circle,#caa14f,transparent 64%);opacity:.38;animation:rpt-drift-c 25s ease-in-out infinite;}
5203 @keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
5204 @keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
5205 @keyframes rpt-drift-c{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(-7vw,8vw,0) scale(1.22);}}
5206 body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
5207 body.dark-theme .rpt-bg-blob{opacity:.36;}
5208 .rpt-load-card{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:22px;width:432px;max-width:88vw;padding:46px 54px 38px;background:linear-gradient(155deg,rgba(255,255,253,.95),rgba(255,248,240,.9));border:1px solid rgba(196,110,40,.16);border-radius:26px;box-shadow:0 1px 0 rgba(255,255,255,.8) inset,0 22px 64px rgba(120,64,16,.16),0 4px 16px rgba(0,0,0,.06);animation:rpt-card-in .5s cubic-bezier(.22,.68,0,1.12) both;}
5209 @keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
5210 body.dark-theme .rpt-load-card{background:linear-gradient(155deg,rgba(42,24,12,.92),rgba(28,15,6,.95));border-color:rgba(200,120,50,.16);box-shadow:0 1px 0 rgba(255,200,140,.05) inset,0 22px 64px rgba(0,0,0,.5),0 4px 16px rgba(0,0,0,.35);}
5211 /* Logo is static — no bounce (kept GPU-cheap) */
5212 .rpt-load-logo{width:58px;height:58px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));animation:rpt-card-in .5s ease .05s both;}
5213 .rpt-spinner-wrap{position:relative;width:90px;height:90px;}
5214 .rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
5215 .rpt-spinner{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,rgba(196,92,16,0) 0%,rgba(196,92,16,.18) 35%,#c45c10 100%);will-change:transform;animation:rpt-spin 1s linear infinite;-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));}
5216 @keyframes rpt-spin{to{transform:rotate(360deg);}}
5217 .rpt-spinner-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:17px;font-weight:800;color:#c45c10;font-variant-numeric:tabular-nums;}
5218 body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
5219 body.dark-theme .rpt-spinner-pct{color:#e8932f;}
5220 .rpt-load-divider{width:54px;height:1px;background:linear-gradient(90deg,transparent,rgba(196,92,16,.22),transparent);}
5221 .rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
5222 .rpt-load-word{background:linear-gradient(90deg,#9a7a64 0%,#c45c10 45%,#e08a3a 55%,#9a7a64 100%);background-size:220% auto;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;animation:rpt-text-shimmer 3.2s linear infinite;}
5223 @keyframes rpt-text-shimmer{to{background-position:-220% center;}}
5224 .rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
5225 .rpt-dot:nth-child(2){animation-delay:.28s;}
5226 .rpt-dot:nth-child(3){animation-delay:.56s;}
5227 @keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
5228 .rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
5229 .rpt-status-in{animation:rpt-status-pop .38s ease both;}
5230 @keyframes rpt-status-pop{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}
5231 .rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
5232 .rpt-progress-bar{height:100%;width:100%;transform:scaleX(0);transform-origin:left center;border-radius:99px;background:linear-gradient(90deg,#e8932f,#c45c10);transition:transform .25s cubic-bezier(.4,0,.2,1);will-change:transform;}
5233 body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
5234 .rpt-feed{width:100%;min-height:66px;display:flex;flex-direction:column;justify-content:flex-end;gap:3px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;color:rgba(138,112,96,.72);text-align:left;overflow:hidden;}
5235 .rpt-feed-line{display:flex;align-items:center;gap:6px;opacity:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;animation:rpt-feed-in .4s ease forwards;}
5236 .rpt-feed-line::before{content:'>';color:#c45c10;font-weight:700;}
5237 @keyframes rpt-feed-in{from{opacity:0;transform:translateX(-6px);}to{opacity:.82;transform:none;}}
5238 body.dark-theme .rpt-feed{color:rgba(204,172,150,.62);}
5239 body.dark-theme .rpt-load-divider{background:linear-gradient(90deg,transparent,rgba(196,92,16,.28),transparent);}
5240 @media (prefers-reduced-motion:reduce){ #rpt-loading-overlay .rpt-bg-blob,#rpt-loading-overlay .rpt-spinner,#rpt-loading-overlay .rpt-load-word,#rpt-loading-overlay .rpt-dot{animation:none!important;}}
5241 /* ── Code Style Analysis section ── */
5242 .style-guide-grid{display:grid;gap:10px;}
5243 .style-guide-row{display:grid;grid-template-columns:140px 1fr 52px;align-items:center;gap:10px;padding:6px 8px;border-radius:8px;cursor:default;position:relative;transition:transform .18s ease,box-shadow .18s ease,background .18s ease;}
5244 .style-guide-row:hover{transform:translateY(-2px);box-shadow:0 6px 22px rgba(77,44,20,0.18);background:var(--surface-2);}
5245 .style-guide-label{font-size:12px;font-weight:800;color:var(--text);text-align:right;white-space:nowrap;}
5246 .style-guide-track{background:var(--surface-3);border-radius:6px;height:20px;overflow:hidden;position:relative;box-shadow:inset 0 1px 3px rgba(0,0,0,.08);}
5247 .style-guide-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,var(--oxide),var(--oxide-2));transition:width .65s cubic-bezier(.25,.46,.45,.94),filter .18s ease;position:relative;}
5248 .style-guide-fill::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(255,255,255,.18) 0%,rgba(255,255,255,.04) 100%);border-radius:6px;}
5249 .style-guide-row:hover .style-guide-fill{filter:brightness(1.12);}
5250 .style-guide-score{font-size:12px;font-weight:800;color:var(--oxide);text-align:right;white-space:nowrap;}
5251 .style-guide-desc{font-size:10px;color:var(--muted);margin-top:2px;grid-column:2/3;}
5252 .style-bar-tip{position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 14px;border-radius:8px;font-size:11px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:300;box-shadow:0 4px 18px rgba(0,0,0,.24);}
5253 .style-bar-tip::after{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:var(--text);}
5254 .style-guide-row:hover .style-bar-tip{opacity:1;}
5255 .style-metrics-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:18px 0 0;}
5256 @media(max-width:800px){.style-metrics-strip{grid-template-columns:repeat(2,1fr);}}
5257 .style-chip{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px;text-align:center;cursor:default;position:relative;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}
5258 .style-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);}
5259 .style-chip-val{font-size:18px;font-weight:900;color:var(--oxide);}
5260 .style-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:3px;}
5261 .style-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;}
5262 .style-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
5263 .style-chip:hover .style-chip-tip{opacity:1;}
5264 .style-file-table{width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed;}
5265 .style-file-table th{background:var(--surface-3);padding:7px 10px;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);text-align:left;border-bottom:2px solid var(--line);cursor:pointer;user-select:none;white-space:nowrap;position:relative;}
5266 .style-file-table th:hover{background:var(--surface-2);color:var(--text);}
5267 .style-sort-ind{display:inline-block;margin-left:4px;font-size:9px;opacity:.4;vertical-align:middle;}
5268 .style-file-table th.sft-sort-asc .style-sort-ind,.style-file-table th.sft-sort-desc .style-sort-ind{opacity:1;color:var(--oxide);}
5269 .style-file-table td{padding:6px 10px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
5270 .style-file-table tr:hover td{background:var(--surface-2);}
5271 .style-score-bar{display:inline-block;width:48px;height:8px;border-radius:4px;background:var(--surface-3);vertical-align:middle;position:relative;margin-right:4px;}
5272 .style-score-fill{position:absolute;left:0;top:0;height:100%;border-radius:4px;background:linear-gradient(90deg,var(--oxide),var(--oxide-2));}
5273 .style-badge{display:inline-block;padding:2px 7px;border-radius:12px;font-size:10px;font-weight:700;background:var(--surface-3);color:var(--oxide);border:1px solid var(--line);text-decoration:none;transition:background .15s,transform .15s,box-shadow .15s;}
5274 a.style-badge:hover{background:var(--oxide);color:#fff !important;transform:translateY(-1px);box-shadow:0 3px 10px rgba(77,44,20,.24);}
5275 .style-heuristic-note{display:flex;align-items:flex-start;gap:10px;background:var(--info-bg);color:var(--info-text);border-radius:10px;padding:11px 14px;font-size:13px;line-height:1.5;margin-top:14px;border:1px solid rgba(68,103,216,0.18);}
5276 .style-lang-tabs{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px;}
5277 .style-lang-tab{padding:4px 12px;border-radius:14px;border:1px solid var(--line);background:var(--surface);font-size:11px;font-weight:700;cursor:pointer;color:var(--text);transition:background .15s;}
5278 .style-lang-tab:hover{background:var(--surface-2);}
5279 .style-lang-tab.active{background:var(--oxide);color:#fff;border-color:var(--oxide);}
5280 .style-sig-chip{display:inline-block;padding:1px 6px;border-radius:8px;font-size:10px;background:var(--surface-2);color:var(--muted);border:1px solid var(--line);margin-right:3px;cursor:default;}
5281 .style-row-warn td{background:rgba(178,48,48,0.06)!important;}
5282 .style-row-warn td:first-child{border-left:3px solid #b23030;}
5283 .style-sig-more{display:inline-block;padding:1px 6px;border-radius:8px;font-size:10px;background:transparent;color:var(--oxide);border:1px solid var(--oxide);margin-right:3px;font-weight:700;cursor:pointer;transition:background .15s,color .15s;}
5284 .style-sig-more:hover{background:var(--oxide);color:#fff;}
5285 .style-sig-info-btn{background:none;border:none;cursor:pointer;font-size:13px;color:var(--muted);padding:0 2px;line-height:1;vertical-align:middle;transition:color .15s;margin-left:4px;}
5286 .style-sig-info-btn:hover{color:var(--oxide);}
5287 .style-sig-pop{position:fixed;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px 14px;box-shadow:0 8px 24px rgba(0,0,0,.18);z-index:9999;min-width:200px;max-width:300px;font-size:12px;line-height:1.6;}
5288 .style-sig-pop-title{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;}
5289 .style-sig-pop-row{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line);}
5290 .style-sig-pop-row:last-child{border-bottom:none;}
5291 .style-sig-pop-key{color:var(--muted);font-weight:700;white-space:nowrap;flex-shrink:0;}
5292 .style-sig-pop-val{color:var(--text);}
5293 .style-sig-info-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:10000;display:flex;align-items:center;justify-content:center;}
5294 .style-sig-info-modal{background:var(--bg);border-radius:14px;padding:22px 26px;max-width:500px;width:92%;box-shadow:0 12px 40px rgba(0,0,0,.2);position:relative;max-height:80vh;overflow-y:auto;}
5295 .style-sig-info-close{position:absolute;top:12px;right:16px;background:none;border:none;cursor:pointer;font-size:20px;color:var(--muted);line-height:1;}
5296 .style-sig-info-close:hover{color:var(--text);}
5297 .style-sig-info-grid{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin-top:14px;font-size:13px;}
5298 .style-sig-info-name{color:var(--oxide);font-weight:700;padding:2px 0;}
5299 .style-sig-info-desc{color:var(--text);padding:2px 0;}
5300 body.dark-theme .style-guide-track{background:var(--surface-3);}
5301 body.dark-theme .style-chip{background:var(--surface-2);}
5302 body.dark-theme .style-file-table th{background:var(--surface-3);}
5303 body.dark-theme .style-heuristic-note{border-color:rgba(100,130,255,0.22);}
5304 body.dark-theme .style-lang-tab{background:var(--surface-2);color:var(--text);}
5305 body.dark-theme .style-lang-tab.active{background:var(--oxide);color:#fff;}
5306 body.dark-theme .style-sig-chip{background:var(--surface-3);color:var(--muted);}
5307 body.dark-theme .style-sig-pop{background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.4);}
5308 body.dark-theme .style-sig-info-modal{background:var(--surface);}
5309 .sig-tip{position:fixed;background:rgba(28,18,8,0.93);color:#f0ebe4;padding:9px 13px 15px 13px;border-radius:9px;font-size:12px;line-height:1.65;pointer-events:none;z-index:9998;opacity:0;transition:opacity .1s;box-shadow:0 4px 16px rgba(0,0,0,.35);display:none;min-width:160px;max-width:300px;}
5310 .sig-tip.visible{opacity:1;}
5311 .sig-tip::after{content:'';position:absolute;top:100%;left:var(--sig-tip-ax,50%);transform:translateX(-50%);border:7px solid transparent;border-top-color:rgba(28,18,8,0.93);}
5312 .sig-tip-hd{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:rgba(240,235,228,.5);margin-bottom:5px;}
5313 .sig-tip-row{display:flex;gap:8px;align-items:baseline;}
5314 .sig-tip-k{color:#e07b3a;font-weight:700;white-space:nowrap;flex-shrink:0;}
5315 .sig-tip-v{color:#f0ebe4;}
5316</style>
5317<script nonce="{{ nonce }}">{{ chart_js|safe }}</script>
5318</head>
5319<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
5320 <div id="rpt-loading-overlay" aria-live="polite" aria-label="Loading report">
5321 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
5322 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
5323 <div class="rpt-bg-blob rpt-blob-c" aria-hidden="true"></div>
5324 <div class="rpt-load-card">
5325 <img src="{{ small_logo_uri }}" alt="oxide-sloc" class="rpt-load-logo" />
5326 <div class="rpt-spinner-wrap">
5327 <div class="rpt-spinner-track"></div>
5328 <div class="rpt-spinner"></div>
5329 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
5330 </div>
5331 <div class="rpt-load-divider"></div>
5332 <div class="rpt-loading-text"><span class="rpt-load-word">Loading report</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span></div>
5333 <div class="rpt-status" id="rpt-status">Initializing analysis engine</div>
5334 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
5335 <div class="rpt-feed" id="rpt-feed" aria-hidden="true"></div>
5336 </div>
5337 </div>
5338 <script nonce="{{ nonce }}">
5339 (function(){
5340 var ov=document.getElementById('rpt-loading-overlay');if(!ov)return;
5341 var statusEl=document.getElementById('rpt-status'),bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),feed=document.getElementById('rpt-feed');
5342 var msgs=['Initializing analysis engine','Discovering source files','Detecting languages','Tokenizing and counting lines','Classifying comments and docstrings','Computing complexity metrics','Aggregating per-language totals','Estimating COCOMO effort','Rendering charts','Finalizing report'];
5343 var logs=['scan: walking directory tree','lexer: state machine warm','metrics: SLOC and ULOC ready','cocomo: effort model loaded','charts: canvas contexts bound','render: assembling sections','dedup: hashing file contents','git: reading activity window'];
5344 var mi=0,li=0,prog=0,ready=false,start=Date.now(),MIN=1700;
5345 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
5346 function nextMsg(){if(statusEl){statusEl.classList.remove('rpt-status-in');void statusEl.offsetWidth;statusEl.textContent=msgs[mi%msgs.length];statusEl.classList.add('rpt-status-in');}mi++;}
5347 function addLog(){if(!feed)return;var l=document.createElement('div');l.className='rpt-feed-line';l.textContent=logs[li%logs.length];feed.appendChild(l);li++;while(feed.childNodes.length>4)feed.removeChild(feed.firstChild);}
5348 nextMsg();addLog();setProg(6);
5349 var msgTimer=setInterval(nextMsg,900),logTimer=setInterval(addLog,640),progTimer=setInterval(function(){var cap=ready?100:90;if(prog<cap){var step=(cap-prog)*0.08+0.6;setProg(Math.min(cap,prog+step));}},80);
5350 function done(){clearInterval(msgTimer);clearInterval(logTimer);clearInterval(progTimer);setProg(100);if(statusEl){statusEl.textContent='Done';statusEl.classList.add('rpt-status-in');}setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},600);},260);}
5351 window.__rptFinish=function(){ready=true;setTimeout(done,Math.max(0,MIN-(Date.now()-start)));};
5352 })();
5353 </script>
5354 <div class="background-watermarks" aria-hidden="true">
5355 <img src="{{ logo_text_uri }}" alt="" />
5356 <img src="{{ logo_text_uri }}" alt="" />
5357 <img src="{{ logo_text_uri }}" alt="" />
5358 <img src="{{ logo_text_uri }}" alt="" />
5359 <img src="{{ logo_text_uri }}" alt="" />
5360 <img src="{{ logo_text_uri }}" alt="" />
5361 <img src="{{ logo_text_uri }}" alt="" />
5362 <img src="{{ logo_text_uri }}" alt="" />
5363 <img src="{{ logo_text_uri }}" alt="" />
5364 <img src="{{ logo_text_uri }}" alt="" />
5365 <img src="{{ logo_text_uri }}" alt="" />
5366 <img src="{{ logo_text_uri }}" alt="" />
5367 </div>
5368 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
5369 {% if let Some(banner) = report_header_footer %}
5370 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
5371 {% endif %}
5372 <div class="top-nav">
5373 <div class="top-nav-inner">
5374 <a class="brand" href="/" data-local-brand="1">
5375 {% if let Some(uri) = custom_logo_uri %}
5376 <img class="brand-logo" src="{{ uri }}" alt="logo" />
5377 {% else %}
5378 <img class="brand-logo" src="{{ small_logo_uri }}" alt="OxideSLOC logo" />
5379 {% endif %}
5380 <div class="brand-copy">
5381 {% if let Some(name) = company_name %}
5382 <div class="brand-title">{{ name }}</div>
5383 {% else %}
5384 <div class="brand-title">OxideSLOC</div>
5385 {% endif %}
5386 <div class="brand-subtitle">Saved HTML report</div>
5387 </div>
5388 </a>
5389 <div class="nav-project-slot">
5390 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ title }}</span></div>
5391 </div>
5392 <div class="nav-status">
5393 <button type="button" class="header-button" data-copy-link>Copy link</button>
5394 <button type="button" class="header-button" data-share-report>Share</button>
5395 <div class="nav-dropdown-wrap">
5396 <button type="button" class="header-button nav-dropdown-trigger" aria-haspopup="true">Export ▾</button>
5397 <div class="nav-dropdown-menu">
5398 <button type="button" class="nav-dropdown-item" data-export-csv>Export CSV</button>
5399 <button type="button" class="nav-dropdown-item" data-export-xls>Export Excel</button>
5400 </div>
5401 </div>
5402 <a id="nav-view-pdf-btn" href="/runs/pdf/{{ run.tool.run_id }}" target="_blank" rel="noopener" class="header-button" style="text-decoration:none;"{% if let Some(purl) = standalone_pdf_url %} data-standalone-pdf="{{ purl }}"{% endif %}>View PDF</a>
5403 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
5404 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
5405 </button>
5406 <button type="button" class="theme-toggle" data-theme-toggle aria-label="Toggle theme" title="Toggle theme">
5407 <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>
5408 <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>
5409 </button>
5410 </div>
5411 </div>
5412 </div>
5413
5414 <div class="page">
5415 <section class="hero panel">
5416 <div class="hero-top">
5417 <div>
5418 <div class="section-kicker">Saved report artifact</div>
5419 <div style="display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;">
5420 <h1>{{ title }}</h1>
5421 <span class="run-id-short-badge" title="Short run ID \u2014 matches the ID shown in View Reports">{{ run_id_short }}</span>
5422 </div>
5423 </div>
5424 </div>
5425 <div class="run-id-row">
5426 <span class="run-id-chip" data-copy="{{ run.tool.run_id }}">
5427 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>Run ID</span>
5428 <span class="run-id-chip-value">{{ run.tool.run_id }}</span>
5429 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
5430 </span>
5431 {% if let Some(long_commit) = run.git_commit_long %}
5432 {% if let Some(commit_url) = git_commit_url %}
5433 <a class="run-id-chip run-id-chip-link" href="{{ commit_url }}" target="_blank" rel="noopener noreferrer" title="Open commit in source control">
5434 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit<svg class="chip-popout-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
5435 <span class="run-id-chip-value">{{ long_commit }}</span>
5436 <span class="chip-tooltip">Opens commit in source control — new tab</span>
5437 </a>
5438 {% else %}
5439 <span class="run-id-chip" data-copy="{{ long_commit }}">
5440 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
5441 <span class="run-id-chip-value">{{ long_commit }}</span>
5442 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
5443 </span>
5444 {% endif %}
5445 {% else %}
5446 <span class="run-id-chip muted-chip">
5447 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
5448 <span class="run-id-chip-value">Not detected</span>
5449 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
5450 </span>
5451 {% endif %}
5452 {% if let Some(branch) = run.git_branch %}
5453 {% if let Some(branch_url) = git_branch_url %}
5454 <a class="run-id-chip run-id-chip-link" href="{{ branch_url }}" target="_blank" rel="noopener noreferrer" title="Open branch in source control">
5455 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch<svg class="chip-popout-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
5456 <span class="run-id-chip-value">{{ branch }}</span>
5457 <span class="chip-tooltip">Opens branch in source control — new tab</span>
5458 </a>
5459 {% else %}
5460 <span class="run-id-chip">
5461 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
5462 <span class="run-id-chip-value">{{ branch }}</span>
5463 <span class="chip-tooltip">Git branch scanned for this report</span>
5464 </span>
5465 {% endif %}
5466 {% else %}
5467 {% if is_sub_report %}
5468 <span class="run-id-chip">
5469 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>Submodule</span>
5470 <span class="run-id-chip-value"><span class="submodule-state-badge">detached HEAD</span></span>
5471 <span class="chip-tooltip">Submodules are pinned to a specific commit — no branch ref</span>
5472 </span>
5473 {% else %}
5474 <span class="run-id-chip muted-chip">
5475 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
5476 <span class="run-id-chip-value">Not detected</span>
5477 <span class="chip-tooltip">No Git branch was found for this scan</span>
5478 </span>
5479 {% endif %}
5480 {% endif %}
5481 {% if let Some(author) = run.git_commit_author %}
5482 <span class="run-id-chip" data-author="{{ author }}">
5483 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
5484 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
5485 <span class="chip-tooltip">Author of the most recent commit in this repository</span>
5486 </span>
5487 {% else %}
5488 <span class="run-id-chip muted-chip">
5489 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
5490 <span class="run-id-chip-value">Not detected</span>
5491 <span class="chip-tooltip">No commit author was found for this scan</span>
5492 </span>
5493 {% endif %}
5494 </div>
5495
5496 <div class="meta">
5497 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
5498 <span class="meta-chip">Scanned <b>{{ scan_time_pst }}</b></span>
5499 <span class="meta-chip">OS <b>{{ run.environment.operating_system }} / {{ run.environment.architecture }}</b></span>
5500 <span class="meta-chip">Files analyzed <b>{{ run.summary_totals.files_analyzed }}</b></span>
5501 <span class="meta-chip">Files skipped <b>{{ run.summary_totals.files_skipped }}</b></span>
5502 </div>
5503
5504 {% if has_delta %}
5505 <div class="prev-scan-banner" aria-label="Changes vs. previous scan">
5506 <div class="prev-scan-banner-top">
5507 <div class="prev-scan-meta">
5508 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
5509 {% if prev_run_id != "" %}<a href="/runs/html/{{ prev_run_id }}" target="_blank" rel="noopener" style="color:inherit;text-decoration:none;font-weight:700;">PREVIOUS SCAN</a>{% else %}<strong>PREVIOUS SCAN</strong>{% endif %}
5510 <span class="prev-scan-ts">{{ prev_scan_label }}</span>
5511 {% if prev_scan_count > 0 %}
5512 <span class="prev-scan-count">· {{ prev_scan_count }} scan{% if prev_scan_count != 1 %}s{% endif %} total</span>
5513 {% endif %}
5514 </div>
5515 <div class="prev-scan-summary">
5516 Code before: <b data-raw="{{ prev_code_lines }}">{{ prev_code_lines }}</b>
5517 →
5518 Code now: <b data-raw="{{ run.summary_totals.code_lines }}">{{ run.summary_totals.code_lines }}</b>
5519 ·
5520 <span class="{% if delta_code_added > 0 %}delta-up{% else %}delta-neutral-text{% endif %}">+<span data-raw="{{ delta_code_added }}">{{ delta_code_added }}</span> added</span>
5521
5522 <span class="{% if delta_code_removed > 0 %}delta-down{% else %}delta-neutral-text{% endif %}">−<span data-raw="{{ delta_code_removed }}">{{ delta_code_removed }}</span> removed</span>
5523 </div>
5524 </div>
5525 <div class="delta-card-row">
5526 <div class="delta-card-inline {% if delta_code_added > 0 %}pos{% endif %}">
5527 <div class="delta-card-val pos">+{{ delta_code_added|commas }}</div>
5528 <div class="delta-card-lbl">Lines added</div>
5529 <div class="delta-card-tip">Code lines added since {{ prev_scan_label }}</div>
5530 </div>
5531 <div class="delta-card-inline {% if delta_code_removed > 0 %}neg{% endif %}">
5532 <div class="delta-card-val neg">−{{ delta_code_removed|commas }}</div>
5533 <div class="delta-card-lbl">Lines removed</div>
5534 <div class="delta-card-tip">Code lines removed since {{ prev_scan_label }}</div>
5535 </div>
5536 <div class="delta-card-inline">
5537 <div class="delta-card-val">{{ delta_unmodified_lines|commas }}</div>
5538 <div class="delta-card-lbl">Unmodified lines</div>
5539 <div class="delta-card-tip">Code lines unchanged since {{ prev_scan_label }}</div>
5540 </div>
5541 <div class="delta-card-inline {% if delta_files_modified > 0 %}mod{% endif %}">
5542 <div class="delta-card-val mod">{{ delta_files_modified|commas }}</div>
5543 <div class="delta-card-lbl">Files modified</div>
5544 <div class="delta-card-tip">Files with at least one line changed</div>
5545 </div>
5546 <div class="delta-card-inline {% if delta_files_added > 0 %}pos{% endif %}">
5547 <div class="delta-card-val pos">{{ delta_files_added|commas }}</div>
5548 <div class="delta-card-lbl">Files added</div>
5549 <div class="delta-card-tip">New files added since {{ prev_scan_label }}</div>
5550 </div>
5551 <div class="delta-card-inline {% if delta_files_removed > 0 %}neg{% endif %}">
5552 <div class="delta-card-val neg">{{ delta_files_removed|commas }}</div>
5553 <div class="delta-card-lbl">Files removed</div>
5554 <div class="delta-card-tip">Files deleted since {{ prev_scan_label }}</div>
5555 </div>
5556 <div class="delta-card-inline">
5557 <div class="delta-card-val">{{ delta_files_unchanged|commas }}</div>
5558 <div class="delta-card-lbl">Files unchanged</div>
5559 <div class="delta-card-tip">Files with no changes since {{ prev_scan_label }}</div>
5560 </div>
5561 </div>
5562 </div>
5563 {% else %}
5564 <div class="prev-scan-banner prev-scan-banner-empty" aria-label="No previous scan">
5565 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
5566 No previous scan found for this project — this report is the baseline.
5567 </div>
5568 {% endif %}
5569
5570 <div class="summary-grid">
5571 <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>
5572 <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>
5573 <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>
5574 <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>
5575 <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>
5576 <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>
5577 <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>
5578 <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>
5579 <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>
5580 <div class="metric" data-metric-value="{{ run.summary_totals.test_count }}"><div class="metric-tooltip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div><div class="metric-label">Tests</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5581 <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>
5582 <div class="metric" data-metric-value="{{ run.summary_totals.files_analyzed }}"><div class="metric-tooltip">Total number of source files included in this analysis.</div><div class="metric-label">Files analyzed</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5583 {% if run.summary_totals.cyclomatic_complexity > 0 %}<div class="metric" data-metric-value="{{ run.summary_totals.cyclomatic_complexity }}"><div class="metric-tooltip">Sum of branch decision keywords (if, for, while, ||, &&, …) across all code lines. Approximates total McCabe cyclomatic complexity.</div><div class="metric-label">Complexity score</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
5584 {% if let Some(lsloc) = run.summary_totals.lsloc %}<div class="metric" data-metric-value="{{ lsloc }}"><div class="metric-tooltip">Logical SLOC: count of executable statements (semicolons for C-family; non-continuation lines for Python/Ruby/Shell). Normalises across coding styles.</div><div class="metric-label">Logical SLOC</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
5585 {% if uloc > 0 %}<div class="metric" data-metric-value="{{ uloc }}"><div class="metric-tooltip">Unique Lines of Code: distinct non-blank code lines across all files. Counts each line once regardless of how many files it appears in.</div><div class="metric-label">Unique SLOC (ULOC)</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
5586 {% if uloc > 0 && dryness_pct_str != "" %}<div class="metric"><div class="metric-tooltip">ULOC ÷ Code Lines — the fraction of code lines that are unique. Higher = less copy-paste across the codebase. 100% means every code line is distinct.</div><div class="metric-label">DRYness</div><div class="metric-value"><span class="metric-big">{{ dryness_pct_str }}%</span></div></div>{% endif %}
5587 {% if duplicate_group_count > 0 %}<div class="metric" data-metric-value="{{ duplicate_group_count }}"><div class="metric-tooltip">Groups of files with identical content detected. These may inflate SLOC totals. Re-run with --no-duplicates to exclude them.</div><div class="metric-label">Duplicate groups</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>{% endif %}
5588 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
5589 odd, so the strip always has an even number of cards that fill exactly
5590 two aligned rows (no oversized card, no empty trailing cell). -->
5591 <div class="metric metric-pad" data-metric-value="{{ run.summary_totals.test_assertion_count }}" style="display:none"><div class="metric-tooltip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div><div class="metric-label">Assertions</div><div class="metric-value"><span class="metric-big"></span></div><span class="metric-exact"></span></div>
5592 </div>
5593 </section>
5594
5595 <!-- ── PDF-only pre-rendered chart variants (hidden on screen) ─────── -->
5596 <div id="pdf-variants" class="pdf-variants-root"></div>
5597
5598 <div class="report-stack">
5599 <!-- ── Chart row 1: Overview + Composition ───────────────────────── -->
5600 <div class="charts-grid">
5601 <section class="panel stack chart-section">
5602 <div>
5603 <div class="toolbar">
5604 <div class="toolbar-left"><h2>Project Overview</h2></div>
5605 <button class="chart-expand-btn" id="overview-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5606 </div>
5607 <div class="chart-pre">
5608 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;line-height:1.6;">A configurable cartesian view of your codebase. Choose what to show on each axis.</p>
5609 <div class="chart-controls">
5610 <label>Y Axis:
5611 <select class="chart-select" id="overview-y-axis">
5612 <option value="code">Code Lines</option>
5613 <option value="comments">Comment Lines</option>
5614 <option value="blanks">Blank Lines</option>
5615 <option value="physical">Total Physical Lines</option>
5616 <option value="files">File Count</option>
5617 </select>
5618 </label>
5619 <label>X Axis / Mode:
5620 <select class="chart-select" id="overview-x-mode">
5621 <option value="languages">Languages</option>
5622 {% if has_submodule_data %}<option value="submodules">Submodules</option>{% endif %}
5623 <option value="history-commits">Per Commit (Web UI)</option>
5624 <option value="history-tags">Per Tag (Web UI)</option>
5625 <option value="history-releases">Per Release (Web UI)</option>
5626 <option value="history-repos">Other Repos (Web UI)</option>
5627 </select>
5628 </label>
5629 </div>
5630 </div>
5631 <div id="overview-chart" class="chart-container"><div id="canvas-proj-wrap" style="position:relative;min-height:150px;"><canvas id="canvas-proj"></canvas></div></div>
5632 <div class="chart-locked-card" id="overview-chart-locked">
5633 <h3>Historical trend requires the web UI</h3>
5634 <p style="margin:0">Run <code>oxide-sloc serve</code> and navigate to <strong>/trend-reports</strong> to view per-commit, per-tag, per-release, and cross-repo comparisons on an interactive timeline chart. The web UI stores scan history and can plot any metric over time.</p>
5635 </div>
5636 </div>
5637 </section>
5638
5639 <section class="panel stack chart-section">
5640 <div>
5641 <div class="toolbar">
5642 <div class="toolbar-left"><h2>Language Composition</h2></div>
5643 <button class="chart-expand-btn" id="comp-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5644 </div>
5645 <div class="chart-pre">
5646 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Code, comments, and blank lines as a percentage of total physical lines per language.</p>
5647 <div class="chart-tab-bar">
5648 <button type="button" class="chart-tab active" data-comp-tab="absolute">Absolute Lines</button>
5649 <button type="button" class="chart-tab" data-comp-tab="pct">Composition %</button>
5650 </div>
5651 </div>
5652 <div id="composition-chart" class="chart-container" style="overflow:hidden;display:flex;align-items:center;justify-content:center;"><div id="comp-svg-container" style="width:100%;"></div></div>
5653 </div>
5654 </section>
5655 </div>
5656
5657 <!-- ── Chart row 2: Scatter + Semantic ───────────────────────────── -->
5658 <div class="charts-grid">
5659 <section class="panel stack chart-section">
5660 <div>
5661 <div class="toolbar">
5662 <div class="toolbar-left"><h2>Files vs Code Lines</h2></div>
5663 <button class="chart-expand-btn" id="scatter-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5664 </div>
5665 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Each bubble is a language. X = files analyzed, Y = code lines, bubble size ∝ total physical lines.</p>
5666 <div id="scatter-chart" class="chart-container" style="position:relative;height:224px;"><canvas id="canvas-scatter"></canvas></div>
5667 </div>
5668 </section>
5669
5670 <section class="panel stack chart-section">
5671 <div>
5672 <div class="toolbar">
5673 <div class="toolbar-left"><h2>Semantic Metrics</h2></div>
5674 {% if has_semantic_data %}
5675 <button class="chart-expand-btn" id="semantic-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5676 {% endif %}
5677 </div>
5678 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Detected structural elements per language. Select a metric to explore.</p>
5679 {% if has_semantic_data %}
5680 <div class="chart-controls">
5681 <label>Metric:
5682 <select class="chart-select" id="semantic-metric">
5683 <option value="functions">Functions</option>
5684 <option value="classes">Classes / Types</option>
5685 <option value="variables">Variables</option>
5686 <option value="imports">Imports</option>
5687 <option value="tests">Tests</option>
5688 </select>
5689 </label>
5690 </div>
5691 <div id="semantic-chart" class="chart-container" style="position:relative;height:234px;"><canvas id="canvas-semantic"></canvas></div>
5692 {% else %}
5693 <div style="display:flex;align-items:center;justify-content:center;height:200px;color:var(--muted);font-size:13px;text-align:center;line-height:1.6;">
5694 <div>No structural metrics detected for this scan.<br>Semantic analysis covers languages with function/class detection<br>(e.g., Go, Python, Rust, Java, C++).</div>
5695 </div>
5696 {% endif %}
5697 </div>
5698 </section>
5699 <section class="panel stack chart-section">
5700 <div>
5701 <div class="toolbar">
5702 <div class="toolbar-left"><h2>Comment Density</h2></div>
5703 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5704 </div>
5705 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Comments as a percentage of significant lines (code + comments) per language — a proxy for documentation coverage.</p>
5706 <div id="density-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-density"></canvas></div>
5707 </div>
5708 </section>
5709
5710 <section class="panel stack chart-section">
5711 <div>
5712 <div class="toolbar">
5713 <div class="toolbar-left"><h2>File Size Distribution</h2></div>
5714 <button class="chart-expand-btn" id="filesize-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5715 </div>
5716 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Number of files in each SLOC bucket — a quick view of whether the codebase favours small focused modules or large files.</p>
5717 <div id="filesize-chart" class="chart-container" style="position:relative;min-height:150px;"><canvas id="canvas-filesize"></canvas></div>
5718 </div>
5719 </section>
5720 </div>
5721
5722 <!-- ── Tests & Coverage ──────────────────────────────────────────── -->
5723 <section class="panel stack">
5724 <div>
5725 <div class="toolbar">
5726 <div class="toolbar-left"><h2>Tests & Coverage</h2></div>
5727 {% if has_coverage_data %}<div class="pill-row"><span class="pill good">LCOV coverage data present</span></div>{% endif %}
5728 </div>
5729 <div class="summary-strip">
5730 <div class="stat-chip">
5731 <div class="stat-chip-val" data-fmt="{{ run.summary_totals.test_count }}">{{ run.summary_totals.test_count|commas }}</div>
5732 <div class="stat-chip-label">Test Functions</div>
5733 <div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div>
5734 <span class="stat-chip-exact">{{ run.summary_totals.test_count|commas }}</span>
5735 </div>
5736 <div class="stat-chip">
5737 <div class="stat-chip-val" data-fmt="{{ test_assertion_count }}">{{ test_assertion_count|commas }}</div>
5738 <div class="stat-chip-label">Assertions</div>
5739 <div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div>
5740 <span class="stat-chip-exact">{{ test_assertion_count|commas }}</span>
5741 </div>
5742 <div class="stat-chip">
5743 <div class="stat-chip-val" data-fmt="{{ test_suite_count }}">{{ test_suite_count|commas }}</div>
5744 <div class="stat-chip-label">Test Suites</div>
5745 <div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div>
5746 <span class="stat-chip-exact">{{ test_suite_count|commas }}</span>
5747 </div>
5748 <div class="stat-chip">
5749 <div class="stat-chip-val">{{ test_files_count|commas }} / {{ run.summary_totals.files_analyzed|commas }}</div>
5750 <div class="stat-chip-label">Test Files</div>
5751 <div class="stat-chip-tip">Files containing at least one detected test definition out of total analyzed files</div>
5752 </div>
5753 </div>
5754 <div class="summary-strip" style="margin-top:0;">
5755 <div class="stat-chip">
5756 <div class="stat-chip-val">{{ test_density }}</div>
5757 <div class="stat-chip-label">Tests per 1K SLOC</div>
5758 <div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div>
5759 </div>
5760 <div class="stat-chip">
5761 <div class="stat-chip-val" style="font-size:15px;word-break:break-word;line-height:1.2;">{{ most_tested_lang }}</div>
5762 <div class="stat-chip-label">Most Tested Language</div>
5763 <div class="stat-chip-tip">Language with the highest absolute test function count</div>
5764 </div>
5765 <div class="stat-chip">
5766 <div class="stat-chip-val">{{ langs_with_tests }}</div>
5767 <div class="stat-chip-label">Languages with Tests</div>
5768 <div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div>
5769 </div>
5770 <div class="stat-chip">
5771 {% if has_coverage_data %}<div class="stat-chip-val">{{ cov_line_pct }}%</div>{% else %}<div class="stat-chip-val" style="color:var(--muted);">—</div>{% endif %}
5772 <div class="stat-chip-label">Line Coverage</div>
5773 <div class="stat-chip-tip">Overall line coverage from LCOV data — run with --lcov-path to populate</div>
5774 </div>
5775 </div>
5776 {% if has_coverage_data %}
5777 <div class="cov-gauge-row">
5778 <div class="cov-gauge-card">
5779 <div class="cov-gauge-label">Line Coverage</div>
5780 <div class="cov-gauge-val" style="color:var(--{{ cov_line_class }}-text);">{{ cov_line_pct }}%</div>
5781 <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_line_pct }}%;background:var(--{{ cov_line_class }}-text);"></div></div>
5782 <div class="cov-gauge-sub">Lines hit / instrumented</div>
5783 </div>
5784 {% if has_fn_coverage %}
5785 <div class="cov-gauge-card">
5786 <div class="cov-gauge-label">Function Coverage</div>
5787 <div class="cov-gauge-val" style="color:var(--{{ cov_fn_class }}-text);">{{ cov_fn_pct }}%</div>
5788 <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_fn_pct }}%;background:var(--{{ cov_fn_class }}-text);"></div></div>
5789 <div class="cov-gauge-sub">Functions hit / found</div>
5790 </div>
5791 {% endif %}
5792 {% if has_branch_coverage %}
5793 <div class="cov-gauge-card">
5794 <div class="cov-gauge-label">Branch Coverage</div>
5795 <div class="cov-gauge-val" style="color:var(--{{ cov_branch_class }}-text);">{{ cov_branch_pct }}%</div>
5796 <div class="cov-gauge-track"><div class="cov-gauge-fill" style="width:{{ cov_branch_pct }}%;background:var(--{{ cov_branch_class }}-text);"></div></div>
5797 <div class="cov-gauge-sub">Branches hit / found</div>
5798 </div>
5799 {% endif %}
5800 </div>
5801 {% endif %}
5802 <div class="table-shell" style="margin-top:16px;">
5803 <table data-sort-table style="min-width:560px;">
5804 <thead>
5805 <tr>
5806 <th data-sort-type="text">Language</th>
5807 <th data-sort-type="number">Test Fns</th>
5808 <th data-sort-type="number">Assertions</th>
5809 <th data-sort-type="number">Suites</th>
5810 <th data-sort-type="text">Density (per 1K SLOC)</th>
5811 </tr>
5812 </thead>
5813 <tbody>
5814 {% for row in language_rows %}
5815 {% if row.test_count > 0 || row.test_assertion_count > 0 %}
5816 <tr>
5817 <td>{{ row.language }}</td>
5818 <td>{{ row.test_count|commas }}</td>
5819 <td>{{ row.test_assertion_count|commas }}</td>
5820 <td>{{ row.test_suite_count|commas }}</td>
5821 <td>{{ row.test_density_str }}</td>
5822 </tr>
5823 {% endif %}
5824 {% endfor %}
5825 {% if run.summary_totals.test_count == 0 && test_assertion_count == 0 %}
5826 <tr class="empty-state-row"><td colspan="5">No test functions or assertions detected in this scan</td></tr>
5827 {% endif %}
5828 </tbody>
5829 </table>
5830 </div>
5831 {% if has_coverage_data %}
5832 <div class="table-shell" style="margin-top:16px;">
5833 <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
5834 <h3 style="margin:0;font-size:14px;font-weight:800;color:var(--text);">Per-File Coverage</h3>
5835 <span class="pill good" style="font-size:10px;">{{ file_rows.len() }} files with data</span>
5836 </div>
5837 <table data-sort-table style="min-width:560px;">
5838 <thead>
5839 <tr>
5840 <th data-sort-type="text">File</th>
5841 <th data-sort-type="number">Line Cov %</th>
5842 <th data-sort-type="text">Lines Hit / Found</th>
5843 {% if has_fn_coverage %}<th data-sort-type="number">Fn Cov %</th>{% endif %}
5844 {% if has_branch_coverage %}<th data-sort-type="number">Branch Cov %</th>{% endif %}
5845 </tr>
5846 </thead>
5847 <tbody>
5848 {% for row in file_rows %}
5849 {% if !row.line_cov_pct.is_empty() %}
5850 <tr>
5851 <td class="mono" style="font-size:11px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
5852 <td class="num-col">{{ row.line_cov_pct }}%</td>
5853 <td class="num-col" style="font-size:11px;color:var(--muted);">{{ row.cov_lines_detail }}</td>
5854 {% if has_fn_coverage %}<td class="num-col">{% if !row.fn_cov_pct.is_empty() %}{{ row.fn_cov_pct }}%{% else %}—{% endif %}</td>{% endif %}
5855 {% if has_branch_coverage %}<td class="num-col">{% if !row.branch_cov_pct.is_empty() %}{{ row.branch_cov_pct }}%{% else %}—{% endif %}</td>{% endif %}
5856 </tr>
5857 {% endif %}
5858 {% endfor %}
5859 {% if file_rows.is_empty() %}
5860 <tr class="empty-state-row"><td colspan="5">No per-file coverage data available</td></tr>
5861 {% endif %}
5862 </tbody>
5863 </table>
5864 </div>
5865 {% else %}
5866 <div class="info-callout">
5867 <span class="info-callout-icon">ℹ️</span>
5868 <span>No code coverage detected. Re-run with <code>--lcov-path coverage.info</code> to see line, function, and branch coverage here.</span>
5869 </div>
5870 {% endif %}
5871 </div>
5872 </section>
5873
5874 <!-- ── Multi-Language Code Style Analysis ───────────────────────── -->
5875 {% if has_style_data %}
5876 {% if let Some(ss) = style_summary %}
5877 <section class="panel stack">
5878 <div>
5879 <div class="toolbar">
5880 <div class="toolbar-left"><h2>Code Style Analysis</h2></div>
5881 <div class="pill-row"><span class="pill info">{{ style_lang_count }} language group(s) · Lexical heuristics</span></div>
5882 </div>
5883 <div class="style-heuristic-note">
5884 <span class="info-callout-icon">ℹ️</span>
5885 <span>Scores are lexical approximations based on indentation, line length, brace placement, and language-specific signals — not a full parse. Use as a directional signal.</span>
5886 </div>
5887 <!-- Summary chips -->
5888 <div class="style-metrics-strip">
5889 <div class="style-chip">
5890 <div class="style-chip-val">{{ ss.files_analyzed }}</div>
5891 <div class="style-chip-label">Files Analyzed</div>
5892 <div class="style-chip-tip">Total files with style data</div>
5893 </div>
5894 <div class="style-chip">
5895 <div class="style-chip-val">{{ style_lang_count }}</div>
5896 <div class="style-chip-label">Language Groups</div>
5897 <div class="style-chip-tip">Distinct language families detected</div>
5898 </div>
5899 <div class="style-chip">
5900 <div class="style-chip-val">{{ ss.common_indent_style }}</div>
5901 <div class="style-chip-label">Common Indent</div>
5902 <div class="style-chip-tip">Most prevalent indentation across all files</div>
5903 </div>
5904 <div class="style-chip">
5905 <div class="style-chip-val">{{ ss.line_col_compliant_pct }}%</div>
5906 <div class="style-chip-label">{{ ss.col_threshold }}-Col Compliant</div>
5907 <div class="style-chip-tip">Files where ≤5% of lines exceed {{ ss.col_threshold }} chars</div>
5908 </div>
5909 </div>
5910 <!-- Language selector tab strip -->
5911 <div style="margin-top:20px;">
5912 <div style="font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:10px;">Style Guide Adherence by Language</div>
5913 <div id="style-lang-tabs" class="style-lang-tabs"></div>
5914 <div class="style-guide-grid" id="style-guide-bars"></div>
5915 </div>
5916 <!-- Per-file style table -->
5917 <div style="margin-top:22px;">
5918 <div class="toolbar" style="margin-bottom:8px;">
5919 <div class="toolbar-left">
5920 <span style="font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);">Per-File Style Details</span>
5921 <input id="sft-search" class="search" type="search" placeholder="Filter files, languages, guides..." style="margin-left:12px;" />
5922 <div class="page-size-row"><label class="page-size-label" for="sft-page-size">Show:</label><select id="sft-page-size" class="page-size-select"><option value="20" selected>20</option><option value="50">50</option><option value="100">100</option><option value="all">All</option></select><span id="sft-count-label" class="page-count-label"></span></div>
5923 </div>
5924 </div>
5925 <div class="table-scroll-wrap">
5926 <table class="style-file-table" id="style-file-table">
5927 <thead>
5928 <tr>
5929 <th data-sort-key="path" style="width:35%;" title="File path relative to the scanned root. Click to sort alphabetically.">File <span class="style-sort-ind">▾</span></th>
5930 <th data-sort-key="lang" style="width:10%;" title="Programming language detected for this file. Click to sort.">Language <span class="style-sort-ind">▾</span></th>
5931 <th data-sort-key="indent" style="width:12%;" title="Dominant indentation style detected: Tabs, 2-Space, 4-Space, 8-Space, Mixed, or Unknown. Click to sort.">Indent <span class="style-sort-ind">▾</span></th>
5932 <th data-sort-key="guide" style="width:20%;" title="Style guide with the highest lexical-adherence score for this file. Click a badge to open the official guide documentation. Click header to sort.">Best Match Guide <span class="style-sort-ind">▾</span></th>
5933 <th data-sort-key="score" style="width:10%;" title="Adherence score (0-100%) for the best-matching style guide. Higher = closer match to that guide's conventions. Lexical heuristic only \u2014 not a full parse. Click to sort.">Score <span class="style-sort-ind">▾</span></th>
5934 <th style="width:13%;" title="Hover a row to see all signals \u2014 signal name and detected value.">Signals</th>
5935 </tr>
5936 </thead>
5937 <tbody id="style-file-tbody">
5938 <tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">Loading...</td></tr>
5939 </tbody>
5940 </table>
5941 </div>
5942 <div id="sft-pagination" class="pagination-bar">
5943 <button id="sft-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
5944 <button id="sft-prev" class="pager-btn" disabled>← Prev</button>
5945 <span class="pager-jump-wrap">Page <input id="sft-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="sft-page-total">—</span></span>
5946 <span id="sft-page-info" class="pager-info"></span>
5947 <button id="sft-next" class="pager-btn">Next →</button>
5948 <button id="sft-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
5949 </div>
5950 </div>
5951 </div>
5952 </section>
5953 {% endif %}
5954 {% endif %}
5955
5956 <!-- ── Submodule Breakdown (2-column, conditional) ─────────────── -->
5957 {% if has_submodule_data %}
5958 <div class="charts-grid">
5959 <section class="panel stack chart-section">
5960 <div>
5961 <div class="toolbar">
5962 <div class="toolbar-left"><h2>Submodule Breakdown</h2></div>
5963 <button class="chart-expand-btn" id="sub-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5964 </div>
5965 <div class="chart-controls">
5966 <label>Y Axis:
5967 <select class="chart-select" id="sub-y-axis">
5968 <option value="code">Code Lines</option>
5969 <option value="comment">Comment Lines</option>
5970 <option value="blank">Blank Lines</option>
5971 <option value="physical">Total Physical Lines</option>
5972 <option value="files">File Count</option>
5973 </select>
5974 </label>
5975 <label>Sort:
5976 <select class="chart-select" id="sub-sort">
5977 <option value="desc">Value ↓</option>
5978 <option value="asc">Value ↑</option>
5979 <option value="name">Name A→Z</option>
5980 </select>
5981 </label>
5982 </div>
5983 <div id="submodule-chart" class="chart-container"><div id="canvas-sub-wrap" style="position:relative;min-height:150px;"><canvas id="canvas-sub"></canvas></div></div>
5984 </div>
5985 </section>
5986 <section class="panel stack chart-section">
5987 <div>
5988 <div class="toolbar">
5989 <div class="toolbar-left"><h2>Submodule Composition</h2></div>
5990 <button class="chart-expand-btn" id="sub-comp-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
5991 </div>
5992 <p style="margin:0 0 14px;color:var(--muted);font-size:13px;">Code vs comments vs blank lines per submodule — bar width reflects relative size.</p>
5993 <div id="submodule-donut" style="width:100%;padding:4px 0;overflow:hidden;"></div>
5994 </div>
5995 </section>
5996 </div>
5997 {% endif %}
5998
5999 {% if has_cocomo %}
6000 <section class="panel" id="cocomo-section">
6001 <div class="toolbar">
6002 <div class="toolbar-left">
6003 <h2>Constructive Cost Model — COCOMO I</h2>
6004 <span class="cocomo-mode-pill-wrap" style="margin-left:12px;">
6005 <span class="pill" style="background:var(--surface-3);color:var(--muted);border:1px solid var(--line);font-size:11px;">{{ cocomo_mode_label }} mode</span>
6006 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
6007 </span>
6008 </div>
6009 </div>
6010 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
6011 <div class="stat-chip">
6012 <div class="stat-chip-label">Person-months</div>
6013 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
6014 <div class="stat-chip-tip">Total estimated developer effort to build this codebase from scratch. One person-month = one developer working full-time for one calendar month. Computed as 2.4 × KSLOC^1.05 (Organic mode).</div>
6015 </div>
6016 <div class="stat-chip">
6017 <div class="stat-chip-label">Schedule (months)</div>
6018 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
6019 <div class="stat-chip-tip">Estimated calendar duration assuming an optimally sized team. Computed as 2.5 × effort^0.38. Adding more people beyond this optimum rarely shortens the timeline.</div>
6020 </div>
6021 <div class="stat-chip">
6022 <div class="stat-chip-label">Avg. Team Size</div>
6023 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
6024 <div class="stat-chip-tip">Average number of engineers working in parallel, derived as effort ÷ schedule. Actual headcount may peak higher during intensive phases of the project.</div>
6025 </div>
6026 <div class="stat-chip">
6027 <div class="stat-chip-label">Input KSLOC</div>
6028 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
6029 <div class="stat-chip-tip">KSLOC = Kilo Source Lines of Code (1 KSLOC = 1,000 lines). This is the primary input to the COCOMO model. Only executable code lines are counted — blank lines and comments are excluded. ({{ run.summary_totals.code_lines|commas }} total code lines)</div>
6030 </div>
6031 </div>
6032 <p style="font-size:13px;color:var(--muted);padding:8px 4px 0;line-height:1.6;white-space:nowrap;">COCOMO I (Constructive Cost Model) is a 1981 algorithmic model by Barry Boehm that converts SLOC into effort, schedule, and team-size estimates.<br>These are ballpark figures — actual outcomes vary widely by team experience, toolchain maturity, and domain complexity.</p>
6033 </section>
6034 {% endif %}
6035
6036 {% if has_hotspots %}
6037 <section class="panel stack" id="hotspots-section">
6038 <div class="toolbar"><div class="toolbar-left"><h2>Git Hotspots</h2><input id="hotspots-search" class="search" type="search" placeholder="Filter files..." /><div class="page-size-row"><label class="page-size-label" for="hotspots-page-size">Show:</label><select id="hotspots-page-size" class="page-size-select"><option value="15" selected>15</option><option value="25">25</option><option value="50">50</option><option value="all">All</option></select><span id="hotspots-count-label" class="page-count-label"></span></div></div></div>
6039 <p style="font-size:13px;color:var(--muted);padding:4px 4px 10px;line-height:1.6;">Files ranked by <strong>code lines × recent commits</strong> over the configured git activity window. Large files that change often are the strongest refactoring candidates. <span class="hs-hint">Click a column header to sort; drag its right edge to resize; hover a header for what it means.</span></p>
6040 <div class="table-shell">
6041 <table id="hotspots-table" data-sort-table class="table-resizable hotspots-table">
6042 <colgroup><col><col><col><col><col></colgroup>
6043 <thead><tr>
6044 <th data-sort-type="text">File<span class="col-tip">Repository-relative path of the file. Click to sort the list alphabetically by path.</span><div class="col-resize-handle"></div></th>
6045 <th data-sort-type="number" class="num-col">Code lines<span class="col-tip col-tip-r">Executable source lines in the file (blank lines and comments excluded). Bigger files are harder to change safely.</span><div class="col-resize-handle"></div></th>
6046 <th data-sort-type="number" class="num-col">Commits<span class="col-tip col-tip-r">How many times the file was committed within the git activity window. More commits = more churn.</span><div class="col-resize-handle"></div></th>
6047 <th data-sort-type="number" class="num-col">Hotspot score<span class="col-tip col-tip-r"><strong>Code lines × Commits.</strong> A large file that changes often scores high — it concentrates both size and churn, making it the strongest refactoring candidate. Lower is calmer.</span><div class="col-resize-handle"></div></th>
6048 <th data-sort-type="text" class="num-col">Last changed<span class="col-tip col-tip-r">Date of the most recent commit that touched this file, within the activity window.</span><div class="col-resize-handle"></div></th>
6049 </tr></thead>
6050 <tbody>
6051 {% for h in hotspot_rows %}
6052 <tr>
6053 <td class="mono" title="{{ h.path }}">{{ h.path }}</td>
6054 <td class="num-col">{{ h.code_lines|commas }}</td>
6055 <td class="num-col">{{ h.commit_count }}</td>
6056 <td class="num-col" style="font-weight:700;color:var(--oxide);">{{ h.score|commas }}</td>
6057 <td class="num-col" style="color:var(--muted);">{{ h.last_commit_date }}</td>
6058 </tr>
6059 {% endfor %}
6060 </tbody>
6061 </table>
6062 </div>
6063 <div id="hotspots-pagination" class="pagination-bar">
6064 <button id="hs-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6065 <button id="hs-prev" class="pager-btn" disabled>← Prev</button>
6066 <span class="pager-jump-wrap">Page <input id="hs-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="hs-page-total">—</span></span>
6067 <span id="hs-page-info" class="pager-info"></span>
6068 <button id="hs-next" class="pager-btn">Next →</button>
6069 <button id="hs-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6070 </div>
6071 </section>
6072 {% endif %}
6073
6074 <section class="panel stack">
6075 <div>
6076 <div class="toolbar"><div class="toolbar-left"><h2>Language Breakdown</h2></div><button class="chart-expand-btn" id="lang-overview-expand-btn" title="View full chart" aria-label="Expand charts">⤢ Full View</button></div>
6077 <div id="report-lang-overview" style="margin:0 0 16px;"></div>
6078 <div class="table-shell">
6079 <table id="lang-breakdown-table" data-sort-table class="table-resizable">
6080 <colgroup>
6081 <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6082 </colgroup>
6083 <thead>
6084 <tr>
6085 <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6086 <th data-sort-type="number" class="num-col">Files<div class="col-resize-handle"></div></th>
6087 <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6088 <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6089 <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6090 <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6091 <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6092 <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6093 <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6094 <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6095 <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6096 <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6097 <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6098 <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6099 </tr>
6100 </thead>
6101 <tbody>
6102 {% for row in language_rows %}
6103 <tr>
6104 <td title="{{ row.language }}">{{ row.language }}</td>
6105 <td class="num-col">{{ row.files|commas }}</td>
6106 <td class="num-col">{{ row.total_physical_lines|commas }}</td>
6107 <td class="num-col">{{ row.code_lines|commas }}</td>
6108 <td class="num-col">{{ row.comment_lines|commas }}</td>
6109 <td class="num-col">{{ row.blank_lines|commas }}</td>
6110 <td class="num-col">{{ row.mixed_lines_separate|commas }}</td>
6111 <td class="num-col">{{ row.functions|commas }}</td>
6112 <td class="num-col">{{ row.classes|commas }}</td>
6113 <td class="num-col">{{ row.variables|commas }}</td>
6114 <td class="num-col">{{ row.imports|commas }}</td>
6115 <td class="num-col">{{ row.test_count|commas }}</td>
6116 <td class="num-col">{{ row.test_assertion_count|commas }}</td>
6117 <td class="num-col">{{ row.test_suite_count|commas }}</td>
6118 </tr>
6119 {% endfor %}
6120 </tbody>
6121 </table>
6122 </div>
6123 </div>
6124 </section>
6125
6126 <section class="panel stack">
6127 <div class="toolbar"><div class="toolbar-left"><h2>Per-file detail</h2><input id="per-file-search" class="search" type="search" placeholder="Filter files, languages, status, warnings..." /><div class="page-size-row"><label class="page-size-label" for="per-file-page-size">Show:</label><select id="per-file-page-size" class="page-size-select"><option value="20" selected>20</option><option value="50">50</option><option value="100">100</option><option value="all">All</option></select><span id="per-file-count-label" class="page-count-label"></span></div></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" data-reset-table title="Reset scroll and column layout">↻ Reset</button><button class="export-btn" data-export-csv>↓ CSV</button><button class="export-btn" data-export-xls>↓ Excel</button></div></div></div>
6128 <div class="table-shell table-shell-clip">
6129 <div id="per-file-shell">
6130 <table id="per-file-table" data-sort-table class="table-resizable">
6131 <colgroup>
6132 <col><col><col><col><col><col><col><col><col><col><col><col><col><col>
6133 </colgroup>
6134 <thead>
6135 <tr>
6136 <th data-sort-type="text">File<div class="col-resize-handle"></div></th>
6137 <th data-sort-type="text">Language<div class="col-resize-handle"></div></th>
6138 <th data-sort-type="number" class="num-col">Physical<div class="col-resize-handle"></div></th>
6139 <th data-sort-type="number" class="num-col">Code<div class="col-resize-handle"></div></th>
6140 <th data-sort-type="number" class="num-col">Comments<div class="col-resize-handle"></div></th>
6141 <th data-sort-type="number" class="num-col">Blank<div class="col-resize-handle"></div></th>
6142 <th data-sort-type="number" class="num-col">Mixed<div class="col-resize-handle"></div></th>
6143 <th data-sort-type="number" class="num-col">Functions<div class="col-resize-handle"></div></th>
6144 <th data-sort-type="number" class="num-col">Classes<div class="col-resize-handle"></div></th>
6145 <th data-sort-type="number" class="num-col">Variables<div class="col-resize-handle"></div></th>
6146 <th data-sort-type="number" class="num-col">Imports<div class="col-resize-handle"></div></th>
6147 <th data-sort-type="number" class="num-col">Tests<div class="col-resize-handle"></div></th>
6148 <th data-sort-type="number" class="num-col">Assertions<div class="col-resize-handle"></div></th>
6149 <th data-sort-type="number" class="num-col">Suites<div class="col-resize-handle"></div></th>
6150 {% if has_coverage_data %}<th data-sort-type="text" class="num-col">Line Cov %<div class="col-resize-handle"></div></th><th data-sort-type="text" class="num-col">Fn Cov %<div class="col-resize-handle"></div></th>{% endif %}
6151 </tr>
6152 </thead>
6153 <tbody>
6154 {% for row in file_rows %}
6155 <tr>
6156 <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6157 <td title="{{ row.language }}">{{ row.language }}</td>
6158 <td class="num-col">{{ row.total_physical_lines }}</td>
6159 <td class="num-col">{{ row.code_lines }}</td>
6160 <td class="num-col">{{ row.comment_lines }}</td>
6161 <td class="num-col">{{ row.blank_lines }}</td>
6162 <td class="num-col">{{ row.mixed_lines_separate }}</td>
6163 <td class="num-col">{{ row.functions }}</td>
6164 <td class="num-col">{{ row.classes }}</td>
6165 <td class="num-col">{{ row.variables }}</td>
6166 <td class="num-col">{{ row.imports }}</td>
6167 <td class="num-col">{{ row.test_count }}</td>
6168 <td class="num-col">{{ row.test_assertion_count }}</td>
6169 <td class="num-col">{{ row.test_suite_count }}</td>
6170 {% if has_coverage_data %}<td class="num-col">{{ row.line_cov_pct }}</td><td class="num-col">{{ row.fn_cov_pct }}</td>{% endif %}
6171 </tr>
6172 {% endfor %}
6173 </tbody>
6174 </table>
6175 </div>
6176 </div>
6177 <div id="per-file-pagination" class="pagination-bar">
6178 <button id="pf-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6179 <button id="pf-prev" class="pager-btn" disabled>← Prev</button>
6180 <span class="pager-jump-wrap">Page <input id="pf-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="pf-page-total">—</span></span>
6181 <span id="pf-page-info" class="pager-info"></span>
6182 <button id="pf-next" class="pager-btn">Next →</button>
6183 <button id="pf-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6184 </div>
6185 </section>
6186
6187 <section class="panel stack">
6188 <div class="toolbar"><div class="toolbar-left"><h2>Skipped files</h2><input id="skipped-search" class="search" type="search" placeholder="Filter skipped files, reasons, warnings..." /><div class="page-size-row"><label class="page-size-label" for="skipped-page-size">Show:</label><select id="skipped-page-size" class="page-size-select"><option value="10" selected>10</option><option value="20">20</option><option value="50">50</option><option value="100">100</option><option value="all">All</option></select><span id="skipped-count-label" class="page-count-label"></span></div></div><div class="export-group"><button class="export-btn" id="skipped-export-csv">↓ CSV</button><button class="export-btn" id="skipped-export-xls">↓ Excel</button></div></div>
6189 <div class="table-shell table-shell-clip" style="margin-top:6px;">
6190 <div id="skipped-shell">
6191 <table id="skipped-table" data-sort-table class="table-resizable">
6192 <thead>
6193 <tr>
6194 <th data-sort-type="text" style="width:42%">File</th>
6195 <th data-sort-type="text" style="width:20%">Status</th>
6196 <th data-sort-type="text" style="width:38%">Warnings</th>
6197 </tr>
6198 </thead>
6199 <tbody>
6200 {% for row in skipped_rows %}
6201 <tr>
6202 <td class="mono" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
6203 <td><span class="status-tag status-{{ row.status_class }}">{{ row.status }}</span></td>
6204 <td class="small" title="{{ row.warnings }}">{{ row.warnings }}</td>
6205 </tr>
6206 {% endfor %}
6207 </tbody>
6208 </table>
6209 </div>
6210 </div>
6211 <div id="skipped-pagination" class="pagination-bar">
6212 <button id="sk-first" class="pager-btn pager-edge" disabled title="First page">⇤ First</button>
6213 <button id="sk-prev" class="pager-btn" disabled>← Prev</button>
6214 <span class="pager-jump-wrap">Page <input id="sk-page-jump" class="pager-jump" type="number" min="1" value="1" title="Jump to page"> of <span id="sk-page-total">—</span></span>
6215 <span id="sk-page-info" class="pager-info"></span>
6216 <button id="sk-next" class="pager-btn">Next →</button>
6217 <button id="sk-last" class="pager-btn pager-edge" title="Last page">Last ⇥</button>
6218 </div>
6219 </section>
6220
6221 <section class="panel stack">
6222 <div>
6223 <div class="toolbar">
6224 <div class="toolbar-left"><h2>Diagnostics & Configuration</h2></div>
6225 {% 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 %}
6226 </div>
6227 <p class="effective-config-note">Warning summary, support improvement opportunities, raw diagnostic output, and the exact configuration in effect for this scan.</p>
6228 </div>
6229
6230 {% if !is_sub_report %}
6231 <div style="margin-top:-14px;">
6232 <h3 style="margin:0 0 4px;">Warnings overview</h3>
6233 <p class="support-note">Warning categories produced during the scan. Each row shows the warning type, how many files were affected, and what it means for your results.</p>
6234 {% if !has_run_warnings %}
6235 <div class="pill good">No top-level warnings.</div>
6236 {% else %}
6237 <div class="table-shell">
6238 <table class="support-table">
6239 <thead>
6240 <tr><th style="width:30%;">Category</th><th style="width:8%;">Count</th><th>What this means</th></tr>
6241 </thead>
6242 <tbody>
6243 {% for row in warning_summary_rows %}
6244 <tr class="{{ row.tone_class }}">
6245 <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6246 <td class="warning-count" style="font-weight:800;">{{ row.count }}</td>
6247 <td class="small" style="color:var(--muted);">{{ row.detail }}</td>
6248 </tr>
6249 {% endfor %}
6250 </tbody>
6251 </table>
6252 </div>
6253 {% endif %}
6254 </div>
6255
6256 <div>
6257 <h3 style="margin:0 0 4px;">Skipped file categories</h3>
6258 <p class="support-note">Files that were not analyzed, grouped by category. Each row shows what the files are, how many were skipped, example file names, and how to silence or fix the warning.</p>
6259 {% if warning_opportunity_rows.is_empty() %}
6260 <div class="pill good">No unsupported text-format buckets detected.</div>
6261 {% else %}
6262 <div class="table-shell">
6263 <table class="support-table">
6264 <thead>
6265 <tr><th style="width:20%;">Category</th><th style="width:6%;">Count</th><th style="width:24%;">What these files are</th><th>Example files & how to fix</th></tr>
6266 </thead>
6267 <tbody>
6268 {% for row in warning_opportunity_rows %}
6269 <tr>
6270 <td style="font-weight:700;" title="{{ row.label }}">{{ row.label }}</td>
6271 <td style="font-weight:800;color:var(--oxide);">{{ row.count }}</td>
6272 <td class="small" style="color:var(--muted);">{{ row.bucket_description }}</td>
6273 <td>
6274 {% if !row.example_files.is_empty() %}
6275 <div style="margin-bottom:6px;">
6276 {% for f in row.example_files %}<span class="support-example-file">{{ f }}</span> {% endfor %}
6277 {% if row.count > row.example_files.len() %}<span style="font-size:11px;color:var(--muted);font-style:italic;">+{{ row.count - row.example_files.len() }} more</span>{% endif %}
6278 </div>
6279 {% endif %}
6280 <p class="support-recommendation">{{ row.recommendation }}</p>
6281 </td>
6282 </tr>
6283 {% endfor %}
6284 </tbody>
6285 </table>
6286 </div>
6287 {% endif %}
6288 </div>
6289
6290 <div>
6291 <details open class="warnings-details">
6292 <summary>Detailed run warnings ({{ warning_count }})</summary>
6293 <div>
6294 <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Raw warning messages emitted during the scan — unsupported file formats, encoding fallbacks, binary detections, and per-file parse issues. Scroll to see all warnings. High counts typically indicate many non-code assets (JSON configs, docs, lockfiles) in the scanned directory.</p>
6295 {% if !has_run_warnings %}
6296 <div class="pill good">No top-level warnings.</div>
6297 {% else %}
6298 <div class="code-block-toolbar">
6299 <button type="button" class="code-copy-btn" id="warning-console-copy-btn" aria-label="Copy warnings">Copy</button>
6300 </div>
6301 <pre class="warning-console" id="warning-console-full" style="max-height:210px;">{{ warning_console_full }}</pre>
6302 {% endif %}
6303 </div>
6304 </details>
6305 </div>
6306 {% endif %}
6307
6308 <div>
6309 <details open>
6310 <summary>Effective configuration</summary>
6311 <div>
6312 <div style="display:flex;gap:8px;margin-bottom:10px;">
6313 <button type="button" class="export-btn" data-copy-config>Copy</button>
6314 <button type="button" class="export-btn" data-download-config>Download</button>
6315 </div>
6316 <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">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>
6317 <div class="config-pre-wrap">
6318 <div class="code-block-toolbar">
6319 <button type="button" class="code-copy-btn" id="config-inline-copy-btn" aria-label="Copy configuration">Copy</button>
6320 </div>
6321 <pre class="config-pre" id="config-json-block">{{ config_json }}</pre>
6322 </div>
6323 </div>
6324 </details>
6325 </div>
6326 </section>
6327 </div>
6328 </div>
6329
6330 <div id="r-tt" aria-hidden="true"></div>
6331 <script nonce="{{ nonce }}">
6332 // Hide "View PDF" button and block brand-link navigation when opened as a local file
6333 (function () {
6334 var pdfBtn = document.getElementById('nav-view-pdf-btn');
6335 if (pdfBtn && window.location.protocol === 'file:') {
6336 pdfBtn.style.display = 'none';
6337 }
6338 var brand = document.querySelector('a[data-local-brand]');
6339 if (brand && window.location.protocol === 'file:') {
6340 brand.addEventListener('click', function (e) { e.preventDefault(); });
6341 }
6342 })();
6343
6344 (function () {
6345 var body = document.body;
6346 var storageKey = 'oxide-sloc-theme';
6347 var themeToggle = document.querySelector('[data-theme-toggle]');
6348 var copyLinkButtons = Array.prototype.slice.call(document.querySelectorAll('[data-copy-link]'));
6349 var shareButtons = Array.prototype.slice.call(document.querySelectorAll('[data-share-report]'));
6350 var printButtons = Array.prototype.slice.call(document.querySelectorAll('[data-print-report]'));
6351
6352 function applyTheme(theme) {
6353 body.classList.toggle('dark-theme', theme === 'dark');
6354 }
6355
6356 function currentTheme() {
6357 return body.classList.contains('dark-theme') ? 'dark' : 'light';
6358 }
6359
6360 try {
6361 var saved = localStorage.getItem(storageKey);
6362 if (saved === 'dark' || saved === 'light') {
6363 applyTheme(saved);
6364 }
6365 } catch (e) {}
6366
6367 if (themeToggle) {
6368 themeToggle.addEventListener('click', function () {
6369 var next = currentTheme() === 'dark' ? 'light' : 'dark';
6370 applyTheme(next);
6371 try { localStorage.setItem(storageKey, next); } catch (e) {}
6372 });
6373 }
6374
6375 function copyText(value) {
6376 if (!value) return;
6377 if (navigator.clipboard && navigator.clipboard.writeText) {
6378 navigator.clipboard.writeText(value).catch(function () {});
6379 }
6380 }
6381
6382 copyLinkButtons.forEach(function (button) {
6383 button.addEventListener('click', function () {
6384 copyText(window.location.href);
6385 });
6386 });
6387
6388 shareButtons.forEach(function (button) {
6389 button.addEventListener('click', function () {
6390 if (navigator.share) {
6391 navigator.share({ title: document.title, url: window.location.href }).catch(function () {});
6392 } else {
6393 copyText(window.location.href);
6394 }
6395 });
6396 });
6397
6398 printButtons.forEach(function (button) {
6399 button.addEventListener('click', function () {
6400 window.print();
6401 });
6402 });
6403
6404 // "View PDF" nav button.
6405 // Priority order:
6406 // 1. data-standalone-pdf attr — pre-generated PDF in the same directory
6407 // (set when oxide-sloc CLI was invoked with both --html-out and
6408 // --pdf-out). Opens the file directly; works in Jenkins HTML Publisher.
6409 // 2. Server route (/runs/pdf/<id>) — oxide-sloc web server generates
6410 // the PDF on demand via headless Chrome. Checked via HEAD request.
6411 // 3. Neither available — inform the user how to generate a PDF via CLI.
6412 var pdfNavBtn = document.getElementById('nav-view-pdf-btn');
6413 if (pdfNavBtn) {
6414 pdfNavBtn.addEventListener('click', function (e) {
6415 e.preventDefault();
6416 var standaloneUrl = pdfNavBtn.getAttribute('data-standalone-pdf');
6417 if (standaloneUrl) {
6418 window.open(standaloneUrl, '_blank', 'noopener');
6419 return;
6420 }
6421 var serverUrl = pdfNavBtn.getAttribute('href');
6422 var xhr = new XMLHttpRequest();
6423 xhr.open('HEAD', serverUrl, true);
6424 xhr.onreadystatechange = function () {
6425 if (xhr.readyState === 4) {
6426 if (xhr.status >= 200 && xhr.status < 300) {
6427 window.open(serverUrl, '_blank', 'noopener');
6428 } else {
6429 alert('PDF not available.\n\nTo generate one, run:\n oxide-sloc report result.json --pdf-out report.pdf\n\nOr enable GENERATE_PDF in your Jenkins pipeline.');
6430 }
6431 }
6432 };
6433 xhr.onerror = function () {
6434 alert('PDF not available.\n\nTo generate one, run:\n oxide-sloc report result.json --pdf-out report.pdf\n\nOr enable GENERATE_PDF in your Jenkins pipeline.');
6435 };
6436 xhr.send();
6437 });
6438 }
6439
6440 var copyConfigBtn = document.querySelector('[data-copy-config]');
6441 var downloadConfigBtn = document.querySelector('[data-download-config]');
6442 var configBlock = document.getElementById('config-json-block');
6443 var inlineCopyBtn = document.getElementById('config-inline-copy-btn');
6444 function handleConfigCopy(btn) {
6445 if (!btn || !configBlock) return;
6446 btn.addEventListener('click', function (e) {
6447 e.stopPropagation();
6448 copyText(configBlock.textContent);
6449 var orig = btn.textContent;
6450 btn.textContent = 'Copied!';
6451 setTimeout(function () { btn.textContent = orig; }, 1600);
6452 });
6453 }
6454 handleConfigCopy(copyConfigBtn);
6455 handleConfigCopy(inlineCopyBtn);
6456
6457 var warnCopyBtn = document.getElementById('warning-console-copy-btn');
6458 var warnBlock = document.getElementById('warning-console-full');
6459 if (warnCopyBtn && warnBlock) {
6460 warnCopyBtn.addEventListener('click', function () {
6461 copyText(warnBlock.textContent);
6462 var orig = warnCopyBtn.textContent;
6463 warnCopyBtn.textContent = 'Copied!';
6464 setTimeout(function () { warnCopyBtn.textContent = orig; }, 1600);
6465 });
6466 }
6467
6468 if (downloadConfigBtn && configBlock) {
6469 downloadConfigBtn.addEventListener('click', function (e) {
6470 e.stopPropagation();
6471 var blob = new Blob([configBlock.textContent], { type: 'application/json' });
6472 var url = URL.createObjectURL(blob);
6473 var a = document.createElement('a');
6474 a.href = url; a.download = 'effective-config.json';
6475 document.body.appendChild(a); a.click();
6476 document.body.removeChild(a);
6477 setTimeout(function () { URL.revokeObjectURL(url); }, 200);
6478 });
6479 }
6480
6481 function detectType(value) {
6482 // Strip thousands separators so comma-formatted numbers (e.g. "121,542")
6483 // still sort numerically rather than lexicographically.
6484 var v = value.trim().replace(/,/g, '');
6485 return /^-?\d+(?:\.\d+)?$/.test(v) ? parseFloat(v) : value.trim().toLowerCase();
6486 }
6487
6488 document.querySelectorAll('[data-sort-table]').forEach(function (table) {
6489 var headers = Array.prototype.slice.call(table.querySelectorAll('th'));
6490 var allMarkers = [];
6491 headers.forEach(function (th, idx) {
6492 var direction = 1;
6493 var marker = document.createElement('span');
6494 marker.className = 'sort-indicator';
6495 marker.textContent = ' \u2195';
6496 th.style.cursor = 'pointer';
6497 th.appendChild(marker);
6498 allMarkers.push(marker);
6499 th.addEventListener('click', function (e) {
6500 if (e.target.closest && e.target.closest('.col-resize-handle')) return;
6501 var tbody = table.tBodies[0];
6502 var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
6503 rows.sort(function (a, b) {
6504 var av = detectType((a.children[idx].textContent || '').trim());
6505 var bv = detectType((b.children[idx].textContent || '').trim());
6506 if (av < bv) return -1 * direction;
6507 if (av > bv) return 1 * direction;
6508 return 0;
6509 });
6510 rows.forEach(function (row) { tbody.appendChild(row); });
6511 allMarkers.forEach(function(m) { m.textContent = ' \u2195'; });
6512 direction = direction * -1;
6513 marker.textContent = direction === -1 ? ' \u2191' : ' \u2193';
6514 table.dispatchEvent(new CustomEvent('sloc-sorted'));
6515 });
6516 });
6517 });
6518
6519 // ── Column resize for all table-resizable tables ──────────────────────────
6520 (function() {
6521 document.querySelectorAll('.table-resizable').forEach(function(table) {
6522 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
6523 var ths = Array.prototype.slice.call(table.querySelectorAll('thead th'));
6524 ths.forEach(function(th, i) {
6525 var handle = th.querySelector('.col-resize-handle');
6526 if (!handle || !cols[i]) return;
6527 var startX, startW;
6528 handle.addEventListener('mousedown', function(e) {
6529 e.stopPropagation(); e.preventDefault();
6530 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
6531 handle.classList.add('dragging');
6532 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
6533 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
6534 document.addEventListener('mousemove', onMove);
6535 document.addEventListener('mouseup', onUp);
6536 });
6537 });
6538 });
6539 })();
6540
6541 document.querySelectorAll('[data-table-filter]').forEach(function (input) {
6542 var table = document.getElementById(input.getAttribute('data-table-filter'));
6543 if (!table) return;
6544 var filterTimer = null;
6545 var rowCache = null;
6546 input.addEventListener('input', function () {
6547 clearTimeout(filterTimer);
6548 var q = input.value.toLowerCase();
6549 filterTimer = setTimeout(function () {
6550 if (!rowCache) {
6551 rowCache = Array.prototype.map.call(table.tBodies[0].rows, function (row) {
6552 return { row: row, text: row.textContent.toLowerCase() };
6553 });
6554 }
6555 rowCache.forEach(function (item) {
6556 item.row.style.display = q === '' || item.text.indexOf(q) >= 0 ? '' : 'none';
6557 });
6558 }, 200);
6559 });
6560 });
6561
6562 // ── Per-file table pagination ────────────────────────────────────────────
6563 (function () {
6564 var table = document.getElementById('per-file-table');
6565 if (!table) return;
6566 var tbody = table.tBodies[0];
6567 var searchInput = document.getElementById('per-file-search');
6568 var pageSizeSelect = document.getElementById('per-file-page-size');
6569 var firstBtn = document.getElementById('pf-first');
6570 var prevBtn = document.getElementById('pf-prev');
6571 var nextBtn = document.getElementById('pf-next');
6572 var lastBtn = document.getElementById('pf-last');
6573 var pageInfo = document.getElementById('pf-page-info');
6574 var jumpInput = document.getElementById('pf-page-jump');
6575 var pageTotal = document.getElementById('pf-page-total');
6576 var countLabel = document.getElementById('per-file-count-label');
6577 var filteredRows = [];
6578 var currentPage = 1;
6579 var totalAll = tbody.rows.length;
6580
6581 function getPageSize() {
6582 var v = pageSizeSelect ? pageSizeSelect.value : '20';
6583 return v === 'all' ? Infinity : parseInt(v, 10);
6584 }
6585
6586 function applyFilter() {
6587 var q = searchInput ? searchInput.value.toLowerCase() : '';
6588 var rows = Array.prototype.slice.call(tbody.rows);
6589 filteredRows = q === '' ? rows : rows.filter(function (row) {
6590 return row.textContent.toLowerCase().indexOf(q) >= 0;
6591 });
6592 currentPage = 1;
6593 render();
6594 }
6595
6596 function render() {
6597 var ps = getPageSize();
6598 var total = filteredRows.length;
6599 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6600 if (currentPage > totalPages) currentPage = totalPages;
6601 if (currentPage < 1) currentPage = 1;
6602 var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6603 var end = ps === Infinity ? total : Math.min(start + ps, total);
6604 Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6605 for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6606 if (pageInfo) {
6607 if (total === 0) {
6608 pageInfo.textContent = 'No results';
6609 } else if (ps === Infinity) {
6610 pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6611 } else {
6612 pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
6613 }
6614 }
6615 if (countLabel) {
6616 countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6617 }
6618 var edgeDisabled = ps === Infinity;
6619 if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6620 if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6621 if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6622 if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6623 if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6624 if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6625 }
6626
6627 if (searchInput) {
6628 var filterTimer = null;
6629 searchInput.addEventListener('input', function () {
6630 clearTimeout(filterTimer);
6631 filterTimer = setTimeout(applyFilter, 200);
6632 });
6633 }
6634 if (pageSizeSelect) {
6635 pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6636 }
6637 if (firstBtn) {
6638 firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6639 }
6640 if (prevBtn) {
6641 prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6642 }
6643 if (nextBtn) {
6644 nextBtn.addEventListener('click', function () {
6645 var ps = getPageSize();
6646 var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6647 if (currentPage < totalPages) { currentPage++; render(); }
6648 });
6649 }
6650 if (lastBtn) {
6651 lastBtn.addEventListener('click', function () {
6652 var ps = getPageSize();
6653 currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6654 render();
6655 });
6656 }
6657 if (jumpInput) {
6658 function pfJump() {
6659 var ps = getPageSize();
6660 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6661 var v = parseInt(jumpInput.value, 10);
6662 if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6663 }
6664 jumpInput.addEventListener('change', pfJump);
6665 jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') pfJump(); });
6666 }
6667 table.addEventListener('sloc-sorted', function () { applyFilter(); });
6668 window._pfPaginationReset = function () { currentPage = 1; applyFilter(); };
6669 applyFilter();
6670 })();
6671
6672 // ── Skipped-files table pagination ───────────────────────────────────────
6673 (function () {
6674 var table = document.getElementById('skipped-table');
6675 if (!table) return;
6676 var tbody = table.tBodies[0];
6677 var searchInput = document.getElementById('skipped-search');
6678 var pageSizeSelect = document.getElementById('skipped-page-size');
6679 var firstBtn = document.getElementById('sk-first');
6680 var prevBtn = document.getElementById('sk-prev');
6681 var nextBtn = document.getElementById('sk-next');
6682 var lastBtn = document.getElementById('sk-last');
6683 var pageInfo = document.getElementById('sk-page-info');
6684 var jumpInput = document.getElementById('sk-page-jump');
6685 var pageTotal = document.getElementById('sk-page-total');
6686 var countLabel = document.getElementById('skipped-count-label');
6687 var filteredRows = [];
6688 var currentPage = 1;
6689 var totalAll = tbody.rows.length;
6690
6691 function getPageSize() {
6692 var v = pageSizeSelect ? pageSizeSelect.value : '10';
6693 return v === 'all' ? Infinity : parseInt(v, 10);
6694 }
6695
6696 function applyFilter() {
6697 var q = searchInput ? searchInput.value.toLowerCase() : '';
6698 var rows = Array.prototype.slice.call(tbody.rows);
6699 filteredRows = q === '' ? rows : rows.filter(function (row) {
6700 return row.textContent.toLowerCase().indexOf(q) >= 0;
6701 });
6702 currentPage = 1;
6703 render();
6704 }
6705
6706 function render() {
6707 var ps = getPageSize();
6708 var total = filteredRows.length;
6709 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6710 if (currentPage > totalPages) currentPage = totalPages;
6711 if (currentPage < 1) currentPage = 1;
6712 var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6713 var end = ps === Infinity ? total : Math.min(start + ps, total);
6714 Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6715 for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6716 if (pageInfo) {
6717 if (total === 0) {
6718 pageInfo.textContent = 'No results';
6719 } else if (ps === Infinity) {
6720 pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6721 } else {
6722 pageInfo.textContent = (start + 1) + '\u2013' + end + ' of ' + total.toLocaleString() + ' files';
6723 }
6724 }
6725 if (countLabel) {
6726 countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6727 }
6728 var edgeDisabled = ps === Infinity;
6729 if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6730 if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6731 if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6732 if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6733 if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6734 if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6735 }
6736
6737 if (searchInput) {
6738 var filterTimer = null;
6739 searchInput.addEventListener('input', function () {
6740 clearTimeout(filterTimer);
6741 filterTimer = setTimeout(applyFilter, 200);
6742 });
6743 }
6744 if (pageSizeSelect) {
6745 pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6746 }
6747 if (firstBtn) {
6748 firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6749 }
6750 if (prevBtn) {
6751 prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6752 }
6753 if (nextBtn) {
6754 nextBtn.addEventListener('click', function () {
6755 var ps = getPageSize();
6756 var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6757 if (currentPage < totalPages) { currentPage++; render(); }
6758 });
6759 }
6760 if (lastBtn) {
6761 lastBtn.addEventListener('click', function () {
6762 var ps = getPageSize();
6763 currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6764 render();
6765 });
6766 }
6767 if (jumpInput) {
6768 function skJump() {
6769 var ps = getPageSize();
6770 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6771 var v = parseInt(jumpInput.value, 10);
6772 if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6773 }
6774 jumpInput.addEventListener('change', skJump);
6775 jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') skJump(); });
6776 }
6777 table.addEventListener('sloc-sorted', function () { applyFilter(); });
6778 applyFilter();
6779 })();
6780
6781 // ── Hotspots table pagination ────────────────────────────────────────────
6782 (function () {
6783 var table = document.getElementById('hotspots-table');
6784 if (!table) return;
6785 var tbody = table.tBodies[0];
6786 var searchInput = document.getElementById('hotspots-search');
6787 var pageSizeSelect = document.getElementById('hotspots-page-size');
6788 var firstBtn = document.getElementById('hs-first');
6789 var prevBtn = document.getElementById('hs-prev');
6790 var nextBtn = document.getElementById('hs-next');
6791 var lastBtn = document.getElementById('hs-last');
6792 var pageInfo = document.getElementById('hs-page-info');
6793 var jumpInput = document.getElementById('hs-page-jump');
6794 var pageTotal = document.getElementById('hs-page-total');
6795 var countLabel = document.getElementById('hotspots-count-label');
6796 var filteredRows = [];
6797 var currentPage = 1;
6798 var totalAll = tbody.rows.length;
6799
6800 function getPageSize() {
6801 var v = pageSizeSelect ? pageSizeSelect.value : '15';
6802 return v === 'all' ? Infinity : parseInt(v, 10);
6803 }
6804
6805 function applyFilter() {
6806 var q = searchInput ? searchInput.value.toLowerCase() : '';
6807 var rows = Array.prototype.slice.call(tbody.rows);
6808 filteredRows = q === '' ? rows : rows.filter(function (row) {
6809 return row.textContent.toLowerCase().indexOf(q) >= 0;
6810 });
6811 currentPage = 1;
6812 render();
6813 }
6814
6815 function render() {
6816 var ps = getPageSize();
6817 var total = filteredRows.length;
6818 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(total / ps));
6819 if (currentPage > totalPages) currentPage = totalPages;
6820 if (currentPage < 1) currentPage = 1;
6821 var start = ps === Infinity ? 0 : (currentPage - 1) * ps;
6822 var end = ps === Infinity ? total : Math.min(start + ps, total);
6823 Array.prototype.forEach.call(tbody.rows, function (row) { row.style.display = 'none'; });
6824 for (var i = start; i < end; i++) { filteredRows[i].style.display = ''; }
6825 if (pageInfo) {
6826 if (total === 0) {
6827 pageInfo.textContent = 'No results';
6828 } else if (ps === Infinity) {
6829 pageInfo.textContent = 'All ' + total.toLocaleString() + ' files';
6830 } else {
6831 pageInfo.textContent = (start + 1) + '–' + end + ' of ' + total.toLocaleString() + ' files';
6832 }
6833 }
6834 if (countLabel) {
6835 countLabel.textContent = (total < totalAll && total > 0) ? '(' + total.toLocaleString() + ' matching)' : '';
6836 }
6837 var edgeDisabled = ps === Infinity;
6838 if (firstBtn) firstBtn.disabled = currentPage <= 1 || edgeDisabled;
6839 if (prevBtn) prevBtn.disabled = currentPage <= 1 || edgeDisabled;
6840 if (nextBtn) nextBtn.disabled = currentPage >= totalPages || edgeDisabled;
6841 if (lastBtn) lastBtn.disabled = currentPage >= totalPages || edgeDisabled;
6842 if (jumpInput) { jumpInput.value = currentPage; jumpInput.max = totalPages; jumpInput.disabled = edgeDisabled; }
6843 if (pageTotal) pageTotal.textContent = totalPages.toLocaleString();
6844 }
6845
6846 if (searchInput) {
6847 var filterTimer = null;
6848 searchInput.addEventListener('input', function () {
6849 clearTimeout(filterTimer);
6850 filterTimer = setTimeout(applyFilter, 200);
6851 });
6852 }
6853 if (pageSizeSelect) {
6854 pageSizeSelect.addEventListener('change', function () { currentPage = 1; render(); });
6855 }
6856 if (firstBtn) {
6857 firstBtn.addEventListener('click', function () { currentPage = 1; render(); });
6858 }
6859 if (prevBtn) {
6860 prevBtn.addEventListener('click', function () { if (currentPage > 1) { currentPage--; render(); } });
6861 }
6862 if (nextBtn) {
6863 nextBtn.addEventListener('click', function () {
6864 var ps = getPageSize();
6865 var totalPages = ps === Infinity ? 1 : Math.ceil(filteredRows.length / ps);
6866 if (currentPage < totalPages) { currentPage++; render(); }
6867 });
6868 }
6869 if (lastBtn) {
6870 lastBtn.addEventListener('click', function () {
6871 var ps = getPageSize();
6872 currentPage = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6873 render();
6874 });
6875 }
6876 if (jumpInput) {
6877 function hsJump() {
6878 var ps = getPageSize();
6879 var totalPages = ps === Infinity ? 1 : Math.max(1, Math.ceil(filteredRows.length / ps));
6880 var v = parseInt(jumpInput.value, 10);
6881 if (!isNaN(v)) { currentPage = Math.max(1, Math.min(v, totalPages)); render(); }
6882 }
6883 jumpInput.addEventListener('change', hsJump);
6884 jumpInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') hsJump(); });
6885 }
6886 table.addEventListener('sloc-sorted', function () { applyFilter(); });
6887 applyFilter();
6888 })();
6889 })();
6890
6891 (function randomizeWatermarks() {
6892 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
6893 if (!wms.length) return;
6894 var placed = [];
6895 function tooClose(t, l) {
6896 for (var i = 0; i < placed.length; i++) {
6897 var dt = Math.abs(placed[i][0] - t);
6898 var dl = Math.abs(placed[i][1] - l);
6899 if (dt < 18 && dl < 18) return true;
6900 }
6901 return false;
6902 }
6903 function pick(leftBias) {
6904 for (var attempt = 0; attempt < 40; attempt++) {
6905 var t = Math.random() * 90;
6906 var l = leftBias ? Math.random() * 50 : 40 + Math.random() * 55;
6907 if (!tooClose(t, l)) { placed.push([t, l]); return [t, l]; }
6908 }
6909 var fb = [Math.random() * 90, Math.random() * 95];
6910 placed.push(fb);
6911 return fb;
6912 }
6913 var half = Math.floor(wms.length / 2);
6914 wms.forEach(function (img, i) {
6915 var pos = pick(i < half);
6916 var sz = Math.floor(Math.random() * 80 + 110);
6917 var rot = (Math.random() * 360).toFixed(1);
6918 var op = (Math.random() * 0.07 + 0.10).toFixed(2);
6919 img.style.cssText = 'width:' + sz + 'px;top:' + pos[0].toFixed(1) + '%;left:' + pos[1].toFixed(1) + '%;transform:rotate(' + rot + 'deg);opacity:' + op + ';';
6920 });
6921 })();
6922
6923 (function spawnCodeParticles() {
6924 var container = document.getElementById('code-particles');
6925 if (!container) return;
6926 var snippets = ['1,247 sloc', 'fn analyze()', 'code_lines', '0 mixed', 'blanks: 312', '// comment', 'pub fn run', 'use std::fs', 'Result<()>', 'let mut n = 0', 'git main', '#[derive]', 'impl Scan', '3,841 physical', 'files: 60', '450 comments', 'cargo build', 'Ok(run)', 'Vec<String>', 'match lang', 'fn main() {', '.rs .go .py', 'sloc_core', 'render_html', '2,163 code'];
6927 for (var i = 0; i < 38; i++) {
6928 (function (idx) {
6929 var el = document.createElement('span');
6930 el.className = 'code-particle';
6931 el.textContent = snippets[idx % snippets.length];
6932 var left = Math.random() * 94 + 2;
6933 var top = Math.random() * 88 + 6;
6934 var dur = (Math.random() * 10 + 9).toFixed(1);
6935 var delay = (Math.random() * 18).toFixed(1);
6936 var rot = (Math.random() * 26 - 13).toFixed(1);
6937 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
6938 el.style.left = left.toFixed(1) + '%';
6939 el.style.top = top.toFixed(1) + '%';
6940 el.style.setProperty('--rot', rot + 'deg');
6941 el.style.setProperty('--op', op);
6942 el.style.animationDuration = dur + 's';
6943 el.style.animationDelay = '-' + delay + 's';
6944 container.appendChild(el);
6945 })(i);
6946 }
6947 })();
6948 // ── Metric number formatting ─────────────────────────────────────────────
6949 (function () {
6950 function fmtBig(n) {
6951 if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
6952 if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
6953 return n.toLocaleString();
6954 }
6955 function fmtExact(n) { return n.toLocaleString(); }
6956 document.querySelectorAll('[data-metric-value]').forEach(function (el) {
6957 var n = parseInt(el.getAttribute('data-metric-value'), 10);
6958 if (isNaN(n)) return;
6959 var big = el.querySelector('.metric-big');
6960 var exact = el.querySelector('.metric-exact');
6961 if (big) big.textContent = fmtBig(n);
6962 if (exact) exact.textContent = n >= 1e4 ? fmtExact(n) : '';
6963 });
6964 var densityCard = document.querySelector('[data-metric-density]');
6965 if (densityCard) {
6966 var phys = 0, code = 0;
6967 document.querySelectorAll('[data-metric-value]').forEach(function (el) {
6968 var lbl = el.querySelector('.metric-label');
6969 if (!lbl) return;
6970 var t = lbl.textContent.trim().toLowerCase();
6971 var v = parseInt(el.getAttribute('data-metric-value'), 10) || 0;
6972 if (t === 'physical lines') phys = v;
6973 if (t === 'code') code = v;
6974 });
6975 var pct = phys > 0 ? (code / phys * 100) : 0;
6976 var big = densityCard.querySelector('.metric-big');
6977 var exact = densityCard.querySelector('.metric-exact');
6978 if (big) big.textContent = pct.toFixed(1) + '%';
6979 if (exact) exact.textContent = '';
6980 }
6981 (function(){
6982 var g=document.querySelector('.summary-grid');if(!g)return;
6983 var pad=g.querySelector('.metric-pad');
6984 var real=Array.prototype.slice.call(g.querySelectorAll('.metric')).filter(function(el){return el!==pad;});
6985 if(!real.length)return;
6986 function upd(){
6987 // Pad the strip to an EVEN card count so a true CSS grid lays it out as
6988 // exactly two full rows with every column aligned and every card the
6989 // same size. When the real-card count is odd, reveal the reserve
6990 // "Assertions" pad card; otherwise keep it hidden.
6991 var n=real.length;
6992 if(pad){ if(n%2===1){pad.style.display='';n++;} else {pad.style.display='none';} }
6993 var perRow=window.innerWidth<=640?2:Math.ceil(n/2);
6994 g.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';
6995 }
6996 upd();window.addEventListener('resize',upd);
6997 })();
6998 (function(){if(typeof window.__rptFinish==='function'){window.__rptFinish();return;}var ov=document.getElementById('rpt-loading-overlay');if(ov){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},450);}})();
6999 })();
7000 // ── Info chip interactivity ───────────────────────────────────────────────
7001 (function() {
7002 document.querySelectorAll('.run-id-chip[data-copy]').forEach(function(chip) {
7003 chip.addEventListener('click', function() {
7004 var val = chip.getAttribute('data-copy');
7005 var tt = chip.querySelector('.chip-tooltip');
7006 var orig = tt ? tt.textContent : '';
7007 if (!navigator.clipboard) return;
7008 navigator.clipboard.writeText(val).then(function() {
7009 chip.classList.add('chip-copied-flash');
7010 if (tt) tt.textContent = 'Copied!';
7011 setTimeout(function() {
7012 chip.classList.remove('chip-copied-flash');
7013 if (tt) tt.textContent = orig;
7014 }, 1100);
7015 });
7016 });
7017 });
7018 document.querySelectorAll('.run-id-chip[data-author]').forEach(function(chip) {
7019 var author = chip.getAttribute('data-author');
7020 var el = chip.querySelector('.author-handle');
7021 if (el) el.textContent = '/' + author.replace(/\s+/g, '');
7022 });
7023 })();
7024 // ── Export helpers ────────────────────────────────────────────────────────
7025 function _slocUnh(s){var e=document.createElement('div');e.innerHTML=s;return e.textContent;}
7026 var _SLOC_META={runId:"{{ run.tool.run_id }}",gitCommit:"{% if let Some(c) = run.git_commit_long %}{{ c }}{% else %}(not detected){% endif %}",branch:_slocUnh("{% if let Some(b) = run.git_branch %}{{ b }}{% else %}(not detected){% endif %}"),lastCommitBy:_slocUnh("{% if let Some(a) = run.git_commit_author %}{{ a }}{% else %}(not detected){% endif %}"),scanBy:_slocUnh("{{ scan_performed_by }}"),scanned:"{{ scan_time_pst }}",os:"{{ run.environment.operating_system }} / {{ run.environment.architecture }}",filesAnalyzed:{{ run.summary_totals.files_analyzed }},filesSkipped:{{ run.summary_totals.files_skipped }},physicalLines:{{ run.summary_totals.total_physical_lines }},codeLines:{{ run.summary_totals.code_lines }},commentLines:{{ run.summary_totals.comment_lines }},blankLines:{{ run.summary_totals.blank_lines }},mixedSeparate:{{ run.summary_totals.mixed_lines_separate }},functions:{{ run.summary_totals.functions }},classes:{{ run.summary_totals.classes }},variables:{{ run.summary_totals.variables }},imports:{{ run.summary_totals.imports }},tests:{{ run.summary_totals.test_count }},toolVersion:"{{ tool_version }}"};
7027 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
7028 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
7029 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);}
7030 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;');}
7031 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');}
7032 function slocXlsMulti(fname,sheets){
7033 var enc=new TextEncoder();
7034 var CT=[];
7035 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;}
7036 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;}
7037 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
7038 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
7039 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
7040 var ss=[],si={};
7041 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
7042 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;}
7043 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
7044 // Style indices: 0=normal 1=col-header(orange-fill/white-bold) 2=number(#,##0/right) 3=section(cream-fill/orange-bold) 4=bold-label 5=number(#,##0/left) 6=text(@)
7045 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
7046 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
7047 +'<fonts count="3">'
7048 +'<font><sz val="11"/><name val="Calibri"/></font>'
7049 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
7050 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
7051 +'</fonts>'
7052 +'<fills count="4">'
7053 +'<fill><patternFill patternType="none"/></fill>'
7054 +'<fill><patternFill patternType="gray125"/></fill>'
7055 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
7056 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
7057 +'</fills>'
7058 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
7059 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
7060 +'<cellXfs count="7">'
7061 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
7062 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7063 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
7064 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
7065 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
7066 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
7067 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
7068 +'</cellXfs>'
7069 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
7070 +'</styleSheet>';
7071 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
7072 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
7073 sheets.forEach(function(sh,sheetIdx){
7074 var rx='<row r="1">';
7075 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
7076 rx+='</row>';
7077 var rn=2;
7078 sh.rows.forEach(function(row){
7079 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
7080 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
7081 rx+='<row r="'+rn+'">';
7082 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
7083 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
7084 rx+='</row>';rn++;return;
7085 }
7086 rx+='<row r="'+rn+'">';
7087 row.forEach(function(cell,c){
7088 var ref=colRef(c,rn);
7089 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
7090 if(typeof cell==='object'&&cell!==null){
7091 var cv=cell.v,cs=cell.s!=null?cell.s:0;
7092 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
7093 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
7094 return;
7095 }
7096 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
7097 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
7098 });
7099 rx+='</row>';rn++;
7100 });
7101 var cw='';
7102 if(sh.colWidths&&sh.colWidths.length>0){
7103 cw='<cols>';
7104 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
7105 cw+='</cols>';
7106 }
7107 var tblParts='';
7108 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
7109 tableCounter++;
7110 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
7111 var tRef='A1:'+colNm(colCount)+rowCount;
7112 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7113 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
7114 +'<autoFilter ref="'+tRef+'"/>'
7115 +'<tableColumns count="'+colCount+'">'
7116 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
7117 +'</tableColumns>'
7118 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
7119 +'</table>';
7120 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
7121 +'<Relationships xmlns="'+pns+'relationships">'
7122 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
7123 +'</Relationships>';
7124 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
7125 }
7126 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
7127 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
7128 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
7129 });
7130 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>';
7131 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
7132 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
7133 var ctXml='<?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"/>'+ctOver+ctTable+'<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>';
7134 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
7135 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
7136 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
7137 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
7138 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
7139 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
7140 var F={'[Content_Types].xml':ctXml,'_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':wbXml,'xl/_rels/workbook.xml.rels':wbRXml,'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml};
7141 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
7142 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
7143 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
7144 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
7145 var zparts=[],zcds=[],zoff=0,znf=0;
7146 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++;});
7147 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
7148 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]);
7149 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
7150 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
7151 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
7152 zout.set(new Uint8Array(ea),zpos);
7153 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
7154 }
7155 window.resetPerFileTable = function() {
7156 var tbl = document.getElementById('per-file-table');
7157 if (!tbl) return;
7158 var shell = tbl.closest('.table-shell');
7159 if (shell) shell.scrollLeft = 0;
7160 Array.prototype.slice.call(tbl.querySelectorAll('th')).forEach(function(th) { th.style.width = ''; });
7161 Array.prototype.slice.call(tbl.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
7162 if (window._pfPaginationReset) window._pfPaginationReset();
7163 var si = document.getElementById('per-file-search');
7164 if (si) si.value = '';
7165 };
7166 var _rh=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed Separate','Functions','Classes','Variables','Imports'];
7167 var _titleSlug="{{ title }}".replace(/[^a-zA-Z0-9\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'');
7168 var _commitSlug="{% if let Some(c) = run.git_commit_short %}{{ c }}{% endif %}";
7169 var _exportSlug='per-file_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7170 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;}
7171 window.exportReportCsv=function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());};
7172 window.exportReportXls=function(){
7173 var fname='report_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'')+'.xlsx';
7174 function sec(v){return[{_sec:true,v:v}];}
7175 function B(v){return{v:v,s:4};}
7176 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
7177 // Table cells render with thousands separators (1,656,153) via the |commas
7178 // filter; Number() on that string is NaN, which would store the value as text
7179 // (green-triangle warning, left-aligned). Strip separators so numeric cells
7180 // become real numbers and align correctly. Non-numeric text is left untouched.
7181 function numify(v){var s=String(v==null?'':v).trim();if(s==='')return s;var t=s.replace(/,/g,'');return /^-?\d+(\.\d+)?$/.test(t)?Number(t):v;}
7182 function pnum(v){var t=String(v==null?'':v).replace(/,/g,'').trim();return /^-?\d+(\.\d+)?$/.test(t)?Number(t):0;}
7183 var dens=_SLOC_META.physicalLines>0?(_SLOC_META.codeLines/_SLOC_META.physicalLines*100).toFixed(1)+'%':'0%';
7184 var sumRows=[
7185 sec('RUN INFORMATION'),
7186 [B('Run ID'),_SLOC_META.runId,''],
7187 [B('Git Commit'),_SLOC_META.gitCommit,''],
7188 [B('Branch'),_SLOC_META.branch,''],
7189 [B('Last Commit By'),_SLOC_META.lastCommitBy,''],
7190 [B('Scan By'),_SLOC_META.scanBy,''],
7191 [B('Scanned'),_SLOC_META.scanned,''],
7192 [B('OS'),_SLOC_META.os,''],
7193 [B('Files Analyzed'),N(_SLOC_META.filesAnalyzed),'Total source files included in this analysis'],
7194 [B('Files Skipped'),N(_SLOC_META.filesSkipped),'Files excluded (binary, unsupported, or policy-filtered)'],
7195 [],
7196 sec('CODE METRICS'),
7197 [B('Physical Lines'),N(_SLOC_META.physicalLines),'Total lines including code, comments, and blanks'],
7198 [B('Code Lines'),N(_SLOC_META.codeLines),'Lines containing executable source code'],
7199 [B('Comments'),N(_SLOC_META.commentLines),'Lines consisting entirely of comments or documentation'],
7200 [B('Blank Lines'),N(_SLOC_META.blankLines),'Empty or whitespace-only lines'],
7201 [B('Mixed Separate'),N(_SLOC_META.mixedSeparate),'Lines with both code and trailing comment, counted separately'],
7202 [B('Functions'),N(_SLOC_META.functions),'Best-effort count of function/method definitions'],
7203 [B('Classes / Types'),N(_SLOC_META.classes),'Best-effort count of class, struct, interface definitions'],
7204 [B('Variables'),N(_SLOC_META.variables),'Best-effort count of variable and constant declarations'],
7205 [B('Imports'),N(_SLOC_META.imports),'Best-effort count of import, include, module-use statements'],
7206 [B('Tests'),N(_SLOC_META.tests),'Best-effort count of test cases (GTest, PyTest, JUnit, etc.)'],
7207 [B('Code Density'),{v:dens,s:6},'Percentage of physical lines that contain executable source code'],
7208 [B('Tool Version'),'oxide-sloc '+_SLOC_META.toolVersion,''],
7209 ];
7210 var langHdrs=['Language','Files','Physical Lines','Code Lines','Comments','Blank Lines','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7211 var langRows=[];
7212 document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7213 var tds=tr.querySelectorAll('td');
7214 var row=[];
7215 Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>0?numify(v):v);});
7216 langRows.push(row);
7217 });
7218 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Mixed','Functions','Classes','Variables','Imports','Tests','Assertions','Suites'];
7219 var pfRows=[];
7220 document.querySelectorAll('#per-file-table tbody tr').forEach(function(tr){
7221 var tds=tr.querySelectorAll('td');
7222 if(tds.length<11)return;
7223 var row=[];
7224 Array.prototype.forEach.call(tds,function(td,i){var v=td.textContent.trim();row.push(i>=2?numify(v):v);});
7225 pfRows.push(row);
7226 });
7227 var skHdrs=['File','Status','Warnings'];
7228 var skRows=[];
7229 document.querySelectorAll('#skipped-table tbody tr').forEach(function(tr){
7230 var tds=tr.querySelectorAll('td');
7231 if(tds.length<3)return;
7232 skRows.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim()]);
7233 });
7234 var covHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
7235 var covRows=[];
7236 document.querySelectorAll('#lang-breakdown-table tbody tr').forEach(function(tr){
7237 var tds=tr.querySelectorAll('td');
7238 if(tds.length<4)return;
7239 var phys=pnum(tds[2].textContent);
7240 var code=pnum(tds[3].textContent);
7241 var densStr=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
7242 var row=[tds[0].textContent.trim(),pnum(tds[1].textContent),phys,code,{v:densStr,s:6}];
7243 for(var i=7;i<Math.min(tds.length,14);i++){row.push(numify(tds[i].textContent.trim()));}
7244 covRows.push(row);
7245 });
7246 slocXlsMulti(fname,[
7247 {name:'Summary',hdrs:['Field / Metric','Value','Description'],rows:sumRows,colWidths:[22,45,55],isKv:true},
7248 {name:'Language Breakdown',hdrs:langHdrs,rows:langRows,colWidths:[16,8,14,12,12,12,8,10,10,10,10,8,10,8]},
7249 {name:'Per-File Detail',hdrs:pfHdrs,rows:pfRows,colWidths:[50,12,12,12,12,10,8,10,10,10,10,8,10,8]},
7250 {name:'Code Coverage',hdrs:covHdrs,rows:covRows,colWidths:[18,7,14,12,13,11,10,10,10,8,11,12]},
7251 {name:'Skipped Files',hdrs:skHdrs,rows:skRows,colWidths:[60,25,50]}
7252 ]);
7253 };
7254 Array.prototype.slice.call(document.querySelectorAll('[data-export-csv]')).forEach(function(btn){btn.addEventListener('click',function(){slocCsv(_exportSlug+'.csv',_rh,getReportExportRows());});});
7255 Array.prototype.slice.call(document.querySelectorAll('[data-export-xls]')).forEach(function(btn){btn.addEventListener('click',window.exportReportXls);});
7256 Array.prototype.slice.call(document.querySelectorAll('[data-reset-table]')).forEach(function(btn){btn.addEventListener('click',window.resetPerFileTable);});
7257 var _skippedRh=['File','Status','Warnings'];
7258 var _skippedSlug='skipped_'+_titleSlug+(_commitSlug?'_'+_commitSlug:'');
7259 function getSkippedExportRows(){var r=[];document.querySelectorAll('#skipped-table tbody tr').forEach(function(tr){var tds=tr.querySelectorAll('td');if(tds.length<3)return;r.push([tds[0].textContent.trim(),tds[1].textContent.trim(),tds[2].textContent.trim()]);});return r;}
7260 (function(){var b=document.getElementById('skipped-export-csv');if(b)b.addEventListener('click',function(){slocCsv(_skippedSlug+'.csv',_skippedRh,getSkippedExportRows());});})();
7261 (function(){var b=document.getElementById('skipped-export-xls');if(b)b.addEventListener('click',function(){slocXls(_skippedSlug+'.xlsx','Skipped Files',_skippedRh,getSkippedExportRows());});})();
7262 // ── Chart.js initialization ───────────────────────────────────────────────
7263 // Deferred so the browser can repaint (dismiss the loading overlay) before
7264 // the canvas/SVG chart work blocks the main thread.
7265 requestAnimationFrame(function() {
7266 try {
7267 (function() {
7268 var D = {{ lang_chart_json|safe }};
7269 var SUB_D = {{ submodule_chart_json|safe }};
7270 var SCAT_D = {{ scatter_chart_json|safe }};
7271 var SEM_D = {{ semantic_chart_json|safe }};
7272 var HIST_D = {{ file_size_histogram_json|safe }};
7273 if (!D || !D.length) return;
7274
7275 var PALETTE = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030',
7276 '#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082',
7277 '#D0743C','#5BA8A0','#8B3A8B','#3D7A3D','#AA5500','#005599'];
7278 var OX = '#C45C10', GN = '#2A6846', GY = '#BBBBBB';
7279 var ALL_CHARTS = [];
7280 function hexAlpha(hex, a) {
7281 var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
7282 return 'rgba('+r+','+g+','+b+','+a+')';
7283 }
7284
7285 function fmt(n) {
7286 var v = Number(n), a = Math.abs(v);
7287 if (a >= 1e6) return (v/1e6).toFixed(1).replace(/\.0$/,'') + 'M';
7288 if (a >= 1e4) return Math.round(v/1e3) + 'K';
7289 return v.toLocaleString();
7290 }
7291 function isDark() { return document.body.classList.contains('dark-theme'); }
7292 function clr() {
7293 return isDark()
7294 ? { text: '#d4c5b8', grid: 'rgba(255,255,255,0.10)' }
7295 : { text: '#43342d', grid: '#e6d0bf' };
7296 }
7297 // Inline Chart.js plugin: draws a permanent value label on each bar / bubble.
7298 // fmtFn(rawValue, datasetIndex, pointIndex) → string | null
7299 // anchor: 'top' = above vertical bar, 'end' = right of horizontal bar, 'bubble' = above bubble
7300 function makeDlPlugin(fmtFn, anchor) {
7301 return {
7302 afterDatasetsDraw: function(chart) {
7303 var ctx = chart.ctx;
7304 var tc = clr().text;
7305 chart.data.datasets.forEach(function(ds, di) {
7306 var meta = chart.getDatasetMeta(di);
7307 meta.data.forEach(function(el, idx) {
7308 var label = fmtFn(ds.data[idx], di, idx);
7309 if (label == null || label === '') return;
7310 ctx.save();
7311 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
7312 ctx.fillStyle = tc;
7313 if (anchor === 'top') {
7314 ctx.textAlign = 'center';
7315 ctx.textBaseline = 'bottom';
7316 ctx.fillText(String(label), el.x, el.y - 3);
7317 } else if (anchor === 'end') {
7318 ctx.textAlign = 'left';
7319 ctx.textBaseline = 'middle';
7320 ctx.fillText(String(label), el.x + 5, el.y);
7321 } else {
7322 ctx.textAlign = 'center';
7323 ctx.textBaseline = 'bottom';
7324 var r = (el.options && el.options.radius) ? el.options.radius : 10;
7325 ctx.fillText(String(label), el.x, el.y - r - 3);
7326 }
7327 ctx.restore();
7328 });
7329 });
7330 }
7331 };
7332 }
7333 function makeStackedEndPlugin(fmtFn) {
7334 return {
7335 afterDatasetsDraw: function(chart) {
7336 var ctx = chart.ctx;
7337 var tc = clr().text;
7338 var nDs = chart.data.datasets.length;
7339 if (nDs === 0) return;
7340 var lastMeta = chart.getDatasetMeta(nDs - 1);
7341 lastMeta.data.forEach(function(el, idx) {
7342 var total = 0;
7343 chart.data.datasets.forEach(function(ds) { total += ds.data[idx] || 0; });
7344 var label = fmtFn(total, idx);
7345 if (label == null || label === '') return;
7346 ctx.save();
7347 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
7348 ctx.fillStyle = tc;
7349 ctx.textAlign = 'left';
7350 ctx.textBaseline = 'middle';
7351 ctx.fillText(String(label), el.x + 5, el.y);
7352 ctx.restore();
7353 });
7354 }
7355 };
7356 }
7357
7358 function wireDonutLegend(svg) {
7359 if(!svg) return;
7360 var paths=svg.querySelectorAll('path[data-lang]');
7361 function hl(lang){for(var i=0;i<paths.length;i++){if(paths[i].getAttribute('data-lang')===lang){paths[i].style.filter='brightness(1.18) drop-shadow(0 2px 8px rgba(0,0,0,.25))';paths[i].style.transform='scale(1.05)';paths[i].style.opacity='1';}else{paths[i].style.opacity='0.32';paths[i].style.filter='none';paths[i].style.transform='none';}}}
7362 function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
7363 svg.addEventListener('mouseover',function(e){var t=e.target;while(t&&t!==svg){var l=t.getAttribute&&t.getAttribute('data-lang');if(l){hl(l);return;}t=t.parentNode;}rst();});
7364 svg.addEventListener('mousemove',function(e){var t=e.target;while(t&&t!==svg){if(t.getAttribute&&t.getAttribute('data-lang'))return;t=t.parentNode;}rst();});
7365 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
7366 }
7367 function wireMixLegend(svg) {
7368 if(!svg) return;
7369 var legGs=svg.querySelectorAll('g[data-kind]');
7370 var allRects=svg.querySelectorAll('rect[data-kind]');
7371 if(!legGs.length) return;
7372 function hlKind(kind) {
7373 for(var i=0;i<allRects.length;i++){var r=allRects[i];if(r.getAttribute('data-kind')===kind){r.style.opacity='1';r.style.filter='brightness(1.18) drop-shadow(0 2px 6px rgba(0,0,0,.22))';}else{r.style.opacity='0.18';r.style.filter='none';}}
7374 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}
7375 }
7376 function rst(){for(var i=0;i<allRects.length;i++){allRects[i].style.opacity='';allRects[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
7377 for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hlKind(g.getAttribute('data-kind'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
7378 }
7379
7380 // ── Language overview: SVG donut + horizontal stacked bars ───────────────
7381 (function() {
7382 var el = document.getElementById('report-lang-overview');
7383 if (!el || !D || !D.length) return;
7384 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7385 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
7386 function px(n){return Math.round(n);}
7387 function tt(label,val){return ' class="rchit" data-ttl="'+String(label).replace(/&/g,'&').replace(/"/g,'"')+'" data-ttv="'+String(val).replace(/&/g,'&').replace(/"/g,'"')+'"';}
7388 var tot = D.reduce(function(a,d){return a+d.code;},0)||1;
7389 // Donut — height matches the stacked-bar chart so both panels align
7390 var rHb_d=28;
7391 var DH=Math.max(220,D.length*rHb_d+32);
7392 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48,legX=208,DW=395;
7393 var legCount=D.length;
7394 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
7395 var legYStart=Math.round((DH-legCount*legSpacing)/2);
7396 var ds='<svg viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
7397 if(D.length===1){
7398 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
7399 ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+PALETTE[0]+'" stroke-width="'+rsw+'"/>';
7400 } else {
7401 var smalls=[];
7402 var ang=-Math.PI/2;
7403 D.forEach(function(d,i){
7404 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
7405 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
7406 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
7407 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
7408 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
7409 var pct=Math.round(d.code/tot*100);
7410 ds+='<path'+tt(d.lang,fmt(d.code)+' code lines ('+pct+'%)')+' data-lang="'+esc(d.lang)+'" 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="'+(PALETTE[i%PALETTE.length])+'" stroke="white" stroke-width="2"/>';
7411 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text x="'+px(cx+mR*Math.cos(mAng))+'" y="'+px(cy+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white" style="pointer-events:none;">'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:PALETTE[i%PALETTE.length]});}
7412 ang+=sw;
7413 });
7414 // Small slices (<5%) get outside labels positioned near each slice's own
7415 // angular position (a slice on the left gets its label/leader on the left),
7416 // then nudged apart horizontally so text never overlaps. Leader lines point
7417 // from each slice to its label. Horizontal text keeps long names legible;
7418 // the whole SVG scales up in Full View so these stay readable there too.
7419 if(smalls.length){
7420 smalls.sort(function(a,b){return a.mAng-b.mAng;});
7421 var sPad=6,sRowY=11;
7422 smalls.forEach(function(sm){sm.txt=sm.lang+' '+sm.pct+'%';sm.w=sm.txt.length*5+8;sm.x=Math.max(sPad+sm.w/2,Math.min(DW-sPad-sm.w/2,cx+(Ro+14)*Math.cos(sm.mAng)));});
7423 for(var si=1;si<smalls.length;si++){var mnX=smalls[si-1].x+smalls[si-1].w/2+smalls[si].w/2+3;if(smalls[si].x<mnX)smalls[si].x=mnX;}
7424 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
7425 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
7426 smalls.forEach(function(sm){
7427 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
7428 ds+='<line x1="'+px(axx)+'" y1="'+px(ayy)+'" x2="'+px(sm.x)+'" y2="'+px(sRowY+4)+'" stroke="'+sm.col+'" stroke-width="1" opacity="0.5" style="pointer-events:none;"/>';
7429 ds+='<text x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="pointer-events:none;">'+esc(sm.txt)+'</text>';
7430 });
7431 }
7432 }
7433 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
7434 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
7435 D.forEach(function(d,i){
7436 var ly=legYStart+i*legSpacing;
7437 var pctL=Math.round(d.code/tot*100);
7438 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
7439 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
7440 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
7441 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
7442 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(PALETTE[i%PALETTE.length])+'"/>';
7443 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
7444 ds+='<text x="'+(legX+100)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(10,legSpacing-3)+'" font-weight="700" fill="#7b675b">'+fmt(d.code)+' ('+pctL+'%)</text>';
7445 ds+='</g>';
7446 });
7447 ds+='</svg>';
7448 // Horizontal stacked-bar chart
7449 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
7450 var LW=108,BW=260,svgW=LW+BW+68;
7451 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
7452 var barBH=Math.min(32,Math.round(barRhb*0.7));
7453 var SH=DH;
7454 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
7455 var bs='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
7456 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
7457 // it cannot fit legibly even at the 6.5 floor (labels shrink to fit instead
7458 // of disappearing; the SVG scales up in Full View so small fonts stay legible).
7459 function fitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
7460 D.forEach(function(d,i){
7461 var y=barTopPad+i*barRhb,x=LW;
7462 var phys=d.physical||d.code+d.comments+d.blanks;
7463 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
7464 var lmid=y+barBH/2+4;
7465 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
7466 bs+='<g class="lang-bar-row">';
7467 // Hit area ends just past the total label so empty space to the right of the
7468 // bar does not trigger the tooltip — only the name, bar and total are hot.
7469 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
7470 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
7471 bs+='<text'+tt(d.lang,ttv)+' x="'+(LW-6)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+esc(d.lang)+'</text>';
7472 if(cW>0.5){bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+barBH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code),cW);if(_fc)bs+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code)+'</text>';x+=cW;}
7473 if(cmW>0.5){bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+barBH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments),cmW);if(_fm)bs+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments)+'</text>';x+=cmW;}
7474 if(blW>0.5){bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+barBH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks),blW);if(_fb)bs+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks)+'</text>';}
7475 bs+='<text'+tt(d.lang,ttv)+' x="'+px(LW+phys/maxT*BW+8)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
7476 bs+='</g>';
7477 });
7478 var ly=SH-14;
7479 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
7480 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
7481 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
7482 var totAll=totC+totCm+totBl||1;
7483 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
7484 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
7485 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
7486 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
7487 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
7488 bs+='<g data-kind="code" style="cursor:pointer;">'
7489 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
7490 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
7491 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
7492 +'</g>';
7493 bs+='<g data-kind="comment" style="cursor:pointer;">'
7494 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
7495 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
7496 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
7497 +'</g>';
7498 bs+='<g data-kind="blank" style="cursor:pointer;">'
7499 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
7500 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
7501 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
7502 +'</g>';
7503 bs+='</svg>';
7504 el.innerHTML='<div class="r-lang-overview">'+
7505 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
7506 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
7507 '</div>';
7508 wireDonutLegend(el.querySelector('svg'));
7509 wireMixLegend(el.querySelectorAll('svg')[1]);
7510 })();
7511
7512 // Shared cursor helper: pointer over data elements, default elsewhere.
7513 // Added to options.onHover on every Chart.js instance.
7514 // Legend items handled separately via legend.onHover / legend.onLeave.
7515 function chartCursor(e, els) {
7516 var t = e.native && e.native.target;
7517 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
7518 }
7519 function legendCursorOn(e) { var t=e.native&&e.native.target; if(t)t.style.cursor='pointer'; }
7520 function legendCursorOff(e){ var t=e.native&&e.native.target; if(t)t.style.cursor='default'; }
7521 // Pushes a right-positioned legend away from the plot by `gap` px. Chart.js
7522 // (v4) places a right legend flush against the plot area: fit() reserves the
7523 // legend box width and _draw() lays items out from `this.left + padding`, so
7524 // the column hugs the bubbles. We reserve `gap` extra width in fit() (which
7525 // shrinks the plot by `gap`), then translate the canvas right by `gap` while
7526 // the legend draws so the column lands in that reserved space — clear of the
7527 // plot. The legendHitBoxes (used only for hover hit-testing, not drawing) are
7528 // shifted by the same `gap` so hover targets stay aligned with what's drawn.
7529 function legendGapPlugin(gap) {
7530 return {
7531 id: 'legendGap',
7532 beforeInit: function(chart) {
7533 var lg = chart.legend; if (!lg) return;
7534 var origFit = lg.fit, origDraw = lg.draw;
7535 lg.fit = function() { origFit.call(this); this.width += gap; this._needGap = true; };
7536 lg.draw = function() {
7537 if (this._needGap && this.legendHitBoxes) {
7538 this.legendHitBoxes.forEach(function(h){ h.left += gap; });
7539 this._needGap = false;
7540 }
7541 var ctx = this.ctx;
7542 ctx.save();
7543 ctx.translate(gap, 0);
7544 origDraw.call(this);
7545 ctx.restore();
7546 };
7547 }
7548 };
7549 }
7550
7551 // ── Project Overview bar ─────────────────────────────────────────────────
7552 var projChart = null;
7553 (function() {
7554 var ySel = document.getElementById('overview-y-axis');
7555 var xSel = document.getElementById('overview-x-mode');
7556 var el = document.getElementById('overview-chart');
7557 var lockedEl = document.getElementById('overview-chart-locked');
7558 var wrap = document.getElementById('canvas-proj-wrap');
7559 var canvas = document.getElementById('canvas-proj');
7560 if (!canvas || !ySel || !xSel) return;
7561 var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines',
7562 physical:'Physical Lines', files:'Files', comment:'Comment Lines', blank:'Blank Lines' };
7563 function getData() {
7564 var yKey = ySel.value, mode = xSel.value;
7565 var src = mode === 'submodules' ? SUB_D : D;
7566 var lKey = mode === 'submodules' ? 'name' : 'lang';
7567 var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
7568 return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
7569 }
7570 function renderOverview() {
7571 var mode = xSel.value, isHist = mode.indexOf('history') === 0;
7572 if (el) el.style.display = isHist ? 'none' : 'block';
7573 if (lockedEl) lockedEl.style.display = isHist ? 'block' : 'none';
7574 if (isHist) return;
7575 var r = getData();
7576 var c = clr();
7577 if (wrap) wrap.style.height = Math.max(200, Math.min(432, r.sorted.length * 29 + 60)) + 'px';
7578 if (projChart) {
7579 projChart.data.labels = r.sorted.map(function(d){return d[r.lKey];});
7580 projChart.data.datasets[0].data = r.sorted.map(function(d){return d[r.yKey]||0;});
7581 projChart.data.datasets[0].backgroundColor = r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
7582 projChart.data.datasets[0].label = r.yLabel;
7583 projChart.options.scales.x.title.text = r.yLabel;
7584 projChart.update('none'); return;
7585 }
7586 projChart = new Chart(canvas, {
7587 type: 'bar',
7588 data: {
7589 labels: r.sorted.map(function(d){return d[r.lKey];}),
7590 datasets: [{ label: r.yLabel,
7591 data: r.sorted.map(function(d){return d[r.yKey]||0;}),
7592 backgroundColor: r.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
7593 borderRadius: 3 }]
7594 },
7595 options: {
7596 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
7597 onHover: chartCursor,
7598 animation: { duration: 500, easing: 'easeOutQuart' },
7599 layout: { padding: { right: 64 } },
7600 scales: {
7601 x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
7602 title: { display: true, text: r.yLabel, color: c.text } },
7603 y: { grid: { display: false }, ticks: { color: c.text } }
7604 },
7605 plugins: {
7606 legend: { display: false },
7607 tooltip: {
7608 callbacks: {
7609 title: function(items) { return items.length ? items[0].label : ''; },
7610 label: function(ctx) {
7611 return ' ' + ctx.dataset.label + ': ' + Number(ctx.parsed.x).toLocaleString();
7612 }
7613 }
7614 }
7615 }
7616 },
7617 plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
7618 });
7619 ALL_CHARTS.push(projChart);
7620 }
7621 ySel.addEventListener('change', renderOverview);
7622 xSel.addEventListener('change', renderOverview);
7623 renderOverview();
7624
7625 var overviewExpandBtn = document.getElementById('overview-expand-btn');
7626 if (overviewExpandBtn) {
7627 overviewExpandBtn.addEventListener('click', function() {
7628 var r = getData();
7629 var n = r.sorted.length || 1;
7630 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
7631 var modalH = Math.min(Math.max(480, n * 29 + 96), maxH);
7632 var overlay = document.createElement('div');
7633 overlay.className = 'chart-modal-overlay';
7634 overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;">'
7635 + '<button class="chart-modal-close" aria-label="Close">×</button>'
7636 + '<div class="chart-modal-header">'
7637 + '<span class="chart-modal-title">Project Overview \u2014 Full View</span>'
7638 + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Y Axis:'
7639 + '<select id="ov-modal-y" class="chart-select">'
7640 + '<option value="code">Code Lines</option>'
7641 + '<option value="comments">Comment Lines</option>'
7642 + '<option value="blanks">Blank Lines</option>'
7643 + '<option value="physical">Total Physical Lines</option>'
7644 + '<option value="files">File Count</option>'
7645 + '</select></label>'
7646 + (SUB_D && SUB_D.length ? '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;">X Axis:'
7647 + '<select id="ov-modal-x" class="chart-select">'
7648 + '<option value="languages">Languages</option>'
7649 + '<option value="submodules">Submodules</option>'
7650 + '</select></label>' : '')
7651 + '</div>'
7652 + '<div style="position:relative;height:' + modalH + 'px;width:100%;"><canvas id="canvas-proj-modal"></canvas></div></div>';
7653 document.body.appendChild(overlay);
7654 overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
7655 overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
7656 var Y_LABELS = { code:'Code Lines', comments:'Comment Lines', blanks:'Blank Lines', physical:'Physical Lines', files:'Files' };
7657 var modalYSel = document.getElementById('ov-modal-y');
7658 var modalXSel = document.getElementById('ov-modal-x');
7659 if (modalYSel) modalYSel.value = ySel ? ySel.value : 'code';
7660 if (modalXSel && xSel) modalXSel.value = (xSel.value === 'languages' || xSel.value === 'submodules') ? xSel.value : 'languages';
7661 var modalCanvas = document.getElementById('canvas-proj-modal');
7662 if (!modalCanvas) return;
7663 var c = clr();
7664 function getModalData() {
7665 var yKey = modalYSel ? modalYSel.value : 'code';
7666 var mode = modalXSel ? modalXSel.value : 'languages';
7667 var src = mode === 'submodules' ? SUB_D : D;
7668 var lKey = mode === 'submodules' ? 'name' : 'lang';
7669 var sorted = src.slice().sort(function(a,b){ return (b[yKey]||0)-(a[yKey]||0); });
7670 return { sorted: sorted, lKey: lKey, yKey: yKey, yLabel: Y_LABELS[yKey]||yKey };
7671 }
7672 var ovModalChart = null;
7673 function renderOverviewModal() {
7674 var r2 = getModalData();
7675 if (ovModalChart) {
7676 ovModalChart.data.labels = r2.sorted.map(function(d){return d[r2.lKey];});
7677 ovModalChart.data.datasets[0].data = r2.sorted.map(function(d){return d[r2.yKey]||0;});
7678 ovModalChart.data.datasets[0].backgroundColor = r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];});
7679 ovModalChart.data.datasets[0].label = r2.yLabel;
7680 ovModalChart.options.scales.x.title.text = r2.yLabel;
7681 ovModalChart.update('none'); return;
7682 }
7683 ovModalChart = new Chart(modalCanvas, {
7684 type: 'bar',
7685 data: {
7686 labels: r2.sorted.map(function(d){return d[r2.lKey];}),
7687 datasets: [{ label: r2.yLabel,
7688 data: r2.sorted.map(function(d){return d[r2.yKey]||0;}),
7689 backgroundColor: r2.sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}),
7690 borderRadius: 3 }]
7691 },
7692 options: {
7693 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
7694 onHover: chartCursor,
7695 animation: { duration: 500, easing: 'easeOutQuart' },
7696 layout: { padding: { right: 64 } },
7697 scales: {
7698 x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
7699 title:{display:true, text:r2.yLabel, color:c.text} },
7700 y: { grid:{display:false}, ticks:{color:c.text} }
7701 },
7702 plugins: {
7703 legend:{display:false},
7704 tooltip:{callbacks:{
7705 title:function(items){return items.length?items[0].label:'';},
7706 label:function(ctx){return ' '+ctx.dataset.label+': '+Number(ctx.parsed.x).toLocaleString();}
7707 }}
7708 }
7709 },
7710 plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end')]
7711 });
7712 }
7713 renderOverviewModal();
7714 if (modalYSel) modalYSel.addEventListener('change', renderOverviewModal);
7715 if (modalXSel) modalXSel.addEventListener('change', renderOverviewModal);
7716 });
7717 }
7718 })();
7719
7720 // ── Language Composition (SVG — matches /runs/result behaviour) ──────────
7721 (function() {
7722 var el = document.getElementById('comp-svg-container');
7723 if (!el || !D || !D.length) return;
7724 var cData = D.slice(0, 15);
7725 var cMode = 'absolute';
7726 var CX = OX, CG = GN, CB = '#BBBBBB';
7727 var CFONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
7728 function cEsc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
7729 function cPx(n){return Math.round(n);}
7730 function cTT(l,v){return ' class="rchit" data-ttl="'+String(l).replace(/&/g,'&').replace(/"/g,'"')+'" data-ttv="'+String(v).replace(/&/g,'&').replace(/"/g,'"')+'"';}
7731 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if it
7732 // cannot fit legibly even at the 6.5 floor (labels shrink to fit rather than
7733 // disappear; the SVG scales up in Full View so small fonts stay legible).
7734 function cFitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
7735 function cLT(l,v){return ' data-ttl="'+l+'" data-ttv="'+v.replace(/"/g,'"')+'"';}
7736 function renderCompSVG() {
7737 var isPct = cMode === 'pct';
7738 var totC=cData.reduce(function(a,d){return a+(d.code||0);},0);
7739 var totCm=cData.reduce(function(a,d){return a+(d.comments||0);},0);
7740 var totBl=cData.reduce(function(a,d){return a+(d.blanks||0);},0);
7741 var totAll=totC+totCm+totBl||1;
7742 var svgW=Math.max(320,el.offsetWidth||540);
7743 var LW=108,legendH=24,topPad=4;
7744 var MIN_SVG_H=220;
7745 var rHb=Math.min(80,Math.max(26,Math.floor((MIN_SVG_H-legendH-topPad-10)/cData.length)));
7746 var bH=Math.min(38,Math.round(rHb*0.68));
7747 var BW=Math.max(120,svgW-LW-84);
7748 var SH=Math.max(MIN_SVG_H,cData.length*rHb+legendH+topPad+10);
7749 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
7750 if(isPct){
7751 cData.forEach(function(d,i){
7752 var t2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
7753 var cW=(d.code||0)/t2*BW,cmW=(d.comments||0)/t2*BW,blW=(d.blanks||0)/t2*BW;
7754 var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
7755 var lmid=y+Math.floor(bH/2)+4;
7756 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||t2);
7757 s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+CFONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+cEsc(d.lang)+'</text>';
7758 if(cW>0.5){s+='<rect'+cTT(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cW)+'" height="'+bH+'" fill="'+CX+'"/>';var _fc=cFitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+cPx(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
7759 if(cmW>0.5){s+='<rect'+cTT(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cmW)+'" height="'+bH+'" fill="'+CG+'"/>';var _fm=cFitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+cPx(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
7760 if(blW>0.5){s+='<rect'+cTT(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(blW)+'" height="'+bH+'" fill="'+CB+'"/>';var _fb=cFitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+cPx(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
7761 s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+CFONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+Math.round((d.code||0)/t2*100)+'%</text>';
7762 });
7763 } else {
7764 var maxT=Math.max.apply(null,cData.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);})) || 1;
7765 cData.forEach(function(d,i){
7766 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
7767 var y=topPad+i*rHb+Math.floor((rHb-bH)/2),x=LW;
7768 var lmid=y+Math.floor(bH/2)+4;
7769 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0));
7770 s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+CFONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+cEsc(d.lang)+'</text>';
7771 if(cW>0.5){s+='<rect'+cTT(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cW)+'" height="'+bH+'" fill="'+CX+'"/>';var _fc=cFitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+cPx(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
7772 if(cmW>0.5){s+='<rect'+cTT(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(cmW)+'" height="'+bH+'" fill="'+CG+'"/>';var _fm=cFitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+cPx(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
7773 if(blW>0.5){s+='<rect'+cTT(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+cPx(x)+'" y="'+y+'" width="'+cPx(blW)+'" height="'+bH+'" fill="'+CB+'"/>';var _fb=cFitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+cPx(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+CFONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
7774 var phys=d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0);
7775 s+='<text'+cTT(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+CFONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
7776 });
7777 }
7778 var ly=SH-legendH+4;
7779 var ttC=cLT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
7780 var ttCm=cLT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
7781 var ttBl=cLT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
7782 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
7783 s+='<g data-kind="code" style="cursor:pointer;"><rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/><rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+CX+'"'+ttC+'/><text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+CFONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text></g>';
7784 s+='<g data-kind="comment" style="cursor:pointer;"><rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/><rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+CG+'"'+ttCm+'/><text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+CFONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text></g>';
7785 s+='<g data-kind="blank" style="cursor:pointer;"><rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/><rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+CB+'"'+ttBl+'/><text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+CFONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text></g>';
7786 s+='</svg>';
7787 el.innerHTML=s;
7788 wireMixLegend(el.querySelector('svg'));
7789 }
7790 document.querySelectorAll('[data-comp-tab]').forEach(function(btn){
7791 btn.addEventListener('click', function(){
7792 document.querySelectorAll('[data-comp-tab]').forEach(function(b){b.classList.remove('active');});
7793 btn.classList.add('active');
7794 cMode=btn.getAttribute('data-comp-tab');
7795 renderCompSVG();
7796 });
7797 });
7798 renderCompSVG();
7799 window.addEventListener('resize', renderCompSVG);
7800 })();
7801
7802 // Custom HTML legend for the bubble chart: balanced columns (~15 rows max
7803 // per column, evenly distributed) placed to the right of the canvas. Chart.js'
7804 // native right-legend fills one column to full height then dumps the rest into
7805 // a tiny second column (and clips when space is tight) — this gives even columns
7806 // and never hides languages. Hover mirrors the old dim/highlight behaviour.
7807 //
7808 // Must run BEFORE `new Chart(canvas, …)` so Chart.js' resize observer binds to
7809 // the inner wrapper (not the full-width host). Returns a holder whose `.chart`
7810 // field the caller assigns once the chart exists, so hover can drive it.
7811 // expandBtnId: when set (compact card), the legend is capped to what fits in
7812 // 2 columns at the available height and a trailing "+N more" row links to Full
7813 // View. When null (Full View itself) every language is shown across (up to) 2
7814 // tall columns. Languages are ordered by code lines so the compact view keeps
7815 // the biggest ones; colours/hover still key off each language's original index.
7816 function attachScatterLegend(canvas, expandBtnId) {
7817 var holder = { chart: null };
7818 var host = canvas && canvas.parentNode;
7819 if (!host) return holder;
7820 host.style.display = 'flex';
7821 host.style.alignItems = 'center';
7822 host.style.gap = '12px';
7823 var cwrap = document.createElement('div');
7824 cwrap.style.cssText = 'position:relative;flex:1 1 auto;min-width:0;height:100%;';
7825 host.insertBefore(cwrap, canvas);
7826 cwrap.appendChild(canvas);
7827
7828 var n = SCAT_D.length;
7829 var availH = Math.max(120, host.clientHeight || 224);
7830 var rowsFit = Math.max(2, Math.floor(availH / 18)); // readable pitch
7831 // Never more than 2 columns; compact view truncates to fit, Full View shows all.
7832 var truncated = expandBtnId ? (n > 2 * rowsFit) : false;
7833 var realShown = truncated ? (2 * rowsFit - 1) : n;
7834 var totalItems = truncated ? (2 * rowsFit) : n;
7835 // Split into 2 equal columns once a single column would exceed ~18 rows, even
7836 // when the (tall) Full-View modal could fit them all in one column.
7837 var cols = totalItems > Math.min(rowsFit, 18) ? 2 : 1;
7838 var perCol = Math.ceil(totalItems / cols);
7839 var rowH = Math.max(14, Math.min(30, Math.floor(availH / perCol)));
7840
7841 // Order by code lines desc so the compact view keeps the biggest languages.
7842 var order = SCAT_D.map(function(_, i){ return i; })
7843 .sort(function(a, b){ return (SCAT_D[b].code || 0) - (SCAT_D[a].code || 0); });
7844
7845 var leg = document.createElement('div');
7846 leg.style.cssText = 'flex:0 0 auto;display:grid;grid-auto-flow:column;'
7847 + 'grid-template-rows:repeat(' + perCol + ',' + rowH + 'px);column-gap:18px;'
7848 + 'align-content:center;font-size:12px;line-height:1;';
7849 function setHi(idx) {
7850 var chart = holder.chart; if (!chart) return;
7851 chart.data.datasets.forEach(function(ds, i) {
7852 var b = PALETTE[i % PALETTE.length];
7853 ds.backgroundColor = i === idx ? b + 'b8' : b + '20';
7854 ds.borderColor = i === idx ? b : b + '30';
7855 });
7856 chart.setActiveElements([{ datasetIndex: idx, index: 0 }]);
7857 chart.update();
7858 }
7859 function clearHi() {
7860 var chart = holder.chart; if (!chart) return;
7861 chart.data.datasets.forEach(function(ds, i) {
7862 var b = PALETTE[i % PALETTE.length];
7863 ds.backgroundColor = b + 'b8';
7864 ds.borderColor = b;
7865 });
7866 chart.setActiveElements([]);
7867 chart.update('none');
7868 }
7869 function addItem(swColor, label, idx, isMore) {
7870 var it = document.createElement('div');
7871 it.style.cssText = 'display:flex;align-items:center;gap:7px;white-space:nowrap;'
7872 + ((idx != null || isMore) ? 'cursor:pointer;' : '');
7873 var sw = document.createElement('span');
7874 sw.style.cssText = 'width:22px;height:12px;border-radius:2px;flex:0 0 auto;background:'
7875 + swColor + ';' + (isMore ? 'opacity:0.45;' : '');
7876 var tx = document.createElement('span');
7877 tx.textContent = label;
7878 if (isMore) { tx.style.fontStyle = 'italic'; tx.style.opacity = '0.8'; }
7879 it.appendChild(sw); it.appendChild(tx);
7880 if (idx != null) {
7881 it.addEventListener('mouseenter', function(){ setHi(idx); });
7882 it.addEventListener('mouseleave', clearHi);
7883 }
7884 if (isMore) {
7885 it.addEventListener('click', function(){
7886 var b = document.getElementById(expandBtnId); if (b) b.click();
7887 });
7888 }
7889 leg.appendChild(it);
7890 }
7891 for (var k = 0; k < realShown; k++) {
7892 var oi = order[k];
7893 addItem(PALETTE[oi % PALETTE.length], SCAT_D[oi].lang, oi, false);
7894 }
7895 if (truncated) addItem('#9a8c82', '+' + (n - realShown) + ' more — Full View', null, true);
7896 host.appendChild(leg);
7897 return holder;
7898 }
7899
7900 // ── Scatter / Bubble chart ────────────────────────────────────────────────
7901 (function() {
7902 var canvas = document.getElementById('canvas-scatter');
7903 if (!canvas || !SCAT_D || !SCAT_D.length) return;
7904 var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
7905 var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
7906 var c = clr();
7907 var legHolder = attachScatterLegend(canvas, 'scatter-expand-btn');
7908 var chart = new Chart(canvas, {
7909 type: 'bubble',
7910 data: {
7911 datasets: SCAT_D.map(function(d, i) {
7912 return {
7913 label: d.lang,
7914 data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
7915 backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
7916 borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
7917 hoverBorderWidth: 2
7918 };
7919 })
7920 },
7921 options: {
7922 responsive: true, maintainAspectRatio: false,
7923 onHover: chartCursor,
7924 animation: { duration: 500, easing: 'easeOutQuart' },
7925 layout: { padding: { top: 44, right: 12 } },
7926 scales: {
7927 x: { type: 'logarithmic', min: 0.8, max: maxFx * 2.6,
7928 grid: { color: c.grid },
7929 ticks: { color: c.text, font: { size: 11 }, maxTicksLimit: 6, callback: function(v){ return fmt(v); } },
7930 title: { display: true, text: 'Files Analyzed', color: c.text, font: { size: 11 } } },
7931 y: { grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, callback: function(v){return fmt(v);} },
7932 title: { display: true, text: 'Code Lines', color: c.text, font: { size: 11 } } }
7933 },
7934 plugins: {
7935 legend: { display: false },
7936 tooltip: {
7937 callbacks: {
7938 title: function(items) { return items.length ? items[0].dataset.label : ''; },
7939 label: function(ctx){
7940 var d = SCAT_D[ctx.datasetIndex];
7941 return [
7942 ' Files analyzed: ' + fmt(d.files),
7943 ' Code lines: ' + Number(d.code).toLocaleString(),
7944 ' Physical lines: ' + Number(d.physical).toLocaleString()
7945 ];
7946 }
7947 }
7948 }
7949 }
7950 },
7951 plugins: [(function(){return{afterDatasetsDraw:function(chart){
7952 var ctx=chart.ctx,tc=clr().text,ca=chart.chartArea;
7953 chart.data.datasets.forEach(function(ds,di){
7954 var meta=chart.getDatasetMeta(di),d=SCAT_D[di];if(!d)return;
7955 meta.data.forEach(function(el){
7956 var r=(el.options&&el.options.radius)?el.options.radius:10;
7957 var codeStr=fmt(d.code);
7958 // render in layout.padding.top space — clamp only to canvas top, not chartArea.top
7959 var ty2=Math.max(14,el.y-r-3);
7960 var ty1=Math.max(1,ty2-14);
7961 // label always centred directly on bubble — padding.right gives room at the edge
7962 ctx.save();ctx.fillStyle=tc;ctx.textBaseline='bottom';ctx.textAlign='center';
7963 ctx.font='800 11px Inter,ui-sans-serif,sans-serif';
7964 ctx.fillText(d.lang,el.x,ty1);
7965 ctx.font='700 10px Inter,ui-sans-serif,sans-serif';
7966 ctx.fillText(codeStr,el.x,ty2);
7967 ctx.restore();
7968 });
7969 });
7970 }};})()]
7971 });
7972 ALL_CHARTS.push(chart);
7973 legHolder.chart = chart;
7974 })();
7975
7976 // ── Submodule breakdown ──────────────────────────────────────────────────
7977 // No-op plugins: hover row-dimming was removed because the flashing row
7978 // background looked out of place vs. every other chart. Kept as empty stubs
7979 // so the (inline + Full View) chart configs that reference them stay valid.
7980 var rowDimPlugin = {};
7981 var barJumpPlugin = {};
7982 var subChart = null;
7983 (function() {
7984 if (!SUB_D || !SUB_D.length) return;
7985 var subYSel = document.getElementById('sub-y-axis');
7986 var subSortSel = document.getElementById('sub-sort');
7987 var wrap = document.getElementById('canvas-sub-wrap');
7988 var canvas = document.getElementById('canvas-sub');
7989 if (!canvas) return;
7990 var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines',
7991 physical:'Physical Lines', files:'Files' };
7992 var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
7993 function renderSubmodule() {
7994 var yKey = subYSel ? subYSel.value : 'code';
7995 var sortMode = subSortSel ? subSortSel.value : 'desc';
7996 var data = SUB_D.slice();
7997 if (sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
7998 else if (sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
7999 else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8000 data = data.slice(0, 30);
8001 var c = clr();
8002 var col = SUB_COLS[yKey] || OX;
8003 if (wrap) wrap.style.height = Math.max(200, Math.min(540, data.length * 28 + 60)) + 'px';
8004 if (subChart) {
8005 subChart.data.labels = data.map(function(d){return d.name;});
8006 subChart.data.datasets[0].data = data.map(function(d){return d[yKey]||0;});
8007 subChart.data.datasets[0].backgroundColor = col;
8008 subChart.data.datasets[0].label = Y_LABELS[yKey]||yKey;
8009 subChart.options.scales.x.title.text = Y_LABELS[yKey]||yKey;
8010 subChart.update('none'); return;
8011 }
8012 subChart = new Chart(canvas, {
8013 type: 'bar',
8014 data: {
8015 labels: data.map(function(d){return d.name;}),
8016 datasets: [{ label: Y_LABELS[yKey]||yKey,
8017 data: data.map(function(d){return d[yKey]||0;}),
8018 backgroundColor: col, hoverBackgroundColor: col === OX ? '#d97020' : col,
8019 borderRadius: 3 }]
8020 },
8021 options: {
8022 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8023 onHover: chartCursor,
8024 animation: { duration: 500, easing: 'easeOutQuart' },
8025 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8026 layout: { padding: { right: 64 } },
8027 scales: {
8028 x: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} },
8029 title: { display: true, text: Y_LABELS[yKey]||yKey, color: c.text } },
8030 y: { grid: { display: false }, ticks: { color: c.text } }
8031 },
8032 plugins: {
8033 legend: { display: false },
8034 tooltip: {
8035 callbacks: {
8036 title: function(items) { return items.length ? items[0].label : ''; },
8037 label: function(ctx){
8038 var d = data[ctx.dataIndex] || {};
8039 return [
8040 ' Code: ' + Number(d.code||0).toLocaleString(),
8041 ' Comments: ' + Number(d.comment||0).toLocaleString(),
8042 ' Blanks: ' + Number(d.blank||0).toLocaleString(),
8043 ' Physical: ' + Number(d.physical||0).toLocaleString(),
8044 ' Files: ' + fmt(d.files||0)
8045 ];
8046 }
8047 }
8048 }
8049 }
8050 },
8051 plugins: [makeDlPlugin(function(v){ return fmt(v||0); }, 'end'), barJumpPlugin]
8052 });
8053 ALL_CHARTS.push(subChart);
8054 }
8055 if (subYSel) subYSel.addEventListener('change', renderSubmodule);
8056 if (subSortSel) subSortSel.addEventListener('change', renderSubmodule);
8057 renderSubmodule();
8058 })();
8059
8060 // ── Submodule composition: stacked horizontal bar (Chart.js) ─────────────
8061 var subCompChart = null;
8062 // Plugin: draw value label inside each visible segment of a stacked horizontal bar.
8063 var segLabelPlugin = {
8064 afterDatasetsDraw: function(chart) {
8065 var ctx = chart.ctx, nDs = chart.data.datasets.length;
8066 var tc = clr().text;
8067 for (var di = 0; di < nDs; di++) {
8068 var meta = chart.getDatasetMeta(di);
8069 if (meta.hidden) continue;
8070 meta.data.forEach(function(el, idx) {
8071 var v = chart.data.datasets[di].data[idx] || 0;
8072 if (!v) return;
8073 var w = Math.abs(el.x - el.base);
8074 if (w < 28) return; // too narrow to show label
8075 ctx.save();
8076 ctx.font = '600 10px Inter,ui-sans-serif,sans-serif';
8077 ctx.fillStyle = di === 0 ? '#fff' : (di === 1 ? '#fff' : '#555');
8078 ctx.textAlign = 'center';
8079 ctx.textBaseline = 'middle';
8080 ctx.fillText(fmt(v), el.base + w / 2, el.y);
8081 ctx.restore();
8082 });
8083 }
8084 }
8085 };
8086 (function() {
8087 var el = document.getElementById('submodule-donut');
8088 if (!el || !SUB_D || !SUB_D.length) return;
8089 var data = SUB_D.slice().sort(function(a,b){
8090 return ((b.code||0)+(b.comment||0)+(b.blank||0))-((a.code||0)+(a.comment||0)+(a.blank||0));
8091 }).slice(0, 15);
8092 var h = Math.max(150, Math.min(540, data.length * 40 + 90));
8093 el.style.height = h + 'px';
8094 el.style.position = 'relative';
8095 var cv = document.createElement('canvas');
8096 cv.id = 'canvas-sub-comp';
8097 el.innerHTML = '';
8098 el.appendChild(cv);
8099 var c = clr();
8100 subCompChart = new Chart(cv, {
8101 type: 'bar',
8102 data: {
8103 labels: data.map(function(d){ return d.name; }),
8104 datasets: [
8105 { label: 'Code', data: data.map(function(d){ return d.code||0; }), backgroundColor: OX, hoverBackgroundColor: '#d97020', borderRadius: 0, borderSkipped: false },
8106 { label: 'Comments', data: data.map(function(d){ return d.comment||0; }), backgroundColor: GN, hoverBackgroundColor: '#3a8a5e', borderRadius: 0, borderSkipped: false },
8107 { label: 'Blank', data: data.map(function(d){ return d.blank||0; }), backgroundColor: GY, hoverBackgroundColor: '#999', borderRadius: 0, borderSkipped: false }
8108 ]
8109 },
8110 options: {
8111 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8112 onHover: chartCursor,
8113 animation: { duration: 500, easing: 'easeOutQuart' },
8114 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8115 layout: { padding: { right: 56 } },
8116 scales: {
8117 x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){ return fmt(v); } } },
8118 y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8119 },
8120 plugins: {
8121 legend: {
8122 position: 'bottom',
8123 labels: { color: c.text, usePointStyle: true, pointStyle: 'rect', font: { size: 11, weight: '700' }, padding: 16 },
8124 onHover: function(e, item, leg) {
8125 legendCursorOn(e);
8126 var ch = leg.chart, di = item.datasetIndex;
8127 var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8128 ch.data.datasets.forEach(function(ds, i) {
8129 ds.backgroundColor = i===di ? orig[i] : hexAlpha(orig[i], 0.15);
8130 ds.hoverBackgroundColor = i===di ? hov[i] : hexAlpha(orig[i], 0.15);
8131 });
8132 // show tooltip on first bar row with all datasets (index mode)
8133 var n = ch.data.datasets.length, ae = [];
8134 for (var ii = 0; ii < n; ii++) { ae.push({ datasetIndex: ii, index: 0 }); }
8135 var fp = ch.getDatasetMeta(di).data[0];
8136 ch.setActiveElements([{ datasetIndex: di, index: 0 }]);
8137 ch.tooltip.setActiveElements(ae, fp ? { x: fp.x, y: fp.y } : { x: 0, y: 0 });
8138 ch.update();
8139 },
8140 onLeave: function(e, item, leg) {
8141 legendCursorOff(e);
8142 var ch = leg.chart;
8143 var orig = [OX, GN, GY], hov = ['#d97020','#3a8a5e','#999'];
8144 ch.data.datasets.forEach(function(ds, i) { ds.backgroundColor = orig[i]; ds.hoverBackgroundColor = hov[i]; });
8145 ch.setActiveElements([]);
8146 ch.tooltip.setActiveElements([], {});
8147 ch.update('none');
8148 }
8149 },
8150 tooltip: {
8151 mode: 'index',
8152 callbacks: {
8153 title: function(items){ return items.length ? items[0].label : ''; },
8154 label: function(ctx){
8155 var v = ctx.parsed.x || 0;
8156 return ' ' + ctx.dataset.label + ': ' + Number(v).toLocaleString();
8157 },
8158 footer: function(items){
8159 var tot = items.reduce(function(s,i){ return s + (i.parsed.x||0); }, 0);
8160 return 'Total: ' + Number(tot).toLocaleString();
8161 }
8162 }
8163 }
8164 }
8165 },
8166 plugins: [makeStackedEndPlugin(function(v){ return fmt(v); }), segLabelPlugin, rowDimPlugin]
8167 });
8168 ALL_CHARTS.push(subCompChart);
8169 })();
8170
8171 // ── Semantic Metrics ─────────────────────────────────────────────────────
8172 (function() {
8173 if (!SEM_D || !SEM_D.length) return;
8174 var semSel = document.getElementById('semantic-metric');
8175 var canvas = document.getElementById('canvas-semantic');
8176 if (!canvas) return;
8177 var SEM_LABELS = { functions:'Functions', classes:'Classes / Types', variables:'Variables',
8178 imports:'Imports', tests:'Tests' };
8179 var SEM_COLS = { functions:OX, classes:'#4472C4', variables:GN, imports:'#805099', tests:'#B23030' };
8180 var SEM_HCOLS = { functions:'#d97020', classes:'#5a8ad8', variables:'#3a8a5e', imports:'#9a68b3', tests:'#cc4545' };
8181 var semChart = null;
8182 function renderSemantic() {
8183 var mKey = semSel ? semSel.value : 'functions';
8184 var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
8185 var c = clr();
8186 var col = SEM_COLS[mKey] || OX;
8187 var hCol = SEM_HCOLS[mKey] || '#d97020';
8188 if (semChart) {
8189 semChart.data.labels = data.map(function(d){return d.lang;});
8190 semChart.data.datasets[0].data = data.map(function(d){return d[mKey]||0;});
8191 semChart.data.datasets[0].backgroundColor = col;
8192 semChart.data.datasets[0].hoverBackgroundColor = hCol;
8193 semChart.data.datasets[0].label = SEM_LABELS[mKey]||mKey;
8194 semChart.update('none'); return;
8195 }
8196 semChart = new Chart(canvas, {
8197 type: 'bar',
8198 data: {
8199 labels: data.map(function(d){return d.lang;}),
8200 datasets: [{ label: SEM_LABELS[mKey]||mKey,
8201 data: data.map(function(d){return d[mKey]||0;}),
8202 backgroundColor: col, hoverBackgroundColor: hCol,
8203 borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8204 },
8205 options: {
8206 responsive: true, maintainAspectRatio: false,
8207 onHover: chartCursor,
8208 animation: { duration: 500, easing: 'easeOutQuart' },
8209 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8210 layout: { padding: { top: 18 } },
8211 scales: {
8212 x: { grid: { display: false }, ticks: { color: c.text } },
8213 y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8214 },
8215 plugins: {
8216 legend: { display: false },
8217 tooltip: {
8218 callbacks: {
8219 title: function(items) { return items.length ? items[0].label : ''; },
8220 label: function(ctx) {
8221 var d = data[ctx.dataIndex] || {};
8222 var lines = [' ' + (SEM_LABELS[mKey]||mKey) + ': ' + Number(ctx.parsed.y).toLocaleString()];
8223 var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== mKey && (d[k]||0) > 0; });
8224 others.forEach(function(k) {
8225 lines.push(' ' + SEM_LABELS[k] + ': ' + Number(d[k]||0).toLocaleString());
8226 });
8227 return lines;
8228 }
8229 }
8230 }
8231 }
8232 },
8233 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8234 });
8235 ALL_CHARTS.push(semChart);
8236 }
8237 if (semSel) semSel.addEventListener('change', renderSemantic);
8238 renderSemantic();
8239
8240 var semExpandBtn = document.getElementById('semantic-expand-btn');
8241 if (semExpandBtn) {
8242 semExpandBtn.addEventListener('click', function() {
8243 var mKey = semSel ? semSel.value : 'functions';
8244 var n = SEM_D.length || 1;
8245 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8246 var modalH = Math.min(Math.max(400, n * 46 + 96), maxH);
8247 var overlay = document.createElement('div');
8248 overlay.className = 'chart-modal-overlay';
8249 var semOptHtml = '<option value="functions">Functions</option>'
8250 + '<option value="classes">Classes / Types</option>'
8251 + '<option value="variables">Variables</option>'
8252 + '<option value="imports">Imports</option>'
8253 + '<option value="tests">Tests</option>';
8254 var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">Semantic Metrics \u2014 Full View</span>'
8255 + '<select class="chart-select" id="sem-modal-metric">' + semOptHtml + '</select></div>';
8256 overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;"><button class="chart-modal-close" aria-label="Close">×</button>' + hdr + '<div style="position:relative;height:' + modalH + 'px;width:100%;"><canvas id="canvas-semantic-modal"></canvas></div></div>';
8257 document.body.appendChild(overlay);
8258 overlay.querySelector('.chart-modal-close').addEventListener('click', function() { document.body.removeChild(overlay); });
8259 overlay.addEventListener('click', function(e) { if (e.target === overlay) document.body.removeChild(overlay); });
8260 var modalSel = document.getElementById('sem-modal-metric');
8261 if (modalSel) modalSel.value = mKey;
8262 var modalCanvas = document.getElementById('canvas-semantic-modal');
8263 var semModalChart = null;
8264 function renderSemModal(key) {
8265 if (semModalChart) { semModalChart.destroy(); semModalChart = null; }
8266 if (!modalCanvas) return;
8267 var data = SEM_D.slice().sort(function(a,b){return (b[key]||0)-(a[key]||0);});
8268 var c = clr();
8269 var col = SEM_COLS[key] || OX;
8270 var hcol = SEM_HCOLS[key] || '#d97020';
8271 semModalChart = new Chart(modalCanvas, {
8272 type: 'bar',
8273 data: {
8274 labels: data.map(function(d){return d.lang;}),
8275 datasets: [{ label: SEM_LABELS[key]||key, data: data.map(function(d){return d[key]||0;}),
8276 backgroundColor: col, hoverBackgroundColor: hcol,
8277 borderRadius: 4, borderWidth: 0, hoverBorderWidth: 0 }]
8278 },
8279 options: {
8280 responsive: true, maintainAspectRatio: false,
8281 onHover: chartCursor,
8282 animation: { duration: 500, easing: 'easeOutQuart' },
8283 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8284 layout: { padding: { top: 18 } },
8285 scales: {
8286 x: { grid: { display: false }, ticks: { color: c.text } },
8287 y: { grid: { color: c.grid }, ticks: { color: c.text, callback: function(v){return fmt(v);} } }
8288 },
8289 plugins: { legend: { display: false }, tooltip: { callbacks: {
8290 title: function(items){return items.length?items[0].label:'';},
8291 label: function(ctx){
8292 var d = data[ctx.dataIndex] || {};
8293 var lines = [' '+(SEM_LABELS[key]||key)+': '+Number(ctx.parsed.y).toLocaleString()];
8294 var others = Object.keys(SEM_LABELS).filter(function(k){ return k !== key && (d[k]||0) > 0; });
8295 others.forEach(function(k){ lines.push(' '+SEM_LABELS[k]+': '+Number(d[k]||0).toLocaleString()); });
8296 return lines;
8297 }
8298 }}}
8299 },
8300 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8301 });
8302 }
8303 renderSemModal(mKey);
8304 if (modalSel) modalSel.addEventListener('change', function() { renderSemModal(this.value); });
8305 });
8306 }
8307 })();
8308
8309 // ── Comment Density: comments / (code + comments) per language ──────────
8310 (function() {
8311 var canvas = document.getElementById('canvas-density');
8312 if (!canvas || !D || !D.length) return;
8313 var data = D.slice().sort(function(a,b){
8314 var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
8315 var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
8316 return db-da;
8317 });
8318 var labels = data.map(function(d){return d.lang;});
8319 var densities = data.map(function(d){
8320 var sig=(d.code||0)+(d.comments||0);
8321 return sig>0?Math.round((d.comments||0)/sig*1000)/10:0;
8322 });
8323 var wrap = canvas.parentElement;
8324 if (wrap) wrap.style.height = Math.max(150, Math.min(500, data.length*29+36))+'px';
8325 var c = clr();
8326 var densChart = new Chart(canvas, {
8327 type: 'bar',
8328 data: {
8329 labels: labels,
8330 datasets: [{ label: 'Comment %',
8331 data: densities,
8332 backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}),
8333 borderRadius: 4
8334 }]
8335 },
8336 options: {
8337 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8338 onHover: chartCursor,
8339 animation: { duration: 500, easing: 'easeOutQuart' },
8340 layout: { padding: { right: 42 } },
8341 scales: {
8342 x: { min: 0, max: 100,
8343 grid: { color: c.grid },
8344 ticks: { color: c.text, callback: function(v){return v+'%';} },
8345 title: { display: true, text: 'Comment %', color: c.text } },
8346 y: { grid: { display: false }, ticks: { color: c.text } }
8347 },
8348 plugins: {
8349 legend: { display: false },
8350 tooltip: { callbacks: {
8351 title: function(items){return items.length?items[0].label:'';},
8352 label: function(ctx){
8353 var d=data[ctx.dataIndex]||{};
8354 var sig=(d.code||0)+(d.comments||0);
8355 return [' Comment ratio: '+ctx.parsed.x+'%',
8356 ' Comments: '+Number(d.comments||0).toLocaleString(),
8357 ' Significant lines: '+Number(sig).toLocaleString()];
8358 }
8359 }}
8360 }
8361 },
8362 plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
8363 });
8364 ALL_CHARTS.push(densChart);
8365 })();
8366
8367 // ── File Size Distribution histogram ──────────────────────────────────────
8368 (function() {
8369 var canvas = document.getElementById('canvas-filesize');
8370 if (!canvas || !HIST_D || !HIST_D.length) return;
8371 var labels = HIST_D.map(function(d){return d.label;});
8372 var counts = HIST_D.map(function(d){return d.count||0;});
8373 var total = counts.reduce(function(a,b){return a+b;},0);
8374 var c = clr();
8375 var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
8376 var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
8377 var fsChart = new Chart(canvas, {
8378 type: 'bar',
8379 data: {
8380 labels: labels,
8381 datasets: [{ label: 'Files',
8382 data: counts,
8383 backgroundColor: fsBg,
8384 hoverBackgroundColor: fsHv,
8385 borderRadius: 6,
8386 borderWidth: 0,
8387 hoverBorderWidth: 0
8388 }]
8389 },
8390 options: {
8391 responsive: true, maintainAspectRatio: false,
8392 onHover: chartCursor,
8393 animation: { duration: 500, easing: 'easeOutQuart' },
8394 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8395 layout: { padding: { top: 18 } },
8396 scales: {
8397 x: { grid: { display: false }, ticks: { color: c.text, font: { size: 11 } } },
8398 y: { beginAtZero: true,
8399 grid: { color: c.grid },
8400 ticks: { color: c.text, precision: 0 },
8401 title: { display: true, text: 'File Count', color: c.text } }
8402 },
8403 plugins: {
8404 legend: { display: false },
8405 tooltip: { callbacks: {
8406 label: function(ctx) {
8407 var n = ctx.parsed.y;
8408 var pct = total > 0 ? Math.round(n/total*1000)/10 : 0;
8409 return [' Files: '+n, ' Share: '+pct+'%'];
8410 }
8411 }}
8412 }
8413 },
8414 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8415 });
8416 ALL_CHARTS.push(fsChart);
8417 })();
8418
8419 // ── Expand button handlers ────────────────────────────────────────────────
8420 (function() {
8421 function makeOverlay(title, h, subtitle, ctrlHtml) {
8422 var overlay = document.createElement('div');
8423 overlay.className = 'chart-modal-overlay';
8424 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8425 var hAttr = 'height:' + Math.min(h || 696, maxH) + 'px;';
8426 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
8427 var hdr = '<div class="chart-modal-header"><span class="chart-modal-title">' + title + '</span>' + (ctrlHtml || '') + '</div>';
8428 overlay.innerHTML = '<div class="chart-modal" style="max-width:1320px;"><button class="chart-modal-close" aria-label="Close">×</button>' + hdr + subHtml + '<div style="position:relative;width:100%;' + hAttr + '"><canvas id="modal-expand-canvas"></canvas></div></div>';
8429 document.body.appendChild(overlay);
8430 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
8431 overlay.addEventListener('click', function(e){ if(e.target === overlay) document.body.removeChild(overlay); });
8432 return document.getElementById('modal-expand-canvas');
8433 }
8434
8435 // Language Composition
8436 (function(){
8437 var btn = document.getElementById('comp-expand-btn');
8438 if(!btn) return;
8439 btn.addEventListener('click', function(){
8440 var activeTab = document.querySelector('[data-comp-tab].active');
8441 var compMode = activeTab ? activeTab.getAttribute('data-comp-tab') : 'absolute';
8442 var ctrlHtml = '<select class="chart-select" id="comp-modal-mode">'
8443 + '<option value="absolute">Absolute Lines</option>'
8444 + '<option value="pct">100% Normalized</option>'
8445 + '</select>';
8446 var canvas = makeOverlay('Language Composition \u2014 Full View', undefined, null, ctrlHtml);
8447 if(!canvas) return;
8448 var modalMode = document.getElementById('comp-modal-mode');
8449 if(modalMode) modalMode.value = compMode;
8450 var compModalChart = null;
8451 function renderCompModal(mode) {
8452 if(compModalChart) { compModalChart.destroy(); compModalChart = null; }
8453 var data = D.slice(0, 15);
8454 var c = clr(), isPct = mode === 'pct';
8455 var tot = function(d){ return (d.code||0)+(d.comments||0)+(d.blanks||0)||1; };
8456 var codeD = data.map(function(d){ return isPct ? (d.code||0)/tot(d)*100 : d.code||0; });
8457 var cmD = data.map(function(d){ return isPct ? (d.comments||0)/tot(d)*100 : d.comments||0; });
8458 var blD = data.map(function(d){ return isPct ? (d.blanks||0)/tot(d)*100 : d.blanks||0; });
8459 var tickCb = isPct ? function(v){return v.toFixed(0)+'%';} : function(v){return fmt(v);};
8460 compModalChart = new Chart(canvas, {
8461 type: 'bar',
8462 data: {
8463 labels: data.map(function(d){ return d.lang; }),
8464 datasets: [
8465 { label:'Code', data: codeD, backgroundColor: OX, borderRadius: 3 },
8466 { label:'Comments', data: cmD, backgroundColor: GN, borderRadius: 3 },
8467 { label:'Blanks', data: blD, backgroundColor: GY, borderRadius: 3 }
8468 ]
8469 },
8470 options: {
8471 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8472 layout: { padding: { right: 64 } },
8473 scales: {
8474 x: { stacked: true, grid: { color: c.grid }, ticks: { color: c.text, callback: tickCb } },
8475 y: { stacked: true, grid: { display: false }, ticks: { color: c.text } }
8476 },
8477 plugins: { legend: { position: 'bottom', labels: { color: c.text } } }
8478 },
8479 plugins: [makeStackedEndPlugin(function(total, idx) {
8480 if (isPct) return '';
8481 var d = data[idx]; return fmt(Math.round(d && d.physical ? d.physical : total));
8482 })]
8483 });
8484 }
8485 renderCompModal(compMode);
8486 if(modalMode) modalMode.addEventListener('change', function(){ renderCompModal(this.value); });
8487 });
8488 })();
8489
8490 // Files vs Code Lines (Scatter)
8491 (function(){
8492 var btn = document.getElementById('scatter-expand-btn');
8493 if(!btn || !SCAT_D || !SCAT_D.length) return;
8494 btn.addEventListener('click', function(){
8495 var canvas = makeOverlay('Files vs Code Lines \u2014 Full View', undefined, 'File count vs SLOC per language');
8496 if(!canvas) return;
8497 var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical;})) || 1;
8498 var maxFx = Math.max.apply(null, SCAT_D.map(function(d){return d.files;})) || 1;
8499 var c = clr();
8500 var scLegHolder = attachScatterLegend(canvas);
8501 var scExpand = new Chart(canvas, {
8502 type: 'bubble',
8503 data: {
8504 datasets: SCAT_D.map(function(d, i) {
8505 return {
8506 label: d.lang,
8507 data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt(d.physical/maxP)*20)) }],
8508 backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8509 borderColor: PALETTE[i % PALETTE.length], borderWidth: 1,
8510 hoverBorderWidth: 2
8511 };
8512 })
8513 },
8514 options: {
8515 responsive: true, maintainAspectRatio: false,
8516 onHover: chartCursor,
8517 animation: { duration: 500, easing: 'easeOutQuart' },
8518 layout: { padding: { top: 44, right: 12 } },
8519 scales: {
8520 x: { type: 'logarithmic', min: 0.8, max: maxFx * 2.6, grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, maxTicksLimit: 6, callback: function(v){ return fmt(v); } }, title: { display: true, text: 'Files Analyzed', color: c.text, font: { size: 11 } } },
8521 y: { grid: { color: c.grid }, ticks: { color: c.text, font: { size: 11 }, callback: function(v){return fmt(v);} }, title: { display: true, text: 'Code Lines', color: c.text, font: { size: 11 } } }
8522 },
8523 plugins: {
8524 legend: { display: false },
8525 tooltip: { callbacks: {
8526 title: function(items){ return items.length ? items[0].dataset.label : ''; },
8527 label: function(ctx){
8528 var d = SCAT_D[ctx.datasetIndex];
8529 return [' Files analyzed: '+fmt(d.files), ' Code lines: '+Number(d.code).toLocaleString(), ' Physical lines: '+Number(d.physical).toLocaleString()];
8530 }
8531 }}
8532 }
8533 },
8534 plugins: [(function(){return{afterDatasetsDraw:function(chart){
8535 var ctx=chart.ctx,tc=clr().text,ca=chart.chartArea;
8536 chart.data.datasets.forEach(function(ds,di){
8537 var meta=chart.getDatasetMeta(di),d=SCAT_D[di];if(!d)return;
8538 meta.data.forEach(function(el){
8539 var r=(el.options&&el.options.radius)?el.options.radius:10;
8540 var codeStr=fmt(d.code);
8541 var ty2=Math.max(14,el.y-r-3);
8542 var ty1=Math.max(1,ty2-14);
8543 ctx.save();ctx.fillStyle=tc;ctx.textBaseline='bottom';ctx.textAlign='center';
8544 ctx.font='800 11px Inter,ui-sans-serif,sans-serif';
8545 ctx.fillText(d.lang,el.x,ty1);
8546 ctx.font='700 10px Inter,ui-sans-serif,sans-serif';
8547 ctx.fillText(codeStr,el.x,ty2);
8548 ctx.restore();
8549 });
8550 });
8551 }};})()]
8552 });
8553 scLegHolder.chart = scExpand;
8554 });
8555 })();
8556
8557 // Comment Density
8558 (function(){
8559 var btn = document.getElementById('density-expand-btn');
8560 if(!btn) return;
8561 btn.addEventListener('click', function(){
8562 var data = D.slice().sort(function(a,b){
8563 var da=(a.comments||0)/Math.max((a.code||0)+(a.comments||0),1);
8564 var db=(b.comments||0)/Math.max((b.code||0)+(b.comments||0),1);
8565 return db-da;
8566 });
8567 var h = Math.min(Math.max(672, data.length * 46 + 96), Math.max(400, Math.floor(window.innerHeight * 0.82) - 130));
8568 var canvas = makeOverlay('Comment Density \u2014 Full View', h, 'Comment ratio per language');
8569 if(!canvas) return;
8570 var densities = data.map(function(d){ var sig=(d.code||0)+(d.comments||0); return sig>0?Math.round((d.comments||0)/sig*1000)/10:0; });
8571 var c = clr();
8572 new Chart(canvas, {
8573 type: 'bar',
8574 data: {
8575 labels: data.map(function(d){return d.lang;}),
8576 datasets: [{ label: 'Comment %', data: densities,
8577 backgroundColor: data.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius: 4 }]
8578 },
8579 options: {
8580 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8581 animation: { duration: 500, easing: 'easeOutQuart' },
8582 layout: { padding: { right: 42 } },
8583 scales: {
8584 x: { min:0, max:100, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return v+'%';}} },
8585 y: { grid:{display:false}, ticks:{color:c.text} }
8586 },
8587 plugins: { legend:{display:false}, tooltip:{callbacks:{
8588 title:function(items){return items.length?items[0].label:'';},
8589 label:function(ctx){
8590 var d=data[ctx.dataIndex]||{};
8591 var sig=(d.code||0)+(d.comments||0);
8592 return [' Comment ratio: '+ctx.parsed.x.toFixed(1)+'%',
8593 ' Comments: '+Number(d.comments||0).toLocaleString(),
8594 ' Significant lines: '+Number(sig).toLocaleString()];
8595 }
8596 }}}
8597 },
8598 plugins: [makeDlPlugin(function(v) { return (v || 0) + '%'; }, 'end')]
8599 });
8600 });
8601 })();
8602
8603 // File Size Distribution
8604 (function(){
8605 var btn = document.getElementById('filesize-expand-btn');
8606 if(!btn || !HIST_D || !HIST_D.length) return;
8607 btn.addEventListener('click', function(){
8608 var canvas = makeOverlay('File Size Distribution \u2014 Full View', undefined, 'File count per SLOC bucket');
8609 if(!canvas) return;
8610 var labels = HIST_D.map(function(d){return d.label;});
8611 var counts = HIST_D.map(function(d){return d.count||0;});
8612 var total = counts.reduce(function(a,b){return a+b;},0);
8613 var fsBg = ['#2A6846','#4472C4','#C45C10','#D4A017','#B23030'];
8614 var fsHv = ['#3a8a5e','#5a8ad8','#d97020','#e8b520','#cc4545'];
8615 var c = clr();
8616 new Chart(canvas, {
8617 type: 'bar',
8618 data: {
8619 labels: labels,
8620 datasets: [{ label: 'Files', data: counts,
8621 backgroundColor: fsBg, hoverBackgroundColor: fsHv,
8622 borderRadius: 6, borderWidth: 0, hoverBorderWidth: 0 }]
8623 },
8624 options: {
8625 responsive: true, maintainAspectRatio: false,
8626 animation: { duration: 500, easing: 'easeOutQuart' },
8627 transitions: { active: { animation: { duration: 200, easing: 'easeOutQuart' } } },
8628 layout: { padding: { top: 18 } },
8629 scales: {
8630 x: { grid:{display:false}, ticks:{color:c.text} },
8631 y: { beginAtZero:true, grid:{color:c.grid}, ticks:{color:c.text, precision:0}, title:{display:true, text:'File Count', color:c.text} }
8632 },
8633 plugins: { legend:{display:false}, tooltip:{callbacks:{label:function(ctx){ var pct=total>0?Math.round(ctx.parsed.y/total*1000)/10:0; return [' Files: '+ctx.parsed.y, ' Share: '+pct+'%']; }}} }
8634 },
8635 plugins: [makeDlPlugin(function(v) { return fmt(v || 0); }, 'top')]
8636 });
8637 });
8638 })();
8639
8640 // Submodule Breakdown — Full View with live Y Axis + Sort controls
8641 (function(){
8642 var btn = document.getElementById('sub-expand-btn');
8643 if(!btn || !SUB_D || !SUB_D.length) return;
8644 btn.addEventListener('click', function(){
8645 var subYSel = document.getElementById('sub-y-axis');
8646 var subSortSel = document.getElementById('sub-sort');
8647 var initY = subYSel ? subYSel.value : 'code';
8648 var initSort = subSortSel ? subSortSel.value : 'desc';
8649 var Y_LABELS = { code:'Code Lines', comment:'Comment Lines', blank:'Blank Lines', physical:'Physical Lines', files:'Files' };
8650 var SUB_COLS = { code:OX, comment:GN, blank:GY, physical:'#4472C4', files:'#805099' };
8651 var SUB_HCOLS = { code:'#d97020', comment:'#3a8a5e', blank:'#999', physical:'#5a8ad8', files:'#9a68b3' };
8652 var n = Math.min(SUB_D.length, 30);
8653 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8654 var modalH = Math.min(Math.max(480, n * 36 + 96), maxH);
8655 var ctrlHtml = '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Y Axis:'
8656 + '<select class="chart-select" id="sub-modal-y">'
8657 + '<option value="code">Code Lines</option>'
8658 + '<option value="comment">Comment Lines</option>'
8659 + '<option value="blank">Blank Lines</option>'
8660 + '<option value="physical">Physical Lines</option>'
8661 + '<option value="files">File Count</option>'
8662 + '</select></label>'
8663 + '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
8664 + '<select class="chart-select" id="sub-modal-sort">'
8665 + '<option value="desc">Value \u2193</option>'
8666 + '<option value="asc">Value \u2191</option>'
8667 + '<option value="name">Name A\u2192Z</option>'
8668 + '</select></label>';
8669 var canvas = makeOverlay('Submodule Breakdown \u2014 Full View', modalH, null, ctrlHtml);
8670 if(!canvas) return;
8671 var modalY = document.getElementById('sub-modal-y');
8672 var modalSort = document.getElementById('sub-modal-sort');
8673 if(modalY) modalY.value = initY;
8674 if(modalSort) modalSort.value = initSort;
8675 var subModalChart = null;
8676 function renderSubModal(yKey, sortMode) {
8677 if(subModalChart) { subModalChart.destroy(); subModalChart = null; }
8678 var data = SUB_D.slice();
8679 if(sortMode==='desc') data.sort(function(a,b){return (b[yKey]||0)-(a[yKey]||0);});
8680 else if(sortMode==='asc') data.sort(function(a,b){return (a[yKey]||0)-(b[yKey]||0);});
8681 else data.sort(function(a,b){return a.name.localeCompare(b.name);});
8682 data = data.slice(0, 30);
8683 var c = clr(), col = SUB_COLS[yKey]||OX, hcol = SUB_HCOLS[yKey]||'#d97020';
8684 subModalChart = new Chart(canvas, {
8685 type: 'bar',
8686 data: {
8687 labels: data.map(function(d){return d.name;}),
8688 datasets: [{ label: Y_LABELS[yKey]||yKey,
8689 data: data.map(function(d){return d[yKey]||0;}),
8690 backgroundColor: col, hoverBackgroundColor: hcol, borderRadius: 3 }]
8691 },
8692 options: {
8693 indexAxis: 'y', responsive: true, maintainAspectRatio: false,
8694 onHover: chartCursor,
8695 animation: { duration: 500, easing: 'easeOutQuart' },
8696 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8697 layout: { padding: { right: 72 } },
8698 scales: {
8699 x: { grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}},
8700 title:{display:true, text:Y_LABELS[yKey]||yKey, color:c.text} },
8701 y: { grid:{display:false}, ticks:{color:c.text} }
8702 },
8703 plugins: {
8704 legend:{display:false},
8705 tooltip: { callbacks: {
8706 title: function(items){return items.length?items[0].label:'';},
8707 label: function(ctx){
8708 var d = data[ctx.dataIndex]||{};
8709 return [' Code: '+Number(d.code||0).toLocaleString(),
8710 ' Comments: '+Number(d.comment||0).toLocaleString(),
8711 ' Blanks: '+Number(d.blank||0).toLocaleString(),
8712 ' Physical: '+Number(d.physical||0).toLocaleString(),
8713 ' Files: '+fmt(d.files||0)];
8714 }
8715 }}
8716 }
8717 },
8718 plugins: [makeDlPlugin(function(v){return fmt(v||0);}, 'end'), barJumpPlugin]
8719 });
8720 }
8721 renderSubModal(initY, initSort);
8722 if(modalY) modalY.addEventListener('change', function(){ renderSubModal(this.value, modalSort ? modalSort.value : 'desc'); });
8723 if(modalSort) modalSort.addEventListener('change', function(){ renderSubModal(modalY ? modalY.value : 'code', this.value); });
8724 });
8725 })();
8726
8727 // Submodule Composition — Full View (Chart.js with sort control)
8728 (function(){
8729 var btn = document.getElementById('sub-comp-expand-btn');
8730 if(!btn || !SUB_D || !SUB_D.length) return;
8731 btn.addEventListener('click', function(){
8732 var n = Math.min(SUB_D.length, 20);
8733 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
8734 var modalH = Math.min(Math.max(400, n * 40 + 90), maxH);
8735 var ctrlHtml = '<label style="font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:6px;flex-shrink:0;">Sort:'
8736 + '<select class="chart-select" id="sub-comp-modal-sort">'
8737 + '<option value="desc">Total Lines \u2193</option>'
8738 + '<option value="asc">Total Lines \u2191</option>'
8739 + '<option value="name">Name A\u2192Z</option>'
8740 + '</select></label>';
8741 var canvas = makeOverlay('Submodule Composition \u2014 Full View', modalH, null, ctrlHtml);
8742 if(!canvas) return;
8743 var modalSort = document.getElementById('sub-comp-modal-sort');
8744 var scModalChart = null;
8745 function renderSCModal(sortMode) {
8746 if(scModalChart) { scModalChart.destroy(); scModalChart = null; }
8747 var data = SUB_D.slice();
8748 if(sortMode==='asc') data.sort(function(a,b){return ((a.code||0)+(a.comment||0)+(a.blank||0))-((b.code||0)+(b.comment||0)+(b.blank||0));});
8749 else if(sortMode==='name') data.sort(function(a,b){return a.name.localeCompare(b.name);});
8750 else data.sort(function(a,b){return ((b.code||0)+(b.comment||0)+(b.blank||0))-((a.code||0)+(a.comment||0)+(a.blank||0));});
8751 data = data.slice(0, 20);
8752 var c = clr();
8753 scModalChart = new Chart(canvas, {
8754 type: 'bar',
8755 data: {
8756 labels: data.map(function(d){ return d.name; }),
8757 datasets: [
8758 { label:'Code', data:data.map(function(d){return d.code||0;}), backgroundColor:OX, hoverBackgroundColor:'#d97020', borderRadius:0, borderSkipped:false },
8759 { label:'Comments', data:data.map(function(d){return d.comment||0;}), backgroundColor:GN, hoverBackgroundColor:'#3a8a5e', borderRadius:0, borderSkipped:false },
8760 { label:'Blank', data:data.map(function(d){return d.blank||0;}), backgroundColor:GY, hoverBackgroundColor:'#999', borderRadius:0, borderSkipped:false }
8761 ]
8762 },
8763 options: {
8764 indexAxis:'y', responsive:true, maintainAspectRatio:false,
8765 onHover: chartCursor,
8766 animation: { duration: 500, easing: 'easeOutQuart' },
8767 transitions: { active: { animation: { duration: 180, easing: 'easeOutQuart' } } },
8768 layout:{ padding:{ right:72 } },
8769 scales: {
8770 x:{ stacked:true, grid:{color:c.grid}, ticks:{color:c.text, callback:function(v){return fmt(v);}} },
8771 y:{ stacked:true, grid:{display:false}, ticks:{color:c.text} }
8772 },
8773 plugins: {
8774 legend:{ position:'bottom', labels:{color:c.text, usePointStyle:true, pointStyle:'rect', font:{size:11,weight:'700'}, padding:16},
8775 onHover:function(e,item,leg){ legendCursorOn(e); var ch=leg.chart,di=item.datasetIndex; var orig=[OX,GN,GY],hov=['#d97020','#3a8a5e','#999']; ch.data.datasets.forEach(function(ds,i){ ds.backgroundColor=i===di?orig[i]:hexAlpha(orig[i],0.15); ds.hoverBackgroundColor=i===di?hov[i]:hexAlpha(orig[i],0.15); }); var n=ch.data.datasets.length,ae=[]; for(var ii=0;ii<n;ii++){ae.push({datasetIndex:ii,index:0});} var fp=ch.getDatasetMeta(di).data[0]; ch.setActiveElements([{datasetIndex:di,index:0}]); ch.tooltip.setActiveElements(ae,fp?{x:fp.x,y:fp.y}:{x:0,y:0}); ch.update(); },
8776 onLeave:function(e,item,leg){ legendCursorOff(e); var ch=leg.chart; var orig=[OX,GN,GY],hov=['#d97020','#3a8a5e','#999']; ch.data.datasets.forEach(function(ds,i){ ds.backgroundColor=orig[i]; ds.hoverBackgroundColor=hov[i]; }); ch.setActiveElements([]); ch.tooltip.setActiveElements([],{}); ch.update('none'); }
8777 },
8778 tooltip:{ mode:'index', callbacks:{
8779 title:function(items){return items.length?items[0].label:'';},
8780 label:function(ctx){return ' '+ctx.dataset.label+': '+Number(ctx.parsed.x||0).toLocaleString();},
8781 footer:function(items){var t=items.reduce(function(s,i){return s+(i.parsed.x||0);},0);return 'Total: '+Number(t).toLocaleString();}
8782 }}
8783 }
8784 },
8785 plugins: [makeStackedEndPlugin(function(v){return fmt(v);}), segLabelPlugin, rowDimPlugin]
8786 });
8787 }
8788 renderSCModal('desc');
8789 if(modalSort) modalSort.addEventListener('change', function(){ renderSCModal(this.value); });
8790 });
8791 })();
8792
8793 // Language overview (donut + line-mix) — clone both SVGs side-by-side
8794 (function(){
8795 var btn = document.getElementById('lang-overview-expand-btn');
8796 if(!btn) return;
8797 btn.addEventListener('click', function(){
8798 var src = document.getElementById('report-lang-overview');
8799 if(!src) return;
8800 var overlay = document.createElement('div');
8801 overlay.className = 'chart-modal-overlay';
8802 overlay.innerHTML = '<div class="chart-modal" style="max-width:1600px;"><button class="chart-modal-close" aria-label="Close">×</button><div class="chart-modal-header"><span class="chart-modal-title">Language Breakdown \u2014 Full View</span></div><div id="lang-overview-modal-wrap" style="width:100%;"></div></div>';
8803 document.body.appendChild(overlay);
8804 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){ document.body.removeChild(overlay); });
8805 overlay.addEventListener('click', function(e){ if(e.target===overlay) document.body.removeChild(overlay); });
8806 var wrap = document.getElementById('lang-overview-modal-wrap');
8807 if(wrap) {
8808 wrap.innerHTML = src.innerHTML;
8809 var svgs = wrap.querySelectorAll('svg');
8810 for(var i=0;i<svgs.length;i++){
8811 svgs[i].removeAttribute('width');
8812 svgs[i].removeAttribute('height');
8813 svgs[i].style.cssText='display:block;width:100%;height:auto;';
8814 }
8815 var ov = wrap.querySelector('.r-lang-overview');
8816 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
8817 var cells = wrap.querySelectorAll('.r-lang-overview-cell');
8818 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
8819 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
8820 wireDonutLegend(wrap.querySelector('svg'));
8821 wireMixLegend(wrap.querySelectorAll('svg')[1]);
8822 requestAnimationFrame(function(){
8823 var ss=wrap.querySelectorAll('svg');
8824 if(ss.length>=2){var bh=ss[1].getBoundingClientRect().height;if(bh>0){ss[0].style.cssText='display:block;height:'+bh+'px;width:auto;max-width:100%;';}}
8825 });
8826 }
8827 });
8828 })();
8829 })();
8830
8831 // ── Dark mode sync ────────────────────────────────────────────────────────
8832 document.querySelectorAll('[data-theme-toggle]').forEach(function(btn) {
8833 btn.addEventListener('click', function() {
8834 setTimeout(function() {
8835 var c = clr();
8836 ALL_CHARTS.forEach(function(chart) {
8837 if (chart.options.scales) {
8838 Object.keys(chart.options.scales).forEach(function(k) {
8839 var ax = chart.options.scales[k];
8840 if (ax.grid) ax.grid.color = c.grid;
8841 if (ax.ticks) ax.ticks.color = c.text;
8842 if (ax.title) ax.title.color = c.text;
8843 });
8844 }
8845 if (chart.options.plugins && chart.options.plugins.legend && chart.options.plugins.legend.labels)
8846 chart.options.plugins.legend.labels.color = c.text;
8847 chart.update('none');
8848 });
8849 }, 60);
8850 });
8851 });
8852
8853 // ── Pre-render all chart variants for PDF export ──────────────────────────
8854 (function() {
8855 var root = document.getElementById('pdf-variants');
8856 if (!root) return;
8857
8858 // Plugin: fill a light background behind every off-screen chart so the PNG
8859 // is opaque — without this Chart.js canvases are transparent and render as
8860 // blank white boxes in print.
8861 var PDF_BG = {
8862 id: 'pdfBg',
8863 beforeDraw: function(ch) {
8864 var ctx = ch.canvas.getContext('2d');
8865 ctx.save();
8866 ctx.globalCompositeOperation = 'destination-over';
8867 ctx.fillStyle = '#faf6f0';
8868 ctx.fillRect(0, 0, ch.canvas.width, ch.canvas.height);
8869 ctx.restore();
8870 }
8871 };
8872
8873 // Off-screen Chart.js render → PNG data-URL → destroy chart
8874 function snap(type, data, opts, w, h) {
8875 var c = document.createElement('canvas');
8876 c.width = w || 900; c.height = h || 280;
8877 var ch = new Chart(c, {
8878 type: type, data: data,
8879 options: Object.assign({}, opts, {
8880 animation: false, responsive: false, devicePixelRatio: 1,
8881 // Breathing room so labels never clip at the canvas edge
8882 layout: { padding: { top: 10, right: 18, bottom: 10, left: 10 } }
8883 }),
8884 plugins: [PDF_BG]
8885 });
8886 var png = c.toDataURL('image/png');
8887 ch.destroy();
8888 return png;
8889 }
8890
8891 function mkPanel(label, imgSrc) {
8892 var d = document.createElement('div'); d.className = 'pdf-variant-panel';
8893 if (label) {
8894 var lbl = document.createElement('div');
8895 lbl.className = 'pdf-variant-label'; lbl.textContent = label;
8896 d.appendChild(lbl);
8897 }
8898 if (imgSrc) {
8899 var img = document.createElement('img');
8900 img.className = 'pdf-variant-img'; img.src = imgSrc;
8901 d.appendChild(img);
8902 }
8903 return d;
8904 }
8905
8906 function mkGroup(title) {
8907 var g = document.createElement('div'); g.className = 'pdf-variant-group';
8908 var h = document.createElement('h2'); h.className = 'pdf-variant-group-title'; h.textContent = title;
8909 g.appendChild(h);
8910 var grid = document.createElement('div'); grid.className = 'pdf-variant-grid';
8911 g.appendChild(grid);
8912 return { group: g, grid: grid };
8913 }
8914
8915 var tc = '#43342d', gc = 'rgba(0,0,0,0.07)';
8916
8917 // ── Project Overview — 4 Y-axis variants ─────────────────────────────────
8918 var pgProj = mkGroup('Project Overview');
8919 var projVariants = [
8920 { label:'Code Lines', fn:function(d){return d.code||0;} },
8921 { label:'Comment Lines', fn:function(d){return d.comments||0;} },
8922 { label:'Physical Lines', fn:function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0);} },
8923 { label:'File Count', fn:function(d){return d.files||0;} }
8924 ];
8925 projVariants.forEach(function(y) {
8926 var sorted = D.slice().sort(function(a,b){return y.fn(b)-y.fn(a);});
8927 var h = Math.max(110, Math.min(360, sorted.length*18+40));
8928 var png = snap('bar', {
8929 labels: sorted.map(function(d){return d.lang;}),
8930 datasets:[{ label:y.label, data:sorted.map(y.fn),
8931 backgroundColor:sorted.map(function(_,i){return PALETTE[i%PALETTE.length];}), borderRadius:3 }]
8932 }, {
8933 indexAxis:'y',
8934 scales:{
8935 x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
8936 y:{grid:{display:false},ticks:{color:tc}}
8937 },
8938 plugins:{legend:{display:false}}
8939 }, 900, h);
8940 pgProj.grid.appendChild(mkPanel(y.label, png));
8941 });
8942 root.appendChild(pgProj.group);
8943
8944 // ── Language Composition — Absolute Lines + Composition % ────────────────
8945 var pgComp = mkGroup('Language Composition');
8946 var cData = D.slice(0,15);
8947 var totFn = function(d){return (d.code||0)+(d.comments||0)+(d.blanks||0)||1;};
8948 var compH = Math.max(110, Math.min(340, cData.length*18+50));
8949 [{id:'absolute',label:'Absolute Lines',isPct:false},{id:'pct',label:'Composition %',isPct:true}]
8950 .forEach(function(m) {
8951 var pct = m.isPct;
8952 var png = snap('bar', {
8953 labels: cData.map(function(d){return d.lang;}),
8954 datasets:[
8955 {label:'Code', data:cData.map(function(d){return pct?(d.code||0)/totFn(d)*100:d.code||0;}), backgroundColor:OX,borderRadius:3},
8956 {label:'Comments', data:cData.map(function(d){return pct?(d.comments||0)/totFn(d)*100:d.comments||0;}),backgroundColor:GN,borderRadius:3},
8957 {label:'Blanks', data:cData.map(function(d){return pct?(d.blanks||0)/totFn(d)*100:d.blanks||0;}), backgroundColor:GY,borderRadius:3}
8958 ]
8959 }, {
8960 indexAxis:'y',
8961 scales:{
8962 x:{stacked:true,grid:{color:gc},ticks:{color:tc,callback:pct?function(v){return v.toFixed(0)+'%';}:function(v){return fmt(v);}}},
8963 y:{stacked:true,grid:{display:false},ticks:{color:tc}}
8964 },
8965 plugins:{legend:{position:'bottom',labels:{color:tc}}}
8966 }, 900, compH);
8967 pgComp.grid.appendChild(mkPanel(m.label, png));
8968 });
8969 root.appendChild(pgComp.group);
8970
8971 // ── Files vs Code Lines — render off-screen (bubble chart, single-col centred) ─
8972 if (SCAT_D && SCAT_D.length) {
8973 var pgScat = mkGroup('Files vs Code Lines');
8974 pgScat.grid.classList.add('single-col'); // CSS class drives centering in print
8975 var maxP = Math.max.apply(null, SCAT_D.map(function(d){return d.physical||0;})) || 1;
8976 var scatPng = snap('bubble', {
8977 datasets: SCAT_D.map(function(d, i) {
8978 return {
8979 label: d.lang,
8980 data: [{ x: d.files, y: d.code, r: Math.max(5, Math.round(Math.sqrt((d.physical||0)/maxP)*20)) }],
8981 backgroundColor: PALETTE[i % PALETTE.length] + 'b8',
8982 borderColor: PALETTE[i % PALETTE.length], borderWidth: 1
8983 };
8984 })
8985 }, {
8986 scales: {
8987 x: { grid:{color:gc}, ticks:{color:tc}, title:{display:true, text:'Files Analyzed', color:tc} },
8988 y: { grid:{color:gc}, ticks:{color:tc, callback:function(v){return fmt(v);}}, title:{display:true, text:'Code Lines', color:tc} }
8989 },
8990 plugins: { legend:{position:'right', labels:{color:tc, boxWidth:12}} }
8991 }, 900, 260);
8992 pgScat.grid.appendChild(mkPanel('Files \u00d7 Code Lines (bubble size \u221d physical lines)', scatPng));
8993 root.appendChild(pgScat.group);
8994 }
8995
8996 // ── Semantic Metrics — up to 5 metrics, skip empty ones ─────────────────
8997 if (SEM_D && SEM_D.length) {
8998 var pgSem = mkGroup('Semantic Metrics');
8999 var SL={functions:'Functions',classes:'Classes / Types',variables:'Variables',imports:'Imports',tests:'Tests'};
9000 var SC={functions:OX,classes:'#4472C4',variables:GN,imports:'#805099',tests:'#B23030'};
9001 Object.keys(SL).forEach(function(mKey) {
9002 var data = SEM_D.slice().sort(function(a,b){return (b[mKey]||0)-(a[mKey]||0);}).slice(0,15);
9003 if (!data.some(function(d){return (d[mKey]||0)>0;})) return;
9004 var semH = 210; // vertical bar — fixed height; width drives layout, not row count
9005 var png = snap('bar', {
9006 labels: data.map(function(d){return d.lang;}),
9007 datasets:[{label:SL[mKey],data:data.map(function(d){return d[mKey]||0;}),backgroundColor:SC[mKey],borderRadius:4}]
9008 }, {
9009 scales:{
9010 x:{grid:{display:false},ticks:{color:tc}},
9011 y:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}}}
9012 },
9013 plugins:{legend:{display:false}}
9014 }, 900, semH);
9015 pgSem.grid.appendChild(mkPanel(SL[mKey], png));
9016 });
9017 root.appendChild(pgSem.group);
9018 }
9019
9020 // ── Submodule Breakdown — 3 Y-axis variants + donut SVG clone ────────────
9021 if (SUB_D && SUB_D.length) {
9022 var pgSub = mkGroup('Submodule Breakdown');
9023 [{key:'code',label:'Code Lines',col:OX},{key:'comment',label:'Comment Lines',col:GN},{key:'files',label:'File Count',col:'#805099'}]
9024 .forEach(function(y) {
9025 var data = SUB_D.slice().sort(function(a,b){return (b[y.key]||0)-(a[y.key]||0);}).slice(0,30);
9026 if (!data.length) return;
9027 var subH = Math.max(100, Math.min(420, data.length*16+30));
9028 var png = snap('bar', {
9029 labels: data.map(function(d){return d.name;}),
9030 datasets:[{label:y.label,data:data.map(function(d){return d[y.key]||0;}),backgroundColor:y.col,borderRadius:3}]
9031 }, {
9032 indexAxis:'y',
9033 scales:{
9034 x:{grid:{color:gc},ticks:{color:tc,callback:function(v){return fmt(v);}},title:{display:true,text:y.label,color:tc}},
9035 y:{grid:{display:false},ticks:{color:tc}}
9036 },
9037 plugins:{legend:{display:false}}
9038 }, 900, subH);
9039 pgSub.grid.appendChild(mkPanel(y.label, png));
9040 });
9041 var donutEl = document.getElementById('submodule-donut');
9042 if (donutEl && donutEl.innerHTML.trim()) {
9043 var dp = document.createElement('div'); dp.className = 'pdf-variant-panel';
9044 var dl = document.createElement('div'); dl.className = 'pdf-variant-label'; dl.textContent = 'Distribution';
9045 dp.appendChild(dl);
9046 var dw = document.createElement('div'); dw.style.cssText = 'display:flex;justify-content:center;';
9047 dw.innerHTML = donutEl.innerHTML; dp.appendChild(dw);
9048 pgSub.grid.appendChild(dp);
9049 }
9050 root.appendChild(pgSub.group);
9051 }
9052 })();
9053 })();
9054 window.oxSlocChartsReady = true;
9055 } catch(e) { window.oxSlocChartError = String(e); window.oxSlocChartsReady = true; }
9056 }); // end requestAnimationFrame
9057 // Safety net: if rAF never fires (headless browsers throttle it), mark ready
9058 // unconditionally so the PDF capture does not wait the full 15 s.
9059 setTimeout(function() { if (!window.oxSlocChartsReady) window.oxSlocChartsReady = true; }, 3000);
9060 // ── SVG tooltip delegation ───────────────────────────────────────────────
9061 (function(){
9062 var tt = document.getElementById('r-tt');
9063 if (!tt) return;
9064 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
9065 function show(e, html) { tt.innerHTML=html; tt.style.display='block'; move(e); }
9066 function hide() { tt.style.display='none'; }
9067 function move(e) {
9068 var x=e.clientX+16, y=e.clientY-12;
9069 var r=tt.getBoundingClientRect();
9070 if (x+r.width>window.innerWidth-8) x=e.clientX-r.width-8;
9071 if (y+r.height>window.innerHeight-8) y=e.clientY-r.height-8;
9072 tt.style.left=x+'px'; tt.style.top=y+'px';
9073 }
9074 document.addEventListener('mouseover', function(e) {
9075 var t=e.target;
9076 while(t&&t.getAttribute){
9077 var l=t.getAttribute('data-ttl');
9078 if(l!==null){ show(e,'<strong>'+escH(l)+'</strong><br>'+escH(t.getAttribute('data-ttv')||'').replace(/\n/g,'<br>')); return; }
9079 t=t.parentNode;
9080 }
9081 });
9082 document.addEventListener('mouseout', function(e) {
9083 var t=e.target;
9084 while(t&&t.getAttribute){
9085 if(t.getAttribute('data-ttl')!==null){ hide(); return; }
9086 t=t.parentNode;
9087 }
9088 });
9089 document.addEventListener('mousemove', function(e) {
9090 if(tt.style.display!=='none') move(e);
9091 });
9092 window.addEventListener('blur', function() { hide(); });
9093 document.addEventListener('visibilitychange', function() { if(document.hidden) hide(); });
9094 })();
9095 // Auto-populate title on any td that is visually truncated but has no explicit title
9096 requestAnimationFrame(function() {
9097 document.querySelectorAll('td').forEach(function(td) {
9098 if (!td.title && td.scrollWidth > td.clientWidth) {
9099 td.title = td.textContent.trim();
9100 }
9101 });
9102 });
9103
9104 </script>
9105 <script nonce="{{ nonce }}">
9106 (function(){
9107 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
9108 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
9109 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
9110 function init(){
9111 var btn=document.getElementById('settings-btn');if(!btn)return;
9112 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
9113 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
9114 document.body.appendChild(m);
9115 var g=document.getElementById('scheme-grid');
9116 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
9117 var cl=document.getElementById('settings-close');
9118 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});return v.year+'-'+v.month+'-'+v.day+' '+v.hour+':'+v.minute+' '+window.tzAbbr(tz);}catch(e){return'';}};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};var tzSel=document.getElementById('tz-select');var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);
9119 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
9120 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
9121 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
9122 }
9123 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9124 }());
9125 </script>
9126 <script nonce="{{ nonce }}">
9127 (function(){
9128 // Format delta card unmodified-lines value with comma separators
9129 Array.prototype.slice.call(document.querySelectorAll('.delta-card-inline[data-raw] .delta-card-val')).forEach(function(el){
9130 var raw=parseInt(el.parentNode.getAttribute('data-raw'),10);
9131 if(!isNaN(raw))el.textContent=raw.toLocaleString();
9132 });
9133 // Format code-before / code-now numbers in the prev-scan summary line
9134 Array.prototype.slice.call(document.querySelectorAll('.prev-scan-summary [data-raw]')).forEach(function(el){
9135 var raw=parseInt(el.getAttribute('data-raw'),10);
9136 if(!isNaN(raw))el.textContent=raw.toLocaleString();
9137 });
9138 }());
9139 </script>
9140 {% if has_style_data %}
9141 <script nonce="{{ nonce }}">
9142 (function(){
9143 var CHART_DATA = {{ style_chart_json|safe }};
9144 var FILE_DATA = {{ style_file_json|safe }};
9145 var SCORE_THRESHOLD = {{ style_score_threshold }};
9146 var activeLang = CHART_DATA.length ? CHART_DATA[0].family : '';
9147 var sftSortKey = '';
9148 var sftSortDir = 1;
9149 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
9150 // Official style guide URLs — covers every guide produced by the language analysers
9151 var GUIDE_URLS = {
9152 'PEP 8':'https://peps.python.org/pep-0008/',
9153 'PEP 8 (99-col)':'https://peps.python.org/pep-0008/',
9154 'Black':'https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html',
9155 'Google Python':'https://google.github.io/styleguide/pyguide.html',
9156 'Effective Go':'https://go.dev/doc/effective_go',
9157 'Uber Go':'https://github.com/uber-go/guide/blob/master/style.md',
9158 'Google Go':'https://google.github.io/styleguide/go/',
9159 'LLVM':'https://llvm.org/docs/CodingStandards.html',
9160 'Google':'https://google.github.io/styleguide/cppguide.html',
9161 'Mozilla':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/',
9162 'Microsoft':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9163 'WebKit':'https://webkit.org/code-style-guidelines/',
9164 'rustfmt defaults':'https://doc.rust-lang.org/rustfmt/',
9165 'Mozilla Rust':'https://firefox-source-docs.mozilla.org/code-quality/coding-style/coding-style-rust.html',
9166 'Rust API Guidelines':'https://rust-lang.github.io/api-guidelines/',
9167 'Relaxed (120-col)':'https://doc.rust-lang.org/rustfmt/',
9168 'Airbnb':'https://airbnb.io/javascript/',
9169 'Google JS':'https://google.github.io/styleguide/jsguide.html',
9170 'Standard.js':'https://standardjs.com/',
9171 'Prettier':'https://prettier.io/docs/en/options.html',
9172 'Airbnb TS':'https://airbnb.io/javascript/',
9173 'Google TS':'https://google.github.io/styleguide/tsguide.html',
9174 'Angular':'https://angular.dev/style-guide',
9175 'Microsoft TS':'https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines',
9176 'Google Java':'https://google.github.io/styleguide/javaguide.html',
9177 'Oracle/Sun':'https://www.oracle.com/java/technologies/javase/codeconventions-contents.html',
9178 'Spring':'https://github.com/spring-projects/spring-framework/wiki/Code-Style',
9179 'JetBrains':'https://www.jetbrains.com/help/idea/code-style.html',
9180 'Android':'https://source.android.com/docs/setup/contribute/code-style',
9181 'Google Kotlin':'https://developer.android.com/kotlin/style-guide',
9182 'Apache Groovy':'https://groovy-lang.org/style-guide.html',
9183 'Gradle DSL':'https://docs.gradle.org/current/userguide/groovy_build_script_primer.html',
9184 'Scala Style Guide':'https://docs.scala-lang.org/style/',
9185 'Lightbend':'https://docs.scala-lang.org/style/',
9186 'Spark':'https://spark.apache.org/contributing.html',
9187 'RuboCop':'https://docs.rubocop.org/rubocop/',
9188 'Airbnb Ruby':'https://github.com/airbnb/ruby',
9189 'Standard Ruby':'https://github.com/standardrb/standard',
9190 'Microsoft .NET':'https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions',
9191 'Google C#':'https://google.github.io/styleguide/csharp-style.html',
9192 'StyleCop':'https://github.com/DotNetAnalyzers/StyleCopAnalyzers',
9193 'Microsoft F#':'https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/formatting',
9194 'FSharp.Formatting':'https://fsprojects.github.io/FSharp.Formatting/'
9195 };
9196 // Human-readable descriptions for each guide shown in bar tooltips
9197 var GUIDE_DESC = {
9198 'PEP 8':'4-space | 79-col | Python style standard',
9199 'PEP 8 (99-col)':'4-space | 99-col | relaxed line limit',
9200 'Black':'4-space | 88-col | double quotes enforced',
9201 'Google Python':'4-space | 80-col | double quotes preferred',
9202 'Effective Go':'tabs | ~80-col | gofmt standard',
9203 'Uber Go':'tabs | 120-col max',
9204 'Google Go':'tabs | 80-col',
9205 'LLVM':'2-space | 80-col | C/C++ LLVM project style',
9206 'Google':'2-space | 80-col | Google C++ style',
9207 'Mozilla':'4-space | 80-col | Firefox codebase style',
9208 'Microsoft':'4-space | Allman braces | C++ Win32 style',
9209 'WebKit':'4-space | 80-col | WebKit engine style',
9210 'rustfmt defaults':'4-space | 100-col | official Rust formatter',
9211 'Mozilla Rust':'4-space | 100-col | Firefox Rust style',
9212 'Rust API Guidelines':'4-space | naming + docs conventions',
9213 'Relaxed (120-col)':'4-space | 120-col | relaxed line limit',
9214 'Airbnb':'2-space | single quotes | no semicolons opt',
9215 'Google JS':'2-space | 80-col | single quotes',
9216 'Standard.js':'2-space | no semicolons | single quotes',
9217 'Prettier':'2-space | 80-col | double quotes | semicolons',
9218 'Airbnb TS':'2-space | single quotes | TypeScript variant',
9219 'Google TS':'2-space | 80-col | single quotes | TypeScript',
9220 'Angular':'2-space | Angular team TypeScript conventions',
9221 'Microsoft TS':'4-space | TypeScript compiler team style',
9222 'Google Java':'2-space | 100-col | Google Java guide',
9223 'Oracle/Sun':'4-space | 80-col | original Java conventions',
9224 'Spring':'4-space | Spring Framework code style',
9225 'JetBrains':'4-space | IntelliJ default Java style',
9226 'Android':'4-space | 100-col | AOSP Java style',
9227 'Google Kotlin':'4-space | 100-col | Android Kotlin style',
9228 'Apache Groovy':'4-space | Apache Groovy style',
9229 'Gradle DSL':'4-space | Gradle build script conventions',
9230 'Scala Style Guide':'2-space | 100-col | official Scala style',
9231 'Lightbend':'2-space | Lightbend/Akka Scala style',
9232 'Spark':'2-space | Apache Spark Scala style',
9233 'RuboCop':'2-space | 120-col | community Ruby style',
9234 'Airbnb Ruby':'2-space | 80-col | Airbnb Ruby guide',
9235 'Standard Ruby':'2-space | 80-col | StandardRB formatter',
9236 'Microsoft .NET':'4-space | Allman braces | .NET C# style',
9237 'Google C#':'2-space | Google C# style guide',
9238 'StyleCop':'4-space | StyleCop analyzer rules',
9239 'Microsoft F#':'4-space | official F# formatting guide',
9240 'FSharp.Formatting':'4-space | FSharp.Formatting conventions'
9241 };
9242 function renderBars(family){
9243 var wrap=document.getElementById('style-guide-bars');
9244 if(!wrap)return;
9245 wrap.innerHTML='';
9246 var grp=null;
9247 for(var i=0;i<CHART_DATA.length;i++){if(CHART_DATA[i].family===family){grp=CHART_DATA[i];break;}}
9248 if(!grp||!grp.guides.length)return;
9249 grp.guides.forEach(function(d){
9250 var isTop=(d.guide===grp.dominant);
9251 var row=document.createElement('div');row.className='style-guide-row';
9252 // Hover tooltip showing guide name + score + description
9253 var tip=document.createElement('div');tip.className='style-bar-tip';
9254 var desc=GUIDE_DESC[d.guide]||'';
9255 tip.textContent=d.guide+': '+d.score+'%'+(desc?' \u00b7 '+desc:'');
9256 var lbl=document.createElement('div');lbl.className='style-guide-label';
9257 lbl.textContent=d.guide;
9258 if(isTop)lbl.style.color='var(--oxide)';
9259 var track=document.createElement('div');track.className='style-guide-track';
9260 var fill=document.createElement('div');fill.className='style-guide-fill';
9261 fill.style.width='0%';
9262 var pct=document.createElement('div');pct.className='style-guide-score';
9263 pct.textContent=d.score+'%';
9264 if(isTop)pct.style.color='var(--oxide)';
9265 track.appendChild(fill);
9266 row.appendChild(tip);
9267 row.appendChild(lbl);row.appendChild(track);row.appendChild(pct);
9268 wrap.appendChild(row);
9269 setTimeout(function(f,s){return function(){f.style.width=s+'%';};}(fill,d.score),60);
9270 });
9271 }
9272 function initTabs(){
9273 var tabsWrap=document.getElementById('style-lang-tabs');
9274 if(!tabsWrap||!CHART_DATA.length)return;
9275 CHART_DATA.forEach(function(grp){
9276 var btn=document.createElement('button');
9277 btn.className='style-lang-tab'+(grp.family===activeLang?' active':'');
9278 btn.textContent=grp.family+' ('+grp.files+')';
9279 btn.onclick=function(){
9280 activeLang=grp.family;
9281 var tabs=tabsWrap.querySelectorAll('.style-lang-tab');
9282 for(var i=0;i<tabs.length;i++)tabs[i].className='style-lang-tab';
9283 btn.className='style-lang-tab active';
9284 renderBars(activeLang);
9285 };
9286 tabsWrap.appendChild(btn);
9287 });
9288 renderBars(activeLang);
9289 }
9290 function buildGuideHtml(guide){
9291 if(!guide||guide==='\u2014'||guide==='Unknown')return'<span style="color:var(--muted);">\u2014</span>';
9292 var url=GUIDE_URLS[guide];
9293 var desc=GUIDE_DESC[guide]||'';
9294 var tipText='Open official '+guide+' documentation'+(desc?' \u00b7 '+desc:'');
9295 if(url){return'<a href="'+escH(url)+'" target="_blank" rel="noopener" class="style-badge">'+escH(guide)+'</a>';}
9296 return'<span class="style-badge">'+escH(guide)+'</span>';
9297 }
9298 function buildSigsHtml(sigs){
9299 if(!sigs||!sigs.length)return'<span style="color:var(--muted);">\u2014</span>';
9300 var html='';
9301 var visible=sigs.slice(0,2);
9302 var rest=sigs.slice(2);
9303 visible.forEach(function(s){html+='<span class="style-sig-chip">'+escH(s.v)+'</span>';});
9304 if(rest.length){html+='<span style="color:var(--muted);font-size:11px;margin-left:2px;">\u22EF</span>';}
9305 return html;
9306 }
9307 var _sigPop=null;
9308 window.showSigPop=function(btn,ev){
9309 ev.stopPropagation();
9310 if(_sigPop){var prev=_sigPop;_sigPop=null;prev.remove();if(btn._ownPop===prev)return;}
9311 var sigs;try{sigs=JSON.parse(btn.getAttribute('data-sigs'));}catch(e){return;}
9312 var pop=document.createElement('div');
9313 pop.className='style-sig-pop';
9314 pop.setAttribute('role','tooltip');
9315 var inner='<div class="style-sig-pop-title">All Signals</div>';
9316 sigs.forEach(function(s){
9317 inner+='<div class="style-sig-pop-row"><span class="style-sig-pop-key">'+escH(s.k)+':</span><span class="style-sig-pop-val">'+escH(s.v)+'</span></div>';
9318 });
9319 pop.innerHTML=inner;
9320 document.body.appendChild(pop);
9321 _sigPop=pop;
9322 btn._ownPop=pop;
9323 var r=btn.getBoundingClientRect();
9324 var pw=pop.offsetWidth||220;
9325 var left=r.left;
9326 if(left+pw>window.innerWidth-8)left=window.innerWidth-pw-8;
9327 if(left<8)left=8;
9328 var top=r.bottom+6;
9329 if(top+(pop.offsetHeight||120)>window.innerHeight-8)top=r.top-(pop.offsetHeight||120)-6;
9330 pop.style.left=left+'px';
9331 pop.style.top=top+'px';
9332 function dismiss(e){if(!pop.contains(e.target)){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
9333 function dismissKey(e){if(e.key==='Escape'){pop.remove();if(_sigPop===pop)_sigPop=null;document.removeEventListener('click',dismiss);document.removeEventListener('keydown',dismissKey);}}
9334 setTimeout(function(){document.addEventListener('click',dismiss);document.addEventListener('keydown',dismissKey);},0);
9335 }
9336 var sftRows=[];
9337 var sftFilteredRows=[];
9338 var sftCurrentPage=1;
9339 function sftGetPageSize(){
9340 var sel=document.getElementById('sft-page-size');
9341 var v=sel?sel.value:'20';
9342 return v==='all'?Infinity:parseInt(v,10);
9343 }
9344 function sftApplyFilter(){
9345 var inp=document.getElementById('sft-search');
9346 var q=inp?inp.value.toLowerCase():'';
9347 var sorted=sftRows.slice();
9348 if(sftSortKey){
9349 sorted.sort(function(a,b){
9350 if(sftSortKey==='score'){var av=a.score||0,bv=b.score||0;return sftSortDir*(av-bv);}
9351 var av=String(a[sftSortKey]||'').toLowerCase(),bv=String(b[sftSortKey]||'').toLowerCase();
9352 return av<bv?-1*sftSortDir:av>bv?1*sftSortDir:0;
9353 });
9354 }
9355 sftFilteredRows=q===''?sorted:sorted.filter(function(f){
9356 return (f.path||'').toLowerCase().indexOf(q)>=0
9357 ||(f.lang||'').toLowerCase().indexOf(q)>=0
9358 ||(f.guide||'').toLowerCase().indexOf(q)>=0
9359 ||(f.indent||'').toLowerCase().indexOf(q)>=0;
9360 });
9361 sftCurrentPage=1;
9362 renderSftTable();
9363 }
9364 function renderSftTable(){
9365 var tbody=document.getElementById('style-file-tbody');
9366 if(!tbody)return;
9367 var ps=sftGetPageSize();
9368 var total=sftFilteredRows.length;
9369 var totalAll=sftRows.length;
9370 var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(total/ps));
9371 if(sftCurrentPage>totalPages)sftCurrentPage=totalPages;
9372 if(sftCurrentPage<1)sftCurrentPage=1;
9373 var start=ps===Infinity?0:(sftCurrentPage-1)*ps;
9374 var end=ps===Infinity?total:Math.min(start+ps,total);
9375 var page=sftFilteredRows.slice(start,end);
9376 var html='';
9377 page.forEach(function(f){
9378 var barW=Math.round(f.score);
9379 var guide=f.guide&&f.guide!=='Unknown'?f.guide:'';
9380 var badge=guide?buildGuideHtml(guide):'<span style="color:var(--muted);">\u2014</span>';
9381 var sigHtml=buildSigsHtml(f.signals);
9382 var rowClass=SCORE_THRESHOLD>0&&f.score<SCORE_THRESHOLD?' class="style-row-warn"':'';
9383 html+='<tr'+rowClass+'>'
9384 +'<td title="'+escH(f.path)+'">'+escH(f.path.replace(/^.*[\/\\]/,''))+'</td>'
9385 +'<td>'+escH(f.lang)+'</td>'
9386 +'<td>'+escH(f.indent)+'</td>'
9387 +'<td class="guide-cell" data-gtip="'+(guide?(escH(guide)+(GUIDE_DESC[guide]?' \u00b7 '+escH(GUIDE_DESC[guide]):'')):'')+'">' +badge+'</td>'
9388 +'<td><span class="style-score-bar"><span class="style-score-fill" style="width:'+barW+'%"></span></span>'+f.score+'%</td>'
9389 +'<td class="sig-cell" data-sigs="'+escH(JSON.stringify(f.signals||[]))+'">'+sigHtml+'</td>'
9390 +'</tr>';
9391 });
9392 tbody.innerHTML=html||'<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
9393 var pageInfo=document.getElementById('sft-page-info');
9394 var firstBtn=document.getElementById('sft-first');
9395 var prevBtn=document.getElementById('sft-prev');
9396 var nextBtn=document.getElementById('sft-next');
9397 var lastBtn=document.getElementById('sft-last');
9398 var jumpInput=document.getElementById('sft-page-jump');
9399 var pageTotal=document.getElementById('sft-page-total');
9400 var countLabel=document.getElementById('sft-count-label');
9401 if(pageInfo){
9402 if(total===0){pageInfo.textContent='No results';}
9403 else if(ps===Infinity){pageInfo.textContent='All '+total.toLocaleString()+' files';}
9404 else{pageInfo.textContent=(start+1)+'\u2013'+end+' of '+total.toLocaleString()+' files';}
9405 }
9406 if(countLabel){countLabel.textContent=(total<totalAll&&total>0)?'('+total.toLocaleString()+' matching)':'';}
9407 var edgeOff=ps===Infinity;
9408 if(firstBtn)firstBtn.disabled=sftCurrentPage<=1||edgeOff;
9409 if(prevBtn)prevBtn.disabled=sftCurrentPage<=1||edgeOff;
9410 if(nextBtn)nextBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
9411 if(lastBtn)lastBtn.disabled=sftCurrentPage>=totalPages||edgeOff;
9412 if(jumpInput){jumpInput.value=sftCurrentPage;jumpInput.max=totalPages;jumpInput.disabled=edgeOff;}
9413 if(pageTotal)pageTotal.textContent=totalPages.toLocaleString();
9414 }
9415 function initStyleTable(){
9416 if(!FILE_DATA.length){
9417 var tb=document.getElementById('style-file-tbody');
9418 if(tb)tb.innerHTML='<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:18px;">No style-analysed files</td></tr>';
9419 return;
9420 }
9421 sftRows=FILE_DATA.slice();
9422 sftFilteredRows=sftRows.slice();
9423 sftApplyFilter();
9424 // Signal & guide cell tooltip (appears above hovered cell, arrow points down)
9425 var chipTipEl=document.createElement('div');
9426 chipTipEl.className='sig-tip';
9427 document.body.appendChild(chipTipEl);
9428 function _showSigTip(html,cell){
9429 chipTipEl.innerHTML=html;
9430 chipTipEl.style.display='block';
9431 var r=cell.getBoundingClientRect();
9432 var tw=chipTipEl.offsetWidth||220;
9433 var th=chipTipEl.offsetHeight||80;
9434 var cx=r.left+r.width/2;
9435 var left=cx-tw/2;
9436 if(left<8)left=8;
9437 if(left+tw>window.innerWidth-8)left=window.innerWidth-tw-8;
9438 var arrowPct=Math.round((cx-left)/tw*100);
9439 if(arrowPct<10)arrowPct=10;
9440 if(arrowPct>90)arrowPct=90;
9441 chipTipEl.style.setProperty('--sig-tip-ax',arrowPct+'%');
9442 var top=r.top-th-12;
9443 if(top<8)top=r.bottom+8;
9444 chipTipEl.style.left=left+'px';
9445 chipTipEl.style.top=top+'px';
9446 chipTipEl.classList.add('visible');
9447 }
9448 function _hideSigTip(){
9449 chipTipEl.classList.remove('visible');
9450 chipTipEl.style.display='none';
9451 }
9452 function _buildSigHtml(cell){
9453 var sigs;try{sigs=JSON.parse(cell.getAttribute('data-sigs'));}catch(ex){return null;}
9454 if(!sigs||!sigs.length)return null;
9455 var html='<div class="sig-tip-hd">Signals</div>';
9456 sigs.forEach(function(s){
9457 html+='<div class="sig-tip-row"><span class="sig-tip-k">'+escH(s.k)+':</span><span class="sig-tip-v">'+escH(s.v)+'</span></div>';
9458 });
9459 return html;
9460 }
9461 function _buildGuideHtml(cell){
9462 var tip=cell.getAttribute('data-gtip')||'';
9463 if(!tip)return null;
9464 var parts=tip.split(' \u00b7 ',2);
9465 var html='<div class="sig-tip-hd">'+escH(parts[0])+'</div>';
9466 if(parts[1])html+='<div class="sig-tip-v" style="font-size:11px;">'+escH(parts[1])+'</div>';
9467 return html;
9468 }
9469 var sigTbl=document.getElementById('style-file-table');
9470 if(sigTbl){
9471 sigTbl.addEventListener('mouseover',function(e){
9472 var sc=e.target.closest?e.target.closest('.sig-cell'):null;
9473 var gc=e.target.closest?e.target.closest('.guide-cell'):null;
9474 if(sc){var h=_buildSigHtml(sc);if(h)_showSigTip(h,sc);return;}
9475 if(gc){var h=_buildGuideHtml(gc);if(h){var badge=gc.querySelector('.style-badge')||gc;_showSigTip(h,badge);}return;}
9476 _hideSigTip();
9477 });
9478 sigTbl.addEventListener('mouseleave',function(){
9479 _hideSigTip();
9480 });
9481 sigTbl.addEventListener('mouseout',function(e){
9482 if(!e.relatedTarget||!sigTbl.contains(e.relatedTarget))_hideSigTip();
9483 });
9484 }
9485 // Wire up sortable column headers
9486 var ths=document.querySelectorAll('#style-file-table thead th[data-sort-key]');
9487 for(var i=0;i<ths.length;i++){(function(th){
9488 th.style.cursor='pointer';
9489 th.addEventListener('click',function(){
9490 var key=th.getAttribute('data-sort-key');
9491 if(sftSortKey===key){sftSortDir*=-1;}else{sftSortKey=key;sftSortDir=1;}
9492 for(var j=0;j<ths.length;j++){
9493 ths[j].classList.remove('sft-sort-asc','sft-sort-desc');
9494 var ind=ths[j].querySelector('.style-sort-ind');
9495 if(ind)ind.textContent='\u25BE';
9496 }
9497 th.classList.add(sftSortDir===1?'sft-sort-asc':'sft-sort-desc');
9498 var tind=th.querySelector('.style-sort-ind');
9499 if(tind)tind.textContent=sftSortDir===1?'\u25B2':'\u25BC';
9500 sftApplyFilter();
9501 });
9502 })(ths[i]);}
9503 var searchInput=document.getElementById('sft-search');
9504 if(searchInput){
9505 var sftTimer=null;
9506 searchInput.addEventListener('input',function(){clearTimeout(sftTimer);sftTimer=setTimeout(sftApplyFilter,200);});
9507 }
9508 var pageSel=document.getElementById('sft-page-size');
9509 if(pageSel){pageSel.addEventListener('change',function(){sftCurrentPage=1;renderSftTable();});}
9510 var sftFirstBtn=document.getElementById('sft-first');
9511 var sftPrevBtn=document.getElementById('sft-prev');
9512 var sftNextBtn=document.getElementById('sft-next');
9513 var sftLastBtn=document.getElementById('sft-last');
9514 var sftJumpInput=document.getElementById('sft-page-jump');
9515 if(sftFirstBtn){sftFirstBtn.addEventListener('click',function(){sftCurrentPage=1;renderSftTable();});}
9516 if(sftPrevBtn){sftPrevBtn.addEventListener('click',function(){if(sftCurrentPage>1){sftCurrentPage--;renderSftTable();}});}
9517 if(sftNextBtn){sftNextBtn.addEventListener('click',function(){
9518 var ps=sftGetPageSize();
9519 var totalPages=ps===Infinity?1:Math.ceil(sftFilteredRows.length/ps);
9520 if(sftCurrentPage<totalPages){sftCurrentPage++;renderSftTable();}
9521 });}
9522 if(sftLastBtn){sftLastBtn.addEventListener('click',function(){
9523 var ps=sftGetPageSize();
9524 sftCurrentPage=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
9525 renderSftTable();
9526 });}
9527 if(sftJumpInput){
9528 function sftJump(){
9529 var ps=sftGetPageSize();
9530 var totalPages=ps===Infinity?1:Math.max(1,Math.ceil(sftFilteredRows.length/ps));
9531 var v=parseInt(sftJumpInput.value,10);
9532 if(!isNaN(v)){sftCurrentPage=Math.max(1,Math.min(v,totalPages));renderSftTable();}
9533 }
9534 sftJumpInput.addEventListener('change',sftJump);
9535 sftJumpInput.addEventListener('keydown',function(e){if(e.key==='Enter')sftJump();});
9536 }
9537 }
9538 function initSigInfoBtn(){
9539 var btn=document.getElementById('sig-info-btn');
9540 if(!btn)return;
9541 btn.addEventListener('click',function(){
9542 var overlay=document.createElement('div');
9543 overlay.className='style-sig-info-overlay';
9544 var GLOSSARY=[
9545 ['Quote Style','Dominant string quote character used in the file (single quotes, double quotes, or mixed)'],
9546 ['Indentation','Leading-whitespace style detected: Tabs, 2-Space, 4-Space, 8-Space, or Mixed'],
9547 ['Brace Style','Opening brace placement: K\u0026R / Attach (same line as statement) or Allman (own line)'],
9548 ['Semicolons','Whether statement-ending semicolons are present (JS/TS). \u201cNone detected\u201d means ASI-style.'],
9549 ['Variable Declarations','Preferred declaration keyword: const/let vs var (JS), short := vs var (Go)'],
9550 ['Function Naming','Dominant function naming convention: snake_case or CamelCase'],
9551 ['Type Hints','Whether Python PEP 484 type annotations (:Type, ->Type) are used in the file'],
9552 ['Wildcard Imports','Presence of import * wildcard import statements (Java/Kotlin)'],
9553 ['Pointer Style','Pointer/reference alignment in C/C++: *var (name-attached) or Type* (type-attached)'],
9554 ['Arrow Functions','Count of arrow function => expressions detected in JS/TS files'],
9555 ['Max Line Length','Character length of the longest line found in the file'],
9556 ['Error Handling','Presence of Go-style if err != nil error-checking patterns'],
9557 ['Type Inference','Whether the C# var keyword is used for implicit type inference'],
9558 ['Frozen String Literal','Whether the # frozen_string_literal: true pragma is present (Ruby)'],
9559 ['Space Before Paren','Spacing convention before opening parentheses in control structures (C/C++)'],
9560 ['Include Guard','Whether #pragma once is used as a header include guard (C/C++)']
9561 ];
9562 var rows='';
9563 GLOSSARY.forEach(function(g){rows+='<span class="style-sig-info-name">'+escH(g[0])+'</span><span class="style-sig-info-desc">'+escH(g[1])+'</span>';});
9564 overlay.innerHTML='<div class="style-sig-info-modal" role="dialog" aria-modal="true" aria-label="Signal glossary"><button type="button" class="style-sig-info-close" aria-label="Close">\u00D7</button><h3 style="margin:0 0 6px;font-size:17px;">Signal Glossary</h3><p style="color:var(--muted);font-size:13px;margin:0 0 2px;">Lexical signals detected per file. Values reflect dominant patterns in the source text.</p><div class="style-sig-info-grid">'+rows+'</div></div>';
9565 document.body.appendChild(overlay);
9566 overlay.addEventListener('click',function(e){if(e.target===overlay||e.target.classList.contains('style-sig-info-close')){overlay.remove();}});
9567 });
9568 }
9569 function init(){initTabs();initStyleTable();initSigInfoBtn();}
9570 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
9571 }());
9572 </script>
9573 {% endif %}
9574 <script nonce="{{ nonce }}">
9575 (function(){
9576 var params=new URLSearchParams(location.search);
9577 if(params.get('autoprint')!=='1')return;
9578 var overlay=document.createElement('div');
9579 overlay.id='autoprint-overlay';
9580 overlay.style.cssText='position:fixed;inset:0;z-index:99999;background:var(--bg,#fff);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;';
9581 overlay.innerHTML='<div style="font-size:20px;font-weight:800;color:var(--text,#1a1a1a);">Preparing PDF\u2026</div>'
9582 +'<div style="font-size:13px;color:var(--muted,#666);">Use your browser\u2019s print dialog \u2192 <strong>Save as PDF</strong>.</div>'
9583 +'<div style="width:200px;height:4px;border-radius:2px;background:rgba(0,0,0,0.1);overflow:hidden;">'
9584 +'<div id="autoprint-bar" style="height:100%;width:0%;background:#e07b3a;transition:width 1.5s ease;border-radius:2px;"></div></div>';
9585 document.body.appendChild(overlay);
9586 setTimeout(function(){var b=document.getElementById('autoprint-bar');if(b)b.style.width='80%';},50);
9587 var deadline=Date.now()+12000;
9588 function tryPrint(){
9589 if(window.oxSlocChartsReady||Date.now()>deadline){
9590 var b=document.getElementById('autoprint-bar');
9591 if(b)b.style.width='100%';
9592 setTimeout(function(){
9593 overlay.style.display='none';
9594 window.print();
9595 },350);
9596 } else {
9597 setTimeout(tryPrint,150);
9598 }
9599 }
9600 if(document.readyState==='loading'){
9601 document.addEventListener('DOMContentLoaded',function(){setTimeout(tryPrint,250);});
9602 } else {
9603 setTimeout(tryPrint,250);
9604 }
9605 window.addEventListener('afterprint',function(){overlay.remove();});
9606 }());
9607 </script>
9608 <footer class="report-footer">local code analysis — metrics, history and reports · oxide-sloc v{{ tool_version }} · AGPL-3.0-or-later · offline / air-gapped build</footer>
9609 {% if let Some(banner) = report_header_footer %}
9610 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
9611 {% endif %}
9612</body>
9613</html>"##,
9614 ext = "html"
9615)]
9616#[allow(clippy::struct_excessive_bools, dead_code)]
9619struct ReportTemplate<'a> {
9620 nonce: String,
9621 title: String,
9622 browser_title: String,
9623 scan_performed_by: String,
9624 scan_time_pst: String,
9625 tool_version: String,
9626 is_sub_report: bool,
9627 run: &'a AnalysisRun,
9628 language_rows: Vec<LanguageRow>,
9629 file_rows: Vec<FileRow>,
9630 skipped_rows: Vec<FileRow>,
9631 config_json: String,
9632 lang_chart_json: String,
9633 submodule_chart_json: String,
9634 scatter_chart_json: String,
9635 semantic_chart_json: String,
9636 file_size_histogram_json: String,
9637 has_submodule_data: bool,
9638 has_semantic_data: bool,
9639 has_coverage_data: bool,
9640 has_fn_coverage: bool,
9641 has_branch_coverage: bool,
9642 test_files_count: u64,
9643 test_assertion_count: u64,
9644 test_suite_count: u64,
9645 test_density: String,
9646 most_tested_lang: String,
9647 langs_with_tests: usize,
9648 cov_line_pct: String,
9649 cov_fn_pct: String,
9650 cov_branch_pct: String,
9651 cov_line_class: String,
9652 cov_fn_class: String,
9653 cov_branch_class: String,
9654 has_run_warnings: bool,
9655 warning_count: usize,
9656 warning_summary_rows: Vec<WarningSummaryRow>,
9657 warning_opportunity_rows: Vec<WarningOpportunityRow>,
9658 warning_console_full: String,
9659 logo_text_uri: String,
9660 small_logo_uri: String,
9661 custom_logo_uri: Option<String>,
9663 company_name: Option<String>,
9665 accent_hex: Option<String>,
9667 report_header_footer: Option<String>,
9669 chart_js: &'static str,
9670 run_id_short: String,
9671 standalone_pdf_url: Option<String>,
9675 git_commit_url: Option<String>,
9678 git_branch_url: Option<String>,
9681 has_style_data: bool,
9683 style_lang_count: usize,
9685 style_score_threshold: u8,
9687 style_chart_json: String,
9689 style_file_json: String,
9691 style_summary: Option<StyleSummary>,
9693 has_delta: bool,
9695 delta_code_added: i64,
9696 delta_code_removed: i64,
9697 delta_unmodified_lines: i64,
9698 delta_files_added: usize,
9699 delta_files_removed: usize,
9700 delta_files_modified: usize,
9701 delta_files_unchanged: usize,
9702 prev_code_lines: u64,
9703 prev_scan_count: usize,
9704 prev_scan_label: String,
9705 prev_run_id: String,
9706 has_cocomo: bool,
9708 cocomo_effort_str: String,
9710 cocomo_duration_str: String,
9712 cocomo_staff_str: String,
9714 cocomo_ksloc_str: String,
9716 cocomo_mode_label: String,
9718 cocomo_mode_tooltip: String,
9720 uloc: u64,
9722 dryness_pct_str: String,
9724 duplicate_group_count: usize,
9726 has_hotspots: bool,
9728 hotspot_rows: Vec<HotspotRow>,
9730}
9731
9732fn csv_escape(s: &str) -> String {
9737 if s.contains(',') || s.contains('"') || s.contains('\n') {
9738 format!("\"{}\"", s.replace('"', "\"\""))
9739 } else {
9740 s.to_string()
9741 }
9742}
9743
9744pub fn write_csv(run: &AnalysisRun, path: &Path) -> Result<()> {
9750 let mut out = String::new();
9751
9752 out.push_str("# Summary\r\n");
9754 out.push_str("Metric,Value\r\n");
9755 let _ = write!(out, "Run ID,{}\r\n", csv_escape(&run.tool.run_id));
9756 let _ = write!(
9757 out,
9758 "Timestamp,{}\r\n",
9759 csv_escape(
9760 &run.tool
9761 .timestamp_utc
9762 .format("%Y-%m-%d %H:%M:%S UTC")
9763 .to_string()
9764 )
9765 );
9766 let _ = write!(
9767 out,
9768 "Report Title,{}\r\n",
9769 csv_escape(&run.effective_configuration.reporting.report_title)
9770 );
9771 let _ = write!(
9772 out,
9773 "Files Analyzed,{}\r\n",
9774 run.summary_totals.files_analyzed
9775 );
9776 let _ = write!(
9777 out,
9778 "Files Skipped,{}\r\n",
9779 run.summary_totals.files_skipped
9780 );
9781 let _ = write!(
9782 out,
9783 "Physical Lines,{}\r\n",
9784 run.summary_totals.total_physical_lines
9785 );
9786 let _ = write!(out, "Code Lines,{}\r\n", run.summary_totals.code_lines);
9787 let _ = write!(
9788 out,
9789 "Comment Lines,{}\r\n",
9790 run.summary_totals.comment_lines
9791 );
9792 let _ = write!(out, "Blank Lines,{}\r\n", run.summary_totals.blank_lines);
9793 let _ = write!(
9794 out,
9795 "Mixed Lines (separate),{}\r\n",
9796 run.summary_totals.mixed_lines_separate
9797 );
9798
9799 out.push_str("\r\n# By Language\r\n");
9801 out.push_str(
9802 "Language,Files,Physical Lines,Code Lines,Comment Lines,Blank Lines,Mixed Lines\r\n",
9803 );
9804 for lang in &run.totals_by_language {
9805 let _ = write!(
9806 out,
9807 "{},{},{},{},{},{},{}\r\n",
9808 csv_escape(lang.language.display_name()),
9809 lang.files,
9810 lang.total_physical_lines,
9811 lang.code_lines,
9812 lang.comment_lines,
9813 lang.blank_lines,
9814 lang.mixed_lines_separate,
9815 );
9816 }
9817
9818 write_csv_per_file_section(&mut out, run);
9820
9821 fs::write(path, out).with_context(|| format!("failed to write CSV to {}", path.display()))
9822}
9823
9824fn write_csv_per_file_section(out: &mut String, run: &AnalysisRun) {
9826 if run.per_file_records.is_empty() {
9827 return;
9828 }
9829 let has_activity = run
9831 .per_file_records
9832 .iter()
9833 .any(|r| r.commit_count.is_some());
9834 out.push_str("\r\n# Per File\r\n");
9835 out.push_str(
9836 "Path,Language,Size (bytes),Code Lines,Comment Lines,Blank Lines,Physical Lines,Generated,Minified,Vendor",
9837 );
9838 if has_activity {
9839 out.push_str(",Commits,Last Changed");
9840 }
9841 out.push_str("\r\n");
9842 for rec in &run.per_file_records {
9843 let _ = write!(
9844 out,
9845 "{},{},{},{},{},{},{},{},{},{}",
9846 csv_escape(&rec.relative_path),
9847 csv_escape(
9848 &rec.language
9849 .map(|l| l.display_name().to_string())
9850 .unwrap_or_default()
9851 ),
9852 rec.size_bytes,
9853 rec.effective_counts.code_lines,
9854 rec.effective_counts.comment_lines,
9855 rec.effective_counts.blank_lines,
9856 rec.raw_line_categories.total_physical_lines,
9857 rec.generated,
9858 rec.minified,
9859 rec.vendor,
9860 );
9861 if has_activity {
9862 let _ = write!(
9863 out,
9864 ",{},{}",
9865 rec.commit_count.map(|c| c.to_string()).unwrap_or_default(),
9866 csv_escape(rec.last_commit_date.as_deref().unwrap_or("")),
9867 );
9868 }
9869 out.push_str("\r\n");
9870 }
9871}
9872
9873pub fn write_diff_csv(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
9879 let s = &cmp.summary;
9880 let mut out = String::new();
9881
9882 out.push_str("# Diff Summary\r\n");
9883 out.push_str("Metric,Value\r\n");
9884 let _ = write!(out, "Baseline Run,{}\r\n", csv_escape(&s.baseline_run_id));
9885 let _ = write!(out, "Current Run,{}\r\n", csv_escape(&s.current_run_id));
9886 let _ = write!(out, "Files Added,{}\r\n", cmp.files_added);
9887 let _ = write!(out, "Files Removed,{}\r\n", cmp.files_removed);
9888 let _ = write!(out, "Files Modified,{}\r\n", cmp.files_modified);
9889 let _ = write!(out, "Files Unchanged,{}\r\n", cmp.files_unchanged);
9890 let _ = write!(out, "Code Δ,{}\r\n", s.code_lines_delta);
9891 let _ = write!(out, "Comment Δ,{}\r\n", s.comment_lines_delta);
9892 let _ = write!(out, "Blank Δ,{}\r\n", s.blank_lines_delta);
9893 let _ = write!(out, "Total Δ,{}\r\n", s.total_lines_delta);
9894
9895 out.push_str("\r\n# File Deltas\r\n");
9896 out.push_str("Status,Path,Language,Baseline Code,Current Code,Code Δ,Baseline Comment,Current Comment,Comment Δ,Baseline Blank,Current Blank,Blank Δ,Total Δ\r\n");
9897 for f in &cmp.file_deltas {
9898 let status = match f.status {
9899 sloc_core::FileChangeStatus::Added => "Added",
9900 sloc_core::FileChangeStatus::Removed => "Removed",
9901 sloc_core::FileChangeStatus::Modified => "Modified",
9902 sloc_core::FileChangeStatus::Unchanged => "Unchanged",
9903 };
9904 let _ = write!(
9905 out,
9906 "{},{},{},{},{},{},{},{},{},{},{},{},{}\r\n",
9907 status,
9908 csv_escape(&f.relative_path),
9909 csv_escape(f.language.as_deref().unwrap_or("")),
9910 f.baseline_code,
9911 f.current_code,
9912 f.code_delta,
9913 f.baseline_comment,
9914 f.current_comment,
9915 f.comment_delta,
9916 f.baseline_blank,
9917 f.current_blank,
9918 f.blank_delta,
9919 f.total_delta,
9920 );
9921 }
9922
9923 fs::write(path, out).with_context(|| format!("failed to write diff CSV to {}", path.display()))
9924}
9925
9926fn crc32(data: &[u8]) -> u32 {
9935 let mut crc: u32 = 0xffff_ffff;
9936 for &b in data {
9937 crc ^= u32::from(b);
9938 for _ in 0..8 {
9939 crc = if crc & 1 == 0 {
9940 crc >> 1
9941 } else {
9942 (crc >> 1) ^ 0xedb8_8320
9943 };
9944 }
9945 }
9946 !crc
9947}
9948
9949struct ZipEntry {
9950 name: Vec<u8>,
9951 data: Vec<u8>,
9952 crc: u32,
9953 offset: u32,
9954}
9955
9956#[allow(clippy::cast_possible_truncation)] fn zip_add(entries: &mut Vec<ZipEntry>, buf: &mut Vec<u8>, name: &str, data: Vec<u8>) {
9958 let crc = crc32(&data);
9959 let offset = buf.len() as u32;
9960 let name_bytes = name.as_bytes().to_vec();
9961 let size = data.len() as u32;
9962
9963 buf.extend_from_slice(&0x0403_4b50_u32.to_le_bytes());
9965 buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&crc.to_le_bytes());
9971 buf.extend_from_slice(&size.to_le_bytes()); buf.extend_from_slice(&size.to_le_bytes()); buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
9974 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&name_bytes);
9976 buf.extend_from_slice(&data);
9977
9978 entries.push(ZipEntry {
9979 name: name_bytes,
9980 data,
9981 crc,
9982 offset,
9983 });
9984}
9985
9986#[allow(clippy::cast_possible_truncation)] fn zip_finish(mut buf: Vec<u8>, entries: &[ZipEntry]) -> Vec<u8> {
9988 let central_start = buf.len() as u32;
9989
9990 for e in entries {
9991 let size = e.data.len() as u32;
9992 buf.extend_from_slice(&0x0201_4b50_u32.to_le_bytes()); buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&20u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&e.crc.to_le_bytes());
10000 buf.extend_from_slice(&size.to_le_bytes());
10001 buf.extend_from_slice(&size.to_le_bytes());
10002 buf.extend_from_slice(&(e.name.len() as u16).to_le_bytes());
10003 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&e.offset.to_le_bytes());
10009 buf.extend_from_slice(&e.name);
10010 }
10011
10012 let central_size = buf.len() as u32 - central_start;
10013 let n = entries.len() as u16;
10014
10015 buf.extend_from_slice(&0x0605_4b50_u32.to_le_bytes());
10017 buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&0u16.to_le_bytes()); buf.extend_from_slice(&n.to_le_bytes()); buf.extend_from_slice(&n.to_le_bytes()); buf.extend_from_slice(¢ral_size.to_le_bytes());
10022 buf.extend_from_slice(¢ral_start.to_le_bytes());
10023 buf.extend_from_slice(&0u16.to_le_bytes()); buf
10026}
10027
10028fn xml_escape(s: &str) -> String {
10029 s.replace('&', "&")
10030 .replace('<', "<")
10031 .replace('>', ">")
10032 .replace('"', """)
10033 .replace('\'', "'")
10034}
10035
10036const XLS_HEADER: u32 = 1;
10048const XLS_BODY: u32 = 2;
10049const XLS_BODY_ALT: u32 = 3;
10050const XLS_NUM: u32 = 4;
10051const XLS_NUM_ALT: u32 = 5;
10052const XLS_KV_KEY: u32 = 6;
10053const XLS_KV_VAL: u32 = 7;
10054
10055struct XlSheet<'a> {
10056 name: &'a str,
10057 tab_color: &'a str, headers: &'a [&'a str],
10059 rows: Vec<Vec<String>>,
10060 col_widths: Vec<f64>, is_kv: bool, }
10063
10064#[allow(clippy::cast_possible_truncation)] fn xl_col_name(idx: usize) -> String {
10066 let mut n = idx + 1;
10067 let mut s = String::new();
10068 while n > 0 {
10069 n -= 1;
10070 s.insert(0, char::from(b'A' + (n % 26) as u8));
10071 n /= 26;
10072 }
10073 s
10074}
10075
10076const fn xl_styles() -> &'static str {
10077 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
10078<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
10079<numFmts count=\"1\">\
10080<numFmt numFmtId=\"164\" formatCode=\"#,##0\"/>\
10081</numFmts>\
10082<fonts count=\"3\">\
10083<font><sz val=\"11\"/><name val=\"Calibri\"/></font>\
10084<font><b/><sz val=\"11\"/><color rgb=\"FFFFFFFF\"/><name val=\"Calibri\"/></font>\
10085<font><b/><sz val=\"11\"/><color rgb=\"FF283790\"/><name val=\"Calibri\"/></font>\
10086</fonts>\
10087<fills count=\"5\">\
10088<fill><patternFill patternType=\"none\"/></fill>\
10089<fill><patternFill patternType=\"gray125\"/></fill>\
10090<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FF283790\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10091<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFF5EFE8\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10092<fill><patternFill patternType=\"solid\"><fgColor rgb=\"FFFBF7F2\"/><bgColor indexed=\"64\"/></patternFill></fill>\
10093</fills>\
10094<borders count=\"2\">\
10095<border><left/><right/><top/><bottom/><diagonal/></border>\
10096<border>\
10097<left style=\"thin\"><color rgb=\"FFD0B8A0\"/></left>\
10098<right style=\"thin\"><color rgb=\"FFD0B8A0\"/></right>\
10099<top style=\"thin\"><color rgb=\"FFD0B8A0\"/></top>\
10100<bottom style=\"thin\"><color rgb=\"FFD0B8A0\"/></bottom>\
10101<diagonal/>\
10102</border>\
10103</borders>\
10104<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>\
10105<cellXfs count=\"8\">\
10106<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/>\
10107<xf numFmtId=\"0\" fontId=\"1\" fillId=\"2\" borderId=\"1\" xfId=\"0\" \
10108applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10109<alignment horizontal=\"center\" vertical=\"center\"/></xf>\
10110<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10111<xf numFmtId=\"0\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" applyFill=\"1\" applyBorder=\"1\"/>\
10112<xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" \
10113applyNumberFormat=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10114<alignment horizontal=\"right\"/></xf>\
10115<xf numFmtId=\"164\" fontId=\"0\" fillId=\"3\" borderId=\"1\" xfId=\"0\" \
10116applyNumberFormat=\"1\" applyFill=\"1\" applyBorder=\"1\" applyAlignment=\"1\">\
10117<alignment horizontal=\"right\"/></xf>\
10118<xf numFmtId=\"0\" fontId=\"2\" fillId=\"4\" borderId=\"1\" xfId=\"0\" \
10119applyFont=\"1\" applyFill=\"1\" applyBorder=\"1\"/>\
10120<xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"1\" xfId=\"0\" applyBorder=\"1\"/>\
10121</cellXfs>\
10122</styleSheet>"
10123}
10124
10125fn xl_sheet_xml(sheet: &XlSheet<'_>) -> Vec<u8> {
10126 let ncols = sheet.headers.len();
10127 let ndata = sheet.rows.len();
10128 let last_col = xl_col_name(ncols.saturating_sub(1));
10129 let last_row = ndata + 1;
10130 let range = format!("A1:{last_col}{last_row}");
10131
10132 let mut xml = String::with_capacity(4096 + ndata * 256);
10133 let _ = write!(
10134 xml,
10135 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10136 <worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\n\
10137 <sheetPr><tabColor rgb=\"{tc}\"/></sheetPr>\n\
10138 <dimension ref=\"{rng}\"/>\n\
10139 <sheetViews><sheetView workbookViewId=\"0\">\
10140 <pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/>\
10141 <selection pane=\"bottomLeft\" activeCell=\"A2\" sqref=\"A2\"/>\
10142 </sheetView></sheetViews>\n\
10143 <sheetFormatPr defaultRowHeight=\"15\"/>\n",
10144 tc = sheet.tab_color,
10145 rng = range,
10146 );
10147
10148 xl_write_col_widths(&mut xml, &sheet.col_widths, ncols);
10149 xml.push_str("<sheetData>\n");
10150 xl_write_header_row(&mut xml, sheet.headers);
10151 xl_write_data_rows(&mut xml, &sheet.rows, sheet.is_kv);
10152 xml.push_str("</sheetData>\n");
10153 if !sheet.is_kv && ncols > 0 {
10154 let _ = writeln!(xml, "<autoFilter ref=\"{range}\"/>");
10155 }
10156 xml.push_str("</worksheet>");
10157 xml.into_bytes()
10158}
10159
10160fn xl_write_col_widths(xml: &mut String, col_widths: &[f64], ncols: usize) {
10161 if col_widths.is_empty() {
10162 return;
10163 }
10164 let default_w = *col_widths.last().unwrap_or(&10.0);
10165 xml.push_str("<cols>\n");
10166 for ci in 0..ncols {
10167 let w = col_widths.get(ci).copied().unwrap_or(default_w);
10168 let _ = writeln!(
10169 xml,
10170 " <col min=\"{n}\" max=\"{n}\" width=\"{w:.1}\" customWidth=\"1\"/>",
10171 n = ci + 1
10172 );
10173 }
10174 xml.push_str("</cols>\n");
10175}
10176
10177fn xl_write_header_row(xml: &mut String, headers: &[&str]) {
10178 let _ = write!(xml, "<row r=\"1\" ht=\"18\" customHeight=\"1\">");
10179 for (ci, &h) in headers.iter().enumerate() {
10180 let _ = write!(
10181 xml,
10182 "<c r=\"{}1\" t=\"inlineStr\" s=\"{}\"><is><t>{}</t></is></c>",
10183 xl_col_name(ci),
10184 XLS_HEADER,
10185 xml_escape(h),
10186 );
10187 }
10188 xml.push_str("</row>\n");
10189}
10190
10191const fn xl_cell_style(is_kv: bool, ci: usize, is_num: bool, is_alt: bool) -> u32 {
10192 if is_kv {
10193 if ci == 0 {
10194 XLS_KV_KEY
10195 } else if is_num {
10196 XLS_NUM
10197 } else {
10198 XLS_KV_VAL
10199 }
10200 } else if is_num {
10201 if is_alt {
10202 XLS_NUM_ALT
10203 } else {
10204 XLS_NUM
10205 }
10206 } else if is_alt {
10207 XLS_BODY_ALT
10208 } else {
10209 XLS_BODY
10210 }
10211}
10212
10213fn xl_write_data_rows(xml: &mut String, rows: &[Vec<String>], is_kv: bool) {
10214 for (ri, row) in rows.iter().enumerate() {
10215 let row_num = ri + 2;
10216 let is_alt = ri % 2 == 1;
10217 let _ = write!(xml, "<row r=\"{row_num}\">");
10218 for (ci, cell) in row.iter().enumerate() {
10219 let cell_ref = format!("{}{}", xl_col_name(ci), row_num);
10220 let is_num = !cell.is_empty() && cell.parse::<f64>().is_ok();
10221 let s = xl_cell_style(is_kv, ci, is_num, is_alt);
10222 if is_num {
10223 let _ = write!(
10224 xml,
10225 "<c r=\"{cell_ref}\" s=\"{s}\"><v>{}</v></c>",
10226 xml_escape(cell)
10227 );
10228 } else {
10229 let _ = write!(
10230 xml,
10231 "<c r=\"{cell_ref}\" t=\"inlineStr\" s=\"{s}\"><is><t>{}</t></is></c>",
10232 xml_escape(cell),
10233 );
10234 }
10235 }
10236 xml.push_str("</row>\n");
10237 }
10238}
10239
10240fn build_xlsx(sheets: &[XlSheet<'_>]) -> Vec<u8> {
10241 let mut buf: Vec<u8> = Vec::new();
10242 let mut entries: Vec<ZipEntry> = Vec::new();
10243
10244 let mut ct = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10246 ct.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n");
10247 ct.push_str(" <Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n");
10248 ct.push_str(" <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n");
10249 ct.push_str(" <Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>\n");
10250 ct.push_str(" <Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>\n");
10251 for (i, _) in sheets.iter().enumerate() {
10252 let _ = writeln!(
10253 ct,
10254 " <Override PartName=\"/xl/worksheets/sheet{}.xml\" \
10255 ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>",
10256 i + 1
10257 );
10258 }
10259 ct.push_str("</Types>");
10260 zip_add(
10261 &mut entries,
10262 &mut buf,
10263 "[Content_Types].xml",
10264 ct.into_bytes(),
10265 );
10266
10267 let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
10269<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n\
10270 <Relationship Id=\"rId1\" \
10271 Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" \
10272 Target=\"xl/workbook.xml\"/>\n\
10273</Relationships>";
10274 zip_add(
10275 &mut entries,
10276 &mut buf,
10277 "_rels/.rels",
10278 rels.as_bytes().to_vec(),
10279 );
10280
10281 let mut wb = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10283 wb.push_str(
10284 "<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" \
10285 xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\n",
10286 );
10287 wb.push_str(" <sheets>\n");
10288 for (i, sheet) in sheets.iter().enumerate() {
10289 let _ = writeln!(
10290 wb,
10291 " <sheet name=\"{}\" sheetId=\"{}\" r:id=\"rId{}\"/>",
10292 xml_escape(sheet.name),
10293 i + 1,
10294 i + 1
10295 );
10296 }
10297 wb.push_str(" </sheets>\n</workbook>");
10298 zip_add(&mut entries, &mut buf, "xl/workbook.xml", wb.into_bytes());
10299
10300 let mut wbr = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
10302 wbr.push_str(
10303 "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n",
10304 );
10305 for (i, _) in sheets.iter().enumerate() {
10306 let _ = writeln!(
10307 wbr,
10308 " <Relationship Id=\"rId{}\" \
10309 Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" \
10310 Target=\"worksheets/sheet{}.xml\"/>",
10311 i + 1,
10312 i + 1
10313 );
10314 }
10315 let _ = writeln!(
10316 wbr,
10317 " <Relationship Id=\"rId{}\" \
10318 Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" \
10319 Target=\"styles.xml\"/>",
10320 sheets.len() + 1
10321 );
10322 wbr.push_str("</Relationships>");
10323 zip_add(
10324 &mut entries,
10325 &mut buf,
10326 "xl/_rels/workbook.xml.rels",
10327 wbr.into_bytes(),
10328 );
10329
10330 zip_add(
10332 &mut entries,
10333 &mut buf,
10334 "xl/styles.xml",
10335 xl_styles().as_bytes().to_vec(),
10336 );
10337
10338 for (i, sheet) in sheets.iter().enumerate() {
10340 let sheet_xml = xl_sheet_xml(sheet);
10341 let name = format!("xl/worksheets/sheet{}.xml", i + 1);
10342 zip_add(&mut entries, &mut buf, &name, sheet_xml);
10343 }
10344
10345 zip_finish(buf, &entries)
10346}
10347
10348#[allow(clippy::too_many_lines)]
10354pub fn write_xlsx(run: &AnalysisRun, path: &Path) -> Result<()> {
10355 let summary_rows: Vec<Vec<String>> = vec![
10357 vec!["Run ID".into(), run.tool.run_id.clone()],
10358 vec![
10359 "Timestamp".into(),
10360 run.tool
10361 .timestamp_utc
10362 .format("%Y-%m-%d %H:%M:%S UTC")
10363 .to_string(),
10364 ],
10365 vec![
10366 "Report Title".into(),
10367 run.effective_configuration.reporting.report_title.clone(),
10368 ],
10369 vec![
10370 "Files Analyzed".into(),
10371 run.summary_totals.files_analyzed.to_string(),
10372 ],
10373 vec![
10374 "Files Skipped".into(),
10375 run.summary_totals.files_skipped.to_string(),
10376 ],
10377 vec![
10378 "Physical Lines".into(),
10379 run.summary_totals.total_physical_lines.to_string(),
10380 ],
10381 vec![
10382 "Code Lines".into(),
10383 run.summary_totals.code_lines.to_string(),
10384 ],
10385 vec![
10386 "Comment Lines".into(),
10387 run.summary_totals.comment_lines.to_string(),
10388 ],
10389 vec![
10390 "Blank Lines".into(),
10391 run.summary_totals.blank_lines.to_string(),
10392 ],
10393 vec![
10394 "Mixed Lines (separate)".into(),
10395 run.summary_totals.mixed_lines_separate.to_string(),
10396 ],
10397 ];
10398
10399 let lang_rows: Vec<Vec<String>> = run
10401 .totals_by_language
10402 .iter()
10403 .map(|l| {
10404 vec![
10405 l.language.display_name().to_string(),
10406 l.files.to_string(),
10407 l.total_physical_lines.to_string(),
10408 l.code_lines.to_string(),
10409 l.comment_lines.to_string(),
10410 l.blank_lines.to_string(),
10411 l.mixed_lines_separate.to_string(),
10412 ]
10413 })
10414 .collect();
10415
10416 let file_rows: Vec<Vec<String>> = run
10418 .per_file_records
10419 .iter()
10420 .map(|r| {
10421 vec![
10422 r.relative_path.clone(),
10423 r.language
10424 .map(|l| l.display_name().to_string())
10425 .unwrap_or_default(),
10426 r.size_bytes.to_string(),
10427 r.effective_counts.code_lines.to_string(),
10428 r.effective_counts.comment_lines.to_string(),
10429 r.effective_counts.blank_lines.to_string(),
10430 r.raw_line_categories.total_physical_lines.to_string(),
10431 r.generated.to_string(),
10432 r.minified.to_string(),
10433 r.vendor.to_string(),
10434 ]
10435 })
10436 .collect();
10437
10438 let skipped_rows: Vec<Vec<String>> = run
10440 .skipped_file_records
10441 .iter()
10442 .map(|r| {
10443 vec![
10444 r.relative_path.clone(),
10445 format!("{:?}", r.status),
10446 r.size_bytes.to_string(),
10447 ]
10448 })
10449 .collect();
10450
10451 let summary_hdrs: &[&str] = &["Metric", "Value"];
10452 let lang_hdrs: &[&str] = &[
10453 "Language",
10454 "Files",
10455 "Physical Lines",
10456 "Code Lines",
10457 "Comments",
10458 "Blank",
10459 "Mixed",
10460 ];
10461 let file_hdrs: &[&str] = &[
10462 "Path",
10463 "Language",
10464 "Size (bytes)",
10465 "Code Lines",
10466 "Comments",
10467 "Blank Lines",
10468 "Physical Lines",
10469 "Generated",
10470 "Minified",
10471 "Vendor",
10472 ];
10473 let skipped_hdrs: &[&str] = &["Path", "Status", "Size (bytes)"];
10474
10475 let sheets = vec![
10476 XlSheet {
10477 name: "Summary",
10478 tab_color: "FF283790",
10479 headers: summary_hdrs,
10480 rows: summary_rows,
10481 col_widths: vec![26.0, 44.0],
10482 is_kv: true,
10483 },
10484 XlSheet {
10485 name: "By Language",
10486 tab_color: "FFB85D33",
10487 headers: lang_hdrs,
10488 rows: lang_rows,
10489 col_widths: vec![20.0, 9.0, 15.0, 13.0, 13.0, 11.0, 11.0],
10490 is_kv: false,
10491 },
10492 XlSheet {
10493 name: "Per File",
10494 tab_color: "FF2A6846",
10495 headers: file_hdrs,
10496 rows: file_rows,
10497 col_widths: vec![48.0, 14.0, 13.0, 13.0, 11.0, 11.0, 15.0, 11.0, 11.0, 9.0],
10498 is_kv: false,
10499 },
10500 XlSheet {
10501 name: "Skipped",
10502 tab_color: "FF7B675B",
10503 headers: skipped_hdrs,
10504 rows: skipped_rows,
10505 col_widths: vec![52.0, 24.0, 13.0],
10506 is_kv: false,
10507 },
10508 ];
10509
10510 let bytes = build_xlsx(&sheets);
10511 fs::write(path, bytes).with_context(|| format!("failed to write XLSX to {}", path.display()))
10512}
10513
10514pub fn write_diff_xlsx(cmp: &sloc_core::ScanComparison, path: &Path) -> Result<()> {
10520 let s = &cmp.summary;
10521
10522 let summary_rows: Vec<Vec<String>> = vec![
10523 vec!["Baseline Run".into(), s.baseline_run_id.clone()],
10524 vec!["Current Run".into(), s.current_run_id.clone()],
10525 vec!["Files Added".into(), cmp.files_added.to_string()],
10526 vec!["Files Removed".into(), cmp.files_removed.to_string()],
10527 vec!["Files Modified".into(), cmp.files_modified.to_string()],
10528 vec!["Files Unchanged".into(), cmp.files_unchanged.to_string()],
10529 vec!["Code Δ".into(), s.code_lines_delta.to_string()],
10530 vec!["Comment Δ".into(), s.comment_lines_delta.to_string()],
10531 vec!["Blank Δ".into(), s.blank_lines_delta.to_string()],
10532 vec!["Total Δ".into(), s.total_lines_delta.to_string()],
10533 ];
10534
10535 let delta_rows: Vec<Vec<String>> = cmp
10536 .file_deltas
10537 .iter()
10538 .map(|f| {
10539 let status = match f.status {
10540 sloc_core::FileChangeStatus::Added => "Added",
10541 sloc_core::FileChangeStatus::Removed => "Removed",
10542 sloc_core::FileChangeStatus::Modified => "Modified",
10543 sloc_core::FileChangeStatus::Unchanged => "Unchanged",
10544 };
10545 vec![
10546 status.to_string(),
10547 f.relative_path.clone(),
10548 f.language.clone().unwrap_or_default(),
10549 f.baseline_code.to_string(),
10550 f.current_code.to_string(),
10551 f.code_delta.to_string(),
10552 f.baseline_comment.to_string(),
10553 f.current_comment.to_string(),
10554 f.comment_delta.to_string(),
10555 f.total_delta.to_string(),
10556 ]
10557 })
10558 .collect();
10559
10560 let summary_hdrs: &[&str] = &["Metric", "Value"];
10561 let delta_hdrs: &[&str] = &[
10562 "Status",
10563 "Path",
10564 "Language",
10565 "Baseline Code",
10566 "Current Code",
10567 "Code Δ",
10568 "Baseline Comment",
10569 "Current Comment",
10570 "Comment Δ",
10571 "Total Δ",
10572 ];
10573
10574 let sheets = vec![
10575 XlSheet {
10576 name: "Diff Summary",
10577 tab_color: "FF283790",
10578 headers: summary_hdrs,
10579 rows: summary_rows,
10580 col_widths: vec![26.0, 44.0],
10581 is_kv: true,
10582 },
10583 XlSheet {
10584 name: "File Deltas",
10585 tab_color: "FFB85D33",
10586 headers: delta_hdrs,
10587 rows: delta_rows,
10588 col_widths: vec![12.0, 48.0, 16.0, 14.0, 14.0, 11.0, 14.0, 14.0, 11.0, 11.0],
10589 is_kv: false,
10590 },
10591 ];
10592
10593 let bytes = build_xlsx(&sheets);
10594 fs::write(path, bytes)
10595 .with_context(|| format!("failed to write diff XLSX to {}", path.display()))
10596}
10597
10598fn html_esc(s: &str) -> String {
10601 s.replace('&', "&")
10602 .replace('<', "<")
10603 .replace('>', ">")
10604 .replace('"', """)
10605}
10606
10607#[must_use]
10611pub fn render_confluence_storage(run: &AnalysisRun, report_url: Option<&str>) -> String {
10612 let mut out = String::with_capacity(8192);
10613
10614 let project = run.effective_configuration.reporting.report_title.as_str();
10615 let branch = run.git_branch.as_deref().unwrap_or("—");
10616 let commit = run.git_commit_short.as_deref().unwrap_or("—");
10617 let scanned = run
10618 .tool
10619 .timestamp_utc
10620 .format("%Y-%m-%d %H:%M UTC")
10621 .to_string();
10622
10623 out.push_str(
10625 "<ac:structured-macro ac:name=\"info\" ac:schema-version=\"1\">\
10626 <ac:rich-text-body><p>",
10627 );
10628 let _ = write!(
10629 out,
10630 "<strong>Project:</strong> {proj} · \
10631 <strong>Branch:</strong> {branch} · \
10632 <strong>Commit:</strong> {commit} · \
10633 <strong>Scanned:</strong> {scanned}",
10634 proj = html_esc(project),
10635 branch = html_esc(branch),
10636 commit = html_esc(commit),
10637 scanned = html_esc(&scanned),
10638 );
10639 out.push_str("</p></ac:rich-text-body></ac:structured-macro>");
10640
10641 out.push_str("<h2>Summary</h2>");
10643 out.push_str(
10644 "<table><thead><tr>\
10645 <th>Files Analyzed</th><th>Code Lines</th><th>Comment Lines</th>\
10646 <th>Blank Lines</th><th>Languages</th>\
10647 </tr></thead><tbody><tr>",
10648 );
10649 let t = &run.summary_totals;
10650 let _ = write!(
10651 out,
10652 "<td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td>",
10653 t.files_analyzed,
10654 t.code_lines,
10655 t.comment_lines,
10656 t.blank_lines,
10657 run.totals_by_language.len(),
10658 );
10659 out.push_str("</tr></tbody></table>");
10660
10661 if !run.totals_by_language.is_empty() {
10663 out.push_str("<h2>Language Breakdown</h2>");
10664 out.push_str(
10665 "<table><thead><tr>\
10666 <th>Language</th><th>Files</th><th>Code</th><th>Comments</th><th>Blank</th>\
10667 </tr></thead><tbody>",
10668 );
10669 for lang in &run.totals_by_language {
10670 let _ = write!(
10671 out,
10672 "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
10673 html_esc(lang.language.display_name()),
10674 lang.files,
10675 lang.code_lines,
10676 lang.comment_lines,
10677 lang.blank_lines,
10678 );
10679 }
10680 out.push_str("</tbody></table>");
10681 }
10682
10683 if let Some(url) = report_url {
10685 let _ = write!(
10686 out,
10687 "<p><strong>Full interactive report:</strong> \
10688 <a href=\"{url}\">{url_disp}</a></p>",
10689 url = html_esc(url),
10690 url_disp = html_esc(url),
10691 );
10692 }
10693
10694 out
10695}
10696
10697#[must_use]
10700pub fn render_confluence_wiki_markup(run: &AnalysisRun) -> String {
10701 let mut out = String::with_capacity(4096);
10702
10703 let project = run.effective_configuration.reporting.report_title.as_str();
10704 let branch = run.git_branch.as_deref().unwrap_or("—");
10705 let commit = run.git_commit_short.as_deref().unwrap_or("—");
10706 let scanned = run
10707 .tool
10708 .timestamp_utc
10709 .format("%Y-%m-%d %H:%M UTC")
10710 .to_string();
10711
10712 let _ = writeln!(out, "{{info}}");
10713 let _ = writeln!(
10714 out,
10715 "Project: {project} · Branch: {branch} · Commit: {commit} · Scanned: {scanned}"
10716 );
10717 let _ = writeln!(out, "{{info}}");
10718 out.push('\n');
10719
10720 let t = &run.summary_totals;
10721 let _ = writeln!(out, "h2. Summary");
10722 let _ = writeln!(
10723 out,
10724 "||Files Analyzed||Code Lines||Comment Lines||Blank Lines||Languages||"
10725 );
10726 let _ = writeln!(
10727 out,
10728 "|{}|{}|{}|{}|{}|",
10729 t.files_analyzed,
10730 t.code_lines,
10731 t.comment_lines,
10732 t.blank_lines,
10733 run.totals_by_language.len(),
10734 );
10735 out.push('\n');
10736
10737 if !run.totals_by_language.is_empty() {
10738 let _ = writeln!(out, "h2. Language Breakdown");
10739 let _ = writeln!(out, "||Language||Files||Code||Comments||Blank||");
10740 for lang in &run.totals_by_language {
10741 let _ = writeln!(
10742 out,
10743 "|{}|{}|{}|{}|{}|",
10744 lang.language.display_name(),
10745 lang.files,
10746 lang.code_lines,
10747 lang.comment_lines,
10748 lang.blank_lines,
10749 );
10750 }
10751 out.push('\n');
10752 }
10753
10754 let _ = writeln!(
10755 out,
10756 "*Total:* {} code lines · {} files · {} languages",
10757 t.code_lines,
10758 t.files_analyzed,
10759 run.totals_by_language.len(),
10760 );
10761
10762 out
10763}
10764
10765#[cfg(test)]
10766mod tests {
10767 use super::*;
10768 use tempfile::tempdir;
10769
10770 #[test]
10773 fn base64_encode_empty() {
10774 assert_eq!(base64_encode(b""), "");
10775 }
10776
10777 #[test]
10778 fn base64_encode_one_byte() {
10779 assert_eq!(base64_encode(b"M"), "TQ==");
10780 }
10781
10782 #[test]
10783 fn base64_encode_two_bytes() {
10784 assert_eq!(base64_encode(b"Ma"), "TWE=");
10785 }
10786
10787 #[test]
10788 fn base64_encode_three_bytes_no_padding() {
10789 assert_eq!(base64_encode(b"Man"), "TWFu");
10790 }
10791
10792 #[test]
10793 fn base64_encode_hello() {
10794 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
10795 }
10796
10797 #[test]
10798 fn base64_encode_roundtrip_length_multiple_of_3() {
10799 let data = b"abcdef";
10800 let encoded = base64_encode(data);
10801 assert_eq!(encoded.len(), 8);
10802 assert!(!encoded.contains('='));
10803 }
10804
10805 #[test]
10806 fn base64_encode_all_zeros() {
10807 assert_eq!(base64_encode(&[0u8, 0, 0]), "AAAA");
10808 }
10809
10810 #[test]
10811 fn base64_encode_binary_data() {
10812 let data: Vec<u8> = (0u8..=255).collect();
10813 let encoded = base64_encode(&data);
10814 assert!(!encoded.is_empty());
10815 assert!(encoded
10816 .chars()
10817 .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '='));
10818 }
10819
10820 #[test]
10823 fn json_escape_no_special_chars() {
10824 assert_eq!(json_escape("hello world"), "hello world");
10825 }
10826
10827 #[test]
10828 fn json_escape_backslash() {
10829 assert_eq!(json_escape(r"path\to\file"), r"path\\to\\file");
10830 }
10831
10832 #[test]
10833 fn json_escape_double_quote() {
10834 assert_eq!(json_escape(r#"say "hi""#), r#"say \"hi\""#);
10835 }
10836
10837 #[test]
10838 fn json_escape_both_special_chars() {
10839 assert_eq!(json_escape(r#"a\"b"#), r#"a\\\"b"#);
10840 }
10841
10842 #[test]
10843 fn json_escape_empty_string() {
10844 assert_eq!(json_escape(""), "");
10845 }
10846
10847 #[test]
10848 fn json_escape_only_backslashes() {
10849 assert_eq!(json_escape(r"\\"), r"\\\\");
10850 }
10851
10852 #[test]
10855 fn coverage_pct_str_zero_found_returns_empty() {
10856 assert_eq!(coverage_pct_str(0, 0), "");
10857 }
10858
10859 #[test]
10860 fn coverage_pct_str_full_coverage() {
10861 assert_eq!(coverage_pct_str(100, 100), "100.0");
10862 }
10863
10864 #[test]
10865 fn coverage_pct_str_half_coverage() {
10866 assert_eq!(coverage_pct_str(50, 100), "50.0");
10867 }
10868
10869 #[test]
10870 fn coverage_pct_str_one_decimal_precision() {
10871 let s = coverage_pct_str(7, 10);
10872 assert_eq!(s, "70.0");
10873 }
10874
10875 #[test]
10876 fn coverage_pct_str_zero_hit_but_found() {
10877 assert_eq!(coverage_pct_str(0, 10), "0.0");
10878 }
10879
10880 #[test]
10881 fn coverage_pct_str_non_round_percentage() {
10882 let s = coverage_pct_str(1, 3);
10883 assert!(!s.is_empty());
10884 assert!(s.contains('.'), "result must have decimal point");
10885 }
10886
10887 #[test]
10890 fn coverage_class_zero_found_is_muted() {
10891 assert_eq!(coverage_class(0, 0), "muted");
10892 }
10893
10894 #[test]
10895 fn coverage_class_100_pct_is_good() {
10896 assert_eq!(coverage_class(100, 100), "good");
10897 }
10898
10899 #[test]
10900 fn coverage_class_80_pct_is_good() {
10901 assert_eq!(coverage_class(80, 100), "good");
10902 }
10903
10904 #[test]
10905 fn coverage_class_79_pct_is_warn() {
10906 assert_eq!(coverage_class(79, 100), "warn");
10907 }
10908
10909 #[test]
10910 fn coverage_class_60_pct_is_warn() {
10911 assert_eq!(coverage_class(60, 100), "warn");
10912 }
10913
10914 #[test]
10915 fn coverage_class_59_pct_is_danger() {
10916 assert_eq!(coverage_class(59, 100), "danger");
10917 }
10918
10919 #[test]
10920 fn coverage_class_zero_hit_is_danger() {
10921 assert_eq!(coverage_class(0, 100), "danger");
10922 }
10923
10924 #[test]
10927 fn format_test_density_zero_code_returns_zero() {
10928 assert_eq!(format_test_density(0, 5), "0.0");
10929 }
10930
10931 #[test]
10932 fn format_test_density_zero_tests_returns_zero() {
10933 assert_eq!(format_test_density(100, 0), "0.0");
10934 }
10935
10936 #[test]
10937 fn format_test_density_both_zero() {
10938 assert_eq!(format_test_density(0, 0), "0.0");
10939 }
10940
10941 #[test]
10942 fn format_test_density_1_test_per_1000_lines() {
10943 assert_eq!(format_test_density(1000, 1), "1.0");
10944 }
10945
10946 #[test]
10947 fn format_test_density_10_tests_per_100_lines() {
10948 assert_eq!(format_test_density(100, 10), "100.0");
10949 }
10950
10951 #[test]
10952 fn format_test_density_fractional() {
10953 let s = format_test_density(1000, 3);
10954 assert!(!s.is_empty());
10955 assert!(s.contains('.'));
10956 }
10957
10958 #[test]
10961 fn html_esc_no_special_chars() {
10962 assert_eq!(html_esc("hello"), "hello");
10963 }
10964
10965 #[test]
10966 fn html_esc_ampersand() {
10967 assert_eq!(html_esc("a&b"), "a&b");
10968 }
10969
10970 #[test]
10971 fn html_esc_less_than() {
10972 assert_eq!(html_esc("a<b"), "a<b");
10973 }
10974
10975 #[test]
10976 fn html_esc_greater_than() {
10977 assert_eq!(html_esc("a>b"), "a>b");
10978 }
10979
10980 #[test]
10981 fn html_esc_double_quote() {
10982 assert_eq!(html_esc(r#"a"b"#), "a"b");
10983 }
10984
10985 #[test]
10986 fn html_esc_all_special_chars() {
10987 assert_eq!(
10988 html_esc(r#"<a href="x&y">z</a>"#),
10989 "<a href="x&y">z</a>"
10990 );
10991 }
10992
10993 #[test]
10994 fn html_esc_empty_string() {
10995 assert_eq!(html_esc(""), "");
10996 }
10997
10998 #[test]
11001 fn png_data_uri_has_correct_prefix() {
11002 let uri = png_data_uri(b"\x89PNG\r\n\x1a\n");
11003 assert!(uri.starts_with("data:image/png;base64,"));
11004 }
11005
11006 #[test]
11007 fn png_data_uri_non_empty_for_non_empty_input() {
11008 let uri = png_data_uri(b"fake-png-bytes");
11009 assert!(uri.len() > "data:image/png;base64,".len());
11010 }
11011
11012 #[test]
11015 fn load_custom_logo_nonexistent_file_returns_none() {
11016 let result = load_custom_logo(std::path::Path::new("/nonexistent/__sloc_logo__.png"));
11017 assert!(result.is_none());
11018 }
11019
11020 #[test]
11021 fn load_custom_logo_png_file_returns_data_uri() {
11022 let dir = tempdir().unwrap();
11023 let path = dir.path().join("logo.png");
11024 std::fs::write(&path, b"\x89PNG\r\n\x1a\nfake-png-data").unwrap();
11025 let result = load_custom_logo(&path);
11026 assert!(result.is_some());
11027 let uri = result.unwrap();
11028 assert!(uri.starts_with("data:image/png;base64,"));
11029 }
11030
11031 #[test]
11032 fn load_custom_logo_svg_file_uses_svg_mime() {
11033 let dir = tempdir().unwrap();
11034 let path = dir.path().join("logo.svg");
11035 std::fs::write(&path, b"<svg></svg>").unwrap();
11036 let result = load_custom_logo(&path);
11037 assert!(result.is_some());
11038 let uri = result.unwrap();
11039 assert!(uri.starts_with("data:image/svg+xml;base64,"));
11040 }
11041
11042 #[test]
11043 fn load_custom_logo_unknown_extension_treated_as_png() {
11044 let dir = tempdir().unwrap();
11045 let path = dir.path().join("logo.bin");
11046 std::fs::write(&path, b"some-bytes").unwrap();
11047 let result = load_custom_logo(&path);
11048 assert!(result.is_some());
11049 let uri = result.unwrap();
11050 assert!(uri.starts_with("data:image/png;base64,"));
11051 }
11052}
11053
11054#[cfg(test)]
11055mod coverage_boost_report_tests {
11056 use super::*;
11057 use std::path::Path;
11058
11059 #[test]
11062 fn derive_commit_url_github_uses_commit_segment() {
11063 let url = derive_commit_url(
11064 "https://github.com/org/repo.git",
11065 "abc1234abc1234abc1234abc1234abc1234abc1234",
11066 );
11067 assert_eq!(
11068 url.as_deref(),
11069 Some("https://github.com/org/repo/commit/abc1234abc1234abc1234abc1234abc1234abc1234")
11070 );
11071 }
11072
11073 #[test]
11074 fn derive_commit_url_bitbucket_uses_commits_plural() {
11075 let url = derive_commit_url("https://bitbucket.org/org/repo.git", "deadbeef");
11076 assert_eq!(
11077 url.as_deref(),
11078 Some("https://bitbucket.org/org/repo/commits/deadbeef")
11079 );
11080 }
11081
11082 #[test]
11083 fn derive_commit_url_gitlab_uses_dash_commit() {
11084 let url = derive_commit_url("https://gitlab.example.com/org/repo.git", "cafe0000");
11085 assert_eq!(
11086 url.as_deref(),
11087 Some("https://gitlab.example.com/org/repo/-/commit/cafe0000")
11088 );
11089 }
11090
11091 #[test]
11092 fn derive_branch_url_github_uses_tree() {
11093 let url = derive_branch_url("https://github.com/org/repo.git", "main");
11094 assert_eq!(
11095 url.as_deref(),
11096 Some("https://github.com/org/repo/tree/main")
11097 );
11098 }
11099
11100 #[test]
11101 fn derive_branch_url_bitbucket_uses_branch_segment() {
11102 let url = derive_branch_url("https://bitbucket.org/org/repo.git", "develop");
11103 assert_eq!(
11104 url.as_deref(),
11105 Some("https://bitbucket.org/org/repo/branch/develop")
11106 );
11107 }
11108
11109 #[test]
11110 fn derive_branch_url_gitlab_uses_dash_tree() {
11111 let url = derive_branch_url("https://gitlab.mycompany.com/org/repo.git", "feature");
11112 assert_eq!(
11113 url.as_deref(),
11114 Some("https://gitlab.mycompany.com/org/repo/-/tree/feature")
11115 );
11116 }
11117
11118 #[test]
11119 fn derive_commit_url_invalid_url_returns_none() {
11120 let url = derive_commit_url("not-a-url", "abc123");
11121 assert!(url.is_none());
11122 }
11123
11124 #[test]
11125 fn normalize_remote_url_variants() {
11126 assert_eq!(
11127 normalize_remote_url("git@github.com:org/repo.git").as_deref(),
11128 Some("https://github.com/org/repo")
11129 );
11130 assert_eq!(
11131 normalize_remote_url("https://gitlab.com/a/b.git").as_deref(),
11132 Some("https://gitlab.com/a/b")
11133 );
11134 assert_eq!(
11135 normalize_remote_url("http://host/x").as_deref(),
11136 Some("http://host/x")
11137 );
11138 assert_eq!(normalize_remote_url("not a url"), None);
11139 }
11140
11141 #[test]
11142 fn classify_and_bucket_helpers() {
11143 assert_eq!(
11144 classify_unsupported_path("README.md"),
11145 "Documentation / text"
11146 );
11147 assert_eq!(
11148 classify_unsupported_path("pkg.json"),
11149 "JSON manifests and config"
11150 );
11151 assert_eq!(
11152 classify_unsupported_path("Cargo.toml"),
11153 "Project metadata and packaging"
11154 );
11155 assert_eq!(classify_unsupported_path("page.html"), "HTML templates");
11156 assert_eq!(classify_unsupported_path("notes.txt"), "Plain text assets");
11157 assert_eq!(
11158 classify_unsupported_path("data.xyz"),
11159 "Other unsupported text formats"
11160 );
11161 assert_eq!(
11162 classify_unsupported_path("Makefile_noext"),
11163 "Extensionless or custom text files"
11164 );
11165 for label in [
11167 "Documentation / text",
11168 "JSON manifests and config",
11169 "Project metadata and packaging",
11170 "HTML templates",
11171 "Plain text assets",
11172 "Extensionless or custom text files",
11173 "Unknown bucket",
11174 ] {
11175 assert!(!bucket_description(label).is_empty());
11176 assert!(!bucket_recommendation(label).is_empty());
11177 }
11178 }
11179
11180 #[test]
11181 fn summarize_warnings_groups_categories() {
11182 let warnings = vec![
11183 "file 'a.md': unsupported or undetected language".to_string(),
11184 "file 'b.bin': binary file skipped by default".to_string(),
11185 "file 'c.min.js': minified file skipped by policy".to_string(),
11186 "file 'big.txt': file exceeded max_file_size_bytes".to_string(),
11187 ];
11188 let rows = summarize_warnings(&warnings);
11189 assert!(!rows.is_empty(), "warnings should summarize into buckets");
11190 }
11191
11192 #[test]
11193 fn pdf_number_and_string_formatters() {
11194 assert_eq!(pdf_fmt_full(0), "0");
11195 assert!(pdf_fmt_full(1_234_567).contains('1'));
11196 let s = pdf_safe_str("héllo\tworld\u{1F600}");
11198 assert!(!s.is_empty());
11199 }
11200
11201 #[test]
11202 fn file_url_produces_uri() {
11203 let url = file_url(Path::new("/tmp/report.html"));
11204 assert!(url.starts_with("file://") || url.contains("report.html"));
11205 }
11206
11207 #[test]
11208 fn browser_discovery_is_callable_without_panicking() {
11209 std::env::remove_var("SLOC_BROWSER");
11212 std::env::remove_var("BROWSER");
11213 let _ = discover_browser();
11214 let _ = discover_browser_from_env();
11215 #[cfg(windows)]
11216 let _ = windows_browser_candidates();
11217 #[cfg(not(windows))]
11218 let _ = linux_browser_candidates();
11219 std::env::set_var("SLOC_BROWSER", "/no/such/browser/path");
11221 let _ = discover_browser_from_env();
11222 let p = normalize_browser_env_path("\"/quoted/path/chrome\"");
11223 assert!(p.to_string_lossy().contains("chrome"));
11224 std::env::remove_var("SLOC_BROWSER");
11225 }
11226
11227 #[test]
11228 fn which_in_path_returns_none_for_missing() {
11229 assert!(which_in_path("definitely-not-a-real-exe-xyz123").is_none());
11230 }
11231
11232 #[test]
11233 fn write_pdf_from_html_without_browser_errors_gracefully() {
11234 std::env::remove_var("SLOC_BROWSER");
11235 std::env::remove_var("BROWSER");
11236 let dir = std::env::temp_dir().join("sloc_report_pdf_test");
11237 let _ = std::fs::create_dir_all(&dir);
11238 let html = dir.join("in.html");
11239 std::fs::write(&html, "<html><body>hi</body></html>").unwrap();
11240 let out = dir.join("out.pdf");
11241 let res = write_pdf_from_html(&html, &out);
11243 let _ = res;
11245 let _ = std::fs::remove_dir_all(&dir);
11246 }
11247
11248 #[test]
11251 fn helvetica_advance_uppercase_a_differs_by_weight() {
11252 assert_eq!(helvetica_advance('A', true), 722);
11253 assert_eq!(helvetica_advance('A', false), 667);
11254 }
11255
11256 #[test]
11257 fn helvetica_advance_uppercase_w_same_both_weights() {
11258 assert_eq!(helvetica_advance('W', true), 944);
11259 assert_eq!(helvetica_advance('W', false), 944);
11260 }
11261
11262 #[test]
11263 fn helvetica_advance_lowercase_i_differs_by_weight() {
11264 assert_eq!(helvetica_advance('i', true), 278);
11265 assert_eq!(helvetica_advance('i', false), 222);
11266 }
11267
11268 #[test]
11269 fn helvetica_advance_digits_are_556_both_weights() {
11270 for d in '0'..='9' {
11271 assert_eq!(helvetica_advance(d, true), 556, "bold digit {d}");
11272 assert_eq!(helvetica_advance(d, false), 556, "regular digit {d}");
11273 }
11274 }
11275
11276 #[test]
11277 fn helvetica_advance_middle_dot_is_278() {
11278 assert_eq!(helvetica_advance('\u{00B7}', true), 278);
11279 assert_eq!(helvetica_advance('\u{00B7}', false), 278);
11280 }
11281
11282 #[test]
11283 fn helvetica_advance_unknown_char_returns_nonzero_fallback() {
11284 let bold_fb = helvetica_advance('\u{1F600}', true);
11285 let reg_fb = helvetica_advance('\u{1F600}', false);
11286 assert_eq!(bold_fb, 556);
11287 assert_eq!(reg_fb, 500);
11288 }
11289
11290 #[test]
11293 fn helvetica_width_mm_empty_is_zero() {
11294 assert!(helvetica_width_mm("", 10.0, false).abs() < f32::EPSILON);
11295 assert!(helvetica_width_mm("", 10.0, true).abs() < f32::EPSILON);
11296 }
11297
11298 #[test]
11299 fn helvetica_width_mm_scales_linearly_with_pt_size() {
11300 let w6 = helvetica_width_mm("Hello", 6.0, false);
11301 let w12 = helvetica_width_mm("Hello", 12.0, false);
11302 assert!(
11303 2.0_f32.mul_add(-w6, w12).abs() < 1e-4,
11304 "width must be proportional to pt size"
11305 );
11306 }
11307
11308 #[test]
11309 fn helvetica_width_mm_bold_a_wider_than_regular_a() {
11310 let bold = helvetica_width_mm("A", 10.0, true);
11311 let reg = helvetica_width_mm("A", 10.0, false);
11312 assert!(
11313 bold > reg,
11314 "bold 'A' (722) must be wider than regular 'A' (667)"
11315 );
11316 }
11317
11318 #[test]
11319 fn helvetica_width_mm_single_char_matches_manual_calculation() {
11320 let expected = 667.0_f32 * 10.0 * (25.4 / 72.0) / 1000.0;
11322 let got = helvetica_width_mm("A", 10.0, false);
11323 assert!((got - expected).abs() < 1e-4);
11324 }
11325}