//! utilities for compiling and comparing ttx

use std::{
    collections::HashMap,
    env::temp_dir,
    ffi::OsStr,
    fmt::{Debug, Display, Write},
    path::{Path, PathBuf},
    process::{Command, Stdio},
    time::SystemTime,
};

use crate::{
    compile::{error::CompilerError, Compiler, MockVariationInfo, NopFeatureProvider, Opts},
    DiagnosticSet, GlyphIdent, GlyphMap, ParseTree,
};

use ansi_term::Color;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};

static IGNORED_TESTS: &[&str] = &[
    // ## tests with unofficial syntax extensiosn we haven't implemented yet ## //
    "AlternateChained.fea",
    "GSUB_6.fea",
    "GSUB_2.fea",
    //
    // ## tests that should be revisited ## //
    //
    // includes syntax that is (i think) useless, and should at least be a warning
    "GSUB_8.fea",
    // # tests of variable syntax extension #
    "variable_bug2772.fea",
    "variable_scalar_anchor.fea",
    "variable_scalar_valuerecord.fea",
    "variable_mark_anchor.fea",
];

/// An environment variable that can be set to specify where to write generated files.
///
/// This can be set during debugging if you want to inspect the generated files.
static TEMP_DIR_ENV: &str = "TTX_TEMP_DIR";

/// The combined results of this set of tests
#[derive(Default, Serialize, Deserialize)]
pub struct Report {
    /// All of the test cases for this report
    pub results: Vec<TestCase>,
}

#[derive(Default)]
struct ReportSummary {
    passed: u32,
    panic: u32,
    parse: u32,
    compile: u32,
    compare: u32,
    other: u32,
    sum_compare_perc: f64,
}

struct ReportComparePrinter<'a> {
    old: &'a Report,
    new: &'a Report,
}

/// A specific test and its result
#[derive(Serialize, Deserialize)]
pub struct TestCase {
    /// The path of the input file
    pub path: PathBuf,
    /// The result of running the test
    pub reason: TestResult,
}

/// The result of a ttx test
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub enum TestResult {
    /// The output exactly matched the expectation
    Success,
    /// A panic occured somewhere during compilation
    Panic,
    /// The input could not be parsed
    ParseFail(String),
    /// The input could not be compiled
    CompileFail(String),
    /// Compilation succeeded, but shouldn't have
    UnexpectedSuccess,
    /// A call to the `ttx` utility failed
    #[allow(missing_docs)]
    TtxFail { code: Option<i32>, std_err: String },
    /// The output did not match the expectation
    #[allow(missing_docs)]
    CompareFail {
        expected: String,
        result: String,
        diff_percent: f64,
    },
    /// There was a saved "expected diff" for this input, but it did not match
    /// the actual diff.
    #[allow(missing_docs)]
    ExpectedDiffFail { expected: String, result: String },
}

struct ReasonPrinter<'a> {
    verbose: bool,
    reason: &'a TestResult,
}

/// Assert that we can find the `ttx` executable
pub fn assert_has_ttx_executable() {
    assert!(
        Command::new("ttx")
            .arg("--version")
            .stdout(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false),
        "\nmissing `ttx` executable. Install it with `pip install fonttools`."
    )
}

/// Selectively filter which files to run.
pub struct Filter<'a>(Vec<&'a str>);

impl<'a> Filter<'a> {
    /// Create a new filter from a comma-separated list of inputs
    pub fn new(input: Option<&'a String>) -> Self {
        Self(
            input
                .map(|s| s.split(',').map(|s| s.trim()).collect::<Vec<_>>())
                .unwrap_or_default(),
        )
    }

    /// true if this matches the filter, false if not
    pub fn filter(&self, item: &str) -> bool {
        self.0.is_empty() || self.0.iter().any(|needle| item.contains(needle))
    }
}

/// Run the fonttools tests.
///
/// This compiles the test files, generates ttx, and compares that with what
/// is generated by fonttools.
///
/// `filter` is an optional comma-separated list of strings. If present, only
/// tests which contain one of the strings in the list will be run.
pub fn run_fonttools_tests(filter: Option<&String>) -> Report {
    let fonttools_data_dir = test_data_dir().join("fonttools-tests");
    let glyph_map = fonttools_test_glyph_order();
    let filter = Filter::new(filter);
    let var_info = make_var_info();

    let result = iter_compile_tests(fonttools_data_dir.as_ref(), filter)
        .par_bridge()
        .map(|path| run_test(path, &glyph_map, &var_info))
        .collect::<Vec<_>>();

    finalize_results(result)
}

