use thiserror::Error;
#[derive(Error, Debug, Clone, PartialEq, Eq, Hash)]
pub enum TempsError {
#[error("Failed to parse time expression: {message}")]
ParseError {
message: String,
input: String,
position: Option<usize>,
},
#[error("Date calculation error: {message}")]
DateCalculationError {
message: String,
context: Option<String>,
},
#[error("Invalid date: year={year}, month={month}, day={day}")]
InvalidDate {
year: u16,
month: u8,
day: u8,
},
#[error("Invalid time: {hour:02}:{minute:02}:{second:02}")]
InvalidTime {
hour: u8,
minute: u8,
second: u8,
},
#[error("Invalid timezone offset: {hours:+03}:{minutes:02}")]
InvalidTimezoneOffset {
hours: i8,
minutes: u8,
},
#[error("Ambiguous local time: {message}")]
AmbiguousTime {
message: String,
},
#[error("Arithmetic overflow: {operation}")]
ArithmeticOverflow {
operation: String,
},
#[error("Unsupported operation: {operation}")]
UnsupportedOperation {
operation: String,
},
#[error("Backend error: {message}")]
BackendError {
message: String,
backend: String,
},
}
impl TempsError {
#[must_use]
pub fn parse_error(message: impl Into<String>, input: impl Into<String>) -> Self {
Self::ParseError {
message: message.into(),
input: input.into(),
position: None,
}
}
#[must_use]
pub fn parse_error_with_position(
message: impl Into<String>,
input: impl Into<String>,
position: usize,
) -> Self {
Self::ParseError {
message: message.into(),
input: input.into(),
position: Some(position),
}
}
#[must_use]
pub fn date_calculation(message: impl Into<String>) -> Self {
Self::DateCalculationError {
message: message.into(),
context: None,
}
}
#[must_use]
pub fn date_calculation_with_source(
message: impl Into<String>,
context: impl Into<String>,
) -> Self {
Self::DateCalculationError {
message: message.into(),
context: Some(context.into()),
}
}
#[must_use]
pub fn invalid_date(year: u16, month: u8, day: u8) -> Self {
Self::InvalidDate { year, month, day }
}
#[must_use]
pub fn invalid_time(hour: u8, minute: u8, second: u8) -> Self {
Self::InvalidTime {
hour,
minute,
second,
}
}
#[must_use]
pub fn invalid_timezone_offset(hours: i8, minutes: u8) -> Self {
Self::InvalidTimezoneOffset { hours, minutes }
}
#[must_use]
pub fn ambiguous_time(message: impl Into<String>) -> Self {
Self::AmbiguousTime {
message: message.into(),
}
}
#[must_use]
pub fn arithmetic_overflow(operation: impl Into<String>) -> Self {
Self::ArithmeticOverflow {
operation: operation.into(),
}
}
#[must_use]
pub fn unsupported_operation(operation: impl Into<String>) -> Self {
Self::UnsupportedOperation {
operation: operation.into(),
}
}
#[must_use]
pub fn backend_error(message: impl Into<String>, backend: impl Into<String>) -> Self {
Self::BackendError {
message: message.into(),
backend: backend.into(),
}
}
}
pub type Result<T> = std::result::Result<T, TempsError>;
#[must_use]
pub fn rich_errors_to_temps_error(
input: &str,
errors: Vec<chumsky::error::Rich<'_, char>>,
) -> TempsError {
use ariadne::{Color, Label, Report, ReportKind, Source};
if input.is_empty() {
return TempsError::parse_error_with_position(
"input is empty; expected a time expression like `now`, `in 5 minutes`, or an ISO date",
input,
0,
);
}
let position = errors.first().map(|e| e.span().start).unwrap_or(0);
let source_id: &str = "input";
let mut rendered = String::new();
for err in &errors {
let span = err.span();
let range = span.start..span.end.max(span.start + 1).min(input.len().max(1));
let mut buf = Vec::new();
let (headline, detail) = format_rich(err);
let report = Report::build(ReportKind::Error, (source_id, range.clone()))
.with_message(headline)
.with_label(
Label::new((source_id, range))
.with_message(detail)
.with_color(Color::Red),
)
.finish();
if report
.write((source_id, Source::from(input)), &mut buf)
.is_ok()
{
rendered.push_str(&String::from_utf8_lossy(&buf));
} else {
rendered.push_str(&err.to_string());
rendered.push('\n');
}
}
let message = if rendered.is_empty() {
"Failed to parse time expression".to_string()
} else {
rendered.trim_end().to_string()
};
TempsError::parse_error_with_position(message, input, position)
}
fn format_rich(err: &chumsky::error::Rich<'_, char>) -> (String, String) {
use chumsky::error::RichReason;
match err.reason() {
RichReason::Custom(msg) => ("invalid time expression".to_string(), msg.clone()),
_ => {
let found = match err.found() {
Some(c) => format!("`{}`", c.escape_default()),
None => "end of input".to_string(),
};
let mut seen = std::collections::BTreeSet::new();
let mut expected: Vec<String> = Vec::new();
for pat in err.expected() {
let rendered = pat.to_string();
if seen.insert(rendered.clone()) {
expected.push(rendered);
}
}
let detail = match expected.as_slice() {
[] => format!("unexpected {found}"),
[one] => format!("expected {one}, found {found}"),
many => {
let last = many.last().expect("non-empty");
let head = &many[..many.len() - 1];
format!(
"expected one of {} or {}, found {found}",
head.join(", "),
last
)
}
};
("could not parse time expression".to_string(), detail)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display() {
let err = TempsError::invalid_date(2024, 13, 32);
assert_eq!(err.to_string(), "Invalid date: year=2024, month=13, day=32");
let err = TempsError::invalid_time(25, 61, 61);
assert_eq!(err.to_string(), "Invalid time: 25:61:61");
let err = TempsError::parse_error("unexpected token", "in 5 minuts");
assert_eq!(
err.to_string(),
"Failed to parse time expression: unexpected token"
);
}
#[test]
fn test_error_creation_helpers() {
let err = TempsError::date_calculation("month out of range");
match err {
TempsError::DateCalculationError { message, context } => {
assert_eq!(message, "month out of range");
assert!(context.is_none());
}
_ => panic!("Wrong error type"),
}
let err = TempsError::backend_error("conversion failed", "chrono");
match err {
TempsError::BackendError { message, backend } => {
assert_eq!(message, "conversion failed");
assert_eq!(backend, "chrono");
}
_ => panic!("Wrong error type"),
}
}
}