A real HeyDeer review, from first finding to merge
View on GitHubThis pull request adds recursive folders to slimdeer, our open-source image compressor. HeyDeer reviewed it on every push: 8 findings over 5 reviews (2 high, 3 medium, 3 low), each fixed by the author, until HeyDeer approved it.
Compress whole folders with -r #6
main from recursive-foldersslimdeer -r assets compresses every PNG and JPEG in a folder and the folders below it, the way most projects keep their images.
slimdeer -r assets # beside each image, as NAME.min.EXT
slimdeer -r assets -d dist # into dist, in the same folders as under assets
-
What it picks up: PNG and JPEG files by extension (any case). Hidden files and folders (
.git,.cache) and earlierNAME.min.EXToutputs are left out, so running it on the same folder again doesn't compress the outputs a second time. -
Order: files are taken in path order, so
-vand--jsonlist them the same way on every run. -
Mistakes it refuses: a folder given without
-ris reported and skipped (exit 1).-dnaming the folder-rreads is refused, since-dkeeps the inputs' names and would write over them.-owith-ris refused, as it names a single output. -
Code layout:
main.rshad grown to the whole command line (about 470 lines), and folders add more path handling, so it is split intosrc/cli/:args.rs: options and help;files.rs: which files, and where their outputs go;run.rs: running a command;encode.rs: formats and encoders;report.rs:-vand--json;tools.rs: the--scoreand--upscalehelpers.
Apart from
-r, nothing changes in behaviour. -
Site: the terminal on the page showed the JSON report the command line no longer prints by default; it now shows a folder run with
-v.
Tests: two new CLI tests cover recursion (nested folders, mixed-case extensions, hidden folders, other files, a rerun) and the mirrored -d tree with its refusals. cargo fmt --check, cargo clippy --all-targets -D warnings (with and without optional features) and cargo test --release pass.
🤖 Generated with Claude Code
7e086b8The latest updates on your projects. Learn more about Vercel for GitHub.
| Project | Deployment | Actions | Updated |
|---|---|---|---|
| slimdeer | Ready | Preview | Nov 4, 2026 5:09am UTC |
🔎 Review completed. 5 findings need attention.
ℹ️ About this review
- Risk level: medium. The PR adds recursive folder handling to a local image compressor and splits main.rs into src/cli/ modules, so a missed mistake in the path mapping or the -d refusal logic could write compressed files to the wrong place, but it would only affect the user's own output files and is contained by a revert or a follow-up fix.
- Review depth: Standard
- Trigger source: Automatic (PR opened)
HeyDeer reviews public repositories for free. Set it up for your open-source project →
src/cli/files.rsResolved
+ let extension = match path.extension() {+ Some(own) if same => own.to_string_lossy(),+ _ => format.extension().into(),+ };+ match (dir, &input.root) {+ // Command::run has checked that -d's folder isn't the one -r reads, so a file from it can't+ // land on itself, and needn't be checked again one at a time+ (Some(dir), Some(root)) => {+ let folder = path.parent().and_then(|parent| parent.strip_prefix(root).ok()).unwrap_or(Path::new(""));+ Destination::File(dir.join(folder).join(format!("{stem}.{extension}")), false)
High (Correctness): Equivalent directory paths bypass protection against overwriting originals
When -r and -d identify the same directory using different spellings, destination disables the overwrite check and replaces source images with their compressed versions. The lexical comparison in Command::run does not establish that the directories differ.
Learn more
For example, slimdeer -r assets -d ./assets passes the comparison at run.rs:39. An input assets/photo.png maps to ./assets/photo.png with Destination::File(..., false), bypassing the canonical-path check at run.rs:104; fs::write then overwrites the original. Absolute versus relative paths and directory symlinks provide other aliases. Before this change, generated -d destinations enabled the canonical-path check.
Suggested fix: Keep the per-file canonical-path overwrite check enabled for recursive destinations, including when directory names are aliases.
luin · Nov 4, 4:40 AM UTC
Fixed in 54615a8. -d is now compared with each folder -r reads by real path (fs::canonicalize), so ./assets, an absolute path, assets/../assets or a link to it is refused like assets. Outputs from a tree are also checked against their input again before they're written (Destination::File(.., true)), as every other made-up name is. The new test covers all four spellings and checks the original is untouched.
src/cli/files.rsResolved
++/// The PNG and JPEG files under `root`, by their extension, in order: every folder below it, but+/// not hidden files or folders, nor slimdeer's own NAME.min.EXT outputs, so that running it on a+/// folder again doesn't compress those too.+pub fn images_under(root: &Path) -> io::Result<Vec<PathBuf>> {+ let mut found = Vec::new();+ let mut folders = vec![root.to_path_buf()];+ while let Some(folder) = folders.pop() {+ for entry in fs::read_dir(&folder)? {+ let path = entry?.path();
Medium (Reliability): An unreadable subfolder discards all images collected from its root
If any descendant directory cannot be read, images_under returns an error and discards every image collected from that root. Accessible sibling images are consequently skipped, contrary to the documented behavior that failures do not stop other files.
Learn more
With readable assets/a.png and an unreadable assets/private/, either read_dir failure propagates through ?. Command::inputs only extends its input list on Ok(found) at run.rs:73, so a.png is never compressed regardless of traversal order. The error also names assets rather than the failing descendant.
Suggested fix: Preserve successfully discovered images while reporting individual traversal failures and retaining a nonzero exit status.
luin · Nov 4, 4:40 AM UTC
Fixed in 54615a8. images_under now returns the images it found plus each folder it couldn't read. The run reports those folders by their own path (tree/locked: permission denied), compresses everything else, and exits 1. Covered by a test that skips its assertions when it runs as root, since root can still read the folder.
src/cli/files.rsResolved
+ let mut found = Vec::new();+ let mut folders = vec![root.to_path_buf()];+ while let Some(folder) = folders.pop() {+ for entry in fs::read_dir(&folder)? {+ let path = entry?.path();+ if path.file_name().is_some_and(|name| name.to_string_lossy().starts_with('.')) {+ continue;+ }+ if path.is_dir() {+ folders.push(path);
Low (Reliability): Directory symlinks extend compression beyond the selected tree
When a selected tree contains a directory symlink, images_under follows it through path.is_dir() and compresses images in the linked directory too. Without -d, this writes generated files outside the selected tree; ancestor links also cause repeated traversal.
Learn more
For assets/shared pointing to a separate shared-images directory, slimdeer -r assets discovers assets/shared/photo.png and writes assets/shared/photo.min.png, which resides in shared-images. No symlink exclusion or visited-directory tracking exists. A link back to an ancestor repeatedly collects the same physical images under increasingly long paths until path resolution stops further descent.
Suggested fix: Use non-following directory-entry metadata to exclude directory symlinks, or explicitly bound and document traversal if following them is required.
luin · Nov 4, 4:40 AM UTC
Fixed in 54615a8. The walk uses DirEntry::file_type, which doesn't follow links, so linked folders are skipped: no outputs outside the tree, and no loops. Linked image files are still compressed. A test covers a link out of the tree and one back up it.
src/cli/files.rsResolved
+ while let Some(folder) = folders.pop() {+ for entry in fs::read_dir(&folder)? {+ let path = entry?.path();+ if path.file_name().is_some_and(|name| name.to_string_lossy().starts_with('.')) {+ continue;+ }+ if path.is_dir() {+ folders.push(path);+ } else if is_image(&path) && !is_output(&path) {+ found.push(path);
Medium (Correctness): Nested output directories are compressed again on subsequent runs
When -d lies inside a recursive input root, images_under includes previous outputs because they retain ordinary image names. Repeating the command re-encodes those outputs and creates another nested output tree.
Learn more
Starting with assets/a.png, slimdeer -r assets -d assets/dist creates assets/dist/a.png. The second invocation collects both files and writes assets/dist/dist/a.png; subsequent invocations keep adding levels. is_output only excludes stems ending in .min, and the directory guard only rejects equality with the root.
Suggested fix: Exclude the destination directory by filesystem identity during traversal, or reject destinations located inside recursive roots.
luin · Nov 4, 4:40 AM UTC
Fixed in 54615a8. When -d is inside a tree -r reads, the walk skips that folder, matched by real path. A second -r assets -d assets/min no longer creates assets/min/min; the test runs it twice.
src/cli/files.rsResolved
+ let extension = match path.extension() {+ Some(own) if same => own.to_string_lossy(),+ _ => format.extension().into(),+ };+ match (dir, &input.root) {+ // Command::run has checked that -d's folder isn't the one -r reads, so a file from it can't+ // land on itself, and needn't be checked again one at a time+ (Some(dir), Some(root)) => {+ let folder = path.parent().and_then(|parent| parent.strip_prefix(root).ok()).unwrap_or(Path::new(""));+ Destination::File(dir.join(folder).join(format!("{stem}.{extension}")), false)
Medium (Correctness): Recursive inputs can silently overwrite each other's outputs
When recursively discovered files map to the same destination, destination produces identical paths and the later write silently replaces the earlier result. The command can report success for both files while retaining only one output.
Learn more
slimdeer -r a b -d dist maps both a/x.png and b/x.png to dist/x.png. Likewise, slimdeer -r icons --format webp maps icons/logo.jpg and icons/logo.png to icons/logo.min.webp. The compression loop tracks no destination ownership, and fs::write replaces existing outputs. Although explicit file lists could already collide, recursive collection newly introduces these collisions from folder arguments.
Suggested fix: Detect destination collisions across the collected inputs and report them without overwriting an earlier result.
luin · Nov 4, 4:40 AM UTC
Fixed in 54615a8. The run records each output path it writes and the input it came from. A later input that maps to the same path is reported (dist/x.png is already a/x.png's output) and skipped, and the exit code is 1. This also covers the logo.png/logo.jpg with --format webp case and the explicit-file-list case. Tested with -r a b -d dist.
luin · Nov 4, 4:49 AM UTC
Follow-up in 1ae86c1, for the earlier concern about aliases. Outputs are now recorded by the file itself (device and inode on Unix, canonical path elsewhere) instead of by path text. On a volume that ignores case, dist/X.png and dist/x.png are one file, so the second is reported (is already a/X.png's output); output paths that go through a link are caught the same way. The test checks this on case-insensitive volumes, and on case-sensitive ones that both files are written.
54615a8🔎 Review completed. 1 finding needs attention.
1 issue in the inline comments.
Earlier concerns to address
-
🟡 Medium (Correctness): Output aliases bypass collision detection View discussion
The new map prevents identical destination paths from being written twice, but its literal keys miss filesystem aliases. Differently capitalized filenames from separate roots still overwrite one output on a case-insensitive volume.
💡 Not planning to address an earlier concern?
Someone with write access can explain why in the original discussion, or resolve it. HeyDeer will reconsider the decision in the next review.
ℹ️ About this review
- Risk level: medium. The follow-up adds folder-link skipping, a collision check for outputs that share a name, and a canonical-path check for -d inside the tree, all in the local image CLI's path mapping and write logic, so a missed mistake could write compressed files to the wrong place, but it would only touch the user's own output files and a revert or follow-up fix would contain it.
- Review depth: Standard
- Trigger source: Automatic (New commits pushed)
src/cli/run.rsResolved
+ }+ let own = if jpeg { Format::Jpeg } else { Format::Png };+ let format = self.format.or(named_by(self.output.as_deref())).unwrap_or(own);+ let destination = destination(input, self.output.as_deref(), self.dir.as_deref(), format, format == own);+ match &destination {+ Destination::Stdout if io::stdout().is_terminal() => {+ return Err("an image can't go to the terminal: redirect stdout, or use -o".into());+ }+ Destination::File(path, true) if path.exists() && fs::canonicalize(path)? == fs::canonicalize(&input.path)? => {+ return Err(format!("{} would replace the input; name another with -o", path.display()).into());
High (Correctness): Generated destinations can still overwrite another input
When -d is an ancestor of a recursive root, compress checks the destination against only the current input. A generated output can therefore overwrite a different original image.
Learn more
With distinct images at assets/icons/b.png and assets/icons/icons/b.png, run slimdeer -r assets/icons -d assets. The first maps to assets/b.png; the second maps to assets/icons/b.png, replacing the first original. Neither same_folder nor the destination-subtree exclusion rejects this layout. The overwritten path is absent from written, since that map contains outputs rather than input paths. This recursive mapping did not exist at the merge base.
Suggested fix: Validate generated destinations against all collected input paths while preserving explicit replacement through -o.
luin · Nov 4, 4:48 AM UTC
Fixed in 1ae86c1. Inputs and outputs are now tracked by the file itself (device and inode on Unix, canonical path elsewhere), and each made-up output is checked against every input, not just its own. With -r assets/icons -d assets, icons/icons/b.png is now refused (assets/icons/b.png is another input, which this would replace) and the original stays as it was. This also covers files listed by hand, which had the same problem before this PR (x.png sub/x.png -d sub). -o still replaces on purpose. Added a test.
1ae86c1🔎 Review completed. 1 finding needs attention.
ℹ️ About this review
- Risk level: medium. The follow-up swaps the overwrite guards for inode-based file identity and adds an input-versus-output collision check in the local CLI's output path logic, so a missed mistake could overwrite the user's own input images, but it would only affect files the user chose to compress and a follow-up fix or revert would contain it.
- Review depth: Standard
- Trigger source: Automatic (New commits pushed)
src/cli/run.rsResolved
+ Destination::Stdout if io::stdout().is_terminal() => {+ return Err("an image can't go to the terminal: redirect stdout, or use -o".into());+ }+ Destination::File(path, checked) => {+ if let Some(id) = file_id(path) {+ if let Some(owner) = files.outputs.get(&id) {+ return Err(format!("{} is already {owner}'s output; give one of them another name or folder", path.display()).into());+ }+ match files.inputs.get(&id) {+ Some(owner) if *checked && *owner == input.name() => {
Low (Correctness): File aliases receive a misleading replacement diagnostic
When the input list includes two spellings of one file, compress compares display names to classify self-replacement and can incorrectly report that the destination is another input. The write is safely refused, but the diagnostic misidentifies the conflict.
Some(_) if *checked && file_id(&input.path).as_ref() == Some(&id) => {
Learn more
For slimdeer -d . a.png ./a.png, Files.inputs retains ./a.png as the name for their shared identity. Processing a.png produces destination ./a.png, but the stored name differs from input.name(), so execution reaches the “another input” error instead of the self-replacement error. The previous canonical-path comparison classified this correctly.
Suggested fix: Classify self-replacement using the current input's filesystem identity while preserving explicit replacement through -o.
luin · Nov 4, 4:53 AM UTC
Fixed in f5e3509, as suggested: whether an output lands on its own input is now judged by the current input's file identity, not its name. slimdeer -d . a.png ./a.png now reports "would replace the input" for both, and the test checks that. -o still replaces on purpose.
f5e3509🔎 Review completed. 1 finding needs attention.
ℹ️ About this review
- Risk level: medium. The follow-up changes the input-collision check in the local CLI's output path logic to compare file identity instead of names, so a missed mistake could still overwrite the user's own input images, but only for files the user explicitly points the tool at and the damage is limited to local files that a follow-up fix or revert would restore.
- Review depth: Standard
- Trigger source: Automatic (New commits pushed)
src/cli/run.rsResolved
+ }+ Ok(())+ }+}++/// The files a run reads and writes, and whose they are: a name slimdeer makes up mustn't replace an+/// input, nor any output another input's (x.png from two folders -r reads into one -d, X.png and+/// x.png where a volume ignores case, logo.png and logo.jpg as WebP).+struct Files {+ inputs: HashMap<FileId, String>,
Low (Maintainability): Files.inputs still stores input names that nothing reads any more
After the identity comparison replaces the name comparison, Files.inputs still builds and stores an unused name for every input. This leaves redundant ownership state in a collection now used only for membership checks.
Learn more
run constructs (file_id, input.name()) pairs at line 50. The only lookup is at line 115, and both matching arms discard the value with Some(_). A HashSet<FileId> would directly express the membership requirement and remove the unused values; outputs still needs its owner names for diagnostics.
Suggested fix: Represent input membership with a HashSet<FileId> while preserving output owner names for collision diagnostics.
luin · Nov 4, 4:58 AM UTC
Fixed in 82c2b11. Files.inputs is now a HashSet<FileId>, used only to check membership. Files.outputs keeps the owner names it needs for the collision message.
82c2b11✅ Review completed. No issues found.
ℹ️ About this review
- Review summary: The input-set refactor preserves collision checks and file-alias diagnostics. Input names are no longer stored unnecessarily; output owner names remain available for diagnostics.
- Risk level: medium. The follow-up is a behaviour-preserving refactor of the input-collision guard in the local CLI's output path, where inputs are now a set of file identities instead of a map to names, so a missed mistake could still overwrite the user's own input images, but only for files they explicitly point the tool at and the damage is local and fixable by a follow-up change.
- Review depth: Standard
- Trigger source: Automatic (New commits pushed)
eafae5e into main · Nov 4, 5:09 AM UTC7 checks passedPull request successfully merged and closed
You’re all set — the branch has been merged.

Compress whole folders with -r
luin committed · Nov 4, 4:12 AM UTC
7e086b8
Keep -r from replacing images, following links or losing outputs
luin committed · Nov 4, 4:40 AM UTC
54615a8
Compare outputs with inputs and each other as files, not paths
luin committed · Nov 4, 4:48 AM UTC
1ae86c1
Tell an input apart from itself by file, not by name
luin committed · Nov 4, 4:53 AM UTC
f5e3509
Keep the run's inputs as a set of files
luin committed · Nov 4, 4:57 AM UTC
82c2b11
All checks have passed6 successful checks
HeyDeer Review · Review completed — no issues found
HeyDeer- G
CI / arm (pull_request) · Successful in 4m 58s
GitHub Actions - G
CI / arm (push) · Successful in 4m 57s
GitHub Actions - G
CI / rust (pull_request) · Successful in 6m 23s
GitHub Actions - G
CI / rust (push) · Successful in 6m 31s
GitHub Actions - V
Vercel Preview Comments · ✅ No unresolved feedback
Vercel
README.md+2 −1
@@ -112,14 +112,15 @@ The [research notes](experiments/palette-feedback/FINDINGS.md) follow every step```bashslimdeer photo.jpg # photo.min.jpg, at a quality picked for itslimdeer *.png -d dist # dist/NAME.png for each+slimdeer -r assets -d dist # every PNG and JPEG under assets, into the same folders under distslimdeer logo.png --format webp # logo.min.webp (also avif; from a PNG or a JPEG)slimdeer in.png -o out.avif # out.avif, the format as its name saysslimdeer shot.png --target 90 # a fixed SSIMULACRA2 score: 90 looks lossless, 70 is high qualityslimdeer - < in.png > out.png # stdin to stdoutslimdeer --help```-Like `cp`, it says nothing unless something goes wrong: `-v` says what each file came to, and `--json` prints each file's report, a JSON line per file. A file that fails doesn't stop the others (slimdeer then exits 1). A name it makes up never replaces an input; `-o` can.+Like `cp`, it says nothing unless something goes wrong: `-v` says what each file came to, and `--json` prints each file's report, a JSON line per file. A file that fails doesn't stop the others (slimdeer then exits 1). A name it makes up never replaces an input; `-o` can. With `-r`, a folder stands for the PNGs and JPEGs in it and every folder below it: hidden ones, linked folders, earlier `NAME.min.EXT` outputs and a `-d` folder inside it are left out, so running it again on the same folder doesn't compress them twice. Two inputs that would get the same output name are reported, not written over each other.From source: `cargo build --release`, and the binary is `target/release/slimdeer`.
src/cli/args.rs+172 −0
@@ -0,0 +1,172 @@+//! The options, and what's wrong with a command line before any file is read.++use std::path::PathBuf;++use slimdeer::search;++use super::encode::{named_by, Format};++const HELP: &str = "slimdeer: smaller PNG and JPEG files, or WebP and AVIF, that look the same++Usage: slimdeer [OPTIONS] FILE...++Writes each FILE, a PNG or a JPEG, smaller, at a quality picked for it, next to it+as NAME.min.EXT. Says nothing unless something goes wrong.++Examples:+ slimdeer photo.jpg writes photo.min.jpg+ slimdeer *.png -d dist writes dist/NAME.png for each+ slimdeer -r assets -d dist writes every image under assets to dist, in the same folders+ slimdeer logo.png --format webp writes logo.min.webp+ slimdeer in.png -o out.avif writes out.avif, the format as its name says+ slimdeer shot.png --target 90 keeps a score of at least 90+ slimdeer - < in.png > out.png from stdin to stdout++Options:+ -o, --output FILE Write the one FILE's output here (- for stdout)+ -d, --dir DIR Write the outputs into DIR, under the inputs' names+ -r, --recursive Take a FILE that is a folder as the PNGs and JPEGs in it and+ below it; -d's folder gets the same layout+ --format FORMAT png, jpeg, webp or avif [default: the input's, or -o's]+ (a PNG can't become a JPEG, nor a JPEG a PNG)+ --target T The SSIMULACRA2 score to keep, 0 to 100: 90 looks lossless,+ 70 is high quality [default: auto, about TinyPNG's, per image]+ -v, --verbose Say what each file came to, on stderr+ --json Print each file's report, a JSON line, on stdout+ -h, --help Print this help+ -V, --version Print the version++Also:+ slimdeer ORIGINAL --score OTHER Print OTHER's SSIMULACRA2 score against ORIGINAL";++pub struct Command {+ pub(super) files: Vec<String>,+ pub(super) output: Option<String>,+ pub(super) dir: Option<PathBuf>,+ pub(super) recursive: bool,+ pub(super) format: Option<Format>,+ pub(super) verbose: bool,+ pub(super) json: bool,+ pub(super) score_against: Option<String>,+ pub(super) upscale: Option<usize>,+ pub(super) settings: slimdeer::Settings,+}++/// The command line, or None where it asked for the help or the version (printed), or what's wrong+/// with it.+pub fn parse(args: impl Iterator<Item = String>) -> Result<Option<Command>, String> {+ let mut command = Command {+ files: Vec::new(),+ output: None,+ dir: None,+ recursive: false,+ format: None,+ verbose: false,+ json: false,+ score_against: None,+ upscale: None,+ settings: slimdeer::Settings { clock: Some(super::now_ms), ..Default::default() },+ };+ let settings = &mut command.settings;+ let mut args = args.peekable();+ let mut options = true;+ while let Some(arg) = args.next() {+ if !options || arg == "-" || !arg.starts_with('-') {+ command.files.push(arg);+ continue;+ }+ // --name=value as well as --name value+ let (name, mut inline) = match arg.split_once('=') {+ Some((name, value)) if arg.starts_with("--") => (name.to_string(), Some(value.to_string())),+ _ => (arg.clone(), None),+ };+ let mut value = || inline.take().or_else(|| args.next()).ok_or_else(|| format!("{name} needs a value"));+ match name.as_str() {+ "--" => options = false,+ "-o" | "--output" => command.output = Some(value()?),+ "-d" | "--dir" => command.dir = Some(value()?.into()),+ "-r" | "--recursive" => command.recursive = true,+ "--format" => {+ let v = value()?;+ command.format = Some(Format::named(&v).ok_or_else(|| format!("unknown format: {v} (png, jpeg, webp or avif)"))?);+ }+ "--target" => {+ let v = value()?;+ settings.target = match v.as_str() {+ "auto" => None,+ _ => match v.parse::<f64>() {+ Ok(t) if (0.0..=100.0).contains(&t) => Some(t),+ _ => return Err(format!("--target: a score from 0 to 100, or auto, not {v}")),+ },+ };+ }+ "-v" | "--verbose" => command.verbose = true,+ "--json" => command.json = true,+ "-h" | "--help" => {+ println!("{HELP}");+ return Ok(None);+ }+ "-V" | "--version" => {+ println!("slimdeer {}", env!("CARGO_PKG_VERSION"));+ return Ok(None);+ }+ "--score" => command.score_against = Some(value()?),+ "--upscale" => command.upscale = Some(number(&name, value()?)?),+ "--preview-pixels" => settings.preview_pixels = number(&name, value()?)?,+ "--variants" => settings.variants = search::parse_variants(&value()?).map_err(|e| format!("--variants: {e}"))?,+ "--webp-method" => settings.webp.method = number(&name, value()?)?,+ "--webp-lossless-level" => settings.webp.slow_lossless_level = Some(number(&name, value()?)?),+ "--webp-sharp-yuv" => settings.webp.sharp_yuv = value()? != "0",+ "--webp-alpha-filter" => settings.webp.alpha_filtering = number(&name, value()?)?,+ "--webp-autofilter" => settings.webp.autofilter = value()? != "0",+ "--webp-large-alpha-effort" => settings.webp.large_alpha_effort = number(&name, value()?)?,+ "--webp-reference" => {+ let v = value()?;+ settings.webp.reference_quality = if v == "none" { None } else { Some(number(&name, v)?) };+ }+ _ => return Err(format!("unknown option: {arg}")),+ }+ if inline.is_some() {+ return Err(format!("{name} takes no value"));+ }+ }+ let files = &command.files;+ if files.is_empty() {+ return Err("no input files".into());+ }+ if (command.score_against.is_some() || command.upscale.is_some()) && files.len() > 1 {+ return Err("--score and --upscale take one file".into());+ }+ if command.upscale.is_some() && command.output.is_none() {+ return Err("--upscale needs -o".into());+ }+ if command.output.is_some() && command.dir.is_some() {+ return Err("-o and -d both say where to write; give one".into());+ }+ if command.output.is_some() && (files.len() > 1 || command.recursive) {+ return Err("-o names one output; for several files, use -d DIR".into());+ }+ if files.iter().filter(|f| *f == "-").count() > 1 {+ return Err("stdin (-) can be read once".into());+ }+ if files.iter().any(|f| f == "-") && command.dir.is_some() {+ return Err("stdin (-) has no name to write under in -d's folder; use -o".into());+ }+ if let (Some(format), Some(named)) = (command.format, named_by(command.output.as_deref())) {+ if format != named {+ return Err(format!("--format {}, but -o's name says {}", format.extension(), named.extension()));+ }+ }+ let to_stdout = match &command.output {+ Some(output) => output == "-",+ None => command.dir.is_none() && files.iter().any(|f| f == "-"),+ };+ if command.json && to_stdout {+ return Err("--json and the image can't both go to stdout".into());+ }+ Ok(Some(command))+}++fn number<T: std::str::FromStr>(option: &str, value: String) -> Result<T, String> {+ value.parse().map_err(|_| format!("{option}: not a number: {value}"))+}
src/cli/encode.rs+104 −0
@@ -0,0 +1,104 @@+//! The output formats, and the library's encoder for each.++use std::path::Path;++use super::Error;++pub const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";++#[derive(Clone, Copy, PartialEq)]+pub enum Format {+ Png,+ Jpeg,+ Webp,+ Avif,+}++impl Format {+ pub fn named(name: &str) -> Option<Format> {+ match name.to_ascii_lowercase().as_str() {+ "png" => Some(Format::Png),+ "jpeg" | "jpg" => Some(Format::Jpeg),+ "webp" => Some(Format::Webp),+ "avif" => Some(Format::Avif),+ _ => None,+ }+ }++ pub fn extension(self) -> &'static str {+ match self {+ Format::Png => "png",+ Format::Jpeg => "jpg",+ Format::Webp => "webp",+ Format::Avif => "avif",+ }+ }+}++/// The format an output's name says, by its extension.+pub fn named_by(output: Option<&str>) -> Option<Format> {+ Format::named(&Path::new(output?).extension()?.to_string_lossy())+}++/// The file in `format`, its JSON report, and whether it is the input unchanged (nothing smaller+/// kept the quality).+pub fn encode(input: &[u8], jpeg: bool, format: Format, settings: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {+ match (format, jpeg) {+ (Format::Webp, _) => encode_webp(input, settings),+ (Format::Avif, _) => encode_avif(input, settings),+ (Format::Jpeg, true) => encode_jpeg(input, settings),+ (Format::Png, false) => {+ let result = slimdeer::compress(input, settings)?;+ let json = result.json();+ Ok((result.png, json, result.outcome == "original"))+ }+ (Format::Png, true) => Err("a JPEG becomes a JPEG, WebP or AVIF, not a PNG".into()),+ (Format::Jpeg, false) => Err("a PNG becomes a PNG, WebP or AVIF, not a JPEG".into()),+ }+}++#[cfg(feature = "webp")]+fn encode_webp(input: &[u8], settings: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {+ let result = slimdeer::compress_webp(input, settings)?;+ let json = result.json();+ Ok((result.webp, json, false))+}++#[cfg(not(feature = "webp"))]+fn encode_webp(_: &[u8], _: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {+ Err("this slimdeer was built without the webp feature".into())+}++#[cfg(feature = "avif")]+fn encode_avif(input: &[u8], settings: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {+ let result = slimdeer::compress_avif(input, settings)?;+ let json = result.json();+ Ok((result.avif, json, false))+}++#[cfg(not(feature = "avif"))]+fn encode_avif(_: &[u8], _: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {+ Err("this slimdeer was built without the avif feature".into())+}++#[cfg(feature = "jpeg")]+pub fn is_jpeg(input: &[u8]) -> bool {+ slimdeer::jpeg::is_jpeg(input)+}++#[cfg(not(feature = "jpeg"))]+pub fn is_jpeg(_: &[u8]) -> bool {+ false+}++#[cfg(feature = "jpeg")]+fn encode_jpeg(input: &[u8], settings: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {+ let result = slimdeer::compress_jpeg(input, settings)?;+ let json = result.json();+ Ok((result.jpeg, json, result.outcome == "original"))+}++#[cfg(not(feature = "jpeg"))]+fn encode_jpeg(_: &[u8], _: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {+ Err("this slimdeer was built without the jpeg feature".into())+}
src/cli/files.rs+161 −0
@@ -0,0 +1,161 @@+//! Which files a command compresses, and where each one's output goes.++use std::{+ fs, io,+ path::{Path, PathBuf},+};++use super::encode::Format;++/// A file to compress: its path ("-" for stdin), and with -r the folder it was found in, whose+/// layout -d's folder copies.+pub struct Input {+ pub path: PathBuf,+ pub root: Option<PathBuf>,+}++impl Input {+ pub fn is_stdin(&self) -> bool {+ self.path.as_os_str() == "-"+ }++ /// As slimdeer names it in what it says.+ pub fn name(&self) -> String {+ if self.is_stdin() {+ "stdin".into()+ } else {+ self.path.display().to_string()+ }+ }+}++/// A file as the filesystem knows it, however its path is spelt (in another case on a volume that+/// ignores case, through a link, relative or absolute): its device and inode on Unix, elsewhere its+/// canonical path.+#[cfg(unix)]+pub type FileId = (u64, u64);+#[cfg(not(unix))]+pub type FileId = PathBuf;++/// `path`'s FileId, or None where nothing is there.+pub fn file_id(path: &Path) -> Option<FileId> {+ #[cfg(unix)]+ {+ use std::os::unix::fs::MetadataExt;+ fs::metadata(path).ok().map(|m| (m.dev(), m.ino()))+ }+ #[cfg(not(unix))]+ {+ fs::canonicalize(path).ok()+ }+}++/// Where a file's output goes.+pub enum Destination {+ Stdout,+ /// A path, and whether to check it isn't an input before writing over it (not -o's, which may+ /// replace one on purpose).+ File(PathBuf, bool),+}++impl Destination {+ /// As --json names it.+ pub fn name(&self) -> String {+ match self {+ Destination::Stdout => "-".into(),+ Destination::File(path, _) => path.display().to_string(),+ }+ }++ /// As -v names it.+ pub fn shown(&self) -> String {+ match self {+ Destination::Stdout => "stdout".into(),+ Destination::File(path, _) => path.display().to_string(),+ }+ }+}++/// The PNG and JPEG files under `root`, by their extension, in order, and each folder below it that+/// couldn't be read, with why. Every folder below is read, but not hidden files or folders, nor+/// slimdeer's own NAME.min.EXT outputs, so that running it on a folder again doesn't compress those+/// too; nor `skip` (-d's folder, whose outputs keep the inputs' names, where it is in the tree);+/// nor linked folders, which can lead out of the tree, or back up it and round again.+pub fn images_under(root: &Path, skip: Option<&FileId>) -> (Vec<PathBuf>, Vec<(PathBuf, io::Error)>) {+ let (mut found, mut unread) = (Vec::new(), Vec::new());+ let mut folders = vec![root.to_path_buf()];+ while let Some(folder) = folders.pop() {+ let entries = match fs::read_dir(&folder) {+ Ok(entries) => entries,+ Err(e) => {+ unread.push((folder, e));+ continue;+ }+ };+ for entry in entries {+ let entry = match entry {+ Ok(entry) => entry,+ Err(e) => {+ unread.push((folder.clone(), e));+ continue;+ }+ };+ let path = entry.path();+ if path.file_name().is_some_and(|name| name.to_string_lossy().starts_with('.')) {+ continue;+ }+ // the entry's own type: a link to a folder isn't a folder here, nor a file to compress+ match entry.file_type() {+ Ok(kind) if kind.is_dir() => {+ if skip.is_none() || file_id(&path).as_ref() != skip {+ folders.push(path);+ }+ }+ Ok(kind) if kind.is_symlink() && path.is_dir() => {}+ Ok(_) => {+ if is_image(&path) && !is_output(&path) {+ found.push(path);+ }+ }+ Err(e) => unread.push((path, e)),+ }+ }+ }+ found.sort();+ (found, unread)+}++fn is_image(path: &Path) -> bool {+ path.extension().is_some_and(|e| ["png", "jpg", "jpeg"].iter().any(|ext| e.eq_ignore_ascii_case(ext)))+}++/// slimdeer's output beside an input: NAME.min.EXT.+fn is_output(path: &Path) -> bool {+ path.file_stem().is_some_and(|stem| stem.to_string_lossy().ends_with(".min"))+}++/// Where `input`'s output goes: -o's, else stdout for stdin, else NAME.EXT in -d's folder (for a file+/// -r found, in the same folder there as under its root) or NAME.min.EXT beside the input. EXT is+/// the input's own, as spelt, where the format stays.+pub fn destination(input: &Input, output: Option<&str>, dir: Option<&Path>, format: Format, same: bool) -> Destination {+ match output {+ Some("-") => return Destination::Stdout,+ Some(output) => return Destination::File(output.into(), false),+ None if input.is_stdin() => return Destination::Stdout,+ None => {}+ }+ let path = &input.path;+ let stem = path.file_stem().unwrap_or_default().to_string_lossy();+ let extension = match path.extension() {+ Some(own) if same => own.to_string_lossy(),+ _ => format.extension().into(),+ };+ match (dir, &input.root) {+ (Some(dir), Some(root)) => {+ let folder = path.parent().and_then(|parent| parent.strip_prefix(root).ok()).unwrap_or(Path::new(""));+ Destination::File(dir.join(folder).join(format!("{stem}.{extension}")), true)+ }+ (Some(dir), None) => Destination::File(dir.join(format!("{stem}.{extension}")), true),+ (None, _) => Destination::File(path.with_file_name(format!("{stem}.min.{extension}")), true),+ }+}
src/cli/mod.rs+23 −0
@@ -0,0 +1,23 @@+//! The command line's parts; main.rs says what it does.++use std::{sync::OnceLock, time::Instant};++pub mod args;+mod encode;+mod files;+mod report;+mod run;+mod tools;++pub type Error = Box<dyn std::error::Error>;++/// Says what's wrong with a command it can't run; the exit code.+pub fn usage_error(message: &str) -> i32 {+ eprintln!("slimdeer: {message}\nTry 'slimdeer --help' for more.");+ 2+}++pub fn now_ms() -> f64 {+ static START: OnceLock<Instant> = OnceLock::new();+ START.get_or_init(Instant::now).elapsed().as_secs_f64() * 1000.0+}
src/cli/report.rs+53 −0
@@ -0,0 +1,53 @@+//! What slimdeer says about a file: -v's line, --json's report, and errors as people say them.++use std::io;++/// -v's line: what `input_bytes` came to, and how long it took.+pub fn verbose_line(from: &str, to: &str, input_bytes: usize, output_bytes: usize, unchanged: bool, seconds: f64) -> String {+ let what = if unchanged {+ format!("{}, unchanged: nothing smaller keeps its quality", size(input_bytes))+ } else {+ let change = (output_bytes as f64 / input_bytes as f64 - 1.0) * 100.0;+ let sign = if change < 0.0 { "\u{2212}" } else { "+" };+ format!("{} \u{2192} {} ({sign}{:.0}%)", size(input_bytes), size(output_bytes), change.abs())+ };+ format!("{from} \u{2192} {to}: {what}, {seconds:.1} s")+}++/// --json's line: the library's report with the input, the output and the input's size added.+pub fn json_line(report: &str, input: &str, output: &str, input_bytes: usize) -> String {+ format!(r#"{}, "input": {}, "output": {}, "input_bytes": {}}}"#, &report[..report.len() - 1], json_string(input), json_string(output), input_bytes)+}++/// An I/O error as people say it, without the OS's error number.+pub fn plain(e: io::Error) -> String {+ match e.kind() {+ io::ErrorKind::NotFound => "no such file".into(),+ io::ErrorKind::PermissionDenied => "permission denied".into(),+ io::ErrorKind::IsADirectory => "a folder, not a file".into(),+ _ => e.to_string(),+ }+}++/// A size in bytes for people: B, KB or MB, in thousands as file managers count.+fn size(bytes: usize) -> String {+ match bytes {+ 0..1000 => format!("{bytes} B"),+ 1000..1_000_000 => format!("{:.1} KB", bytes as f64 / 1e3),+ _ => format!("{:.1} MB", bytes as f64 / 1e6),+ }+}++fn json_string(text: &str) -> String {+ let mut out = String::from('"');+ for c in text.chars() {+ match c {+ '"' => out.push_str("\\\""),+ '\\' => out.push_str("\\\\"),+ c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),+ c => out.push(c),+ }+ }+ out.push('"');+ out+}
src/cli/run.rs+161 −0
@@ -0,0 +1,161 @@+//! Running a command: each file read, compressed, written and reported.++use std::{+ collections::{HashMap, HashSet},+ fs,+ io::{self, IsTerminal, Read, Write},+ path::{Path, PathBuf},+ time::Instant,+};++use super::{+ args::Command,+ encode::{encode, is_jpeg, named_by, Format, PNG_SIGNATURE},+ files::{destination, file_id, images_under, Destination, FileId, Input},+ report::{json_line, plain, verbose_line},+ tools::{score, upscale},+ usage_error, Error,+};++impl Command {+ /// Runs it; the exit code.+ pub fn run(&self) -> i32 {+ let helper = if let Some(other) = &self.score_against {+ Some(score(&self.files[0], other))+ } else {+ self.upscale.map(|k| upscale(&self.files[0], self.output.as_deref().unwrap_or_default(), k))+ };+ if let Some(result) = helper {+ return match result {+ Ok(()) => 0,+ Err(e) => {+ eprintln!("slimdeer: {e}");+ 1+ }+ };+ }+ if let Some(dir) = &self.dir {+ // -d's copy of a tree keeps the inputs' names, so in the tree's own folder it would+ // write over them (each file is checked again before it is written)+ if self.recursive && self.files.iter().any(|file| same_folder(Path::new(file), dir)) {+ return usage_error(&format!("-d {} is a folder -r reads: its images would be replaced", dir.display()));+ }+ if let Err(e) = fs::create_dir_all(dir) {+ eprintln!("slimdeer: {}: {}", dir.display(), plain(e));+ return 1;+ }+ }+ let (inputs, mut failed) = self.inputs();+ let mut files =+ Files { inputs: inputs.iter().filter(|input| !input.is_stdin()).filter_map(|input| file_id(&input.path)).collect(), outputs: HashMap::new() };+ for input in &inputs {+ if let Err(e) = self.compress(input, &mut files) {+ eprintln!("slimdeer: {}: {e}", input.name());+ failed = true;+ }+ }+ failed as i32+ }++ /// The files to compress: each FILE, and with -r the images in each FILE that is a folder; and+ /// whether any FILE, or folder in one, failed (said).+ fn inputs(&self) -> (Vec<Input>, bool) {+ let (mut inputs, mut failed) = (Vec::new(), false);+ // -d's folder inside a tree holds outputs under the inputs' own names: not inputs again+ let skip = self.dir.as_deref().and_then(file_id);+ for file in &self.files {+ let path = PathBuf::from(file);+ if file == "-" || !path.is_dir() {+ inputs.push(Input { path, root: None });+ continue;+ }+ if !self.recursive {+ eprintln!("slimdeer: {file}: a folder; -r compresses the images in it");+ failed = true;+ continue;+ }+ let (found, unread) = images_under(&path, skip.as_ref());+ for (folder, e) in unread {+ eprintln!("slimdeer: {}: {}", folder.display(), plain(e));+ failed = true;+ }+ inputs.extend(found.into_iter().map(|image| Input { path: image, root: Some(path.clone()) }));+ }+ (inputs, failed)+ }++ /// One file: compressed, written, and reported as asked.+ fn compress(&self, input: &Input, files: &mut Files) -> Result<(), Error> {+ let start = Instant::now();+ let bytes = if input.is_stdin() {+ let mut bytes = Vec::new();+ io::stdin().read_to_end(&mut bytes).map_err(plain)?;+ bytes+ } else {+ fs::read(&input.path).map_err(plain)?+ };+ let jpeg = is_jpeg(&bytes);+ if !jpeg && !bytes.starts_with(PNG_SIGNATURE) {+ return Err("not a PNG or a JPEG".into());+ }+ let own = if jpeg { Format::Jpeg } else { Format::Png };+ let format = self.format.or(named_by(self.output.as_deref())).unwrap_or(own);+ let destination = destination(input, self.output.as_deref(), self.dir.as_deref(), format, format == own);+ match &destination {+ Destination::Stdout if io::stdout().is_terminal() => {+ return Err("an image can't go to the terminal: redirect stdout, or use -o".into());+ }+ Destination::File(path, checked) => {+ if let Some(id) = file_id(path) {+ if let Some(owner) = files.outputs.get(&id) {+ return Err(format!("{} is already {owner}'s output; give one of them another name or folder", path.display()).into());+ }+ if *checked && files.inputs.contains(&id) {+ if file_id(&input.path).as_ref() == Some(&id) {+ return Err(format!("{} would replace the input; name another with -o", path.display()).into());+ }+ return Err(format!("{} is another input, which this would replace", path.display()).into());+ }+ }+ }+ _ => {}+ }+ let (output, report, unchanged) = encode(&bytes, jpeg, format, &self.settings)?;+ match &destination {+ Destination::Stdout => io::stdout().lock().write_all(&output).map_err(plain)?,+ Destination::File(path, _) => {+ if input.root.is_some() {+ if let Some(folder) = path.parent() {+ fs::create_dir_all(folder).map_err(|e| format!("{}: {}", folder.display(), plain(e)))?;+ }+ }+ fs::write(path, &output).map_err(|e| format!("{}: {}", path.display(), plain(e)))?;+ if let Some(id) = file_id(path) {+ files.outputs.insert(id, input.name());+ }+ }+ }+ if self.verbose {+ let seconds = start.elapsed().as_secs_f64();+ eprintln!("{}", verbose_line(&input.name(), &destination.shown(), bytes.len(), output.len(), unchanged, seconds));+ }+ if self.json {+ let name = if input.is_stdin() { "-".into() } else { input.path.display().to_string() };+ println!("{}", json_line(&report, &name, &destination.name(), bytes.len()));+ }+ Ok(())+ }+}++/// The files a run reads, and those it has written and whose they are: a name slimdeer makes up+/// mustn't replace an input, nor any output another input's (x.png from two folders -r reads into one -d, X.png and+/// x.png where a volume ignores case, logo.png and logo.jpg as WebP).+struct Files {+ inputs: HashSet<FileId>,+ outputs: HashMap<FileId, String>,+}++/// Whether `a` and `b` are the same folder, however each is spelt; not if either doesn't exist.+fn same_folder(a: &Path, b: &Path) -> bool {+ file_id(a).is_some_and(|a| file_id(b) == Some(a))+}
src/cli/tools.rs+72 −0
@@ -0,0 +1,72 @@+//! --score and --upscale, the helpers bench/bench.py runs.++use std::fs;++use slimdeer::{codec, search};++use super::Error;++/// --score: SSIMULACRA2 of `other` against `file`, printed.+pub fn score(file: &str, other: &str) -> Result<(), Error> {+ let input = fs::read(file)?;+ if slimdeer::apng::is_animated(&input) {+ println!("{:.4}", frame_scores(&input, &fs::read(other)?)?.into_iter().fold(f64::INFINITY, f64::min));+ return Ok(());+ }+ let (a, b) = (decode_any(&input)?, decode_any(&fs::read(other)?)?);+ if (a.width, a.height) != (b.width, b.height) {+ return Err("images differ in size".into());+ }+ println!("{:.4}", search::score(&a.pixels, &b.pixels, a.width, a.height)?);+ Ok(())+}++/// --upscale: `file` enlarged k times, nearest neighbour, written to `output` as RGBA.+pub fn upscale(file: &str, output: &str, k: usize) -> Result<(), Error> {+ let image = codec::decode(&fs::read(file)?)?;+ let (w, h, pixels) = (image.width, image.height, &image.pixels);+ let big: Vec<_> = (0..h * k).flat_map(|y| (0..w * k).map(move |x| pixels[(y / k) * w + x / k])).collect();+ fs::write(output, codec::encode_rgba(&big, w * k, h * k))?;+ Ok(())+}++/// SSIMULACRA2 of each frame of animation `a` against the frame animation `b` shows at the same+/// time (b may have merged frames that repeat into longer ones), default images aside.+fn frame_scores(a: &[u8], b: &[u8]) -> Result<Vec<f64>, Error> {+ let (a, b) = (slimdeer::apng::decode(a)?, slimdeer::apng::decode(b)?);+ if (a.width, a.height) != (b.width, b.height) {+ return Err("animations differ in size".into());+ }+ let starts = |timing: &slimdeer::apng::Timing| {+ let seconds = |&(num, den): &(u16, u16)| num as f64 / if den == 0 { 100.0 } else { den as f64 };+ timing.delays.iter().scan(0.0, |t, d| Some(std::mem::replace(t, *t + seconds(d)))).collect::<Vec<f64>>()+ };+ let (sa, sb) = (starts(&a.timing), starts(&b.timing));+ fn frame(anim: &slimdeer::apng::Animation, i: usize) -> &[slimdeer::RGBA] {+ let (n, f) = (anim.width * anim.height, i + anim.timing.default_image as usize);+ &anim.pixels[f * n..(f + 1) * n]+ }+ sa.iter()+ .enumerate()+ .map(|(i, &t)| {+ let j = sb.iter().rposition(|&s| s <= t + 1e-9).unwrap_or(0);+ search::score(frame(&a, i), frame(&b, j), a.width, a.height)+ })+ .collect()+}++/// A PNG, a WebP file with the webp feature, or a JPEG with the jpeg feature (turned as its EXIF+/// orientation says, as viewers show it), as RGBA.+fn decode_any(bytes: &[u8]) -> Result<codec::Bitmap, Error> {+ #[cfg(feature = "jpeg")]+ if slimdeer::jpeg::is_jpeg(bytes) {+ let photo = slimdeer::jpeg::decode(bytes)?;+ let (pixels, width, height) = slimdeer::jpeg::oriented(photo.pixels, photo.width, photo.height, photo.orientation);+ return Ok(codec::Bitmap { pixels, width, height });+ }+ #[cfg(feature = "webp")]+ if bytes.starts_with(b"RIFF") {+ return slimdeer::webp::decode(bytes);+ }+ codec::decode(bytes)+}
src/main.rs+13 −511
@@ -3,7 +3,8 @@//! slimdeer [OPTIONS] FILE... (HELP below)//!//! Each FILE, a PNG or a JPEG, comes out smaller at a quality picked for it, next to it as-//! NAME.min.EXT, in a folder with -d, or where -o says. Like cp and pngquant, it says nothing unless+//! NAME.min.EXT, in a folder with -d, or where -o says. With -r a FILE that is a folder stands for+//! the images in it and below it, and -d's folder gets a copy of its layout. Like cp and pngquant, it says nothing unless//! something goes wrong: -v says what each file came to (on stderr), --json prints each file's//! report (on stdout, one a line). A file that fails doesn't stop the others; slimdeer then exits 1,//! and 2 on a command it can't run. A name it makes up never replaces an input; -o can.@@ -42,524 +43,25 @@//! default; see search::parse_variants).//!//! Two helpers for bench/bench.py:-//! slimdeer IN.png --score OTHER SSIMULACRA2 of OTHER (PNG, WebP or JPEG) against IN, as the-//! search scores; for an animated IN, the worst frame's,+//! slimdeer IN.png --score OTHER SSIMULACRA2 of OTHER (PNG, WebP or JPEG) against IN, as+//! the search scores; for an animated IN, the worst frame's,//! each against the frame OTHER shows at that time (in the//! help too)-//! slimdeer IN.png -o OUT.png --upscale K nearest-neighbour K× enlargement, written as RGBA--use std::{- env, fs,- io::{self, IsTerminal, Read, Write},- path::{Path, PathBuf},- process,- sync::OnceLock,- time::Instant,-};--use slimdeer::{codec, search};--const HELP: &str = "slimdeer: smaller PNG and JPEG files, or WebP and AVIF, that look the same--Usage: slimdeer [OPTIONS] FILE...--Writes each FILE, a PNG or a JPEG, smaller, at a quality picked for it, next to it-as NAME.min.EXT. Says nothing unless something goes wrong.--Examples:- slimdeer photo.jpg writes photo.min.jpg- slimdeer *.png -d dist writes dist/NAME.png for each- slimdeer logo.png --format webp writes logo.min.webp- slimdeer in.png -o out.avif writes out.avif, the format as its name says- slimdeer shot.png --target 90 keeps a score of at least 90- slimdeer - < in.png > out.png from stdin to stdout--Options:- -o, --output FILE Write the one FILE's output here (- for stdout)- -d, --dir DIR Write the outputs into DIR, under the inputs' names- --format FORMAT png, jpeg, webp or avif [default: the input's, or -o's]- (a PNG can't become a JPEG, nor a JPEG a PNG)- --target T The SSIMULACRA2 score to keep, 0 to 100: 90 looks lossless,- 70 is high quality [default: auto, about TinyPNG's, per image]- -v, --verbose Say what each file came to, on stderr- --json Print each file's report, a JSON line, on stdout- -h, --help Print this help- -V, --version Print the version+//! slimdeer IN.png -o OUT.png --upscale K nearest-neighbour K× enlargement, written as RGBA+//!+//! The parts are in cli/: the options (args.rs), which files and where their outputs go (files.rs),+//! running a command (run.rs), the encoders (encode.rs), what it says (report.rs) and the helpers+//! (tools.rs).-Also:- slimdeer ORIGINAL --score OTHER Print OTHER's SSIMULACRA2 score against ORIGINAL";+use std::{env, process};-const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";+mod cli;fn main() {- let code = match parse(env::args().skip(1)) {+ let code = match cli::args::parse(env::args().skip(1)) {Ok(Some(command)) => command.run(),Ok(None) => 0,- Err(message) => {- eprintln!("slimdeer: {message}\nTry 'slimdeer --help' for more.");- 2- }+ Err(message) => cli::usage_error(&message),};process::exit(code);}--type Error = Box<dyn std::error::Error>;--#[derive(Clone, Copy, PartialEq)]-enum Format {- Png,- Jpeg,- Webp,- Avif,-}--impl Format {- fn named(name: &str) -> Option<Format> {- match name.to_ascii_lowercase().as_str() {- "png" => Some(Format::Png),- "jpeg" | "jpg" => Some(Format::Jpeg),- "webp" => Some(Format::Webp),- "avif" => Some(Format::Avif),- _ => None,- }- }-- fn extension(self) -> &'static str {- match self {- Format::Png => "png",- Format::Jpeg => "jpg",- Format::Webp => "webp",- Format::Avif => "avif",- }- }-}--/// The format an output's name says, by its extension.-fn named_by(output: Option<&str>) -> Option<Format> {- Format::named(&Path::new(output?).extension()?.to_string_lossy())-}--/// Where a file's output goes.-enum Destination {- Stdout,- /// A path, and whether slimdeer made it up (one that mustn't replace the input).- File(PathBuf, bool),-}--impl Destination {- fn name(&self) -> String {- match self {- Destination::Stdout => "-".into(),- Destination::File(path, _) => path.display().to_string(),- }- }-}--struct Command {- files: Vec<String>,- output: Option<String>,- dir: Option<PathBuf>,- format: Option<Format>,- verbose: bool,- json: bool,- score_against: Option<String>,- upscale: Option<usize>,- settings: slimdeer::Settings,-}--/// The command line, or None where it asked for the help or the version (printed), or what's wrong-/// with it.-fn parse(args: impl Iterator<Item = String>) -> Result<Option<Command>, String> {- let mut command = Command {- files: Vec::new(),- output: None,- dir: None,- format: None,- verbose: false,- json: false,- score_against: None,- upscale: None,- settings: slimdeer::Settings { clock: Some(now_ms), ..Default::default() },- };- let settings = &mut command.settings;- let mut args = args.peekable();- let mut options = true;- while let Some(arg) = args.next() {- if !options || arg == "-" || !arg.starts_with('-') {- command.files.push(arg);- continue;- }- // --name=value as well as --name value- let (name, mut inline) = match arg.split_once('=') {- Some((name, value)) if arg.starts_with("--") => (name.to_string(), Some(value.to_string())),- _ => (arg.clone(), None),- };- let mut value = || inline.take().or_else(|| args.next()).ok_or_else(|| format!("{name} needs a value"));- match name.as_str() {- "--" => options = false,- "-o" | "--output" => command.output = Some(value()?),- "-d" | "--dir" => command.dir = Some(value()?.into()),- "--format" => {- let v = value()?;- command.format = Some(Format::named(&v).ok_or_else(|| format!("unknown format: {v} (png, jpeg, webp or avif)"))?);- }- "--target" => {- let v = value()?;- settings.target = match v.as_str() {- "auto" => None,- _ => match v.parse::<f64>() {- Ok(t) if (0.0..=100.0).contains(&t) => Some(t),- _ => return Err(format!("--target: a score from 0 to 100, or auto, not {v}")),- },- };- }- "-v" | "--verbose" => command.verbose = true,- "--json" => command.json = true,- "-h" | "--help" => {- println!("{HELP}");- return Ok(None);- }- "-V" | "--version" => {- println!("slimdeer {}", env!("CARGO_PKG_VERSION"));- return Ok(None);- }- "--score" => command.score_against = Some(value()?),- "--upscale" => command.upscale = Some(number(&name, value()?)?),- "--preview-pixels" => settings.preview_pixels = number(&name, value()?)?,- "--variants" => settings.variants = search::parse_variants(&value()?).map_err(|e| format!("--variants: {e}"))?,- "--webp-method" => settings.webp.method = number(&name, value()?)?,- "--webp-lossless-level" => settings.webp.slow_lossless_level = Some(number(&name, value()?)?),- "--webp-sharp-yuv" => settings.webp.sharp_yuv = value()? != "0",- "--webp-alpha-filter" => settings.webp.alpha_filtering = number(&name, value()?)?,- "--webp-autofilter" => settings.webp.autofilter = value()? != "0",- "--webp-large-alpha-effort" => settings.webp.large_alpha_effort = number(&name, value()?)?,- "--webp-reference" => {- let v = value()?;- settings.webp.reference_quality = if v == "none" { None } else { Some(number(&name, v)?) };- }- _ => return Err(format!("unknown option: {arg}")),- }- if inline.is_some() {- return Err(format!("{name} takes no value"));- }- }- let files = &command.files;- if files.is_empty() {- return Err("no input files".into());- }- if (command.score_against.is_some() || command.upscale.is_some()) && files.len() > 1 {- return Err("--score and --upscale take one file".into());- }- if command.upscale.is_some() && command.output.is_none() {- return Err("--upscale needs -o".into());- }- if command.output.is_some() && command.dir.is_some() {- return Err("-o and -d both say where to write; give one".into());- }- if command.output.is_some() && files.len() > 1 {- return Err("-o names one output; for several files, use -d DIR".into());- }- if files.iter().filter(|f| *f == "-").count() > 1 {- return Err("stdin (-) can be read once".into());- }- if files.iter().any(|f| f == "-") && command.dir.is_some() {- return Err("stdin (-) has no name to write under in -d's folder; use -o".into());- }- if let (Some(format), Some(named)) = (command.format, named_by(command.output.as_deref())) {- if format != named {- return Err(format!("--format {}, but -o's name says {}", format.extension(), named.extension()));- }- }- let to_stdout = match &command.output {- Some(output) => output == "-",- None => command.dir.is_none() && files.iter().any(|f| f == "-"),- };- if command.json && to_stdout {- return Err("--json and the image can't both go to stdout".into());- }- Ok(Some(command))-}--impl Command {- /// Runs it; the exit code.- fn run(&self) -> i32 {- let helper = if let Some(other) = &self.score_against {- Some(score(&self.files[0], other))- } else {- self.upscale.map(|k| upscale(&self.files[0], self.output.as_deref().unwrap_or_default(), k))- };- if let Some(result) = helper {- return match result {- Ok(()) => 0,- Err(e) => {- eprintln!("slimdeer: {e}");- 1- }- };- }- if let Some(dir) = &self.dir {- if let Err(e) = fs::create_dir_all(dir) {- eprintln!("slimdeer: {}: {}", dir.display(), plain(e));- return 1;- }- }- let mut failed = false;- for file in &self.files {- if let Err(e) = self.compress(file) {- eprintln!("slimdeer: {}: {e}", if file == "-" { "stdin" } else { file });- failed = true;- }- }- failed as i32- }-- /// One file: compressed, written, and reported as asked.- fn compress(&self, file: &str) -> Result<(), Error> {- let start = Instant::now();- let input = if file == "-" {- let mut bytes = Vec::new();- io::stdin().read_to_end(&mut bytes).map_err(plain)?;- bytes- } else {- fs::read(file).map_err(plain)?- };- let jpeg = is_jpeg(&input);- if !jpeg && !input.starts_with(PNG_SIGNATURE) {- return Err("not a PNG or a JPEG".into());- }- let own = if jpeg { Format::Jpeg } else { Format::Png };- let format = self.format.or(named_by(self.output.as_deref())).unwrap_or(own);- let destination = self.destination(file, format, format == own);- if let Destination::Stdout = destination {- if io::stdout().is_terminal() {- return Err("an image can't go to the terminal: redirect stdout, or use -o".into());- }- }- if let Destination::File(path, true) = &destination {- if file != "-" && path.exists() && fs::canonicalize(path)? == fs::canonicalize(file)? {- return Err(format!("{} would replace the input; name another with -o", path.display()).into());- }- }- let (bytes, report, unchanged) = encode(&input, jpeg, format, &self.settings)?;- match &destination {- Destination::Stdout => io::stdout().lock().write_all(&bytes).map_err(plain)?,- Destination::File(path, _) => fs::write(path, &bytes).map_err(|e| format!("{}: {}", path.display(), plain(e)))?,- }- if self.verbose {- let from = if file == "-" { "stdin" } else { file };- let to = match &destination {- Destination::Stdout => "stdout".into(),- Destination::File(path, _) => path.display().to_string(),- };- let what = if unchanged {- format!("{}, unchanged: nothing smaller keeps its quality", size(input.len()))- } else {- let change = (bytes.len() as f64 / input.len() as f64 - 1.0) * 100.0;- let sign = if change < 0.0 { "\u{2212}" } else { "+" };- format!("{} \u{2192} {} ({sign}{:.0}%)", size(input.len()), size(bytes.len()), change.abs())- };- eprintln!("{from} \u{2192} {to}: {what}, {:.1} s", start.elapsed().as_secs_f64());- }- if self.json {- println!(- r#"{}, "input": {}, "output": {}, "input_bytes": {}}}"#,- &report[..report.len() - 1],- json_string(file),- json_string(&destination.name()),- input.len()- );- }- Ok(())- }-- /// Where `file`'s output goes: -o's, else stdout for stdin, else NAME.EXT in -d's folder or- /// NAME.min.EXT beside it. EXT is the input's own (as spelt) where the format stays.- fn destination(&self, file: &str, format: Format, same: bool) -> Destination {- match &self.output {- Some(output) if output == "-" => return Destination::Stdout,- Some(output) => return Destination::File(output.into(), false),- None if file == "-" => return Destination::Stdout,- None => {}- }- let path = Path::new(file);- let stem = path.file_stem().unwrap_or_default().to_string_lossy();- let extension = match path.extension() {- Some(own) if same => own.to_string_lossy(),- _ => format.extension().into(),- };- match &self.dir {- Some(dir) => Destination::File(dir.join(format!("{stem}.{extension}")), true),- None => Destination::File(path.with_file_name(format!("{stem}.min.{extension}")), true),- }- }-}--/// The file in `format`, its JSON report, and whether it is the input unchanged (nothing smaller-/// kept the quality).-fn encode(input: &[u8], jpeg: bool, format: Format, settings: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {- match (format, jpeg) {- (Format::Webp, _) => encode_webp(input, settings),- (Format::Avif, _) => encode_avif(input, settings),- (Format::Jpeg, true) => encode_jpeg(input, settings),- (Format::Png, false) => {- let result = slimdeer::compress(input, settings)?;- let json = result.json();- Ok((result.png, json, result.outcome == "original"))- }- (Format::Png, true) => Err("a JPEG becomes a JPEG, WebP or AVIF, not a PNG".into()),- (Format::Jpeg, false) => Err("a PNG becomes a PNG, WebP or AVIF, not a JPEG".into()),- }-}--#[cfg(feature = "webp")]-fn encode_webp(input: &[u8], settings: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {- let result = slimdeer::compress_webp(input, settings)?;- let json = result.json();- Ok((result.webp, json, false))-}--#[cfg(not(feature = "webp"))]-fn encode_webp(_: &[u8], _: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {- Err("this slimdeer was built without the webp feature".into())-}--#[cfg(feature = "avif")]-fn encode_avif(input: &[u8], settings: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {- let result = slimdeer::compress_avif(input, settings)?;- let json = result.json();- Ok((result.avif, json, false))-}--#[cfg(not(feature = "avif"))]-fn encode_avif(_: &[u8], _: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {- Err("this slimdeer was built without the avif feature".into())-}--#[cfg(feature = "jpeg")]-fn is_jpeg(input: &[u8]) -> bool {- slimdeer::jpeg::is_jpeg(input)-}--#[cfg(not(feature = "jpeg"))]-fn is_jpeg(_: &[u8]) -> bool {- false-}--#[cfg(feature = "jpeg")]-fn encode_jpeg(input: &[u8], settings: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {- let result = slimdeer::compress_jpeg(input, settings)?;- let json = result.json();- Ok((result.jpeg, json, result.outcome == "original"))-}--#[cfg(not(feature = "jpeg"))]-fn encode_jpeg(_: &[u8], _: &slimdeer::Settings) -> Result<(Vec<u8>, String, bool), Error> {- Err("this slimdeer was built without the jpeg feature".into())-}--/// An I/O error as people say it, without the OS's error number.-fn plain(e: io::Error) -> String {- match e.kind() {- io::ErrorKind::NotFound => "no such file".into(),- io::ErrorKind::PermissionDenied => "permission denied".into(),- io::ErrorKind::IsADirectory => "a folder, not a file".into(),- _ => e.to_string(),- }-}--fn number<T: std::str::FromStr>(option: &str, value: String) -> Result<T, String> {- value.parse().map_err(|_| format!("{option}: not a number: {value}"))-}--/// A size in bytes for people: B, KB or MB, in thousands as file managers count.-fn size(bytes: usize) -> String {- match bytes {- 0..1000 => format!("{bytes} B"),- 1000..1_000_000 => format!("{:.1} KB", bytes as f64 / 1e3),- _ => format!("{:.1} MB", bytes as f64 / 1e6),- }-}--fn json_string(text: &str) -> String {- let mut out = String::from('"');- for c in text.chars() {- match c {- '"' => out.push_str("\\\""),- '\\' => out.push_str("\\\\"),- c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),- c => out.push(c),- }- }- out.push('"');- out-}--/// --score: SSIMULACRA2 of `other` against `file`, printed.-fn score(file: &str, other: &str) -> Result<(), Error> {- let input = fs::read(file)?;- if slimdeer::apng::is_animated(&input) {- println!("{:.4}", frame_scores(&input, &fs::read(other)?)?.into_iter().fold(f64::INFINITY, f64::min));- return Ok(());- }- let (a, b) = (decode_any(&input)?, decode_any(&fs::read(other)?)?);- if (a.width, a.height) != (b.width, b.height) {- return Err("images differ in size".into());- }- println!("{:.4}", search::score(&a.pixels, &b.pixels, a.width, a.height)?);- Ok(())-}--/// --upscale: `file` enlarged k times, nearest neighbour, written to `output` as RGBA.-fn upscale(file: &str, output: &str, k: usize) -> Result<(), Error> {- let image = codec::decode(&fs::read(file)?)?;- let (w, h, pixels) = (image.width, image.height, &image.pixels);- let big: Vec<_> = (0..h * k).flat_map(|y| (0..w * k).map(move |x| pixels[(y / k) * w + x / k])).collect();- fs::write(output, codec::encode_rgba(&big, w * k, h * k))?;- Ok(())-}--/// SSIMULACRA2 of each frame of animation `a` against the frame animation `b` shows at the same-/// time (b may have merged frames that repeat into longer ones), default images aside.-fn frame_scores(a: &[u8], b: &[u8]) -> Result<Vec<f64>, Error> {- let (a, b) = (slimdeer::apng::decode(a)?, slimdeer::apng::decode(b)?);- if (a.width, a.height) != (b.width, b.height) {- return Err("animations differ in size".into());- }- let starts = |timing: &slimdeer::apng::Timing| {- let seconds = |&(num, den): &(u16, u16)| num as f64 / if den == 0 { 100.0 } else { den as f64 };- timing.delays.iter().scan(0.0, |t, d| Some(std::mem::replace(t, *t + seconds(d)))).collect::<Vec<f64>>()- };- let (sa, sb) = (starts(&a.timing), starts(&b.timing));- fn frame(anim: &slimdeer::apng::Animation, i: usize) -> &[slimdeer::RGBA] {- let (n, f) = (anim.width * anim.height, i + anim.timing.default_image as usize);- &anim.pixels[f * n..(f + 1) * n]- }- sa.iter()- .enumerate()- .map(|(i, &t)| {- let j = sb.iter().rposition(|&s| s <= t + 1e-9).unwrap_or(0);- search::score(frame(&a, i), frame(&b, j), a.width, a.height)- })- .collect()-}--/// A PNG, a WebP file with the webp feature, or a JPEG with the jpeg feature (turned as its EXIF-/// orientation says, as viewers show it), as RGBA.-fn decode_any(bytes: &[u8]) -> Result<codec::Bitmap, Error> {- #[cfg(feature = "jpeg")]- if slimdeer::jpeg::is_jpeg(bytes) {- let photo = slimdeer::jpeg::decode(bytes)?;- let (pixels, width, height) = slimdeer::jpeg::oriented(photo.pixels, photo.width, photo.height, photo.orientation);- return Ok(codec::Bitmap { pixels, width, height });- }- #[cfg(feature = "webp")]- if bytes.starts_with(b"RIFF") {- return slimdeer::webp::decode(bytes);- }- codec::decode(bytes)-}--fn now_ms() -> f64 {- static START: OnceLock<Instant> = OnceLock::new();- START.get_or_init(Instant::now).elapsed().as_secs_f64() * 1000.0-}
tests/cli.rs+133 −0
@@ -132,3 +132,136 @@ fn a_command_it_cant_run_exits_2() {assert!(!dir.join("a.min.png").exists() && !dir.join("x.png").exists());fs::remove_dir_all(&dir).unwrap();}++#[test]+fn recurses_into_folders_with_r() {+ let dir = folder("recursive", &[]);+ for name in ["assets/a.png", "assets/icons/b.PNG", "assets/icons/small/c.png", "assets/.cache/d.png"] {+ fs::create_dir_all(dir.join(name).parent().unwrap()).unwrap();+ fs::write(dir.join(name), sample()).unwrap();+ }+ fs::write(dir.join("assets/icons/notes.txt"), "not an image").unwrap();+ // a folder needs -r+ let run = slimdeer(&dir, &["assets"]);+ assert_eq!(run.status.code(), Some(1));+ assert!(String::from_utf8_lossy(&run.stderr).contains("assets: a folder; -r"));+ // beside each image, hidden folders and other files left alone, in the folders' order+ let run = slimdeer(&dir, &["-r", "assets", "--json"]);+ assert_eq!(run.status.code(), Some(0), "{}", String::from_utf8_lossy(&run.stderr));+ let outputs: Vec<String> = String::from_utf8(run.stdout)+ .unwrap()+ .lines()+ .map(|line| line.split(r#""output": ""#).nth(1).unwrap().split('"').next().unwrap().to_string())+ .collect();+ assert_eq!(outputs, ["assets/a.min.png", "assets/icons/b.min.PNG", "assets/icons/small/c.min.png"]);+ assert!(!dir.join("assets/.cache/d.min.png").exists());+ // again: the outputs aren't compressed a second time+ let run = slimdeer(&dir, &["-r", "assets", "--json"]);+ assert_eq!(String::from_utf8(run.stdout).unwrap().lines().count(), 3);+ fs::remove_dir_all(&dir).unwrap();+}++#[test]+fn copies_the_tree_into_d_and_keeps_its_images() {+ let dir = folder("tree", &[]);+ for name in ["assets/a.png", "assets/icons/b.png"] {+ fs::create_dir_all(dir.join(name).parent().unwrap()).unwrap();+ fs::write(dir.join(name), sample()).unwrap();+ }+ let run = slimdeer(&dir, &["-r", "assets", "-d", "dist"]);+ assert_eq!(run.status.code(), Some(0), "{}", String::from_utf8_lossy(&run.stderr));+ assert!(is_png(&dir.join("dist/a.png")) && is_png(&dir.join("dist/icons/b.png")));+ // -d can't be the folder -r reads, whose images it would replace, however it is spelt+ for same in ["assets", "./assets", "assets/../assets", dir.join("assets").to_str().unwrap()] {+ let run = slimdeer(&dir, &["-r", "assets", "-d", same]);+ assert_eq!(run.status.code(), Some(2), "{same}");+ }+ assert_eq!(fs::read(dir.join("assets/icons/b.png")).unwrap(), sample());+ // -d inside the tree: its outputs aren't read as inputs on the next run+ for _ in 0..2 {+ assert_eq!(slimdeer(&dir, &["-r", "assets", "-d", "assets/min"]).status.code(), Some(0));+ }+ assert!(is_png(&dir.join("assets/min/icons/b.png")) && !dir.join("assets/min/min").exists());+ // nor does -r go with -o's one output+ assert_eq!(slimdeer(&dir, &["-r", "assets", "-o", "x.png"]).status.code(), Some(2));+ fs::remove_dir_all(&dir).unwrap();+}++#[test]+fn two_inputs_with_one_output_name_dont_overwrite_each_other() {+ let dir = folder("collide", &[]);+ for name in ["a/x.png", "b/x.png"] {+ fs::create_dir_all(dir.join(name).parent().unwrap()).unwrap();+ fs::write(dir.join(name), sample()).unwrap();+ }+ let run = slimdeer(&dir, &["-r", "a", "b", "-d", "dist", "--json"]);+ assert_eq!(run.status.code(), Some(1));+ assert!(String::from_utf8_lossy(&run.stderr).contains("dist/x.png is already a/x.png's output"));+ assert_eq!(String::from_utf8(run.stdout).unwrap().lines().count(), 1);+ fs::remove_dir_all(&dir).unwrap();+}++#[cfg(unix)]+#[test]+fn folder_links_arent_followed_and_unreadable_folders_dont_stop_the_rest() {+ use std::os::unix::fs::{symlink, PermissionsExt};+ let dir = folder("links", &[]);+ for name in ["tree/a.png", "tree/locked/b.png", "elsewhere/c.png"] {+ fs::create_dir_all(dir.join(name).parent().unwrap()).unwrap();+ fs::write(dir.join(name), sample()).unwrap();+ }+ // out of the tree, and back up it+ symlink("../elsewhere", dir.join("tree/linked")).unwrap();+ symlink("..", dir.join("tree/up")).unwrap();+ fs::set_permissions(dir.join("tree/locked"), fs::Permissions::from_mode(0o000)).unwrap();+ let locked = fs::read_dir(dir.join("tree/locked")).is_err(); // not as root, which reads it anyway+ let run = slimdeer(&dir, &["-r", "tree", "--json"]);+ fs::set_permissions(dir.join("tree/locked"), fs::Permissions::from_mode(0o755)).unwrap();+ let outputs = String::from_utf8(run.stdout).unwrap();+ assert!(outputs.contains(r#""output": "tree/a.min.png""#), "{outputs}");+ assert!(!dir.join("elsewhere/c.min.png").exists());+ if locked {+ assert_eq!(run.status.code(), Some(1));+ assert!(String::from_utf8_lossy(&run.stderr).contains("tree/locked: permission denied"));+ assert_eq!(outputs.lines().count(), 1);+ }+ fs::remove_dir_all(&dir).unwrap();+}++#[test]+fn an_output_never_replaces_another_input() {+ // -d above the tree: icons/icons/b.png's output would be icons/b.png, the other input+ let dir = folder("ancestor", &[]);+ fs::create_dir_all(dir.join("assets/icons/icons")).unwrap();+ fs::write(dir.join("assets/icons/b.png"), sample()).unwrap();+ fs::write(dir.join("assets/icons/icons/b.png"), sample()).unwrap();+ let run = slimdeer(&dir, &["-r", "assets/icons", "-d", "assets"]);+ assert_eq!(run.status.code(), Some(1));+ assert!(String::from_utf8_lossy(&run.stderr).contains("assets/icons/b.png is another input"));+ assert_eq!(fs::read(dir.join("assets/icons/b.png")).unwrap(), sample());+ // one file given twice, spelt two ways, is each time its own input+ fs::write(dir.join("a.png"), sample()).unwrap();+ let said = String::from_utf8(slimdeer(&dir, &["-d", ".", "a.png", "./a.png"]).stderr).unwrap();+ assert_eq!(said.matches("would replace the input").count(), 2, "{said}");+ fs::remove_dir_all(&dir).unwrap();+}++#[test]+fn outputs_differing_in_case_dont_overwrite_each_other() {+ let dir = folder("case", &[]);+ for name in ["a/X.png", "b/x.png"] {+ fs::create_dir_all(dir.join(name).parent().unwrap()).unwrap();+ fs::write(dir.join(name), sample()).unwrap();+ }+ // a volume that ignores case (macOS's and Windows' by default) has one dist/x.png for both+ let ignores_case = dir.join("A/X.PNG").exists();+ let run = slimdeer(&dir, &["-r", "a", "b", "-d", "dist"]);+ if ignores_case {+ assert_eq!(run.status.code(), Some(1));+ assert!(String::from_utf8_lossy(&run.stderr).contains("is already a/X.png's output"));+ } else {+ assert_eq!(run.status.code(), Some(0));+ assert!(is_png(&dir.join("dist/X.png")) && is_png(&dir.join("dist/x.png")));+ }+ fs::remove_dir_all(&dir).unwrap();+}
web/index.html+7 −5
@@ -588,12 +588,14 @@ <h3>Stays on your device</h3><div class="kicker"><i></i>Command line</div><h2 class="big">Same bytes in your build.</h2><p>The browser and the command line run the same Rust code, down to the math library, so a file compressed here and in your pipeline comes out identical.</p>- <p>Pass <span class="mono">--target 85</span> for a fixed quality instead of the automatic one.</p>+ <p>Give it a folder with <span class="mono">-r</span> to compress every image in it, and <span class="mono">--target 85</span> for a fixed quality instead of the automatic one.</p></div>- <div class="term reveal" role="img" aria-label="Terminal: installing slimdeer with Homebrew and compressing a photo"><div class="dots"><i></i><i></i><i></i></div><span class="p">$</span> <span class="k">brew install heydeer/tap/slimdeer</span>-<span class="p">$</span> <span class="k">slimdeer kodim01.png -o kodim01.min.png</span>-<span class="c">{"target": 86.26, "colors": 212, "variant": "d50",- "score": 87.271, …, "outcome": "hit", "bytes": 254373}</span></div>+ <div class="term reveal" role="img" aria-label="Terminal: installing slimdeer with Homebrew and compressing a folder of images"><div class="dots"><i></i><i></i><i></i></div><span class="p">$</span> <span class="k">brew install heydeer/tap/slimdeer</span>+<span class="p">$</span> <span class="k">slimdeer -r assets -d dist -v</span>+<span class="c">assets/icons/trophy.png → dist/icons/trophy.png:+ 31.9 KB → 9.3 KB (−71%), 0.3 s+assets/kodim01.png → dist/kodim01.png:+ 736.5 KB → 254.4 KB (−65%), 0.9 s</span></div></div></section>