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 #[error("offline: not in cache ({0})")]
174 Offline(String),
175}
176
177pub type Result<T> = std::result::Result<T, SbomDiffError>;
183
184impl SbomDiffError {
189 pub fn parse(context: impl Into<String>, source: ParseErrorKind) -> Self {
191 Self::Parse {
192 context: context.into(),
193 source,
194 }
195 }
196
197 pub fn unknown_format(path: impl Into<String>) -> Self {
199 Self::parse(format!("at {}", path.into()), ParseErrorKind::UnknownFormat)
200 }
201
202 pub fn missing_field(field: impl Into<String>, context: impl Into<String>) -> Self {
204 Self::parse(
205 "missing required field",
206 ParseErrorKind::MissingField {
207 field: field.into(),
208 context: context.into(),
209 },
210 )
211 }
212
213 pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
215 let path = path.into();
216 let message = format!("{source}");
217 Self::Io {
218 path: Some(path),
219 message,
220 source,
221 }
222 }
223
224 pub fn validation(message: impl Into<String>) -> Self {
226 Self::Validation(message.into())
227 }
228
229 pub fn config(message: impl Into<String>) -> Self {
231 Self::Config(message.into())
232 }
233
234 pub fn diff(context: impl Into<String>, source: DiffErrorKind) -> Self {
236 Self::Diff {
237 context: context.into(),
238 source,
239 }
240 }
241
242 pub fn report(context: impl Into<String>, source: ReportErrorKind) -> Self {
244 Self::Report {
245 context: context.into(),
246 source,
247 }
248 }
249
250 pub fn enrichment(context: impl Into<String>, source: EnrichmentErrorKind) -> Self {
252 Self::Enrichment {
253 context: context.into(),
254 source,
255 }
256 }
257}
258
259impl From<std::io::Error> for SbomDiffError {
264 fn from(err: std::io::Error) -> Self {
265 Self::Io {
266 path: None,
267 message: format!("{err}"),
268 source: err,
269 }
270 }
271}
272
273impl From<serde_json::Error> for SbomDiffError {
274 fn from(err: serde_json::Error) -> Self {
275 Self::parse(
276 "JSON deserialization",
277 ParseErrorKind::InvalidJson(err.to_string()),
278 )
279 }
280}
281
282pub trait ErrorContext<T> {
313 fn context(self, context: impl Into<String>) -> Result<T>;
318
319 fn with_context<F, C>(self, f: F) -> Result<T>
324 where
325 F: FnOnce() -> C,
326 C: Into<String>;
327}
328
329impl<T, E: Into<SbomDiffError>> ErrorContext<T> for std::result::Result<T, E> {
330 fn context(self, context: impl Into<String>) -> Result<T> {
331 let ctx: String = context.into();
332 self.map_err(|e| add_context_to_error(e.into(), &ctx))
333 }
334
335 fn with_context<F, C>(self, f: F) -> Result<T>
336 where
337 F: FnOnce() -> C,
338 C: Into<String>,
339 {
340 self.map_err(|e| {
341 let ctx: String = f().into();
342 add_context_to_error(e.into(), &ctx)
343 })
344 }
345}
346
347fn add_context_to_error(err: SbomDiffError, new_ctx: &str) -> SbomDiffError {
349 match err {
350 SbomDiffError::Parse {
351 context: existing,
352 source,
353 } => SbomDiffError::Parse {
354 context: chain_context(new_ctx, &existing),
355 source,
356 },
357 SbomDiffError::Diff {
358 context: existing,
359 source,
360 } => SbomDiffError::Diff {
361 context: chain_context(new_ctx, &existing),
362 source,
363 },
364 SbomDiffError::Report {
365 context: existing,
366 source,
367 } => SbomDiffError::Report {
368 context: chain_context(new_ctx, &existing),
369 source,
370 },
371 SbomDiffError::Matching {
372 context: existing,
373 source,
374 } => SbomDiffError::Matching {
375 context: chain_context(new_ctx, &existing),
376 source,
377 },
378 SbomDiffError::Enrichment {
379 context: existing,
380 source,
381 } => SbomDiffError::Enrichment {
382 context: chain_context(new_ctx, &existing),
383 source,
384 },
385 SbomDiffError::Io {
386 path,
387 message,
388 source,
389 } => SbomDiffError::Io {
390 path,
391 message: chain_context(new_ctx, &message),
392 source,
393 },
394 SbomDiffError::Config(msg) => SbomDiffError::Config(chain_context(new_ctx, &msg)),
395 SbomDiffError::Validation(msg) => SbomDiffError::Validation(chain_context(new_ctx, &msg)),
396 }
397}
398
399fn chain_context(new: &str, existing: &str) -> String {
404 if existing.is_empty() {
405 new.to_string()
406 } else {
407 format!("{new}: {existing}")
408 }
409}
410
411pub trait OptionContext<T> {
413 fn context_none(self, context: impl Into<String>) -> Result<T>;
415
416 fn with_context_none<F, C>(self, f: F) -> Result<T>
418 where
419 F: FnOnce() -> C,
420 C: Into<String>;
421}
422
423impl<T> OptionContext<T> for Option<T> {
424 fn context_none(self, context: impl Into<String>) -> Result<T> {
425 self.ok_or_else(|| SbomDiffError::Validation(context.into()))
426 }
427
428 fn with_context_none<F, C>(self, f: F) -> Result<T>
429 where
430 F: FnOnce() -> C,
431 C: Into<String>,
432 {
433 self.ok_or_else(|| SbomDiffError::Validation(f().into()))
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn test_error_display() {
443 let err = SbomDiffError::unknown_format("test.json");
444 let display = err.to_string();
446 assert!(
447 display.contains("parse") || display.contains("SBOM"),
448 "Error message should mention parsing or SBOM: {}",
449 display
450 );
451
452 let err = SbomDiffError::missing_field("version", "component");
453 let display = err.to_string();
454 assert!(
455 display.contains("Missing") || display.contains("field"),
456 "Error message should mention missing field: {}",
457 display
458 );
459 }
460
461 #[test]
462 fn test_error_chain() {
463 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
464 let err = SbomDiffError::io("/path/to/file.json", io_err);
465
466 assert!(err.to_string().contains("/path/to/file.json"));
467 }
468
469 #[test]
470 fn test_context_chaining() {
471 let initial_err: Result<()> = Err(SbomDiffError::parse(
473 "initial context",
474 ParseErrorKind::UnknownFormat,
475 ));
476
477 let err_with_context = initial_err.context("outer context");
479
480 match err_with_context {
481 Err(SbomDiffError::Parse { context, .. }) => {
482 assert!(
483 context.contains("outer context"),
484 "Should contain outer context: {}",
485 context
486 );
487 assert!(
488 context.contains("initial context"),
489 "Should contain initial context: {}",
490 context
491 );
492 }
493 _ => panic!("Expected Parse error"),
494 }
495 }
496
497 #[test]
498 fn test_context_chaining_multiple_levels() {
499 fn inner() -> Result<()> {
500 Err(SbomDiffError::parse("base", ParseErrorKind::UnknownFormat))
501 }
502
503 fn middle() -> Result<()> {
504 inner().context("middle layer")
505 }
506
507 fn outer() -> Result<()> {
508 middle().context("outer layer")
509 }
510
511 let result = outer();
512 match result {
513 Err(SbomDiffError::Parse { context, .. }) => {
514 assert!(
516 context.contains("outer layer"),
517 "Missing outer: {}",
518 context
519 );
520 assert!(
521 context.contains("middle layer"),
522 "Missing middle: {}",
523 context
524 );
525 assert!(context.contains("base"), "Missing base: {}", context);
526 }
527 _ => panic!("Expected Parse error"),
528 }
529 }
530
531 #[test]
532 fn test_with_context_lazy_evaluation() {
533 let mut called = false;
534
535 let ok_result: Result<i32> = Ok(42);
537 let _ = ok_result.with_context(|| {
538 called = true;
539 "should not be called"
540 });
541 assert!(!called, "Closure should not be called for Ok result");
542
543 let err_result: Result<i32> = Err(SbomDiffError::validation("error"));
545 let _ = err_result.with_context(|| {
546 called = true;
547 "should be called"
548 });
549 assert!(called, "Closure should be called for Err result");
550 }
551
552 #[test]
553 fn test_option_context() {
554 let some_value: Option<i32> = Some(42);
555 let result = some_value.context_none("missing value");
556 assert!(result.is_ok());
557 assert_eq!(result.unwrap(), 42);
558
559 let none_value: Option<i32> = None;
560 let result = none_value.context_none("missing value");
561 assert!(result.is_err());
562 match result {
563 Err(SbomDiffError::Validation(msg)) => {
564 assert_eq!(msg, "missing value");
565 }
566 _ => panic!("Expected Validation error"),
567 }
568 }
569
570 #[test]
571 fn test_chain_context_helper() {
572 assert_eq!(chain_context("new", ""), "new");
573 assert_eq!(chain_context("new", "existing"), "new: existing");
574 assert_eq!(
575 chain_context("outer", "middle: inner"),
576 "outer: middle: inner"
577 );
578 }
579}