// Copyright 2026 TiKV Project Authors. Licensed under Apache-2.0.

use std::{fs, path::Path};

use anyhow::{Context, Result, ensure};

use crate::metrics::ReportData;

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

/// Writes the machine-readable JSON summary and the standalone HTML report.
pub fn write_reports(report_data: &ReportData) -> Result<()> {
    let config = &report_data.config;
    ensure!(
        config.summary_path != config.report_path,
        "summary-path and report-path must be different"
    );

    let pretty_json = serde_json::to_string_pretty(report_data)
        .context("failed to serialize YATP simulator report data")?;
    let html = render_html(&pretty_json)?;

    write_file(&config.summary_path, pretty_json.as_bytes(), "JSON summary")?;
    write_file(&config.report_path, html.as_bytes(), "HTML report")?;

    println!("wrote JSON summary to {}", config.summary_path.display());
    println!("wrote HTML report to {}", config.report_path.display());
    Ok(())
}

fn render_html(pretty_json: &str) -> Result<String> {
    ensure!(
        REPORT_TEMPLATE.matches(REPORT_DATA_PLACEHOLDER).count() == 1,
        "report template must contain exactly one data placeholder"
    );
    let embedded_json = escape_json_for_html_script(pretty_json);
    Ok(REPORT_TEMPLATE.replacen(REPORT_DATA_PLACEHOLDER, &embedded_json, 1))
}

fn escape_json_for_html_script(json: &str) -> String {
    json.replace('&', "\\u0026")
        .replace('<', "\\u003c")
        .replace('>', "\\u003e")
        .replace('\u{2028}', "\\u2028")
        .replace('\u{2029}', "\\u2029")
}

fn write_file(path: &Path, contents: &[u8], description: &str) -> Result<()> {
    if let Some(parent) = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent).with_context(|| {
            format!(
                "failed to create parent directory for {description} {}",
                path.display()
            )
        })?;
    }
    fs::write(path, contents)
        .with_context(|| format!("failed to write {description} {}", path.display()))
}

#[cfg(test)]
mod tests {
    use std::{
        path::PathBuf,
        process,
        time::{SystemTime, UNIX_EPOCH},
    };

    use serde_json::Value;

    use super::*;
    use crate::{
        config::SimulatorConfig,
        metrics::{
            DistributionReport, HistogramBucketReport, KeyspaceDescriptor, KeyspaceMetricsReport,
            MetricsWindow, ReportPhase, RunSummary, SlowPollReport, SlowTaskReport, TaskDescriptor,
            TaskMetricsReport, WorkloadPhaseReport,
        },
    };

    fn test_config() -> SimulatorConfig {
        toml::from_str(
            r#"
duration = "1s"
stats-print-interval = "100ms"
report-path = "report.html"
summary-path = "summary.json"

[read-pool]
workers = 2

[read-pool.keyspace-fairness]
enabled = false

[[keyspace-groups]]
name = "test"

[[keyspace-groups.tasks]]
name = "read"
arrival-mode = "closed-loop"
concurrency = 1
task-cpu-us = 10
slice-cpu-us = 5
slow-threshold = "1ms"
"#,
        )
        .unwrap()
    }

    fn distribution() -> DistributionReport {
        DistributionReport {
            count: 2,
            sum: 0.03,
            average: Some(0.015),
            p50: Some(0.01),
            p80: Some(0.02),
            p95: Some(0.02),
            p99: Some(0.02),
            overflow_count: 0,
            buckets: vec![HistogramBucketReport {
                upper_bound: 0.02,
                cumulative_count: 2,
            }],
        }
    }

