Repository: linebender/kompari
Pull request #28: Size check improvements
Author: login: spirali
Created: 2025-03-08T08:58:37Z | Merged: 2025-03-12T08:28:06Z

* Outer rayon parallelization over files (rayon is already dependency of oxipng)
* Added progressbar
* Added `--size-limit` and `--improvement-limit` to enable signaling an error in CI when an limit is breached
* Code moved from `kompari` to `kompari-tasks`  


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 51dfb51..f6caa28 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -313,6 +313,19 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
 
+[[package]]
+name = "console"
+version = "0.15.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "once_cell",
+ "unicode-width",
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.7"
@@ -380,6 +393,12 @@ version = "1.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b7914353092ddf589ad78f25c5c1c21b7f80b0ff8621e7c814c3485b5306da9d"
 
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
 [[package]]
 name = "equivalent"
 version = "1.0.2"
@@ -658,6 +677,19 @@ dependencies = [
  "rayon",
 ]
 
+[[package]]
+name = "indicatif"
+version = "0.17.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
+dependencies = [
+ "console",
+ "number_prefix",
+ "portable-atomic",
+ "unicode-width",
+ "web-time",
+]
+
 [[package]]
 name = "is_terminal_polyfill"
 version = "1.70.1"
@@ -723,8 +755,10 @@ version = "0.1.0"
 dependencies = [
  "clap",
  "humansize",
+ "indicatif",
  "kompari",
  "kompari-html",
+ "rayon",
  "termcolor",
 ]
 
@@ -857,6 +891,12 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "number_prefix"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+
 [[package]]
 name = "object"
 version = "0.36.7"
@@ -921,6 +961,12 @@ dependencies = [
  "miniz_oxide",
 ]
 
+[[package]]
+name = "portable-atomic"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
+
 [[package]]
 name = "predicates"
 version = "3.1.3"
@@ -1314,6 +1360,12 @@ version = "1.0.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "11cd88e12b17c6494200a9c1b683a04fcac9573ed74cd1b62aeb2727c5592243"
 
+[[package]]
+name = "unicode-width"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
+
 [[package]]
 name = "utf8parse"
 version = "0.2.2"
@@ -1409,6 +1461,16 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "winapi-util"
 version = "0.1.9"
diff --git a/example/Cargo.lock b/example/Cargo.lock
index 4cd9e8b..429251c 100644
--- a/example/Cargo.lock
+++ b/example/Cargo.lock
@@ -285,6 +285,19 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
 
+[[package]]
+name = "console"
+version = "0.15.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "once_cell",
+ "unicode-width",
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "core-foundation-sys"
 version = "0.8.7"
@@ -347,6 +360,12 @@ version = "1.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b7914353092ddf589ad78f25c5c1c21b7f80b0ff8621e7c814c3485b5306da9d"
 
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
 [[package]]
 name = "equivalent"
 version = "1.0.2"
@@ -598,6 +617,19 @@ dependencies = [
  "rayon",
 ]
 
+[[package]]
+name = "indicatif"
+version = "0.17.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
+dependencies = [
+ "console",
+ "number_prefix",
+ "portable-atomic",
+ "unicode-width",
+ "web-time",
+]
+
 [[package]]
 name = "is_terminal_polyfill"
 version = "1.70.1"
@@ -651,8 +683,10 @@ version = "0.1.0"
 dependencies = [
  "clap",
  "humansize",
+ "indicatif",
  "kompari",
  "kompari-html",
+ "rayon",
  "termcolor",
 ]
 
@@ -779,6 +813,12 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "number_prefix"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+
 [[package]]
 name = "object"
 version = "0.36.7"
@@ -843,6 +883,12 @@ dependencies = [
  "miniz_oxide",
 ]
 
+[[package]]
+name = "portable-atomic"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.92"
@@ -1170,6 +1216,12 @@ version = "1.0.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
 
+[[package]]
+name = "unicode-width"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
+
 [[package]]
 name = "utf8parse"
 version = "0.2.2"
@@ -1252,6 +1304,16 @@ version = "0.2.99"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
 
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "winapi-util"
 version = "0.1.9"
