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