1use std::path::PathBuf;
7use thiserror::Error;
8
9#[derive(Error, Debug)]
11#[non_exhaustive]
12pub enum SbomDiffError {
13 #[error("Failed to parse SBOM: {context}")]
15 Parse {
16 context: String,
17 #[source]
18 source: ParseErrorKind,
19 },
20
21 #[error("Diff computation failed: {context}")]
23 Diff {
24 context: String,
25 #[source]
26 source: DiffErrorKind,
27 },
28
29 #[error("Report generation failed: {context}")]
31 Report {
32 context: String,
33 #[source]
34 source: ReportErrorKind,
35 },
36
37 #[error("Matching operation failed: {context}")]
39 Matching {
40 context: String,
41 #[source]
42 source: MatchingErrorKind,
43 },
44
45 #[error("Enrichment failed: {context}")]
47 Enrichment {
48 context: String,
49 #[source]
50 source: EnrichmentErrorKind,
51 },
52
53 #[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 #[error("Invalid configuration: {0}")]
64 Config(String),
65
66 #[error("Validation failed: {0}")]
68 Validation(String),
69}
70
71#[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#[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#[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#[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#[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
174pub type Result<T> = std::result::Result<T, SbomDiffError>;
180
181impl SbomDiffError {
186 pub fn parse(context: impl Into<String>, source: ParseErrorKind) -> Self {
188 Self::Parse {
189 context: context.into(),
190 source,
191 }
192 }
193
194 pub fn unknown_format(path: impl Into<String>) -> Self {
196 Self::parse(format!("at {}", path.into()), ParseErrorKind::UnknownFormat)
197 }
198
199 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 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 pub fn validation(message: impl Into<String>) -> Self {
223 Self::Validation(message.into())
224 }
225
226 pub fn config(message: impl Into<String>) -> Self {
228 Self::Config(message.into())
229 }
230
231 pub fn diff(context: impl Into<String>, source: DiffErrorKind) -> Self {
233 Self::Diff {
234 context: context.into(),
235 source,
236 }
237 }
238
239 pub fn report(context: impl Into<String>, source: ReportErrorKind) -> Self {
241 Self::Report {
242 context: context.into(),
243 source,
244 }
245 }
246
247 pub fn enrichment(context: impl Into<String>, source: EnrichmentErrorKind) -> Self {
249 Self::Enrichment {
250 context: context.into(),
251 source,
252 }
253 }
254}
255
256impl 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
279pub trait ErrorContext<T> {
310 fn context(self, context: impl Into<String>) -> Result<T>;
315
316 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
344fn 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
396fn 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
408pub trait OptionContext<T> {
410 fn context_none(self, context: impl Into<String>) -> Result<T>;
412
413 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 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 let initial_err: Result<()> = Err(SbomDiffError::parse(
470 "initial context",
471 ParseErrorKind::UnknownFormat,
472 ));
473
474 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 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 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 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}