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("Invalid timezone offset: {hours:+03}:{minutes:02}")]
103    InvalidTimezoneOffset {
104        /// The hour offset (-12 to +14)
105        hours: i8,
106        /// The minute offset (0-59)
107        minutes: u8,
108    },
109
110    /// Error for ambiguous local time (e.g., during DST transitions)
111    #[error("Ambiguous local time: {message}")]
112    AmbiguousTime {
113        /// Description of the ambiguity
114        message: String,
115    },
116
117    /// Error for arithmetic overflow in date calculations
118    #[error("Arithmetic overflow: {operation}")]
119    ArithmeticOverflow {
120        /// The operation that caused the overflow
121        operation: String,
122    },
123
124    /// Error for unsupported operations
125    #[error("Unsupported operation: {operation}")]
126    UnsupportedOperation {
127        /// Description of the unsupported operation
128        operation: String,
129    },
130
131    /// Error from the underlying datetime backend (chrono, jiff, etc.)
132    #[error("Backend error: {message}")]
133    BackendError {
134        /// The error message from the backend
135        message: String,
136        /// The backend that produced the error
137        backend: String,
138    },
139}
140
141impl TempsError {
142    /// Creates a new parse error without position information.
143    ///
144    /// Use this when you know parsing failed but don't have a specific
145    /// position in the input where the error occurred.
146    ///
147    /// # Arguments
148    ///
149    /// * `message` - Description of what went wrong
150    /// * `input` - The input string that failed to parse
151    ///
152    /// # Example
153    ///
154    /// ```
155    /// use temps_core::TempsError;
156    ///
157    /// let err = TempsError::parse_error(
158    ///     "Expected time unit",
159    ///     "in 5"
160    /// );
161    /// ```
162    #[must_use]
163    pub fn parse_error(message: impl Into<String>, input: impl Into<String>) -> Self {
164        Self::ParseError {
165            message: message.into(),
166            input: input.into(),
167            position: None,
168        }
169    }
170
171    /// Creates a new parse error with position information.
172    ///
173    /// Use this when you know exactly where in the input the parse error occurred.
174    ///
175    /// # Arguments
176    ///
177    /// * `message` - Description of what went wrong
178    /// * `input` - The input string that failed to parse
179    /// * `position` - Character position where parsing failed
180    ///
181    /// # Example
182    ///
183    /// ```
184    /// use temps_core::TempsError;
185    ///
186    /// let err = TempsError::parse_error_with_position(
187    ///     "Unexpected character",
188    ///     "in 5 minuts",
189    ///     9  // Points to the 't' in "minuts"
190    /// );
191    /// ```
192    #[must_use]
193    pub fn parse_error_with_position(
194        message: impl Into<String>,
195        input: impl Into<String>,
196        position: usize,
197    ) -> Self {
198        Self::ParseError {
199            message: message.into(),
200            input: input.into(),
201            position: Some(position),
202        }
203    }
204
205    /// Creates a new date calculation error.
206    ///
207    /// Use this for errors that occur during date arithmetic operations.
208    ///
209    /// # Example
210    ///
211    /// ```
212    /// use temps_core::TempsError;
213    ///
214    /// let err = TempsError::date_calculation(
215    ///     "Cannot subtract 13 months from January"
216    /// );
217    /// ```
218    #[must_use]
219    pub fn date_calculation(message: impl Into<String>) -> Self {
220        Self::DateCalculationError {
221            message: message.into(),
222            context: None,
223        }
224    }
225
226    /// Creates a new date calculation error with additional context.
227    ///
228    /// Use this when you want to include information about what caused
229    /// the calculation to fail (e.g., an error from the backend library).
230    ///
231    /// # Example
232    ///
233    /// ```
234    /// use temps_core::TempsError;
235    ///
236    /// let err = TempsError::date_calculation_with_source(
237    ///     "Failed to add months",
238    ///     "chronos error: date out of range"
239    /// );
240    /// ```
241    #[must_use]
242    pub fn date_calculation_with_source(
243        message: impl Into<String>,
244        context: impl Into<String>,
245    ) -> Self {
246        Self::DateCalculationError {
247            message: message.into(),
248            context: Some(context.into()),
249        }
250    }
251
252    /// Creates an invalid date error
253    #[must_use]
254    pub fn invalid_date(year: u16, month: u8, day: u8) -> Self {
255        Self::InvalidDate { year, month, day }
256    }
257
258    /// Creates an invalid time error
259    #[must_use]
260    pub fn invalid_time(hour: u8, minute: u8, second: u8) -> Self {
261        Self::InvalidTime {
262            hour,
263            minute,
264            second,
265        }
266    }
267
268    /// Creates an invalid timezone offset error
269    #[must_use]
270    pub fn invalid_timezone_offset(hours: i8, minutes: u8) -> Self {
271        Self::InvalidTimezoneOffset { hours, minutes }
272    }
273
274    /// Creates an ambiguous time error
275    #[must_use]
276    pub fn ambiguous_time(message: impl Into<String>) -> Self {
277        Self::AmbiguousTime {
278            message: message.into(),
279        }
280    }
281
282    /// Creates an arithmetic overflow error
283    #[must_use]
284    pub fn arithmetic_overflow(operation: impl Into<String>) -> Self {
285        Self::ArithmeticOverflow {
286            operation: operation.into(),
287        }
288    }
289
290    /// Creates an unsupported operation error
291    #[must_use]
292    pub fn unsupported_operation(operation: impl Into<String>) -> Self {
293        Self::UnsupportedOperation {
294            operation: operation.into(),
295        }
296    }
297
298    /// Creates a backend error
299    #[must_use]
300    pub fn backend_error(message: impl Into<String>, backend: impl Into<String>) -> Self {
301        Self::BackendError {
302            message: message.into(),
303            backend: backend.into(),
304        }
305    }
306}
307
308/// Result type alias for temps operations.
309///
310/// All parsing and time calculation operations in the temps library
311/// return this result type.
312///
313/// # Example
314///
315/// ```
316/// use temps_core::Result;
317///
318/// fn parse_time(input: &str) -> Result<String> {
319///     // Implementation
320///     Ok("parsed".to_string())
321/// }
322/// ```
323pub type Result<T> = std::result::Result<T, TempsError>;
324
325/// Convert a collection of chumsky parser errors into a [`TempsError`]
326/// and an ariadne-rendered diagnostic string.
327///
328/// The first error's span is used for the position field. The full
329/// rendered report (with source context) is folded into the error's
330/// message so callers that simply display the error still get a useful,
331/// human-readable diagnostic.
332#[must_use]
333pub fn rich_errors_to_temps_error(
334    input: &str,
335    errors: Vec<chumsky::error::Rich<'_, char>>,
336) -> TempsError {
337    use ariadne::{Color, Label, Report, ReportKind, Source};
338
339    if input.is_empty() {
340        return TempsError::parse_error_with_position(
341            "input is empty; expected a time expression like `now`, `in 5 minutes`, or an ISO date",
342            input,
343            0,
344        );
345    }
346
347    let position = errors.first().map(|e| e.span().start).unwrap_or(0);
348
349    let source_id: &str = "input";
350    let mut rendered = String::new();
351    for err in &errors {
352        let span = err.span();
353        let range = span.start..span.end.max(span.start + 1).min(input.len().max(1));
354        let mut buf = Vec::new();
355        let (headline, detail) = format_rich(err);
356        let report = Report::build(ReportKind::Error, (source_id, range.clone()))
357            .with_message(headline)
358            .with_label(
359                Label::new((source_id, range))
360                    .with_message(detail)
361                    .with_color(Color::Red),
362            )
363            .finish();
364
365        if report
366            .write((source_id, Source::from(input)), &mut buf)
367            .is_ok()
368        {
369            rendered.push_str(&String::from_utf8_lossy(&buf));
370        } else {
371            rendered.push_str(&err.to_string());
372            rendered.push('\n');
373        }
374    }
375
376    let message = if rendered.is_empty() {
377        "Failed to parse time expression".to_string()
378    } else {
379        rendered.trim_end().to_string()
380    };
381
382    TempsError::parse_error_with_position(message, input, position)
383}
384
385/// Render a chumsky [`Rich`](chumsky::error::Rich) error as a `(headline, detail)`
386/// pair suitable for an ariadne report.
387fn format_rich(err: &chumsky::error::Rich<'_, char>) -> (String, String) {
388    use chumsky::error::RichReason;
389
390    match err.reason() {
391        RichReason::Custom(msg) => ("invalid time expression".to_string(), msg.clone()),
392        _ => {
393            let found = match err.found() {
394                Some(c) => format!("`{}`", c.escape_default()),
395                None => "end of input".to_string(),
396            };
397
398            let mut seen = std::collections::BTreeSet::new();
399            let mut expected: Vec<String> = Vec::new();
400            for pat in err.expected() {
401                let rendered = pat.to_string();
402                if seen.insert(rendered.clone()) {
403                    expected.push(rendered);
404                }
405            }
406
407            let detail = match expected.as_slice() {
408                [] => format!("unexpected {found}"),
409                [one] => format!("expected {one}, found {found}"),
410                many => {
411                    let last = many.last().expect("non-empty");
412                    let head = &many[..many.len() - 1];
413                    format!(
414                        "expected one of {} or {}, found {found}",
415                        head.join(", "),
416                        last
417                    )
418                }
419            };
420
421            ("could not parse time expression".to_string(), detail)
422        }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_error_display() {
432        let err = TempsError::invalid_date(2024, 13, 32);
433        assert_eq!(err.to_string(), "Invalid date: year=2024, month=13, day=32");
434
435        let err = TempsError::invalid_time(25, 61, 61);
436        assert_eq!(err.to_string(), "Invalid time: 25:61:61");
437
438        let err = TempsError::parse_error("unexpected token", "in 5 minuts");
439        assert_eq!(
440            err.to_string(),
441            "Failed to parse time expression: unexpected token"
442        );
443    }
444
445    #[test]
446    fn test_error_creation_helpers() {
447        let err = TempsError::date_calculation("month out of range");
448        match err {
449            TempsError::DateCalculationError { message, context } => {
450                assert_eq!(message, "month out of range");
451                assert!(context.is_none());
452            }
453            _ => panic!("Wrong error type"),
454        }
455
456        let err = TempsError::backend_error("conversion failed", "chrono");
457        match err {
458            TempsError::BackendError { message, backend } => {
459                assert_eq!(message, "conversion failed");
460                assert_eq!(backend, "chrono");
461            }
462            _ => panic!("Wrong error type"),
463        }
464    }
465}