Support multiple exit codes

..with more specific error enums.
This commit is contained in:
Dustin J. Mitchell 2021-05-03 17:57:04 -04:00
parent 2345a57940
commit bb7130f960
23 changed files with 112 additions and 34 deletions

1
Cargo.lock generated
View file

@ -2176,6 +2176,7 @@ dependencies = [
"tempfile",
"termcolor",
"textwrap 0.13.4",
"thiserror",
"toml",
"toml_edit",
]

View file

@ -35,12 +35,10 @@ Considered to be part of the API policy.
## CLI exit codes
- `0` No errors, normal exit.
- `1` Generic error.
- `2` Never used to avoid conflicts with Bash.
- `3` Unable to execute with the given parameters.
- `4` I/O error.
- `5` Database error.
- `0` - No errors, normal exit.
- `1` - Generic error.
- `2` - Never used to avoid conflicts with Bash.
- `3` - Command-line Syntax Error.
# Security

View file

@ -8,6 +8,7 @@ version = "0.3.0"
dirs-next = "^2.0.0"
env_logger = "^0.8.3"
anyhow = "1.0"
thiserror = "1.0"
log = "^0.4.14"
nom = "^6.1.2"
prettytable-rs = "^0.8.0"

View file

@ -1,6 +1,5 @@
use super::args::*;
use super::{ArgList, Subcommand};
use anyhow::bail;
use nom::{combinator::*, sequence::*, Err, IResult};
/// A command is the overall command that the CLI should execute.
@ -29,13 +28,22 @@ impl Command {
}
/// Parse a command from the given list of strings.
pub fn from_argv(argv: &[&str]) -> anyhow::Result<Command> {
pub fn from_argv(argv: &[&str]) -> Result<Command, crate::Error> {
match Command::parse(argv) {
Ok((&[], cmd)) => Ok(cmd),
Ok((trailing, _)) => bail!("command line has trailing arguments: {:?}", trailing),
Ok((trailing, _)) => Err(crate::Error::for_arguments(format!(
"command line has trailing arguments: {:?}",
trailing
))),
Err(Err::Incomplete(_)) => unreachable!(),
Err(Err::Error(e)) => bail!("command line not recognized: {:?}", e),
Err(Err::Failure(e)) => bail!("command line not recognized: {:?}", e),
Err(Err::Error(e)) => Err(crate::Error::for_arguments(format!(
"command line not recognized: {:?}",
e
))),
Err(Err::Failure(e)) => Err(crate::Error::for_arguments(format!(
"command line not recognized: {:?}",
e
))),
}
}
}

View file

