#![cfg_attr(not(feature = "cli"), allow(dead_code))]
pub const SUCCESS: u8 = 0;
pub const FAILURE: u8 = 1;
pub const CHANGES_DETECTED: u8 = 2;
pub const NO_MATCHES: u8 = 3;
#[derive(Debug)]
pub struct NoMatchError {
pub msg: String,
}
impl std::fmt::Display for NoMatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for NoMatchError {}
pub fn is_no_match(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<NoMatchError>().is_some())
}
#[derive(Debug)]
pub struct AmbiguousError {
pub msg: String,
}
impl std::fmt::Display for AmbiguousError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for AmbiguousError {}
pub fn is_ambiguous(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<AmbiguousError>().is_some())
}
#[derive(Debug)]
pub struct InvalidInputError {
pub msg: String,
}
impl std::fmt::Display for InvalidInputError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for InvalidInputError {}
pub fn is_invalid_input(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<InvalidInputError>().is_some())
}
pub fn is_io_not_found(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound)
})
}
#[derive(Debug)]
pub struct AlreadyExistsError {
pub msg: String,
}
impl std::fmt::Display for AlreadyExistsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for AlreadyExistsError {}
pub fn is_already_exists(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<AlreadyExistsError>().is_some())
}
#[derive(Debug)]
pub struct TypeErrorError {
pub msg: String,
}
impl std::fmt::Display for TypeErrorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for TypeErrorError {}
pub fn is_type_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<TypeErrorError>().is_some())
}
#[derive(Debug)]
pub struct ConflictsError {
pub msg: String,
}
impl std::fmt::Display for ConflictsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for ConflictsError {}
pub fn is_conflicts(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<ConflictsError>().is_some())
}
#[derive(Debug)]
pub struct ParseErrorError {
pub msg: String,
}
impl std::fmt::Display for ParseErrorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for ParseErrorError {}
pub fn is_parse_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<ParseErrorError>().is_some())
}
#[derive(Debug)]
pub struct ChangesDetectedError {
pub msg: String,
}
impl std::fmt::Display for ChangesDetectedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for ChangesDetectedError {}
pub fn is_changes_detected(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<ChangesDetectedError>().is_some())
}
#[derive(Debug)]
pub struct FormatFailedError {
pub msg: String,
}
impl std::fmt::Display for FormatFailedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for FormatFailedError {}
pub fn is_format_failed(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.downcast_ref::<FormatFailedError>().is_some())
}
pub fn classify_typed_error(err: &anyhow::Error) -> Option<(&'static str, u8)> {
if is_no_match(err) {
Some(("no_matches", NO_MATCHES))
} else if is_ambiguous(err) {
Some(("ambiguous", AMBIGUOUS))
} else if is_invalid_input(err) {
Some(("invalid_input", FAILURE))
} else if is_io_not_found(err) {
Some(("not_found", FAILURE))
} else if is_already_exists(err) {
Some(("already_exists", FAILURE))
} else if is_type_error(err) {
Some(("type_error", FAILURE))
} else if is_conflicts(err) {
Some(("conflicts", CONFLICTS))
} else if is_parse_error(err) {
Some(("parse_error", PARSE_ERROR))
} else if is_changes_detected(err) {
Some(("changes_detected", CHANGES_DETECTED))
} else if is_format_failed(err) {
Some(("format_failed", FAILURE))
} else {
None
}
}
pub const PARSE_ERROR: u8 = 4;
pub const AMBIGUOUS: u8 = 5;
pub const VALIDATION_FAILED: u8 = 6;
pub const ROLLBACK: u8 = 7;
pub const CONFLICTS: u8 = 8;
pub const OPERATION_FAILED: u8 = 9;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_typed_error_maps_format_failed() {
let err: anyhow::Error = FormatFailedError {
msg: "format command failed".into(),
}
.into();
let wrapped = err.context("files were written but formatting failed");
assert_eq!(
classify_typed_error(&wrapped),
Some(("format_failed", FAILURE))
);
}
#[test]
fn classify_typed_error_maps_all_kinds() {
let cases: Vec<(anyhow::Error, &str, u8)> = vec![
(
NoMatchError { msg: "none".into() }.into(),
"no_matches",
NO_MATCHES,
),
(
AmbiguousError { msg: "many".into() }.into(),
"ambiguous",
AMBIGUOUS,
),
(
InvalidInputError { msg: "bad".into() }.into(),
"invalid_input",
FAILURE,
),
(
std::io::Error::new(std::io::ErrorKind::NotFound, "gone").into(),
"not_found",
FAILURE,
),
(
AlreadyExistsError {
msg: "exists".into(),
}
.into(),
"already_exists",
FAILURE,
),
(
TypeErrorError { msg: "type".into() }.into(),
"type_error",
FAILURE,
),
(
ConflictsError {
msg: "conflict".into(),
}
.into(),
"conflicts",
CONFLICTS,
),
(
ParseErrorError {
msg: "parse".into(),
}
.into(),
"parse_error",
PARSE_ERROR,
),
(
ChangesDetectedError { msg: "diff".into() }.into(),
"changes_detected",
CHANGES_DETECTED,
),
(
FormatFailedError { msg: "fmt".into() }.into(),
"format_failed",
FAILURE,
),
];
for (err, kind, code) in cases {
assert_eq!(
classify_typed_error(&err),
Some((kind, code)),
"kind={kind}"
);
}
}
#[test]
fn classify_typed_error_none_for_plain() {
let err = anyhow::anyhow!("plain");
assert_eq!(classify_typed_error(&err), None);
}
#[test]
fn exit_code_values() {
assert_eq!(SUCCESS, 0);
assert_eq!(FAILURE, 1);
assert_eq!(CHANGES_DETECTED, 2);
assert_eq!(NO_MATCHES, 3);
assert_eq!(PARSE_ERROR, 4);
assert_eq!(AMBIGUOUS, 5);
assert_eq!(VALIDATION_FAILED, 6);
assert_eq!(ROLLBACK, 7);
assert_eq!(CONFLICTS, 8);
assert_eq!(OPERATION_FAILED, 9);
}
#[test]
fn no_match_error_downcast() {
let err: anyhow::Error = NoMatchError {
msg: "selector 'x' matched nothing".to_string(),
}
.into();
assert!(
err.downcast_ref::<NoMatchError>().is_some(),
"NoMatchError should be downcastable from anyhow::Error"
);
}
#[test]
fn no_match_error_display() {
let err = NoMatchError {
msg: "test message".to_string(),
};
assert_eq!(err.to_string(), "test message");
}
#[test]
fn non_no_match_error_not_downcast() {
let err = anyhow::anyhow!("some other error");
assert!(
err.downcast_ref::<NoMatchError>().is_none(),
"plain anyhow error should not downcast to NoMatchError"
);
}
#[test]
fn is_no_match_finds_wrapped_error() {
let err: anyhow::Error = NoMatchError {
msg: "selector matched nothing".to_string(),
}
.into();
let wrapped = err.context("operation 1 (doc.update) failed");
assert!(
is_no_match(&wrapped),
"is_no_match should find NoMatchError through .context() wrapper"
);
}
#[test]
fn is_no_match_rejects_wrapped_non_no_match() {
let err = anyhow::anyhow!("some other error").context("operation 1 failed");
assert!(
!is_no_match(&err),
"is_no_match should return false for non-NoMatchError in chain"
);
}
#[test]
fn is_ambiguous_finds_wrapped_error() {
let err: anyhow::Error = AmbiguousError {
msg: "ambiguous match: pattern \"a\" matches 2 times".to_string(),
}
.into();
let wrapped = err.context("operation 1 (replace) failed");
assert!(
is_ambiguous(&wrapped),
"is_ambiguous should find AmbiguousError through .context() wrapper"
);
assert!(!is_no_match(&wrapped));
}
#[test]
fn is_ambiguous_rejects_plain_error() {
let err = anyhow::anyhow!("some other error").context("operation 1 failed");
assert!(!is_ambiguous(&err));
}
#[test]
fn is_invalid_input_finds_wrapped_error() {
let err: anyhow::Error = InvalidInputError {
msg: "path rejected by workspace guard: escapes".to_string(),
}
.into();
let wrapped = err.context("create failed");
assert!(is_invalid_input(&wrapped));
assert!(!is_no_match(&wrapped));
assert!(!is_ambiguous(&wrapped));
}
#[test]
fn exit_codes_are_unique() {
let codes = [
SUCCESS,
FAILURE,
CHANGES_DETECTED,
NO_MATCHES,
PARSE_ERROR,
AMBIGUOUS,
VALIDATION_FAILED,
ROLLBACK,
CONFLICTS,
OPERATION_FAILED,
];
let mut seen = std::collections::HashSet::new();
for code in codes {
assert!(seen.insert(code), "duplicate exit code: {code}");
}
}
#[test]
fn is_io_not_found_preserves_with_context_chain() {
use anyhow::Context;
let err = std::fs::read_to_string("/tmp/patchloom-definitely-missing-xyz-99999")
.with_context(|| "failed to read path");
let err = err.unwrap_err();
assert!(
is_io_not_found(&err),
"with_context should keep NotFound in chain: {err:#}"
);
}
}