• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolumeGraphics / havocompare / 9379681508

05 Jun 2024 07:04AM UTC coverage: 83.973%. Remained the same
9379681508

push

github

web-flow
Merge pull request #53 from ChrisRega/main

Update json-diff to 0.6 seriesx

14 of 15 new or added lines in 1 file covered. (93.33%)

1 existing line in 1 file now uncovered.

2908 of 3463 relevant lines covered (83.97%)

2683.4 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.33
/src/lib.rs
1
#![crate_name = "havocompare"]
2
//! # Comparing folders and files by rules
3
//! Havocompare allows to compare folders (or to be more exact: the files inside the folders) following user definable rules.
4
//! A self contained html report is generated. To use it without the CLI, the main method is: [`compare_folders`].
5
//!
6
#![warn(missing_docs)]
7
#![warn(unused_qualifications)]
8
#![deny(deprecated)]
9
#![deny(clippy::unwrap_used)]
10
#![deny(clippy::expect_used)]
11

12
use std::borrow::Cow;
13
use std::fs::File;
14
use std::io::{BufReader, Read};
15
use std::path::{Path, PathBuf};
16

17
use schemars::schema_for;
18
use schemars_derive::JsonSchema;
19
use serde::{Deserialize, Serialize};
20
use thiserror::Error;
21
use tracing::{debug, error, info, span};
22
use vg_errortools::{fat_io_wrap_std, FatIOError};
23

24
pub use csv::CSVCompareConfig;
25
pub use hash::HashConfig;
26

27
use crate::external::ExternalConfig;
28
pub use crate::html::HTMLCompareConfig;
29
pub use crate::image::ImageCompareConfig;
30
pub use crate::json::JsonConfig;
31
use crate::properties::PropertiesConfig;
32
use crate::report::{DiffDetail, Difference};
33

34
/// comparison module for csv comparison
35
pub mod csv;
36

37
mod external;
38
mod hash;
39
mod html;
40
mod image;
41
mod pdf;
42
mod properties;
43
mod report;
44

45
mod json;
46