@ -1,8 +1,11 @@
use std::process::exit;
pub fn main() {
if let Err(err) = taskchampion_cli::main() {
eprintln!("{:?}", err);
exit(1);
match taskchampion_cli::main() {
Ok(_) => exit(0),
Err(e) => {
eprintln!("{:?}", e);
exit(e.exit_status());
}
}
}

59
cli/src/errors.rs Normal file
View file

@ -0,0 +1,59 @@
use taskchampion::Error as TcError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Command-Line Syntax Error: {0}")]
Arguments(String),
#[error(transparent)]
TaskChampion(#[from] TcError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl Error {
/// Construct a new command-line argument error
pub(crate) fn for_arguments<S: ToString>(msg: S) -> Self {
Error::Arguments(msg.to_string())
}
/// Determine the exit status for this error, as documented.
pub fn exit_status(&self) -> i32 {
match *self {
Error::Arguments(_) => 3,
_ => 1,
}
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
let err: anyhow::Error = err.into();
Error::Other(err)
}
}
#[cfg(test)]
mod test {
use super::*;
use anyhow::anyhow;
#[test]
fn test_exit_status() {
let mut err: Error;
err = anyhow!("uhoh").into();
assert_eq!(err.exit_status(), 1);
err = Error::Arguments("uhoh".to_string());
assert_eq!(err.exit_status(), 3);
err = std::io::Error::last_os_error().into();
assert_eq!(err.exit_status(), 1);
err = TcError::Database("uhoh".to_string()).into();
assert_eq!(err.exit_status(), 1);
}
}

View file

@ -6,7 +6,7 @@ pub(crate) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
modification: Modification,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
let description = match modification.description {
DescriptionMod::Set(ref s) => s.clone(),
_ => "(no description)".to_owned(),

View file

@ -6,7 +6,7 @@ pub(crate) fn execute<W: WriteColor>(
w: &mut W,
config_operation: ConfigOperation,
settings: &Settings,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
match config_operation {
ConfigOperation::Set(key, value) => {
let filename = settings.set(&key, &value)?;

View file

@ -1,7 +1,7 @@
use taskchampion::Replica;
use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> anyhow::Result<()> {
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> {
log::debug!("rebuilding working set");
replica.rebuild_working_set(true)?;
writeln!(w, "garbage collected.")?;

View file

@ -5,7 +5,7 @@ pub(crate) fn execute<W: WriteColor>(
w: &mut W,
command_name: String,
summary: bool,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
let usage = Usage::new();
usage.write_help(w, command_name.as_ref(), summary)?;
Ok(())

View file

@ -10,7 +10,7 @@ pub(crate) fn execute<W: WriteColor>(
replica: &mut Replica,
filter: Filter,
debug: bool,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
let working_set = replica.working_set()?;
for task in filtered_tasks(replica, &filter)? {

View file

@ -8,7 +8,7 @@ pub(crate) fn execute<W: WriteColor>(
replica: &mut Replica,
filter: Filter,
modification: Modification,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
for task in filtered_tasks(replica, &filter)? {
let mut task = task.into_mut(replica);

View file

@ -10,7 +10,7 @@ pub(crate) fn execute<W: WriteColor>(
settings: &Settings,
report_name: String,
filter: Filter,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
display_report(w, replica, settings, report_name, filter)
}

View file

@ -5,7 +5,7 @@ pub(crate) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
server: &mut Box<dyn Server>,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
replica.sync(server)?;
writeln!(w, "sync complete.")?;
Ok(())

View file

@ -1,6 +1,6 @@
use termcolor::{ColorSpec, WriteColor};
pub(crate) fn execute<W: WriteColor>(w: &mut W) -> anyhow::Result<()> {
pub(crate) fn execute<W: WriteColor>(w: &mut W) -> Result<(), crate::Error> {
write!(w, "TaskChampion ")?;
w.set_color(ColorSpec::new().set_bold(true))?;
writeln!(w, "{}", env!("CARGO_PKG_VERSION"))?;

View file

@ -19,7 +19,7 @@ use report::display_report;
/// Invoke the given Command in the context of the given settings
#[allow(clippy::needless_return)]
pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()> {
pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate::Error> {
log::debug!("command: {:?}", command);
log::debug!("settings: {:?}", settings);

View file

@ -80,7 +80,7 @@ pub(super) fn display_report<W: WriteColor>(
settings: &Settings,
report_name: String,
filter: Filter,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
let mut t = Table::new();
let working_set = replica.working_set()?;

View file

@ -38,23 +38,26 @@ use std::string::FromUtf8Error;
mod macros;
mod argparse;
mod errors;
mod invocation;
mod settings;
mod table;
mod usage;
pub(crate) use errors::Error;
use settings::Settings;
/// The main entry point for the command-line interface. This builds an Invocation
/// from the particulars of the operating-system interface, and then executes it.
pub fn main() -> anyhow::Result<()> {
pub fn main() -> Result<(), Error> {
env_logger::init();
// parse the command line into a vector of &str, failing if
// there are invalid utf-8 sequences.
let argv: Vec<String> = std::env::args_os()
.map(|oss| String::from_utf8(oss.into_vec()))
.collect::<Result<_, FromUtf8Error>>()?;
.collect::<Result<_, FromUtf8Error>>()
.map_err(|_| Error::for_arguments("arguments must be valid utf-8"))?;
let argv: Vec<&str> = argv.iter().map(|s| s.as_ref()).collect();
// parse the command line

View file

@ -56,7 +56,8 @@ fn invalid_option() -> Result<(), Box<dyn std::error::Error>> {
cmd.arg("--no-such-option");
cmd.assert()
.failure()
.stderr(predicate::str::contains("command line not recognized"));
.stderr(predicate::str::contains("command line not recognized"))
.code(predicate::eq(3));
Ok(())
}

View file

@ -1,6 +1,9 @@
use thiserror::Error;
#[derive(Debug, Error, Eq, PartialEq, Clone)]
#[non_exhaustive]
/// Errors returned from taskchampion operations
pub enum Error {
#[error("Task Database Error: {}", _0)]
DbError(String),
#[error("Task Database Error: {0}")]
Database(String),
}

View file

@ -40,6 +40,7 @@ mod taskdb;
mod utils;
mod workingset;
pub use errors::Error;
pub use replica::Replica;
pub use server::{Server, ServerConfig};
pub use storage::StorageConfig;

View file

@ -113,7 +113,7 @@ impl Replica {
// check that it already exists; this is a convenience check, as the task may already exist
// when this Create operation is finally sync'd with operations from other replicas
if self.taskdb.get_task(uuid)?.is_none() {
return Err(Error::DbError(format!("Task {} does not exist", uuid)).into());
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
self.taskdb.apply(Operation::Delete { uuid })?;
trace!("task {} deleted", uuid);

View file

@ -49,12 +49,12 @@ impl TaskDb {
Operation::Create { uuid } => {
// insert if the task does not already exist
if !txn.create_task(*uuid)? {
return Err(Error::DbError(format!("Task {} already exists", uuid)).into());
return Err(Error::Database(format!("Task {} already exists", uuid)).into());
}
}
Operation::Delete { ref uuid } => {
if !txn.delete_task(*uuid)? {
return Err(Error::DbError(format!("Task {} does not exist", uuid)).into());
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
}
Operation::Update {
@ -71,7 +71,7 @@ impl TaskDb {
};
txn.set_task(*uuid, task)?;
} else {
return Err(Error::DbError(format!("Task {} does not exist", uuid)).into());
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
}
}