diff --git a/kompari-cli/src/main.rs b/kompari-cli/src/main.rs
index 4d17a2c..b5fac1c 100644
--- a/kompari-cli/src/main.rs
+++ b/kompari-cli/src/main.rs
@@ -13,9 +13,9 @@
 #![cfg_attr(docsrs, feature(doc_auto_cfg))]
 
 use clap::Parser;
-use kompari::{check_size_optimizations, DirDiffConfig};
+use kompari::DirDiffConfig;
 use kompari_html::{render_html_report, start_review_server, ReportConfig};
-use kompari_tasks::output::print_size_optimization_results;
+use kompari_tasks::check_size_optimizations;
 use std::path::PathBuf;
 
 #[derive(Parser, Debug)]
@@ -114,8 +114,7 @@ fn main() -> kompari::Result<()> {
             start_review_server(&diff_config, &report_config, args.args.port)?
         }
         Args::SizeCheck(args) => {
-            let results = check_size_optimizations(&args.path, args.args.optimize)?;
-            print_size_optimization_results(&results)?;
+            check_size_optimizations(&args.path, &args.args)?;
         }
     }
     Ok(())
diff --git a/kompari-tasks/Cargo.toml b/kompari-tasks/Cargo.toml
index 24633bf..bb79eac 100644
--- a/kompari-tasks/Cargo.toml
+++ b/kompari-tasks/Cargo.toml
@@ -18,3 +18,5 @@ kompari-html = { path = "../kompari-html" }
 clap = { workspace = true }
 termcolor = "1.4"
 humansize = "2.1"
+indicatif = "0.17"
+rayon = "1.10"  # This should be same as in oxipng
diff --git a/kompari-tasks/src/args.rs b/kompari-tasks/src/args.rs
index 5561dfc..e45fc15 100644
--- a/kompari-tasks/src/args.rs
+++ b/kompari-tasks/src/args.rs
@@ -49,4 +49,17 @@ pub struct SizeCheckArgs {
     /// If enabled, images on file system are replaced with optimized version
     #[arg(long, default_value_t = false)]
     pub optimize: bool,
+
+    /// Command will fail if at least one image can be optimized by more than given ratio.
+    /// E.g. --improvement-limit=0.8 means that error is signaled when an image can be optimized
+    /// more than 80% of its original size
+    /// (that is, the optimized image's size is 20% or less of the original).
+    #[arg(long)]
+    pub improvement_limit: Option<f32>,
+
+    /// Command will fail if at least one image has a size larger than the given limit (in KiB).
+    /// If --optimize is used then limit is computed from target size, otherwise the limit is applied
+    /// on the original size
+    #[arg(long)]
+    pub size_limit: Option<usize>,
 }
diff --git a/kompari-tasks/src/lib.rs b/kompari-tasks/src/lib.rs
index 08c3253..ca25271 100644
--- a/kompari-tasks/src/lib.rs
+++ b/kompari-tasks/src/lib.rs
@@ -13,8 +13,9 @@
 #![cfg_attr(docsrs, feature(doc_auto_cfg))]
 
 pub mod args;
-pub mod output;
+mod optimizations;
 mod task;
 
 pub use args::Args;
+pub use optimizations::{check_size_optimizations, OptimizationResult};
 pub use task::{Actions, Task};
