cuke-dedup 0.1.0

Static analysis for duplicate and reusable Cucumber step definitions
Documentation
use super::shared::{bounded_findings, count_label, report_safe, report_value};
use super::ReportContext;
use crate::model::{AnalysisResult, DefinitionComparison, MatcherDiff};
use anyhow::{Context, Result};
use std::path::Path;

const HTML_TEMPLATE: &str = include_str!("templates/report.html");

/// Renders the self-contained interactive HTML report without writing it to disk.
pub fn render_html(result: &AnalysisResult, root: &Path) -> Result<String> {
    render_html_with_threshold(result, root, 0.0)
}

/// Renders self-contained HTML using the configured duplication threshold.
pub fn render_html_with_threshold(
    result: &AnalysisResult,
    root: &Path,
    threshold: f64,
) -> Result<String> {
    render_html_context(&ReportContext::new(result, root, threshold))
}

pub(super) fn render_html_context(context: &ReportContext<'_>) -> Result<String> {
    let result = context.result;
    let root = context.root;
    let summary = &context.summary;
    let rule_summary = if summary.by_rule.is_empty() {
        String::new()
    } else {
        let counts = summary
            .by_rule
            .iter()
            .map(|(rule, count)| {
                format!(
                    r#"<li><code>{}</code><strong>{count}</strong></li>"#,
                    html_escape(rule)
                )
            })
            .collect::<String>();
        format!(r#"<ul class="rule-summary" aria-label="Findings by rule">{counts}</ul>"#)
    };
    let contributing_rules = if summary.duplication.rules.is_empty() {
        String::new()
    } else {
        format!(
            r#"<p class="threshold-rules">Contributing rules: {}</p>"#,
            summary
                .duplication
                .rules
                .iter()
                .map(|rule| format!("<code>{}</code>", html_escape(rule.as_str())))
                .collect::<Vec<_>>()
                .join(", ")
        )
    };
    let report_json = json_for_html(
        &serde_json::to_string(&report_value(context))
            .context("failed to serialize HTML report data")?,
    );
    let mut cards = String::new();
    let selected = bounded_findings(result, true);
    let truncated = selected.truncated;
    for finding in selected.findings {
        let location = finding.primary.display(root);
        let related_locations = finding
            .related
            .iter()
            .map(|related| {
                format!(
                    r#"<li><code>{}</code></li>"#,
                    html_escape(&related.display(root))
                )
            })
            .collect::<String>();
        let related = if related_locations.is_empty() {
            String::new()
        } else {
            format!(r#"<ul class="related">{related_locations}</ul>"#)
        };
        let comparison = finding
            .evidence
            .comparison
            .as_ref()
            .map(render_comparison)
            .unwrap_or_default();
        let handler_evidence = if finding.evidence.handler_evidence.is_empty() {
            String::new()
        } else {
            format!(
                r#"<p><strong>Handler evidence:</strong> {}</p>"#,
                html_escape(&finding.evidence.handler_evidence)
            )
        };
        cards.push_str(&format!(
            r#"<article class="finding" data-rule="{}" data-severity="{}"><header><span class="severity {}">{}</span><code>{}</code></header><h2>{}</h2><p class="location">{}</p>{related}<p>{}</p>{handler_evidence}{comparison}<p><strong>Action:</strong> {}</p></article>"#,
            finding.rule,
            finding.severity,
            finding.severity,
            finding.severity,
            finding.rule,
            html_escape(&finding.message),
            html_escape(&location),
            html_escape(&finding.evidence.matcher_difference),
            html_escape(&finding.suggested_action),
        ));
    }
    if cards.is_empty() {
        cards.push_str("<p class=empty>No active findings.</p>");
    }
    let truncation_notice = if truncated == 0 {
        String::new()
    } else {
        format!(
            r#"<p class="truncation-notice" role="status">Showing the highest-severity active findings; {truncated} additional findings are available in the summary and streaming JSONL report.</p>"#
        )
    };

    let replacements = [
        (
            "{{DEFINITIONS}}",
            count_label(summary.definitions_analyzed, "definition"),
        ),
        (
            "{{FEATURE_STEPS}}",
            count_label(summary.feature_steps_analyzed, "feature step"),
        ),
        ("{{ERRORS}}", count_label(summary.errors, "error")),
        ("{{WARNINGS}}", count_label(summary.warnings, "warning")),
        ("{{SUPPRESSED}}", summary.suppressed.to_string()),
        (
            "{{THRESHOLD_CLASS}}",
            if summary.duplication.passed {
                "pass".to_owned()
            } else {
                "fail".to_owned()
            },
        ),
        (
            "{{DUPLICATED_DEFINITIONS}}",
            summary.duplication.duplicated_definitions.to_string(),
        ),
        (
            "{{TOTAL_DEFINITIONS}}",
            summary.duplication.total_definitions.to_string(),
        ),
        (
            "{{DUPLICATION_PERCENTAGE}}",
            format!("{:.2}", summary.duplication.percentage),
        ),
        (
            "{{DUPLICATION_THRESHOLD}}",
            format!("{:.2}", summary.duplication.threshold),
        ),
        (
            "{{THRESHOLD_STATUS}}",
            if summary.duplication.passed {
                "PASS".to_owned()
            } else {
                "FAIL".to_owned()
            },
        ),
        ("{{RULE_SUMMARY}}", rule_summary),
        ("{{CONTRIBUTING_RULES}}", contributing_rules),
        ("{{TRUNCATION_NOTICE}}", truncation_notice),
        ("{{CARDS}}", cards),
        ("{{REPORT_JSON}}", report_json),
    ];
    Ok(render_template(
        HTML_TEMPLATE.strip_suffix('\n').unwrap_or(HTML_TEMPLATE),
        &replacements,
    ))
}

fn render_template(template: &str, replacements: &[(&str, String)]) -> String {
    let mut rendered = String::with_capacity(template.len());
    let mut remaining = template;
    while let Some((index, placeholder, value)) = replacements
        .iter()
        .filter_map(|(placeholder, value)| {
            remaining
                .find(placeholder)
                .map(|index| (index, *placeholder, value))
        })
        .min_by_key(|(index, _, _)| *index)
    {
        rendered.push_str(&remaining[..index]);
        rendered.push_str(value);
        remaining = &remaining[index + placeholder.len()..];
    }
    rendered.push_str(remaining);
    rendered
}

fn render_comparison(comparison: &DefinitionComparison) -> String {
    format!(
        r#"<div class="comparison"><section><h3>Primary definition</h3><code class="matcher">{}</code><pre><code>{}</code></pre></section><section><h3>Related definition</h3><code class="matcher">{}</code><pre><code>{}</code></pre></section></div>"#,
        render_matcher_side(&comparison.matcher_diff, true),
        html_escape(&comparison.left_handler),
        render_matcher_side(&comparison.matcher_diff, false),
        html_escape(&comparison.right_handler),
    )
}

fn render_matcher_side(diff: &MatcherDiff, left: bool) -> String {
    let change = if left {
        &diff.left_change
    } else {
        &diff.right_change
    };
    let change = if change.is_empty() {
        String::new()
    } else {
        format!("<mark>{}</mark>", html_escape(change))
    };
    format!(
        "{}{}{}",
        html_escape(&diff.prefix),
        change,
        html_escape(&diff.suffix)
    )
}

fn html_escape(value: &str) -> String {
    report_safe(value)
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

fn json_for_html(value: &str) -> String {
    let mut output = String::with_capacity(value.len());
    for character in value.chars() {
        if matches!(
            character,
            '<' | '>' | '&'
                | '\u{061c}'
                | '\u{200e}'
                | '\u{200f}'
                | '\u{202a}'..='\u{202e}'
                | '\u{2066}'..='\u{2069}'
        ) {
            output.push_str(&format!("\\u{:04x}", character as u32));
        } else {
            output.push(character);
        }
    }
    output
}