diff --git a/src/main.rs b/src/main.rs index 65a1041..177daf6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -224,7 +224,8 @@ fn format_duration(dur: Duration) -> String { mod tests { use clap::Parser; - use super::{Args, split_csv}; + use super::{Args, calc_savings, parse_compression, split_csv}; + use crate::precompress::Algorithm; #[test] fn args_respect_ignore_by_default() { @@ -244,4 +245,73 @@ mod tests { let values = split_csv(vec![String::from("a,b"), String::from("c")]).collect::>(); assert_eq!(values, vec!["a", "b", "c"]); } + + #[test] + fn args_accept_repeated_and_comma_separated_filters() { + let args = Args::parse_from([ + "precompress", + "--extensions", + "js,css", + "--extensions", + "html", + "--exclude", + "dist/**,build/**", + "--exclude", + "*.map", + ".", + ]); + + assert_eq!( + split_csv(args.extensions.expect("extensions should be parsed")).collect::>(), + vec!["js", "css", "html"] + ); + assert_eq!( + split_csv(args.exclude.expect("exclude should be parsed")).collect::>(), + vec!["dist/**", "build/**", "*.map"] + ); + } + + #[test] + fn parse_compression_supports_aliases_and_quality_overrides() { + let (algorithms, quality) = parse_compression(Some(vec![ + String::from("br:11,gzip:5"), + String::from("zst:-3"), + ])); + + assert!(algorithms.brotli); + assert!(!algorithms.deflate); + assert!(algorithms.gzip); + assert!(algorithms.zstd); + assert_eq!(quality.brotli, 11); + assert_eq!(quality.gzip, 5); + assert_eq!(quality.zstd, -3); + } + + #[test] + fn parse_compression_defaults_match_enabled_algorithms() { + let (algorithms, quality) = parse_compression(None); + let enabled = algorithms + .iter() + .map(|alg| alg.to_string()) + .collect::>(); + + assert_eq!( + enabled, + vec![ + Algorithm::Brotli.to_string(), + Algorithm::Gzip.to_string(), + Algorithm::Zstd.to_string(), + ] + ); + assert_eq!(quality.brotli, 10); + assert_eq!(quality.gzip, 7); + assert_eq!(quality.zstd, 19); + } + + #[test] + fn calc_savings_handles_zero_positive_and_negative_values() { + assert_eq!(calc_savings(0, 0), 0); + assert_eq!(calc_savings(50, 50), 50); + assert_eq!(calc_savings(-50, 100), 0); + } } diff --git a/src/precompress.rs b/src/precompress.rs index 4dcab96..8daa1ca 100644 --- a/src/precompress.rs +++ b/src/precompress.rs @@ -440,6 +440,7 @@ static EXTENSIONS: phf::Set<&'static str> = phf::phf_set! { #[cfg(test)] mod tests { use std::{ + collections::HashSet, fs, io::{self, Write}, path::{Path, PathBuf}, @@ -555,6 +556,123 @@ mod tests { Ok(()) } + #[test] + fn compressor_uses_default_and_custom_extension_filters() { + let default = Compressor::new(1, 1, Quality::default(), Algorithms::default(), None, false); + let custom = Compressor::new( + 1, + 1, + Quality::default(), + Algorithms::default(), + Some(HashSet::from([String::from("bin")])), + false, + ); + + assert!(default.should_compress(Path::new("asset.js"))); + assert!(!default.should_compress(Path::new("asset.bin"))); + assert!(!default.should_compress(Path::new("LICENSE"))); + assert!(custom.should_compress(Path::new("asset.bin"))); + assert!(!custom.should_compress(Path::new("asset.js"))); + } + + #[test] + fn compressor_skips_small_and_filtered_out_files() -> Result<()> { + let root = test_dir("skip-files"); + fs::write(root.join("small.js"), "tiny")?; + fs::write(root.join("note.txt"), "ignored extension")?; + + let compressor = Compressor::new( + 1, + 32, + Quality::default(), + Algorithms { + brotli: false, + deflate: false, + gzip: true, + zstd: false, + }, + Some(HashSet::from([String::from("js")])), + false, + ); + compressor.precompress(&root, &WalkOptions::default())?; + let stats = compressor.finish(); + + assert_eq!(stats.num_source_files, 0); + assert_eq!(stats.num_errors, 0); + assert!(!root.join("small.js.gz").exists()); + assert!(!root.join("note.txt.gz").exists()); + + fs::remove_dir_all(root)?; + Ok(()) + } + + #[test] + fn compressor_overwrites_existing_outputs() -> Result<()> { + let root = test_dir("overwrite-output"); + let src_path = root.join("asset.js"); + fs::write(&src_path, "const payload = 'hello world';\n".repeat(256))?; + let dst_path = root.join("asset.js.gz"); + fs::write(&dst_path, b"stale artifact")?; + + let original = fs::read(&dst_path)?; + let compressor = Compressor::new( + 1, + 1, + Quality::default(), + Algorithms { + brotli: false, + deflate: false, + gzip: true, + zstd: false, + }, + None, + false, + ); + compressor.precompress(&root, &WalkOptions::default())?; + let stats = compressor.finish(); + + assert_eq!(stats.num_source_files, 1); + assert_eq!(stats.num_errors, 0); + assert_ne!(fs::read(&dst_path)?, original); + + fs::remove_dir_all(root)?; + Ok(()) + } + + #[test] + fn compressor_cleans_up_temp_output_after_failed_replace() -> Result<()> { + let root = test_dir("cleanup-failed-replace"); + let src_path = root.join("asset.js"); + fs::write(&src_path, "const payload = 'hello world';\n".repeat(256))?; + let dst_path = root.join("asset.js.gz"); + let tmp_path = tmp_output_path(&dst_path); + fs::create_dir(&dst_path)?; + + let compressor = Compressor::new( + 1, + 1, + Quality::default(), + Algorithms { + brotli: false, + deflate: false, + gzip: true, + zstd: false, + }, + None, + false, + ); + compressor.precompress(&root, &WalkOptions::default())?; + let stats = compressor.finish(); + + assert_eq!(stats.num_source_files, 0); + assert_eq!(stats.num_errors, 1); + assert!(dst_path.is_dir()); + assert!(!tmp_path.exists()); + + fs::remove_dir_all(root)?; + Ok(()) + } + fn walk_paths(root: &Path, options: &WalkOptions) -> Result> { let mut paths = build_walk(root, options)? .filter_map(|entry| entry.ok())