Skip to main content

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}