    fn test_report_data() -> ReportData {
        let mut config = test_config();
        let unsafe_name = "</script><&>\u{2028}\u{2029}".to_owned();
        config.keyspace_groups[0].name.clone_from(&unsafe_name);

        let task = TaskMetricsReport {
            runtime_id: 0,
            keyspace_id: 1,
            keyspace_name: unsafe_name.clone(),
            group_name: unsafe_name.clone(),
            group_instance: 1,
            task_name: unsafe_name.clone(),
            offered: 3,
            submitted: 2,
            suppressed: 1,
            completed: 2,
            cancelled: 0,
            outstanding_at_end: 0,
            throughput_qps: 2.0,
            task_latency_seconds: distribution(),
            task_poll_cpu_seconds: 0.5,
            task_poll_wall_seconds: 0.6,
            task_poll_cpu_cores: 0.5,
            task_poll_wall_cores: 0.6,
            task_slice_wait_seconds: distribution(),
            task_slice_waits: distribution(),
        };
        let keyspace = KeyspaceMetricsReport {
            keyspace_id: 1,
            keyspace_name: unsafe_name.clone(),
            group_name: unsafe_name.clone(),
            group_instance: 1,
            offered: 3,
            submitted: 2,
            suppressed: 1,
            completed: 2,
            cancelled: 0,
            outstanding_at_end: 0,
            throughput_qps: 2.0,
            task_latency_seconds: distribution(),
            task_poll_cpu_seconds: 0.5,
            task_poll_wall_seconds: 0.6,
            task_poll_cpu_cores: 0.5,
            task_poll_wall_cores: 0.6,
            accounted_seconds: None,
            accounted_service_share: None,
            fair_queue_length: Some(distribution()),
            fair_queue_task_slice_waits: None,
            fair_queue_task_slice_wait_seconds: None,
        };
        let measurement = MetricsWindow {
            phase: ReportPhase::Measurement,
            start_seconds: 0.0,
            end_seconds: 1.0,
            duration_seconds: 1.0,
            total_cpu_seconds: 0.75,
            total_cpu_cores: 0.75,
            keyspaces: vec![keyspace],
            tasks: vec![task],
        };
        ReportData {
            schema_version: 1,
            generated_at_unix_seconds: 1,
            config,
            keyspaces: vec![KeyspaceDescriptor {
                keyspace_id: 1,
                keyspace_name: unsafe_name.clone(),
                group_name: unsafe_name.clone(),
                group_instance: 1,
            }],
            task_runtimes: vec![TaskDescriptor {
                runtime_id: 0,
                keyspace_id: 1,
                keyspace_name: unsafe_name.clone(),
                group_name: unsafe_name,
                group_instance: 1,
                task_name: "read".to_owned(),
            }],
            windows: vec![measurement.clone()],
            slow_tasks: vec![SlowTaskReport {
                task_id: 7,
                runtime_id: 0,
                offer_sequence: 3,
                submitted_at_seconds: 0.1,
                completed_at_seconds: 0.135,
                latency_seconds: 0.035,
                slow_threshold_seconds: 0.001,
                polls: vec![
                    SlowPollReport {
                        poll_index: 1,
                        dispatch_index: 1,
                        immediate_repoll: false,
                        interval_start_seconds: 0.1,
                        poll_start_seconds: 0.105,
                        poll_end_seconds: 0.115,
                        wait_seconds: 0.005,
                        queue_wait_seconds: 0.004,
                        other_wait_seconds: 0.001,
                        poll_wall_seconds: 0.01,
                        poll_cpu_seconds: 0.008,
                        fair_queue_waited_task_slices: Some(2),
                    },
                    SlowPollReport {
                        poll_index: 2,
                        dispatch_index: 1,
                        immediate_repoll: true,
                        interval_start_seconds: 0.115,
                        poll_start_seconds: 0.1155,
                        poll_end_seconds: 0.12,
                        wait_seconds: 0.0005,
                        queue_wait_seconds: 0.0,
                        other_wait_seconds: 0.0005,
                        poll_wall_seconds: 0.0045,
                        poll_cpu_seconds: 0.004,
                        fair_queue_waited_task_slices: None,
                    },
                    SlowPollReport {
                        poll_index: 3,
                        dispatch_index: 2,
                        immediate_repoll: false,
                        interval_start_seconds: 0.12,
                        poll_start_seconds: 0.125,
                        poll_end_seconds: 0.135,
                        wait_seconds: 0.005,
                        queue_wait_seconds: 0.004,
                        other_wait_seconds: 0.001,
                        poll_wall_seconds: 0.01,
                        poll_cpu_seconds: 0.009,
                        fair_queue_waited_task_slices: Some(1),
                    },
                ],
            }],
            summary: RunSummary {
                warmup: None,
                measurement: measurement.clone(),
                drain: Some(MetricsWindow {
                    phase: ReportPhase::Drain,
                    start_seconds: 1.0,
                    end_seconds: 1.1,
                    duration_seconds: 0.1,
                    total_cpu_seconds: 0.05,
                    total_cpu_cores: 0.5,
                    keyspaces: measurement.keyspaces.clone(),
                    tasks: measurement.tasks.clone(),
                }),
                total: measurement.clone(),
                workload_phases: vec![WorkloadPhaseReport {
                    number: 1,
                    active_runtime_ids: vec![0],
                    metrics: measurement,
                }],
            },
        }
    }

