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