Files
zstd-rs/rust/src/zstd_cli.rs
T
ddidderr 428dfbc4f5 docs(rust): refresh migration boundary
Update the Rust rewrite documentation to match the current implementation
boundary.  The legacy decoder set is complete, and Rust now owns several
CLI file-I/O policy and resource leaves while C retains format dispatch,
metadata, and remaining orchestration.  Keep the stated boundary explicit so
passing hybrid tests do not imply that the full rewrite is finished.

Test Plan:
- git diff --cached --check
2026-07-18 18:56:29 +02:00

3567 lines
116 KiB
Rust

#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(clippy::missing_safety_doc)]
//! Rust command-line frontend for zstd.
//!
//! This is the Rust parser and dispatch layer plus selected file-I/O leaves,
//! not yet a fully independent file-I/O implementation. It shares the
//! mature C `fileio` layer through a narrow public-in-the-programs-tree ABI:
//! Rust owns preference policy, filename decisions, file opening, dictionary
//! loading, asynchronous pools, and pass-through copying, while C still owns
//! format-specific dispatch, metadata preservation, and remaining streaming
//! orchestration.
//!
//! Benchmark mode (`-b`) parses and dispatches through the Rust CLI archive.
//! The bridge preserves the C CLI's argument normalization and uses the
//! existing Rust `benchzstd` APIs. Builds without the `benchmark` feature
//! return the same unavailable-build result as `ZSTD_NOBENCH` builds without
//! referencing benchmark symbols.
//!
//! Recursive expansion (`-r`), `--filelist`, the output-directory modes, and
//! `--list` reuse the C `FileNamesTable` helpers from `programs/util.c` and
//! `FIO_listMultipleFiles`, so directory traversal and frame inspection stay
//! byte-identical with the C CLI.
//!
//! Remaining C-only CLI boundaries are called out in `unsupported()` below:
//! a few compatibility-only options. Trace logging uses the existing C trace
//! backend through a narrow lifecycle bridge, while dictionary training uses
//! the temporary `DiB_trainFromFiles` bridge and Rust-owned algorithms.
use std::env;
use std::ffi::{CStr, CString, OsStr, OsString};
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::os::raw::{c_char, c_int, c_uint};
use std::path::Path;
use std::ptr;
#[cfg(unix)]
use std::os::unix::ffi::{OsStrExt, OsStringExt};
#[cfg(unix)]
use std::os::unix::fs::FileTypeExt;
const DEFAULT_CLEVEL: i32 = 3;
#[cfg(feature = "compression")]
const DEFAULT_MAX_CLEVEL: i32 = 19;
const DEFAULT_BENCH_NB_SECONDS: u32 = 3;
const DEFAULT_MEM_LIMIT: u32 = 1 << 27;
const DEFAULT_LONG_WINDOW_LOG: u32 = 27;
const DEFAULT_DICT_NAME: &str = "dictionary";
const DEFAULT_MAX_DICT_SIZE: usize = 110 << 10;
const DEFAULT_DICT_SELECTIVITY: u32 = 9;
const DEFAULT_SHRINK_DICT_REGRESSION: u32 = 1;
const DEFAULT_FASTCOVER_ACCEL: u32 = 1;
const MAX_FAST_ACCELERATION: i32 = 128 << 10;
#[cfg(target_pointer_width = "32")]
const MAX_WINDOW_LOG: u32 = 30;
#[cfg(not(target_pointer_width = "32"))]
const MAX_WINDOW_LOG: u32 = 31;
#[cfg(target_pointer_width = "32")]
const MAX_CHAIN_LOG: u32 = 29;
#[cfg(not(target_pointer_width = "32"))]
const MAX_CHAIN_LOG: u32 = 30;
const MAX_HASH_LOG: u32 = 30;
const MAX_SEARCH_LOG: u32 = MAX_WINDOW_LOG - 1;
const MAX_MIN_MATCH: u32 = 3;
const MAX_TARGET_LENGTH: u32 = 1 << 17;
const MAX_STRATEGY: c_int = 9;
const MAX_OVERLAP_LOG: i32 = 9;
const MAX_LDM_HASH_LOG: i32 = 30;
const MAX_LDM_MIN_MATCH: i32 = 16;
const MAX_LDM_BUCKET_SIZE_LOG: i32 = 8;
const STDIN_MARK: &str = "/*stdin*\\";
const STDOUT_MARK: &str = "/*stdout*\\";
#[cfg(windows)]
const NULL_MARK: &str = "NUL";
#[cfg(not(windows))]
const NULL_MARK: &str = "/dev/null";
const ZSTD_SUFFIX: &[u8] = b".zst\0";
const GZ_SUFFIX: &[u8] = b".gz\0";
const XZ_SUFFIX: &[u8] = b".xz\0";
const LZMA_SUFFIX: &[u8] = b".lzma\0";
const LZ4_SUFFIX: &[u8] = b".lz4\0";
const FIO_ZSTD_COMPRESSION: c_int = 0;
const FIO_GZIP_COMPRESSION: c_int = 1;
const FIO_XZ_COMPRESSION: c_int = 2;
const FIO_LZMA_COMPRESSION: c_int = 3;
const FIO_LZ4_COMPRESSION: c_int = 4;
const FIO_PS_AUTO: c_int = 0;
const FIO_PS_NEVER: c_int = 1;
const FIO_PS_ALWAYS: c_int = 2;
const ZSTD_PS_AUTO: c_int = 0;
const ZSTD_PS_ENABLE: c_int = 1;
const ZSTD_PS_DISABLE: c_int = 2;
#[repr(C)]
struct FIO_prefs_t {
_private: [u8; 0],
}
#[repr(C)]
struct FIO_ctx_t {
_private: [u8; 0],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct ZSTD_compressionParameters {
windowLog: u32,
chainLog: u32,
hashLog: u32,
searchLog: u32,
minMatch: u32,
targetLength: u32,
strategy: c_int,
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct ZDICT_params_t {
compressionLevel: c_int,
notificationLevel: c_uint,
dictID: c_uint,
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct ZDICT_cover_params_t {
k: c_uint,
d: c_uint,
steps: c_uint,
nbThreads: c_uint,
splitPoint: f64,
shrinkDict: c_uint,
shrinkDictMaxRegression: c_uint,
zParams: ZDICT_params_t,
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct ZDICT_fastCover_params_t {
k: c_uint,
d: c_uint,
f: c_uint,
steps: c_uint,
nbThreads: c_uint,
splitPoint: f64,
accel: c_uint,
shrinkDict: c_uint,
shrinkDictMaxRegression: c_uint,
zParams: ZDICT_params_t,
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg(all(feature = "compression", feature = "dict-builder"))]
struct ZDICT_legacy_params_t {
selectivityLevel: c_uint,
zParams: ZDICT_params_t,
}
/// Mirror of the `FileNamesTable` in `programs/util.h`; the tables returned
/// by the `UTIL_*FNT` helpers are only read and released here, never resized.
#[repr(C)]
struct FileNamesTable {
fileNames: *mut *const c_char,
buf: *mut c_char,
tableSize: usize,
tableCapacity: usize,
}
unsafe extern "C" {
fn ZSTD_versionString() -> *const c_char;
fn ZSTD_rust_cli_expected_version() -> *const c_char;
static mut g_utilDisplayLevel: c_int;
#[cfg(feature = "compression")]
fn ZSTD_minCLevel() -> c_int;
#[cfg(feature = "compression")]
fn ZSTD_maxCLevel() -> c_int;
#[cfg(feature = "compression")]
fn UTIL_countPhysicalCores() -> c_int;
#[cfg(feature = "compression")]
fn UTIL_countLogicalCores() -> c_int;
fn UTIL_getFileSize(input_file_name: *const c_char) -> u64;
fn UTIL_isConsole(file: *mut libc::FILE) -> c_int;
fn UTIL_isLink(input_file_name: *const c_char) -> c_int;
fn UTIL_fakeStderrIsConsole();
fn UTIL_traceFileStat();
static mut stdin: *mut libc::FILE;
static mut stdout: *mut libc::FILE;
static mut stderr: *mut libc::FILE;
fn UTIL_createFileNamesTable_fromFileName(
input_file_name: *const c_char,
) -> *mut FileNamesTable;
fn UTIL_createExpandedFNT(
file_names: *const *const c_char,
nb_file_names: usize,
follow_links: c_int,
) -> *mut FileNamesTable;
fn UTIL_freeFileNamesTable(table: *mut FileNamesTable);
fn FIO_createPreferences() -> *mut FIO_prefs_t;
fn FIO_freePreferences(prefs: *mut FIO_prefs_t);
fn FIO_createContext() -> *mut FIO_ctx_t;
fn FIO_freeContext(ctx: *mut FIO_ctx_t);
fn FIO_addAbortHandler();
fn FIO_setCompressionType(prefs: *mut FIO_prefs_t, compression_type: c_int);
fn FIO_overwriteMode(prefs: *mut FIO_prefs_t);
fn FIO_setAdaptiveMode(prefs: *mut FIO_prefs_t, adapt: c_int);
#[cfg(feature = "compression")]
fn FIO_setAdaptMin(prefs: *mut FIO_prefs_t, level: c_int);
#[cfg(feature = "compression")]
fn FIO_setAdaptMax(prefs: *mut FIO_prefs_t, level: c_int);
fn FIO_setUseRowMatchFinder(prefs: *mut FIO_prefs_t, mode: c_int);
fn FIO_setBlockSize(prefs: *mut FIO_prefs_t, block_size: c_int);
fn FIO_setChecksumFlag(prefs: *mut FIO_prefs_t, checksum: c_int);
fn FIO_setDictIDFlag(prefs: *mut FIO_prefs_t, dict_id: c_int);
fn FIO_setLdmBucketSizeLog(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setLdmFlag(prefs: *mut FIO_prefs_t, value: c_uint);
fn FIO_setLdmHashRateLog(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setLdmHashLog(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setLdmMinMatch(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setMemLimit(prefs: *mut FIO_prefs_t, limit: c_uint);
#[cfg(feature = "compression")]
fn FIO_setNbWorkers(prefs: *mut FIO_prefs_t, workers: c_int);
fn FIO_setOverlapLog(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setRemoveSrcFile(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setSparseWrite(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setRsyncable(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setStreamSrcSize(prefs: *mut FIO_prefs_t, value: usize);
fn FIO_setTargetCBlockSize(prefs: *mut FIO_prefs_t, value: usize);
fn FIO_setSrcSizeHint(prefs: *mut FIO_prefs_t, value: usize);
#[cfg(feature = "decompression")]
fn FIO_setTestMode(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setLiteralCompressionMode(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setProgressSetting(value: c_int);
fn FIO_setNotificationLevel(value: c_int);
fn FIO_setExcludeCompressedFile(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setAllowBlockDevices(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setContentSize(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setAsyncIOFlag(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setPassThroughFlag(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setPatchFromMode(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setMMapDict(prefs: *mut FIO_prefs_t, value: c_int);
fn FIO_setNbFilesTotal(ctx: *mut FIO_ctx_t, value: c_int);
fn FIO_setHasStdinInput(ctx: *mut FIO_ctx_t, value: c_int);
fn FIO_setHasStdoutOutput(ctx: *mut FIO_ctx_t, value: c_int);
fn TRACE_enable(filename: *const c_char);
fn TRACE_finish();
#[cfg(feature = "compression")]
fn FIO_compressFilename(
ctx: *mut FIO_ctx_t,
prefs: *mut FIO_prefs_t,
output: *const c_char,
input: *const c_char,
dict: *const c_char,
level: c_int,
params: ZSTD_compressionParameters,
) -> c_int;
#[cfg(feature = "decompression")]
fn FIO_decompressFilename(
ctx: *mut FIO_ctx_t,
prefs: *mut FIO_prefs_t,
output: *const c_char,
input: *const c_char,
dict: *const c_char,
) -> c_int;
#[cfg(feature = "compression")]
fn FIO_compressMultipleFilenames(
ctx: *mut FIO_ctx_t,
prefs: *mut FIO_prefs_t,
inputs: *const *const c_char,
output_mirror_dir: *const c_char,
output_dir: *const c_char,
output: *const c_char,
suffix: *const c_char,
dict: *const c_char,
level: c_int,
params: ZSTD_compressionParameters,
) -> c_int;
#[cfg(feature = "decompression")]
fn FIO_decompressMultipleFilenames(
ctx: *mut FIO_ctx_t,
prefs: *mut FIO_prefs_t,
inputs: *const *const c_char,
output_mirror_dir: *const c_char,
output_dir: *const c_char,
output: *const c_char,
dict: *const c_char,
) -> c_int;
#[cfg(feature = "decompression")]
fn FIO_listMultipleFiles(
nb_files: c_uint,
file_names: *const *const c_char,
display_level: c_int,
) -> c_int;
/// Narrow bridge to `programs/dibio.c`. The file loader and dictionary
/// algorithms remain on the C/Rust library side of this boundary.
#[cfg(all(feature = "compression", feature = "dict-builder"))]
fn DiB_trainFromFiles(
dict_file_name: *const c_char,
max_dict_size: usize,
file_names: *const *const c_char,
nb_files: c_int,
chunk_size: usize,
legacy_params: *mut ZDICT_legacy_params_t,
cover_params: *mut ZDICT_cover_params_t,
fast_cover_params: *mut ZDICT_fastCover_params_t,
optimize: c_int,
mem_limit: c_uint,
) -> c_int;
#[cfg(feature = "compression")]
fn ZSTD_getCParams(
compression_level: c_int,
estimated_src_size: u64,
dict_size: usize,
) -> ZSTD_compressionParameters;
}
const ZSTD_STRATEGY_NAMES: [&str; 10] = [
"",
"ZSTD_fast",
"ZSTD_dfast",
"ZSTD_greedy",
"ZSTD_lazy",
"ZSTD_lazy2",
"ZSTD_btlazy2",
"ZSTD_btopt",
"ZSTD_btultra",
"ZSTD_btultra2",
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Operation {
Compress,
Decompress,
Test,
Bench,
List,
Train,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CompressionFormat {
Zstd,
Gzip,
Xz,
Lzma,
Lz4,
}
impl CompressionFormat {
fn parse(value: &str) -> Result<Self, String> {
match value {
"zstd" => Ok(Self::Zstd),
"gzip" => Ok(Self::Gzip),
"xz" => Ok(Self::Xz),
"lzma" => Ok(Self::Lzma),
"lz4" => Ok(Self::Lz4),
_ => Err(format!("unknown compression format {value:?}")),
}
}
const fn fio_type(self) -> c_int {
match self {
Self::Zstd => FIO_ZSTD_COMPRESSION,
Self::Gzip => FIO_GZIP_COMPRESSION,
Self::Xz => FIO_XZ_COMPRESSION,
Self::Lzma => FIO_LZMA_COMPRESSION,
Self::Lz4 => FIO_LZ4_COMPRESSION,
}
}
const fn suffix(self) -> &'static [u8] {
match self {
Self::Zstd => ZSTD_SUFFIX,
Self::Gzip => GZ_SUFFIX,
Self::Xz => XZ_SUFFIX,
Self::Lzma => LZMA_SUFFIX,
Self::Lz4 => LZ4_SUFFIX,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum TrainingAlgorithm {
Cover,
#[default]
FastCover,
Legacy,
}
#[derive(Debug)]
enum Action {
Run(Box<Cli>),
Help { advanced: bool },
Version { quiet: bool },
}
#[derive(Debug)]
struct Cli {
operation: Operation,
bench_decode: bool,
compression_format: CompressionFormat,
inputs: Vec<CString>,
output: Option<CString>,
dictionary: Option<CString>,
patch_from: Option<CString>,
level: i32,
ultra: bool,
display_level: i32,
force: bool,
force_stdout: bool,
remove_source: bool,
checksum: Option<i32>,
sparse: Option<i32>,
pass_through: Option<i32>,
content_size: i32,
dict_id: Option<i32>,
async_io: Option<i32>,
mmap_dict: i32,
progress: i32,
workers: Option<i32>,
single_thread: bool,
auto_threads_logical: bool,
block_size: Option<usize>,
mem_limit: Option<u32>,
ldm: bool,
ldm_hash_log: Option<i32>,
ldm_min_match: Option<i32>,
ldm_bucket_size_log: Option<i32>,
ldm_hash_rate_log: Option<i32>,
overlap_log: Option<i32>,
adapt: bool,
adapt_min: Option<i32>,
adapt_max: Option<i32>,
rsyncable: bool,
stream_src_size: Option<usize>,
target_cblock_size: Option<usize>,
src_size_hint: Option<usize>,
literal_compression: Option<i32>,
row_match_finder: i32,
exclude_compressed: bool,
compression_params: ZSTD_compressionParameters,
show_default_cparams: bool,
bench_end_level: Option<i32>,
bench_nb_seconds: Option<u32>,
recursive: bool,
file_lists: Vec<CString>,
output_dir_flat: Option<CString>,
output_dir_mirror: Option<CString>,
training_algorithm: TrainingAlgorithm,
max_dict_size: usize,
dictionary_id: u32,
dictionary_selectivity: u32,
trace: Option<CString>,
fake_stderr_is_console: bool,
trace_file_stat: bool,
gzip_compat: bool,
cover_params: ZDICT_cover_params_t,
fast_cover_params: ZDICT_fastCover_params_t,
}
impl Cli {
fn new(program_name: &str) -> Self {
let mut cli = Self {
operation: Operation::Compress,
bench_decode: false,
compression_format: CompressionFormat::Zstd,
inputs: Vec::new(),
output: None,
dictionary: None,
patch_from: None,
level: default_level(),
ultra: false,
display_level: 2,
force: false,
force_stdout: false,
remove_source: false,
checksum: None,
sparse: None,
pass_through: None,
content_size: 1,
dict_id: None,
async_io: None,
mmap_dict: ZSTD_PS_AUTO,
progress: FIO_PS_AUTO,
workers: None,
single_thread: false,
auto_threads_logical: false,
block_size: None,
mem_limit: None,
ldm: false,
ldm_hash_log: None,
ldm_min_match: None,
ldm_bucket_size_log: None,
ldm_hash_rate_log: None,
overlap_log: None,
adapt: false,
adapt_min: None,
adapt_max: None,
rsyncable: false,
stream_src_size: None,
target_cblock_size: None,
src_size_hint: None,
literal_compression: None,
row_match_finder: ZSTD_PS_AUTO,
exclude_compressed: false,
compression_params: ZSTD_compressionParameters::default(),
show_default_cparams: false,
bench_end_level: None,
bench_nb_seconds: None,
recursive: false,
file_lists: Vec::new(),
output_dir_flat: None,
output_dir_mirror: None,
training_algorithm: TrainingAlgorithm::default(),
max_dict_size: DEFAULT_MAX_DICT_SIZE,
dictionary_id: 0,
dictionary_selectivity: DEFAULT_DICT_SELECTIVITY,
trace: None,
fake_stderr_is_console: false,
trace_file_stat: false,
gzip_compat: false,
cover_params: ZDICT_cover_params_t::default(),
fast_cover_params: default_fast_cover_params(),
};
match program_name {
"unzstd" => cli.operation = Operation::Decompress,
"zstdmt" => cli.workers = Some(0),
"zstdcat" | "zcat" => {
cli.operation = Operation::Decompress;
cli.output = Some(cstring(STDOUT_MARK).expect("static stdout marker"));
cli.force = true;
cli.force_stdout = true;
cli.pass_through = Some(1);
cli.display_level = 1;
}
"gzip" => {
cli.compression_format = CompressionFormat::Gzip;
cli.remove_source = true;
cli.gzip_compat = true;
}
"gunzip" => {
cli.compression_format = CompressionFormat::Gzip;
cli.operation = Operation::Decompress;
cli.remove_source = true;
}
"gzcat" => {
cli.compression_format = CompressionFormat::Gzip;
cli.operation = Operation::Decompress;
cli.output = Some(cstring(STDOUT_MARK).expect("static stdout marker"));
cli.force = true;
cli.force_stdout = true;
cli.pass_through = Some(1);
cli.display_level = 1;
}
"xz" => {
cli.compression_format = CompressionFormat::Xz;
cli.remove_source = true;
}
"unxz" => {
cli.compression_format = CompressionFormat::Xz;
cli.operation = Operation::Decompress;
cli.remove_source = true;
}
"lzma" => {
cli.compression_format = CompressionFormat::Lzma;
cli.remove_source = true;
}
"unlzma" => {
cli.compression_format = CompressionFormat::Lzma;
cli.operation = Operation::Decompress;
cli.remove_source = true;
}
"lz4" => cli.compression_format = CompressionFormat::Lz4,
"unlz4" => {
cli.compression_format = CompressionFormat::Lz4;
cli.operation = Operation::Decompress;
}
_ => {}
}
cli
}
}
fn cstring(value: &str) -> Result<CString, String> {
CString::new(value).map_err(|_| format!("argument contains an interior NUL: {value:?}"))
}
fn os_cstring(value: &OsStr) -> Result<CString, String> {
#[cfg(unix)]
{
CString::new(value.as_bytes()).map_err(|_| "file name contains an interior NUL".to_owned())
}
#[cfg(not(unix))]
{
cstring(&value.to_string_lossy())
}
}
fn default_level() -> i32 {
let Ok(value) = env::var("ZSTD_CLEVEL") else {
return DEFAULT_CLEVEL;
};
match value.parse::<i128>() {
Ok(level) if (i32::MIN as i128..=i32::MAX as i128).contains(&level) => level as i32,
Ok(_) => {
eprintln!(
"Ignore environment variable setting ZSTD_CLEVEL={value}: numeric value too large "
);
DEFAULT_CLEVEL
}
Err(_) if value_has_only_sign_and_digits(&value) => {
eprintln!(
"Ignore environment variable setting ZSTD_CLEVEL={value}: numeric value too large "
);
DEFAULT_CLEVEL
}
Err(_) => {
eprintln!(
"Ignore environment variable setting ZSTD_CLEVEL={value}: not a valid integer value "
);
DEFAULT_CLEVEL
}
}
}
fn value_has_only_sign_and_digits(value: &str) -> bool {
let digits = value
.strip_prefix('+')
.or_else(|| value.strip_prefix('-'))
.unwrap_or(value);
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
}
#[cfg(feature = "compression")]
unsafe fn default_worker_count() -> i32 {
if let Ok(value) = env::var("ZSTD_NBTHREADS") {
if let Ok(workers) = value.parse::<u32>() {
if let Ok(workers) = i32::try_from(workers) {
return workers;
}
}
}
let logical_cores = unsafe { UTIL_countLogicalCores() }.max(1);
(logical_cores / 4).clamp(1, 4)
}
#[cfg(feature = "compression")]
unsafe fn resolved_worker_count(
workers: Option<i32>,
single_thread: bool,
auto_threads_logical: bool,
) -> i32 {
match workers {
/* --single-thread pins zero workers; a bare zero (-T0 or the zstdmt
* program name) auto-detects the core count as in the C CLI. */
Some(0) if single_thread => 0,
Some(0) if auto_threads_logical => unsafe { UTIL_countLogicalCores() }.max(1),
Some(0) => unsafe { UTIL_countPhysicalCores() }.max(1),
Some(workers) => workers,
None => unsafe { default_worker_count() },
}
}
fn program_basename(value: &OsStr) -> String {
Path::new(value)
.file_name()
.unwrap_or(value)
.to_string_lossy()
.split('.')
.next()
.unwrap_or("zstd")
.to_owned()
}
#[cfg(feature = "compression")]
fn max_c_level_for_help() -> i32 {
DEFAULT_MAX_CLEVEL
}
#[cfg(not(feature = "compression"))]
const fn max_c_level_for_help() -> i32 {
19
}
#[cfg(feature = "compression")]
fn max_c_level_for_ultra() -> i32 {
unsafe { ZSTD_maxCLevel() }
}
#[cfg(not(feature = "compression"))]
const fn max_c_level_for_ultra() -> i32 {
19
}
fn write_basic_usage<W: Write>(out: &mut W, program_name: &str) {
let max_level = max_c_level_for_help();
let _ = writeln!(
out,
"Compress or decompress the INPUT file(s); reads from STDIN if INPUT is `-` or not provided."
);
let _ = writeln!(
out,
"\nUsage: {program_name} [OPTIONS...] [INPUT... | -] [-o OUTPUT]"
);
let _ = writeln!(out, "\nOptions:");
let _ = writeln!(
out,
" -o OUTPUT Write output to a single file, OUTPUT."
);
let _ = writeln!(
out,
" -k, --keep Preserve INPUT file(s). [Default] "
);
let _ = writeln!(
out,
" --rm Remove INPUT file(s) after successful (de)compression."
);
if program_name == "gzip" {
let _ = writeln!(
out,
" -n, --no-name Do not store original filename when compressing."
);
}
let _ = writeln!(out);
let _ = writeln!(
out,
" -# Desired compression level, where `#` is a number between 1 and {max_level};"
);
let _ = writeln!(
out,
" lower numbers provide faster compression, higher numbers yield"
);
let _ = writeln!(
out,
" better compression ratios. [Default: {DEFAULT_CLEVEL}]"
);
let _ = writeln!(out);
let _ = writeln!(
out,
" -d, --decompress Perform decompression."
);
let _ = writeln!(
out,
" -D DICT Use DICT as the dictionary for compression or decompression."
);
let _ = writeln!(out);
let _ = writeln!(
out,
" -f, --force Disable input and output checks. Allows overwriting existing files,"
);
let _ = writeln!(
out,
" receiving input from the console, printing output to STDOUT, and"
);
let _ = writeln!(
out,
" operating on links, block devices, etc. Unrecognized formats will be"
);
let _ = writeln!(
out,
" passed-through through as-is."
);
let _ = writeln!(out);
let _ = writeln!(
out,
" -h Display short usage and exit."
);
let _ = writeln!(
out,
" -H, --help Display full help and exit."
);
let _ = writeln!(
out,
" -V, --version Display the program version and exit."
);
let _ = writeln!(out);
}
fn write_advanced_usage<W: Write>(out: &mut W, program_name: &str) {
let version = unsafe { CStr::from_ptr(ZSTD_versionString()) }.to_string_lossy();
let _ = writeln!(
out,
"*** Zstandard CLI ({}-bit) v{version}, by Yann Collet ***",
usize::BITS
);
let _ = writeln!(out);
write_basic_usage(out, program_name);
let _ = writeln!(out, "\nAdvanced options:");
let _ = writeln!(
out,
" -c, --stdout Write to STDOUT (even if it is a console) and keep the INPUT file(s)."
);
let _ = writeln!(
out,
" -v, --verbose Enable verbose output; pass multiple times to increase verbosity."
);
let _ = writeln!(
out,
" -q, --quiet Suppress warnings; pass twice to suppress errors."
);
let _ = writeln!(
out,
" --trace LOG Log tracing information to LOG."
);
let _ = writeln!(out);
let _ = writeln!(
out,
" --[no-]progress Forcibly show/hide the progress counter."
);
let _ = writeln!(out);
let _ = writeln!(
out,
" -r Operate recursively on directories."
);
let _ = writeln!(
out,
" --filelist LIST Read a list of files to operate on from LIST."
);
let _ = writeln!(
out,
" --output-dir-flat DIR Store processed files in DIR."
);
#[cfg(unix)]
let _ = writeln!(
out,
" --output-dir-mirror DIR Store processed files in DIR, respecting original directory structure."
);
let _ = writeln!(out);
let _ = writeln!(
out,
" -- Treat remaining arguments after `--` as files."
);
let _ = writeln!(out, "\nAdvanced compression options:");
let _ = writeln!(
out,
" --ultra Enable levels beyond 19, up to {}; requires more memory.",
max_c_level_for_ultra()
);
let _ = writeln!(
out,
" --fast[=#] Use very fast compression levels. [Default: 1]"
);
if program_name == "gzip" {
let _ = writeln!(
out,
" --best Compatibility alias for `-9`."
);
}
let _ = writeln!(
out,
" --adapt Dynamically adapt compression level to I/O conditions."
);
let _ = writeln!(
out,
" --long[=#] Enable long distance matching with window log #."
);
let _ = writeln!(out, " --patch-from=REF Use REF as the reference point for Zstandard's diff engine.");
let _ = writeln!(
out,
" -T# Spawn # compression threads."
);
let _ = writeln!(
out,
" --single-thread Share a single thread for I/O and compression."
);
let _ = writeln!(out, " --auto-threads={{physical|logical}}");
let _ = writeln!(out, " -B# Set job size to #.");
let _ = writeln!(
out,
" --rsyncable Compress using a rsync-friendly method."
);
let _ = writeln!(
out,
" --exclude-compressed Only compress files that are not already compressed."
);
let _ = writeln!(
out,
" --stream-size=# Specify size of streaming input from STDIN."
);
let _ = writeln!(
out,
" --size-hint=# Optimize compression parameters for streaming input."
);
let _ = writeln!(out, " --target-compressed-block-size=#");
let _ = writeln!(
out,
" Generate compressed blocks of approximately # size."
);
let _ = writeln!(
out,
" --no-dictID Don't write `dictID` into the header."
);
let _ = writeln!(
out,
" --[no-]compress-literals Force (un)compressed literals."
);
let _ = writeln!(
out,
" --[no-]row-match-finder Enable/disable the row-based matchfinder."
);
let _ = writeln!(
out,
" --format=zstd Compress files to the `.zst` format. [Default]"
);
let _ = writeln!(
out,
" --format=gzip Compress files to the `.gz` format."
);
let _ = writeln!(
out,
" --format=xz Compress files to the `.xz` format."
);
let _ = writeln!(
out,
" --format=lzma Compress files to the `.lzma` format."
);
let _ = writeln!(
out,
" --format=lz4 Compress files to the `.lz4` format."
);
let _ = writeln!(out, "\nAdvanced decompression options:");
let _ = writeln!(
out,
" -l Print information about compressed files."
);
let _ = writeln!(
out,
" --test Test compressed file integrity."
);
let _ = writeln!(
out,
" -M# Set the memory usage limit to # megabytes."
);
let _ = writeln!(
out,
" --[no-]sparse Enable/disable sparse mode."
);
let _ = writeln!(
out,
" --[no-]pass-through Pass through uncompressed files as-is."
);
let _ = writeln!(out, "\nDictionary builder:");
let _ = writeln!(
out,
" --train Create a dictionary from a training set of files."
);
let _ = writeln!(out, " --train-cover[=k=#,d=#,steps=#,split=#,shrink[=#]]");
let _ = writeln!(
out,
" --train-fastcover[=k=#,d=#,f=#,steps=#,split=#,accel=#,shrink[=#]]"
);
let _ = writeln!(
out,
" --train-legacy[=s=#] Use the legacy algorithm."
);
let _ = writeln!(
out,
" --maxdict=# Limit dictionary to specified size #."
);
let _ = writeln!(
out,
" --dictID=# Force dictionary ID to #."
);
let _ = writeln!(out, "\nBenchmark options:");
let _ = writeln!(
out,
" -b# Perform benchmarking with compression level #."
);
let _ = writeln!(
out,
" -e# Test all compression levels up to #."
);
let _ = writeln!(
out,
" -i# Set the minimum evaluation time to # seconds."
);
}
fn usage(advanced: bool) {
let mut out = io::stdout().lock();
if advanced {
write_advanced_usage(&mut out, "zstd");
} else {
write_basic_usage(&mut out, "zstd");
}
}
fn print_version(quiet: bool) {
let version = unsafe { CStr::from_ptr(ZSTD_versionString()) }
.to_string_lossy()
.into_owned();
if quiet {
println!("{version}");
} else {
println!(
"*** Zstandard CLI ({}-bit) v{version}, by Yann Collet ***",
usize::BITS
);
}
}
fn print_cli_header(display_level: i32) {
if display_level >= 3 {
let version = unsafe { CStr::from_ptr(ZSTD_versionString()) }.to_string_lossy();
eprintln!(
"*** Zstandard CLI ({}-bit) v{version}, by Yann Collet ***",
usize::BITS
);
}
}
fn check_lib_version() -> Result<(), String> {
let expected = unsafe { CStr::from_ptr(ZSTD_rust_cli_expected_version()) };
let actual = unsafe { CStr::from_ptr(ZSTD_versionString()) };
if expected == actual {
return Ok(());
}
Err(format!(
"incorrect library version (expecting: {}; actual: {})",
expected.to_string_lossy(),
actual.to_string_lossy()
))
}
fn parse_size(value: &str) -> Result<usize, String> {
let split = value
.find(|character: char| !character.is_ascii_digit())
.unwrap_or(value.len());
let (digits, suffix) = value.split_at(split);
if digits.is_empty() {
return Err(
"error: only numeric values with optional suffixes K, KB, KiB, M, MB, MiB are allowed "
.to_owned(),
);
}
let mut number = digits
.parse::<usize>()
.map_err(|_| "error: numeric value overflows size_t ".to_owned())?;
let shift = match suffix {
"" => 0,
"K" | "KB" | "KiB" => 10,
"M" | "MB" | "MiB" => 20,
_ => return Err(
"error: only numeric values with optional suffixes K, KB, KiB, M, MB, MiB are allowed "
.to_owned(),
),
};
number = number
.checked_shl(shift)
.ok_or_else(|| "error: numeric value overflows size_t ".to_owned())?;
Ok(number)
}
fn parse_u32(value: &str, name: &str) -> Result<u32, String> {
let _ = name;
let size = parse_size(value)?;
u32::try_from(size)
.map_err(|_| "error: numeric value overflows 32-bit unsigned int ".to_owned())
}
fn parse_i32(value: &str, name: &str) -> Result<i32, String> {
value
.parse::<i32>()
.map_err(|_| format!("invalid {name}: {value:?}"))
}
fn parse_compression_level(value: &str) -> Result<i32, String> {
/* The C CLI reads short-form levels as U32 before storing them in its
* signed level field. Preserve that conversion for values such as
* 4294967295, which represents -1 on the supported two's-complement
* targets and is a valid fast-compression level. */
Ok(parse_u32(value, "compression level")? as i32)
}
fn parse_worker_count(value: &str) -> Result<i32, String> {
let workers = parse_i32(value, "thread count")?;
if workers < 0 {
return Err(format!("thread count must not be negative: {value:?}"));
}
Ok(workers)
}
fn next_value(
attached: Option<&str>,
args: &[OsString],
index: &mut usize,
option: &str,
) -> Result<String, String> {
if let Some(value) = attached {
if !value.is_empty() {
return Ok(value.to_owned());
}
}
*index += 1;
let Some(value) = args.get(*index) else {
return Err(format!("missing argument for {option}"));
};
let rendered = value.to_string_lossy().into_owned();
if rendered.starts_with('-') {
return Err(format!(
"{option} cannot be separated from its argument by another option"
));
}
Ok(rendered)
}
fn next_os_value(args: &[OsString], index: &mut usize, option: &str) -> Result<OsString, String> {
*index += 1;
let Some(value) = args.get(*index) else {
return Err(format!("missing argument for {option}"));
};
if value.to_string_lossy().starts_with('-') {
return Err(format!(
"{option} cannot be separated from its argument by another option"
));
}
Ok(value.clone())
}
/// Mirrors the C CLI `NEXT_FIELD` macro: an attached `=value` is used as-is
/// (even when empty), otherwise the next argument is consumed. Unlike
/// `next_value`, an empty attached value never falls through to the next
/// argument, so `--output-dir-flat=` stays an empty (and rejected) name.
fn next_field(
attached: Option<&str>,
args: &[OsString],
index: &mut usize,
option: &str,
) -> Result<String, String> {
if let Some(value) = attached {
return Ok(value.to_owned());
}
*index += 1;
let Some(value) = args.get(*index) else {
return Err(format!("missing argument for {option}"));
};
let rendered = value.to_string_lossy().into_owned();
if rendered.starts_with('-') {
return Err(format!(
"{option} cannot be separated from its argument by another option"
));
}
Ok(rendered)
}
#[cfg(unix)]
fn short_attached_value(value: &OsStr, start: usize) -> Option<OsString> {
let bytes = &value.as_bytes()[start..];
if bytes.is_empty() {
return None;
}
let bytes = if bytes.first() == Some(&b'=') {
&bytes[1..]
} else {
bytes
};
Some(OsString::from_vec(bytes.to_vec()))
}
#[cfg(not(unix))]
fn short_attached_value(value: &OsStr, start: usize) -> Option<OsString> {
let rendered = value.to_string_lossy();
let attached = &rendered[start..];
if attached.is_empty() {
return None;
}
Some(OsString::from(
attached.strip_prefix('=').unwrap_or(attached),
))
}
fn parse_compression_parameters(value: &str, cli: &mut Cli) -> Result<(), String> {
for item in value.split(',') {
let Some((name, raw)) = item.split_once('=') else {
return Err(format!("invalid --zstd parameter {item:?}"));
};
let parsed = parse_u32(raw, name)?;
match name {
"windowLog" | "wlog" => cli.compression_params.windowLog = parsed,
"chainLog" | "clog" => cli.compression_params.chainLog = parsed,
"hashLog" | "hlog" => cli.compression_params.hashLog = parsed,
"searchLog" | "slog" => cli.compression_params.searchLog = parsed,
"minMatch" | "mml" => cli.compression_params.minMatch = parsed,
"targetLength" | "tlen" => cli.compression_params.targetLength = parsed,
"strategy" | "strat" => cli.compression_params.strategy = parsed as c_int,
"overlapLog" | "ovlog" => cli.overlap_log = Some(parsed as i32),
"ldmHashLog" | "lhlog" => cli.ldm_hash_log = Some(parsed as i32),
"ldmMinMatch" | "lmml" => cli.ldm_min_match = Some(parsed as i32),
"ldmBucketSizeLog" | "lblog" => cli.ldm_bucket_size_log = Some(parsed as i32),
"ldmHashRateLog" | "lhrlog" => cli.ldm_hash_rate_log = Some(parsed as i32),
_ => return Err(format!("unknown --zstd parameter {name:?}")),
}
}
Ok(())
}
fn parse_adapt(value: &str, cli: &mut Cli) -> Result<(), String> {
cli.adapt = true;
if value.is_empty() {
return Err("invalid --adapt parameter".to_owned());
}
for item in value.split(',') {
let Some((name, raw)) = item.split_once('=') else {
return Err(format!("invalid --adapt parameter {item:?}"));
};
match name {
"min" => cli.adapt_min = Some(parse_i32(raw, "adapt minimum")?),
"max" => cli.adapt_max = Some(parse_i32(raw, "adapt maximum")?),
_ => return Err(format!("unknown --adapt parameter {name:?}")),
}
}
if let (Some(minimum), Some(maximum)) = (cli.adapt_min, cli.adapt_max) {
if minimum > maximum {
return Err("--adapt minimum must not exceed its maximum".to_owned());
}
}
Ok(())
}
fn default_fast_cover_params() -> ZDICT_fastCover_params_t {
ZDICT_fastCover_params_t {
d: 8,
f: 20,
steps: 4,
splitPoint: 0.75,
accel: DEFAULT_FASTCOVER_ACCEL,
shrinkDictMaxRegression: DEFAULT_SHRINK_DICT_REGRESSION,
..ZDICT_fastCover_params_t::default()
}
}
fn parse_training_u32(value: &str, option: &str, field: &str) -> Result<u32, String> {
parse_u32(value, &format!("{option} {field}"))
}
fn parse_cover_parameters(value: &str) -> Result<ZDICT_cover_params_t, String> {
let mut params = ZDICT_cover_params_t::default();
for item in value.split(',') {
if item == "shrink" {
params.shrinkDict = 1;
params.shrinkDictMaxRegression = DEFAULT_SHRINK_DICT_REGRESSION;
continue;
}
if let Some(raw) = item.strip_prefix("shrink=") {
params.shrinkDict = 1;
params.shrinkDictMaxRegression =
parse_training_u32(raw, "--train-cover", "shrink regression")?;
continue;
}
let Some((field, raw)) = item.split_once('=') else {
return Err(format!("invalid --train-cover parameter {item:?}"));
};
let parsed = parse_training_u32(raw, "--train-cover", field)?;
match field {
"k" => params.k = parsed,
"d" => params.d = parsed,
"steps" => params.steps = parsed,
"split" => params.splitPoint = f64::from(parsed) / 100.0,
_ => return Err(format!("unknown --train-cover parameter {field:?}")),
}
}
Ok(params)
}
fn parse_fast_cover_parameters(value: &str) -> Result<ZDICT_fastCover_params_t, String> {
let mut params = ZDICT_fastCover_params_t::default();
for item in value.split(',') {
if item == "shrink" {
params.shrinkDict = 1;
params.shrinkDictMaxRegression = DEFAULT_SHRINK_DICT_REGRESSION;
continue;
}
if let Some(raw) = item.strip_prefix("shrink=") {
params.shrinkDict = 1;
params.shrinkDictMaxRegression =
parse_training_u32(raw, "--train-fastcover", "shrink regression")?;
continue;
}
let Some((field, raw)) = item.split_once('=') else {
return Err(format!("invalid --train-fastcover parameter {item:?}"));
};
let parsed = parse_training_u32(raw, "--train-fastcover", field)?;
match field {
"k" => params.k = parsed,
"d" => params.d = parsed,
"f" => params.f = parsed,
"steps" => params.steps = parsed,
"split" => params.splitPoint = f64::from(parsed) / 100.0,
"accel" => params.accel = parsed,
_ => return Err(format!("unknown --train-fastcover parameter {field:?}")),
}
}
Ok(params)
}
fn parse_legacy_parameters(value: &str) -> Result<u32, String> {
let Some(raw) = value
.strip_prefix("s=")
.or_else(|| value.strip_prefix("selectivity="))
else {
return Err(format!("invalid --train-legacy parameter {value:?}"));
};
parse_training_u32(raw, "--train-legacy", "selectivity")
}
fn select_training(cli: &mut Cli, algorithm: TrainingAlgorithm) -> Result<(), String> {
cli.operation = Operation::Train;
cli.training_algorithm = algorithm;
if cli.output.is_none() {
cli.output = Some(cstring(DEFAULT_DICT_NAME)?);
}
Ok(())
}
fn unsupported(option: &str) -> Result<(), String> {
Err(format!(
"{option} is not yet implemented by the Rust CLI frontend"
))
}
fn set_max_compression(cli: &mut Cli) -> Result<(), String> {
if cfg!(target_pointer_width = "32") {
return Err("--max is incompatible with 32-bit mode".to_owned());
}
cli.ultra = true;
cli.ldm = true;
cli.compression_params = ZSTD_compressionParameters {
windowLog: MAX_WINDOW_LOG,
chainLog: MAX_CHAIN_LOG,
hashLog: MAX_HASH_LOG,
searchLog: MAX_SEARCH_LOG,
minMatch: MAX_MIN_MATCH,
targetLength: MAX_TARGET_LENGTH,
strategy: MAX_STRATEGY,
};
cli.overlap_log = Some(MAX_OVERLAP_LOG);
cli.ldm_hash_log = Some(MAX_LDM_HASH_LOG);
cli.ldm_hash_rate_log = Some(0);
cli.ldm_min_match = Some(MAX_LDM_MIN_MATCH);
cli.ldm_bucket_size_log = Some(MAX_LDM_BUCKET_SIZE_LOG);
Ok(())
}
fn parse_long_option(
option: &str,
args: &[OsString],
index: &mut usize,
cli: &mut Cli,
) -> Result<Option<Action>, String> {
let (name, attached) = option
.split_once('=')
.map_or((option, None), |(name, value)| (name, Some(value)));
if attached.is_some()
&& matches!(
name,
"--compress"
| "--decompress"
| "--uncompress"
| "--test"
| "--force"
| "--keep"
| "--rm"
| "--stdout"
| "--version"
| "--help"
| "--verbose"
| "--quiet"
| "--check"
| "--no-check"
| "--sparse"
| "--no-sparse"
| "--pass-through"
| "--no-pass-through"
| "--content-size"
| "--no-content-size"
| "--no-dictID"
| "--asyncio"
| "--no-asyncio"
| "--mmap-dict"
| "--no-mmap-dict"
| "--progress"
| "--no-progress"
| "--ultra"
| "--no-row-match-finder"
| "--row-match-finder"
| "--rsyncable"
| "--single-thread"
| "--compress-literals"
| "--no-compress-literals"
| "--exclude-compressed"
| "--no-name"
| "--best"
| "--list"
| "--show-default-cparams"
| "--train"
| "--max"
| "--fake-stderr-is-console"
| "--trace-file-stat"
)
{
return Err(format!("{name} does not take an argument"));
}
match name {
"--" => Ok(None),
"--compress" => {
cli.operation = Operation::Compress;
Ok(None)
}
"--decompress" | "--uncompress" => {
cli.bench_decode = true;
if cli.operation != Operation::Bench {
cli.operation = Operation::Decompress;
}
Ok(None)
}
"--test" => {
cli.operation = Operation::Test;
Ok(None)
}
"--force" => {
cli.force = true;
Ok(None)
}
"--keep" => {
cli.remove_source = false;
Ok(None)
}
"--no-name" => Ok(None),
"--best" if cli.gzip_compat => {
cli.level = 9;
Ok(None)
}
"--rm" => {
cli.remove_source = true;
Ok(None)
}
"--stdout" => {
cli.output = Some(cstring(STDOUT_MARK)?);
cli.force_stdout = true;
Ok(None)
}
"--version" => Ok(Some(Action::Version {
quiet: cli.display_level < 2,
})),
"--help" => Ok(Some(Action::Help { advanced: true })),
"--verbose" => {
cli.display_level += 1;
Ok(None)
}
"--quiet" => {
cli.display_level -= 1;
Ok(None)
}
"--check" => {
cli.checksum = Some(2);
Ok(None)
}
"--no-check" => {
cli.checksum = Some(0);
Ok(None)
}
"--sparse" => {
cli.sparse = Some(2);
Ok(None)
}
"--no-sparse" => {
cli.sparse = Some(0);
Ok(None)
}
"--pass-through" => {
cli.pass_through = Some(1);
Ok(None)
}
"--no-pass-through" => {
cli.pass_through = Some(0);
Ok(None)
}
"--content-size" => {
cli.content_size = 1;
Ok(None)
}
"--no-content-size" => {
cli.content_size = 0;
Ok(None)
}
"--no-dictID" => {
cli.dict_id = Some(0);
Ok(None)
}
"--asyncio" => {
cli.async_io = Some(1);
Ok(None)
}
"--no-asyncio" => {
cli.async_io = Some(0);
Ok(None)
}
"--mmap-dict" => {
cli.mmap_dict = ZSTD_PS_ENABLE;
Ok(None)
}
"--no-mmap-dict" => {
cli.mmap_dict = ZSTD_PS_DISABLE;
Ok(None)
}
"--progress" => {
cli.progress = FIO_PS_ALWAYS;
Ok(None)
}
"--no-progress" => {
cli.progress = FIO_PS_NEVER;
Ok(None)
}
"--ultra" => {
cli.ultra = true;
Ok(None)
}
"--fast" => {
let mut level = match attached {
Some(value) => parse_u32(value, "fast level")?,
None => 1,
};
if level == 0 {
return Err("fast level must be positive".to_owned());
}
level = level.min(MAX_FAST_ACCELERATION as u32);
cli.level = -(level as i32);
Ok(None)
}
"--long" => {
cli.ldm = true;
cli.ultra = true;
if let Some(value) = attached {
cli.compression_params.windowLog = parse_u32(value, "long window log")?;
} else if cli.compression_params.windowLog == 0 {
cli.compression_params.windowLog = DEFAULT_LONG_WINDOW_LOG;
}
Ok(None)
}
"--adapt" => {
if let Some(value) = attached {
parse_adapt(value, cli)?;
} else {
cli.adapt = true;
}
Ok(None)
}
"--no-row-match-finder" => {
cli.row_match_finder = ZSTD_PS_DISABLE;
Ok(None)
}
"--row-match-finder" => {
cli.row_match_finder = ZSTD_PS_ENABLE;
Ok(None)
}
"--rsyncable" => {
cli.rsyncable = true;
Ok(None)
}
"--compress-literals" => {
cli.literal_compression = Some(ZSTD_PS_ENABLE);
Ok(None)
}
"--no-compress-literals" => {
cli.literal_compression = Some(ZSTD_PS_DISABLE);
Ok(None)
}
"--exclude-compressed" => {
cli.exclude_compressed = true;
Ok(None)
}
"--threads" => {
let value = next_value(attached, args, index, "--threads")?;
cli.workers = Some(parse_worker_count(&value)?);
Ok(None)
}
"--auto-threads" => {
let value = next_field(attached, args, index, name)?;
cli.auto_threads_logical = match value.as_str() {
"physical" => false,
"logical" => true,
_ => return Err(format!("unknown auto-thread mode {value:?}")),
};
Ok(None)
}
"--single-thread" => {
/* As in the C CLI: zero workers plus a latch that suppresses the
* automatic core-count resolution, so fileio runs its
* single-thread streaming mode (slightly different from -T1). */
cli.workers = Some(0);
cli.single_thread = true;
Ok(None)
}
"--memlimit" | "--memory" | "--memlimit-decompress" => {
let value = next_value(attached, args, index, name)?;
cli.mem_limit = Some(parse_u32(&value, "memory limit")?);
Ok(None)
}
"--block-size" => {
let value = next_value(attached, args, index, name)?;
cli.block_size = Some(parse_size(&value)?);
Ok(None)
}
"--stream-size" => {
let value = next_value(attached, args, index, name)?;
cli.stream_src_size = Some(parse_size(&value)?);
Ok(None)
}
"--target-compressed-block-size" => {
let value = next_value(attached, args, index, name)?;
cli.target_cblock_size = Some(parse_size(&value)?);
Ok(None)
}
"--size-hint" => {
let value = next_value(attached, args, index, name)?;
cli.src_size_hint = Some(parse_size(&value)?);
Ok(None)
}
"--zstd" => {
let value = next_value(attached, args, index, "--zstd")?;
parse_compression_parameters(&value, cli)?;
Ok(None)
}
"--list" => {
cli.operation = Operation::List;
Ok(None)
}
"--show-default-cparams" => {
cli.show_default_cparams = true;
Ok(None)
}
"--train" => {
select_training(cli, cli.training_algorithm)?;
Ok(None)
}
"--train-cover" => {
let params = attached.map_or_else(
|| Ok(ZDICT_cover_params_t::default()),
parse_cover_parameters,
)?;
select_training(cli, TrainingAlgorithm::Cover)?;
cli.cover_params = params;
Ok(None)
}
"--train-fastcover" => {
let params = attached.map_or_else(
|| Ok(ZDICT_fastCover_params_t::default()),
parse_fast_cover_parameters,
)?;
select_training(cli, TrainingAlgorithm::FastCover)?;
cli.fast_cover_params = params;
Ok(None)
}
"--train-legacy" => {
let selectivity =
attached.map_or_else(|| Ok(cli.dictionary_selectivity), parse_legacy_parameters)?;
select_training(cli, TrainingAlgorithm::Legacy)?;
cli.dictionary_selectivity = selectivity;
Ok(None)
}
"--maxdict" => {
let value = next_field(attached, args, index, name)?;
cli.max_dict_size = parse_u32(&value, "maximum dictionary size")? as usize;
Ok(None)
}
"--dictID" => {
let value = next_field(attached, args, index, name)?;
cli.dictionary_id = parse_u32(&value, "dictionary ID")?;
Ok(None)
}
"--format" => {
let value = next_field(attached, args, index, name)?;
cli.compression_format = CompressionFormat::parse(&value)?;
Ok(None)
}
"--trace" => {
let value = next_field(attached, args, index, name)?;
cli.trace = Some(cstring(&value)?);
Ok(None)
}
"--filelist" => {
let value = next_field(attached, args, index, name)?;
cli.file_lists.push(cstring(&value)?);
Ok(None)
}
"--output-dir-flat" => {
let value = next_field(attached, args, index, name)?;
if value.is_empty() {
return Err(
"output dir cannot be empty string (did you mean to pass '.' instead?)"
.to_owned(),
);
}
cli.output_dir_flat = Some(cstring(&value)?);
Ok(None)
}
#[cfg(unix)]
"--output-dir-mirror" => {
/* Parsed only where the C CLI defines UTIL_HAS_MIRRORFILELIST
* (POSIX); elsewhere the option falls through as unknown. */
let value = next_field(attached, args, index, name)?;
if value.is_empty() {
return Err(
"output dir cannot be empty string (did you mean to pass '.' instead?)"
.to_owned(),
);
}
cli.output_dir_mirror = Some(cstring(&value)?);
Ok(None)
}
"--max" => {
set_max_compression(cli)?;
Ok(None)
}
"--fake-stderr-is-console" => {
cli.fake_stderr_is_console = true;
Ok(None)
}
"--trace-file-stat" => {
cli.trace_file_stat = true;
Ok(None)
}
"--patch-from" => {
if cli.dictionary.is_some() {
return Err("can't use -D and --patch-from=# at the same time".to_owned());
}
let value = next_field(attached, args, index, name)?;
cli.patch_from = Some(cstring(&value)?);
cli.ultra = true;
Ok(None)
}
"--priority" | "--fake-stdin-is-console" | "--fake-stdout-is-console" => {
unsupported(name)?;
Ok(None)
}
_ => Err(format!("unknown option {option}")),
}
}
fn parse_short_options(
value: &str,
raw_value: &OsStr,
args: &[OsString],
index: &mut usize,
cli: &mut Cli,
) -> Result<Option<Action>, String> {
let mut offset = 1usize;
let bytes = value.as_bytes();
while offset < bytes.len() {
let option = bytes[offset] as char;
match option {
'0'..='9' => {
let mut digits_end = offset;
while digits_end < bytes.len() && bytes[digits_end].is_ascii_digit() {
digits_end += 1;
}
cli.level = parse_compression_level(&value[offset..digits_end])?;
offset = digits_end;
continue;
}
'd' => {
cli.bench_decode = true;
if cli.operation != Operation::Bench {
cli.operation = Operation::Decompress;
}
}
'z' => cli.operation = Operation::Compress,
't' => cli.operation = Operation::Test,
'b' => cli.operation = Operation::Bench,
'l' => cli.operation = Operation::List,
#[cfg(any(unix, windows))]
'r' => cli.recursive = true,
'e' | 'i' => {
// Benchmark range end (-e#) and duration (-i#): like the C
// parser, digits attach directly and default to 0.
let mut digits_end = offset + 1;
while digits_end < bytes.len() && bytes[digits_end].is_ascii_digit() {
digits_end += 1;
}
let digits = &value[offset + 1..digits_end];
if option == 'e' {
cli.bench_end_level = Some(if digits.is_empty() {
0
} else {
parse_i32(digits, "benchmark end level")?
});
} else {
cli.bench_nb_seconds = Some(if digits.is_empty() {
0
} else {
digits
.parse::<u32>()
.map_err(|_| format!("invalid benchmark duration: {digits:?}"))?
});
}
offset = digits_end;
continue;
}
'c' => {
cli.output = Some(cstring(STDOUT_MARK)?);
cli.force_stdout = true;
}
'f' => cli.force = true,
'k' => cli.remove_source = false,
'n' => {}
'q' => cli.display_level -= 1,
'v' => cli.display_level += 1,
'C' => cli.checksum = Some(2),
'h' => return Ok(Some(Action::Help { advanced: false })),
'H' => return Ok(Some(Action::Help { advanced: true })),
'V' => {
return Ok(Some(Action::Version {
quiet: cli.display_level < 2,
}))
}
'o' | 'D' | 'T' | 'M' | 'B' => {
let attached = short_attached_value(raw_value, offset + 1);
let argument = attached
.map(Ok)
.unwrap_or_else(|| next_os_value(args, index, &format!("-{option}")))?;
match option {
'o' => cli.output = Some(os_cstring(&argument)?),
'D' => {
if cli.patch_from.is_some() {
return Err(
"can't use -D and --patch-from=# at the same time".to_owned()
);
}
cli.dictionary = Some(os_cstring(&argument)?);
}
'T' => cli.workers = Some(parse_worker_count(&argument.to_string_lossy())?),
'M' => {
cli.mem_limit =
Some(parse_u32(&argument.to_string_lossy(), "memory limit")?)
}
'B' => cli.block_size = Some(parse_size(&argument.to_string_lossy())?),
_ => unreachable!(),
}
break;
}
's' => {
let mut digits_end = offset + 1;
while digits_end < bytes.len() && bytes[digits_end].is_ascii_digit() {
digits_end += 1;
}
if digits_end == offset + 1 {
return Err("missing argument for -s".to_owned());
}
cli.dictionary_selectivity =
parse_u32(&value[offset + 1..digits_end], "dictionary selectivity")?;
offset = digits_end;
continue;
}
'p' | 'P' | 'S' => {
unsupported(&format!("-{option}"))?;
}
_ => return Err(format!("unknown option -{option}")),
}
offset += 1;
}
Ok(None)
}
#[derive(Debug)]
struct ParseError {
message: String,
#[allow(dead_code)]
details: String,
show_usage: bool,
}
impl ParseError {
fn direct(message: String) -> Self {
Self::direct_with_details(message.clone(), message)
}
fn direct_with_details(message: String, details: String) -> Self {
Self {
message,
details,
show_usage: false,
}
}
fn incorrect_parameter(parameter: &str, details: String, show_usage: bool) -> Self {
Self {
message: format!("Incorrect parameter: {parameter}"),
details,
show_usage,
}
}
#[cfg(test)]
fn contains(&self, needle: &str) -> bool {
self.message.contains(needle) || self.details.contains(needle)
}
}
fn classify_parse_error(error: String, parameter: &str, display_level: i32) -> ParseError {
if error.starts_with("error:") {
return ParseError::direct(error);
}
if error.starts_with("output dir cannot be empty string") {
return ParseError::direct(format!("error: {error}"));
}
if error.starts_with("missing argument for ") {
return ParseError::direct_with_details(
"error: missing command argument ".to_owned(),
error,
);
}
if error.contains("cannot be separated from its argument") {
return ParseError::direct_with_details(
"error: command cannot be separated from its argument by another command ".to_owned(),
error,
);
}
if let Some(option) = error.strip_prefix("unknown option ") {
return ParseError::incorrect_parameter(option, error.clone(), display_level >= 2);
}
ParseError::incorrect_parameter(parameter, error, display_level >= 2)
}
fn parse_args(args: Vec<OsString>) -> Result<Action, ParseError> {
let program_name = args
.first()
.map_or_else(|| "zstd".to_owned(), |value| program_basename(value));
let mut cli = Cli::new(&program_name);
let mut end_of_options = false;
let mut index = 1usize;
while index < args.len() {
let rendered = args[index].to_string_lossy().into_owned();
if end_of_options {
cli.inputs
.push(os_cstring(&args[index]).map_err(ParseError::direct)?);
} else if rendered == "--" {
end_of_options = true;
} else if rendered == "-" {
cli.inputs
.push(cstring(STDIN_MARK).map_err(ParseError::direct)?);
} else if rendered.starts_with("--") {
let action = parse_long_option(&rendered, &args, &mut index, &mut cli)
.map_err(|error| classify_parse_error(error, &rendered, cli.display_level))?;
if let Some(action) = action {
return Ok(action);
}
} else if rendered.starts_with('-') {
let action = parse_short_options(&rendered, &args[index], &args, &mut index, &mut cli)
.map_err(|error| classify_parse_error(error, &rendered, cli.display_level))?;
if let Some(action) = action {
return Ok(action);
}
} else {
cli.inputs
.push(os_cstring(&args[index]).map_err(ParseError::direct)?);
}
index += 1;
}
Ok(Action::Run(Box::new(cli)))
}
unsafe fn apply_preferences(cli: &Cli, prefs: *mut FIO_prefs_t, ctx: *mut FIO_ctx_t) {
let display_level = if is_stdout(cli.output.as_ref()) && cli.display_level == 2 {
1
} else {
cli.display_level
};
unsafe {
g_utilDisplayLevel = display_level;
FIO_setCompressionType(prefs, cli.compression_format.fio_type());
FIO_setNotificationLevel(display_level);
FIO_setProgressSetting(
if !cli.fake_stderr_is_console
&& !io::stderr().is_terminal()
&& cli.progress != FIO_PS_ALWAYS
{
FIO_PS_NEVER
} else {
cli.progress
},
);
FIO_setRemoveSrcFile(
prefs,
i32::from(
cli.remove_source
&& cli.operation != Operation::Test
&& !is_stdout(cli.output.as_ref()),
),
);
FIO_setAllowBlockDevices(prefs, i32::from(cli.force));
FIO_setMMapDict(prefs, cli.mmap_dict);
FIO_setUseRowMatchFinder(prefs, cli.row_match_finder);
FIO_setMemLimit(
prefs,
cli.mem_limit
.filter(|limit| *limit != 0)
.unwrap_or_else(|| {
if cli.compression_params.windowLog == 0 {
DEFAULT_MEM_LIMIT
} else {
1_u32 << (cli.compression_params.windowLog & 31)
}
}),
);
#[cfg(feature = "compression")]
FIO_setNbWorkers(
prefs,
resolved_worker_count(cli.workers, cli.single_thread, cli.auto_threads_logical),
);
FIO_setLdmFlag(prefs, u32::from(cli.ldm));
FIO_setAdaptiveMode(prefs, i32::from(cli.adapt));
FIO_setRsyncable(prefs, i32::from(cli.rsyncable));
FIO_setExcludeCompressedFile(prefs, i32::from(cli.exclude_compressed));
if cli.force {
FIO_overwriteMode(prefs);
}
if let Some(value) = cli.checksum {
FIO_setChecksumFlag(prefs, value);
}
if cli.operation == Operation::Compress {
FIO_setSparseWrite(prefs, 0);
} else if let Some(value) = cli.sparse {
FIO_setSparseWrite(prefs, value);
}
if let Some(value) = cli.pass_through {
FIO_setPassThroughFlag(prefs, value);
}
FIO_setPatchFromMode(prefs, i32::from(cli.patch_from.is_some()));
FIO_setContentSize(prefs, cli.content_size);
if let Some(value) = cli.dict_id {
FIO_setDictIDFlag(prefs, value);
}
if let Some(value) = cli.async_io {
FIO_setAsyncIOFlag(prefs, value);
}
if let Some(value) = cli.block_size {
FIO_setBlockSize(prefs, value as c_int);
}
if let Some(value) = cli.ldm_hash_log {
FIO_setLdmHashLog(prefs, value);
}
if let Some(value) = cli.ldm_min_match {
FIO_setLdmMinMatch(prefs, value);
}
if let Some(value) = cli.ldm_bucket_size_log {
FIO_setLdmBucketSizeLog(prefs, value);
}
if let Some(value) = cli.ldm_hash_rate_log {
FIO_setLdmHashRateLog(prefs, value);
}
if let Some(value) = cli.overlap_log {
FIO_setOverlapLog(prefs, value);
}
#[cfg(feature = "compression")]
{
FIO_setAdaptMin(prefs, cli.adapt_min.unwrap_or_else(|| ZSTD_minCLevel()));
FIO_setAdaptMax(prefs, cli.adapt_max.unwrap_or_else(|| ZSTD_maxCLevel()));
}
if let Some(value) = cli.stream_src_size {
FIO_setStreamSrcSize(prefs, value);
}
if let Some(value) = cli.target_cblock_size {
FIO_setTargetCBlockSize(prefs, value);
}
if let Some(value) = cli.src_size_hint {
FIO_setSrcSizeHint(prefs, value);
}
if let Some(value) = cli.literal_compression {
FIO_setLiteralCompressionMode(prefs, value);
}
FIO_setNbFilesTotal(ctx, cli.inputs.len() as c_int);
FIO_setHasStdinInput(ctx, i32::from(cli.inputs.iter().any(is_stdin)));
FIO_setHasStdoutOutput(ctx, i32::from(is_stdout(cli.output.as_ref())));
}
}
fn is_stdout(value: Option<&CString>) -> bool {
value.is_some_and(|value| value.as_bytes() == STDOUT_MARK.as_bytes())
}
fn is_stdin(value: &CString) -> bool {
value.as_bytes() == STDIN_MARK.as_bytes()
}
fn fileio_dictionary(cli: &Cli) -> Option<&CString> {
cli.patch_from.as_ref().or(cli.dictionary.as_ref())
}
#[cfg(unix)]
fn is_non_fifo_symlink(input: &CString) -> bool {
if unsafe { UTIL_isLink(input.as_ptr()) } == 0 {
return false;
}
let path = Path::new(OsStr::from_bytes(input.as_bytes()));
let Ok(metadata) = fs::symlink_metadata(path) else {
return false;
};
metadata.file_type().is_symlink()
&& !fs::metadata(path).is_ok_and(|target| target.file_type().is_fifo())
}
#[cfg(not(unix))]
fn is_non_fifo_symlink(input: &CString) -> bool {
if unsafe { UTIL_isLink(input.as_ptr()) } == 0 {
return false;
}
let path = Path::new(&input.to_string_lossy().into_owned());
fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink())
}
fn filter_symlink_inputs(cli: &mut Cli) {
if cli.force {
return;
}
cli.inputs.retain(|input| {
if is_stdin(input) || !is_non_fifo_symlink(input) {
return true;
}
if cli.display_level >= 2 {
eprintln!(
"zstd: Warning : {} is a symbolic link, ignoring",
input.to_string_lossy()
);
}
false
});
}
fn check_terminal_safety(cli: &Cli) -> Result<(), String> {
let has_stdin = cli.inputs.iter().any(is_stdin);
let stdin_is_console = if has_stdin {
unsafe { UTIL_isConsole(stdin) != 0 }
} else {
false
};
let output_is_stdout = is_stdout(cli.output.as_ref());
let stdout_is_console =
if output_is_stdout || (cli.operation == Operation::Decompress && !has_stdin) {
unsafe { UTIL_isConsole(stdout) != 0 }
} else {
false
};
let stderr_is_console = unsafe { UTIL_isConsole(stderr) != 0 };
if has_stdin && !cli.force && stdin_is_console {
return Err("stdin is a console, aborting".to_owned());
}
if has_stdin
&& output_is_stdout
&& !cli.force
&& !cli.force_stdout
&& cli.operation != Operation::Decompress
&& stdout_is_console
{
return Err("stdout is a console, aborting".to_owned());
}
let _ = stderr_is_console;
Ok(())
}
#[cfg(feature = "compression")]
unsafe fn print_default_cparams(cli: &Cli) {
let dict_size = fileio_dictionary(cli).map_or(0, |dictionary| unsafe {
UTIL_getFileSize(dictionary.as_ptr()) as usize
});
for input in &cli.inputs {
let file_size = unsafe { UTIL_getFileSize(input.as_ptr()) };
let cparams = unsafe { ZSTD_getCParams(cli.level, file_size, dict_size) };
let strategy = ZSTD_STRATEGY_NAMES
.get(cparams.strategy as usize)
.copied()
.unwrap_or("");
if file_size != u64::MAX {
eprintln!("{} ({} bytes)", input.to_string_lossy(), file_size);
} else {
eprintln!("{} (src size unknown)", input.to_string_lossy());
}
eprintln!(" - windowLog : {}", cparams.windowLog);
eprintln!(" - chainLog : {}", cparams.chainLog);
eprintln!(" - hashLog : {}", cparams.hashLog);
eprintln!(" - searchLog : {}", cparams.searchLog);
eprintln!(" - minMatch : {}", cparams.minMatch);
eprintln!(" - targetLength : {}", cparams.targetLength);
eprintln!(" - strategy : {strategy} ({})", cparams.strategy);
}
}
#[cfg(feature = "compression")]
unsafe fn print_actual_cparams(cli: &Cli) {
let dict_size = fileio_dictionary(cli).map_or(0, |dictionary| unsafe {
UTIL_getFileSize(dictionary.as_ptr()) as usize
});
for input in &cli.inputs {
let file_size = unsafe { UTIL_getFileSize(input.as_ptr()) };
let mut actual = unsafe { ZSTD_getCParams(cli.level, file_size, dict_size) };
let requested = cli.compression_params;
if requested.windowLog != 0 {
actual.windowLog = requested.windowLog;
}
if requested.chainLog != 0 {
actual.chainLog = requested.chainLog;
}
if requested.hashLog != 0 {
actual.hashLog = requested.hashLog;
}
if requested.searchLog != 0 {
actual.searchLog = requested.searchLog;
}
if requested.minMatch != 0 {
actual.minMatch = requested.minMatch;
}
if requested.targetLength != 0 {
actual.targetLength = requested.targetLength;
}
if requested.strategy != 0 {
actual.strategy = requested.strategy;
}
eprintln!(
"--zstd=wlog={},clog={},hlog={},slog={},mml={},tlen={},strat={}",
actual.windowLog,
actual.chainLog,
actual.hashLog,
actual.searchLog,
actual.minMatch,
actual.targetLength,
actual.strategy
);
}
}
#[cfg(feature = "compression")]
unsafe fn run_compress(
cli: &Cli,
ctx: *mut FIO_ctx_t,
prefs: *mut FIO_prefs_t,
inputs: &[*const c_char],
output: *const c_char,
dictionary: *const c_char,
) -> c_int {
if inputs.len() == 1 && !output.is_null() {
unsafe {
FIO_compressFilename(
ctx,
prefs,
output,
inputs[0],
dictionary,
cli.level,
cli.compression_params,
)
}
} else {
unsafe {
FIO_compressMultipleFilenames(
ctx,
prefs,
inputs.as_ptr(),
output_dir_mirror(cli),
output_dir_flat(cli),
output,
cli.compression_format.suffix().as_ptr().cast(),
dictionary,
cli.level,
cli.compression_params,
)
}
}
}
fn output_dir_flat(cli: &Cli) -> *const c_char {
cli.output_dir_flat
.as_ref()
.map_or(ptr::null(), |value| value.as_ptr())
}
fn output_dir_mirror(cli: &Cli) -> *const c_char {
cli.output_dir_mirror
.as_ref()
.map_or(ptr::null(), |value| value.as_ptr())
}
#[cfg(feature = "decompression")]
unsafe fn run_decompress(
cli: &Cli,
ctx: *mut FIO_ctx_t,
prefs: *mut FIO_prefs_t,
inputs: &[*const c_char],
output: *const c_char,
dictionary: *const c_char,
) -> c_int {
match cli.operation {
Operation::Test => {
let null_output = cstring(NULL_MARK).expect("static null marker");
unsafe {
FIO_setTestMode(prefs, 1);
FIO_decompressMultipleFilenames(
ctx,
prefs,
inputs.as_ptr(),
output_dir_mirror(cli),
output_dir_flat(cli),
null_output.as_ptr(),
dictionary,
)
}
}
Operation::Decompress if inputs.len() == 1 && !output.is_null() => unsafe {
FIO_decompressFilename(ctx, prefs, output, inputs[0], dictionary)
},
Operation::Decompress => unsafe {
FIO_decompressMultipleFilenames(
ctx,
prefs,
inputs.as_ptr(),
output_dir_mirror(cli),
output_dir_flat(cli),
output,
dictionary,
)
},
Operation::Compress | Operation::Bench | Operation::List | Operation::Train => {
unreachable!("compression, benchmark, and list are dispatched separately")
}
}
}
/// Copies every name out of a C `FileNamesTable` and releases the table.
unsafe fn drain_file_names_table(table: *mut FileNamesTable, into: &mut Vec<CString>) {
unsafe {
for entry in 0..(*table).tableSize {
let name = *(*table).fileNames.add(entry);
if !name.is_null() {
into.push(CStr::from_ptr(name).to_owned());
}
}
UTIL_freeFileNamesTable(table);
}
}
/// Appends the names read from every `--filelist` argument to the inputs.
/// As in the C CLI, a list that cannot be read (missing, irregular, empty,
/// or with over-long lines) aborts the whole run.
fn expand_file_lists(cli: &mut Cli) -> Result<(), String> {
let file_lists = std::mem::take(&mut cli.file_lists);
for list in &file_lists {
let table = unsafe { UTIL_createFileNamesTable_fromFileName(list.as_ptr()) };
if table.is_null() {
return Err(format!("error reading {}", list.to_string_lossy()));
}
unsafe { drain_file_names_table(table, &mut cli.inputs) };
}
Ok(())
}
/// Replaces the inputs with their recursive directory expansion. Symbolic
/// links are followed only under --force, matching the C `followLinks` flag.
fn expand_recursive_inputs(cli: &mut Cli) -> Result<(), String> {
let pointers: Vec<*const c_char> = cli.inputs.iter().map(|value| value.as_ptr()).collect();
let names = if pointers.is_empty() {
ptr::null()
} else {
pointers.as_ptr()
};
let table = unsafe { UTIL_createExpandedFNT(names, pointers.len(), c_int::from(cli.force)) };
if table.is_null() {
/* The C CLI treats this allocation failure as fatal (CONTROL). */
return Err("recursive directory expansion failed".to_owned());
}
let mut expanded = Vec::new();
unsafe { drain_file_names_table(table, &mut expanded) };
cli.inputs = expanded;
Ok(())
}
/// Prints frame information for every input through `FIO_listMultipleFiles`,
/// which owns the display-level gating, the stdin refusal, and the totals.
#[cfg(feature = "decompression")]
fn run_list(cli: &Cli) -> i32 {
let inputs: Vec<*const c_char> = cli.inputs.iter().map(|value| value.as_ptr()).collect();
unsafe { FIO_listMultipleFiles(inputs.len() as c_uint, inputs.as_ptr(), cli.display_level) }
}
#[cfg(feature = "benchmark")]
fn normalize_benchmark_levels(
start_c_level: c_int,
end_c_level: c_int,
mode: c_int,
max_c_level: c_int,
) -> (c_int, c_int) {
let mut start_level = start_c_level;
let mut end_level = end_c_level;
if mode == crate::benchzstd::BMK_decodeOnly {
start_level = 0;
end_level = 0;
}
if start_level > max_c_level {
start_level = max_c_level;
}
if end_level > max_c_level {
end_level = max_c_level;
}
if end_level < start_level {
end_level = start_level;
}
(start_level, end_level)
}
/// Benchmark bridge formerly implemented by `programs/zstdcli.c`.
///
/// Keep the exported name because it is a useful program-side ABI boundary,
/// but make the Rust CLI archive own both the normalization and dispatch.
#[cfg(feature = "benchmark")]
#[no_mangle]
pub unsafe extern "C" fn ZSTD_rust_cli_bench(
file_names: *const *const c_char,
nb_files: c_uint,
dict_file_name: *const c_char,
start_c_level: c_int,
end_c_level: c_int,
compression_params: *const ZSTD_compressionParameters,
display_level: c_int,
nb_seconds: c_uint,
block_size: usize,
nb_workers: c_int,
mode: c_int,
) -> c_int {
let mut advanced_params = crate::benchzstd::BMK_initAdvancedParams();
advanced_params.nbSeconds = nb_seconds;
advanced_params.blockSize = block_size;
advanced_params.nbWorkers = nb_workers;
advanced_params.mode = mode;
let (start_level, end_level) =
normalize_benchmark_levels(start_c_level, end_c_level, mode, unsafe {
ZSTD_maxCLevel()
});
let compression_params = compression_params.cast();
if nb_files == 0 {
/* No input file: benchmark a synthetic sample (lorem generator). */
unsafe {
crate::benchzstd::BMK_syntheticTest(
-1.0,
start_level,
end_level,
compression_params,
display_level,
&advanced_params,
)
}
} else {
unsafe {
crate::benchzstd::BMK_benchFilesAdvanced(
file_names,
nb_files,
dict_file_name,
start_level,
end_level,
compression_params,
display_level,
&advanced_params,
)
}
}
}
/// `ZSTD_NOBENCH` compatibility bridge for compact CLI archives.
#[cfg(not(feature = "benchmark"))]
#[no_mangle]
pub unsafe extern "C" fn ZSTD_rust_cli_bench(
file_names: *const *const c_char,
nb_files: c_uint,
dict_file_name: *const c_char,
start_c_level: c_int,
end_c_level: c_int,
compression_params: *const ZSTD_compressionParameters,
display_level: c_int,
nb_seconds: c_uint,
block_size: usize,
nb_workers: c_int,
mode: c_int,
) -> c_int {
let _ = (
file_names,
nb_files,
dict_file_name,
start_c_level,
end_c_level,
compression_params,
display_level,
nb_seconds,
block_size,
nb_workers,
mode,
);
-1
}
/// Runs benchmark mode through the Rust bridge. No input file means a
/// synthetic-sample benchmark, matching the C CLI.
fn run_bench(cli: &Cli) -> Result<i32, String> {
let inputs: Vec<*const c_char> = cli.inputs.iter().map(|value| value.as_ptr()).collect();
let dictionary = cli
.dictionary
.as_ref()
.map_or(ptr::null(), |value| value.as_ptr());
let workers = {
#[cfg(feature = "compression")]
{
unsafe {
resolved_worker_count(cli.workers, cli.single_thread, cli.auto_threads_logical)
}
}
#[cfg(not(feature = "compression"))]
{
cli.workers.unwrap_or(1)
}
};
let result = unsafe {
ZSTD_rust_cli_bench(
inputs.as_ptr(),
inputs.len() as c_uint,
dictionary,
cli.level,
cli.bench_end_level.unwrap_or(cli.level),
&cli.compression_params,
cli.display_level,
cli.bench_nb_seconds.unwrap_or(DEFAULT_BENCH_NB_SECONDS),
cli.block_size.unwrap_or(0),
// The C CLI benchmarks single-threaded unless -T was given.
workers,
if cli.bench_decode { 1 } else { 0 },
)
};
if result < 0 {
return Err("benchmark mode is not available in this build".to_owned());
}
Ok(result)
}
/// Runs dictionary training through the narrow `programs/dibio.c` ABI. The
/// file loading and output path stay in that bridge for now; the selected
/// dictionary builder symbols are supplied by the Rust library archive.
fn run_train(cli: &Cli) -> Result<i32, String> {
#[cfg(not(all(feature = "compression", feature = "dict-builder")))]
{
let _ = cli;
Err("training mode not available".to_owned())
}
#[cfg(all(feature = "compression", feature = "dict-builder"))]
{
let default_output = cstring(DEFAULT_DICT_NAME).expect("static dictionary name");
let output = cli.output.as_ref().unwrap_or(&default_output);
let inputs: Vec<*const c_char> = cli.inputs.iter().map(|value| value.as_ptr()).collect();
let workers = unsafe {
resolved_worker_count(cli.workers, cli.single_thread, cli.auto_threads_logical)
} as c_uint;
let z_params = ZDICT_params_t {
compressionLevel: cli.level,
notificationLevel: cli.display_level as c_uint,
dictID: cli.dictionary_id,
};
let chunk_size = cli.block_size.unwrap_or(0);
let mem_limit = cli.mem_limit.unwrap_or(0);
let result = match cli.training_algorithm {
TrainingAlgorithm::Cover => {
let mut params = cli.cover_params;
params.nbThreads = workers;
params.zParams = z_params;
let optimize = c_int::from(params.k == 0 || params.d == 0);
unsafe {
DiB_trainFromFiles(
output.as_ptr(),
cli.max_dict_size,
inputs.as_ptr(),
inputs.len() as c_int,
chunk_size,
ptr::null_mut(),
&mut params,
ptr::null_mut(),
optimize,
mem_limit,
)
}
}
TrainingAlgorithm::FastCover => {
let mut params = cli.fast_cover_params;
params.nbThreads = workers;
params.zParams = z_params;
let optimize = c_int::from(params.k == 0 || params.d == 0);
unsafe {
DiB_trainFromFiles(
output.as_ptr(),
cli.max_dict_size,
inputs.as_ptr(),
inputs.len() as c_int,
chunk_size,
ptr::null_mut(),
ptr::null_mut(),
&mut params,
optimize,
mem_limit,
)
}
}
TrainingAlgorithm::Legacy => {
let mut params = ZDICT_legacy_params_t {
selectivityLevel: cli.dictionary_selectivity,
zParams: z_params,
};
unsafe {
DiB_trainFromFiles(
output.as_ptr(),
cli.max_dict_size,
inputs.as_ptr(),
inputs.len() as c_int,
chunk_size,
&mut params,
ptr::null_mut(),
ptr::null_mut(),
0,
mem_limit,
)
}
}
};
Ok(result)
}
}
struct TraceGuard {
enabled: bool,
}
impl TraceGuard {
fn new(filename: Option<&CString>) -> Self {
if let Some(filename) = filename {
unsafe { TRACE_enable(filename.as_ptr()) };
Self { enabled: true }
} else {
Self { enabled: false }
}
}
}
impl Drop for TraceGuard {
fn drop(&mut self) {
if self.enabled {
unsafe { TRACE_finish() };
}
}
}
fn run_cli(mut cli: Cli) -> Result<i32, String> {
if cli.fake_stderr_is_console {
unsafe { UTIL_fakeStderrIsConsole() };
}
if cli.trace_file_stat {
unsafe { UTIL_traceFileStat() };
}
let _trace_guard = TraceGuard::new(cli.trace.as_ref());
unsafe {
/* The C CLI publishes the display level to util.c before any table
* expansion, so traversal warnings obey -q/-v. */
g_utilDisplayLevel = cli.display_level;
}
let explicit_input_count = cli.inputs.len();
filter_symlink_inputs(&mut cli);
if explicit_input_count > 0 && cli.inputs.is_empty() {
return Ok(1);
}
expand_file_lists(&mut cli)?;
/* Names known before recursive expansion: an input set that named only
* (possibly empty) directories must not fall back to stdin below. */
let input_name_count = cli.inputs.len();
if cli.recursive {
expand_recursive_inputs(&mut cli)?;
}
if cli.operation == Operation::List {
#[cfg(feature = "decompression")]
return Ok(run_list(&cli));
#[cfg(not(feature = "decompression"))]
return Err("file information is not supported".to_owned());
}
if cli.operation == Operation::Bench {
if cli.compression_format != CompressionFormat::Zstd {
return Err("benchmark mode only supports the zstd format".to_owned());
}
return run_bench(&cli);
}
if cli.operation == Operation::Train {
return run_train(&cli);
}
if cli.operation == Operation::Test {
cli.output = Some(cstring(NULL_MARK)?);
cli.remove_source = false;
}
if cli.inputs.is_empty() {
if input_name_count > 0 {
if cli.display_level >= 1 {
eprintln!(
"please provide correct input file(s) or non-empty directories -- ignored"
);
}
return Ok(0);
}
cli.inputs.push(cstring(STDIN_MARK)?);
if cli.output.is_none() {
cli.output = Some(cstring(STDOUT_MARK)?);
}
}
if cli.inputs.len() == 1
&& cli.inputs[0].as_bytes() == STDIN_MARK.as_bytes()
&& cli.output.is_none()
{
cli.output = Some(cstring(STDOUT_MARK)?);
}
if cli.show_default_cparams && cli.operation == Operation::Decompress {
return Err("error : can't use --show-default-cparams in decompression mode".to_owned());
}
check_terminal_safety(&cli)?;
print_cli_header(cli.display_level);
#[cfg(feature = "compression")]
if cli.operation == Operation::Decompress
&& cli.display_level >= 2
&& cli.workers.is_some_and(|workers| workers > 1)
{
eprintln!("Warning : decompression does not support multi-threading");
}
if cli.operation == Operation::Compress {
#[cfg(not(feature = "compression"))]
return Err("Compression not supported".to_owned());
#[cfg(feature = "compression")]
{
let min_level = unsafe { ZSTD_minCLevel() };
let max_level = unsafe { ZSTD_maxCLevel() };
let ceiling = if cli.ultra {
max_level
} else {
DEFAULT_MAX_CLEVEL.min(max_level)
};
if cli.level > ceiling {
if cli.display_level >= 2 {
eprintln!("zstd: warning: compression level reduced to {ceiling}");
}
cli.level = ceiling;
}
if cli.level < min_level {
return Err(format!(
"compression level {} is below {min_level}",
cli.level
));
}
if let (Some(minimum), Some(maximum)) = (cli.adapt_min, cli.adapt_max) {
if minimum > maximum {
return Err("adaptation minimum exceeds maximum".to_owned());
}
cli.level = cli.level.clamp(minimum, maximum);
}
}
} else {
#[cfg(not(feature = "decompression"))]
return Err("Decompression not supported".to_owned());
}
if cli.patch_from.is_some() && cli.inputs.len() > 1 {
return Err("can't use --patch-from=# on multiple files".to_owned());
}
let prefs = unsafe { FIO_createPreferences() };
let ctx = unsafe { FIO_createContext() };
if prefs.is_null() || ctx.is_null() {
unsafe {
if !prefs.is_null() {
FIO_freePreferences(prefs);
}
if !ctx.is_null() {
FIO_freeContext(ctx);
}
}
return Err("could not allocate C file-I/O state".to_owned());
}
let result = {
unsafe {
FIO_addAbortHandler();
apply_preferences(&cli, prefs, ctx);
#[cfg(feature = "compression")]
if cli.operation == Operation::Compress {
if cli.show_default_cparams {
print_default_cparams(&cli);
}
if cli.display_level >= 4 {
print_actual_cparams(&cli);
}
}
}
let output = cli
.output
.as_ref()
.map_or(ptr::null(), |value| value.as_ptr());
let dictionary = fileio_dictionary(&cli).map_or(ptr::null(), |value| value.as_ptr());
let inputs: Vec<*const c_char> = cli.inputs.iter().map(|value| value.as_ptr()).collect();
match cli.operation {
Operation::Compress => {
#[cfg(feature = "compression")]
{
unsafe { run_compress(&cli, ctx, prefs, &inputs, output, dictionary) }
}
#[cfg(not(feature = "compression"))]
unreachable!("unsupported compression was rejected above")
}
Operation::Decompress | Operation::Test => {
#[cfg(feature = "decompression")]
{
unsafe { run_decompress(&cli, ctx, prefs, &inputs, output, dictionary) }
}
#[cfg(not(feature = "decompression"))]
unreachable!("unsupported decompression was rejected above")
}
Operation::Bench | Operation::List | Operation::Train => {
unreachable!("benchmark and list modes were dispatched earlier")
}
}
};
unsafe {
FIO_freePreferences(prefs);
FIO_freeContext(ctx);
}
Ok(result)
}
fn run_from_args(args: Vec<OsString>) -> c_int {
let program_name = args
.first()
.map_or_else(|| "zstd".to_owned(), |value| program_basename(value));
match parse_args(args) {
Ok(Action::Help { advanced }) => {
usage(advanced);
0
}
Ok(Action::Version { quiet }) => {
print_version(quiet);
0
}
Ok(Action::Run(cli)) => match run_cli(*cli) {
Ok(result) => result,
Err(error) => {
eprintln!("zstd: {error}");
1
}
},
Err(error) => {
eprintln!("{}", error.message);
if error.show_usage {
let mut out = io::stderr().lock();
write_basic_usage(&mut out, &program_name);
}
1
}
}
}
unsafe fn argv_to_os_strings(
arg_count: c_int,
argv: *const *const c_char,
) -> Result<Vec<OsString>, String> {
if arg_count <= 0 || argv.is_null() {
return Err("invalid argv supplied by C main".to_owned());
}
let count = arg_count as usize;
let mut args = Vec::with_capacity(count);
for index in 0..count {
let argument = unsafe { *argv.add(index) };
if argument.is_null() {
return Err(format!("argv[{index}] is null"));
}
let bytes = unsafe { CStr::from_ptr(argument) }.to_bytes();
#[cfg(unix)]
args.push(OsString::from_vec(bytes.to_vec()));
#[cfg(not(unix))]
args.push(OsString::from(String::from_utf8_lossy(bytes).into_owned()));
}
Ok(args)
}
/// C `main()` entry point retained by the small `programs/zstdcli.c` forwarder.
#[no_mangle]
pub unsafe extern "C" fn ZSTD_rust_cli_main(arg_count: c_int, argv: *const *const c_char) -> c_int {
if let Err(error) = check_lib_version() {
eprintln!("zstd: {error}");
return 1;
}
match unsafe { argv_to_os_strings(arg_count, argv) } {
Ok(args) => run_from_args(args),
Err(error) => {
eprintln!("zstd: {error}");
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(values: &[&str]) -> Cli {
let args = values.iter().map(OsString::from).collect();
match parse_args(args).expect("arguments should parse") {
Action::Run(cli) => *cli,
Action::Help { .. } | Action::Version { .. } => panic!("expected a run action"),
}
}
#[test]
fn defaults_preserve_the_c_fileio_contract() {
let cli = parse(&["zstd", "input"]);
assert_eq!(cli.content_size, 1);
assert_eq!(cli.workers, None);
assert_eq!(cli.operation, Operation::Compress);
assert_eq!(cli.compression_format, CompressionFormat::Zstd);
}
#[test]
fn normal_help_ceiling_excludes_ultra_levels() {
assert_eq!(max_c_level_for_help(), 19);
}
#[test]
fn format_option_selects_frame_type_and_suffix() {
let cli = parse(&["zstd", "--format=gzip", "input"]);
assert_eq!(cli.compression_format, CompressionFormat::Gzip);
assert_eq!(cli.compression_format.fio_type(), FIO_GZIP_COMPRESSION);
assert_eq!(cli.compression_format.suffix(), b".gz\0");
let cli = parse(&["zstd", "--format=gzip", "--format=zstd", "input"]);
assert_eq!(cli.compression_format, CompressionFormat::Zstd);
}
#[test]
fn format_option_rejects_unknown_formats() {
let error = parse_args(vec![
OsString::from("zstd"),
OsString::from("--format=zip"),
OsString::from("input"),
])
.expect_err("unknown formats must be rejected");
assert!(error.contains("unknown compression format"));
}
#[test]
fn trace_option_accepts_attached_and_separate_paths() {
let cli = parse(&["zstd", "--trace=first.trace", "input"]);
assert_eq!(
cli.trace.as_deref().map(CStr::to_bytes),
Some(&b"first.trace"[..])
);
let cli = parse(&["zstd", "--trace", "second.trace", "input"]);
assert_eq!(
cli.trace.as_deref().map(CStr::to_bytes),
Some(&b"second.trace"[..])
);
}
#[test]
fn long_mode_uses_the_cli_default_window_and_enables_ultra() {
let cli = parse(&["zstd", "--long", "input"]);
assert!(cli.ldm);
assert!(cli.ultra);
assert_eq!(cli.compression_params.windowLog, DEFAULT_LONG_WINDOW_LOG);
}
#[test]
fn long_mode_does_not_replace_an_explicit_window_log() {
let cli = parse(&["zstd", "--zstd=wlog=25", "--long", "input"]);
assert_eq!(cli.compression_params.windowLog, 25);
}
#[test]
fn max_mode_sets_the_full_c_and_ldm_parameter_bundle() {
let cli = parse(&["zstd", "--max", "input"]);
assert!(cli.ultra);
assert!(cli.ldm);
assert_eq!(
cli.compression_params,
ZSTD_compressionParameters {
windowLog: MAX_WINDOW_LOG,
chainLog: MAX_CHAIN_LOG,
hashLog: MAX_HASH_LOG,
searchLog: MAX_SEARCH_LOG,
minMatch: MAX_MIN_MATCH,
targetLength: MAX_TARGET_LENGTH,
strategy: MAX_STRATEGY,
}
);
assert_eq!(cli.overlap_log, Some(MAX_OVERLAP_LOG));
assert_eq!(cli.ldm_hash_log, Some(MAX_LDM_HASH_LOG));
assert_eq!(cli.ldm_hash_rate_log, Some(0));
assert_eq!(cli.ldm_min_match, Some(MAX_LDM_MIN_MATCH));
assert_eq!(cli.ldm_bucket_size_log, Some(MAX_LDM_BUCKET_SIZE_LOG));
}
#[test]
fn max_mode_is_applied_at_the_option_position() {
let cli = parse(&["zstd", "--max", "--zstd=wlog=25", "input"]);
assert_eq!(cli.compression_params.windowLog, 25);
let cli = parse(&["zstd", "--zstd=wlog=25", "--max", "input"]);
assert_eq!(cli.compression_params.windowLog, MAX_WINDOW_LOG);
}
#[test]
fn console_and_file_stat_compatibility_switches_are_recorded() {
let cli = parse(&[
"zstd",
"--fake-stderr-is-console",
"--trace-file-stat",
"input",
]);
assert!(cli.fake_stderr_is_console);
assert!(cli.trace_file_stat);
}
#[test]
fn gzip_best_is_an_alias_for_level_nine() {
let cli = parse(&["gzip", "--best", "input"]);
assert!(cli.gzip_compat);
assert_eq!(cli.level, 9);
let error = parse_args(vec![OsString::from("zstd"), OsString::from("--best")])
.expect_err("--best is a gzip compatibility option");
assert!(error.contains("Incorrect parameter: --best"));
}
#[test]
fn stdio_and_dictionary_short_options_are_preserved() {
let cli = parse(&["zstd", "-dc", "-D", "dict", "-"]);
assert_eq!(cli.operation, Operation::Decompress);
assert!(is_stdout(cli.output.as_ref()));
assert_eq!(
cli.dictionary.as_deref().map(CStr::to_bytes),
Some(&b"dict"[..])
);
assert_eq!(
cli.inputs
.iter()
.map(|input| input.as_bytes())
.collect::<Vec<_>>(),
vec![STDIN_MARK.as_bytes()]
);
}
#[test]
fn patch_from_accepts_attached_and_separate_reference_paths() {
let attached = parse(&["zstd", "--patch-from=reference", "input"]);
assert_eq!(
attached.patch_from.as_deref().map(CStr::to_bytes),
Some(&b"reference"[..])
);
assert!(attached.ultra);
let separate = parse(&["zstd", "--patch-from", "reference", "input"]);
assert_eq!(
separate.patch_from.as_deref().map(CStr::to_bytes),
Some(&b"reference"[..])
);
assert!(separate.ultra);
}
#[test]
fn patch_from_rejects_conflicts_with_dictionary_mode_in_both_orders() {
for values in [
&["zstd", "-D", "dict", "--patch-from=reference", "input"][..],
&["zstd", "--patch-from", "reference", "-D", "dict", "input"][..],
] {
let args = values.iter().map(OsString::from).collect();
let error = parse_args(args).expect_err("-D and --patch-from must conflict");
assert!(error.contains("can't use -D and --patch-from=# at the same time"));
}
}
#[test]
fn stdout_selection_does_not_enable_force_or_pass_through() {
let cli = parse(&["zstd", "-c", "input"]);
assert!(cli.force_stdout);
assert!(!cli.force);
assert_eq!(cli.pass_through, None);
}
#[test]
fn short_level_can_be_combined_with_other_flags() {
let cli = parse(&["zstd", "-5q", "input"]);
assert_eq!(cli.level, 5);
assert_eq!(cli.display_level, 1);
}
#[test]
fn fast_levels_preserve_the_unsigned_32_bit_conversion() {
let cli = parse(&["zstd", "--fast", "-4294967295", "input"]);
assert_eq!(cli.level, -1);
let cli = parse(&["zstd", "--fast=4294967295", "input"]);
assert_eq!(cli.level, -MAX_FAST_ACCELERATION);
}
#[test]
fn short_option_equals_form_is_accepted() {
let cli = parse(&["zstd", "-T=2", "-M=64M", "-B=1M", "input"]);
assert_eq!(cli.workers, Some(2));
assert_eq!(cli.mem_limit, Some(64 << 20));
assert_eq!(cli.block_size, Some(1 << 20));
}
#[test]
fn valueless_long_flags_reject_attached_values() {
let error = parse_args(vec![OsString::from("zstd"), OsString::from("--rm=0")])
.expect_err("an attached value must not activate --rm");
assert!(error.contains("does not take an argument"));
}
#[test]
fn empty_attached_adapt_is_rejected_but_bare_adapt_is_enabled() {
let bare = parse(&["zstd", "--adapt", "input"]);
assert!(bare.adapt);
let error = parse_args(vec![OsString::from("zstd"), OsString::from("--adapt=")])
.expect_err("an empty attached adapt value must be rejected");
assert!(error.contains("Incorrect parameter: --adapt="));
}
#[test]
fn adapt_bounds_and_rsyncable_single_thread_are_parsed_for_runtime_checks() {
let error = parse_args(vec![
OsString::from("zstd"),
OsString::from("--adapt=min=10,max=9"),
])
.expect_err("incoherent adaptation bounds must be rejected");
assert!(error.contains("Incorrect parameter: --adapt=min=10,max=9"));
let cli = parse(&["zstd", "--rsyncable", "--single-thread", "input"]);
assert!(cli.rsyncable);
assert!(cli.single_thread);
assert_eq!(cli.workers, Some(0));
}
#[test]
fn zstdmt_uses_auto_threads() {
let cli = parse(&["zstdmt", "input"]);
assert_eq!(cli.workers, Some(0));
assert!(!cli.single_thread);
}
#[test]
fn auto_threads_selects_core_family() {
let physical = parse(&["zstd", "-T0", "--auto-threads=physical", "input"]);
assert_eq!(physical.workers, Some(0));
assert!(!physical.auto_threads_logical);
let logical = parse(&["zstd", "-T0", "--auto-threads", "logical", "input"]);
assert_eq!(logical.workers, Some(0));
assert!(logical.auto_threads_logical);
}
#[test]
fn auto_threads_rejects_unknown_core_family() {
let error = parse_args(vec![
OsString::from("zstd"),
OsString::from("--auto-threads=bogus"),
])
.expect_err("an unknown core family must be rejected");
assert!(error.contains("unknown auto-thread mode"));
}
#[test]
fn single_thread_pins_zero_workers() {
let cli = parse(&["zstd", "--single-thread", "input"]);
assert_eq!(cli.workers, Some(0));
assert!(cli.single_thread);
}
#[test]
fn a_later_thread_count_overrides_single_thread_workers() {
/* Mirrors the C CLI: -T after --single-thread wins the worker count,
* while the single-thread latch stays set. */
let cli = parse(&["zstd", "--single-thread", "-T2", "input"]);
assert_eq!(cli.workers, Some(2));
assert!(cli.single_thread);
}
#[test]
fn single_thread_rejects_attached_values() {
let error = parse_args(vec![
OsString::from("zstd"),
OsString::from("--single-thread=1"),
])
.expect_err("an attached value must not activate --single-thread");
assert!(error.contains("does not take an argument"));
}
#[test]
fn alternate_format_aliases_select_compatibility_modes() {
let cli = Cli::new("gzip");
assert_eq!(cli.compression_format, CompressionFormat::Gzip);
assert_eq!(cli.operation, Operation::Compress);
assert!(cli.remove_source);
let cli = Cli::new("unxz");
assert_eq!(cli.compression_format, CompressionFormat::Xz);
assert_eq!(cli.operation, Operation::Decompress);
assert!(cli.remove_source);
let cli = Cli::new("lzma");
assert_eq!(cli.compression_format, CompressionFormat::Lzma);
assert_eq!(cli.operation, Operation::Compress);
}
#[cfg(unix)]
#[test]
fn short_path_arguments_keep_non_utf8_bytes() {
let output = OsString::from_vec(vec![b'-', b'o', 0xff, b'.', b'z', b's', b't']);
let action = parse_args(vec![
OsString::from("zstd"),
output,
OsString::from("input"),
])
.expect("arguments should parse");
let Action::Run(cli) = action else {
panic!("expected a run action");
};
assert_eq!(
cli.output.as_deref().map(CStr::to_bytes),
Some(&b"\xff.zst"[..])
);
}
#[test]
fn train_defaults_to_fastcover_and_dictionary_output() {
let cli = parse(&["zstd", "--train", "sample"]);
assert_eq!(cli.operation, Operation::Train);
assert_eq!(cli.training_algorithm, TrainingAlgorithm::FastCover);
assert_eq!(
cli.output.as_deref().map(CStr::to_bytes),
Some(&b"dictionary"[..])
);
assert_eq!(cli.max_dict_size, DEFAULT_MAX_DICT_SIZE);
assert_eq!(cli.fast_cover_params.d, 8);
assert_eq!(cli.fast_cover_params.f, 20);
assert_eq!(cli.fast_cover_params.steps, 4);
assert_eq!(cli.fast_cover_params.splitPoint, 0.75);
assert_eq!(cli.fast_cover_params.accel, DEFAULT_FASTCOVER_ACCEL);
}
#[test]
fn train_cover_parameters_select_algorithm_and_parse_optional_fields() {
let cli = parse(&[
"zstd",
"--train-cover=k=48,d=8,steps=32,split=80,shrink=3",
"sample",
]);
assert_eq!(cli.operation, Operation::Train);
assert_eq!(cli.training_algorithm, TrainingAlgorithm::Cover);
assert_eq!(cli.cover_params.k, 48);
assert_eq!(cli.cover_params.d, 8);
assert_eq!(cli.cover_params.steps, 32);
assert_eq!(cli.cover_params.splitPoint, 0.8);
assert_eq!(cli.cover_params.shrinkDict, 1);
assert_eq!(cli.cover_params.shrinkDictMaxRegression, 3);
}
#[test]
fn train_fastcover_and_legacy_parameter_forms_share_training_options() {
let fast = parse(&[
"zstd",
"--train-fastcover=k=64,d=8,f=21,steps=12,split=75,accel=2,shrink",
"--maxdict",
"128K",
"--dictID=42",
"-B4K",
"-T2",
"-o",
"custom.dict",
"sample",
]);
assert_eq!(fast.training_algorithm, TrainingAlgorithm::FastCover);
assert_eq!(fast.fast_cover_params.k, 64);
assert_eq!(fast.fast_cover_params.d, 8);
assert_eq!(fast.fast_cover_params.f, 21);
assert_eq!(fast.fast_cover_params.steps, 12);
assert_eq!(fast.fast_cover_params.splitPoint, 0.75);
assert_eq!(fast.fast_cover_params.accel, 2);
assert_eq!(fast.fast_cover_params.shrinkDict, 1);
assert_eq!(fast.fast_cover_params.shrinkDictMaxRegression, 1);
assert_eq!(fast.max_dict_size, 128 << 10);
assert_eq!(fast.dictionary_id, 42);
assert_eq!(fast.block_size, Some(4 << 10));
assert_eq!(fast.workers, Some(2));
assert_eq!(
fast.output.as_deref().map(CStr::to_bytes),
Some(&b"custom.dict"[..])
);
let legacy = parse(&["zstd", "--train-legacy=selectivity=12", "sample"]);
assert_eq!(legacy.operation, Operation::Train);
assert_eq!(legacy.training_algorithm, TrainingAlgorithm::Legacy);
assert_eq!(legacy.dictionary_selectivity, 12);
let legacy_short = parse(&["zstd", "--train-legacy", "-s5", "sample"]);
assert_eq!(legacy_short.training_algorithm, TrainingAlgorithm::Legacy);
assert_eq!(legacy_short.dictionary_selectivity, 5);
}
#[test]
fn training_options_preserve_upstream_validation_boundaries() {
let attached = parse_args(vec![
OsString::from("zstd"),
OsString::from("--train=value"),
])
.expect_err("--train does not accept an attached value");
assert!(attached.contains("does not take an argument"));
let unknown = parse_args(vec![
OsString::from("zstd"),
OsString::from("--train-cover=unknown=1"),
])
.expect_err("unknown cover parameters must be rejected");
assert!(unknown.contains("unknown --train-cover parameter"));
let missing = parse_args(vec![OsString::from("zstd"), OsString::from("--dictID")])
.expect_err("--dictID requires a value");
assert!(missing.contains("missing argument for --dictID"));
}
#[test]
fn recursive_flag_can_be_aggregated_with_other_shorts() {
let cli = parse(&["zstd", "-rq", "dir"]);
assert!(cli.recursive);
assert_eq!(cli.display_level, 1);
}
#[test]
fn list_mode_is_selected_by_short_and_long_flags() {
let short = parse(&["zstd", "-l", "file.zst"]);
let long = parse(&["zstd", "--list", "file.zst"]);
assert_eq!(short.operation, Operation::List);
assert_eq!(long.operation, Operation::List);
}
#[test]
fn list_rejects_attached_values() {
let error = parse_args(vec![OsString::from("zstd"), OsString::from("--list=x")])
.expect_err("an attached value must not activate --list");
assert!(error.contains("does not take an argument"));
}
#[test]
fn show_default_cparams_is_a_compression_information_flag() {
let cli = parse(&["zstd", "--show-default-cparams", "input"]);
assert!(cli.show_default_cparams);
assert_eq!(cli.operation, Operation::Compress);
}
#[test]
fn show_default_cparams_rejects_attached_values() {
let error = parse_args(vec![
OsString::from("zstd"),
OsString::from("--show-default-cparams=x"),
])
.expect_err("show-default-cparams must not consume an attached value");
assert!(error.contains("does not take an argument"));
}
#[test]
fn filelist_accumulates_both_syntaxes() {
let cli = parse(&["zstd", "--filelist=one.txt", "--filelist", "two.txt", "in"]);
assert_eq!(
cli.file_lists
.iter()
.map(|list| list.as_bytes())
.collect::<Vec<_>>(),
vec![&b"one.txt"[..], &b"two.txt"[..]]
);
}
#[test]
fn filelist_argument_must_not_be_another_option() {
let error = parse_args(vec![
OsString::from("zstd"),
OsString::from("--filelist"),
OsString::from("-q"),
])
.expect_err("an option must not be consumed as the list name");
assert!(error.contains("cannot be separated from its argument"));
}
#[test]
fn output_dir_flat_accepts_attached_and_separate_values() {
let attached = parse(&["zstd", "--output-dir-flat=out", "in"]);
let separate = parse(&["zstd", "--output-dir-flat", "out", "in"]);
assert_eq!(
attached.output_dir_flat.as_deref().map(CStr::to_bytes),
Some(&b"out"[..])
);
assert_eq!(
separate.output_dir_flat.as_deref().map(CStr::to_bytes),
Some(&b"out"[..])
);
}
#[test]
fn output_dir_flat_rejects_an_empty_name() {
/* Mirrors the C NEXT_FIELD semantics: `--output-dir-flat=` keeps the
* empty attached value instead of consuming the next argument. */
let error = parse_args(vec![
OsString::from("zstd"),
OsString::from("--output-dir-flat="),
OsString::from("out"),
])
.expect_err("an empty output dir must be rejected");
assert!(error.contains("output dir cannot be empty string"));
}
#[cfg(unix)]
#[test]
fn output_dir_mirror_is_parsed_on_posix_targets() {
let cli = parse(&["zstd", "--output-dir-mirror", "out", "in"]);
assert_eq!(
cli.output_dir_mirror.as_deref().map(CStr::to_bytes),
Some(&b"out"[..])
);
}
#[test]
fn bench_mode_parses_level_duration_and_defaults() {
let cli = parse(&["zstd", "-b1", "-i0", "input"]);
assert_eq!(cli.operation, Operation::Bench);
assert_eq!(cli.level, 1);
assert_eq!(cli.bench_nb_seconds, Some(0));
assert_eq!(cli.bench_end_level, None);
assert_eq!(
cli.inputs
.iter()
.map(|input| input.as_bytes())
.collect::<Vec<_>>(),
vec![&b"input"[..]]
);
}
#[test]
fn bench_range_aggregates_within_a_single_argument() {
let cli = parse(&["zstd", "-b5e6i2", "input"]);
assert_eq!(cli.operation, Operation::Bench);
assert_eq!(cli.level, 5);
assert_eq!(cli.bench_end_level, Some(6));
assert_eq!(cli.bench_nb_seconds, Some(2));
}
#[test]
fn bench_duration_without_digits_defaults_to_zero() {
let cli = parse(&["zstd", "-b", "-e", "-i"]);
assert_eq!(cli.operation, Operation::Bench);
assert_eq!(cli.level, DEFAULT_CLEVEL);
assert_eq!(cli.bench_end_level, Some(0));
assert_eq!(cli.bench_nb_seconds, Some(0));
}
#[test]
fn bench_decompression_keeps_benchmark_operation() {
let cli = parse(&["zstd", "-b", "-d", "-i0", "input"]);
assert_eq!(cli.operation, Operation::Bench);
assert!(cli.bench_decode);
}
#[cfg(feature = "benchmark")]
#[test]
fn benchmark_level_normalization_matches_c_bridge() {
assert_eq!(
normalize_benchmark_levels(100, 4, crate::benchzstd::BMK_both, 22),
(22, 22)
);
assert_eq!(
normalize_benchmark_levels(7, 3, crate::benchzstd::BMK_both, 22),
(7, 7)
);
assert_eq!(
normalize_benchmark_levels(7, 3, crate::benchzstd::BMK_decodeOnly, 22),
(0, 0)
);
}
#[cfg(not(feature = "benchmark"))]
#[test]
fn benchmark_bridge_returns_unavailable_without_feature() {
assert_eq!(
unsafe {
ZSTD_rust_cli_bench(
ptr::null(),
0,
ptr::null(),
3,
3,
ptr::null(),
0,
3,
0,
1,
0,
)
},
-1
);
}
}