1use std::fmt;
31
32pub const SUPPORTED_BIDS_VERSION: BidsVersion = BidsVersion::new(1, 9, 0);
37
38pub const MIN_COMPATIBLE_VERSION: BidsVersion = BidsVersion::new(1, 4, 0);
43
44#[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 #[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 #[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 #[must_use]
103 pub fn is_compatible_with(&self, other: &BidsVersion) -> bool {
104 self.major == other.major && *other >= MIN_COMPATIBLE_VERSION
105 }
106
107 #[must_use]
109 pub fn is_older_than(&self, other: &BidsVersion) -> bool {
110 self < other
111 }
112
113 #[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#[derive(Debug, Clone, PartialEq, Eq)]
163#[non_exhaustive]
164pub enum Compatibility {
165 Exact,
167 Compatible {
170 dataset: BidsVersion,
171 library: BidsVersion,
172 },
173 Newer {
176 dataset: BidsVersion,
177 library: BidsVersion,
178 },
179 Incompatible { reason: String },
182}
183
184impl Compatibility {
185 #[must_use]
187 pub fn is_ok(&self) -> bool {
188 matches!(self, Self::Exact | Self::Compatible { .. })
189 }
190
191 #[must_use]
193 pub fn has_warnings(&self) -> bool {
194 matches!(self, Self::Newer { .. })
195 }
196
197 #[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#[derive(Debug, Clone)]
232pub struct SpecChange {
233 pub version: BidsVersion,
235 pub summary: &'static str,
237 pub new_entities: &'static [&'static str],
239 pub new_datatypes: &'static [&'static str],
241 pub new_suffixes: &'static [&'static str],
243 pub deprecated_entities: &'static [&'static str],
245 pub breaking: bool,
247}
248
249pub 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#[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#[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#[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 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}