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 the [`ContextRecord`](crate::ContextRecord) trait (whose entries appear
9/// in the error's `Display` output), metadata stored here is **not** visible in
10/// terminal output. It is intended for programmatic consumers: serialization,
11/// snapshots, structured logs, and API responses.
12///
13/// Supports `String`, `bool`, `i64`, and `u64` values via [`MetadataValue`].
14pub struct ErrorMetadata {
15    fields: BTreeMap<String, MetadataValue>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20#[cfg_attr(feature = "serde", serde(untagged))]
21/// A typed value stored in [`ErrorMetadata`].
22///
23/// Supports string, boolean, and integer variants. Use `from()` / `into()`
24/// to convert from native Rust types.
25pub enum MetadataValue {
26    String(String),
27    Bool(bool),
28    I64(i64),
29    U64(u64),
30}
31
32impl ErrorMetadata {
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    pub fn is_empty(&self) -> bool {
38        self.fields.is_empty()
39    }
40
41    pub fn as_map(&self) -> &BTreeMap<String, MetadataValue> {
42        &self.fields
43    }
44
45    pub fn insert<K, V>(&mut self, key: K, value: V)
46    where
47        K: Into<String>,
48        V: Into<MetadataValue>,
49    {
50        let key = key.into();
51        debug_assert!(!key.is_empty(), "metadata key must not be empty");
52        if key.is_empty() {
53            return;
54        }
55
56        self.fields.insert(key, value.into());
57    }
58
59    pub fn get(&self, key: &str) -> Option<&MetadataValue> {
60        self.fields.get(key)
61    }
62
63    pub fn get_str(&self, key: &str) -> Option<&str> {
64        match self.get(key) {
65            Some(MetadataValue::String(value)) => Some(value.as_str()),
66            _ => None,
67        }
68    }
69
70    pub fn iter(&self) -> impl Iterator<Item = (&String, &MetadataValue)> {
71        self.fields.iter()
72    }
73
74    /// Merge entries from `other`, keeping existing values.
75    ///
76    /// For each key in `other`, the value is only inserted if the key does not
77    /// already exist in `self`. This implements an **inner/first wins** strategy
78    /// useful when merging layered metadata where earlier layers should take
79    /// priority.
80    ///
81    /// See [`StructError::context_metadata()`](crate::StructError::context_metadata)
82    /// for a usage example.
83    pub fn merge_missing(&mut self, other: &ErrorMetadata) {
84        for (key, value) in other.iter() {
85            self.fields
86                .entry(key.clone())
87                .or_insert_with(|| value.clone());
88        }
89    }
90}
91
92impl From<String> for MetadataValue {
93    fn from(value: String) -> Self {
94        Self::String(value)
95    }
96}
97
98impl From<&str> for MetadataValue {
99    fn from(value: &str) -> Self {
100        Self::String(value.to_string())
101    }
102}
103
104impl From<bool> for MetadataValue {
105    fn from(value: bool) -> Self {
106        Self::Bool(value)
107    }
108}
109
110impl From<i64> for MetadataValue {
111    fn from(value: i64) -> Self {
112        Self::I64(value)
113    }
114}
115
116impl From<i32> for MetadataValue {
117    fn from(value: i32) -> Self {
118        Self::I64(i64::from(value))
119    }
120}
121
122impl From<u64> for MetadataValue {
123    fn from(value: u64) -> Self {
124        Self::U64(value)
125    }
126}
127
128impl From<u32> for MetadataValue {
129    fn from(value: u32) -> Self {
130        Self::U64(u64::from(value))
131    }
132}
133
134impl From<usize> for MetadataValue {
135    fn from(value: usize) -> Self {
136        Self::U64(value as u64)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::{ErrorMetadata, MetadataValue};
143
144    #[test]
145    fn test_metadata_insert_overwrites_duplicate_keys() {
146        let mut metadata = ErrorMetadata::new();
147        metadata.insert("config.kind", "sink_route");
148        metadata.insert("config.kind", "sink_defaults");
149
150        assert_eq!(metadata.get_str("config.kind"), Some("sink_defaults"));
151    }
152
153    #[test]
154    fn test_metadata_merge_missing_keeps_existing_values() {
155        let mut merged = ErrorMetadata::new();
156        merged.insert("config.kind", "sink_defaults");
157
158        let mut outer = ErrorMetadata::new();
159        outer.insert("config.kind", "sink_route");
160        outer.insert("config.group", "infra");
161
162        merged.merge_missing(&outer);
163
164        assert_eq!(merged.get_str("config.kind"), Some("sink_defaults"));
165        assert_eq!(merged.get_str("config.group"), Some("infra"));
166    }
167
168    #[test]
169    fn test_metadata_get_str_only_for_string_values() {
170        let mut metadata = ErrorMetadata::new();
171        metadata.insert("parse.line", 7u32);
172
173        assert_eq!(metadata.get("parse.line"), Some(&MetadataValue::U64(7)));
174        assert_eq!(metadata.get_str("parse.line"), None);
175    }
176}