Skip to main content

rss_gen/
error.rs

1// Copyright © 2024 RSS Gen. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4// src/error.rs
5
6use log;
7use std::string::FromUtf8Error;
8use thiserror::Error;
9
10/// Errors that can occur when generating or parsing RSS feeds.
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum RssError {
14    /// Error occurred while writing XML.
15    #[error("XML error occurred: {0}")]
16    XmlWriteError(#[from] oxml::tree::NodeError),
17
18    /// Error occurred during XML parsing.
19    #[error("XML parse error occurred: {0}")]
20    XmlParseError(#[from] oxml::Error),
21
22    /// Error occurred during UTF-8 conversion.
23    #[error("UTF-8 conversion error occurred: {0}")]
24    Utf8Error(#[from] FromUtf8Error),
25
26    /// Error indicating a required field is missing.
27    #[error("A required field is missing: {0}")]
28    MissingField(String),
29
30    /// Error indicating a date parsing failure.
31    #[error("Date parse error: {0}")]
32    DateParseError(String),
33
34    /// General I/O error.
35    #[error("I/O error occurred: {0}")]
36    IoError(#[from] std::io::Error),
37
38    /// Error for invalid input data.
39    #[error("Invalid input data provided: {0}")]
40    InvalidInput(String),
41
42    /// Error for invalid URL provided.
43    #[error("Invalid URL provided: {0}")]
44    InvalidUrl(String),
45
46    /// Error for unknown XML elements encountered during parsing.
47    #[error("Unknown XML element found: {0}")]
48    UnknownElement(String),
49
50    /// Error for validation errors.
51    ///
52    /// Carries a `Vec<ValidationError>` rather than `Vec<String>` so callers
53    /// can programmatically inspect the offending `field` rather than parse
54    /// human-readable strings. The wrapped `ValidationError::Display` impl
55    /// formats as the bare `message` (e.g. `channel.title is missing`),
56    /// matching the string format used in earlier releases.
57    #[error("Validation errors: {0:?}")]
58    ValidationErrors(Vec<ValidationError>),
59
60    /// Error for date sort errors.
61    #[error("Date sort error: {0:?}")]
62    DateSortError(Vec<DateSortError>),
63
64    /// Error for item validation errors.
65    #[error("Item validation error: {0}")]
66    ItemValidationError(String),
67
68    /// Error for unknown field encountered during parsing.
69    #[error("Unknown field encountered: {0}")]
70    UnknownField(String),
71
72    /// Custom error for unforeseen scenarios.
73    #[error("Custom error: {0}")]
74    Custom(String),
75
76    /// Error for invalid RSS version.
77    #[error("Invalid RSS version: {0}")]
78    InvalidRssVersion(String),
79    // #[error("Unknown RSS element: {0}")]
80    // UnknownElement(String),
81
82    // #[error("XML parsing error: {0}")]
83    // XmlParseError(#[from] quick_xml::Error),
84
85    // #[error("IO error: {0}")]
86    // IoError(#[from] std::io::Error),
87}
88
89/// Represents a specific validation error.
90///
91/// `field` identifies the offending element using a dotted path
92/// (`channel.title`, `item.0.link`, `feed.id`, `entry.2.updated`, …) so
93/// downstream tooling can dispatch on the field without parsing strings.
94/// `message` is the full human-readable error text and is what
95/// [`std::fmt::Display`] writes — preserving the bare-string format that previous
96/// releases of this crate emitted as the [`RssError::ValidationErrors`]
97/// payload.
98#[derive(Debug, Clone, PartialEq, Eq, Error)]
99#[non_exhaustive]
100#[error("{message}")]
101pub struct ValidationError {
102    /// The dotted path identifying the field that failed validation.
103    pub field: String,
104    /// The full human-readable error text. Read via `Display` /
105    /// `to_string()`; matches the string format used in
106    /// pre-v0.0.6 releases.
107    pub message: String,
108}
109
110impl ValidationError {
111    /// Constructs a [`ValidationError`].
112    ///
113    /// `field` should be the dotted path (e.g. `"channel.title"`);
114    /// `message` is the full human-readable error text and is what
115    /// [`std::fmt::Display`] writes back out — keeping the bare-string format used
116    /// by earlier releases of this crate.
117    #[must_use]
118    pub fn new<F: Into<String>, M: Into<String>>(
119        field: F,
120        message: M,
121    ) -> Self {
122        Self {
123            field: field.into(),
124            message: message.into(),
125        }
126    }
127}
128
129/// Represents a specific date sorting error.
130#[derive(Debug, Error)]
131#[non_exhaustive]
132#[error("Date sort error: {message}")]
133pub struct DateSortError {
134    /// The index of the item with the date sort error.
135    pub index: usize,
136    /// The error message.
137    pub message: String,
138}
139
140/// Result type for RSS operations.
141///
142/// This type alias provides a convenient way to return results from RSS operations,
143/// where the error type is always `RssError`.
144pub type Result<T> = std::result::Result<T, RssError>;
145
146impl RssError {
147    /// Creates a new `RssError::MissingField` error.
148    ///
149    /// # Arguments
150    ///
151    /// * `field_name` - The name of the missing field.
152    ///
153    /// # Returns
154    ///
155    /// Returns a new `RssError::MissingField` instance.
156    pub fn missing_field<S: Into<String>>(field_name: S) -> Self {
157        RssError::MissingField(field_name.into())
158    }
159
160    /// Creates a new `DateSortError`.
161    ///
162    /// # Arguments
163    ///
164    /// * `index` - The index of the item with the date sort error.
165    /// * `message` - The error message.
166    ///
167    /// # Returns
168    ///
169    /// Returns a new `DateSortError` instance.
170    pub fn date_sort_error<S: Into<String>>(
171        index: usize,
172        message: S,
173    ) -> DateSortError {
174        DateSortError {
175            index,
176            message: message.into(),
177        }
178    }
179
180    /// Creates a new `RssError::InvalidInput` error.
181    ///
182    /// # Arguments
183    ///
184    /// * `message` - A description of why the input is invalid.
185    ///
186    /// # Returns
187    ///
188    /// Returns a new `RssError::InvalidInput` instance.
189    pub fn invalid_input<S: Into<String>>(message: S) -> Self {
190        RssError::InvalidInput(message.into())
191    }
192
193    /// Creates a new `RssError::Custom` error.
194    ///
195    /// # Arguments
196    ///
197    /// * `message` - A custom error message.
198    ///
199    /// # Returns
200    ///
201    /// Returns a new `RssError::Custom` instance.
202    pub fn custom<S: Into<String>>(message: S) -> Self {
203        RssError::Custom(message.into())
204    }
205
206    /// Logs the error using the `log` crate.
207    ///
208    /// This method logs the error at the error level. It uses the `log` crate,
209    /// so the application using this library should configure a logger.
210    pub fn log(&self) {
211        log::error!("RSS Error occurred: {self}");
212    }
213
214    /// Converts the `RssError` into an appropriate HTTP status code.
215    ///
216    /// This method is useful when the library is used in web services.
217    ///
218    /// # Returns
219    ///
220    /// Returns a `u16` representing an HTTP status code.
221    #[must_use]
222    pub fn to_http_status(&self) -> u16 {
223        match self {
224            // Combine all cases that map to 500
225            RssError::XmlWriteError(_)
226            | RssError::XmlParseError(_)
227            | RssError::Utf8Error(_)
228            | RssError::IoError(_)
229            | RssError::UnknownElement(_)
230            | RssError::DateSortError(_)
231            | RssError::UnknownField(_)
232            | RssError::Custom(_) => 500,
233
234            // Combine all cases that map to 400
235            RssError::MissingField(_)
236            | RssError::InvalidInput(_)
237            | RssError::DateParseError(_)
238            | RssError::InvalidUrl(_)
239            | RssError::ValidationErrors(_)
240            | RssError::ItemValidationError(_)
241            | RssError::InvalidRssVersion(_) => 400,
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use std::error::Error;
250    use std::io;
251
252    #[test]
253    fn test_rss_error_display() {
254        let error = RssError::missing_field("title");
255        assert_eq!(
256            error.to_string(),
257            "A required field is missing: title"
258        );
259    }
260
261    #[test]
262    fn test_xml_write_error() {
263        let xml_error = oxml::tree::NodeError::InvalidText;
264        let error = RssError::XmlWriteError(xml_error);
265        assert_eq!(
266            error.to_string(),
267            "XML error occurred: the text cannot be written as XML"
268        );
269    }
270
271    #[test]
272    fn test_utf8_error() {
273        let utf8_error =
274            String::from_utf8(vec![0, 159, 146, 150]).unwrap_err();
275        let error = RssError::Utf8Error(utf8_error);
276        assert_eq!(error.to_string(), "UTF-8 conversion error occurred: invalid utf-8 sequence of 1 bytes from index 1");
277    }
278
279    #[test]
280    fn test_io_error() {
281        let io_error =
282            io::Error::new(io::ErrorKind::NotFound, "File not found");
283        let error: RssError = io_error.into();
284        assert_eq!(
285            error.to_string(),
286            "I/O error occurred: File not found"
287        );
288    }
289
290    #[test]
291    fn test_error_is_send_and_sync() {
292        fn assert_send_sync<T: Send + Sync>() {}
293        assert_send_sync::<RssError>();
294    }
295
296    #[test]
297    fn test_error_source() {
298        let xml_error = oxml::tree::NodeError::InvalidText;
299        let error = RssError::XmlWriteError(xml_error);
300        assert!(error.source().is_some());
301
302        let io_error: RssError =
303            io::Error::new(io::ErrorKind::NotFound, "File not found")
304                .into();
305        assert!(io_error.source().is_some());
306    }
307
308    #[test]
309    fn test_missing_field_with_string() {
310        let error = RssError::missing_field(String::from("author"));
311        assert_eq!(
312            error.to_string(),
313            "A required field is missing: author"
314        );
315    }
316
317    #[test]
318    fn test_missing_field_with_str() {
319        let error = RssError::missing_field("description");
320        assert_eq!(
321            error.to_string(),
322            "A required field is missing: description"
323        );
324    }
325
326    #[test]
327    fn test_error_downcast() {
328        let error: Box<dyn Error> =
329            Box::new(RssError::missing_field("category"));
330        let downcast_result = error.downcast::<RssError>();
331        assert!(downcast_result.is_ok());
332    }
333
334    #[test]
335    fn test_invalid_input_error() {
336        let error = RssError::invalid_input("Invalid date format");
337        assert_eq!(
338            error.to_string(),
339            "Invalid input data provided: Invalid date format"
340        );
341    }
342
343    #[test]
344    fn test_custom_error() {
345        let error = RssError::custom("Unforeseen error occurred");
346        assert_eq!(
347            error.to_string(),
348            "Custom error: Unforeseen error occurred"
349        );
350    }
351
352    #[test]
353    fn test_to_http_status() {
354        assert_eq!(
355            RssError::missing_field("title").to_http_status(),
356            400
357        );
358        assert_eq!(
359            RssError::XmlWriteError(oxml::tree::NodeError::InvalidText)
360                .to_http_status(),
361            500
362        );
363        assert_eq!(
364            RssError::InvalidInput("Bad input".to_string())
365                .to_http_status(),
366            400
367        );
368    }
369
370    #[test]
371    fn test_validation_error() {
372        // v0.0.6: Display writes the bare `message`, not the previous
373        // "Validation error: …" prefix — callers that compared the
374        // string against `e.to_string()` now see the same strings the
375        // crate produced as `Vec<String>` entries pre-v0.0.6, which is
376        // what the property tests in tests/property_tests.rs assert.
377        let error = ValidationError::new(
378            "channel.title",
379            "channel.title is missing",
380        );
381        assert_eq!(error.to_string(), "channel.title is missing");
382        assert_eq!(error.field, "channel.title");
383        assert_eq!(error.message, "channel.title is missing");
384    }
385
386    #[test]
387    fn test_date_sort_error() {
388        let error = DateSortError {
389            index: 0,
390            message: "Invalid date".to_string(),
391        };
392        assert_eq!(error.to_string(), "Date sort error: Invalid date");
393    }
394
395    #[test]
396    fn test_missing_field_error() {
397        let rss_error = RssError::MissingField("title".to_string());
398
399        assert_eq!(
400            format!("{rss_error}"),
401            "A required field is missing: title"
402        );
403    }
404
405    #[test]
406    fn test_date_parse_error() {
407        let rss_error =
408            RssError::DateParseError("Invalid date format".to_string());
409
410        assert_eq!(
411            format!("{rss_error}"),
412            "Date parse error: Invalid date format"
413        );
414    }
415
416    #[test]
417    fn test_invalid_url_error() {
418        let rss_error =
419            RssError::InvalidUrl("https://invalid-url".to_string());
420
421        assert_eq!(
422            format!("{rss_error}"),
423            "Invalid URL provided: https://invalid-url"
424        );
425    }
426
427    #[test]
428    fn test_unknown_element_error() {
429        let rss_error =
430            RssError::UnknownElement("unknown-element".to_string());
431
432        assert_eq!(
433            format!("{rss_error}"),
434            "Unknown XML element found: unknown-element"
435        );
436    }
437
438    #[test]
439    fn test_validation_errors() {
440        let validation_errors = vec![
441            ValidationError::new(
442                "channel.title",
443                "channel.title is missing",
444            ),
445            ValidationError::new(
446                "channel.pub_date",
447                "Invalid channel.pub_date: 2026/06/28",
448            ),
449        ];
450        let rss_error =
451            RssError::ValidationErrors(validation_errors.clone());
452
453        // Display of ValidationError is the bare `message` — matches the
454        // string format pre-v0.0.6 callers were used to.
455        assert_eq!(
456            validation_errors[0].to_string(),
457            "channel.title is missing"
458        );
459        // The wrapping RssError formats the Vec via its Debug impl so
460        // both the field and the message are surfaced.
461        let rendered = format!("{rss_error}");
462        assert!(rendered.contains("channel.title"));
463        assert!(rendered.contains("Invalid channel.pub_date"));
464    }
465
466    #[test]
467    fn test_validation_error_field_is_accessible() {
468        let err = ValidationError::new(
469            "item.0.link",
470            "item.0.link is missing",
471        );
472        assert_eq!(err.field, "item.0.link");
473        assert_eq!(err.to_string(), "item.0.link is missing");
474    }
475
476    #[test]
477    fn test_error_log() {
478        let error = RssError::missing_field("title");
479        // log() writes to the log crate; just verify it doesn't panic
480        error.log();
481
482        let error = RssError::custom("something went wrong");
483        error.log();
484    }
485
486    #[test]
487    fn test_custom_error_http_status() {
488        assert_eq!(
489            RssError::Custom("err".to_string()).to_http_status(),
490            500
491        );
492    }
493
494    #[test]
495    fn test_date_sort_error_constructor() {
496        let error = RssError::date_sort_error(3, "dates out of order");
497        let DateSortError { index, message } = error;
498        assert_eq!(index, 3);
499        assert_eq!(message, "dates out of order");
500    }
501}