use crate::coupling::CouplingReport;
pub fn render_coupling_html(report: &CouplingReport) -> String {
let json_data = serde_json::to_string(report).unwrap_or_else(|_| "{}".to_string());
let escaped_json = super::escape::escape_json_for_script(&json_data);
format!(
"<!DOCTYPE html>\n\
<html lang=\"en\">\n\
<head>\n\
<meta charset=\"UTF-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>Coupling Report — Barad-dur</title>\n\
<style>\n{css}\n</style>\n\
</head>\n\
<body>\n\
<header>\n\
<h1>Multi-Repository Coupling Analysis</h1>\n\
<p class=\"summary\">{repo_count} repos · {pair_count} coupling pairs \
· highest score: {highest:.1}</p>\n\
</header>\n\
<nav class=\"tabs\">\n\
<button class=\"tab active\" data-tab=\"tab-graph\">Graph</button>\n\
<button class=\"tab\" data-tab=\"tab-matrix\">Matrix</button>\n\
<button class=\"tab\" data-tab=\"tab-methodology\">Methodology</button>\n\
</nav>\n\
<div class=\"filters\">\n\
<label><input type=\"checkbox\" id=\"filter-temporal\" checked> Temporal</label>\n\
<label><input type=\"checkbox\" id=\"filter-team\" checked> Team</label>\n\
<label><input type=\"checkbox\" id=\"filter-dependency\" checked> Dependency</label>\n\
</div>\n\
<div id=\"tab-graph\" class=\"tab-content active\">\n\
<div id=\"graph\"></div>\n\
<div class=\"legend\">\n\
<h3>How to read this graph</h3>\n\
<div class=\"legend-section\">\n\
<div class=\"label\">Line color = coupling strength</div>\n\
<div class=\"legend-row\"><span class=\"legend-swatch\" style=\"background:#22c55e\"></span> Low (< 40)</div>\n\
<div class=\"legend-row\"><span class=\"legend-swatch\" style=\"background:#f59e0b\"></span> Medium (40 – 70)</div>\n\
<div class=\"legend-row\"><span class=\"legend-swatch\" style=\"background:#ef4444\"></span> High (> 70)</div>\n\
</div>\n\
<div class=\"legend-section\">\n\
<div class=\"label\">Line thickness = coupling score</div>\n\
<div style=\"color:#64748b\">Thicker lines mean a higher combined score</div>\n\
</div>\n\
<div class=\"legend-section\">\n\
<div class=\"label\">Circle size = number of connections</div>\n\
<div class=\"legend-row\"><span class=\"legend-circle\" style=\"width:10px;height:10px\"></span> Few connections</div>\n\
<div class=\"legend-row\"><span class=\"legend-circle\" style=\"width:18px;height:18px\"></span> Many connections</div>\n\
</div>\n\
<div class=\"legend-section\" style=\"color:#64748b\">\n\
Hover a node for details. Drag to rearrange.\n\
</div>\n\
</div>\n\
</div>\n\
<div id=\"tab-matrix\" class=\"tab-content\">\n\
<div id=\"matrix\"></div>\n\
</div>\n\
<div id=\"tab-methodology\" class=\"tab-content\">\n\
<div class=\"methodology\">\n\
<h2>How Coupling Scores Are Calculated</h2>\n\
<p class=\"intro\">Each pair of repositories is scored on three independent dimensions, \
then combined into a single 0–100 score. Higher means tighter coupling — \
changes in one repo are more likely to require changes in the other.</p>\n\
<div class=\"method-section\">\n\
<h3>1. Temporal Coupling (35% of combined score)</h3>\n\
<p><strong>What it measures:</strong> How often commits happen in both repos within \
the same time window (default: 24 hours).</p>\n\
<p><strong>How it works:</strong></p>\n\
<ol>\n\
<li>All commits across all repos are merged into a single timeline.</li>\n\
<li>For each commit, we look for commits in other repos within ±24h.</li>\n\
<li><strong>Same-author boost:</strong> If the <em>same person</em> committed to \
both repos within the window, that co-change counts <strong>3×</strong> more \
than different-author co-changes. A developer intentionally working across repos \
is strong evidence of real coupling.</li>\n\
<li><strong>Statistical baseline:</strong> We subtract the number of co-changes you \
would expect <em>by pure coincidence</em> given how often each repo is committed to. \
This filters out false positives from teams that simply commit during the same \
business hours.</li>\n\
<li>The adjusted count is divided by the smaller repo’s commit count and \
expressed as a percentage (0–100).</li>\n\
</ol>\n\
<p class=\"formula\">score = min(100, max(0, weighted_co_changes − expected_random) \
/ min(commits_A, commits_B) × 100)</p>\n\
</div>\n\
<div class=\"method-section\">\n\
<h3>2. Team Coupling (30% of combined score)</h3>\n\
<p><strong>What it measures:</strong> How much the contributor pools overlap between \
two repos.</p>\n\
<p><strong>How it works:</strong></p>\n\
<ol>\n\
<li>Authors are matched by display name (case-insensitive).</li>\n\
<li>The score is the ratio of shared authors to total unique authors across \
both repos.</li>\n\
</ol>\n\
<p class=\"formula\">score = shared_authors / (unique_authors_A ∪ unique_authors_B) \
× 100</p>\n\
<p>A high team score means the same people maintain both repos — changes in one \
are likely understood (and possibly required) by someone who also works on the other.</p>\n\
</div>\n\
<div class=\"method-section\">\n\
<h3>3. Dependency Coupling (35% of combined score)</h3>\n\
<p><strong>What it measures:</strong> Structural dependencies between repos based on \
their declared packages and imports.</p>\n\
<p><strong>How it works:</strong></p>\n\
<ol>\n\
<li>Manifest files are scanned (Cargo.toml, package.json, go.mod, requirements.txt).</li>\n\
<li>Shared third-party dependencies are counted.</li>\n\
<li>Direct repo-to-repo dependencies are detected (repo A imports repo B).</li>\n\
</ol>\n\
<p>A high dependency score means both repos rely on the same libraries or directly \
depend on each other — a breaking change in a shared dependency affects both.</p>\n\
</div>\n\
<div class=\"method-section\">\n\
<h3>Combined Score</h3>\n\
<p>The three dimension scores are combined with fixed weights:</p>\n\
<p class=\"formula\">combined = temporal × 0.35 + team × 0.30 + dependency \
× 0.35</p>\n\
<p>Temporal is weighted lower because commit-timing correlation is inherently noisy. \
Team and dependency signals are structural facts rather than statistical inferences.</p>\n\
</div>\n\
<div class=\"method-section\">\n\
<h3>Confidence Levels</h3>\n\
<p>Temporal coupling pairs also carry a confidence rating based on raw co-change count:</p>\n\
<ul>\n\
<li><strong>Low:</strong> 3–9 co-changes — could be coincidence, treat \
with caution</li>\n\
<li><strong>Medium:</strong> 10–29 co-changes — likely real coupling</li>\n\
<li><strong>High:</strong> 30+ co-changes — strong evidence of coupled \
development</li>\n\
</ul>\n\
</div>\n\
</div>\n\
</div>\n\
<div id=\"tooltip\" class=\"tooltip\"></div>\n\
<script>window.__COUPLING_DATA__={json};</script>\n\
<script>\n{js}\n</script>\n\
</body>\n\
</html>",
css = CSS,
repo_count = report.summary.total_repos,
pair_count = report.summary.pairs_above_threshold,
highest = report.summary.highest_coupling_score,
json = escaped_json,
js = JS,
)
}
const CSS: &str = include_str!("templates/coupling_style.css");
const JS: &str = include_str!("templates/coupling_graph.js");
#[cfg(test)]
mod tests {
use super::*;
use crate::coupling::{
CouplingDetails, CouplingPair, CouplingReportSummary, DependencyDetails, RepoInfo,
TeamDetails, TemporalDetails,
};
use std::path::PathBuf;
fn minimal_report() -> CouplingReport {
CouplingReport {
repos: vec![
RepoInfo {
name: "alpha".to_string(),
path: PathBuf::from("/r/alpha"),
commit_count: 10,
author_count: 2,
},
RepoInfo {
name: "beta".to_string(),
path: PathBuf::from("/r/beta"),
commit_count: 20,
author_count: 3,
},
],
pairs: vec![CouplingPair {
repo_a: "alpha".to_string(),
repo_b: "beta".to_string(),
temporal_score: 50.0,
team_score: 25.0,
dependency_score: 10.0,
combined_score: 55.0,
details: CouplingDetails {
temporal: TemporalDetails {
co_commit_count: 5,
total_windows: 10,
},
team: TeamDetails {
shared_authors: 1,
total_authors: 4,
},
dependency: DependencyDetails {
shared_dependencies: 2,
relationship: "shared-lib".to_string(),
},
},
}],
summary: CouplingReportSummary {
total_repos: 2,
total_pairs_analyzed: 1,
pairs_above_threshold: 1,
highest_coupling_score: 55.0,
},
blast_radius: vec![],
}
}
#[test]
fn json_data_with_closing_script_tag_in_repo_name_is_escaped() {
let mut report = minimal_report();
report.repos[0].name = "evil</script><script>alert(1)</script>".to_string();
report.pairs[0].repo_a = report.repos[0].name.clone();
let html = render_coupling_html(&report);
assert!(
!html.contains("</script><script>alert(1)"),
"unescaped closing script tag in JSON data allows XSS"
);
assert!(
html.contains("<\\/script>"),
"expected <\\/ escaping in embedded JSON"
);
}
#[test]
fn html_contains_dark_theme_background() {
let report = minimal_report();
let html = render_coupling_html(&report);
assert!(
html.contains("#080a0f"),
"missing dark theme background color"
);
}
#[test]
fn html_contains_matrix_tab_navigation() {
let report = minimal_report();
let html = render_coupling_html(&report);
assert!(
html.contains("tab-graph") && html.contains("tab-matrix"),
"missing tab navigation containers"
);
}
#[test]
fn html_contains_dimension_filter_checkboxes() {
let report = minimal_report();
let html = render_coupling_html(&report);
assert!(
html.contains("type=\"checkbox\""),
"missing dimension filter checkboxes"
);
}
#[test]
fn html_contains_heatmap_color_function() {
let report = minimal_report();
let html = render_coupling_html(&report);
assert!(
html.contains("heatmapColor") || html.contains("cellColor"),
"missing heatmap color mapping function in JS"
);
}
}