1use std::path::PathBuf;
7use thiserror::Error;
8
9#[derive(Error, Debug)]
11pub enum SbomDiffError {
12 #[error("Failed to parse SBOM: {context}")]
14 Parse {
15 context: String,
16 #[source]
17 source: ParseErrorKind,
18 },
19
20 #[error("Diff computation failed: {context}")]
22 Diff {
23 context: String,
24 #[source]
25 source: DiffErrorKind,
26 },
27
28 #[error("Report generation failed: {context}")]
30 Report {
31 context: String,
32 #[source]
33 source: ReportErrorKind,
34 },
35
36 #[error("Matching operation failed: {context}")]
38 Matching {
39 context: String,
40 #[source]
41 source: MatchingErrorKind,
42 },
43
44 #[error("Enrichment failed: {context}")]
46 Enrichment {
47 context: String,
48 #[source]
49 source: EnrichmentErrorKind,
50 },
51
52 #[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 #[error("Invalid configuration: {0}")]
63 Config(String),
64
65 #[error("Validation failed: {0}")]
67 Validation(String),
68}
69
70#[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#[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#[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#[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#[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
168pub type Result<T> = std::result::Result<T, SbomDiffError>;
174
175impl SbomDiffError {
180 pub fn parse(context: impl Into<String>, source: ParseErrorKind) -> Self {
182 Self::Parse {
183 context: context.into(),
184 source,
185 }
186 }
187
188 pub fn unknown_format(path: impl Into<String>) -> Self {
190 Self::parse(format!("at {}", path.into()), ParseErrorKind::UnknownFormat)
191 }
192
193 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 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 pub fn validation(message: impl Into<String>) -> Self {
217 Self::Validation(message.into())
218 }
219
220 pub fn config(message: impl Into<String>) -> Self {
222 Self::Config(message.into())
223 }
224
225 pub fn diff(context: impl Into<String>, source: DiffErrorKind) -> Self {
227 Self::Diff {
228 context: context.into(),
229 source,
230 }
231 }
232
233 pub fn report(context: impl Into<String>, source: ReportErrorKind) -> Self {
235 Self::Report {
236 context: context.into(),
237 source,
238 }
239 }
240
241 pub fn enrichment(context: impl Into<String>, source: EnrichmentErrorKind) -> Self {
243 Self::Enrichment {
244 context: context.into(),
245 source,
246 }
247 }
248}
249
250impl 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
273pub trait ErrorContext<T> {
304 fn context(self, context: impl Into<String>) -> Result<T>;
309
310 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
334fn 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
388fn 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
400pub trait OptionContext<T> {
402 fn context_none(self, context: impl Into<String>) -> Result<T>;
404
405 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 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 let initial_err: Result<()> = Err(SbomDiffError::parse(
462 "initial context",
463 ParseErrorKind::UnknownFormat,
464 ));
465
466 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 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 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 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}