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, ViewList, ViewSpec};
25use crate::query::query_selection::{
26    Bound, IgnoredEntries, Selection, ShareThreshold, SizeMetric, SortKey,
27};
28use crate::query::query_values::{
29    parse_control_budget, parse_control_line_limit, parse_size, parse_when, system_time_to_nanos,
30};
31use crate::scan::ScanConfig;
32
33/// Semantic filesystem scope, independent of scheduling and batching.
34#[derive(Clone, Debug)]
35#[allow(clippy::struct_excessive_bools)]
36pub struct Scope {
37    /// Maximum retained depth.
38    pub max_depth: Option<usize>,
39    /// Whether directory symlinks are followed.
40    pub follow_symlinks: bool,
41    /// Whether traversal stays on one filesystem.
42    pub one_filesystem: bool,
43    /// Hidden-component admission.
44    pub hidden: Option<std::sync::Arc<crate::admission::HiddenPolicy>>,
45    /// Whether special filesystem objects are excluded.
46    pub exclude_special: bool,
47    /// Classification rules.
48    pub types: Option<std::sync::Arc<crate::classify::TypeRegistry>>,
49    /// Whether gitignore control files are observed.
50    pub read_controls: bool,
51    /// Ignored population retained by this basis.
52    pub population: IgnoredEntries,
53    /// Control admission limits.
54    pub control_limits: ControlLimits,
55}
56impl Default for Scope {
57    fn default() -> Self {
58        ScanConfig::default().into()
59    }
60}
61impl From<ScanConfig> for Scope {
62    fn from(scan: ScanConfig) -> Self {
63        Self {
64            max_depth: scan.max_depth,
65            follow_symlinks: scan.follow_symlinks,
66            one_filesystem: scan.one_filesystem,
67            hidden: scan.hidden,
68            exclude_special: scan.exclude_special,
69            types: scan.types,
70            read_controls: scan.read_controls,
71            population: scan.population,
72            control_limits: scan.control_limits,
73        }
74    }
75}
76impl Scope {
77    /// Derive the scanner's operational configuration from this scope and a delivery.
78    pub fn scan_config(&self, delivery: &Delivery) -> ScanConfig {
79        ScanConfig {
80            max_depth: self.max_depth,
81            follow_symlinks: self.follow_symlinks,
82            one_filesystem: self.one_filesystem,
83            hidden: self.hidden.clone(),
84            exclude_special: self.exclude_special,
85            types: self.types.clone(),
86            read_controls: self.read_controls,
87            population: self.population,
88            control_limits: self.control_limits,
89            threads: delivery.workers.scan,
90            batch_size: delivery.batch_size,
91            order: delivery.order,
92            progress: None,
93        }
94    }
95    fn identity_config(&self) -> ScanConfig {
96        ScanConfig {
97            max_depth: self.max_depth,
98            follow_symlinks: self.follow_symlinks,
99            one_filesystem: self.one_filesystem,
100            hidden: self.hidden.clone(),
101            exclude_special: self.exclude_special,
102            types: self.types.clone(),
103            read_controls: self.read_controls,
104            population: self.population,
105            control_limits: self.control_limits,
106            ..ScanConfig::default()
107        }
108    }
109    /// Semantic identity observed by the scanner.
110    pub fn scope(&self) -> crate::ScanScope {
111        self.identity_config().scope()
112    }
113    /// Identity of the persisted metadata tiers.
114    pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
115        self.identity_config().snapshot_identity()
116    }
117    pub(crate) fn unsupported_axis(&self) -> Option<ScopeAxis> {
118        self.identity_config().unsupported_axis()
119    }
120}
121
122/// Operational worker limits. Zero analysis workers selects available parallelism.
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
124pub struct Workers {
125    /// Directory-reading workers; absent selects the engine's bounded automatic pool.
126    pub scan: Option<usize>,
127    /// Content-reader workers.
128    pub analysis: usize,
129}
130
131/// What a retained index or an opened root holds for its lifetime.
132///
133/// Everything here shapes the stored state itself, so a read can only be answered by a
134/// holder of the same basis: see [`Request::validate_read`].
135#[derive(Clone, Debug)]
136pub struct Basis {
137    /// The directory the answer is about.
138    pub root: PathBuf,
139    /// What the scan observes and retains.
140    ///
141    /// Scheduling is supplied separately by [`Delivery`].
142    pub scope: Scope,
143    /// The analyzers whose results the answer may report.
144    pub content: AnalysisSet,
145}
146
147impl Basis {
148    /// The basis a retained index holds, as the index itself can still state it.
149    ///
150    /// Scope comes back from [`ScanScope`](crate::ScanScope) and the control tier rather
151    /// than from the `ScanConfig` that made it: what a read validates against is what the
152    /// index observed, and the fields a config keeps beyond that -- threads, batch size,
153    /// order -- are delivery, which no answer depends on.
154    ///
155    /// The control tier carries both halves of what the scan observed, so both are read
156    /// from it: whether any rule was read, and the limits the ones that were read were
157    /// admitted under. An index that observed nothing applied no limits, and the table's
158    /// are what a scope that reads no rule would have been taken under; nothing in a basis
159    /// that observes no control state depends on them.
160    pub fn held_by(index: &crate::Index) -> Self {
161        let scope = index.scope();
162        let controls = index.control_identity();
163        Self {
164            root: index.root_path().to_path_buf(),
165            scope: Scope {
166                max_depth: scope.max_depth,
167                follow_symlinks: scope.follow_symlinks,
168                one_filesystem: scope.one_filesystem,
169                exclude_special: scope.exclude_special,
170                read_controls: controls.is_observed(),
171                population: scope.population,
172                control_limits: match controls {
173                    crate::ControlTierIdentity::Observed { limits } => limits,
174                    crate::ControlTierIdentity::NotObserved => Self::UNOBSERVED_LIMITS,
175                },
176                ..Scope::default()
177            },
178            content: index.content_set(),
179        }
180    }
181
182    /// The basis a spec names, without the read half it also carries.
183    ///
184    /// What a holder is fixed with for its lifetime: root, scope, and analyzers. A caller
185    /// that opens an index and then reads it many times parses these once, and each read
186    /// hands them back to [`Request::read`]; building a whole request and discarding its
187    /// query was the shape that made the basis look like the read's to overwrite.
188    ///
189    /// # Errors
190    ///
191    /// [`RequestError`] for a value no grammar accepts, named as `axes` spells its axis.
192    pub fn build(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Self, RequestError> {
193        let content = parse_content(spec, axes)?;
194        let mut scope = parse_scope(spec, axes)?;
195        scope.population = parse_population(spec.read.ignored, axes)?;
196        Ok(Self { root: spec.root.to_path_buf(), scope, content })
197    }
198
199    /// The limits a basis records when its scan observed no control state at all.
200    ///
201    /// The defaults table's, because they are what the scope would have been taken under
202    /// had it read a rule; no rule this model states reads them when `read_controls` is
203    /// off, so this is a placeholder named rather than a value implied.
204    const UNOBSERVED_LIMITS: ControlLimits = Request::DEFAULTS.control_limits;
205}
206
207/// How a request is carried out, which never changes what its answer says.
208///
209/// No `Default`, deliberately. Every field here is a decision its caller has already made,
210/// and the cache policy is the one that decides whether an answer touches the filesystem
211/// and whether it leaves a trace; a default one reads `cache: Auto` whatever the caller
212/// asked for, which is exactly how the watch session came to validate against a cache
213/// policy nobody had chosen and `WatchCacheOnly` became unreachable inside the engine
214/// (fdu-i18y). A caller that wants the ordinary delivery names it.
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct Delivery {
217    /// How the snapshot cache may be used.
218    pub cache: CachePolicy,
219    /// Answer from the snapshot alone, without touching the tree.
220    ///
221    /// The one delivery whose answer can be stale, and it says so: the report's
222    /// provenance is `cache_only` and its freshness `stale`. It fails when no usable
223    /// snapshot exists rather than quietly scanning, because a fast path that is sometimes
224    /// a full walk, with nothing in the output to say which happened, is worse than none.
225    /// It writes nothing, and it is refused with [`CachePolicy::Off`], with a watch, and
226    /// by a refresh, none of which can take an unverified snapshot as their answer.
227    pub stale_ok: bool,
228    /// Where the snapshot for this root lives, or `None` for no cache at all.
229    pub cache_path: Option<PathBuf>,
230    /// Whether a partial answer is accepted as a success.
231    pub accept_partial: bool,
232    /// Whether the answer repeats as a watch, and how.
233    pub watch: Option<WatchDelivery>,
234    /// Directory and content reader workers.
235    pub workers: Workers,
236    /// Maximum operations in one scanner batch.
237    pub batch_size: usize,
238    /// Directory traversal scheduling.
239    pub order: crate::ScanOrder,
240}
241
242impl Delivery {
243    /// Ordinary execution settings with an explicitly chosen cache policy and location.
244    pub fn new(cache: CachePolicy, cache_path: Option<PathBuf>) -> Self {
245        Self {
246            cache,
247            stale_ok: false,
248            cache_path,
249            accept_partial: false,
250            watch: None,
251            workers: Workers::default(),
252            batch_size: ScanConfig::default().batch_size,
253            order: crate::ScanOrder::default(),
254        }
255    }
256
257    /// Ordinary execution settings that answer from the snapshot at `cache_path` alone.
258    pub fn stale_ok(cache_path: Option<PathBuf>) -> Self {
259        Self { stale_ok: true, ..Self::new(CachePolicy::Auto, cache_path) }
260    }
261
262    /// Representative deliveries for checking policy independently of route.
263    /// Worker counts and cache location are fixed; every cache, stale-answer,
264    /// partial-answer, and watch choice is represented.
265    pub fn enumerate() -> impl Iterator<Item = Self> {
266        [
267            (CachePolicy::Auto, false),
268            (CachePolicy::On, false),
269            (CachePolicy::Off, false),
270            (CachePolicy::Auto, true),
271            (CachePolicy::On, true),
272            (CachePolicy::Off, true),
273        ]
274        .into_iter()
275        .flat_map(|(cache, stale_ok)| {
276            [false, true].into_iter().flat_map(move |accept_partial| {
277                [None, Some(WatchDelivery::default())].into_iter().map(move |watch| Self {
278                    cache,
279                    stale_ok,
280                    cache_path: Some(PathBuf::from("cache.fdu")),
281                    accept_partial,
282                    watch,
283                    workers: Workers { analysis: 1, ..Workers::default() },
284                    batch_size: ScanConfig::default().batch_size,
285                    order: crate::ScanOrder::default(),
286                })
287            })
288        })
289    }
290}
291
292/// How a watch repeats its answer.
293#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294pub struct WatchDelivery {
295    /// The longest a repaint waits for changes; change detection itself is event-driven.
296    pub interval: Duration,
297}
298
299impl Default for WatchDelivery {
300    fn default() -> Self {
301        Self { interval: Self::DEFAULT_INTERVAL }
302    }
303}
304impl WatchDelivery {
305    /// Shared default repaint cadence for every surface.
306    pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(2);
307}
308
309/// Everything that determines an answer.
310///
311/// The three public fields are the answer's whole input. A private fourth records how the
312/// basis's analyzers were chosen, which no answer reads and only a refusal does, so a
313/// request outside this crate is made with [`Self::new`], [`Self::read`], or
314/// [`Self::build`] rather than a struct literal.
315#[derive(Clone, Debug)]
316pub struct Request {
317    /// Root, scope, and content: what a holder of stored state must match.
318    pub basis: Basis,
319    /// Selection, views, and view options.
320    pub query: Query,
321    /// The instant relative time windows were resolved against.
322    ///
323    /// Fixed when the request is built, so [`Selection::modified`] is absolute and a watch
324    /// that builds its request once at start never slides its window.
325    pub now: SystemTime,
326    /// How the basis's analyzers were chosen.
327    ///
328    /// Private, because it is provenance rather than input: the answer is the same whether
329    /// a caller named `code` as a view or as an analyzer, and a public field could be set
330    /// out of step with `basis.content`, so that a refusal named a view that caused
331    /// nothing. Fixed by the constructor that read the caller's axes.
332    origin: Origin,
333}
334
335/// How a request's analyzers were chosen: what its caller named, and which views implied
336/// the rest.
337///
338/// What a refusal needs to name what the caller wrote, and to offer a remedy the caller's
339/// route accepts: a request built from an analyzer axis can add an analyzer, and a basis
340/// supplied whole cannot be told to.
341#[derive(Clone, Debug, PartialEq, Eq)]
342enum Origin {
343    /// The caller supplied the basis whole ([`Request::new`], [`Request::read`]), so nothing
344    /// was named or implied.
345    Supplied,
346    /// [`Request::build`] read the caller's analyzer and view axes.
347    Built {
348        /// What the analyzer axis named; `none` when it named nothing.
349        named: AnalysisSet,
350        /// The named views that imply an analyzer, in the caller's order.
351        implied_by: Vec<ViewSpec>,
352    },
353}
354
355/// What fixed the analyzers a read is answered from, which decides the remedy a refusal
356/// can offer for analysis the read needs and the basis lacks.
357///
358/// Each route accepts a different remedy. A request that builds its own basis takes
359/// another analyzer; a retained index answers only what it was opened with, so the
360/// remedy is to open it again; an opened root runs no analyzer at all; and a basis a
361/// caller supplied whole is the caller's to widen. A refusal that named one route's remedy
362/// on another sent the caller looking for a parameter that does not exist.
363#[derive(Clone, Copy, Debug, PartialEq, Eq)]
364#[non_exhaustive]
365pub enum BasisHolder {
366    /// [`Request::build`]: the request enables what its analyzer axis names.
367    Built,
368    /// [`Request::new`]: a basis supplied whole, which nothing holds.
369    Supplied,
370    /// A retained index, which holds the analyzers it was opened with.
371    Index,
372    /// An opened root, which runs no analyzer.
373    OpenedRoot,
374}
375
376impl BasisHolder {
377    /// What the caller can do about analysis `needed` that the basis, holding `held`, lacks.
378    ///
379    /// Appended to a sentence that already names what is needed, so each form starts with
380    /// its own punctuation.
381    fn remedy(self, needed: AnalysisSet, held: AnalysisSet, axes: &AxisNames) -> String {
382        let analyze = axes.analyze;
383        match self {
384            Self::Built => format!(": add {analyze} {}", needed.request_label()),
385            Self::Supplied => format!("; its basis holds {analyze} {}", held.request_label()),
386            Self::Index => {
387                format!("; this index was opened with {analyze} {}", held.request_label())
388            }
389            Self::OpenedRoot => format!(
390                ", which an opened root never runs; use a one-shot report, or an index opened \
391                 with {analyze} {}",
392                needed.request_label()
393            ),
394        }
395    }
396}
397
398/// A request as a caller wrote it: raw values, before any grammar has read them.
399///
400/// Surface-neutral, so the command line and the Python API fill one shape and
401/// [`Request::build`] parses both identically. A value a surface already holds typed, such
402/// as `--scan-depth`, reaches this through its `Display`, so a disagreement between that
403/// type and the grammar here shows up as a golden difference rather than a silent one.
404/// `None` and an empty list mean the caller named nothing, and [`Request::DEFAULTS`]
405/// decides; the switches whose only default is off are plain booleans.
406#[derive(Clone, Copy, Debug)]
407pub struct RequestSpec<'a> {
408    /// The directory the answer is about.
409    pub root: &'a Path,
410    /// Retention depth: a whole number.
411    pub scan_depth: Option<&'a str>,
412    /// Stay on the root's filesystem.
413    pub one_filesystem: bool,
414    /// Observe `.gitignore`.
415    pub read_controls: Option<bool>,
416    /// The `.gitignore` budget: a size or `all`.
417    pub control_budget: Option<&'a str>,
418    /// The longest `.gitignore` line: a size or `all`.
419    pub control_line_limit: Option<&'a str>,
420    /// Analyzers: a comma list of `none`, `lines`, `code`, `words`, or `all`.
421    pub analyze: Option<&'a str>,
422    /// What this read asks of the basis the axes above describe.
423    pub read: ReadSpec<'a>,
424}
425
426/// What one read supplies, as its caller wrote it: the [`Query`] half of a request.
427///
428/// Split from the basis half rather than flattened beside it, because a holder of stored
429/// state fixes root, scope, and analyzers once and then answers many reads: a read names
430/// only this, and [`Request::read`] is what takes the two together. A field declared in
431/// both halves is a field one of them could silently drop, which is why this is the one
432/// declaration and [`RequestSpec`] contains it.
433#[derive(Clone, Copy, Debug)]
434pub struct ReadSpec<'a> {
435    /// Views: a comma list, or `full`.
436    pub views: Option<&'a str>,
437    /// Presentation format; omitted means automatic human output.
438    pub format: Option<&'a str>,
439    /// Logical words per document page: a positive integer.
440    pub words_per_page: Option<&'a str>,
441    /// Patterns an entry must match one of.
442    pub include: &'a [String],
443    /// Patterns that exclude an entry.
444    pub exclude: &'a [String],
445    /// Smallest size, as `512`, `10M`, or `1.5GiB`.
446    pub min_size: Option<&'a str>,
447    /// Inclusive lower bound on modification time, as `2h` or a timestamp.
448    pub modified_since: Option<&'a str>,
449    /// Exclusive upper bound on modification time.
450    pub modified_before: Option<&'a str>,
451    /// Entry kinds: a comma list of `file`, `dir`, `symlink`, or `other`.
452    pub kinds: Option<&'a str>,
453    /// Selection by ignored state: `include`, `exclude`, or `only`.
454    pub ignored: Option<&'a str>,
455    /// Rendered tree depth: a whole number or `all`.
456    pub depth: Option<&'a str>,
457    /// Minimum displayed contribution to the selected root, as a percentage.
458    pub min_share: Option<&'a str>,
459    /// Maximum immediate children shown per directory: a whole number or `all`.
460    pub breadth: Option<&'a str>,
461    /// Rows per view: a whole number or `all`.
462    pub limit: Option<&'a str>,
463    /// Ordering key: `size`, `count`, `mtime`, or `name`.
464    pub sort: Option<&'a str>,
465    /// Reverse the ordering.
466    pub reverse: bool,
467    /// Size metric: `allocated` or `apparent`.
468    pub size: Option<&'a str>,
469}
470
471impl ReadSpec<'_> {
472    /// A read that names nothing, so every axis takes its default.
473    pub const fn new() -> Self {
474        Self {
475            views: None,
476            format: None,
477            words_per_page: None,
478            include: &[],
479            exclude: &[],
480            min_size: None,
481            modified_since: None,
482            modified_before: None,
483            kinds: None,
484            ignored: None,
485            depth: None,
486            min_share: None,
487            breadth: None,
488            limit: None,
489            sort: None,
490            reverse: false,
491            size: None,
492        }
493    }
494}
495
496impl Default for ReadSpec<'_> {
497    fn default() -> Self {
498        Self::new()
499    }
500}
501
502impl<'a> RequestSpec<'a> {
503    /// A spec that names only its root, so every other axis takes its default.
504    pub const fn new(root: &'a Path) -> Self {
505        Self {
506            root,
507            scan_depth: None,
508            one_filesystem: false,
509            read_controls: None,
510            control_budget: None,
511            control_line_limit: None,
512            analyze: None,
513            read: ReadSpec::new(),
514        }
515    }
516}
517
518/// Every default a request takes when its caller names nothing, stated once.
519///
520/// | Axis | Default |
521/// | --- | --- |
522/// | Size | allocated |
523/// | Views of a report | [`Self::report_view`]: [`ViewSpec::default_for`] the content |
524/// | Views of a watch | [`Self::report_view`] of no content, which is `tree` |
525/// | Words per page | 250 |
526/// | Content | no analyzer |
527/// | `.gitignore` | observed, under the default budget and line limit |
528///
529/// Each answers a question: "how much disk does this use" is allocated bytes, as `du`
530/// reports them; a request that pays to read files displays what it read; and a tree is
531/// what "what is big here" looks like. Surfaces take these rather than declaring their own,
532/// because a default declared twice drifts: size was apparent in Rust and allocated
533/// everywhere else, and `words_per_page` was written out in three places.
534///
535/// A watch has no view default of its own, and no field here for one. It is derived rather
536/// than declared because it is not a separate decision: [`RequestError::WatchContent`]
537/// refuses a watch with any analyzer, named or implied by a view, so the content a watch
538/// serves is always none and its view is the report default for none. A field would have
539/// restated `tree` beside the rule that makes it true, which is the shape a default drifts
540/// out of.
541///
542/// Content is `none` beyond what the views imply: [`Request::build`] adds the analyzers a
543/// content view shows ([`ViewSpec::implies`]), so `none` names no analyzer of its own rather
544/// than forbidding one.
545#[derive(Clone, Copy, Debug, PartialEq, Eq)]
546pub struct RequestDefaults {
547    /// The size metric.
548    pub size: SizeMetric,
549    /// Logical words per derived document page.
550    pub words_per_page: u64,
551    /// The analyzers a request enables.
552    pub content: AnalysisSet,
553    /// Whether a scan observes `.gitignore`.
554    pub read_controls: bool,
555    /// The limits `.gitignore` files are applied under.
556    pub control_limits: ControlLimits,
557}
558
559impl RequestDefaults {
560    /// The view a report displays when its caller named none: the one that shows what
561    /// `content` read, or the tree when it read nothing.
562    pub const fn report_view(self, content: AnalysisSet) -> ViewSpec {
563        ViewSpec::default_for(content)
564    }
565}
566
567impl Request {
568    /// The defaults table.
569    pub const DEFAULTS: RequestDefaults = RequestDefaults {
570        size: SizeMetric::Allocated,
571        words_per_page: 250,
572        content: AnalysisSet::NONE,
573        read_controls: true,
574        control_limits: ControlLimits {
575            budget: Some(DEFAULT_CONTROL_BUDGET),
576            line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
577        },
578    };
579
580    /// One read of what `basis` holds, from a query its caller already has typed.
581    ///
582    /// The composition six call sites wrote out by hand, each of them a holder's basis and
583    /// one read's query and instant. A literal is not wrong, but a name is where the rule
584    /// can be stated: `basis` is the holder's, never the read's, and `now` is this read's,
585    /// fixed so a watch that repaints does not slide its own window.
586    ///
587    /// No validation, because the query is already typed and its holder is the one that
588    /// knows which rule applies -- [`Self::validate_read`] for a retained index,
589    /// [`Self::validate`] for a request that is its own basis. [`Self::read`] is the
590    /// constructor that parses and validates in one step.
591    ///
592    /// Nothing is implied: the basis is the caller's, whole. A refusal says what the basis
593    /// holds ([`BasisHolder::Supplied`]) rather than naming an analyzer to add, because no
594    /// analyzer axis built it.
595    pub const fn new(basis: Basis, query: Query, now: SystemTime) -> Self {
596        Self { basis, query, now, origin: Origin::Supplied }
597    }
598
599    /// The named views whose analyzers [`Self::build`] added to the basis, in the caller's
600    /// order; empty for a basis supplied whole.
601    ///
602    /// Provenance, not part of the answer: the basis's content already includes what they
603    /// imply. It is what a watch refusal names ([`RequestError::WatchContent`]), since the
604    /// caller wrote a view rather than an analyzer.
605    pub fn implied_by(&self) -> &[ViewSpec] {
606        match &self.origin {
607            Origin::Built { implied_by, .. } => implied_by.as_slice(),
608            Origin::Supplied => &[],
609        }
610    }
611
612    /// Which rule fixed this request's analyzers, for a refusal of its own basis.
613    const fn holder(&self) -> BasisHolder {
614        match self.origin {
615            Origin::Built { .. } => BasisHolder::Built,
616            Origin::Supplied => BasisHolder::Supplied,
617        }
618    }
619
620    /// One read of what `basis` holds, written in the value grammars.
621    ///
622    /// The constructor every read site wants: a holder supplies the basis it was opened
623    /// with, and the caller supplies only what this read asks. The view default comes from
624    /// the analyzers the basis already holds, so a typed set never has to be spelled back
625    /// into the grammar to find out what a request that read files displays, and no caller
626    /// builds a request with a throw-away basis and overwrites it afterwards.
627    ///
628    /// A held basis is never widened: a view that implies an analyzer the holder was not
629    /// opened with is refused ([`RequestError::ViewNeedsAnalyzer`]) rather than read for,
630    /// because a read cannot add to what the holder paid for.
631    ///
632    /// # Errors
633    ///
634    /// [`RequestError`] for a value no grammar accepts, and for a read the basis cannot
635    /// answer: [`Self::validate`]'s rules, which here are the holder's own. A refusal for
636    /// missing analysis names what the index was opened with ([`BasisHolder::Index`]),
637    /// since that is the holder this constructor reads for.
638    pub fn read(
639        basis: Basis,
640        spec: &ReadSpec<'_>,
641        now: SystemTime,
642        axes: &'static AxisNames,
643    ) -> Result<Self, RequestError> {
644        Self::read_held(basis, spec, now, axes, BasisHolder::Index)
645    }
646
647    /// One read of an opened root, written in the value grammars.
648    ///
649    /// [`Self::read`] over [`OpenedIndex::basis`](crate::OpenedIndex::basis), the one
650    /// statement of what every opened root holds, except that a refusal for missing
651    /// analysis says an opened root runs no analyzer ([`BasisHolder::OpenedRoot`]) rather
652    /// than naming an analyzer to open it with, which its constructor does not take.
653    ///
654    /// # Errors
655    ///
656    /// [`RequestError`] as [`Self::read`] returns it.
657    pub fn read_opened(
658        spec: &ReadSpec<'_>,
659        now: SystemTime,
660        axes: &'static AxisNames,
661    ) -> Result<Self, RequestError> {
662        Self::read_held(crate::OpenedIndex::basis(), spec, now, axes, BasisHolder::OpenedRoot)
663    }
664
665    /// The read both held constructors share, refused in `holder`'s words.
666    fn read_held(
667        basis: Basis,
668        spec: &ReadSpec<'_>,
669        now: SystemTime,
670        axes: &'static AxisNames,
671        holder: BasisHolder,
672    ) -> Result<Self, RequestError> {
673        let views = parse_views(spec.views, axes)?;
674        let mut query = build_query(basis.content, views, spec, now, axes)?;
675        if spec.ignored.is_none() {
676            query.selection.ignored = basis.scope.population;
677        }
678        let request = Self::new(basis, query, now);
679        request.validate_against(&request.basis, holder)?;
680        Ok(request)
681    }
682
683    /// Parse a spec into a request, resolving relative time windows against `now`.
684    ///
685    /// Refusals name each axis as `axes` spells it, and so do the report diagnostics of the
686    /// query built here. Every value is parsed before any is checked against another, in
687    /// the order the command line reads its flags: content, views, selection, the page
688    /// denominator, then scope. Rules that relate axes are [`Self::validate`]'s.
689    ///
690    /// This request builds its own basis, so a view that shows analysis requests it: the
691    /// basis enables the analyzers `analyze` names together with the ones the named views
692    /// imply ([`ViewSpec::implies`]), and `full` and the default view are resolved against
693    /// that union. `--view code` and `--analyze code` therefore build one basis, and share
694    /// one sidecar and one answer. Only a view with no metadata meaning implies anything,
695    /// so no metadata report ever turns into a read of file bodies.
696    pub fn build(
697        spec: &RequestSpec<'_>,
698        now: SystemTime,
699        axes: &'static AxisNames,
700    ) -> Result<Self, RequestError> {
701        // The three steps in the order the command line reads its flags, which is the order
702        // a refusal names when two axes are both wrong: content, then everything this read
703        // supplies, then scope. `Basis::build` runs the first and the third together, for
704        // the callers that fix a basis and read it many times; it implies nothing, because
705        // a holder's analyzers are what it was explicitly opened with.
706        let named = parse_content(spec, axes)?;
707        let views = parse_views(spec.read.views, axes)?;
708        let implied_by = views.implying();
709        let content = named.union(views.implies());
710        let query = build_query(content, views, &spec.read, now, axes)?;
711        let mut scope = parse_scope(spec, axes)?;
712        scope.population = query.selection.ignored;
713        Ok(Self {
714            origin: Origin::Built { named, implied_by },
715            ..Self::new(Basis { root: spec.root.to_path_buf(), scope, content }, query, now)
716        })
717    }
718
719    /// Refuse a request no holder of its own basis could answer.
720    ///
721    /// In order: more views than one report carries, a view its content cannot answer, and
722    /// a selection by ignored state its scope does not observe. A refusal for missing
723    /// analysis offers the remedy of the constructor that made the request: the analyzer to
724    /// add for one [`Self::build`] made, and what the basis holds for one supplied whole.
725    pub fn validate(&self) -> Result<(), RequestError> {
726        self.validate_against(&self.basis, self.holder())
727    }
728
729    /// Refuse a read that `held`, the basis of a retained index, cannot answer.
730    ///
731    /// Content must be equal: an index built with other analyzers holds other metrics, and
732    /// serving a narrower request from a wider store is a projection this model does not
733    /// define. The remaining rules are [`Self::validate`]'s, applied to what `held`
734    /// observed rather than to what the request says it would have, and a refusal for
735    /// missing analysis names what the index was opened with. Scope equality is not
736    /// checked here; `ScanConfig` owns it.
737    pub fn validate_read(&self, held: &Basis) -> Result<(), RequestError> {
738        let entries = held.scope.snapshot_identity().entries;
739        let wanted = crate::ContentTierIdentity::for_request(entries, self.basis.content);
740        let stored = crate::ContentTierIdentity::for_request(entries, held.content);
741        if wanted.admit(&stored).is_none() {
742            return Err(RequestError::ContentMismatch {
743                held: held.content,
744                requested: self.basis.content,
745            });
746        }
747        self.validate_against(held, BasisHolder::Index)
748    }
749
750    /// Refuse a read an opened root cannot answer: [`Self::validate`]'s rules over the
751    /// opened root's basis, whose refusal for missing analysis says that an opened root
752    /// runs no analyzer rather than naming an option its constructor does not have.
753    pub(crate) fn validate_opened(&self) -> Result<(), RequestError> {
754        self.validate_against(&self.basis, BasisHolder::OpenedRoot)
755    }
756
757    /// Refuse a delivery that cannot carry this request out.
758    ///
759    /// Everything a watch cannot do, in one place, because a watch is the one delivery that
760    /// changes which requests can be answered at all:
761    ///
762    /// - A narrowed scan scope ([`RequestError::WatchScope`]): a watcher cannot filter its
763    ///   backend's events against a boundary the scan drew. Selection still works, because
764    ///   it filters the retained index rather than the scan.
765    /// - Content analysis ([`RequestError::WatchContent`]): nothing re-reads a file the
766    ///   watch sees change, so a session would go on reporting the metrics it started with
767    ///   as fresh. Named or implied alike, and the refusal names every axis that enabled it
768    ///   -- the analyzers named and the views that implied the rest ([`Self::implied_by`])
769    ///   -- since those are what the caller wrote, and dropping only one would not help.
770    /// - A snapshot nothing verified ([`RequestError::WatchCacheOnly`]): the window between
771    ///   the snapshot and the session's start is never observed, so the first answer would
772    ///   describe a tree that may have moved and every later one would build on it.
773    ///
774    /// Each was a guard on one surface, which is why a library caller and a Python caller
775    /// could ask for what the command line refuses.
776    ///
777    /// One refusal applies to every route: a stale answer comes from the snapshot, which
778    /// [`CachePolicy::Off`] never reads ([`RequestError::StaleOkCacheOff`]).
779    pub fn validate_delivery(&self, delivery: &Delivery) -> Result<(), RequestError> {
780        if delivery.stale_ok && delivery.cache == CachePolicy::Off {
781            return Err(RequestError::StaleOkCacheOff);
782        }
783        if delivery.watch.is_none() {
784            return Ok(());
785        }
786        if self.basis.scope.max_depth.is_some() || self.basis.scope.one_filesystem {
787            return Err(RequestError::WatchScope);
788        }
789        if self.basis.content.is_enabled() {
790            return Err(match &self.origin {
791                Origin::Built { named, implied_by } => {
792                    RequestError::WatchContent { named: *named, views: implied_by.clone() }
793                }
794                // A basis supplied whole was not named on any axis; its analyzers are what
795                // the caller chose, so the refusal speaks of them as named.
796                Origin::Supplied => {
797                    RequestError::WatchContent { named: self.basis.content, views: Vec::new() }
798                }
799            });
800        }
801        if delivery.stale_ok {
802            return Err(RequestError::WatchCacheOnly);
803        }
804        Ok(())
805    }
806
807    /// [`Self::validate`]'s rules against `basis`, which `holder` fixed: the holder only
808    /// words the remedy of a refusal for missing analysis, and decides nothing.
809    fn validate_against(&self, basis: &Basis, holder: BasisHolder) -> Result<(), RequestError> {
810        // Capability first, and against the scope the request names rather than against
811        // whatever a holder retained: a scope this build cannot honour has no answer at any
812        // delivery, so the refusal must not wait for a scan that a cache-only read never
813        // runs, nor for a snapshot that a cold read never loads.
814        if let Some(axis) = self.basis.scope.unsupported_axis() {
815            return Err(RequestError::ScopeUnsupported { axis, reason: axis.reason() });
816        }
817        let views = self.query.views.len().saturating_add(self.query.omitted_views.len());
818        if views > crate::MAX_REPORT_VIEWS {
819            return Err(RequestError::ViewLimit {
820                attempted: views,
821                limit: crate::MAX_REPORT_VIEWS,
822            });
823        }
824        let format = self.query.format;
825        if matches!(
826            format,
827            crate::report_format::Format::Tree
828                | crate::report_format::Format::Paths
829                | crate::report_format::Format::Long
830        ) {
831            let compatible = self.query.views.len() == 1
832                && self.query.views.iter().all(|view| {
833                    matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
834                        || (format != crate::report_format::Format::Tree
835                            && matches!(view, ViewSpec::Largest | ViewSpec::Recent))
836                });
837            if !compatible {
838                return Err(invalid(
839                    self.query.axes.format,
840                    format.label(),
841                    "requires a single list view; use text or a machine format for aggregate/mixed views (largest/recent support paths and long)",
842                ));
843            }
844        }
845        check_views(&self.query.views, basis.content, holder)?;
846        if let Some(SortKey::Metric(name)) = self.query.selection.sort {
847            let metric =
848                crate::content::METRICS.iter().find(|metric| metric.name == name).ok_or_else(
849                    || invalid(self.query.axes.sort, name, "expected a registered numeric metric"),
850                )?;
851            if self.query.views.contains(&ViewSpec::Extensions) {
852                return Err(invalid(
853                    self.query.axes.sort,
854                    name,
855                    "extensions cannot sort by content metrics; use size, count, or name, or select files or another metric-capable view",
856                ));
857            }
858            // A sort orders rows; it never says what the report is about, so unlike a
859            // content view it implies nothing and is refused on every basis.
860            if !basis.content.contains(metric.owner) {
861                return Err(RequestError::SortNeedsAnalyzer {
862                    metric: metric.name,
863                    analyzer: metric.owner,
864                    held: basis.content,
865                    holder,
866                });
867            }
868        }
869        let hierarchy = self.query.views.iter().any(|view| self.query.tree_for(*view));
870        // Neutral bounds compose across views, including the CLI --full shorthand.
871        // A finite hierarchy bound on flat output would promise filtering it cannot do.
872        if matches!(self.query.selection.depth, Some(Bound::Limit(_))) && !hierarchy {
873            return Err(invalid(self.query.axes.depth, "", "requires a hierarchical view"));
874        }
875        if matches!(self.query.selection.breadth, Some(Bound::Limit(_))) && !hierarchy {
876            return Err(invalid(self.query.axes.breadth, "", "requires a hierarchical view"));
877        }
878        let additive = self.query.views.iter().any(|view| {
879            self.query.tree_for(*view)
880                || matches!(
881                    view,
882                    ViewSpec::Extensions
883                        | ViewSpec::Types
884                        | ViewSpec::Families
885                        | ViewSpec::Languages
886                        | ViewSpec::Documents
887                        | ViewSpec::Code
888                )
889        });
890        if self.query.selection.min_share.as_ref().is_some_and(|share| !share.admits(0, 1))
891            && !additive
892        {
893            return Err(invalid(self.query.axes.min_share, "", "requires an additive view"));
894        }
895        check_observation(basis.scope.population, basis.scope.read_controls)?;
896        check_observation(self.query.selection.ignored, basis.scope.read_controls)?;
897        if basis.scope.population != IgnoredEntries::Include
898            && self.query.selection.ignored != basis.scope.population
899        {
900            return Err(RequestError::PopulationMismatch {
901                held: basis.scope.population,
902                requested: self.query.selection.ignored,
903            });
904        }
905        Ok(())
906    }
907}
908
909/// The analyzers a spec names, or the table's default.
910fn parse_content(
911    spec: &RequestSpec<'_>,
912    axes: &'static AxisNames,
913) -> Result<AnalysisSet, RequestError> {
914    spec.analyze.map_or(Ok(Request::DEFAULTS.content), |value| {
915        AnalysisSet::parse_rejecting(value).map_err(|rejection| rejection.on(axes.analyze))
916    })
917}
918
919fn parse_population(
920    value: Option<&str>,
921    axes: &'static AxisNames,
922) -> Result<IgnoredEntries, RequestError> {
923    value.map_or(Ok(IgnoredEntries::Include), |value| {
924        IgnoredEntries::parse(value)
925            .map_err(|expected| Rejection::new(value, expected).on(axes.ignored))
926    })
927}
928
929/// The scan scope a spec names, with the table's defaults for what it leaves out.
930///
931/// The delivery fields a `ScanConfig` still carries -- threads, batch size, order -- are
932/// its own defaults: no answer depends on them, and they move into `Delivery` with
933/// `Workers`.
934fn parse_scope(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Scope, RequestError> {
935    let limits = Request::DEFAULTS.control_limits;
936    Ok(Scope {
937        max_depth: spec
938            .scan_depth
939            .map(|value| {
940                value
941                    .trim()
942                    .parse::<usize>()
943                    .map_err(|_| invalid(axes.scan_depth, value, "expected a whole number"))
944            })
945            .transpose()?,
946        one_filesystem: spec.one_filesystem,
947        read_controls: spec.read_controls.unwrap_or(Request::DEFAULTS.read_controls),
948        control_limits: ControlLimits {
949            budget: spec.control_budget.map_or(Ok(limits.budget), |value| {
950                parse_control_budget(value)
951                    .map_err(|error| named_refusal(error, axes.control_budget))
952            })?,
953            line_limit: spec.control_line_limit.map_or(Ok(limits.line_limit), |value| {
954                parse_control_line_limit(value)
955                    .map_err(|error| named_refusal(error, axes.control_line_limit))
956            })?,
957        },
958        ..Scope::default()
959    })
960}
961
962/// The view axis a read names, in its grammar, before any analyzer resolves it.
963///
964/// An analyzer's name in the view axis is refused with the view that shows it, because it
965/// is the one mistake the two vocabularies invite.
966fn parse_views(spec: Option<&str>, axes: &'static AxisNames) -> Result<ViewList, RequestError> {
967    ViewList::parse(spec).map_err(|rejection| {
968        let suggestion = match rejection.value().to_ascii_lowercase().as_str() {
969            "lines" => Some(("lines", ViewSpec::Families)),
970            "words" => Some(("words", ViewSpec::Documents)),
971            _ => None,
972        };
973        match suggestion {
974            Some((analyzer, suggested_view)) => RequestError::AnalyzerNamedAsView {
975                value: rejection.value().to_string(),
976                analyzer,
977                suggested_view,
978            },
979            None => rejection.on(axes.view),
980        }
981    })
982}
983
984/// The query one read supplies, with its views resolved against the analyzers its basis
985/// holds.
986///
987/// `content` decides `full` and the view default and nothing else here: a request that
988/// paid to read files displays what it read. Every value is parsed before any is checked
989/// against another, in one order for every surface -- views ([`parse_views`], which each
990/// caller runs first), selection, then the page denominator.
991fn build_query(
992    content: AnalysisSet,
993    views: ViewList,
994    spec: &ReadSpec<'_>,
995    now: SystemTime,
996    axes: &'static AxisNames,
997) -> Result<Query, RequestError> {
998    let (views, omitted_views) = views.resolve(content);
999
1000    let mut selection = Selection {
1001        depth: spec.depth.map(|value| parse_bound(value, axes.depth)).transpose()?,
1002        min_share: spec
1003            .min_share
1004            .map(|value| {
1005                ShareThreshold::parse(value).ok_or_else(|| {
1006                    invalid(axes.min_share, value, "expected a percentage from 0% through 100%")
1007                })
1008            })
1009            .transpose()?,
1010        breadth: spec.breadth.map(|value| parse_bound(value, axes.breadth)).transpose()?,
1011        limit: spec.limit.map(|value| parse_bound(value, axes.limit)).transpose()?,
1012        reverse: spec.reverse,
1013        size: spec
1014            .size
1015            .map_or(Ok(Request::DEFAULTS.size), |value| parse_size_metric(value, axes.size))?,
1016        ..Selection::default()
1017    };
1018    for pattern in spec.include {
1019        selection.include.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
1020    }
1021    for pattern in spec.exclude {
1022        selection.exclude.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
1023    }
1024    if let Some(value) = spec.min_size {
1025        selection.min_size = Some(parse_size(value).map_err(grammar_refusal)?);
1026    }
1027    if let Some(value) = spec.modified_since {
1028        let when = parse_when(value, now).map_err(grammar_refusal)?;
1029        selection.modified.since = Some(bound_nanos(value, when, axes.modified_since)?);
1030    }
1031    if let Some(value) = spec.modified_before {
1032        let when = parse_when(value, now).map_err(grammar_refusal)?;
1033        selection.modified.before = Some(bound_nanos(value, when, axes.modified_before)?);
1034    }
1035    if let Some(value) = spec.kinds {
1036        selection.kinds = parse_kinds(value, axes.kind)?;
1037    }
1038    if let Some(value) = spec.sort {
1039        selection.sort = Some(parse_sort(value, axes.sort)?);
1040    }
1041    selection.ignored = parse_population(spec.ignored, axes)?;
1042    let words_per_page =
1043        spec.words_per_page.map_or(Ok(Request::DEFAULTS.words_per_page), |value| {
1044            value
1045                .trim()
1046                .parse::<u64>()
1047                .ok()
1048                .filter(|words| *words > 0)
1049                .ok_or_else(|| invalid(axes.words_per_page, value, "expected a positive integer"))
1050        })?;
1051
1052    let format = spec.format.map_or(Ok(crate::report_format::Format::Text), |value| {
1053        crate::report_format::Format::parse(value).ok_or_else(|| {
1054            invalid(
1055                axes.format,
1056                value,
1057                format!("expected one of {}", crate::report_format::Format::ALL.join(", ")),
1058            )
1059        })
1060    })?;
1061    Ok(Query { selection, views, format, omitted_views, axes, words_per_page })
1062}
1063
1064/// A scan-scope axis a build may be unable to honour at all.
1065///
1066/// Not every axis a scan config carries: only the two whose support is a property of the
1067/// build rather than of the tree, so asking for one is a request no delivery can carry out
1068/// and no stored state can rescue. Which of them this build refuses is stated once, by
1069/// `ScanConfig::unsupported_axis`, and asked there by [`Request::validate`] for every route
1070/// and by the scan config's own validation for the engine-internal callers that never build
1071/// a request.
1072#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1073pub enum ScopeAxis {
1074    /// Walking into what a symbolic link points at.
1075    FollowSymlinks,
1076    /// Keeping the walk on the root's own filesystem.
1077    OneFilesystem,
1078}
1079
1080impl ScopeAxis {
1081    /// Why the axis has no supported semantics, in the library's field names.
1082    ///
1083    /// One sentence per axis, and the only one: the engine-internal callers that report
1084    /// [`Error::UnsupportedScanConfig`](crate::Error::UnsupportedScanConfig) print it
1085    /// verbatim, and [`RequestError::message`] prints it with the axis renamed, so no
1086    /// surface can drift from the rule by rewording its own copy.
1087    pub const fn reason(self) -> &'static str {
1088        match self {
1089            Self::FollowSymlinks => {
1090                "follow_symlinks requires cycle, root-boundary, and filesystem-boundary semantics"
1091            }
1092            Self::OneFilesystem => "one_filesystem requires platform device identity",
1093        }
1094    }
1095
1096    /// How `axes` names the axis.
1097    const fn named(self, axes: &AxisNames) -> &'static str {
1098        match self {
1099            Self::FollowSymlinks => axes.follow_symlinks,
1100            Self::OneFilesystem => axes.one_filesystem,
1101        }
1102    }
1103}
1104
1105/// Why a request cannot be answered as asked.
1106///
1107/// Typed so a caller can match the refusal it can act on, and rendered by
1108/// [`Self::message`] in the vocabulary of the surface the request came through.
1109///
1110/// Non-exhaustive, so a later refusal is an additive change: a match outside this crate
1111/// names the refusals it acts on and renders the rest with [`Self::message`].
1112/// [`Self::AnalyzerNamedAsView`], [`Self::ViewNeedsAnalyzer`], [`Self::SortNeedsAnalyzer`],
1113/// and [`Self::WatchContent`] are non-exhaustive too, so a field one of them gains later
1114/// is additive as well: a match outside this crate reads them with `..`.
1115#[derive(Clone, Debug, PartialEq, Eq)]
1116#[non_exhaustive]
1117pub enum RequestError {
1118    /// A value did not match its axis's grammar.
1119    InvalidValue {
1120        /// The axis, as the requesting surface names it.
1121        axis: &'static str,
1122        /// The rejected value, as the grammar quotes it.
1123        value: String,
1124        /// What the grammar accepts instead.
1125        expected: String,
1126    },
1127    /// An analyzer name was supplied where a report view was expected.
1128    #[non_exhaustive]
1129    AnalyzerNamedAsView {
1130        /// The rejected token in the caller's spelling.
1131        value: String,
1132        /// The analyzer the token names.
1133        analyzer: &'static str,
1134        /// The view that shows that analyzer's results.
1135        suggested_view: ViewSpec,
1136    },
1137    /// A view shows analysis the basis does not hold.
1138    ///
1139    /// Only a basis the request did not build can raise this -- a retained index, an opened
1140    /// root, or one supplied whole -- because [`Request::build`] enables what its views
1141    /// imply, and a read never widens what a holder was opened with.
1142    #[non_exhaustive]
1143    ViewNeedsAnalyzer {
1144        /// The view the read named.
1145        view: ViewSpec,
1146        /// The analyzers the basis holds.
1147        held: AnalysisSet,
1148        /// What fixed those analyzers, which decides the remedy the refusal names.
1149        holder: BasisHolder,
1150    },
1151    /// A sort by a content metric whose analyzer the request did not enable.
1152    ///
1153    /// Unlike a content view, a sort implies nothing: it orders rows and never says what a
1154    /// report is about, so the analyzer must be named.
1155    #[non_exhaustive]
1156    SortNeedsAnalyzer {
1157        /// The metric, as the sort axis spells it.
1158        metric: &'static str,
1159        /// The analyzers that measure it.
1160        analyzer: AnalysisSet,
1161        /// The analyzers the basis holds.
1162        held: AnalysisSet,
1163        /// What fixed those analyzers, which decides the remedy the refusal names.
1164        holder: BasisHolder,
1165    },
1166    /// A selection by ignored state over a scan that observes no `.gitignore`.
1167    IgnoredWithoutObservation(IgnoredEntries),
1168    /// A retained narrow population cannot answer a read of another population.
1169    PopulationMismatch {
1170        /// Population retained by the holder.
1171        held: IgnoredEntries,
1172        /// Population this read selected.
1173        requested: IgnoredEntries,
1174    },
1175    /// A scan scope this build cannot honour, whatever the delivery.
1176    ScopeUnsupported {
1177        /// Which axis, so each surface names it in its own words.
1178        axis: ScopeAxis,
1179        /// The rule, one sentence, in the library's field names.
1180        reason: &'static str,
1181    },
1182    /// A read asks for another analyzer set than the retained index holds.
1183    ContentMismatch {
1184        /// The analyzers the index was built with.
1185        held: AnalysisSet,
1186        /// The analyzers the read asks for.
1187        requested: AnalysisSet,
1188    },
1189    /// A watch was asked to narrow its scan scope.
1190    WatchScope,
1191    /// A watch was asked to keep content analysis current.
1192    ///
1193    /// Names every axis that enabled analysis, so a caller who drops one is not refused
1194    /// again for the other.
1195    #[non_exhaustive]
1196    WatchContent {
1197        /// The analyzers the caller named, or that a basis supplied whole holds; `none`
1198        /// when every analyzer was implied by a view.
1199        named: AnalysisSet,
1200        /// The views that implied analysis, in the caller's order; empty when none did.
1201        views: Vec<ViewSpec>,
1202    },
1203    /// A watch was asked to start from a snapshot nothing verifies.
1204    WatchCacheOnly,
1205    /// A stale answer was asked of a delivery that never reads the snapshot.
1206    StaleOkCacheOff,
1207    /// An operation names another root than the retained index it would mutate.
1208    RootMismatch {
1209        /// Root held by the index.
1210        held: PathBuf,
1211        /// Root requested by the caller.
1212        requested: PathBuf,
1213    },
1214    /// A route cannot honor the requested execution policy.
1215    DeliveryUnsupported {
1216        /// The lifecycle that refuses it.
1217        route: &'static str,
1218        /// The unsupported policy combination.
1219        reason: &'static str,
1220    },
1221    /// A read names more views than one report may carry.
1222    ViewLimit {
1223        /// Views and omitted views the request carries.
1224        attempted: usize,
1225        /// The most one report accepts.
1226        limit: usize,
1227    },
1228}
1229
1230impl RequestError {
1231    /// The refusal in the vocabulary of the surface `axes` describes.
1232    ///
1233    /// [`Self::InvalidValue`] already carries its axis, named by the surface that parsed
1234    /// it, so `axes` names only the knobs the other refusals point at.
1235    pub fn message(&self, axes: &AxisNames) -> String {
1236        match self {
1237            Self::InvalidValue { axis, value, expected } => invalid_message(axis, value, expected),
1238            // A view that implies its analyzer is the whole answer; one that does not needs
1239            // the analyzer named beside it.
1240            Self::AnalyzerNamedAsView { value, analyzer, suggested_view } => {
1241                let shown = if suggested_view.implies().is_enabled() {
1242                    String::new()
1243                } else {
1244                    format!(", with {} {analyzer}", axes.analyze)
1245                };
1246                format!(
1247                    "invalid {} {value:?}: {analyzer} is an analyzer; its view is {}{shown}",
1248                    axes.view,
1249                    suggested_view.label()
1250                )
1251            }
1252            Self::ViewNeedsAnalyzer { view, held, holder } => format!(
1253                "{} {} needs {} analysis{}",
1254                axes.view,
1255                view.label(),
1256                view.implies().named().join(" and "),
1257                holder.remedy(view.implies(), *held, axes)
1258            ),
1259            Self::SortNeedsAnalyzer { metric, analyzer, held, holder } => format!(
1260                "{} {metric} needs {} analysis{}",
1261                axes.sort,
1262                analyzer.named().join(" and "),
1263                holder.remedy(*analyzer, *held, axes)
1264            ),
1265            Self::IgnoredWithoutObservation(ignored) => format!(
1266                "{} needs .gitignore classification, and {} turned it off; drop one of them",
1267                match ignored {
1268                    IgnoredEntries::Exclude => axes.exclude_ignored,
1269                    IgnoredEntries::Only => axes.only_ignored,
1270                    IgnoredEntries::Include => axes.ignored,
1271                },
1272                axes.read_controls
1273            ),
1274            Self::PopulationMismatch { held, requested } => format!(
1275                "{}={} cannot be read from retained {}={} scope",
1276                axes.ignored,
1277                requested.label(),
1278                axes.ignored,
1279                held.label()
1280            ),
1281            // The sentence the engine has always printed, with only the axis renamed: a
1282            // Python caller reads the words they wrote, and the command line names its
1283            // flag. The kind stays in front of it, so this refusal is the same sentence
1284            // whichever door raised it.
1285            Self::ScopeUnsupported { axis, reason } => format!(
1286                "unsupported scan configuration: {}",
1287                reason.replacen(axis.named(&AxisNames::FIELDS), axis.named(axes), 1)
1288            ),
1289            Self::ContentMismatch { held, requested } => format!(
1290                "{analyze} {requested} cannot be answered by an index built with {analyze} \
1291                 {held}; open the root again with {analyze} {requested}",
1292                analyze = axes.analyze,
1293                requested = analysis_label(*requested),
1294                held = analysis_label(*held),
1295            ),
1296            Self::WatchScope => watch_scope_message(axes),
1297            Self::RootMismatch { held, requested } => {
1298                format!(
1299                    "requested root {} does not match retained root {}",
1300                    requested.display(),
1301                    held.display()
1302                )
1303            }
1304            Self::DeliveryUnsupported { route, reason } => format!("{route}: {reason}"),
1305            Self::WatchContent { views, .. } if views.is_empty() => format!(
1306                "{} is not yet supported with {}; use a one-shot report",
1307                axes.analyze, axes.watch
1308            ),
1309            Self::WatchContent { named, views } => watch_content_message(*named, views, axes),
1310            Self::WatchCacheOnly => format!(
1311                "{watch} cannot start from a {stale_ok} answer: nothing verifies what changed \
1312                 between the snapshot and the start of the watch; drop {stale_ok}",
1313                watch = axes.watch,
1314                stale_ok = axes.stale_ok,
1315            ),
1316            Self::StaleOkCacheOff => format!(
1317                "{stale_ok} answers from the snapshot, which {cache} off never reads; drop one \
1318                 of them",
1319                stale_ok = axes.stale_ok,
1320                cache = axes.cache,
1321            ),
1322            Self::ViewLimit { attempted, limit } => {
1323                format!("report request contains {attempted} views or omissions; limit is {limit}")
1324            }
1325        }
1326    }
1327}
1328
1329/// The library's vocabulary, as [`AxisNames::default`] is: a refusal rendered without
1330/// naming a surface belongs to a library caller, not to the command line.
1331impl fmt::Display for RequestError {
1332    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1333        formatter.write_str(&self.message(&AxisNames::FIELDS))
1334    }
1335}
1336
1337impl std::error::Error for RequestError {}
1338
1339/// A watch refused for analysis some view implied, naming each axis that enabled it.
1340///
1341/// The analyzers are listed in the order the caller wrote them: the named ones first, then
1342/// each view's in the views' order, so `--view documents,code` reads as documents needing
1343/// words and code needing code rather than pairing documents with code.
1344fn watch_content_message(named: AnalysisSet, views: &[ViewSpec], axes: &AxisNames) -> String {
1345    let mut analyzers = named.named();
1346    for name in views.iter().flat_map(|view| view.implies().named()) {
1347        if !analyzers.contains(&name) {
1348            analyzers.push(name);
1349        }
1350    }
1351    let views = views.iter().map(|view| view.label()).collect::<Vec<_>>().join(",");
1352    let subject = if named.is_enabled() {
1353        format!("{} {} and {} {views} need", axes.analyze, named.request_label(), axes.view)
1354    } else {
1355        format!("{} {views} needs", axes.view)
1356    };
1357    format!(
1358        "{subject} {} analysis, which {} cannot keep current; use a one-shot report",
1359        analyzers.join(" and "),
1360        axes.watch
1361    )
1362}
1363
1364/// An analyzer set as its axis spells it back: `none`, or the analyzers joined by commas.
1365fn analysis_label(set: AnalysisSet) -> String {
1366    if set.is_enabled() { set.labels().join(",") } else { AnalysisSet::NONE_LABEL.to_string() }
1367}
1368
1369/// The watch-scope rule, with each knob named as `axes` names it.
1370///
1371/// One pass over whole words of [`crate::scan::WATCH_SCOPE_GUIDANCE`], which is written
1372/// in the library's field names, never re-scanning a replacement. A sequential replace
1373/// does re-scan: `max_depth` becomes `--scan-depth`, and then `depth` matches inside it,
1374/// giving `--scan---depth` (fdu-7j6z). The command line carried this substitution
1375/// itself until the rule moved here.
1376fn watch_scope_message(axes: &AxisNames) -> String {
1377    let fields = &AxisNames::FIELDS;
1378    let vocabulary = [
1379        (fields.scan_depth, axes.scan_depth),
1380        (fields.one_filesystem, axes.one_filesystem),
1381        (fields.modified_since, axes.modified_since),
1382        (fields.depth, axes.depth),
1383        (fields.include, axes.include),
1384    ];
1385    let is_word = |character: char| character.is_ascii_alphanumeric() || character == '_';
1386    crate::scan::WATCH_SCOPE_GUIDANCE
1387        .split_inclusive(|character: char| !is_word(character))
1388        .map(|piece| {
1389            let end = piece.find(|character: char| !is_word(character)).unwrap_or(piece.len());
1390            let (word, tail) = piece.split_at(end);
1391            match vocabulary.iter().find(|(field, _)| *field == word) {
1392                Some((_, name)) => format!("{name}{tail}"),
1393                None => piece.to_string(),
1394            }
1395        })
1396        .collect()
1397}
1398
1399/// Refuse a view the basis's analyzers cannot answer.
1400///
1401/// One containment test, against the table [`ViewSpec::implies`] states, so which views
1402/// need content is decided once, where a new view must decide it. A request that built its
1403/// own basis enabled what its views imply and always passes; only a basis it did not build,
1404/// which a read never widens, can fail it, and `holder` words the refusal for that route.
1405fn check_views(
1406    views: &[ViewSpec],
1407    content: AnalysisSet,
1408    holder: BasisHolder,
1409) -> Result<(), RequestError> {
1410    match views.iter().find(|view| !content.contains(view.implies())) {
1411        Some(view) => Err(RequestError::ViewNeedsAnalyzer { view: *view, held: content, holder }),
1412        None => Ok(()),
1413    }
1414}
1415
1416/// Refuse a selection by ignored state when the scan observes no control state.
1417pub(crate) fn check_observation(
1418    ignored: IgnoredEntries,
1419    observes_controls: bool,
1420) -> Result<(), RequestError> {
1421    match ignored {
1422        IgnoredEntries::Include => Ok(()),
1423        IgnoredEntries::Exclude | IgnoredEntries::Only if observes_controls => Ok(()),
1424        IgnoredEntries::Exclude | IgnoredEntries::Only => {
1425            Err(RequestError::IgnoredWithoutObservation(ignored))
1426        }
1427    }
1428}
1429
1430/// A value a grammar refused, before any surface has named the axis.
1431///
1432/// The list grammars that predate this model ([`ViewSpec::resolve`] and
1433/// [`AnalysisSet::parse_labeled`]) take a free-form label and return a sentence; they
1434/// produce this instead, so the request model can put a typed axis on it and they can go on
1435/// rendering the identical sentence.
1436#[derive(Clone, Debug, PartialEq, Eq)]
1437pub(crate) struct Rejection {
1438    value: String,
1439    expected: String,
1440}
1441
1442impl Rejection {
1443    pub(crate) fn new(value: impl Into<String>, expected: impl Into<String>) -> Self {
1444        Self { value: value.into(), expected: expected.into() }
1445    }
1446
1447    pub(crate) fn value(&self) -> &str {
1448        &self.value
1449    }
1450
1451    /// The refusal, on the axis a surface named.
1452    pub(crate) fn on(self, axis: &'static str) -> RequestError {
1453        RequestError::InvalidValue { axis, value: self.value, expected: self.expected }
1454    }
1455
1456    /// The refusal's sentence, for a caller holding only a label.
1457    pub(crate) fn labeled(&self, label: &str) -> String {
1458        invalid_message(label, &self.value, &self.expected)
1459    }
1460}
1461
1462fn invalid_message(axis: &str, value: &str, expected: &str) -> String {
1463    format!("invalid {axis} {value:?}: {expected}")
1464}
1465
1466fn invalid(
1467    axis: &'static str,
1468    value: impl Into<String>,
1469    expected: impl Into<String>,
1470) -> RequestError {
1471    Rejection::new(value, expected).on(axis)
1472}
1473
1474/// An engine value-grammar error, which already names its grammar: `invalid size "10X"`
1475/// and `invalid pattern` read the same on every surface.
1476fn grammar_refusal(error: crate::Error) -> RequestError {
1477    match error {
1478        crate::Error::InvalidValue { kind, value, hint } => invalid(kind, value, hint),
1479        other => invalid("value", String::new(), other.to_string()),
1480    }
1481}
1482
1483/// An engine value-grammar error on a knob each surface names, as the `.gitignore` limits
1484/// are: `--gitignore-budget` and `control_budget`, never `control budget`.
1485fn named_refusal(error: crate::Error, axis: &'static str) -> RequestError {
1486    match error {
1487        crate::Error::InvalidValue { value, hint, .. } => invalid(axis, value, hint),
1488        other => invalid(axis, String::new(), other.to_string()),
1489    }
1490}
1491
1492/// Parse one entry kind: `file`, `dir`, `symlink`, or `other`.
1493///
1494/// `axis` names the knob as the calling surface spells it: `--kind` or `kind`.
1495pub fn parse_kind(value: &str, axis: &'static str) -> Result<EntryKind, RequestError> {
1496    match value.trim().to_ascii_lowercase().as_str() {
1497        "file" => Ok(EntryKind::File),
1498        "dir" => Ok(EntryKind::Dir),
1499        "symlink" => Ok(EntryKind::Symlink),
1500        "other" => Ok(EntryKind::Other),
1501        other => Err(invalid(axis, other, "expected one of file, dir, symlink, other")),
1502    }
1503}
1504
1505/// Parse a comma-separated list of entry kinds.
1506///
1507/// Closed vocabularies are comma lists and open pattern values are repeatable, because
1508/// glob brace syntax (`*.{rs,toml}`) contains commas and would be shredded by a split.
1509/// An empty entry and a repeated kind are errors rather than silent no-ops, as they are
1510/// for views: repeating a value is far more likely to be a typo than an intention.
1511pub fn parse_kinds(list: &str, axis: &'static str) -> Result<Vec<EntryKind>, RequestError> {
1512    let mut kinds = Vec::new();
1513    for token in list.split(',') {
1514        let token = token.trim();
1515        if token.is_empty() {
1516            return Err(invalid(axis, list, "empty entry in the list"));
1517        }
1518        let kind = parse_kind(token, axis)?;
1519        if kinds.contains(&kind) {
1520            return Err(invalid(axis, list, format!("{token:?} appears more than once")));
1521        }
1522        kinds.push(kind);
1523    }
1524    Ok(kinds)
1525}
1526
1527/// Parse a bound that accepts `all` for unbounded, as `--depth` and `--limit` are written.
1528pub fn parse_bound(value: &str, axis: &'static str) -> Result<Bound, RequestError> {
1529    let value = value.trim();
1530    if value.eq_ignore_ascii_case("all") {
1531        return Ok(Bound::All);
1532    }
1533    value
1534        .parse::<usize>()
1535        .map(Bound::Limit)
1536        .map_err(|_| invalid(axis, value, "expected a whole number or `all`"))
1537}
1538
1539/// Parse a metadata ordering key or a registered numeric content metric.
1540pub fn parse_sort(value: &str, axis: &'static str) -> Result<SortKey, RequestError> {
1541    match value.trim().to_ascii_lowercase().as_str() {
1542        "size" => Ok(SortKey::Size),
1543        "count" => Ok(SortKey::Count),
1544        "mtime" => Ok(SortKey::Mtime),
1545        "name" => Ok(SortKey::Name),
1546        other => crate::content::METRICS.iter().find(|metric| metric.name == other).map_or_else(
1547            || {
1548                Err(invalid(
1549                    axis,
1550                    other,
1551                    "expected size, count, mtime, name, or a registered numeric metric",
1552                ))
1553            },
1554            |metric| Ok(SortKey::Metric(metric.name)),
1555        ),
1556    }
1557}
1558
1559/// Parse a size metric: `allocated` or `apparent`.
1560pub fn parse_size_metric(value: &str, axis: &'static str) -> Result<SizeMetric, RequestError> {
1561    match value.trim().to_ascii_lowercase().as_str() {
1562        "allocated" => Ok(SizeMetric::Allocated),
1563        "apparent" => Ok(SizeMetric::Apparent),
1564        other => Err(invalid(axis, other, "expected allocated or apparent")),
1565    }
1566}
1567
1568/// Convert a parsed time bound to index nanoseconds, or refuse the value.
1569///
1570/// [`system_time_to_nanos`] returns `None` for an instant outside the range the index can
1571/// represent (roughly 1677-2262). Storing that `None` would leave the bound unset, so the
1572/// query would run with no time filter at all while the caller believed one was active --
1573/// a silently wrong answer, which is worse than a refused value.
1574pub fn bound_nanos(value: &str, when: SystemTime, axis: &'static str) -> Result<i64, RequestError> {
1575    system_time_to_nanos(when).ok_or_else(|| {
1576        invalid(
1577            axis,
1578            value,
1579            "that time is outside the range fdu can represent (about 1677 to 2262)",
1580        )
1581    })
1582}
1583
1584/// Parse a cache policy: `auto`, `on`, or `off`.
1585///
1586/// The three values that earlier releases also accepted are refused with the replacement,
1587/// since each still appears in scripts and a bare list of values would not say where
1588/// `only` went.
1589pub fn parse_cache_policy(value: &str, axis: &'static str) -> Result<CachePolicy, RequestError> {
1590    let stale_ok = if axis == AxisNames::FLAGS.cache {
1591        AxisNames::FLAGS.stale_ok
1592    } else {
1593        AxisNames::FIELDS.stale_ok
1594    };
1595    match value.trim().to_ascii_lowercase().as_str() {
1596        "auto" => Ok(CachePolicy::Auto),
1597        "on" => Ok(CachePolicy::On),
1598        "off" => Ok(CachePolicy::Off),
1599        "only" => Err(invalid(
1600            axis,
1601            "only",
1602            format!("answering from the snapshot alone is now {stale_ok}"),
1603        )),
1604        "refresh" => Err(invalid(
1605            axis,
1606            "refresh",
1607            "removed; use on, which also writes after a one-shot report",
1608        )),
1609        "read-only" => Err(invalid(
1610            axis,
1611            "read-only",
1612            "removed; auto no longer writes after a one-shot metadata report, and off reads nothing",
1613        )),
1614        other => Err(invalid(axis, other, "expected one of auto, on, off")),
1615    }
1616}
1617
1618#[cfg(test)]
1619mod tests {
1620    use super::*;
1621
1622    #[test]
1623    fn each_grammar_names_its_axis_as_the_surface_spells_it() {
1624        let flags = &AxisNames::FLAGS;
1625        let fields = &AxisNames::FIELDS;
1626        let cases: [(&str, RequestError, RequestError); 6] = [
1627            (
1628                "kind",
1629                parse_kind(" Socket ", flags.kind).expect_err("unknown kind"),
1630                parse_kind(" Socket ", fields.kind).expect_err("unknown kind"),
1631            ),
1632            (
1633                "bound",
1634                parse_bound("two", flags.depth).expect_err("not a number"),
1635                parse_bound("two", fields.depth).expect_err("not a number"),
1636            ),
1637            (
1638                "sort",
1639                parse_sort("Newest", flags.sort).expect_err("unknown key"),
1640                parse_sort("Newest", fields.sort).expect_err("unknown key"),
1641            ),
1642            (
1643                "size",
1644                parse_size_metric("logical", flags.size).expect_err("unknown metric"),
1645                parse_size_metric("logical", fields.size).expect_err("unknown metric"),
1646            ),
1647            (
1648                "time",
1649                bound_nanos("2300-01-01T00:00:00Z", far_future(), flags.modified_since)
1650                    .expect_err("unrepresentable"),
1651                bound_nanos("2300-01-01T00:00:00Z", far_future(), fields.modified_since)
1652                    .expect_err("unrepresentable"),
1653            ),
1654            (
1655                "cache",
1656                parse_cache_policy("readonly", flags.cache).expect_err("unreleased alias"),
1657                parse_cache_policy("readonly", fields.cache).expect_err("unreleased alias"),
1658            ),
1659        ];
1660        let expected = [
1661            (
1662                "invalid --kind \"socket\": expected one of file, dir, symlink, other",
1663                "invalid kind \"socket\": expected one of file, dir, symlink, other",
1664            ),
1665            (
1666                "invalid --depth \"two\": expected a whole number or `all`",
1667                "invalid depth \"two\": expected a whole number or `all`",
1668            ),
1669            (
1670                "invalid --sort \"newest\": expected size, count, mtime, name, or a registered numeric metric",
1671                "invalid sort \"newest\": expected size, count, mtime, name, or a registered numeric metric",
1672            ),
1673            (
1674                "invalid --size \"logical\": expected allocated or apparent",
1675                "invalid size \"logical\": expected allocated or apparent",
1676            ),
1677            (
1678                "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
1679                 range fdu can represent (about 1677 to 2262)",
1680                "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
1681                 fdu can represent (about 1677 to 2262)",
1682            ),
1683            (
1684                "invalid --cache \"readonly\": expected one of auto, on, off",
1685                "invalid cache policy \"readonly\": expected one of auto, on, off",
1686            ),
1687        ];
1688        for ((grammar, flag, field), (flag_text, field_text)) in cases.into_iter().zip(expected) {
1689            assert_eq!(flag.message(&AxisNames::FLAGS), flag_text, "{grammar}");
1690            assert_eq!(field.message(&AxisNames::FIELDS), field_text, "{grammar}");
1691            // The axis travels with the value, so rendering never re-names it.
1692            assert_eq!(flag.message(&AxisNames::FIELDS), flag_text, "{grammar}");
1693        }
1694    }
1695
1696    fn far_future() -> SystemTime {
1697        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10_000_000_000)
1698    }
1699
1700    #[test]
1701    fn the_grammars_accept_their_whole_vocabulary() {
1702        let axis = AxisNames::FIELDS.kind;
1703        for (spelling, kind) in [
1704            ("file", EntryKind::File),
1705            ("DIR", EntryKind::Dir),
1706            (" symlink", EntryKind::Symlink),
1707            ("other", EntryKind::Other),
1708        ] {
1709            assert_eq!(parse_kind(spelling, axis), Ok(kind));
1710        }
1711        assert_eq!(parse_kinds("file, dir", axis), Ok(vec![EntryKind::File, EntryKind::Dir]));
1712        assert_eq!(parse_bound(" ALL ", axis), Ok(Bound::All));
1713        assert_eq!(parse_bound("0", axis), Ok(Bound::Limit(0)));
1714        for (spelling, key) in [
1715            ("size", SortKey::Size),
1716            ("count", SortKey::Count),
1717            ("MTIME", SortKey::Mtime),
1718            ("name", SortKey::Name),
1719            ("CODE_LINES", SortKey::Metric("code_lines")),
1720        ] {
1721            assert_eq!(parse_sort(spelling, axis), Ok(key));
1722        }
1723        assert_eq!(parse_size_metric("Allocated", axis), Ok(SizeMetric::Allocated));
1724        assert_eq!(parse_size_metric("apparent", axis), Ok(SizeMetric::Apparent));
1725        for (spelling, policy) in
1726            [("auto", CachePolicy::Auto), ("ON", CachePolicy::On), ("off", CachePolicy::Off)]
1727        {
1728            assert_eq!(parse_cache_policy(spelling, axis), Ok(policy));
1729        }
1730        // The retired values name their replacement, in each surface's own words.
1731        for (spelling, flags, fields) in [
1732            (
1733                "only",
1734                "invalid --cache \"only\": answering from the snapshot alone is now --stale-ok",
1735                "invalid cache policy \"only\": answering from the snapshot alone is now stale_ok",
1736            ),
1737            (
1738                "refresh",
1739                "invalid --cache \"refresh\": removed; use on, which also writes after a one-shot \
1740                 report",
1741                "invalid cache policy \"refresh\": removed; use on, which also writes after a \
1742                 one-shot report",
1743            ),
1744            (
1745                "read-only",
1746                "invalid --cache \"read-only\": removed; auto no longer writes after a one-shot \
1747                 metadata report, and off reads nothing",
1748                "invalid cache policy \"read-only\": removed; auto no longer writes after a \
1749                 one-shot metadata report, and off reads nothing",
1750            ),
1751        ] {
1752            let message = |axis| {
1753                parse_cache_policy(spelling, axis).expect_err("retired").message(&AxisNames::FLAGS)
1754            };
1755            assert_eq!(message(AxisNames::FLAGS.cache), flags);
1756            assert_eq!(message(AxisNames::FIELDS.cache), fields);
1757        }
1758        let epoch = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(2);
1759        assert_eq!(bound_nanos("@2", epoch, axis), Ok(2_000_000_000));
1760    }
1761
1762    #[test]
1763    fn a_kind_list_refuses_empty_and_repeated_entries() {
1764        let axis = AxisNames::FLAGS.kind;
1765        assert_eq!(
1766            parse_kinds("file,,dir", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1767            Err("invalid --kind \"file,,dir\": empty entry in the list".to_string())
1768        );
1769        assert_eq!(
1770            parse_kinds("file, FILE", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1771            Err("invalid --kind \"file, FILE\": \"FILE\" appears more than once".to_string())
1772        );
1773    }
1774
1775    /// Every refusal, in both vocabularies, quoted whole: the wording is the contract the
1776    /// goldens and the parity harness hold each surface to.
1777    #[test]
1778    fn every_refusal_renders_in_flag_and_field_wording() {
1779        let cases = [
1780            (
1781                RequestError::ViewNeedsAnalyzer {
1782                    view: ViewSpec::Code,
1783                    held: AnalysisSet::NONE,
1784                    holder: BasisHolder::Index,
1785                },
1786                "--view code needs code analysis; this index was opened with --analyze none",
1787                "view code needs code analysis; this index was opened with analyze none",
1788            ),
1789            (
1790                RequestError::ViewNeedsAnalyzer {
1791                    view: ViewSpec::Documents,
1792                    held: AnalysisSet::CODE_ONLY,
1793                    holder: BasisHolder::Index,
1794                },
1795                "--view documents needs words analysis; this index was opened with --analyze code",
1796                "view documents needs words analysis; this index was opened with analyze code",
1797            ),
1798            // Each holder names the remedy its route accepts: an opened root has no analyzer
1799            // option, and a basis supplied whole was opened by nothing.
1800            (
1801                RequestError::ViewNeedsAnalyzer {
1802                    view: ViewSpec::Documents,
1803                    held: AnalysisSet::NONE,
1804                    holder: BasisHolder::OpenedRoot,
1805                },
1806                "--view documents needs words analysis, which an opened root never runs; use a \
1807                 one-shot report, or an index opened with --analyze words",
1808                "view documents needs words analysis, which an opened root never runs; use a \
1809                 one-shot report, or an index opened with analyze words",
1810            ),
1811            (
1812                RequestError::ViewNeedsAnalyzer {
1813                    view: ViewSpec::Code,
1814                    held: AnalysisSet::WORDS_ONLY,
1815                    holder: BasisHolder::Supplied,
1816                },
1817                "--view code needs code analysis; its basis holds --analyze words",
1818                "view code needs code analysis; its basis holds analyze words",
1819            ),
1820            (
1821                RequestError::SortNeedsAnalyzer {
1822                    metric: "code_lines",
1823                    analyzer: AnalysisSet::CODE_ONLY,
1824                    held: AnalysisSet::NONE,
1825                    holder: BasisHolder::Built,
1826                },
1827                "--sort code_lines needs code analysis: add --analyze code",
1828                "sort code_lines needs code analysis: add analyze code",
1829            ),
1830            (
1831                RequestError::SortNeedsAnalyzer {
1832                    metric: "code_lines",
1833                    analyzer: AnalysisSet::CODE_ONLY,
1834                    held: AnalysisSet::LINES_ONLY,
1835                    holder: BasisHolder::Index,
1836                },
1837                "--sort code_lines needs code analysis; this index was opened with --analyze \
1838                 lines",
1839                "sort code_lines needs code analysis; this index was opened with analyze lines",
1840            ),
1841            (
1842                RequestError::SortNeedsAnalyzer {
1843                    metric: "document_words",
1844                    analyzer: AnalysisSet::WORDS_ONLY,
1845                    held: AnalysisSet::NONE,
1846                    holder: BasisHolder::OpenedRoot,
1847                },
1848                "--sort document_words needs words analysis, which an opened root never runs; \
1849                 use a one-shot report, or an index opened with --analyze words",
1850                "sort document_words needs words analysis, which an opened root never runs; use \
1851                 a one-shot report, or an index opened with analyze words",
1852            ),
1853            (
1854                RequestError::SortNeedsAnalyzer {
1855                    metric: "code_lines",
1856                    analyzer: AnalysisSet::CODE_ONLY,
1857                    held: AnalysisSet::NONE,
1858                    holder: BasisHolder::Supplied,
1859                },
1860                "--sort code_lines needs code analysis; its basis holds --analyze none",
1861                "sort code_lines needs code analysis; its basis holds analyze none",
1862            ),
1863            (
1864                RequestError::AnalyzerNamedAsView {
1865                    value: "words".to_owned(),
1866                    analyzer: "words",
1867                    suggested_view: ViewSpec::Documents,
1868                },
1869                "invalid --view \"words\": words is an analyzer; its view is documents",
1870                "invalid view \"words\": words is an analyzer; its view is documents",
1871            ),
1872            (
1873                RequestError::AnalyzerNamedAsView {
1874                    value: "lines".to_owned(),
1875                    analyzer: "lines",
1876                    suggested_view: ViewSpec::Families,
1877                },
1878                "invalid --view \"lines\": lines is an analyzer; its view is families, with \
1879                 --analyze lines",
1880                "invalid view \"lines\": lines is an analyzer; its view is families, with \
1881                 analyze lines",
1882            ),
1883            (
1884                RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude),
1885                "--ignored=exclude needs .gitignore classification, and --no-gitignore turned it \
1886                 off; drop one of them",
1887                "ignored=exclude needs .gitignore classification, and read_controls turned it \
1888                 off; drop one of them",
1889            ),
1890            (
1891                RequestError::IgnoredWithoutObservation(IgnoredEntries::Only),
1892                "--ignored=only needs .gitignore classification, and --no-gitignore turned it \
1893                 off; drop one of them",
1894                "ignored=only needs .gitignore classification, and read_controls turned it off; \
1895                 drop one of them",
1896            ),
1897            (
1898                RequestError::ContentMismatch {
1899                    held: AnalysisSet::NONE,
1900                    requested: AnalysisSet::NONE.with_code(),
1901                },
1902                "--analyze lines,code cannot be answered by an index built with --analyze none; \
1903                 open the root again with --analyze lines,code",
1904                "analyze lines,code cannot be answered by an index built with analyze none; open \
1905                 the root again with analyze lines,code",
1906            ),
1907            (
1908                RequestError::ScopeUnsupported {
1909                    axis: ScopeAxis::OneFilesystem,
1910                    reason: ScopeAxis::OneFilesystem.reason(),
1911                },
1912                "unsupported scan configuration: --one-filesystem requires platform device \
1913                 identity",
1914                "unsupported scan configuration: one_filesystem requires platform device identity",
1915            ),
1916            (
1917                RequestError::ScopeUnsupported {
1918                    axis: ScopeAxis::FollowSymlinks,
1919                    reason: ScopeAxis::FollowSymlinks.reason(),
1920                },
1921                "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1922                 and filesystem-boundary semantics",
1923                "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1924                 and filesystem-boundary semantics",
1925            ),
1926            (
1927                RequestError::WatchContent { named: AnalysisSet::LINES_ONLY, views: Vec::new() },
1928                "--analyze is not yet supported with --watch; use a one-shot report",
1929                "analyze is not yet supported with watch; use a one-shot report",
1930            ),
1931            (
1932                RequestError::WatchContent {
1933                    named: AnalysisSet::NONE,
1934                    views: vec![ViewSpec::Code],
1935                },
1936                "--view code needs code analysis, which --watch cannot keep current; use a \
1937                 one-shot report",
1938                "view code needs code analysis, which watch cannot keep current; use a one-shot \
1939                 report",
1940            ),
1941            // The analyzers follow the views' order, so documents reads as needing words.
1942            (
1943                RequestError::WatchContent {
1944                    named: AnalysisSet::NONE,
1945                    views: vec![ViewSpec::Documents, ViewSpec::Code],
1946                },
1947                "--view documents,code needs words and code analysis, which --watch cannot keep \
1948                 current; use a one-shot report",
1949                "view documents,code needs words and code analysis, which watch cannot keep \
1950                 current; use a one-shot report",
1951            ),
1952            // Both axes, so dropping the view alone is not refused again for the analyzer.
1953            (
1954                RequestError::WatchContent {
1955                    named: AnalysisSet::WORDS_ONLY,
1956                    views: vec![ViewSpec::Code],
1957                },
1958                "--analyze words and --view code need words and code analysis, which --watch \
1959                 cannot keep current; use a one-shot report",
1960                "analyze words and view code need words and code analysis, which watch cannot \
1961                 keep current; use a one-shot report",
1962            ),
1963            (
1964                RequestError::WatchCacheOnly,
1965                "--watch cannot start from a --stale-ok answer: nothing verifies what changed \
1966                 between the snapshot and the start of the watch; drop --stale-ok",
1967                "watch cannot start from a stale_ok answer: nothing verifies what changed between \
1968                 the snapshot and the start of the watch; drop stale_ok",
1969            ),
1970            (
1971                RequestError::StaleOkCacheOff,
1972                "--stale-ok answers from the snapshot, which --cache off never reads; drop one of \
1973                 them",
1974                "stale_ok answers from the snapshot, which cache policy off never reads; drop one \
1975                 of them",
1976            ),
1977            (
1978                RequestError::ViewLimit { attempted: 17, limit: 16 },
1979                "report request contains 17 views or omissions; limit is 16",
1980                "report request contains 17 views or omissions; limit is 16",
1981            ),
1982        ];
1983        for (refusal, flags, fields) in cases {
1984            assert_eq!(refusal.message(&AxisNames::FLAGS), flags);
1985            assert_eq!(refusal.message(&AxisNames::FIELDS), fields);
1986            assert_eq!(refusal.to_string(), fields, "a refusal displays in the library's words");
1987        }
1988    }
1989
1990    /// The watch-scope rule is the library's constant in the library's words, and the
1991    /// command line's words differ by knob names alone.
1992    ///
1993    /// Asserted against the constant rather than by quoting prose, so a rewording of the
1994    /// rule cannot leave this test measuring its own copy of it.
1995    #[test]
1996    fn the_watch_scope_refusal_substitutes_whole_words_only() {
1997        let source = crate::scan::WATCH_SCOPE_GUIDANCE;
1998        assert_eq!(RequestError::WatchScope.message(&AxisNames::FIELDS), source);
1999
2000        let text = RequestError::WatchScope.message(&AxisNames::FLAGS);
2001        assert!(!text.contains("---"), "{text} re-substituted a replacement");
2002
2003        // Hyphens stay inside a token, so `--scan-depth` is one word and not three.
2004        let names_word = |haystack: &str, word: &str| {
2005            haystack
2006                .split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
2007                .any(|w| w == word)
2008        };
2009        let (fields, flags) = (&AxisNames::FIELDS, &AxisNames::FLAGS);
2010        let vocabulary = [
2011            (fields.scan_depth, flags.scan_depth),
2012            (fields.one_filesystem, flags.one_filesystem),
2013            (fields.modified_since, flags.modified_since),
2014            (fields.depth, flags.depth),
2015            (fields.include, flags.include),
2016        ];
2017        for (field, flag) in vocabulary {
2018            if names_word(source, field) {
2019                assert!(text.contains(flag), "{text} must name {flag} where the rule says {field}");
2020            }
2021            assert!(!names_word(&text, field), "{text} still names {field} untranslated");
2022        }
2023        let mut rebuilt = text.clone();
2024        for (field, flag) in vocabulary {
2025            rebuilt = rebuilt.replace(flag, field);
2026        }
2027        assert_eq!(rebuilt, source, "the flag wording must be the library's, knob names aside");
2028    }
2029
2030    /// Code needs the code analyzer and documents the words analyzer; every other view
2031    /// answers from any basis. Lines alone answers neither: raw words by document format
2032    /// were a cheaper approximation nobody asks for by name.
2033    #[test]
2034    fn documents_and_code_are_the_views_that_need_content() {
2035        for content in [
2036            AnalysisSet::NONE,
2037            AnalysisSet::LINES_ONLY,
2038            AnalysisSet::CODE_ONLY,
2039            AnalysisSet::WORDS_ONLY,
2040            AnalysisSet::ALL,
2041        ] {
2042            for view in ViewSpec::ALL {
2043                let refused = check_views(&[view], content, BasisHolder::Index).is_err();
2044                assert_eq!(
2045                    refused,
2046                    (view == ViewSpec::Documents && !content.includes_words())
2047                        || (view == ViewSpec::Code && !content.includes_code()),
2048                    "{view:?} over {content:?}"
2049                );
2050            }
2051        }
2052        assert_eq!(check_observation(IgnoredEntries::Include, false), Ok(()));
2053        for ignored in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
2054            assert_eq!(check_observation(ignored, true), Ok(()));
2055            assert_eq!(
2056                check_observation(ignored, false),
2057                Err(RequestError::IgnoredWithoutObservation(ignored))
2058            );
2059        }
2060    }
2061
2062    // ---- the request model ----
2063
2064    use crate::content::AnalysisRequest;
2065
2066    fn root() -> &'static Path {
2067        Path::new("/tree")
2068    }
2069
2070    fn instant() -> SystemTime {
2071        SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
2072    }
2073
2074    /// A spec over the test root whose basis is every default and whose read is `read`.
2075    #[allow(clippy::large_types_passed_by_value)]
2076    fn reading(read: ReadSpec<'_>) -> RequestSpec<'_> {
2077        RequestSpec { read, ..RequestSpec::new(root()) }
2078    }
2079
2080    fn built(spec: &RequestSpec<'_>) -> Request {
2081        Request::build(spec, instant(), &AxisNames::FIELDS).expect("the spec parses")
2082    }
2083
2084    #[test]
2085    fn retained_population_allows_narrower_reads_only_from_include() {
2086        let read = |basis: Basis, ignored| {
2087            Request::read(
2088                basis,
2089                &ReadSpec { ignored, ..ReadSpec::default() },
2090                instant(),
2091                &AxisNames::FIELDS,
2092            )
2093        };
2094        let include = Basis {
2095            root: root().to_path_buf(),
2096            scope: Scope::default(),
2097            content: AnalysisSet::NONE,
2098        };
2099        assert_eq!(
2100            read(include.clone(), Some("exclude")).expect("narrow exclude").query.selection.ignored,
2101            IgnoredEntries::Exclude
2102        );
2103        assert_eq!(
2104            read(include, Some("only")).expect("narrow only").query.selection.ignored,
2105            IgnoredEntries::Only
2106        );
2107
2108        let excluded = Basis {
2109            root: root().to_path_buf(),
2110            scope: Scope { population: IgnoredEntries::Exclude, ..Scope::default() },
2111            content: AnalysisSet::NONE,
2112        };
2113        assert_eq!(
2114            read(excluded.clone(), None).expect("basis default").query.selection.ignored,
2115            IgnoredEntries::Exclude
2116        );
2117        assert!(matches!(
2118            read(excluded, Some("only")),
2119            Err(RequestError::PopulationMismatch {
2120                held: IgnoredEntries::Exclude,
2121                requested: IgnoredEntries::Only
2122            })
2123        ));
2124    }
2125
2126    fn refusal(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> String {
2127        Request::build(spec, instant(), axes).expect_err("the spec is refused").message(axes)
2128    }
2129
2130    /// A spec that names nothing builds the table, and every type that also declares a
2131    /// default for one of these axes agrees with it.
2132    #[test]
2133    fn list_formats_are_resolved_and_validated_in_the_shared_request() {
2134        use crate::report_format::Format;
2135        for (view, format, valid, tree) in [
2136            (None, None, true, true),
2137            (Some("list"), Some("tree"), true, true),
2138            (Some("list"), Some("paths"), true, false),
2139            (None, Some("json"), true, false),
2140            (Some("files"), None, true, false),
2141            (Some("files"), Some("tree"), true, true),
2142            (Some("tree"), Some("long"), true, false),
2143            (Some("largest"), Some("long"), true, false),
2144            (Some("summary"), Some("long"), false, false),
2145            (Some("full"), Some("paths"), false, false),
2146            (Some("list,summary"), Some("tree"), false, false),
2147            (Some("list,summary"), Some("json"), true, false),
2148        ] {
2149            let spec = RequestSpec {
2150                read: ReadSpec { views: view, format, ..ReadSpec::new() },
2151                ..RequestSpec::new(Path::new("/absent"))
2152            };
2153            let request =
2154                Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("grammar");
2155            assert_eq!(request.validate().is_ok(), valid, "{view:?} {format:?}");
2156            if valid {
2157                assert_eq!(request.query.tree_for(request.query.views[0]), tree);
2158            }
2159        }
2160        let spec = RequestSpec::new(Path::new("/absent"));
2161        let request =
2162            Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("default");
2163        assert_eq!(request.query.views, [ViewSpec::List]);
2164        assert!(
2165            !request.query.needs_selection_walk(),
2166            "an ordinary tree must retain its bounded projection cost"
2167        );
2168        let mut flat = request.clone();
2169        flat.query.format = Format::Paths;
2170        assert!(flat.query.needs_selection_walk());
2171        assert_eq!(flat.query.limit_for(ViewSpec::List), Bound::All);
2172        assert_eq!(request.query.limit_for(ViewSpec::List), Bound::All);
2173        assert_eq!(request.query.depth_for(ViewSpec::List), Bound::Limit(5));
2174    }
2175
2176    #[test]
2177    fn an_empty_spec_builds_the_defaults_table() {
2178        let defaults = Request::DEFAULTS;
2179        assert_eq!(defaults.size, SizeMetric::Allocated);
2180        assert_eq!(defaults.words_per_page, 250);
2181        assert_eq!(defaults.content, AnalysisSet::NONE);
2182        assert!(defaults.read_controls);
2183        assert_eq!(defaults.control_limits, ControlLimits::default());
2184        // A watch serves no content -- `WatchContent` refuses one that names an analyzer
2185        // -- so its view is the report default for none, derived rather than declared.
2186        assert_eq!(defaults.report_view(AnalysisSet::NONE), ViewSpec::List);
2187        for content in [
2188            AnalysisSet::NONE,
2189            AnalysisSet::NONE.with_lines(),
2190            AnalysisSet::NONE.with_code(),
2191            AnalysisSet::NONE.with_words(),
2192            AnalysisSet::ALL,
2193        ] {
2194            assert_eq!(defaults.report_view(content), ViewSpec::default_for(content));
2195        }
2196
2197        let request = built(&RequestSpec::new(root()));
2198        assert_eq!(request.basis.root, root());
2199        assert_eq!(request.basis.content, defaults.content);
2200        assert_eq!(request.basis.scope.read_controls, defaults.read_controls);
2201        assert_eq!(request.basis.scope.control_limits, defaults.control_limits);
2202        assert_eq!(request.basis.scope.max_depth, None);
2203        assert!(!request.basis.scope.one_filesystem);
2204        assert_eq!(request.query.views, vec![defaults.report_view(defaults.content)]);
2205        assert!(request.query.omitted_views.is_empty());
2206        assert_eq!(request.query.words_per_page, defaults.words_per_page);
2207        assert_eq!(request.query.selection.size, defaults.size);
2208        assert!(request.query.selection.is_unfiltered());
2209        let selection = &request.query.selection;
2210        assert_eq!((selection.depth, selection.limit, selection.sort), (None, None, None));
2211        assert!(!selection.reverse);
2212        assert_eq!(*request.query.axes, AxisNames::FIELDS);
2213        assert_eq!(request.now, instant());
2214        request.validate().expect("the defaults are a valid request");
2215
2216        // The other homes of these defaults read the table rather than restating it.
2217        assert_eq!(SizeMetric::default(), defaults.size);
2218        assert_eq!(Selection::default().size, defaults.size);
2219        assert_eq!(Query::default().words_per_page, defaults.words_per_page);
2220        assert_eq!(ScanConfig::default().read_controls, defaults.read_controls);
2221        assert_eq!(ScanConfig::default().control_limits, defaults.control_limits);
2222        assert_eq!(AnalysisRequest::default().profile, defaults.content);
2223        assert_eq!(AnalysisSet::default(), defaults.content);
2224    }
2225
2226    #[test]
2227    fn every_axis_of_a_spec_reaches_its_typed_value() {
2228        let include = ["*.rs".to_string()];
2229        let exclude = ["target/**".to_string()];
2230        let spec = RequestSpec {
2231            scan_depth: Some("3"),
2232            one_filesystem: true,
2233            read_controls: Some(false),
2234            control_budget: Some("all"),
2235            control_line_limit: Some("64KiB"),
2236            analyze: Some("code"),
2237            read: ReadSpec {
2238                views: Some("languages,tree"),
2239                format: None,
2240                words_per_page: Some("300"),
2241                include: &include,
2242                exclude: &exclude,
2243                min_size: Some("1KiB"),
2244                modified_since: Some("@1700000000"),
2245                modified_before: Some("@1800000000"),
2246                kinds: Some("file,dir"),
2247                ignored: Some("include"),
2248                depth: Some("all"),
2249                min_share: Some("1%"),
2250                breadth: Some("all"),
2251                limit: Some("5"),
2252                sort: Some("name"),
2253                reverse: true,
2254                size: Some("apparent"),
2255            },
2256            ..RequestSpec::new(root())
2257        };
2258        let request = Request::build(&spec, instant(), &AxisNames::FLAGS).expect("parses");
2259        let scope = &request.basis.scope;
2260        assert_eq!(scope.max_depth, Some(3));
2261        assert!(scope.one_filesystem);
2262        assert!(!scope.read_controls);
2263        assert_eq!(scope.control_limits, ControlLimits { budget: None, line_limit: Some(65_536) });
2264        assert_eq!(request.basis.content, AnalysisSet::NONE.with_code());
2265        assert_eq!(request.query.views, vec![ViewSpec::Languages, ViewSpec::Tree]);
2266        assert_eq!(request.query.words_per_page, 300);
2267        assert_eq!(*request.query.axes, AxisNames::FLAGS);
2268        let selection = &request.query.selection;
2269        assert_eq!(selection.include.len(), 1);
2270        assert_eq!(selection.exclude.len(), 1);
2271        assert_eq!(selection.min_size, Some(1024));
2272        assert_eq!(selection.modified.since, Some(1_700_000_000_000_000_000));
2273        assert_eq!(selection.modified.before, Some(1_800_000_000_000_000_000));
2274        assert_eq!(selection.kinds, vec![EntryKind::File, EntryKind::Dir]);
2275        assert_eq!(selection.ignored, IgnoredEntries::Include);
2276        assert_eq!(selection.depth, Some(Bound::All));
2277        assert_eq!(selection.limit, Some(Bound::Limit(5)));
2278        assert_eq!(selection.sort, Some(SortKey::Name));
2279        assert!(selection.reverse);
2280        assert_eq!(selection.size, SizeMetric::Apparent);
2281    }
2282
2283    /// Relative windows are resolved once, against the request's own instant, so the
2284    /// selection a request carries is absolute and building it again at the same instant
2285    /// selects exactly the same entries however much later that happens.
2286    #[test]
2287    fn relative_windows_resolve_against_the_requests_instant() {
2288        let spec = reading(ReadSpec {
2289            modified_since: Some("2h"),
2290            modified_before: Some("now"),
2291            ..ReadSpec::new()
2292        });
2293        let request = built(&spec);
2294        let now = system_time_to_nanos(instant()).expect("representable");
2295        let two_hours = 2 * 60 * 60 * 1_000_000_000;
2296        assert_eq!(request.now, instant());
2297        assert_eq!(request.query.selection.modified.since, Some(now - two_hours));
2298        assert_eq!(request.query.selection.modified.before, Some(now));
2299
2300        let later = instant() + Duration::from_secs(60);
2301        let moved = Request::build(&spec, later, &AxisNames::FIELDS).expect("parses");
2302        assert_eq!(moved.query.selection.modified.since, Some(now - two_hours + 60_000_000_000));
2303        let again = built(&spec);
2304        assert_eq!(again.query.selection.modified.since, request.query.selection.modified.since);
2305    }
2306
2307    /// Each refusal `build` raises reads exactly as the path it replaces does today, in
2308    /// both vocabularies.
2309    #[test]
2310    fn build_refuses_each_axis_in_the_surfaces_words() {
2311        let spec = RequestSpec::new(root());
2312        let analyze = RequestSpec { analyze: Some("deep"), ..spec };
2313        for (axes, label) in [(&AxisNames::FLAGS, "--analyze"), (&AxisNames::FIELDS, "analyze")] {
2314            let today = AnalysisSet::parse_labeled("deep", label).expect_err("refused");
2315            assert_eq!(refusal(&analyze, axes), today);
2316        }
2317        for views in ["tree,tree", "tree,,types", "full,tree", "bogus"] {
2318            let spec = reading(ReadSpec { views: Some(views), ..ReadSpec::new() });
2319            for (axes, label) in [(&AxisNames::FLAGS, "--view"), (&AxisNames::FIELDS, "view")] {
2320                let today =
2321                    ViewSpec::resolve(Some(views), AnalysisSet::NONE, label).expect_err("refused");
2322                assert_eq!(refusal(&spec, axes), today);
2323            }
2324        }
2325
2326        let cases: [(RequestSpec<'_>, &str, &str); 7] = [
2327            (
2328                reading(ReadSpec { words_per_page: Some("0"), ..ReadSpec::new() }),
2329                "invalid --words-per-page \"0\": expected a positive integer",
2330                "invalid words_per_page \"0\": expected a positive integer",
2331            ),
2332            (
2333                RequestSpec { scan_depth: Some("deep"), ..spec },
2334                "invalid --scan-depth \"deep\": expected a whole number",
2335                "invalid max_depth \"deep\": expected a whole number",
2336            ),
2337            (
2338                RequestSpec { control_budget: Some("lots"), ..spec },
2339                "invalid --gitignore-budget \"lots\": expected a number before the unit, as in \
2340                 `10M`, or `all` for no bound",
2341                "invalid control_budget \"lots\": expected a number before the unit, as in \
2342                 `10M`, or `all` for no bound",
2343            ),
2344            (
2345                reading(ReadSpec { ignored: Some("maybe"), ..ReadSpec::new() }),
2346                "invalid --ignored \"maybe\": expected one of include, \
2347                 exclude, only",
2348                "invalid ignored \"maybe\": expected one of include, exclude, only",
2349            ),
2350            (
2351                reading(ReadSpec { kinds: Some("file,socket"), ..ReadSpec::new() }),
2352                "invalid --kind \"socket\": expected one of file, dir, symlink, other",
2353                "invalid kind \"socket\": expected one of file, dir, symlink, other",
2354            ),
2355            (
2356                reading(ReadSpec { min_size: Some("10X"), ..ReadSpec::new() }),
2357                "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
2358                 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
2359                "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
2360                 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
2361            ),
2362            (
2363                reading(ReadSpec {
2364                    modified_since: Some("2300-01-01T00:00:00Z"),
2365                    ..ReadSpec::new()
2366                }),
2367                "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
2368                 range fdu can represent (about 1677 to 2262)",
2369                "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
2370                 fdu can represent (about 1677 to 2262)",
2371            ),
2372        ];
2373        for (spec, flags, fields) in cases {
2374            assert_eq!(refusal(&spec, &AxisNames::FLAGS), flags);
2375            assert_eq!(refusal(&spec, &AxisNames::FIELDS), fields);
2376        }
2377    }
2378
2379    /// An analyzer's name in the view axis is refused with the view that shows it: alone
2380    /// when that view implies the analyzer, and beside the analyzer when it does not.
2381    #[test]
2382    fn analyzer_names_in_view_axis_point_to_both_correct_axes() {
2383        for (token, flags, fields) in [
2384            (
2385                "LiNeS",
2386                "invalid --view \"LiNeS\": lines is an analyzer; its view is families, with \
2387                 --analyze lines",
2388                "invalid view \"LiNeS\": lines is an analyzer; its view is families, with analyze \
2389                 lines",
2390            ),
2391            (
2392                "words",
2393                "invalid --view \"words\": words is an analyzer; its view is documents",
2394                "invalid view \"words\": words is an analyzer; its view is documents",
2395            ),
2396        ] {
2397            let spec = reading(ReadSpec { views: Some(token), ..ReadSpec::new() });
2398            for (axes, expected) in [(&AxisNames::FLAGS, flags), (&AxisNames::FIELDS, fields)] {
2399                let error = Request::build(&spec, instant(), axes).expect_err("not a view");
2400                assert!(matches!(error, RequestError::AnalyzerNamedAsView { .. }));
2401                assert_eq!(error.message(axes), expected);
2402            }
2403        }
2404        let combination = reading(ReadSpec { views: Some("full,words"), ..ReadSpec::new() });
2405        assert_eq!(
2406            refusal(&combination, &AxisNames::FLAGS),
2407            ViewSpec::resolve(Some("full,words"), AnalysisSet::NONE, "--view")
2408                .expect_err("full is exclusive")
2409        );
2410    }
2411
2412    /// A request that builds its own basis enables what its content views imply, in union
2413    /// with what `analyze` names; a view with a metadata meaning implies nothing.
2414    #[test]
2415    fn a_built_basis_enables_the_analyzers_its_views_imply() {
2416        /// `analyze`, `views`, the basis's analyzers, and the views that implied them.
2417        type Case = (Option<&'static str>, Option<&'static str>, AnalysisSet, &'static [ViewSpec]);
2418        let cases: [Case; 10] = [
2419            (None, Some("code"), AnalysisSet::CODE_ONLY, &[ViewSpec::Code]),
2420            (None, Some("documents"), AnalysisSet::WORDS_ONLY, &[ViewSpec::Documents]),
2421            (
2422                None,
2423                Some("code,documents"),
2424                AnalysisSet::ALL,
2425                &[ViewSpec::Code, ViewSpec::Documents],
2426            ),
2427            (None, Some("full"), AnalysisSet::NONE, &[]),
2428            (None, Some("languages"), AnalysisSet::NONE, &[]),
2429            (None, Some("types,families,tree"), AnalysisSet::NONE, &[]),
2430            (Some("lines"), Some("code"), AnalysisSet::CODE_ONLY, &[ViewSpec::Code]),
2431            (Some("lines"), Some("documents"), AnalysisSet::WORDS_ONLY, &[ViewSpec::Documents]),
2432            // `none` is the empty set, not a refusal: the views still imply.
2433            (Some("none"), Some("code"), AnalysisSet::CODE_ONLY, &[ViewSpec::Code]),
2434            (Some("words"), Some("summary,code"), AnalysisSet::ALL, &[ViewSpec::Code]),
2435        ];
2436        for (analyze, views, content, implied_by) in cases {
2437            let spec = RequestSpec {
2438                analyze,
2439                read: ReadSpec { views, ..ReadSpec::new() },
2440                ..RequestSpec::new(root())
2441            };
2442            let request = built(&spec);
2443            assert_eq!(request.basis.content, content, "{analyze:?} {views:?}");
2444            assert_eq!(request.implied_by(), implied_by, "{analyze:?} {views:?}");
2445            request.validate().expect("a built basis answers its own views");
2446        }
2447
2448        // One basis whichever axis named the analyzer, so one sidecar and one answer.
2449        for (view, analyzer) in
2450            [("code", "code"), ("documents", "words"), ("code,documents", "all")]
2451        {
2452            let by_view = built(&reading(ReadSpec { views: Some(view), ..ReadSpec::new() }));
2453            let by_analyzer =
2454                built(&RequestSpec { analyze: Some(analyzer), ..RequestSpec::new(root()) });
2455            assert_eq!(by_view.basis.content, by_analyzer.basis.content, "{view}");
2456            assert_eq!(by_view.query.views, by_analyzer.query.views, "{view}");
2457            assert!(by_analyzer.implied_by().is_empty(), "{analyzer} implied nothing");
2458        }
2459
2460        // `full` implies nothing, so alone it enables no analyzer: the metadata digest stays
2461        // a metadata report and names the content views it skipped.
2462        let full = built(&reading(ReadSpec { views: Some("full"), ..ReadSpec::new() }));
2463        assert_eq!(full.query.omitted_views, [ViewSpec::Code, ViewSpec::Documents]);
2464    }
2465
2466    /// A read of a held basis is never widened: a view its analyzers cannot answer is
2467    /// refused, naming the view, its analyzer, and what the holder was opened with, in each
2468    /// surface's words.
2469    #[test]
2470    fn a_held_basis_refuses_a_view_it_cannot_answer() {
2471        let read = |held, views| {
2472            Request::read(
2473                basis(held, true),
2474                &ReadSpec { views: Some(views), ..ReadSpec::new() },
2475                instant(),
2476                &AxisNames::FIELDS,
2477            )
2478        };
2479        let refused = read(AnalysisSet::NONE, "code").expect_err("nothing analyzed code");
2480        assert_eq!(
2481            refused,
2482            RequestError::ViewNeedsAnalyzer {
2483                view: ViewSpec::Code,
2484                held: AnalysisSet::NONE,
2485                holder: BasisHolder::Index,
2486            }
2487        );
2488        assert_eq!(
2489            refused.message(&AxisNames::FIELDS),
2490            "view code needs code analysis; this index was opened with analyze none"
2491        );
2492        assert_eq!(
2493            refused.message(&AxisNames::FLAGS),
2494            "--view code needs code analysis; this index was opened with --analyze none"
2495        );
2496        let refused = read(AnalysisSet::LINES_ONLY, "summary,documents").expect_err("no words");
2497        assert_eq!(
2498            refused.message(&AxisNames::FIELDS),
2499            "view documents needs words analysis; this index was opened with analyze lines"
2500        );
2501        let held = read(AnalysisSet::WORDS_ONLY, "documents").expect("words answers documents");
2502        assert_eq!(held.basis.content, AnalysisSet::WORDS_ONLY, "a read never widens a basis");
2503        assert!(held.implied_by().is_empty(), "a read implies nothing");
2504    }
2505
2506    /// Each route's refusal names a remedy that route accepts.
2507    ///
2508    /// The same missing analyzer, refused four ways: a built request can add it, an index
2509    /// says what it was opened with, an opened root has no analyzer to open with, and a
2510    /// basis a caller supplied whole says what it holds rather than which flag to add.
2511    #[test]
2512    fn a_refusal_for_missing_analysis_names_its_routes_remedy() {
2513        let documents = |holder| {
2514            let request = request_with(
2515                &[ViewSpec::Documents],
2516                Selection::default(),
2517                basis(AnalysisSet::NONE, true),
2518            );
2519            match holder {
2520                BasisHolder::Index => request.validate_read(&basis(AnalysisSet::NONE, true)),
2521                BasisHolder::OpenedRoot => request.validate_opened(),
2522                _ => request.validate(),
2523            }
2524            .expect_err("nothing analyzed words")
2525            .message(&AxisNames::FIELDS)
2526        };
2527        assert_eq!(
2528            documents(BasisHolder::Index),
2529            "view documents needs words analysis; this index was opened with analyze none"
2530        );
2531        assert_eq!(
2532            documents(BasisHolder::OpenedRoot),
2533            "view documents needs words analysis, which an opened root never runs; use a \
2534             one-shot report, or an index opened with analyze words"
2535        );
2536        assert_eq!(
2537            documents(BasisHolder::Supplied),
2538            "view documents needs words analysis; its basis holds analyze none"
2539        );
2540        // The grammar's own opened-root read refuses in the opened root's words too.
2541        assert_eq!(
2542            Request::read_opened(
2543                &ReadSpec { views: Some("documents"), ..ReadSpec::new() },
2544                instant(),
2545                &AxisNames::FIELDS,
2546            )
2547            .map_err(|error| error.message(&AxisNames::FIELDS))
2548            .map(|_| ()),
2549            Err(documents(BasisHolder::OpenedRoot))
2550        );
2551
2552        // A sort implies nothing, so even a built request refuses it, and only there is
2553        // naming the analyzer to add the remedy.
2554        let sort = |analyze| RequestSpec {
2555            analyze,
2556            read: ReadSpec { views: Some("files"), sort: Some("code_lines"), ..ReadSpec::new() },
2557            ..RequestSpec::new(root())
2558        };
2559        assert_eq!(
2560            built(&sort(None)).validate().map_err(|error| error.message(&AxisNames::FLAGS)),
2561            Err("--sort code_lines needs code analysis: add --analyze code".to_owned())
2562        );
2563        let held = Request::read(
2564            basis(AnalysisSet::WORDS_ONLY, true),
2565            &ReadSpec { views: Some("files"), sort: Some("code_lines"), ..ReadSpec::new() },
2566            instant(),
2567            &AxisNames::FIELDS,
2568        )
2569        .expect_err("an index without code cannot sort by it");
2570        assert_eq!(
2571            held.message(&AxisNames::FIELDS),
2572            "sort code_lines needs code analysis; this index was opened with analyze words"
2573        );
2574    }
2575
2576    /// The order `build` names axes in, when more than one of them is wrong.
2577    ///
2578    /// A contract, not an accident: every surface renders the first refusal and stops, so
2579    /// the order decides which mistake a caller is told about, and one that drifted would
2580    /// change what two doors say about the same command line. Pinned by fixing one axis at
2581    /// a time and watching the next one speak -- content, views, the selection, the page
2582    /// denominator, then scope, which is the order the doc comment publishes and the order
2583    /// the command line reads its flags.
2584    #[test]
2585    fn build_names_a_bad_axis_in_the_order_it_publishes() {
2586        let everything = RequestSpec {
2587            scan_depth: Some("deep"),
2588            analyze: Some("deep"),
2589            read: ReadSpec {
2590                views: Some("bogus"),
2591                depth: Some("two"),
2592                words_per_page: Some("0"),
2593                ..ReadSpec::new()
2594            },
2595            ..RequestSpec::new(root())
2596        };
2597        let steps: [(RequestSpec<'_>, &str); 5] = [
2598            (everything, "analyze"),
2599            (RequestSpec { analyze: None, ..everything }, "view"),
2600            (
2601                RequestSpec {
2602                    analyze: None,
2603                    read: ReadSpec { views: None, ..everything.read },
2604                    ..everything
2605                },
2606                "depth",
2607            ),
2608            (
2609                RequestSpec {
2610                    analyze: None,
2611                    read: ReadSpec { views: None, depth: None, ..everything.read },
2612                    ..everything
2613                },
2614                "words_per_page",
2615            ),
2616            (
2617                RequestSpec {
2618                    analyze: None,
2619                    read: ReadSpec {
2620                        views: None,
2621                        format: None,
2622                        depth: None,
2623                        words_per_page: None,
2624                        ..everything.read
2625                    },
2626                    ..everything
2627                },
2628                "max_depth",
2629            ),
2630        ];
2631        for (spec, axis) in steps {
2632            let refused = refusal(&spec, &AxisNames::FIELDS);
2633            assert!(
2634                refused.starts_with(&format!("invalid {axis} ")),
2635                "expected {axis} to speak next, got {refused}"
2636            );
2637        }
2638        // And the last step is the only thing still wrong, so fixing it parses.
2639        Request::build(
2640            &RequestSpec {
2641                scan_depth: None,
2642                analyze: None,
2643                read: ReadSpec::new(),
2644                ..RequestSpec::new(root())
2645            },
2646            instant(),
2647            &AxisNames::FIELDS,
2648        )
2649        .expect("nothing left to refuse");
2650    }
2651
2652    fn request_with(views: &[ViewSpec], selection: Selection, basis: Basis) -> Request {
2653        Request::new(
2654            basis,
2655            Query { selection, views: views.to_vec(), ..Query::default() },
2656            instant(),
2657        )
2658    }
2659
2660    fn basis(content: AnalysisSet, read_controls: bool) -> Basis {
2661        Basis {
2662            root: root().to_path_buf(),
2663            scope: Scope { read_controls, ..Scope::default() },
2664            content,
2665        }
2666    }
2667
2668    /// Moved from the report reader, where a surface-only check stood in for the model.
2669    #[test]
2670    fn language_grouping_is_metadata_only_while_documents_require_analysis() {
2671        let enabled = [
2672            AnalysisSet::NONE.with_lines(),
2673            AnalysisSet::NONE.with_code(),
2674            AnalysisSet::NONE.with_words(),
2675            AnalysisSet::ALL,
2676        ];
2677        for content in std::iter::once(AnalysisSet::NONE).chain(enabled) {
2678            request_with(&[ViewSpec::Languages], Selection::default(), basis(content, true))
2679                .validate()
2680                .expect("language grouping never requires content I/O");
2681        }
2682
2683        // A basis is never widened by a view: documents over a basis without words is
2684        // refused, naming what the basis holds.
2685        for held in [AnalysisSet::NONE, AnalysisSet::LINES_ONLY, AnalysisSet::CODE_ONLY] {
2686            let documents =
2687                request_with(&[ViewSpec::Documents], Selection::default(), basis(held, true));
2688            assert_eq!(
2689                documents.validate(),
2690                Err(RequestError::ViewNeedsAnalyzer {
2691                    view: ViewSpec::Documents,
2692                    held,
2693                    holder: BasisHolder::Supplied,
2694                })
2695            );
2696        }
2697        for content in [AnalysisSet::WORDS_ONLY, AnalysisSet::ALL] {
2698            request_with(&[ViewSpec::Documents], Selection::default(), basis(content, true))
2699                .validate()
2700                .expect("a basis with words answers the document view");
2701        }
2702
2703        request_with(
2704            &[ViewSpec::Types, ViewSpec::Families],
2705            Selection::default(),
2706            basis(AnalysisSet::NONE, true),
2707        )
2708        .validate()
2709        .expect("metadata grouping never requires content I/O");
2710    }
2711
2712    #[test]
2713    fn code_view_and_metric_sort_require_their_registered_analyzer() {
2714        let plain = basis(AnalysisSet::NONE, true);
2715        let code = request_with(&[ViewSpec::Code], Selection::default(), plain.clone());
2716        assert_eq!(
2717            code.validate(),
2718            Err(RequestError::ViewNeedsAnalyzer {
2719                view: ViewSpec::Code,
2720                held: AnalysisSet::NONE,
2721                holder: BasisHolder::Supplied,
2722            })
2723        );
2724        let selection =
2725            Selection { sort: Some(SortKey::Metric("code_lines")), ..Selection::default() };
2726        let files = request_with(&[ViewSpec::Files], selection.clone(), plain);
2727        assert_eq!(
2728            files.validate(),
2729            Err(RequestError::SortNeedsAnalyzer {
2730                metric: "code_lines",
2731                analyzer: AnalysisSet::CODE_ONLY,
2732                held: AnalysisSet::NONE,
2733                holder: BasisHolder::Supplied,
2734            })
2735        );
2736        request_with(&[ViewSpec::Files], selection, basis(AnalysisSet::NONE.with_code(), true))
2737            .validate()
2738            .expect("code metrics are available with the code analyzer");
2739    }
2740
2741    #[test]
2742    fn extension_view_refuses_metric_sort_before_reading() {
2743        let held = basis(AnalysisSet::NONE.with_code(), true);
2744        let selection =
2745            Selection { sort: Some(SortKey::Metric("code_lines")), ..Selection::default() };
2746        let request = request_with(&[ViewSpec::Extensions], selection.clone(), held.clone());
2747        let expected = invalid(
2748            request.query.axes.sort,
2749            "code_lines",
2750            "extensions cannot sort by content metrics; use size, count, or name, or select files or another metric-capable view",
2751        );
2752        assert_eq!(request.validate(), Err(expected.clone()));
2753        assert_eq!(request.validate_read(&held), Err(expected));
2754        request_with(&[ViewSpec::Files], selection, held)
2755            .validate()
2756            .expect("files retain metric sorting");
2757    }
2758
2759    /// A scope this build cannot honour is refused by request validation itself.
2760    ///
2761    /// Both entry points, because they cover different routes: `validate` is what a
2762    /// one-shot report and both command lines ask, `validate_read` what a retained index,
2763    /// an opened root, and a watch session ask. Both weigh the scope the *request* names,
2764    /// not the one a holder retained, so the refusal cannot wait for a scan that a
2765    /// cache-only read never runs -- which is how one request came to name a snapshot miss
2766    /// under one delivery and a scope refusal under another.
2767    ///
2768    /// `follow_symlinks` is refused on every platform, which is how the `one_filesystem`
2769    /// rule -- refused only where the platform has no device identity -- is tested here.
2770    #[test]
2771    fn a_scope_this_build_cannot_honour_is_refused_by_request_validation() {
2772        let held = basis(AnalysisSet::NONE, true);
2773        let mut asked = held.clone();
2774        asked.scope.follow_symlinks = true;
2775        let request = request_with(&[ViewSpec::Summary], Selection::default(), asked);
2776        let refusal = RequestError::ScopeUnsupported {
2777            axis: ScopeAxis::FollowSymlinks,
2778            reason: ScopeAxis::FollowSymlinks.reason(),
2779        };
2780
2781        assert_eq!(request.validate(), Err(refusal.clone()));
2782        assert_eq!(request.validate_read(&held), Err(refusal));
2783        // The sentence is the one the capability rule states, not a copy of it kept here:
2784        // an engine-internal caller that never builds a request prints the same words.
2785        assert_eq!(
2786            ScanConfig { follow_symlinks: true, ..ScanConfig::default() }
2787                .unsupported_axis()
2788                .expect("no build follows symbolic links")
2789                .reason(),
2790            ScopeAxis::FollowSymlinks.reason()
2791        );
2792        request_with(&[ViewSpec::Summary], Selection::default(), held)
2793            .validate()
2794            .expect("a scope this build honours is not refused");
2795    }
2796
2797    /// Moved from the report reader: the refusal half of
2798    /// `an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by`. The
2799    /// reader keeps the library-path half, its typed refusal of an unvalidated query.
2800    #[test]
2801    fn a_scope_that_observes_no_control_state_refuses_selection_by_ignored_state() {
2802        let exclude = Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() };
2803        let only = Selection { ignored: IgnoredEntries::Only, ..Selection::default() };
2804        let blind = basis(AnalysisSet::NONE, false);
2805
2806        let refused = request_with(&[ViewSpec::Summary], exclude.clone(), blind.clone())
2807            .validate()
2808            .expect_err("no entry can be shown to be ignored");
2809        assert_eq!(
2810            refused.message(&AxisNames::FLAGS),
2811            "--ignored=exclude needs .gitignore classification, and --no-gitignore turned it \
2812             off; drop one of them"
2813        );
2814        let refused = request_with(&[ViewSpec::Summary], only, blind.clone())
2815            .validate()
2816            .expect_err("no entry can be shown to be ignored");
2817        assert_eq!(
2818            refused.message(&AxisNames::FIELDS),
2819            "ignored=only needs .gitignore classification, and read_controls turned it off; \
2820             drop one of them"
2821        );
2822        request_with(&[ViewSpec::Summary], exclude, basis(AnalysisSet::NONE, true))
2823            .validate()
2824            .expect("an observing scope can select by ignored state");
2825        request_with(&[ViewSpec::Summary], Selection::default(), blind)
2826            .validate()
2827            .expect("admitting every entry needs no classification");
2828    }
2829
2830    #[test]
2831    fn a_read_is_refused_by_a_holder_of_other_content() {
2832        let request = built(&RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) });
2833        let held = basis(AnalysisSet::NONE, true);
2834        assert_eq!(
2835            request.validate_read(&held),
2836            Err(RequestError::ContentMismatch {
2837                held: AnalysisSet::NONE,
2838                requested: AnalysisSet::NONE.with_lines(),
2839            })
2840        );
2841        // A wider store is refused too: serving a narrower request from it is a projection
2842        // this model does not define.
2843        assert_eq!(
2844            request.validate_read(&basis(AnalysisSet::ALL, true)),
2845            Err(RequestError::ContentMismatch {
2846                held: AnalysisSet::ALL,
2847                requested: AnalysisSet::NONE.with_lines(),
2848            })
2849        );
2850        request
2851            .validate_read(&basis(AnalysisSet::NONE.with_lines(), true))
2852            .expect("equal content serves");
2853
2854        // The remaining rules read what the holder observed, not what the request assumed.
2855        let exclude = built(&reading(ReadSpec { ignored: Some("exclude"), ..ReadSpec::new() }));
2856        assert_eq!(
2857            exclude.validate_read(&basis(AnalysisSet::NONE, false)),
2858            Err(RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude))
2859        );
2860        // Content equality comes first, so the refusal names the more fundamental mismatch.
2861        let documents = request_with(
2862            &[ViewSpec::Documents],
2863            Selection::default(),
2864            basis(AnalysisSet::NONE.with_words(), true),
2865        );
2866        assert!(matches!(
2867            documents.validate_read(&basis(AnalysisSet::NONE, true)),
2868            Err(RequestError::ContentMismatch { .. })
2869        ));
2870    }
2871
2872    /// What a read validates against is what the index observed, not the configuration that
2873    /// happened to make it: a `ScanConfig`'s delivery fields cannot be recovered from an
2874    /// index and no answer depends on them.
2875    #[test]
2876    fn the_basis_a_retained_index_holds_is_what_it_observed() {
2877        let blind =
2878            crate::Index::new_with_scope("/root", crate::test_support::not_observing_controls());
2879        let held = Basis::held_by(&blind);
2880        assert_eq!(held.root, Path::new("/root"));
2881        assert!(!held.scope.read_controls, "an index that read no rule says so");
2882        assert_eq!(held.content, AnalysisSet::NONE, "a metadata index holds no analyzer");
2883
2884        let observing =
2885            crate::Index::new_with_scope("/root", crate::test_support::observing_controls());
2886        assert!(Basis::held_by(&observing).scope.read_controls);
2887
2888        // The limits are the tier's own, not the table's: an index admitted its control
2889        // files under the limits it was built with, and a basis that restated the defaults
2890        // here would compare equal to one taken under any other budget.
2891        let tight = ControlLimits { budget: Some(4_096), line_limit: None };
2892        let narrow = crate::Index::new_with_config(
2893            "/root",
2894            &ScanConfig { read_controls: true, control_limits: tight, ..ScanConfig::default() },
2895        );
2896        assert_eq!(Basis::held_by(&narrow).scope.control_limits, tight);
2897        assert_ne!(tight, Request::DEFAULTS.control_limits, "the fixture must differ");
2898        assert_eq!(
2899            Basis::held_by(&blind).scope.control_limits,
2900            Request::DEFAULTS.control_limits,
2901            "a scan that read no rule applied none, and names the table's"
2902        );
2903
2904        // The scope a request would have to be built with to read this index.
2905        let request =
2906            built(&RequestSpec { read_controls: Some(false), ..RequestSpec::new(root()) });
2907        request
2908            .validate_read(&Basis::held_by(&blind))
2909            .expect("the index's own basis answers a request built the same way");
2910    }
2911
2912    /// Each rule in the order `validate_delivery` applies it, and each one only under a
2913    /// watch: every one of these is a legal one-shot request.
2914    #[test]
2915    fn a_watch_refuses_what_it_cannot_keep_current() {
2916        let one_shot = Delivery {
2917            stale_ok: false,
2918            cache: CachePolicy::Auto,
2919            cache_path: None,
2920            accept_partial: false,
2921            watch: None,
2922            workers: Workers::default(),
2923            batch_size: ScanConfig::default().batch_size,
2924            order: crate::ScanOrder::default(),
2925        };
2926        let watching = Delivery { watch: Some(WatchDelivery::default()), ..one_shot.clone() };
2927        let cases = [
2928            (
2929                RequestSpec { scan_depth: Some("2"), ..RequestSpec::new(root()) },
2930                RequestError::WatchScope,
2931            ),
2932            (
2933                RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) },
2934                RequestError::WatchScope,
2935            ),
2936            (
2937                RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) },
2938                RequestError::WatchContent { named: AnalysisSet::LINES_ONLY, views: Vec::new() },
2939            ),
2940            // Analysis a view implied is refused the same way, naming the view the caller
2941            // wrote rather than an analyzer they never typed.
2942            (
2943                reading(ReadSpec { views: Some("summary,code"), ..ReadSpec::new() }),
2944                RequestError::WatchContent {
2945                    named: AnalysisSet::NONE,
2946                    views: vec![ViewSpec::Code],
2947                },
2948            ),
2949            // `none` names nothing, so only the view is named.
2950            (
2951                RequestSpec {
2952                    analyze: Some("none"),
2953                    read: ReadSpec { views: Some("code"), ..ReadSpec::new() },
2954                    ..RequestSpec::new(root())
2955                },
2956                RequestError::WatchContent {
2957                    named: AnalysisSet::NONE,
2958                    views: vec![ViewSpec::Code],
2959                },
2960            ),
2961            // Both axes enabled analysis, so both are named.
2962            (
2963                RequestSpec {
2964                    analyze: Some("lines"),
2965                    read: ReadSpec { views: Some("documents"), ..ReadSpec::new() },
2966                    ..RequestSpec::new(root())
2967                },
2968                RequestError::WatchContent {
2969                    named: AnalysisSet::LINES_ONLY,
2970                    views: vec![ViewSpec::Documents],
2971                },
2972            ),
2973        ];
2974        for (spec, expected) in cases {
2975            let request = built(&spec);
2976            assert_eq!(request.validate_delivery(&watching), Err(expected));
2977            request.validate_delivery(&one_shot).expect("a one-shot delivers every one");
2978        }
2979        assert_eq!(
2980            built(&reading(ReadSpec { views: Some("code"), ..ReadSpec::new() }))
2981                .validate_delivery(&watching)
2982                .map_err(|error| error.message(&AxisNames::FLAGS)),
2983            Err("--view code needs code analysis, which --watch cannot keep current; use a \
2984                 one-shot report"
2985                .to_owned())
2986        );
2987        // A caller who named an analyzer and a content view is told both, so dropping the
2988        // view does not lead straight to the analyzer's refusal.
2989        let both = RequestSpec {
2990            analyze: Some("words"),
2991            read: ReadSpec { views: Some("code"), ..ReadSpec::new() },
2992            ..RequestSpec::new(root())
2993        };
2994        assert_eq!(
2995            built(&both)
2996                .validate_delivery(&watching)
2997                .map_err(|error| error.message(&AxisNames::FLAGS)),
2998            Err("--analyze words and --view code need words and code analysis, which --watch \
2999                 cannot keep current; use a one-shot report"
3000                .to_owned())
3001        );
3002        // `full` and the default imply nothing, so a request whose views were chosen by its
3003        // analyzers names the analyzers.
3004        for views in [None, Some("full")] {
3005            let spec = RequestSpec {
3006                analyze: Some("all"),
3007                read: ReadSpec { views, ..ReadSpec::new() },
3008                ..RequestSpec::new(root())
3009            };
3010            assert_eq!(
3011                built(&spec).validate_delivery(&watching),
3012                Err(RequestError::WatchContent { named: AnalysisSet::ALL, views: Vec::new() }),
3013                "{views:?}"
3014            );
3015        }
3016        // A basis supplied whole names no view, so its analyzers are refused as named.
3017        assert_eq!(
3018            request_with(
3019                &[ViewSpec::Tree],
3020                Selection::default(),
3021                basis(AnalysisSet::CODE_ONLY, true)
3022            )
3023            .validate_delivery(&watching),
3024            Err(RequestError::WatchContent { named: AnalysisSet::CODE_ONLY, views: Vec::new() })
3025        );
3026
3027        // A content view's analysis is served from a snapshot exactly as a named analyzer's
3028        // is: the basis is one basis, whichever axis enabled it.
3029        let implied = built(&reading(ReadSpec { views: Some("code"), ..ReadSpec::new() }));
3030        implied
3031            .validate_delivery(&Delivery { stale_ok: true, ..one_shot.clone() })
3032            .expect("a stale answer needs only a compatible sidecar");
3033
3034        let plain = built(&RequestSpec::new(root()));
3035        plain.validate_delivery(&watching).expect("a full-scope metadata watch is deliverable");
3036        assert_eq!(
3037            plain.validate_delivery(&Delivery { stale_ok: true, ..watching.clone() }),
3038            Err(RequestError::WatchCacheOnly),
3039            "nothing verifies the window between the snapshot and the start of the watch"
3040        );
3041        plain
3042            .validate_delivery(&Delivery { stale_ok: true, ..one_shot })
3043            .expect("a one-shot report is exactly what a snapshot answers");
3044    }
3045
3046    /// The order the three watch rules speak in, when a request breaks more than one.
3047    ///
3048    /// The command line and the Python API both render the first refusal and stop, so this
3049    /// decides which of three true statements a caller is told, and a rule moved within
3050    /// `validate_delivery` would silently change that. Scope first, because it is a fact
3051    /// about what a watcher can observe at all; then content, which is about what stays
3052    /// current; then the cache policy, which is about the window before the watch started.
3053    #[test]
3054    fn the_watch_rules_speak_in_one_order() {
3055        let cache_only_watch = Delivery {
3056            cache: CachePolicy::Auto,
3057            stale_ok: true,
3058            cache_path: None,
3059            accept_partial: false,
3060            watch: Some(WatchDelivery::default()),
3061            workers: Workers::default(),
3062            batch_size: ScanConfig::default().batch_size,
3063            order: crate::ScanOrder::default(),
3064        };
3065        let everything = RequestSpec {
3066            scan_depth: Some("2"),
3067            analyze: Some("lines"),
3068            ..RequestSpec::new(root())
3069        };
3070        let steps = [
3071            (everything, RequestError::WatchScope),
3072            (
3073                RequestSpec { scan_depth: None, ..everything },
3074                RequestError::WatchContent { named: AnalysisSet::LINES_ONLY, views: Vec::new() },
3075            ),
3076            (
3077                RequestSpec { scan_depth: None, analyze: None, ..everything },
3078                RequestError::WatchCacheOnly,
3079            ),
3080        ];
3081        for (spec, expected) in steps {
3082            assert_eq!(built(&spec).validate_delivery(&cache_only_watch), Err(expected));
3083        }
3084        built(&RequestSpec::new(root()))
3085            .validate_delivery(&Delivery { stale_ok: false, ..cache_only_watch })
3086            .expect("nothing left to refuse");
3087    }
3088
3089    /// The other half of the watch-scope rule, which the command-line golden cannot
3090    /// assert: where a build cannot honor `one_filesystem` at all, the request is refused
3091    /// for that reason first, so the message differs by platform.
3092    #[cfg(unix)]
3093    #[test]
3094    fn a_watch_refuses_one_filesystem_where_the_build_honors_it() {
3095        let watch = Delivery {
3096            stale_ok: false,
3097            cache: CachePolicy::Auto,
3098            cache_path: None,
3099            accept_partial: false,
3100            watch: Some(WatchDelivery::default()),
3101            workers: Workers::default(),
3102            batch_size: ScanConfig::default().batch_size,
3103            order: crate::ScanOrder::default(),
3104        };
3105        let spec = RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) };
3106        let request = built(&spec);
3107        request.validate().expect("one filesystem is honored on this build");
3108        assert_eq!(request.validate_delivery(&watch), Err(RequestError::WatchScope));
3109    }
3110
3111    #[test]
3112    fn a_request_is_refused_past_the_views_one_report_carries() {
3113        let mut request = built(&RequestSpec::new(root()));
3114        request.query.views = vec![ViewSpec::Summary; crate::MAX_REPORT_VIEWS];
3115        request.validate().expect("the limit itself is accepted");
3116        request.query.omitted_views = vec![ViewSpec::Documents];
3117        assert_eq!(
3118            request.validate(),
3119            Err(RequestError::ViewLimit {
3120                attempted: crate::MAX_REPORT_VIEWS + 1,
3121                limit: crate::MAX_REPORT_VIEWS,
3122            })
3123        );
3124    }
3125}