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}