Skip to main content

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}