/// Convert a vector of test results into a report.
pub fn finalize_results(result: Vec<Result<PathBuf, TestCase>>) -> Report {
    let mut result = result
        .into_iter()
        .fold(Report::default(), |mut results, current| {
            match current {
                Err(e) => results.results.push(e),
                Ok(path) => results.results.push(TestCase {
                    path,
                    reason: TestResult::Success,
                }),
            }
            results
        });
    result.results.sort_unstable_by(|a, b| {
        (a.reason.sort_order(), &a.path).cmp(&(b.reason.sort_order(), &b.path))
    });
    result
}

fn iter_compile_tests<'a>(
    path: &'a Path,
    filter: Filter<'a>,
) -> impl Iterator<Item = PathBuf> + 'a {
    iter_fea_files(path).filter(move |p| {
        if p.extension() == Some(OsStr::new("fea")) && p.with_extension("ttx").exists() {
            let path_str = p.file_name().unwrap().to_str().unwrap();
            return should_run_test(path_str) && filter.filter(path_str);
        }
        false
    })
}

fn should_run_test(path: &str) -> bool {
    !IGNORED_TESTS.contains(&path)
}

/// Iterate over all the files in a directory with the 'fea' suffix
pub fn iter_fea_files(path: impl AsRef<Path>) -> impl Iterator<Item = PathBuf> + 'static {
    let mut dir = path.as_ref().read_dir().unwrap();
    std::iter::from_fn(move || loop {
        let entry = dir.next()?.unwrap();
        let path = entry.path();
        if path.extension() == Some(OsStr::new("fea")) {
            return Some(path);
        }
    })
}

/// Attempt to parse a feature file
pub fn try_parse_file(
    path: &Path,
    glyphs: Option<&GlyphMap>,
) -> Result<ParseTree, (ParseTree, DiagnosticSet)> {
    let (tree, errs) = crate::parse::parse_root_file(path, glyphs, None).unwrap();
    if errs.has_errors() {
        Err((tree, errs))
    } else {
        print_diagnostics_if_verbose(&errs);
        Ok(tree)
    }
}

/// Returns `true` if the test case requires variation information
pub(crate) fn is_variable(test_path: &Path) -> bool {
    let as_str = test_path.to_str().expect("paths are utf8");
    as_str.contains("variable") || as_str.contains("variation")
}

/// Run the test case at the provided path.
///
/// the provided fvar table will be passed through only if the path contains
/// the word 'variable'.
pub(crate) fn run_test(
    path: PathBuf,
    glyph_map: &GlyphMap,
    fvar: &MockVariationInfo,
) -> Result<PathBuf, TestCase> {
    let run_result = std::panic::catch_unwind(|| {
        let mut compiler: Compiler<'_, NopFeatureProvider, MockVariationInfo> =
            Compiler::new(&path, glyph_map)
                .print_warnings(std::env::var(super::VERBOSE).is_ok())
                .with_opts(Opts::new().make_post_table(true));
        if is_variable(&path) {
            compiler = compiler.with_variable_info(fvar);
        }

        match compiler.compile_binary() {
            // this means we have a test case that doesn't exist or something weird
            Err(CompilerError::SourceLoad(err)) => panic!("{err}"),
            Err(CompilerError::WriteFail(err)) => panic!("{err}"),
            Err(CompilerError::ParseFail(errs)) => Err(TestResult::ParseFail(errs.to_string(true))),
            Err(CompilerError::ValidationFail(errs) | CompilerError::CompilationFail(errs)) => {
                Err(TestResult::CompileFail(errs.to_string(true)))
            }
            Ok(result) => compare_ttx(&result, &path),
        }
    });

    match run_result {
        Err(_) => Err(TestResult::Panic),
        Ok(Err(reason)) => Err(reason),
        Ok(Ok(_)) => return Ok(path),
    }
    .map_err(|reason| TestCase { reason, path })
}

