Skip to main content

bids_layout/
get_builder.rs

1//! Fluent query builder for filtering BIDS files.
2//!
3//! [`GetBuilder`] provides a chainable API accessed via `layout.get()` to
4//! filter files by entity values, regex, existence, and scope.
5
6use bids_core::error::{BidsError, Result};
7use bids_core::file::BidsFile;
8use bids_core::utils::get_close_matches;
9use std::path::PathBuf;
10
11use crate::layout::BidsLayout;
12use crate::query::{ReturnType, Scope};
13
14/// How to handle unrecognized entity names in query filters.
15///
16/// By default, [`GetBuilder`] returns an error with "did you mean?"
17/// suggestions when a filter references an entity that doesn't exist in
18/// the dataset's index.
19///
20/// # Example
21///
22/// ```no_run
23/// # use bids_layout::{BidsLayout, InvalidFilters};
24/// # let layout = BidsLayout::new("/path").unwrap();
25/// let files = layout.get()
26///     .invalid_filters(InvalidFilters::Drop)  // silently ignore bad filters
27///     .filter("suject", "01")                 // typo — will be dropped
28///     .collect().unwrap();
29/// ```
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum InvalidFilters {
32    /// Return an error with suggestions for close matches (default).
33    Error,
34    /// Silently drop unrecognized filters.
35    Drop,
36    /// Pass unrecognized filters through without validation.
37    Allow,
38}
39
40/// Fluent query builder for [`BidsLayout::get()`](crate::BidsLayout::get).
41///
42/// Provides a chainable API for filtering BIDS files by entity values,
43/// regex patterns, existence checks, and scope. Execute the query with
44/// [`collect()`](Self::collect) (for `BidsFile` objects),
45/// [`return_paths()`](Self::return_paths) (for `PathBuf`s), or
46/// [`return_unique()`](Self::return_unique) (for unique entity values).
47///
48/// # Examples
49///
50/// ```no_run
51/// # use bids_layout::BidsLayout;
52/// # let layout = BidsLayout::new("/path").unwrap();
53/// // Basic entity filters
54/// let files = layout.get()
55///     .suffix("eeg")
56///     .extension(".edf")
57///     .subject("01")
58///     .collect().unwrap();
59///
60/// // Multi-value and regex filters
61/// let files = layout.get()
62///     .filter_any("subject", &["01", "02", "03"])
63///     .filter_regex("suffix", "(bold|sbref)")
64///     .collect().unwrap();
65///
66/// // Existence checks
67/// let files = layout.get()
68///     .query_any("session")    // must have a session
69///     .query_none("recording") // must NOT have a recording
70///     .collect().unwrap();
71///
72/// // Get unique entity values
73/// let tasks = layout.get()
74///     .suffix("bold")
75///     .return_unique("task").unwrap();
76/// ```
77pub struct GetBuilder<'a> {
78    pub(crate) layout: &'a BidsLayout,
79    pub(crate) filters: Vec<(String, Vec<String>, bool)>,
80    pub(crate) return_type: ReturnType,
81    pub(crate) target: Option<String>,
82    pub(crate) scope: Scope,
83    pub(crate) invalid_filters: InvalidFilters,
84}
85
86impl<'a> GetBuilder<'a> {
87    pub(crate) fn new(layout: &'a BidsLayout) -> Self {
88        Self {
89            layout,
90            filters: Vec::new(),
91            return_type: ReturnType::Object,
92            target: None,
93            scope: Scope::All,
94            invalid_filters: InvalidFilters::Error,
95        }
96    }
97
98    // ─── Entity filters ───
99    #[must_use]
100    pub fn subject(self, v: &str) -> Self {
101        self.filter("subject", v)
102    }
103    #[must_use]
104    pub fn session(self, v: &str) -> Self {
105        self.filter("session", v)
106    }
107    #[must_use]
108    pub fn task(self, v: &str) -> Self {
109        self.filter("task", v)
110    }
111    #[must_use]
112    pub fn run(self, v: &str) -> Self {
113        self.filter("run", v)
114    }
115    #[must_use]
116    pub fn datatype(self, v: &str) -> Self {
117        self.filter("datatype", v)
118    }
119    #[must_use]
120    pub fn acquisition(self, v: &str) -> Self {
121        self.filter("acquisition", v)
122    }
123    #[must_use]
124    pub fn recording(self, v: &str) -> Self {
125        self.filter("recording", v)
126    }
127    #[must_use]
128    pub fn space(self, v: &str) -> Self {
129        self.filter("space", v)
130    }
131    #[must_use]
132    pub fn suffix(self, v: &str) -> Self {
133        self.filter("suffix", v)
134    }
135
136    #[must_use]
137    pub fn extension(self, value: &str) -> Self {
138        let v = if value.starts_with('.') {
139            value.to_string()
140        } else {
141            format!(".{value}")
142        };
143        self.filter_owned("extension", vec![v])
144    }
145
146    /// Set scope: "all", "raw", "derivatives", "self", or a pipeline name.
147    #[must_use]
148    pub fn scope(mut self, scope: &str) -> Self {
149        self.scope = Scope::parse(scope);
150        self
151    }
152
153    /// Set invalid filter handling.
154    #[must_use]
155    pub fn invalid_filters(mut self, mode: InvalidFilters) -> Self {
156        self.invalid_filters = mode;
157        self
158    }
159
160    /// Filter by entity name and exact value.
161    #[must_use]
162    pub fn filter(mut self, entity: &str, value: &str) -> Self {
163        self.filters
164            .push((entity.into(), vec![value.into()], false));
165        self
166    }
167
168    /// Filter by entity with multiple allowed values.
169    #[must_use]
170    pub fn filter_any(mut self, entity: &str, values: &[&str]) -> Self {
171        self.filters.push((
172            entity.into(),
173            values
174                .iter()
175                .map(std::string::ToString::to_string)
176                .collect(),
177            false,
178        ));
179        self
180    }
181
182    /// Filter by entity with regex.
183    #[must_use]
184    pub fn filter_regex(mut self, entity: &str, pattern: &str) -> Self {
185        self.filters
186            .push((entity.into(), vec![pattern.into()], true));
187        self
188    }
189
190    /// Require entity to exist (any value).
191    #[must_use]
192    pub fn query_any(mut self, entity: &str) -> Self {
193        self.filters
194            .push((entity.into(), vec!["__ANY__".into()], false));
195        self
196    }
197
198    /// Require entity to NOT exist.
199    #[must_use]
200    pub fn query_none(mut self, entity: &str) -> Self {
201        self.filters
202            .push((entity.into(), vec!["__NONE__".into()], false));
203        self
204    }
205
206    #[must_use]
207    fn filter_owned(mut self, entity: &str, values: Vec<String>) -> Self {
208        self.filters.push((entity.into(), values, false));
209        self
210    }
211
212    #[must_use]
213    pub fn return_filenames(mut self) -> Self {
214        self.return_type = ReturnType::Filename;
215        self
216    }
217    #[must_use]
218    pub fn return_ids(mut self, target: &str) -> Self {
219        self.return_type = ReturnType::Id;
220        self.target = Some(target.into());
221        self
222    }
223    #[must_use]
224    pub fn return_dirs(mut self, target: &str) -> Self {
225        self.return_type = ReturnType::Dir;
226        self.target = Some(target.into());
227        self
228    }
229
230    // ─── Validation ───
231
232    fn validate_filters(&self) -> Result<Vec<(String, Vec<String>, bool)>> {
233        if self.invalid_filters == InvalidFilters::Allow {
234            return Ok(self.filters.clone());
235        }
236        let entities = self.layout.get_entities()?;
237        let entity_set: std::collections::HashSet<&str> =
238            entities.iter().map(std::string::String::as_str).collect();
239        let mut validated = Vec::new();
240        for (name, values, regex) in &self.filters {
241            if !entity_set.contains(name.as_str()) {
242                match self.invalid_filters {
243                    InvalidFilters::Error => {
244                        let suggestions = get_close_matches(name, &entities, 3);
245                        let mut msg = format!("'{name}' is not a recognized entity.");
246                        if !suggestions.is_empty() {
247                            msg.push_str(&format!(" Did you mean {suggestions:?}?"));
248                        }
249                        return Err(BidsError::InvalidFilter(msg));
250                    }
251                    InvalidFilters::Drop => continue,
252                    InvalidFilters::Allow => {}
253                }
254            }
255            validated.push((name.clone(), values.clone(), *regex));
256        }
257        Ok(validated)
258    }
259
260    // ─── Execution ───
261
262    /// Execute query, returning `BidsFile` objects.
263    pub fn collect(self) -> Result<Vec<BidsFile>> {
264        let filters = self.validate_filters()?;
265        let paths = self.layout.query_files_internal(&filters, &self.scope)?;
266        let mut files: Vec<BidsFile> = paths
267            .iter()
268            .filter_map(|p| self.layout.reconstruct_file(p).ok())
269            .collect();
270        files.sort();
271        Ok(files)
272    }
273
274    /// Execute query, returning file paths.
275    pub fn return_paths(self) -> Result<Vec<PathBuf>> {
276        let filters = self.validate_filters()?;
277        let paths = self.layout.query_files_internal(&filters, &self.scope)?;
278        let mut result: Vec<PathBuf> = paths.into_iter().map(PathBuf::from).collect();
279        result.sort();
280        Ok(result)
281    }
282
283    /// Execute query, returning unique values for a target entity.
284    pub fn return_unique(self, target: &str) -> Result<Vec<String>> {
285        let filters = self.validate_filters()?;
286        let paths = self.layout.query_files_internal(&filters, &self.scope)?;
287        let mut seen = std::collections::HashSet::new();
288        let mut values = Vec::new();
289        for path in &paths {
290            for (name, value, _, _) in self.layout.db().get_tags(path)? {
291                if name == target && seen.insert(value.clone()) {
292                    values.push(value);
293                }
294            }
295        }
296        values.sort();
297        Ok(values)
298    }
299
300    /// Execute query, returning directories for a target entity.
301    pub fn return_directories(self, target: &str) -> Result<Vec<String>> {
302        let filters = self.validate_filters()?;
303        self.layout.db().query_directories(target, &filters)
304    }
305
306    /// Deprecated: use [`collect()`](Self::collect) instead.
307    #[deprecated(since = "0.2.0", note = "renamed to `collect()` for clarity")]
308    pub fn returns(self) -> Result<Vec<BidsFile>> {
309        self.collect()
310    }
311}