├── .gitignore
├── clippy.toml
├── tests
├── integ
│ ├── data
│ │ ├── json
│ │ │ ├── actual
│ │ │ │ └── guy.json
│ │ │ └── expected
│ │ │ │ └── guy.json
│ │ ├── images
│ │ │ ├── diff_100_DPI.png
│ │ │ ├── actual
│ │ │ │ ├── SaveImage_100DPI_default_size.jpg
│ │ │ │ └── SaveImage_100DPI_custom_size_not_uniform_500x500.jpg
│ │ │ └── expected
│ │ │ │ ├── SaveImage_100DPI_default_size.jpg
│ │ │ │ └── SaveImage_100DPI_custom_size_not_uniform_500x500.jpg
│ │ └── display_of_status_message_in_cm_tables
│ │ │ ├── actual
│ │ │ ├── Features_VolumeWithAllStatusMessage.csv
│ │ │ ├── Features_VolumeWithAllStatusMessage2.csv
│ │ │ ├── Features_VolumeWithAllStatusMessage3.csv
│ │ │ ├── Features_VolumeWithAllStatusMessage3_2.csv
│ │ │ ├── VolumeWithAllStatusMessage.csv
│ │ │ ├── Volume1.csv
│ │ │ ├── VolumeWithAllStatusMessage3.csv
│ │ │ ├── VolumeWithAllStatusMessage3_2.csv
│ │ │ └── VolumeWithAllStatusMessage2.csv
│ │ │ └── expected
│ │ │ ├── Features_VolumeWithAllStatusMessage.csv
│ │ │ ├── Features_VolumeWithAllStatusMessage2.csv
│ │ │ ├── Features_VolumeWithAllStatusMessage3.csv
│ │ │ ├── Features_VolumeWithAllStatusMessage3_2.csv
│ │ │ ├── VolumeWithAllStatusMessage.csv
│ │ │ ├── Volume1.csv
│ │ │ ├── VolumeWithAllStatusMessage3.csv
│ │ │ ├── VolumeWithAllStatusMessage3_2.csv
│ │ │ └── VolumeWithAllStatusMessage2.csv
│ ├── json.yml
│ ├── jpg_compare.yml
│ ├── config.yml
│ └── vgrf.yml
├── pdf
│ ├── actual.pdf
│ └── expected.pdf
├── csv
│ └── data
│ │ ├── CM_quality_threshold.csv
│ │ ├── no_field_sep.csv
│ │ ├── easy_pore_export_annoration_table_result.csv
│ │ ├── Annotations.csv
│ │ ├── Annotations_diff.csv
│ │ ├── defects_headers.csv
│ │ ├── Components.csv
│ │ ├── defects.csv
│ │ ├── Multi_Apply_Rotation.csv
│ │ ├── DeviationHistogram.csv
│ │ ├── DeviationHistogram_diff.csv
│ │ └── CumulatedHistogram.csv
├── html
│ ├── test.html
│ └── html_changed.html
└── integ.rs
├── .github
└── workflows
│ ├── rust.yml
│ ├── release.yml
│ └── coverage.yml
├── src
├── print_args.rs
├── external.rs
├── pdf.rs
├── hash.rs
├── main.rs
├── html.rs
├── csv
│ ├── value.rs
│ ├── tokenizer
│ │ ├── guess_format.rs
│ │ └── mod.rs
│ └── preprocessing.rs
├── json.rs
├── directory.rs
├── properties.rs
├── image.rs
└── lib.rs
├── LICENSE
├── Cargo.toml
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | .idea
3 | Cargo.lock
4 |
--------------------------------------------------------------------------------
/clippy.toml:
--------------------------------------------------------------------------------
1 | allow-unwrap-in-tests = true
2 | allow-expect-in-tests = true
--------------------------------------------------------------------------------
/tests/integ/data/json/actual/guy.json:
--------------------------------------------------------------------------------
1 | {"name":"Takumi", "age":18, "car": "Panda Trueno"}
--------------------------------------------------------------------------------
/tests/pdf/actual.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VolumeGraphics/havocompare/HEAD/tests/pdf/actual.pdf
--------------------------------------------------------------------------------
/tests/integ/data/json/expected/guy.json:
--------------------------------------------------------------------------------
1 | {"name":"Keisuke", "age":21, "car": "RX7", "brothers": ["Ryosuke"]}
--------------------------------------------------------------------------------
/tests/pdf/expected.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VolumeGraphics/havocompare/HEAD/tests/pdf/expected.pdf
--------------------------------------------------------------------------------
/tests/csv/data/CM_quality_threshold.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | 90%,OK
3 | 95%,OK
4 | 99%,OK
5 | no quality threshold,OK
6 |
--------------------------------------------------------------------------------
/tests/integ/json.yml:
--------------------------------------------------------------------------------
1 | rules:
2 | - name: "Compare JSON files"
3 | pattern_include:
4 | - "**/*.json"
5 | Json: {}
--------------------------------------------------------------------------------
/tests/integ/data/images/diff_100_DPI.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VolumeGraphics/havocompare/HEAD/tests/integ/data/images/diff_100_DPI.png
--------------------------------------------------------------------------------
/tests/integ/jpg_compare.yml:
--------------------------------------------------------------------------------
1 | rules:
2 | - name: "JPG comparison"
3 | pattern_include:
4 | - "**/*.jpg"
5 | Image:
6 | RGBA: Hybrid
7 | threshold: 0.9
8 |
--------------------------------------------------------------------------------
/tests/integ/data/images/actual/SaveImage_100DPI_default_size.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VolumeGraphics/havocompare/HEAD/tests/integ/data/images/actual/SaveImage_100DPI_default_size.jpg
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/actual/Features_VolumeWithAllStatusMessage.csv:
--------------------------------------------------------------------------------
1 | Name,Type,Status
2 | Feature 1,Flatness,Out of sync
3 | Feature 1 [2],Flatness,Out of sync
4 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/actual/Features_VolumeWithAllStatusMessage2.csv:
--------------------------------------------------------------------------------
1 | Name,Type,Status
2 | Feature 1,Flatness,Out of sync
3 | Feature 1 [2],Flatness,Out of sync
4 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/actual/Features_VolumeWithAllStatusMessage3.csv:
--------------------------------------------------------------------------------
1 | Name,Type,Status
2 | Feature 1,Flatness,Out of sync
3 | Feature 1 [2],Flatness,Out of sync
4 |
--------------------------------------------------------------------------------
/tests/integ/data/images/expected/SaveImage_100DPI_default_size.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VolumeGraphics/havocompare/HEAD/tests/integ/data/images/expected/SaveImage_100DPI_default_size.jpg
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/actual/Features_VolumeWithAllStatusMessage3_2.csv:
--------------------------------------------------------------------------------
1 | Name,Type,Status
2 | Feature 1,Flatness,Out of sync
3 | Feature 1 [2],Flatness,Out of sync
4 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/actual/VolumeWithAllStatusMessage.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | Circle 1,OK
3 | Cylinder 1,OK
4 | Plane 1,Invalid: Automatic refit failed
5 | Plane 3,OK
6 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/expected/Features_VolumeWithAllStatusMessage.csv:
--------------------------------------------------------------------------------
1 | Name,Type,Status
2 | Feature 1,Flatness,Out of sync
3 | Feature 1 [2],Flatness,Out of sync
4 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/expected/Features_VolumeWithAllStatusMessage2.csv:
--------------------------------------------------------------------------------
1 | Name,Type,Status
2 | Feature 1,Flatness,Out of sync
3 | Feature 1 [2],Flatness,Out of sync
4 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/expected/Features_VolumeWithAllStatusMessage3.csv:
--------------------------------------------------------------------------------
1 | Name,Type,Status
2 | Feature 1,Flatness,Out of sync
3 | Feature 1 [2],Flatness,Out of sync
4 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/expected/Features_VolumeWithAllStatusMessage3_2.csv:
--------------------------------------------------------------------------------
1 | Name,Type,Status
2 | Feature 1,Flatness,Out of sync
3 | Feature 1 [2],Flatness,Out of sync
4 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/actual/Volume1.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | Cylinder 1,"Isosurface not found for iteration > 0, but previous result could be taken"
3 | Cylinder 2,OK
4 | 0.0, 0.1
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/expected/VolumeWithAllStatusMessage.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | Circle 1,OK
3 | Cylinder 1,OK
4 | Plane 1,Invalid: Automatic refit failed
5 | Plane 3,OK
6 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/expected/Volume1.csv:
--------------------------------------------------------------------------------
1 | Name;Status
2 | Cylinder 1;"Isosurface not found for iteration > 0, but previous result could be taken"
3 | Cylinder 2;OK
4 | 0,0; 0,1
--------------------------------------------------------------------------------
/tests/integ/data/images/actual/SaveImage_100DPI_custom_size_not_uniform_500x500.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VolumeGraphics/havocompare/HEAD/tests/integ/data/images/actual/SaveImage_100DPI_custom_size_not_uniform_500x500.jpg
--------------------------------------------------------------------------------
/tests/integ/data/images/expected/SaveImage_100DPI_custom_size_not_uniform_500x500.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VolumeGraphics/havocompare/HEAD/tests/integ/data/images/expected/SaveImage_100DPI_custom_size_not_uniform_500x500.jpg
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/actual/VolumeWithAllStatusMessage3.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | Circle 1,OK
3 | Cylinder 1,OK
4 | Plane 1,Invalid: Automatic refit failed
5 | Plane 1 [2],Invalid: Automatic refit failed
6 | Plane 2,Invalid: Automatic refit failed
7 | Plane 3,OK
8 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/actual/VolumeWithAllStatusMessage3_2.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | Circle 1,OK
3 | Cylinder 1,OK
4 | Plane 1,Invalid: Automatic refit failed
5 | Plane 1 [2],Invalid: Automatic refit failed
6 | Plane 2,Invalid: Automatic refit failed
7 | Plane 3,OK
8 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/expected/VolumeWithAllStatusMessage3.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | Circle 1,OK
3 | Cylinder 1,OK
4 | Plane 1,Invalid: Automatic refit failed
5 | Plane 1 [2],Invalid: Automatic refit failed
6 | Plane 2,Invalid: Automatic refit failed
7 | Plane 3,OK
8 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/expected/VolumeWithAllStatusMessage3_2.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | Circle 1,OK
3 | Cylinder 1,OK
4 | Plane 1,Invalid: Automatic refit failed
5 | Plane 1 [2],Invalid: Automatic refit failed
6 | Plane 2,Invalid: Automatic refit failed
7 | Plane 3,OK
8 |
--------------------------------------------------------------------------------
/tests/csv/data/no_field_sep.csv:
--------------------------------------------------------------------------------
1 | Area [mm²]
2 | 35.23
3 | 266.22
4 | 303.87
5 | 422.84
6 | 429.09
7 | 579.11
8 | 704.57
9 | 704.60
10 | 803.87
11 | 805.02
12 | 959.84
13 | 1059.25
14 | 1106.24
15 | 1231.01
16 | 1248.21
17 | 1254.11
18 | 1322.43
19 | 1347.02
20 | 1418.68
21 | 1498.85
22 | 1731.46
23 | 1942.96
24 | 2139.71
25 | 2428.87
26 | 3450.56
27 |
--------------------------------------------------------------------------------
/tests/csv/data/easy_pore_export_annoration_table_result.csv:
--------------------------------------------------------------------------------
1 | Name;Volume [mm³];Radius [mm];Diameter [mm];Surface [mm²]
2 | Defect 1;4,41600;1,06066;2,12132;19,92000
3 | Defect 2;34,41606;2,06034;4,12068;76,80000
4 | Defect 3;114,87995;3,06186;6,12373;171,12001
5 | Defect 4;271,07263;4,06264;8,12528;303,84006
6 | Defect 5;528,68768;5,06310;10,12621;474,00003
7 |
8 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/actual/VolumeWithAllStatusMessage2.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | Circle 1,Invalid: Source elements are no longer present
3 | Cylinder 1,Invalid: Not synchronized with current situation (surface determination change?)
4 | Plane 1,Invalid: Not synchronized with current situation (surface determination change?)
5 | Plane 1 [2],Invalid: Not synchronized with current situation (surface determination change?)
6 | Plane 2,Invalid: Not synchronized with current situation (surface determination change?)
7 |
--------------------------------------------------------------------------------
/tests/integ/data/display_of_status_message_in_cm_tables/expected/VolumeWithAllStatusMessage2.csv:
--------------------------------------------------------------------------------
1 | Name,Status
2 | Circle 1,Invalid: Source elements are no longer present
3 | Cylinder 1,Invalid: Not synchronized with current situation (surface determination change?)
4 | Plane 1,Invalid: Not synchronized with current situation (surface determination change?)
5 | Plane 1 [2],Invalid: Not synchronized with current situation (surface determination change?)
6 | Plane 2,Invalid: Not synchronized with current situation (surface determination change?)
7 |
--------------------------------------------------------------------------------
/tests/csv/data/Annotations.csv:
--------------------------------------------------------------------------------
1 | Name,Pos. x [mm],Pos. y [mm],Pos. z [mm],Deviation [mm],Min. Deviation [mm],Max. Deviation [mm],Description,Image,Focused image,Captures,Tolerance status,Type
2 | Deviation 5,-22.663068771,39.907196045,-49.257804871,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
3 | Deviation 4,-22.826931000,39.911468506,-20.156715393,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
4 | Deviation 3,-22.591037750,39.901805878,-33.957965851,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
5 | Deviation 2,-22.739957809,39.908485413,-65.457649231,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
6 | Deviation 1,-22.650812149,41.070060730,-80.756362915,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
7 |
8 |
--------------------------------------------------------------------------------
/tests/integ/config.yml:
--------------------------------------------------------------------------------
1 | rules:
2 | - name: "VGRF-Reporting CSV comparing"
3 | pattern_include:
4 | - "**/*.csv"
5 | pattern_exclude:
6 | - "**/*_diff.csv"
7 | CSV:
8 | comparison_modes:
9 | - Absolute: 1.0
10 | - Relative: 0.1
11 | exclude_field_regex: "Excluded"
12 |
13 | - name: "HTML-Compare strict"
14 | pattern_exclude:
15 | - "**/*_changed.html"
16 | pattern_include:
17 | - "**/*.html"
18 | PlainText:
19 | threshold: 1.0
20 | ignore_lines:
21 | - "stylesheet"
22 | - "next_ignore"
23 | - "[A-Z]*[0-9]"
24 |
25 | - name: "HTML-Compare fuzzy"
26 | pattern_include:
27 | - "**/*.html"
28 | PlainText:
29 | threshold: 0.9
30 |
31 |
32 |
--------------------------------------------------------------------------------
/tests/csv/data/Annotations_diff.csv:
--------------------------------------------------------------------------------
1 | Name,Position x [mm],Position y [mm],Position z [mm],Deviation [mm],Min. Deviation [mm],Max. Deviation [mm],Description,Image,Focused image,Captures,Tolerance status,Type
2 | Deviation 5,-22.663068771,39.907196045,-49.257804871,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
3 | Deviation 4,-22.826931000,39.911468506,-20.156715393,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
4 | Deviation 3,-22.591037750,39.901805878,-33.957965851,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
5 | Deviation 2,-22.739957809,39.908485413,-65.457649231,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
6 | Deviation 1,-22.650812149,41.070060730,-80.756362915,>= 0.600000024,n/a,n/a,,Off,Off,0,No tolerance,Rule based
7 |
8 |
--------------------------------------------------------------------------------
/.github/workflows/rust.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches: [ "main" ]
4 | pull_request:
5 | branches: [ "main" ]
6 |
7 | env:
8 | CARGO_TERM_COLOR: always
9 |
10 | jobs:
11 | build:
12 | strategy:
13 | fail-fast: true
14 | matrix:
15 | include:
16 | - os: ubuntu-latest
17 | - os: windows-latest
18 | - os: macos-latest
19 |
20 | name: rust-ci ${{ matrix.os }}
21 | runs-on: ${{ matrix.os }}
22 | steps:
23 | - uses: actions/checkout@v3
24 | - name: Build
25 | run: cargo build --verbose
26 | - name: Run tests
27 | run: cargo test --release --verbose
28 | - name: Run check
29 | run: cargo check --verbose
30 | - name: Run clippy
31 | run: cargo clippy --verbose
32 |
--------------------------------------------------------------------------------
/tests/csv/data/defects_headers.csv:
--------------------------------------------------------------------------------
1 | Entry,Radius
2 | 1,0.00139
3 | 2,0.00711
4 | 3,0.00829
5 | 4,0.00774
6 | 5,0.00739
7 | 6,0.00882
8 | 7,0.00955
9 | 8,0.00265
10 | 9,0.00161
11 | 10,0.00275
12 | 11,0.00716
13 | 12,0.00579
14 | 13,0.00444
15 | 14,0.00938
16 | 15,0.00734
17 | 16,0.00352
18 | 17,0.00867
19 | 18,0.00105
20 | 19,0.00375
21 | 20,0.00692
22 | 21,0.00167
23 | 22,0.0055
24 | 23,0.00236
25 | 24,0.00775
26 | 25,0.00044
27 | 26,0.00441
28 | 27,0.00671
29 | 28,0.00529
30 | 29,0.00622
31 | 30,0.00153
32 | 31,0.00988
33 | 32,0.00762
34 | 33,0.00864
35 | 34,0.00818
36 | 35,0.00536
37 | 36,0.00603
38 | 37,0.007
39 | 38,0.00491
40 | 39,0.00868
41 | 40,0.00861
42 | 41,0.00766
43 | 42,0.00584
44 | 43,0.0044
45 | 44,0.0021
46 | 45,0.00523
47 | 46,0.00174
48 | 47,0.00562
49 | 48,0.0039
50 | 49,0.00279
51 |
--------------------------------------------------------------------------------
/tests/csv/data/Components.csv:
--------------------------------------------------------------------------------
1 | Pos. x [mm];Pos. y [mm];Pos. z [mm];Component surface [mm²];Component min. deviation [mm];Component max. deviation [mm];Description;Image;Focused image;Captures;Tolerance status
2 | -29,334506989;3,739070892;-9,758856773;0,347590178;-0,598987699;-0,480000019;;Off;Off;0;No tolerance
3 | 49,488979340;12,218115807;-93,429969788;3,691705227;-0,507287800;-0,480000019;;Off;Off;0;No tolerance
4 | -22,663068771;39,907196045;-49,257804871;38,809677124;0,480000019;>= 0,600000024;;Off;Off;0;No tolerance
5 | -22,826931000;39,911468506;-20,156715393;38,821525574;0,480000019;>= 0,600000024;;Off;Off;0;No tolerance
6 | -22,591037750;39,901805878;-33,957965851;38,825820923;0,480000019;>= 0,600000024;;Off;Off;0;No tolerance
7 | -22,739957809;39,908485413;-65,457649231;38,827430725;0,480000019;>= 0,600000024;;Off;Off;0;No tolerance
8 | -22,650812149;41,070056915;-80,756362915;38,859138489;0,480000019;>= 0,600000024;;Off;Off;0;No tolerance
9 |
10 |
--------------------------------------------------------------------------------
/tests/csv/data/defects.csv:
--------------------------------------------------------------------------------
1 | Probability,Radius [mm],Diameter [mm],Center x [mm],Center y [mm],Center z [mm],Volume [mm³],Voxel,Surface [mm²],Classification,Gap [mm],Compactness,Sphericity,Projected size x [mm],Projected size y [mm],Projected size z [mm],PCA deviation 1 [mm],PCA deviation 2 [mm],PCA deviation 3 [mm],PCA max. deviation ratio [%],PCA min. deviation ratio [%],Min. gray value,Max. gray value,Mean gray value,Deviation of gray values,Label,Projected area (yz-plane) [mm²],Projected area (xz-plane) [mm²],Projected area (xy-plane) [mm²],Tolerance status
2 | 0.00,0.02236,0.04472,-9.83108,-5.44005,15.91616,0.00002,0,0.00381,,0.07929,0.51,1.00,0.08943,0.04472,0.04472,0.01118,0.01118,0.01118,49.99999,50.00000,32696,32696,32696,0,6795,0.00200,0.00400,0.00400,Valid
3 | 0.00,0.02236,0.04472,7.45788,-8.50014,23.92020,0.00002,0,0.00381,,0.02511,0.51,1.00,0.08943,0.04472,0.04472,0.01118,0.01118,0.01118,49.99999,50.00000,27158,27158,27158,0,8991,0.00200,0.00400,0.00400,Valid
4 |
5 |
--------------------------------------------------------------------------------
/src/print_args.rs:
--------------------------------------------------------------------------------
1 | use tracing::{info, Level};
2 | use tracing_subscriber::FmtSubscriber;
3 |
4 | fn main() {
5 | // enable colors on windows cmd.exe
6 | // does not fail on powershell, even though powershell can do colors without this
7 | // will fail on jenkins/qa tough, that's why we need to ignore the result
8 | let _ = enable_ansi_support::enable_ansi_support();
9 |
10 | let subscriber = FmtSubscriber::builder()
11 | .with_max_level(Level::INFO)
12 | .finish();
13 | tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
14 | info!("!Print-Args!");
15 | let mut terminate_code = 0;
16 | for arg in std::env::args() {
17 | info!("Argument: {}", &arg);
18 | if arg.as_str() == "--exit-with-error" {
19 | eprintln!("E: setting error code to -1");
20 | terminate_code = -1;
21 | }
22 | }
23 | std::process::exit(terminate_code);
24 | }
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | # .github/workflows/release.yml
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | release:
9 | name: release ${{ matrix.target }}
10 | runs-on: ubuntu-latest
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | include:
15 | - target: x86_64-pc-windows-gnu
16 | archive: zip
17 | - target: x86_64-unknown-linux-musl
18 | archive: tar.gz tar.xz
19 | - target: x86_64-apple-darwin
20 | archive: zip
21 | steps:
22 | - uses: actions/checkout@v4
23 | - name: Compile and release
24 | uses: rust-build/rust-build.action@v1.4.5
25 | env:
26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27 | with:
28 | TOOLCHAIN_VERSION: stable
29 | RUSTTARGET: ${{ matrix.target }}
30 | ARCHIVE_TYPES: ${{ matrix.archive }}
31 | EXTRA_FILES: "README.md LICENSE config_scheme.json"
32 |
--------------------------------------------------------------------------------
/tests/html/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | A Basic HTML5 Template
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/tests/html/html_changed.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | A Basic HTML5 Template
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Volume Graphics GmbH
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/tests/integ/vgrf.yml:
--------------------------------------------------------------------------------
1 | rules:
2 | - name: "VGRF-Reporting CSV comparing"
3 | pattern_include:
4 | - "**/*.csv"
5 | pattern_exclude:
6 | - "**/vg_report.csv"
7 | CSV:
8 | comparison_modes:
9 | - Absolute: 1.0
10 | - Relative: 0.1
11 | exclude_field_regex: "Excluded"
12 | preprocessing:
13 | - ExtractHeaders
14 |
15 | - name: "HTML-Compare strict"
16 | pattern_exclude:
17 | - "**/*_changed.html"
18 | pattern_include:
19 | - "**/*.html"
20 | PlainText:
21 | threshold: 1.0
22 | ignore_lines:
23 | - "stylesheet"
24 | - "next_ignore"
25 | - "[A-Z]*[0-9]"
26 |
27 | - name: "All files are lowercase, no spaces"
28 | pattern_include:
29 | - "**/*.*"
30 | pattern_exclude: null
31 | FileProperties:
32 | forbid_name_regex: "[\\s]"
33 | modification_date_tolerance_secs: 0
34 | file_size_tolerance_bytes: 0
35 |
36 | - name: "External checker"
37 | pattern_include:
38 | - "*.*"
39 | External:
40 | executable: "cargo"
41 | extra_params:
42 | - "run"
43 | - "--bin"
44 | - "print_args"
45 | - "--"
46 | - "--only-images"
47 |
48 |
--------------------------------------------------------------------------------
/.github/workflows/coverage.yml:
--------------------------------------------------------------------------------
1 | name: coverage instrument based
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - uses: actions/checkout@v3
12 | - name: Install latest nightly
13 | uses: actions-rs/toolchain@v1
14 | with:
15 | toolchain: nightly
16 | override: true
17 | components: rustfmt, clippy, llvm-tools-preview
18 |
19 | - name: Install lcov
20 | run: sudo apt-get install lcov
21 |
22 | - name: install grcov
23 | run: cargo install grcov
24 |
25 | - uses: actions/checkout@v3
26 | with:
27 | fetch-depth: 0
28 |
29 | - name: Run grcov
30 | env:
31 | PROJECT_NAME: "havocompare"
32 | RUSTFLAGS: "-Cinstrument-coverage -Ccodegen-units=1 -Copt-level=0 -Clink-dead-code -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort"
33 | CARGO_INCREMENTAL: 0
34 | run: |
35 | cargo +nightly build --verbose
36 | cargo +nightly test --verbose
37 | grcov . -s . --binary-path ./target/debug/ -t lcov --llvm --branch --ignore-not-existing --ignore="/*" --ignore="target/*" --ignore="tests/*" -o lcov.info
38 |
39 | - name: Push grcov results to Coveralls via GitHub Action
40 | uses: coverallsapp/github-action@v2
41 | with:
42 | github-token: ${{ secrets.GITHUB_TOKEN }}
43 | file: "lcov.info"
44 |
45 |
--------------------------------------------------------------------------------
/tests/csv/data/Multi_Apply_Rotation.csv:
--------------------------------------------------------------------------------
1 | Name,Coordinate system,Act. value [mm/deg],Status
2 | Feature XY,Datum system A_XY Rotation,0.00000 mm,OK
3 | Feature XY [2],Datum system A_XY Rotation,0.00000 mm,OK
4 | Feature XY [3],Datum system A_XY Rotation,0.00000 mm,OK
5 | Feature XY [4],Datum system A_XY Rotation,0.00000 mm,OK
6 | Feature XY [5],Datum system A_XY Rotation,0.00000 mm,OK
7 | Feature XY [6],Datum system A_XY Rotation,0.00000 mm,OK
8 | Feature XY [7],Datum system A_XY Rotation,0.00000 mm,OK
9 | Feature XY [8],Datum system A_XY Rotation,0.00000 mm,OK
10 | Feature XZ,Datum system A_XZ Rotation,0.00000 mm,OK
11 | Feature XZ [2],Datum system A_XZ Rotation,0.00000 mm,OK
12 | Feature XZ [3],Datum system A_XZ Rotation,0.00000 mm,OK
13 | Feature XZ [4],Datum system A_XZ Rotation,0.00000 mm,OK
14 | Feature XZ [5],Datum system A_XZ Rotation,0.00000 mm,OK
15 | Feature XZ [6],Datum system A_XZ Rotation,0.00000 mm,OK
16 | Feature XZ [7],Datum system A_XZ Rotation,0.00000 mm,OK
17 | Feature XZ [8],Datum system A_XZ Rotation,0.00000 mm,OK
18 | Feature YZ,Datum system A_YZ Rotation,0.00000 mm,OK
19 | Feature YZ [2],Datum system A_YZ Rotation,0.00000 mm,OK
20 | Feature YZ [3],Datum system A_YZ Rotation,0.00000 mm,OK
21 | Feature YZ [4],Datum system A_YZ Rotation,0.00000 mm,OK
22 | Feature YZ [5],Datum system A_YZ Rotation,0.00000 mm,OK
23 | Feature YZ [6],Datum system A_YZ Rotation,0.00000 mm,OK
24 | Feature YZ [7],Datum system A_YZ Rotation,0.00000 mm,OK
25 | Feature YZ [8],Datum system A_YZ Rotation,0.00000 mm,OK
26 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "havocompare"
3 | description = "A flexible rule-based file and folder comparison tool and crate including nice html reporting. Compares CSVs, JSON, text files, pdf-texts and images."
4 | repository = "https://github.com/VolumeGraphics/havocompare"
5 | homepage = "https://github.com/VolumeGraphics/havocompare"
6 | documentation = "https://docs.rs/havocompare"
7 | version = "0.8.0"
8 | edition = "2021"
9 | license = "MIT"
10 | authors = ["Volume Graphics GmbH"]
11 | exclude = ["tests/pdf", "tests/integ", "tests/html", "target", "tests/csv", ".github", "test_report"]
12 | keywords = ["diff", "compare", "csv", "image", "difference"]
13 | categories = ["filesystem"]
14 | default-run = "havocompare"
15 |
16 | [[bin]]
17 | name = "print_args"
18 | path = "src/print_args.rs"
19 |
20 |
21 | [dependencies]
22 | clap = { version = "4.5", features = ["derive"] }
23 | chrono = "0.4"
24 | serde = "1.0"
25 | serde_yaml = "0.9"
26 | schemars = "0.8"
27 | schemars_derive = "0.8"
28 | thiserror = "2.0"
29 | regex = "1.10"
30 | image = "0.25"
31 | image-compare = "0.4"
32 | tracing = "0.1"
33 | tracing-subscriber = "0.3"
34 | serde_json = "1.0"
35 | glob = "0.3"
36 | test-log = { version = "0.2", features = ["trace"] }
37 | strsim = "0.11"
38 | itertools = "0.14"
39 | tera = "1.19"
40 | sha2 = "0.10"
41 | data-encoding = "2.6"
42 | permutation = "0.4"
43 | pdf-extract = "0.9"
44 | vg_errortools = "0.1"
45 | rayon = "1.10.0"
46 | enable-ansi-support = "0.2"
47 | tempfile = "3.20"
48 | fs_extra = "1.3"
49 | opener = "0.7"
50 | anyhow = "1.0"
51 | json_diff_ng = { version = "0.6", default-features = false }
52 |
53 | [dev-dependencies]
54 | env_logger = "0.11"
55 | tracing = { version = "0.1", default-features = false }
56 | tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt"] }
57 |
--------------------------------------------------------------------------------
/tests/integ.rs:
--------------------------------------------------------------------------------
1 | use havocompare::compare_folders;
2 | use test_log::test;
3 |
4 | #[test]
5 | fn simple_test_identity() {
6 | let report_dir =
7 | tempfile::tempdir().expect("Could not generate temporary directory for report");
8 | let result = compare_folders("tests/", "tests/", "tests/integ/config.yml", report_dir);
9 | assert!(result.unwrap());
10 | }
11 |
12 | #[test]
13 | fn display_of_status_message_in_cm_tables() {
14 | let report_dir =
15 | tempfile::tempdir().expect("Could not generate temporary directory for report");
16 |
17 | assert!(compare_folders(
18 | "tests/integ/data/display_of_status_message_in_cm_tables/expected/",
19 | "tests/integ/data/display_of_status_message_in_cm_tables/actual/",
20 | "tests/integ/vgrf.yml",
21 | report_dir
22 | )
23 | .unwrap());
24 | }
25 |
26 | #[test]
27 | fn images_test() {
28 | let report_dir =
29 | tempfile::tempdir().expect("Could not generate temporary directory for report");
30 |
31 | assert!(compare_folders(
32 | "tests/integ/data/images/expected/",
33 | "tests/integ/data/images/actual/",
34 | "tests/integ/jpg_compare.yml",
35 | report_dir
36 | )
37 | .unwrap());
38 | }
39 |
40 | #[test]
41 | fn json_test() {
42 | let report_dir =
43 | tempfile::tempdir().expect("Could not generate temporary directory for report");
44 |
45 | assert!(!compare_folders(
46 | "tests/integ/data/json/expected/",
47 | "tests/integ/data/json/actual/",
48 | "tests/integ/json.yml",
49 | report_dir
50 | )
51 | .unwrap());
52 | let report_dir =
53 | tempfile::tempdir().expect("Could not generate temporary directory for report");
54 |
55 | assert!(compare_folders(
56 | "tests/integ/data/json/expected/",
57 | "tests/integ/data/json/expected/",
58 | "tests/integ/json.yml",
59 | report_dir
60 | )
61 | .unwrap());
62 | }
63 |
--------------------------------------------------------------------------------
/src/external.rs:
--------------------------------------------------------------------------------
1 | use crate::report::{DiffDetail, Difference};
2 | use crate::Error;
3 | use schemars::JsonSchema;
4 | use serde::{Deserialize, Serialize};
5 | use std::path::Path;
6 | use tracing::{error, info};
7 |
8 | #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
9 | pub struct ExternalConfig {
10 | /// The executable to call - will be started like: `#executable #(#extra_params)* #nominal #actual`
11 | executable: String,
12 | /// Extra parameters to pass
13 | extra_params: Vec,
14 | }
15 |
16 | pub(crate) fn compare_files>(
17 | nominal: P,
18 | actual: P,
19 | config: &ExternalConfig,
20 | ) -> Result {
21 | let mut diff = Difference::new_for_file(&nominal, &actual);
22 | let compared_file_name = nominal.as_ref().to_string_lossy().into_owned();
23 | let output = std::process::Command::new(&config.executable)
24 | .args(&config.extra_params)
25 | .arg(nominal.as_ref())
26 | .arg(actual.as_ref())
27 | .output();
28 | if let Ok(output) = output {
29 | let stdout = String::from_utf8_lossy(&output.stdout).to_string();
30 | let stderr = String::from_utf8_lossy(&output.stderr).to_string();
31 | info!("External stdout: {}", stdout.as_str());
32 | info!("External stderr: {}", stderr.as_str());
33 | if !output.status.success() {
34 | let message = format!("External checker denied file {}", &compared_file_name);
35 | error!("{}", &message);
36 | diff.push_detail(DiffDetail::External { stdout, stderr });
37 | diff.error();
38 | };
39 | } else {
40 | let error_message = format!(
41 | "External checker execution failed for file {}",
42 | &compared_file_name
43 | );
44 | error!("{}", error_message);
45 | diff.push_detail(DiffDetail::Error(error_message));
46 | diff.error();
47 | };
48 | Ok(diff)
49 | }
50 | #[cfg(test)]
51 | mod tests {
52 | use super::*;
53 | use test_log::test;
54 |
55 | #[test]
56 | fn test_non_existent_exe() {
57 | let result = compare_files(
58 | Path::new("file1"),
59 | Path::new("file2"),
60 | &ExternalConfig {
61 | extra_params: Vec::new(),
62 | executable: "non_existent".to_owned(),
63 | },
64 | )
65 | .unwrap();
66 | assert!(result.is_error);
67 | }
68 |
69 | #[test]
70 | fn test_bad_output() {
71 | let result = compare_files(
72 | Path::new("file1"),
73 | Path::new("file2"),
74 | &ExternalConfig {
75 | extra_params: vec![
76 | "run".to_owned(),
77 | "--bin".to_owned(),
78 | "print_args".to_owned(),
79 | "--".to_owned(),
80 | "--exit-with-error".to_owned(),
81 | ],
82 | executable: "cargo".to_owned(),
83 | },
84 | )
85 | .unwrap();
86 | assert!(result.is_error);
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/pdf.rs:
--------------------------------------------------------------------------------
1 | use crate::html::HTMLCompareConfig;
2 | use crate::report;
3 | use crate::report::{DiffDetail, Difference};
4 | use pdf_extract::extract_text;
5 | use std::path::Path;
6 | use strsim::normalized_damerau_levenshtein;
7 | use thiserror::Error;
8 | use tracing::{error, info};
9 | use vg_errortools::FatIOError;
10 |
11 | #[derive(Debug, Error)]
12 | /// Errors during html / plain text checking
13 | pub enum Error {
14 | #[error("Failed to compile regex {0}")]
15 | RegexCompilationFailure(#[from] regex::Error),
16 | #[error("Problem creating hash report {0}")]
17 | ReportingFailure(#[from] report::Error),
18 | #[error("File access failed {0}")]
19 | FileAccessProblem(#[from] FatIOError),
20 | #[error("PDF text extraction error {0}")]
21 | PdfTextExtractionFailed(#[from] pdf_extract::OutputError),
22 | }
23 |
24 | pub fn compare_files>(
25 | nominal_path: P,
26 | actual_path: P,
27 | config: &HTMLCompareConfig,
28 | ) -> Result {
29 | info!("Extracting text from actual pdf");
30 | let actual = extract_text(actual_path.as_ref())?;
31 |
32 | info!("Extracting text from nominal pdf");
33 | let nominal = extract_text(nominal_path.as_ref())?;
34 |
35 | let exclusion_list = config.get_ignore_list()?;
36 | let mut difference = Difference::new_for_file(&nominal_path, &actual_path);
37 | actual
38 | .lines()
39 | .enumerate()
40 | .zip(nominal.lines())
41 | .filter(|((_, a), n)|
42 | exclusion_list.iter().all(|exc| !exc.is_match(a)) && exclusion_list.iter().all(|exc| !exc.is_match(n))
43 | )
44 | .for_each(|((l, a), n)| {
45 | let distance = normalized_damerau_levenshtein(a,n);
46 | if distance < config.threshold {
47 |
48 | let error = format!(
49 | "Missmatch in PDF-Text-file in line {}. Expected: '{}' found '{}' (diff: {}, threshold: {})",
50 | l, n, a, distance, config.threshold
51 | );
52 |
53 | error!("{}" , &error);
54 | difference.push_detail(DiffDetail::Text {actual:a.to_owned(), nominal:n.to_owned(), score: distance, line: l});
55 | difference.error();
56 | }
57 | });
58 |
59 | Ok(difference)
60 | }
61 |
62 | #[cfg(test)]
63 | mod test {
64 | use super::*;
65 |
66 | #[test]
67 | fn test_compare_pdf() {
68 | let result = compare_files(
69 | "tests/pdf/actual.pdf",
70 | "tests/pdf/expected.pdf",
71 | &HTMLCompareConfig::default(),
72 | )
73 | .unwrap();
74 | assert!(result.is_error);
75 |
76 | let result = compare_files(
77 | "tests/pdf/actual.pdf",
78 | "tests/pdf/actual.pdf",
79 | &HTMLCompareConfig::default(),
80 | )
81 | .unwrap();
82 | assert!(!result.is_error);
83 | }
84 |
85 | #[test]
86 | fn test_ignore_line_pdf() {
87 | let result = compare_files(
88 | "tests/pdf/actual.pdf",
89 | "tests/pdf/expected.pdf",
90 | &HTMLCompareConfig {
91 | threshold: 1.0,
92 | ignore_lines: Some(vec!["/workspace/".to_owned()]),
93 | },
94 | )
95 | .unwrap();
96 | assert!(!result.is_error);
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/hash.rs:
--------------------------------------------------------------------------------
1 | use std::fs::File;
2 | use std::io::Read;
3 | use std::path::{Path, PathBuf};
4 |
5 | use data_encoding::HEXLOWER;
6 | use schemars_derive::JsonSchema;
7 | use thiserror::Error;
8 | use vg_errortools::fat_io_wrap_std;
9 | use vg_errortools::FatIOError;
10 |
11 | use crate::report::{DiffDetail, Difference};
12 | use crate::{report, Deserialize, Serialize};
13 |
14 | #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy)]
15 | pub enum HashFunction {
16 | Sha256,
17 | }
18 |
19 | #[derive(Debug, Error)]
20 | /// Errors during hash checking
21 | pub enum Error {
22 | #[error("Failed to compile regex {0}")]
23 | RegexCompilationFailed(#[from] regex::Error),
24 | #[error("Problem creating hash report {0}")]
25 | ReportingFailure(#[from] report::Error),
26 | #[error("File access failed {0}")]
27 | FileAccessProblem(#[from] FatIOError),
28 | }
29 |
30 | impl HashFunction {
31 | fn hash_file(&self, mut file: impl Read) -> Result<[u8; 32], Error> {
32 | match self {
33 | Self::Sha256 => {
34 | use sha2::{Digest, Sha256};
35 | use std::io;
36 |
37 | let mut hasher = Sha256::new();
38 |
39 | let _ = io::copy(&mut file, &mut hasher)
40 | .map_err(|e| FatIOError::from_std_io_err(e, PathBuf::new()))?;
41 | let hash_bytes = hasher.finalize();
42 | Ok(hash_bytes.into())
43 | }
44 | }
45 | }
46 | }
47 |
48 | #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
49 | /// Configuration options for the hash comparison module
50 | pub struct HashConfig {
51 | /// Which hash function to use
52 | pub function: HashFunction,
53 | }
54 |
55 | impl Default for HashConfig {
56 | fn default() -> Self {
57 | HashConfig {
58 | function: HashFunction::Sha256,
59 | }
60 | }
61 | }
62 |
63 | pub fn compare_files>(
64 | nominal_path: P,
65 | actual_path: P,
66 | config: &HashConfig,
67 | ) -> Result {
68 | let act = config
69 | .function
70 | .hash_file(fat_io_wrap_std(actual_path.as_ref(), &File::open)?)?;
71 | let nom = config
72 | .function
73 | .hash_file(fat_io_wrap_std(nominal_path.as_ref(), &File::open)?)?;
74 |
75 | let mut difference = Difference::new_for_file(nominal_path, actual_path);
76 | if act != nom {
77 | difference.push_detail(DiffDetail::Hash {
78 | actual: HEXLOWER.encode(&act),
79 | nominal: HEXLOWER.encode(&nom),
80 | });
81 | difference.error();
82 | }
83 | Ok(difference)
84 | }
85 |
86 | #[cfg(test)]
87 | mod test {
88 | use crate::hash::HashFunction::Sha256;
89 |
90 | use super::*;
91 |
92 | #[test]
93 | fn identity() {
94 | let f1 = Sha256
95 | .hash_file(File::open("tests/integ.rs").unwrap())
96 | .unwrap();
97 | let f2 = Sha256
98 | .hash_file(File::open("tests/integ.rs").unwrap())
99 | .unwrap();
100 | assert_eq!(f1, f2);
101 | }
102 |
103 | #[test]
104 | fn hash_pinning() {
105 | let sum = "378f768a589f29fcbd23835ec4764a53610fc910e60b540e1e5204bdaf2c73a0";
106 | let f1 = Sha256
107 | .hash_file(File::open("tests/integ/data/images/diff_100_DPI.png").unwrap())
108 | .unwrap();
109 | assert_eq!(HEXLOWER.encode(&f1), sum);
110 | }
111 |
112 | #[test]
113 | fn identity_outer() {
114 | let file = "tests/integ.rs";
115 | let result = compare_files(file, file, &HashConfig::default()).unwrap();
116 | assert!(!result.is_error);
117 | }
118 |
119 | #[test]
120 | fn different_files_throw_outer() {
121 | let file_act = "tests/integ/data/images/actual/SaveImage_100DPI_default_size.jpg";
122 | let file_nominal = "tests/integ/data/images/expected/SaveImage_100DPI_default_size.jpg";
123 |
124 | let result = compare_files(file_act, file_nominal, &HashConfig::default()).unwrap();
125 | assert!(result.is_error);
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | use anyhow::anyhow;
2 | use clap::Parser;
3 | use havocompare::{compare_files, compare_folders, get_schema, validate_config, ComparisonMode};
4 | use std::path::{Path, PathBuf};
5 | use tracing::{info, Level};
6 | use tracing_subscriber::FmtSubscriber;
7 |
8 | const DEFAULT_REPORT_FOLDER: &str = "report";
9 |
10 | #[derive(clap::Subcommand)]
11 | enum Commands {
12 | /// Compare two folders using a config file
13 | Compare {
14 | /// Nominal data folder
15 | nominal: String,
16 | /// Actual data folder
17 | actual: String,
18 | /// Path to compare config YAML
19 | compare_config: String,
20 | /// Optional: Folder to store the report to, if not set the default location will be chosen.
21 | #[arg(short, long = "report_path", default_value_t = DEFAULT_REPORT_FOLDER.to_string())]
22 | report_config: String,
23 | /// Open the report immediately after comparison
24 | #[arg(short, long)]
25 | open: bool,
26 | },
27 | /// Compare two files given a config-string that contains a json-serialized config
28 | FileCompare {
29 | /// nominal file
30 | nominal: PathBuf,
31 | /// actual file
32 | actual: PathBuf,
33 | /// compare_configuration in json
34 | config: String,
35 | },
36 |
37 | /// Export the JsonSchema for the config files
38 | Schema,
39 |
40 | /// Validate config yaml
41 | Validate { compare_config: String },
42 | }
43 |
44 | #[derive(Parser)]
45 | #[command(author, version, about, long_about = None)]
46 | struct Arguments {
47 | #[clap(short, long)]
48 | /// print debug information about the run
49 | verbose: bool,
50 | #[clap(subcommand)]
51 | /// choose the command to run
52 | command: Commands,
53 | }
54 |
55 | fn main() -> Result<(), vg_errortools::MainError> {
56 | let args = Arguments::parse();
57 | let level = if args.verbose {
58 | Level::DEBUG
59 | } else {
60 | Level::INFO
61 | };
62 |
63 | // enable colors on windows cmd.exe
64 | // does not fail on powershell, even though powershell can do colors without this
65 | // will fail on jenkins/qa tough, that's why we need to ignore the result
66 | let _ = enable_ansi_support::enable_ansi_support();
67 |
68 | let subscriber = FmtSubscriber::builder().with_max_level(level).finish();
69 | tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
70 | match args.command {
71 | Commands::Schema => {
72 | println!(
73 | "{}",
74 | get_schema().expect("Error occurred writing json schema")
75 | );
76 | Ok(())
77 | }
78 | Commands::Compare {
79 | compare_config,
80 | nominal,
81 | actual,
82 | report_config,
83 | open,
84 | } => {
85 | let report_path = Path::new(report_config.as_str());
86 | let result = compare_folders(nominal, actual, compare_config, report_path)?;
87 | if open {
88 | info!("Opening report");
89 | opener::open(report_path.join("index.html")).expect("Could not open report!");
90 | }
91 | if result {
92 | Ok(())
93 | } else {
94 | Err(anyhow!("Comparison failed!").into())
95 | }
96 | }
97 | Commands::FileCompare {
98 | nominal,
99 | actual,
100 | config,
101 | } => {
102 | use anyhow::Context;
103 | let config: ComparisonMode =
104 | serde_json::from_str(&config).context("Couldn't deserialize the config string")?;
105 | let result = compare_files(nominal, actual, &config);
106 | info!("Diff results: {result:#?}");
107 | if result.is_error {
108 | Err(anyhow!("Comparison failed!").into())
109 | } else {
110 | Ok(())
111 | }
112 | }
113 | Commands::Validate { compare_config } => {
114 | if validate_config(compare_config) {
115 | Ok(())
116 | } else {
117 | Err(anyhow!("Validation failed!").into())
118 | }
119 | }
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/html.rs:
--------------------------------------------------------------------------------
1 | use crate::report;
2 | use crate::report::{DiffDetail, Difference};
3 | use regex::Regex;
4 | use schemars_derive::JsonSchema;
5 | use serde::{Deserialize, Serialize};
6 | use std::fs::File;
7 | use std::io::{BufRead, BufReader};
8 | use std::path::Path;
9 | use strsim::normalized_damerau_levenshtein;
10 | use thiserror::Error;
11 | use tracing::error;
12 | use vg_errortools::fat_io_wrap_std;
13 | use vg_errortools::FatIOError;
14 |
15 | #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
16 | /// Plain text comparison config, also used for PDF
17 | pub struct HTMLCompareConfig {
18 | /// Normalized Damerau-Levenshtein distance, 0.0 = bad, 1.0 = identity
19 | pub threshold: f64,
20 | /// Lines matching any of the given regex will be excluded from comparison
21 | pub ignore_lines: Option>,
22 | }
23 |
24 | impl HTMLCompareConfig {
25 | pub(crate) fn get_ignore_list(&self) -> Result, regex::Error> {
26 | let exclusion_list: Option, regex::Error>> = self
27 | .ignore_lines
28 | .as_ref()
29 | .map(|v| v.iter().map(|exc| Regex::new(exc)).collect());
30 | let exclusion_list = match exclusion_list {
31 | Some(r) => r?,
32 | None => Vec::new(),
33 | };
34 | Ok(exclusion_list)
35 | }
36 | }
37 |
38 | impl Default for HTMLCompareConfig {
39 | fn default() -> Self {
40 | HTMLCompareConfig {
41 | threshold: 1.0,
42 | ignore_lines: None,
43 | }
44 | }
45 | }
46 |
47 | #[derive(Debug, Error)]
48 | /// Errors during html / plain text checking
49 | pub enum Error {
50 | #[error("Failed to compile regex {0}")]
51 | RegexCompilationFailure(#[from] regex::Error),
52 | #[error("Problem creating hash report {0}")]
53 | ReportingProblem(#[from] report::Error),
54 | #[error("File access failed {0}")]
55 | FileAccessFailure(#[from] FatIOError),
56 | }
57 |
58 | pub fn compare_files>(
59 | nominal_path: P,
60 | actual_path: P,
61 | config: &HTMLCompareConfig,
62 | ) -> Result {
63 | let actual = BufReader::new(fat_io_wrap_std(actual_path.as_ref(), &File::open)?);
64 | let nominal = BufReader::new(fat_io_wrap_std(nominal_path.as_ref(), &File::open)?);
65 |
66 | let exclusion_list = config.get_ignore_list()?;
67 | let mut difference = Difference::new_for_file(nominal_path, actual_path);
68 | actual
69 | .lines()
70 | .enumerate()
71 | .filter_map(|l| l.1.ok().map(|a| (l.0, a)))
72 | .zip(nominal.lines().map_while(Result::ok))
73 | .filter(|((_, a), n)|
74 | exclusion_list.iter().all(|exc| !exc.is_match(a)) && exclusion_list.iter().all(|exc| !exc.is_match(n))
75 | )
76 | .for_each(|((l, a), n)| {
77 | let distance = normalized_damerau_levenshtein(a.as_str(),n.as_str());
78 | if distance < config.threshold {
79 |
80 | let error = format!(
81 | "Mismatch in HTML-file in line {}. Expected: '{}' found '{}' (diff: {}, threshold: {})",
82 | l, n, a, distance, config.threshold
83 | );
84 |
85 | error!("{}" , &error);
86 | difference.push_detail(DiffDetail::Text {actual: a, nominal: n, score: distance, line: l});
87 | difference.error();
88 | }
89 | });
90 |
91 | Ok(difference)
92 | }
93 |
94 | #[cfg(test)]
95 | mod test {
96 | use super::*;
97 | use test_log::test;
98 | #[test]
99 | fn test_identity() {
100 | assert!(
101 | !compare_files(
102 | "tests/html/test.html",
103 | "tests/html/test.html",
104 | &HTMLCompareConfig::default(),
105 | )
106 | .unwrap()
107 | .is_error
108 | );
109 | }
110 |
111 | #[test]
112 | fn test_modified() {
113 | let actual = "tests/html/test.html";
114 | let nominal = "tests/html/html_changed.html";
115 |
116 | let result = compare_files(actual, nominal, &HTMLCompareConfig::default()).unwrap();
117 |
118 | assert!(result.is_error);
119 | }
120 |
121 | #[test]
122 | fn test_allow_modified_threshold() {
123 | assert!(
124 | !compare_files(
125 | "tests/html/test.html",
126 | "tests/html/html_changed.html",
127 | &HTMLCompareConfig {
128 | threshold: 0.9,
129 | ignore_lines: None
130 | },
131 | )
132 | .unwrap()
133 | .is_error
134 | );
135 | }
136 |
137 | #[test]
138 | fn test_ignore_lines_regex() {
139 | assert!(
140 | !compare_files(
141 | "tests/html/test.html",
142 | "tests/html/html_changed.html",
143 | &HTMLCompareConfig {
144 | threshold: 1.0,
145 | ignore_lines: Some(vec!["stylesheet".to_owned()])
146 | },
147 | )
148 | .unwrap()
149 | .is_error
150 | );
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/src/csv/value.rs:
--------------------------------------------------------------------------------
1 | use schemars_derive::JsonSchema;
2 | use serde::{Deserialize, Serialize};
3 | use std::borrow::Cow;
4 | use std::fmt::{Display, Formatter};
5 |
6 | #[derive(Debug, Clone, JsonSchema, Deserialize, Serialize, PartialEq)]
7 | pub struct Quantity {
8 | pub(crate) value: f64,
9 | pub(crate) unit: Option,
10 | }
11 |
12 | fn next_up(val: f64) -> f64 {
13 | const TINY_BITS: u64 = 0x1; // Smallest positive f64.
14 | const CLEAR_SIGN_MASK: u64 = 0x7fff_ffff_ffff_ffff;
15 |
16 | let bits = val.to_bits();
17 | if val.is_nan() || bits == f64::INFINITY.to_bits() {
18 | return val;
19 | }
20 |
21 | let abs = bits & CLEAR_SIGN_MASK;
22 | let next_bits = if abs == 0 {
23 | TINY_BITS
24 | } else if bits == abs {
25 | bits + 1
26 | } else {
27 | bits - 1
28 | };
29 | f64::from_bits(next_bits)
30 | }
31 |
32 | fn next_down(val: f64) -> f64 {
33 | const NEG_TINY_BITS: u64 = 0x8000_0000_0000_0001; // Smallest (in magnitude) negative f64.
34 | const CLEAR_SIGN_MASK: u64 = 0x7fff_ffff_ffff_ffff;
35 |
36 | let bits = val.to_bits();
37 | if val.is_nan() || bits == f64::NEG_INFINITY.to_bits() {
38 | return val;
39 | }
40 |
41 | let abs: u64 = bits & CLEAR_SIGN_MASK;
42 | let next_bits = if abs == 0 {
43 | NEG_TINY_BITS
44 | } else if bits == abs {
45 | bits - 1
46 | } else {
47 | bits + 1
48 | };
49 | f64::from_bits(next_bits)
50 | }
51 |
52 | impl Quantity {
53 | #[cfg(test)]
54 | pub(crate) fn new(value: f64, unit: Option<&str>) -> Self {
55 | Self {
56 | unit: unit.map(|s| s.to_owned()),
57 | value,
58 | }
59 | }
60 |
61 | /// This avoids the issue of `(a - b) > d` for `b = a + d` with small `d`
62 | pub(crate) fn minimal_diff(&self, rhs: &Quantity) -> f64 {
63 | let min = self.value.min(rhs.value);
64 | let max = self.value.max(rhs.value);
65 | let min_up = next_up(min);
66 | let max_down = next_down(max);
67 | next_down(max_down - min_up)
68 | }
69 | }
70 |
71 | impl Display for Quantity {
72 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
73 | if let Some(unit) = self.unit.as_deref() {
74 | write!(f, "{} {}", self.value, unit)
75 | } else {
76 | write!(f, "{}", self.value)
77 | }
78 | }
79 | }
80 |
81 | #[derive(Debug, PartialEq, Clone, Serialize)]
82 | pub enum Value {
83 | Quantity(Quantity),
84 | String(String),
85 | }
86 |
87 | impl Display for Value {
88 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
89 | match &self {
90 | Value::Quantity(val) => {
91 | write!(f, "{val}").unwrap();
92 | }
93 | Value::String(val) => {
94 | write!(f, "'{val}'").unwrap();
95 | }
96 | }
97 | Ok(())
98 | }
99 | }
100 |
101 | impl Value {
102 | pub fn deleted() -> Value {
103 | Value::from_str("DELETED", &None)
104 | }
105 |
106 | fn get_numerical_value(field_split: &[&str]) -> Option {
107 | if field_split.len() == 1 || field_split.len() == 2 {
108 | return field_split.first().and_then(|s| s.parse::().ok());
109 | }
110 | None
111 | }
112 |
113 | pub fn from_str(s: &str, decimal_separator: &Option) -> Value {
114 | let field_string: String = if let Some(delim) = decimal_separator {
115 | s.replace(*delim, ".")
116 | } else {
117 | s.into()
118 | };
119 |
120 | let field_split: Vec<_> = field_string.trim().split(' ').collect();
121 |
122 | if let Some(float_value) = Self::get_numerical_value(field_split.as_slice()) {
123 | Value::Quantity(Quantity {
124 | value: float_value,
125 | unit: field_split.get(1).map(|&s| s.to_owned()),
126 | })
127 | } else {
128 | Value::String(s.trim().to_owned())
129 | }
130 | }
131 |
132 | pub fn get_quantity(&self) -> Option<&Quantity> {
133 | match self {
134 | Value::Quantity(quantity) => Some(quantity),
135 | _ => None,
136 | }
137 | }
138 |
139 | pub fn get_string(&self) -> Option {
140 | match self {
141 | Value::String(string) => Some(string.to_owned()),
142 | _ => None,
143 | }
144 | }
145 |
146 | pub fn as_str(&self) -> Cow {
147 | match self {
148 | Value::String(str) => str.as_str().into(),
149 | Value::Quantity(quant) => quant.to_string().into(),
150 | }
151 | }
152 | }
153 |
154 | #[cfg(test)]
155 | mod tests {
156 | use super::*;
157 | use crate::csv::Mode;
158 | #[test]
159 | fn trimming() {
160 | let val_spaced = Value::from_str(" value ", &None);
161 | let reference = Value::from_str("value", &None);
162 | assert_eq!(val_spaced, reference);
163 | }
164 |
165 | #[test]
166 | fn test_secure_diff() {
167 | for base in -30..=30 {
168 | for modulation in -30..=base {
169 | let magic_factor = 1.3;
170 | let num_one = magic_factor * 10.0f64.powi(base);
171 | let delta = magic_factor * 10.0f64.powi(modulation);
172 | let compare_mode = Mode::Absolute(delta.abs());
173 | let num_modulated = num_one + delta;
174 | let q1 = Quantity::new(num_one, None);
175 | let q2 = Quantity::new(num_modulated, None);
176 | // assert!(u_diff <= delta);
177 | assert!(compare_mode.in_tolerance(&q1, &q2));
178 | }
179 | }
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/src/json.rs:
--------------------------------------------------------------------------------
1 | use std::path::Path;
2 |
3 | use itertools::Itertools;
4 | use json_diff_ng::DiffType;
5 | use regex::Regex;
6 | use schemars_derive::JsonSchema;
7 | use serde::{Deserialize, Serialize};
8 | use tracing::error;
9 |
10 | use crate::report::{DiffDetail, Difference};
11 | use crate::Error;
12 |
13 | #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
14 | /// configuration for the json compare module
15 | pub struct JsonConfig {
16 | #[serde(default)]
17 | ignore_keys: Vec,
18 | #[serde(default)]
19 | sort_arrays: bool,
20 | }
21 | impl JsonConfig {
22 | pub(crate) fn get_ignore_list(&self) -> Result, regex::Error> {
23 | self.ignore_keys.iter().map(|v| Regex::new(v)).collect()
24 | }
25 | }
26 |
27 | pub(crate) fn compare_files>(
28 | nominal: P,
29 | actual: P,
30 | config: &JsonConfig,
31 | ) -> Result {
32 | let mut diff = Difference::new_for_file(&nominal, &actual);
33 | let compared_file_name = nominal.as_ref().to_string_lossy().into_owned();
34 |
35 | let nominal: String = vg_errortools::fat_io_wrap_std(&nominal, &std::fs::read_to_string)?;
36 | let actual: String = vg_errortools::fat_io_wrap_std(&actual, &std::fs::read_to_string)?;
37 | let ignores = config.get_ignore_list()?;
38 |
39 | let json_diff = json_diff_ng::compare_strs(&nominal, &actual, config.sort_arrays, &ignores);
40 | let json_diff = match json_diff {
41 | Ok(diff) => diff,
42 | Err(e) => {
43 | let error_message =
44 | format!("JSON comparison failed for {compared_file_name} (error: {e})");
45 | error!("{}", error_message);
46 | diff.push_detail(DiffDetail::Error(error_message));
47 | diff.error();
48 | return Ok(diff);
49 | }
50 | };
51 | let filtered_diff: Vec<_> = json_diff.all_diffs();
52 |
53 | if !filtered_diff.is_empty() {
54 | for (d_type, key) in filtered_diff.iter() {
55 | error!("{d_type}: {key}");
56 | }
57 | let left = filtered_diff
58 | .iter()
59 | .filter_map(|(k, v)| {
60 | if matches!(k, DiffType::LeftExtra) {
61 | Some(v.to_string())
62 | } else {
63 | None
64 | }
65 | })
66 | .join("\n");
67 | let right = filtered_diff
68 | .iter()
69 | .filter_map(|(k, v)| {
70 | if matches!(k, DiffType::RightExtra) {
71 | Some(v.to_string())
72 | } else {
73 | None
74 | }
75 | })
76 | .join("\n");
77 | let differences = filtered_diff
78 | .iter()
79 | .filter_map(|(k, v)| {
80 | if matches!(k, DiffType::Mismatch) {
81 | Some(v.to_string())
82 | } else {
83 | None
84 | }
85 | })
86 | .join("\n");
87 | let root_mismatch = filtered_diff
88 | .iter()
89 | .find(|(k, _v)| matches!(k, DiffType::RootMismatch))
90 | .map(|(_, v)| v.to_string());
91 |
92 | diff.push_detail(DiffDetail::Json {
93 | differences,
94 | left,
95 | right,
96 | root_mismatch,
97 | });
98 |
99 | diff.error();
100 | }
101 |
102 | Ok(diff)
103 | }
104 |
105 | #[cfg(test)]
106 | mod test {
107 | use super::*;
108 |
109 | fn trim_split(list: &str) -> Vec<&str> {
110 | list.split('\n').map(|e| e.trim()).collect()
111 | }
112 |
113 | #[test]
114 | fn no_filter() {
115 | let cfg = JsonConfig {
116 | ignore_keys: vec![],
117 | sort_arrays: false,
118 | };
119 | let result = compare_files(
120 | "tests/integ/data/json/expected/guy.json",
121 | "tests/integ/data/json/actual/guy.json",
122 | &cfg,
123 | )
124 | .unwrap();
125 | if let DiffDetail::Json {
126 | differences,
127 | left,
128 | right,
129 | root_mismatch,
130 | } = result.detail.first().unwrap()
131 | {
132 | let differences = trim_split(differences);
133 |
134 | assert!(differences.contains(&".car.(\"RX7\" != \"Panda Trueno\")"));
135 | assert!(differences.contains(&".age.(21 != 18)"));
136 | assert!(differences.contains(&".name.(\"Keisuke\" != \"Takumi\")"));
137 | assert_eq!(differences.len(), 3);
138 |
139 | assert_eq!(left.as_str(), ".brothers");
140 | assert!(right.is_empty());
141 | assert!(root_mismatch.is_none());
142 | } else {
143 | panic!("wrong diffdetail");
144 | }
145 | }
146 |
147 | #[test]
148 | fn filter_works() {
149 | let cfg = JsonConfig {
150 | ignore_keys: vec!["name".to_string(), "brother(s?)".to_string()],
151 | sort_arrays: false,
152 | };
153 | let result = compare_files(
154 | "tests/integ/data/json/expected/guy.json",
155 | "tests/integ/data/json/actual/guy.json",
156 | &cfg,
157 | )
158 | .unwrap();
159 | if let DiffDetail::Json {
160 | differences,
161 | left,
162 | right,
163 | root_mismatch,
164 | } = result.detail.first().unwrap()
165 | {
166 | let differences = trim_split(differences);
167 | assert!(differences.contains(&".car.(\"RX7\" != \"Panda Trueno\")"));
168 | assert!(differences.contains(&".age.(21 != 18)"));
169 | assert_eq!(differences.len(), 2);
170 | assert!(right.is_empty());
171 | assert!(left.is_empty());
172 | assert!(root_mismatch.is_none());
173 | } else {
174 | panic!("wrong diffdetail");
175 | }
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/src/directory.rs:
--------------------------------------------------------------------------------
1 | use crate::report::{DiffDetail, Difference};
2 | use schemars_derive::JsonSchema;
3 | use serde::{Deserialize, Serialize};
4 | use std::path;
5 | use std::path::{Path, PathBuf};
6 | use thiserror::Error;
7 | use tracing::error;
8 |
9 | #[derive(Debug, Error)]
10 | /// Errors during html / plain text checking
11 | pub enum Error {
12 | #[error("Failed to remove path's prefix")]
13 | StripPrefixError(#[from] path::StripPrefixError),
14 | }
15 |
16 | #[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
17 | pub struct DirectoryConfig {
18 | pub mode: Mode,
19 | }
20 |
21 | #[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
22 | pub enum Mode {
23 | /// check whether both paths are really the same: whether entry is missing in actual, and/or if entry exists in actual but not in nominal
24 | Identical,
25 | /// check only if entry is missing in actual, ignoring entries that exist in actual but not in nominal
26 | MissingOnly,
27 | }
28 |
29 | pub(crate) fn compare_paths>(
30 | nominal: P,
31 | actual: P,
32 | nominal_entries: &[PathBuf],
33 | actual_entries: &[PathBuf],
34 | config: &DirectoryConfig,
35 | ) -> Result {
36 | let nominal_path = nominal.as_ref();
37 | let actual_path = actual.as_ref();
38 |
39 | let mut difference = Difference::new_for_file(nominal_path, actual_path);
40 |
41 | //remove root paths!
42 | let nominal_entries: Result, path::StripPrefixError> = nominal_entries
43 | .iter()
44 | .map(|path| path.strip_prefix(nominal_path))
45 | .collect();
46 | let nominal_entries = nominal_entries?;
47 |
48 | let actual_entries: Result, path::StripPrefixError> = actual_entries
49 | .iter()
50 | .map(|path| path.strip_prefix(actual_path))
51 | .collect();
52 | let actual_entries = actual_entries?;
53 |
54 | let mut is_the_same = true;
55 | if matches!(config.mode, Mode::Identical | Mode::MissingOnly) {
56 | nominal_entries.iter().for_each(|entry| {
57 | let detail = if let Some(f) = actual_entries.iter().find(|a| *a == entry) {
58 | (f.to_string_lossy().to_string(), false)
59 | } else {
60 | error!("{} doesn't exist in the actual folder", entry.display());
61 | is_the_same = false;
62 | ("".to_owned(), true)
63 | };
64 |
65 | difference.push_detail(DiffDetail::File {
66 | nominal: entry.to_string_lossy().to_string(),
67 | actual: detail.0,
68 | error: detail.1,
69 | });
70 | });
71 | }
72 |
73 | if matches!(config.mode, Mode::Identical) {
74 | actual_entries.iter().for_each(|entry| {
75 | if !nominal_entries.iter().any(|n| n == entry) {
76 | difference.push_detail(DiffDetail::File {
77 | nominal: "".to_owned(),
78 | actual: entry.to_string_lossy().to_string(),
79 | error: true,
80 | });
81 |
82 | error!(
83 | "Additional entry {} found in the actual folder",
84 | entry.display()
85 | );
86 | is_the_same = false;
87 | }
88 | });
89 | }
90 |
91 | if !is_the_same {
92 | difference.error();
93 | }
94 |
95 | Ok(difference)
96 | }
97 |
98 | #[cfg(test)]
99 |
100 | mod test {
101 | use super::*;
102 |
103 | #[test]
104 | fn test_compare_directories() {
105 | let nominal_dir = tempfile::tempdir().expect("Could not create nominal temp dir");
106 |
107 | std::fs::create_dir_all(nominal_dir.path().join("dir/a/aa"))
108 | .expect("Could not create directory");
109 | std::fs::create_dir_all(nominal_dir.path().join("dir/b"))
110 | .expect("Could not create directory");
111 | std::fs::create_dir_all(nominal_dir.path().join("dir/c"))
112 | .expect("Could not create directory");
113 |
114 | let actual_dir = tempfile::tempdir().expect("Could not create actual temp dir");
115 |
116 | std::fs::create_dir_all(actual_dir.path().join("dir/a/aa"))
117 | .expect("Could not create directory");
118 | std::fs::create_dir_all(actual_dir.path().join("dir/b"))
119 | .expect("Could not create directory");
120 | std::fs::create_dir_all(actual_dir.path().join("dir/c"))
121 | .expect("Could not create directory");
122 |
123 | let pattern_include = ["**/*/"];
124 | let pattern_exclude: Vec = Vec::new();
125 |
126 | let nominal_entries = crate::get_files(&nominal_dir, &pattern_include, &pattern_exclude)
127 | .expect("Could not get files");
128 | let actual_entries = crate::get_files(&actual_dir, &pattern_include, &pattern_exclude)
129 | .expect("Could not get files");
130 |
131 | let result = compare_paths(
132 | nominal_dir.path(),
133 | actual_dir.path(),
134 | &nominal_entries,
135 | &actual_entries,
136 | &DirectoryConfig {
137 | mode: Mode::Identical,
138 | },
139 | )
140 | .expect("Could not compare paths");
141 |
142 | assert!(!result.is_error);
143 |
144 | std::fs::create_dir_all(actual_dir.path().join("dir/d"))
145 | .expect("Could not create directory");
146 |
147 | let nominal_entries = crate::get_files(&nominal_dir, &pattern_include, &pattern_exclude)
148 | .expect("Could not create directory");
149 | let actual_entries = crate::get_files(&actual_dir, &pattern_include, &pattern_exclude)
150 | .expect("Could not create directory");
151 |
152 | let result = compare_paths(
153 | nominal_dir.path(),
154 | actual_dir.path(),
155 | &nominal_entries,
156 | &actual_entries,
157 | &DirectoryConfig {
158 | mode: Mode::Identical,
159 | },
160 | )
161 | .expect("Could not compare paths");
162 |
163 | assert!(result.is_error);
164 |
165 | let result = compare_paths(
166 | nominal_dir.path(),
167 | actual_dir.path(),
168 | &nominal_entries,
169 | &actual_entries,
170 | &DirectoryConfig {
171 | mode: Mode::MissingOnly,
172 | },
173 | )
174 | .expect("Could not compare paths");
175 |
176 | assert!(!result.is_error);
177 |
178 | std::fs::create_dir_all(nominal_dir.path().join("dir/d"))
179 | .expect("Could not create directory");
180 | std::fs::create_dir_all(nominal_dir.path().join("dir/e"))
181 | .expect("Could not create directory");
182 |
183 | let nominal_entries = crate::get_files(&nominal_dir, &pattern_include, &pattern_exclude)
184 | .expect("Could not create directory");
185 | let actual_entries = crate::get_files(&actual_dir, &pattern_include, &pattern_exclude)
186 | .expect("Could not create directory");
187 |
188 | let result = compare_paths(
189 | nominal_dir.path(),
190 | actual_dir.path(),
191 | &nominal_entries,
192 | &actual_entries,
193 | &DirectoryConfig {
194 | mode: Mode::Identical,
195 | },
196 | )
197 | .expect("Could not compare paths");
198 |
199 | assert!(result.is_error);
200 |
201 | let result = compare_paths(
202 | nominal_dir.path(),
203 | actual_dir.path(),
204 | &nominal_entries,
205 | &actual_entries,
206 | &DirectoryConfig {
207 | mode: Mode::MissingOnly,
208 | },
209 | )
210 | .expect("Could not compare paths");
211 |
212 | assert!(result.is_error);
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/src/properties.rs:
--------------------------------------------------------------------------------
1 | use crate::report::{get_relative_path, DiffDetail, Difference};
2 | use crate::Error;
3 | use chrono::offset::Utc;
4 | use chrono::DateTime;
5 | use regex::Regex;
6 | use schemars::JsonSchema;
7 | use serde::Deserialize;
8 | use serde::Serialize;
9 | use std::path::Path;
10 | use std::time::SystemTime;
11 | use tracing::error;
12 |
13 | /// the configuration struct for file property comparison
14 | #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
15 | pub struct PropertiesConfig {
16 | /// Compare the file size, difference must be smaller then given value
17 | file_size_tolerance_bytes: Option,
18 |
19 | /// Compare the modification date, difference must be smaller then the given value
20 | modification_date_tolerance_secs: Option,
21 |
22 | /// Fail if the name contains that regex
23 | forbid_name_regex: Option,
24 | }
25 |
26 | #[derive(Serialize, Debug, Clone)]
27 | pub enum MetaDataPropertyDiff {
28 | Size { nominal: u64, actual: u64 },
29 | IllegalName,
30 | CreationDate { nominal: String, actual: String },
31 | }
32 |
33 | fn regex_matches_any_path(
34 | nominal_path: &str,
35 | actual_path: &str,
36 | regex: &str,
37 | ) -> Result