Skip to main content

bids_io/
json.rs

1//! JSON sidecar file handling for BIDS metadata.
2//!
3//! BIDS uses JSON sidecar files to provide metadata for data files. These
4//! sidecars follow the BIDS inheritance principle: a sidecar at a higher
5//! directory level applies to all matching data files below it, and can
6//! be overridden by more-specific sidecars closer to the data file.
7//!
8//! This module provides functions for reading, merging, and discovering
9//! JSON sidecars.
10
11use bids_core::error::Result;
12use bids_core::metadata::BidsMetadata;
13use indexmap::IndexMap;
14use serde_json::Value;
15use std::path::Path;
16
17/// Read a JSON sidecar file and return it as `BidsMetadata`.
18///
19/// # Errors
20///
21/// Returns an error if the file can't be read or contains invalid JSON.
22pub fn read_json_sidecar(path: &Path) -> Result<BidsMetadata> {
23    let contents = std::fs::read_to_string(path)?;
24    let map: IndexMap<String, Value> = serde_json::from_str(&contents)?;
25    let mut md = BidsMetadata::with_source(&path.to_string_lossy());
26    md.update_from_map(map);
27    Ok(md)
28}
29
30/// Read a JSON file and return it as a generic `serde_json::Value`.
31///
32/// # Errors
33///
34/// Returns an error if the file can't be read or contains invalid JSON.
35pub fn read_json(path: &Path) -> Result<Value> {
36    let contents = std::fs::read_to_string(path)?;
37    let val: Value = serde_json::from_str(&contents)?;
38    Ok(val)
39}
40
41/// Merge JSON sidecars following BIDS inheritance: more-specific files
42/// override less-specific ones.
43///
44/// `sidecars` should be ordered from most specific (closest to data file)
45/// to least specific (dataset root).
46///
47/// # Errors
48///
49/// Returns an error if any sidecar file can't be read or parsed.
50pub fn merge_json_sidecars(sidecars: &[&Path]) -> Result<BidsMetadata> {
51    let mut merged = BidsMetadata::new();
52    // Process from least specific to most specific, so specific values win
53    for path in sidecars.iter().rev() {
54        let md = read_json_sidecar(path)?;
55        merged.extend(md);
56    }
57    Ok(merged)
58}
59
60/// Find all applicable JSON sidecar files for a given data file,
61/// walking up the directory tree (BIDS inheritance principle).
62///
63/// Returns paths ordered from most specific to least specific.
64pub fn find_sidecars(data_file: &Path, root: &Path) -> Vec<std::path::PathBuf> {
65    let mut sidecars = Vec::new();
66
67    // Get the suffix from the data file
68    let stem = data_file.file_stem().and_then(|s| s.to_str()).unwrap_or("");
69    // Handle .tsv.gz double extension
70    let stem = stem.strip_suffix(".tsv").unwrap_or(stem);
71
72    let suffix = stem.rsplit('_').next().unwrap_or("");
73    if suffix.is_empty() {
74        return sidecars;
75    }
76
77    // Walk from the data file's directory up to root
78    let mut dir = data_file.parent();
79    while let Some(current_dir) = dir {
80        // Look for JSON files in this directory that might match
81        if let Ok(entries) = std::fs::read_dir(current_dir) {
82            for entry in entries.flatten() {
83                let path = entry.path();
84                if path.extension().is_some_and(|e| e == "json") {
85                    let json_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
86                    let json_suffix = json_stem.rsplit('_').next().unwrap_or("");
87
88                    if json_suffix == suffix && is_sidecar_for(&path, data_file) {
89                        sidecars.push(path);
90                    }
91                }
92            }
93        }
94
95        if current_dir == root {
96            break;
97        }
98        dir = current_dir.parent();
99    }
100
101    sidecars
102}
103
104/// Check whether a JSON sidecar applies to a data file.
105///
106/// A sidecar applies if all key-value entity pairs in its filename (parts
107/// containing `-`) also appear in the data file's filename. Suffix parts
108/// (without `-`) must match when both are present.
109fn is_sidecar_for(sidecar: &Path, data_file: &Path) -> bool {
110    let sc_stem = sidecar.file_stem().and_then(|s| s.to_str()).unwrap_or("");
111    let df_stem = data_file.file_stem().and_then(|s| s.to_str()).unwrap_or("");
112    // Handle .tsv.gz double extension
113    let df_stem = df_stem.strip_suffix(".tsv").unwrap_or(df_stem);
114
115    let sc_parts: Vec<&str> = sc_stem.split('_').collect();
116    let df_parts: Vec<&str> = df_stem.split('_').collect();
117
118    for part in &sc_parts {
119        if part.contains('-') {
120            // Entity key-value pair must appear in the data file
121            if !df_parts.contains(part) {
122                return false;
123            }
124        }
125        // Suffix parts (no '-') are implicitly matched by find_sidecars()
126        // which already filters by suffix, so no extra check needed here.
127    }
128
129    true
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::io::Write;
136
137    #[test]
138    fn test_read_json_sidecar() {
139        let dir = std::env::temp_dir().join("bids_io_json_test");
140        std::fs::create_dir_all(&dir).unwrap();
141        let path = dir.join("sub-01_task-rest_eeg.json");
142        let mut f = std::fs::File::create(&path).unwrap();
143        write!(f, r#"{{"SamplingFrequency": 256, "EEGReference": "Cz"}}"#).unwrap();
144
145        let md = read_json_sidecar(&path).unwrap();
146        assert_eq!(md.get_f64("SamplingFrequency"), Some(256.0));
147        assert_eq!(md.get_str("EEGReference"), Some("Cz"));
148        std::fs::remove_dir_all(&dir).unwrap();
149    }
150}