modewise_dataset/
pattern.rs1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use regex::Regex;
5
6use crate::DatasetError;
7
8pub type Match = (PathBuf, Vec<(String, String)>);
10
11#[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 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(®ex::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(®ex::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 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 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 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}