Skip to main content

fdu_core/query/
query_request.rs

1//! The request model: what determines an answer, the grammars its values are written in,
2//! and the typed refusals that name a bad request in the caller's own vocabulary.
3//!
4//! A [`Request`] is the [`Basis`] a retained index or opened root holds for its lifetime --
5//! root, scope, and content -- plus what each read supplies: the [`Query`] and `now`, the
6//! instant relative time windows resolve against. [`Delivery`] is how the caller asks for
7//! it to be carried out, and never changes what the answer says.
8//!
9//! A refusal is a value, not a sentence. Each surface renders it through its
10//! [`AxisNames`], so the rule and its wording are stated once here while the command line
11//! names flags and the library and the Python API name fields. The grammars used to live
12//! in both front ends, each with its own copy of every spelling and every message, which
13//! is how one request came to mean two things depending on the door it came through.
14
15use std::fmt;
16use std::path::{Path, PathBuf};
17use std::time::{Duration, SystemTime};
18
19use crate::CachePolicy;
20use crate::content::AnalysisSet;
21use crate::control::{ControlLimits, DEFAULT_CONTROL_BUDGET, DEFAULT_CONTROL_LINE_LIMIT};
22use crate::engine_contract::EntryKind;
23use crate::query::query_glob::Pattern;
24use crate::query::query_report::{AxisNames, Query, ViewSpec};
25use crate::query::query_selection::{Bound, IgnoredEntries, Selection, SizeMetric, SortKey};
26use crate::query::query_values::{
27    parse_control_budget, parse_control_line_limit, parse_size, parse_when, system_time_to_nanos,
28};
29use crate::scan::ScanConfig;
30
31/// Semantic filesystem scope, independent of scheduling and batching.
32#[derive(Clone, Debug)]
33#[allow(clippy::struct_excessive_bools)]
34pub struct Scope {
35    /// Maximum retained depth.
36    pub max_depth: Option<usize>,
37    /// Whether directory symlinks are followed.
38    pub follow_symlinks: bool,
39    /// Whether traversal stays on one filesystem.
40    pub one_filesystem: bool,
41    /// Hidden-component admission.
42    pub hidden: Option<std::sync::Arc<crate::admission::HiddenPolicy>>,
43    /// Whether special filesystem objects are excluded.
44    pub exclude_special: bool,
45    /// Classification rules.
46    pub types: Option<std::sync::Arc<crate::classify::TypeRegistry>>,
47    /// Whether gitignore control files are observed.
48    pub read_controls: bool,
49    /// Control admission limits.
50    pub control_limits: ControlLimits,
51}
52impl Default for Scope {
53    fn default() -> Self {
54        ScanConfig::default().into()
55    }
56}
57impl From<ScanConfig> for Scope {
58    fn from(scan: ScanConfig) -> Self {
59        Self {
60            max_depth: scan.max_depth,
61            follow_symlinks: scan.follow_symlinks,
62            one_filesystem: scan.one_filesystem,
63            hidden: scan.hidden,
64            exclude_special: scan.exclude_special,
65            types: scan.types,
66            read_controls: scan.read_controls,
67            control_limits: scan.control_limits,
68        }
69    }
70}
71impl Scope {
72    /// Derive the scanner's operational configuration from this scope and a delivery.
73    pub fn scan_config(&self, delivery: &Delivery) -> ScanConfig {
74        ScanConfig {
75            max_depth: self.max_depth,
76            follow_symlinks: self.follow_symlinks,
77            one_filesystem: self.one_filesystem,
78            hidden: self.hidden.clone(),
79            exclude_special: self.exclude_special,
80            types: self.types.clone(),
81            read_controls: self.read_controls,
82            control_limits: self.control_limits,
83            threads: delivery.workers.scan,
84            batch_size: delivery.batch_size,
85            order: delivery.order,
86            progress: None,
87        }
88    }
89    fn identity_config(&self) -> ScanConfig {
90        ScanConfig {
91            max_depth: self.max_depth,
92            follow_symlinks: self.follow_symlinks,
93            one_filesystem: self.one_filesystem,
94            hidden: self.hidden.clone(),
95            exclude_special: self.exclude_special,
96            types: self.types.clone(),
97            read_controls: self.read_controls,
98            control_limits: self.control_limits,
99            ..ScanConfig::default()
100        }
101    }
102    /// Semantic identity observed by the scanner.
103    pub fn scope(&self) -> crate::ScanScope {
104        self.identity_config().scope()
105    }
106    /// Identity of the persisted metadata tiers.
107    pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
108        self.identity_config().snapshot_identity()
109    }
110    pub(crate) fn unsupported_axis(&self) -> Option<ScopeAxis> {
111        self.identity_config().unsupported_axis()
112    }
113}
114
115/// Operational worker limits. Zero analysis workers selects available parallelism.
116#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117pub struct Workers {
118    /// Directory-reading workers; absent selects the engine's bounded automatic pool.
119    pub scan: Option<usize>,
120    /// Content-reader workers.
121    pub analysis: usize,
122}
123
124/// What a retained index or an opened root holds for its lifetime.
125///
126/// Everything here shapes the stored state itself, so a read can only be answered by a
127/// holder of the same basis: see [`Request::validate_read`].
128#[derive(Clone, Debug)]
129pub struct Basis {
130    /// The directory the answer is about.
131    pub root: PathBuf,
132    /// What the scan observes and retains.
133    ///
134    /// Scheduling is supplied separately by [`Delivery`].
135    pub scope: Scope,
136    /// The analyzers whose results the answer may report.
137    pub content: AnalysisSet,
138}
139
140impl Basis {
141    /// The basis a retained index holds, as the index itself can still state it.
142    ///
143    /// Scope comes back from [`ScanScope`](crate::ScanScope) and the control tier rather
144    /// than from the `ScanConfig` that made it: what a read validates against is what the
145    /// index observed, and the fields a config keeps beyond that -- threads, batch size,
146    /// order -- are delivery, which no answer depends on.
147    ///
148    /// The control tier carries both halves of what the scan observed, so both are read
149    /// from it: whether any rule was read, and the limits the ones that were read were
150    /// admitted under. An index that observed nothing applied no limits, and the table's
151    /// are what a scope that reads no rule would have been taken under; nothing in a basis
152    /// that observes no control state depends on them.
153    pub fn held_by(index: &crate::Index) -> Self {
154        let scope = index.scope();
155        let controls = index.control_identity();
156        Self {
157            root: index.root_path().to_path_buf(),
158            scope: Scope {
159                max_depth: scope.max_depth,
160                follow_symlinks: scope.follow_symlinks,
161                one_filesystem: scope.one_filesystem,
162                exclude_special: scope.exclude_special,
163                read_controls: controls.is_observed(),
164                control_limits: match controls {
165                    crate::ControlTierIdentity::Observed { limits } => limits,
166                    crate::ControlTierIdentity::NotObserved => Self::UNOBSERVED_LIMITS,
167                },
168                ..Scope::default()
169            },
170            content: index.content_set(),
171        }
172    }
173
174    /// The basis a spec names, without the read half it also carries.
175    ///
176    /// What a holder is fixed with for its lifetime: root, scope, and analyzers. A caller
177    /// that opens an index and then reads it many times parses these once, and each read
178    /// hands them back to [`Request::read`]; building a whole request and discarding its
179    /// query was the shape that made the basis look like the read's to overwrite.
180    ///
181    /// # Errors
182    ///
183    /// [`RequestError`] for a value no grammar accepts, named as `axes` spells its axis.
184    pub fn build(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Self, RequestError> {
185        let content = parse_content(spec, axes)?;
186        let scope = parse_scope(spec, axes)?;
187        Ok(Self { root: spec.root.to_path_buf(), scope, content })
188    }
189
190    /// The limits a basis records when its scan observed no control state at all.
191    ///
192    /// The defaults table's, because they are what the scope would have been taken under
193    /// had it read a rule; no rule this model states reads them when `read_controls` is
194    /// off, so this is a placeholder named rather than a value implied.
195    const UNOBSERVED_LIMITS: ControlLimits = Request::DEFAULTS.control_limits;
196}
197
198/// How a request is carried out, which never changes what its answer says.
199///
200/// No `Default`, deliberately. Every field here is a decision its caller has already made,
201/// and the cache policy is the one that decides whether an answer touches the filesystem
202/// and whether it leaves a trace; a default one reads `cache: Auto` whatever the caller
203/// asked for, which is exactly how the watch session came to validate against a cache
204/// policy nobody had chosen and `WatchCacheOnly` became unreachable inside the engine
205/// (fdu-i18y). A caller that wants the ordinary delivery names it.
206#[derive(Clone, Debug, PartialEq, Eq)]
207pub struct Delivery {
208    /// How the snapshot cache may be used.
209    pub cache: CachePolicy,
210    /// Where the snapshot for this root lives, or `None` for no cache at all.
211    pub cache_path: Option<PathBuf>,
212    /// Whether a partial answer is accepted as a success.
213    pub accept_partial: bool,
214    /// Whether the answer repeats as a watch, and how.
215    pub watch: Option<WatchDelivery>,
216    /// Directory and content reader workers.
217    pub workers: Workers,
218    /// Maximum operations in one scanner batch.
219    pub batch_size: usize,
220    /// Directory traversal scheduling.
221    pub order: crate::ScanOrder,
222}
223
224impl Delivery {
225    /// Ordinary execution settings with an explicitly chosen cache policy and location.
226    pub fn new(cache: CachePolicy, cache_path: Option<PathBuf>) -> Self {
227        Self {
228            cache,
229            cache_path,
230            accept_partial: false,
231            watch: None,
232            workers: Workers::default(),
233            batch_size: ScanConfig::default().batch_size,
234            order: crate::ScanOrder::default(),
235        }
236    }
237
238    /// Representative deliveries for checking policy independently of route.
239    /// Worker counts and cache location are fixed; every cache, partial-answer, and
240    /// watch choice is represented.
241    pub fn enumerate() -> impl Iterator<Item = Self> {
242        [
243            CachePolicy::Auto,
244            CachePolicy::Refresh,
245            CachePolicy::ReadOnly,
246            CachePolicy::Only,
247            CachePolicy::Off,
248        ]
249        .into_iter()
250        .flat_map(|cache| {
251            [false, true].into_iter().flat_map(move |accept_partial| {
252                [None, Some(WatchDelivery::default())].into_iter().map(move |watch| Self {
253                    cache,
254                    cache_path: Some(PathBuf::from("cache.fdu")),
255                    accept_partial,
256                    watch,
257                    workers: Workers { analysis: 1, ..Workers::default() },
258                    batch_size: ScanConfig::default().batch_size,
259                    order: crate::ScanOrder::default(),
260                })
261            })
262        })
263    }
264}
265
266/// How a watch repeats its answer.
267#[derive(Clone, Copy, Debug, PartialEq, Eq)]
268pub struct WatchDelivery {
269    /// The longest a repaint waits for changes; change detection itself is event-driven.
270    pub interval: Duration,
271}
272
273impl Default for WatchDelivery {
274    fn default() -> Self {
275        Self { interval: Self::DEFAULT_INTERVAL }
276    }
277}
278impl WatchDelivery {
279    /// Shared default repaint cadence for every surface.
280    pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(2);
281}
282
283/// Everything that determines an answer.
284#[derive(Clone, Debug)]
285pub struct Request {
286    /// Root, scope, and content: what a holder of stored state must match.
287    pub basis: Basis,
288    /// Selection, views, and view options.
289    pub query: Query,
290    /// The instant relative time windows were resolved against.
291    ///
292    /// Fixed when the request is built, so [`Selection::modified`] is absolute and a watch
293    /// that builds its request once at start never slides its window.
294    pub now: SystemTime,
295}
296
297/// A request as a caller wrote it: raw values, before any grammar has read them.
298///
299/// Surface-neutral, so the command line and the Python API fill one shape and
300/// [`Request::build`] parses both identically. A value a surface already holds typed, such
301/// as `--scan-depth`, reaches this through its `Display`, so a disagreement between that
302/// type and the grammar here shows up as a golden difference rather than a silent one.
303/// `None` and an empty list mean the caller named nothing, and [`Request::DEFAULTS`]
304/// decides; the switches whose only default is off are plain booleans.
305#[derive(Clone, Copy, Debug)]
306pub struct RequestSpec<'a> {
307    /// The directory the answer is about.
308    pub root: &'a Path,
309    /// Retention depth: a whole number.
310    pub scan_depth: Option<&'a str>,
311    /// Stay on the root's filesystem.
312    pub one_filesystem: bool,
313    /// Observe `.gitignore`.
314    pub read_controls: Option<bool>,
315    /// The `.gitignore` budget: a size or `all`.
316    pub control_budget: Option<&'a str>,
317    /// The longest `.gitignore` line: a size or `all`.
318    pub control_line_limit: Option<&'a str>,
319    /// Analyzers: a comma list of `none`, `lines`, `code`, `words`, or `all`.
320    pub analyze: Option<&'a str>,
321    /// What this read asks of the basis the axes above describe.
322    pub read: ReadSpec<'a>,
323}
324
325/// What one read supplies, as its caller wrote it: the [`Query`] half of a request.
326///
327/// Split from the basis half rather than flattened beside it, because a holder of stored
328/// state fixes root, scope, and analyzers once and then answers many reads: a read names
329/// only this, and [`Request::read`] is what takes the two together. A field declared in
330/// both halves is a field one of them could silently drop, which is why this is the one
331/// declaration and [`RequestSpec`] contains it.
332#[derive(Clone, Copy, Debug)]
333pub struct ReadSpec<'a> {
334    /// Views: a comma list, or `full`.
335    pub views: Option<&'a str>,
336    /// Presentation format; omitted means automatic human output.
337    pub format: Option<&'a str>,
338    /// Logical words per document page: a positive integer.
339    pub words_per_page: Option<&'a str>,
340    /// Patterns an entry must match one of.
341    pub include: &'a [String],
342    /// Patterns that exclude an entry.
343    pub exclude: &'a [String],
344    /// Smallest size, as `512`, `10M`, or `1.5GiB`.
345    pub min_size: Option<&'a str>,
346    /// Inclusive lower bound on modification time, as `2h` or a timestamp.
347    pub modified_since: Option<&'a str>,
348    /// Exclusive upper bound on modification time.
349    pub modified_before: Option<&'a str>,
350    /// Entry kinds: a comma list of `file`, `dir`, `symlink`, or `other`.
351    pub kinds: Option<&'a str>,
352    /// Selection by ignored state: `include`, `exclude`, or `only`.
353    pub ignored: Option<&'a str>,
354    /// Rendered tree depth: a whole number or `all`.
355    pub depth: Option<&'a str>,
356    /// Rows per view: a whole number or `all`.
357    pub limit: Option<&'a str>,
358    /// Ordering key: `size`, `count`, `mtime`, or `name`.
359    pub sort: Option<&'a str>,
360    /// Reverse the ordering.
361    pub reverse: bool,
362    /// Size metric: `allocated` or `apparent`.
363    pub size: Option<&'a str>,
364}
365
366impl ReadSpec<'_> {
367    /// A read that names nothing, so every axis takes its default.
368    pub const fn new() -> Self {
369        Self {
370            views: None,
371            format: None,
372            words_per_page: None,
373            include: &[],
374            exclude: &[],
375            min_size: None,
376            modified_since: None,
377            modified_before: None,
378            kinds: None,
379            ignored: None,
380            depth: None,
381            limit: None,
382            sort: None,
383            reverse: false,
384            size: None,
385        }
386    }
387}
388
389impl Default for ReadSpec<'_> {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395impl<'a> RequestSpec<'a> {
396    /// A spec that names only its root, so every other axis takes its default.
397    pub const fn new(root: &'a Path) -> Self {
398        Self {
399            root,
400            scan_depth: None,
401            one_filesystem: false,
402            read_controls: None,
403            control_budget: None,
404            control_line_limit: None,
405            analyze: None,
406            read: ReadSpec::new(),
407        }
408    }
409}
410
411/// Every default a request takes when its caller names nothing, stated once.
412///
413/// | Axis | Default |
414/// | --- | --- |
415/// | Size | allocated |
416/// | Views of a report | [`Self::report_view`]: [`ViewSpec::default_for`] the content |
417/// | Views of a watch | [`Self::report_view`] of no content, which is `tree` |
418/// | Words per page | 250 |
419/// | Content | no analyzer |
420/// | `.gitignore` | observed, under the default budget and line limit |
421///
422/// Each answers a question: "how much disk does this use" is allocated bytes, as `du`
423/// reports them; a request that pays to read files displays what it read; and a tree is
424/// what "what is big here" looks like. Surfaces take these rather than declaring their own,
425/// because a default declared twice drifts: size was apparent in Rust and allocated
426/// everywhere else, and `words_per_page` was written out in three places.
427///
428/// A watch has no view default of its own, and no field here for one. It is derived rather
429/// than declared because it is not a separate decision: [`RequestError::WatchContent`]
430/// refuses a watch that names an analyzer, so the content a watch serves is always none and
431/// its view is the report default for none. A field would have restated `tree` beside the
432/// rule that makes it true, which is the shape a default drifts out of.
433#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434pub struct RequestDefaults {
435    /// The size metric.
436    pub size: SizeMetric,
437    /// Logical words per derived document page.
438    pub words_per_page: u64,
439    /// The analyzers a request enables.
440    pub content: AnalysisSet,
441    /// Whether a scan observes `.gitignore`.
442    pub read_controls: bool,
443    /// The limits `.gitignore` files are applied under.
444    pub control_limits: ControlLimits,
445}
446
447impl RequestDefaults {
448    /// The view a report displays when its caller named none: the one that shows what
449    /// `content` read, or the tree when it read nothing.
450    pub const fn report_view(self, content: AnalysisSet) -> ViewSpec {
451        ViewSpec::default_for(content)
452    }
453}
454
455impl Request {
456    /// The defaults table.
457    pub const DEFAULTS: RequestDefaults = RequestDefaults {
458        size: SizeMetric::Allocated,
459        words_per_page: 250,
460        content: AnalysisSet::NONE,
461        read_controls: true,
462        control_limits: ControlLimits {
463            budget: Some(DEFAULT_CONTROL_BUDGET),
464            line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
465        },
466    };
467
468    /// One read of what `basis` holds, from a query its caller already has typed.
469    ///
470    /// The composition six call sites wrote out by hand, each of them a holder's basis and
471    /// one read's query and instant. A literal is not wrong, but a name is where the rule
472    /// can be stated: `basis` is the holder's, never the read's, and `now` is this read's,
473    /// fixed so a watch that repaints does not slide its own window.
474    ///
475    /// No validation, because the query is already typed and its holder is the one that
476    /// knows which rule applies -- [`Self::validate_read`] for a retained index,
477    /// [`Self::validate`] for a request that is its own basis. [`Self::read`] is the
478    /// constructor that parses and validates in one step.
479    pub const fn new(basis: Basis, query: Query, now: SystemTime) -> Self {
480        Self { basis, query, now }
481    }
482
483    /// One read of what `basis` holds, written in the value grammars.
484    ///
485    /// The constructor every read site wants: a holder supplies the basis it was opened
486    /// with, and the caller supplies only what this read asks. The view default comes from
487    /// the analyzers the basis already holds, so a typed set never has to be spelled back
488    /// into the grammar to find out what a request that read files displays, and no caller
489    /// builds a request with a throw-away basis and overwrites it afterwards.
490    ///
491    /// # Errors
492    ///
493    /// [`RequestError`] for a value no grammar accepts, and for a read the basis cannot
494    /// answer: [`Self::validate`]'s rules, which here are the holder's own.
495    pub fn read(
496        basis: Basis,
497        spec: &ReadSpec<'_>,
498        now: SystemTime,
499        axes: &'static AxisNames,
500    ) -> Result<Self, RequestError> {
501        let query = build_query(basis.content, spec, now, axes)?;
502        let request = Self::new(basis, query, now);
503        request.validate()?;
504        Ok(request)
505    }
506
507    /// Parse a spec into a request, resolving relative time windows against `now`.
508    ///
509    /// Refusals name each axis as `axes` spells it, and so do the report diagnostics of the
510    /// query built here. Every value is parsed before any is checked against another, in
511    /// the order the command line reads its flags: content, views, selection, the page
512    /// denominator, then scope. Rules that relate axes are [`Self::validate`]'s.
513    pub fn build(
514        spec: &RequestSpec<'_>,
515        now: SystemTime,
516        axes: &'static AxisNames,
517    ) -> Result<Self, RequestError> {
518        // The three steps in the order the command line reads its flags, which is the order
519        // a refusal names when two axes are both wrong: content, then everything this read
520        // supplies, then scope. `Basis::build` runs the first and the third together, for
521        // the callers that fix a basis and read it many times.
522        let content = parse_content(spec, axes)?;
523        let query = build_query(content, &spec.read, now, axes)?;
524        let scope = parse_scope(spec, axes)?;
525        Ok(Self::new(Basis { root: spec.root.to_path_buf(), scope, content }, query, now))
526    }
527
528    /// Refuse a request no holder of its own basis could answer.
529    ///
530    /// In order: more views than one report carries, a view its content cannot answer, and
531    /// a selection by ignored state its scope does not observe.
532    pub fn validate(&self) -> Result<(), RequestError> {
533        self.validate_against(&self.basis)
534    }
535
536    /// Refuse a read that `held`, the basis of a retained index or opened root, cannot
537    /// answer.
538    ///
539    /// Content must be equal: an index built with other analyzers holds other metrics, and
540    /// serving a narrower request from a wider store is a projection this model does not
541    /// define. The remaining rules are [`Self::validate`]'s, applied to what `held`
542    /// observed rather than to what the request says it would have. Scope equality is not
543    /// checked here; `ScanConfig` owns it.
544    pub fn validate_read(&self, held: &Basis) -> Result<(), RequestError> {
545        let entries = held.scope.snapshot_identity().entries;
546        let wanted = crate::ContentTierIdentity::for_request(entries, self.basis.content);
547        let stored = crate::ContentTierIdentity::for_request(entries, held.content);
548        if wanted.admit(&stored).is_none() {
549            return Err(RequestError::ContentMismatch {
550                held: held.content,
551                requested: self.basis.content,
552            });
553        }
554        self.validate_against(held)
555    }
556
557    /// Refuse a delivery that cannot carry this request out.
558    ///
559    /// Everything a watch cannot do, in one place, because a watch is the one delivery that
560    /// changes which requests can be answered at all:
561    ///
562    /// - A narrowed scan scope ([`RequestError::WatchScope`]): a watcher cannot filter its
563    ///   backend's events against a boundary the scan drew. Selection still works, because
564    ///   it filters the retained index rather than the scan.
565    /// - Content analysis ([`RequestError::WatchContent`]): nothing re-reads a file the
566    ///   watch sees change, so a session would go on reporting the metrics it started with
567    ///   as fresh.
568    /// - A snapshot nothing verified ([`RequestError::WatchCacheOnly`]): the window between
569    ///   the snapshot and the session's start is never observed, so the first answer would
570    ///   describe a tree that may have moved and every later one would build on it.
571    ///
572    /// Each was a guard on one surface, which is why a library caller and a Python caller
573    /// could ask for what the command line refuses.
574    pub fn validate_delivery(&self, delivery: &Delivery) -> Result<(), RequestError> {
575        if delivery.watch.is_none() {
576            return Ok(());
577        }
578        if self.basis.scope.max_depth.is_some() || self.basis.scope.one_filesystem {
579            return Err(RequestError::WatchScope);
580        }
581        if self.basis.content.is_enabled() {
582            return Err(RequestError::WatchContent);
583        }
584        if delivery.cache == CachePolicy::Only {
585            return Err(RequestError::WatchCacheOnly);
586        }
587        Ok(())
588    }
589
590    fn validate_against(&self, basis: &Basis) -> Result<(), RequestError> {
591        // Capability first, and against the scope the request names rather than against
592        // whatever a holder retained: a scope this build cannot honour has no answer at any
593        // delivery, so the refusal must not wait for a scan that a cache-only read never
594        // runs, nor for a snapshot that a cold read never loads.
595        if let Some(axis) = self.basis.scope.unsupported_axis() {
596            return Err(RequestError::ScopeUnsupported { axis, reason: axis.reason() });
597        }
598        let views = self.query.views.len().saturating_add(self.query.omitted_views.len());
599        if views > crate::MAX_REPORT_VIEWS {
600            return Err(RequestError::ViewLimit {
601                attempted: views,
602                limit: crate::MAX_REPORT_VIEWS,
603            });
604        }
605        let format = self.query.format;
606        if matches!(
607            format,
608            crate::report_format::Format::Tree
609                | crate::report_format::Format::Paths
610                | crate::report_format::Format::Long
611        ) {
612            let compatible = self.query.views.len() == 1
613                && self.query.views.iter().all(|view| {
614                    matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
615                        || (format != crate::report_format::Format::Tree
616                            && matches!(view, ViewSpec::Largest | ViewSpec::Recent))
617                });
618            if !compatible {
619                return Err(invalid(
620                    self.query.axes.format,
621                    format.label(),
622                    "requires a single list view; use text or a machine format for aggregate/mixed views (largest/recent support paths and long)",
623                ));
624            }
625        }
626        check_views(&self.query.views, basis.content)?;
627        check_observation(self.query.selection.ignored, basis.scope.read_controls)
628    }
629}
630
631/// The analyzers a spec names, or the table's default.
632fn parse_content(
633    spec: &RequestSpec<'_>,
634    axes: &'static AxisNames,
635) -> Result<AnalysisSet, RequestError> {
636    spec.analyze.map_or(Ok(Request::DEFAULTS.content), |value| {
637        AnalysisSet::parse_rejecting(value).map_err(|rejection| rejection.on(axes.analyze))
638    })
639}
640
641/// The scan scope a spec names, with the table's defaults for what it leaves out.
642///
643/// The delivery fields a `ScanConfig` still carries -- threads, batch size, order -- are
644/// its own defaults: no answer depends on them, and they move into `Delivery` with
645/// `Workers`.
646fn parse_scope(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Scope, RequestError> {
647    let limits = Request::DEFAULTS.control_limits;
648    Ok(Scope {
649        max_depth: spec
650            .scan_depth
651            .map(|value| {
652                value
653                    .trim()
654                    .parse::<usize>()
655                    .map_err(|_| invalid(axes.scan_depth, value, "expected a whole number"))
656            })
657            .transpose()?,
658        one_filesystem: spec.one_filesystem,
659        read_controls: spec.read_controls.unwrap_or(Request::DEFAULTS.read_controls),
660        control_limits: ControlLimits {
661            budget: spec.control_budget.map_or(Ok(limits.budget), |value| {
662                parse_control_budget(value)
663                    .map_err(|error| named_refusal(error, axes.control_budget))
664            })?,
665            line_limit: spec.control_line_limit.map_or(Ok(limits.line_limit), |value| {
666                parse_control_line_limit(value)
667                    .map_err(|error| named_refusal(error, axes.control_line_limit))
668            })?,
669        },
670        ..Scope::default()
671    })
672}
673
674/// The query one read supplies, parsed against the analyzers its basis holds.
675///
676/// `content` decides the view default and nothing else here: a request that paid to read
677/// files displays what it read. Every value is parsed before any is checked against
678/// another, in one order for every surface -- views, selection, then the page denominator.
679fn build_query(
680    content: AnalysisSet,
681    spec: &ReadSpec<'_>,
682    now: SystemTime,
683    axes: &'static AxisNames,
684) -> Result<Query, RequestError> {
685    let (views, omitted_views) = ViewSpec::resolve_rejecting(spec.views, content)
686        .map_err(|rejection| rejection.on(axes.view))?;
687
688    let mut selection = Selection {
689        depth: spec.depth.map(|value| parse_bound(value, axes.depth)).transpose()?,
690        limit: spec.limit.map(|value| parse_bound(value, axes.limit)).transpose()?,
691        reverse: spec.reverse,
692        size: spec
693            .size
694            .map_or(Ok(Request::DEFAULTS.size), |value| parse_size_metric(value, axes.size))?,
695        ..Selection::default()
696    };
697    for pattern in spec.include {
698        selection.include.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
699    }
700    for pattern in spec.exclude {
701        selection.exclude.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
702    }
703    if let Some(value) = spec.min_size {
704        selection.min_size = Some(parse_size(value).map_err(grammar_refusal)?);
705    }
706    if let Some(value) = spec.modified_since {
707        let when = parse_when(value, now).map_err(grammar_refusal)?;
708        selection.modified.since = Some(bound_nanos(value, when, axes.modified_since)?);
709    }
710    if let Some(value) = spec.modified_before {
711        let when = parse_when(value, now).map_err(grammar_refusal)?;
712        selection.modified.before = Some(bound_nanos(value, when, axes.modified_before)?);
713    }
714    if let Some(value) = spec.kinds {
715        selection.kinds = parse_kinds(value, axes.kind)?;
716    }
717    if let Some(value) = spec.sort {
718        selection.sort = Some(parse_sort(value, axes.sort)?);
719    }
720    if let Some(value) = spec.ignored {
721        selection.ignored = IgnoredEntries::parse(value)
722            .map_err(|expected| Rejection::new(value, expected).on(axes.ignored))?;
723    }
724    let words_per_page =
725        spec.words_per_page.map_or(Ok(Request::DEFAULTS.words_per_page), |value| {
726            value
727                .trim()
728                .parse::<u64>()
729                .ok()
730                .filter(|words| *words > 0)
731                .ok_or_else(|| invalid(axes.words_per_page, value, "expected a positive integer"))
732        })?;
733
734    let format = spec.format.map_or(Ok(crate::report_format::Format::Text), |value| {
735        crate::report_format::Format::parse(value).ok_or_else(|| {
736            invalid(
737                axes.format,
738                value,
739                format!("expected one of {}", crate::report_format::Format::ALL.join(", ")),
740            )
741        })
742    })?;
743    Ok(Query { selection, views, format, omitted_views, axes, words_per_page })
744}
745
746/// A scan-scope axis a build may be unable to honour at all.
747///
748/// Not every axis a scan config carries: only the two whose support is a property of the
749/// build rather than of the tree, so asking for one is a request no delivery can carry out
750/// and no stored state can rescue. Which of them this build refuses is stated once, by
751/// `ScanConfig::unsupported_axis`, and asked there by [`Request::validate`] for every route
752/// and by the scan config's own validation for the engine-internal callers that never build
753/// a request.
754#[derive(Clone, Copy, Debug, PartialEq, Eq)]
755pub enum ScopeAxis {
756    /// Walking into what a symbolic link points at.
757    FollowSymlinks,
758    /// Keeping the walk on the root's own filesystem.
759    OneFilesystem,
760}
761
762impl ScopeAxis {
763    /// Why the axis has no supported semantics, in the library's field names.
764    ///
765    /// One sentence per axis, and the only one: the engine-internal callers that report
766    /// [`Error::UnsupportedScanConfig`](crate::Error::UnsupportedScanConfig) print it
767    /// verbatim, and [`RequestError::message`] prints it with the axis renamed, so no
768    /// surface can drift from the rule by rewording its own copy.
769    pub const fn reason(self) -> &'static str {
770        match self {
771            Self::FollowSymlinks => {
772                "follow_symlinks requires cycle, root-boundary, and filesystem-boundary semantics"
773            }
774            Self::OneFilesystem => "one_filesystem requires platform device identity",
775        }
776    }
777
778    /// How `axes` names the axis.
779    const fn named(self, axes: &AxisNames) -> &'static str {
780        match self {
781            Self::FollowSymlinks => axes.follow_symlinks,
782            Self::OneFilesystem => axes.one_filesystem,
783        }
784    }
785}
786
787/// Why a request cannot be answered as asked.
788///
789/// Typed so a caller can match the refusal it can act on, and rendered by
790/// [`Self::message`] in the vocabulary of the surface the request came through.
791#[derive(Clone, Debug, PartialEq, Eq)]
792pub enum RequestError {
793    /// A value did not match its axis's grammar.
794    InvalidValue {
795        /// The axis, as the requesting surface names it.
796        axis: &'static str,
797        /// The rejected value, as the grammar quotes it.
798        value: String,
799        /// What the grammar accepts instead.
800        expected: String,
801    },
802    /// A view has no metadata-only projection, and the request enables no analyzer.
803    ViewNeedsContent(ViewSpec),
804    /// A selection by ignored state over a scan that observes no `.gitignore`.
805    IgnoredWithoutObservation(IgnoredEntries),
806    /// A scan scope this build cannot honour, whatever the delivery.
807    ScopeUnsupported {
808        /// Which axis, so each surface names it in its own words.
809        axis: ScopeAxis,
810        /// The rule, one sentence, in the library's field names.
811        reason: &'static str,
812    },
813    /// A read asks for another analyzer set than the retained index holds.
814    ContentMismatch {
815        /// The analyzers the index was built with.
816        held: AnalysisSet,
817        /// The analyzers the read asks for.
818        requested: AnalysisSet,
819    },
820    /// A watch was asked to narrow its scan scope.
821    WatchScope,
822    /// A watch was asked to keep content analysis current.
823    WatchContent,
824    /// A watch was asked to start from a snapshot nothing verifies.
825    WatchCacheOnly,
826    /// An operation names another root than the retained index it would mutate.
827    RootMismatch {
828        /// Root held by the index.
829        held: PathBuf,
830        /// Root requested by the caller.
831        requested: PathBuf,
832    },
833    /// A route cannot honor the requested execution policy.
834    DeliveryUnsupported {
835        /// The lifecycle that refuses it.
836        route: &'static str,
837        /// The unsupported policy combination.
838        reason: &'static str,
839    },
840    /// A read names more views than one report may carry.
841    ViewLimit {
842        /// Views and omitted views the request carries.
843        attempted: usize,
844        /// The most one report accepts.
845        limit: usize,
846    },
847}
848
849impl RequestError {
850    /// The refusal in the vocabulary of the surface `axes` describes.
851    ///
852    /// [`Self::InvalidValue`] already carries its axis, named by the surface that parsed
853    /// it, so `axes` names only the knobs the other refusals point at.
854    pub fn message(&self, axes: &AxisNames) -> String {
855        match self {
856            Self::InvalidValue { axis, value, expected } => invalid_message(axis, value, expected),
857            Self::ViewNeedsContent(view) => format!(
858                "{} {} requires content analysis: add {} lines, code, words, or all; views never \
859                 enable content analysis implicitly",
860                axes.view,
861                view.label(),
862                axes.analyze
863            ),
864            Self::IgnoredWithoutObservation(ignored) => format!(
865                "{} needs .gitignore classification, and {} turned it off; drop one of them",
866                match ignored {
867                    IgnoredEntries::Exclude => axes.exclude_ignored,
868                    IgnoredEntries::Only => axes.only_ignored,
869                    IgnoredEntries::Include => axes.ignored,
870                },
871                axes.read_controls
872            ),
873            // The sentence the engine has always printed, with only the axis renamed: a
874            // Python caller reads the words they wrote, and the command line names its
875            // flag. The kind stays in front of it, so this refusal is the same sentence
876            // whichever door raised it.
877            Self::ScopeUnsupported { axis, reason } => format!(
878                "unsupported scan configuration: {}",
879                reason.replacen(axis.named(&AxisNames::FIELDS), axis.named(axes), 1)
880            ),
881            Self::ContentMismatch { held, requested } => format!(
882                "{analyze} {requested} cannot be answered by an index built with {analyze} \
883                 {held}; open the root again with {analyze} {requested}",
884                analyze = axes.analyze,
885                requested = analysis_label(*requested),
886                held = analysis_label(*held),
887            ),
888            Self::WatchScope => watch_scope_message(axes),
889            Self::RootMismatch { held, requested } => {
890                format!(
891                    "requested root {} does not match retained root {}",
892                    requested.display(),
893                    held.display()
894                )
895            }
896            Self::DeliveryUnsupported { route, reason } => format!("{route}: {reason}"),
897            Self::WatchContent => format!(
898                "{} is not yet supported with {}; use a one-shot report",
899                axes.analyze, axes.watch
900            ),
901            Self::WatchCacheOnly => format!(
902                "{watch} cannot start from {cache} only: nothing verifies what changed between \
903                 the snapshot and the start of the watch; use {cache} auto or read-only",
904                watch = axes.watch,
905                cache = axes.cache,
906            ),
907            Self::ViewLimit { attempted, limit } => {
908                format!("report request contains {attempted} views or omissions; limit is {limit}")
909            }
910        }
911    }
912}
913
914/// The library's vocabulary, as [`AxisNames::default`] is: a refusal rendered without
915/// naming a surface belongs to a library caller, not to the command line.
916impl fmt::Display for RequestError {
917    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
918        formatter.write_str(&self.message(&AxisNames::FIELDS))
919    }
920}
921
922impl std::error::Error for RequestError {}
923
924/// An analyzer set as its axis spells it back: `none`, or the analyzers joined by commas.
925fn analysis_label(set: AnalysisSet) -> String {
926    if set.is_enabled() { set.labels().join(",") } else { AnalysisSet::NONE_LABEL.to_string() }
927}
928
929/// The watch-scope rule, with each knob named as `axes` names it.
930///
931/// One pass over whole words of [`crate::scan::WATCH_SCOPE_GUIDANCE`], which is written
932/// in the library's field names, never re-scanning a replacement. A sequential replace
933/// does re-scan: `max_depth` becomes `--scan-depth`, and then `depth` matches inside it,
934/// giving `--scan---depth` (fdu-7j6z). The command line carried this substitution
935/// itself until the rule moved here.
936fn watch_scope_message(axes: &AxisNames) -> String {
937    let fields = &AxisNames::FIELDS;
938    let vocabulary = [
939        (fields.scan_depth, axes.scan_depth),
940        (fields.one_filesystem, axes.one_filesystem),
941        (fields.modified_since, axes.modified_since),
942        (fields.depth, axes.depth),
943        (fields.include, axes.include),
944    ];
945    let is_word = |character: char| character.is_ascii_alphanumeric() || character == '_';
946    crate::scan::WATCH_SCOPE_GUIDANCE
947        .split_inclusive(|character: char| !is_word(character))
948        .map(|piece| {
949            let end = piece.find(|character: char| !is_word(character)).unwrap_or(piece.len());
950            let (word, tail) = piece.split_at(end);
951            match vocabulary.iter().find(|(field, _)| *field == word) {
952                Some((_, name)) => format!("{name}{tail}"),
953                None => piece.to_string(),
954            }
955        })
956        .collect()
957}
958
959/// Refuse a view that no enabled analyzer can answer.
960///
961/// A match over every view rather than a list of the exceptions, so a new view forces a
962/// decision here about whether it needs content.
963pub(crate) fn check_views(views: &[ViewSpec], content: AnalysisSet) -> Result<(), RequestError> {
964    for view in views {
965        match view {
966            ViewSpec::Documents if !content.is_enabled() => {
967                return Err(RequestError::ViewNeedsContent(*view));
968            }
969            ViewSpec::List
970            | ViewSpec::Tree
971            | ViewSpec::Types
972            | ViewSpec::Extensions
973            | ViewSpec::Families
974            | ViewSpec::Languages
975            | ViewSpec::Documents
976            | ViewSpec::Files
977            | ViewSpec::Largest
978            | ViewSpec::Recent
979            | ViewSpec::Summary => {}
980        }
981    }
982    Ok(())
983}
984
985/// Refuse a selection by ignored state when the scan observes no control state.
986pub(crate) fn check_observation(
987    ignored: IgnoredEntries,
988    observes_controls: bool,
989) -> Result<(), RequestError> {
990    match ignored {
991        IgnoredEntries::Include => Ok(()),
992        IgnoredEntries::Exclude | IgnoredEntries::Only if observes_controls => Ok(()),
993        IgnoredEntries::Exclude | IgnoredEntries::Only => {
994            Err(RequestError::IgnoredWithoutObservation(ignored))
995        }
996    }
997}
998
999/// A value a grammar refused, before any surface has named the axis.
1000///
1001/// The list grammars that predate this model ([`ViewSpec::resolve`] and
1002/// [`AnalysisSet::parse_labeled`]) take a free-form label and return a sentence; they
1003/// produce this instead, so the request model can put a typed axis on it and they can go on
1004/// rendering the identical sentence.
1005#[derive(Clone, Debug, PartialEq, Eq)]
1006pub(crate) struct Rejection {
1007    value: String,
1008    expected: String,
1009}
1010
1011impl Rejection {
1012    pub(crate) fn new(value: impl Into<String>, expected: impl Into<String>) -> Self {
1013        Self { value: value.into(), expected: expected.into() }
1014    }
1015
1016    /// The refusal, on the axis a surface named.
1017    pub(crate) fn on(self, axis: &'static str) -> RequestError {
1018        RequestError::InvalidValue { axis, value: self.value, expected: self.expected }
1019    }
1020
1021    /// The refusal's sentence, for a caller holding only a label.
1022    pub(crate) fn labeled(&self, label: &str) -> String {
1023        invalid_message(label, &self.value, &self.expected)
1024    }
1025}
1026
1027fn invalid_message(axis: &str, value: &str, expected: &str) -> String {
1028    format!("invalid {axis} {value:?}: {expected}")
1029}
1030
1031fn invalid(
1032    axis: &'static str,
1033    value: impl Into<String>,
1034    expected: impl Into<String>,
1035) -> RequestError {
1036    Rejection::new(value, expected).on(axis)
1037}
1038
1039/// An engine value-grammar error, which already names its grammar: `invalid size "10X"`
1040/// and `invalid pattern` read the same on every surface.
1041fn grammar_refusal(error: crate::Error) -> RequestError {
1042    match error {
1043        crate::Error::InvalidValue { kind, value, hint } => invalid(kind, value, hint),
1044        other => invalid("value", String::new(), other.to_string()),
1045    }
1046}
1047
1048/// An engine value-grammar error on a knob each surface names, as the `.gitignore` limits
1049/// are: `--gitignore-budget` and `control_budget`, never `control budget`.
1050fn named_refusal(error: crate::Error, axis: &'static str) -> RequestError {
1051    match error {
1052        crate::Error::InvalidValue { value, hint, .. } => invalid(axis, value, hint),
1053        other => invalid(axis, String::new(), other.to_string()),
1054    }
1055}
1056
1057/// Parse one entry kind: `file`, `dir`, `symlink`, or `other`.
1058///
1059/// `axis` names the knob as the calling surface spells it: `--kind` or `kind`.
1060pub fn parse_kind(value: &str, axis: &'static str) -> Result<EntryKind, RequestError> {
1061    match value.trim().to_ascii_lowercase().as_str() {
1062        "file" => Ok(EntryKind::File),
1063        "dir" => Ok(EntryKind::Dir),
1064        "symlink" => Ok(EntryKind::Symlink),
1065        "other" => Ok(EntryKind::Other),
1066        other => Err(invalid(axis, other, "expected one of file, dir, symlink, other")),
1067    }
1068}
1069
1070/// Parse a comma-separated list of entry kinds.
1071///
1072/// Closed vocabularies are comma lists and open pattern values are repeatable, because
1073/// glob brace syntax (`*.{rs,toml}`) contains commas and would be shredded by a split.
1074/// An empty entry and a repeated kind are errors rather than silent no-ops, as they are
1075/// for views: repeating a value is far more likely to be a typo than an intention.
1076pub fn parse_kinds(list: &str, axis: &'static str) -> Result<Vec<EntryKind>, RequestError> {
1077    let mut kinds = Vec::new();
1078    for token in list.split(',') {
1079        let token = token.trim();
1080        if token.is_empty() {
1081            return Err(invalid(axis, list, "empty entry in the list"));
1082        }
1083        let kind = parse_kind(token, axis)?;
1084        if kinds.contains(&kind) {
1085            return Err(invalid(axis, list, format!("{token:?} appears more than once")));
1086        }
1087        kinds.push(kind);
1088    }
1089    Ok(kinds)
1090}
1091
1092/// Parse a bound that accepts `all` for unbounded, as `--depth` and `--limit` are written.
1093pub fn parse_bound(value: &str, axis: &'static str) -> Result<Bound, RequestError> {
1094    let value = value.trim();
1095    if value.eq_ignore_ascii_case("all") {
1096        return Ok(Bound::All);
1097    }
1098    value
1099        .parse::<usize>()
1100        .map(Bound::Limit)
1101        .map_err(|_| invalid(axis, value, "expected a whole number or `all`"))
1102}
1103
1104/// Parse an ordering key: `size`, `count`, `mtime`, or `name`.
1105pub fn parse_sort(value: &str, axis: &'static str) -> Result<SortKey, RequestError> {
1106    match value.trim().to_ascii_lowercase().as_str() {
1107        "size" => Ok(SortKey::Size),
1108        "count" => Ok(SortKey::Count),
1109        "mtime" => Ok(SortKey::Mtime),
1110        "name" => Ok(SortKey::Name),
1111        other => Err(invalid(axis, other, "expected one of size, count, mtime, name")),
1112    }
1113}
1114
1115/// Parse a size metric: `allocated` or `apparent`.
1116pub fn parse_size_metric(value: &str, axis: &'static str) -> Result<SizeMetric, RequestError> {
1117    match value.trim().to_ascii_lowercase().as_str() {
1118        "allocated" => Ok(SizeMetric::Allocated),
1119        "apparent" => Ok(SizeMetric::Apparent),
1120        other => Err(invalid(axis, other, "expected allocated or apparent")),
1121    }
1122}
1123
1124/// Convert a parsed time bound to index nanoseconds, or refuse the value.
1125///
1126/// [`system_time_to_nanos`] returns `None` for an instant outside the range the index can
1127/// represent (roughly 1677-2262). Storing that `None` would leave the bound unset, so the
1128/// query would run with no time filter at all while the caller believed one was active --
1129/// a silently wrong answer, which is worse than a refused value.
1130pub fn bound_nanos(value: &str, when: SystemTime, axis: &'static str) -> Result<i64, RequestError> {
1131    system_time_to_nanos(when).ok_or_else(|| {
1132        invalid(
1133            axis,
1134            value,
1135            "that time is outside the range fdu can represent (about 1677 to 2262)",
1136        )
1137    })
1138}
1139
1140/// Parse a cache policy: `auto`, `refresh`, `read-only`, `only`, or `off`.
1141pub fn parse_cache_policy(value: &str, axis: &'static str) -> Result<CachePolicy, RequestError> {
1142    match value.trim().to_ascii_lowercase().as_str() {
1143        "auto" => Ok(CachePolicy::Auto),
1144        "refresh" => Ok(CachePolicy::Refresh),
1145        "read-only" => Ok(CachePolicy::ReadOnly),
1146        "only" => Ok(CachePolicy::Only),
1147        "off" => Ok(CachePolicy::Off),
1148        other => Err(invalid(axis, other, "expected one of auto, refresh, read-only, only, off")),
1149    }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155
1156    #[test]
1157    fn each_grammar_names_its_axis_as_the_surface_spells_it() {
1158        let flags = &AxisNames::FLAGS;
1159        let fields = &AxisNames::FIELDS;
1160        let cases: [(&str, RequestError, RequestError); 6] = [
1161            (
1162                "kind",
1163                parse_kind(" Socket ", flags.kind).expect_err("unknown kind"),
1164                parse_kind(" Socket ", fields.kind).expect_err("unknown kind"),
1165            ),
1166            (
1167                "bound",
1168                parse_bound("two", flags.depth).expect_err("not a number"),
1169                parse_bound("two", fields.depth).expect_err("not a number"),
1170            ),
1171            (
1172                "sort",
1173                parse_sort("Newest", flags.sort).expect_err("unknown key"),
1174                parse_sort("Newest", fields.sort).expect_err("unknown key"),
1175            ),
1176            (
1177                "size",
1178                parse_size_metric("logical", flags.size).expect_err("unknown metric"),
1179                parse_size_metric("logical", fields.size).expect_err("unknown metric"),
1180            ),
1181            (
1182                "time",
1183                bound_nanos("2300-01-01T00:00:00Z", far_future(), flags.modified_since)
1184                    .expect_err("unrepresentable"),
1185                bound_nanos("2300-01-01T00:00:00Z", far_future(), fields.modified_since)
1186                    .expect_err("unrepresentable"),
1187            ),
1188            (
1189                "cache",
1190                parse_cache_policy("readonly", flags.cache).expect_err("unreleased alias"),
1191                parse_cache_policy("readonly", fields.cache).expect_err("unreleased alias"),
1192            ),
1193        ];
1194        let expected = [
1195            (
1196                "invalid --kind \"socket\": expected one of file, dir, symlink, other",
1197                "invalid kind \"socket\": expected one of file, dir, symlink, other",
1198            ),
1199            (
1200                "invalid --depth \"two\": expected a whole number or `all`",
1201                "invalid depth \"two\": expected a whole number or `all`",
1202            ),
1203            (
1204                "invalid --sort \"newest\": expected one of size, count, mtime, name",
1205                "invalid sort \"newest\": expected one of size, count, mtime, name",
1206            ),
1207            (
1208                "invalid --size \"logical\": expected allocated or apparent",
1209                "invalid size \"logical\": expected allocated or apparent",
1210            ),
1211            (
1212                "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
1213                 range fdu can represent (about 1677 to 2262)",
1214                "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
1215                 fdu can represent (about 1677 to 2262)",
1216            ),
1217            (
1218                "invalid --cache \"readonly\": expected one of auto, refresh, read-only, only, off",
1219                "invalid cache policy \"readonly\": expected one of auto, refresh, read-only, \
1220                 only, off",
1221            ),
1222        ];
1223        for ((grammar, flag, field), (flag_text, field_text)) in cases.into_iter().zip(expected) {
1224            assert_eq!(flag.message(&AxisNames::FLAGS), flag_text, "{grammar}");
1225            assert_eq!(field.message(&AxisNames::FIELDS), field_text, "{grammar}");
1226            // The axis travels with the value, so rendering never re-names it.
1227            assert_eq!(flag.message(&AxisNames::FIELDS), flag_text, "{grammar}");
1228        }
1229    }
1230
1231    fn far_future() -> SystemTime {
1232        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10_000_000_000)
1233    }
1234
1235    #[test]
1236    fn the_grammars_accept_their_whole_vocabulary() {
1237        let axis = AxisNames::FIELDS.kind;
1238        for (spelling, kind) in [
1239            ("file", EntryKind::File),
1240            ("DIR", EntryKind::Dir),
1241            (" symlink", EntryKind::Symlink),
1242            ("other", EntryKind::Other),
1243        ] {
1244            assert_eq!(parse_kind(spelling, axis), Ok(kind));
1245        }
1246        assert_eq!(parse_kinds("file, dir", axis), Ok(vec![EntryKind::File, EntryKind::Dir]));
1247        assert_eq!(parse_bound(" ALL ", axis), Ok(Bound::All));
1248        assert_eq!(parse_bound("0", axis), Ok(Bound::Limit(0)));
1249        for (spelling, key) in [
1250            ("size", SortKey::Size),
1251            ("count", SortKey::Count),
1252            ("MTIME", SortKey::Mtime),
1253            ("name", SortKey::Name),
1254        ] {
1255            assert_eq!(parse_sort(spelling, axis), Ok(key));
1256        }
1257        assert_eq!(parse_size_metric("Allocated", axis), Ok(SizeMetric::Allocated));
1258        assert_eq!(parse_size_metric("apparent", axis), Ok(SizeMetric::Apparent));
1259        for (spelling, policy) in [
1260            ("auto", CachePolicy::Auto),
1261            ("refresh", CachePolicy::Refresh),
1262            ("read-only", CachePolicy::ReadOnly),
1263            ("ONLY", CachePolicy::Only),
1264            ("off", CachePolicy::Off),
1265        ] {
1266            assert_eq!(parse_cache_policy(spelling, axis), Ok(policy));
1267        }
1268        let epoch = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(2);
1269        assert_eq!(bound_nanos("@2", epoch, axis), Ok(2_000_000_000));
1270    }
1271
1272    #[test]
1273    fn a_kind_list_refuses_empty_and_repeated_entries() {
1274        let axis = AxisNames::FLAGS.kind;
1275        assert_eq!(
1276            parse_kinds("file,,dir", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1277            Err("invalid --kind \"file,,dir\": empty entry in the list".to_string())
1278        );
1279        assert_eq!(
1280            parse_kinds("file, FILE", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1281            Err("invalid --kind \"file, FILE\": \"FILE\" appears more than once".to_string())
1282        );
1283    }
1284
1285    /// Every refusal, in both vocabularies, quoted whole: the wording is the contract the
1286    /// goldens and the parity harness hold each surface to.
1287    #[test]
1288    fn every_refusal_renders_in_flag_and_field_wording() {
1289        let cases = [
1290            (
1291                RequestError::ViewNeedsContent(ViewSpec::Documents),
1292                "--view documents requires content analysis: add --analyze lines, code, words, \
1293                 or all; views never enable content analysis implicitly",
1294                "view documents requires content analysis: add analyze lines, code, words, or \
1295                 all; views never enable content analysis implicitly",
1296            ),
1297            (
1298                RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude),
1299                "--exclude-ignored needs .gitignore classification, and --no-gitignore turned it \
1300                 off; drop one of them",
1301                "ignored=exclude needs .gitignore classification, and read_controls turned it \
1302                 off; drop one of them",
1303            ),
1304            (
1305                RequestError::IgnoredWithoutObservation(IgnoredEntries::Only),
1306                "--only-ignored needs .gitignore classification, and --no-gitignore turned it \
1307                 off; drop one of them",
1308                "ignored=only needs .gitignore classification, and read_controls turned it off; \
1309                 drop one of them",
1310            ),
1311            (
1312                RequestError::ContentMismatch {
1313                    held: AnalysisSet::NONE,
1314                    requested: AnalysisSet::NONE.with_code(),
1315                },
1316                "--analyze lines,code cannot be answered by an index built with --analyze none; \
1317                 open the root again with --analyze lines,code",
1318                "analyze lines,code cannot be answered by an index built with analyze none; open \
1319                 the root again with analyze lines,code",
1320            ),
1321            (
1322                RequestError::ScopeUnsupported {
1323                    axis: ScopeAxis::OneFilesystem,
1324                    reason: ScopeAxis::OneFilesystem.reason(),
1325                },
1326                "unsupported scan configuration: --one-filesystem requires platform device \
1327                 identity",
1328                "unsupported scan configuration: one_filesystem requires platform device identity",
1329            ),
1330            (
1331                RequestError::ScopeUnsupported {
1332                    axis: ScopeAxis::FollowSymlinks,
1333                    reason: ScopeAxis::FollowSymlinks.reason(),
1334                },
1335                "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1336                 and filesystem-boundary semantics",
1337                "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1338                 and filesystem-boundary semantics",
1339            ),
1340            (
1341                RequestError::WatchContent,
1342                "--analyze is not yet supported with --watch; use a one-shot report",
1343                "analyze is not yet supported with watch; use a one-shot report",
1344            ),
1345            (
1346                RequestError::WatchCacheOnly,
1347                "--watch cannot start from --cache only: nothing verifies what changed between the \
1348                 snapshot and the start of the watch; use --cache auto or read-only",
1349                "watch cannot start from cache policy only: nothing verifies what changed between \
1350                 the snapshot and the start of the watch; use cache policy auto or read-only",
1351            ),
1352            (
1353                RequestError::ViewLimit { attempted: 17, limit: 16 },
1354                "report request contains 17 views or omissions; limit is 16",
1355                "report request contains 17 views or omissions; limit is 16",
1356            ),
1357        ];
1358        for (refusal, flags, fields) in cases {
1359            assert_eq!(refusal.message(&AxisNames::FLAGS), flags);
1360            assert_eq!(refusal.message(&AxisNames::FIELDS), fields);
1361            assert_eq!(refusal.to_string(), fields, "a refusal displays in the library's words");
1362        }
1363    }
1364
1365    /// The watch-scope rule is the library's constant in the library's words, and the
1366    /// command line's words differ by knob names alone.
1367    ///
1368    /// Asserted against the constant rather than by quoting prose, so a rewording of the
1369    /// rule cannot leave this test measuring its own copy of it.
1370    #[test]
1371    fn the_watch_scope_refusal_substitutes_whole_words_only() {
1372        let source = crate::scan::WATCH_SCOPE_GUIDANCE;
1373        assert_eq!(RequestError::WatchScope.message(&AxisNames::FIELDS), source);
1374
1375        let text = RequestError::WatchScope.message(&AxisNames::FLAGS);
1376        assert!(!text.contains("---"), "{text} re-substituted a replacement");
1377
1378        // Hyphens stay inside a token, so `--scan-depth` is one word and not three.
1379        let names_word = |haystack: &str, word: &str| {
1380            haystack
1381                .split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
1382                .any(|w| w == word)
1383        };
1384        let (fields, flags) = (&AxisNames::FIELDS, &AxisNames::FLAGS);
1385        let vocabulary = [
1386            (fields.scan_depth, flags.scan_depth),
1387            (fields.one_filesystem, flags.one_filesystem),
1388            (fields.modified_since, flags.modified_since),
1389            (fields.depth, flags.depth),
1390            (fields.include, flags.include),
1391        ];
1392        for (field, flag) in vocabulary {
1393            if names_word(source, field) {
1394                assert!(text.contains(flag), "{text} must name {flag} where the rule says {field}");
1395            }
1396            assert!(!names_word(&text, field), "{text} still names {field} untranslated");
1397        }
1398        let mut rebuilt = text.clone();
1399        for (field, flag) in vocabulary {
1400            rebuilt = rebuilt.replace(flag, field);
1401        }
1402        assert_eq!(rebuilt, source, "the flag wording must be the library's, knob names aside");
1403    }
1404
1405    #[test]
1406    fn documents_is_the_only_view_that_needs_content() {
1407        for content in [AnalysisSet::NONE, AnalysisSet::NONE.with_lines(), AnalysisSet::ALL] {
1408            for view in ViewSpec::ALL {
1409                let refused = check_views(&[view], content).is_err();
1410                assert_eq!(
1411                    refused,
1412                    view == ViewSpec::Documents && !content.is_enabled(),
1413                    "{view:?}"
1414                );
1415            }
1416        }
1417        assert_eq!(check_observation(IgnoredEntries::Include, false), Ok(()));
1418        for ignored in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
1419            assert_eq!(check_observation(ignored, true), Ok(()));
1420            assert_eq!(
1421                check_observation(ignored, false),
1422                Err(RequestError::IgnoredWithoutObservation(ignored))
1423            );
1424        }
1425    }
1426
1427    // ---- the request model ----
1428
1429    use crate::content::AnalysisRequest;
1430
1431    fn root() -> &'static Path {
1432        Path::new("/tree")
1433    }
1434
1435    fn instant() -> SystemTime {
1436        SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
1437    }
1438
1439    /// A spec over the test root whose basis is every default and whose read is `read`.
1440    fn reading(read: ReadSpec<'_>) -> RequestSpec<'_> {
1441        RequestSpec { read, ..RequestSpec::new(root()) }
1442    }
1443
1444    fn built(spec: &RequestSpec<'_>) -> Request {
1445        Request::build(spec, instant(), &AxisNames::FIELDS).expect("the spec parses")
1446    }
1447
1448    fn refusal(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> String {
1449        Request::build(spec, instant(), axes).expect_err("the spec is refused").message(axes)
1450    }
1451
1452    /// A spec that names nothing builds the table, and every type that also declares a
1453    /// default for one of these axes agrees with it.
1454    #[test]
1455    fn list_formats_are_resolved_and_validated_in_the_shared_request() {
1456        use crate::report_format::Format;
1457        for (view, format, valid, tree) in [
1458            (None, None, true, true),
1459            (Some("list"), Some("tree"), true, true),
1460            (Some("list"), Some("paths"), true, false),
1461            (None, Some("json"), true, false),
1462            (Some("files"), None, true, false),
1463            (Some("files"), Some("tree"), true, true),
1464            (Some("tree"), Some("long"), true, false),
1465            (Some("largest"), Some("long"), true, false),
1466            (Some("summary"), Some("long"), false, false),
1467            (Some("full"), Some("paths"), false, false),
1468            (Some("list,summary"), Some("tree"), false, false),
1469            (Some("list,summary"), Some("json"), true, false),
1470        ] {
1471            let spec = RequestSpec {
1472                read: ReadSpec { views: view, format, ..ReadSpec::new() },
1473                ..RequestSpec::new(Path::new("/absent"))
1474            };
1475            let request =
1476                Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("grammar");
1477            assert_eq!(request.validate().is_ok(), valid, "{view:?} {format:?}");
1478            if valid {
1479                assert_eq!(request.query.tree_for(request.query.views[0]), tree);
1480            }
1481        }
1482        let spec = RequestSpec::new(Path::new("/absent"));
1483        let request =
1484            Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("default");
1485        assert_eq!(request.query.views, [ViewSpec::List]);
1486        assert!(
1487            !request.query.needs_selection_walk(),
1488            "an ordinary tree must retain its bounded projection cost"
1489        );
1490        let mut flat = request.clone();
1491        flat.query.format = Format::Paths;
1492        assert!(flat.query.needs_selection_walk());
1493        assert_eq!(flat.query.limit_for(ViewSpec::List), Bound::All);
1494        assert_eq!(request.query.limit_for(ViewSpec::List), Bound::Limit(10));
1495    }
1496
1497    #[test]
1498    fn an_empty_spec_builds_the_defaults_table() {
1499        let defaults = Request::DEFAULTS;
1500        assert_eq!(defaults.size, SizeMetric::Allocated);
1501        assert_eq!(defaults.words_per_page, 250);
1502        assert_eq!(defaults.content, AnalysisSet::NONE);
1503        assert!(defaults.read_controls);
1504        assert_eq!(defaults.control_limits, ControlLimits::default());
1505        // A watch serves no content -- `WatchContent` refuses one that names an analyzer
1506        // -- so its view is the report default for none, derived rather than declared.
1507        assert_eq!(defaults.report_view(AnalysisSet::NONE), ViewSpec::List);
1508        for content in [
1509            AnalysisSet::NONE,
1510            AnalysisSet::NONE.with_lines(),
1511            AnalysisSet::NONE.with_code(),
1512            AnalysisSet::NONE.with_words(),
1513            AnalysisSet::ALL,
1514        ] {
1515            assert_eq!(defaults.report_view(content), ViewSpec::default_for(content));
1516        }
1517
1518        let request = built(&RequestSpec::new(root()));
1519        assert_eq!(request.basis.root, root());
1520        assert_eq!(request.basis.content, defaults.content);
1521        assert_eq!(request.basis.scope.read_controls, defaults.read_controls);
1522        assert_eq!(request.basis.scope.control_limits, defaults.control_limits);
1523        assert_eq!(request.basis.scope.max_depth, None);
1524        assert!(!request.basis.scope.one_filesystem);
1525        assert_eq!(request.query.views, vec![defaults.report_view(defaults.content)]);
1526        assert!(request.query.omitted_views.is_empty());
1527        assert_eq!(request.query.words_per_page, defaults.words_per_page);
1528        assert_eq!(request.query.selection.size, defaults.size);
1529        assert!(request.query.selection.is_unfiltered());
1530        let selection = &request.query.selection;
1531        assert_eq!((selection.depth, selection.limit, selection.sort), (None, None, None));
1532        assert!(!selection.reverse);
1533        assert_eq!(*request.query.axes, AxisNames::FIELDS);
1534        assert_eq!(request.now, instant());
1535        request.validate().expect("the defaults are a valid request");
1536
1537        // The other homes of these defaults read the table rather than restating it.
1538        assert_eq!(SizeMetric::default(), defaults.size);
1539        assert_eq!(Selection::default().size, defaults.size);
1540        assert_eq!(Query::default().words_per_page, defaults.words_per_page);
1541        assert_eq!(ScanConfig::default().read_controls, defaults.read_controls);
1542        assert_eq!(ScanConfig::default().control_limits, defaults.control_limits);
1543        assert_eq!(AnalysisRequest::default().profile, defaults.content);
1544        assert_eq!(AnalysisSet::default(), defaults.content);
1545    }
1546
1547    #[test]
1548    fn every_axis_of_a_spec_reaches_its_typed_value() {
1549        let include = ["*.rs".to_string()];
1550        let exclude = ["target/**".to_string()];
1551        let spec = RequestSpec {
1552            scan_depth: Some("3"),
1553            one_filesystem: true,
1554            read_controls: Some(false),
1555            control_budget: Some("all"),
1556            control_line_limit: Some("64KiB"),
1557            analyze: Some("code"),
1558            read: ReadSpec {
1559                views: Some("languages,tree"),
1560                format: None,
1561                words_per_page: Some("300"),
1562                include: &include,
1563                exclude: &exclude,
1564                min_size: Some("1KiB"),
1565                modified_since: Some("@1700000000"),
1566                modified_before: Some("@1800000000"),
1567                kinds: Some("file,dir"),
1568                ignored: Some("include"),
1569                depth: Some("all"),
1570                limit: Some("5"),
1571                sort: Some("name"),
1572                reverse: true,
1573                size: Some("apparent"),
1574            },
1575            ..RequestSpec::new(root())
1576        };
1577        let request = Request::build(&spec, instant(), &AxisNames::FLAGS).expect("parses");
1578        let scope = &request.basis.scope;
1579        assert_eq!(scope.max_depth, Some(3));
1580        assert!(scope.one_filesystem);
1581        assert!(!scope.read_controls);
1582        assert_eq!(scope.control_limits, ControlLimits { budget: None, line_limit: Some(65_536) });
1583        assert_eq!(request.basis.content, AnalysisSet::NONE.with_code());
1584        assert_eq!(request.query.views, vec![ViewSpec::Languages, ViewSpec::Tree]);
1585        assert_eq!(request.query.words_per_page, 300);
1586        assert_eq!(*request.query.axes, AxisNames::FLAGS);
1587        let selection = &request.query.selection;
1588        assert_eq!(selection.include.len(), 1);
1589        assert_eq!(selection.exclude.len(), 1);
1590        assert_eq!(selection.min_size, Some(1024));
1591        assert_eq!(selection.modified.since, Some(1_700_000_000_000_000_000));
1592        assert_eq!(selection.modified.before, Some(1_800_000_000_000_000_000));
1593        assert_eq!(selection.kinds, vec![EntryKind::File, EntryKind::Dir]);
1594        assert_eq!(selection.ignored, IgnoredEntries::Include);
1595        assert_eq!(selection.depth, Some(Bound::All));
1596        assert_eq!(selection.limit, Some(Bound::Limit(5)));
1597        assert_eq!(selection.sort, Some(SortKey::Name));
1598        assert!(selection.reverse);
1599        assert_eq!(selection.size, SizeMetric::Apparent);
1600    }
1601
1602    /// Relative windows are resolved once, against the request's own instant, so the
1603    /// selection a request carries is absolute and building it again at the same instant
1604    /// selects exactly the same entries however much later that happens.
1605    #[test]
1606    fn relative_windows_resolve_against_the_requests_instant() {
1607        let spec = reading(ReadSpec {
1608            modified_since: Some("2h"),
1609            modified_before: Some("now"),
1610            ..ReadSpec::new()
1611        });
1612        let request = built(&spec);
1613        let now = system_time_to_nanos(instant()).expect("representable");
1614        let two_hours = 2 * 60 * 60 * 1_000_000_000;
1615        assert_eq!(request.now, instant());
1616        assert_eq!(request.query.selection.modified.since, Some(now - two_hours));
1617        assert_eq!(request.query.selection.modified.before, Some(now));
1618
1619        let later = instant() + Duration::from_secs(60);
1620        let moved = Request::build(&spec, later, &AxisNames::FIELDS).expect("parses");
1621        assert_eq!(moved.query.selection.modified.since, Some(now - two_hours + 60_000_000_000));
1622        let again = built(&spec);
1623        assert_eq!(again.query.selection.modified.since, request.query.selection.modified.since);
1624    }
1625
1626    /// Each refusal `build` raises reads exactly as the path it replaces does today, in
1627    /// both vocabularies.
1628    #[test]
1629    fn build_refuses_each_axis_in_the_surfaces_words() {
1630        let spec = RequestSpec::new(root());
1631        let analyze = RequestSpec { analyze: Some("deep"), ..spec };
1632        for (axes, label) in [(&AxisNames::FLAGS, "--analyze"), (&AxisNames::FIELDS, "analyze")] {
1633            let today = AnalysisSet::parse_labeled("deep", label).expect_err("refused");
1634            assert_eq!(refusal(&analyze, axes), today);
1635        }
1636        for views in ["tree,tree", "tree,,types", "full,tree", "bogus"] {
1637            let spec = reading(ReadSpec { views: Some(views), ..ReadSpec::new() });
1638            for (axes, label) in [(&AxisNames::FLAGS, "--view"), (&AxisNames::FIELDS, "view")] {
1639                let today =
1640                    ViewSpec::resolve(Some(views), AnalysisSet::NONE, label).expect_err("refused");
1641                assert_eq!(refusal(&spec, axes), today);
1642            }
1643        }
1644
1645        let cases: [(RequestSpec<'_>, &str, &str); 7] = [
1646            (
1647                reading(ReadSpec { words_per_page: Some("0"), ..ReadSpec::new() }),
1648                "invalid --words-per-page \"0\": expected a positive integer",
1649                "invalid words_per_page \"0\": expected a positive integer",
1650            ),
1651            (
1652                RequestSpec { scan_depth: Some("deep"), ..spec },
1653                "invalid --scan-depth \"deep\": expected a whole number",
1654                "invalid max_depth \"deep\": expected a whole number",
1655            ),
1656            (
1657                RequestSpec { control_budget: Some("lots"), ..spec },
1658                "invalid --gitignore-budget \"lots\": expected a number before the unit, as in \
1659                 `10M`, or `all` for no bound",
1660                "invalid control_budget \"lots\": expected a number before the unit, as in \
1661                 `10M`, or `all` for no bound",
1662            ),
1663            (
1664                reading(ReadSpec { ignored: Some("maybe"), ..ReadSpec::new() }),
1665                "invalid --exclude-ignored/--only-ignored \"maybe\": expected one of include, \
1666                 exclude, only",
1667                "invalid ignored \"maybe\": expected one of include, exclude, only",
1668            ),
1669            (
1670                reading(ReadSpec { kinds: Some("file,socket"), ..ReadSpec::new() }),
1671                "invalid --kind \"socket\": expected one of file, dir, symlink, other",
1672                "invalid kind \"socket\": expected one of file, dir, symlink, other",
1673            ),
1674            (
1675                reading(ReadSpec { min_size: Some("10X"), ..ReadSpec::new() }),
1676                "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
1677                 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
1678                "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
1679                 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
1680            ),
1681            (
1682                reading(ReadSpec {
1683                    modified_since: Some("2300-01-01T00:00:00Z"),
1684                    ..ReadSpec::new()
1685                }),
1686                "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
1687                 range fdu can represent (about 1677 to 2262)",
1688                "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
1689                 fdu can represent (about 1677 to 2262)",
1690            ),
1691        ];
1692        for (spec, flags, fields) in cases {
1693            assert_eq!(refusal(&spec, &AxisNames::FLAGS), flags);
1694            assert_eq!(refusal(&spec, &AxisNames::FIELDS), fields);
1695        }
1696    }
1697
1698    /// The order `build` names axes in, when more than one of them is wrong.
1699    ///
1700    /// A contract, not an accident: every surface renders the first refusal and stops, so
1701    /// the order decides which mistake a caller is told about, and one that drifted would
1702    /// change what two doors say about the same command line. Pinned by fixing one axis at
1703    /// a time and watching the next one speak -- content, views, the selection, the page
1704    /// denominator, then scope, which is the order the doc comment publishes and the order
1705    /// the command line reads its flags.
1706    #[test]
1707    fn build_names_a_bad_axis_in_the_order_it_publishes() {
1708        let everything = RequestSpec {
1709            scan_depth: Some("deep"),
1710            analyze: Some("deep"),
1711            read: ReadSpec {
1712                views: Some("bogus"),
1713                depth: Some("two"),
1714                words_per_page: Some("0"),
1715                ..ReadSpec::new()
1716            },
1717            ..RequestSpec::new(root())
1718        };
1719        let steps: [(RequestSpec<'_>, &str); 5] = [
1720            (everything, "analyze"),
1721            (RequestSpec { analyze: None, ..everything }, "view"),
1722            (
1723                RequestSpec {
1724                    analyze: None,
1725                    read: ReadSpec { views: None, ..everything.read },
1726                    ..everything
1727                },
1728                "depth",
1729            ),
1730            (
1731                RequestSpec {
1732                    analyze: None,
1733                    read: ReadSpec { views: None, depth: None, ..everything.read },
1734                    ..everything
1735                },
1736                "words_per_page",
1737            ),
1738            (
1739                RequestSpec {
1740                    analyze: None,
1741                    read: ReadSpec {
1742                        views: None,
1743                        format: None,
1744                        depth: None,
1745                        words_per_page: None,
1746                        ..everything.read
1747                    },
1748                    ..everything
1749                },
1750                "max_depth",
1751            ),
1752        ];
1753        for (spec, axis) in steps {
1754            let refused = refusal(&spec, &AxisNames::FIELDS);
1755            assert!(
1756                refused.starts_with(&format!("invalid {axis} ")),
1757                "expected {axis} to speak next, got {refused}"
1758            );
1759        }
1760        // And the last step is the only thing still wrong, so fixing it parses.
1761        Request::build(
1762            &RequestSpec {
1763                scan_depth: None,
1764                analyze: None,
1765                read: ReadSpec::new(),
1766                ..RequestSpec::new(root())
1767            },
1768            instant(),
1769            &AxisNames::FIELDS,
1770        )
1771        .expect("nothing left to refuse");
1772    }
1773
1774    fn request_with(views: &[ViewSpec], selection: Selection, basis: Basis) -> Request {
1775        Request::new(
1776            basis,
1777            Query { selection, views: views.to_vec(), ..Query::default() },
1778            instant(),
1779        )
1780    }
1781
1782    fn basis(content: AnalysisSet, read_controls: bool) -> Basis {
1783        Basis {
1784            root: root().to_path_buf(),
1785            scope: Scope { read_controls, ..Scope::default() },
1786            content,
1787        }
1788    }
1789
1790    /// Moved from the report reader, where a surface-only check stood in for the model.
1791    #[test]
1792    fn language_grouping_is_metadata_only_while_documents_require_analysis() {
1793        let enabled = [
1794            AnalysisSet::NONE.with_lines(),
1795            AnalysisSet::NONE.with_code(),
1796            AnalysisSet::NONE.with_words(),
1797            AnalysisSet::ALL,
1798        ];
1799        for content in std::iter::once(AnalysisSet::NONE).chain(enabled) {
1800            request_with(&[ViewSpec::Languages], Selection::default(), basis(content, true))
1801                .validate()
1802                .expect("language grouping never requires content I/O");
1803        }
1804
1805        let documents = request_with(
1806            &[ViewSpec::Documents],
1807            Selection::default(),
1808            basis(AnalysisSet::NONE, true),
1809        );
1810        assert_eq!(documents.validate(), Err(RequestError::ViewNeedsContent(ViewSpec::Documents)));
1811        for content in enabled {
1812            request_with(&[ViewSpec::Documents], Selection::default(), basis(content, true))
1813                .validate()
1814                .expect("every enabled profile includes the basic document metrics");
1815        }
1816
1817        request_with(
1818            &[ViewSpec::Types, ViewSpec::Families],
1819            Selection::default(),
1820            basis(AnalysisSet::NONE, true),
1821        )
1822        .validate()
1823        .expect("metadata grouping never requires content I/O");
1824    }
1825
1826    /// A scope this build cannot honour is refused by request validation itself.
1827    ///
1828    /// Both entry points, because they cover different routes: `validate` is what a
1829    /// one-shot report and both command lines ask, `validate_read` what a retained index,
1830    /// an opened root, and a watch session ask. Both weigh the scope the *request* names,
1831    /// not the one a holder retained, so the refusal cannot wait for a scan that a
1832    /// cache-only read never runs -- which is how one request came to name a snapshot miss
1833    /// under one delivery and a scope refusal under another.
1834    ///
1835    /// `follow_symlinks` is refused on every platform, which is how the `one_filesystem`
1836    /// rule -- refused only where the platform has no device identity -- is tested here.
1837    #[test]
1838    fn a_scope_this_build_cannot_honour_is_refused_by_request_validation() {
1839        let held = basis(AnalysisSet::NONE, true);
1840        let mut asked = held.clone();
1841        asked.scope.follow_symlinks = true;
1842        let request = request_with(&[ViewSpec::Summary], Selection::default(), asked);
1843        let refusal = RequestError::ScopeUnsupported {
1844            axis: ScopeAxis::FollowSymlinks,
1845            reason: ScopeAxis::FollowSymlinks.reason(),
1846        };
1847
1848        assert_eq!(request.validate(), Err(refusal.clone()));
1849        assert_eq!(request.validate_read(&held), Err(refusal));
1850        // The sentence is the one the capability rule states, not a copy of it kept here:
1851        // an engine-internal caller that never builds a request prints the same words.
1852        assert_eq!(
1853            ScanConfig { follow_symlinks: true, ..ScanConfig::default() }
1854                .unsupported_axis()
1855                .expect("no build follows symbolic links")
1856                .reason(),
1857            ScopeAxis::FollowSymlinks.reason()
1858        );
1859        request_with(&[ViewSpec::Summary], Selection::default(), held)
1860            .validate()
1861            .expect("a scope this build honours is not refused");
1862    }
1863
1864    /// Moved from the report reader: the refusal half of
1865    /// `an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by`. The
1866    /// reader keeps the library-path half, its typed refusal of an unvalidated query.
1867    #[test]
1868    fn a_scope_that_observes_no_control_state_refuses_selection_by_ignored_state() {
1869        let exclude = Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() };
1870        let only = Selection { ignored: IgnoredEntries::Only, ..Selection::default() };
1871        let blind = basis(AnalysisSet::NONE, false);
1872
1873        let refused = request_with(&[ViewSpec::Summary], exclude.clone(), blind.clone())
1874            .validate()
1875            .expect_err("no entry can be shown to be ignored");
1876        assert_eq!(
1877            refused.message(&AxisNames::FLAGS),
1878            "--exclude-ignored needs .gitignore classification, and --no-gitignore turned it \
1879             off; drop one of them"
1880        );
1881        let refused = request_with(&[ViewSpec::Summary], only, blind.clone())
1882            .validate()
1883            .expect_err("no entry can be shown to be ignored");
1884        assert_eq!(
1885            refused.message(&AxisNames::FIELDS),
1886            "ignored=only needs .gitignore classification, and read_controls turned it off; \
1887             drop one of them"
1888        );
1889        request_with(&[ViewSpec::Summary], exclude, basis(AnalysisSet::NONE, true))
1890            .validate()
1891            .expect("an observing scope can select by ignored state");
1892        request_with(&[ViewSpec::Summary], Selection::default(), blind)
1893            .validate()
1894            .expect("admitting every entry needs no classification");
1895    }
1896
1897    #[test]
1898    fn a_read_is_refused_by_a_holder_of_other_content() {
1899        let request = built(&RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) });
1900        let held = basis(AnalysisSet::NONE, true);
1901        assert_eq!(
1902            request.validate_read(&held),
1903            Err(RequestError::ContentMismatch {
1904                held: AnalysisSet::NONE,
1905                requested: AnalysisSet::NONE.with_lines(),
1906            })
1907        );
1908        // A wider store is refused too: serving a narrower request from it is a projection
1909        // this model does not define.
1910        assert_eq!(
1911            request.validate_read(&basis(AnalysisSet::ALL, true)),
1912            Err(RequestError::ContentMismatch {
1913                held: AnalysisSet::ALL,
1914                requested: AnalysisSet::NONE.with_lines(),
1915            })
1916        );
1917        request
1918            .validate_read(&basis(AnalysisSet::NONE.with_lines(), true))
1919            .expect("equal content serves");
1920
1921        // The remaining rules read what the holder observed, not what the request assumed.
1922        let exclude = built(&reading(ReadSpec { ignored: Some("exclude"), ..ReadSpec::new() }));
1923        assert_eq!(
1924            exclude.validate_read(&basis(AnalysisSet::NONE, false)),
1925            Err(RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude))
1926        );
1927        // Content equality comes first, so the refusal names the more fundamental mismatch.
1928        let documents = request_with(
1929            &[ViewSpec::Documents],
1930            Selection::default(),
1931            basis(AnalysisSet::NONE.with_words(), true),
1932        );
1933        assert!(matches!(
1934            documents.validate_read(&basis(AnalysisSet::NONE, true)),
1935            Err(RequestError::ContentMismatch { .. })
1936        ));
1937    }
1938
1939    /// What a read validates against is what the index observed, not the configuration that
1940    /// happened to make it: a `ScanConfig`'s delivery fields cannot be recovered from an
1941    /// index and no answer depends on them.
1942    #[test]
1943    fn the_basis_a_retained_index_holds_is_what_it_observed() {
1944        let blind =
1945            crate::Index::new_with_scope("/root", crate::test_support::not_observing_controls());
1946        let held = Basis::held_by(&blind);
1947        assert_eq!(held.root, Path::new("/root"));
1948        assert!(!held.scope.read_controls, "an index that read no rule says so");
1949        assert_eq!(held.content, AnalysisSet::NONE, "a metadata index holds no analyzer");
1950
1951        let observing =
1952            crate::Index::new_with_scope("/root", crate::test_support::observing_controls());
1953        assert!(Basis::held_by(&observing).scope.read_controls);
1954
1955        // The limits are the tier's own, not the table's: an index admitted its control
1956        // files under the limits it was built with, and a basis that restated the defaults
1957        // here would compare equal to one taken under any other budget.
1958        let tight = ControlLimits { budget: Some(4_096), line_limit: None };
1959        let narrow = crate::Index::new_with_config(
1960            "/root",
1961            &ScanConfig { read_controls: true, control_limits: tight, ..ScanConfig::default() },
1962        );
1963        assert_eq!(Basis::held_by(&narrow).scope.control_limits, tight);
1964        assert_ne!(tight, Request::DEFAULTS.control_limits, "the fixture must differ");
1965        assert_eq!(
1966            Basis::held_by(&blind).scope.control_limits,
1967            Request::DEFAULTS.control_limits,
1968            "a scan that read no rule applied none, and names the table's"
1969        );
1970
1971        // The scope a request would have to be built with to read this index.
1972        let request =
1973            built(&RequestSpec { read_controls: Some(false), ..RequestSpec::new(root()) });
1974        request
1975            .validate_read(&Basis::held_by(&blind))
1976            .expect("the index's own basis answers a request built the same way");
1977    }
1978
1979    /// Each rule in the order `validate_delivery` applies it, and each one only under a
1980    /// watch: every one of these is a legal one-shot request.
1981    #[test]
1982    fn a_watch_refuses_what_it_cannot_keep_current() {
1983        let one_shot = Delivery {
1984            cache: CachePolicy::Auto,
1985            cache_path: None,
1986            accept_partial: false,
1987            watch: None,
1988            workers: Workers::default(),
1989            batch_size: ScanConfig::default().batch_size,
1990            order: crate::ScanOrder::default(),
1991        };
1992        let watching = Delivery { watch: Some(WatchDelivery::default()), ..one_shot.clone() };
1993        let cases = [
1994            (
1995                RequestSpec { scan_depth: Some("2"), ..RequestSpec::new(root()) },
1996                RequestError::WatchScope,
1997            ),
1998            (
1999                RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) },
2000                RequestError::WatchScope,
2001            ),
2002            (
2003                RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) },
2004                RequestError::WatchContent,
2005            ),
2006        ];
2007        for (spec, expected) in cases {
2008            let request = built(&spec);
2009            assert_eq!(request.validate_delivery(&watching), Err(expected));
2010            request.validate_delivery(&one_shot).expect("a one-shot delivers all three");
2011        }
2012
2013        let plain = built(&RequestSpec::new(root()));
2014        plain.validate_delivery(&watching).expect("a full-scope metadata watch is deliverable");
2015        assert_eq!(
2016            plain.validate_delivery(&Delivery { cache: CachePolicy::Only, ..watching.clone() }),
2017            Err(RequestError::WatchCacheOnly),
2018            "nothing verifies the window between the snapshot and the start of the watch"
2019        );
2020        plain
2021            .validate_delivery(&Delivery { cache: CachePolicy::Only, ..one_shot })
2022            .expect("a one-shot report is exactly what a snapshot answers");
2023    }
2024
2025    /// The order the three watch rules speak in, when a request breaks more than one.
2026    ///
2027    /// The command line and the Python API both render the first refusal and stop, so this
2028    /// decides which of three true statements a caller is told, and a rule moved within
2029    /// `validate_delivery` would silently change that. Scope first, because it is a fact
2030    /// about what a watcher can observe at all; then content, which is about what stays
2031    /// current; then the cache policy, which is about the window before the watch started.
2032    #[test]
2033    fn the_watch_rules_speak_in_one_order() {
2034        let cache_only_watch = Delivery {
2035            cache: CachePolicy::Only,
2036            cache_path: None,
2037            accept_partial: false,
2038            watch: Some(WatchDelivery::default()),
2039            workers: Workers::default(),
2040            batch_size: ScanConfig::default().batch_size,
2041            order: crate::ScanOrder::default(),
2042        };
2043        let everything = RequestSpec {
2044            scan_depth: Some("2"),
2045            analyze: Some("lines"),
2046            ..RequestSpec::new(root())
2047        };
2048        let steps = [
2049            (everything, RequestError::WatchScope),
2050            (RequestSpec { scan_depth: None, ..everything }, RequestError::WatchContent),
2051            (
2052                RequestSpec { scan_depth: None, analyze: None, ..everything },
2053                RequestError::WatchCacheOnly,
2054            ),
2055        ];
2056        for (spec, expected) in steps {
2057            assert_eq!(built(&spec).validate_delivery(&cache_only_watch), Err(expected));
2058        }
2059        built(&RequestSpec::new(root()))
2060            .validate_delivery(&Delivery { cache: CachePolicy::Auto, ..cache_only_watch })
2061            .expect("nothing left to refuse");
2062    }
2063
2064    /// The other half of the watch-scope rule, which the command-line golden cannot
2065    /// assert: where a build cannot honor `one_filesystem` at all, the request is refused
2066    /// for that reason first, so the message differs by platform.
2067    #[cfg(unix)]
2068    #[test]
2069    fn a_watch_refuses_one_filesystem_where_the_build_honors_it() {
2070        let watch = Delivery {
2071            cache: CachePolicy::Auto,
2072            cache_path: None,
2073            accept_partial: false,
2074            watch: Some(WatchDelivery::default()),
2075            workers: Workers::default(),
2076            batch_size: ScanConfig::default().batch_size,
2077            order: crate::ScanOrder::default(),
2078        };
2079        let spec = RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) };
2080        let request = built(&spec);
2081        request.validate().expect("one filesystem is honored on this build");
2082        assert_eq!(request.validate_delivery(&watch), Err(RequestError::WatchScope));
2083    }
2084
2085    #[test]
2086    fn a_request_is_refused_past_the_views_one_report_carries() {
2087        let mut request = built(&RequestSpec::new(root()));
2088        request.query.views = vec![ViewSpec::Summary; crate::MAX_REPORT_VIEWS];
2089        request.validate().expect("the limit itself is accepted");
2090        request.query.omitted_views = vec![ViewSpec::Documents];
2091        assert_eq!(
2092            request.validate(),
2093            Err(RequestError::ViewLimit {
2094                attempted: crate::MAX_REPORT_VIEWS + 1,
2095                limit: crate::MAX_REPORT_VIEWS,
2096            })
2097        );
2098    }
2099}