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(
68 "File '{path}' is too large ({actual} bytes). Maximum allowed: {max} bytes ({max_mb} MB)"
69 )]
70 FileTooLarge {
71 /// The file path that exceeded the limit
72 path: PathBuf,
73 /// The actual file size in bytes
74 actual: u64,
75 /// The maximum allowed file size in bytes
76 max: u64,
77 /// The maximum allowed file size in MB (for display)
78 max_mb: u64,
79 },
80
81 /// I/O operation timed out.
82 ///
83 /// This prevents indefinite hangs on slow or unresponsive filesystems.
84 #[error("I/O operation timed out for '{path}' after {timeout_secs} seconds")]
85 IoTimeout {
86 /// The file path that timed out
87 path: PathBuf,
88 /// The timeout duration in seconds
89 timeout_secs: u64,
90 },
91
92 /// HEDL parsing error.
93 ///
94 /// This wraps errors from the hedl-core parser with additional context.
95 #[error("Parse error: {0}")]
96 Parse(String),
97
98 /// HEDL canonicalization error.
99 ///
100 /// This wraps errors from the hedl-c14n canonicalizer.
101 #[error("Canonicalization error: {0}")]
102 Canonicalization(String),
103
104 /// JSON conversion error.
105 ///
106 /// This includes both HEDL→JSON and JSON→HEDL conversion errors.
107 #[error("JSON conversion error: {0}")]
108 JsonConversion(String),
109
110 /// JSON serialization/deserialization error.
111 ///
112 /// This wraps `serde_json` errors during formatting.
113 #[error("JSON format error: {message}")]
114 JsonFormat {
115 /// The error message
116 message: String,
117 },
118
119 /// YAML conversion error.
120 ///
121 /// This includes both HEDL→YAML and YAML→HEDL conversion errors.
122 #[error("YAML conversion error: {0}")]
123 YamlConversion(String),
124
125 /// XML conversion error.
126 ///
127 /// This includes both HEDL→XML and XML→HEDL conversion errors.
128 #[error("XML conversion error: {0}")]
129 XmlConversion(String),
130
131 /// CSV conversion error.
132 ///
133 /// This includes both HEDL→CSV and CSV→HEDL conversion errors.
134 #[error("CSV conversion error: {0}")]
135 CsvConversion(String),
136
137 /// Parquet conversion error.
138 ///
139 /// This includes both HEDL→Parquet and Parquet→HEDL conversion errors.
140 #[error("Parquet conversion error: {0}")]
141 ParquetConversion(String),
142
143 /// Linting error.
144 ///
145 /// This indicates that linting found issues that should cause failure.
146 #[error("Lint errors found")]
147 LintErrors,
148
149 /// File is not in canonical form.
150 ///
151 /// This is returned by the `format --check` command.
152 #[error("File is not in canonical form")]
153 NotCanonical,
154
155 /// Invalid input provided by the user.
156 ///
157 /// This covers validation failures like invalid type names, empty files, etc.
158 #[error("Invalid input: {0}")]
159 InvalidInput(String),
160
161 /// Thread pool creation error.
162 ///
163 /// This occurs when creating a local Rayon thread pool fails, typically due to
164 /// invalid configuration (e.g., zero threads) or resource exhaustion.
165 ///
166 /// # Context
167 ///
168 /// * `message` - Detailed error message from Rayon
169 /// * `requested_threads` - The number of threads requested
170 ///
171 /// # Examples
172 ///
173 /// ```rust,no_run
174 /// use hedl_cli::error::CliError;
175 ///
176 /// // Requesting zero threads is invalid
177 /// let err = CliError::thread_pool_error("thread count must be positive", 0);
178 /// ```
179 #[error("Failed to create thread pool: {message}")]
180 ThreadPoolError {
181 /// The error message from Rayon
182 message: String,
183 /// The number of threads requested
184 requested_threads: usize,
185 },
186
187 /// Invalid glob pattern.
188 ///
189 /// This error occurs when a glob pattern is malformed or contains invalid syntax.
190 ///
191 /// # Examples
192 ///
193 /// ```rust,no_run
194 /// use hedl_cli::error::CliError;
195 ///
196 /// let err = CliError::GlobPattern {
197 /// pattern: "[invalid".to_string(),
198 /// message: "unclosed character class".to_string(),
199 /// };
200 /// ```
201 #[error("Invalid glob pattern '{pattern}': {message}")]
202 GlobPattern {
203 /// The invalid pattern
204 pattern: String,
205 /// The error message
206 message: String,
207 },
208
209 /// No files matched the provided patterns.
210 ///
211 /// This error occurs when glob patterns don't match any files.
212 ///
213 /// # Examples
214 ///
215 /// ```rust,no_run
216 /// use hedl_cli::error::CliError;
217 ///
218 /// let err = CliError::NoFilesMatched {
219 /// patterns: vec!["*.hedl".to_string(), "test/*.hedl".to_string()],
220 /// };
221 /// ```
222 #[error("File discovery failed: no files matched patterns: {}", patterns.join(", "))]
223 NoFilesMatched {
224 /// The patterns that didn't match any files
225 patterns: Vec<String>,
226 },
227
228 /// Directory traversal error.
229 ///
230 /// This error occurs when directory traversal fails due to permissions,
231 /// I/O errors, or other filesystem issues.
232 ///
233 /// # Examples
234 ///
235 /// ```rust,no_run
236 /// use hedl_cli::error::CliError;
237 /// use std::path::PathBuf;
238 ///
239 /// let err = CliError::DirectoryTraversal {
240 /// path: PathBuf::from("/restricted"),
241 /// message: "permission denied".to_string(),
242 /// };
243 /// ```
244 #[error("Failed to traverse directory '{path}': {message}")]
245 DirectoryTraversal {
246 /// The directory path that caused the error
247 path: PathBuf,
248 /// The error message
249 message: String,
250 },
251
252 /// Resource exhaustion error.
253 ///
254 /// This error occurs when system resources are exhausted (e.g., file handles, memory).
255 ///
256 /// # Examples
257 ///
258 /// ```rust,no_run
259 /// use hedl_cli::error::CliError;
260 ///
261 /// let err = CliError::ResourceExhaustion {
262 /// resource_type: "file_handles".to_string(),
263 /// message: "too many open files".to_string(),
264 /// current_usage: 1024,
265 /// limit: 1024,
266 /// };
267 /// ```
268 #[error("Resource exhaustion: {resource_type} - {message} (usage: {current_usage}/{limit})")]
269 ResourceExhaustion {
270 /// The type of resource exhausted
271 resource_type: String,
272 /// The error message
273 message: String,
274 /// Current resource usage
275 current_usage: u64,
276 /// Resource limit
277 limit: u64,
278 },
279}
280
281impl CliError {
282 /// Create an I/O error with file path context.
283 ///
284 /// # Arguments
285 ///
286 /// * `path` - The file path that caused the error
287 /// * `source` - The underlying I/O error
288 ///
289 /// # Examples
290 ///
291 /// ```rust,no_run
292 /// use hedl_cli::error::CliError;
293 /// use std::fs;
294 ///
295 /// let result = fs::read_to_string("file.hedl")
296 /// .map_err(|e| CliError::io_error("file.hedl", e));
297 /// ```
298 pub fn io_error(path: impl Into<PathBuf>, source: io::Error) -> Self {
299 Self::Io {
300 path: path.into(),
301 message: source.to_string(),
302 }
303 }
304
305 /// Create a file-too-large error.
306 ///
307 /// # Arguments
308 ///
309 /// * `path` - The file path that exceeded the limit
310 /// * `actual` - The actual file size in bytes
311 /// * `max` - The maximum allowed file size in bytes
312 ///
313 /// # Examples
314 ///
315 /// ```rust,no_run
316 /// use hedl_cli::error::CliError;
317 ///
318 /// const MAX_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
319 /// let err = CliError::file_too_large("huge.hedl", 200_000_000, MAX_SIZE);
320 /// ```
321 pub fn file_too_large(path: impl Into<PathBuf>, actual: u64, max: u64) -> Self {
322 Self::FileTooLarge {
323 path: path.into(),
324 actual,
325 max,
326 max_mb: max / (1024 * 1024),
327 }
328 }
329
330 /// Create an I/O timeout error.
331 ///
332 /// # Arguments
333 ///
334 /// * `path` - The file path that timed out
335 /// * `timeout_secs` - The timeout duration in seconds
336 ///
337 /// # Examples
338 ///
339 /// ```rust,no_run
340 /// use hedl_cli::error::CliError;
341 ///
342 /// let err = CliError::io_timeout("/slow/filesystem/file.hedl", 30);
343 /// ```
344 pub fn io_timeout(path: impl Into<PathBuf>, timeout_secs: u64) -> Self {
345 Self::IoTimeout {
346 path: path.into(),
347 timeout_secs,
348 }
349 }
350
351 /// Create a parse error.
352 ///
353 /// # Arguments
354 ///
355 /// * `msg` - The parse error message
356 pub fn parse(msg: impl Into<String>) -> Self {
357 Self::Parse(msg.into())
358 }
359
360 /// Create a canonicalization error.
361 ///
362 /// # Arguments
363 ///
364 /// * `msg` - The canonicalization error message
365 pub fn canonicalization(msg: impl Into<String>) -> Self {
366 Self::Canonicalization(msg.into())
367 }
368
369 /// Create an invalid input error.
370 ///
371 /// # Arguments
372 ///
373 /// * `msg` - Description of the invalid input
374 ///
375 /// # Examples
376 ///
377 /// ```rust,no_run
378 /// use hedl_cli::error::CliError;
379 ///
380 /// let err = CliError::invalid_input("Type name must be alphanumeric");
381 /// ```
382 pub fn invalid_input(msg: impl Into<String>) -> Self {
383 Self::InvalidInput(msg.into())
384 }
385
386 /// Create a JSON conversion error.
387 ///
388 /// # Arguments
389 ///
390 /// * `msg` - The JSON conversion error message
391 pub fn json_conversion(msg: impl Into<String>) -> Self {
392 Self::JsonConversion(msg.into())
393 }
394
395 /// Create a YAML conversion error.
396 ///
397 /// # Arguments
398 ///
399 /// * `msg` - The YAML conversion error message
400 pub fn yaml_conversion(msg: impl Into<String>) -> Self {
401 Self::YamlConversion(msg.into())
402 }
403
404 /// Create an XML conversion error.
405 ///
406 /// # Arguments
407 ///
408 /// * `msg` - The XML conversion error message
409 pub fn xml_conversion(msg: impl Into<String>) -> Self {
410 Self::XmlConversion(msg.into())
411 }
412
413 /// Create a CSV conversion error.
414 ///
415 /// # Arguments
416 ///
417 /// * `msg` - The CSV conversion error message
418 pub fn csv_conversion(msg: impl Into<String>) -> Self {
419 Self::CsvConversion(msg.into())
420 }
421
422 /// Create a Parquet conversion error.
423 ///
424 /// # Arguments
425 ///
426 /// * `msg` - The Parquet conversion error message
427 pub fn parquet_conversion(msg: impl Into<String>) -> Self {
428 Self::ParquetConversion(msg.into())
429 }
430
431 /// Create a thread pool error.
432 ///
433 /// # Arguments
434 ///
435 /// * `msg` - The error message from Rayon
436 /// * `requested_threads` - The number of threads requested
437 ///
438 /// # Examples
439 ///
440 /// ```rust,no_run
441 /// use hedl_cli::error::CliError;
442 ///
443 /// let err = CliError::thread_pool_error("thread count must be positive", 0);
444 /// ```
445 pub fn thread_pool_error(msg: impl Into<String>, requested_threads: usize) -> Self {
446 Self::ThreadPoolError {
447 message: msg.into(),
448 requested_threads,
449 }
450 }
451
452 /// Check if two errors are similar for grouping purposes.
453 ///
454 /// Errors are considered similar if they have the same variant type,
455 /// allowing aggregation of similar errors in batch processing.
456 ///
457 /// # Examples
458 ///
459 /// ```rust
460 /// use hedl_cli::error::CliError;
461 ///
462 /// let err1 = CliError::parse("syntax error");
463 /// let err2 = CliError::parse("unexpected token");
464 /// assert!(err1.similar_to(&err2));
465 ///
466 /// let err3 = CliError::NotCanonical;
467 /// assert!(!err1.similar_to(&err3));
468 /// ```
469 #[must_use]
470 pub fn similar_to(&self, other: &CliError) -> bool {
471 std::mem::discriminant(self) == std::mem::discriminant(other)
472 }
473
474 /// Get the error category for reporting.
475 ///
476 /// Categorizes errors into broad types for summary reporting.
477 ///
478 /// # Examples
479 ///
480 /// ```rust
481 /// use hedl_cli::error::{CliError, ErrorCategory};
482 ///
483 /// let err = CliError::parse("syntax error");
484 /// assert!(matches!(err.category(), ErrorCategory::ParseError));
485 /// ```
486 #[must_use]
487 pub fn category(&self) -> ErrorCategory {
488 match self {
489 CliError::Io { .. } | CliError::FileTooLarge { .. } | CliError::IoTimeout { .. } => {
490 ErrorCategory::IoError
491 }
492 CliError::Parse(_) => ErrorCategory::ParseError,
493 CliError::Canonicalization(_) | CliError::NotCanonical => ErrorCategory::FormatError,
494 CliError::LintErrors => ErrorCategory::LintError,
495 CliError::GlobPattern { .. }
496 | CliError::NoFilesMatched { .. }
497 | CliError::DirectoryTraversal { .. } => ErrorCategory::FileDiscoveryError,
498 CliError::ResourceExhaustion { .. } | CliError::ThreadPoolError { .. } => {
499 ErrorCategory::ResourceError
500 }
501 CliError::JsonConversion(_)
502 | CliError::JsonFormat { .. }
503 | CliError::YamlConversion(_)
504 | CliError::XmlConversion(_)
505 | CliError::CsvConversion(_)
506 | CliError::ParquetConversion(_) => ErrorCategory::ConversionError,
507 CliError::InvalidInput(_) => ErrorCategory::ValidationError,
508 }
509 }
510}
511
512/// Error category for classification and reporting.
513///
514/// Used to group errors by type in batch processing reports.
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub enum ErrorCategory {
517 /// I/O errors (file not found, permission denied, etc.)
518 IoError,
519 /// Parsing errors (syntax errors, malformed input)
520 ParseError,
521 /// Formatting/canonicalization errors
522 FormatError,
523 /// Lint errors and warnings
524 LintError,
525 /// File discovery errors (glob patterns, directory traversal)
526 FileDiscoveryError,
527 /// Resource exhaustion (memory, file handles, threads)
528 ResourceError,
529 /// Format conversion errors (JSON, YAML, XML, CSV, Parquet)
530 ConversionError,
531 /// Input validation errors
532 ValidationError,
533}
534
535// Automatic conversion from serde_json::Error
536impl From<serde_json::Error> for CliError {
537 fn from(source: serde_json::Error) -> Self {
538 Self::JsonFormat {
539 message: source.to_string(),
540 }
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn test_io_error_display() {
550 let err = CliError::io_error(
551 "test.hedl",
552 io::Error::new(io::ErrorKind::NotFound, "file not found"),
553 );
554 let msg = err.to_string();
555 assert!(msg.contains("test.hedl"));
556 assert!(msg.contains("file not found"));
557 }
558
559 #[test]
560 fn test_file_too_large_display() {
561 let err = CliError::file_too_large("big.hedl", 200_000_000, 100 * 1024 * 1024);
562 let msg = err.to_string();
563 assert!(msg.contains("big.hedl"));
564 assert!(msg.contains("200000000 bytes"));
565 assert!(msg.contains("100 MB"));
566 }
567
568 #[test]
569 fn test_io_timeout_display() {
570 let err = CliError::io_timeout("/slow/file.hedl", 30);
571 let msg = err.to_string();
572 assert!(msg.contains("/slow/file.hedl"));
573 assert!(msg.contains("30 seconds"));
574 }
575
576 #[test]
577 fn test_parse_error_display() {
578 let err = CliError::parse("unexpected token");
579 assert_eq!(err.to_string(), "Parse error: unexpected token");
580 }
581
582 #[test]
583 fn test_invalid_input_display() {
584 let err = CliError::invalid_input("CSV file is empty");
585 assert_eq!(err.to_string(), "Invalid input: CSV file is empty");
586 }
587
588 #[test]
589 fn test_json_format_error_conversion() {
590 let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
591 let cli_err: CliError = json_err.into();
592 assert!(matches!(cli_err, CliError::JsonFormat { .. }));
593 }
594
595 #[test]
596 fn test_error_cloning() {
597 let err = CliError::io_error(
598 "test.hedl",
599 io::Error::new(io::ErrorKind::NotFound, "not found"),
600 );
601 let cloned = err.clone();
602 assert_eq!(err.to_string(), cloned.to_string());
603 }
604}