use std::fmt;
use crate::name::InvalidName;
pub type Outcome = Result<(), Failure>;
#[derive(Debug)]
pub struct Failure {
message: String,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
status: RefusalStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RefusalStatus(u8);
const PANIC_STATUS: u8 = 101;
const FIRST_SHELL_STATUS: u8 = 126;
impl RefusalStatus {
pub const fn new(code: u8) -> Result<Self, InvalidRefusalStatus> {
let reason = match code {
0 => Reserved::Success,
2 => Reserved::UsageError,
PANIC_STATUS => Reserved::Panic,
FIRST_SHELL_STATUS..=u8::MAX => Reserved::Shell,
_ => return Ok(Self(code)),
};
Err(InvalidRefusalStatus { code, reason })
}
#[must_use]
pub const fn get(self) -> u8 {
self.0
}
}
impl Default for RefusalStatus {
fn default() -> Self {
Self(1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Reserved {
Success,
UsageError,
Panic,
Shell,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidRefusalStatus {
code: u8,
reason: Reserved,
}
impl fmt::Display for InvalidRefusalStatus {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let reason = match self.reason {
Reserved::Success => "0 means success",
Reserved::UsageError => "2 is what a usage error exits with",
Reserved::Panic => "101 is what a panic exits with",
Reserved::Shell => "126 and above are what a shell exits with",
};
write!(
formatter,
"{} cannot be a refusal's exit status; {reason}, so a refusal uses 1, 3 to 100, or 102 to 125",
self.code
)
}
}
impl std::error::Error for InvalidRefusalStatus {}
impl Failure {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
source: None,
status: RefusalStatus::default(),
}
}
#[must_use]
pub fn caused_by(mut self, cause: impl std::error::Error + Send + Sync + 'static) -> Self {
self.source = Some(Box::new(cause));
self
}
#[must_use]
pub const fn exiting_with(mut self, status: RefusalStatus) -> Self {
self.status = status;
self
}
#[must_use]
pub const fn status(&self) -> RefusalStatus {
self.status
}
#[must_use]
pub fn with_causes(&self) -> impl fmt::Display + '_ {
WithCauses(self)
}
}
struct WithCauses<'failure>(&'failure Failure);
impl fmt::Display for WithCauses<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0.message)?;
let mut cause = std::error::Error::source(self.0);
while let Some(link) = cause {
write!(formatter, ": {link}")?;
cause = link.source();
}
Ok(())
}
}
impl fmt::Display for Failure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for Failure {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_deref()
.map(|error| error as &(dyn std::error::Error + 'static))
}
}
impl From<InvalidName> for Failure {
fn from(error: InvalidName) -> Self {
Self::new(error.to_string())
}
}
#[cfg(test)]
mod tests {
use std::error::Error;
use super::{Failure, RefusalStatus};
use crate::name::Name;
fn status(code: u8) -> RefusalStatus {
RefusalStatus::new(code).expect("a test passes only statuses it knows are usable")
}
#[test]
fn a_plain_failure_exits_with_status_one() {
assert_eq!(Failure::new("refused").status().get(), 1);
}
#[test]
fn exiting_with_sets_the_status_and_leaves_the_message() {
let failure = Failure::new("3 tasks differ from the declared set").exiting_with(status(3));
assert_eq!(failure.status().get(), 3);
assert_eq!(failure.to_string(), "3 tasks differ from the declared set");
}
#[test]
fn the_outermost_failure_decides_the_status() {
let inner = Failure::new("the registry did not answer").exiting_with(status(4));
let outer = Failure::new("syncing failed").caused_by(inner);
assert_eq!(outer.status().get(), 1);
let inner = Failure::new("the registry did not answer").exiting_with(status(4));
let passed_on = Failure::new("syncing failed")
.exiting_with(inner.status())
.caused_by(inner);
assert_eq!(passed_on.status().get(), 4);
}
#[test]
fn zero_is_success_and_cannot_be_a_refusal_status() {
assert!(RefusalStatus::new(0).is_err());
}
#[test]
fn two_is_claps_usage_error_and_cannot_be_a_refusal_status() {
assert!(RefusalStatus::new(2).is_err());
}
#[test]
fn one_hundred_and_twenty_six_and_above_belong_to_the_shell() {
for code in [126, 127, 128, 130, 255] {
assert!(RefusalStatus::new(code).is_err(), "{code} must be refused");
}
}
#[test]
fn one_hundred_and_one_is_a_panic_and_cannot_be_a_refusal_status() {
assert!(RefusalStatus::new(101).is_err());
}
#[test]
fn one_and_three_to_one_hundred_and_twenty_five_but_one_hundred_and_one_are_refusal_statuses() {
let usable = std::iter::once(1).chain(3..=100).chain(102..=125);
for code in usable {
assert_eq!(RefusalStatus::new(code).map(RefusalStatus::get), Ok(code));
}
}
#[test]
fn a_refused_status_says_which_code_and_why() {
let reasons = [
(0, "0 means success"),
(2, "2 is what a usage error exits with"),
(101, "101 is what a panic exits with"),
(126, "126 and above are what a shell exits with"),
];
for (code, reason) in reasons {
let refused = RefusalStatus::new(code).map(RefusalStatus::get);
assert!(
refused
.as_ref()
.is_err_and(|error| error.to_string().contains(reason)),
"expected {code} to be refused because {reason}: {refused:?}"
);
}
}
#[test]
fn display_is_the_message() {
let failure = Failure::new(".rituals/lint already exists");
assert_eq!(failure.to_string(), ".rituals/lint already exists");
}
#[test]
fn source_is_absent_until_caused_by_is_called() {
let failure = Failure::new("something went wrong");
assert!(failure.source().is_none());
}
#[test]
fn source_is_the_cause_after_caused_by() {
#[derive(Debug)]
struct Cause;
impl std::fmt::Display for Cause {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("the underlying cause")
}
}
impl Error for Cause {}
let failure = Failure::new("writing failed").caused_by(Cause);
let source = failure.source();
assert!(source.is_some(), "a cause was attached");
if let Some(source) = source {
assert_eq!(source.to_string(), "the underlying cause");
}
}
#[test]
fn display_is_the_message_alone_whether_or_not_a_cause_is_attached() {
#[derive(Debug)]
struct Cause;
impl std::fmt::Display for Cause {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("the underlying cause")
}
}
impl Error for Cause {}
let caused = Failure::new("writing failed").caused_by(Cause);
assert_eq!(caused.to_string(), "writing failed");
let plain = Failure::new("writing failed");
assert_eq!(plain.to_string(), "writing failed");
}
#[test]
fn from_invalid_name_keeps_the_message_verbatim() {
assert!(Name::new("../evil").is_err(), "`../evil` must be refused");
if let Err(invalid_name) = Name::new("../evil") {
let expected = invalid_name.to_string();
let failure = Failure::from(invalid_name);
assert_eq!(failure.to_string(), expected);
}
}
}