    fn embedded_report_json(html: &str) -> &str {
        const START: &str = "<script id=\"report-data\" type=\"application/json\">";
        let start = html.find(START).unwrap() + START.len();
        let end = html[start..].find("</script>").unwrap() + start;
        &html[start..end]
    }

    fn unique_temp_directory() -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("yatp-sim-report-{}-{nonce}", process::id()))
    }

    #[test]
    fn test_render_html_embeds_report_data_safely() {
        let report = test_report_data();
        let pretty_json = serde_json::to_string_pretty(&report).unwrap();
        let html = render_html(&pretty_json).unwrap();

        assert!(!html.contains(REPORT_DATA_PLACEHOLDER));
        assert!(!html.contains("</script><&>"));
        assert!(!html.contains('\u{2028}'));
        assert!(!html.contains('\u{2029}'));
        assert!(html.contains("\\u003c/script\\u003e\\u003c\\u0026\\u003e"));
        assert!(!html.contains("<script src="));
        assert!(!html.contains("src=\"https://"));
        assert!(!html.contains("src=\"http://"));

        let embedded: Value = serde_json::from_str(embedded_report_json(&html)).unwrap();
        assert_eq!(embedded, serde_json::to_value(report).unwrap());
        assert_eq!(embedded["schema_version"], 1);
        assert_eq!(
            embedded["slow_tasks"][0]["polls"].as_array().unwrap().len(),
            3
        );
    }

    #[test]
    fn test_write_reports_creates_parent_directories() {
        let temp_dir = unique_temp_directory();
        let mut report = test_report_data();
        report.config.summary_path = temp_dir.join("json/summary.json");
        report.config.report_path = temp_dir.join("html/report.html");

        write_reports(&report).unwrap();

        let summary = fs::read_to_string(&report.config.summary_path).unwrap();
        let written: Value = serde_json::from_str(&summary).unwrap();
        assert_eq!(written, serde_json::to_value(&report).unwrap());
        let html = fs::read_to_string(&report.config.report_path).unwrap();
        let embedded: Value = serde_json::from_str(embedded_report_json(&html)).unwrap();
        assert_eq!(embedded, written);

        fs::remove_dir_all(temp_dir).unwrap();
    }

    #[test]
    fn test_template_is_standalone_and_contains_report_sections() {
        assert_eq!(REPORT_TEMPLATE.matches(REPORT_DATA_PLACEHOLDER).count(), 1);
        for section in [
            "configuration",
            "configuration-details",
            "workload-summary",
            "workload-phase-summary",
            "slow-tasks",
            "slow-task-list",
            "windows",
            "window-details",
            "task-latency-avg-chart",
            "task-latency-p99-chart",
            "slice-wait-p99-chart",
            "qps-chart",
            "cpu-chart",
            "task-slice-waits-chart",
            "keyspace-queue-length-chart",
        ] {
            assert!(
                REPORT_TEMPLATE.contains(&format!("id=\"{section}\"")),
                "missing report section {section}"
            );
        }
        let mut previous_title_position = 0;
        for title in [
            "Task latency avg",
            "Task latency p99",
            "Task-slice wait p99",
            "Task QPS",
            "CPU consume",
            "Fair-queue task-slice waits p99",
            "Keyspace queue length p99",
        ] {
            let marker = format!("<h3>{title}</h3>");
            let position = REPORT_TEMPLATE
                .find(&marker)
                .unwrap_or_else(|| panic!("missing chart title {title}"));
            assert!(
                position > previous_title_position,
                "chart title {title} is out of order"
            );
            previous_title_position = position;
        }
        assert!(REPORT_TEMPLATE.contains("<details id=\"configuration-details\">"));
        assert!(!REPORT_TEMPLATE.contains("<details id=\"configuration-details\" open"));
        assert!(REPORT_TEMPLATE.contains("<details id=\"window-details\">"));
        assert!(!REPORT_TEMPLATE.contains("<details id=\"window-details\" open"));
        assert!(REPORT_TEMPLATE.contains("<h2 id=\"slow-task-title\">Slow tasks</h2>"));
        assert!(!REPORT_TEMPLATE.contains("slow-tasks-details"));
        assert!(
            REPORT_TEMPLATE.find("<section id=\"slow-tasks\">").unwrap()
                > REPORT_TEMPLATE.find("<section id=\"windows\">").unwrap(),
            "slow tasks should follow the interval charts"
        );
        for interactive_marker in [
            "tooltip.className = \"chart-tooltip hidden\"",
            "addEventListener(\"pointermove\"",
            "classList.toggle(\"is-disabled\"",
            "item.title = \"Click to isolate this series; Shift-click to toggle it\"",
            "if (event.shiftKey)",
            "hiddenSeries.delete(entry.id)",
            "hiddenSeries.add(entry.id)",
            "const isOnlyVisibleSeries =",
            "hiddenSeries.clear()",
            "candidate.id !== entry.id",
            "setAttribute(\"aria-pressed\"",
            "const tooltipHeight = tooltip.offsetHeight",
        ] {
            assert!(
                REPORT_TEMPLATE.contains(interactive_marker),
                "missing interactive chart behavior {interactive_marker}"
            );
        }
        for chart_marker in [
            "function formatAxisNumber(value)",
            "function timeDisplayUnit(maxSeconds)",
            "function taskRuntimeSeries(labels, metricName, value)",
            "function keyspaceSeries(labels, metricName, value)",
            "const series = options.series",
            "const cpuSeries = [",
            "id: \"total:cpu\"",
            "value: (window) => window.total_cpu_cores",
            "const xDisplay = timeDisplayUnit(totalDurationSeconds)",
            "const paddedMaxY = rawMaxY > 0 ? rawMaxY * 1.08 : 0",
            "timeDisplayUnit(paddedMaxY)",
            "timeValues: true",
            "formatAxisNumber(maxY * (1 - ratio) * yDisplay.scale)",
            "formatAxisNumber(tickSeconds * xDisplay.scale)",
            "queue-length-p99",
            "task-slice-waits-p99",
        ] {
            assert!(
                REPORT_TEMPLATE.contains(chart_marker),
                "missing chart implementation marker {chart_marker}"
            );
        }
        assert!(REPORT_TEMPLATE.contains("row.fair_queue_length"));
        assert!(!REPORT_TEMPLATE.contains("row.task_queue_length"));
        assert!(REPORT_TEMPLATE.contains("grid-template-columns: minmax(0, 1fr)"));
        assert!(REPORT_TEMPLATE.contains("min-height: 210px"));
        assert!(REPORT_TEMPLATE.contains("const height = 215;"));
        assert!(REPORT_TEMPLATE.contains("`${descriptor.keyspace_name}-${descriptor.task_name}`"));
        assert!(REPORT_TEMPLATE.contains("window.phase !== \"drain\""));
        for summary_marker in [
            "report.summary.workload_phases.forEach((phase) =>",
            "function formatCompactNumber(value, digits = 3)",
            "Phase${phase.number}: [",
            "tasks.map(taskLabel).join(\", \")",
            "`[${taskLabel(task)}] QPS:",
            "`Latency: avg=",
            "`Total CPU:",
            "taskRuntimeSeries(taskLabels, \"latency-avg\"",
        ] {
            assert!(
                REPORT_TEMPLATE.contains(summary_marker),
                "missing workload summary implementation marker {summary_marker}"
            );
        }
        for slow_task_marker in [
            "const slowTasks = Array.isArray(report.slow_tasks)",
            "const NANOSECONDS_PER_SECOND = 1000000000;",
            "const MIN_SLOW_POLL_WIDTH_PX = 72;",
            "const MAX_SLOW_BREAKDOWN_PHASE_RATIO = 6;",
            "const MAX_SLOW_BREAKDOWN_INNER_RATIO = 10;",
            "function clampSlowBreakdownPhaseWeights(firstSeconds, secondSeconds)",
            "function smallerSlowBreakdownPartWithoutLabel(parts)",
            "function initializeSlowTasks()",
            "function renderSlowTasks()",
            "function groupSlowTasksByWorkload()",
            "function renderSlowTaskCard(container, task, taskIndex, workload)",
            "function renderSlowTaskTimeline(body, task, taskIndex, workload, polls)",
            "function renderSlowTaskOverview(body, task, polls)",
            "function renderSlowPollSelection(selection, poll)",
            "function renderSlowPollDetail(detail, poll)",
            "function formatSignificantNumber(value, significantDigits = 4)",
            "function formatTimelineDuration(seconds)",
            "const card = document.createElement(\"details\");",
            "const header = document.createElement(\"summary\");",
            "card.addEventListener(\"toggle\", () => {",
            "ensureTimelineRendered();",
            "button.className = \"slow-poll-button\";",
            "button.setAttribute(\"aria-expanded\", \"false\");",
            "button.addEventListener(\"click\", () => {",
            "button.classList.add(\"is-selected\");",
            "selection.hidden = false;",
            "track.style.minWidth = `${polls.length * MIN_SLOW_POLL_WIDTH_PX}px`;",
            "setTimelineFlex(button, pollSpan);",
            "nonNegativeSeconds(seconds) * NANOSECONDS_PER_SECOND",
            "slow-poll-block",
            "Timeline example",
            "poll-block-example",
            "Poll block: Wait before poll | Actual poll wall",
            "waitPart.className = \"slow-poll-overview-part wait\";",
            "setTimelineFlex(waitPart, waitSeconds);",
            "pollPart.className = \"slow-poll-overview-part poll\";",
            "setTimelineFlex(pollPart, pollWallSeconds);",
            "slow-breakdown-track",
            "slow-breakdown-phase",
            "slow-breakdown-phase-segments",
            "is-label-hidden",
            "displaySeconds = layoutSeconds",
            "slow-breakdown-segment-annotation",
            "slow-queue-wait",
            "slow-other-wait",
            "slow-poll-cpu",
            "slow-poll-non-cpu",
            "Queue wait",
            "Other wait",
            "Poll CPU",
            "Non-CPU wall",
            "duration composition",
            "slice waits",
            "N/A slice waits",
            "const rawCpuSeconds = nonNegativeSeconds(poll.poll_cpu_seconds);",
            "const cpuLayoutSeconds = Math.min(",
            "annotation: cpuLayoutCapped ? \"layout capped\" : null",
            "selection.setAttribute(\"aria-live\", \"polite\");",
            "Task overview",
            "Task start",
            "slow-task-group",
            "slow-task-group-header",
            "slow-task-group-body",
            "slow-task-group-title",
            "group.tasks.sort((left, right)",
            "let groupTasksRendered = false;",
            "if (!group.open || groupTasksRendered) return;",
            "[runtime.keyspace_name, runtime.task_name]",
            "${tasks.length} slow ${tasks.length === 1 ? \"task\" : \"tasks\"}",
            "`[${workload}] start ${formatPreciseDuration(task.submitted_at_seconds)} · duration ${formatPreciseDuration(task.latency_seconds)} · ${polls.length}",
            "Task duration / latency",
            "Total wait",
            "Total queue wait",
            "Total other wait",
            "Total poll wall",
            "Total poll CPU",
            "Total waited slices",
            "else if (!poll.immediate_repoll)",
            "fair_queue_waited_task_slices",
            "Poll CPU / wall",
            "initializeSlowTasks();",
        ] {
            assert!(
                REPORT_TEMPLATE.contains(slow_task_marker),
                "missing slow-task timeline behavior {slow_task_marker}"
            );
        }
        assert!(REPORT_TEMPLATE.contains("min-width: 72px"));
        assert!(!REPORT_TEMPLATE.contains("MIN_SLOW_BREAKDOWN_SEGMENT_WIDTH_PX"));
        assert!(!REPORT_TEMPLATE.contains("phase.style.minWidth"));
        assert!(!REPORT_TEMPLATE.contains("minimumTrackWidth"));
        assert!(REPORT_TEMPLATE.contains(
            "larger.layoutSeconds / smaller.layoutSeconds > MAX_SLOW_BREAKDOWN_INNER_RATIO"
        ));
        assert!(REPORT_TEMPLATE.contains("outline: 4px solid #f59e0b"));
        assert!(REPORT_TEMPLATE.contains(
            ".slow-task-overview .slow-poll-detail-item dt,\n    .slow-task-overview .slow-poll-detail-item dd {\n      white-space: nowrap;"
        ));
        assert!(REPORT_TEMPLATE.contains("overflow-x: auto"));
        assert!(REPORT_TEMPLATE.contains(".slow-breakdown-scroll {\n      width: 100%;"));
        assert!(REPORT_TEMPLATE.contains("\"Actual poll wall\","));
        assert!(
            REPORT_TEMPLATE
                .contains(".slow-task-legend-example-segment.wait-overview {\n      flex: 1;")
        );
        assert!(
            REPORT_TEMPLATE
                .contains(".slow-task-legend-example-segment.poll-overview {\n      flex: 1;")
        );
        assert!(
            REPORT_TEMPLATE.contains(".slow-task-legend-example-segment.queue {\n      flex: 3;")
        );
        assert!(!REPORT_TEMPLATE.contains("gap: 3px"));
        assert!(!REPORT_TEMPLATE.contains("appendSlowPollDetailItem(grid, \"Workload\""));
        assert!(!REPORT_TEMPLATE.contains("appendSlowPollDetailItem(grid, \"Task ID\""));
        assert!(!REPORT_TEMPLATE.contains("appendSlowPollDetailItem(grid, \"Task latency\""));
        assert!(!REPORT_TEMPLATE.contains(" from sim start"));
        assert!(!REPORT_TEMPLATE.contains("task #${task.task_id} · runtime #"));
        assert!(
            !REPORT_TEMPLATE
                .contains("`interval [${formatTimelineOffset(task.submitted_at_seconds)}")
        );
        assert!(!REPORT_TEMPLATE.contains("\"Dispatch mode\""));
        assert!(!REPORT_TEMPLATE.contains("\"Poll interval\""));
        assert!(!REPORT_TEMPLATE.contains("detail.setAttribute(\"aria-live\""));
        assert!(!REPORT_TEMPLATE.contains("group.open = true"));
        assert!(
            REPORT_TEMPLATE
                .contains("`Slow tasks (${slowTasks.length})`;\n        renderSlowTasks();")
        );
        assert!(
            REPORT_TEMPLATE.find("label: \"Other wait\"").unwrap()
                < REPORT_TEMPLATE.find("label: \"Queue wait\"").unwrap(),
            "other wait should precede queue wait in the wait breakdown"
        );
        assert!(
            REPORT_TEMPLATE
                .find("renderSlowTaskOverview(body, task, polls);")
                .unwrap()
                < REPORT_TEMPLATE
                    .find("appendText(body, \"h3\", \"Poll timeline\"")
                    .unwrap(),
            "task overview should precede the poll timeline"
        );
        assert!(!REPORT_TEMPLATE.contains("card.open = true"));
        assert!(!REPORT_TEMPLATE.contains("Measurement keyspace internals"));
        assert!(!REPORT_TEMPLATE.contains("Measurement task classes"));
        assert!(!REPORT_TEMPLATE.contains("measurement-keyspace-table"));
        assert!(!REPORT_TEMPLATE.contains("measurement-task-table"));
        assert!(!REPORT_TEMPLATE.contains("Measurement overview"));
        assert!(!REPORT_TEMPLATE.contains("id=\"overview\""));
        assert!(!REPORT_TEMPLATE.contains("overview-cards"));
        assert!(!REPORT_TEMPLATE.contains("id=\"drain\""));
        assert!(!REPORT_TEMPLATE.contains("renderDrain"));
        assert!(REPORT_TEMPLATE.contains("return \"N/A\""));
        assert!(!REPORT_TEMPLATE.contains("innerHTML"));
        assert!(!REPORT_TEMPLATE.contains("<script src="));
        assert!(!REPORT_TEMPLATE.contains("src=\"https://"));
        assert!(!REPORT_TEMPLATE.contains("src=\"http://"));
    }

    #[test]
    fn test_rejects_shared_output_path() {
        let mut report = test_report_data();
        report.config.report_path = PathBuf::from("same-output");
        report.config.summary_path = report.config.report_path.clone();

        let error = write_reports(&report).unwrap_err();
        assert!(
            error
                .to_string()
                .contains("summary-path and report-path must be different")
        );
    }
}
