Skip to main content

sbom_tools/
error.rs

1//! Unified error types for sbom-tools.
2//!
3//! This module provides a comprehensive error hierarchy for the library,
4//! with rich context for debugging and user-friendly messages.
5
6use std::path::PathBuf;
7use thiserror::Error;
8
9/// Main error type for sbom-tools operations.
10#[derive(Error, Debug)]
11pub enum SbomDiffError {
12    /// Errors during SBOM parsing
13    #[error("Failed to parse SBOM: {context}")]
14    Parse {
15        context: String,
16        #[source]
17        source: ParseErrorKind,
18    },
19
20    /// Errors during diff computation
21    #[error("Diff computation failed: {context}")]
22    Diff {
23        context: String,
24        #[source]
25        source: DiffErrorKind,
26    },
27
28    /// Errors during report generation
29    #[error("Report generation failed: {context}")]
30    Report {
31        context: String,
32        #[source]
33        source: ReportErrorKind,
34    },
35
36    /// Errors during matching operations
37    #[error("Matching operation failed: {context}")]
38    Matching {
39        context: String,
40        #[source]
41        source: MatchingErrorKind,
42    },
43
44    /// Errors during enrichment operations
45    #[error("Enrichment failed: {context}")]
46    Enrichment {
47        context: String,
48        #[source]
49        source: EnrichmentErrorKind,
50    },
51
52    /// IO errors with context
53    #[error("IO error at {path:?}: {message}")]
54    Io {
55        path: Option<PathBuf>,
56        message: String,
57        #[source]
58        source: std::io::Error,
59    },
60
61    /// Configuration errors
62    #[error("Invalid configuration: {0}")]
63    Config(String),
64
65    /// Validation errors
66    #[error("Validation failed: {0}")]
67    Validation(String),
68}
69
70/// Specific parse error kinds
71#[derive(Error, Debug)]
72pub enum ParseErrorKind {
73    #[error("Unknown SBOM format - expected CycloneDX or SPDX markers")]
74    UnknownFormat,
75
76    #[error("Unsupported format version: {version} (supported: {supported})")]
77    UnsupportedVersion { version: String, supported: String },
78
79    #[error("Invalid JSON structure: {0}")]
80    InvalidJson(String),
81
82    #[error("Invalid XML structure: {0}")]
83    InvalidXml(String),
84
85    #[error("Missing required field: {field} in {context}")]
86    MissingField { field: String, context: String },
87
88    #[error("Invalid field value for '{field}': {message}")]
89    InvalidValue { field: String, message: String },
90
91    #[error("Malformed PURL: {purl} - {reason}")]
92    InvalidPurl { purl: String, reason: String },
93
94    #[error("CycloneDX parsing error: {0}")]
95    CycloneDx(String),
96
97    #[error("SPDX parsing error: {0}")]
98    Spdx(String),
99}
100
101/// Specific diff error kinds
102#[derive(Error, Debug)]
103pub enum DiffErrorKind {
104    #[error("Component matching failed: {0}")]
105    MatchingFailed(String),
106
107    #[error("Cost model configuration error: {0}")]
108    CostModelError(String),
109
110    #[error("Graph construction failed: {0}")]
111    GraphError(String),
112
113    #[error("Empty SBOM provided")]
114    EmptySbom,
115}
116
117/// Specific report error kinds
118#[derive(Error, Debug)]
119pub enum ReportErrorKind {
120    #[error("Template rendering failed: {0}")]
121    TemplateError(String),
122
123    #[error("JSON serialization failed: {0}")]
124    JsonSerializationError(String),
125
126    #[error("SARIF generation failed: {0}")]
127    SarifError(String),
128
129    #[error("Output format not supported for this operation: {0}")]
130    UnsupportedFormat(String),
131}
132
133/// Specific matching error kinds
134#[derive(Error, Debug)]
135pub enum MatchingErrorKind {
136    #[error("Alias table not found: {0}")]
137    AliasTableNotFound(String),
138
139    #[error("Invalid threshold value: {0} (must be 0.0-1.0)")]
140    InvalidThreshold(f64),
141
142    #[error("Ecosystem not supported: {0}")]
143    UnsupportedEcosystem(String),
144}
145
146/// Specific enrichment error kinds
147#[derive(Error, Debug)]
148pub enum EnrichmentErrorKind {
149    #[error("API request failed: {0}")]
150    ApiError(String),
151
152    #[error("Network error: {0}")]
153    NetworkError(String),
154
155    #[error("Cache error: {0}")]
156    CacheError(String),
157
158    #[error("Invalid response format: {0}")]
159    InvalidResponse(String),
160
161    #[error("Rate limited: {0}")]
162    RateLimited(String),
163
164    #[error("Provider unavailable: {0}")]
165    ProviderUnavailable(String),
166}
167
168// ============================================================================
169// Result type alias
170// ============================================================================
171
172/// Convenient Result type for sbom-tools operations
173pub type Result<T> = std::result::Result<T, SbomDiffError>;
174
175// ============================================================================
176// Error construction helpers
177// ============================================================================
178
179impl SbomDiffError {
180    /// Create a parse error with context
181    pub fn parse(context: impl Into<String>, source: ParseErrorKind) -> Self {
182        Self::Parse {
183            context: context.into(),
184            source,
185        }
186    }
187
188    /// Create a parse error for unknown format
189    pub fn unknown_format(path: impl Into<String>) -> Self {
190        Self::parse(format!("at {}", path.into()), ParseErrorKind::UnknownFormat)
191    }
192
193    /// Create a parse error for missing field
194    pub fn missing_field(field: impl Into<String>, context: impl Into<String>) -> Self {
195        Self::parse(
196            "missing required field",
197            ParseErrorKind::MissingField {
198                field: field.into(),
199                context: context.into(),
200            },
201        )
202    }
203
204    /// Create an IO error with path context
205    pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
206        let path = path.into();
207        let message = format!("{}", source);
208        Self::Io {
209            path: Some(path),
210            message,
211            source,
212        }
213    }
214
215    /// Create a validation error
216    pub fn validation(message: impl Into<String>) -> Self {
217        Self::Validation(message.into())
218    }
219
220    /// Create a config error
221    pub fn config(message: impl Into<String>) -> Self {
222        Self::Config(message.into())
223    }
224
225    /// Create a diff error
226    pub fn diff(context: impl Into<String>, source: DiffErrorKind) -> Self {
227        Self::Diff {
228            context: context.into(),
229            source,
230        }
231    }
232
233    /// Create a report error
234    pub fn report(context: impl Into<String>, source: ReportErrorKind) -> Self {
235        Self::Report {
236            context: context.into(),
237            source,
238        }
239    }
240
241    /// Create an enrichment error
242    pub fn enrichment(context: impl Into<String>, source: EnrichmentErrorKind) -> Self {
243        Self::Enrichment {
244            context: context.into(),
245            source,
246        }
247    }
248}
249
250// ============================================================================
251// Conversions from existing error types
252// ============================================================================
253
254impl From<std::io::Error> for SbomDiffError {
255    fn from(err: std::io::Error) -> Self {
256        Self::Io {
257            path: None,
258            message: format!("{}", err),
259            source: err,
260        }
261    }
262}
263
264impl From<serde_json::Error> for SbomDiffError {
265    fn from(err: serde_json::Error) -> Self {
266        Self::parse(
267            "JSON deserialization",
268            ParseErrorKind::InvalidJson(err.to_string()),
269        )
270    }
271}
272
273// ============================================================================
274// Error context extension trait
275// ============================================================================
276
277/// Extension trait for adding context to errors.
278///
279/// This trait provides methods to add context information to errors,
280/// creating a chain of context that helps trace the source of problems.
281///
282/// # Example
283///
284/// ```ignore
285/// use sbom_tools::error::ErrorContext;
286///
287/// fn parse_component(data: &str) -> Result<Component> {
288///     let json: Value = serde_json::from_str(data)
289///         .context("parsing component JSON")?;
290///
291///     extract_component(&json)
292///         .with_context(|| format!("extracting component from {}", data.chars().take(50).collect::<String>()))?
293/// }
294///
295/// fn load_sbom(path: &Path) -> Result<NormalizedSbom> {
296///     let content = std::fs::read_to_string(path)
297///         .context("reading SBOM file")?;
298///
299///     parse_sbom_str(&content)
300///         .with_context(|| format!("parsing SBOM from {}", path.display()))?
301/// }
302/// ```
303pub trait ErrorContext<T> {
304    /// Add context to an error.
305    ///
306    /// The context string is prepended to the error's existing context,
307    /// creating a chain that shows the path through the code.
308    fn context(self, context: impl Into<String>) -> Result<T>;
309
310    /// Add context from a closure (lazy evaluation).
311    ///
312    /// The closure is only called if the result is an error,
313    /// which is more efficient when the context string is expensive to compute.
314    fn with_context<F, C>(self, f: F) -> Result<T>
315    where
316        F: FnOnce() -> C,
317        C: Into<String>;
318}
319
320impl<T, E: Into<SbomDiffError>> ErrorContext<T> for std::result::Result<T, E> {
321    fn context(self, context: impl Into<String>) -> Result<T> {
322        self.map_err(|e| add_context_to_error(e.into(), context.into()))
323    }
324
325    fn with_context<F, C>(self, f: F) -> Result<T>
326    where
327        F: FnOnce() -> C,
328        C: Into<String>,
329    {
330        self.map_err(|e| add_context_to_error(e.into(), f().into()))
331    }
332}
333
334/// Add context to an error, chaining with any existing context.
335fn add_context_to_error(err: SbomDiffError, new_ctx: String) -> SbomDiffError {
336    match err {
337        SbomDiffError::Parse {
338            context: existing,
339            source,
340        } => SbomDiffError::Parse {
341            context: chain_context(&new_ctx, &existing),
342            source,
343        },
344        SbomDiffError::Diff {
345            context: existing,
346            source,
347        } => SbomDiffError::Diff {
348            context: chain_context(&new_ctx, &existing),
349            source,
350        },
351        SbomDiffError::Report {
352            context: existing,
353            source,
354        } => SbomDiffError::Report {
355            context: chain_context(&new_ctx, &existing),
356            source,
357        },
358        SbomDiffError::Matching {
359            context: existing,
360            source,
361        } => SbomDiffError::Matching {
362            context: chain_context(&new_ctx, &existing),
363            source,
364        },
365        SbomDiffError::Enrichment {
366            context: existing,
367            source,
368        } => SbomDiffError::Enrichment {
369            context: chain_context(&new_ctx, &existing),
370            source,
371        },
372        SbomDiffError::Io {
373            path,
374            message,
375            source,
376        } => SbomDiffError::Io {
377            path,
378            message: chain_context(&new_ctx, &message),
379            source,
380        },
381        SbomDiffError::Config(msg) => SbomDiffError::Config(chain_context(&new_ctx, &msg)),
382        SbomDiffError::Validation(msg) => {
383            SbomDiffError::Validation(chain_context(&new_ctx, &msg))
384        }
385    }
386}
387
388/// Chain two context strings together.
389///
390/// If the existing context is empty, returns just the new context.
391/// Otherwise, returns "new_context: existing_context".
392fn chain_context(new: &str, existing: &str) -> String {
393    if existing.is_empty() {
394        new.to_string()
395    } else {
396        format!("{}: {}", new, existing)
397    }
398}
399
400/// Extension trait for Option types to convert to errors with context.
401pub trait OptionContext<T> {
402    /// Convert None to an error with the given context.
403    fn context_none(self, context: impl Into<String>) -> Result<T>;
404
405    /// Convert None to an error with context from a closure.
406    fn with_context_none<F, C>(self, f: F) -> Result<T>
407    where
408        F: FnOnce() -> C,
409        C: Into<String>;
410}
411
412impl<T> OptionContext<T> for Option<T> {
413    fn context_none(self, context: impl Into<String>) -> Result<T> {
414        self.ok_or_else(|| SbomDiffError::Validation(context.into()))
415    }
416
417    fn with_context_none<F, C>(self, f: F) -> Result<T>
418    where
419        F: FnOnce() -> C,
420        C: Into<String>,
421    {
422        self.ok_or_else(|| SbomDiffError::Validation(f().into()))
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_error_display() {
432        let err = SbomDiffError::unknown_format("test.json");
433        // The error wraps ParseErrorKind::UnknownFormat which says "Unknown SBOM format"
434        let display = err.to_string();
435        assert!(
436            display.contains("parse") || display.contains("SBOM"),
437            "Error message should mention parsing or SBOM: {}",
438            display
439        );
440
441        let err = SbomDiffError::missing_field("version", "component");
442        let display = err.to_string();
443        assert!(
444            display.contains("Missing") || display.contains("field"),
445            "Error message should mention missing field: {}",
446            display
447        );
448    }
449
450    #[test]
451    fn test_error_chain() {
452        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
453        let err = SbomDiffError::io("/path/to/file.json", io_err);
454
455        assert!(err.to_string().contains("/path/to/file.json"));
456    }
457
458    #[test]
459    fn test_context_chaining() {
460        // Create an initial error
461        let initial_err: Result<()> = Err(SbomDiffError::parse(
462            "initial context",
463            ParseErrorKind::UnknownFormat,
464        ));
465
466        // Add context - it should chain, not replace
467        let err_with_context = initial_err.context("outer context");
468
469        match err_with_context {
470            Err(SbomDiffError::Parse { context, .. }) => {
471                assert!(
472                    context.contains("outer context"),
473                    "Should contain outer context: {}",
474                    context
475                );
476                assert!(
477                    context.contains("initial context"),
478                    "Should contain initial context: {}",
479                    context
480                );
481            }
482            _ => panic!("Expected Parse error"),
483        }
484    }
485
486    #[test]
487    fn test_context_chaining_multiple_levels() {
488        fn inner() -> Result<()> {
489            Err(SbomDiffError::parse("base", ParseErrorKind::UnknownFormat))
490        }
491
492        fn middle() -> Result<()> {
493            inner().context("middle layer")
494        }
495
496        fn outer() -> Result<()> {
497            middle().context("outer layer")
498        }
499
500        let result = outer();
501        match result {
502            Err(SbomDiffError::Parse { context, .. }) => {
503                // Context should be chained: "outer layer: middle layer: base"
504                assert!(
505                    context.contains("outer layer"),
506                    "Missing outer: {}",
507                    context
508                );
509                assert!(
510                    context.contains("middle layer"),
511                    "Missing middle: {}",
512                    context
513                );
514                assert!(context.contains("base"), "Missing base: {}", context);
515            }
516            _ => panic!("Expected Parse error"),
517        }
518    }
519
520    #[test]
521    fn test_with_context_lazy_evaluation() {
522        let mut called = false;
523
524        // This should NOT call the closure
525        let ok_result: Result<i32> = Ok(42);
526        let _ = ok_result.with_context(|| {
527            called = true;
528            "should not be called"
529        });
530        assert!(!called, "Closure should not be called for Ok result");
531
532        // This SHOULD call the closure
533        let err_result: Result<i32> = Err(SbomDiffError::validation("error"));
534        let _ = err_result.with_context(|| {
535            called = true;
536            "should be called"
537        });
538        assert!(called, "Closure should be called for Err result");
539    }
540
541    #[test]
542    fn test_option_context() {
543        let some_value: Option<i32> = Some(42);
544        let result = some_value.context_none("missing value");
545        assert!(result.is_ok());
546        assert_eq!(result.unwrap(), 42);
547
548        let none_value: Option<i32> = None;
549        let result = none_value.context_none("missing value");
550        assert!(result.is_err());
551        match result {
552            Err(SbomDiffError::Validation(msg)) => {
553                assert_eq!(msg, "missing value");
554            }
555            _ => panic!("Expected Validation error"),
556        }
557    }
558
559    #[test]
560    fn test_chain_context_helper() {
561        assert_eq!(chain_context("new", ""), "new");
562        assert_eq!(chain_context("new", "existing"), "new: existing");
563        assert_eq!(
564            chain_context("outer", "middle: inner"),
565            "outer: middle: inner"
566        );
567    }
568}