Skip to main content

langcodec/
error.rs

1//! All error types for the langcodec crate.
2//!
3//! These are returned from all fallible operations (parsing, serialization, conversion, etc.).
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8/// Stable machine-readable category for [`enum@Error`].
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ErrorCode {
12    UnknownFormat,
13    Parse,
14    XmlParse,
15    CsvParse,
16    Io,
17    DataMismatch,
18    InvalidResource,
19    UnsupportedFormat,
20    Conversion,
21    Validation,
22    MissingLanguage,
23    AmbiguousMatch,
24    PolicyViolation,
25}
26
27/// Optional structured metadata attached to an error.
28#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
29pub struct ErrorContext {
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub path: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub format: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub key: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub language: Option<String>,
38    #[serde(skip_serializing_if = "Vec::is_empty", default)]
39    pub candidates: Vec<String>,
40}
41
42/// Serializable structured representation of an [`enum@Error`].
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct StructuredError {
45    pub code: ErrorCode,
46    pub message: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub context: Option<ErrorContext>,
49}
50
51#[derive(Error, Debug)]
52pub enum Error {
53    #[error("unknown format `{0}`")]
54    UnknownFormat(String),
55
56    #[error("parse error: {0}")]
57    Parse(#[from] serde_json::Error),
58
59    #[error("XML parse error: {0}")]
60    XmlParse(#[from] quick_xml::Error),
61
62    #[error("CSV parse error: {0}")]
63    CsvParse(#[from] csv::Error),
64
65    #[error("I/O error: {0}")]
66    Io(#[from] std::io::Error),
67
68    #[error("invalid data: {0}")]
69    DataMismatch(String),
70
71    #[error("invalid resource: {0}")]
72    InvalidResource(String),
73
74    #[error("unsupported format: {0}")]
75    UnsupportedFormat(String),
76
77    #[error("conversion error: {message}")]
78    Conversion {
79        message: String,
80        #[source]
81        source: Option<Box<dyn std::error::Error + Send + Sync>>,
82    },
83
84    #[error("validation error: {0}")]
85    Validation(String),
86
87    #[error("missing language for `{path}` ({format})")]
88    MissingLanguage { path: String, format: String },
89
90    #[error("ambiguous match for key `{key}` in language `{language}`: {candidates:?}")]
91    AmbiguousMatch {
92        key: String,
93        language: String,
94        candidates: Vec<String>,
95    },
96
97    #[error("policy violation: {0}")]
98    PolicyViolation(String),
99}
100
101impl Error {
102    /// Creates a new conversion error with optional source error
103    pub fn conversion_error(
104        message: impl Into<String>,
105        source: Option<Box<dyn std::error::Error + Send + Sync>>,
106    ) -> Self {
107        Error::Conversion {
108            message: message.into(),
109            source,
110        }
111    }
112
113    /// Creates a new validation error
114    pub fn validation_error(message: impl Into<String>) -> Self {
115        Error::Validation(message.into())
116    }
117
118    /// Creates a new missing-language error.
119    pub fn missing_language(path: impl Into<String>, format: impl Into<String>) -> Self {
120        Error::MissingLanguage {
121            path: path.into(),
122            format: format.into(),
123        }
124    }
125
126    /// Creates a new policy violation error.
127    pub fn policy_violation(message: impl Into<String>) -> Self {
128        Error::PolicyViolation(message.into())
129    }
130
131    /// Returns a machine-readable error code.
132    pub fn error_code(&self) -> ErrorCode {
133        match self {
134            Error::UnknownFormat(_) => ErrorCode::UnknownFormat,
135            Error::Parse(_) => ErrorCode::Parse,
136            Error::XmlParse(_) => ErrorCode::XmlParse,
137            Error::CsvParse(_) => ErrorCode::CsvParse,
138            Error::Io(_) => ErrorCode::Io,
139            Error::DataMismatch(_) => ErrorCode::DataMismatch,
140            Error::InvalidResource(_) => ErrorCode::InvalidResource,
141            Error::UnsupportedFormat(_) => ErrorCode::UnsupportedFormat,
142            Error::Conversion { .. } => ErrorCode::Conversion,
143            Error::Validation(_) => ErrorCode::Validation,
144            Error::MissingLanguage { .. } => ErrorCode::MissingLanguage,
145            Error::AmbiguousMatch { .. } => ErrorCode::AmbiguousMatch,
146            Error::PolicyViolation(_) => ErrorCode::PolicyViolation,
147        }
148    }
149
150    /// Returns optional structured context for the error.
151    pub fn context(&self) -> Option<ErrorContext> {
152        match self {
153            Error::MissingLanguage { path, format } => Some(ErrorContext {
154                path: Some(path.clone()),
155                format: Some(format.clone()),
156                ..ErrorContext::default()
157            }),
158            Error::AmbiguousMatch {
159                key,
160                language,
161                candidates,
162            } => Some(ErrorContext {
163                key: Some(key.clone()),
164                language: Some(language.clone()),
165                candidates: candidates.clone(),
166                ..ErrorContext::default()
167            }),
168            _ => None,
169        }
170    }
171
172    /// Converts this error into a serializable structured shape.
173    pub fn structured(&self) -> StructuredError {
174        StructuredError {
175            code: self.error_code(),
176            message: self.to_string(),
177            context: self.context(),
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::io;
186
187    #[test]
188    fn test_unknown_format_error() {
189        let error = Error::UnknownFormat("invalid_format".to_string());
190        assert_eq!(error.to_string(), "unknown format `invalid_format`");
191    }
192
193    #[test]
194    fn test_parse_error() {
195        let json_error = serde_json::from_str::<serde_json::Value>("{ invalid json }").unwrap_err();
196        let error = Error::Parse(json_error);
197        assert!(error.to_string().contains("parse error"));
198    }
199
200    #[test]
201    fn test_io_error() {
202        let io_error = io::Error::new(io::ErrorKind::NotFound, "File not found");
203        let error = Error::Io(io_error);
204        assert!(error.to_string().contains("I/O error"));
205    }
206
207    #[test]
208    fn test_data_mismatch_error() {
209        let error = Error::DataMismatch("Invalid data format".to_string());
210        assert_eq!(error.to_string(), "invalid data: Invalid data format");
211    }
212
213    #[test]
214    fn test_invalid_resource_error() {
215        let error = Error::InvalidResource("Missing required field".to_string());
216        assert_eq!(
217            error.to_string(),
218            "invalid resource: Missing required field"
219        );
220    }
221
222    #[test]
223    fn test_unsupported_format_error() {
224        let error = Error::UnsupportedFormat("xyz".to_string());
225        assert_eq!(error.to_string(), "unsupported format: xyz");
226    }
227
228    #[test]
229    fn test_conversion_error_with_source() {
230        let source_error = Box::new(io::Error::new(io::ErrorKind::NotFound, "Source error"));
231        let error = Error::conversion_error("Conversion failed", Some(source_error));
232        assert!(
233            error
234                .to_string()
235                .contains("conversion error: Conversion failed")
236        );
237    }
238
239    #[test]
240    fn test_conversion_error_without_source() {
241        let error = Error::conversion_error("Conversion failed", None);
242        assert!(
243            error
244                .to_string()
245                .contains("conversion error: Conversion failed")
246        );
247    }
248
249    #[test]
250    fn test_validation_error() {
251        let error = Error::validation_error("Validation failed");
252        assert_eq!(error.to_string(), "validation error: Validation failed");
253    }
254
255    #[test]
256    fn test_error_display() {
257        let errors = vec![
258            Error::UnknownFormat("test".to_string()),
259            Error::DataMismatch("test".to_string()),
260            Error::InvalidResource("test".to_string()),
261            Error::UnsupportedFormat("test".to_string()),
262            Error::Validation("test".to_string()),
263            Error::PolicyViolation("test".to_string()),
264        ];
265
266        for error in errors {
267            let display = format!("{}", error);
268            assert!(!display.is_empty());
269            assert!(display.contains("test"));
270        }
271    }
272
273    #[test]
274    fn test_error_debug() {
275        let error = Error::UnknownFormat("test".to_string());
276        let debug = format!("{:?}", error);
277        assert!(debug.contains("UnknownFormat"));
278        assert!(debug.contains("test"));
279    }
280
281    #[test]
282    fn test_structured_error_for_missing_language() {
283        let error = Error::missing_language("/tmp/Localizable.strings", "strings");
284        let structured = error.structured();
285        assert_eq!(structured.code, ErrorCode::MissingLanguage);
286        assert_eq!(
287            structured.context.as_ref().and_then(|c| c.path.as_deref()),
288            Some("/tmp/Localizable.strings")
289        );
290    }
291
292    #[test]
293    fn test_structured_error_for_ambiguous_match() {
294        let error = Error::AmbiguousMatch {
295            key: "welcome".to_string(),
296            language: "fr".to_string(),
297            candidates: vec!["a".to_string(), "b".to_string()],
298        };
299        let structured = error.structured();
300        assert_eq!(structured.code, ErrorCode::AmbiguousMatch);
301        assert_eq!(
302            structured.context.as_ref().map(|c| c.candidates.clone()),
303            Some(vec!["a".to_string(), "b".to_string()])
304        );
305    }
306}