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 #[error("Invalid matching rule: {0}")]
151 InvalidRule(String),
152}
153
154#[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
180pub type Result<T> = std::result::Result<T, SbomDiffError>;
186
187impl SbomDiffError {
192 pub fn parse(context: impl Into<String>, source: ParseErrorKind) -> Self {
194 Self::Parse {
195 context: context.into(),
196 source,
197 }
198 }
199
200 pub fn unknown_format(path: impl Into<String>) -> Self {
202 Self::parse(format!("at {}", path.into()), ParseErrorKind::UnknownFormat)
203 }
204
205 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 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 pub fn validation(message: impl Into<String>) -> Self {
229 Self::Validation(message.into())
230 }
231
232 pub fn config(message: impl Into<String>) -> Self {
234 Self::Config(message.into())
235 }
236
237 pub fn diff(context: impl Into<String>, source: DiffErrorKind) -> Self {
239 Self::Diff {
240 context: context.into(),
241 source,
242 }
243 }
244
245 pub fn report(context: impl Into<String>, source: ReportErrorKind) -> Self {
247 Self::Report {
248 context: context.into(),
249 source,
250 }
251 }
252
253 pub fn enrichment(context: impl Into<String>, source: EnrichmentErrorKind) -> Self {
255 Self::Enrichment {
256 context: context.into(),
257 source,
258 }
259 }
260}
261
262impl 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
285pub trait ErrorContext<T> {
316 fn context(self, context: impl Into<String>) -> Result<T>;
321
322 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
350fn 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
402fn 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
414pub trait OptionContext<T> {
416 fn context_none(self, context: impl Into<String>) -> Result<T>;
418
419 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 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 let initial_err: Result<()> = Err(SbomDiffError::parse(
476 "initial context",
477 ParseErrorKind::UnknownFormat,
478 ));
479
480 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 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 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 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}