diff --git a/kompari-tasks/src/optimizations.rs b/kompari-tasks/src/optimizations.rs
new file mode 100644
index 0000000..a4abe54
--- /dev/null
+++ b/kompari-tasks/src/optimizations.rs
@@ -0,0 +1,118 @@
+// Copyright 2025 the Kompari Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+use crate::args::SizeCheckArgs;
+use humansize::{format_size, DECIMAL};
+use kompari::{list_image_dir, optimize_png, SizeOptimizationLevel};
+use rayon::iter::{IntoParallelIterator, ParallelIterator};
+use std::cmp::min;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use termcolor::{Color, ColorSpec, WriteColor};
+
+#[derive(Debug)]
+pub struct OptimizationResult {
+    pub path: PathBuf,
+    pub old_size: usize,
+    pub new_size: usize,
+    pub improvement: f32,
+    pub size_limit_breached: bool,
+    pub improvement_limit_breached: bool,
+}
+
+pub fn check_file_optimizations(
+    path: PathBuf,
+    args: &SizeCheckArgs,
+) -> kompari::Result<Option<OptimizationResult>> {
+    let old_data = std::fs::read(&path)?;
+    let old_size = old_data.len();
+    let new_data = optimize_png(old_data, SizeOptimizationLevel::High);
+    let new_size = min(new_data.len(), old_size);
+
+    let is_big = args.size_limit.is_some_and(|limit| {
+        if args.optimize {
+            new_size > limit * 1024 // Limit is given in KiB
+        } else {
+            old_size > limit * 1024
+        }
+    });
+
+    Ok(if old_size > new_size || is_big {
+        if args.optimize {
+            std::fs::write(&path, new_data)?;
+        }
+        let improvement = (old_size as f32 - new_size as f32) / old_size as f32;
+        Some(OptimizationResult {
+            path,
+            old_size,
+            new_size,
+            improvement,
+            size_limit_breached: is_big,
+            improvement_limit_breached: args
+                .improvement_limit
+                .is_some_and(|limit| limit < improvement),
+        })
+    } else {
+        None
+    })
+}
+
+pub fn check_size_optimizations(dir_path: &Path, args: &SizeCheckArgs) -> kompari::Result<()> {
+    let paths: Vec<_> = list_image_dir(dir_path)?.collect();
+    let progressbar = indicatif::ProgressBar::new(paths.len() as u64);
+    let results = paths
+        .into_par_iter()
+        .map(|path| {
+            let result = check_file_optimizations(path, args);
+            progressbar.inc(1);
+            result
+        })
+        .collect::<kompari::Result<Vec<_>>>()?;
+    progressbar.finish_with_message("");
+    let mut results: Vec<_> = results.into_iter().flatten().collect();
+    results.sort_unstable_by(|a, b| a.path.cmp(&b.path));
+    if print_size_optimization_results(&results)? && !args.optimize {
+        std::process::exit(1);
+    }
+    Ok(())
+}
+
+pub fn print_size_optimization_results(results: &[OptimizationResult]) -> kompari::Result<bool> {
+    if results.is_empty() {
+        println!("Nothing to optimize");
+        return Ok(false);
+    }
+    let stdout = termcolor::StandardStream::stdout(termcolor::ColorChoice::Auto);
+    let mut stdout = stdout.lock();
+    let mut total_size = 0;
+    let mut total_diff = 0;
+    let mut has_error = false;
+    for result in results {
+        let diff = result.old_size - result.new_size;
+        stdout.set_color(ColorSpec::new().set_fg(None))?;
+        write!(stdout, "{} :", result.path.display(),)?;
+        stdout.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
+        write!(stdout, "{} ", format_size(result.new_size, DECIMAL))?;
+        stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?;
+        writeln!(stdout, "(-{})", format_size(diff, DECIMAL))?;
+        total_size += result.new_size;
+        total_diff += diff;
+        if result.size_limit_breached {
+            stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)))?;
+            writeln!(stdout, "Size limit breached")?;
+            has_error = true;
+        }
+        if result.improvement_limit_breached {
+            stdout.set_color(ColorSpec::new().set_fg(Some(Color::Red)))?;
+            writeln!(stdout, "Improvement limit breached")?;
+            has_error = true;
+        }
+    }
+    stdout.set_color(ColorSpec::new().set_fg(None))?;
+    write!(stdout, "----------------------------\nTotal size: ",)?;
+    stdout.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
+    write!(stdout, "{} ", format_size(total_size, DECIMAL))?;
+    stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?;
+    writeln!(stdout, "(-{})", format_size(total_diff, DECIMAL))?;
+    Ok(has_error)
+}
diff --git a/kompari-tasks/src/output.rs b/kompari-tasks/src/output.rs
deleted file mode 100644
index eb0b7b3..0000000
--- a/kompari-tasks/src/output.rs
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright 2025 the Kompari Authors
-// SPDX-License-Identifier: Apache-2.0 OR MIT
-
-use humansize::{format_size, DECIMAL};
-use kompari::OptimizationResult;
-use std::io::Write;
-use termcolor::{Color, ColorSpec, WriteColor};
-
-pub fn print_size_optimization_results(results: &[OptimizationResult]) -> kompari::Result<()> {
-    if results.is_empty() {
-        println!("Nothing to optimize");
-        return Ok(());
-    }
-    let stdout = termcolor::StandardStream::stdout(termcolor::ColorChoice::Auto);
-    let mut stdout = stdout.lock();
-    let mut total_size = 0;
-    let mut total_diff = 0;
-    for result in results {
-        let diff = result.old_size - result.new_size;
-        stdout.set_color(ColorSpec::new().set_fg(None))?;
-        write!(stdout, "{}: ", result.path.display(),)?;
-        stdout.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
-        write!(stdout, "{} ", format_size(result.new_size, DECIMAL))?;
-        stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?;
-        writeln!(stdout, "(-{})", format_size(diff, DECIMAL))?;
-        total_size += result.new_size;
-        total_diff += diff;
-    }
-    stdout.set_color(ColorSpec::new().set_fg(None))?;
-    write!(stdout, "----------------------------\nTotal size: ",)?;
-    stdout.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
-    write!(stdout, "{} ", format_size(total_size, DECIMAL))?;
-    stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?;
-    writeln!(stdout, "(-{})", format_size(total_diff, DECIMAL))?;
-    Ok(())
-}
diff --git a/kompari-tasks/src/task.rs b/kompari-tasks/src/task.rs
index b7daa18..ddb3eb3 100644
--- a/kompari-tasks/src/task.rs
+++ b/kompari-tasks/src/task.rs
@@ -2,8 +2,8 @@
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 
 use crate::args::{Args, Command, DeadSnapshotArgs};
