Skip to main content

fdu_core/query/
query_selection.rs

1//! Which retained entries a query considers, and how its results are shaped.
2//!
3//! Selection is evaluated at view time against the index that a scan already built, never
4//! during the walk. That split is what makes filters cheap and the cache reusable: scope
5//! decides what is observed and cached, so one snapshot answers every selection, and
6//! changing `--include` never invalidates anything. It is the same reasoning as tagging
7//! ignored entries rather than pruning them.
8
9use std::path::Path;
10
11use crate::engine_contract::{EntryKind, Error, Result};
12use crate::query::query_glob::Pattern;
13
14/// Which size metric a report answers in.
15#[derive(Clone, Copy, PartialEq, Eq, Debug)]
16pub enum SizeMetric {
17    /// Bytes the file's contents occupy logically.
18    Apparent,
19    /// Bytes the filesystem allocated, which sparse files and clones make differ.
20    Allocated,
21}
22
23impl SizeMetric {
24    /// Stable label, the inverse of [`parse_size_metric`](crate::query::parse_size_metric).
25    ///
26    /// `const` so a surface can declare the default metric's spelling from the defaults
27    /// table rather than writing the word out again: the command line's `--size` help said
28    /// `allocated` in a literal of its own.
29    pub const fn label(self) -> &'static str {
30        match self {
31            Self::Apparent => "apparent",
32            Self::Allocated => "allocated",
33        }
34    }
35}
36
37/// Allocated, from the request model's defaults table.
38///
39/// Read from the table rather than declared here, because this was the one place the
40/// default was apparent: every surface answered in allocated bytes, and a Rust caller or
41/// an opened-root read that named no metric got a different answer to the same request.
42impl Default for SizeMetric {
43    fn default() -> Self {
44        crate::query::Request::DEFAULTS.size
45    }
46}
47
48/// Which key results are ordered by.
49#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
50pub enum SortKey {
51    /// Bytes, in the selected metric.
52    #[default]
53    Size,
54    /// Entry counts.
55    Count,
56    /// Newest modification time.
57    Mtime,
58    /// Path or name, lexicographically.
59    Name,
60}
61
62/// An inclusive-start, exclusive-end window over modification times, in nanoseconds.
63///
64/// Half-open on purpose: `[since, before)` composes without double-counting when a caller
65/// walks a tree in windows, and the inclusive start is the safe side for sync — a file
66/// whose mtime equals the watermark re-lists, because duplicates are cheap and omissions
67/// are not.
68#[derive(Clone, Copy, Debug, Default)]
69pub struct ModifiedWindow {
70    /// Inclusive lower bound.
71    pub since: Option<i64>,
72    /// Exclusive upper bound.
73    pub before: Option<i64>,
74}
75
76impl ModifiedWindow {
77    /// Whether a modification time falls inside the window.
78    pub fn contains(&self, mtime_ns: i64) -> bool {
79        self.since.is_none_or(|since| mtime_ns >= since)
80            && self.before.is_none_or(|before| mtime_ns < before)
81    }
82
83    /// Whether the window constrains anything at all.
84    pub fn is_unbounded(&self) -> bool {
85        self.since.is_none() && self.before.is_none()
86    }
87}
88
89/// Which entries a query considers by their `.gitignore` classification.
90///
91/// Selection rather than scope, like every other filter: the scan classifies every entry
92/// and the index keeps both partitions, so choosing one never costs a rescan. An ignored
93/// directory's descendants are all ignored, so rejecting entries one at a time prunes
94/// exactly the subtrees `git` would.
95#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
96pub enum IgnoredEntries {
97    /// Every entry, ignored or not. Rows carry their ignored share.
98    #[default]
99    Include,
100    /// Only entries no `.gitignore` rule ignores.
101    Exclude,
102    /// Only entries a `.gitignore` rule ignores.
103    Only,
104}
105
106impl IgnoredEntries {
107    /// Whether an entry with this classification passes.
108    pub const fn admits(self, ignored: bool) -> bool {
109        match self {
110            Self::Include => true,
111            Self::Exclude => !ignored,
112            Self::Only => ignored,
113        }
114    }
115
116    /// Parse the library's spelling: `include`, `exclude`, or `only`.
117    ///
118    /// The expectation only, as [`crate::query::ViewSpec::parse`] returns it, so each
119    /// surface names its own knob in front of it.
120    pub fn parse(value: &str) -> std::result::Result<Self, String> {
121        match value.trim().to_ascii_lowercase().as_str() {
122            "include" => Ok(Self::Include),
123            "exclude" => Ok(Self::Exclude),
124            "only" => Ok(Self::Only),
125            _ => Err("expected one of include, exclude, only".to_string()),
126        }
127    }
128
129    /// Stable label, the inverse of [`Self::parse`].
130    pub const fn label(self) -> &'static str {
131        match self {
132            Self::Include => "include",
133            Self::Exclude => "exclude",
134            Self::Only => "only",
135        }
136    }
137}
138
139/// A bound that may be unlimited.
140///
141/// `--depth all` and `-n all` are spelled the same way as their numeric forms rather than
142/// as a separate flag, so "how deep" and "how many" stay single questions.
143#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
144pub enum Bound {
145    /// No limit.
146    #[default]
147    All,
148    /// At most this many.
149    Limit(usize),
150}
151
152impl Bound {
153    /// Whether a zero-based index is within the bound.
154    pub fn admits(self, index: usize) -> bool {
155        match self {
156            Self::All => true,
157            Self::Limit(limit) => index < limit,
158        }
159    }
160
161    /// The bound as a count, when it has one.
162    pub fn limit(self) -> Option<usize> {
163        match self {
164            Self::All => None,
165            Self::Limit(limit) => Some(limit),
166        }
167    }
168}
169
170/// Which retained entries a query considers, and how its results are shaped.
171#[derive(Clone, Debug, Default)]
172pub struct Selection {
173    /// Patterns an entry must match at least one of, when non-empty.
174    pub include: Vec<Pattern>,
175    /// Patterns that exclude an entry outright; exclusion wins over inclusion.
176    pub exclude: Vec<Pattern>,
177    /// Smallest size, in the selected metric, an entry may have.
178    pub min_size: Option<u64>,
179    /// Entry kinds to consider; empty means every kind.
180    pub kinds: Vec<EntryKind>,
181    /// Modification-time window.
182    pub modified: ModifiedWindow,
183    /// Entries to consider by `.gitignore` classification.
184    ///
185    /// Anything but [`IgnoredEntries::Include`] needs an index that observed control
186    /// state; [`crate::query::Request::validate`] refuses it otherwise.
187    pub ignored: IgnoredEntries,
188    /// How deep a rendered tree descends, or `None` to let each view apply its own.
189    ///
190    /// Optional for the same reason `limit` and `sort` are. The depth that suits a tree
191    /// is not the depth that suits a flat enumeration, and while this was a plain
192    /// `Bound` the only default the library could offer was "unbounded" -- so the CLI
193    /// declared `default_value = "2"` itself and every other caller silently got a
194    /// different report for the same request.
195    pub depth: Option<Bound>,
196    /// How many entries a view reports.
197    /// Rows to keep, or `None` to let each view apply its own bound.
198    ///
199    /// Optional for the same reason `sort` is: a bound that suits a per-directory tree
200    /// is not the bound that suits a complete enumeration, and a single shared default
201    /// produced "the ten alphabetically-first entries" of a 192,871-entry tree.
202    pub limit: Option<Bound>,
203    /// Ordering key, or `None` to let each view apply its own default.
204    ///
205    /// Optional rather than defaulted here because the sensible default differs by view:
206    /// a tree and a type breakdown rank by size, while a flat file listing reads in name
207    /// order. One shared default would be wrong for one of them.
208    pub sort: Option<SortKey>,
209    /// Whether the ordering is reversed.
210    pub reverse: bool,
211    /// Which size metric the report answers in.
212    pub size: SizeMetric,
213}
214
215/// The facts about one entry that selection examines.
216///
217/// Passing a small explicit record rather than an index handle keeps the predicate pure
218/// and trivially testable, and keeps selection from reaching into index internals.
219///
220/// `relative` and `name` carry one spelling of the entry, and every name-shaped predicate
221/// -- globs, exact names, extensions, terminal suffixes, ancestor names -- is evaluated
222/// against that spelling. A one-shot report and a watch use the native path. An opened-root
223/// read uses the portable path a page returns, so a caller filters by the names it was
224/// shown: see [`EntrySelection`].
225#[derive(Clone, Copy, Debug)]
226pub struct Candidate<'a> {
227    /// Path relative to the index root, in the spelling the selection is evaluated in.
228    pub relative: &'a Path,
229    /// Final path component, in the same spelling as `relative`.
230    pub name: &'a str,
231    /// What the entry is.
232    pub kind: EntryKind,
233    /// Apparent size in bytes.
234    pub bytes: u64,
235    /// Allocated size in bytes.
236    pub allocated: u64,
237    /// Modification time in nanoseconds since the Unix epoch.
238    pub mtime_ns: i64,
239    /// Whether a `.gitignore` rule ignores the entry, or an ancestor of it.
240    ///
241    /// `false` in an index that observed no control state, where no selection by it is
242    /// accepted.
243    pub ignored: bool,
244}
245
246/// Additive selection for portable opened-root entry projections.
247///
248/// The established [`Selection`] remains the shared one-shot query contract. This value
249/// composes it instead of adding fields to that public struct, preserving source
250/// compatibility for existing Rust callers while keeping interactive row predicates in
251/// one pure engine-owned value.
252///
253/// **Every axis sees the portable identity.** Inside an opened-root read, the name, the
254/// relative path an anchored glob matches, and every ancestor component are the canonical
255/// escaped `/`-joined spelling a page row carries as `portable_path`, never the native
256/// path. `100%.txt` is `100%25.txt` to every predicate, and a directory whose native name
257/// is not UTF-8 is matched by its escaped name, which is the only spelling a caller could
258/// have been shown. A path taken from a page can therefore be passed back into a filter
259/// unchanged. The same rule governs the base [`Selection`] and a `Report` projection's
260/// selection inside an opened read; one-shot reports and watches keep native names.
261///
262/// Terminal suffixes and ancestor names are validated where they are written, by
263/// [`Self::admit_terminal_extension`] and [`Self::admit_ancestor_name`], and again by
264/// [`Self::validate`] when a read receives the selection: every spelling that could only
265/// ever match nothing is refused, the same set the `MetaBrowser` `CatalogQuery` contract
266/// refuses, so the two providers reject the same values rather than one answering with an
267/// empty page.
268#[derive(Clone, Debug, Default)]
269pub struct EntrySelection {
270    /// Existing fdu query predicates.
271    pub query: Selection,
272    /// Largest size, inclusive, in the selected metric.
273    ///
274    /// A caller with an exclusive upper bound translates `less_than: n` to `n - 1`.
275    pub max_size: Option<u64>,
276    /// Exclude entries in the fixed ignored partition.
277    pub exclude_ignored: bool,
278    /// Logical extensions to admit, including the leading dot.
279    ///
280    /// This is name identity, not the registry's canonical classification bucket.
281    pub logical_extensions: Vec<String>,
282    /// Exact basenames to admit, compared case-insensitively.
283    ///
284    /// When either this or `logical_extensions` is nonempty, matching either admits the
285    /// name. That represents one identity filter rather than two intersected filters.
286    pub exact_names: Vec<String>,
287    /// Lowercase terminal suffixes to admit, including the leading dot.
288    ///
289    /// Unlike a logical extension, only the final dotted component participates. Each
290    /// entry is unique, starts with a dot, is lowercase, and is one suffix: `.rs`, never
291    /// `rs`, `.RS`, `.`, `.tar.gz`, or a value holding a separator.
292    pub terminal_extensions: Vec<String>,
293    /// Exact ancestor path-component names to admit, as portable components.
294    ///
295    /// Each entry is unique and one whole component: never empty, `.`, `..`, or a value
296    /// holding `/` or `\`.
297    pub ancestor_names: Vec<String>,
298}
299
300/// Which spelling of an entry's path a selection is evaluated against.
301#[derive(Clone, Copy, PartialEq, Eq, Debug)]
302pub(crate) enum NameIdentity {
303    /// The native path, as one-shot reports and watches match it.
304    Native,
305    /// The canonical escaped path, as every opened-root read matches it.
306    Portable,
307}
308
309impl From<Selection> for EntrySelection {
310    fn from(query: Selection) -> Self {
311        Self { query, ..Self::default() }
312    }
313}
314
315impl Selection {
316    /// Heap payload retained when an opened-root continuation owns this selection.
317    pub(crate) fn retained_heap_bytes(&self) -> usize {
318        let pattern_bytes =
319            self.include.iter().chain(&self.exclude).fold(0_usize, |total, pattern| {
320                total.saturating_add(pattern.retained_heap_bytes())
321            });
322        self.include
323            .capacity()
324            .saturating_add(self.exclude.capacity())
325            .saturating_mul(std::mem::size_of::<Pattern>())
326            .saturating_add(pattern_bytes)
327            .saturating_add(self.kinds.capacity().saturating_mul(std::mem::size_of::<EntryKind>()))
328    }
329
330    /// Whether this selection constrains which entries are considered.
331    ///
332    /// An unconstrained selection lets a view read pre-computed roll-up state directly
333    /// instead of traversing entries, which is the difference between the two performance
334    /// tiers a report can run in.
335    pub fn is_unfiltered(&self) -> bool {
336        self.include.is_empty()
337            && self.exclude.is_empty()
338            && self.min_size.is_none()
339            && self.kinds.is_empty()
340            && self.modified.is_unbounded()
341            && self.ignored == IgnoredEntries::Include
342    }
343
344    /// Whether an entry passes every filter.
345    pub fn admits(&self, candidate: &Candidate<'_>) -> bool {
346        if !self.kinds.is_empty() && !self.kinds.contains(&candidate.kind) {
347            return false;
348        }
349        if !self.ignored.admits(candidate.ignored) {
350            return false;
351        }
352        if let Some(min_size) = self.min_size {
353            if self.size_of(candidate) < min_size {
354                return false;
355            }
356        }
357        if !self.modified.contains(candidate.mtime_ns) {
358            return false;
359        }
360        // Exclusion wins: a pattern the caller wrote to keep something out should not be
361        // overridden by a broader pattern they wrote to let things in.
362        if self.exclude.iter().any(|p| p.matches(candidate.relative, candidate.name)) {
363            return false;
364        }
365        if self.include.is_empty() {
366            return true;
367        }
368        self.include.iter().any(|p| p.matches(candidate.relative, candidate.name))
369    }
370
371    /// The size of an entry in the selected metric.
372    pub fn size_of(&self, candidate: &Candidate<'_>) -> u64 {
373        match self.size {
374            SizeMetric::Apparent => candidate.bytes,
375            SizeMetric::Allocated => candidate.allocated,
376        }
377    }
378}
379
380impl EntrySelection {
381    /// Add one terminal suffix, refusing a spelling that could only ever match nothing.
382    ///
383    /// # Errors
384    ///
385    /// [`Error::InvalidValue`] for a duplicate, an undotted or non-lowercase suffix, a bare
386    /// dot, a compound suffix such as `.tar.gz`, or a value holding a separator.
387    pub fn admit_terminal_extension(&mut self, value: impl Into<String>) -> Result<()> {
388        let value = value.into();
389        if self.terminal_extensions.contains(&value) {
390            return Err(refusal(TERMINAL_KIND, &value, TERMINAL_UNIQUE));
391        }
392        check_value(TERMINAL_KIND, &value, TERMINAL_RULES)?;
393        self.terminal_extensions.push(value);
394        Ok(())
395    }
396
397    /// Add one ancestor name, refusing a value that is not one whole path component.
398    ///
399    /// # Errors
400    ///
401    /// [`Error::InvalidValue`] for a duplicate, an empty name, `.` or `..`, or a value
402    /// holding `/` or `\`.
403    pub fn admit_ancestor_name(&mut self, value: impl Into<String>) -> Result<()> {
404        let value = value.into();
405        if self.ancestor_names.contains(&value) {
406            return Err(refusal(ANCESTOR_KIND, &value, ANCESTOR_UNIQUE));
407        }
408        check_value(ANCESTOR_KIND, &value, ANCESTOR_RULES)?;
409        self.ancestor_names.push(value);
410        Ok(())
411    }
412
413    /// Check the axes a caller may have written straight into the public fields.
414    ///
415    /// The admitting constructors apply the same rules one value at a time; a read applies
416    /// this to every selection it receives, so a hand-built value is refused the same way.
417    ///
418    /// # Errors
419    ///
420    /// The refusal `MetaBrowser`'s `CatalogQuery` and the Python `EntrySelection` raise
421    /// first, in their order: terminal extensions before ancestor names, and within each
422    /// list uniqueness first, then each rule across the whole list. A list wrong in two
423    /// ways therefore gets the same message on every surface.
424    pub fn validate(&self) -> Result<()> {
425        check_list(TERMINAL_KIND, &self.terminal_extensions, TERMINAL_UNIQUE, TERMINAL_RULES)?;
426        check_list(ANCESTOR_KIND, &self.ancestor_names, ANCESTOR_UNIQUE, ANCESTOR_RULES)
427    }
428
429    /// Heap payload retained when an opened-root continuation owns this selection.
430    pub(crate) fn retained_heap_bytes(&self) -> usize {
431        self.query
432            .retained_heap_bytes()
433            .saturating_add(retained_strings(
434                &self.logical_extensions,
435                self.logical_extensions.capacity(),
436            ))
437            .saturating_add(retained_strings(&self.exact_names, self.exact_names.capacity()))
438            .saturating_add(retained_strings(
439                &self.terminal_extensions,
440                self.terminal_extensions.capacity(),
441            ))
442            .saturating_add(retained_strings(&self.ancestor_names, self.ancestor_names.capacity()))
443    }
444
445    /// Whether this portable entry selection constrains any row.
446    pub fn is_unfiltered(&self) -> bool {
447        self.query.is_unfiltered()
448            && self.max_size.is_none()
449            && !self.exclude_ignored
450            && self.logical_extensions.is_empty()
451            && self.exact_names.is_empty()
452            && self.terminal_extensions.is_empty()
453            && self.ancestor_names.is_empty()
454    }
455
456    /// Whether an entry passes the base query and every opened-row predicate.
457    pub fn admits(&self, candidate: &Candidate<'_>) -> bool {
458        if !self.query.admits(candidate) {
459            return false;
460        }
461        if let Some(max_size) = self.max_size {
462            if self.query.size_of(candidate) > max_size {
463                return false;
464            }
465        }
466        if self.exclude_ignored && candidate.ignored {
467            return false;
468        }
469        if !self.logical_extensions.is_empty() || !self.exact_names.is_empty() {
470            if candidate.kind != EntryKind::File {
471                return false;
472            }
473            let extension_matches = crate::classify::logical_ext(candidate.name.as_ref())
474                .is_some_and(|extension| {
475                    self.logical_extensions
476                        .iter()
477                        .any(|expected| extension.eq_ignore_ascii_case(expected))
478                });
479            let name_matches = self
480                .exact_names
481                .iter()
482                .any(|expected| candidate.name.eq_ignore_ascii_case(expected));
483            if !extension_matches && !name_matches {
484                return false;
485            }
486        }
487        if !self.terminal_extensions.is_empty() {
488            if candidate.kind != EntryKind::File {
489                return false;
490            }
491            let Some(suffix) = terminal_suffix(candidate.name) else {
492                return false;
493            };
494            if !self
495                .terminal_extensions
496                .iter()
497                .any(|expected| suffix.eq_ignore_ascii_case(expected))
498            {
499                return false;
500            }
501        }
502        if !self.ancestor_names.is_empty()
503            && !candidate.relative.parent().is_some_and(|parent| {
504                parent.components().any(|component| {
505                    let std::path::Component::Normal(name) = component else {
506                        return false;
507                    };
508                    self.ancestor_names.iter().any(|expected| name == expected.as_str())
509                })
510            })
511        {
512            return false;
513        }
514        true
515    }
516}
517
518const TERMINAL_KIND: &str = "terminal extension";
519const ANCESTOR_KIND: &str = "ancestor name";
520const TERMINAL_UNIQUE: &str = "terminal_extensions entries must be unique";
521const ANCESTOR_UNIQUE: &str = "ancestor_names entries must be unique";
522
523/// One `CatalogQuery` rule: whether a value breaks it, and the message that says so.
524type Rule = (fn(&str) -> bool, &'static str);
525
526/// The `MetaBrowser` `CatalogQuery` rules for terminal suffixes, in its order.
527///
528/// Each rule may assume the ones before it held for every value in the list: the last
529/// slices past a leading dot the first one proved.
530const TERMINAL_RULES: &[Rule] = &[
531    (undotted, "terminal_extensions entries must start with a dot"),
532    (not_lowercase, "terminal_extensions entries must be lowercase"),
533    (not_terminal_suffix, "terminal_extensions entries must be canonical terminal suffixes"),
534];
535
536/// The `MetaBrowser` `CatalogQuery` rule for ancestor names.
537const ANCESTOR_RULES: &[Rule] =
538    &[(not_path_component, "ancestor_names entries must be exact path-component names")];
539
540fn undotted(value: &str) -> bool {
541    !value.starts_with('.')
542}
543
544/// Unicode lowering, as the contract's `str.lower` check is: an uppercase letter outside
545/// ASCII is refused too, even though matching folds only ASCII.
546fn not_lowercase(value: &str) -> bool {
547    value.to_lowercase() != value
548}
549
550fn not_terminal_suffix(value: &str) -> bool {
551    value.chars().count() < 2 || value.contains(['/', '\\']) || value[1..].contains('.')
552}
553
554fn not_path_component(value: &str) -> bool {
555    value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\'])
556}
557
558fn refusal(kind: &'static str, value: &str, hint: &str) -> Error {
559    Error::InvalidValue { kind, value: value.to_owned(), hint: hint.to_owned() }
560}
561
562/// Refuse one value by the first rule it breaks.
563fn check_value(kind: &'static str, value: &str, rules: &[Rule]) -> Result<()> {
564    match rules.iter().find(|(breaks, _)| breaks(value)) {
565        Some((_, hint)) => Err(refusal(kind, value, hint)),
566        None => Ok(()),
567    }
568}
569
570/// Refuse a whole list the way `CatalogQuery.__post_init__` does: uniqueness first, then
571/// each rule across every value before the next rule is tried.
572fn check_list(kind: &'static str, values: &[String], unique: &str, rules: &[Rule]) -> Result<()> {
573    let mut seen = std::collections::HashSet::with_capacity(values.len());
574    if let Some(repeated) = values.iter().find(|value| !seen.insert(value.as_str())) {
575        return Err(refusal(kind, repeated, unique));
576    }
577    for (breaks, hint) in rules {
578        if let Some(value) = values.iter().find(|value| breaks(value)) {
579            return Err(refusal(kind, value, hint));
580        }
581    }
582    Ok(())
583}
584
585fn retained_strings(values: &[String], capacity: usize) -> usize {
586    capacity.saturating_mul(std::mem::size_of::<String>()).saturating_add(
587        values.iter().fold(0_usize, |total, value| total.saturating_add(value.capacity())),
588    )
589}
590
591fn terminal_suffix(name: &str) -> Option<&str> {
592    let dot = name.rfind('.')?;
593    (dot > 0 && dot + 1 < name.len()).then_some(&name[dot..])
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use std::path::PathBuf;
600
601    fn candidate(path: &str, kind: EntryKind, bytes: u64, mtime_ns: i64) -> (PathBuf, String) {
602        let relative = PathBuf::from(path);
603        let name = relative
604            .file_name()
605            .map(|name| name.to_string_lossy().into_owned())
606            .unwrap_or_default();
607        let _ = (kind, bytes, mtime_ns);
608        (relative, name)
609    }
610
611    fn entry_admits(
612        selection: &EntrySelection,
613        path: &str,
614        kind: EntryKind,
615        bytes: u64,
616        mtime: i64,
617        ignored: bool,
618    ) -> bool {
619        let (relative, name) = candidate(path, kind, bytes, mtime);
620        selection.admits(&Candidate {
621            relative: &relative,
622            name: &name,
623            kind,
624            bytes,
625            allocated: bytes.div_ceil(512) * 512,
626            mtime_ns: mtime,
627            ignored,
628        })
629    }
630
631    fn admits(selection: &Selection, path: &str, kind: EntryKind, bytes: u64, mtime: i64) -> bool {
632        classified_admits(selection, path, kind, bytes, mtime, false)
633    }
634
635    fn classified_admits(
636        selection: &Selection,
637        path: &str,
638        kind: EntryKind,
639        bytes: u64,
640        mtime: i64,
641        ignored: bool,
642    ) -> bool {
643        let (relative, name) = candidate(path, kind, bytes, mtime);
644        selection.admits(&Candidate {
645            relative: &relative,
646            name: &name,
647            kind,
648            bytes,
649            allocated: bytes.div_ceil(512) * 512,
650            mtime_ns: mtime,
651            ignored,
652        })
653    }
654
655    fn pattern(source: &str) -> Pattern {
656        Pattern::parse(source).expect("pattern compiles")
657    }
658
659    #[test]
660    fn a_default_selection_admits_everything_and_reads_the_fast_tier() {
661        let selection = Selection::default();
662        assert!(selection.is_unfiltered());
663        assert!(admits(&selection, "src/main.rs", EntryKind::File, 10, 5));
664        assert!(admits(&selection, "src", EntryKind::Dir, 0, 5));
665    }
666
667    #[test]
668    fn include_patterns_narrow_and_exclude_patterns_win() {
669        let mut selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
670        assert!(!selection.is_unfiltered());
671        assert!(admits(&selection, "src/main.rs", EntryKind::File, 10, 5));
672        assert!(!admits(&selection, "src/main.toml", EntryKind::File, 10, 5));
673
674        // An exclusion beats a matching inclusion, so a narrowing rule cannot be undone
675        // by a broader one written elsewhere on the command line.
676        selection.exclude.push(pattern("**/generated/**"));
677        assert!(!admits(&selection, "src/generated/api.rs", EntryKind::File, 10, 5));
678        assert!(admits(&selection, "src/hand/api.rs", EntryKind::File, 10, 5));
679    }
680
681    #[test]
682    fn min_size_follows_the_selected_metric() {
683        let apparent = Selection { min_size: Some(600), ..Selection::default() };
684        // 100 apparent bytes occupy 512 allocated bytes: neither reaches 600.
685        assert!(!admits(&apparent, "a.bin", EntryKind::File, 100, 0));
686
687        let allocated =
688            Selection { min_size: Some(600), size: SizeMetric::Allocated, ..Selection::default() };
689        // 600 apparent bytes occupy 1024 allocated bytes, which does reach 600.
690        assert!(admits(&allocated, "a.bin", EntryKind::File, 600, 0));
691        assert!(!admits(&allocated, "b.bin", EntryKind::File, 100, 0));
692    }
693
694    #[test]
695    fn the_modified_window_is_half_open() {
696        let selection = Selection {
697            modified: ModifiedWindow { since: Some(100), before: Some(200) },
698            ..Selection::default()
699        };
700        // Inclusive start: a file at exactly the watermark re-lists, because for sync a
701        // duplicate is safe and an omission is not.
702        assert!(admits(&selection, "a", EntryKind::File, 1, 100));
703        assert!(admits(&selection, "b", EntryKind::File, 1, 150));
704        // Exclusive end.
705        assert!(!admits(&selection, "c", EntryKind::File, 1, 200));
706        assert!(!admits(&selection, "d", EntryKind::File, 1, 99));
707    }
708
709    #[test]
710    fn kinds_filter_and_an_empty_list_means_every_kind() {
711        let files = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
712        assert!(admits(&files, "a.rs", EntryKind::File, 1, 0));
713        assert!(!admits(&files, "src", EntryKind::Dir, 0, 0));
714
715        let both =
716            Selection { kinds: vec![EntryKind::File, EntryKind::Dir], ..Selection::default() };
717        assert!(admits(&both, "src", EntryKind::Dir, 0, 0));
718        assert!(!admits(&both, "link", EntryKind::Symlink, 0, 0));
719    }
720
721    /// Selecting by ignored state is a filter like any other: it forces the traversal tier,
722    /// and each mode admits exactly its partition.
723    #[test]
724    fn ignored_entries_select_one_partition_or_both() {
725        let include = Selection::default();
726        let exclude = Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() };
727        let only = Selection { ignored: IgnoredEntries::Only, ..Selection::default() };
728        assert!(!exclude.is_unfiltered() && !only.is_unfiltered());
729        for (selection, admits_unignored, admits_ignored) in
730            [(&include, true, true), (&exclude, true, false), (&only, false, true)]
731        {
732            assert_eq!(
733                classified_admits(selection, "src/lib.rs", EntryKind::File, 1, 0, false),
734                admits_unignored,
735                "{:?} on an unignored entry",
736                selection.ignored
737            );
738            assert_eq!(
739                classified_admits(selection, "dist", EntryKind::Dir, 0, 0, true),
740                admits_ignored,
741                "{:?} on an ignored entry",
742                selection.ignored
743            );
744        }
745        for mode in [IgnoredEntries::Include, IgnoredEntries::Exclude, IgnoredEntries::Only] {
746            assert_eq!(IgnoredEntries::parse(mode.label()), Ok(mode));
747        }
748        assert_eq!(
749            IgnoredEntries::parse("some"),
750            Err("expected one of include, exclude, only".to_string())
751        );
752    }
753
754    #[test]
755    fn portable_catalog_predicates_compose_without_client_side_filtering() {
756        let selection = EntrySelection {
757            // Apparent, so the bound reads the sizes written below rather than their blocks.
758            query: Selection { size: SizeMetric::Apparent, ..Selection::default() },
759            max_size: Some(10),
760            exclude_ignored: true,
761            terminal_extensions: vec![".rs".to_string(), ".md".to_string()],
762            ancestor_names: vec!["src".to_string(), "docs".to_string()],
763            ..EntrySelection::default()
764        };
765        assert!(entry_admits(&selection, "src/lib.rs", EntryKind::File, 10, 0, false));
766        assert!(entry_admits(&selection, "docs/readme.md", EntryKind::File, 9, 0, false));
767        assert!(!entry_admits(&selection, "src/lib.RS", EntryKind::File, 11, 0, false));
768        assert!(!entry_admits(&selection, "tests/lib.rs", EntryKind::File, 9, 0, false));
769        assert!(!entry_admits(&selection, "src/lib.rs", EntryKind::File, 9, 0, true));
770        assert!(!entry_admits(&selection, "src/.gitignore", EntryKind::File, 1, 0, false));
771    }
772
773    #[test]
774    fn logical_extensions_and_exact_names_form_one_name_identity_filter() {
775        let selection = EntrySelection {
776            logical_extensions: vec![".v2.zip".to_string()],
777            exact_names: vec!["makefile".to_string()],
778            ..EntrySelection::default()
779        };
780        assert!(entry_admits(&selection, "release.v2.zip", EntryKind::File, 1, 0, false));
781        assert!(entry_admits(&selection, "Makefile", EntryKind::File, 1, 0, false));
782        assert!(!entry_admits(&selection, "plain.zip", EntryKind::File, 1, 0, false));
783        assert!(!entry_admits(&selection, "README", EntryKind::File, 1, 0, false));
784    }
785
786    #[test]
787    fn terminal_extensions_and_ancestor_names_refuse_what_could_never_match() {
788        let refused = |hint: &str, outcome: Result<()>| match outcome {
789            Err(Error::InvalidValue { hint: actual, .. }) => {
790                assert!(actual.contains(hint), "{actual:?} names {hint:?}");
791            }
792            other => panic!("expected a refusal naming {hint:?}, got {other:?}"),
793        };
794        for (value, hint) in [
795            ("rs", "start with a dot"),
796            (".RS", "lowercase"),
797            (".Ée", "lowercase"),
798            (".", "canonical terminal suffixes"),
799            (".tar.gz", "canonical terminal suffixes"),
800            ("..", "canonical terminal suffixes"),
801            (".a/b", "canonical terminal suffixes"),
802            (".a\\b", "canonical terminal suffixes"),
803        ] {
804            let mut selection = EntrySelection::default();
805            refused(hint, selection.admit_terminal_extension(value));
806            assert!(selection.terminal_extensions.is_empty(), "{value:?} was not added");
807            let written = EntrySelection {
808                terminal_extensions: vec![value.to_string()],
809                ..Default::default()
810            };
811            refused(hint, written.validate());
812        }
813        for value in ["", ".", "..", "a/b", "a\\b"] {
814            let mut selection = EntrySelection::default();
815            refused("exact path-component names", selection.admit_ancestor_name(value));
816            let written =
817                EntrySelection { ancestor_names: vec![value.to_string()], ..Default::default() };
818            refused("exact path-component names", written.validate());
819        }
820
821        let mut selection = EntrySelection::default();
822        selection.admit_terminal_extension(".rs").expect("a canonical suffix");
823        selection.admit_terminal_extension(".c++").expect("a non-alphanumeric suffix");
824        refused("unique", selection.admit_terminal_extension(".rs"));
825        selection.admit_ancestor_name("src").expect("a component");
826        selection.admit_ancestor_name("x%FF").expect("an escaped component");
827        selection.admit_ancestor_name("..foo").expect("dots inside a name");
828        refused("unique", selection.admit_ancestor_name("src"));
829        assert_eq!(selection.terminal_extensions, [".rs", ".c++"]);
830        assert_eq!(selection.ancestor_names, ["src", "x%FF", "..foo"]);
831        selection.validate().expect("admitted values validate");
832        refused(
833            "unique",
834            EntrySelection {
835                terminal_extensions: vec![".md".to_string(), ".md".to_string()],
836                ..Default::default()
837            }
838            .validate(),
839        );
840        refused(
841            "unique",
842            EntrySelection {
843                ancestor_names: vec!["docs".to_string(), "docs".to_string()],
844                ..Default::default()
845            }
846            .validate(),
847        );
848    }
849
850    /// A list wrong in two ways gets the refusal `CatalogQuery` and the Python
851    /// `EntrySelection` raise, so every surface names the same fault: uniqueness before
852    /// shape, and each rule across the whole list before the next rule.
853    #[test]
854    fn a_list_wrong_twice_is_refused_in_the_catalog_query_order() {
855        let hint_of = |selection: EntrySelection| match selection.validate() {
856            Err(Error::InvalidValue { hint, .. }) => hint,
857            other => panic!("expected a refusal, got {other:?}"),
858        };
859        let terminal = |values: &[&str]| EntrySelection {
860            terminal_extensions: values.iter().map(ToString::to_string).collect(),
861            ..Default::default()
862        };
863        let ancestors = |values: &[&str]| EntrySelection {
864            ancestor_names: values.iter().map(ToString::to_string).collect(),
865            ..Default::default()
866        };
867        for (selection, hint) in [
868            (terminal(&["rs", "rs"]), TERMINAL_UNIQUE),
869            (terminal(&[".RS", ".RS"]), TERMINAL_UNIQUE),
870            (terminal(&[".RS", "rs"]), "terminal_extensions entries must start with a dot"),
871            (terminal(&[".tar.gz", ".RS"]), "terminal_extensions entries must be lowercase"),
872            (ancestors(&["..", ".."]), ANCESTOR_UNIQUE),
873            (
874                EntrySelection {
875                    terminal_extensions: vec!["rs".to_string()],
876                    ancestor_names: vec!["src".to_string(), "src".to_string()],
877                    ..Default::default()
878                },
879                "terminal_extensions entries must start with a dot",
880            ),
881        ] {
882            assert_eq!(hint_of(selection), hint);
883        }
884    }
885
886    #[test]
887    fn bounds_admit_by_index_and_report_their_limit() {
888        assert!(Bound::All.admits(1_000_000));
889        assert_eq!(Bound::All.limit(), None);
890        assert!(Bound::Limit(2).admits(0));
891        assert!(Bound::Limit(2).admits(1));
892        assert!(!Bound::Limit(2).admits(2));
893        assert_eq!(Bound::Limit(2).limit(), Some(2));
894        // `--depth 0` keeps du's meaning: root totals only, nothing below.
895        assert!(!Bound::Limit(0).admits(0));
896    }
897
898    #[test]
899    fn an_unbounded_window_does_not_constrain() {
900        assert!(ModifiedWindow::default().is_unbounded());
901        assert!(ModifiedWindow::default().contains(i64::MIN));
902        assert!(ModifiedWindow::default().contains(i64::MAX));
903    }
904}