oximedia_core/error_context.rs
1//! Structured error context and error chaining utilities.
2//!
3//! Provides [`ErrorContext`], [`ErrorChain`], [`ErrorContextBuilder`], and
4//! [`ErrorFrame`] for attaching structured metadata to errors propagated through
5//! the media pipeline.
6//!
7//! # Examples
8//!
9//! ```
10//! use oximedia_core::error_context::{ErrorContext, ErrorChain, ErrorFrame};
11//!
12//! let ctx = ErrorContext::new("demuxer", "read_packet", "unexpected EOF");
13//! assert_eq!(ctx.component(), "demuxer");
14//!
15//! let chain = ErrorChain::root(ctx);
16//! assert_eq!(chain.depth(), 1);
17//!
18//! // Structured frame chain on ErrorContext
19//! let mut ctx2 = ErrorContext::new("codec", "decode", "buffer underflow");
20//! ctx2.push_frame(ErrorFrame {
21//! file: "src/codec.rs",
22//! line: 42,
23//! function: "decode_frame",
24//! message: std::borrow::Cow::Borrowed("buffer underflow"),
25//! });
26//! let display = ctx2.frames_display();
27//! assert!(!display.is_empty());
28//! ```
29//!
30//! ## Building an Error Context Chain
31//!
32//! Use the `ctx!` macro to attach a location frame, then chain frames as errors
33//! propagate up the call stack:
34//!
35//! ```
36//! use oximedia_core::error_context::{ErrorContext, ErrorFrame};
37//! use std::borrow::Cow;
38//!
39//! let mut ctx = ErrorContext::new("demuxer", "read_packet", "initial error");
40//! ctx.push_frame(ErrorFrame {
41//! file: file!(),
42//! line: line!(),
43//! function: "my_function",
44//! message: Cow::Borrowed("initial error"),
45//! });
46//! ctx.push_frame(ErrorFrame {
47//! file: file!(),
48//! line: line!(),
49//! function: "caller",
50//! message: Cow::Borrowed("wrapping context"),
51//! });
52//! assert_eq!(ctx.frame_count(), 2);
53//! // frames_display() returns a multi-line string showing file:line [fn]: message
54//! let display = ctx.frames_display();
55//! assert!(display.contains("my_function"));
56//! assert!(display.contains("caller"));
57//! ```
58
59#![allow(dead_code)]
60#![allow(clippy::module_name_repetitions)]
61
62use std::borrow::Cow;
63use std::collections::HashMap;
64use std::fmt;
65
66// ---------------------------------------------------------------------------
67// ErrorFrame — a single source-location frame in a context chain
68// ---------------------------------------------------------------------------
69
70/// A single frame in an error context chain, capturing the source location
71/// and a human-readable message at the point where context was attached.
72///
73/// Instances are typically created via the `ctx!` macro rather than
74/// constructed by hand.
75///
76/// # Examples
77///
78/// ```
79/// use oximedia_core::error_context::ErrorFrame;
80///
81/// let frame = ErrorFrame {
82/// file: "src/lib.rs",
83/// line: 10,
84/// function: "my_func",
85/// message: std::borrow::Cow::Borrowed("something went wrong"),
86/// };
87/// let s = frame.to_string();
88/// assert!(s.contains("src/lib.rs"));
89/// assert!(s.contains("10"));
90/// assert!(s.contains("my_func"));
91/// assert!(s.contains("something went wrong"));
92/// ```
93#[derive(Debug, Clone)]
94pub struct ErrorFrame {
95 /// Source file path captured by `file!()`.
96 pub file: &'static str,
97 /// Line number captured by `line!()`.
98 pub line: u32,
99 /// Function or module path captured by the `current_fn_name!()` macro.
100 pub function: &'static str,
101 /// Human-readable context message.
102 pub message: Cow<'static, str>,
103}
104
105impl fmt::Display for ErrorFrame {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 write!(
108 f,
109 " at {}:{} [{}]: {}",
110 self.file, self.line, self.function, self.message
111 )
112 }
113}
114
115// ---------------------------------------------------------------------------
116// current_fn_name! and ctx! macros
117// ---------------------------------------------------------------------------
118
119/// Captures the fully-qualified path of the enclosing function at compile time.
120///
121/// The result is a `&'static str` with the `::f` helper suffix stripped,
122/// yielding the module path of the call site (e.g. `"my_crate::module::fn"`).
123///
124/// # Examples
125///
126/// ```
127/// fn example() {
128/// let name = oximedia_core::current_fn_name!();
129/// // name ends with "example"
130/// assert!(name.contains("example"));
131/// }
132/// example();
133/// ```
134#[macro_export]
135macro_rules! current_fn_name {
136 () => {{
137 fn f() {}
138 fn type_name_of<T>(_: T) -> &'static str {
139 std::any::type_name::<T>()
140 }
141 let full = type_name_of(f);
142 // Strip the trailing "::f" (3 chars) added by the inner fn
143 &full[..full.len() - 3]
144 }};
145}
146
147/// Creates an [`ErrorFrame`] capturing the current source file, line, and
148/// function name via built-in macros.
149///
150/// Two forms:
151/// - `ctx!("literal message")` — zero-allocation borrowed string.
152/// - `ctx!("fmt {}", arg)` — allocated owned string via `format!`.
153///
154/// # Examples
155///
156/// ```
157/// use oximedia_core::{ctx, error_context::ErrorFrame};
158///
159/// let frame: ErrorFrame = ctx!("something failed");
160/// assert!(frame.to_string().contains("something failed"));
161///
162/// let n = 42u32;
163/// let frame2: ErrorFrame = ctx!("value was {}", n);
164/// assert!(frame2.to_string().contains("42"));
165/// ```
166#[macro_export]
167macro_rules! ctx {
168 ($msg:literal) => {
169 $crate::error_context::ErrorFrame {
170 file: file!(),
171 line: line!(),
172 function: $crate::current_fn_name!(),
173 message: std::borrow::Cow::Borrowed($msg),
174 }
175 };
176 ($fmt:expr, $($arg:tt)*) => {
177 $crate::error_context::ErrorFrame {
178 file: file!(),
179 line: line!(),
180 function: $crate::current_fn_name!(),
181 message: std::borrow::Cow::Owned(format!($fmt, $($arg)*)),
182 }
183 };
184}
185
186// ---------------------------------------------------------------------------
187// OxiErrorExt
188// ---------------------------------------------------------------------------
189
190/// Extension trait that attaches an [`ErrorFrame`] as additional context to
191/// any error type that implements [`std::error::Error`].
192///
193/// This avoids a dependency on `anyhow` by embedding the frame's `Display`
194/// representation into a new [`crate::error::OxiError::InvalidData`] wrapper.
195pub trait OxiErrorExt: Sized {
196 /// Wraps `self` by prepending the frame's display string as context.
197 fn with_oxi_context(self, frame: ErrorFrame) -> crate::error::OxiError;
198}
199
200impl<E: std::error::Error> OxiErrorExt for E {
201 fn with_oxi_context(self, frame: ErrorFrame) -> crate::error::OxiError {
202 crate::error::OxiError::InvalidData(format!("{frame}\ncaused by: {self}"))
203 }
204}
205
206// ---------------------------------------------------------------------------
207
208/// Structured context attached to a single error occurrence.
209///
210/// Records where an error happened (`component`, `operation`) and a
211/// human-readable `message`. Optional key/value pairs may carry additional
212/// diagnostic information. An ordered list of [`ErrorFrame`] records captures
213/// the source locations where context was attached via the `ctx!` macro.
214///
215/// # Examples
216///
217/// ```
218/// use oximedia_core::error_context::{ErrorContext, ErrorFrame};
219///
220/// let mut ctx = ErrorContext::new("muxer", "write_header", "disk full");
221/// assert_eq!(ctx.component(), "muxer");
222/// assert_eq!(ctx.operation(), "write_header");
223/// assert_eq!(ctx.message(), "disk full");
224///
225/// ctx.push_frame(ErrorFrame {
226/// file: "src/muxer.rs",
227/// line: 99,
228/// function: "write_header",
229/// message: std::borrow::Cow::Borrowed("disk full"),
230/// });
231/// assert_eq!(ctx.frame_count(), 1);
232/// ```
233#[derive(Debug, Clone)]
234pub struct ErrorContext {
235 component: String,
236 operation: String,
237 message: String,
238 fields: HashMap<String, String>,
239 /// Ordered list of source-location frames attached via [`ctx!`].
240 frames: Vec<ErrorFrame>,
241}
242
243/// `ErrorContext` values are compared by component, operation, message, and fields only.
244/// The `frames` chain is intentionally excluded from equality so that adding trace
245/// frames does not alter the identity of an error context.
246impl PartialEq for ErrorContext {
247 fn eq(&self, other: &Self) -> bool {
248 self.component == other.component
249 && self.operation == other.operation
250 && self.message == other.message
251 && self.fields == other.fields
252 }
253}
254
255impl Eq for ErrorContext {}
256
257impl ErrorContext {
258 /// Creates a new context with the given component, operation, and message.
259 #[must_use]
260 pub fn new(component: &str, operation: &str, message: &str) -> Self {
261 Self {
262 component: component.to_owned(),
263 operation: operation.to_owned(),
264 message: message.to_owned(),
265 fields: HashMap::new(),
266 frames: Vec::new(),
267 }
268 }
269
270 /// Appends an [`ErrorFrame`] to the context chain.
271 ///
272 /// Frames accumulate in the order they are pushed (earliest first).
273 pub fn push_frame(&mut self, frame: ErrorFrame) {
274 self.frames.push(frame);
275 }
276
277 /// Consumes `self`, appends `frame`, and returns the modified context.
278 ///
279 /// Useful for method chaining.
280 #[must_use]
281 pub fn with_frame(mut self, frame: ErrorFrame) -> Self {
282 self.frames.push(frame);
283 self
284 }
285
286 /// Returns the number of frames in the context chain.
287 #[must_use]
288 pub fn frame_count(&self) -> usize {
289 self.frames.len()
290 }
291
292 /// Returns an iterator over the attached frames (earliest first).
293 pub fn frames(&self) -> impl Iterator<Item = &ErrorFrame> {
294 self.frames.iter()
295 }
296
297 /// Returns a multi-line string rendering of all attached frames,
298 /// or `"(no context frames)"` if the chain is empty.
299 ///
300 /// # Examples
301 ///
302 /// ```
303 /// use oximedia_core::error_context::{ErrorContext, ErrorFrame};
304 ///
305 /// let mut ctx = ErrorContext::new("a", "b", "c");
306 /// ctx.push_frame(ErrorFrame {
307 /// file: "x.rs", line: 1, function: "f",
308 /// message: std::borrow::Cow::Borrowed("first"),
309 /// });
310 /// ctx.push_frame(ErrorFrame {
311 /// file: "y.rs", line: 2, function: "g",
312 /// message: std::borrow::Cow::Borrowed("second"),
313 /// });
314 /// let s = ctx.frames_display();
315 /// assert!(s.contains("first"));
316 /// assert!(s.contains("second"));
317 /// ```
318 #[must_use]
319 pub fn frames_display(&self) -> String {
320 if self.frames.is_empty() {
321 return "(no context frames)".to_owned();
322 }
323 self.frames
324 .iter()
325 .enumerate()
326 .map(|(i, f)| {
327 if i == 0 {
328 f.to_string()
329 } else {
330 format!("\n{f}")
331 }
332 })
333 .collect()
334 }
335
336 /// Returns the name of the component that raised the error.
337 #[inline]
338 #[must_use]
339 pub fn component(&self) -> &str {
340 &self.component
341 }
342
343 /// Returns the name of the operation that was in progress.
344 #[inline]
345 #[must_use]
346 pub fn operation(&self) -> &str {
347 &self.operation
348 }
349
350 /// Returns the human-readable error message.
351 #[inline]
352 #[must_use]
353 pub fn message(&self) -> &str {
354 &self.message
355 }
356
357 /// Attaches an additional key/value diagnostic field.
358 ///
359 /// Calling this multiple times with the same key overwrites the previous
360 /// value.
361 pub fn with_field(&mut self, key: &str, value: &str) -> &mut Self {
362 self.fields.insert(key.to_owned(), value.to_owned());
363 self
364 }
365
366 /// Returns the value of a diagnostic field, or `None` if absent.
367 #[must_use]
368 pub fn field(&self, key: &str) -> Option<&str> {
369 self.fields.get(key).map(String::as_str)
370 }
371
372 /// Returns an iterator over all attached diagnostic fields.
373 pub fn fields(&self) -> impl Iterator<Item = (&str, &str)> {
374 self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
375 }
376}
377
378impl std::fmt::Display for ErrorContext {
379 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380 write!(
381 f,
382 "[{}::{}] {}",
383 self.component, self.operation, self.message
384 )
385 }
386}
387
388// ---------------------------------------------------------------------------
389
390/// A chain of [`ErrorContext`] records representing an error's call stack.
391///
392/// Errors are pushed from innermost (root cause) to outermost (top-level
393/// context). [`depth`](Self::depth) returns the number of frames.
394///
395/// # Examples
396///
397/// ```
398/// use oximedia_core::error_context::{ErrorContext, ErrorChain};
399///
400/// let root = ErrorContext::new("io", "read", "timeout");
401/// let mut chain = ErrorChain::root(root);
402/// chain.push(ErrorContext::new("demuxer", "read_packet", "I/O error"));
403/// assert_eq!(chain.depth(), 2);
404/// ```
405#[derive(Debug, Clone)]
406pub struct ErrorChain {
407 frames: Vec<ErrorContext>,
408}
409
410impl ErrorChain {
411 /// Creates a chain containing a single root (innermost) context.
412 #[must_use]
413 pub fn root(ctx: ErrorContext) -> Self {
414 Self { frames: vec![ctx] }
415 }
416
417 /// Creates an empty chain.
418 #[must_use]
419 pub fn empty() -> Self {
420 Self { frames: Vec::new() }
421 }
422
423 /// Pushes an outer context onto the chain.
424 pub fn push(&mut self, ctx: ErrorContext) {
425 self.frames.push(ctx);
426 }
427
428 /// Returns the number of context frames in the chain.
429 #[must_use]
430 pub fn depth(&self) -> usize {
431 self.frames.len()
432 }
433
434 /// Returns `true` if the chain contains no frames.
435 #[must_use]
436 pub fn is_empty(&self) -> bool {
437 self.frames.is_empty()
438 }
439
440 /// Returns the root (innermost / first-cause) context, or `None` if empty.
441 #[must_use]
442 pub fn root_cause(&self) -> Option<&ErrorContext> {
443 self.frames.first()
444 }
445
446 /// Returns the outermost context (most recently pushed), or `None` if empty.
447 #[must_use]
448 pub fn outermost(&self) -> Option<&ErrorContext> {
449 self.frames.last()
450 }
451
452 /// Returns an iterator over all frames from root to outermost.
453 pub fn iter(&self) -> impl Iterator<Item = &ErrorContext> {
454 self.frames.iter()
455 }
456
457 /// Returns `true` if any frame's component matches `component`.
458 #[must_use]
459 pub fn involves(&self, component: &str) -> bool {
460 self.frames.iter().any(|f| f.component() == component)
461 }
462}
463
464impl std::fmt::Display for ErrorChain {
465 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 for (i, frame) in self.frames.iter().enumerate() {
467 if i > 0 {
468 write!(f, " -> ")?;
469 }
470 write!(f, "{frame}")?;
471 }
472 Ok(())
473 }
474}
475
476// ---------------------------------------------------------------------------
477
478/// A builder that constructs an [`ErrorContext`] with a fluent API.
479///
480/// # Examples
481///
482/// ```
483/// use oximedia_core::error_context::ErrorContextBuilder;
484///
485/// let ctx = ErrorContextBuilder::new("codec", "decode_frame")
486/// .message("bitstream error")
487/// .field("pts", "12345")
488/// .build();
489///
490/// assert_eq!(ctx.component(), "codec");
491/// assert_eq!(ctx.field("pts"), Some("12345"));
492/// ```
493#[derive(Debug, Default)]
494pub struct ErrorContextBuilder {
495 component: String,
496 operation: String,
497 message: String,
498 fields: HashMap<String, String>,
499}
500
501impl ErrorContextBuilder {
502 /// Starts a new builder with the given `component` and `operation`.
503 #[must_use]
504 pub fn new(component: &str, operation: &str) -> Self {
505 Self {
506 component: component.to_owned(),
507 operation: operation.to_owned(),
508 message: String::new(),
509 fields: HashMap::new(),
510 }
511 }
512
513 /// Sets the error message.
514 #[must_use]
515 pub fn message(mut self, msg: &str) -> Self {
516 msg.clone_into(&mut self.message);
517 self
518 }
519
520 /// Attaches a key/value diagnostic field.
521 #[must_use]
522 pub fn field(mut self, key: &str, value: &str) -> Self {
523 self.fields.insert(key.to_owned(), value.to_owned());
524 self
525 }
526
527 /// Consumes the builder and returns the constructed [`ErrorContext`].
528 #[must_use]
529 pub fn build(self) -> ErrorContext {
530 ErrorContext {
531 component: self.component,
532 operation: self.operation,
533 message: self.message,
534 fields: self.fields,
535 frames: Vec::new(),
536 }
537 }
538}
539
540// ---------------------------------------------------------------------------
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn error_context_accessors() {
548 let ctx = ErrorContext::new("demuxer", "read_packet", "EOF");
549 assert_eq!(ctx.component(), "demuxer");
550 assert_eq!(ctx.operation(), "read_packet");
551 assert_eq!(ctx.message(), "EOF");
552 }
553
554 #[test]
555 fn error_context_with_field() {
556 let mut ctx = ErrorContext::new("codec", "decode", "error");
557 ctx.with_field("pts", "1000");
558 assert_eq!(ctx.field("pts"), Some("1000"));
559 }
560
561 #[test]
562 fn error_context_missing_field_is_none() {
563 let ctx = ErrorContext::new("x", "y", "z");
564 assert!(ctx.field("nonexistent").is_none());
565 }
566
567 #[test]
568 fn error_context_field_overwrite() {
569 let mut ctx = ErrorContext::new("a", "b", "c");
570 ctx.with_field("k", "v1");
571 ctx.with_field("k", "v2");
572 assert_eq!(ctx.field("k"), Some("v2"));
573 }
574
575 #[test]
576 fn error_context_display() {
577 let ctx = ErrorContext::new("muxer", "write", "disk full");
578 let s = ctx.to_string();
579 assert!(s.contains("muxer"));
580 assert!(s.contains("write"));
581 assert!(s.contains("disk full"));
582 }
583
584 #[test]
585 fn error_chain_root_depth_one() {
586 let ctx = ErrorContext::new("io", "read", "timeout");
587 let chain = ErrorChain::root(ctx);
588 assert_eq!(chain.depth(), 1);
589 }
590
591 #[test]
592 fn error_chain_push_increases_depth() {
593 let mut chain = ErrorChain::root(ErrorContext::new("a", "op", "msg"));
594 chain.push(ErrorContext::new("b", "op2", "msg2"));
595 assert_eq!(chain.depth(), 2);
596 }
597
598 #[test]
599 fn error_chain_root_cause() {
600 let ctx = ErrorContext::new("inner", "op", "root cause");
601 let chain = ErrorChain::root(ctx.clone());
602 assert_eq!(chain.root_cause(), Some(&ctx));
603 }
604
605 #[test]
606 fn error_chain_outermost() {
607 let mut chain = ErrorChain::root(ErrorContext::new("inner", "op", "cause"));
608 let outer = ErrorContext::new("outer", "handle", "context");
609 chain.push(outer.clone());
610 assert_eq!(chain.outermost(), Some(&outer));
611 }
612
613 #[test]
614 fn error_chain_involves() {
615 let mut chain = ErrorChain::root(ErrorContext::new("io", "read", "err"));
616 chain.push(ErrorContext::new("demuxer", "parse", "err2"));
617 assert!(chain.involves("io"));
618 assert!(chain.involves("demuxer"));
619 assert!(!chain.involves("encoder"));
620 }
621
622 #[test]
623 fn error_chain_empty() {
624 let chain = ErrorChain::empty();
625 assert!(chain.is_empty());
626 assert_eq!(chain.depth(), 0);
627 assert!(chain.root_cause().is_none());
628 assert!(chain.outermost().is_none());
629 }
630
631 #[test]
632 fn error_chain_display_multi_frame() {
633 let mut chain = ErrorChain::root(ErrorContext::new("a", "op", "first"));
634 chain.push(ErrorContext::new("b", "op2", "second"));
635 let s = chain.to_string();
636 assert!(s.contains("first"));
637 assert!(s.contains("second"));
638 assert!(s.contains("->"));
639 }
640
641 #[test]
642 fn builder_creates_correct_context() {
643 let ctx = ErrorContextBuilder::new("codec", "decode_frame")
644 .message("bitstream error")
645 .field("pts", "12345")
646 .build();
647 assert_eq!(ctx.component(), "codec");
648 assert_eq!(ctx.operation(), "decode_frame");
649 assert_eq!(ctx.message(), "bitstream error");
650 assert_eq!(ctx.field("pts"), Some("12345"));
651 }
652
653 #[test]
654 fn builder_default_message_is_empty() {
655 let ctx = ErrorContextBuilder::new("c", "op").build();
656 assert_eq!(ctx.message(), "");
657 }
658
659 #[test]
660 fn error_chain_iter_count_matches_depth() {
661 let mut chain = ErrorChain::root(ErrorContext::new("a", "op", "e1"));
662 chain.push(ErrorContext::new("b", "op2", "e2"));
663 chain.push(ErrorContext::new("c", "op3", "e3"));
664 assert_eq!(chain.iter().count(), chain.depth());
665 }
666}