use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{CommandFactory, Parser};
use stuffr::FormatId;
use stuffr::entries::{self, ExtractOpts, Selection};
use stuffr::ops::{self, CompressOpts, DecompressOpts, Input, Output};
use stuffr_cli::cli::{Cli, Command};
const EXAMPLES: &str = include_str!("examples.txt");
fn input_of(s: &str) -> Input {
if s == "-" {
Input::Stdin
} else {
Input::Path(PathBuf::from(s))
}
}
fn output_of(s: &str) -> Output {
if s == "-" {
Output::Stdout
} else {
Output::Path(PathBuf::from(s))
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
if cli.examples {
print!("{EXAMPLES}");
return ExitCode::SUCCESS;
}
let command = match cli.command {
Some(c) => c,
None => {
let _ = Cli::command().print_help();
println!();
return ExitCode::from(2);
}
};
match run(command) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("stuffr: {e}");
ExitCode::from(e.exit_code() as u8)
}
}
}
fn destination_is_stdout(cmd: &Command) -> bool {
match cmd {
Command::Cat { .. } | Command::Info { .. } | Command::Formats | Command::List { .. } => {
true
}
Command::Pack {
output: Some(o), ..
}
| Command::Unpack {
output: Some(o), ..
} => o == "-",
_ => false,
}
}
fn run(command: Command) -> stuffr::Result<()> {
let stdout_dest = destination_is_stdout(&command);
match dispatch(command) {
Ok(()) => Ok(()),
Err(stuffr::Error::Io(e)) if stdout_dest && e.kind() == std::io::ErrorKind::BrokenPipe => {
Ok(())
}
Err(e) => Err(e),
}
}
fn parse_memory_limit(raw: Option<String>) -> stuffr::Result<Option<u64>> {
match raw {
Some(s) => Ok(Some(
stuffr_cli::size::parse_size(&s).map_err(stuffr::Error::Usage)?,
)),
None => Ok(None),
}
}
fn dispatch(command: Command) -> stuffr::Result<()> {
match command {
Command::Pack {
paths,
output,
format,
level,
force,
no_sync,
allow_weak_encoder,
threads,
turbo,
memory_limit,
strict_fidelity,
} => {
let memory_limit = parse_memory_limit(memory_limit)?;
let fmt = match format.as_deref() {
Some(name) => Some(format_by_name(name)?),
None => None,
};
let opts = CompressOpts {
format: fmt,
level,
force,
sync: !no_sync,
allow_weak_encoder,
threads,
turbo,
memory_limit,
};
if let Some((container, codec)) = resolve_pack_chain(fmt, output.as_deref())? {
refuse_unhonoured_pack_flags(&opts, codec.is_some())?;
let inputs = pack_inputs(&paths)?;
let dst = match output {
Some(o) => output_of(&o),
None if inputs.len() == 1 => {
Output::Path(ops::suggest_packed(&inputs[0], container)?)
}
None => {
return Err(stuffr::Error::Usage(
"collecting several paths into an archive needs an explicit \
-o NAME.tar"
.into(),
));
}
};
let out = entries::create_archive(&inputs, dst, container, codec, &opts)?;
let losses = out.fidelity.warnings.len();
eprintln!(
"{} path(s) -> {} ({} -> {} bytes, {})",
inputs.len(),
out.format,
out.bytes_in,
out.bytes_out,
match losses {
0 => "no fidelity loss".to_string(),
n => format!("{n} fidelity loss(es)"),
}
);
for note in &out.notes {
eprintln!("stuffr: note: {note}");
}
return report_fidelity(&out.fidelity, strict_fidelity);
}
if paths.len() > 1 {
return Err(stuffr::Error::Usage(format!(
"packing {} paths needs an output naming a container (`-o bundle.tar`, \
or --format tar); a codec compresses one stream and has nowhere to \
put a second. A container INSIDE a codec does work in one step — \
`-o bundle.tar.gz` and `-o bundle.tgz` both name a container.",
paths.len()
)));
}
let input = paths.into_iter().next().expect("clap requires one path");
let dst = match output {
Some(o) => output_of(&o),
None => {
let path = match input_of(&input) {
Input::Path(p) => p,
Input::Stdin => {
return Err(stuffr::Error::Usage(
"reading stdin needs an explicit -o".into(),
));
}
};
let chosen = match fmt {
Some(f) => f,
None => ops::default_format()?,
};
Output::Path(ops::suggest_packed(&path, chosen)?)
}
};
let out = ops::compress(input_of(&input), dst, &opts)?;
eprintln!(
"{} -> {} ({} -> {} bytes, {} fidelity)",
input, out.format, out.bytes_in, out.bytes_out, out.fidelity.rung
);
report_fidelity(&out.fidelity, strict_fidelity)
}
Command::Unpack {
input,
patterns,
index,
directory,
output,
force,
format,
max_ratio,
no_sync,
memory_limit,
strict_fidelity,
} => {
let named_entries = !patterns.is_empty() || !index.is_empty();
if let Some(dir) = directory {
let selection = selection_of(patterns, index)?;
refuse_unhonoured_extract_flags(output.is_some(), format.is_some())?;
let opts = ExtractOpts {
max_ratio: max_ratio.unwrap_or(stuffr::DEFAULT_MAX_RATIO),
force,
memory_limit: Some(
parse_memory_limit(memory_limit)?
.unwrap_or_else(stuffr::default_memory_limit),
),
..Default::default()
};
let out = entries::extract(input_of(&input), Path::new(&dir), &selection, &opts)?;
eprintln!(
"{} -> {} bytes extracted into {dir} ({} fidelity)",
out.format, out.bytes_out, out.fidelity.rung
);
return report_fidelity(&out.fidelity, strict_fidelity);
}
if named_entries {
let named = match patterns.first() {
Some(p) => format!("`{p}`"),
None => format!("`--index {}`", index[0]),
};
return Err(stuffr::Error::Usage(format!(
"{named} names an archive entry; pass -C DIR to say where entries \
should be extracted to"
)));
}
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
let fmt = match format.as_deref() {
Some(name) => Some(format_by_name(name)?),
None => None,
};
let mut opts = DecompressOpts {
force,
sync: !no_sync,
memory_limit,
..Default::default()
};
opts.format = fmt;
if let Some(r) = max_ratio {
opts.max_ratio = r;
}
let dst = match output {
Some(o) => output_of(&o),
None => match input_of(&input) {
Input::Path(p) => Output::Path(ops::suggest_unpacked(&p)?),
Input::Stdin => {
return Err(stuffr::Error::Usage(
"reading stdin needs an explicit -o".into(),
));
}
},
};
let out = ops::decompress(input_of(&input), dst, &opts)?;
eprintln!("{} -> {} bytes", out.format, out.bytes_out);
report_fidelity(&out.fidelity, strict_fidelity)
}
Command::Cat {
input,
patterns,
index,
format,
max_ratio,
memory_limit,
} => {
if !patterns.is_empty() || !index.is_empty() {
let selection = selection_of(patterns, index)?;
refuse_unhonoured_extract_flags(false, format.is_some())?;
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
let mut out = std::io::stdout();
entries::cat(
input_of(&input),
&selection,
max_ratio.unwrap_or(stuffr::DEFAULT_MAX_RATIO),
memory_limit,
&mut out,
)?;
return Ok(());
}
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
let fmt = match format.as_deref() {
Some(name) => Some(format_by_name(name)?),
None => None,
};
let mut opts = DecompressOpts {
format: fmt,
memory_limit,
..Default::default()
};
if let Some(r) = max_ratio {
opts.max_ratio = r;
}
ops::decompress(input_of(&input), Output::Stdout, &opts)?;
Ok(())
}
Command::Info {
input,
json,
memory_limit,
} => {
use std::io::Write;
let memory_limit =
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit);
let i = ops::inspect(input_of(&input))?;
let mut out = std::io::stdout();
if json {
let mut v = serde_json::to_value(&i).expect("Inspection serializes");
if let serde_json::Value::Object(ref mut map) = v {
map.insert("memory_limit".into(), serde_json::json!(memory_limit));
}
writeln!(out, "{v}")?;
} else {
writeln!(out, "format: {}", i.format)?;
writeln!(out, "chain: {}", i.chain)?;
writeln!(out, "rung: {}", i.fidelity.rung)?;
match i.bytes_in {
Some(n) => writeln!(out, "size: {n} bytes")?,
None => writeln!(out, "size: unknown (stream)")?,
}
if !i.fidelity_evaluated {
writeln!(
out,
"fidelity: not evaluated (info does not open the archive; \
run `stuffr test` for that)"
)?;
} else if i.fidelity.has_warnings() {
writeln!(out, "fidelity: {} warning(s)", i.fidelity.warnings.len())?;
for w in &i.fidelity.warnings {
writeln!(out, " - {w}")?;
}
} else {
writeln!(out, "fidelity: nothing approximated")?;
}
writeln!(
out,
"memory: {}",
stuffr_cli::size::format_size(memory_limit)
)?;
}
Ok(())
}
Command::Formats => print_formats(),
Command::List {
input,
json,
max_ratio,
memory_limit,
strict_fidelity,
} => {
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
print_list(
input_of(&input),
json,
max_ratio.unwrap_or(stuffr::DEFAULT_MAX_RATIO),
memory_limit,
strict_fidelity,
)
}
Command::Test {
input,
max_ratio,
memory_limit,
strict_fidelity,
} => {
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
let out = entries::test(
input_of(&input),
max_ratio.unwrap_or(stuffr::DEFAULT_MAX_RATIO),
memory_limit,
)?;
let losses = out.fidelity.warnings.len();
eprintln!(
"{} -> {} bytes verified ({})",
out.format,
out.bytes_out,
match losses {
0 => format!("{} fidelity", out.fidelity.rung),
n => format!("{} access, {n} fidelity loss(es)", out.fidelity.rung),
}
);
report_fidelity(&out.fidelity, strict_fidelity)
}
}
}
fn report_fidelity(report: &stuffr::FidelityReport, strict: bool) -> stuffr::Result<()> {
if !report.has_warnings() {
return Ok(());
}
const SHOWN: usize = 10;
eprintln!("stuffr: {} fidelity warning(s):", report.warnings.len());
for w in report.warnings.iter().take(SHOWN) {
eprintln!(" - {w}");
}
if let Some(rest) = report.warnings.len().checked_sub(SHOWN).filter(|n| *n > 0) {
eprintln!(" … and {rest} more");
}
if strict {
return Err(stuffr::Error::FidelityDegraded(report.warnings.len()));
}
Ok(())
}
fn resolve_pack_chain(
fmt: Option<FormatId>,
output: Option<&str>,
) -> stuffr::Result<Option<(FormatId, Option<FormatId>)>> {
let registry = stuffr::registry();
if let Some(id) = fmt {
if registry.container(id).is_none() {
return Ok(None);
}
let chain = output.map(|o| stuffr::chain_for_new_path(registry, Path::new(o)));
if let Some(chain) = &chain
&& let Some(named) = chain.container()
&& named != id
{
let out = output.unwrap_or_default();
return Err(stuffr::Error::Usage(format!(
"--format {id} asks for a {id} archive, but the output name `{out}` says \
{named}. Writing it would exit 0 and leave a file `stuffr list` \
then calls corrupt, because a reader takes the container from \
the name. Rename the output, or drop --format and let the name \
decide."
)));
}
let codec = chain.and_then(|c| c.outermost_codec());
return Ok(Some((id, codec)));
}
let Some(output) = output else {
return Ok(None);
};
let chain = stuffr::chain_for_new_path(registry, Path::new(output));
match chain.container() {
Some(container) => Ok(Some((container, chain.outermost_codec()))),
None => Ok(None),
}
}
fn pack_inputs(paths: &[String]) -> stuffr::Result<Vec<PathBuf>> {
if paths.iter().any(|p| p == "-") {
return Err(stuffr::Error::Usage(
"stdin has no name to store an entry under; name files instead of `-`".into(),
));
}
Ok(paths.iter().map(PathBuf::from).collect())
}
fn refuse_unhonoured_extract_flags(output: bool, format: bool) -> stuffr::Result<()> {
if output {
return Err(stuffr::Error::Usage(
"-o writes one decoded stream to one file; -C extracts entries into a \
directory. Pass one or the other."
.into(),
));
}
if format {
return Err(stuffr::Error::Usage(
"--format names a codec for a single-stream decode; entry-aware extraction \
detects the container (and any codec above it) from the archive itself"
.into(),
));
}
Ok(())
}
fn refuse_unhonoured_pack_flags(opts: &CompressOpts, has_codec: bool) -> stuffr::Result<()> {
if has_codec {
return Ok(());
}
if opts.threads.is_some() {
return Err(stuffr::Error::Usage(
"--threads governs a codec's parallel encoder; no container in this build \
compresses anything itself, so there is no worker count to hand it"
.into(),
));
}
if opts.turbo {
return Err(stuffr::Error::Usage(
"--turbo lifts the CPU cap for a codec's parallel encoder; no container in \
this build compresses anything itself"
.into(),
));
}
if opts.allow_weak_encoder {
return Err(stuffr::Error::Usage(
"--allow-weak-encoder consents to a codec's fallback encoder; no container \
in this build compresses anything itself"
.into(),
));
}
Ok(())
}
fn entry_kind_str(kind: &stuffr::EntryKind) -> &'static str {
match kind {
stuffr::EntryKind::File => "file",
stuffr::EntryKind::Dir => "dir",
stuffr::EntryKind::Symlink { .. } => "symlink",
stuffr::EntryKind::Other => "other",
_ => "other",
}
}
fn print_list(
src: Input,
json: bool,
max_ratio: u64,
memory_limit: Option<u64>,
strict_fidelity: bool,
) -> stuffr::Result<()> {
use std::io::Write;
let (entries, outcome) = entries::list(src, max_ratio, memory_limit)?;
let mut out = std::io::stdout();
if json {
let rows: Vec<serde_json::Value> = entries
.iter()
.enumerate()
.map(|(i, e)| {
serde_json::json!({
"index": i,
"name": e.name,
"kind": entry_kind_str(&e.kind),
"size": e.size,
})
})
.collect();
writeln!(out, "{}", serde_json::Value::Array(rows))?;
} else {
for (i, e) in entries.iter().enumerate() {
match e.size {
Some(n) => writeln!(out, "{i:>5} {n:>12} {}", e.name)?,
None => writeln!(out, "{i:>5} {:>12} {}", "-", e.name)?,
}
}
}
out.flush()?;
report_fidelity(&outcome.fidelity, strict_fidelity)
}
fn selection_of(patterns: Vec<String>, index: Vec<usize>) -> stuffr::Result<Selection> {
match (patterns.is_empty(), index.is_empty()) {
(true, true) => Ok(Selection::All),
(false, true) => Ok(Selection::Names(patterns)),
(true, false) => Ok(Selection::Indices(index)),
(false, false) => Err(stuffr::Error::Usage(
"--index and entry patterns are two ways of choosing the same thing; \
pass one or the other, not both"
.into(),
)),
}
}
fn format_by_name(name: &str) -> stuffr::Result<FormatId> {
stuffr::registry()
.matrix()
.into_iter()
.find(|r| r.id.as_str() == name)
.map(|r| r.id)
.ok_or_else(|| {
let known: Vec<&str> = stuffr::registry()
.matrix()
.iter()
.map(|r| r.id.as_str())
.collect();
stuffr::Error::Usage(format!(
"unknown format `{name}`; this build has: {}",
if known.is_empty() {
"none".to_string()
} else {
known.join(", ")
}
))
})
}
fn print_formats() -> stuffr::Result<()> {
use std::io::Write;
let mut out = std::io::stdout();
let rows = stuffr::registry().matrix();
if rows.is_empty() {
writeln!(out, "This build contains 0 formats.")?;
writeln!(
out,
"(Phase 0 validates the core against mock formats; Phase 1 adds real ones.)"
)?;
return Ok(());
}
writeln!(
out,
"{:<16} {:<10} {:<5} {:<5} {:<8} EXTENSIONS",
"FORMAT", "KIND", "READ", "WRITE", "PARALLEL"
)?;
let registry = stuffr::registry();
for r in rows {
let weak = r.write
&& r.kind == stuffr::FormatKind::Codec
&& registry.codec(r.id).is_some_and(|c| c.caps().weak_encoder);
writeln!(
out,
"{:<16} {:<10} {:<5} {:<5} {:<8} {}",
r.id.as_str(),
match r.kind {
stuffr::FormatKind::Codec => "codec",
stuffr::FormatKind::Container => "container",
},
if r.read { "yes" } else { "-" },
if weak {
"weak"
} else if r.write {
"yes"
} else {
"-"
},
if r.parallel { "yes" } else { "-" },
r.extensions.join(", "),
)?;
}
writeln!(out)?;
writeln!(
out,
"(WRITE shows `weak` for a codec whose encoder in this build is a fallback markedly \
worse than the format's usual one — see --allow-weak-encoder.)"
)?;
Ok(())
}