Skip to main content

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}