-use crate::output::print_size_optimization_results;
-use kompari::{check_size_optimizations, list_image_dir, list_image_dir_names, DirDiffConfig};
+use crate::check_size_optimizations;
+use kompari::{list_image_dir, list_image_dir_names, DirDiffConfig};
 use kompari_html::{render_html_report, start_review_server, ReportConfig};
 use std::collections::BTreeSet;
 use std::ops::Deref;
@@ -71,9 +71,7 @@ impl Task {
                 )?;
             }
             Command::SizeCheck(sc_args) => {
-                let results =
-                    check_size_optimizations(self.diff_config.left_path(), sc_args.optimize)?;
-                print_size_optimization_results(&results)?;
+                check_size_optimizations(self.diff_config.left_path(), sc_args)?;
             }
         }
         Ok(())
diff --git a/kompari/src/lib.rs b/kompari/src/lib.rs
index 5e7536e..d923b8a 100644
--- a/kompari/src/lib.rs
+++ b/kompari/src/lib.rs
@@ -24,7 +24,6 @@ mod dirdiff;
 mod fsutils;
 mod imageutils;
 mod imgdiff;
-mod optimizations;
 
 /// The image type used throughout Kompari.
 pub type Image = image::RgbaImage;
@@ -53,4 +52,3 @@ pub use dirdiff::{DirDiff, DirDiffConfig, LeftRightError, PairResult};
 pub use fsutils::{list_image_dir, list_image_dir_names};
 pub use imageutils::{bless_image, image_to_png, load_image, optimize_png, SizeOptimizationLevel};
 pub use imgdiff::{compare_images, DiffImage, DiffImageMethod, ImageDifference};
-pub use optimizations::{check_size_optimizations, OptimizationResult};
diff --git a/kompari/src/optimizations.rs b/kompari/src/optimizations.rs
deleted file mode 100644
index a4489d0..0000000
--- a/kompari/src/optimizations.rs
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright 2025 the Kompari Authors
-// SPDX-License-Identifier: Apache-2.0 OR MIT
-
-use crate::{list_image_dir, optimize_png, SizeOptimizationLevel};
-use std::path::{Path, PathBuf};
-
-#[derive(Debug)]
-pub struct OptimizationResult {
-    pub path: PathBuf,
-    pub old_size: usize,
-    pub new_size: usize,
-}
-
-pub fn check_size_optimizations(
-    dir_path: &Path,
-    optimize: bool,
-) -> crate::Result<Vec<OptimizationResult>> {
-    let mut results = Vec::new();
-    for path in list_image_dir(dir_path)? {
-        let old_data = std::fs::read(&path)?;
-        let old_size = old_data.len();
-        let new_data = optimize_png(old_data, SizeOptimizationLevel::High);
-        let new_size = new_data.len();
-        if old_size > new_size {
-            if optimize {
-                std::fs::write(&path, new_data)?;
-            }
-            results.push(OptimizationResult {
-                path,
-                old_size,
-                new_size,
-            });
-        }
-    }
-    results.sort_unstable_by(|a, b| a.path.cmp(&b.path));
-    Ok(results)
-}


