hedl_cli/
error.rs

1// Dweve HEDL - Hierarchical Entity Data Language
2//
3// Copyright (c) 2025 Dweve IP B.V. and individual contributors.
4//
5// SPDX-License-Identifier: Apache-2.0
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the LICENSE file at the
10// root of this repository or at: http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Structured error types for the HEDL CLI.
19//!
20//! This module provides type-safe, composable error handling using `thiserror`.
21//! All CLI operations return `Result<T, CliError>` for consistent error reporting.
22
23use std::io;
24use std::path::PathBuf;
25use thiserror::Error;
26
27/// The main error type for HEDL CLI operations.
28///
29/// This enum represents all possible error conditions that can occur during
30/// CLI command execution. Each variant provides rich context for debugging
31/// and user-friendly error messages.
32///
33/// # Cloning
34///
35/// Implements `Clone` to support parallel error handling in multi-threaded
36/// operations.
37///
38/// # Examples
39///
40/// ```rust,no_run
41/// use hedl_cli::error::CliError;
42///
43/// fn read_and_parse(path: &str) -> Result<(), CliError> {
44///     // Error is automatically converted and contextualized
45///     let content = std::fs::read_to_string(path)
46///         .map_err(|e| CliError::io_error(path, e))?;
47///     Ok(())
48/// }
49/// ```
50#[derive(Error, Debug, Clone)]
51pub enum CliError {
52    /// I/O operation failed (file read, write, or metadata access).
53    ///
54    /// This error includes the file path and the error kind/message.
55    #[error("I/O error for '{path}': {message}")]
56    Io {
57        /// The file path that caused the error
58        path: PathBuf,
59        /// The error message
60        message: String,
61    },
62
63    /// File size exceeds the maximum allowed limit (100 MB).
64    ///
65    /// This prevents denial-of-service attacks via memory exhaustion.
66    /// The error includes the actual file size and the configured limit.
67    #[error("File '{path}' is too large ({actual} bytes). Maximum allowed: {max} bytes ({max_mb} MB)")]
68    FileTooLarge {
69        /// The file path that exceeded the limit
70        path: PathBuf,
71        /// The actual file size in bytes
72        actual: u64,
73        /// The maximum allowed file size in bytes
74        max: u64,
75        /// The maximum allowed file size in MB (for display)
76        max_mb: u64,
77    },
78
79    /// I/O operation timed out.
80    ///
81    /// This prevents indefinite hangs on slow or unresponsive filesystems.
82    #[error("I/O operation timed out for '{path}' after {timeout_secs} seconds")]
83    IoTimeout {
84        /// The file path that timed out
85        path: PathBuf,
86        /// The timeout duration in seconds
87        timeout_secs: u64,
88    },
89
90    /// HEDL parsing error.
91    ///
92    /// This wraps errors from the hedl-core parser with additional context.
93    #[error("Parse error: {0}")]
94    Parse(String),
95
96    /// HEDL canonicalization error.
97    ///
98    /// This wraps errors from the hedl-c14n canonicalizer.
99    #[error("Canonicalization error: {0}")]
100    Canonicalization(String),
101
102    /// JSON conversion error.
103    ///
104    /// This includes both HEDL→JSON and JSON→HEDL conversion errors.
105    #[error("JSON conversion error: {0}")]
106    JsonConversion(String),
107
108    /// JSON serialization/deserialization error.
109    ///
110    /// This wraps serde_json errors during formatting.
111    #[error("JSON format error: {message}")]
112    JsonFormat {
113        /// The error message
114        message: String,
115    },
116
117    /// YAML conversion error.
118    ///
119    /// This includes both HEDL→YAML and YAML→HEDL conversion errors.
120    #[error("YAML conversion error: {0}")]
121    YamlConversion(String),
122
123    /// XML conversion error.
124    ///
125    /// This includes both HEDL→XML and XML→HEDL conversion errors.
126    #[error("XML conversion error: {0}")]
127    XmlConversion(String),
128
129    /// CSV conversion error.
130    ///
131    /// This includes both HEDL→CSV and CSV→HEDL conversion errors.
132    #[error("CSV conversion error: {0}")]
133    CsvConversion(String),
134
135    /// Parquet conversion error.
136    ///
137    /// This includes both HEDL→Parquet and Parquet→HEDL conversion errors.
138    #[error("Parquet conversion error: {0}")]
139    ParquetConversion(String),
140
141    /// Linting error.
142    ///
143    /// This indicates that linting found issues that should cause failure.
144    #[error("Lint errors found")]
145    LintErrors,
146
147    /// File is not in canonical form.
148    ///
149    /// This is returned by the `format --check` command.
150    #[error("File is not in canonical form")]
151    NotCanonical,
152
153    /// Invalid input provided by the user.
154    ///
155    /// This covers validation failures like invalid type names, empty files, etc.
156    #[error("Invalid input: {0}")]
157    InvalidInput(String),
158}
159
160impl CliError {
161    /// Create an I/O error with file path context.
162    ///
163    /// # Arguments
164    ///
165    /// * `path` - The file path that caused the error
166    /// * `source` - The underlying I/O error
167    ///
168    /// # Examples
169    ///
170    /// ```rust,no_run
171    /// use hedl_cli::error::CliError;
172    /// use std::fs;
173    ///
174    /// let result = fs::read_to_string("file.hedl")
175    ///     .map_err(|e| CliError::io_error("file.hedl", e));
176    /// ```
177    pub fn io_error(path: impl Into<PathBuf>, source: io::Error) -> Self {
178        Self::Io {
179            path: path.into(),
180            message: source.to_string(),
181        }
182    }
183
184    /// Create a file-too-large error.
185    ///
186    /// # Arguments
187    ///
188    /// * `path` - The file path that exceeded the limit
189    /// * `actual` - The actual file size in bytes
190    /// * `max` - The maximum allowed file size in bytes
191    ///
192    /// # Examples
193    ///
194    /// ```rust,no_run
195    /// use hedl_cli::error::CliError;
196    ///
197    /// const MAX_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
198    /// let err = CliError::file_too_large("huge.hedl", 200_000_000, MAX_SIZE);
199    /// ```
200    pub fn file_too_large(path: impl Into<PathBuf>, actual: u64, max: u64) -> Self {
201        Self::FileTooLarge {
202            path: path.into(),
203            actual,
204            max,
205            max_mb: max / (1024 * 1024),
206        }
207    }
208
209    /// Create an I/O timeout error.
210    ///
211    /// # Arguments
212    ///
213    /// * `path` - The file path that timed out
214    /// * `timeout_secs` - The timeout duration in seconds
215    ///
216    /// # Examples
217    ///
218    /// ```rust,no_run
219    /// use hedl_cli::error::CliError;
220    ///
221    /// let err = CliError::io_timeout("/slow/filesystem/file.hedl", 30);
222    /// ```
223    pub fn io_timeout(path: impl Into<PathBuf>, timeout_secs: u64) -> Self {
224        Self::IoTimeout {
225            path: path.into(),
226            timeout_secs,
227        }
228    }
229
230    /// Create a parse error.
231    ///
232    /// # Arguments
233    ///
234    /// * `msg` - The parse error message
235    pub fn parse(msg: impl Into<String>) -> Self {
236        Self::Parse(msg.into())
237    }
238
239    /// Create a canonicalization error.
240    ///
241    /// # Arguments
242    ///
243    /// * `msg` - The canonicalization error message
244    pub fn canonicalization(msg: impl Into<String>) -> Self {
245        Self::Canonicalization(msg.into())
246    }
247
248    /// Create an invalid input error.
249    ///
250    /// # Arguments
251    ///
252    /// * `msg` - Description of the invalid input
253    ///
254    /// # Examples
255    ///
256    /// ```rust,no_run
257    /// use hedl_cli::error::CliError;
258    ///
259    /// let err = CliError::invalid_input("Type name must be alphanumeric");
260    /// ```
261    pub fn invalid_input(msg: impl Into<String>) -> Self {
262        Self::InvalidInput(msg.into())
263    }
264}
265
266// Automatic conversion from serde_json::Error
267impl From<serde_json::Error> for CliError {
268    fn from(source: serde_json::Error) -> Self {
269        Self::JsonFormat {
270            message: source.to_string(),
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_io_error_display() {
281        let err = CliError::io_error(
282            "test.hedl",
283            io::Error::new(io::ErrorKind::NotFound, "file not found"),
284        );
285        let msg = err.to_string();
286        assert!(msg.contains("test.hedl"));
287        assert!(msg.contains("file not found"));
288    }
289
290    #[test]
291    fn test_file_too_large_display() {
292        let err = CliError::file_too_large("big.hedl", 200_000_000, 100 * 1024 * 1024);
293        let msg = err.to_string();
294        assert!(msg.contains("big.hedl"));
295        assert!(msg.contains("200000000 bytes"));
296        assert!(msg.contains("100 MB"));
297    }
298
299    #[test]
300    fn test_io_timeout_display() {
301        let err = CliError::io_timeout("/slow/file.hedl", 30);
302        let msg = err.to_string();
303        assert!(msg.contains("/slow/file.hedl"));
304        assert!(msg.contains("30 seconds"));
305    }
306
307    #[test]
308    fn test_parse_error_display() {
309        let err = CliError::parse("unexpected token");
310        assert_eq!(err.to_string(), "Parse error: unexpected token");
311    }
312
313    #[test]
314    fn test_invalid_input_display() {
315        let err = CliError::invalid_input("CSV file is empty");
316        assert_eq!(err.to_string(), "Invalid input: CSV file is empty");
317    }
318
319    #[test]
320    fn test_json_format_error_conversion() {
321        let json_err = serde_json::from_str::<serde_json::Value>("invalid json")
322            .unwrap_err();
323        let cli_err: CliError = json_err.into();
324        assert!(matches!(cli_err, CliError::JsonFormat { .. }));
325    }
326
327    #[test]
328    fn test_error_cloning() {
329        let err = CliError::io_error("test.hedl", io::Error::new(io::ErrorKind::NotFound, "not found"));
330        let cloned = err.clone();
331        assert_eq!(err.to_string(), cloned.to_string());
332    }
333}