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