From 1f7d0a1ff6c0e9440d6ffc23261f9d61787f60aa Mon Sep 17 00:00:00 2001 From: Danny McClanahan <1305167+cosmicexplorer@users.noreply.github.com> Date: Wed, 14 Aug 2024 10:00:22 -0400 Subject: [PATCH] parse the whole command line without clap --- cli/Cargo.toml | 2 +- cli/clite/src/main.rs | 10 +- cli/src/args.rs | 459 +++++++++++++++++++++++++++++++++++++++++- cli/src/compress.rs | 43 ++-- cli/src/lib.rs | 2 +- cli/src/main.rs | 14 +- 6 files changed, 492 insertions(+), 38 deletions(-) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c0a9baf82..4d17d2d56 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -26,7 +26,7 @@ name = "zip-cli" [dependencies] clap = { version = "4.5.15", features = ["derive"], optional = true } -eyre = "0.6" +color-eyre = "0.6" [dependencies.zip] path = ".." diff --git a/cli/clite/src/main.rs b/cli/clite/src/main.rs index 83751f7e6..8be4bee96 100644 --- a/cli/clite/src/main.rs +++ b/cli/clite/src/main.rs @@ -1,6 +1,6 @@ -use std::io; #[cfg(not(feature = "clap"))] use std::env; +use std::io; #[cfg(feature = "clap")] use clap::{error::ErrorKind, Parser}; @@ -15,7 +15,7 @@ fn main() -> Result<(), Report> { let ZipCli { verbose, command } = match ZipCli::try_parse() { Ok(args) => args, Err(e) => match e.kind() { - ErrorKind::Format | ErrorKind::Io | ErrorKind::InvalidUtf8 => return Err(e.into()), + ErrorKind::Format | ErrorKind::Io => return Err(e.into()), _ => e.exit(), }, }; @@ -28,7 +28,9 @@ fn main() -> Result<(), Report> { }; match command { - ZipCommand::Info | ZipCommand::Extract => Ok(()), - ZipCommand::Compress(compress) => execute_compress(&mut err, compress), + ZipCommand::Info => eyre::bail!("info command not implemented"), + ZipCommand::Extract => eyre::bail!("extract command not implemented"), + ZipCommand::Compress(compress) => execute_compress(&mut err, compress)?, } + Ok(()) } diff --git a/cli/src/args.rs b/cli/src/args.rs index fea4f4344..a5926e527 100644 --- a/cli/src/args.rs +++ b/cli/src/args.rs @@ -1,5 +1,5 @@ #[cfg(not(feature = "clap"))] -use eyre::Report; +use color_eyre::eyre::Report; #[cfg(feature = "clap")] use clap::{ @@ -7,9 +7,13 @@ use clap::{ FromArgMatches, Parser, Subcommand, ValueEnum, }; -#[cfg(feature = "clap")] -use std::collections::VecDeque; -use std::{ffi::OsString, num::ParseIntError, path::PathBuf}; +use std::{collections::VecDeque, ffi::OsString, num::ParseIntError, path::PathBuf}; +#[cfg(not(feature = "clap"))] +use std::{ + io::{self, Write}, + process, + sync::OnceLock, +}; #[derive(Debug)] #[cfg_attr(feature = "clap", derive(Parser))] @@ -22,10 +26,141 @@ pub struct ZipCli { pub command: ZipCommand, } +#[cfg(not(feature = "clap"))] +#[derive(Debug)] +enum SubcommandName { + Compress, + Info, + Extract, +} + +#[cfg(not(feature = "clap"))] +static PARSED_EXE_NAME: OnceLock = OnceLock::new(); + #[cfg(not(feature = "clap"))] impl ZipCli { - pub fn parse_argv(_argv: impl IntoIterator) -> Result { - todo!() + const VERSION: &'static str = env!("CARGO_PKG_VERSION"); + const DESCRIPTION: &'static str = env!("CARGO_PKG_DESCRIPTION"); + + pub const ARGV_PARSE_FAILED_EXIT_CODE: i32 = 2; + pub const NON_FAILURE_EXIT_CODE: i32 = 0; + + pub const INFO_DESCRIPTION: &'static str = "do an info"; + pub const EXTRACT_DESCRIPTION: &'static str = "do an extract"; + + pub fn binary_name() -> &'static str { + PARSED_EXE_NAME.get().expect("binary name was not set yet") + } + + fn generate_version_text() -> String { + format!("{} {}\n", Self::binary_name(), Self::VERSION) + } + + fn generate_usage_line() -> String { + format!("Usage: {} [OPTIONS] ", Self::binary_name()) + } + + fn generate_full_help_text() -> String { + format!( + "\ +{} + +{} + +Commands: + {} {} + info {} + extract {} + +Options: + -v, --verbose Write information logs to stderr + -h, --help Print help + -V, --version Print version + +Build this binary with '--features clap' for more thorough help text. +", + Self::DESCRIPTION, + Self::generate_usage_line(), + Compress::COMMAND_NAME, + Compress::COMMAND_DESCRIPTION, + Self::INFO_DESCRIPTION, + Self::EXTRACT_DESCRIPTION, + ) + } + + fn generate_brief_help_text(context: &str) -> String { + format!( + "\ +error: {context} + +{} + +For more information, try '--help'. +", + Self::generate_usage_line() + ) + } + + fn parse_up_to_subcommand_name( + argv: &mut VecDeque, + ) -> Result<(bool, SubcommandName), Report> { + let mut verbose: bool = false; + let mut subcommand_name: Option = None; + while subcommand_name.is_none() { + match argv.pop_front() { + None => { + let help_text = Self::generate_full_help_text(); + let _ = io::stderr().write_all(help_text.as_bytes()); + process::exit(Self::ARGV_PARSE_FAILED_EXIT_CODE); + } + Some(arg) => match arg.as_encoded_bytes() { + b"-v" | b"--verbose" => verbose = true, + b"-V" | b"--version" => { + let version_text = Self::generate_version_text(); + io::stdout().write_all(version_text.as_bytes())?; + process::exit(Self::NON_FAILURE_EXIT_CODE) + } + b"-h" | b"--help" => { + let help_text = Self::generate_full_help_text(); + io::stdout().write_all(help_text.as_bytes())?; + process::exit(Self::NON_FAILURE_EXIT_CODE); + } + b"compress" => subcommand_name = Some(SubcommandName::Compress), + b"info" => subcommand_name = Some(SubcommandName::Info), + b"extract" => subcommand_name = Some(SubcommandName::Extract), + arg_bytes => { + let context = if arg_bytes.starts_with(b"-") { + format!("unrecognized flag {arg:?}") + } else { + format!("unrecognized subcommand name {arg:?}") + }; + let help_text = Self::generate_brief_help_text(&context); + let _ = io::stderr().write_all(help_text.as_bytes()); + process::exit(Self::ARGV_PARSE_FAILED_EXIT_CODE) + } + }, + } + } + Ok((verbose, subcommand_name.unwrap())) + } + + pub fn parse_argv(argv: impl IntoIterator) -> Result { + let mut argv: VecDeque = argv.into_iter().collect(); + let exe_name: String = argv + .pop_front() + .expect("exe name not on command line") + .into_string() + .expect("exe name not valid unicode"); + PARSED_EXE_NAME + .set(exe_name) + .expect("exe name already written"); + let (verbose, subcommand_name) = Self::parse_up_to_subcommand_name(&mut argv)?; + let command = match subcommand_name { + SubcommandName::Info => ZipCommand::Info, + SubcommandName::Extract => ZipCommand::Extract, + SubcommandName::Compress => ZipCommand::Compress(Compress::parse_argv(argv)?), + }; + Ok(Self { verbose, command }) } } @@ -95,6 +230,318 @@ pub struct Compress { pub positional_paths: Vec, } +#[cfg(not(feature = "clap"))] +impl Compress { + #[cfg(feature = "deflate64")] + const DEFLATE64_HELP_LINE: &'static str = " - deflate64: with deflate64\n"; + #[cfg(not(feature = "deflate64"))] + const DEFLATE64_HELP_LINE: &'static str = ""; + + #[cfg(feature = "bzip2")] + const BZIP2_HELP_LINE: &'static str = " - bzip2: with bzip2\n"; + #[cfg(not(feature = "bzip2"))] + const BZIP2_HELP_LINE: &'static str = ""; + + #[cfg(feature = "zstd")] + const ZSTD_HELP_LINE: &'static str = " - zstd: with zstd\n"; + #[cfg(not(feature = "zstd"))] + const ZSTD_HELP_LINE: &'static str = ""; + + pub const COMMAND_NAME: &'static str = "compress"; + pub const COMMAND_DESCRIPTION: &'static str = "do a compress"; + + fn generate_usage_line() -> String { + format!( + "Usage: {} {} [-h|--help] [OUTPUT-FLAG] [ENTRIES]... [--] [PATH]...", + ZipCli::binary_name(), + Self::COMMAND_NAME, + ) + } + + fn generate_full_help_text() -> String { + format!( + "\ +{} + +{} + + -h, --help Print help + +Output flags: +Where and how to write the generated zip archive. + -o, --output-file + Output zip file path to write. + The output file is currently always truncated if it already exists. + If not provided, output is written to stdout. + + --stdout + Allow writing output to stdout even if stdout is a tty. + +ENTRIES: +After at most one output flag is provided, the rest of the command line is attributes and +entry data. Attributes modify later entries. + +Sticky attributes: +These flags apply to everything that comes after them until reset by another instance of the +same attribute. Sticky attributes continue to apply to positional arguments received after +processing all flags. + + -c, --compression-method + Which compression technique to use. + Defaults to deflate if not specified. + + Possible values: + - stored: uncompressed + - deflate: with deflate (default) +{}{}{} + + -l, --compression-level + How much compression to perform, from 0..=24. + The accepted range of values differs for each technique. + + -m, --mode + Unix permissions to apply to the file, in octal (like chmod). + + --large-file [true|false] + Whether to enable large file support. + This may take up more space for records, but allows files over 32 bits in length to be + written, up to 64 bit sizes. + +Non-sticky attributes: +These flags only apply to the next entry after them, and may not be repeated. + + -n, --name + The name to apply to the entry. + + -s, --symlink + Make the next entry into a symlink entry. + A symlink entry may be immediate with -i, or it may read the symlink value from the + filesystem with -f. + +Entry data: +Each of these flags creates an entry in the output zip archive. + + -d, --dir + Create a directory entry. + A name must be provided beforehand with -n. + + -i, --imm + Write an entry containing this data. + A name must be provided beforehand with -n. + + -f, --file + Write an entry with the contents of this file path. + A name may be provided beforehand with -n, otherwise the name will be inferred from + relativizing the given path to the working directory. + + -r, --recursive-dir + Write all the recursive contents of this directory path. + A name may be provided beforehand with -n, which will be used as the prefix for all + recursive contents of this directory. Otherwise, the name will be inferred from + relativizing the given path to the working directory. + +Positional entries: + [PATH]... + Write the file or recursive directory contents, relativizing the path. + If the given path points to a file, then a single file entry will be written. + If the given path is a symlink, then a single symlink entry will be written. + If the given path refers to a directory, then the recursive contents will be written. +", + Self::COMMAND_DESCRIPTION, + Self::generate_usage_line(), + Self::DEFLATE64_HELP_LINE, + Self::BZIP2_HELP_LINE, + Self::ZSTD_HELP_LINE, + ) + } + + fn generate_brief_help_text(context: &str) -> String { + format!( + "\ +error: {context} + +{} +", + Self::generate_usage_line() + ) + } + + pub fn exit_arg_invalid(context: &str) -> ! { + let message = Self::generate_brief_help_text(context); + let _ = io::stderr().write_all(message.as_bytes()); + process::exit(ZipCli::ARGV_PARSE_FAILED_EXIT_CODE) + } + + pub fn parse_argv(mut argv: VecDeque) -> Result { + let mut allow_stdout: bool = false; + let mut output_path: Option = None; + let mut args: Vec = Vec::new(); + let mut positional_paths: Vec = Vec::new(); + + while let Some(arg) = argv.pop_front() { + match arg.as_encoded_bytes() { + b"-h" | b"--help" => { + let help_text = Self::generate_full_help_text(); + io::stdout().write_all(help_text.as_bytes())?; + process::exit(ZipCli::NON_FAILURE_EXIT_CODE); + } + + /* Output flags */ + b"--stdout" => { + if output_path.is_some() { + Self::exit_arg_invalid("--stdout provided along with output file"); + } else if !args.is_empty() || !positional_paths.is_empty() { + Self::exit_arg_invalid("--stdout provided after entries"); + } else if allow_stdout { + Self::exit_arg_invalid("--stdout provided twice"); + } else { + allow_stdout = true; + } + } + b"-o" | b"--output-file" => { + if output_path.is_some() { + Self::exit_arg_invalid("--output-file provided twice"); + } else if allow_stdout { + Self::exit_arg_invalid("--stdout provided along with output file"); + } else if !args.is_empty() || !positional_paths.is_empty() { + Self::exit_arg_invalid("-o/--output-file provided after entries"); + } else { + match argv.pop_front() { + Some(path) => { + output_path = Some(path.into()); + } + None => { + Self::exit_arg_invalid("no argument provided for -o/--output-file"); + } + } + } + } + + /* Attributes */ + b"-c" | b"--compression-method" => match argv.pop_front() { + None => { + Self::exit_arg_invalid("no argument provided for -c/--compression-method") + } + Some(name) => match name.as_encoded_bytes() { + b"stored" => args.push(CompressionArg::CompressionMethod( + CompressionMethodArg::Stored, + )), + b"deflate" => args.push(CompressionArg::CompressionMethod( + CompressionMethodArg::Deflate, + )), + #[cfg(feature = "deflate64")] + b"deflate64" => args.push(CompressionArg::CompressionMethod( + CompressionMethodArg::Deflate64, + )), + #[cfg(feature = "bzip2")] + b"bzip2" => args.push(CompressionArg::CompressionMethod( + CompressionMethodArg::Bzip2, + )), + #[cfg(feature = "zstd")] + b"zstd" => args.push(CompressionArg::CompressionMethod( + CompressionMethodArg::Zstd, + )), + _ => Self::exit_arg_invalid("unrecognized compression method {name:?}"), + }, + }, + b"-l" | b"--compression-level" => match argv.pop_front() { + None => { + Self::exit_arg_invalid("no argument provided for -l/--compression-level") + } + Some(level) => match level.into_string() { + Err(level) => Self::exit_arg_invalid(&format!( + "invalid unicode provided for compression level: {level:?}" + )), + Ok(level) => match i64::from_str_radix(&level, 10) { + Err(e) => Self::exit_arg_invalid(&format!( + "failed to parse integer for compression level: {e}" + )), + Ok(level) => { + if (0..=24).contains(&level) { + args.push(CompressionArg::Level(CompressionLevel(level))) + } else { + Self::exit_arg_invalid(&format!( + "compression level {level} was not between 0 and 24" + )) + } + } + }, + }, + }, + b"-m" | b"--mode" => match argv.pop_front() { + None => Self::exit_arg_invalid("no argument provided for -m/--mode"), + Some(mode) => match mode.into_string() { + Err(mode) => Self::exit_arg_invalid(&format!( + "invalid unicode provided for mode: {mode:?}" + )), + Ok(mode) => match Mode::parse(&mode) { + Err(e) => Self::exit_arg_invalid(&format!( + "failed to parse integer for mode: {e}" + )), + Ok(mode) => args.push(CompressionArg::Mode(mode)), + }, + }, + }, + b"--large-file" => match argv.pop_front() { + None => Self::exit_arg_invalid("no argument provided for --large-file"), + Some(large_file) => match large_file.as_encoded_bytes() { + b"true" => args.push(CompressionArg::LargeFile(true)), + b"false" => args.push(CompressionArg::LargeFile(false)), + _ => Self::exit_arg_invalid(&format!( + "unrecognized value for --large-file: {large_file:?}" + )), + }, + }, + + /* Data */ + b"-n" | b"--name" => match argv.pop_front() { + None => Self::exit_arg_invalid("no argument provided for -n/--name"), + Some(name) => match name.into_string() { + Err(name) => Self::exit_arg_invalid(&format!( + "invalid unicode provided for name: {name:?}" + )), + Ok(name) => args.push(CompressionArg::Name(name)), + }, + }, + b"-s" | b"--symlink" => args.push(CompressionArg::Symlink), + b"-d" | b"--dir" => args.push(CompressionArg::Dir), + b"-i" | b"--immediate" => match argv.pop_front() { + None => Self::exit_arg_invalid("no argument provided for -i/--immediate"), + Some(data) => args.push(CompressionArg::Immediate(data)), + }, + b"-f" | b"--file" => match argv.pop_front() { + None => Self::exit_arg_invalid("no argument provided for -f/--file"), + Some(file) => args.push(CompressionArg::FilePath(file.into())), + }, + b"-r" | b"--recursive-dir" => match argv.pop_front() { + None => Self::exit_arg_invalid("no argument provided for -r/--recursive-dir"), + Some(dir) => args.push(CompressionArg::RecursiveDirPath(dir.into())), + }, + + /* Transition to positional args */ + b"--" => break, + arg_bytes => { + if arg_bytes.starts_with(b"-") { + Self::exit_arg_invalid(&format!("unrecognized flag {arg:?}")) + } else { + argv.push_front(arg); + break; + } + } + } + } + + positional_paths.extend(argv.into_iter().map(|arg| arg.into())); + + Ok(Self { + allow_stdout, + output_path, + args, + positional_paths, + }) + } +} + #[cfg(feature = "clap")] impl FromArgMatches for Compress { fn from_arg_matches(matches: &ArgMatches) -> Result { diff --git a/cli/src/compress.rs b/cli/src/compress.rs index a85ad2d8d..6be418afa 100644 --- a/cli/src/compress.rs +++ b/cli/src/compress.rs @@ -4,7 +4,7 @@ use std::{ path::Path, }; -use eyre::{eyre, Report}; +use color_eyre::eyre::{self, Report}; use zip::{ unstable::path_to_string, @@ -70,7 +70,7 @@ fn enter_recursive_dir_entries( let entry_basename: String = dir_entry .file_name() .into_string() - .map_err(|name| eyre!("failed to decode basename {name:?}"))?; + .map_err(|name| eyre::eyre!("failed to decode basename {name:?}"))?; components.push(&entry_basename); let full_path: String = components.join("/"); readdir_stack.push((readdir, top_component)); @@ -129,7 +129,8 @@ pub fn execute_compress( "writing to stdout and buffering compressed zip in memory" )?; if io::stdout().is_terminal() && !allow_stdout { - return Err(eyre!("stdout is a tty, but --stdout was not set")); + /* TODO: maybe figure out some way to ensure --stdout is still the correct flag */ + Compress::exit_arg_invalid("stdout is a tty, but --stdout was not set"); } OutputHandle::InMem(Cursor::new(Vec::new())) } @@ -174,7 +175,7 @@ pub fn execute_compress( CompressionArg::Name(name) => { writeln!(err, "setting name of next entry to {name:?}")?; if let Some(last_name) = last_name { - return Err(eyre!( + Compress::exit_arg_invalid(&format!( "got two names before an entry: {last_name} and {name}" )); } @@ -183,30 +184,32 @@ pub fn execute_compress( CompressionArg::Dir => { writeln!(err, "writing dir entry")?; if symlink_flag { - return Err(eyre!("symlink flag provided before dir entry with ")); + Compress::exit_arg_invalid("symlink flag provided before dir entry"); } - let dirname = last_name - .take() - .ok_or_else(|| eyre!("no name provided before dir entry with"))?; + let dirname = last_name.take().unwrap_or_else(|| { + Compress::exit_arg_invalid("no name provided before dir entry") + }); writer.add_directory(dirname, options)?; } CompressionArg::Symlink => { writeln!(err, "setting symlink flag for next entry")?; if symlink_flag { /* TODO: make this a warning? */ - return Err(eyre!("symlink flag provided twice before entry")); + Compress::exit_arg_invalid("symlink flag provided twice before entry"); } symlink_flag = true; } CompressionArg::Immediate(data) => { - let name = last_name - .take() - .ok_or_else(|| eyre!("no name provided for immediate data {data:?}"))?; + let name = last_name.take().unwrap_or_else(|| { + Compress::exit_arg_invalid("no name provided for immediate data {data:?}") + }); if symlink_flag { /* This is a symlink entry. */ - let target = data - .into_string() - .map_err(|target| eyre!("failed to decode symlink target {target:?}"))?; + let target = data.into_string().unwrap_or_else(|target| { + Compress::exit_arg_invalid(&format!( + "failed to decode immediate symlink target {target:?}" + )) + }); writeln!( err, "writing immediate symlink entry with name {name:?} and target {target:?}" @@ -248,7 +251,7 @@ pub fn execute_compress( } CompressionArg::RecursiveDirPath(r) => { if symlink_flag { - return Err(eyre!("symlink flag provided before recursive dir entry")); + Compress::exit_arg_invalid("symlink flag provided before recursive dir entry"); } writeln!( err, @@ -259,14 +262,12 @@ pub fn execute_compress( } } if symlink_flag { - return Err(eyre!( - "symlink flag remaining after all entry flags processed" - )); + Compress::exit_arg_invalid("symlink flag remaining after all entry flags processed"); } if let Some(last_name) = last_name { - return Err(eyre!( + Compress::exit_arg_invalid(&format!( "name {last_name} remaining after all entry flags processed" - )); + )) } for pos_arg in positional_paths.into_iter() { let file_type = fs::symlink_metadata(&pos_arg)?.file_type(); diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 58c2517fb..a543cbdb5 100755 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -20,7 +20,7 @@ where fn write(&mut self, buf: &[u8]) -> io::Result { match self { Self::Output(w) => w.write(buf), - Self::NoOutput => Ok(0), + Self::NoOutput => Ok(buf.len()), } } diff --git a/cli/src/main.rs b/cli/src/main.rs index 83751f7e6..bbc8bd971 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,21 +1,23 @@ -use std::io; #[cfg(not(feature = "clap"))] use std::env; +use std::io; #[cfg(feature = "clap")] use clap::{error::ErrorKind, Parser}; -use eyre::Report; +use color_eyre::eyre::{self, Report}; use zip_cli::args::*; use zip_cli::compress::execute_compress; use zip_cli::ErrHandle; fn main() -> Result<(), Report> { + color_eyre::install()?; + #[cfg(feature = "clap")] let ZipCli { verbose, command } = match ZipCli::try_parse() { Ok(args) => args, Err(e) => match e.kind() { - ErrorKind::Format | ErrorKind::Io | ErrorKind::InvalidUtf8 => return Err(e.into()), + ErrorKind::Format | ErrorKind::Io => return Err(e.into()), _ => e.exit(), }, }; @@ -28,7 +30,9 @@ fn main() -> Result<(), Report> { }; match command { - ZipCommand::Info | ZipCommand::Extract => Ok(()), - ZipCommand::Compress(compress) => execute_compress(&mut err, compress), + ZipCommand::Info => eyre::bail!("info command not implemented"), + ZipCommand::Extract => eyre::bail!("extract command not implemented"), + ZipCommand::Compress(compress) => execute_compress(&mut err, compress)?, } + Ok(()) }