UNCOV
47
#[derive(Error, Debug)]
×
48
/// Top-Level Error class for all errors that can happen during havocompare-running
49
pub enum Error {
50
    /// Pattern used for globbing was invalid
51
    #[error("Failed to evaluate globbing pattern! {0}")]
52
    IllegalGlobbingPattern(#[from] glob::PatternError),
53
    /// Regex pattern requested could not be compiled
54
    #[error("Failed to compile regex! {0}")]
55
    RegexCompilationError(#[from] regex::Error),
56
    /// An error occurred in the csv rule checker
57
    #[error("CSV module error")]
58
    CSVModuleError(#[from] csv::Error),
59
    /// An error occurred in the image rule checker
60
    #[error("CSV module error")]
61
    ImageModuleError(#[from] image::Error),
62

63
    /// An error occurred in the reporting module
64
    #[error("Error occurred during report creation {0}")]
65
    ReportingError(#[from] report::Error),
66
    /// An error occurred during reading yaml
67
    #[error("Serde error, loading a yaml: {0}")]
68
    SerdeYamlFail(#[from] serde_yaml::Error),
69
    /// An error occurred during writing json
70
    #[error("Serde error, writing json: {0}")]
71
    SerdeJsonFail(#[from] serde_json::Error),
72
    /// A problem happened while accessing a file
73
    #[error("File access failed {0}")]
74
    FileAccessError(#[from] FatIOError),
75

76
    /// could not extract filename from path
77
    #[error("File path parsing failed")]
78
    FilePathParsingFails(String),
79

80
    /// Different number of files matched pattern in actual and nominal
81
    #[error("Different number of files matched pattern in actual {0} and nominal {1}")]
82
    DifferentNumberOfFiles(usize, usize),
83
}
84

85
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
20✔
86
#[allow(clippy::upper_case_acronyms)]
87
/// Representing the comparison mode
88
pub enum ComparisonMode {
89
    /// smart CSV compare
90
    CSV(CSVCompareConfig),
91
    /// thresholds comparison
92
    Image(ImageCompareConfig),
93
    /// plain text compare
94
    PlainText(HTMLCompareConfig),
95
    /// Compare using file hashes
96
    Hash(HashConfig),
97
    /// PDF text compare
98
    PDFText(HTMLCompareConfig),
99
    /// Compare file-properties
100
    FileProperties(PropertiesConfig),
101

102
    /// Compare JSON files
103
    Json(JsonConfig),
104

105
    /// Run external comparison executable
106
    External(ExternalConfig),
107
}
108

109
fn get_file_name(path: &Path) -> Option<Cow<str>> {
1✔
110
    path.file_name().map(|f| f.to_string_lossy())
1✔
111
}
1✔
112

113
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
10✔
114
/// Represents a whole configuration file consisting of several comparison rules
115
pub struct ConfigurationFile {
116
    /// A list of all rules to be checked on run
117
    pub rules: Vec<Rule>,
118
}
119

120
impl ConfigurationFile {
121
    /// creates a [`ConfigurationFile`] file struct from anything implementing `Read`
122
    pub fn from_reader(reader: impl Read) -> Result<ConfigurationFile, Error> {
5✔
123
        let config: ConfigurationFile = serde_yaml::from_reader(reader)?;
5✔
124
        Ok(config)
5✔
125
    }
5✔
126

127
    /// creates a [`ConfigurationFile`] from anything path-convertible
128
    pub fn from_file(file: impl AsRef<Path>) -> Result<ConfigurationFile, Error> {
5✔
129
        let config_reader = fat_io_wrap_std(file, &File::open)?;
5✔
130
        Self::from_reader(BufReader::new(config_reader))
5✔
131
    }
5✔
132
}
133

134
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
45✔
135
/// Representing a single comparison rule
136
pub struct Rule {
137
    /// The name of the rule - will be displayed in logs
138
    pub name: String,
139
    /// A list of glob-patterns to include
140
    pub pattern_include: Vec<String>,
141
    /// A list of glob-patterns to exclude - optional
142
    pub pattern_exclude: Option<Vec<String>>,
143
    /// How these files shall be compared
144
    #[serde(flatten)]
145
    pub file_type: ComparisonMode,
146
}
147

148
fn glob_files(
46✔
149
    path: impl AsRef<Path>,
46✔
150
    patterns: &[impl AsRef<str>],
46✔
151
) -> Result<Vec<PathBuf>, glob::PatternError> {
46✔
152
    let mut files = Vec::new();
46✔
153
    for pattern in patterns {
84✔
154
        let path_prefix = path.as_ref().join(pattern.as_ref());
38✔
155
        let path_pattern = path_prefix.to_string_lossy();
38✔
156
        debug!("Globbing: {}", path_pattern);
38✔
157
        files.extend(glob::glob(path_pattern.as_ref())?.filter_map(|p| p.ok()));
146✔
158
    }
159
    Ok(files)
46✔
160
}
46✔
161

162
fn filter_exclude(paths: Vec<PathBuf>, excludes: Vec<PathBuf>) -> Vec<PathBuf> {
83✔
163
    debug!(
83✔
164
        "Filtering paths {:#?} with exclusion list {:#?}",
×
165
        &paths, &excludes
×
166
    );
167
    paths
83✔
168
        .into_iter()
83✔
169
        .filter_map(|p| if excludes.contains(&p) { None } else { Some(p) })
526✔
170
        .collect()
83✔
171
}
83✔
172

173
/// Use this to compare a single file against another file using a given rule
174
pub fn compare_files(
62✔
175
    nominal: impl AsRef<Path>,
62✔
176
    actual: impl AsRef<Path>,
62✔
177
    comparison_mode: &ComparisonMode,
62✔
178
) -> Difference {
62✔
179
    let file_name_nominal = nominal.as_ref().to_string_lossy();
62✔
180
    let file_name_actual = actual.as_ref().to_string_lossy();
62✔
181
    let _file_span = span!(tracing::Level::INFO, "Processing").entered();
62✔
182

62✔
183
    info!("File: {file_name_nominal} | {file_name_actual}");
62✔
184

185
    let compare_result: Result<Difference, Box<dyn std::error::Error>> = {
62✔
186
        match comparison_mode {
62✔
187
            ComparisonMode::CSV(conf) => {
37✔
188
                csv::compare_paths(nominal.as_ref(), actual.as_ref(), conf).map_err(|e| e.into())
37✔
189
            }
190
            ComparisonMode::Image(conf) => {
2✔
191
                image::compare_paths(nominal.as_ref(), actual.as_ref(), conf).map_err(|e| e.into())
2✔
192
            }
193
            ComparisonMode::PlainText(conf) => {
3✔
194
                html::compare_files(nominal.as_ref(), actual.as_ref(), conf).map_err(|e| e.into())
3✔
195
            }
196
            ComparisonMode::Hash(conf) => {
×
197
                hash::compare_files(nominal.as_ref(), actual.as_ref(), conf).map_err(|e| e.into())
×
198
            }
199
            ComparisonMode::PDFText(conf) => {
×
200
                pdf::compare_files(nominal.as_ref(), actual.as_ref(), conf).map_err(|e| e.into())
×
201
            }
202
            ComparisonMode::FileProperties(conf) => {
9✔
203
                properties::compare_files(nominal.as_ref(), actual.as_ref(), conf)
9✔
204
                    .map_err(|e| e.into())
9✔
205
            }
206
            ComparisonMode::External(conf) => {
9✔
207
                external::compare_files(nominal.as_ref(), actual.as_ref(), conf)
9✔
208
                    .map_err(|e| e.into())
9✔
209
            }
210
            ComparisonMode::Json(conf) => {
2✔
211
                json::compare_files(nominal.as_ref(), actual.as_ref(), conf).map_err(|e| e.into())
2✔
212
            }
213
        }
214
    };
215
    let compare_result = match compare_result {
62✔
216
        Ok(r) => r,
62✔
217
        Err(e) => {
×
218
            let e = e.to_string();
×
219
            error!("Problem comparing the files {}", &e);
×
220
            let mut d = Difference::new_for_file(nominal, actual);
×
221
            d.error();
×
222
            d.push_detail(DiffDetail::Error(e));
×
223
            d
×
224
        }
225
    };
226

227
    if compare_result.is_error {
62✔
228
        error!("Files didn't match");
1✔
229
    } else {
230
        debug!("Files matched");
61✔
231
    }
232

233
    compare_result
62✔
234
}
62✔
235

236
fn get_files(
23✔
237
    path: impl AsRef<Path>,
23✔
238
    patterns_include: &[impl AsRef<str>],
23✔
239
    patterns_exclude: &[impl AsRef<str>],
23✔
240
) -> Result<Vec<PathBuf>, glob::PatternError> {
23✔
241
    let files_exclude = glob_files(path.as_ref(), patterns_exclude)?;
23✔
242
    let files_include: Vec<_> = glob_files(path.as_ref(), patterns_include)?;
23✔
243
    Ok(filter_exclude(files_include, files_exclude))
23✔
244
}
23✔
245

246
fn process_rule(
12✔
247
    nominal: impl AsRef<Path>,
12✔
248
    actual: impl AsRef<Path>,
12✔
249
    rule: &Rule,
12✔
250
    compare_results: &mut Vec<Difference>,
12✔
251
) -> Result<bool, Error> {
12✔
252
    let _file_span = span!(tracing::Level::INFO, "Rule").entered();
12✔
253
    info!("Name: {}", rule.name.as_str());
12✔
254
    if !nominal.as_ref().is_dir() {
12✔
255
        error!(
1✔
256
            "Nominal folder {} is not a folder",
×
257
            nominal.as_ref().to_string_lossy()
×
258
        );
259
        return Ok(false);
1✔
260
    }
11✔
261
    if !actual.as_ref().is_dir() {
11✔
262
        error!(
1✔
263
            "Actual folder {} is not a folder",
×
264
            actual.as_ref().to_string_lossy()
×
265
        );
266
        return Ok(false);
1✔
267
    }
10✔
268

10✔
269
    let exclude_patterns = rule.pattern_exclude.as_deref().unwrap_or_default();
10✔
270

271
    let nominal_cleaned_paths =
10✔
272
        get_files(nominal.as_ref(), &rule.pattern_include, exclude_patterns)?;
10✔
273
    let actual_cleaned_paths = get_files(actual.as_ref(), &rule.pattern_include, exclude_patterns)?;
10✔
274

275
    info!(
10✔
276
        "Found {} files matching includes in actual, {} files in nominal",
×
277
        actual_cleaned_paths.len(),
×
278
        nominal_cleaned_paths.len()
×
279
    );
280
    let actual_files = actual_cleaned_paths.len();
10✔
281
    let nominal_files = nominal_cleaned_paths.len();
10✔
282

10✔
283
    if actual_files != nominal_files {
10✔
284
        return Err(Error::DifferentNumberOfFiles(actual_files, nominal_files));
×
285
    }
10✔
286

10✔
287
    let mut all_okay = true;
10✔
288
    nominal_cleaned_paths
10✔
289
        .into_iter()
10✔
290
        .zip(actual_cleaned_paths)
10✔
291
        .for_each(|(n, a)| {
62✔
292
            let compare_result = compare_files(n, a, &rule.file_type);
62✔
293
            all_okay &= !compare_result.is_error;
62✔
294
            compare_results.push(compare_result);
62✔
295
        });
62✔
296

10✔
297
    Ok(all_okay)
10✔
298
}
12✔
299

300
/// Use this function if you don't want this crate to load and parse a config file but provide a custom rules struct yourself
301
pub fn compare_folders_cfg(
5✔
302
    nominal: impl AsRef<Path>,
5✔
303
    actual: impl AsRef<Path>,
5✔
304
    config_struct: ConfigurationFile,
5✔
305
    report_path: impl AsRef<Path>,
5✔
306
) -> Result<bool, Error> {
5✔
307
    let mut rule_results: Vec<report::RuleDifferences> = Vec::new();
5✔
308

5✔
309
    let results: Vec<bool> = config_struct
5✔
310
        .rules
5✔
311
        .into_iter()
5✔
312
        .map(|rule| {
10✔
313
            let mut compare_results: Vec<Difference> = Vec::new();
10✔
314
            let okay = process_rule(
10✔
315
                nominal.as_ref(),
10✔
316
                actual.as_ref(),
10✔
317
                &rule,
10✔
318
                &mut compare_results,
10✔
319
            );
10✔
320

10✔
321
            let rule_name = rule.name.as_str();
10✔
322

10✔
323
            let result = okay.unwrap_or_else(|e| {
10✔
324
                println!("Error occurred during rule-processing for rule {rule_name}: {e}");
×
325
                false
×
326
            });
10✔
327
            rule_results.push(report::RuleDifferences {
10✔
328
                rule,
10✔
329
                diffs: compare_results,
10✔
330
            });
10✔
331

10✔
332
            result
10✔
333
        })
10✔
334
        .collect();
5✔
335

5✔
336
    let all_okay = results.iter().all(|result| *result);
10✔
337
    report::create_reports(&rule_results, &report_path)?;
5✔
338
    Ok(all_okay)
5✔
339
}
5✔
340

341
/// The main function for comparing folders. It will parse a config file in yaml format, create a report in report_path and compare the folders nominal and actual.
342
pub fn compare_folders(
5✔
343
    nominal: impl AsRef<Path>,
5✔
344
    actual: impl AsRef<Path>,
5✔
345
    config_file: impl AsRef<Path>,
5✔
346
    report_path: impl AsRef<Path>,
5✔
347
) -> Result<bool, Error> {
5✔
348
    let config = ConfigurationFile::from_file(config_file)?;
5✔
349
    compare_folders_cfg(nominal, actual, config, report_path)
5✔
350
}
5✔
351

352
/// Create the jsonschema for the current configuration file format
353
pub fn get_schema() -> Result<String, Error> {
×
354
    let schema = schema_for!(ConfigurationFile);
×
355
    Ok(serde_json::to_string_pretty(&schema)?)
×
356
}
×
357

358
/// Try to load config yaml and check whether it is a valid one. Returns true if file can be loaded, otherwise false
359
pub fn validate_config(config_file: impl AsRef<Path>) -> bool {
×
360
    let config_file = config_file.as_ref();
×
361
    let config_file_string = config_file.to_string_lossy();
×
362
    if !config_file.exists() {
×
363
        error!("Could not find config file: {config_file_string}");
×
364
        return false;
×
365
    }
×
366

×
367
    match ConfigurationFile::from_file(config_file) {
×
368
        Ok(_) => {
369
            info!("Config file {config_file_string} loaded successfully");
×
370
            true
×
371
        }
372
        Err(e) => {
×
373
            error!(
×
374
                "Could not load config file {config_file_string}: {}",
×
375
                e.to_string()
×
376
            );
377
            false
×
378
        }
379
    }
380
}
×
381

382
#[cfg(test)]
383
mod tests {
384
    use crate::image::{CompareMode, RGBCompareMode};
385

386
    use super::*;
387

388
    #[test]
389
    fn folder_not_found_is_false() {
1✔
390
        let rule = Rule {
1✔
391
            name: "test rule".to_string(),
1✔
392
            file_type: ComparisonMode::Image(ImageCompareConfig {
1✔
393
                threshold: 1.0,
1✔
394
                mode: CompareMode::RGB(RGBCompareMode::Hybrid),
1✔
395
            }),
1✔
396
            pattern_include: vec!["*.".to_string()],
1✔
397
            pattern_exclude: None,
1✔
398
        };
1✔
399
        let mut result = Vec::new();
1✔
400
        assert!(!process_rule("NOT_EXISTING", ".", &rule, &mut result).unwrap());
1✔
401
        assert!(!process_rule(".", "NOT_EXISTING", &rule, &mut result).unwrap());
1✔
402
    }
1✔
403

404
    #[test]
405
    fn multiple_include_exclude_works() {
1✔
406
        let pattern_include = vec![
1✔
407
            "**/Components.csv".to_string(),
1✔
408
            "**/CumulatedHistogram.csv".to_string(),
1✔
409
        ];
1✔
410
        let empty = vec![""];
1✔
411
        let result =
1✔
412
            get_files("tests/csv/data/", &pattern_include, &empty).expect("could not glob");
1✔
413
        assert_eq!(result.len(), 2);
1✔
414
        let excludes = vec!["**/Components.csv".to_string()];
1✔
415
        let result =
1✔
416
            get_files("tests/csv/data/", &pattern_include, &excludes).expect("could not glob");
1✔
417
        assert_eq!(result.len(), 1);
1✔
418
        let excludes = vec![
1✔
419
            "**/Components.csv".to_string(),
1✔
420
            "**/CumulatedHistogram.csv".to_string(),
1✔
421
        ];
1✔
422
        let result =
1✔
423
            get_files("tests/csv/data/", &pattern_include, &excludes).expect("could not glob");
1✔
424
        assert!(result.is_empty());
1✔
425
    }
1✔
426
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc