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    #[error("Invalid matching rule: {0}")]
151    InvalidRule(String),
152}
153
154/// Specific enrichment error kinds
155#[derive(Error, Debug)]
156#[non_exhaustive]
157pub enum EnrichmentErrorKind {
158    #[error("API request failed: {0}")]
159    ApiError(String),
160
161    #[error("Network error: {0}")]
162    NetworkError(String),
163
164    #[error("Cache error: {0}")]
165    CacheError(String),
166
167    #[error("Invalid response format: {0}")]
168    InvalidResponse(String),
169
170    #[error("Rate limited: {0}")]
171    RateLimited(String),
172
173    #[error("Provider unavailable: {0}")]
174    ProviderUnavailable(String),
175
176    #[error("offline: not in cache ({0})")]
177    Offline(String),
178}
179
180// ============================================================================
181// Result type alias
182// ============================================================================
183
184/// Convenient Result type for sbom-tools operations
185pub type Result<T> = std::result::Result<T, SbomDiffError>;
186
187// ============================================================================
188// Error construction helpers
189// ============================================================================
190
191impl SbomDiffError {
192    /// Create a parse error with context
193    pub fn parse(context: impl Into<String>, source: ParseErrorKind) -> Self {
194        Self::Parse {
195            context: context.into(),
196            source,
197        }
198    }
199
200    /// Create a parse error for unknown format
201    pub fn unknown_format(path: impl Into<String>) -> Self {
202        Self::parse(format!("at {}", path.into()), ParseErrorKind::UnknownFormat)
203    }
204
205    /// Create a parse error for missing field
206    pub fn missing_field(field: impl Into<String>, context: impl Into<String>) -> Self {
207        Self::parse(
208            "missing required field",
209            ParseErrorKind::MissingField {
210                field: field.into(),
211                context: context.into(),
212            },
213        )
214    }
215
216    /// Create an IO error with path context
217    pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
218        let path = path.into();
219        let message = format!("{source}");
220        Self::Io {
221            path: Some(path),
222            message,
223            source,
224        }
225    }
226
227    /// Create a validation error
228    pub fn validation(message: impl Into<String>) -> Self {
229        Self::Validation(message.into())
230    }
231
232    /// Create a config error
233    pub fn config(message: impl Into<String>) -> Self {
234        Self::Config(message.into())
235    }
236
237    /// Create a diff error
238    pub fn diff(context: impl Into<String>, source: DiffErrorKind) -> Self {
239        Self::Diff {
240            context: context.into(),
241            source,
242        }
243    }
244
245    /// Create a report error
246    pub fn report(context: impl Into<String>, source: ReportErrorKind) -> Self {
247        Self::Report {
248            context: context.into(),
249            source,
250        }
251    }
252
253    /// Create an enrichment error
254    pub fn enrichment(context: impl Into<String>, source: EnrichmentErrorKind) -> Self {
255        Self::Enrichment {
256            context: context.into(),
257            source,
258        }
259    }
260}
261
262// ============================================================================
263// Conversions from existing error types
264// ============================================================================
265
266impl From<std::io::Error> for SbomDiffError {
267    fn from(err: std::io::Error) -> Self {
268        Self::Io {
269            path: None,
270            message: format!("{err}"),
271            source: err,
272        }
273    }
274}
275
276impl From<serde_json::Error> for SbomDiffError {
277    fn from(err: serde_json::Error) -> Self {
278        Self::parse(
279            "JSON deserialization",
280            ParseErrorKind::InvalidJson(err.to_string()),
281        )
282    }
283}
284
285// ============================================================================
286// Error context extension trait
287// ============================================================================
288
289/// Extension trait for adding context to errors.
290///
291/// This trait provides methods to add context information to errors,
292/// creating a chain of context that helps trace the source of problems.
293///
294/// # Example
295///
296/// ```ignore
297/// use sbom_tools::error::ErrorContext;
298///
299/// fn parse_component(data: &str) -> Result<Component> {
300///     let json: Value = serde_json::from_str(data)
301///         .context("parsing component JSON")?;
302///
303///     extract_component(&json)
304///         .with_context(|| format!("extracting component from {}", data.chars().take(50).collect::<String>()))?
305/// }
306///
307/// fn load_sbom(path: &Path) -> Result<NormalizedSbom> {
308///     let content = std::fs::read_to_string(path)
309///         .context("reading SBOM file")?;
310///
311///     parse_sbom_str(&content)
312///         .with_context(|| format!("parsing SBOM from {}", path.display()))?
313/// }
314/// ```
315pub trait ErrorContext<T> {
316    /// Add context to an error.
317    ///
318    /// The context string is prepended to the error's existing context,
319    /// creating a chain that shows the path through the code.
320    fn context(self, context: impl Into<String>) -> Result<T>;
321
322    /// Add context from a closure (lazy evaluation).
323    ///
324    /// The closure is only called if the result is an error,
325    /// which is more efficient when the context string is expensive to compute.
326    fn with_context<F, C>(self, f: F) -> Result<T>
327    where
328        F: FnOnce() -> C,
329        C: Into<String>;
330}
331
332impl<T, E: Into<SbomDiffError>> ErrorContext<T> for std::result::Result<T, E> {
333    fn context(self, context: impl Into<String>) -> Result<T> {
334        let ctx: String = context.into();
335        self.map_err(|e| add_context_to_error(e.into(), &ctx))
336    }
337
338    fn with_context<F, C>(self, f: F) -> Result<T>
339    where
340        F: FnOnce() -> C,
341        C: Into<String>,
342    {
343        self.map_err(|e| {
344            let ctx: String = f().into();
345            add_context_to_error(e.into(), &ctx)
346        })
347    }
348}
349
350/// Add context to an error, chaining with any existing context.
351fn add_context_to_error(err: SbomDiffError, new_ctx: &str) -> SbomDiffError {
352    match err {
353        SbomDiffError::Parse {
354            context: existing,
355            source,
356        } => SbomDiffError::Parse {
357            context: chain_context(new_ctx, &existing),
358            source,
359        },
360        SbomDiffError::Diff {
361            context: existing,
362            source,
363        } => SbomDiffError::Diff {
364            context: chain_context(new_ctx, &existing),
365            source,
366        },
367        SbomDiffError::Report {
368            context: existing,
369            source,
370        } => SbomDiffError::Report {
371            context: chain_context(new_ctx, &existing),
372            source,
373        },
374        SbomDiffError::Matching {
375            context: existing,
376            source,
377        } => SbomDiffError::Matching {
378            context: chain_context(new_ctx, &existing),
379            source,
380        },
381        SbomDiffError::Enrichment {
382            context: existing,
383            source,
384        } => SbomDiffError::Enrichment {
385            context: chain_context(new_ctx, &existing),
386            source,
387        },
388        SbomDiffError::Io {
389            path,
390            message,
391            source,
392        } => SbomDiffError::Io {
393            path,
394            message: chain_context(new_ctx, &message),
395            source,
396        },
397        SbomDiffError::Config(msg) => SbomDiffError::Config(chain_context(new_ctx, &msg)),
398        SbomDiffError::Validation(msg) => SbomDiffError::Validation(chain_context(new_ctx, &msg)),
399    }
400}
401
402/// Chain two context strings together.
403///
404/// If the existing context is empty, returns just the new context.
405/// Otherwise, returns "`new_context`: `existing_context`".
406fn chain_context(new: &str, existing: &str) -> String {
407    if existing.is_empty() {
408        new.to_string()
409    } else {
410        format!("{new}: {existing}")
411    }
412}
413
414/// Extension trait for Option types to convert to errors with context.
415pub trait OptionContext<T> {
416    /// Convert None to an error with the given context.
417    fn context_none(self, context: impl Into<String>) -> Result<T>;
418
419    /// Convert None to an error with context from a closure.
420    fn with_context_none<F, C>(self, f: F) -> Result<T>
421    where
422        F: FnOnce() -> C,
423        C: Into<String>;
424}
425
426impl<T> OptionContext<T> for Option<T> {
427    fn context_none(self, context: impl Into<String>) -> Result<T> {
428        self.ok_or_else(|| SbomDiffError::Validation(context.into()))
429    }
430
431    fn with_context_none<F, C>(self, f: F) -> Result<T>
432    where
433        F: FnOnce() -> C,
434        C: Into<String>,
435    {
436        self.ok_or_else(|| SbomDiffError::Validation(f().into()))
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_error_display() {
446        let err = SbomDiffError::unknown_format("test.json");
447        // The error wraps ParseErrorKind::UnknownFormat which says "Unknown SBOM format"
448        let display = err.to_string();
449        assert!(
450            display.contains("parse") || display.contains("SBOM"),
451            "Error message should mention parsing or SBOM: {}",
452            display
453        );
454
455        let err = SbomDiffError::missing_field("version", "component");
456        let display = err.to_string();
457        assert!(
458            display.contains("Missing") || display.contains("field"),
459            "Error message should mention missing field: {}",
460            display
461        );
462    }
463
464    #[test]
465    fn test_error_chain() {
466        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
467        let err = SbomDiffError::io("/path/to/file.json", io_err);
468
469        assert!(err.to_string().contains("/path/to/file.json"));
470    }
471
472    #[test]
473    fn test_context_chaining() {
474        // Create an initial error
475        let initial_err: Result<()> = Err(SbomDiffError::parse(
476            "initial context",
477            ParseErrorKind::UnknownFormat,
478        ));
479
480        // Add context - it should chain, not replace
481        let err_with_context = initial_err.context("outer context");
482
483        match err_with_context {
484            Err(SbomDiffError::Parse { context, .. }) => {
485                assert!(
486                    context.contains("outer context"),
487                    "Should contain outer context: {}",
488                    context
489                );
490                assert!(
491                    context.contains("initial context"),
492                    "Should contain initial context: {}",
493                    context
494                );
495            }
496            _ => panic!("Expected Parse error"),
497        }
498    }
499
500    #[test]
501    fn test_context_chaining_multiple_levels() {
502        fn inner() -> Result<()> {
503            Err(SbomDiffError::parse("base", ParseErrorKind::UnknownFormat))
504        }
505
506        fn middle() -> Result<()> {
507            inner().context("middle layer")
508        }
509
510        fn outer() -> Result<()> {
511            middle().context("outer layer")
512        }
513
514        let result = outer();
515        match result {
516            Err(SbomDiffError::Parse { context, .. }) => {
517                // Context should be chained: "outer layer: middle layer: base"
518                assert!(
519                    context.contains("outer layer"),
520                    "Missing outer: {}",
521                    context
522                );
523                assert!(
524                    context.contains("middle layer"),
525                    "Missing middle: {}",
526                    context
527                );
528                assert!(context.contains("base"), "Missing base: {}", context);
529            }
530            _ => panic!("Expected Parse error"),
531        }
532    }
533
534    #[test]
535    fn test_with_context_lazy_evaluation() {
536        let mut called = false;
537
538        // This should NOT call the closure
539        let ok_result: Result<i32> = Ok(42);
540        let _ = ok_result.with_context(|| {
541            called = true;
542            "should not be called"
543        });
544        assert!(!called, "Closure should not be called for Ok result");
545
546        // This SHOULD call the closure
547        let err_result: Result<i32> = Err(SbomDiffError::validation("error"));
548        let _ = err_result.with_context(|| {
549            called = true;
550            "should be called"
551        });
552        assert!(called, "Closure should be called for Err result");
553    }
554
555    #[test]
556    fn test_option_context() {
557        let some_value: Option<i32> = Some(42);
558        let result = some_value.context_none("missing value");
559        assert!(result.is_ok());
560        assert_eq!(result.unwrap(), 42);
561
562        let none_value: Option<i32> = None;
563        let result = none_value.context_none("missing value");
564        assert!(result.is_err());
565        match result {
566            Err(SbomDiffError::Validation(msg)) => {
567                assert_eq!(msg, "missing value");
568            }
569            _ => panic!("Expected Validation error"),
570        }
571    }
572
573    #[test]
574    fn test_chain_context_helper() {
575        assert_eq!(chain_context("new", ""), "new");
576        assert_eq!(chain_context("new", "existing"), "new: existing");
577        assert_eq!(
578            chain_context("outer", "middle: inner"),
579            "outer: middle: inner"
580        );
581    }
582}