fn print_diagnostics_if_verbose(diagnostics: &DiagnosticSet) {
    if std::env::var(super::VERBOSE).is_ok() && !diagnostics.is_empty() {
        eprintln!("{}", diagnostics.display());
    }
}

fn get_temp_dir() -> PathBuf {
    match std::env::var(TEMP_DIR_ENV) {
        Ok(dir) => {
            let dir = PathBuf::from(dir);
            if !dir.exists() {
                std::fs::create_dir_all(&dir).unwrap();
            }
            dir
        }
        Err(_) => temp_dir(),
    }
}

fn get_temp_file_name(in_file: &Path) -> PathBuf {
    let stem = in_file.file_stem().unwrap().to_str().unwrap();
    let millis = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_millis();
    Path::new(&format!("{stem}_{millis}")).with_extension("ttf")
}

fn compare_ttx(font_data: &[u8], fea_path: &Path) -> Result<(), TestResult> {
    let ttx_path = fea_path.with_extension("ttx");
    let expected_diff_path = fea_path.with_extension("expected_diff");
    let temp_path = get_temp_dir().join(get_temp_file_name(fea_path));
    std::fs::write(&temp_path, font_data).unwrap();

    const TO_WRITE: &[&str] = &[
        "head", "name", "BASE", "GDEF", "GSUB", "GPOS", "OS/2", "STAT", "hhea", "vhea",
    ];

    let mut cmd = Command::new("ttx");
    for table in TO_WRITE {
        cmd.arg("-t").arg(table);
    }
    let status = cmd
        .arg(&temp_path)
        .output()
        .unwrap_or_else(|_| panic!("failed to execute for path {}", fea_path.display()));
    if !status.status.success() {
        let std_err = String::from_utf8_lossy(&status.stderr).into_owned();
        return Err(TestResult::TtxFail {
            code: status.status.code(),
            std_err,
        });
    }

    let ttx_out_path = temp_path.with_extension("ttx");
    assert!(ttx_out_path.exists());

    let result = std::fs::read_to_string(ttx_out_path).unwrap();

    let result = rewrite_ttx(&result);

    let expected = ttx_path
        .exists()
        .then(|| std::fs::read_to_string(&ttx_path).unwrap())
        .unwrap_or_default();
    let expected = rewrite_ttx(&expected);

    if expected_diff_path.exists() {
        let expected_diff = std::fs::read_to_string(&expected_diff_path).unwrap();
        let simple_diff = plain_text_diff(&expected, &result);
        if diffs_are_equal_ignoring_comments(&expected_diff, &simple_diff) {
            return Ok(());
        } else {
            return Err(TestResult::ExpectedDiffFail {
                expected: expected_diff,
                result: simple_diff,
            });
        }
    }

    if expected == result {
        return Ok(());
    }

    if std::env::var(super::WRITE_RESULTS_VAR).is_ok() {
        std::fs::write(&ttx_path, &result).unwrap();
    }
    let diff_percent = compute_diff_percentage(&expected, &result);

    Err(TestResult::CompareFail {
        expected,
        result,
        diff_percent,
    })
}

// we want to be able to add a comment when we save an 'expected diff', so that
// a future reader can understand our justification
fn diffs_are_equal_ignoring_comments(one: &str, two: &str) -> bool {
    strip_comments(one) == strip_comments(two)
}

fn strip_comments(s: &str) -> String {
    s.lines()
        .skip_while(|line| {
            let line = line.trim();
            line.is_empty() || line.starts_with('#')
        })
        .collect()
}

/// take some output and compare it to the expected output (saved on disk)
pub fn compare_to_expected_output(
    output: &str,
    src_path: &Path,
    cmp_ext: &str,
) -> Result<(), TestCase> {
    let cmp_path = src_path.with_extension(cmp_ext);
    let expected = if cmp_path.exists() {
        std::fs::read_to_string(&cmp_path).expect("failed to read cmp_path")
    } else {
        String::new()
    };

    if expected != output {
        eprintln!("{expected}\n\n{output}");
        let diff_percent = compute_diff_percentage(&expected, output);
        return Err(TestCase {
            path: src_path.to_owned(),
            reason: TestResult::CompareFail {
                expected,
                result: output.to_string(),
                diff_percent,
            },
        });
    }
    Ok(())
}
// hacky way to make our ttx output match fonttools'
fn rewrite_ttx(input: &str) -> String {
    let mut out = String::with_capacity(input.len());

    for line in input.lines() {
        if line.starts_with("<ttFont") {
            out.push_str("<ttFont>\n");
        } else {
            out.push_str(line);
            out.push('\n')
        }
    }
    out
}

