Skip to main content

bids_schema/
version.rs

1//! BIDS specification version tracking and compatibility checking.
2//!
3//! This module provides the infrastructure for pinning `bids-rs` to a specific
4//! BIDS specification version, detecting version mismatches, and planning
5//! migration paths when the spec evolves.
6//!
7//! # Design
8//!
9//! The BIDS specification is versioned using [SemVer](https://semver.org/).
10//! Each release of `bids-rs` targets a specific spec version, declared in
11//! [`SUPPORTED_BIDS_VERSION`]. When loading a dataset whose
12//! `dataset_description.json` declares a different `BIDSVersion`, the library
13//! can warn or error depending on the compatibility policy.
14//!
15//! ## How spec updates flow through the codebase
16//!
17//! All spec-derived knowledge is concentrated in two places:
18//!
19//! 1. **`bids-schema`** — Entity definitions, valid datatypes/suffixes/extensions,
20//!    and filename validation patterns.  When the BIDS spec adds a new entity
21//!    or datatype, only this crate needs updating.
22//!
23//! 2. **`bids-core/src/configs/`** — `bids.json` and `derivatives.json` config
24//!    files containing entity regex patterns and path-building templates.
25//!    These are `include_str!`'d at compile time.
26//!
27//! All other crates pull their spec knowledge from these two sources rather
28//! than hardcoding it.
29
30use std::fmt;
31
32/// The BIDS specification version that this release of `bids-rs` targets.
33///
34/// Update this constant (and the schema/config data) when adopting a new
35/// spec version.  CI should verify that this matches the embedded schema.
36pub const SUPPORTED_BIDS_VERSION: BidsVersion = BidsVersion::new(1, 9, 0);
37
38/// The minimum BIDS version that this release can read without data loss.
39///
40/// Datasets older than this may be missing required fields or use deprecated
41/// conventions that the library cannot handle correctly.
42pub const MIN_COMPATIBLE_VERSION: BidsVersion = BidsVersion::new(1, 4, 0);
43
44/// A parsed BIDS specification version (SemVer).
45///
46/// # Examples
47///
48/// ```
49/// use bids_schema::version::BidsVersion;
50///
51/// let v = BidsVersion::parse("1.9.0").unwrap();
52/// assert_eq!(v.major, 1);
53/// assert_eq!(v.minor, 9);
54/// assert_eq!(v.patch, 0);
55///
56/// let older = BidsVersion::parse("1.6.0").unwrap();
57/// assert!(v > older);
58/// assert!(v.is_compatible_with(&older));
59/// ```
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
61pub struct BidsVersion {
62    pub major: u16,
63    pub minor: u16,
64    pub patch: u16,
65}
66
67impl BidsVersion {
68    /// Create a new version.
69    #[must_use]
70    pub const fn new(major: u16, minor: u16, patch: u16) -> Self {
71        Self {
72            major,
73            minor,
74            patch,
75        }
76    }
77
78    /// Parse a version string like `"1.9.0"`.
79    ///
80    /// Accepts 2-part (`"1.9"`) and 3-part (`"1.9.0"`) versions.
81    /// Returns `None` if parsing fails.
82    #[must_use]
83    pub fn parse(s: &str) -> Option<Self> {
84        let parts: Vec<&str> = s.trim().split('.').collect();
85        if parts.len() < 2 || parts.len() > 3 {
86            return None;
87        }
88        let major = parts[0].parse().ok()?;
89        let minor = parts[1].parse().ok()?;
90        let patch = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
91        Some(Self {
92            major,
93            minor,
94            patch,
95        })
96    }
97
98    /// Check whether this version is compatible with `other`.
99    ///
100    /// Two versions are compatible if they share the same major version and
101    /// `other` is at least [`MIN_COMPATIBLE_VERSION`].
102    #[must_use]
103    pub fn is_compatible_with(&self, other: &BidsVersion) -> bool {
104        self.major == other.major && *other >= MIN_COMPATIBLE_VERSION
105    }
106
107    /// Check whether `other` is from a newer spec than this version.
108    #[must_use]
109    pub fn is_older_than(&self, other: &BidsVersion) -> bool {
110        self < other
111    }
112
113    /// Return the compatibility status between this library version and
114    /// a dataset's declared BIDS version.
115    #[must_use]
116    pub fn check_compatibility(&self, dataset_version: &BidsVersion) -> Compatibility {
117        if *dataset_version == *self {
118            Compatibility::Exact
119        } else if dataset_version.major != self.major {
120            Compatibility::Incompatible {
121                reason: format!(
122                    "Major version mismatch: dataset is BIDS {dataset_version}, \
123                     library targets BIDS {self}"
124                ),
125            }
126        } else if *dataset_version < MIN_COMPATIBLE_VERSION {
127            Compatibility::Incompatible {
128                reason: format!(
129                    "Dataset BIDS version {dataset_version} is below minimum \
130                     compatible version {MIN_COMPATIBLE_VERSION}"
131                ),
132            }
133        } else if dataset_version > self {
134            Compatibility::Newer {
135                dataset: *dataset_version,
136                library: *self,
137            }
138        } else {
139            Compatibility::Compatible {
140                dataset: *dataset_version,
141                library: *self,
142            }
143        }
144    }
145}
146
147impl fmt::Display for BidsVersion {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
150    }
151}
152
153impl std::str::FromStr for BidsVersion {
154    type Err = String;
155
156    fn from_str(s: &str) -> Result<Self, Self::Err> {
157        Self::parse(s).ok_or_else(|| format!("Invalid BIDS version: '{s}'"))
158    }
159}
160
161/// Result of checking a dataset's BIDS version against the library's supported version.
162#[derive(Debug, Clone, PartialEq, Eq)]
163#[non_exhaustive]
164pub enum Compatibility {
165    /// Dataset version exactly matches the library's target version.
166    Exact,
167    /// Dataset is from an older (but supported) spec version.
168    /// Some newer features may not be present in the dataset.
169    Compatible {
170        dataset: BidsVersion,
171        library: BidsVersion,
172    },
173    /// Dataset declares a *newer* spec version than the library supports.
174    /// The library may not understand new entities, datatypes, or conventions.
175    Newer {
176        dataset: BidsVersion,
177        library: BidsVersion,
178    },
179    /// Dataset is fundamentally incompatible (different major version or
180    /// below minimum supported version).
181    Incompatible { reason: String },
182}
183
184impl Compatibility {
185    /// Returns `true` if the dataset can be safely processed.
186    #[must_use]
187    pub fn is_ok(&self) -> bool {
188        matches!(self, Self::Exact | Self::Compatible { .. })
189    }
190
191    /// Returns `true` if processing may lose information or produce warnings.
192    #[must_use]
193    pub fn has_warnings(&self) -> bool {
194        matches!(self, Self::Newer { .. })
195    }
196
197    /// Returns `true` if the dataset should not be processed.
198    #[must_use]
199    pub fn is_incompatible(&self) -> bool {
200        matches!(self, Self::Incompatible { .. })
201    }
202}
203
204impl fmt::Display for Compatibility {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        match self {
207            Self::Exact => write!(f, "exact match"),
208            Self::Compatible { dataset, library } => {
209                write!(f, "compatible (dataset {dataset}, library {library})")
210            }
211            Self::Newer { dataset, library } => {
212                write!(
213                    f,
214                    "WARNING: dataset uses BIDS {dataset}, but library only \
215                     supports up to {library}. Some features may not be recognized."
216                )
217            }
218            Self::Incompatible { reason } => write!(f, "INCOMPATIBLE: {reason}"),
219        }
220    }
221}
222
223/// Structured record of what changed between two BIDS spec versions.
224///
225/// Used by [`CHANGELOG`] to drive automated migration and validation.
226/// When bumping the supported BIDS version, add a new entry describing
227/// what changed so the library can:
228/// - Warn about deprecated features
229/// - Recognize new entities/datatypes
230/// - Validate against the correct rules
231#[derive(Debug, Clone)]
232pub struct SpecChange {
233    /// The version that introduced this change.
234    pub version: BidsVersion,
235    /// Human-readable summary.
236    pub summary: &'static str,
237    /// New entities added in this version (if any).
238    pub new_entities: &'static [&'static str],
239    /// New datatypes added (if any).
240    pub new_datatypes: &'static [&'static str],
241    /// New suffixes added (if any).
242    pub new_suffixes: &'static [&'static str],
243    /// Entities deprecated or removed (if any).
244    pub deprecated_entities: &'static [&'static str],
245    /// Breaking changes that require code updates.
246    pub breaking: bool,
247}
248
249/// Registry of known spec changes.  
250///
251/// When updating `SUPPORTED_BIDS_VERSION`, add an entry here documenting
252/// what the new version adds/changes.  This serves as both documentation
253/// and a machine-readable migration guide.
254///
255/// # Adding a new BIDS version
256///
257/// 1. Add a `SpecChange` entry to [`CHANGELOG`] below.
258/// 2. Update [`SUPPORTED_BIDS_VERSION`] at the top of this file.
259/// 3. Update `bids-schema/src/lib.rs` — add new entities/datatypes/suffixes/extensions.
260/// 4. Update `bids-core/src/configs/bids.json` — add regex patterns for new entities
261///    and path patterns for new file types.
262/// 5. Update `bids-core/src/configs/derivatives.json` if derivative patterns changed.
263/// 6. Update `bids-core/src/entities.rs` `ENTITY_ORDER` if new entities were added.
264/// 7. Run `cargo test --workspace` and fix any failing validations.
265/// 8. Update the `bids-cli upgrade` command if migration logic is needed.
266pub const CHANGELOG: &[SpecChange] = &[
267    SpecChange {
268        version: BidsVersion::new(1, 7, 0),
269        summary: "Added microscopy (micr) datatype, near-infrared spectroscopy (NIRS), \
270                  and genetic descriptor files",
271        new_entities: &["sample", "staining", "chunk"],
272        new_datatypes: &["micr"],
273        new_suffixes: &[
274            "TEM", "SEM", "uCT", "BF", "DF", "PC", "DIC", "FLUO", "CONF", "PLI", "CARS", "2PE",
275            "MPE", "SR", "NLO", "OCT", "SPIM",
276        ],
277        deprecated_entities: &[],
278        breaking: false,
279    },
280    SpecChange {
281        version: BidsVersion::new(1, 8, 0),
282        summary: "Added motion capture (motion), MR spectroscopy (mrs), PET, perfusion (perf), \
283                  and NIRS datatypes. Added quantitative MRI entities and suffixes.",
284        new_entities: &["tracksys", "nucleus", "volume"],
285        new_datatypes: &["motion", "mrs", "nirs", "perf"],
286        new_suffixes: &[
287            "motion",
288            "nirs",
289            "optodes",
290            "svs",
291            "mrsi",
292            "unloc",
293            "mrsref",
294            "asl",
295            "m0scan",
296            "aslcontext",
297            "asllabeling",
298        ],
299        deprecated_entities: &[],
300        breaking: false,
301    },
302    SpecChange {
303        version: BidsVersion::new(1, 9, 0),
304        summary: "Stabilized positional encoding entities, added atlas entity, \
305                  refined qMRI suffixes and file patterns",
306        new_entities: &["atlas"],
307        new_datatypes: &[],
308        new_suffixes: &[],
309        deprecated_entities: &[],
310        breaking: false,
311    },
312];
313
314/// Get all spec changes between two versions (exclusive of `from`, inclusive of `to`).
315#[must_use]
316pub fn changes_between(from: &BidsVersion, to: &BidsVersion) -> Vec<&'static SpecChange> {
317    CHANGELOG
318        .iter()
319        .filter(|c| c.version > *from && c.version <= *to)
320        .collect()
321}
322
323/// Get all entities that were added after a given version.
324#[must_use]
325pub fn entities_added_since(version: &BidsVersion) -> Vec<&'static str> {
326    CHANGELOG
327        .iter()
328        .filter(|c| c.version > *version)
329        .flat_map(|c| c.new_entities.iter().copied())
330        .collect()
331}
332
333/// Get all datatypes that were added after a given version.
334#[must_use]
335pub fn datatypes_added_since(version: &BidsVersion) -> Vec<&'static str> {
336    CHANGELOG
337        .iter()
338        .filter(|c| c.version > *version)
339        .flat_map(|c| c.new_datatypes.iter().copied())
340        .collect()
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_parse_version() {
349        let v = BidsVersion::parse("1.9.0").unwrap();
350        assert_eq!(v, BidsVersion::new(1, 9, 0));
351
352        let v2 = BidsVersion::parse("1.8").unwrap();
353        assert_eq!(v2, BidsVersion::new(1, 8, 0));
354
355        assert!(BidsVersion::parse("").is_none());
356        assert!(BidsVersion::parse("abc").is_none());
357        assert!(BidsVersion::parse("1.2.3.4").is_none());
358    }
359
360    #[test]
361    fn test_version_ordering() {
362        let v190 = BidsVersion::new(1, 9, 0);
363        let v180 = BidsVersion::new(1, 8, 0);
364        let v160 = BidsVersion::new(1, 6, 0);
365        assert!(v190 > v180);
366        assert!(v180 > v160);
367    }
368
369    #[test]
370    fn test_compatibility_exact() {
371        let lib = SUPPORTED_BIDS_VERSION;
372        let compat = lib.check_compatibility(&lib);
373        assert_eq!(compat, Compatibility::Exact);
374        assert!(compat.is_ok());
375    }
376
377    #[test]
378    fn test_compatibility_older_dataset() {
379        let lib = SUPPORTED_BIDS_VERSION;
380        let dataset = BidsVersion::new(1, 6, 0);
381        let compat = lib.check_compatibility(&dataset);
382        assert!(compat.is_ok());
383        assert!(!compat.has_warnings());
384    }
385
386    #[test]
387    fn test_compatibility_newer_dataset() {
388        let lib = BidsVersion::new(1, 9, 0);
389        let dataset = BidsVersion::new(1, 11, 0);
390        let compat = lib.check_compatibility(&dataset);
391        assert!(compat.has_warnings());
392        assert!(!compat.is_incompatible());
393    }
394
395    #[test]
396    fn test_compatibility_too_old() {
397        let lib = SUPPORTED_BIDS_VERSION;
398        let dataset = BidsVersion::new(1, 2, 0);
399        let compat = lib.check_compatibility(&dataset);
400        assert!(compat.is_incompatible());
401    }
402
403    #[test]
404    fn test_compatibility_major_mismatch() {
405        let lib = SUPPORTED_BIDS_VERSION;
406        let dataset = BidsVersion::new(2, 0, 0);
407        let compat = lib.check_compatibility(&dataset);
408        assert!(compat.is_incompatible());
409    }
410
411    #[test]
412    fn test_changes_between() {
413        let from = BidsVersion::new(1, 6, 0);
414        let to = BidsVersion::new(1, 9, 0);
415        let changes = changes_between(&from, &to);
416        assert!(!changes.is_empty());
417        // Should include 1.7, 1.8, 1.9 but not 1.6
418        assert!(changes.iter().all(|c| c.version > from));
419        assert!(changes.iter().all(|c| c.version <= to));
420    }
421
422    #[test]
423    fn test_entities_added_since() {
424        let v160 = BidsVersion::new(1, 6, 0);
425        let added = entities_added_since(&v160);
426        assert!(added.contains(&"sample"));
427        assert!(added.contains(&"tracksys"));
428        assert!(added.contains(&"atlas"));
429    }
430
431    #[test]
432    fn test_datatypes_added_since() {
433        let v160 = BidsVersion::new(1, 6, 0);
434        let added = datatypes_added_since(&v160);
435        assert!(added.contains(&"micr"));
436        assert!(added.contains(&"motion"));
437        assert!(added.contains(&"nirs"));
438    }
439
440    #[test]
441    fn test_display() {
442        assert_eq!(SUPPORTED_BIDS_VERSION.to_string(), "1.9.0");
443    }
444
445    #[test]
446    fn test_from_str() {
447        let v: BidsVersion = "1.9.0".parse().unwrap();
448        assert_eq!(v, BidsVersion::new(1, 9, 0));
449    }
450
451    #[test]
452    fn test_changelog_is_sorted() {
453        for window in CHANGELOG.windows(2) {
454            assert!(
455                window[0].version < window[1].version,
456                "CHANGELOG must be sorted by version: {} >= {}",
457                window[0].version,
458                window[1].version,
459            );
460        }
461    }
462
463    #[test]
464    fn test_supported_version_matches_last_changelog() {
465        let last = CHANGELOG.last().expect("CHANGELOG should not be empty");
466        assert_eq!(
467            last.version, SUPPORTED_BIDS_VERSION,
468            "SUPPORTED_BIDS_VERSION ({SUPPORTED_BIDS_VERSION}) must match the \
469             last CHANGELOG entry ({}). Did you forget to update one?",
470            last.version,
471        );
472    }
473}