#![allow(dead_code)]
use anyhow::Result;
use askama::Template;
use crate::domain::artifact::{ArtifactGenerator, GenerationContext};
use crate::domain::discovery::DiscoveryReport;
#[derive(Debug, Clone)]
pub struct TocEntry {
pub id: String,
pub label: String,
pub level: u8,
}
#[derive(Debug, Clone)]
pub struct TraceabilityItem {
pub key: String,
pub value: String,
}
#[derive(Template)]
#[template(path = "html/discovery.html")]
pub struct DiscoveryArtifactTemplate {
pub title: String,
pub stage: String,
pub toc_entries: Vec<TocEntry>,
pub has_mermaid: bool,
pub traceability_items: Vec<TraceabilityItem>,
pub content_html: String,
}
pub struct DiscoveryHtmlGenerator<'a> {
pub report: &'a DiscoveryReport,
pub name: &'a str,
pub input: &'a str,
pub state: &'a str,
}
impl<'a> DiscoveryHtmlGenerator<'a> {
pub fn new(report: &'a DiscoveryReport, name: &'a str, input: &'a str, state: &'a str) -> Self {
Self {
report,
name,
input,
state,
}
}
fn build_toc(&self) -> Vec<TocEntry> {
let mut toc = vec![
TocEntry {
id: "resumo".into(),
label: "Resumo".into(),
level: 1,
},
TocEntry {
id: "stack".into(),
label: "Stack Utilizada".into(),
level: 1,
},
TocEntry {
id: "arquitetura".into(),
label: "Arquitetura Inferida".into(),
level: 1,
},
TocEntry {
id: "manifests".into(),
label: "Manifests e Dependências".into(),
level: 1,
},
TocEntry {
id: "paths".into(),
label: "Paths Observados".into(),
level: 1,
},
TocEntry {
id: "comandos".into(),
label: "Comandos".into(),
level: 1,
},
TocEntry {
id: "qualidade".into(),
label: "Qualidade".into(),
level: 1,
},
TocEntry {
id: "operacao".into(),
label: "Operação".into(),
level: 1,
},
];
if !self.report.risks.is_empty() {
toc.push(TocEntry {
id: "riscos".into(),
label: "Riscos".into(),
level: 1,
});
}
toc.push(TocEntry {
id: "rastreabilidade".into(),
label: "Rastreabilidade".into(),
level: 1,
});
toc
}
fn build_traceability(&self) -> Vec<TraceabilityItem> {
vec![
TraceabilityItem {
key: "Estado".into(),
value: self.state.into(),
},
TraceabilityItem {
key: "Projeto Root".into(),
value: escape_html(&self.report.project_root),
},
TraceabilityItem {
key: "Entrada".into(),
value: {
let s = self.input.trim();
if s.is_empty() {
"(inventário geral)".into()
} else {
escape_html(s)
}
},
},
TraceabilityItem {
key: "Lifecycle".into(),
value: escape_html(&self.report.identity.lifecycle),
},
TraceabilityItem {
key: "Tipo".into(),
value: escape_html(&self.report.identity.project_type),
},
]
}
fn build_content_html(&self) -> String {
let r = self.report;
let mut out = String::new();
out.push_str("<section class=\"grid\" id=\"resumo\" aria-label=\"Resumo executivo\">\n");
out.push_str(&metric_card(
"Tipo",
&r.identity.project_type,
"Classificação operacional.",
));
out.push_str(&metric_card(
"Lifecycle",
&r.identity.lifecycle,
"Sinal greenfield/brownfield.",
));
out.push_str(&metric_card(
"Stack",
&r.identity.stack.len().to_string(),
"Tecnologias detectadas.",
));
out.push_str(&metric_card(
"Riscos",
&r.risks.len().to_string(),
"Riscos que pedem atenção.",
));
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"stack\">\n");
out.push_str(" <h2>Stack Utilizada</h2>\n");
out.push_str(" <div class=\"badge-row\">\n");
if r.identity.stack.is_empty() {
out.push_str(" <span class=\"badge\">não detectada</span>\n");
} else {
for item in &r.identity.stack {
out.push_str(&format!(
" <span class=\"badge\">{}</span>\n",
escape_html(item)
));
}
}
out.push_str(" </div>\n");
out.push_str(&format!(
" <p class=\"muted\">Root: <code>{}</code></p>\n",
escape_html(&r.project_root)
));
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"arquitetura\">\n");
out.push_str(" <h2>Arquitetura Inferida</h2>\n");
out.push_str(&format!(
" <p class=\"muted\">Confiança: {}</p>\n",
escape_html(&r.architecture.confidence)
));
if !r.architecture.summary.is_empty() {
out.push_str(" <ul>\n");
for line in &r.architecture.summary {
out.push_str(&format!(" <li>{}</li>\n", escape_html(line)));
}
out.push_str(" </ul>\n");
}
if !r.architecture.entrypoints.is_empty() {
out.push_str(" <details open>\n <summary>Entrypoints</summary>\n");
out.push_str(" <table><thead><tr><th>Path</th><th>Tipo</th><th>Papel</th></tr></thead><tbody>\n");
for e in &r.architecture.entrypoints {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",
escape_html(&e.path),
escape_html(&e.kind),
escape_html(&e.role)
));
}
out.push_str(" </tbody></table>\n </details>\n");
}
if !r.architecture.layers.is_empty() {
out.push_str(" <details>\n <summary>Camadas e boundaries</summary>\n");
out.push_str(" <table><thead><tr><th>Path</th><th>Tipo</th><th>Papel</th></tr></thead><tbody>\n");
for e in &r.architecture.layers {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",
escape_html(&e.path),
escape_html(&e.kind),
escape_html(&e.role)
));
}
out.push_str(" </tbody></table>\n </details>\n");
}
if !r.architecture.routing.is_empty() {
out.push_str(" <details>\n <summary>Rotas e comandos</summary>\n");
out.push_str(" <table><thead><tr><th>Path</th><th>Tipo</th><th>Papel</th></tr></thead><tbody>\n");
for e in &r.architecture.routing {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td></tr>\n",
escape_html(&e.path),
escape_html(&e.kind),
escape_html(&e.role)
));
}
out.push_str(" </tbody></table>\n </details>\n");
}
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"manifests\">\n");
out.push_str(" <h2>Manifests e Dependências</h2>\n");
if r.manifests.is_empty() {
out.push_str(" <p class=\"muted\">Nenhum manifest detectado.</p>\n");
} else {
out.push_str(" <table><thead><tr><th>Path</th><th>Tipo</th><th>Nome</th><th>Versão</th></tr></thead><tbody>\n");
for m in &r.manifests {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
escape_html(&m.path),
escape_html(&m.kind),
m.name.as_deref().map(escape_html).unwrap_or_default(),
m.version.as_deref().map(escape_html).unwrap_or_default(),
));
}
out.push_str(" </tbody></table>\n");
}
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"paths\">\n");
out.push_str(" <h2>Paths Observados</h2>\n");
out.push_str(" <table><thead><tr><th>Categoria</th><th>Paths</th></tr></thead><tbody>\n");
let path_groups = [
("Fonte", &r.paths.source),
("Testes", &r.paths.tests),
("Docs", &r.paths.docs),
("Migrations", &r.paths.migrations),
("Infra", &r.paths.infra),
("Config", &r.paths.config),
("Artefatos", &r.paths.artifacts),
];
for (label, paths) in &path_groups {
out.push_str(&format!(
" <tr><td>{}</td><td>{}</td></tr>\n",
label,
if paths.is_empty() {
"<span class=\"muted\">não encontrado</span>".to_string()
} else {
paths
.iter()
.map(|p| format!("<code>{}</code>", escape_html(p)))
.collect::<Vec<_>>()
.join(" ")
}
));
}
out.push_str(" </tbody></table>\n");
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"comandos\">\n");
out.push_str(" <h2>Comandos</h2>\n");
out.push_str(" <table><thead><tr><th>Grupo</th><th>Comandos</th></tr></thead><tbody>\n");
let cmd_groups = [
("install", &r.commands.install),
("lint", &r.commands.lint),
("typecheck", &r.commands.typecheck),
("test", &r.commands.test),
("build", &r.commands.build),
("não verificados", &r.commands.not_verified),
];
for (label, cmds) in &cmd_groups {
out.push_str(&format!(
" <tr><td>{}</td><td>{}</td></tr>\n",
label,
if cmds.is_empty() {
"<span class=\"muted\">ausente</span>".to_string()
} else {
cmds.iter()
.map(|c| format!("<code>{}</code>", escape_html(c)))
.collect::<Vec<_>>()
.join(" ")
}
));
}
out.push_str(" </tbody></table>\n");
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"qualidade\">\n");
out.push_str(" <h2>Qualidade</h2>\n");
if r.quality.test_frameworks.is_empty() && r.quality.test_paths.is_empty() {
out.push_str(" <p class=\"muted\">Nenhum framework de testes detectado.</p>\n");
} else {
out.push_str(" <ul>\n");
for fw in &r.quality.test_frameworks {
out.push_str(&format!(
" <li>Framework: <code>{}</code></li>\n",
escape_html(fw)
));
}
for tp in &r.quality.test_paths {
out.push_str(&format!(
" <li>Path: <code>{}</code></li>\n",
escape_html(tp)
));
}
for g in &r.quality.gaps {
out.push_str(&format!(
" <li class=\"warn\">Gap: {}</li>\n",
escape_html(g)
));
}
out.push_str(" </ul>\n");
}
out.push_str("</section>\n");
out.push_str("<section class=\"panel\" id=\"operacao\">\n");
out.push_str(" <h2>Operação</h2>\n");
if !r.operations.ci.is_empty() {
out.push_str(" <p><strong>CI:</strong> ");
out.push_str(
&r.operations
.ci
.iter()
.map(|s| format!("<code>{}</code>", escape_html(s)))
.collect::<Vec<_>>()
.join(", "),
);
out.push_str("</p>\n");
}
if !r.operations.deploy.is_empty() {
out.push_str(" <p><strong>Deploy:</strong> ");
out.push_str(
&r.operations
.deploy
.iter()
.map(|s| format!("<code>{}</code>", escape_html(s)))
.collect::<Vec<_>>()
.join(", "),
);
out.push_str("</p>\n");
}
if !r.operations.env_vars.is_empty() {
out.push_str(" <p><strong>Env vars:</strong> ");
out.push_str(
&r.operations
.env_vars
.iter()
.map(|s| format!("<code>{}</code>", escape_html(s)))
.collect::<Vec<_>>()
.join(", "),
);
out.push_str("</p>\n");
}
if r.operations.ci.is_empty() && r.operations.deploy.is_empty() {
out.push_str(" <p class=\"muted\">Sem dados de CI/deploy detectados.</p>\n");
}
out.push_str("</section>\n");
if !r.risks.is_empty() {
out.push_str("<section class=\"panel\" id=\"riscos\">\n");
out.push_str(" <h2>Riscos</h2>\n");
out.push_str(" <ul>\n");
for risk in &r.risks {
out.push_str(&format!(
" <li class=\"risk\">{}</li>\n",
escape_html(risk)
));
}
out.push_str(" </ul>\n");
out.push_str("</section>\n");
}
out
}
}
impl ArtifactGenerator for DiscoveryHtmlGenerator<'_> {
fn generate_markdown(&self, _ctx: &GenerationContext<'_>) -> Result<String> {
anyhow::bail!("DiscoveryHtmlGenerator: use generate_html para o formato HTML ou o DiscoveryMarkdownGenerator para Markdown.")
}
fn generate_html(&self, _ctx: &GenerationContext<'_>) -> Result<String> {
let toc = self.build_toc();
let traceability = self.build_traceability();
let content = self.build_content_html();
let tmpl = DiscoveryArtifactTemplate {
title: format!("Project Discovery — {}", self.name),
stage: "project-discovery".to_string(),
toc_entries: toc,
has_mermaid: false, traceability_items: traceability,
content_html: content,
};
tmpl.render()
.map_err(|e| anyhow::anyhow!("Erro ao renderizar template HTML de discovery: {e}"))
}
}
#[derive(Template)]
#[template(path = "html/stage.html")]
pub struct StageArtifactTemplate {
pub title: String,
pub stage: String,
pub toc_entries: Vec<TocEntry>,
pub has_mermaid: bool,
pub traceability_items: Vec<TraceabilityItem>,
pub content_html: String,
}
pub struct StageHtmlGenerator<'a> {
pub markdown: &'a str,
pub stage: &'a str,
pub name: &'a str,
}
impl<'a> StageHtmlGenerator<'a> {
pub fn new(markdown: &'a str, stage: &'a str, name: &'a str) -> Self {
Self {
markdown,
stage,
name,
}
}
pub fn render(&self) -> Result<String> {
let (toc, has_mermaid, content_html) = md_to_html(self.markdown);
let traceability_items = extract_traceability_items(self.markdown);
let title = format!("{} — {}", stage_label_display(self.stage), self.name);
let tmpl = StageArtifactTemplate {
title,
stage: self.stage.to_string(),
toc_entries: toc,
has_mermaid,
traceability_items,
content_html,
};
tmpl.render().map_err(|e| {
anyhow::anyhow!(
"Erro ao renderizar template HTML de stage '{stage}': {e}",
stage = self.stage
)
})
}
}
fn stage_label_display(stage: &str) -> &str {
match stage {
"idea" => "Ideia",
"prd" => "PRD",
"techspec" => "Tech Spec",
"tasks" => "Tasks",
"refinement" => "Refinement",
"execution" => "Execução",
"review" => "Review",
"qa" => "QA",
"memory" => "Memória",
"project-discovery" => "Project Discovery",
"risk-classifier" => "Risk Classification",
other => other,
}
}
pub fn md_to_html(md: &str) -> (Vec<TocEntry>, bool, String) {
let mut toc: Vec<TocEntry> = Vec::new();
let mut has_mermaid = false;
let mut html = String::new();
let mut in_code_block = false;
let mut code_lang = String::new();
let mut code_buf: Vec<String> = Vec::new();
let mut in_list = false;
let mut list_ordered = false;
let mut in_table = false;
let mut table_header_done = false;
let mut paragraph_buf: Vec<String> = Vec::new();
let flush_paragraph = |buf: &mut Vec<String>, out: &mut String| {
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
out.push_str("<p>");
out.push_str(&inline_md(&trimmed));
out.push_str("</p>\n");
}
buf.clear();
}
};
let flush_list = |in_list: &mut bool, list_ordered: &mut bool, out: &mut String| {
if *in_list {
out.push_str(if *list_ordered { "</ol>\n" } else { "</ul>\n" });
*in_list = false;
*list_ordered = false;
}
};
let flush_table = |in_table: &mut bool, table_header_done: &mut bool, out: &mut String| {
if *in_table {
out.push_str("</tbody></table>\n");
*in_table = false;
*table_header_done = false;
}
};
let lines: Vec<&str> = md.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if line.starts_with("```") {
if in_code_block {
in_code_block = false;
let code_content = code_buf.join("\n");
if code_lang == "mermaid" {
has_mermaid = true;
html.push_str("<div class=\"mermaid-wrap\">\n");
html.push_str(" <div class=\"mermaid-diagram\">");
html.push_str(&escape_html(&code_content));
html.push_str("</div>\n");
html.push_str(" <pre class=\"mermaid-fallback\">");
html.push_str(&escape_html(&code_content));
html.push_str("</pre>\n");
html.push_str("</div>\n");
} else if code_lang.is_empty() {
html.push_str("<pre><code>");
html.push_str(&escape_html(&code_content));
html.push_str("</code></pre>\n");
} else {
html.push_str(&format!(
"<pre><code class=\"language-{}\">",
escape_html(&code_lang)
));
html.push_str(&escape_html(&code_content));
html.push_str("</code></pre>\n");
}
code_buf.clear();
code_lang.clear();
i += 1;
continue;
} else {
{
let buf = &mut paragraph_buf;
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
html.push_str("<p>");
html.push_str(&inline_md(&trimmed));
html.push_str("</p>\n");
}
buf.clear();
}
}
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
in_code_block = true;
code_lang = line.trim_start_matches('`').trim().to_lowercase();
i += 1;
continue;
}
}
if in_code_block {
code_buf.push(line.to_string());
i += 1;
continue;
}
if line.starts_with('#') {
{
let buf = &mut paragraph_buf;
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
html.push_str("<p>");
html.push_str(&inline_md(&trimmed));
html.push_str("</p>\n");
}
buf.clear();
}
}
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
let level = line.chars().take_while(|c| *c == '#').count();
let text = line.trim_start_matches('#').trim();
let id = heading_id(text);
match level {
1 => {
html.push_str(&format!("<h1 id=\"{}\">{}</h1>\n", id, escape_html(text)));
}
2 => {
html.push_str(&format!("<h2 id=\"{}\">{}</h2>\n", id, escape_html(text)));
toc.push(TocEntry {
id,
label: text.to_string(),
level: 1,
});
}
3 => {
html.push_str(&format!("<h3 id=\"{}\">{}</h3>\n", id, escape_html(text)));
toc.push(TocEntry {
id,
label: text.to_string(),
level: 2,
});
}
4 => {
html.push_str(&format!("<h4 id=\"{}\">{}</h4>\n", id, escape_html(text)));
toc.push(TocEntry {
id,
label: text.to_string(),
level: 3,
});
}
_ => {
html.push_str(&format!(
"<h{level} id=\"{id}\">{}</h{level}>\n",
escape_html(text)
));
}
}
i += 1;
continue;
}
if line.trim() == "---" || line.trim() == "***" || line.trim() == "___" {
{
let buf = &mut paragraph_buf;
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
html.push_str("<p>");
html.push_str(&inline_md(&trimmed));
html.push_str("</p>\n");
}
buf.clear();
}
}
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
html.push_str("<hr>\n");
i += 1;
continue;
}
if line.trim().is_empty() {
flush_paragraph(&mut paragraph_buf, &mut html);
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
i += 1;
continue;
}
let ordered_prefix = {
let t = line.trim_start();
let dot_pos = t.find(". ");
match dot_pos {
Some(p) if p > 0 && t[..p].chars().all(|c| c.is_ascii_digit()) => Some(p + 2),
_ => None,
}
};
if let Some(content_start) = ordered_prefix {
flush_paragraph(&mut paragraph_buf, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
if !in_list || !list_ordered {
if in_list {
html.push_str("</ul>\n");
}
html.push_str("<ol>\n");
in_list = true;
list_ordered = true;
}
let item_text = line.trim_start()[content_start..].trim();
html.push_str("<li>");
html.push_str(&inline_md(item_text));
html.push_str("</li>\n");
i += 1;
continue;
}
let unordered_content = {
let t = line.trim_start();
if t.starts_with("- ") || t.starts_with("* ") || t.starts_with("+ ") {
Some(&t[2..])
} else {
None
}
};
if let Some(item_text) = unordered_content {
flush_paragraph(&mut paragraph_buf, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
if !in_list || list_ordered {
if in_list {
html.push_str("</ol>\n");
}
html.push_str("<ul>\n");
in_list = true;
list_ordered = false;
}
html.push_str("<li>");
html.push_str(&inline_md(item_text.trim()));
html.push_str("</li>\n");
i += 1;
continue;
}
if line.trim_start().starts_with('|') {
flush_paragraph(&mut paragraph_buf, &mut html);
flush_list(&mut in_list, &mut list_ordered, &mut html);
let is_separator = line
.trim()
.chars()
.all(|c| c == '|' || c == '-' || c == ':' || c == ' ');
if is_separator && line.contains('-') {
if in_table && !table_header_done {
html.push_str("</thead><tbody>\n");
table_header_done = true;
}
i += 1;
continue;
}
let cells: Vec<&str> = line
.trim()
.trim_start_matches('|')
.trim_end_matches('|')
.split('|')
.map(|c| c.trim())
.collect();
if !in_table {
html.push_str("<table><thead>\n");
in_table = true;
table_header_done = false;
}
if !table_header_done {
html.push_str("<tr>");
for cell in &cells {
html.push_str("<th>");
html.push_str(&inline_md(cell));
html.push_str("</th>");
}
html.push_str("</tr>\n");
} else {
html.push_str("<tr>");
for cell in &cells {
html.push_str("<td>");
html.push_str(&inline_md(cell));
html.push_str("</td>");
}
html.push_str("</tr>\n");
}
i += 1;
continue;
}
flush_list(&mut in_list, &mut list_ordered, &mut html);
flush_table(&mut in_table, &mut table_header_done, &mut html);
paragraph_buf.push(line.to_string());
i += 1;
}
{
let buf = &mut paragraph_buf;
if !buf.is_empty() {
let text = buf.join(" ");
let trimmed = text.trim().to_string();
if !trimmed.is_empty() {
html.push_str("<p>");
html.push_str(&inline_md(&trimmed));
html.push_str("</p>\n");
}
}
}
if in_list {
html.push_str(if list_ordered { "</ol>\n" } else { "</ul>\n" });
}
if in_table {
html.push_str("</tbody></table>\n");
}
(toc, has_mermaid, html)
}
fn inline_md(text: &str) -> String {
let mut out = String::with_capacity(text.len() * 2);
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut j = 0;
while j < len {
if chars[j] == '`' {
if let Some(end) = find_closing(&chars, j + 1, '`') {
let code: String = chars[j + 1..end].iter().collect();
out.push_str("<code>");
out.push_str(&escape_html(&code));
out.push_str("</code>");
j = end + 1;
continue;
}
}
if chars[j] == '[' {
if let Some((label, url, after)) = parse_link(&chars, j) {
out.push_str("<a href=\"");
out.push_str(&escape_html(&url));
out.push_str("\">");
out.push_str(&escape_html(&label));
out.push_str("</a>");
j = after;
continue;
}
}
if j + 1 < len && chars[j] == '*' && chars[j + 1] == '*' {
if let Some(end) = find_str_closing(&chars, j + 2, "**") {
let inner: String = chars[j + 2..end].iter().collect();
out.push_str("<strong>");
out.push_str(&escape_html(&inner));
out.push_str("</strong>");
j = end + 2;
continue;
}
}
if chars[j] == '*' {
if let Some(end) = find_closing(&chars, j + 1, '*') {
let inner: String = chars[j + 1..end].iter().collect();
out.push_str("<em>");
out.push_str(&escape_html(&inner));
out.push_str("</em>");
j = end + 1;
continue;
}
}
if j + 1 < len && chars[j] == '_' && chars[j + 1] == '_' {
if let Some(end) = find_str_closing(&chars, j + 2, "__") {
let inner: String = chars[j + 2..end].iter().collect();
out.push_str("<strong>");
out.push_str(&escape_html(&inner));
out.push_str("</strong>");
j = end + 2;
continue;
}
}
if chars[j] == '_' {
if let Some(end) = find_closing(&chars, j + 1, '_') {
let inner: String = chars[j + 1..end].iter().collect();
out.push_str("<em>");
out.push_str(&escape_html(&inner));
out.push_str("</em>");
j = end + 1;
continue;
}
}
match chars[j] {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
c => out.push(c),
}
j += 1;
}
out
}
fn find_closing(chars: &[char], start: usize, delimiter: char) -> Option<usize> {
chars[start..]
.iter()
.position(|&c| c == delimiter)
.map(|p| start + p)
}
fn find_str_closing(chars: &[char], start: usize, delimiter: &str) -> Option<usize> {
let dchars: Vec<char> = delimiter.chars().collect();
let dl = dchars.len();
for k in start..chars.len().saturating_sub(dl - 1) {
if &chars[k..k + dl] == dchars.as_slice() {
return Some(k);
}
}
None
}
fn parse_link(chars: &[char], start: usize) -> Option<(String, String, usize)> {
let close_bracket = find_closing(chars, start + 1, ']')?;
if close_bracket + 1 >= chars.len() || chars[close_bracket + 1] != '(' {
return None;
}
let close_paren = find_closing(chars, close_bracket + 2, ')')?;
let label: String = chars[start + 1..close_bracket].iter().collect();
let url: String = chars[close_bracket + 2..close_paren].iter().collect();
Some((label, url, close_paren + 1))
}
fn heading_id(text: &str) -> String {
text.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c.to_lowercase().next().unwrap()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn extract_traceability_items(md: &str) -> Vec<TraceabilityItem> {
let mut items = Vec::new();
let mut in_section = false;
for line in md.lines() {
if line.trim_start().starts_with("## Rastreabilidade") {
in_section = true;
continue;
}
if in_section {
if line.starts_with("## ") {
break;
}
let t = line.trim();
if t.starts_with("- ") || t.starts_with("* ") {
let content = &t[2..];
let content = content.trim_start_matches("**").replacen("**", "", 1);
if let Some(colon) = content.find(':') {
let key = content[..colon].trim().trim_matches('*').to_string();
let value = content[colon + 1..].trim().to_string();
if !key.is_empty() {
items.push(TraceabilityItem { key, value });
}
}
}
}
}
items
}
pub fn render_markdown_file_to_html(
md_path: &std::path::Path,
stage: &str,
name: &str,
) -> Result<String> {
let markdown = std::fs::read_to_string(md_path).map_err(|e| {
anyhow::anyhow!(
"Não foi possível ler '{path}': {e}",
path = md_path.display()
)
})?;
StageHtmlGenerator::new(&markdown, stage, name).render()
}
pub fn escape_html(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
fn metric_card(label: &str, value: &str, description: &str) -> String {
format!(
" <div class=\"card\">\n <span class=\"metric\">{}</span>\n <span class=\"label\">{}</span>\n <p class=\"muted\" style=\"margin-top:6px;font-size:0.82rem;\">{}</p>\n </div>\n",
escape_html(value),
escape_html(label),
escape_html(description),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::artifact::{ArtifactFormat, GenerationContext};
use crate::domain::discovery::{
ArchitectureEvidence, ArchitectureReport, CapabilityRecommendation, CodeIntelligenceStatus,
DataContractReport, DiscoveryCommands, DiscoveryPaths, DomainLanguageReport,
IntegrationReport, OperationsReport, ProjectIdentity, ProjectManifest, QualityReport,
};
use crate::domain::providers::ProviderSelection;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn dummy_report() -> DiscoveryReport {
DiscoveryReport {
project_root: "/tmp/test-project".into(),
identity: ProjectIdentity {
name: "test-project".into(),
lifecycle: "brownfield".into(),
stack: vec!["Rust".into(), "Cargo".into()],
project_type: "cli".into(),
},
manifests: vec![ProjectManifest {
path: "Cargo.toml".into(),
kind: "cargo".into(),
name: Some("test-project".into()),
version: Some("0.1.0".into()),
package_manager: None,
workspace: false,
scripts: vec![],
dependencies: vec!["anyhow".into()],
signals: vec![],
}],
paths: DiscoveryPaths {
source: vec!["src/".into()],
tests: vec!["tests/".into()],
docs: vec![],
migrations: vec![],
infra: vec![],
config: vec!["Cargo.toml".into()],
artifacts: vec![],
},
commands: DiscoveryCommands {
install: vec![],
lint: vec!["cargo clippy".into()],
typecheck: vec![],
test: vec!["cargo test".into()],
build: vec!["cargo build".into()],
not_verified: vec![],
missing: vec![],
},
architecture: ArchitectureReport {
summary: vec!["CLI Rust com monolito em src/main.rs".into()],
entrypoints: vec![ArchitectureEvidence {
path: "src/main.rs".into(),
kind: "binary".into(),
role: "entrypoint".into(),
evidence: vec![],
}],
components: vec![],
layers: vec![],
routing: vec![],
conventions: vec![],
confidence: "alta".into(),
},
quality: QualityReport {
test_frameworks: vec!["cargo test".into()],
test_paths: vec!["tests/".into()],
commands: vec!["cargo test".into()],
coverage: vec![],
gaps: vec![],
},
operations: OperationsReport {
ci: vec![".github/workflows/".into()],
deploy: vec![],
containers: vec![],
env_files: vec![],
env_vars: vec![],
observability: vec![],
},
data_contracts: DataContractReport {
migrations: vec![],
schemas: vec![],
api_contracts: vec![],
orm: vec![],
seeds_fixtures: vec![],
},
integrations: IntegrationReport {
external_services: vec![],
auth: vec![],
webhooks: vec![],
queues_jobs: vec![],
storage: vec![],
},
domain_language: DomainLanguageReport {
context_files: vec![],
context_map: None,
adr_dirs: vec![],
recommendation: "nenhuma".into(),
},
code_intelligence: vec![CodeIntelligenceStatus {
id: "codegraph".into(),
available: true,
indexed: true,
command: "codegraph".into(),
index_marker: ".codegraph/".into(),
suggested_commands: vec![],
fallback: "grep".into(),
}],
capabilities: vec![CapabilityRecommendation {
id: "html-gen".into(),
title: "Geração HTML".into(),
source: "tech-spec".into(),
source_url: String::new(),
trigger: String::new(),
stages: vec![],
local_skill: String::new(),
mode: String::new(),
risk: String::new(),
fallback: String::new(),
}],
risks: vec!["Monolito grande em src/main.rs".into()],
}
}
fn dummy_ctx<'a>(provider: &'a ProviderSelection, root: &'a Path) -> GenerationContext<'a> {
GenerationContext {
stage: "project-discovery",
name: "test-project",
input: "teste de integração",
root,
provider,
format: ArtifactFormat::Html,
real_telemetry: None,
no_telemetry: true,
}
}
fn dummy_provider() -> ProviderSelection {
ProviderSelection {
id: "anthropic".into(),
kind: "claude".into(),
model: "claude-sonnet-4-5".into(),
effort: "normal".into(),
offline: false,
enabled: true,
auth_env: None,
auth_present: None,
auth_methods: vec![],
models: vec![],
efforts: vec![],
stage_models: BTreeMap::new(),
stage_efforts: BTreeMap::new(),
capabilities: vec![],
token_budget: None,
usage_limits: vec![],
explicit: false,
}
}
#[test]
fn discovery_html_contains_prefers_color_scheme() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
html.contains("prefers-color-scheme"),
"HTML deve conter regra prefers-color-scheme para tema claro/escuro automático"
);
}
#[test]
fn discovery_html_has_nav_toc() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
html.contains("<nav") && html.contains("toc-list"),
"HTML deve conter <nav> com índice (toc-list)"
);
}
#[test]
fn discovery_html_no_mermaid_blocks_when_none() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
!html.contains("mermaid.min.js"),
"HTML de discovery não deve carregar o script Mermaid CDN (has_mermaid=false)"
);
assert!(
!html.contains("<pre class=\"mermaid-fallback\">"),
"HTML de discovery não deve emitir elemento <pre class=mermaid-fallback> quando não há blocos"
);
}
#[test]
fn discovery_html_mermaid_fallback_structure_in_css() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
html.contains("mermaid-fallback"),
"CSS do base.html deve definir .mermaid-fallback mesmo quando has_mermaid=false"
);
}
#[test]
fn discovery_html_has_traceability_section() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
html.contains("id=\"rastreabilidade\""),
"HTML deve conter seção de rastreabilidade com id='rastreabilidade'"
);
assert!(
html.contains("Estado"),
"Bloco de rastreabilidade deve conter o campo 'Estado'"
);
}
#[test]
fn discovery_html_structure_well_formed_minimal() {
let report = dummy_report();
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
for tag in &[
"<!doctype html>",
"<html",
"<head>",
"</head>",
"<body>",
"</body>",
"</html>",
"<nav",
"</nav>",
"<main",
"</main>",
] {
assert!(
html.to_lowercase().contains(&tag.to_lowercase()),
"HTML deve conter tag obrigatória: {tag}"
);
}
}
#[test]
fn discovery_html_escapes_special_chars() {
let mut report = dummy_report();
report
.risks
.push("Risco com <script>alert('xss')</script>".into());
let provider = dummy_provider();
let root = PathBuf::from("/tmp");
let ctx = dummy_ctx(&provider, &root);
let gen = DiscoveryHtmlGenerator::new(&report, "test-project", "teste", "recorded");
let html = gen
.generate_html(&ctx)
.expect("generate_html não deve falhar");
assert!(
!html.contains("<script>alert("),
"HTML deve escapar conteúdo de risco (XSS prevention)"
);
assert!(
html.contains("<script>"),
"HTML deve conter versão escapada da tag script"
);
}
#[test]
fn md_to_html_extracts_h2_toc_entries() {
let md = "## Objetivo\n\nTexto do objetivo.\n\n## Requisitos\n\nLista de req.\n";
let (toc, _, _) = md_to_html(md);
assert_eq!(toc.len(), 2);
assert_eq!(toc[0].label, "Objetivo");
assert_eq!(toc[0].level, 1);
assert_eq!(toc[1].label, "Requisitos");
assert_eq!(toc[1].level, 1);
}
#[test]
fn md_to_html_mermaid_block_emits_both_diagram_and_fallback() {
let md = "## Diagrama\n\n```mermaid\ngraph TD\n A --> B\n```\n";
let (_, has_mermaid, html) = md_to_html(md);
assert!(
has_mermaid,
"has_mermaid deve ser true quando há bloco mermaid"
);
assert!(
html.contains("<div class=\"mermaid-diagram\">"),
"deve emitir elemento mermaid-diagram"
);
assert!(
html.contains("<pre class=\"mermaid-fallback\">"),
"deve emitir elemento mermaid-fallback"
);
}
#[test]
fn md_to_html_no_mermaid_when_no_block() {
let md = "## Seção\n\nApenas texto sem mermaid.\n";
let (_, has_mermaid, html) = md_to_html(md);
assert!(!has_mermaid);
assert!(!html.contains("mermaid-diagram"));
}
#[test]
fn md_to_html_table_renders_thead_tbody() {
let md = "| Col A | Col B |\n|-------|-------|\n| val1 | val2 |\n";
let (_, _, html) = md_to_html(md);
assert!(html.contains("<table>"), "deve gerar <table>");
assert!(html.contains("<thead>"), "deve gerar <thead>");
assert!(html.contains("<tbody>"), "deve gerar <tbody>");
assert!(html.contains("<th>"), "deve gerar <th>");
assert!(html.contains("val1"), "deve conter valor da célula");
}
#[test]
fn stage_html_generator_renders_full_html_structure() {
let md = "# PRD — Minha Feature\n\n## Objetivo\n\nDescrição do objetivo.\n\n## Rastreabilidade\n\n- Origem: 01-idea.md\n- Estado: aprovado\n";
let gen = StageHtmlGenerator::new(md, "prd", "minha-feature");
let html = gen.render().expect("render não deve falhar");
assert!(
html.to_lowercase().contains("<!doctype html>"),
"deve ter DOCTYPE"
);
assert!(html.contains("<nav"), "deve ter nav");
assert!(html.contains("toc-list"), "deve ter toc-list");
assert!(html.contains("Objetivo"), "deve conter heading h2");
assert!(
html.contains("id=\"rastreabilidade\""),
"deve ter seção de rastreabilidade"
);
}
#[test]
fn stage_html_generator_prd_with_mermaid_has_script_and_fallback() {
let md = "## Fluxo\n\n```mermaid\nsequenceDiagram\n A->>B: mensagem\n```\n";
let gen = StageHtmlGenerator::new(md, "prd", "teste-orq");
let html = gen.render().expect("render não deve falhar");
assert!(
html.contains("mermaid.min.js"),
"deve carregar script Mermaid CDN"
);
assert!(
html.contains("<div class=\"mermaid-diagram\">"),
"deve ter elemento mermaid-diagram"
);
assert!(
html.contains("<pre class=\"mermaid-fallback\">"),
"deve ter fallback pre"
);
}
#[test]
fn render_markdown_file_to_html_reads_real_prd_file() {
let prd_path = std::path::Path::new("docs/geracao-mais-robusta-e-organizada-redesenhar-pipeline-de-geracao-de-artefatos-do-sd-7baa90d82a5a/02-prd.md");
if !prd_path.exists() {
return;
}
let html = render_markdown_file_to_html(prd_path, "prd", "geracao-mais-robusta")
.expect("render_markdown_file_to_html não deve falhar para PRD existente");
assert!(
html.contains("<div class=\"mermaid-diagram\">"),
"PRD real deve gerar elemento mermaid-diagram"
);
assert!(
html.contains("<pre class=\"mermaid-fallback\">"),
"PRD real deve gerar elemento mermaid-fallback"
);
assert!(html.contains("toc-list"), "deve ter TOC");
assert!(
html.contains("<!doctype html>") || html.contains("<!DOCTYPE html>"),
"deve ter DOCTYPE"
);
}
#[test]
fn heading_id_converts_accented_text() {
let id = heading_id("Rastreabilidade e Contexto");
assert!(!id.contains(' '), "id não deve conter espaços");
assert!(
id.starts_with('r') || id.contains('-'),
"id deve ser minúsculo com hífens"
);
}
#[test]
fn inline_md_bold_and_italic() {
let out = inline_md("Texto **negrito** e *itálico*.");
assert!(
out.contains("<strong>negrito</strong>"),
"deve gerar <strong>"
);
assert!(out.contains("<em>itálico</em>"), "deve gerar <em>");
}
#[test]
fn inline_md_code_span() {
let out = inline_md("Execute `cargo build`.");
assert!(
out.contains("<code>cargo build</code>"),
"deve gerar <code>"
);
}
#[test]
fn escape_html_escapes_all_special_chars() {
let input = r#"<div class="test">&'value'</div>"#;
let out = escape_html(input);
assert!(out.contains("<"), "deve escapar < como <");
assert!(out.contains(">"), "deve escapar > como >");
assert!(out.contains("""), "deve escapar \" como "");
assert!(out.contains("&"), "deve escapar & como &");
assert!(out.contains("'"), "deve escapar ' como '");
let stripped = out
.replace("<", "")
.replace(">", "")
.replace(""", "")
.replace("&", "")
.replace("'", "");
assert!(
!stripped.contains('<'),
"nenhum < literal deve restar após stripping"
);
assert!(
!stripped.contains('>'),
"nenhum > literal deve restar após stripping"
);
assert!(
!stripped.contains('"'),
"nenhum \" literal deve restar após stripping"
);
}
}