fn write_lines(f: &mut impl Write, lines: &[&str], line_num: usize, prefix: char) {
    writeln!(f, "L{}", line_num).unwrap();
    for line in lines {
        writeln!(f, "{}  {}", prefix, line).unwrap();
    }
}

static DIFF_PREAMBLE: &str = "\
# generated automatically by fea-rs
# this file represents an acceptable difference between the output of
# fonttools and the output of fea-rs for a given input.
";

fn compute_diff_percentage(left: &str, right: &str) -> f64 {
    let lines = diff::lines(left, right);
    let same = lines
        .iter()
        .filter(|l| matches!(l, diff::Result::Both { .. }))
        .count();
    let total = lines.len() as f64;
    let perc = (same as f64) / total;

    const PRECISION_SMUDGE: f64 = 10000.0;
    (perc * PRECISION_SMUDGE).trunc() / PRECISION_SMUDGE
}

/// a simple diff (without highlighting) suitable for writing to disk
pub fn plain_text_diff(left: &str, right: &str) -> String {
    let lines = diff::lines(left, right);
    let mut result = DIFF_PREAMBLE.to_string();
    let mut temp: Vec<&str> = Vec::new();
    let mut left_or_right = None;
    let mut section_start = 0;

    for (i, line) in lines.iter().enumerate() {
        match line {
            diff::Result::Left(line) => {
                if left_or_right == Some('R') {
                    write_lines(&mut result, &temp, section_start, '<');
                    temp.clear();
                } else if left_or_right != Some('L') {
                    section_start = i;
                }
                temp.push(line);
                left_or_right = Some('L');
            }
            diff::Result::Right(line) => {
                if left_or_right == Some('L') {
                    write_lines(&mut result, &temp, section_start, '>');
                    temp.clear();
                } else if left_or_right != Some('R') {
                    section_start = i;
                }
                temp.push(line);
                left_or_right = Some('R');
            }
            diff::Result::Both { .. } => {
                match left_or_right.take() {
                    Some('R') => write_lines(&mut result, &temp, section_start, '<'),
                    Some('L') => write_lines(&mut result, &temp, section_start, '>'),
                    _ => (),
                }
                temp.clear();
            }
        }
    }
    match left_or_right.take() {
        Some('R') => write_lines(&mut result, &temp, section_start, '<'),
        Some('L') => write_lines(&mut result, &temp, section_start, '>'),
        _ => (),
    }
    result
}

/// return the path to the test-data directory.
///
/// This figures out if we're running from the crate root or the project root.
fn test_data_dir() -> PathBuf {
    let cwd = std::env::current_dir().expect("could not retrieve current directory");
    // hack: during testing cwd is the crate root, but when running a binary
    // it may be the project root
    let path = if cwd.ends_with("fea-rs") {
        assert!(!cwd.parent().expect("always presnt").ends_with("fea-rs"));
        "./test-data"
    } else {
        "./fea-rs/test-data"
    };
    let path = Path::new(path);
    if !path.exists() {
        panic!(
            "could not locate fea-rs test-data. Please run from crate or project root. (cwd '{}')",
            cwd.display()
        );
    }
    path.to_owned()
}

/// Generate the sample glyph map.
///
/// This is the glyph map used in the feaLib test suite.
pub(crate) fn fonttools_test_glyph_order() -> GlyphMap {
    let mut path = test_data_dir();
    path.push("simple_glyph_order.txt");
    if !path.exists() {
        panic!("could not locate glyph map at {}", path.display());
    }
    let order_str = std::fs::read_to_string(path).unwrap();
    crate::compile::parse_glyph_order(&order_str)
        .unwrap()
        .iter()
        .chain((800_u16..=1001).map(GlyphIdent::Cid))
        .collect()
}

