Skip to main content

qubit_fs/directory/
list_options.rs

1// =============================================================================
2//    Copyright (c) 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Directory listing options.
9
10use std::time::Duration;
11use std::time::Instant;
12
13use crate::directory::ListFilter;
14use crate::error::FsError;
15use crate::error::FsErrorKind;
16use crate::error::FsOperation;
17use crate::error::FsResult;
18use crate::metadata::SymlinkPolicy;
19use crate::path::PathSemantics;
20use crate::path::RelativePath;
21
22/// Options controlling directory or prefix listing.
23///
24/// Options are validated by [`crate::FileSystem::list`] before a provider
25/// session is opened. A page-size hint is bounded by the provider's declared
26/// limit; `max_depth` and `max_entries` are caller-side safety bounds.
27///
28/// # Examples
29///
30/// ```
31/// use qubit_fs::directory::{ListFilter, ListOptions};
32///
33/// let options = ListOptions::default()
34///     .with_page_size(Some(100))
35///     .with_max_depth(Some(2))
36///     .with_max_entries(Some(500))
37///     .with_filter(Some(ListFilter::Subtree("reports".to_owned())));
38/// assert_eq!(options.page_size(), Some(100));
39/// assert_eq!(options.max_depth(), Some(2));
40/// assert_eq!(options.max_entries(), Some(500));
41/// assert!(options.validate().is_ok());
42/// ```
43#[non_exhaustive]
44#[derive(Clone, Debug, Eq, PartialEq, Default)]
45pub struct ListOptions {
46    /// Whether listing should recurse into child containers.
47    recursive: bool,
48    /// Optional symbolic-link policy overriding the filesystem default.
49    symlink_policy: Option<SymlinkPolicy>,
50    /// Whether entries should include metadata when available.
51    include_metadata: bool,
52    /// Optional provider page size hint.
53    page_size: Option<usize>,
54    /// Optional lexical prefix filter relative to the requested list root.
55    ///
56    /// The filter uses canonical `/`-separated relative paths. For example,
57    /// listing `/root` with `prefix: Some("nested/item")` matches
58    /// `/root/nested/item`, while `prefix: Some("item")` only matches an
59    /// immediate child named `item`.
60    filter: Option<ListFilter>,
61    /// Maximum returned descendant depth relative to the list root.
62    max_depth: Option<usize>,
63    /// Maximum number of entries returned to the caller.
64    max_entries: Option<usize>,
65    /// Maximum elapsed duration from stream creation.
66    deadline: Option<Duration>,
67}
68
69impl ListOptions {
70    /// Returns a copy with recursive traversal replaced.
71    #[inline]
72    #[must_use]
73    pub const fn with_recursive(mut self, recursive: bool) -> Self {
74        self.recursive = recursive;
75        self
76    }
77
78    /// Returns whether traversal recurses into child containers.
79    #[inline]
80    #[must_use]
81    pub const fn recursive(&self) -> bool {
82        self.recursive
83    }
84
85    /// Returns a copy with the symbolic-link policy override replaced.
86    #[inline]
87    #[must_use]
88    pub const fn with_symlink_policy(mut self, policy: SymlinkPolicy) -> Self {
89        self.symlink_policy = Some(policy);
90        self
91    }
92
93    /// Returns the optional symbolic-link policy override.
94    #[inline]
95    #[must_use]
96    pub const fn symlink_policy_override(&self) -> Option<SymlinkPolicy> {
97        self.symlink_policy
98    }
99
100    /// Returns a copy with metadata inclusion replaced.
101    #[inline]
102    #[must_use]
103    pub const fn with_include_metadata(mut self, include: bool) -> Self {
104        self.include_metadata = include;
105        self
106    }
107
108    /// Returns whether metadata is requested for entries.
109    #[inline]
110    #[must_use]
111    pub const fn include_metadata(&self) -> bool {
112        self.include_metadata
113    }
114
115    /// Returns a copy with the page-size hint replaced.
116    #[inline]
117    #[must_use]
118    pub const fn with_page_size(mut self, page_size: Option<usize>) -> Self {
119        self.page_size = page_size;
120        self
121    }
122
123    /// Returns the optional page-size hint.
124    #[inline]
125    #[must_use]
126    pub const fn page_size(&self) -> Option<usize> {
127        self.page_size
128    }
129
130    /// Returns a copy with the lexical prefix replaced.
131    #[inline]
132    #[must_use]
133    pub fn with_prefix(mut self, prefix: Option<String>) -> Self {
134        self.filter = prefix.map(ListFilter::Subtree);
135        self
136    }
137
138    /// Returns the optional lexical prefix.
139    ///
140    /// # Returns
141    /// `Some` with the configured subtree prefix, or `None` when no subtree
142    /// prefix filter is configured.
143    #[inline]
144    #[must_use]
145    pub fn prefix(&self) -> Option<&str> {
146        match self.filter.as_ref() {
147            Some(ListFilter::Subtree(prefix)) => Some(prefix),
148            _ => None,
149        }
150    }
151
152    /// Replaces the explicit listing filter.
153    #[must_use]
154    pub fn with_filter(mut self, filter: Option<ListFilter>) -> Self {
155        self.filter = filter;
156        self
157    }
158
159    /// Returns the explicit listing filter.
160    ///
161    /// # Returns
162    /// `Some` with the configured filter, or `None` when listing is unfiltered.
163    #[must_use]
164    pub fn filter(&self) -> Option<&ListFilter> {
165        self.filter.as_ref()
166    }
167
168    /// Returns defaults for a flat object-key listing.
169    #[must_use]
170    pub fn object_keys() -> Self {
171        Self {
172            recursive: true,
173            filter: Some(ListFilter::LiteralPrefix(String::new())),
174            ..Self::default()
175        }
176    }
177
178    /// Returns a copy with the maximum descendant depth replaced.
179    #[inline]
180    #[must_use]
181    pub const fn with_max_depth(mut self, max_depth: Option<usize>) -> Self {
182        self.max_depth = max_depth;
183        self
184    }
185
186    /// Returns the optional maximum descendant depth.
187    #[inline]
188    #[must_use]
189    pub const fn max_depth(&self) -> Option<usize> {
190        self.max_depth
191    }
192
193    /// Returns a copy with the maximum returned entry count replaced.
194    #[inline]
195    #[must_use]
196    pub const fn with_max_entries(mut self, max_entries: Option<usize>) -> Self {
197        self.max_entries = max_entries;
198        self
199    }
200
201    /// Returns the optional maximum returned entry count.
202    #[inline]
203    #[must_use]
204    pub const fn max_entries(&self) -> Option<usize> {
205        self.max_entries
206    }
207
208    /// Returns a copy with the maximum elapsed duration replaced.
209    #[inline]
210    #[must_use]
211    pub const fn with_deadline(mut self, deadline: Option<Duration>) -> Self {
212        self.deadline = deadline;
213        self
214    }
215
216    /// Returns the optional maximum elapsed duration from stream creation.
217    #[inline]
218    #[must_use]
219    pub const fn deadline(&self) -> Option<Duration> {
220        self.deadline
221    }
222
223    /// Validates pagination and canonical provider-facing prefix values.
224    ///
225    /// # Errors
226    ///
227    /// Returns an invalid-options error when the page size is zero or the
228    /// prefix is not a canonical relative path.
229    pub fn validate(&self) -> FsResult<()> {
230        if self.page_size == Some(0) {
231            return Err(FsError::new(
232                FsErrorKind::InvalidOptions,
233                FsOperation::List,
234                "list page size must be greater than zero",
235            ));
236        }
237        self.validate_common()?;
238        if let Some(ListFilter::Subtree(prefix)) = self.filter.as_ref() {
239            let parsed = RelativePath::parse(prefix).map_err(|_| {
240                FsError::new(
241                    FsErrorKind::InvalidOptions,
242                    FsOperation::List,
243                    "list subtree must be a canonical relative path",
244                )
245            })?;
246            if parsed.as_str() != prefix {
247                return Err(FsError::new(
248                    FsErrorKind::InvalidOptions,
249                    FsOperation::List,
250                    "list subtree must be a canonical relative path",
251                ));
252            }
253        }
254        if self
255            .deadline
256            .is_some_and(|deadline| Instant::now().checked_add(deadline).is_none())
257        {
258            return Err(FsError::new(
259                FsErrorKind::InvalidOptions,
260                FsOperation::List,
261                "list deadline exceeds the platform monotonic-clock range",
262            ));
263        }
264        Ok(())
265    }
266
267    /// Validates options against the filesystem path semantics.
268    ///
269    /// # Parameters
270    /// - `semantics`: Provider path semantics used to validate the filter.
271    ///
272    /// # Errors
273    /// Returns an invalid-options error when the configured filter is not
274    /// representable under `semantics`.
275    pub fn validate_for(&self, semantics: PathSemantics) -> FsResult<()> {
276        self.validate_common()?;
277        self.validate_filter(semantics)
278    }
279
280    /// Validates options that do not depend on path semantics.
281    fn validate_common(&self) -> FsResult<()> {
282        if self.page_size == Some(0) {
283            return Err(FsError::new(
284                FsErrorKind::InvalidOptions,
285                FsOperation::List,
286                "list page size must be greater than zero",
287            ));
288        }
289        if self
290            .deadline
291            .is_some_and(|deadline| Instant::now().checked_add(deadline).is_none())
292        {
293            return Err(FsError::new(
294                FsErrorKind::InvalidOptions,
295                FsOperation::List,
296                "list deadline exceeds the platform monotonic-clock range",
297            ));
298        }
299        Ok(())
300    }
301
302    /// Validates the selected filter against provider path semantics.
303    fn validate_filter(&self, semantics: PathSemantics) -> FsResult<()> {
304        match (semantics, self.filter.as_ref()) {
305            (PathSemantics::Hierarchical, Some(ListFilter::Subtree(prefix))) => {
306                let parsed = RelativePath::parse(prefix).map_err(|_| {
307                    FsError::new(
308                        FsErrorKind::InvalidOptions,
309                        FsOperation::List,
310                        "list subtree must be a canonical relative path",
311                    )
312                })?;
313                if parsed.as_str() != prefix {
314                    return Err(FsError::new(
315                        FsErrorKind::InvalidOptions,
316                        FsOperation::List,
317                        "list subtree must be a canonical relative path",
318                    ));
319                }
320            }
321            (PathSemantics::Hierarchical, Some(ListFilter::LiteralPrefix(_))) => {
322                return Err(FsError::new(
323                    FsErrorKind::InvalidOptions,
324                    FsOperation::List,
325                    "literal prefix requires flat path semantics",
326                ));
327            }
328            (PathSemantics::ObjectKey | PathSemantics::ProviderSpecific, Some(ListFilter::Subtree(_))) => {
329                return Err(FsError::new(
330                    FsErrorKind::InvalidOptions,
331                    FsOperation::List,
332                    "subtree filter requires hierarchical path semantics",
333                ));
334            }
335            (PathSemantics::ObjectKey | PathSemantics::ProviderSpecific, Some(ListFilter::LiteralPrefix(prefix)))
336                if prefix.contains('\0') =>
337            {
338                return Err(FsError::new(
339                    FsErrorKind::InvalidOptions,
340                    FsOperation::List,
341                    "literal prefix contains NUL",
342                ));
343            }
344            _ => {}
345        }
346        if matches!(semantics, PathSemantics::ObjectKey | PathSemantics::ProviderSpecific)
347            && (!self.recursive || self.max_depth.is_some())
348        {
349            return Err(FsError::new(
350                FsErrorKind::InvalidOptions,
351                FsOperation::List,
352                "flat listing requires recursive traversal without max_depth",
353            ));
354        }
355        Ok(())
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use std::hint::black_box;
362    use std::time::Duration;
363
364    use super::ListOptions;
365    use crate::directory::ListFilter;
366    use crate::metadata::SymlinkPolicy;
367
368    #[test]
369    fn option_accessors_are_executed_at_runtime() {
370        let constructor: fn() -> ListOptions = black_box(Default::default);
371        let with_recursive: fn(ListOptions, bool) -> ListOptions = black_box(ListOptions::with_recursive);
372        let recursive: fn(&ListOptions) -> bool = black_box(ListOptions::recursive);
373        let with_symlink_policy: fn(ListOptions, SymlinkPolicy) -> ListOptions =
374            black_box(ListOptions::with_symlink_policy);
375        let symlink_policy_override: fn(&ListOptions) -> Option<SymlinkPolicy> =
376            black_box(ListOptions::symlink_policy_override);
377        let with_include_metadata: fn(ListOptions, bool) -> ListOptions = black_box(ListOptions::with_include_metadata);
378        let include_metadata: fn(&ListOptions) -> bool = black_box(ListOptions::include_metadata);
379        let with_page_size: fn(ListOptions, Option<usize>) -> ListOptions = black_box(ListOptions::with_page_size);
380        let page_size: fn(&ListOptions) -> Option<usize> = black_box(ListOptions::page_size);
381        let with_prefix: fn(ListOptions, Option<String>) -> ListOptions = black_box(ListOptions::with_prefix);
382        let prefix: for<'a> fn(&'a ListOptions) -> Option<&'a str> = black_box(ListOptions::prefix);
383        let with_filter: fn(ListOptions, Option<ListFilter>) -> ListOptions = black_box(ListOptions::with_filter);
384        let filter: for<'a> fn(&'a ListOptions) -> Option<&'a ListFilter> = black_box(ListOptions::filter);
385        let object_keys: fn() -> ListOptions = black_box(ListOptions::object_keys);
386        let with_max_depth: fn(ListOptions, Option<usize>) -> ListOptions = black_box(ListOptions::with_max_depth);
387        let max_depth: fn(&ListOptions) -> Option<usize> = black_box(ListOptions::max_depth);
388        let with_max_entries: fn(ListOptions, Option<usize>) -> ListOptions = black_box(ListOptions::with_max_entries);
389        let max_entries: fn(&ListOptions) -> Option<usize> = black_box(ListOptions::max_entries);
390        let with_deadline: fn(ListOptions, Option<Duration>) -> ListOptions = black_box(ListOptions::with_deadline);
391        let deadline: fn(&ListOptions) -> Option<Duration> = black_box(ListOptions::deadline);
392
393        let options = with_deadline(
394            with_max_entries(
395                with_max_depth(
396                    with_filter(
397                        with_prefix(
398                            with_page_size(
399                                with_include_metadata(
400                                    with_symlink_policy(with_recursive(constructor(), true), SymlinkPolicy::Reject),
401                                    true,
402                                ),
403                                Some(20),
404                            ),
405                            Some("reports".to_owned()),
406                        ),
407                        Some(ListFilter::Subtree("reports".to_owned())),
408                    ),
409                    Some(2),
410                ),
411                Some(10),
412            ),
413            Some(Duration::from_secs(1)),
414        );
415
416        assert!(recursive(&options));
417        assert_eq!(Some(SymlinkPolicy::Reject), symlink_policy_override(&options));
418        assert!(include_metadata(&options));
419        assert_eq!(Some(20), page_size(&options));
420        assert_eq!(Some("reports"), prefix(&options));
421        assert!(matches!(filter(&options), Some(ListFilter::Subtree(value)) if value == "reports"));
422        assert!(recursive(&object_keys()));
423        assert_eq!(Some(2), max_depth(&options));
424        assert_eq!(Some(10), max_entries(&options));
425        assert_eq!(Some(Duration::from_secs(1)), deadline(&options));
426    }
427}