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}