diff --git a/Cargo.toml b/Cargo.toml index 64b853c..f637308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ description = "A flexible rule-based file and folder comparison tool and crate i repository = "https://github.com/VolumeGraphics/havocompare" homepage = "https://github.com/VolumeGraphics/havocompare" documentation = "https://docs.rs/havocompare" -version = "0.3.2" +version = "0.4.0" edition = "2021" license = "MIT" authors = ["Volume Graphics GmbH"] @@ -18,14 +18,14 @@ path = "src/print_args.rs" [dependencies] -clap = {version= "4.1", features=["derive"]} +clap = {version= "4.3", features=["derive"]} chrono = "0.4" serde = "1.0" serde_yaml = "0.9" schemars = "0.8" schemars_derive = "0.8" thiserror = "1.0" -regex = "1.6" +regex = "1.8" image = "0.24" image-compare = "0.3.0" tracing = "0.1" @@ -35,15 +35,15 @@ glob = "0.3" test-log = {version="0.2", features=["trace"]} strsim = "0.10" itertools = "0.11" -tera = "1.17" +tera = "1.19" sha2 = "0.10" -data-encoding = "2.3" +data-encoding = "2.4" permutation = "0.4" -pdf-extract = "0.6.4" +pdf-extract = "0.6" vg_errortools = "0.1" -rayon = "1.6" +rayon = "1.7.0" enable-ansi-support = "0.2" -tempfile = "3.3" +tempfile = "3.6" fs_extra = "1.3" opener = "0.6" anyhow = "1.0" diff --git a/README.md b/README.md index f6bdc13..a885702 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,10 @@ rules: ## Changelog +### 0.4.0 +- Separate reporting logic from comparison logic +- Implement a machine-readable JSON reporting + ### 0.3.2 - Allow direct opening of reports after comparison with `--open` - Parsing failures when running `compare` are now propagated to terminal diff --git a/src/csv/mod.rs b/src/csv/mod.rs index 7749ccf..9a4485d 100644 --- a/src/csv/mod.rs +++ b/src/csv/mod.rs @@ -32,9 +32,6 @@ pub enum Error { #[error("Failed to compile regex {0}")] /// Regex compilation failed RegexCompilationFailed(#[from] regex::Error), - #[error("Problem creating csv report {0}")] - /// Reporting could not be created - ReportingFailed(#[from] report::Error), #[error("File access failed {0}")] /// File access failed FileAccessFailed(#[from] FatIOError), @@ -59,32 +56,52 @@ pub enum Error { UnequalRowCount(usize, usize), } -#[derive(Clone, Copy, Debug)] -pub(crate) struct Position { +/// A position inside a table +#[derive(Clone, Copy, Debug, Serialize)] +pub struct Position { + /// row number, starting with zero pub row: usize, + /// column number, starting with zero pub col: usize, } -#[derive(Debug)] -pub(crate) enum DiffType { +#[derive(Debug, Serialize, Clone)] +/// Difference of a table entry +pub enum DiffType { + /// Both entries were strings, but had different contents UnequalStrings { + /// nominal string nominal: String, + /// actual string actual: String, + /// position position: Position, }, + /// Both entries were [`Quantity`]s but exceeded tolerances OutOfTolerance { + /// nominal nominal: Quantity, + /// actual actual: Quantity, + /// compare mode that was exceeded mode: Mode, + /// position in table position: Position, }, + /// both fields had different value types DifferentValueTypes { + /// nominal nominal: Value, + /// actual actual: Value, + /// position position: Position, }, + /// Both fields were headers but with different contents UnequalHeader { + /// nominal nominal: String, + /// actual actual: String, }, } @@ -209,7 +226,7 @@ impl Mode { } } -#[derive(JsonSchema, Deserialize, Serialize, Debug, Default)] +#[derive(JsonSchema, Deserialize, Serialize, Debug, Default, Clone)] /// Settings for the CSV comparison module pub struct CSVCompareConfig { #[serde(flatten)] @@ -489,23 +506,19 @@ pub(crate) fn compare_paths( nominal: impl AsRef, actual: impl AsRef, config: &CSVCompareConfig, -) -> Result { +) -> Result { let nominal_file = fat_io_wrap_std(nominal.as_ref(), &File::open)?; let actual_file = fat_io_wrap_std(actual.as_ref(), &File::open)?; - let (nominal_table, actual_table, results) = - get_diffs_readers(&nominal_file, &actual_file, config)?; + let (_, _, results) = get_diffs_readers(&nominal_file, &actual_file, config)?; results.iter().for_each(|error| { error!("{}", &error); }); - - Ok(report::write_csv_detail( - nominal_table, - actual_table, - nominal.as_ref(), - actual.as_ref(), - results.as_slice(), - )?) + let is_error = !results.is_empty(); + let mut result = report::Difference::new_for_file(nominal.as_ref(), actual.as_ref()); + result.is_error = is_error; + result.detail = results.into_iter().map(report::DiffDetail::CSV).collect(); + Ok(result) } #[cfg(test)] diff --git a/src/csv/preprocessing.rs b/src/csv/preprocessing.rs index 6a4ef5c..b54d7ff 100644 --- a/src/csv/preprocessing.rs +++ b/src/csv/preprocessing.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use std::cmp::Ordering::Equal; use tracing::{debug, warn}; -#[derive(JsonSchema, Deserialize, Serialize, Debug)] +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] /// Preprocessor options pub enum Preprocessor { /// Try to extract the headers from the first row - fallible if first row contains a number @@ -237,7 +237,7 @@ mod tests { File::open("tests/csv/data/defects_headers.csv").unwrap(), &delimiters, ) - .unwrap() + .unwrap() } #[test] diff --git a/src/csv/value.rs b/src/csv/value.rs index 1f4ddbc..e9be707 100644 --- a/src/csv/value.rs +++ b/src/csv/value.rs @@ -78,7 +78,7 @@ impl Display for Quantity { } } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Serialize)] pub enum Value { Quantity(Quantity), String(String), diff --git a/src/external.rs b/src/external.rs index 9f565e5..23bb2b7 100644 --- a/src/external.rs +++ b/src/external.rs @@ -1,11 +1,11 @@ -use crate::report::FileCompareResult; -use crate::{report, Error}; +use crate::report::{DiffDetail, Difference}; +use crate::Error; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::path::Path; use tracing::{error, info}; -#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] pub struct ExternalConfig { /// The executable to call - will be started like: `#executable #(#extra_params)* #nominal #actual` executable: String, @@ -17,46 +17,35 @@ pub(crate) fn compare_files>( nominal: P, actual: P, config: &ExternalConfig, -) -> Result { +) -> Result { + let mut diff = Difference::new_for_file(&nominal, &actual); let compared_file_name = nominal.as_ref().to_string_lossy().into_owned(); - let mut is_error = false; let output = std::process::Command::new(&config.executable) .args(&config.extra_params) .arg(nominal.as_ref()) .arg(actual.as_ref()) .output(); - let (stdout_string, stderr_string, error_message) = if let Ok(output) = output { + if let Ok(output) = output { let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); info!("External stdout: {}", stdout.as_str()); info!("External stderr: {}", stderr.as_str()); - let error_message = if !output.status.success() { + if !output.status.success() { let message = format!("External checker denied file {}", &compared_file_name); error!("{}", &message); - is_error = true; - message - } else { - "".to_owned() + diff.push_detail(DiffDetail::External { stdout, stderr }); + diff.error(); }; - - (stdout, stderr, error_message) } else { let error_message = format!( "External checker execution failed for file {}", &compared_file_name ); error!("{}", error_message); - is_error = true; - ("".to_owned(), "".to_owned(), error_message) + diff.push_detail(DiffDetail::Error(error_message)); + diff.error(); }; - Ok(report::write_external_detail( - nominal, - actual, - is_error, - &stdout_string, - &stderr_string, - &error_message, - )?) + Ok(diff) } #[cfg(test)] mod tests { diff --git a/src/hash.rs b/src/hash.rs index 28db8b1..c709205 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -1,6 +1,7 @@ use crate::{report, Deserialize, Serialize}; use data_encoding::HEXLOWER; +use crate::report::{DiffDetail, Difference}; use schemars_derive::JsonSchema; use std::fs::File; use std::io::Read; @@ -9,7 +10,7 @@ use thiserror::Error; use vg_errortools::fat_io_wrap_std; use vg_errortools::FatIOError; -#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy)] pub enum HashFunction { Sha256, } @@ -43,7 +44,7 @@ impl HashFunction { } } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] /// Configuration options for the hash comparison module pub struct HashConfig { /// Which hash function to use @@ -62,7 +63,7 @@ pub fn compare_files>( nominal_path: P, actual_path: P, config: &HashConfig, -) -> Result { +) -> Result { let act = config .function .hash_file(fat_io_wrap_std(actual_path.as_ref(), &File::open)?)?; @@ -70,21 +71,15 @@ pub fn compare_files>( .function .hash_file(fat_io_wrap_std(nominal_path.as_ref(), &File::open)?)?; - let diff = if act != nom { - vec![format!( - "Nominal file's hash is '{}' actual is '{}'", - HEXLOWER.encode(&nom), - HEXLOWER.encode(&act) - )] - } else { - vec![] - }; - - Ok(report::write_html_detail( - nominal_path, - actual_path, - diff.as_slice(), - )?) + let mut difference = Difference::new_for_file(nominal_path, actual_path); + if act != nom { + difference.push_detail(DiffDetail::Hash { + actual: HEXLOWER.encode(&act), + nominal: HEXLOWER.encode(&nom), + }); + difference.error(); + } + Ok(difference) } #[cfg(test)] diff --git a/src/html.rs b/src/html.rs index 935e464..5049f8c 100644 --- a/src/html.rs +++ b/src/html.rs @@ -1,4 +1,5 @@ use crate::report; +use crate::report::{DiffDetail, Difference}; use regex::Regex; use schemars_derive::JsonSchema; use serde::{Deserialize, Serialize}; @@ -11,7 +12,7 @@ use tracing::error; use vg_errortools::fat_io_wrap_std; use vg_errortools::FatIOError; -#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] /// Plain text comparison config, also used for PDF pub struct HTMLCompareConfig { /// Normalized Damerau-Levenshtein distance, 0.0 = bad, 1.0 = identity @@ -58,14 +59,12 @@ pub fn compare_files>( nominal_path: P, actual_path: P, config: &HTMLCompareConfig, -) -> Result { +) -> Result { let actual = BufReader::new(fat_io_wrap_std(actual_path.as_ref(), &File::open)?); let nominal = BufReader::new(fat_io_wrap_std(nominal_path.as_ref(), &File::open)?); - let mut diffs: Vec = Vec::new(); - let exclusion_list = config.get_ignore_list()?; - + let mut difference = Difference::new_for_file(nominal_path, actual_path); actual .lines() .enumerate() @@ -84,16 +83,12 @@ pub fn compare_files>( ); error!("{}" , &error); - - diffs.push(error); + difference.push_detail(DiffDetail::Text {actual: a, nominal: n, score: distance, line: l}); + difference.error(); } }); - Ok(report::write_html_detail( - nominal_path.as_ref(), - actual_path.as_ref(), - &diffs, - )?) + Ok(difference) } #[cfg(test)] @@ -121,8 +116,6 @@ mod test { let result = compare_files(actual, nominal, &HTMLCompareConfig::default()).unwrap(); assert!(result.is_error); - - assert!(result.detail_path.is_some()); } #[test] diff --git a/src/image.rs b/src/image.rs index 8fb76dd..8fa5671 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,3 +1,4 @@ +use crate::report::DiffDetail; use crate::{get_file_name, report}; use schemars_derive::JsonSchema; use serde::{Deserialize, Serialize}; @@ -5,7 +6,7 @@ use std::path::{Path, PathBuf}; use thiserror::Error; use tracing::error; -#[derive(JsonSchema, Deserialize, Serialize, Debug)] +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] /// Image comparison config options pub struct ImageCompareConfig { /// Threshold for image comparison < 0.5 is very dissimilar, 1.0 is identical @@ -41,8 +42,7 @@ pub fn compare_paths>( nominal_path: P, actual_path: P, config: &ImageCompareConfig, -) -> Result { - let mut diffs: Vec = Vec::new(); +) -> Result { let nominal = image::open(nominal_path.as_ref())?.into_rgba8(); let actual = image::open(actual_path.as_ref())?.into_rgba8(); @@ -53,6 +53,7 @@ pub fn compare_paths>( nominal_path.as_ref() )))?; let out_path = (nominal_file_name + "diff_image.png").to_string(); + let mut result_diff = report::Difference::new_for_file(&nominal_path, &actual_path); if result.score < config.threshold { let color_map = result.image.to_color_map(); @@ -64,23 +65,20 @@ pub fn compare_paths>( config.threshold, result.score ); - error!("{}", &error_message); - - diffs.push(error_message); - diffs.push(out_path); + result_diff.push_detail(DiffDetail::Image { + diff_image: out_path, + score: result.score, + }); + result_diff.error(); } - - Ok(report::write_image_detail( - nominal_path.as_ref(), - actual_path.as_ref(), - &diffs, - )?) + Ok(result_diff) } #[cfg(test)] mod test { use crate::image::{compare_paths, ImageCompareConfig}; + use crate::report::DiffDetail; #[test] fn identity() { @@ -102,21 +100,20 @@ mod test { ) .unwrap(); assert!(result.is_error); - assert!(result.detail_path.is_some()); - let img = image::open( - result - .detail_path + if let DiffDetail::Image { + score: _, + diff_image, + } = result.detail.first().unwrap() + { + let img = image::open(diff_image).unwrap().into_rgb8(); + let nom = image::open("tests/integ/data/images/diff_100_DPI.png") .unwrap() - .temp_path - .join("SaveImage_100DPI_default_size.jpgdiff_image.png"), - ) - .expect("Could not load generated diff image") - .into_rgb8(); - let nom = image::open("tests/integ/data/images/diff_100_DPI.png") - .unwrap() - .into_rgb8(); - let diff_result = image_compare::rgb_hybrid_compare(&img, &nom) - .expect("Wrong dimensions of diff images!"); - assert_eq!(diff_result.score, 1.0); + .into_rgb8(); + let diff_result = image_compare::rgb_hybrid_compare(&img, &nom) + .expect("Wrong dimensions of diff images!"); + assert_eq!(diff_result.score, 1.0); + } else { + unreachable!(); + } } } diff --git a/src/lib.rs b/src/lib.rs index 7a41f1c..da5f668 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,7 +27,7 @@ mod report; use crate::external::ExternalConfig; pub use crate::html::HTMLCompareConfig; use crate::properties::PropertiesConfig; -use crate::report::FileCompareResult; +use crate::report::{DiffDetail, Difference}; use schemars::schema_for; use schemars_derive::JsonSchema; use serde::{Deserialize, Serialize}; @@ -76,7 +76,7 @@ pub enum Error { DifferentNumberOfFiles(usize, usize), } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] #[allow(clippy::upper_case_acronyms)] /// Representing the comparison mode pub enum ComparisonMode { @@ -122,7 +122,7 @@ impl ConfigurationFile { } } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] /// Representing a single comparison rule pub struct Rule { /// The name of the rule - will be displayed in logs @@ -161,11 +161,7 @@ fn filter_exclude(paths: Vec, excludes: Vec) -> Vec { .collect() } -fn process_file( - nominal: impl AsRef, - actual: impl AsRef, - rule: &Rule, -) -> FileCompareResult { +fn process_file(nominal: impl AsRef, actual: impl AsRef, rule: &Rule) -> Difference { let file_name_nominal = nominal.as_ref().to_string_lossy(); let file_name_actual = actual.as_ref().to_string_lossy(); let _file_span = span!(tracing::Level::INFO, "Processing"); @@ -173,7 +169,7 @@ fn process_file( info!("File: {file_name_nominal} | {file_name_actual}"); - let compare_result: Result> = { + let compare_result: Result> = { match &rule.file_type { ComparisonMode::CSV(conf) => { csv::compare_paths(nominal.as_ref(), actual.as_ref(), conf).map_err(|e| e.into()) @@ -200,22 +196,25 @@ fn process_file( } } }; - - match compare_result { - Ok(result) => { - if result.is_error { - error!("Files didn't match"); - } else { - debug!("Files matched"); - } - - result - } + let compare_result = match compare_result { + Ok(r) => r, Err(e) => { - error!("Problem comparing the files"); - report::write_error_detail(nominal, actual, e) + let e = e.to_string(); + error!("Problem comparing the files {}", &e); + let mut d = Difference::new_for_file(nominal, actual); + d.error(); + d.push_detail(DiffDetail::Error(e)); + d } + }; + + if compare_result.is_error { + error!("Files didn't match"); + } else { + debug!("Files matched"); } + + compare_result } fn get_files( @@ -232,7 +231,7 @@ fn process_rule( nominal: impl AsRef, actual: impl AsRef, rule: &Rule, - compare_results: &mut Vec, + compare_results: &mut Vec, ) -> Result { let _file_span = span!(tracing::Level::INFO, "Rule"); let _file_span = _file_span.enter(); @@ -276,9 +275,7 @@ fn process_rule( .zip(actual_cleaned_paths.into_iter()) .for_each(|(n, a)| { let compare_result = process_file(n, a, rule); - all_okay &= !compare_result.is_error; - compare_results.push(compare_result); }); @@ -292,13 +289,13 @@ pub fn compare_folders_cfg( config_struct: ConfigurationFile, report_path: impl AsRef, ) -> Result { - let mut rule_results: Vec = Vec::new(); + let mut rule_results: Vec = Vec::new(); let results: Vec = config_struct .rules .into_iter() .map(|rule| { - let mut compare_results: Vec = Vec::new(); + let mut compare_results: Vec = Vec::new(); let okay = process_rule( nominal.as_ref(), actual.as_ref(), @@ -315,9 +312,9 @@ pub fn compare_folders_cfg( false } }; - rule_results.push(report::RuleResult { + rule_results.push(report::RuleDifferences { rule, - compare_results, + diffs: compare_results, }); result @@ -325,7 +322,7 @@ pub fn compare_folders_cfg( .collect(); let all_okay = results.iter().all(|result| *result); - report::create(&rule_results, report_path)?; + report::create_reports(&rule_results, &report_path)?; Ok(all_okay) } diff --git a/src/main.rs b/src/main.rs index 620d1bc..1df5139 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ -use std::path::Path; use anyhow::anyhow; use clap::Parser; use havocompare::{compare_folders, get_schema, validate_config}; +use std::path::Path; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; @@ -22,7 +22,7 @@ enum Commands { report_config: String, /// Open the report immediately after comparison #[arg(short, long)] - open: bool + open: bool, }, /// Export the JsonSchema for the config files @@ -71,11 +71,10 @@ fn main() -> Result<(), vg_errortools::MainError> { nominal, actual, report_config, - open + open, } => { let report_path = Path::new(report_config.as_str()); - let result = - compare_folders(nominal, actual, compare_config, report_path)?; + let result = compare_folders(nominal, actual, compare_config, report_path)?; if open { info!("Opening report"); opener::open(report_path.join("index.html")).expect("Could not open report!"); diff --git a/src/pdf.rs b/src/pdf.rs index bdb620f..1415635 100644 --- a/src/pdf.rs +++ b/src/pdf.rs @@ -1,5 +1,6 @@ use crate::html::HTMLCompareConfig; use crate::report; +use crate::report::{DiffDetail, Difference}; use pdf_extract::extract_text; use std::path::Path; use strsim::normalized_damerau_levenshtein; @@ -24,17 +25,15 @@ pub fn compare_files>( nominal_path: P, actual_path: P, config: &HTMLCompareConfig, -) -> Result { +) -> Result { info!("Extracting text from actual pdf"); let actual = extract_text(actual_path.as_ref())?; info!("Extracting text from nominal pdf"); let nominal = extract_text(nominal_path.as_ref())?; - let mut diffs: Vec<(usize, String)> = Vec::new(); - let exclusion_list = config.get_ignore_list()?; - + let mut difference = Difference::new_for_file(&nominal_path, &actual_path); actual .lines() .enumerate() @@ -52,18 +51,12 @@ pub fn compare_files>( ); error!("{}" , &error); - - diffs.push((l, error)); + difference.push_detail(DiffDetail::Text {actual:a.to_owned(), nominal:n.to_owned(), score: distance, line: l}); + difference.error(); } }); - Ok(report::write_pdf_detail( - nominal_path.as_ref(), - actual_path.as_ref(), - &nominal, - &actual, - &diffs, - )?) + Ok(difference) } #[cfg(test)] diff --git a/src/properties.rs b/src/properties.rs index 31f009c..4aabbe8 100644 --- a/src/properties.rs +++ b/src/properties.rs @@ -1,4 +1,4 @@ -use crate::report::{get_relative_path, AdditionalOverviewColumn, FileCompareResult}; +use crate::report::{get_relative_path, DiffDetail, Difference}; use crate::Error; use chrono::offset::Utc; use chrono::DateTime; @@ -11,7 +11,7 @@ use std::time::SystemTime; use tracing::error; /// the configuration struct for file property comparison -#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] pub struct PropertiesConfig { /// Compare the file size, difference must be smaller then given value file_size_tolerance_bytes: Option, @@ -23,44 +23,51 @@ pub struct PropertiesConfig { forbid_name_regex: Option, } +#[derive(Serialize, Debug, Clone)] +pub enum MetaDataPropertyDiff { + Size { nominal: u64, actual: u64 }, + IllegalName, + CreationDate { nominal: String, actual: String }, +} + fn regex_matches_any_path( nominal_path: &str, actual_path: &str, regex: &str, -) -> Result { - let mut result: AdditionalOverviewColumn = Default::default(); +) -> Result, Error> { let regex = Regex::new(regex)?; if regex.is_match(nominal_path) || regex.is_match(actual_path) { error!("One of the files ({nominal_path}, {actual_path}) matched the regex {regex}"); + let mut result = Difference::new_for_file(nominal_path, actual_path); + result.error(); + result.push_detail(DiffDetail::Properties(MetaDataPropertyDiff::IllegalName)); result.is_error = true; - return Ok(result); + return Ok(Some(result)); } - Ok(result) + Ok(None) } -fn file_size_out_of_tolerance( - nominal: &Path, - actual: &Path, - tolerance: u64, -) -> AdditionalOverviewColumn { - let mut result: AdditionalOverviewColumn = Default::default(); +fn file_size_out_of_tolerance(nominal: &Path, actual: &Path, tolerance: u64) -> Difference { + let mut result = Difference::new_for_file(nominal, actual); if let (Ok(nominal_meta), Ok(actual_meta)) = (nominal.metadata(), actual.metadata()) { let size_diff = (nominal_meta.len() as i128 - actual_meta.len() as i128).unsigned_abs() as u64; if size_diff > tolerance { error!("File size tolerance exceeded, diff is {size_diff}, tolerance was {tolerance}"); - result.is_error = true; + result.error(); } - - result.nominal_value = nominal_meta.len().to_string(); - result.actual_value = actual_meta.len().to_string(); - result.diff_value = size_diff.to_string(); + result.push_detail(DiffDetail::Properties(MetaDataPropertyDiff::Size { + nominal: nominal_meta.len(), + actual: actual_meta.len(), + })); } else { - error!( + let msg = format!( "Could not get file metadata for either: {} or {}", &nominal.to_string_lossy(), &actual.to_string_lossy() ); + error!("{}", &msg); + result.push_detail(DiffDetail::Error(msg)); result.is_error = true; } result @@ -70,16 +77,18 @@ fn file_modification_time_out_of_tolerance( nominal: &Path, actual: &Path, tolerance: u64, -) -> AdditionalOverviewColumn { - let mut result: AdditionalOverviewColumn = Default::default(); +) -> Difference { + let mut result = Difference::new_for_file(nominal, actual); if let (Ok(nominal_meta), Ok(actual_meta)) = (nominal.metadata(), actual.metadata()) { if let (Ok(mod_time_act), Ok(mod_time_nom)) = (nominal_meta.modified(), actual_meta.modified()) { let nominal_datetime: DateTime = mod_time_nom.into(); let actual_datetime: DateTime = mod_time_act.into(); - result.nominal_value = nominal_datetime.format("%Y-%m-%d %T").to_string(); - result.actual_value = actual_datetime.format("%Y-%m-%d %T").to_string(); + result.push_detail(DiffDetail::Properties(MetaDataPropertyDiff::CreationDate { + nominal: nominal_datetime.format("%Y-%m-%d %T").to_string(), + actual: actual_datetime.format("%Y-%m-%d %T").to_string(), + })); let now = SystemTime::now(); @@ -93,22 +102,28 @@ fn file_modification_time_out_of_tolerance( error!("Modification times too far off difference in timestamps {time_diff} s - tolerance {tolerance} s"); result.is_error = true; } - - result.diff_value = time_diff.to_string(); } else { - error!("Could not calculate duration between modification timestamps"); + let msg = + "Could not calculate duration between modification timestamps".to_string(); + error!("{}", &msg); + result.push_detail(DiffDetail::Error(msg)); result.is_error = true; } } else { - error!("Could not read file modification timestamps"); + let msg = "Could not read file modification timestamps".to_string(); + error!("{}", &msg); + result.push_detail(DiffDetail::Error(msg)); result.is_error = true; } } else { - error!( + let msg = format!( "Could not get file metadata for either: {} or {}", &nominal.to_string_lossy(), &actual.to_string_lossy() ); + error!("{}", &msg); + result.push_detail(DiffDetail::Error(msg)); + result.is_error = true; } result @@ -118,51 +133,34 @@ pub(crate) fn compare_files>( nominal: P, actual: P, config: &PropertiesConfig, -) -> Result { +) -> Result { let nominal = nominal.as_ref(); let actual = actual.as_ref(); - let mut is_error = false; let compared_file_name_full = nominal.to_string_lossy(); let actual_file_name_full = actual.to_string_lossy(); - let compared_file_name = get_relative_path(actual, nominal) + get_relative_path(actual, nominal) .to_string_lossy() .to_string(); - let mut additional_columns: Vec = Vec::new(); - - let result: AdditionalOverviewColumn = - if let Some(name_regex) = config.forbid_name_regex.as_deref() { - regex_matches_any_path(&compared_file_name_full, &actual_file_name_full, name_regex)? - } else { - Default::default() - }; - is_error |= result.is_error; - additional_columns.push(result); - - let result: AdditionalOverviewColumn = if let Some(tolerance) = config.file_size_tolerance_bytes - { - file_size_out_of_tolerance(nominal, actual, tolerance) + let mut total_diff = Difference::new_for_file(nominal, actual); + let result = if let Some(name_regex) = config.forbid_name_regex.as_deref() { + regex_matches_any_path(&compared_file_name_full, &actual_file_name_full, name_regex)? } else { - Default::default() + None }; - is_error |= result.is_error; - additional_columns.push(result); + result.map(|r| total_diff.join(r)); - let result: AdditionalOverviewColumn = - if let Some(tolerance) = config.modification_date_tolerance_secs { - file_modification_time_out_of_tolerance(nominal, actual, tolerance) - } else { - Default::default() - }; - is_error |= result.is_error; - additional_columns.push(result); - - Ok(FileCompareResult { - compared_file_name, - is_error, - detail_path: None, - additional_columns, - }) + let result = config + .file_size_tolerance_bytes + .map(|tolerance| file_size_out_of_tolerance(nominal, actual, tolerance)); + result.map(|r| total_diff.join(r)); + + let result = config + .modification_date_tolerance_secs + .map(|tolerance| file_modification_time_out_of_tolerance(nominal, actual, tolerance)); + result.map(|r| total_diff.join(r)); + + Ok(total_diff) } #[cfg(test)] @@ -177,13 +175,14 @@ mod tests { let regex_no_spaces = r"[\s]"; assert!( regex_matches_any_path(file_name_mock, file_name_cap_mock, regex_no_capitals) + .unwrap() .unwrap() .is_error ); assert!( - !regex_matches_any_path(file_name_mock, file_name_cap_mock, regex_no_spaces) + regex_matches_any_path(file_name_mock, file_name_cap_mock, regex_no_spaces) .unwrap() - .is_error + .is_none() ); } diff --git a/src/report/mod.rs b/src/report/mod.rs index 1876b03..5a2af8a 100644 --- a/src/report/mod.rs +++ b/src/report/mod.rs @@ -1,6 +1,9 @@ mod template; use crate::csv::{DiffType, Position, Table}; +use crate::properties::MetaDataPropertyDiff; +use crate::{CSVCompareConfig, ComparisonMode, Rule}; +use pdf_extract::extract_text; use serde::Serialize; use std::borrow::Cow; use std::ffi::OsStr; @@ -27,34 +30,25 @@ pub enum Error { IOIssue(#[from] std::io::Error), #[error("fs_extra crate error {0}")] FsExtraFailed(#[from] fs_extra::error::Error), + #[error("JSON serialization failed {0}")] + Serde(#[from] serde_json::Error), + #[error("CSV failed {0}")] + Csv(#[from] crate::csv::Error), + #[error("PDF Extract failed {0}")] + PdfExtract(#[from] pdf_extract::OutputError), } -#[derive(Serialize, Debug)] -pub struct FileCompareResult { - pub compared_file_name: String, - pub is_error: bool, - pub detail_path: Option, - pub additional_columns: Vec, -} - -#[derive(Serialize, Debug, Default)] +#[derive(Serialize, Debug, Default, Clone)] pub struct AdditionalOverviewColumn { pub nominal_value: String, pub actual_value: String, pub is_error: bool, - pub diff_value: String, } -#[derive(Serialize, Debug)] +#[derive(Serialize, Debug, Clone)] pub struct DetailPath { - pub temp_path: PathBuf, - pub path_name: String, -} - -#[derive(Serialize, Debug)] -pub(crate) struct RuleResult { - pub rule: crate::Rule, - pub compare_results: Vec, + pub path: PathBuf, + pub name: String, } #[derive(Serialize, Debug, Clone)] @@ -67,14 +61,99 @@ pub struct CSVReportColumn { #[derive(Serialize, Debug, Clone)] pub struct CSVReportRow { pub columns: Vec, - pub has_diff: bool, - pub has_error: bool, + pub has_diff: bool, //tolerable error + pub has_error: bool, //intolerable error +} + +#[derive(Serialize, Debug, Clone)] +pub struct RuleDifferences { + pub rule: Rule, + pub diffs: Vec, +} + +#[derive(Serialize, Debug, Clone)] +pub struct RenderToHtmlRuleDifferences { + pub rule: Rule, + pub diffs: Vec, +} + +#[derive(Serialize, Debug, Clone, Default)] +pub struct Difference { + pub nominal_file: PathBuf, + pub actual_file: PathBuf, + pub relative_file_path: String, + pub is_error: bool, + pub detail: Vec, +} + +#[derive(Serialize, Debug, Clone, Default)] +pub struct RenderToHtmlDifference { + #[serde(flatten)] + pub diff: Difference, + pub detail_path: Option, + pub additional_columns: Vec, +} + +impl Difference { + pub fn new_for_file(nominal: impl AsRef, actual: impl AsRef) -> Self { + Self { + relative_file_path: get_relative_path(actual.as_ref(), nominal.as_ref()) + .to_string_lossy() + .to_string(), + nominal_file: nominal.as_ref().to_path_buf(), + actual_file: actual.as_ref().to_path_buf(), + ..Default::default() + } + } + + pub fn error(&mut self) { + self.is_error = true; + } + + pub fn push_detail(&mut self, detail: DiffDetail) { + self.detail.push(detail); + } + + pub fn join(&mut self, other: Self) -> bool { + if self.nominal_file != other.nominal_file { + return false; + } + self.is_error |= other.is_error; + self.detail.extend(other.detail.into_iter()); + true + } +} + +#[derive(Serialize, Debug, Clone)] +#[allow(clippy::upper_case_acronyms)] +pub enum DiffDetail { + CSV(DiffType), + Image { + score: f64, + diff_image: String, + }, + Text { + actual: String, + nominal: String, + line: usize, + score: f64, + }, + Hash { + actual: String, + nominal: String, + }, + External { + stdout: String, + stderr: String, + }, + Properties(MetaDataPropertyDiff), + Error(String), } -pub fn create_sub_folder() -> Result { +pub fn create_detail_folder(report_dir: impl AsRef) -> Result { let temp_path = tempfile::Builder::new() .prefix("havocompare-") - .tempdir()? + .tempdir_in(report_dir.as_ref())? .into_path(); let path_name = temp_path @@ -89,8 +168,8 @@ pub fn create_sub_folder() -> Result { .to_string(); Ok(DetailPath { - temp_path, - path_name, + path: temp_path, + name: path_name, }) } @@ -98,23 +177,15 @@ pub fn write_html_detail( nominal: impl AsRef, actual: impl AsRef, diffs: &[String], -) -> Result { - let mut result = FileCompareResult { - compared_file_name: get_relative_path(actual.as_ref(), nominal.as_ref()) - .to_string_lossy() - .to_string(), - is_error: false, - detail_path: None, - additional_columns: vec![], - }; - + report_dir: impl AsRef, +) -> Result, Error> { if diffs.is_empty() { - return Ok(result); + return Ok(None); } - let sub_folder = create_sub_folder()?; + let detail_path = create_detail_folder(report_dir.as_ref())?; - let detail_file = sub_folder.temp_path.join(template::DETAIL_FILENAME); + let detail_file = detail_path.path.join(template::DETAIL_FILENAME); let mut tera = Tera::default(); tera.add_raw_template( @@ -134,34 +205,32 @@ pub fn write_html_detail( tera.render_to(&detail_file.to_string_lossy(), &ctx, file)?; - result.is_error = true; - result.detail_path = Some(sub_folder); - - Ok(result) + Ok(Some(detail_path)) } pub(crate) fn write_csv_detail( - nominal_table: Table, - actual_table: Table, nominal: impl AsRef, actual: impl AsRef, - diffs: &[DiffType], -) -> Result { - let mut result = FileCompareResult { - compared_file_name: get_relative_path(actual.as_ref(), nominal.as_ref()) - .to_string_lossy() - .to_string(), - is_error: false, - detail_path: None, - additional_columns: vec![], - }; - + diffs: &[&DiffType], + config: &CSVCompareConfig, + report_dir: impl AsRef, +) -> Result, Error> { let mut headers: CSVReportRow = CSVReportRow { columns: vec![], has_diff: false, has_error: false, }; + let mut nominal_table = Table::from_reader(File::open(nominal.as_ref())?, &config.delimiters)?; + let mut actual_table = Table::from_reader(File::open(actual.as_ref())?, &config.delimiters)?; + + if let Some(preprocessors) = &config.preprocessing { + for preprocessor in preprocessors.iter() { + preprocessor.process(&mut nominal_table)?; + preprocessor.process(&mut actual_table)?; + } + } + nominal_table .columns .iter() @@ -250,9 +319,9 @@ pub(crate) fn write_csv_detail( }) .collect(); - let sub_folder = create_sub_folder()?; + let detail_path = create_detail_folder(report_dir)?; - let detail_file = sub_folder.temp_path.join(template::DETAIL_FILENAME); + let detail_file = detail_path.path.join(template::DETAIL_FILENAME); let mut tera = Tera::default(); tera.add_raw_template( @@ -271,33 +340,22 @@ pub(crate) fn write_csv_detail( tera.render_to(&detail_file.to_string_lossy(), &ctx, file)?; - result.is_error = !diffs.is_empty(); - result.detail_path = Some(sub_folder); - - Ok(result) + Ok(Some(detail_path)) } pub fn write_image_detail( nominal: impl AsRef, actual: impl AsRef, - diffs: &[String], -) -> Result { - let mut result = FileCompareResult { - compared_file_name: get_relative_path(actual.as_ref(), nominal.as_ref()) - .to_string_lossy() - .to_string(), - is_error: false, - detail_path: None, - additional_columns: vec![], - }; - + diffs: &[(&f64, &String)], + report_dir: impl AsRef, +) -> Result, Error> { if diffs.is_empty() { - return Ok(result); + return Ok(None); } - let sub_folder = create_sub_folder()?; + let detail_path = create_detail_folder(report_dir.as_ref())?; - let detail_file = sub_folder.temp_path.join(template::DETAIL_FILENAME); + let detail_file = detail_path.path.join(template::DETAIL_FILENAME); let mut tera = Tera::default(); tera.add_raw_template( @@ -323,17 +381,17 @@ pub fn write_image_detail( let actual_image = format!("actual_image_{}", get_file_name(actual.as_ref())?); let nominal_image = format!("nominal_image_.{}", get_file_name(nominal.as_ref())?); - fs::copy(actual.as_ref(), sub_folder.temp_path.join(&actual_image)) + fs::copy(actual.as_ref(), detail_path.path.join(&actual_image)) .map_err(|e| FatIOError::from_std_io_err(e, actual.as_ref().to_path_buf()))?; - fs::copy(nominal.as_ref(), sub_folder.temp_path.join(&nominal_image)) + fs::copy(nominal.as_ref(), detail_path.path.join(&nominal_image)) .map_err(|e| FatIOError::from_std_io_err(e, nominal.as_ref().to_path_buf()))?; - let diff_image = &diffs[1]; - let img_target = sub_folder.temp_path.join(diff_image); + let (score, diff_image) = diffs[0]; + let img_target = detail_path.path.join(diff_image); fs::copy(diff_image, &img_target) .map_err(|e| FatIOError::from_std_io_err(e, img_target.to_path_buf()))?; - ctx.insert("error", &diffs[0]); + ctx.insert("error", &format!("Score {score}")); ctx.insert("diff_image", diff_image); ctx.insert("actual_image", &actual_image); ctx.insert("nominal_image", &nominal_image); @@ -343,43 +401,33 @@ pub fn write_image_detail( tera.render_to(&detail_file.to_string_lossy(), &ctx, file)?; - result.is_error = true; - result.detail_path = Some(sub_folder); - - Ok(result) + Ok(Some(detail_path)) } pub fn write_pdf_detail( nominal: impl AsRef, actual: impl AsRef, - nominal_string: &String, - actual_string: &String, - diffs: &[(usize, String)], -) -> Result { - let mut result = FileCompareResult { - compared_file_name: get_relative_path(actual.as_ref(), nominal.as_ref()) - .to_string_lossy() - .to_string(), - is_error: false, - detail_path: None, - additional_columns: vec![], - }; + diffs: &[(&usize, String)], + report_dir: impl AsRef, +) -> Result, Error> { + let detail_path = create_detail_folder(report_dir.as_ref())?; - let sub_folder = create_sub_folder()?; + let nominal_string = extract_text(nominal.as_ref())?; + let actual_string = extract_text(actual.as_ref())?; let nominal_extracted_filename = "nominal_extracted_text.txt"; let actual_extracted_filename = "actual_extracted_text.txt"; - let nominal_extracted_file = sub_folder.temp_path.join(nominal_extracted_filename); + let nominal_extracted_file = detail_path.path.join(nominal_extracted_filename); fs::write(&nominal_extracted_file, nominal_string.as_bytes()) .map_err(|e| FatIOError::from_std_io_err(e, nominal_extracted_file))?; - let actual_extracted_file = sub_folder.temp_path.join(actual_extracted_filename); + let actual_extracted_file = detail_path.path.join(actual_extracted_filename); fs::write(&actual_extracted_file, actual_string.as_bytes()) .map_err(|e| FatIOError::from_std_io_err(e, actual_extracted_file))?; info!("Extracted text written to files"); - let detail_file = sub_folder.temp_path.join(template::DETAIL_FILENAME); + let detail_file = detail_path.path.join(template::DETAIL_FILENAME); let mut tera = Tera::default(); tera.add_raw_template( @@ -398,7 +446,7 @@ pub fn write_pdf_detail( diffs: vec![], }; - if let Some(diff) = diffs.iter().find(|(i, _msg)| *i == l) { + if let Some(diff) = diffs.iter().find(|(i, _msg)| **i == l) { result.diffs.push(diff.1.clone()); }; @@ -409,7 +457,6 @@ pub fn write_pdf_detail( let mut ctx = Context::new(); ctx.insert("actual", &actual.as_ref().to_string_lossy()); ctx.insert("nominal", &nominal.as_ref().to_string_lossy()); - ctx.insert("diffs", &diffs); ctx.insert("combined_lines", &combined_lines); ctx.insert("nominal_extracted_filename", nominal_extracted_filename); ctx.insert("actual_extracted_filename", actual_extracted_filename); @@ -420,31 +467,18 @@ pub fn write_pdf_detail( tera.render_to(&detail_file.to_string_lossy(), &ctx, file)?; - result.is_error = !diffs.is_empty(); - result.detail_path = Some(sub_folder); - - Ok(result) + Ok(Some(detail_path)) } pub fn write_external_detail( nominal: impl AsRef, actual: impl AsRef, - is_error: bool, stdout: &str, stderr: &str, - message: &str, -) -> Result { - let mut result = FileCompareResult { - compared_file_name: get_relative_path(actual.as_ref(), nominal.as_ref()) - .to_string_lossy() - .to_string(), - is_error, - detail_path: None, - additional_columns: vec![], - }; - - let sub_folder = create_sub_folder()?; - let detail_file = sub_folder.temp_path.join(template::DETAIL_FILENAME); + report_dir: impl AsRef, +) -> Result, Error> { + let detail_path = create_detail_folder(report_dir.as_ref())?; + let detail_file = detail_path.path.join(template::DETAIL_FILENAME); let mut tera = Tera::default(); tera.add_raw_template( @@ -457,25 +491,23 @@ pub fn write_external_detail( ctx.insert("nominal", &nominal.as_ref().to_string_lossy()); ctx.insert("stdout", stdout); ctx.insert("stderr", stderr); - ctx.insert("message", message); let file = fat_io_wrap_std(&detail_file, &File::create)?; debug!("detail html {:?} created", &detail_file); tera.render_to(&detail_file.to_string_lossy(), &ctx, file)?; - result.detail_path = Some(sub_folder); - - Ok(result) + Ok(Some(detail_path)) } fn create_error_detail( nominal: impl AsRef, actual: impl AsRef, - error: Box, + errors: &[&String], + report_dir: impl AsRef, ) -> Result { - let sub_folder = create_sub_folder()?; - let detail_file = sub_folder.temp_path.join(template::DETAIL_FILENAME); + let sub_folder = create_detail_folder(report_dir.as_ref())?; + let detail_file = sub_folder.path.join(template::DETAIL_FILENAME); let mut tera = Tera::default(); tera.add_raw_template( @@ -486,7 +518,7 @@ fn create_error_detail( let mut ctx = Context::new(); ctx.insert("actual", &actual.as_ref().to_string_lossy()); ctx.insert("nominal", &nominal.as_ref().to_string_lossy()); - ctx.insert("error", &error.to_string()); + ctx.insert("errors", errors); let file = fat_io_wrap_std(&detail_file, &File::create)?; @@ -498,28 +530,18 @@ fn create_error_detail( pub fn write_error_detail( nominal: impl AsRef, actual: impl AsRef, - error: Box, -) -> FileCompareResult { - let mut result = FileCompareResult { - compared_file_name: get_relative_path(actual.as_ref(), nominal.as_ref()) - .to_string_lossy() - .to_string(), - is_error: true, - detail_path: None, - additional_columns: vec![], - }; - - if let Ok(sub_folder) = create_error_detail(nominal, actual, error) { - result.detail_path = Some(sub_folder); + errors: &[&String], + report_dir: impl AsRef, +) -> Option { + if let Ok(sub_folder) = create_error_detail(nominal, actual, errors, report_dir) { + Some(sub_folder) } else { - error!("Could not create error detail"); + None } - - result } -pub(crate) fn create( - rule_results: &[RuleResult], +pub(crate) fn create_reports( + rule_differences: &[RuleDifferences], report_path: impl AsRef, ) -> Result<(), Error> { let _reporting_span = span!(tracing::Level::INFO, "Reporting"); @@ -529,34 +551,291 @@ pub(crate) fn create( info!("Delete report folder"); fat_io_wrap_std(&report_dir, &fs::remove_dir_all)?; } - info!("create report folder"); fat_io_wrap_std(&report_dir, &fs::create_dir)?; - //move folders - for rule_result in rule_results.iter() { - let sub_folder = report_dir.join(&rule_result.rule.name); + create_json(rule_differences, &report_path)?; + create_html(rule_differences, &report_path)?; + + Ok(()) +} + +pub(crate) fn create_json( + rule_differences: &[RuleDifferences], + report_path: impl AsRef, +) -> Result<(), Error> { + let _reporting_span = span!(tracing::Level::INFO, "JSON"); + let _reporting_span = _reporting_span.enter(); + let report_dir = report_path.as_ref(); + let writer = report_dir.join("report.json"); + let writer = fat_io_wrap_std(writer, &File::create)?; + serde_json::to_writer_pretty(writer, &rule_differences)?; + Ok(()) +} + +pub(crate) fn create_html( + rule_differences: &[RuleDifferences], + report_path: impl AsRef, +) -> Result<(), Error> { + let _reporting_span = span!(tracing::Level::INFO, "HTML"); + let _reporting_span = _reporting_span.enter(); + let report_dir = report_path.as_ref(); + + let mut html_rule_differences: Vec = Vec::new(); + for rule_difference in rule_differences.iter() { + let sub_folder = report_dir.join(&rule_difference.rule.name); debug!("Create subfolder {:?}", &sub_folder); fat_io_wrap_std(&sub_folder, &fs::create_dir)?; - for file_result in rule_result.compare_results.iter() { - if let Some(detail_path) = &file_result.detail_path { - debug!( - "moving subfolder {:?} to {:?}", - &detail_path.temp_path, &sub_folder - ); - - let options = fs_extra::dir::CopyOptions::new(); - fs_extra::dir::copy(&detail_path.temp_path, &sub_folder, &options)?; - } - } + + let render_diffs: Vec<_> = rule_difference + .diffs + .iter() + .map(|file| { + let errors: Vec<&String> = file + .detail + .iter() + .filter_map(|r| match r { + DiffDetail::Error(s) => Some(s), + _ => None, + }) + .collect(); + + if !errors.is_empty() { + return RenderToHtmlDifference { + diff: file.clone(), + detail_path: write_error_detail( + &file.nominal_file, + &file.actual_file, + &errors, + &sub_folder, + ), + additional_columns: Vec::new(), + }; + } + + let detail_path = match &rule_difference.rule.file_type { + ComparisonMode::CSV(config) => { + let diffs: Vec<&DiffType> = file + .detail + .iter() + .filter_map(|r| match r { + DiffDetail::CSV(d) => Some(d), + _ => None, + }) + .collect(); + + write_csv_detail( + &file.nominal_file, + &file.actual_file, + &diffs, + &config, + &sub_folder, + ) + .unwrap_or_else(|e| log_detail_html_creation_error(&e)) + } + ComparisonMode::PlainText(_) => { + let diffs: Vec = file + .detail + .iter() + .filter_map(|r| match r { + DiffDetail::Text { + line, + score, + actual, + nominal, + } => Some(format!( + "Mismatch in line {}. Expected: '{}' found '{}' (diff: {})", + line, nominal, actual, score + )), + _ => None, + }) + .collect(); + + write_html_detail( + &file.nominal_file, + &file.actual_file, + &diffs, + &sub_folder, + ) + .unwrap_or_else(|e| log_detail_html_creation_error(&e)) + } + ComparisonMode::PDFText(_) => { + let diffs: Vec<(&usize, String)> = + file.detail + .iter() + .filter_map(|r| match r { + DiffDetail::Text { + line, + score, + actual, + nominal, + } => Some(( + line, + format!( + "Mismatch in line {}. Expected: '{}' found '{}' (diff: {})", + line + 1, nominal, actual, score + ), + )), + _ => None, + }) + .collect(); + + write_pdf_detail(&file.nominal_file, &file.actual_file, &diffs, &sub_folder) + .unwrap_or_else(|e| log_detail_html_creation_error(&e)) + } + ComparisonMode::Image(_) => { + let diffs: Vec<(&f64, &String)> = file + .detail + .iter() + .filter_map(|r| match r { + DiffDetail::Image { score, diff_image } => { + Some((score, diff_image)) + } + _ => None, + }) + .collect(); + + write_image_detail( + &file.nominal_file, + &file.actual_file, + &diffs, //should actually only 1 image per file compare + &sub_folder, + ) + .unwrap_or_else(|e| log_detail_html_creation_error(&e)) + } + ComparisonMode::External(_) => { + if let Some((stdout, stderr)) = file + .detail + .iter() + .filter_map(|r| match r { + DiffDetail::External { stdout, stderr } => Some((stdout, stderr)), + _ => None, + }) + .next() + { + write_external_detail( + &file.nominal_file, + &file.actual_file, + stdout, + stderr, + &sub_folder, + ) + .unwrap_or_else(|e| log_detail_html_creation_error(&e)) + } else { + None + } + } + ComparisonMode::FileProperties(_) => None, //we need only additional columns in the index.html + ComparisonMode::Hash(_) => { + let diffs: Vec = file + .detail + .iter() + .filter_map(|r| match r { + DiffDetail::Hash { actual, nominal } => Some(format!( + "Nominal file's hash is '{}' actual is '{}'", + nominal, actual + )), + _ => None, + }) + .collect(); + + write_html_detail( + &file.nominal_file, + &file.actual_file, + &diffs, + &sub_folder, + ) + .unwrap_or_else(|e| log_detail_html_creation_error(&e)) + } + }; + + let additional_columns: Vec = + match &rule_difference.rule.file_type { + ComparisonMode::FileProperties(_) => { + let mut additional_columns: Vec = Vec::new(); + + let diffs: Vec<&MetaDataPropertyDiff> = file + .detail + .iter() + .filter_map(|r| match r { + DiffDetail::Properties(diff) => Some(diff), + _ => None, + }) + .collect(); + + let result: AdditionalOverviewColumn = if diffs + .iter() + .any(|d| matches!(d, MetaDataPropertyDiff::IllegalName)) + { + AdditionalOverviewColumn { + nominal_value: file.nominal_file.to_string_lossy().to_string(), + actual_value: file.actual_file.to_string_lossy().to_string(), + is_error: true, + } + } else { + Default::default() + }; + additional_columns.push(result); + + let result: AdditionalOverviewColumn = + if let Some(MetaDataPropertyDiff::Size { nominal, actual }) = diffs + .iter() + .find(|d| matches!(d, MetaDataPropertyDiff::Size { .. })) + { + AdditionalOverviewColumn { + nominal_value: format!("{nominal}"), + actual_value: format!("{actual}"), + is_error: true, + } + } else { + Default::default() + }; + additional_columns.push(result); + + let result: AdditionalOverviewColumn = + if let Some(MetaDataPropertyDiff::CreationDate { + nominal, + actual, + }) = diffs.iter().find(|d| { + matches!(d, MetaDataPropertyDiff::CreationDate { .. }) + }) { + AdditionalOverviewColumn { + nominal_value: nominal.clone(), + actual_value: actual.clone(), + is_error: true, + } + } else { + Default::default() + }; + additional_columns.push(result); + + additional_columns + } + _ => Vec::new(), + }; + + RenderToHtmlDifference { + diff: file.clone(), + detail_path, + additional_columns, + } + }) + .collect(); + + html_rule_differences.push(RenderToHtmlRuleDifferences { + rule: rule_difference.rule.clone(), + diffs: render_diffs, + }); } - write_index(report_dir, rule_results) + write_index(report_dir, &html_rule_differences)?; + + Ok(()) } pub(crate) fn write_index( report_dir: impl AsRef, - rule_results: &[RuleResult], + rule_results: &[RenderToHtmlRuleDifferences], ) -> Result<(), Error> { let index_file = report_dir.as_ref().join(template::INDEX_FILENAME); @@ -602,6 +881,11 @@ pub(crate) fn get_relative_path( PathBuf::from_iter(paths) } +fn log_detail_html_creation_error(e: &Error) -> Option { + error!("Could not create HTML-Detail: {}", e.to_string()); + None +} + #[cfg(test)] mod tests { use super::*; @@ -647,8 +931,9 @@ mod tests { #[test] fn test_create_sub_folder() { - let sub_folder = create_sub_folder().unwrap(); - assert!(sub_folder.temp_path.is_dir()); - assert!(!sub_folder.path_name.is_empty()); + let report_dir = tempfile::tempdir().unwrap(); + let sub_folder = create_detail_folder(&report_dir).unwrap(); + assert!(sub_folder.path.is_dir()); + assert!(!sub_folder.name.is_empty()); } } diff --git a/src/report/template.rs b/src/report/template.rs index 81f6889..4ea1c98 100644 --- a/src/report/template.rs +++ b/src/report/template.rs @@ -73,11 +73,11 @@ pub const INDEX_TEMPLATE: &str = r###" {% endif %} - {% for file in rule_report.compare_results %} + {% for file in rule_report.diffs %} {% if rule_report.rule.FileProperties %} - {{ file.compared_file_name }} + {{ file.relative_file_path }} {{ file.additional_columns.1.nominal_value }} @@ -95,9 +95,9 @@ pub const INDEX_TEMPLATE: &str = r###" {% else %} {% if file.detail_path %} - {{ file.compared_file_name }} + {{ file.relative_file_path }} {% else %} - {{ file.compared_file_name }} + {{ file.relative_file_path }} {% endif %} {% if file.is_error %} {% else %} {% endif %} @@ -529,9 +529,26 @@ pub const ERROR_DETAIL_TEMPLATE: &str = r###" - Error - @@ -552,10 +569,34 @@ pub const ERROR_DETAIL_TEMPLATE: &str = r###"

-

- {{ error }} -

+ + + + + + + + {% for error in errors %} + + + + {% endfor %} + +
Error
{{ error }}
+ + + @@ -619,10 +660,6 @@ pub const PLAIN_EXTERNAL_DETAIL_TEMPLATE: &str = r###"

Compare Result of {{ actual }} and {{ nominal }}

-

-{{ message }} -

-