temps_core/error.rs
1//! Error types for the temps library.
2//!
3//! This module defines the error types used throughout the temps ecosystem.
4//! All parsing and date calculation operations return `Result<T, TempsError>`.
5//!
6//! # Error Categories
7//!
8//! - **Parse Errors**: When input cannot be parsed as a valid time expression
9//! - **Date Calculation Errors**: When date arithmetic results in invalid dates
10//! - **Invalid Component Errors**: When date/time components are out of range
11//! - **Backend Errors**: When the underlying datetime library reports an error
12//!
13//! # Examples
14//!
15//! ```
16//! use temps_core::{parse, Language, TempsError};
17//!
18//! // Parse error example
19//! let result = parse("invalid input", Language::English);
20//! match result {
21//! Err(TempsError::ParseError { message, input, position }) => {
22//! println!("Parse failed: {}", message);
23//! }
24//! _ => {}
25//! }
26//! ```
27
28use thiserror::Error;
29
30/// The main error type for the temps library.
31///
32/// This enum represents all possible errors that can occur during
33/// parsing and time calculation operations.
34#[derive(Error, Debug, Clone, PartialEq, Eq, Hash)]
35pub enum TempsError {
36 /// Error that occurs during parsing of time expressions.
37 ///
38 /// This error is returned when the input string cannot be parsed
39 /// as a valid time expression in the specified language.
40 ///
41 /// # Example
42 ///
43 /// ```
44 /// use temps_core::TempsError;
45 ///
46 /// let err = TempsError::parse_error("Unrecognized time unit", "in 5 blargs");
47 /// ```
48 #[error("Failed to parse time expression: {message}")]
49 ParseError {
50 /// The specific parsing error message
51 message: String,
52 /// The input that failed to parse
53 input: String,
54 /// Optional position in the input where parsing failed
55 position: Option<usize>,
56 },
57
58 /// Error that occurs during date/time calculations.
59 ///
60 /// This error is returned when date arithmetic operations fail,
61 /// such as when adding months to January 31st would result in
62 /// February 31st (which doesn't exist).
63 ///
64 /// # Example
65 ///
66 /// ```
67 /// use temps_core::TempsError;
68 ///
69 /// let err = TempsError::date_calculation("Month overflow");
70 /// ```
71 #[error("Date calculation error: {message}")]
72 DateCalculationError {
73 /// The specific calculation error message
74 message: String,
75 /// Optional context about what caused the error
76 context: Option<String>,
77 },
78
79 /// Error for invalid date components
80 #[error("Invalid date: year={year}, month={month}, day={day}")]
81 InvalidDate {
82 /// The year component
83 year: u16,
84 /// The month component (1-12)
85 month: u8,
86 /// The day component (1-31)
87 day: u8,
88 },
89
90 /// Error for invalid time components
91 #[error("Invalid time: {hour:02}:{minute:02}:{second:02}")]
92 InvalidTime {
93 /// The hour component (0-23)
94 hour: u8,
95 /// The minute component (0-59)
96 minute: u8,
97 /// The second component (0-59)
98 second: u8,
99 },
100
101 /// Error for invalid timezone offset
102 #[error("{}", temps_core_format_offset(*total_minutes))]
103 InvalidTimezoneOffset {
104 /// The offset from UTC in minutes (-720 to +840)
105 total_minutes: i16,
106 },
107
108 /// Error for ambiguous local time (e.g., during DST transitions)
109 #[error("Ambiguous local time: {message}")]
110 AmbiguousTime {
111 /// Description of the ambiguity
112 message: String,
113 },
114
115 /// Error for arithmetic overflow in date calculations
116 #[error("Arithmetic overflow: {operation}")]
117 ArithmeticOverflow {
118 /// The operation that caused the overflow
119 operation: String,
120 },
121
122 /// Error for unsupported operations
123 #[error("Unsupported operation: {operation}")]
124 UnsupportedOperation {
125 /// Description of the unsupported operation
126 operation: String,
127 },
128
129 /// Error from the underlying datetime backend (chrono, jiff, etc.)
130 #[error("Backend error: {message}")]
131 BackendError {
132 /// The error message from the backend
133 message: String,
134 /// The backend that produced the error
135 backend: String,
136 },
137}
138
139impl TempsError {
140 /// Creates a new parse error without position information.
141 ///
142 /// Use this when you know parsing failed but don't have a specific
143 /// position in the input where the error occurred.
144 ///
145 /// # Arguments
146 ///
147 /// * `message` - Description of what went wrong
148 /// * `input` - The input string that failed to parse
149 ///
150 /// # Example
151 ///
152 /// ```
153 /// use temps_core::TempsError;
154 ///
155 /// let err = TempsError::parse_error(
156 /// "Expected time unit",
157 /// "in 5"
158 /// );
159 /// ```
160 #[must_use]
161 pub fn parse_error(message: impl Into<String>, input: impl Into<String>) -> Self {
162 Self::ParseError {
163 message: message.into(),
164 input: input.into(),
165 position: None,
166 }
167 }
168
169 /// Creates a new parse error with position information.
170 ///
171 /// Use this when you know exactly where in the input the parse error occurred.
172 ///
173 /// # Arguments
174 ///
175 /// * `message` - Description of what went wrong
176 /// * `input` - The input string that failed to parse
177 /// * `position` - Character position where parsing failed
178 ///
179 /// # Example
180 ///
181 /// ```
182 /// use temps_core::TempsError;
183 ///
184 /// let err = TempsError::parse_error_with_position(
185 /// "Unexpected character",
186 /// "in 5 minuts",
187 /// 9 // Points to the 't' in "minuts"
188 /// );
189 /// ```
190 #[must_use]
191 pub fn parse_error_with_position(
192 message: impl Into<String>,
193 input: impl Into<String>,
194 position: usize,
195 ) -> Self {
196 Self::ParseError {
197 message: message.into(),
198 input: input.into(),
199 position: Some(position),
200 }
201 }
202
203 /// Creates a new date calculation error.
204 ///
205 /// Use this for errors that occur during date arithmetic operations.
206 ///
207 /// # Example
208 ///
209 /// ```
210 /// use temps_core::TempsError;
211 ///
212 /// let err = TempsError::date_calculation(
213 /// "Cannot subtract 13 months from January"
214 /// );
215 /// ```
216 #[must_use]
217 pub fn date_calculation(message: impl Into<String>) -> Self {
218 Self::DateCalculationError {
219 message: message.into(),
220 context: None,
221 }
222 }
223
224 /// Creates a new date calculation error with additional context.
225 ///
226 /// Use this when you want to include information about what caused
227 /// the calculation to fail (e.g., an error from the backend library).
228 ///
229 /// # Example
230 ///
231 /// ```
232 /// use temps_core::TempsError;
233 ///
234 /// let err = TempsError::date_calculation_with_source(
235 /// "Failed to add months",
236 /// "chronos error: date out of range"
237 /// );
238 /// ```
239 #[must_use]
240 pub fn date_calculation_with_source(
241 message: impl Into<String>,
242 context: impl Into<String>,
243 ) -> Self {
244 Self::DateCalculationError {
245 message: message.into(),
246 context: Some(context.into()),
247 }
248 }
249
250 /// Creates an invalid date error
251 #[must_use]
252 pub fn invalid_date(year: u16, month: u8, day: u8) -> Self {
253 Self::InvalidDate { year, month, day }
254 }
255
256 /// Creates an invalid time error
257 #[must_use]
258 pub fn invalid_time(hour: u8, minute: u8, second: u8) -> Self {
259 Self::InvalidTime {
260 hour,
261 minute,
262 second,
263 }
264 }
265
266 /// Creates an invalid timezone offset error
267 #[must_use]
268 pub fn invalid_timezone_offset(total_minutes: i16) -> Self {
269 Self::InvalidTimezoneOffset { total_minutes }
270 }
271
272 /// Creates an ambiguous time error
273 #[must_use]
274 pub fn ambiguous_time(message: impl Into<String>) -> Self {
275 Self::AmbiguousTime {
276 message: message.into(),
277 }
278 }
279
280 /// Creates an arithmetic overflow error
281 #[must_use]
282 pub fn arithmetic_overflow(operation: impl Into<String>) -> Self {
283 Self::ArithmeticOverflow {
284 operation: operation.into(),
285 }
286 }
287
288 /// Creates an unsupported operation error
289 #[must_use]
290 pub fn unsupported_operation(operation: impl Into<String>) -> Self {
291 Self::UnsupportedOperation {
292 operation: operation.into(),
293 }
294 }
295
296 /// Creates a backend error
297 #[must_use]
298 pub fn backend_error(message: impl Into<String>, backend: impl Into<String>) -> Self {
299 Self::BackendError {
300 message: message.into(),
301 backend: backend.into(),
302 }
303 }
304}
305
306/// Result type alias for temps operations.
307///
308/// All parsing and time calculation operations in the temps library
309/// return this result type.
310///
311/// # Example
312///
313/// ```
314/// use temps_core::Result;
315///
316/// fn parse_time(input: &str) -> Result<String> {
317/// // Implementation
318/// Ok("parsed".to_string())
319/// }
320/// ```
321pub type Result<T> = std::result::Result<T, TempsError>;
322
323/// Convert a collection of chumsky parser errors into a [`TempsError`]
324/// and an ariadne-rendered diagnostic string.
325///
326/// The first error's span is used for the position field. The full
327/// rendered report (with source context) is folded into the error's
328/// message so callers that simply display the error still get a useful,
329/// human-readable diagnostic.
330/// The parsers run over tokens, so their spans are the lexer's BYTE offsets
331/// into the original source — see [`crate::lexer::lex`]. That is exactly what
332/// the byte-to-character translation below needs, and it is why umlaut input
333/// still gets a caret in the right place.
334#[must_use]
335pub fn rich_errors_to_temps_error(
336 input: &str,
337 errors: Vec<chumsky::error::Rich<'_, crate::lexer::Token<'_>>>,
338) -> TempsError {
339 use ariadne::{Color, Config, Label, Report, ReportKind, Source};
340
341 // Token spans are BYTE offsets, but ariadne's `Source` indexes by
342 // CHARACTER. Feeding one to the other mislocates the caret on any
343 // non-ASCII input and silently drops the label once the byte offset runs
344 // past the character count. Translate up front.
345 let byte_to_char = |byte: usize| -> usize {
346 input
347 .char_indices()
348 .position(|(b, _)| b >= byte)
349 .unwrap_or_else(|| input.chars().count())
350 };
351 let char_len = input.chars().count();
352
353 if input.is_empty() {
354 return TempsError::parse_error_with_position(
355 "input is empty; expected a time expression like `now`, `in 5 minutes`, or an ISO date",
356 input,
357 0,
358 );
359 }
360
361 let position = errors
362 .first()
363 .map(|e| byte_to_char(e.span().start))
364 .unwrap_or(0);
365
366 let source_id: &str = "input";
367 let mut rendered = String::new();
368 for err in &errors {
369 let span = err.span();
370 let start = byte_to_char(span.start);
371 let end = byte_to_char(span.end).max(start + 1).min(char_len.max(1));
372 let range = start..end;
373 let mut buf = Vec::new();
374 let (headline, detail) = format_rich(err);
375 let report = Report::build(ReportKind::Error, (source_id, range.clone()))
376 .with_config(Config::default().with_color(false))
377 .with_message(headline)
378 .with_label(
379 Label::new((source_id, range))
380 .with_message(detail)
381 .with_color(Color::Red),
382 )
383 .finish();
384
385 if report
386 .write((source_id, Source::from(input)), &mut buf)
387 .is_ok()
388 {
389 rendered.push_str(&String::from_utf8_lossy(&buf));
390 } else {
391 rendered.push_str(&err.to_string());
392 rendered.push('\n');
393 }
394 }
395
396 let message = if rendered.is_empty() {
397 "Failed to parse time expression".to_string()
398 } else {
399 rendered.trim_end().to_string()
400 };
401
402 TempsError::parse_error_with_position(message, input, position)
403}
404
405/// Render a chumsky [`Rich`](chumsky::error::Rich) error as a `(headline, detail)`
406/// pair suitable for an ariadne report.
407fn format_rich(err: &chumsky::error::Rich<'_, crate::lexer::Token<'_>>) -> (String, String) {
408 use crate::lexer::Token;
409 use chumsky::error::RichReason;
410
411 match err.reason() {
412 RichReason::Custom(msg) => ("invalid time expression".to_string(), msg.clone()),
413 _ => {
414 // `Token`'s `Display` already spells `Space` out as "whitespace",
415 // which reads badly inside backticks.
416 let found = match err.found() {
417 Some(Token::Space) => "whitespace".to_string(),
418 Some(token) => format!("`{token}`"),
419 None => "end of input".to_string(),
420 };
421
422 let mut seen = std::collections::BTreeSet::new();
423 let mut expected: Vec<String> = Vec::new();
424 for pat in err.expected() {
425 let rendered = pat.to_string();
426 if seen.insert(rendered.clone()) {
427 expected.push(rendered);
428 }
429 }
430
431 let detail = match expected.as_slice() {
432 [] => format!("unexpected {found}"),
433 [one] => format!("expected {one}, found {found}"),
434 many => {
435 let last = many.last().expect("non-empty");
436 let head = &many[..many.len() - 1];
437 format!(
438 "expected one of {} or {}, found {found}",
439 head.join(", "),
440 last
441 )
442 }
443 };
444
445 ("could not parse time expression".to_string(), detail)
446 }
447 }
448}
449
450/// Render an offset for [`TempsError::InvalidTimezoneOffset`]'s `Display`.
451fn temps_core_format_offset(total_minutes: i16) -> String {
452 crate::errors::format_invalid_timezone_offset(total_minutes)
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn test_error_display() {
461 let err = TempsError::invalid_date(2024, 13, 32);
462 assert_eq!(err.to_string(), "Invalid date: year=2024, month=13, day=32");
463
464 let err = TempsError::invalid_time(25, 61, 61);
465 assert_eq!(err.to_string(), "Invalid time: 25:61:61");
466
467 let err = TempsError::parse_error("unexpected token", "in 5 minuts");
468 assert_eq!(
469 err.to_string(),
470 "Failed to parse time expression: unexpected token"
471 );
472 }
473
474 #[test]
475 fn test_error_creation_helpers() {
476 let err = TempsError::date_calculation("month out of range");
477 match err {
478 TempsError::DateCalculationError { message, context } => {
479 assert_eq!(message, "month out of range");
480 assert!(context.is_none());
481 }
482 _ => panic!("Wrong error type"),
483 }
484
485 let err = TempsError::backend_error("conversion failed", "chrono");
486 match err {
487 TempsError::BackendError { message, backend } => {
488 assert_eq!(message, "conversion failed");
489 assert_eq!(backend, "chrono");
490 }
491 _ => panic!("Wrong error type"),
492 }
493 }
494
495 /// The lexer's spans are byte offsets, ariadne indexes characters. An
496 /// umlaut before the error site is what tells the two apart.
497 #[test]
498 fn parse_error_position_is_a_character_offset() {
499 use crate::common::{ParserError, TokenInput, space, token_stream, word_ci};
500 use crate::lexer::lex;
501 use chumsky::prelude::*;
502
503 fn expects_zwei<'t, 's: 't, I>() -> impl Parser<'t, I, (), ParserError<'t, 's>> + Clone
504 where
505 I: TokenInput<'t, 's>,
506 {
507 word_ci("in")
508 .then_ignore(space())
509 .then_ignore(word_ci("zwei"))
510 .ignored()
511 }
512
513 let input = "in fünf";
514 let tokens = lex(input);
515 let errors = expects_zwei()
516 .then_ignore(end())
517 .parse(token_stream(input, &tokens))
518 .into_result()
519 .expect_err("`fünf` is not `zwei`");
520
521 match rich_errors_to_temps_error(input, errors) {
522 TempsError::ParseError {
523 message, position, ..
524 } => {
525 // Byte offset 3, and character offset 3 too — but the label
526 // ariadne draws spans `fünf`, which is 5 bytes and 4 chars.
527 assert_eq!(position, Some(3));
528 assert!(message.contains("fünf"), "{message}");
529 assert!(message.contains("zwei"), "{message}");
530 }
531 other => panic!("expected a parse error, got {other:?}"),
532 }
533 }
534
535 #[test]
536 fn empty_input_gets_a_dedicated_message() {
537 let err = rich_errors_to_temps_error("", Vec::new());
538 match err {
539 TempsError::ParseError {
540 message, position, ..
541 } => {
542 assert_eq!(position, Some(0));
543 assert!(message.contains("input is empty"), "{message}");
544 }
545 other => panic!("expected a parse error, got {other:?}"),
546 }
547 }
548}