modewise_dataset/
pattern.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use regex::Regex;
5
6use crate::DatasetError;
7
8/// A matched file and its entity values, in pattern order.
9pub type Match = (PathBuf, Vec<(String, String)>);
10
11/// A relative path with `{entity}` placeholders.
12#[derive(Debug, Clone)]
13pub struct Pattern {
14    text: String,
15    segments: Vec<Segment>,
16}
17
18#[derive(Debug, Clone)]
19struct Segment {
20    regex: Regex,
21    /// Placeholder names, in capture-group order.
22    names: Vec<String>,
23}
24
25impl Pattern {
26    pub fn parse(text: &str) -> Result<Self, DatasetError> {
27        let error = |message: &str| DatasetError::Pattern {
28            pattern: text.to_string(),
29            message: message.to_string(),
30        };
31        if text.is_empty() || text.starts_with('/') {
32            return Err(error("must be a non-empty relative path"));
33        }
34        let placeholder = Regex::new(r"\{([^{}]*)\}").map_err(|e| error(&e.to_string()))?;
35        let valid_name =
36            Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").map_err(|e| error(&e.to_string()))?;
37        let segments = text
38            .split('/')
39            .map(|segment| {
40                let mut source = String::from("^");
41                let mut names = Vec::new();
42                let mut last = 0;
43                for capture in placeholder.captures_iter(segment) {
44                    let (Some(whole), Some(name)) = (capture.get(0), capture.get(1)) else {
45                        continue;
46                    };
47                    if !valid_name.is_match(name.as_str()) {
48                        return Err(error(&format!("invalid placeholder {{{}}}", name.as_str())));
49                    }
50                    source.push_str(&regex::escape(&segment[last..whole.start()]));
51                    source.push_str("([^/]+?)");
52                    names.push(name.as_str().to_string());
53                    last = whole.end();
54                }
55                let rest = &segment[last..];
56                if rest.contains('{') || rest.contains('}') {
57                    return Err(error("unbalanced braces"));
58                }
59                source.push_str(&regex::escape(rest));
60                source.push('$');
61                let regex = Regex::new(&source).map_err(|e| error(&e.to_string()))?;
62                Ok(Segment { regex, names })
63            })
64            .collect::<Result<Vec<_>, _>>()?;
65        Ok(Self {
66            text: text.to_string(),
67            segments,
68        })
69    }
70
71    /// Placeholder names in order of first appearance.
72    /// Entity values from text matching a one-segment pattern, such as `sub-{subject}` against
73    /// `sub-01`. `None` when the text does not match or the pattern has several segments.
74    pub fn capture(&self, text: &str) -> Option<BTreeMap<String, String>> {
75        let [segment] = self.segments.as_slice() else {
76            return None;
77        };
78        let captures = segment.regex.captures(text)?;
79        Some(
80            segment
81                .names
82                .iter()
83                .enumerate()
84                .map(|(i, name)| {
85                    (
86                        name.clone(),
87                        captures.get(i + 1).map_or("", |m| m.as_str()).to_string(),
88                    )
89                })
90                .collect(),
91        )
92    }
93
94    pub fn entities(&self) -> Vec<String> {
95        let mut seen = Vec::new();
96        for name in self.segments.iter().flat_map(|s| &s.names) {
97            if !seen.contains(name) {
98                seen.push(name.clone());
99            }
100        }
101        seen
102    }
103
104    /// Every file under `root` that matches, sorted by path, with its entity values.
105    pub fn find(&self, root: &Path) -> Result<Vec<Match>, DatasetError> {
106        let mut found = Vec::new();
107        self.walk(root, 0, &BTreeMap::new(), &mut found)?;
108        found.sort_by(|a, b| a.0.cmp(&b.0));
109        let order = self.entities();
110        Ok(found
111            .into_iter()
112            .map(|(path, values)| {
113                let ordered = order
114                    .iter()
115                    .map(|k| (k.clone(), values[k].clone()))
116                    .collect();
117                (path, ordered)
118            })
119            .collect())
120    }
121
122    /// The path for given entity values.
123    pub fn fill(&self, values: &BTreeMap<String, String>) -> Result<PathBuf, DatasetError> {
124        let mut path = self.text.clone();
125        for name in self.entities() {
126            let value = values.get(&name).ok_or_else(|| DatasetError::Pattern {
127                pattern: self.text.clone(),
128                message: format!("no value for {{{name}}}"),
129            })?;
130            path = path.replace(&format!("{{{name}}}"), value);
131        }
132        Ok(PathBuf::from(path))
133    }
134
135    fn walk(
136        &self,
137        dir: &Path,
138        depth: usize,
139        values: &BTreeMap<String, String>,
140        found: &mut Vec<(PathBuf, BTreeMap<String, String>)>,
141    ) -> Result<(), DatasetError> {
142        let segment = &self.segments[depth];
143        let last = depth + 1 == self.segments.len();
144        let entries = match std::fs::read_dir(dir) {
145            Ok(entries) => entries,
146            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
147            Err(e) => {
148                return Err(DatasetError::Io {
149                    path: dir.display().to_string(),
150                    message: e.to_string(),
151                });
152            }
153        };
154        for entry in entries.flatten() {
155            let name = entry.file_name().to_string_lossy().into_owned();
156            let Some(captures) = segment.regex.captures(&name) else {
157                continue;
158            };
159            let mut values = values.clone();
160            let consistent = segment.names.iter().enumerate().all(|(i, key)| {
161                let value = captures.get(i + 1).map_or("", |m| m.as_str()).to_string();
162                values.entry(key.clone()).or_insert_with(|| value.clone()) == &value
163            });
164            if !consistent {
165                continue;
166            }
167            let path = entry.path();
168            if last {
169                if path.is_file() {
170                    found.push((path, values));
171                }
172            } else if path.is_dir() {
173                self.walk(&path, depth + 1, &values, found)?;
174            }
175        }
176        Ok(())
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn lists_entities_in_order_and_fills_paths() {
186        let p = Pattern::parse("sub-{subject}/func/sub-{subject}_task-{task}_bold.h5").unwrap();
187        assert_eq!(p.entities(), ["subject", "task"]);
188        let values = BTreeMap::from([
189            ("subject".into(), "01".into()),
190            ("task".into(), "rest".into()),
191        ]);
192        assert_eq!(
193            p.fill(&values).unwrap(),
194            PathBuf::from("sub-01/func/sub-01_task-rest_bold.h5")
195        );
196    }
197
198    #[test]
199    fn fill_needs_every_entity() {
200        let p = Pattern::parse("sub-{subject}_task-{task}.h5").unwrap();
201        let values = BTreeMap::from([("subject".to_string(), "01".to_string())]);
202        assert!(matches!(p.fill(&values), Err(DatasetError::Pattern { .. })));
203    }
204
205    #[cfg(unix)]
206    #[test]
207    fn unreadable_folder_is_an_error_not_an_empty_result() {
208        use std::os::unix::fs::PermissionsExt;
209        let root =
210            std::env::temp_dir().join(format!("modewise-pattern-perm-{}", std::process::id()));
211        let locked = root.join("sub-01");
212        std::fs::create_dir_all(&locked).unwrap();
213        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
214        let result = Pattern::parse("sub-{subject}/x.h5").unwrap().find(&root);
215        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
216        assert!(matches!(result, Err(DatasetError::Io { .. })), "{result:?}");
217    }
218
219    #[test]
220    fn rejects_invalid_patterns() {
221        for text in ["", "/abs/{x}.h5", "a/{1x}.h5", "a/{x.h5", "a/{}.h5"] {
222            assert!(Pattern::parse(text).is_err(), "accepted {text:?}");
223        }
224    }
225}