#![expect(clippy::redundant_pub_crate)]
use std::borrow::Cow;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
use bzip3_rs::BlockSize;
use bzip3_rs::default_workers;
use clap::ColorChoice;
use clap::Parser;
use clap::error::ErrorKind;
const VERSION_TEXT: &str = concat!(
env!("CARGO_PKG_VERSION"),
"\nCopyright (C) 2026 The bzip3-rs contributors.\n\
License: Apache-2.0 <https://www.apache.org/licenses/LICENSE-2.0>"
);
const AFTER_HELP: &str = "Levels (use -N to set the block size):\n \
-1 65 KiB -6 2 MiB -11 64 MiB\n \
-2 128 KiB -7 4 MiB -12 128 MiB\n \
-3 256 KiB -8 8 MiB -13 256 MiB\n \
-4 512 KiB -9 16 MiB -14 511 MiB\n \
-5 1 MiB -10 32 MiB\n\n\
The default level is -9. The last block size flag wins.\n\n\
Report bugs to: https://github.com/ing-systems/bzip3-rs";
const MAX_WORKERS: usize = 64;
const CLAP_TIP: &str = "\n\nFor more information, try '";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Mode {
Encode,
Decode,
Test,
Recover,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Alias {
Bzip3,
Bunzip3,
Bz3cat,
}
impl Alias {
fn from_argv0(argv0: Option<&OsStr>) -> Self {
let name = argv0
.map(Path::new)
.and_then(Path::file_name)
.and_then(OsStr::to_str);
match name {
Some("bunzip3") => Self::Bunzip3,
Some("bz3cat") => Self::Bz3cat,
_ => Self::Bzip3,
}
}
const fn default_mode(self) -> Mode {
match self {
Self::Bzip3 => Mode::Encode,
Self::Bunzip3 | Self::Bz3cat => Mode::Decode,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlockSizeArg {
Valid(BlockSize),
OutOfRange,
}
#[expect(clippy::struct_excessive_bools)]
#[derive(Parser)]
#[command(
name = "bzip3",
bin_name = "bzip3",
version = VERSION_TEXT,
about = "Better and stronger spiritual successor to bzip2.",
after_help = AFTER_HELP,
color = ColorChoice::Never,
args_override_self = true
)]
struct Args {
#[arg(short = 'e', visible_short_alias = 'z', long, overrides_with_all = ["decode", "test", "recover"])]
encode: bool,
#[arg(short = 'd', long, overrides_with_all = ["encode", "test", "recover"])]
decode: bool,
#[arg(short = 't', long, overrides_with_all = ["encode", "decode", "recover"])]
test: bool,
#[arg(short = 'r', long, overrides_with_all = ["encode", "decode", "test"])]
recover: bool,
#[arg(short = 'c', long)]
stdout: bool,
#[arg(short, long)]
force: bool,
#[arg(short, long)]
keep: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short, long)]
quiet: bool,
#[arg(
short = 'b',
long = "block",
value_name = "N",
value_parser = parse_block_size,
overrides_with_all = ["level", "fast", "best"]
)]
block: Option<BlockSizeArg>,
#[arg(
long,
hide = true,
value_name = "N",
value_parser = parse_level,
overrides_with_all = ["block", "fast", "best"]
)]
level: Option<BlockSizeArg>,
#[arg(long, overrides_with_all = ["block", "level", "best"])]
fast: bool,
#[arg(long, overrides_with_all = ["block", "level", "fast"])]
best: bool,
#[arg(
short = 'j',
long,
value_name = "N",
value_parser = parse_jobs,
default_value = "0"
)]
jobs: usize,
#[arg(short = 'B', long = "batch", hide = true)]
_batch: bool,
#[arg(value_name = "files")]
files: Vec<PathBuf>,
}
impl Args {
fn block_size(&self) -> Result<BlockSize, ExitError> {
let chosen = if self.fast {
Some(BlockSizeArg::Valid(BlockSize::MIN))
} else if self.best {
Some(BlockSizeArg::Valid(BlockSize::MAX))
} else {
self.level.or(self.block)
};
match chosen {
None => Ok(BlockSize::DEFAULT),
Some(BlockSizeArg::Valid(size)) => Ok(size),
Some(BlockSizeArg::OutOfRange) => Err(ExitError::usage(
"Block size must be between 65 KiB and 511 MiB.",
)),
}
}
const fn mode(&self, alias: Alias) -> Mode {
if self.decode {
Mode::Decode
} else if self.test {
Mode::Test
} else if self.recover {
Mode::Recover
} else if self.encode {
Mode::Encode
} else {
alias.default_mode()
}
}
}
#[expect(clippy::struct_excessive_bools)]
#[derive(Debug)]
pub(crate) struct Config {
pub(crate) mode: Mode,
pub(crate) to_stdout: bool,
pub(crate) force: bool,
pub(crate) keep: bool,
pub(crate) verbose: bool,
pub(crate) quiet: bool,
pub(crate) block_size: BlockSize,
pub(crate) workers: usize,
pub(crate) files: Vec<PathBuf>,
}
#[derive(Debug)]
pub(crate) struct ExitError {
pub(crate) code: u8,
pub(crate) message: String,
}
impl ExitError {
pub(crate) fn usage(message: impl Into<String>) -> Self {
Self {
code: 1,
message: message.into(),
}
}
pub(crate) fn corrupt(message: impl Into<String>) -> Self {
Self {
code: 2,
message: message.into(),
}
}
pub(crate) fn from_codec(error: &bzip3_rs::error::Error, mode: Mode, parallel: bool) -> Self {
let encode = mode == Mode::Encode;
match error {
bzip3_rs::error::Error::Io(io_error) => {
Self::usage(format!("Write error: {}", os_error_text(io_error)))
}
bzip3_rs::error::Error::OutOfMemory
| bzip3_rs::error::Error::InvalidBlockSize
| bzip3_rs::error::Error::ThreadPoolFailed => {
Self::usage("Failed to create a block encoder state.")
}
_ => {
let what = match (encode, parallel) {
(true, true) => "Failed to encode data: ",
(true, false) => "Failed to encode a block: ",
(false, true) => "Failed to decode data: ",
(false, false) => "Failed to decode a block: ",
};
let message = format!("{what}{error}");
if encode {
Self::usage(message)
} else {
Self::corrupt(message)
}
}
}
}
}
impl fmt::Display for ExitError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
#[derive(Debug)]
pub(crate) enum Stop {
Display(clap::Error),
Error(ExitError),
}
impl From<ExitError> for Stop {
fn from(error: ExitError) -> Self {
Self::Error(error)
}
}
impl From<clap::Error> for Stop {
fn from(error: clap::Error) -> Self {
use std::error::Error as _;
match (error.kind(), error.source()) {
(ErrorKind::DisplayHelp | ErrorKind::DisplayVersion, _) => Self::Display(error),
(ErrorKind::ValueValidation, Some(source)) => {
Self::Error(ExitError::usage(format!("bzip3: {source}")))
}
_ => Self::Error(ExitError::usage(render_usage_error(&error))),
}
}
}
fn render_usage_error(error: &clap::Error) -> String {
let text = error.to_string();
let body = text
.split_once(CLAP_TIP)
.map_or_else(|| text.trim_end(), |(body, _)| body);
format!("{body}\nTry 'bzip3 --help' for more information.")
}
pub(crate) fn os_error_text(error: &std::io::Error) -> Cow<'static, str> {
use std::io::ErrorKind as Kind;
let text = match error.kind() {
Kind::NotFound => "No such file or directory",
Kind::PermissionDenied => "Permission denied",
Kind::AlreadyExists => "File exists",
Kind::BrokenPipe => "Broken pipe",
Kind::IsADirectory => "Is a directory",
Kind::InvalidInput => "Invalid argument",
Kind::ReadOnlyFilesystem => "Read-only file system",
_ => return Cow::Owned(error.to_string()),
};
Cow::Borrowed(text)
}
fn parse_block_size(text: &str) -> Result<BlockSizeArg, String> {
if text.is_empty() {
return Ok(BlockSizeArg::OutOfRange);
}
let split = text
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(text.len());
let (digits, suffix) = text.split_at(split);
if digits.is_empty() {
return Err(format!("invalid block size: {text}"));
}
let count = u32::try_from(digits.parse::<u64>().unwrap_or(u64::MAX)).ok();
let size = match suffix.to_ascii_lowercase().as_str() {
"" | "m" | "mib" => count.and_then(|mib| BlockSize::mib(mib).ok()),
"k" | "kib" => count.and_then(|kib| BlockSize::kib(kib).ok()),
_ => return Err(format!("invalid block size: {text}")),
};
Ok(size.map_or(BlockSizeArg::OutOfRange, BlockSizeArg::Valid))
}
fn parse_level(text: &str) -> Result<BlockSizeArg, String> {
text.parse::<u8>()
.ok()
.and_then(|level| BlockSize::from_level(level).ok())
.map(BlockSizeArg::Valid)
.ok_or_else(|| format!("invalid level: {text}"))
}
fn parse_jobs(text: &str) -> Result<usize, String> {
if !text.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!("invalid amount of jobs: {text}"));
}
if text.is_empty() {
return Ok(0);
}
Ok(text.parse().unwrap_or(usize::MAX))
}
fn parse_level_token(token: &OsStr) -> Option<u8> {
let digits = token.to_str()?.strip_prefix('-')?;
if digits.len() > 2 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
digits
.parse()
.ok()
.filter(|&level| BlockSize::from_level(level).is_ok())
}
fn rewrite_level_tokens(argv: Vec<OsString>) -> Vec<OsString> {
let mut literal = false;
argv.into_iter()
.enumerate()
.map(|(index, arg)| {
if index == 0 || literal {
return arg;
}
if arg == "--" {
literal = true;
return arg;
}
parse_level_token(&arg).map_or(arg, |level| format!("--level={level}").into())
})
.collect()
}
fn resolve_workers(requested: usize) -> usize {
let workers = if requested == 0 {
default_workers()
} else {
requested
};
workers.min(MAX_WORKERS)
}
pub(crate) fn parse_config(argv: Vec<OsString>) -> Result<Config, Stop> {
let alias = Alias::from_argv0(argv.first().map(OsString::as_os_str));
let parsed = Args::try_parse_from(rewrite_level_tokens(argv))?;
let block_size = parsed.block_size()?;
if parsed.jobs > MAX_WORKERS {
return Err(ExitError::usage("Number of workers must be between 0 and 64.").into());
}
Ok(Config {
mode: parsed.mode(alias),
to_stdout: parsed.stdout || alias == Alias::Bz3cat,
force: parsed.force,
keep: parsed.keep,
verbose: parsed.verbose,
quiet: parsed.quiet,
block_size,
workers: resolve_workers(parsed.jobs),
files: parsed.files,
})
}
#[cfg(test)]
mod tests {
use super::Alias;
use super::BlockSizeArg;
use super::Config;
use super::MAX_WORKERS;
use super::Mode;
use super::Stop;
use clap::error::ErrorKind;
use super::parse_block_size;
use super::parse_config;
use super::parse_level_token;
use super::resolve_workers;
use super::rewrite_level_tokens;
use bzip3_rs::BlockSize;
use bzip3_rs::default_workers;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::path::PathBuf;
fn parse(args: &[&str]) -> Result<Config, Stop> {
parse_config(args.iter().map(OsString::from).collect())
}
fn config(args: &[&str]) -> Config {
parse(args).expect("valid command line")
}
fn error_text(args: &[&str]) -> String {
match parse(args) {
Err(Stop::Error(error)) => error.message,
other => panic!("expected an error for {args:?}, got {other:?}"),
}
}
fn level(level: u8) -> BlockSize {
BlockSize::from_level(level).expect("valid level")
}
#[test]
fn block_values_accept_kib_mib_suffixes() {
let mib16 = BlockSize::mib(16).unwrap();
let max = BlockSize::MAX;
let min = BlockSize::MIN;
for text in [
"16", "16M", "16m", "16MiB", "16mib", "16MIB", "16384K", "16384k", "16384KiB",
"16384kib",
] {
assert_eq!(
parse_block_size(text),
Ok(BlockSizeArg::Valid(mib16)),
"{text}"
);
}
for text in ["65K", "65k", "65KIB", "65kib", "65KiB"] {
assert_eq!(
parse_block_size(text),
Ok(BlockSizeArg::Valid(min)),
"{text}"
);
}
for text in ["511M", "511mib", "523264K", "523264kib"] {
assert_eq!(
parse_block_size(text),
Ok(BlockSizeArg::Valid(max)),
"{text}"
);
}
}
#[test]
fn block_values_reject_malformed() {
for text in [
"xx", "K", "M", "KiB", "-1", "1 ", " 1", "16B", "16MB", "16KB", "16MI", "1.5M", "0x10M",
] {
assert_eq!(
parse_block_size(text),
Err(format!("invalid block size: {text}")),
"{text}"
);
}
}
#[test]
fn block_values_flag_out_of_range() {
for text in [
"",
"0",
"512",
"64K",
"523265K",
"512M",
"9999999999M",
"9999999999K",
"0K",
] {
assert_eq!(
parse_block_size(text),
Ok(BlockSizeArg::OutOfRange),
"{text}"
);
}
}
#[test]
fn worker_counts_map_to_pool() {
assert_eq!(resolve_workers(1), 1);
assert_eq!(resolve_workers(2), 2);
assert_eq!(resolve_workers(64), 64);
assert_eq!(resolve_workers(65), 64);
assert_eq!(resolve_workers(usize::MAX), 64);
assert_eq!(resolve_workers(0), default_workers().min(MAX_WORKERS));
assert!((1..=MAX_WORKERS).contains(&resolve_workers(0)));
}
#[test]
fn alias_selects_default_mode() {
let alias = |name: &str| Alias::from_argv0(Some(OsStr::new(name)));
assert_eq!(alias("/usr/bin/bunzip3"), Alias::Bunzip3);
assert_eq!(alias("bz3cat"), Alias::Bz3cat);
assert_eq!(alias("bzip3"), Alias::Bzip3);
assert_eq!(alias("other"), Alias::Bzip3);
assert_eq!(Alias::from_argv0(None), Alias::Bzip3);
assert_eq!(config(&["bunzip3"]).mode, Mode::Decode);
assert_eq!(config(&["bunzip3", "-e"]).mode, Mode::Encode);
assert_eq!(config(&["bunzip3", "-r"]).mode, Mode::Recover);
assert_eq!(config(&["bz3cat", "--recover"]).mode, Mode::Recover);
assert!(config(&["bz3cat"]).to_stdout);
}
#[test]
fn last_mode_flag_wins() {
assert_eq!(config(&["bzip3"]).mode, Mode::Encode);
assert_eq!(config(&["bzip3", "-e", "-d"]).mode, Mode::Decode);
assert_eq!(config(&["bzip3", "-d", "-t"]).mode, Mode::Test);
assert_eq!(config(&["bzip3", "-d", "-z"]).mode, Mode::Encode);
assert_eq!(config(&["bzip3", "-d", "-d"]).mode, Mode::Decode);
assert_eq!(config(&["bzip3", "-d", "-r"]).mode, Mode::Recover);
assert_eq!(config(&["bzip3", "-r", "-t"]).mode, Mode::Test);
assert_eq!(config(&["bzip3", "-r", "-e"]).mode, Mode::Encode);
}
#[test]
fn repeated_flags_are_accepted() {
let parsed = config(&["bzip3", "-k", "-k", "-j", "2", "-j", "3"]);
assert!(parsed.keep);
assert_eq!(parsed.workers, 3);
}
#[test]
fn help_and_version_follow_argument_order() {
assert!(matches!(
parse(&["bzip3", "-h", "-b", "xx"]),
Err(Stop::Display(ref e)) if e.kind() == ErrorKind::DisplayHelp
));
assert!(matches!(
parse(&["bzip3", "-h", "--bogus"]),
Err(Stop::Display(ref e)) if e.kind() == ErrorKind::DisplayHelp
));
assert!(matches!(
parse(&["bzip3", "-V", "-j", "xx"]),
Err(Stop::Display(ref e)) if e.kind() == ErrorKind::DisplayVersion
));
assert_eq!(
error_text(&["bzip3", "-b", "xx", "-h"]),
"bzip3: invalid block size: xx"
);
assert_eq!(
error_text(&["bzip3", "-j", "xx", "--version"]),
"bzip3: invalid amount of jobs: xx"
);
}
#[test]
fn last_block_flag_wins() {
assert_eq!(config(&["bzip3"]).block_size, BlockSize::DEFAULT);
assert_eq!(config(&["bzip3", "-b", "1", "-14"]).block_size, level(14));
assert_eq!(
config(&["bzip3", "-14", "-b", "1"]).block_size,
BlockSize::mib(1).unwrap()
);
assert_eq!(config(&["bzip3", "--fast", "--best"]).block_size, level(14));
assert_eq!(config(&["bzip3", "--best", "--fast"]).block_size, level(1));
assert_eq!(config(&["bzip3", "-1", "-14"]).block_size, level(14));
assert_eq!(config(&["bzip3", "-14", "-1"]).block_size, level(1));
assert_eq!(config(&["bzip3", "-b", "512", "-14"]).block_size, level(14));
assert_eq!(
config(&["bzip3", "-b", "1", "--", "-14"]).files,
[PathBuf::from("-14")]
);
assert_eq!(
error_text(&["bzip3", "-14", "-b", "512"]),
"Block size must be between 65 KiB and 511 MiB."
);
assert_eq!(
error_text(&["bzip3", "-b", "xx", "-14"]),
"bzip3: invalid block size: xx"
);
}
#[test]
fn jobs_follow_reference_rules() {
assert_eq!(config(&["bzip3", "-j", "1"]).workers, 1);
assert_eq!(config(&["bzip3", "-j", "64"]).workers, 64);
assert!(config(&["bzip3", "-j", ""]).workers >= 1);
assert_eq!(
error_text(&["bzip3", "-j", "65"]),
"Number of workers must be between 0 and 64."
);
for text in ["+1", "1x"] {
assert_eq!(
error_text(&["bzip3", "-j", text]),
format!("bzip3: invalid amount of jobs: {text}")
);
}
let text = error_text(&["bzip3", "-j", "-1"]);
assert!(text.starts_with("error: a value is required"), "{text}");
}
#[test]
fn digit_clusters_stay_unknown_flags() {
for flag in ["-19", "-0", "-15", "-91"] {
let text = error_text(&["bzip3", flag]);
assert!(text.starts_with("error: unexpected argument"), "{text}");
}
}
#[test]
fn usage_errors_show_one_hint() {
let text = error_text(&["bzip3", "--bogus"]);
assert!(text.contains("Usage: bzip3"), "{text}");
assert!(
text.ends_with("\nTry 'bzip3 --help' for more information."),
"{text}"
);
assert_eq!(text.matches("Try '").count(), 1, "{text}");
assert!(!text.contains("For more information"), "{text}");
}
#[test]
fn from_codec_covers_all_arms() {
use super::ExitError;
use bzip3_rs::error::Error;
let io = Error::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
let fault = ExitError::from_codec(&io, Mode::Encode, false);
assert_eq!(fault.code, 1);
assert_eq!(fault.message, "Write error: No such file or directory");
for state in [
Error::OutOfMemory,
Error::InvalidBlockSize,
Error::ThreadPoolFailed,
] {
let fault = ExitError::from_codec(&state, Mode::Encode, true);
assert_eq!(fault.code, 1);
assert_eq!(fault.message, "Failed to create a block encoder state.");
}
let fault = ExitError::from_codec(&Error::BwtFailed, Mode::Encode, false);
assert_eq!(fault.code, 1);
assert!(
fault.message.starts_with("Failed to encode a block: "),
"{fault}"
);
let fault = ExitError::from_codec(&Error::BwtFailed, Mode::Encode, true);
assert_eq!(fault.code, 1);
assert!(
fault.message.starts_with("Failed to encode data: "),
"{fault}"
);
let fault = ExitError::from_codec(&Error::TruncatedData, Mode::Decode, false);
assert_eq!(fault.code, 2);
assert!(
fault.message.starts_with("Failed to decode a block: "),
"{fault}"
);
let fault = ExitError::from_codec(&Error::TruncatedData, Mode::Decode, true);
assert_eq!(fault.code, 2);
assert!(
fault.message.starts_with("Failed to decode data: "),
"{fault}"
);
for mode in [Mode::Test, Mode::Recover] {
let fault = ExitError::from_codec(&Error::TruncatedData, mode, true);
assert_eq!(fault.code, 2);
assert!(
fault.message.starts_with("Failed to decode data: "),
"{fault}"
);
}
let shown = format!(
"{}",
ExitError::from_codec(&Error::BwtFailed, Mode::Encode, false)
);
assert!(shown.starts_with("Failed to encode a block: "), "{shown}");
}
#[test]
fn os_error_text_covers_known_and_fallback() {
use super::os_error_text;
use std::io::ErrorKind as Kind;
for (kind, text) in [
(Kind::NotFound, "No such file or directory"),
(Kind::PermissionDenied, "Permission denied"),
(Kind::AlreadyExists, "File exists"),
(Kind::BrokenPipe, "Broken pipe"),
(Kind::IsADirectory, "Is a directory"),
(Kind::InvalidInput, "Invalid argument"),
(Kind::ReadOnlyFilesystem, "Read-only file system"),
] {
assert_eq!(os_error_text(&std::io::Error::new(kind, "x")), text);
}
let other = std::io::Error::other("custom boom");
assert_eq!(os_error_text(&other), "custom boom");
}
#[cfg(unix)]
fn non_utf8_level_tokens() -> Vec<OsString> {
use std::os::unix::ffi::OsStrExt as _;
vec![
OsStr::from_bytes(b"\xff\xfe").to_owned(),
OsStr::from_bytes(b"-\xff").to_owned(),
]
}
#[cfg(windows)]
fn non_utf8_level_tokens() -> Vec<OsString> {
use std::os::windows::ffi::OsStringExt as _;
vec![
OsString::from_wide(&[0xD800]),
OsString::from_wide(&[u16::from(b'-'), 0xD800]),
]
}
#[cfg(not(any(unix, windows)))]
fn non_utf8_level_tokens() -> Vec<OsString> {
Vec::new()
}
#[test]
fn parse_level_token_covers_edges() {
assert_eq!(parse_level_token(OsStr::new("-1")), Some(1));
assert_eq!(parse_level_token(OsStr::new("-14")), Some(14));
assert_eq!(parse_level_token(OsStr::new("-01")), Some(1));
for text in ["", "-", "--1", "-0", "-15", "-19", "1", "-1a", "-x", "--"] {
assert_eq!(parse_level_token(OsStr::new(text)), None, "{text}");
}
for raw in non_utf8_level_tokens() {
assert_eq!(parse_level_token(&raw), None);
}
}
#[test]
fn rewrite_level_tokens_covers_dashdash() {
let argv =
|items: &[&str]| rewrite_level_tokens(items.iter().map(OsString::from).collect());
assert_eq!(
argv(&["bzip3", "-1"]),
[OsString::from("bzip3"), OsString::from("--level=1")]
);
assert_eq!(
argv(&["bzip3", "-14", "a", "-2"]),
[
OsString::from("bzip3"),
OsString::from("--level=14"),
OsString::from("a"),
OsString::from("--level=2")
]
);
assert_eq!(
argv(&["-1", "-2"]),
[OsString::from("-1"), OsString::from("--level=2")]
);
assert_eq!(
argv(&["bzip3", "--", "-1"]),
[
OsString::from("bzip3"),
OsString::from("--"),
OsString::from("-1")
]
);
assert_eq!(
argv(&["bzip3", "-b", "xx"]),
[
OsString::from("bzip3"),
OsString::from("-b"),
OsString::from("xx")
]
);
}
}