/// Generate variation info
pub(crate) fn make_var_info() -> MockVariationInfo {
    MockVariationInfo::new(&[("wght", 200, 200, 1000), ("wdth", 100, 100, 200)])
}

impl Report {
    ///  Returns `true` if any tests have failed.
    pub fn has_failures(&self) -> bool {
        self.results.iter().any(|r| !r.reason.is_success())
    }

    /// Convert this type into a Result.
    ///
    /// This result type can be returned from a test method.
    pub fn into_error(self) -> Result<(), Self> {
        if self.has_failures() {
            Err(self)
        } else {
            Ok(())
        }
    }

    /// Return a type that can print comparison results
    pub fn compare_printer<'a, 'b: 'a>(&'b self, old: &'a Report) -> impl std::fmt::Debug + 'a {
        ReportComparePrinter { old, new: self }
    }

    /// returns the number of chars in the widest path
    fn widest_path(&self) -> usize {
        self.results
            .iter()
            .map(|item| &item.path)
            .map(|p| p.file_name().unwrap().to_str().unwrap().chars().count())
            .max()
            .unwrap_or(0)
    }

    fn summary(&self) -> ReportSummary {
        let mut summary = ReportSummary::default();
        for item in &self.results {
            match &item.reason {
                TestResult::Success => summary.passed += 1,
                TestResult::Panic => summary.panic += 1,
                TestResult::ParseFail(_) => summary.parse += 1,
                TestResult::CompileFail(_) => summary.compile += 1,
                TestResult::UnexpectedSuccess
                | TestResult::TtxFail { .. }
                | TestResult::ExpectedDiffFail { .. } => summary.other += 1,
                TestResult::CompareFail { diff_percent, .. } => {
                    summary.compare += 1;
                    summary.sum_compare_perc += diff_percent;
                }
            }
        }
        summary
    }
}

impl TestResult {
    fn sort_order(&self) -> u8 {
        match self {
            Self::Success => 1,
            Self::Panic => 2,
            Self::ParseFail(_) => 3,
            Self::CompileFail(_) => 4,
            Self::UnexpectedSuccess => 6,
            Self::TtxFail { .. } => 10,
            Self::ExpectedDiffFail { .. } => 15,
            Self::CompareFail { .. } => 50,
        }
    }

    fn is_success(&self) -> bool {
        matches!(self, Self::Success)
    }

    /// Return an (optionally verbose) type for printing the result
    pub fn printer(&self, verbose: bool) -> impl std::fmt::Display + '_ {
        ReasonPrinter {
            reason: self,
            verbose,
        }
    }
}

impl std::fmt::Debug for ReportComparePrinter<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        debug_impl(f, self.new, Some(self.old), false)
    }
}

struct OldResults<'a> {
    map: Option<HashMap<&'a Path, TestResult>>,
}

impl<'a> OldResults<'a> {
    fn new(report: Option<&'a Report>) -> Self {
        Self {
            map: report.map(|report| {
                report
                    .results
                    .iter()
                    .map(|test| (test.path.as_path(), test.reason.clone()))
                    .collect()
            }),
        }
    }

    fn get(&self, result: &TestCase) -> ComparePrinter {
        match self.map.as_ref() {
            None => ComparePrinter::NotComparing,
            Some(map) => match map.get(result.path.as_path()) {
                None => ComparePrinter::Missing,
                Some(prev) => match (prev, &result.reason) {
                    (
                        TestResult::CompareFail {
                            diff_percent: old, ..
                        },
                        TestResult::CompareFail {
                            diff_percent: new, ..
                        },
                    ) => {
                        if (old - new).abs() > f64::EPSILON {
                            ComparePrinter::PercChange((new - old) * 100.)
                        } else {
                            ComparePrinter::Same
                        }
                    }
                    (x, y) if x == y => ComparePrinter::Same,
                    (old, _) => ComparePrinter::Different(old.clone()),
                },
            },
        }
    }
}

enum ComparePrinter {
    // print nothing, we aren't comparing
    NotComparing,
    // this item didn't previously exist
    Missing,
    // no diff
    Same,
    /// we are both compare failures, with a percentage change
    PercChange(f64),
    /// we are some other difference
    Different(TestResult),
}

