Refactoring main to be more lean
This commit is contained in:
parent
f37acd3b9b
commit
3e24bae549
|
@ -1,122 +0,0 @@
|
||||||
# .github/workflows/deploy.yml
|
|
||||||
name: Build Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
# branches:
|
|
||||||
# - 'main'
|
|
||||||
tags:
|
|
||||||
- "[0-9]+.[0-9]+.[0-9]+"
|
|
||||||
workflow_dispatch:
|
|
||||||
repository_dispatch:
|
|
||||||
types: [ webhook ]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-upload:
|
|
||||||
name: Build and upload
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
# You can add more, for any target you'd like!
|
|
||||||
include:
|
|
||||||
- build: linux x86
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: x86_64-unknown-linux-musl
|
|
||||||
- build: linux arm64
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: aarch64-unknown-linux-musl
|
|
||||||
|
|
||||||
- build: macos x86
|
|
||||||
os: macos-latest
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
- build: macos arm64
|
|
||||||
os: macos-latest
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
|
|
||||||
- build: win x86
|
|
||||||
os: windows-latest
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
# - build: win arm64
|
|
||||||
# os: windows-latest
|
|
||||||
# target: aarch64-pc-windows-msvc
|
|
||||||
# error: failed to run custom build command for `mozjpeg-sys v*`
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get the release version from the tag
|
|
||||||
shell: bash
|
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
# Or @nightly if you want
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
# Arguments to pass in
|
|
||||||
with:
|
|
||||||
# Make Rust compile to our target (defined in the matrix)
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
uses: clechasseur/rs-cargo@v2
|
|
||||||
with:
|
|
||||||
use-cross: true
|
|
||||||
command: build
|
|
||||||
args: --verbose --release --target ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Build archive
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Replace with the name of your binary
|
|
||||||
binary_name="caesiumclt"
|
|
||||||
|
|
||||||
dirname="$binary_name-${{ env.VERSION }}-${{ matrix.target }}"
|
|
||||||
mkdir "$dirname"
|
|
||||||
if [ "${{ matrix.os }}" = "windows-latest" ]; then
|
|
||||||
mv "target/${{ matrix.target }}/release/$binary_name.exe" "$dirname"
|
|
||||||
else
|
|
||||||
mv "target/${{ matrix.target }}/release/$binary_name" "$dirname"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${{ matrix.os }}" = "windows-latest" ]; then
|
|
||||||
7z a "$dirname.zip" "$dirname"
|
|
||||||
echo "ASSET=$dirname.zip" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
tar -czf "$dirname.tar.gz" "$dirname"
|
|
||||||
echo "ASSET=$dirname.tar.gz" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
# https://github.com/softprops/action-gh-release?tab=readme-ov-file#-customizing
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
${{ env.ASSET }}
|
|
||||||
# body_path: ''
|
|
||||||
body: "|Arch|Filename|\n
|
|
||||||
|:--: |:--:|\n
|
|
||||||
|MacOS ARM| caesiumclt-v*-aarch64-apple-darwin.tar.gz|\n
|
|
||||||
|MacOS x86_64| caesiumclt-v*-x86_64-apple-darwin.tar.gz|\n
|
|
||||||
|Linux ARM| caesiumclt-v*-aarch64-unknown-linux-musl.tar.gz|\n
|
|
||||||
|Linux x86_64| caesiumclt-v*-x86_64-unknown-linux-musl.tar.gz|\n
|
|
||||||
|Windows x86_64| caesiumclt-v*-x86_64-pc-windows-msvc.zip|\n"
|
|
||||||
|
|
||||||
- name: Upload Artifact 🚀
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ env.ASSET }}
|
|
||||||
path: ${{ env.ASSET }}
|
|
||||||
|
|
||||||
- name: Upload binaries to release ☕
|
|
||||||
uses: svenstaro/upload-release-action@v2
|
|
||||||
with:
|
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
file: ${{ env.ASSET }}
|
|
||||||
asset_name: ${{ env.ASSET }}
|
|
||||||
tag: ${{ github.ref }}
|
|
||||||
overwrite: true
|
|
||||||
body: "Generated by Github Actions"
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --verbose --release
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --verbose --release
|
137
README.md
137
README.md
|
@ -1,5 +1,5 @@
|
||||||
## Caesium CommandLineTools
|
## Caesium CommandLineTools
|
||||||
###### caesium-clt - v0.21.0
|
###### caesium-clt - v1.0.0-beta.0
|
||||||
|
|
||||||
###### REQUIREMENTS
|
###### REQUIREMENTS
|
||||||
* [Rust](https://www.rust-lang.org/tools/install)
|
* [Rust](https://www.rust-lang.org/tools/install)
|
||||||
|
@ -10,51 +10,114 @@
|
||||||
|
|
||||||
----------
|
----------
|
||||||
|
|
||||||
###### COMMAND LINE ARGUMENTS
|
###### USAGE
|
||||||
|
|
||||||
```
|
```
|
||||||
USAGE:
|
Usage: caesiumclt [OPTIONS] <--quality <QUALITY>|--lossless|--max-size <MAX_SIZE>> <--output <OUTPUT>|--same-folder-as-input> [FILES]...
|
||||||
caesiumclt.exe [FLAGS] [OPTIONS] --quality <quality> [FILE]...
|
|
||||||
|
|
||||||
FLAGS:
|
Arguments:
|
||||||
-d, --dry-run do not compress files but just show output paths
|
[FILES]...
|
||||||
-e, --exif keeps EXIF info during compression
|
|
||||||
-h, --help Prints help information
|
|
||||||
--keep-dates keep original file date information
|
|
||||||
-S, --keep-structure keep the folder structure, can be used only with -R
|
|
||||||
-l, --lossless perform lossless compression
|
|
||||||
-Q, --quiet suppress all output
|
|
||||||
-R, --recursive if input is a folder, scan subfolders too
|
|
||||||
--same-folder-as-input sets the output folder to be the same as the input folder. Overwrites original files
|
|
||||||
-V, --version Prints version information
|
|
||||||
--zopfli use zopfli when optimizing PNG files (it may take a very long time to complete)
|
|
||||||
|
|
||||||
OPTIONS:
|
|
||||||
--height <height> height of the output image, if width is not set will preserve aspect ratio
|
|
||||||
--long-edge <long-edge> sets the size of the longest edge of the image
|
|
||||||
--max-size <max-size> set the expected maximum output size in bytes
|
|
||||||
-o, --output <output> output folder
|
|
||||||
--output-format <output-format> convert the image to the selected format (jpg, png, webp, tiff) [default:
|
|
||||||
none]
|
|
||||||
-O, --overwrite <overwrite> overwrite policy [default: all]
|
|
||||||
--png-opt-level <png-opt-level> select level for PNG optimization, between [0-6] [default: 3]
|
|
||||||
-q, --quality <quality> sets output file quality between [0-100], 0 for optimization
|
|
||||||
--short-edge <short-edge> sets the size of the shortest edge of the image
|
|
||||||
--suffix <suffix> convert the image to the selected format (jpg, png, webp, tiff) [default:
|
|
||||||
none]
|
|
||||||
--threads <threads> specify the number of parallel jobs (max is the number of processors
|
|
||||||
available) [default: 0]
|
|
||||||
--verbose <verbose> select how much output you want to see, 0 is equal to -Q, --quiet [default:
|
|
||||||
1]
|
|
||||||
--width <width> width of the output image, if height is not set will preserve aspect ratio
|
|
||||||
|
|
||||||
ARGS:
|
Options:
|
||||||
<FILE>... Files to process
|
-q, --quality <QUALITY>
|
||||||
|
sets output file quality between [0-100]
|
||||||
|
|
||||||
|
--lossless
|
||||||
|
perform lossless compression
|
||||||
|
|
||||||
|
--max-size <MAX_SIZE>
|
||||||
|
set the expected maximum output size in bytes
|
||||||
|
|
||||||
|
--width <WIDTH>
|
||||||
|
width of the output image, if height is not set will preserve aspect ratio
|
||||||
|
|
||||||
|
--height <HEIGHT>
|
||||||
|
height of the output image, if width is not set will preserve aspect ratio
|
||||||
|
|
||||||
|
--long-edge <LONG_EDGE>
|
||||||
|
sets the size of the longest edge of the image
|
||||||
|
|
||||||
|
--short-edge <SHORT_EDGE>
|
||||||
|
sets the size of the shortest edge of the image
|
||||||
|
|
||||||
|
-o, --output <OUTPUT>
|
||||||
|
output folder
|
||||||
|
|
||||||
|
--same-folder-as-input
|
||||||
|
sets the output folder to be the same as the input folder, overwrites original files
|
||||||
|
|
||||||
|
--format <FORMAT>
|
||||||
|
convert to the selected output format, or keep the original
|
||||||
|
|
||||||
|
[default: original]
|
||||||
|
[possible values: jpeg, png, webp, tiff, original]
|
||||||
|
|
||||||
|
--png-opt-level <PNG_OPT_LEVEL>
|
||||||
|
select level for PNG optimization, between [0-6]
|
||||||
|
|
||||||
|
[default: 3]
|
||||||
|
|
||||||
|
--zopfli
|
||||||
|
use zopfli when optimizing PNG files (it may take a very long time to complete)
|
||||||
|
|
||||||
|
-e, --exif
|
||||||
|
keeps EXIF info during compression
|
||||||
|
|
||||||
|
--keep-dates
|
||||||
|
keep original file date information
|
||||||
|
|
||||||
|
--suffix <SUFFIX>
|
||||||
|
add a suffix to the output filename
|
||||||
|
|
||||||
|
-R, --recursive
|
||||||
|
if input is a folder, scan subfolders too
|
||||||
|
|
||||||
|
-S, --keep-structure
|
||||||
|
keep the folder structure, can be used only with -R
|
||||||
|
|
||||||
|
-d, --dry-run
|
||||||
|
do not write output files
|
||||||
|
|
||||||
|
--threads <THREADS>
|
||||||
|
specify the number of parallel jobs (max is the number of processors available)
|
||||||
|
|
||||||
|
[default: 1]
|
||||||
|
|
||||||
|
-O, --overwrite <OVERWRITE>
|
||||||
|
overwrite policy
|
||||||
|
|
||||||
|
[default: all]
|
||||||
|
|
||||||
|
Possible values:
|
||||||
|
- all: Always overwrite
|
||||||
|
- never: Never overwrite
|
||||||
|
- bigger: Overwrite only if the file to be overwritten is bigger
|
||||||
|
|
||||||
|
-Q, --quiet
|
||||||
|
suppress all output
|
||||||
|
|
||||||
|
--verbose <VERBOSE>
|
||||||
|
select how much output you want to see
|
||||||
|
|
||||||
|
[default: progress]
|
||||||
|
|
||||||
|
Possible values:
|
||||||
|
- quiet: Suppress all output
|
||||||
|
- progress: Show only progress and final results
|
||||||
|
- warnings-and-errors: Show also skipped and error messages
|
||||||
|
- all: Print all
|
||||||
|
|
||||||
|
-h, --help
|
||||||
|
Print help (see a summary with '-h')
|
||||||
|
|
||||||
|
-V, --version
|
||||||
|
Print version
|
||||||
```
|
```
|
||||||
|
|
||||||
----------
|
----------
|
||||||
|
|
||||||
###### USAGE EXAMPLES
|
###### EX AMPLES
|
||||||
|
|
||||||
Losslessly compress ```image1.jpg```, located in the ```home``` directory, into a folder called ```output```
|
Losslessly compress ```image1.jpg```, located in the ```home``` directory, into a folder called ```output```
|
||||||
```
|
```
|
||||||
|
|
|
@ -0,0 +1,578 @@
|
||||||
|
use crate::options::{CommandLineArgs, OutputFormat, OverwritePolicy};
|
||||||
|
use crate::scan_files::get_file_mime_type;
|
||||||
|
use crate::CompressionStatus;
|
||||||
|
use caesium::parameters::CSParameters;
|
||||||
|
use caesium::{compress_in_memory, compress_to_size_in_memory, convert_in_memory, SupportedFileTypes};
|
||||||
|
use filetime::{set_file_times, FileTime};
|
||||||
|
use indicatif::{ParallelProgressIterator, ProgressBar};
|
||||||
|
use rayon::iter::ParallelIterator;
|
||||||
|
use rayon::prelude::IntoParallelRefIterator;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufReader, Read, Write};
|
||||||
|
use std::path::{absolute, Path, PathBuf};
|
||||||
|
use std::{fs, io};
|
||||||
|
|
||||||
|
pub struct CompressionResult {
|
||||||
|
pub original_path: String,
|
||||||
|
pub output_path: String,
|
||||||
|
pub original_size: u64,
|
||||||
|
pub compressed_size: u64,
|
||||||
|
pub status: CompressionStatus,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn perform_compression(
|
||||||
|
input_files: &Vec<PathBuf>,
|
||||||
|
args: &CommandLineArgs,
|
||||||
|
base_path: &PathBuf,
|
||||||
|
progress_bar: ProgressBar,
|
||||||
|
) -> Vec<CompressionResult> {
|
||||||
|
let needs_resize = args.resize.width.is_some()
|
||||||
|
|| args.resize.height.is_some()
|
||||||
|
|| args.resize.long_edge.is_some()
|
||||||
|
|| args.resize.short_edge.is_some();
|
||||||
|
|
||||||
|
input_files
|
||||||
|
.par_iter()
|
||||||
|
.progress_with(progress_bar)
|
||||||
|
.map(|input_file| {
|
||||||
|
let mut compression_result = CompressionResult {
|
||||||
|
original_path: input_file.display().to_string(),
|
||||||
|
output_path: String::new(),
|
||||||
|
original_size: 0,
|
||||||
|
compressed_size: 0,
|
||||||
|
status: CompressionStatus::Error,
|
||||||
|
message: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_file_metadata = match input_file.metadata() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => {
|
||||||
|
compression_result.message = "Error reading file metadata".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let original_file_size = input_file_metadata.len();
|
||||||
|
compression_result.original_size = original_file_size;
|
||||||
|
|
||||||
|
let output_directory = if args.output_destination.same_folder_as_input {
|
||||||
|
match input_file.parent() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
compression_result.message = "Error getting parent directory".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match args.output_destination.output.as_ref() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
compression_result.message = "Error getting output directory".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (output_directory, filename) = match compute_output_full_path(
|
||||||
|
output_directory,
|
||||||
|
input_file,
|
||||||
|
base_path,
|
||||||
|
args.keep_structure,
|
||||||
|
args.suffix.as_ref().unwrap_or(&String::new()).as_ref(),
|
||||||
|
args.format,
|
||||||
|
) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
compression_result.message = "Error computing output path".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !output_directory.exists() {
|
||||||
|
match fs::create_dir_all(&output_directory) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => {
|
||||||
|
compression_result.message = "Error creating output directory".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let output_full_path = output_directory.join(filename);
|
||||||
|
|
||||||
|
if args.dry_run {
|
||||||
|
compression_result.status = CompressionStatus::Success;
|
||||||
|
return compression_result;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut compression_parameters = build_compression_parameters(args, input_file, needs_resize);
|
||||||
|
let input_file_buffer = match read_file_to_vec(input_file) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => {
|
||||||
|
compression_result.message = "Error reading input file".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let compression = if args.compression.max_size.is_some() {
|
||||||
|
compress_to_size_in_memory(
|
||||||
|
input_file_buffer,
|
||||||
|
&mut compression_parameters,
|
||||||
|
args.compression.max_size.unwrap() as usize,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} else if args.format != OutputFormat::Original {
|
||||||
|
convert_in_memory(
|
||||||
|
input_file_buffer,
|
||||||
|
&compression_parameters,
|
||||||
|
map_supported_formats(args.format),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
compress_in_memory(input_file_buffer, &compression_parameters)
|
||||||
|
};
|
||||||
|
|
||||||
|
let compressed_image = match compression {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
compression_result.message = format!("Error compressing file: {}", e);
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
compression_result.output_path = output_full_path.display().to_string();
|
||||||
|
let output_file_size = compressed_image.len() as u64;
|
||||||
|
|
||||||
|
if output_full_path.exists() {
|
||||||
|
match args.overwrite {
|
||||||
|
OverwritePolicy::Never | OverwritePolicy::Bigger => {
|
||||||
|
if (matches!(args.overwrite, OverwritePolicy::Bigger) && output_file_size >= original_file_size)
|
||||||
|
|| matches!(args.overwrite, OverwritePolicy::Never)
|
||||||
|
{
|
||||||
|
compression_result.status = CompressionStatus::Skipped;
|
||||||
|
compression_result.compressed_size = original_file_size;
|
||||||
|
compression_result.message =
|
||||||
|
"File already exists, skipped due overwrite policy".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output_file = match File::create(&output_full_path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => {
|
||||||
|
compression_result.message = "Error creating output file".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match output_file.write_all(&compressed_image) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => {
|
||||||
|
compression_result.message = "Error writing output file".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if args.keep_dates {
|
||||||
|
let (last_modification_time, last_access_time) = (
|
||||||
|
FileTime::from_last_modification_time(&input_file_metadata),
|
||||||
|
FileTime::from_last_access_time(&input_file_metadata),
|
||||||
|
);
|
||||||
|
match preserve_dates(&output_full_path, last_modification_time, last_access_time) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => {
|
||||||
|
compression_result.message = "Error preserving file dates".to_string();
|
||||||
|
return compression_result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compression_result.status = CompressionStatus::Success;
|
||||||
|
compression_result.compressed_size = output_file_size;
|
||||||
|
compression_result
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_compression_parameters(args: &CommandLineArgs, input_file: &Path, needs_resize: bool) -> CSParameters {
|
||||||
|
let mut parameters = CSParameters::new();
|
||||||
|
let quality = args.compression.quality.unwrap_or(80) as u32;
|
||||||
|
|
||||||
|
parameters.jpeg.quality = quality;
|
||||||
|
parameters.png.quality = quality;
|
||||||
|
parameters.webp.quality = quality;
|
||||||
|
parameters.gif.quality = quality;
|
||||||
|
|
||||||
|
parameters.keep_metadata = args.exif;
|
||||||
|
|
||||||
|
parameters.png.optimization_level = args.png_opt_level;
|
||||||
|
parameters.png.force_zopfli = args.zopfli;
|
||||||
|
|
||||||
|
if needs_resize {
|
||||||
|
let mime_type = get_file_mime_type(input_file);
|
||||||
|
build_resize_parameters(args, &mut parameters, input_file, mime_type).unwrap();
|
||||||
|
//TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_output_full_path(
|
||||||
|
output_directory: &Path,
|
||||||
|
input_file_path: &Path,
|
||||||
|
base_directory: &PathBuf,
|
||||||
|
keep_structure: bool,
|
||||||
|
suffix: &str,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Option<(PathBuf, OsString)> {
|
||||||
|
let extension = match format {
|
||||||
|
OutputFormat::Jpeg => "jpg".into(),
|
||||||
|
OutputFormat::Png => "png".into(),
|
||||||
|
OutputFormat::Webp => "webp".into(),
|
||||||
|
OutputFormat::Tiff => "tiff".into(),
|
||||||
|
OutputFormat::Original => input_file_path.extension().unwrap_or_default().to_os_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let base_name = input_file_path.file_stem().unwrap_or_default().to_os_string();
|
||||||
|
let mut output_file_name = base_name;
|
||||||
|
output_file_name.push(suffix);
|
||||||
|
if !extension.is_empty() {
|
||||||
|
output_file_name.push(".");
|
||||||
|
output_file_name.push(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
if keep_structure {
|
||||||
|
let parent = match absolute(input_file_path.parent()?) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_path_prefix = match parent.strip_prefix(base_directory) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
let full_output_directory = output_directory.join(output_path_prefix);
|
||||||
|
Some((full_output_directory, output_file_name))
|
||||||
|
} else {
|
||||||
|
Some((PathBuf::from(output_directory), output_file_name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_resize_parameters(
|
||||||
|
args: &CommandLineArgs,
|
||||||
|
parameters: &mut CSParameters,
|
||||||
|
input_file_path: &Path,
|
||||||
|
mime_type: Option<String>,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let (width, height) = get_real_resolution(input_file_path, mime_type, args.exif)?;
|
||||||
|
|
||||||
|
if args.resize.width.is_some() {
|
||||||
|
parameters.width = args.resize.width.unwrap_or(0);
|
||||||
|
} else if args.resize.height.is_some() {
|
||||||
|
parameters.height = args.resize.height.unwrap_or(0);
|
||||||
|
} else if args.resize.long_edge.is_some() {
|
||||||
|
let long_edge = args.resize.long_edge.unwrap_or(0);
|
||||||
|
if width > height {
|
||||||
|
parameters.width = long_edge;
|
||||||
|
} else {
|
||||||
|
parameters.height = long_edge;
|
||||||
|
}
|
||||||
|
} else if args.resize.short_edge.is_some() {
|
||||||
|
let short_edge = args.resize.short_edge.unwrap_or(0);
|
||||||
|
if width < height {
|
||||||
|
parameters.width = short_edge;
|
||||||
|
} else {
|
||||||
|
parameters.height = short_edge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_real_resolution(
|
||||||
|
file: &Path,
|
||||||
|
mime_type: Option<String>,
|
||||||
|
keep_metadata: bool,
|
||||||
|
) -> Result<(usize, usize), Box<dyn Error>> {
|
||||||
|
let resolution = imagesize::size(file)?;
|
||||||
|
let mut orientation = 1;
|
||||||
|
let mime = mime_type.unwrap_or("".to_string());
|
||||||
|
if mime == "image/jpeg" && keep_metadata {
|
||||||
|
let f = File::open(file)?;
|
||||||
|
if let Ok(e) = exif::Reader::new().read_from_container(&mut BufReader::new(&f)) {
|
||||||
|
let exif_field = match e.get_field(exif::Tag::Orientation, exif::In::PRIMARY) {
|
||||||
|
Some(f) => f,
|
||||||
|
None => return Ok((resolution.width, resolution.height)),
|
||||||
|
};
|
||||||
|
orientation = exif_field.value.get_uint(0).unwrap_or(1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let (width, height) = match orientation {
|
||||||
|
5..=8 => (resolution.height, resolution.width),
|
||||||
|
_ => (resolution.width, resolution.height),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preserve_dates(output_file: &PathBuf, input_atime: FileTime, input_mtime: FileTime) -> io::Result<()> {
|
||||||
|
set_file_times(output_file, input_atime, input_mtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_supported_formats(format: OutputFormat) -> SupportedFileTypes {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Jpeg => SupportedFileTypes::Jpeg,
|
||||||
|
OutputFormat::Png => SupportedFileTypes::Png,
|
||||||
|
OutputFormat::Webp => SupportedFileTypes::WebP,
|
||||||
|
OutputFormat::Tiff => SupportedFileTypes::Tiff,
|
||||||
|
_ => SupportedFileTypes::Unkn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_file_to_vec(file_path: &PathBuf) -> io::Result<Vec<u8>> {
|
||||||
|
let mut file = File::open(file_path)?;
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
file.read_to_end(&mut buffer)?;
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
#[test]
|
||||||
|
fn test_compute_output_full_path() {
|
||||||
|
let output_directory = PathBuf::from("/output");
|
||||||
|
let base_directory = PathBuf::from("/base");
|
||||||
|
|
||||||
|
// Test case 1: keep_structure = true
|
||||||
|
let input_file_path = PathBuf::from("/base/folder/test.jpg");
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
true,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Original,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
(Path::new("/output/folder").to_path_buf(), "test_suffix.jpg".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 2: keep_structure = false
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Original,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix.jpg".into()));
|
||||||
|
|
||||||
|
// Test case 3: input file without extension
|
||||||
|
let input_file_path = PathBuf::from("/base/folder/test");
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Original,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix".into()));
|
||||||
|
|
||||||
|
// Test case 4: input file with different base directory
|
||||||
|
let input_file_path = PathBuf::from("/different_base/folder/test.jpg");
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Original,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix.jpg".into()));
|
||||||
|
|
||||||
|
// Test case 5: input file with OutputFormat::Jpeg
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Jpeg,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix.jpg".into()));
|
||||||
|
|
||||||
|
// Test case 6: input file with OutputFormat::Png
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Png,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix.png".into()));
|
||||||
|
|
||||||
|
// Test case 7: input file with OutputFormat::Webp
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Webp,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix.webp".into()));
|
||||||
|
|
||||||
|
// Test case 8: input file with OutputFormat::Tiff
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Tiff,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix.tiff".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[test]
|
||||||
|
fn test_compute_output_full_path() {
|
||||||
|
let output_directory = PathBuf::from(r"C:\output");
|
||||||
|
let base_directory = PathBuf::from(r"C:\base");
|
||||||
|
|
||||||
|
// Test case 1: keep_structure = true
|
||||||
|
let input_file_path = PathBuf::from(r"C:\base\folder\test.jpg");
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
true,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Original,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
(Path::new(r"C:\output\folder").to_path_buf(), "test_suffix.jpg".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 2: keep_structure = false
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Original,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
(Path::new(r"C:\output").to_path_buf(), "test_suffix.jpg".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 3: input file without extension
|
||||||
|
let input_file_path = PathBuf::from(r"C:\base\folder\test");
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Original,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, (Path::new(r"C:\output").to_path_buf(), "test_suffix".into()));
|
||||||
|
|
||||||
|
// Test case 4: input file with different base directory
|
||||||
|
let input_file_path = PathBuf::from(r"C:\different_base\folder\test.jpg");
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Original,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
(Path::new(r"C:\output").to_path_buf(), "test_suffix.jpg".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 5: input file with OutputFormat::Jpeg
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Jpeg,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
(Path::new(r"C:\output").to_path_buf(), "test_suffix.jpg".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 6: input file with OutputFormat::Png
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Png,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
(Path::new(r"C:\output").to_path_buf(), "test_suffix.png".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 7: input file with OutputFormat::Webp
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Webp,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
(Path::new(r"C:\output").to_path_buf(), "test_suffix.webp".into())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 8: input file with OutputFormat::Tiff
|
||||||
|
let result = compute_output_full_path(
|
||||||
|
&output_directory,
|
||||||
|
&input_file_path,
|
||||||
|
&base_directory,
|
||||||
|
false,
|
||||||
|
"_suffix",
|
||||||
|
OutputFormat::Tiff,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
(Path::new(r"C:\output").to_path_buf(), "test_suffix.tiff".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
460
src/main.rs
460
src/main.rs
|
@ -1,25 +1,16 @@
|
||||||
use crate::options::{CommandLineArgs, OutputFormat, OverwritePolicy};
|
use crate::compressor::{perform_compression, CompressionResult};
|
||||||
use crate::scan_files::{get_file_mime_type, scan_files};
|
use crate::options::VerboseLevel::{All, Progress, Quiet};
|
||||||
use caesium::parameters::CSParameters;
|
use crate::options::{CommandLineArgs, VerboseLevel};
|
||||||
use caesium::{compress_in_memory, compress_to_size_in_memory, convert_in_memory, SupportedFileTypes};
|
use crate::scan_files::scan_files;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use filetime::{set_file_times, FileTime};
|
|
||||||
use human_bytes::human_bytes;
|
use human_bytes::human_bytes;
|
||||||
use indicatif::{ParallelProgressIterator, ProgressBar, ProgressDrawTarget, ProgressStyle};
|
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||||
use rayon::iter::IntoParallelRefIterator;
|
|
||||||
use rayon::iter::ParallelIterator;
|
|
||||||
use std::error::Error;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::{BufReader, Read, Write};
|
|
||||||
use std::num::NonZero;
|
use std::num::NonZero;
|
||||||
use std::path::{absolute, Path, PathBuf};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{fs, io};
|
|
||||||
use std::ffi::OsString;
|
|
||||||
|
|
||||||
|
mod compressor;
|
||||||
mod options;
|
mod options;
|
||||||
mod scan_files;
|
mod scan_files;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum CompressionStatus {
|
enum CompressionStatus {
|
||||||
Success,
|
Success,
|
||||||
|
@ -27,205 +18,32 @@ enum CompressionStatus {
|
||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CompressionResult {
|
|
||||||
original_path: String,
|
|
||||||
output_path: String,
|
|
||||||
original_size: u64,
|
|
||||||
compressed_size: u64,
|
|
||||||
status: CompressionStatus,
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args = CommandLineArgs::parse();
|
let args = CommandLineArgs::parse();
|
||||||
|
|
||||||
let quiet = args.quiet || args.verbose == 0;
|
|
||||||
let threads_number = get_parallelism_count(
|
let threads_number = get_parallelism_count(
|
||||||
args.threads,
|
args.threads,
|
||||||
std::thread::available_parallelism()
|
std::thread::available_parallelism()
|
||||||
.unwrap_or(NonZero::new(1).unwrap())
|
.unwrap_or(NonZero::new(1).unwrap())
|
||||||
.get(),
|
.get(),
|
||||||
);
|
);
|
||||||
let verbose = if quiet { 0 } else { args.verbose };
|
|
||||||
let needs_resize = args.resize.width.is_some()
|
|
||||||
|| args.resize.height.is_some()
|
|
||||||
|| args.resize.long_edge.is_some()
|
|
||||||
|| args.resize.short_edge.is_some();
|
|
||||||
let (base_path, input_files) = scan_files(&args.files, args.recursive, quiet);
|
|
||||||
|
|
||||||
rayon::ThreadPoolBuilder::new()
|
rayon::ThreadPoolBuilder::new()
|
||||||
.num_threads(threads_number)
|
.num_threads(threads_number)
|
||||||
.build_global()
|
.build_global()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let quiet = args.quiet || args.verbose == Quiet;
|
||||||
|
let verbose = if quiet { Quiet } else { args.verbose };
|
||||||
|
let (base_path, input_files) = scan_files(&args.files, args.recursive, quiet);
|
||||||
let total_files = input_files.len();
|
let total_files = input_files.len();
|
||||||
|
|
||||||
let progress_bar = setup_progress_bar(total_files, verbose);
|
let progress_bar = setup_progress_bar(total_files, verbose);
|
||||||
let compression_results: Vec<CompressionResult> = input_files
|
let compression_results = perform_compression(&input_files, &args, &base_path, progress_bar);
|
||||||
.par_iter()
|
|
||||||
.progress_with(progress_bar)
|
|
||||||
.map(|input_file| {
|
|
||||||
let mut compression_result = CompressionResult {
|
|
||||||
original_path: input_file.display().to_string(),
|
|
||||||
output_path: String::new(),
|
|
||||||
original_size: 0,
|
|
||||||
compressed_size: 0,
|
|
||||||
status: CompressionStatus::Error,
|
|
||||||
message: String::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let input_file_metadata = match input_file.metadata() {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(_) => {
|
|
||||||
compression_result.message = "Error reading file metadata".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let original_file_size = input_file_metadata.len();
|
|
||||||
compression_result.original_size = original_file_size;
|
|
||||||
|
|
||||||
let output_directory = if args.output_destination.same_folder_as_input {
|
|
||||||
match input_file.parent() {
|
|
||||||
Some(p) => p,
|
|
||||||
None => {
|
|
||||||
compression_result.message = "Error getting parent directory".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match args.output_destination.output.as_ref() {
|
|
||||||
Some(p) => p,
|
|
||||||
None => {
|
|
||||||
compression_result.message = "Error getting output directory".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (output_directory, filename) = match compute_output_full_path(
|
|
||||||
output_directory.to_path_buf(),
|
|
||||||
input_file.to_path_buf(),
|
|
||||||
base_path.to_path_buf(),
|
|
||||||
args.keep_structure,
|
|
||||||
args.suffix.as_ref().unwrap_or(&String::new()).as_ref(),
|
|
||||||
) {
|
|
||||||
Some(p) => p,
|
|
||||||
None => {
|
|
||||||
compression_result.message = "Error computing output path".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if !output_directory.exists() {
|
|
||||||
match fs::create_dir_all(&output_directory) {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(_) => {
|
|
||||||
compression_result.message = "Error creating output directory".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let output_full_path = output_directory.join(filename);
|
|
||||||
|
|
||||||
if args.dry_run {
|
|
||||||
compression_result.status = CompressionStatus::Success;
|
|
||||||
return compression_result;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut compression_parameters = build_compression_parameters(&args, input_file, needs_resize);
|
|
||||||
let input_file_buffer = match read_file_to_vec(input_file) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(_) => {
|
|
||||||
compression_result.message = "Error reading input file".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let compression = if args.compression.max_size.is_some() {
|
|
||||||
compress_to_size_in_memory(
|
|
||||||
input_file_buffer,
|
|
||||||
&mut compression_parameters,
|
|
||||||
args.compression.max_size.unwrap() as usize,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
} else if args.format != OutputFormat::Original {
|
|
||||||
convert_in_memory(
|
|
||||||
input_file_buffer,
|
|
||||||
&compression_parameters,
|
|
||||||
map_supported_formats(args.format),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
compress_in_memory(input_file_buffer, &compression_parameters)
|
|
||||||
};
|
|
||||||
|
|
||||||
let compressed_image = match compression {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
compression_result.message = format!("Error compressing file: {}", e);
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
compression_result.output_path = output_full_path.display().to_string();
|
|
||||||
let output_file_size = compressed_image.len() as u64;
|
|
||||||
|
|
||||||
if output_full_path.exists() {
|
|
||||||
match args.overwrite {
|
|
||||||
OverwritePolicy::Never => {
|
|
||||||
compression_result.status = CompressionStatus::Skipped;
|
|
||||||
compression_result.compressed_size = original_file_size;
|
|
||||||
compression_result.message = "File already exists, skipped due overwrite policy".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
OverwritePolicy::Bigger => {
|
|
||||||
if output_file_size >= original_file_size {
|
|
||||||
compression_result.status = CompressionStatus::Skipped;
|
|
||||||
compression_result.compressed_size = original_file_size;
|
|
||||||
compression_result.message =
|
|
||||||
"File already exists, skipped due overwrite policy".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut output_file = match File::create(&output_full_path) {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(_) => {
|
|
||||||
compression_result.message = "Error creating output file".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match output_file.write_all(&compressed_image) {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(_) => {
|
|
||||||
compression_result.message = "Error writing output file".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if args.keep_dates {
|
|
||||||
let (last_modification_time, last_access_time) = (
|
|
||||||
FileTime::from_last_modification_time(&input_file_metadata),
|
|
||||||
FileTime::from_last_access_time(&input_file_metadata),
|
|
||||||
);
|
|
||||||
match preserve_dates(&output_full_path, last_modification_time, last_access_time) {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(_) => {
|
|
||||||
compression_result.message = "Error preserving file dates".to_string();
|
|
||||||
return compression_result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compression_result.status = CompressionStatus::Success;
|
|
||||||
compression_result.compressed_size = output_file_size;
|
|
||||||
compression_result
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
write_recap_message(&compression_results, verbose);
|
write_recap_message(&compression_results, verbose);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_recap_message(compression_results: &[CompressionResult], verbose: u8) {
|
fn write_recap_message(compression_results: &[CompressionResult], verbose: VerboseLevel) {
|
||||||
let mut total_original_size = 0;
|
let mut total_original_size = 0;
|
||||||
let mut total_compressed_size = 0;
|
let mut total_compressed_size = 0;
|
||||||
let total_files = compression_results.len();
|
let total_files = compression_results.len();
|
||||||
|
@ -242,8 +60,8 @@ fn write_recap_message(compression_results: &[CompressionResult], verbose: u8) {
|
||||||
_ => total_success += 1,
|
_ => total_success += 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
if verbose > 1 {
|
if verbose > Progress {
|
||||||
if verbose < 3 && matches!(result.status, CompressionStatus::Success) {
|
if verbose < All && matches!(result.status, CompressionStatus::Success) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
println!(
|
println!(
|
||||||
|
@ -266,8 +84,9 @@ fn write_recap_message(compression_results: &[CompressionResult], verbose: u8) {
|
||||||
let total_saved = total_original_size as f64 - total_compressed_size as f64;
|
let total_saved = total_original_size as f64 - total_compressed_size as f64;
|
||||||
let total_saved_percent = total_saved / total_original_size as f64 * 100.0;
|
let total_saved_percent = total_saved / total_original_size as f64 * 100.0;
|
||||||
|
|
||||||
if verbose > 0 {
|
if verbose > Quiet {
|
||||||
println!("Total files: {}\nSuccess: {}\nSkipped: {}\nErrors: {}\nOriginal size: {}\nCompressed size: {}\nSaved: {} ({:.2}%)",
|
println!(
|
||||||
|
"Compressed {} files ({} success, {} skipped, {} errors)\n{} -> {} [{} | ({:.2}%])",
|
||||||
total_files,
|
total_files,
|
||||||
total_success,
|
total_success,
|
||||||
total_skipped,
|
total_skipped,
|
||||||
|
@ -279,16 +98,6 @@ fn write_recap_message(compression_results: &[CompressionResult], verbose: u8) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_supported_formats(format: OutputFormat) -> SupportedFileTypes {
|
|
||||||
match format {
|
|
||||||
OutputFormat::Jpeg => SupportedFileTypes::Jpeg,
|
|
||||||
OutputFormat::Png => SupportedFileTypes::Png,
|
|
||||||
OutputFormat::Webp => SupportedFileTypes::WebP,
|
|
||||||
OutputFormat::Tiff => SupportedFileTypes::Tiff,
|
|
||||||
_ => SupportedFileTypes::Unkn,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn get_parallelism_count(requested_threads: u32, available_threads: usize) -> usize {
|
fn get_parallelism_count(requested_threads: u32, available_threads: usize) -> usize {
|
||||||
if requested_threads > 0 {
|
if requested_threads > 0 {
|
||||||
std::cmp::min(available_threads, requested_threads as usize)
|
std::cmp::min(available_threads, requested_threads as usize)
|
||||||
|
@ -297,133 +106,9 @@ fn get_parallelism_count(requested_threads: u32, available_threads: usize) -> us
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_compression_parameters(args: &CommandLineArgs, input_file: &Path, needs_resize: bool) -> CSParameters {
|
fn setup_progress_bar(len: usize, verbose: VerboseLevel) -> ProgressBar {
|
||||||
let mut parameters = CSParameters::new();
|
|
||||||
let quality = args.compression.quality.unwrap_or(80) as u32;
|
|
||||||
|
|
||||||
parameters.jpeg.quality = quality;
|
|
||||||
parameters.png.quality = quality;
|
|
||||||
parameters.webp.quality = quality;
|
|
||||||
parameters.gif.quality = quality;
|
|
||||||
|
|
||||||
parameters.keep_metadata = args.exif;
|
|
||||||
|
|
||||||
parameters.png.optimization_level = args.png_opt_level;
|
|
||||||
parameters.png.force_zopfli = args.zopfli;
|
|
||||||
|
|
||||||
if needs_resize {
|
|
||||||
let mime_type = get_file_mime_type(input_file);
|
|
||||||
build_resize_parameters(args, &mut parameters, input_file, mime_type).unwrap();
|
|
||||||
//TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
parameters
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_resize_parameters(
|
|
||||||
args: &CommandLineArgs,
|
|
||||||
parameters: &mut CSParameters,
|
|
||||||
input_file_path: &Path,
|
|
||||||
mime_type: Option<String>,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
let (width, height) = get_real_resolution(input_file_path, mime_type, args.exif)?;
|
|
||||||
|
|
||||||
if args.resize.width.is_some() {
|
|
||||||
parameters.width = args.resize.width.unwrap_or(0);
|
|
||||||
} else if args.resize.height.is_some() {
|
|
||||||
parameters.height = args.resize.height.unwrap_or(0);
|
|
||||||
} else if args.resize.long_edge.is_some() {
|
|
||||||
let long_edge = args.resize.long_edge.unwrap_or(0);
|
|
||||||
if width > height {
|
|
||||||
parameters.width = long_edge;
|
|
||||||
} else {
|
|
||||||
parameters.height = long_edge;
|
|
||||||
}
|
|
||||||
} else if args.resize.short_edge.is_some() {
|
|
||||||
let short_edge = args.resize.short_edge.unwrap_or(0);
|
|
||||||
if width < height {
|
|
||||||
parameters.width = short_edge;
|
|
||||||
} else {
|
|
||||||
parameters.height = short_edge;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_output_full_path(
|
|
||||||
output_directory: PathBuf,
|
|
||||||
input_file_path: PathBuf,
|
|
||||||
base_directory: PathBuf,
|
|
||||||
keep_structure: bool,
|
|
||||||
suffix: &str,
|
|
||||||
) -> Option<(PathBuf, OsString)> {
|
|
||||||
let extension = input_file_path.extension().unwrap_or_default().to_os_string();
|
|
||||||
let base_name = input_file_path.file_stem().unwrap_or_default().to_os_string();
|
|
||||||
let mut output_file_name = base_name;
|
|
||||||
output_file_name.push(suffix);
|
|
||||||
if !extension.is_empty() {
|
|
||||||
output_file_name.push(".");
|
|
||||||
output_file_name.push(extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
if keep_structure {
|
|
||||||
let parent = match absolute(input_file_path.parent()?) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let output_path_prefix = match parent.strip_prefix(base_directory) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => return None,
|
|
||||||
};
|
|
||||||
let full_output_directory = output_directory.join(output_path_prefix);
|
|
||||||
Some((full_output_directory, output_file_name))
|
|
||||||
} else {
|
|
||||||
Some((output_directory, output_file_name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_file_to_vec(file_path: &PathBuf) -> io::Result<Vec<u8>> {
|
|
||||||
let mut file = File::open(file_path)?;
|
|
||||||
let mut buffer = Vec::new();
|
|
||||||
file.read_to_end(&mut buffer)?;
|
|
||||||
Ok(buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preserve_dates(output_file: &PathBuf, input_atime: FileTime, input_mtime: FileTime) -> io::Result<()> {
|
|
||||||
set_file_times(output_file, input_atime, input_mtime)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_real_resolution(
|
|
||||||
file: &Path,
|
|
||||||
mime_type: Option<String>,
|
|
||||||
keep_metadata: bool,
|
|
||||||
) -> Result<(usize, usize), Box<dyn Error>> {
|
|
||||||
let resolution = imagesize::size(file)?;
|
|
||||||
let mut orientation = 1;
|
|
||||||
let mime = mime_type.unwrap_or("".to_string());
|
|
||||||
if mime == "image/jpeg" && keep_metadata {
|
|
||||||
let f = File::open(file)?;
|
|
||||||
if let Ok(e) = exif::Reader::new().read_from_container(&mut BufReader::new(&f)) {
|
|
||||||
let exif_field = match e.get_field(exif::Tag::Orientation, exif::In::PRIMARY) {
|
|
||||||
Some(f) => f,
|
|
||||||
None => return Ok((resolution.width, resolution.height)),
|
|
||||||
};
|
|
||||||
orientation = exif_field.value.get_uint(0).unwrap_or(1);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let (width, height) = match orientation {
|
|
||||||
5..=8 => (resolution.height, resolution.width),
|
|
||||||
_ => (resolution.width, resolution.height),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((width, height))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_progress_bar(len: usize, verbose: u8) -> ProgressBar {
|
|
||||||
let progress_bar = ProgressBar::new(len as u64);
|
let progress_bar = ProgressBar::new(len as u64);
|
||||||
if verbose == 0 {
|
if verbose == Quiet {
|
||||||
progress_bar.set_draw_target(ProgressDrawTarget::hidden());
|
progress_bar.set_draw_target(ProgressDrawTarget::hidden());
|
||||||
} else {
|
} else {
|
||||||
progress_bar.set_draw_target(ProgressDrawTarget::stdout());
|
progress_bar.set_draw_target(ProgressDrawTarget::stdout());
|
||||||
|
@ -441,7 +126,6 @@ fn setup_progress_bar(len: usize, verbose: u8) -> ProgressBar {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_parallelism_count() {
|
fn test_get_parallelism_count() {
|
||||||
|
@ -463,112 +147,4 @@ mod tests {
|
||||||
let result = get_parallelism_count(0, 0);
|
let result = get_parallelism_count(0, 0);
|
||||||
assert_eq!(result, 0);
|
assert_eq!(result, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
#[test]
|
|
||||||
fn test_compute_output_full_path() {
|
|
||||||
let output_directory = PathBuf::from("/output");
|
|
||||||
let base_directory = PathBuf::from("/base");
|
|
||||||
|
|
||||||
// Test case 1: keep_structure = true
|
|
||||||
let input_file_path = PathBuf::from("/base/folder/test.jpg");
|
|
||||||
let result = compute_output_full_path(
|
|
||||||
output_directory.clone(),
|
|
||||||
input_file_path.clone(),
|
|
||||||
base_directory.clone(),
|
|
||||||
true,
|
|
||||||
"_suffix",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result, (Path::new("/output/folder").to_path_buf(), "test_suffix.jpg".into()));
|
|
||||||
|
|
||||||
// Test case 2: keep_structure = false
|
|
||||||
let result = compute_output_full_path(
|
|
||||||
output_directory.clone(),
|
|
||||||
input_file_path.clone(),
|
|
||||||
base_directory.clone(),
|
|
||||||
false,
|
|
||||||
"_suffix",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix.jpg".into()));
|
|
||||||
|
|
||||||
// Test case 3: input file without extension
|
|
||||||
let input_file_path = PathBuf::from("/base/folder/test");
|
|
||||||
let result = compute_output_full_path(
|
|
||||||
output_directory.clone(),
|
|
||||||
input_file_path.clone(),
|
|
||||||
base_directory.clone(),
|
|
||||||
false,
|
|
||||||
"_suffix",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix".into()));
|
|
||||||
|
|
||||||
// Test case 4: input file with different base directory
|
|
||||||
let input_file_path = PathBuf::from("/different_base/folder/test.jpg");
|
|
||||||
let result = compute_output_full_path(
|
|
||||||
output_directory.clone(),
|
|
||||||
input_file_path.clone(),
|
|
||||||
base_directory.clone(),
|
|
||||||
false,
|
|
||||||
"_suffix",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result, (Path::new("/output").to_path_buf(), "test_suffix.jpg".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
#[test]
|
|
||||||
fn test_compute_output_full_path() {
|
|
||||||
let output_directory = PathBuf::from(r"C:\output");
|
|
||||||
let base_directory = PathBuf::from(r"C:\base");
|
|
||||||
|
|
||||||
// Test case 1: keep_structure = true
|
|
||||||
let input_file_path = PathBuf::from(r"C:\base\folder\test.jpg");
|
|
||||||
let result = compute_output_full_path(
|
|
||||||
output_directory.clone(),
|
|
||||||
input_file_path.clone(),
|
|
||||||
base_directory.clone(),
|
|
||||||
true,
|
|
||||||
"_suffix",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result, (Path::new(r"C:\output\folder").to_path_buf(), "test_suffix.jpg".into()));
|
|
||||||
|
|
||||||
// Test case 2: keep_structure = false
|
|
||||||
let result = compute_output_full_path(
|
|
||||||
output_directory.clone(),
|
|
||||||
input_file_path.clone(),
|
|
||||||
base_directory.clone(),
|
|
||||||
false,
|
|
||||||
"_suffix",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result, (Path::new(r"C:\output").to_path_buf(), "test_suffix.jpg".into()));
|
|
||||||
|
|
||||||
// Test case 3: input file without extension
|
|
||||||
let input_file_path = PathBuf::from(r"C:\base\folder\test");
|
|
||||||
let result = compute_output_full_path(
|
|
||||||
output_directory.clone(),
|
|
||||||
input_file_path.clone(),
|
|
||||||
base_directory.clone(),
|
|
||||||
false,
|
|
||||||
"_suffix",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result, (Path::new(r"C:\output").to_path_buf(), "test_suffix".into()));
|
|
||||||
|
|
||||||
// Test case 4: input file with different base directory
|
|
||||||
let input_file_path = PathBuf::from(r"C:\different_base\folder\test.jpg");
|
|
||||||
let result = compute_output_full_path(
|
|
||||||
output_directory.clone(),
|
|
||||||
input_file_path.clone(),
|
|
||||||
base_directory.clone(),
|
|
||||||
false,
|
|
||||||
"_suffix",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result, (Path::new(r"C:\output").to_path_buf(), "test_suffix.jpg".into()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,18 @@ pub enum OutputFormat {
|
||||||
Original,
|
Original,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
|
||||||
|
pub enum VerboseLevel {
|
||||||
|
/// Suppress all output
|
||||||
|
Quiet = 0,
|
||||||
|
/// Show only progress and final results
|
||||||
|
Progress = 1,
|
||||||
|
/// Show also skipped and error messages
|
||||||
|
WarningsAndErrors = 2,
|
||||||
|
/// Print all
|
||||||
|
All = 3
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
pub struct CommandLineArgs {
|
pub struct CommandLineArgs {
|
||||||
|
@ -69,7 +81,7 @@ pub struct CommandLineArgs {
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
|
|
||||||
/// specify the number of parallel jobs (max is the number of processors available)
|
/// specify the number of parallel jobs (max is the number of processors available)
|
||||||
#[arg(long, default_value = "0")]
|
#[arg(long, default_value = "1")]
|
||||||
pub threads: u32,
|
pub threads: u32,
|
||||||
|
|
||||||
/// overwrite policy
|
/// overwrite policy
|
||||||
|
@ -80,9 +92,9 @@ pub struct CommandLineArgs {
|
||||||
#[arg(short = 'Q', long, group = "verbosity")]
|
#[arg(short = 'Q', long, group = "verbosity")]
|
||||||
pub quiet: bool,
|
pub quiet: bool,
|
||||||
|
|
||||||
/// select how much output you want to see, 0 is equal to -Q, --quiet
|
/// select how much output you want to see
|
||||||
#[arg(long, default_value = "1", group = "verbosity")]
|
#[arg(long, value_enum, default_value = "progress", group = "verbosity")]
|
||||||
pub verbose: u8,
|
pub verbose: VerboseLevel,
|
||||||
|
|
||||||
pub files: Vec<String>,
|
pub files: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,18 +44,18 @@ pub fn scan_files(args: &[String], recursive: bool, quiet: bool) -> (PathBuf, Ve
|
||||||
for entry in walk_dir.into_iter().filter_map(|e| e.ok()) {
|
for entry in walk_dir.into_iter().filter_map(|e| e.ok()) {
|
||||||
let path = entry.into_path();
|
let path = entry.into_path();
|
||||||
if is_valid(&path) {
|
if is_valid(&path) {
|
||||||
base_path = canonicalize_and_push(&path, base_path, &mut files);
|
base_path = make_absolute_and_push(&path, base_path, &mut files);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if is_valid(&input) {
|
} else if is_valid(&input) {
|
||||||
base_path = canonicalize_and_push(&input, base_path, &mut files);
|
base_path = make_absolute_and_push(&input, base_path, &mut files);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(base_path, files)
|
(base_path, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonicalize_and_push(path: &Path, mut base_path: PathBuf, files: &mut Vec<PathBuf>) -> PathBuf {
|
fn make_absolute_and_push(path: &Path, mut base_path: PathBuf, files: &mut Vec<PathBuf>) -> PathBuf {
|
||||||
if let Ok(ap) = absolute(path) {
|
if let Ok(ap) = absolute(path) {
|
||||||
base_path = compute_base_folder(&base_path, &ap);
|
base_path = compute_base_folder(&base_path, &ap);
|
||||||
files.push(ap);
|
files.push(ap);
|
||||||
|
@ -185,22 +185,6 @@ mod tests {
|
||||||
temp_file.write_all(bytes.as_slice()).unwrap();
|
temp_file.write_all(bytes.as_slice()).unwrap();
|
||||||
assert!(!is_valid(temp_file.path()));
|
assert!(!is_valid(temp_file.path()));
|
||||||
}
|
}
|
||||||
//
|
|
||||||
// #[test]
|
|
||||||
// fn test_scanfiles() {
|
|
||||||
// let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
// let file_path = temp_dir.path().join("test.jpg");
|
|
||||||
// let mut file = File::create(&file_path).unwrap();
|
|
||||||
// file.write_all(b"test").unwrap();
|
|
||||||
//
|
|
||||||
// let args = vec![file_path.to_str().unwrap().to_string()];
|
|
||||||
// let (base_path, files) = scanfiles(args, false);
|
|
||||||
//
|
|
||||||
// assert_eq!(files.len(), 1);
|
|
||||||
// assert_eq!(files[0], file_path);
|
|
||||||
// assert_eq!(base_path, temp_dir.path().canonicalize().unwrap());
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_compute_base_folder_with_files() {
|
fn test_compute_base_folder_with_files() {
|
||||||
|
|
Loading…
Reference in New Issue