Skip to main content

bids_core/
metadata.rs

1//! Metadata dictionary for BIDS files.
2//!
3//! Provides [`BidsMetadata`], an ordered key-value store for JSON sidecar
4//! metadata that supports typed accessors, iteration, merging, and
5//! deserialization into arbitrary structs.
6
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// Ordered metadata dictionary for a BIDS file.
12///
13/// Wraps an `IndexMap<String, serde_json::Value>` preserving insertion order.
14/// Provides typed accessors for common value types and can be deserialized
15/// into arbitrary structs via [`deserialize_as()`](Self::deserialize_as).
16///
17/// Metadata is populated from JSON sidecar files following the BIDS
18/// inheritance principle, where more-specific sidecars (closer to the data
19/// file) override less-specific ones (closer to the dataset root).
20///
21/// Corresponds to PyBIDS' `BIDSMetadata` class.
22///
23/// # Example
24///
25/// ```
26/// use bids_core::metadata::BidsMetadata;
27/// use serde_json::json;
28///
29/// let mut md = BidsMetadata::new();
30/// md.insert("SamplingFrequency".into(), json!(256.0));
31/// md.insert("EEGReference".into(), json!("Cz"));
32///
33/// assert_eq!(md.get_f64("SamplingFrequency"), Some(256.0));
34/// assert_eq!(md.get_str("EEGReference"), Some("Cz"));
35/// ```
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct BidsMetadata {
38    inner: IndexMap<String, Value>,
39    /// The source file this metadata is associated with.
40    #[serde(skip)]
41    pub source_file: Option<String>,
42}
43
44impl BidsMetadata {
45    /// Create an empty metadata dictionary.
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Create an empty metadata dictionary tagged with a source file path.
51    pub fn with_source(source_file: &str) -> Self {
52        Self {
53            inner: IndexMap::new(),
54            source_file: Some(source_file.to_string()),
55        }
56    }
57
58    /// Get a raw JSON value by key.
59    #[must_use]
60    pub fn get(&self, key: &str) -> Option<&Value> {
61        self.inner.get(key)
62    }
63
64    /// Get a string value by key (returns `None` if missing or not a string).
65    #[must_use]
66    pub fn get_str(&self, key: &str) -> Option<&str> {
67        self.inner.get(key).and_then(|v| v.as_str())
68    }
69
70    /// Get a float value by key (returns `None` if missing or not numeric).
71    #[must_use]
72    pub fn get_f64(&self, key: &str) -> Option<f64> {
73        self.inner.get(key).and_then(serde_json::Value::as_f64)
74    }
75
76    /// Get an integer value by key (returns `None` if missing or not numeric).
77    #[must_use]
78    pub fn get_i64(&self, key: &str) -> Option<i64> {
79        self.inner.get(key).and_then(serde_json::Value::as_i64)
80    }
81
82    /// Get a boolean value by key.
83    #[must_use]
84    pub fn get_bool(&self, key: &str) -> Option<bool> {
85        self.inner.get(key).and_then(serde_json::Value::as_bool)
86    }
87
88    /// Get a JSON array value by key.
89    #[must_use]
90    pub fn get_array(&self, key: &str) -> Option<&Vec<Value>> {
91        self.inner.get(key).and_then(|v| v.as_array())
92    }
93
94    /// Insert a key-value pair, replacing any existing value.
95    pub fn insert(&mut self, key: String, value: Value) {
96        self.inner.insert(key, value);
97    }
98
99    /// Merge all entries from `other` into this metadata (overwrites on conflict).
100    pub fn extend(&mut self, other: BidsMetadata) {
101        self.inner.extend(other.inner);
102    }
103
104    /// Merge entries from an `IndexMap` into this metadata.
105    pub fn update_from_map(&mut self, map: IndexMap<String, Value>) {
106        self.inner.extend(map);
107    }
108
109    /// Check if a key exists.
110    #[must_use]
111    pub fn contains_key(&self, key: &str) -> bool {
112        self.inner.contains_key(key)
113    }
114
115    /// Iterate over keys.
116    #[must_use]
117    pub fn keys(&self) -> indexmap::map::Keys<'_, String, Value> {
118        self.inner.keys()
119    }
120
121    /// Iterate over key-value pairs.
122    #[must_use]
123    pub fn iter(&self) -> indexmap::map::Iter<'_, String, Value> {
124        self.inner.iter()
125    }
126
127    /// Number of metadata entries.
128    #[must_use]
129    pub fn len(&self) -> usize {
130        self.inner.len()
131    }
132
133    /// Returns `true` if the metadata is empty.
134    #[must_use]
135    pub fn is_empty(&self) -> bool {
136        self.inner.is_empty()
137    }
138}
139
140impl FromIterator<(String, Value)> for BidsMetadata {
141    fn from_iter<I: IntoIterator<Item = (String, Value)>>(iter: I) -> Self {
142        Self {
143            inner: IndexMap::from_iter(iter),
144            source_file: None,
145        }
146    }
147}
148
149impl BidsMetadata {
150    /// Try to deserialize this metadata into a typed struct.
151    ///
152    /// Works via JSON roundtrip: metadata map → `serde_json::Value` → `T`.
153    pub fn deserialize_as<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
154        let json = serde_json::to_value(&self.inner).ok()?;
155        serde_json::from_value(json).ok()
156    }
157}
158
159impl IntoIterator for BidsMetadata {
160    type Item = (String, Value);
161    type IntoIter = indexmap::map::IntoIter<String, Value>;
162
163    fn into_iter(self) -> Self::IntoIter {
164        self.inner.into_iter()
165    }
166}
167
168impl std::ops::Index<&str> for BidsMetadata {
169    type Output = Value;
170
171    /// Index into metadata by key.
172    ///
173    /// # Panics
174    ///
175    /// Panics if the key is not present. Use [`get()`](Self::get) for a
176    /// non-panicking alternative.
177    fn index(&self, key: &str) -> &Value {
178        &self.inner[key]
179    }
180}
181
182impl From<IndexMap<String, Value>> for BidsMetadata {
183    fn from(map: IndexMap<String, Value>) -> Self {
184        Self {
185            inner: map,
186            source_file: None,
187        }
188    }
189}
190
191impl From<serde_json::Map<String, Value>> for BidsMetadata {
192    fn from(map: serde_json::Map<String, Value>) -> Self {
193        Self {
194            inner: map.into_iter().collect(),
195            source_file: None,
196        }
197    }
198}
199
200impl std::fmt::Display for BidsMetadata {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        write!(f, "BidsMetadata({} keys", self.inner.len())?;
203        if let Some(src) = &self.source_file {
204            write!(f, " from {src}")?;
205        }
206        write!(f, ")")
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use serde_json::json;
214
215    #[test]
216    fn test_metadata_typed_accessors() {
217        let mut md = BidsMetadata::new();
218        md.insert("SamplingFrequency".into(), json!(256.0));
219        md.insert("EEGReference".into(), json!("Cz"));
220        md.insert("RecordingDuration".into(), json!(600));
221        md.insert("EEGGround".into(), json!(true));
222        md.insert("TaskName".into(), json!(null));
223
224        assert_eq!(md.get_f64("SamplingFrequency"), Some(256.0));
225        assert_eq!(md.get_str("EEGReference"), Some("Cz"));
226        assert_eq!(md.get_i64("RecordingDuration"), Some(600));
227        assert_eq!(md.get_bool("EEGGround"), Some(true));
228        assert!(md.get_str("TaskName").is_none());
229        assert!(md.get_f64("Missing").is_none());
230    }
231
232    #[test]
233    fn test_metadata_extend_overrides() {
234        let mut base = BidsMetadata::new();
235        base.insert("A".into(), json!(1));
236        base.insert("B".into(), json!(2));
237
238        let mut child = BidsMetadata::new();
239        child.insert("B".into(), json!(99));
240        child.insert("C".into(), json!(3));
241
242        base.extend(child);
243        assert_eq!(base.get_i64("A"), Some(1));
244        assert_eq!(base.get_i64("B"), Some(99)); // overridden
245        assert_eq!(base.get_i64("C"), Some(3));
246        assert_eq!(base.len(), 3);
247    }
248
249    #[test]
250    fn test_metadata_deserialize_as() {
251        #[derive(serde::Deserialize)]
252        struct EegMeta {
253            #[serde(rename = "SamplingFrequency")]
254            sampling_frequency: f64,
255            #[serde(rename = "EEGReference")]
256            eeg_reference: String,
257        }
258
259        let mut md = BidsMetadata::new();
260        md.insert("SamplingFrequency".into(), json!(256.0));
261        md.insert("EEGReference".into(), json!("Cz"));
262
263        let typed: EegMeta = md.deserialize_as().unwrap();
264        assert_eq!(typed.sampling_frequency, 256.0);
265        assert_eq!(typed.eeg_reference, "Cz");
266    }
267
268    #[test]
269    fn test_metadata_from_iterator() {
270        let md: BidsMetadata = vec![
271            ("A".to_string(), json!(1)),
272            ("B".to_string(), json!("hello")),
273        ]
274        .into_iter()
275        .collect();
276        assert_eq!(md.len(), 2);
277        assert_eq!(md.get_i64("A"), Some(1));
278    }
279
280    #[test]
281    fn test_metadata_source_file() {
282        let md = BidsMetadata::with_source("/data/sub-01_eeg.json");
283        assert_eq!(md.source_file.as_deref(), Some("/data/sub-01_eeg.json"));
284        assert!(md.is_empty());
285    }
286}