//! BIDS Filename Entity Parsing //! //! Parses BIDS-compliant filenames to extract entities like subject, session, task, etc. use std::path::Path; /// Parsed BIDS filename entities #[derive(Debug, Clone, Default)] pub struct FileEntities { /// Subject label (required) pub subject: String, /// Session label (optional) pub session: Option, /// Task label (optional) pub task: Option, /// Acquisition label (optional) pub acquisition: Option, /// Run number (optional) pub run: Option, /// Processing label (optional) pub processing: Option, /// Split number for large files (optional) pub split: Option, /// Data type suffix (meg, eeg, ieeg) pub datatype: Option, /// File extension pub extension: Option, } impl FileEntities { /// Check if this is an MEG file pub fn is_meg(&self) -> bool { self.datatype.as_deref() == Some("meg") } /// Check if this is an EEG file pub fn is_eeg(&self) -> bool { self.datatype.as_deref() == Some("eeg") } /// Check if this is an iEEG file pub fn is_ieeg(&self) -> bool { self.datatype.as_deref() == Some("ieeg") } } /// Parse a BIDS-compliant filename into entities /// /// # Arguments /// /// * `filename` - The filename (with or without path) to parse /// /// # Returns /// /// `Some(FileEntities)` if the filename matches BIDS conventions, `None` otherwise. /// /// # Example /// /// ``` /// use rtx_neuro_io::bids::parse_bids_filename; /// /// let entities = parse_bids_filename("sub-01_ses-pre_task-rest_meg.fif").unwrap(); /// assert_eq!(entities.subject, "01"); /// assert_eq!(entities.session, Some("pre".to_string())); /// assert_eq!(entities.task, Some("rest".to_string())); /// assert_eq!(entities.datatype, Some("meg".to_string())); /// ``` pub fn parse_bids_filename(filename: &str) -> Option { // Extract just the filename if a path was provided let filename = Path::new(filename) .file_name() .and_then(|n| n.to_str()) .unwrap_or(filename); // Split extension let (name, extension) = if let Some(pos) = filename.rfind('.') { let ext = &filename[pos + 1..]; // Strip extension for parsing, including .ds directories (&filename[..pos], Some(ext.to_string())) } else { (filename, None) }; // Split by underscores let parts: Vec<&str> = name.split('_').collect(); if parts.is_empty() { return None; } let mut entities = FileEntities { extension, ..Default::default() }; // Parse each part for part in &parts { if let Some(value) = part.strip_prefix("sub-") { entities.subject = value.to_string(); } else if let Some(value) = part.strip_prefix("ses-") { entities.session = Some(value.to_string()); } else if let Some(value) = part.strip_prefix("task-") { entities.task = Some(value.to_string()); } else if let Some(value) = part.strip_prefix("acq-") { entities.acquisition = Some(value.to_string()); } else if let Some(value) = part.strip_prefix("run-") { entities.run = value.parse().ok(); } else if let Some(value) = part.strip_prefix("proc-") { entities.processing = Some(value.to_string()); } else if let Some(value) = part.strip_prefix("split-") { entities.split = value.parse().ok(); } } // The last part (before extension) should be the datatype if let Some(last) = parts.last() { if !last.contains('-') { match *last { "meg" | "eeg" | "ieeg" => { entities.datatype = Some(last.to_string()); } _ => {} } } } // Subject is required if entities.subject.is_empty() { return None; } Some(entities) } /// Build a BIDS-compliant filename from entities pub fn build_bids_filename(entities: &FileEntities) -> String { let mut parts = vec![format!("sub-{}", entities.subject)]; if let Some(ref ses) = entities.session { parts.push(format!("ses-{}", ses)); } if let Some(ref task) = entities.task { parts.push(format!("task-{}", task)); } if let Some(ref acq) = entities.acquisition { parts.push(format!("acq-{}", acq)); } if let Some(run) = entities.run { parts.push(format!("run-{:02}", run)); } if let Some(ref proc) = entities.processing { parts.push(format!("proc-{}", proc)); } if let Some(split) = entities.split { parts.push(format!("split-{:02}", split)); } if let Some(ref dt) = entities.datatype { parts.push(dt.clone()); } let name = parts.join("_"); if let Some(ref ext) = entities.extension { format!("{}.{}", name, ext) } else { name } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_simple_filename() { let entities = parse_bids_filename("sub-01_task-rest_meg.fif").unwrap(); assert_eq!(entities.subject, "01"); assert_eq!(entities.task, Some("rest".to_string())); assert_eq!(entities.datatype, Some("meg".to_string())); assert_eq!(entities.extension, Some("fif".to_string())); } #[test] fn test_parse_full_filename() { let entities = parse_bids_filename("sub-control01_ses-001_task-rest_acq-neuromag_run-01_meg.fif") .unwrap(); assert_eq!(entities.subject, "control01"); assert_eq!(entities.session, Some("001".to_string())); assert_eq!(entities.task, Some("rest".to_string())); assert_eq!(entities.acquisition, Some("neuromag".to_string())); assert_eq!(entities.run, Some(1)); assert_eq!(entities.datatype, Some("meg".to_string())); } #[test] fn test_parse_eeg_filename() { let entities = parse_bids_filename("sub-02_task-memory_eeg.edf").unwrap(); assert_eq!(entities.subject, "02"); assert_eq!(entities.task, Some("memory".to_string())); assert!(entities.is_eeg()); assert!(!entities.is_meg()); } #[test] fn test_parse_ctf_directory() { let entities = parse_bids_filename("sub-01_task-rest_meg.ds").unwrap(); assert_eq!(entities.subject, "01"); assert_eq!(entities.datatype, Some("meg".to_string())); assert_eq!(entities.extension, Some("ds".to_string())); } #[test] fn test_parse_invalid_filename() { assert!(parse_bids_filename("random_file.txt").is_none()); assert!(parse_bids_filename("no_subject_here.fif").is_none()); } #[test] fn test_build_filename() { let entities = FileEntities { subject: "01".to_string(), session: Some("pre".to_string()), task: Some("rest".to_string()), datatype: Some("meg".to_string()), extension: Some("fif".to_string()), ..Default::default() }; assert_eq!( build_bids_filename(&entities), "sub-01_ses-pre_task-rest_meg.fif" ); } }