use rustledger_core::NaiveDate;
use rustledger_parser::{Span, Spanned};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorCode {
AccountNotOpen,
AccountAlreadyOpen,
AccountClosed,
AccountCloseNotEmpty,
InvalidAccountName,
BalanceAssertionFailed,
BalanceToleranceExceeded,
PadWithoutBalance,
MultiplePadForBalance,
TransactionUnbalanced,
MultipleInterpolation,
NoPostings,
SinglePosting,
NoMatchingLot,
InsufficientUnits,
AmbiguousLotMatch,
NegativeCost,
UndeclaredCurrency,
CurrencyNotAllowed,
InvalidPrecisionMetadata,
UnknownOption,
InvalidOptionValue,
DuplicateOption,
DocumentNotFound,
DateOutOfOrder,
FutureDate,
}
impl ErrorCode {
pub const ALL: &'static [Self] = &[
Self::AccountNotOpen,
Self::AccountAlreadyOpen,
Self::AccountClosed,
Self::AccountCloseNotEmpty,
Self::InvalidAccountName,
Self::BalanceAssertionFailed,
Self::BalanceToleranceExceeded,
Self::PadWithoutBalance,
Self::MultiplePadForBalance,
Self::TransactionUnbalanced,
Self::MultipleInterpolation,
Self::NoPostings,
Self::SinglePosting,
Self::NoMatchingLot,
Self::InsufficientUnits,
Self::AmbiguousLotMatch,
Self::NegativeCost,
Self::UndeclaredCurrency,
Self::CurrencyNotAllowed,
Self::InvalidPrecisionMetadata,
Self::UnknownOption,
Self::InvalidOptionValue,
Self::DuplicateOption,
Self::DocumentNotFound,
Self::DateOutOfOrder,
Self::FutureDate,
];
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::AccountNotOpen => "E1001",
Self::AccountAlreadyOpen => "E1002",
Self::AccountClosed => "E1003",
Self::AccountCloseNotEmpty => "E1004",
Self::InvalidAccountName => "E1005",
Self::BalanceAssertionFailed => "E2001",
Self::BalanceToleranceExceeded => "E2002",
Self::PadWithoutBalance => "E2003",
Self::MultiplePadForBalance => "E2004",
Self::TransactionUnbalanced => "E3001",
Self::MultipleInterpolation => "E3002",
Self::NoPostings => "E3003",
Self::SinglePosting => "E3004",
Self::NoMatchingLot => "E4001",
Self::InsufficientUnits => "E4002",
Self::AmbiguousLotMatch => "E4003",
Self::NegativeCost => "E4005",
Self::UndeclaredCurrency => "E5001",
Self::CurrencyNotAllowed => "E5002",
Self::InvalidPrecisionMetadata => "E5003",
Self::UnknownOption => "E7001",
Self::InvalidOptionValue => "E7002",
Self::DuplicateOption => "E7003",
Self::DocumentNotFound => "E8001",
Self::DateOutOfOrder => "E10001",
Self::FutureDate => "E10002",
}
}
#[must_use]
pub const fn is_warning(&self) -> bool {
matches!(
self,
Self::FutureDate
| Self::SinglePosting
| Self::AccountCloseNotEmpty
| Self::DateOutOfOrder
| Self::InvalidPrecisionMetadata
)
}
#[must_use]
pub const fn is_info(&self) -> bool {
matches!(self, Self::DateOutOfOrder)
}
#[must_use]
pub const fn is_advisory_only(&self) -> bool {
matches!(self, Self::AccountCloseNotEmpty)
}
#[must_use]
pub const fn severity(&self) -> Severity {
if self.is_info() {
Severity::Info
} else if self.is_warning() {
Severity::Warning
} else {
Severity::Error
}
}
#[must_use]
pub const fn is_parse_phase(&self) -> bool {
matches!(self, Self::InvalidAccountName)
}
}
#[must_use]
pub fn is_advisory_only_code(code: &str) -> bool {
code == ErrorCode::AccountCloseNotEmpty.code()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Severity {
Error,
Warning,
Info,
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.code())
}
}
#[derive(Debug, Clone, Error)]
#[error("{message}")]
#[non_exhaustive]
pub struct ValidationError {
pub code: ErrorCode,
pub message: String,
pub date: NaiveDate,
pub context: Option<String>,
pub note: Option<String>,
pub span: Option<Span>,
pub file_id: Option<u16>,
}
impl ValidationError {
#[must_use]
pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
Self {
code,
message: message.into(),
date,
context: None,
note: None,
span: None,
file_id: None,
}
}
#[must_use]
pub fn with_location<T>(
code: ErrorCode,
message: impl Into<String>,
date: NaiveDate,
spanned: &Spanned<T>,
) -> Self {
Self {
code,
message: message.into(),
date,
context: None,
note: None,
span: Some(spanned.span),
file_id: Some(spanned.file_id),
}
}
#[must_use]
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
#[must_use]
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
#[must_use]
pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
self.span = Some(spanned.span);
self.file_id = Some(spanned.file_id);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_codes_documented_in_spec() {
let spec = include_str!("../../../spec/core/validation.md");
let missing: Vec<&str> = ErrorCode::ALL
.iter()
.map(ErrorCode::code)
.filter(|code| !spec.contains(&format!("`{code}`")))
.collect();
assert!(
missing.is_empty(),
"error codes missing from spec/core/validation.md: {missing:?}"
);
}
#[test]
fn all_lists_distinct_codes() {
let mut codes: Vec<&str> = ErrorCode::ALL.iter().map(ErrorCode::code).collect();
let n = codes.len();
codes.sort_unstable();
codes.dedup();
assert_eq!(codes.len(), n, "duplicate code in ErrorCode::ALL");
}
#[test]
fn invalid_account_name_is_parse_phase() {
assert!(ErrorCode::InvalidAccountName.is_parse_phase());
}
#[test]
fn other_account_errors_are_validate_phase() {
assert!(!ErrorCode::AccountNotOpen.is_parse_phase());
assert!(!ErrorCode::AccountAlreadyOpen.is_parse_phase());
assert!(!ErrorCode::AccountClosed.is_parse_phase());
}
#[test]
fn non_account_errors_are_validate_phase() {
assert!(!ErrorCode::TransactionUnbalanced.is_parse_phase());
assert!(!ErrorCode::BalanceAssertionFailed.is_parse_phase());
assert!(!ErrorCode::UnknownOption.is_parse_phase());
}
}