Skip to main content

oximedia_core/
error_context.rs

1//! Structured error context and error chaining utilities.
2//!
3//! Provides [`ErrorContext`], [`ErrorChain`], and [`ErrorContextBuilder`] for
4//! attaching structured metadata (component, operation, and arbitrary key/value
5//! pairs) to errors propagated through the media pipeline.
6//!
7//! # Examples
8//!
9//! ```
10//! use oximedia_core::error_context::{ErrorContext, ErrorChain};
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
19#![allow(dead_code)]
20#![allow(clippy::module_name_repetitions)]
21
22use std::collections::HashMap;
23
24/// Structured context attached to a single error occurrence.
25///
26/// Records where an error happened (`component`, `operation`) and a
27/// human-readable `message`.  Optional key/value pairs may carry additional
28/// diagnostic information.
29///
30/// # Examples
31///
32/// ```
33/// use oximedia_core::error_context::ErrorContext;
34///
35/// let ctx = ErrorContext::new("muxer", "write_header", "disk full");
36/// assert_eq!(ctx.component(), "muxer");
37/// assert_eq!(ctx.operation(), "write_header");
38/// assert_eq!(ctx.message(), "disk full");
39/// ```
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ErrorContext {
42    component: String,
43    operation: String,
44    message: String,
45    fields: HashMap<String, String>,
46}
47
48impl ErrorContext {
49    /// Creates a new context with the given component, operation, and message.
50    #[must_use]
51    pub fn new(component: &str, operation: &str, message: &str) -> Self {
52        Self {
53            component: component.to_owned(),
54            operation: operation.to_owned(),
55            message: message.to_owned(),
56            fields: HashMap::new(),
57        }
58    }
59
60    /// Returns the name of the component that raised the error.
61    #[inline]
62    #[must_use]
63    pub fn component(&self) -> &str {
64        &self.component
65    }
66
67    /// Returns the name of the operation that was in progress.
68    #[inline]
69    #[must_use]
70    pub fn operation(&self) -> &str {
71        &self.operation
72    }
73
74    /// Returns the human-readable error message.
75    #[inline]
76    #[must_use]
77    pub fn message(&self) -> &str {
78        &self.message
79    }
80
81    /// Attaches an additional key/value diagnostic field.
82    ///
83    /// Calling this multiple times with the same key overwrites the previous
84    /// value.
85    pub fn with_field(&mut self, key: &str, value: &str) -> &mut Self {
86        self.fields.insert(key.to_owned(), value.to_owned());
87        self
88    }
89
90    /// Returns the value of a diagnostic field, or `None` if absent.
91    #[must_use]
92    pub fn field(&self, key: &str) -> Option<&str> {
93        self.fields.get(key).map(String::as_str)
94    }
95
96    /// Returns an iterator over all attached diagnostic fields.
97    pub fn fields(&self) -> impl Iterator<Item = (&str, &str)> {
98        self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
99    }
100}
101
102impl std::fmt::Display for ErrorContext {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        write!(
105            f,
106            "[{}::{}] {}",
107            self.component, self.operation, self.message
108        )
109    }
110}
111
112// ---------------------------------------------------------------------------
113
114/// A chain of [`ErrorContext`] records representing an error's call stack.
115///
116/// Errors are pushed from innermost (root cause) to outermost (top-level
117/// context).  [`depth`](Self::depth) returns the number of frames.
118///
119/// # Examples
120///
121/// ```
122/// use oximedia_core::error_context::{ErrorContext, ErrorChain};
123///
124/// let root = ErrorContext::new("io", "read", "timeout");
125/// let mut chain = ErrorChain::root(root);
126/// chain.push(ErrorContext::new("demuxer", "read_packet", "I/O error"));
127/// assert_eq!(chain.depth(), 2);
128/// ```
129#[derive(Debug, Clone)]
130pub struct ErrorChain {
131    frames: Vec<ErrorContext>,
132}
133
134impl ErrorChain {
135    /// Creates a chain containing a single root (innermost) context.
136    #[must_use]
137    pub fn root(ctx: ErrorContext) -> Self {
138        Self { frames: vec![ctx] }
139    }
140
141    /// Creates an empty chain.
142    #[must_use]
143    pub fn empty() -> Self {
144        Self { frames: Vec::new() }
145    }
146
147    /// Pushes an outer context onto the chain.
148    pub fn push(&mut self, ctx: ErrorContext) {
149        self.frames.push(ctx);
150    }
151
152    /// Returns the number of context frames in the chain.
153    #[must_use]
154    pub fn depth(&self) -> usize {
155        self.frames.len()
156    }
157
158    /// Returns `true` if the chain contains no frames.
159    #[must_use]
160    pub fn is_empty(&self) -> bool {
161        self.frames.is_empty()
162    }
163
164    /// Returns the root (innermost / first-cause) context, or `None` if empty.
165    #[must_use]
166    pub fn root_cause(&self) -> Option<&ErrorContext> {
167        self.frames.first()
168    }
169
170    /// Returns the outermost context (most recently pushed), or `None` if empty.
171    #[must_use]
172    pub fn outermost(&self) -> Option<&ErrorContext> {
173        self.frames.last()
174    }
175
176    /// Returns an iterator over all frames from root to outermost.
177    pub fn iter(&self) -> impl Iterator<Item = &ErrorContext> {
178        self.frames.iter()
179    }
180
181    /// Returns `true` if any frame's component matches `component`.
182    #[must_use]
183    pub fn involves(&self, component: &str) -> bool {
184        self.frames.iter().any(|f| f.component() == component)
185    }
186}
187
188impl std::fmt::Display for ErrorChain {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        for (i, frame) in self.frames.iter().enumerate() {
191            if i > 0 {
192                write!(f, " -> ")?;
193            }
194            write!(f, "{frame}")?;
195        }
196        Ok(())
197    }
198}
199
200// ---------------------------------------------------------------------------
201
202/// A builder that constructs an [`ErrorContext`] with a fluent API.
203///
204/// # Examples
205///
206/// ```
207/// use oximedia_core::error_context::ErrorContextBuilder;
208///
209/// let ctx = ErrorContextBuilder::new("codec", "decode_frame")
210///     .message("bitstream error")
211///     .field("pts", "12345")
212///     .build();
213///
214/// assert_eq!(ctx.component(), "codec");
215/// assert_eq!(ctx.field("pts"), Some("12345"));
216/// ```
217#[derive(Debug, Default)]
218pub struct ErrorContextBuilder {
219    component: String,
220    operation: String,
221    message: String,
222    fields: HashMap<String, String>,
223}
224
225impl ErrorContextBuilder {
226    /// Starts a new builder with the given `component` and `operation`.
227    #[must_use]
228    pub fn new(component: &str, operation: &str) -> Self {
229        Self {
230            component: component.to_owned(),
231            operation: operation.to_owned(),
232            message: String::new(),
233            fields: HashMap::new(),
234        }
235    }
236
237    /// Sets the error message.
238    #[must_use]
239    pub fn message(mut self, msg: &str) -> Self {
240        msg.clone_into(&mut self.message);
241        self
242    }
243
244    /// Attaches a key/value diagnostic field.
245    #[must_use]
246    pub fn field(mut self, key: &str, value: &str) -> Self {
247        self.fields.insert(key.to_owned(), value.to_owned());
248        self
249    }
250
251    /// Consumes the builder and returns the constructed [`ErrorContext`].
252    #[must_use]
253    pub fn build(self) -> ErrorContext {
254        ErrorContext {
255            component: self.component,
256            operation: self.operation,
257            message: self.message,
258            fields: self.fields,
259        }
260    }
261}
262
263// ---------------------------------------------------------------------------
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn error_context_accessors() {
271        let ctx = ErrorContext::new("demuxer", "read_packet", "EOF");
272        assert_eq!(ctx.component(), "demuxer");
273        assert_eq!(ctx.operation(), "read_packet");
274        assert_eq!(ctx.message(), "EOF");
275    }
276
277    #[test]
278    fn error_context_with_field() {
279        let mut ctx = ErrorContext::new("codec", "decode", "error");
280        ctx.with_field("pts", "1000");
281        assert_eq!(ctx.field("pts"), Some("1000"));
282    }
283
284    #[test]
285    fn error_context_missing_field_is_none() {
286        let ctx = ErrorContext::new("x", "y", "z");
287        assert!(ctx.field("nonexistent").is_none());
288    }
289
290    #[test]
291    fn error_context_field_overwrite() {
292        let mut ctx = ErrorContext::new("a", "b", "c");
293        ctx.with_field("k", "v1");
294        ctx.with_field("k", "v2");
295        assert_eq!(ctx.field("k"), Some("v2"));
296    }
297
298    #[test]
299    fn error_context_display() {
300        let ctx = ErrorContext::new("muxer", "write", "disk full");
301        let s = ctx.to_string();
302        assert!(s.contains("muxer"));
303        assert!(s.contains("write"));
304        assert!(s.contains("disk full"));
305    }
306
307    #[test]
308    fn error_chain_root_depth_one() {
309        let ctx = ErrorContext::new("io", "read", "timeout");
310        let chain = ErrorChain::root(ctx);
311        assert_eq!(chain.depth(), 1);
312    }
313
314    #[test]
315    fn error_chain_push_increases_depth() {
316        let mut chain = ErrorChain::root(ErrorContext::new("a", "op", "msg"));
317        chain.push(ErrorContext::new("b", "op2", "msg2"));
318        assert_eq!(chain.depth(), 2);
319    }
320
321    #[test]
322    fn error_chain_root_cause() {
323        let ctx = ErrorContext::new("inner", "op", "root cause");
324        let chain = ErrorChain::root(ctx.clone());
325        assert_eq!(chain.root_cause(), Some(&ctx));
326    }
327
328    #[test]
329    fn error_chain_outermost() {
330        let mut chain = ErrorChain::root(ErrorContext::new("inner", "op", "cause"));
331        let outer = ErrorContext::new("outer", "handle", "context");
332        chain.push(outer.clone());
333        assert_eq!(chain.outermost(), Some(&outer));
334    }
335
336    #[test]
337    fn error_chain_involves() {
338        let mut chain = ErrorChain::root(ErrorContext::new("io", "read", "err"));
339        chain.push(ErrorContext::new("demuxer", "parse", "err2"));
340        assert!(chain.involves("io"));
341        assert!(chain.involves("demuxer"));
342        assert!(!chain.involves("encoder"));
343    }
344
345    #[test]
346    fn error_chain_empty() {
347        let chain = ErrorChain::empty();
348        assert!(chain.is_empty());
349        assert_eq!(chain.depth(), 0);
350        assert!(chain.root_cause().is_none());
351        assert!(chain.outermost().is_none());
352    }
353
354    #[test]
355    fn error_chain_display_multi_frame() {
356        let mut chain = ErrorChain::root(ErrorContext::new("a", "op", "first"));
357        chain.push(ErrorContext::new("b", "op2", "second"));
358        let s = chain.to_string();
359        assert!(s.contains("first"));
360        assert!(s.contains("second"));
361        assert!(s.contains("->"));
362    }
363
364    #[test]
365    fn builder_creates_correct_context() {
366        let ctx = ErrorContextBuilder::new("codec", "decode_frame")
367            .message("bitstream error")
368            .field("pts", "12345")
369            .build();
370        assert_eq!(ctx.component(), "codec");
371        assert_eq!(ctx.operation(), "decode_frame");
372        assert_eq!(ctx.message(), "bitstream error");
373        assert_eq!(ctx.field("pts"), Some("12345"));
374    }
375
376    #[test]
377    fn builder_default_message_is_empty() {
378        let ctx = ErrorContextBuilder::new("c", "op").build();
379        assert_eq!(ctx.message(), "");
380    }
381
382    #[test]
383    fn error_chain_iter_count_matches_depth() {
384        let mut chain = ErrorChain::root(ErrorContext::new("a", "op", "e1"));
385        chain.push(ErrorContext::new("b", "op2", "e2"));
386        chain.push(ErrorContext::new("c", "op3", "e3"));
387        assert_eq!(chain.iter().count(), chain.depth());
388    }
389}