use std::fs::{self, File};
use std::io::{self, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use amiga_lzx::{ArchiveReader, ArchiveWriter, DateTime, EntryBuilder, Level};
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "lzx", version, about = "Amiga LZX archiver")]
struct Args {
#[arg(short = '1', global = true)]
quick: bool,
#[arg(short = '2', global = true)]
normal: bool,
#[arg(short = '3', global = true)]
max: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
#[command(alias = "create")]
C {
archive: PathBuf,
files: Vec<PathBuf>,
},
#[command(alias = "extract")]
X {
archive: PathBuf,
#[arg(default_value = ".")]
outdir: PathBuf,
},
#[command(alias = "list")]
L { archive: PathBuf },
#[command(alias = "test")]
T { archive: PathBuf },
}
impl Args {
fn level(&self) -> Level {
match (self.quick, self.normal, self.max) {
(true, _, _) => Level::Quick,
(_, _, true) => Level::Max,
_ => Level::Normal,
}
}
}
fn main() -> ExitCode {
let args = Args::parse();
let level = args.level();
let result = match args.command {
Command::C { archive, files } => create(&archive, &files, level),
Command::X { archive, outdir } => extract(&archive, &outdir),
Command::L { archive } => list(&archive),
Command::T { archive } => test(&archive),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("lzx: {e}");
ExitCode::FAILURE
}
}
}
fn create(archive: &Path, roots: &[PathBuf], level: Level) -> io::Result<()> {
if roots.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"no input files",
));
}
let inputs = collect_inputs(roots)?;
if inputs.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"no regular files found",
));
}
let out = BufWriter::new(File::create(archive)?);
let mut ar = ArchiveWriter::new(out).map_err(|e| io::Error::other(e.to_string()))?;
for (path, name) in &inputs {
let data = fs::read(path)?;
let datetime = entry_datetime(path);
let mut entry = ar
.add_entry(
EntryBuilder::new(name.clone())
.level(level)
.datetime(datetime),
)
.map_err(|e| io::Error::other(e.to_string()))?;
entry.write_all(&data)?;
entry
.finish()
.map_err(|e| io::Error::other(e.to_string()))?;
eprintln!(" + {} ({} bytes)", name, data.len());
}
ar.finish().map_err(|e| io::Error::other(e.to_string()))?;
Ok(())
}
fn extract(archive: &Path, outdir: &Path) -> io::Result<()> {
fs::create_dir_all(outdir)?;
let mut reader = open_reader(archive)?;
while let Some(entry) = reader
.next_entry()
.map_err(|e| io::Error::other(e.to_string()))?
{
let safe_name = sanitize_archive_path(&entry.filename).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("unsafe archive path: {}", entry.filename),
)
})?;
let dst = outdir.join(&safe_name);
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&dst, &entry.data)?;
let mtime = entry.datetime.to_system_time();
if let Err(e) = File::options()
.write(true)
.open(&dst)
.and_then(|f| f.set_modified(mtime))
{
eprintln!("warning: could not set mtime on {}: {e}", dst.display());
}
eprintln!(" - {} ({} bytes)", entry.filename, entry.data.len());
}
Ok(())
}
fn list(archive: &Path) -> io::Result<()> {
let mut reader = open_reader(archive)?;
println!("{:>10} {:>10} {:<}", "size", "crc32", "name");
while let Some(entry) = reader
.next_entry()
.map_err(|e| io::Error::other(e.to_string()))?
{
println!(
"{:>10} {:>08x} {}",
entry.data.len(),
entry.data_crc,
entry.filename
);
}
Ok(())
}
fn test(archive: &Path) -> io::Result<()> {
let mut reader = open_reader(archive)?;
let mut count = 0;
while let Some(entry) = reader
.next_entry()
.map_err(|e| io::Error::other(e.to_string()))?
{
count += 1;
eprintln!(" ok {}", entry.filename);
}
eprintln!("{count} entries verified");
Ok(())
}
fn open_reader(archive: &Path) -> io::Result<ArchiveReader<BufReader<File>>> {
let f = BufReader::new(File::open(archive)?);
ArchiveReader::new(f).map_err(|e| io::Error::other(e.to_string()))
}
fn entry_datetime(path: &Path) -> DateTime {
match fs::metadata(path).and_then(|m| m.modified()) {
Ok(mtime) => {
let (dt, clamped) = DateTime::from_system_time_clamped(mtime);
if clamped {
eprintln!(
"warning: mtime out of LZX range for {} — clamped",
path.display()
);
}
dt
}
Err(_) => DateTime::ZERO,
}
}
fn collect_inputs(roots: &[PathBuf]) -> io::Result<Vec<(PathBuf, String)>> {
let mut out = Vec::new();
for root in roots {
let meta = fs::symlink_metadata(root)?;
let ft = meta.file_type();
if ft.is_symlink() {
eprintln!("warning: skipping symlink {}", root.display());
continue;
}
if ft.is_file() {
let name = root
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "non-UTF8 filename"))?
.to_owned();
out.push((root.clone(), name));
} else if ft.is_dir() {
let prefix = root
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "non-UTF8 directory"))?
.to_owned();
walk_dir(root, &prefix, &mut out)?;
} else {
eprintln!("warning: skipping non-regular {}", root.display());
}
}
Ok(out)
}
fn walk_dir(dir: &Path, prefix: &str, out: &mut Vec<(PathBuf, String)>) -> io::Result<()> {
let mut entries: Vec<_> = fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.file_name());
for ent in entries {
let path = ent.path();
let meta = fs::symlink_metadata(&path)?;
let ft = meta.file_type();
if ft.is_symlink() {
eprintln!("warning: skipping symlink {}", path.display());
continue;
}
let basename = match path.file_name().and_then(|n| n.to_str()) {
Some(s) => s.to_owned(),
None => {
eprintln!("warning: skipping non-UTF8 path {}", path.display());
continue;
}
};
let archive_name = format!("{prefix}/{basename}");
if ft.is_file() {
out.push((path, archive_name));
} else if ft.is_dir() {
walk_dir(&path, &archive_name, out)?;
}
}
Ok(())
}
fn sanitize_archive_path(name: &str) -> Option<PathBuf> {
let trimmed = name.trim_start_matches('/');
let mut out = PathBuf::new();
for segment in trimmed.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
return None;
}
out.push(segment);
}
if out.as_os_str().is_empty() {
None
} else {
Some(out)
}
}