use std::io::Write;
use serde::Serialize;
use crate::analyses::architecture_roles::ArchitectureRoleRow;
use crate::analyses::architecture_trend::ArchitectureTrendRow;
use crate::analyses::code_familiarity::CodeFamiliarityRow;
use crate::analyses::code_health::CodeHealthRow;
use crate::analyses::coordination_needs::CoordinationNeedsRow;
use crate::analyses::coupling::CouplingRow;
use crate::analyses::dashboard::{
CloneSummary, DailyCommit, ImportEdgeRow, KameiRiskRow, TrendPoint, XRayEntry,
};
use crate::analyses::effort_exposure::EffortExposureRow;
use crate::analyses::entity_ownership::EntityOwnershipRow;
use crate::analyses::function_xray::FunctionXrayRow;
use crate::analyses::hotspots::HotspotRow;
use crate::analyses::knowledge_islands::KnowledgeIslandRow;
use crate::analyses::marginal_owner_risk::MarginalOwnerRiskRow;
use crate::analyses::modularity_violations::ModularityViolationRow;
use crate::analyses::refactoring_targets::RefactoringTargetRow;
use crate::analyses::summary::SummaryRow;
use crate::analyses::team_composition::TeamCompositionRow;
use crate::analyses::unstable_interface::UnstableInterfaceRow;
use crate::{CodeLoreError, Result};
const TEMPLATE: &str = include_str!("spa/template.html");
const WIDGETS_JS: &str = concat!(
include_str!("spa/js/00_setup_boot.js"),
include_str!("spa/js/10_helpers.js"),
include_str!("spa/js/12_drawer.js"),
include_str!("spa/js/14_widgets_summary.js"),
include_str!("spa/js/16_widgets_bars.js"),
include_str!("spa/js/20_hotspots.js"),
include_str!("spa/js/30_coupling_trends.js"),
include_str!("spa/js/40_architecture.js"),
include_str!("spa/js/50_calendar_xray.js"),
include_str!("spa/js/90_toggles_utils.js"),
);
const ECHARTS_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/echarts.min.js"));
const D3_HIERARCHY_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/d3-hierarchy.min.js"));
const ALPINE_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/alpine.min.js"));
const ALPINE_PERSIST_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/alpine-persist.min.js"));
const TAILWIND_DAISY_CSS: &str = include_str!("spa/tailwind.daisyui.min.css");
#[derive(Debug, Default, Serialize, serde::Deserialize)]
pub struct FileFunctionXray {
pub path: String,
pub rows: Vec<FunctionXrayRow>,
}
#[derive(Debug, Default, Serialize)]
pub struct SpaDashboard {
pub hotspots: Vec<HotspotRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub code_health: Vec<CodeHealthRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub summary: Vec<SummaryRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub coupling: Vec<CouplingRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub knowledge_islands: Vec<KnowledgeIslandRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entity_ownership: Vec<EntityOwnershipRow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entity_ownership_cap: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub xray: Vec<XRayEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub daily_commits: Vec<DailyCommit>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trends: Vec<TrendPoint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mi_rollup: Option<crate::analyses::mi::MiRollup>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coupling_density: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clones: Vec<CloneSummary>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub imports: Vec<ImportEdgeRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modularity_violations: Vec<ModularityViolationRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unstable_interface: Vec<UnstableInterfaceRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub architecture_roles: Vec<ArchitectureRoleRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub architecture_trend: Vec<ArchitectureTrendRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub health_trend: Vec<crate::analyses::health_trend::HealthTrendRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub file_health_series: Vec<crate::analyses::health_trend::FileHealthPoint>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub health_transitions: Vec<crate::analyses::health_trend::HealthTransitionRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effort_exposure: Vec<EffortExposureRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub factors: Vec<crate::analyses::factors::FactorTile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub kamei_risk: Vec<KameiRiskRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub marginal_owner_risk: Vec<MarginalOwnerRiskRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub code_familiarity: Vec<CodeFamiliarityRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub team_composition: Vec<TeamCompositionRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub coordination_needs: Vec<CoordinationNeedsRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delivery_metrics: Vec<crate::analyses::delivery_metrics::DeliveryMetricsRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub release_cadence: Vec<crate::analyses::release_cadence::ReleaseCadenceRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delivery_friction: Vec<crate::analyses::delivery_friction::DeliveryFrictionRow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub function_xray: Vec<FileFunctionXray>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refactoring_targets: Vec<RefactoringTargetRow>,
#[serde(default)]
pub options: SpaOptionsSnapshot,
}
#[derive(Debug, Clone, Serialize)]
pub struct SpaOptionsSnapshot {
pub min_revs: u32,
pub min_shared_revs: u32,
pub min_coupling_pct: u8,
pub max_coupling_pct: u8,
pub max_changeset_size: u32,
pub fisher_significance: f64,
pub health_green_min: f64,
pub health_yellow_min: f64,
pub window_days: u32,
}
impl Default for SpaOptionsSnapshot {
fn default() -> Self {
Self {
min_revs: 5,
min_shared_revs: 5,
min_coupling_pct: 30,
max_coupling_pct: 100,
max_changeset_size: 30,
fisher_significance: 0.05,
health_green_min: crate::bands::HEALTH_GREEN_MIN,
health_yellow_min: crate::bands::HEALTH_YELLOW_MIN,
window_days: crate::constants::DEFAULT_WINDOW_DAYS,
}
}
}
impl SpaOptionsSnapshot {
#[must_use]
pub fn from_options(opts: &crate::Options) -> Self {
Self {
min_revs: opts.min_revs,
min_shared_revs: opts.min_shared_revs,
min_coupling_pct: opts.min_coupling_pct,
max_coupling_pct: opts.max_coupling_pct,
max_changeset_size: opts.max_changeset_size,
fisher_significance: opts.fisher_significance,
health_green_min: crate::bands::HEALTH_GREEN_MIN,
health_yellow_min: crate::bands::HEALTH_YELLOW_MIN,
window_days: opts.window_days,
}
}
}
pub fn write_spa<W: Write>(
dash: &SpaDashboard,
title: &str,
repo_path: &str,
generated_at: &str,
w: &mut W,
) -> Result<()> {
let data_json = serde_json::to_string(dash)
.map_err(|e| CodeLoreError::Output(format!("spa json serialize: {e}")))?;
let data_json_safe = data_json.replace("</", "<\\/");
let title_escaped = escape_html(title);
let repo_path_escaped = escape_html(repo_path);
let generated_at_escaped = escape_html(generated_at);
let html = crate::output::template::substitute(
TEMPLATE,
&[
("{{TITLE}}", &title_escaped),
("{{REPO_PATH}}", &repo_path_escaped),
("{{GENERATED_AT}}", &generated_at_escaped),
("{{DATA_JSON}}", &data_json_safe),
("{{ECHARTS_JS}}", ECHARTS_JS),
("{{D3_HIERARCHY_JS}}", D3_HIERARCHY_JS),
("{{ALPINE_JS}}", ALPINE_JS),
("{{ALPINE_PERSIST_JS}}", ALPINE_PERSIST_JS),
("{{TAILWIND_DAISY_CSS}}", TAILWIND_DAISY_CSS),
("{{WIDGETS_JS}}", WIDGETS_JS),
],
);
w.write_all(html.as_bytes())
.map_err(|e| CodeLoreError::Output(format!("spa write: {e}")))?;
Ok(())
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_hotspots() -> Vec<HotspotRow> {
vec![
HotspotRow {
path: "src/main.rs".into(),
revisions: 12,
cognitive: 42.0,
cognitive_health: 78.0,
hotspot_score: 5.5,
mi: Some(54.0),
mi_rank: Some(0.0),
ai_pct: None,
hotspot_score_anchored: None,
},
HotspotRow {
path: "src/lib/util.rs".into(),
revisions: 8,
cognitive: 28.0,
cognitive_health: 88.0,
hotspot_score: 2.1,
mi: Some(82.5),
mi_rank: Some(1.0),
ai_pct: None,
hotspot_score_anchored: None,
},
]
}
#[test]
fn write_spa_embeds_all_expected_markers() {
let dash = SpaDashboard {
hotspots: sample_hotspots(),
..SpaDashboard::default()
};
let mut buf = Vec::new();
write_spa(
&dash,
"CodeLore Dashboard",
"/tmp/example-repo",
"2026-06-11 00:00:00 UTC",
&mut buf,
)
.expect("write_spa");
let html = String::from_utf8(buf).expect("utf8");
assert!(
html.contains("CodeLore Dashboard"),
"title missing from output",
);
assert!(
html.contains("/tmp/example-repo"),
"repo path missing from output",
);
assert!(
html.contains("widget-hotspot-circle-pack"),
"hotspot circle-pack widget mount point missing",
);
assert!(
html.contains("widget-hotspot-table"),
"hotspot table widget mount point missing",
);
assert!(
html.contains("widget-kpi-tiles"),
"KPI tiles widget mount point missing",
);
assert!(
html.contains("widget-knowledge-islands"),
"knowledge islands widget mount point missing",
);
assert!(
html.contains("widget-coupling-sankey"),
"change-coupling sankey widget mount point missing",
);
assert!(
html.contains("file-detail-drawer"),
"file detail drawer mount point missing",
);
assert!(
html.contains("src/main.rs"),
"embedded hotspot row missing from JSON block",
);
assert!(html.contains("echarts"), "ECharts payload missing");
assert!(html.contains("d3"), "d3-hierarchy payload missing");
assert!(
html.contains("renderHotspotCirclePack"),
"widget render fn missing",
);
}
#[test]
fn write_spa_escapes_xss_in_metadata() {
let dash = SpaDashboard::default();
let mut buf = Vec::new();
write_spa(
&dash,
"<script>alert(1)</script>",
"</title><script>alert(2)</script>",
"2026-06-11",
&mut buf,
)
.expect("write_spa");
let html = String::from_utf8(buf).expect("utf8");
assert!(
!html.contains("<script>alert(1)</script>"),
"title injection survived: HTML escape broken",
);
assert!(
!html.contains("<script>alert(2)</script>"),
"repo-path injection survived: HTML escape broken",
);
assert!(
html.contains("<script>alert(1)</script>"),
"expected escaped title",
);
}
#[test]
fn write_spa_escapes_script_terminator_in_json() {
let mut rows = sample_hotspots();
rows[0].path = "src/</script><script>alert('xss')</script>.rs".into();
let dash = SpaDashboard {
hotspots: rows,
..SpaDashboard::default()
};
let mut buf = Vec::new();
write_spa(&dash, "x", "y", "z", &mut buf).expect("write_spa");
let html = String::from_utf8(buf).expect("utf8");
assert!(
html.contains(r"<\/script>"),
"expected escaped script terminator inside JSON block",
);
}
}