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