Reviews:
user: login: DJMcNab
body: Thanks! It's nice to get more of these ad-hoc features to synergise.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-10T09:15:52Z
user: login: spirali
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T09:20:55Z

Review comments:
diff_hunk: @@ -18,3 +18,5 @@ kompari-html = { path = "../kompari-html" }
 clap = { workspace = true }
 termcolor = "1.4"
 humansize = "2.1"
+indicatif = "0.17"
+rayon = "1.10"  # This should be same as in oxypng
path: kompari-tasks/Cargo.toml
user: login: DJMcNab
body: ```suggestion
rayon = "1.10"  # This should be same as in oxipng
```
created_at: 2025-03-10T08:44:52Z
author_association: MEMBER
diff_hunk: @@ -49,4 +49,16 @@ pub struct SizeCheckArgs {
     /// If enabled, images on file system are replaced with optimized version
     #[arg(long, default_value_t = false)]
     pub optimize: bool,
+
+    /// Command will fail if at least one image can be optimized by more than given ratio.
+    /// E.g. --improvement-limit=0.8 means that error is signaled when an image can be optimized
+    /// more than 80% of its original size
path: kompari-tasks/src/args.rs
user: login: DJMcNab
body: ```suggestion
    /// E.g. --improvement-limit=0.8 means that error is signaled when an image can be optimized
    /// more than 80% of its original size (that is, the optimised image's size is 20% or less of the original)
```

This is the kind of thing which is really hard to say unambiguously, because this could read either as "the optimised image is less than 20% of the original size, or the optimised image is less than 80% of the original size".
created_at: 2025-03-10T08:47:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,119 @@
+// Copyright 2025 the Kompari Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+use kompari::{list_image_dir, optimize_png, SizeOptimizationLevel};
+use rayon::iter::{IntoParallelIterator, ParallelIterator};
+use std::cmp::min;
+use std::path::{Path, PathBuf};
+
+#[derive(Debug)]
+pub struct OptimizationResult {
+    pub path: PathBuf,
+    pub old_size: usize,
+    pub new_size: usize,
+    pub improvement: f32,
+    pub size_limit_breached: bool,
+    pub improvement_limit_breached: bool,
+}
+
+pub fn check_file_optimizations(
+    path: PathBuf,
+    args: &SizeCheckArgs,
+) -> kompari::Result<Option<OptimizationResult>> {
+    let old_data = std::fs::read(&path)?;
+    let old_size = old_data.len();
+    let new_data = optimize_png(old_data, SizeOptimizationLevel::High);
+    let new_size = min(new_data.len(), old_size);
+
+    let is_big = args.size_limit.is_some_and(|limit| {
+        if args.optimize {
+            new_size > limit * 1024 // Limit is given in KiB
+        } else {
+            old_size > limit * 1024
+        }
+    });
+
+    Ok(if old_size > new_size || is_big {
+        if args.optimize {
+            std::fs::write(&path, new_data)?;
+        }
+        let improvement = (old_size as f32 - new_size as f32) / old_size as f32;
+        Some(OptimizationResult {
+            path,
+            old_size,
+            new_size,
+            improvement,
+            size_limit_breached: is_big,
+            improvement_limit_breached: args
+                .improvement_limit
+                .is_some_and(|limit| limit < improvement),
+        })
+    } else {
+        None
+    })
+}
+
+pub fn check_size_optimizations(dir_path: &Path, args: &SizeCheckArgs) -> kompari::Result<()> {
+    let paths: Vec<_> = list_image_dir(dir_path)?.collect();
+    let progressbar = indicatif::ProgressBar::new(paths.len() as u64);
+    let results = paths
+        .into_par_iter()
+        .map(|path| {
+            let result = check_file_optimizations(path, args);
+            progressbar.inc(1);
+            result
+        })
+        .collect::<kompari::Result<Vec<_>>>()?;
+    progressbar.finish_with_message("");
+    let mut results: Vec<_> = results.into_iter().flatten().collect();
+    results.sort_unstable_by(|a, b| a.path.cmp(&b.path));
+    if print_size_optimization_results(&results)? && !args.optimize {
+        std::process::exit(1);
path: kompari-tasks/src/optimizations.rs
user: login: DJMcNab
body: Ideally, we'd use [`ExitCode`](https://doc.rust-lang.org/stable/std/process/struct.ExitCode.html) here, I think. I can understand deferring that for now.
created_at: 2025-03-10T08:53:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,119 @@
+// Copyright 2025 the Kompari Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+use kompari::{list_image_dir, optimize_png, SizeOptimizationLevel};
+use rayon::iter::{IntoParallelIterator, ParallelIterator};
+use std::cmp::min;
+use std::path::{Path, PathBuf};
+
+#[derive(Debug)]
+pub struct OptimizationResult {
+    pub path: PathBuf,
+    pub old_size: usize,
+    pub new_size: usize,
+    pub improvement: f32,
+    pub size_limit_breached: bool,
+    pub improvement_limit_breached: bool,
+}
+
+pub fn check_file_optimizations(
+    path: PathBuf,
+    args: &SizeCheckArgs,
+) -> kompari::Result<Option<OptimizationResult>> {
+    let old_data = std::fs::read(&path)?;
+    let old_size = old_data.len();
+    let new_data = optimize_png(old_data, SizeOptimizationLevel::High);
+    let new_size = min(new_data.len(), old_size);
+
+    let is_big = args.size_limit.is_some_and(|limit| {
+        if args.optimize {
+            new_size > limit * 1024 // Limit is given in KiB
+        } else {
+            old_size > limit * 1024
+        }
+    });
+
+    Ok(if old_size > new_size || is_big {
+        if args.optimize {
+            std::fs::write(&path, new_data)?;
+        }
+        let improvement = (old_size as f32 - new_size as f32) / old_size as f32;
+        Some(OptimizationResult {
+            path,
+            old_size,
+            new_size,
+            improvement,
+            size_limit_breached: is_big,
+            improvement_limit_breached: args
+                .improvement_limit
+                .is_some_and(|limit| limit < improvement),
+        })
+    } else {
+        None
+    })
+}
+
+pub fn check_size_optimizations(dir_path: &Path, args: &SizeCheckArgs) -> kompari::Result<()> {
+    let paths: Vec<_> = list_image_dir(dir_path)?.collect();
+    let progressbar = indicatif::ProgressBar::new(paths.len() as u64);
+    let results = paths
+        .into_par_iter()
+        .map(|path| {
+            let result = check_file_optimizations(path, args);
+            progressbar.inc(1);
+            result
+        })
+        .collect::<kompari::Result<Vec<_>>>()?;
+    progressbar.finish_with_message("");
+    let mut results: Vec<_> = results.into_iter().flatten().collect();
+    results.sort_unstable_by(|a, b| a.path.cmp(&b.path));
+    if print_size_optimization_results(&results)? && !args.optimize {
+        std::process::exit(1);
+    }
+    Ok(())
+}
+
+use crate::args::SizeCheckArgs;
+use humansize::{format_size, DECIMAL};
+use std::io::Write;
+use termcolor::{Color, ColorSpec, WriteColor};
path: kompari-tasks/src/optimizations.rs
user: login: DJMcNab
body: These imports should be at the top of the file - groups like this are self-growing, because rust-analyzer adds imports to the final group in the file.
created_at: 2025-03-10T08:54:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,119 @@
+// Copyright 2025 the Kompari Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+use kompari::{list_image_dir, optimize_png, SizeOptimizationLevel};
+use rayon::iter::{IntoParallelIterator, ParallelIterator};
+use std::cmp::min;
+use std::path::{Path, PathBuf};
+
+#[derive(Debug)]
+pub struct OptimizationResult {
+    pub path: PathBuf,
+    pub old_size: usize,
+    pub new_size: usize,
+    pub improvement: f32,
+    pub size_limit_breached: bool,
+    pub improvement_limit_breached: bool,
+}
+
+pub fn check_file_optimizations(
+    path: PathBuf,
+    args: &SizeCheckArgs,
+) -> kompari::Result<Option<OptimizationResult>> {
+    let old_data = std::fs::read(&path)?;
+    let old_size = old_data.len();
+    let new_data = optimize_png(old_data, SizeOptimizationLevel::High);
+    let new_size = min(new_data.len(), old_size);
+
+    let is_big = args.size_limit.is_some_and(|limit| {
+        if args.optimize {
+            new_size > limit * 1024 // Limit is given in KiB
+        } else {
+            old_size > limit * 1024
+        }
+    });
+
+    Ok(if old_size > new_size || is_big {
+        if args.optimize {
+            std::fs::write(&path, new_data)?;
+        }
+        let improvement = (old_size as f32 - new_size as f32) / old_size as f32;
+        Some(OptimizationResult {
+            path,
+            old_size,
+            new_size,
+            improvement,
+            size_limit_breached: is_big,
+            improvement_limit_breached: args
+                .improvement_limit
+                .is_some_and(|limit| limit < improvement),
+        })
+    } else {
+        None
+    })
+}
+
+pub fn check_size_optimizations(dir_path: &Path, args: &SizeCheckArgs) -> kompari::Result<()> {
+    let paths: Vec<_> = list_image_dir(dir_path)?.collect();
+    let progressbar = indicatif::ProgressBar::new(paths.len() as u64);
+    let results = paths
+        .into_par_iter()
path: kompari-tasks/src/optimizations.rs
user: login: DJMcNab
body: All the instances of `oxipng` which get started use the same thread pool, right? (i.e. they're not competing for cores)
created_at: 2025-03-10T09:09:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,119 @@
+// Copyright 2025 the Kompari Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+use kompari::{list_image_dir, optimize_png, SizeOptimizationLevel};
+use rayon::iter::{IntoParallelIterator, ParallelIterator};
+use std::cmp::min;
+use std::path::{Path, PathBuf};
+
+#[derive(Debug)]
+pub struct OptimizationResult {
+    pub path: PathBuf,
+    pub old_size: usize,
+    pub new_size: usize,
+    pub improvement: f32,
+    pub size_limit_breached: bool,
+    pub improvement_limit_breached: bool,
+}
+
+pub fn check_file_optimizations(
+    path: PathBuf,
+    args: &SizeCheckArgs,
+) -> kompari::Result<Option<OptimizationResult>> {
+    let old_data = std::fs::read(&path)?;
+    let old_size = old_data.len();
+    let new_data = optimize_png(old_data, SizeOptimizationLevel::High);
+    let new_size = min(new_data.len(), old_size);
+
+    let is_big = args.size_limit.is_some_and(|limit| {
+        if args.optimize {
+            new_size > limit * 1024 // Limit is given in KiB
+        } else {
+            old_size > limit * 1024
+        }
+    });
+
+    Ok(if old_size > new_size || is_big {
+        if args.optimize {
+            std::fs::write(&path, new_data)?;
+        }
+        let improvement = (old_size as f32 - new_size as f32) / old_size as f32;
+        Some(OptimizationResult {
+            path,
+            old_size,
+            new_size,
+            improvement,
+            size_limit_breached: is_big,
+            improvement_limit_breached: args
+                .improvement_limit
+                .is_some_and(|limit| limit < improvement),
+        })
+    } else {
+        None
+    })
+}
+
+pub fn check_size_optimizations(dir_path: &Path, args: &SizeCheckArgs) -> kompari::Result<()> {
+    let paths: Vec<_> = list_image_dir(dir_path)?.collect();
+    let progressbar = indicatif::ProgressBar::new(paths.len() as u64);
+    let results = paths
+        .into_par_iter()
path: kompari-tasks/src/optimizations.rs
user: login: spirali
body: Yes, it should share the thread pool and generate same tasks for work stealing. At least as the Rayon version matches.
created_at: 2025-03-10T09:20:55Z
author_association: COLLABORATOR

PR comments:
user: login: DJMcNab
created_at: 2025-03-10T13:35:43Z
body: Does this fix #25, incidentally?
author_association: MEMBER
user: login: spirali
created_at: 2025-03-12T08:26:09Z
body: > Does this fix #25, incidentally?

Yes. It solves #25.
author_association: COLLABORATOR