use std::collections::BTreeMap;
use thiserror::Error;
use super::evidence::EvidenceReport;
use super::ir::DiagramDocument;
use super::layout::{layout_components, Layout};
use super::quality::{apply_profile, check_artifact, QualityProfile};
use super::route::route_edges;
use super::svg::render_svg;
use crate::diagnostics::{Diagnostic, HashInfo, ValidationSummary};
const TEMPLATE: &str = include_str!("template.html");
#[derive(Debug, Error)]
pub enum DiagramError {
#[error("quality gate blocked delivery: {} blocking diagnostics", .0.len())]
QualityGate(Vec<Diagnostic>),
#[error("template placeholder missing: {0}")]
Template(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct RenderedDiagram {
pub html: String,
pub specification: HashInfo,
pub validation: ValidationSummary,
pub diagnostics: Vec<Diagnostic>,
}
const GATE_COUNT: u32 = 7;
pub fn render(
doc: &DiagramDocument,
profile: QualityProfile,
evidence: Option<&EvidenceReport>,
) -> Result<RenderedDiagram, DiagramError> {
let layout = layout_components(&doc.components);
let edges = route_edges(doc, &layout);
render_with(doc, &layout, &edges, profile, evidence)
}
pub fn render_with(
doc: &DiagramDocument,
layout: &Layout,
edges: &[super::route::RoutedEdge],
profile: QualityProfile,
evidence: Option<&EvidenceReport>,
) -> Result<RenderedDiagram, DiagramError> {
let spec_json = serde_json::to_string(doc)
.map_err(|_| DiagramError::Template("ir serialization".to_string()))?;
let specification = HashInfo {
algorithm: "blake3".to_string(),
hash: blake3::hash(spec_json.as_bytes()).to_string(),
bytes: spec_json.len() as u64,
};
let svg = render_svg(doc, layout, edges);
let diagnostics = apply_profile(check_artifact(&svg, doc, layout, edges, profile), profile);
let errors = diagnostics
.iter()
.filter(|d| d.severity == crate::diagnostics::Severity::Error)
.count() as u32;
let warnings = diagnostics
.iter()
.filter(|d| d.severity == crate::diagnostics::Severity::Warning)
.count() as u32;
if errors > 0 {
return Err(DiagramError::QualityGate(diagnostics));
}
let blocking_codes: std::collections::BTreeSet<&str> =
diagnostics.iter().map(|d| d.code.as_str()).collect();
let validation = ValidationSummary {
checks_passed: GATE_COUNT - blocking_codes.len() as u32,
check_count: GATE_COUNT,
errors,
warnings,
quality_profile: match profile {
QualityProfile::Standard => "standard".to_string(),
QualityProfile::Showcase => "showcase".to_string(),
},
};
let html = fill_template(doc, &svg, evidence).map_err(DiagramError::Template)?;
Ok(RenderedDiagram {
html,
specification,
validation,
diagnostics,
})
}
fn script_json<T: serde::Serialize>(value: &T) -> String {
let json = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
json.replace('<', "\\u003c")
}
fn escape_html(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
fn fill_template(
doc: &DiagramDocument,
svg: &str,
evidence: Option<&EvidenceReport>,
) -> Result<String, String> {
fill_stage_template(doc, svg, evidence)
}
pub fn render_viewer_html(
doc: &DiagramDocument,
evidence: Option<&EvidenceReport>,
stage_markup: &str,
) -> Result<String, String> {
fill_stage_template(doc, stage_markup, evidence)
}
fn fill_stage_template(
doc: &DiagramDocument,
stage_content: &str,
evidence: Option<&EvidenceReport>,
) -> Result<String, String> {
let locale = if doc.meta.locale == "zh-CN" {
"zh-CN"
} else {
"en"
};
let mut node_sources: BTreeMap<String, Vec<super::evidence::VerifiedSource>> = BTreeMap::new();
if let Some(report) = evidence {
for component in &doc.components {
let mut refs = Vec::new();
for source in &component.sources {
if let Some(verified) = report.references.iter().find(|r| r.path == source.path) {
refs.push(verified.clone());
}
}
node_sources.insert(component.id.clone(), refs);
}
}
let data = serde_json::json!({
"nodes": doc.components.iter().map(|c| serde_json::json!({
"id": c.id,
"label": c.label,
"sublabel": c.sublabel,
"tag": c.tag,
"type": c.r#type,
})).collect::<Vec<_>>(),
"edges": doc.connections.iter().map(|c| serde_json::json!({
"from": c.from,
"to": c.to,
"variant": c.variant,
"label": c.label,
})).collect::<Vec<_>>(),
});
let evidence_payload = evidence.map(|report| {
serde_json::json!({
"verified": report.verified,
"repository": report.repository,
"revision": report.revision,
"node_sources": node_sources,
})
});
let placeholders = [
"__CNX_TITLE__",
"__CNX_SVG__",
"__CNX_DATA__",
"__CNX_EVIDENCE__",
"__CNX_LOCALE__",
];
let mut html = TEMPLATE.to_string();
for placeholder in placeholders {
if !html.contains(placeholder) {
return Err(placeholder.to_string());
}
}
html = html
.replace(
"__CNX_TITLE__",
&format!("{} — CodeNexus", escape_html(&doc.meta.title)),
)
.replace("__CNX_SVG__", stage_content)
.replace("__CNX_DATA__", &script_json(&data))
.replace("__CNX_EVIDENCE__", &script_json(&evidence_payload))
.replace("__CNX_LOCALE__", locale);
Ok(html)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagram::ir::{
ComponentType, DiagramComponent, DiagramConnection, DiagramMeta, EdgeVariant,
};
use crate::diagram::layout::Placement;
fn doc() -> DiagramDocument {
DiagramDocument {
schema_version: 1,
diagram_type: "architecture".to_string(),
meta: DiagramMeta {
title: "demo".to_string(),
subtitle: None,
locale: "en".to_string(),
repository: None,
},
components: vec![
DiagramComponent {
id: "a".to_string(),
r#type: ComponentType::Interface,
label: "api".to_string(),
sublabel: Some("2 files".to_string()),
tag: Some("Controller".to_string()),
sources: vec![],
},
DiagramComponent {
id: "b".to_string(),
r#type: ComponentType::Storage,
label: "db".to_string(),
sublabel: None,
tag: None,
sources: vec![],
},
],
connections: vec![DiagramConnection {
from: "a".to_string(),
to: "b".to_string(),
label: String::new(),
variant: EdgeVariant::Primary,
}],
}
}
#[test]
fn rendering_is_deterministic_and_self_contained() {
let document = doc();
let r1 = render(&document, QualityProfile::Standard, None).unwrap();
let r2 = render(&document, QualityProfile::Standard, None).unwrap();
assert_eq!(r1.html, r2.html, "byte-identical across runs");
assert_eq!(r1.specification, r2.specification);
assert!(r1.html.contains("data-node-id=\"a\""), "semantic attrs");
assert!(r1.html.contains("static index facts"), "truth boundary");
assert!(r1.html.contains("id=\"cnx-data\""), "data slot");
assert!(r1.html.contains("id=\"cnx-evidence\""), "evidence slot");
assert!(!r1.html.contains("http://cdn"), "no external resources");
assert!(r1.html.starts_with("<!DOCTYPE html>"));
assert!(r1.validation.checks_passed == r1.validation.check_count);
}
#[test]
fn showcase_violation_blocks_rendering() {
let document = doc();
let layout = Layout {
placements: vec![Placement {
id: "a".to_string(),
x: 400.0,
y: 40.0,
w: 200.0,
h: 100.0,
}],
view_box: (0.0, 0.0, 800.0, 300.0),
};
let edges = vec![crate::diagram::route::RoutedEdge {
from: "a".to_string(),
to: "a".to_string(),
variant: EdgeVariant::Primary,
points: vec![(100.0, 200.0), (103.0, 200.0)],
label_anchor: (101.5, 196.0),
}];
let err = render_with(&document, &layout, &edges, QualityProfile::Showcase, None)
.expect_err("showcase must block");
match err {
DiagramError::QualityGate(diagnostics) => {
assert!(diagnostics.iter().any(|d| d.code == "diagram/route-rhythm"
&& d.severity == crate::diagnostics::Severity::Error));
}
other => panic!("expected QualityGate, got {other:?}"),
}
}
#[test]
fn evidence_is_baked_per_node_with_blob_links() {
let _document = doc();
let report = EvidenceReport {
verified: true,
repository: Some("https://github.com/owner/repo".to_string()),
revision: Some("a".repeat(40)),
references: vec![super::super::evidence::VerifiedSource {
path: "/src/api.rs".to_string(),
label: None,
line: Some(1),
end_line: Some(5),
href: Some("https://github.com/owner/repo/blob/x#/L1-L5".to_string()),
}],
};
let mut with_sources = doc();
with_sources.components[0].sources = vec![crate::diagram::ir::SourceRef {
path: "/src/api.rs".to_string(),
line: Some(1),
end_line: Some(5),
label: None,
}];
let rendered = render(&with_sources, QualityProfile::Standard, Some(&report)).unwrap();
assert!(
rendered.html.contains("node_sources"),
"{:?}",
rendered.validation
);
assert!(rendered.html.contains("/src/api.rs"));
assert!(rendered.html.contains("\"verified\":true"));
}
}