Skip to main content

orion_error/core/
metadata.rs

1use std::collections::BTreeMap;
2
3#[derive(Debug, Clone, PartialEq, Eq, Default)]
4#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
5#[cfg_attr(feature = "serde", serde(transparent))]
6/// Typed structured metadata attached to an error context.
7///
8/// Unlike [`OperationContext::with_field`](crate::OperationContext::with_field)
9/// and [`OperationContext::record`](crate::OperationContext::record), whose
10/// entries appear in the error's `Display` output, metadata stored here is **not** visible in
11/// terminal output. It is intended for programmatic consumers: serialization,
12/// snapshots, structured logs, and API responses.
13///
14/// Supports `String`, `bool`, `i64`, and `u64` values via [`MetadataValue`].
15pub struct ErrorMetadata {
16    fields: BTreeMap<String, MetadataValue>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[cfg_attr(feature = "serde", serde(untagged))]
22/// A typed value stored in [`ErrorMetadata`].
23///
24/// Supports string, boolean, and integer variants. Use `from()` / `into()`
25/// to convert from native Rust types.
26pub enum MetadataValue {
27    String(String),
28    Bool(bool),
29    I64(i64),
30    U64(u64),
31}
32
33impl ErrorMetadata {
34    /// Create an empty metadata container.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Returns `true` if no entries are stored.
40    pub fn is_empty(&self) -> bool {
41        self.fields.is_empty()
42    }
43
44    /// Borrow the underlying map for read-only access.
45    pub fn as_map(&self) -> &BTreeMap<String, MetadataValue> {
46        &self.fields
47    }
48
49    /// Insert a key-value pair into the metadata.
50    ///
51    /// Supports any key type that converts into `String` and any value
52    /// type that converts into [`MetadataValue`] (i.e. `String`, `bool`,
53    /// `i64`, `u64`, and their subtypes).
54    pub fn insert<K, V>(&mut self, key: K, value: V)
55    where
56        K: Into<String>,
57        V: Into<MetadataValue>,
58    {
59        let key = key.into();
60        debug_assert!(!key.is_empty(), "metadata key must not be empty");
61        if key.is_empty() {
62            return;
63        }
64
65        self.fields.insert(key, value.into());
66    }
67
68    /// Look up a key and return its typed value.
69    pub fn get(&self, key: &str) -> Option<&MetadataValue> {
70        self.fields.get(key)
71    }
72
73    /// Look up a key and return its `&str` value, or `None` if the key
74    /// is absent or the value is not a string variant.
75    pub fn get_str(&self, key: &str) -> Option<&str> {
76        match self.get(key) {
77            Some(MetadataValue::String(value)) => Some(value.as_str()),
78            _ => None,
79        }
80    }
81
82    /// Iterate over all key-value pairs.
83    pub fn iter(&self) -> impl Iterator<Item = (&String, &MetadataValue)> {
84        self.fields.iter()
85    }
86
87    /// Merge entries from `other`, keeping existing values.
88    ///
89    /// For each key in `other`, the value is only inserted if the key does not
90    /// already exist in `self`. This implements an **inner/first wins** strategy
91    /// useful when merging layered metadata where earlier layers should take
92    /// priority.
93    ///
94    /// See [`StructError::context_metadata()`](crate::StructError::context_metadata)
95    /// for a usage example.
96    pub fn merge_missing(&mut self, other: &ErrorMetadata) {
97        for (key, value) in other.iter() {
98            self.fields
99                .entry(key.clone())
100                .or_insert_with(|| value.clone());
101        }
102    }
103}
104
105impl From<String> for MetadataValue {
106    fn from(value: String) -> Self {
107        Self::String(value)
108    }
109}
110
111impl From<&str> for MetadataValue {
112    fn from(value: &str) -> Self {
113        Self::String(value.to_string())
114    }
115}
116
117impl From<bool> for MetadataValue {
118    fn from(value: bool) -> Self {
119        Self::Bool(value)
120    }
121}
122
123impl From<i64> for MetadataValue {
124    fn from(value: i64) -> Self {
125        Self::I64(value)
126    }
127}
128
129impl From<i32> for MetadataValue {
130    fn from(value: i32) -> Self {
131        Self::I64(i64::from(value))
132    }
133}
134
135impl From<u64> for MetadataValue {
136    fn from(value: u64) -> Self {
137        Self::U64(value)
138    }
139}
140
141impl From<u32> for MetadataValue {
142    fn from(value: u32) -> Self {
143        Self::U64(u64::from(value))
144    }
145}
146
147impl From<usize> for MetadataValue {
148    fn from(value: usize) -> Self {
149        Self::U64(value as u64)
150    }
151}
152
153impl std::fmt::Display for MetadataValue {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            Self::String(s) => write!(f, "{s}"),
157            Self::Bool(b) => write!(f, "{b}"),
158            Self::I64(i) => write!(f, "{i}"),
159            Self::U64(u) => write!(f, "{u}"),
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::{ErrorMetadata, MetadataValue};
167
168    #[test]
169    fn test_metadata_insert_overwrites_duplicate_keys() {
170        let mut metadata = ErrorMetadata::new();
171        metadata.insert("config.kind", "sink_route");
172        metadata.insert("config.kind", "sink_defaults");
173
174        assert_eq!(metadata.get_str("config.kind"), Some("sink_defaults"));
175    }
176
177    #[test]
178    fn test_metadata_merge_missing_keeps_existing_values() {
179        let mut merged = ErrorMetadata::new();
180        merged.insert("config.kind", "sink_defaults");
181
182        let mut outer = ErrorMetadata::new();
183        outer.insert("config.kind", "sink_route");
184        outer.insert("config.group", "infra");
185
186        merged.merge_missing(&outer);
187
188        assert_eq!(merged.get_str("config.kind"), Some("sink_defaults"));
189        assert_eq!(merged.get_str("config.group"), Some("infra"));
190    }
191
192    #[test]
193    fn test_metadata_get_str_only_for_string_values() {
194        let mut metadata = ErrorMetadata::new();
195        metadata.insert("parse.line", 7u32);
196
197        assert_eq!(metadata.get("parse.line"), Some(&MetadataValue::U64(7)));
198        assert_eq!(metadata.get_str("parse.line"), None);
199    }
200
201    #[test]
202    fn test_metadata_value_display() {
203        assert_eq!(MetadataValue::String("hello".into()).to_string(), "hello");
204        assert_eq!(MetadataValue::Bool(true).to_string(), "true");
205        assert_eq!(MetadataValue::I64(-42).to_string(), "-42");
206        assert_eq!(MetadataValue::U64(256).to_string(), "256");
207    }
208}