impl std::fmt::Display for ComparePrinter {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            ComparePrinter::NotComparing => Ok(()),
            ComparePrinter::Missing => write!(f, "(new)"),
            ComparePrinter::Same => write!(f, "--"),
            ComparePrinter::PercChange(val) if val.is_sign_positive() => {
                write!(f, "{}", Color::Green.paint(format!("+{val:.2}")))
            }
            ComparePrinter::PercChange(val) => {
                write!(f, "{}", Color::Red.paint(format!("-{val:.2}")))
            }
            ComparePrinter::Different(reason) => write!(f, "{reason:?}"),
        }
    }
}

fn debug_impl(
    f: &mut std::fmt::Formatter,
    report: &Report,
    old: Option<&Report>,
    verbose: bool,
) -> std::fmt::Result {
    writeln!(f, "failed test cases")?;
    let path_pad = report.widest_path();
    let old_results = OldResults::new(old);

    for result in &report.results {
        let old = old_results.get(result);
        let file_name = result.path.file_name().unwrap().to_str().unwrap();
        writeln!(
            f,
            "{file_name:path_pad$}  {:<30}  {old}",
            result.reason.printer(verbose).to_string(),
        )?;
    }
    let summary = report.summary();
    let prefix = if old.is_some() { "new: " } else { "" };
    writeln!(f, "{prefix}{summary}")?;
    if let Some(old_summary) = old.map(Report::summary) {
        writeln!(f, "old: {old_summary}")?;
    }
    if !verbose {
        writeln!(f, "Set FEA_VERBOSE=1 for detailed output.")?;
    }

    Ok(())
}

impl std::fmt::Debug for Report {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        let verbose = std::env::var(super::VERBOSE).is_ok();
        debug_impl(f, self, None, verbose)
    }
}

impl Display for ReasonPrinter<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self.reason {
            TestResult::Success => write!(f, "{}", Color::Green.paint("success")),
            TestResult::Panic => write!(f, "{}", Color::Red.paint("panic")),
            TestResult::ParseFail(diagnostics) => {
                write!(f, "{}", Color::Purple.paint("parse failure"))?;
                if self.verbose {
                    write!(f, "\n{}", diagnostics)?;
                }
                Ok(())
            }
            TestResult::CompileFail(diagnostics) => {
                write!(f, "{}", Color::Yellow.paint("compile failure"))?;
                if self.verbose {
                    write!(f, "\n{}", diagnostics)?;
                }
                Ok(())
            }
            TestResult::UnexpectedSuccess => {
                write!(f, "{}", Color::Yellow.paint("unexpected success"))
            }
            TestResult::TtxFail { code, std_err } => {
                write!(f, "ttx failure ({:?}) stderr:\n{}", code, std_err)
            }
            TestResult::ExpectedDiffFail { expected, result } => {
                if self.verbose {
                    writeln!(f, "expected diff fail")?;
                    super::write_line_diff(f, expected, result)
                } else {
                    write!(f, "{}", Color::Yellow.paint("expected diff fail"))
                }
            }
            TestResult::CompareFail {
                expected,
                result,
                diff_percent,
            } => {
                if self.verbose {
                    writeln!(f, "compare failure")?;
                    super::write_line_diff(f, expected, result)
                } else {
                    write!(
                        f,
                        "{} ({:.0}%)",
                        Color::Blue.paint("compare failure"),
                        diff_percent * 100.0
                    )
                }
            }
        }
    }
}

impl Debug for TestResult {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.printer(std::env::var(super::VERBOSE).is_ok()).fmt(f)
    }
}

impl ReportSummary {
    fn total_items(&self) -> u32 {
        self.passed + self.panic + self.parse + self.compile + self.compare + self.other
    }

    fn average_diff_percent(&self) -> f64 {
        (self.sum_compare_perc + (self.passed as f64)) / self.total_items() as f64 * 100.
    }
}

impl Display for ReportSummary {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let total = self.total_items();
        let perc = self.average_diff_percent();
        let ReportSummary {
            passed,
            panic,
            parse,
            compile,
            ..
        } = self;
        write!(f, "passed {passed}/{total} tests: ({panic} panics {parse} unparsed {compile} compile) {perc:.2}% avg diff")
    }
}
