Skip to main content

fdu_core/query/
query_request.rs

1//! The request model: what determines an answer, the grammars its values are written in,
2//! and the typed refusals that name a bad request in the caller's own vocabulary.
3//!
4//! A [`Request`] is the [`Basis`] a retained index or opened root holds for its lifetime --
5//! root, scope, and content -- plus what each read supplies: the [`Query`] and `now`, the
6//! instant relative time windows resolve against. [`Delivery`] is how the caller asks for
7//! it to be carried out, and never changes what the answer says.
8//!
9//! A refusal is a value, not a sentence. Each surface renders it through its
10//! [`AxisNames`], so the rule and its wording are stated once here while the command line
11//! names flags and the library and the Python API name fields. The grammars used to live
12//! in both front ends, each with its own copy of every spelling and every message, which
13//! is how one request came to mean two things depending on the door it came through.
14
15use std::fmt;
16use std::path::{Path, PathBuf};
17use std::time::{Duration, SystemTime};
18
19use crate::CachePolicy;
20use crate::content::AnalysisSet;
21use crate::control::{ControlLimits, DEFAULT_CONTROL_BUDGET, DEFAULT_CONTROL_LINE_LIMIT};
22use crate::engine_contract::EntryKind;
23use crate::query::query_glob::Pattern;
24use crate::query::query_report::{AxisNames, Query, ViewSpec};
25use crate::query::query_selection::{
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#[derive(Clone, Debug)]
311pub struct Request {
312    /// Root, scope, and content: what a holder of stored state must match.
313    pub basis: Basis,
314    /// Selection, views, and view options.
315    pub query: Query,
316    /// The instant relative time windows were resolved against.
317    ///
318    /// Fixed when the request is built, so [`Selection::modified`] is absolute and a watch
319    /// that builds its request once at start never slides its window.
320    pub now: SystemTime,
321}
322
323/// A request as a caller wrote it: raw values, before any grammar has read them.
324///
325/// Surface-neutral, so the command line and the Python API fill one shape and
326/// [`Request::build`] parses both identically. A value a surface already holds typed, such
327/// as `--scan-depth`, reaches this through its `Display`, so a disagreement between that
328/// type and the grammar here shows up as a golden difference rather than a silent one.
329/// `None` and an empty list mean the caller named nothing, and [`Request::DEFAULTS`]
330/// decides; the switches whose only default is off are plain booleans.
331#[derive(Clone, Copy, Debug)]
332pub struct RequestSpec<'a> {
333    /// The directory the answer is about.
334    pub root: &'a Path,
335    /// Retention depth: a whole number.
336    pub scan_depth: Option<&'a str>,
337    /// Stay on the root's filesystem.
338    pub one_filesystem: bool,
339    /// Observe `.gitignore`.
340    pub read_controls: Option<bool>,
341    /// The `.gitignore` budget: a size or `all`.
342    pub control_budget: Option<&'a str>,
343    /// The longest `.gitignore` line: a size or `all`.
344    pub control_line_limit: Option<&'a str>,
345    /// Analyzers: a comma list of `none`, `lines`, `code`, `words`, or `all`.
346    pub analyze: Option<&'a str>,
347    /// What this read asks of the basis the axes above describe.
348    pub read: ReadSpec<'a>,
349}
350
351/// What one read supplies, as its caller wrote it: the [`Query`] half of a request.
352///
353/// Split from the basis half rather than flattened beside it, because a holder of stored
354/// state fixes root, scope, and analyzers once and then answers many reads: a read names
355/// only this, and [`Request::read`] is what takes the two together. A field declared in
356/// both halves is a field one of them could silently drop, which is why this is the one
357/// declaration and [`RequestSpec`] contains it.
358#[derive(Clone, Copy, Debug)]
359pub struct ReadSpec<'a> {
360    /// Views: a comma list, or `full`.
361    pub views: Option<&'a str>,
362    /// Presentation format; omitted means automatic human output.
363    pub format: Option<&'a str>,
364    /// Logical words per document page: a positive integer.
365    pub words_per_page: Option<&'a str>,
366    /// Patterns an entry must match one of.
367    pub include: &'a [String],
368    /// Patterns that exclude an entry.
369    pub exclude: &'a [String],
370    /// Smallest size, as `512`, `10M`, or `1.5GiB`.
371    pub min_size: Option<&'a str>,
372    /// Inclusive lower bound on modification time, as `2h` or a timestamp.
373    pub modified_since: Option<&'a str>,
374    /// Exclusive upper bound on modification time.
375    pub modified_before: Option<&'a str>,
376    /// Entry kinds: a comma list of `file`, `dir`, `symlink`, or `other`.
377    pub kinds: Option<&'a str>,
378    /// Selection by ignored state: `include`, `exclude`, or `only`.
379    pub ignored: Option<&'a str>,
380    /// Rendered tree depth: a whole number or `all`.
381    pub depth: Option<&'a str>,
382    /// Minimum displayed contribution to the selected root, as a percentage.
383    pub min_share: Option<&'a str>,
384    /// Maximum immediate children shown per directory: a whole number or `all`.
385    pub breadth: Option<&'a str>,
386    /// Rows per view: a whole number or `all`.
387    pub limit: Option<&'a str>,
388    /// Ordering key: `size`, `count`, `mtime`, or `name`.
389    pub sort: Option<&'a str>,
390    /// Reverse the ordering.
391    pub reverse: bool,
392    /// Size metric: `allocated` or `apparent`.
393    pub size: Option<&'a str>,
394}
395
396impl ReadSpec<'_> {
397    /// A read that names nothing, so every axis takes its default.
398    pub const fn new() -> Self {
399        Self {
400            views: None,
401            format: None,
402            words_per_page: None,
403            include: &[],
404            exclude: &[],
405            min_size: None,
406            modified_since: None,
407            modified_before: None,
408            kinds: None,
409            ignored: None,
410            depth: None,
411            min_share: None,
412            breadth: None,
413            limit: None,
414            sort: None,
415            reverse: false,
416            size: None,
417        }
418    }
419}
420
421impl Default for ReadSpec<'_> {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427impl<'a> RequestSpec<'a> {
428    /// A spec that names only its root, so every other axis takes its default.
429    pub const fn new(root: &'a Path) -> Self {
430        Self {
431            root,
432            scan_depth: None,
433            one_filesystem: false,
434            read_controls: None,
435            control_budget: None,
436            control_line_limit: None,
437            analyze: None,
438            read: ReadSpec::new(),
439        }
440    }
441}
442
443/// Every default a request takes when its caller names nothing, stated once.
444///
445/// | Axis | Default |
446/// | --- | --- |
447/// | Size | allocated |
448/// | Views of a report | [`Self::report_view`]: [`ViewSpec::default_for`] the content |
449/// | Views of a watch | [`Self::report_view`] of no content, which is `tree` |
450/// | Words per page | 250 |
451/// | Content | no analyzer |
452/// | `.gitignore` | observed, under the default budget and line limit |
453///
454/// Each answers a question: "how much disk does this use" is allocated bytes, as `du`
455/// reports them; a request that pays to read files displays what it read; and a tree is
456/// what "what is big here" looks like. Surfaces take these rather than declaring their own,
457/// because a default declared twice drifts: size was apparent in Rust and allocated
458/// everywhere else, and `words_per_page` was written out in three places.
459///
460/// A watch has no view default of its own, and no field here for one. It is derived rather
461/// than declared because it is not a separate decision: [`RequestError::WatchContent`]
462/// refuses a watch that names an analyzer, so the content a watch serves is always none and
463/// its view is the report default for none. A field would have restated `tree` beside the
464/// rule that makes it true, which is the shape a default drifts out of.
465#[derive(Clone, Copy, Debug, PartialEq, Eq)]
466pub struct RequestDefaults {
467    /// The size metric.
468    pub size: SizeMetric,
469    /// Logical words per derived document page.
470    pub words_per_page: u64,
471    /// The analyzers a request enables.
472    pub content: AnalysisSet,
473    /// Whether a scan observes `.gitignore`.
474    pub read_controls: bool,
475    /// The limits `.gitignore` files are applied under.
476    pub control_limits: ControlLimits,
477}
478
479impl RequestDefaults {
480    /// The view a report displays when its caller named none: the one that shows what
481    /// `content` read, or the tree when it read nothing.
482    pub const fn report_view(self, content: AnalysisSet) -> ViewSpec {
483        ViewSpec::default_for(content)
484    }
485}
486
487impl Request {
488    /// The defaults table.
489    pub const DEFAULTS: RequestDefaults = RequestDefaults {
490        size: SizeMetric::Allocated,
491        words_per_page: 250,
492        content: AnalysisSet::NONE,
493        read_controls: true,
494        control_limits: ControlLimits {
495            budget: Some(DEFAULT_CONTROL_BUDGET),
496            line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
497        },
498    };
499
500    /// One read of what `basis` holds, from a query its caller already has typed.
501    ///
502    /// The composition six call sites wrote out by hand, each of them a holder's basis and
503    /// one read's query and instant. A literal is not wrong, but a name is where the rule
504    /// can be stated: `basis` is the holder's, never the read's, and `now` is this read's,
505    /// fixed so a watch that repaints does not slide its own window.
506    ///
507    /// No validation, because the query is already typed and its holder is the one that
508    /// knows which rule applies -- [`Self::validate_read`] for a retained index,
509    /// [`Self::validate`] for a request that is its own basis. [`Self::read`] is the
510    /// constructor that parses and validates in one step.
511    pub const fn new(basis: Basis, query: Query, now: SystemTime) -> Self {
512        Self { basis, query, now }
513    }
514
515    /// One read of what `basis` holds, written in the value grammars.
516    ///
517    /// The constructor every read site wants: a holder supplies the basis it was opened
518    /// with, and the caller supplies only what this read asks. The view default comes from
519    /// the analyzers the basis already holds, so a typed set never has to be spelled back
520    /// into the grammar to find out what a request that read files displays, and no caller
521    /// builds a request with a throw-away basis and overwrites it afterwards.
522    ///
523    /// # Errors
524    ///
525    /// [`RequestError`] for a value no grammar accepts, and for a read the basis cannot
526    /// answer: [`Self::validate`]'s rules, which here are the holder's own.
527    pub fn read(
528        basis: Basis,
529        spec: &ReadSpec<'_>,
530        now: SystemTime,
531        axes: &'static AxisNames,
532    ) -> Result<Self, RequestError> {
533        let mut query = build_query(basis.content, spec, now, axes)?;
534        if spec.ignored.is_none() {
535            query.selection.ignored = basis.scope.population;
536        }
537        let request = Self::new(basis, query, now);
538        request.validate()?;
539        Ok(request)
540    }
541
542    /// Parse a spec into a request, resolving relative time windows against `now`.
543    ///
544    /// Refusals name each axis as `axes` spells it, and so do the report diagnostics of the
545    /// query built here. Every value is parsed before any is checked against another, in
546    /// the order the command line reads its flags: content, views, selection, the page
547    /// denominator, then scope. Rules that relate axes are [`Self::validate`]'s.
548    pub fn build(
549        spec: &RequestSpec<'_>,
550        now: SystemTime,
551        axes: &'static AxisNames,
552    ) -> Result<Self, RequestError> {
553        // The three steps in the order the command line reads its flags, which is the order
554        // a refusal names when two axes are both wrong: content, then everything this read
555        // supplies, then scope. `Basis::build` runs the first and the third together, for
556        // the callers that fix a basis and read it many times.
557        let content = parse_content(spec, axes)?;
558        let query = build_query(content, &spec.read, now, axes)?;
559        let mut scope = parse_scope(spec, axes)?;
560        scope.population = query.selection.ignored;
561        Ok(Self::new(Basis { root: spec.root.to_path_buf(), scope, content }, query, now))
562    }
563
564    /// Refuse a request no holder of its own basis could answer.
565    ///
566    /// In order: more views than one report carries, a view its content cannot answer, and
567    /// a selection by ignored state its scope does not observe.
568    pub fn validate(&self) -> Result<(), RequestError> {
569        self.validate_against(&self.basis)
570    }
571
572    /// Refuse a read that `held`, the basis of a retained index or opened root, cannot
573    /// answer.
574    ///
575    /// Content must be equal: an index built with other analyzers holds other metrics, and
576    /// serving a narrower request from a wider store is a projection this model does not
577    /// define. The remaining rules are [`Self::validate`]'s, applied to what `held`
578    /// observed rather than to what the request says it would have. Scope equality is not
579    /// checked here; `ScanConfig` owns it.
580    pub fn validate_read(&self, held: &Basis) -> Result<(), RequestError> {
581        let entries = held.scope.snapshot_identity().entries;
582        let wanted = crate::ContentTierIdentity::for_request(entries, self.basis.content);
583        let stored = crate::ContentTierIdentity::for_request(entries, held.content);
584        if wanted.admit(&stored).is_none() {
585            return Err(RequestError::ContentMismatch {
586                held: held.content,
587                requested: self.basis.content,
588            });
589        }
590        self.validate_against(held)
591    }
592
593    /// Refuse a delivery that cannot carry this request out.
594    ///
595    /// Everything a watch cannot do, in one place, because a watch is the one delivery that
596    /// changes which requests can be answered at all:
597    ///
598    /// - A narrowed scan scope ([`RequestError::WatchScope`]): a watcher cannot filter its
599    ///   backend's events against a boundary the scan drew. Selection still works, because
600    ///   it filters the retained index rather than the scan.
601    /// - Content analysis ([`RequestError::WatchContent`]): nothing re-reads a file the
602    ///   watch sees change, so a session would go on reporting the metrics it started with
603    ///   as fresh.
604    /// - A snapshot nothing verified ([`RequestError::WatchCacheOnly`]): the window between
605    ///   the snapshot and the session's start is never observed, so the first answer would
606    ///   describe a tree that may have moved and every later one would build on it.
607    ///
608    /// Each was a guard on one surface, which is why a library caller and a Python caller
609    /// could ask for what the command line refuses.
610    ///
611    /// One refusal applies to every route: a stale answer comes from the snapshot, which
612    /// [`CachePolicy::Off`] never reads ([`RequestError::StaleOkCacheOff`]).
613    pub fn validate_delivery(&self, delivery: &Delivery) -> Result<(), RequestError> {
614        if delivery.stale_ok && delivery.cache == CachePolicy::Off {
615            return Err(RequestError::StaleOkCacheOff);
616        }
617        if delivery.watch.is_none() {
618            return Ok(());
619        }
620        if self.basis.scope.max_depth.is_some() || self.basis.scope.one_filesystem {
621            return Err(RequestError::WatchScope);
622        }
623        if self.basis.content.is_enabled() {
624            return Err(RequestError::WatchContent);
625        }
626        if delivery.stale_ok {
627            return Err(RequestError::WatchCacheOnly);
628        }
629        Ok(())
630    }
631
632    fn validate_against(&self, basis: &Basis) -> Result<(), RequestError> {
633        // Capability first, and against the scope the request names rather than against
634        // whatever a holder retained: a scope this build cannot honour has no answer at any
635        // delivery, so the refusal must not wait for a scan that a cache-only read never
636        // runs, nor for a snapshot that a cold read never loads.
637        if let Some(axis) = self.basis.scope.unsupported_axis() {
638            return Err(RequestError::ScopeUnsupported { axis, reason: axis.reason() });
639        }
640        let views = self.query.views.len().saturating_add(self.query.omitted_views.len());
641        if views > crate::MAX_REPORT_VIEWS {
642            return Err(RequestError::ViewLimit {
643                attempted: views,
644                limit: crate::MAX_REPORT_VIEWS,
645            });
646        }
647        let format = self.query.format;
648        if matches!(
649            format,
650            crate::report_format::Format::Tree
651                | crate::report_format::Format::Paths
652                | crate::report_format::Format::Long
653        ) {
654            let compatible = self.query.views.len() == 1
655                && self.query.views.iter().all(|view| {
656                    matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
657                        || (format != crate::report_format::Format::Tree
658                            && matches!(view, ViewSpec::Largest | ViewSpec::Recent))
659                });
660            if !compatible {
661                return Err(invalid(
662                    self.query.axes.format,
663                    format.label(),
664                    "requires a single list view; use text or a machine format for aggregate/mixed views (largest/recent support paths and long)",
665                ));
666            }
667        }
668        check_views(&self.query.views, basis.content)?;
669        if let Some(SortKey::Metric(name)) = self.query.selection.sort {
670            let metric =
671                crate::content::METRICS.iter().find(|metric| metric.name == name).ok_or_else(
672                    || invalid(self.query.axes.sort, name, "expected a registered numeric metric"),
673                )?;
674            if self.query.views.contains(&ViewSpec::Extensions) {
675                return Err(invalid(
676                    self.query.axes.sort,
677                    name,
678                    "extensions cannot sort by content metrics; use size, count, or name, or select files or another metric-capable view",
679                ));
680            }
681            if !basis.content.contains(metric.owner) {
682                let analyzer = if metric.owner.includes_code() {
683                    "code"
684                } else if metric.owner.includes_words() {
685                    "words"
686                } else {
687                    "lines"
688                };
689                return Err(RequestError::NeedsAnalyzer { item: metric.name, analyzer });
690            }
691        }
692        let hierarchy = self.query.views.iter().any(|view| self.query.tree_for(*view));
693        // Neutral bounds compose across views, including the CLI --full shorthand.
694        // A finite hierarchy bound on flat output would promise filtering it cannot do.
695        if matches!(self.query.selection.depth, Some(Bound::Limit(_))) && !hierarchy {
696            return Err(invalid(self.query.axes.depth, "", "requires a hierarchical view"));
697        }
698        if matches!(self.query.selection.breadth, Some(Bound::Limit(_))) && !hierarchy {
699            return Err(invalid(self.query.axes.breadth, "", "requires a hierarchical view"));
700        }
701        let additive = self.query.views.iter().any(|view| {
702            self.query.tree_for(*view)
703                || matches!(
704                    view,
705                    ViewSpec::Extensions
706                        | ViewSpec::Types
707                        | ViewSpec::Families
708                        | ViewSpec::Languages
709                        | ViewSpec::Documents
710                        | ViewSpec::Code
711                )
712        });
713        if self.query.selection.min_share.as_ref().is_some_and(|share| !share.admits(0, 1))
714            && !additive
715        {
716            return Err(invalid(self.query.axes.min_share, "", "requires an additive view"));
717        }
718        check_observation(basis.scope.population, basis.scope.read_controls)?;
719        check_observation(self.query.selection.ignored, basis.scope.read_controls)?;
720        if basis.scope.population != IgnoredEntries::Include
721            && self.query.selection.ignored != basis.scope.population
722        {
723            return Err(RequestError::PopulationMismatch {
724                held: basis.scope.population,
725                requested: self.query.selection.ignored,
726            });
727        }
728        Ok(())
729    }
730}
731
732/// The analyzers a spec names, or the table's default.
733fn parse_content(
734    spec: &RequestSpec<'_>,
735    axes: &'static AxisNames,
736) -> Result<AnalysisSet, RequestError> {
737    spec.analyze.map_or(Ok(Request::DEFAULTS.content), |value| {
738        AnalysisSet::parse_rejecting(value).map_err(|rejection| rejection.on(axes.analyze))
739    })
740}
741
742fn parse_population(
743    value: Option<&str>,
744    axes: &'static AxisNames,
745) -> Result<IgnoredEntries, RequestError> {
746    value.map_or(Ok(IgnoredEntries::Include), |value| {
747        IgnoredEntries::parse(value)
748            .map_err(|expected| Rejection::new(value, expected).on(axes.ignored))
749    })
750}
751
752/// The scan scope a spec names, with the table's defaults for what it leaves out.
753///
754/// The delivery fields a `ScanConfig` still carries -- threads, batch size, order -- are
755/// its own defaults: no answer depends on them, and they move into `Delivery` with
756/// `Workers`.
757fn parse_scope(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Scope, RequestError> {
758    let limits = Request::DEFAULTS.control_limits;
759    Ok(Scope {
760        max_depth: spec
761            .scan_depth
762            .map(|value| {
763                value
764                    .trim()
765                    .parse::<usize>()
766                    .map_err(|_| invalid(axes.scan_depth, value, "expected a whole number"))
767            })
768            .transpose()?,
769        one_filesystem: spec.one_filesystem,
770        read_controls: spec.read_controls.unwrap_or(Request::DEFAULTS.read_controls),
771        control_limits: ControlLimits {
772            budget: spec.control_budget.map_or(Ok(limits.budget), |value| {
773                parse_control_budget(value)
774                    .map_err(|error| named_refusal(error, axes.control_budget))
775            })?,
776            line_limit: spec.control_line_limit.map_or(Ok(limits.line_limit), |value| {
777                parse_control_line_limit(value)
778                    .map_err(|error| named_refusal(error, axes.control_line_limit))
779            })?,
780        },
781        ..Scope::default()
782    })
783}
784
785/// The query one read supplies, parsed against the analyzers its basis holds.
786///
787/// `content` decides the view default and nothing else here: a request that paid to read
788/// files displays what it read. Every value is parsed before any is checked against
789/// another, in one order for every surface -- views, selection, then the page denominator.
790fn build_query(
791    content: AnalysisSet,
792    spec: &ReadSpec<'_>,
793    now: SystemTime,
794    axes: &'static AxisNames,
795) -> Result<Query, RequestError> {
796    let (views, omitted_views) =
797        ViewSpec::resolve_rejecting(spec.views, content).map_err(|rejection| {
798            let suggestion = match rejection.value().to_ascii_lowercase().as_str() {
799                "lines" => Some(("lines", "families")),
800                "words" => Some(("words", "documents")),
801                _ => None,
802            };
803            match suggestion {
804                Some((analyzer, suggested_view)) => RequestError::AnalyzerNamedAsView {
805                    value: rejection.value().to_string(),
806                    analyzer,
807                    suggested_view,
808                },
809                None => rejection.on(axes.view),
810            }
811        })?;
812
813    let mut selection = Selection {
814        depth: spec.depth.map(|value| parse_bound(value, axes.depth)).transpose()?,
815        min_share: spec
816            .min_share
817            .map(|value| {
818                ShareThreshold::parse(value).ok_or_else(|| {
819                    invalid(axes.min_share, value, "expected a percentage from 0% through 100%")
820                })
821            })
822            .transpose()?,
823        breadth: spec.breadth.map(|value| parse_bound(value, axes.breadth)).transpose()?,
824        limit: spec.limit.map(|value| parse_bound(value, axes.limit)).transpose()?,
825        reverse: spec.reverse,
826        size: spec
827            .size
828            .map_or(Ok(Request::DEFAULTS.size), |value| parse_size_metric(value, axes.size))?,
829        ..Selection::default()
830    };
831    for pattern in spec.include {
832        selection.include.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
833    }
834    for pattern in spec.exclude {
835        selection.exclude.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
836    }
837    if let Some(value) = spec.min_size {
838        selection.min_size = Some(parse_size(value).map_err(grammar_refusal)?);
839    }
840    if let Some(value) = spec.modified_since {
841        let when = parse_when(value, now).map_err(grammar_refusal)?;
842        selection.modified.since = Some(bound_nanos(value, when, axes.modified_since)?);
843    }
844    if let Some(value) = spec.modified_before {
845        let when = parse_when(value, now).map_err(grammar_refusal)?;
846        selection.modified.before = Some(bound_nanos(value, when, axes.modified_before)?);
847    }
848    if let Some(value) = spec.kinds {
849        selection.kinds = parse_kinds(value, axes.kind)?;
850    }
851    if let Some(value) = spec.sort {
852        selection.sort = Some(parse_sort(value, axes.sort)?);
853    }
854    selection.ignored = parse_population(spec.ignored, axes)?;
855    let words_per_page =
856        spec.words_per_page.map_or(Ok(Request::DEFAULTS.words_per_page), |value| {
857            value
858                .trim()
859                .parse::<u64>()
860                .ok()
861                .filter(|words| *words > 0)
862                .ok_or_else(|| invalid(axes.words_per_page, value, "expected a positive integer"))
863        })?;
864
865    let format = spec.format.map_or(Ok(crate::report_format::Format::Text), |value| {
866        crate::report_format::Format::parse(value).ok_or_else(|| {
867            invalid(
868                axes.format,
869                value,
870                format!("expected one of {}", crate::report_format::Format::ALL.join(", ")),
871            )
872        })
873    })?;
874    Ok(Query { selection, views, format, omitted_views, axes, words_per_page })
875}
876
877/// A scan-scope axis a build may be unable to honour at all.
878///
879/// Not every axis a scan config carries: only the two whose support is a property of the
880/// build rather than of the tree, so asking for one is a request no delivery can carry out
881/// and no stored state can rescue. Which of them this build refuses is stated once, by
882/// `ScanConfig::unsupported_axis`, and asked there by [`Request::validate`] for every route
883/// and by the scan config's own validation for the engine-internal callers that never build
884/// a request.
885#[derive(Clone, Copy, Debug, PartialEq, Eq)]
886pub enum ScopeAxis {
887    /// Walking into what a symbolic link points at.
888    FollowSymlinks,
889    /// Keeping the walk on the root's own filesystem.
890    OneFilesystem,
891}
892
893impl ScopeAxis {
894    /// Why the axis has no supported semantics, in the library's field names.
895    ///
896    /// One sentence per axis, and the only one: the engine-internal callers that report
897    /// [`Error::UnsupportedScanConfig`](crate::Error::UnsupportedScanConfig) print it
898    /// verbatim, and [`RequestError::message`] prints it with the axis renamed, so no
899    /// surface can drift from the rule by rewording its own copy.
900    pub const fn reason(self) -> &'static str {
901        match self {
902            Self::FollowSymlinks => {
903                "follow_symlinks requires cycle, root-boundary, and filesystem-boundary semantics"
904            }
905            Self::OneFilesystem => "one_filesystem requires platform device identity",
906        }
907    }
908
909    /// How `axes` names the axis.
910    const fn named(self, axes: &AxisNames) -> &'static str {
911        match self {
912            Self::FollowSymlinks => axes.follow_symlinks,
913            Self::OneFilesystem => axes.one_filesystem,
914        }
915    }
916}
917
918/// Why a request cannot be answered as asked.
919///
920/// Typed so a caller can match the refusal it can act on, and rendered by
921/// [`Self::message`] in the vocabulary of the surface the request came through.
922#[derive(Clone, Debug, PartialEq, Eq)]
923pub enum RequestError {
924    /// A value did not match its axis's grammar.
925    InvalidValue {
926        /// The axis, as the requesting surface names it.
927        axis: &'static str,
928        /// The rejected value, as the grammar quotes it.
929        value: String,
930        /// What the grammar accepts instead.
931        expected: String,
932    },
933    /// An analyzer name was supplied where a report view was expected.
934    AnalyzerNamedAsView {
935        /// The rejected token in the caller's spelling.
936        value: String,
937        /// The analysis unit to request.
938        analyzer: &'static str,
939        /// A canonical view of that analysis.
940        suggested_view: &'static str,
941    },
942    /// A view has no metadata-only projection, and the request enables no analyzer.
943    ViewNeedsContent(ViewSpec),
944    /// A view or ordering key requires an analyzer the request did not enable.
945    NeedsAnalyzer {
946        /// Requested view or metric label.
947        item: &'static str,
948        /// Analysis unit to add.
949        analyzer: &'static str,
950    },
951    /// A selection by ignored state over a scan that observes no `.gitignore`.
952    IgnoredWithoutObservation(IgnoredEntries),
953    /// A retained narrow population cannot answer a read of another population.
954    PopulationMismatch {
955        /// Population retained by the holder.
956        held: IgnoredEntries,
957        /// Population this read selected.
958        requested: IgnoredEntries,
959    },
960    /// A scan scope this build cannot honour, whatever the delivery.
961    ScopeUnsupported {
962        /// Which axis, so each surface names it in its own words.
963        axis: ScopeAxis,
964        /// The rule, one sentence, in the library's field names.
965        reason: &'static str,
966    },
967    /// A read asks for another analyzer set than the retained index holds.
968    ContentMismatch {
969        /// The analyzers the index was built with.
970        held: AnalysisSet,
971        /// The analyzers the read asks for.
972        requested: AnalysisSet,
973    },
974    /// A watch was asked to narrow its scan scope.
975    WatchScope,
976    /// A watch was asked to keep content analysis current.
977    WatchContent,
978    /// A watch was asked to start from a snapshot nothing verifies.
979    WatchCacheOnly,
980    /// A stale answer was asked of a delivery that never reads the snapshot.
981    StaleOkCacheOff,
982    /// An operation names another root than the retained index it would mutate.
983    RootMismatch {
984        /// Root held by the index.
985        held: PathBuf,
986        /// Root requested by the caller.
987        requested: PathBuf,
988    },
989    /// A route cannot honor the requested execution policy.
990    DeliveryUnsupported {
991        /// The lifecycle that refuses it.
992        route: &'static str,
993        /// The unsupported policy combination.
994        reason: &'static str,
995    },
996    /// A read names more views than one report may carry.
997    ViewLimit {
998        /// Views and omitted views the request carries.
999        attempted: usize,
1000        /// The most one report accepts.
1001        limit: usize,
1002    },
1003}
1004
1005impl RequestError {
1006    /// The refusal in the vocabulary of the surface `axes` describes.
1007    ///
1008    /// [`Self::InvalidValue`] already carries its axis, named by the surface that parsed
1009    /// it, so `axes` names only the knobs the other refusals point at.
1010    pub fn message(&self, axes: &AxisNames) -> String {
1011        match self {
1012            Self::InvalidValue { axis, value, expected } => invalid_message(axis, value, expected),
1013            Self::AnalyzerNamedAsView { value, analyzer, suggested_view } => format!(
1014                "invalid {} {value:?}: {analyzer} is an analyzer; use {}={analyzer} with {}={suggested_view}",
1015                axes.view, axes.analyze, axes.view
1016            ),
1017            Self::ViewNeedsContent(view) => format!(
1018                "{} {} requires content analysis: add {} lines, code, words, or all; views never \
1019                 enable content analysis implicitly",
1020                axes.view,
1021                view.label(),
1022                axes.analyze
1023            ),
1024            Self::NeedsAnalyzer { item, analyzer } => format!(
1025                "{item} requires {analyzer} analysis: add {} {analyzer}; views and sorts never enable analysis implicitly",
1026                axes.analyze
1027            ),
1028            Self::IgnoredWithoutObservation(ignored) => format!(
1029                "{} needs .gitignore classification, and {} turned it off; drop one of them",
1030                match ignored {
1031                    IgnoredEntries::Exclude => axes.exclude_ignored,
1032                    IgnoredEntries::Only => axes.only_ignored,
1033                    IgnoredEntries::Include => axes.ignored,
1034                },
1035                axes.read_controls
1036            ),
1037            Self::PopulationMismatch { held, requested } => format!(
1038                "{}={} cannot be read from retained {}={} scope",
1039                axes.ignored,
1040                requested.label(),
1041                axes.ignored,
1042                held.label()
1043            ),
1044            // The sentence the engine has always printed, with only the axis renamed: a
1045            // Python caller reads the words they wrote, and the command line names its
1046            // flag. The kind stays in front of it, so this refusal is the same sentence
1047            // whichever door raised it.
1048            Self::ScopeUnsupported { axis, reason } => format!(
1049                "unsupported scan configuration: {}",
1050                reason.replacen(axis.named(&AxisNames::FIELDS), axis.named(axes), 1)
1051            ),
1052            Self::ContentMismatch { held, requested } => format!(
1053                "{analyze} {requested} cannot be answered by an index built with {analyze} \
1054                 {held}; open the root again with {analyze} {requested}",
1055                analyze = axes.analyze,
1056                requested = analysis_label(*requested),
1057                held = analysis_label(*held),
1058            ),
1059            Self::WatchScope => watch_scope_message(axes),
1060            Self::RootMismatch { held, requested } => {
1061                format!(
1062                    "requested root {} does not match retained root {}",
1063                    requested.display(),
1064                    held.display()
1065                )
1066            }
1067            Self::DeliveryUnsupported { route, reason } => format!("{route}: {reason}"),
1068            Self::WatchContent => format!(
1069                "{} is not yet supported with {}; use a one-shot report",
1070                axes.analyze, axes.watch
1071            ),
1072            Self::WatchCacheOnly => format!(
1073                "{watch} cannot start from a {stale_ok} answer: nothing verifies what changed \
1074                 between the snapshot and the start of the watch; drop {stale_ok}",
1075                watch = axes.watch,
1076                stale_ok = axes.stale_ok,
1077            ),
1078            Self::StaleOkCacheOff => format!(
1079                "{stale_ok} answers from the snapshot, which {cache} off never reads; drop one \
1080                 of them",
1081                stale_ok = axes.stale_ok,
1082                cache = axes.cache,
1083            ),
1084            Self::ViewLimit { attempted, limit } => {
1085                format!("report request contains {attempted} views or omissions; limit is {limit}")
1086            }
1087        }
1088    }
1089}
1090
1091/// The library's vocabulary, as [`AxisNames::default`] is: a refusal rendered without
1092/// naming a surface belongs to a library caller, not to the command line.
1093impl fmt::Display for RequestError {
1094    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1095        formatter.write_str(&self.message(&AxisNames::FIELDS))
1096    }
1097}
1098
1099impl std::error::Error for RequestError {}
1100
1101/// An analyzer set as its axis spells it back: `none`, or the analyzers joined by commas.
1102fn analysis_label(set: AnalysisSet) -> String {
1103    if set.is_enabled() { set.labels().join(",") } else { AnalysisSet::NONE_LABEL.to_string() }
1104}
1105
1106/// The watch-scope rule, with each knob named as `axes` names it.
1107///
1108/// One pass over whole words of [`crate::scan::WATCH_SCOPE_GUIDANCE`], which is written
1109/// in the library's field names, never re-scanning a replacement. A sequential replace
1110/// does re-scan: `max_depth` becomes `--scan-depth`, and then `depth` matches inside it,
1111/// giving `--scan---depth` (fdu-7j6z). The command line carried this substitution
1112/// itself until the rule moved here.
1113fn watch_scope_message(axes: &AxisNames) -> String {
1114    let fields = &AxisNames::FIELDS;
1115    let vocabulary = [
1116        (fields.scan_depth, axes.scan_depth),
1117        (fields.one_filesystem, axes.one_filesystem),
1118        (fields.modified_since, axes.modified_since),
1119        (fields.depth, axes.depth),
1120        (fields.include, axes.include),
1121    ];
1122    let is_word = |character: char| character.is_ascii_alphanumeric() || character == '_';
1123    crate::scan::WATCH_SCOPE_GUIDANCE
1124        .split_inclusive(|character: char| !is_word(character))
1125        .map(|piece| {
1126            let end = piece.find(|character: char| !is_word(character)).unwrap_or(piece.len());
1127            let (word, tail) = piece.split_at(end);
1128            match vocabulary.iter().find(|(field, _)| *field == word) {
1129                Some((_, name)) => format!("{name}{tail}"),
1130                None => piece.to_string(),
1131            }
1132        })
1133        .collect()
1134}
1135
1136/// Refuse a view that no enabled analyzer can answer.
1137///
1138/// A match over every view rather than a list of the exceptions, so a new view forces a
1139/// decision here about whether it needs content.
1140pub(crate) fn check_views(views: &[ViewSpec], content: AnalysisSet) -> Result<(), RequestError> {
1141    for view in views {
1142        match view {
1143            ViewSpec::Code if !content.includes_code() => {
1144                return Err(RequestError::NeedsAnalyzer { item: "code view", analyzer: "code" });
1145            }
1146            ViewSpec::Documents if !content.is_enabled() => {
1147                return Err(RequestError::ViewNeedsContent(*view));
1148            }
1149            ViewSpec::List
1150            | ViewSpec::Tree
1151            | ViewSpec::Types
1152            | ViewSpec::Extensions
1153            | ViewSpec::Families
1154            | ViewSpec::Languages
1155            | ViewSpec::Code
1156            | ViewSpec::Documents
1157            | ViewSpec::Files
1158            | ViewSpec::Largest
1159            | ViewSpec::Recent
1160            | ViewSpec::Summary => {}
1161        }
1162    }
1163    Ok(())
1164}
1165
1166/// Refuse a selection by ignored state when the scan observes no control state.
1167pub(crate) fn check_observation(
1168    ignored: IgnoredEntries,
1169    observes_controls: bool,
1170) -> Result<(), RequestError> {
1171    match ignored {
1172        IgnoredEntries::Include => Ok(()),
1173        IgnoredEntries::Exclude | IgnoredEntries::Only if observes_controls => Ok(()),
1174        IgnoredEntries::Exclude | IgnoredEntries::Only => {
1175            Err(RequestError::IgnoredWithoutObservation(ignored))
1176        }
1177    }
1178}
1179
1180/// A value a grammar refused, before any surface has named the axis.
1181///
1182/// The list grammars that predate this model ([`ViewSpec::resolve`] and
1183/// [`AnalysisSet::parse_labeled`]) take a free-form label and return a sentence; they
1184/// produce this instead, so the request model can put a typed axis on it and they can go on
1185/// rendering the identical sentence.
1186#[derive(Clone, Debug, PartialEq, Eq)]
1187pub(crate) struct Rejection {
1188    value: String,
1189    expected: String,
1190}
1191
1192impl Rejection {
1193    pub(crate) fn new(value: impl Into<String>, expected: impl Into<String>) -> Self {
1194        Self { value: value.into(), expected: expected.into() }
1195    }
1196
1197    pub(crate) fn value(&self) -> &str {
1198        &self.value
1199    }
1200
1201    /// The refusal, on the axis a surface named.
1202    pub(crate) fn on(self, axis: &'static str) -> RequestError {
1203        RequestError::InvalidValue { axis, value: self.value, expected: self.expected }
1204    }
1205
1206    /// The refusal's sentence, for a caller holding only a label.
1207    pub(crate) fn labeled(&self, label: &str) -> String {
1208        invalid_message(label, &self.value, &self.expected)
1209    }
1210}
1211
1212fn invalid_message(axis: &str, value: &str, expected: &str) -> String {
1213    format!("invalid {axis} {value:?}: {expected}")
1214}
1215
1216fn invalid(
1217    axis: &'static str,
1218    value: impl Into<String>,
1219    expected: impl Into<String>,
1220) -> RequestError {
1221    Rejection::new(value, expected).on(axis)
1222}
1223
1224/// An engine value-grammar error, which already names its grammar: `invalid size "10X"`
1225/// and `invalid pattern` read the same on every surface.
1226fn grammar_refusal(error: crate::Error) -> RequestError {
1227    match error {
1228        crate::Error::InvalidValue { kind, value, hint } => invalid(kind, value, hint),
1229        other => invalid("value", String::new(), other.to_string()),
1230    }
1231}
1232
1233/// An engine value-grammar error on a knob each surface names, as the `.gitignore` limits
1234/// are: `--gitignore-budget` and `control_budget`, never `control budget`.
1235fn named_refusal(error: crate::Error, axis: &'static str) -> RequestError {
1236    match error {
1237        crate::Error::InvalidValue { value, hint, .. } => invalid(axis, value, hint),
1238        other => invalid(axis, String::new(), other.to_string()),
1239    }
1240}
1241
1242/// Parse one entry kind: `file`, `dir`, `symlink`, or `other`.
1243///
1244/// `axis` names the knob as the calling surface spells it: `--kind` or `kind`.
1245pub fn parse_kind(value: &str, axis: &'static str) -> Result<EntryKind, RequestError> {
1246    match value.trim().to_ascii_lowercase().as_str() {
1247        "file" => Ok(EntryKind::File),
1248        "dir" => Ok(EntryKind::Dir),
1249        "symlink" => Ok(EntryKind::Symlink),
1250        "other" => Ok(EntryKind::Other),
1251        other => Err(invalid(axis, other, "expected one of file, dir, symlink, other")),
1252    }
1253}
1254
1255/// Parse a comma-separated list of entry kinds.
1256///
1257/// Closed vocabularies are comma lists and open pattern values are repeatable, because
1258/// glob brace syntax (`*.{rs,toml}`) contains commas and would be shredded by a split.
1259/// An empty entry and a repeated kind are errors rather than silent no-ops, as they are
1260/// for views: repeating a value is far more likely to be a typo than an intention.
1261pub fn parse_kinds(list: &str, axis: &'static str) -> Result<Vec<EntryKind>, RequestError> {
1262    let mut kinds = Vec::new();
1263    for token in list.split(',') {
1264        let token = token.trim();
1265        if token.is_empty() {
1266            return Err(invalid(axis, list, "empty entry in the list"));
1267        }
1268        let kind = parse_kind(token, axis)?;
1269        if kinds.contains(&kind) {
1270            return Err(invalid(axis, list, format!("{token:?} appears more than once")));
1271        }
1272        kinds.push(kind);
1273    }
1274    Ok(kinds)
1275}
1276
1277/// Parse a bound that accepts `all` for unbounded, as `--depth` and `--limit` are written.
1278pub fn parse_bound(value: &str, axis: &'static str) -> Result<Bound, RequestError> {
1279    let value = value.trim();
1280    if value.eq_ignore_ascii_case("all") {
1281        return Ok(Bound::All);
1282    }
1283    value
1284        .parse::<usize>()
1285        .map(Bound::Limit)
1286        .map_err(|_| invalid(axis, value, "expected a whole number or `all`"))
1287}
1288
1289/// Parse a metadata ordering key or a registered numeric content metric.
1290pub fn parse_sort(value: &str, axis: &'static str) -> Result<SortKey, RequestError> {
1291    match value.trim().to_ascii_lowercase().as_str() {
1292        "size" => Ok(SortKey::Size),
1293        "count" => Ok(SortKey::Count),
1294        "mtime" => Ok(SortKey::Mtime),
1295        "name" => Ok(SortKey::Name),
1296        other => crate::content::METRICS.iter().find(|metric| metric.name == other).map_or_else(
1297            || {
1298                Err(invalid(
1299                    axis,
1300                    other,
1301                    "expected size, count, mtime, name, or a registered numeric metric",
1302                ))
1303            },
1304            |metric| Ok(SortKey::Metric(metric.name)),
1305        ),
1306    }
1307}
1308
1309/// Parse a size metric: `allocated` or `apparent`.
1310pub fn parse_size_metric(value: &str, axis: &'static str) -> Result<SizeMetric, RequestError> {
1311    match value.trim().to_ascii_lowercase().as_str() {
1312        "allocated" => Ok(SizeMetric::Allocated),
1313        "apparent" => Ok(SizeMetric::Apparent),
1314        other => Err(invalid(axis, other, "expected allocated or apparent")),
1315    }
1316}
1317
1318/// Convert a parsed time bound to index nanoseconds, or refuse the value.
1319///
1320/// [`system_time_to_nanos`] returns `None` for an instant outside the range the index can
1321/// represent (roughly 1677-2262). Storing that `None` would leave the bound unset, so the
1322/// query would run with no time filter at all while the caller believed one was active --
1323/// a silently wrong answer, which is worse than a refused value.
1324pub fn bound_nanos(value: &str, when: SystemTime, axis: &'static str) -> Result<i64, RequestError> {
1325    system_time_to_nanos(when).ok_or_else(|| {
1326        invalid(
1327            axis,
1328            value,
1329            "that time is outside the range fdu can represent (about 1677 to 2262)",
1330        )
1331    })
1332}
1333
1334/// Parse a cache policy: `auto`, `on`, or `off`.
1335///
1336/// The three values that earlier releases also accepted are refused with the replacement,
1337/// since each still appears in scripts and a bare list of values would not say where
1338/// `only` went.
1339pub fn parse_cache_policy(value: &str, axis: &'static str) -> Result<CachePolicy, RequestError> {
1340    let stale_ok = if axis == AxisNames::FLAGS.cache {
1341        AxisNames::FLAGS.stale_ok
1342    } else {
1343        AxisNames::FIELDS.stale_ok
1344    };
1345    match value.trim().to_ascii_lowercase().as_str() {
1346        "auto" => Ok(CachePolicy::Auto),
1347        "on" => Ok(CachePolicy::On),
1348        "off" => Ok(CachePolicy::Off),
1349        "only" => Err(invalid(
1350            axis,
1351            "only",
1352            format!("answering from the snapshot alone is now {stale_ok}"),
1353        )),
1354        "refresh" => Err(invalid(
1355            axis,
1356            "refresh",
1357            "removed; use on, which also writes after a one-shot report",
1358        )),
1359        "read-only" => Err(invalid(
1360            axis,
1361            "read-only",
1362            "removed; auto no longer writes after a one-shot metadata report, and off reads nothing",
1363        )),
1364        other => Err(invalid(axis, other, "expected one of auto, on, off")),
1365    }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371
1372    #[test]
1373    fn each_grammar_names_its_axis_as_the_surface_spells_it() {
1374        let flags = &AxisNames::FLAGS;
1375        let fields = &AxisNames::FIELDS;
1376        let cases: [(&str, RequestError, RequestError); 6] = [
1377            (
1378                "kind",
1379                parse_kind(" Socket ", flags.kind).expect_err("unknown kind"),
1380                parse_kind(" Socket ", fields.kind).expect_err("unknown kind"),
1381            ),
1382            (
1383                "bound",
1384                parse_bound("two", flags.depth).expect_err("not a number"),
1385                parse_bound("two", fields.depth).expect_err("not a number"),
1386            ),
1387            (
1388                "sort",
1389                parse_sort("Newest", flags.sort).expect_err("unknown key"),
1390                parse_sort("Newest", fields.sort).expect_err("unknown key"),
1391            ),
1392            (
1393                "size",
1394                parse_size_metric("logical", flags.size).expect_err("unknown metric"),
1395                parse_size_metric("logical", fields.size).expect_err("unknown metric"),
1396            ),
1397            (
1398                "time",
1399                bound_nanos("2300-01-01T00:00:00Z", far_future(), flags.modified_since)
1400                    .expect_err("unrepresentable"),
1401                bound_nanos("2300-01-01T00:00:00Z", far_future(), fields.modified_since)
1402                    .expect_err("unrepresentable"),
1403            ),
1404            (
1405                "cache",
1406                parse_cache_policy("readonly", flags.cache).expect_err("unreleased alias"),
1407                parse_cache_policy("readonly", fields.cache).expect_err("unreleased alias"),
1408            ),
1409        ];
1410        let expected = [
1411            (
1412                "invalid --kind \"socket\": expected one of file, dir, symlink, other",
1413                "invalid kind \"socket\": expected one of file, dir, symlink, other",
1414            ),
1415            (
1416                "invalid --depth \"two\": expected a whole number or `all`",
1417                "invalid depth \"two\": expected a whole number or `all`",
1418            ),
1419            (
1420                "invalid --sort \"newest\": expected size, count, mtime, name, or a registered numeric metric",
1421                "invalid sort \"newest\": expected size, count, mtime, name, or a registered numeric metric",
1422            ),
1423            (
1424                "invalid --size \"logical\": expected allocated or apparent",
1425                "invalid size \"logical\": expected allocated or apparent",
1426            ),
1427            (
1428                "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
1429                 range fdu can represent (about 1677 to 2262)",
1430                "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
1431                 fdu can represent (about 1677 to 2262)",
1432            ),
1433            (
1434                "invalid --cache \"readonly\": expected one of auto, on, off",
1435                "invalid cache policy \"readonly\": expected one of auto, on, off",
1436            ),
1437        ];
1438        for ((grammar, flag, field), (flag_text, field_text)) in cases.into_iter().zip(expected) {
1439            assert_eq!(flag.message(&AxisNames::FLAGS), flag_text, "{grammar}");
1440            assert_eq!(field.message(&AxisNames::FIELDS), field_text, "{grammar}");
1441            // The axis travels with the value, so rendering never re-names it.
1442            assert_eq!(flag.message(&AxisNames::FIELDS), flag_text, "{grammar}");
1443        }
1444    }
1445
1446    fn far_future() -> SystemTime {
1447        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10_000_000_000)
1448    }
1449
1450    #[test]
1451    fn the_grammars_accept_their_whole_vocabulary() {
1452        let axis = AxisNames::FIELDS.kind;
1453        for (spelling, kind) in [
1454            ("file", EntryKind::File),
1455            ("DIR", EntryKind::Dir),
1456            (" symlink", EntryKind::Symlink),
1457            ("other", EntryKind::Other),
1458        ] {
1459            assert_eq!(parse_kind(spelling, axis), Ok(kind));
1460        }
1461        assert_eq!(parse_kinds("file, dir", axis), Ok(vec![EntryKind::File, EntryKind::Dir]));
1462        assert_eq!(parse_bound(" ALL ", axis), Ok(Bound::All));
1463        assert_eq!(parse_bound("0", axis), Ok(Bound::Limit(0)));
1464        for (spelling, key) in [
1465            ("size", SortKey::Size),
1466            ("count", SortKey::Count),
1467            ("MTIME", SortKey::Mtime),
1468            ("name", SortKey::Name),
1469            ("CODE_LINES", SortKey::Metric("code_lines")),
1470        ] {
1471            assert_eq!(parse_sort(spelling, axis), Ok(key));
1472        }
1473        assert_eq!(parse_size_metric("Allocated", axis), Ok(SizeMetric::Allocated));
1474        assert_eq!(parse_size_metric("apparent", axis), Ok(SizeMetric::Apparent));
1475        for (spelling, policy) in
1476            [("auto", CachePolicy::Auto), ("ON", CachePolicy::On), ("off", CachePolicy::Off)]
1477        {
1478            assert_eq!(parse_cache_policy(spelling, axis), Ok(policy));
1479        }
1480        // The retired values name their replacement, in each surface's own words.
1481        for (spelling, flags, fields) in [
1482            (
1483                "only",
1484                "invalid --cache \"only\": answering from the snapshot alone is now --stale-ok",
1485                "invalid cache policy \"only\": answering from the snapshot alone is now stale_ok",
1486            ),
1487            (
1488                "refresh",
1489                "invalid --cache \"refresh\": removed; use on, which also writes after a one-shot \
1490                 report",
1491                "invalid cache policy \"refresh\": removed; use on, which also writes after a \
1492                 one-shot report",
1493            ),
1494            (
1495                "read-only",
1496                "invalid --cache \"read-only\": removed; auto no longer writes after a one-shot \
1497                 metadata report, and off reads nothing",
1498                "invalid cache policy \"read-only\": removed; auto no longer writes after a \
1499                 one-shot metadata report, and off reads nothing",
1500            ),
1501        ] {
1502            let message = |axis| {
1503                parse_cache_policy(spelling, axis).expect_err("retired").message(&AxisNames::FLAGS)
1504            };
1505            assert_eq!(message(AxisNames::FLAGS.cache), flags);
1506            assert_eq!(message(AxisNames::FIELDS.cache), fields);
1507        }
1508        let epoch = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(2);
1509        assert_eq!(bound_nanos("@2", epoch, axis), Ok(2_000_000_000));
1510    }
1511
1512    #[test]
1513    fn a_kind_list_refuses_empty_and_repeated_entries() {
1514        let axis = AxisNames::FLAGS.kind;
1515        assert_eq!(
1516            parse_kinds("file,,dir", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1517            Err("invalid --kind \"file,,dir\": empty entry in the list".to_string())
1518        );
1519        assert_eq!(
1520            parse_kinds("file, FILE", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1521            Err("invalid --kind \"file, FILE\": \"FILE\" appears more than once".to_string())
1522        );
1523    }
1524
1525    /// Every refusal, in both vocabularies, quoted whole: the wording is the contract the
1526    /// goldens and the parity harness hold each surface to.
1527    #[test]
1528    fn every_refusal_renders_in_flag_and_field_wording() {
1529        let cases = [
1530            (
1531                RequestError::ViewNeedsContent(ViewSpec::Documents),
1532                "--view documents requires content analysis: add --analyze lines, code, words, \
1533                 or all; views never enable content analysis implicitly",
1534                "view documents requires content analysis: add analyze lines, code, words, or \
1535                 all; views never enable content analysis implicitly",
1536            ),
1537            (
1538                RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude),
1539                "--ignored=exclude needs .gitignore classification, and --no-gitignore turned it \
1540                 off; drop one of them",
1541                "ignored=exclude needs .gitignore classification, and read_controls turned it \
1542                 off; drop one of them",
1543            ),
1544            (
1545                RequestError::IgnoredWithoutObservation(IgnoredEntries::Only),
1546                "--ignored=only needs .gitignore classification, and --no-gitignore turned it \
1547                 off; drop one of them",
1548                "ignored=only needs .gitignore classification, and read_controls turned it off; \
1549                 drop one of them",
1550            ),
1551            (
1552                RequestError::ContentMismatch {
1553                    held: AnalysisSet::NONE,
1554                    requested: AnalysisSet::NONE.with_code(),
1555                },
1556                "--analyze lines,code cannot be answered by an index built with --analyze none; \
1557                 open the root again with --analyze lines,code",
1558                "analyze lines,code cannot be answered by an index built with analyze none; open \
1559                 the root again with analyze lines,code",
1560            ),
1561            (
1562                RequestError::ScopeUnsupported {
1563                    axis: ScopeAxis::OneFilesystem,
1564                    reason: ScopeAxis::OneFilesystem.reason(),
1565                },
1566                "unsupported scan configuration: --one-filesystem requires platform device \
1567                 identity",
1568                "unsupported scan configuration: one_filesystem requires platform device identity",
1569            ),
1570            (
1571                RequestError::ScopeUnsupported {
1572                    axis: ScopeAxis::FollowSymlinks,
1573                    reason: ScopeAxis::FollowSymlinks.reason(),
1574                },
1575                "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1576                 and filesystem-boundary semantics",
1577                "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1578                 and filesystem-boundary semantics",
1579            ),
1580            (
1581                RequestError::WatchContent,
1582                "--analyze is not yet supported with --watch; use a one-shot report",
1583                "analyze is not yet supported with watch; use a one-shot report",
1584            ),
1585            (
1586                RequestError::WatchCacheOnly,
1587                "--watch cannot start from a --stale-ok answer: nothing verifies what changed \
1588                 between the snapshot and the start of the watch; drop --stale-ok",
1589                "watch cannot start from a stale_ok answer: nothing verifies what changed between \
1590                 the snapshot and the start of the watch; drop stale_ok",
1591            ),
1592            (
1593                RequestError::StaleOkCacheOff,
1594                "--stale-ok answers from the snapshot, which --cache off never reads; drop one of \
1595                 them",
1596                "stale_ok answers from the snapshot, which cache policy off never reads; drop one \
1597                 of them",
1598            ),
1599            (
1600                RequestError::ViewLimit { attempted: 17, limit: 16 },
1601                "report request contains 17 views or omissions; limit is 16",
1602                "report request contains 17 views or omissions; limit is 16",
1603            ),
1604        ];
1605        for (refusal, flags, fields) in cases {
1606            assert_eq!(refusal.message(&AxisNames::FLAGS), flags);
1607            assert_eq!(refusal.message(&AxisNames::FIELDS), fields);
1608            assert_eq!(refusal.to_string(), fields, "a refusal displays in the library's words");
1609        }
1610    }
1611
1612    /// The watch-scope rule is the library's constant in the library's words, and the
1613    /// command line's words differ by knob names alone.
1614    ///
1615    /// Asserted against the constant rather than by quoting prose, so a rewording of the
1616    /// rule cannot leave this test measuring its own copy of it.
1617    #[test]
1618    fn the_watch_scope_refusal_substitutes_whole_words_only() {
1619        let source = crate::scan::WATCH_SCOPE_GUIDANCE;
1620        assert_eq!(RequestError::WatchScope.message(&AxisNames::FIELDS), source);
1621
1622        let text = RequestError::WatchScope.message(&AxisNames::FLAGS);
1623        assert!(!text.contains("---"), "{text} re-substituted a replacement");
1624
1625        // Hyphens stay inside a token, so `--scan-depth` is one word and not three.
1626        let names_word = |haystack: &str, word: &str| {
1627            haystack
1628                .split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
1629                .any(|w| w == word)
1630        };
1631        let (fields, flags) = (&AxisNames::FIELDS, &AxisNames::FLAGS);
1632        let vocabulary = [
1633            (fields.scan_depth, flags.scan_depth),
1634            (fields.one_filesystem, flags.one_filesystem),
1635            (fields.modified_since, flags.modified_since),
1636            (fields.depth, flags.depth),
1637            (fields.include, flags.include),
1638        ];
1639        for (field, flag) in vocabulary {
1640            if names_word(source, field) {
1641                assert!(text.contains(flag), "{text} must name {flag} where the rule says {field}");
1642            }
1643            assert!(!names_word(&text, field), "{text} still names {field} untranslated");
1644        }
1645        let mut rebuilt = text.clone();
1646        for (field, flag) in vocabulary {
1647            rebuilt = rebuilt.replace(flag, field);
1648        }
1649        assert_eq!(rebuilt, source, "the flag wording must be the library's, knob names aside");
1650    }
1651
1652    #[test]
1653    fn documents_and_code_are_the_views_that_need_content() {
1654        for content in [AnalysisSet::NONE, AnalysisSet::NONE.with_lines(), AnalysisSet::ALL] {
1655            for view in ViewSpec::ALL {
1656                let refused = check_views(&[view], content).is_err();
1657                assert_eq!(
1658                    refused,
1659                    (view == ViewSpec::Documents && !content.is_enabled())
1660                        || (view == ViewSpec::Code && !content.includes_code()),
1661                    "{view:?}"
1662                );
1663            }
1664        }
1665        assert_eq!(check_observation(IgnoredEntries::Include, false), Ok(()));
1666        for ignored in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
1667            assert_eq!(check_observation(ignored, true), Ok(()));
1668            assert_eq!(
1669                check_observation(ignored, false),
1670                Err(RequestError::IgnoredWithoutObservation(ignored))
1671            );
1672        }
1673    }
1674
1675    // ---- the request model ----
1676
1677    use crate::content::AnalysisRequest;
1678
1679    fn root() -> &'static Path {
1680        Path::new("/tree")
1681    }
1682
1683    fn instant() -> SystemTime {
1684        SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
1685    }
1686
1687    /// A spec over the test root whose basis is every default and whose read is `read`.
1688    #[allow(clippy::large_types_passed_by_value)]
1689    fn reading(read: ReadSpec<'_>) -> RequestSpec<'_> {
1690        RequestSpec { read, ..RequestSpec::new(root()) }
1691    }
1692
1693    fn built(spec: &RequestSpec<'_>) -> Request {
1694        Request::build(spec, instant(), &AxisNames::FIELDS).expect("the spec parses")
1695    }
1696
1697    #[test]
1698    fn retained_population_allows_narrower_reads_only_from_include() {
1699        let read = |basis: Basis, ignored| {
1700            Request::read(
1701                basis,
1702                &ReadSpec { ignored, ..ReadSpec::default() },
1703                instant(),
1704                &AxisNames::FIELDS,
1705            )
1706        };
1707        let include = Basis {
1708            root: root().to_path_buf(),
1709            scope: Scope::default(),
1710            content: AnalysisSet::NONE,
1711        };
1712        assert_eq!(
1713            read(include.clone(), Some("exclude")).expect("narrow exclude").query.selection.ignored,
1714            IgnoredEntries::Exclude
1715        );
1716        assert_eq!(
1717            read(include, Some("only")).expect("narrow only").query.selection.ignored,
1718            IgnoredEntries::Only
1719        );
1720
1721        let excluded = Basis {
1722            root: root().to_path_buf(),
1723            scope: Scope { population: IgnoredEntries::Exclude, ..Scope::default() },
1724            content: AnalysisSet::NONE,
1725        };
1726        assert_eq!(
1727            read(excluded.clone(), None).expect("basis default").query.selection.ignored,
1728            IgnoredEntries::Exclude
1729        );
1730        assert!(matches!(
1731            read(excluded, Some("only")),
1732            Err(RequestError::PopulationMismatch {
1733                held: IgnoredEntries::Exclude,
1734                requested: IgnoredEntries::Only
1735            })
1736        ));
1737    }
1738
1739    fn refusal(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> String {
1740        Request::build(spec, instant(), axes).expect_err("the spec is refused").message(axes)
1741    }
1742
1743    /// A spec that names nothing builds the table, and every type that also declares a
1744    /// default for one of these axes agrees with it.
1745    #[test]
1746    fn list_formats_are_resolved_and_validated_in_the_shared_request() {
1747        use crate::report_format::Format;
1748        for (view, format, valid, tree) in [
1749            (None, None, true, true),
1750            (Some("list"), Some("tree"), true, true),
1751            (Some("list"), Some("paths"), true, false),
1752            (None, Some("json"), true, false),
1753            (Some("files"), None, true, false),
1754            (Some("files"), Some("tree"), true, true),
1755            (Some("tree"), Some("long"), true, false),
1756            (Some("largest"), Some("long"), true, false),
1757            (Some("summary"), Some("long"), false, false),
1758            (Some("full"), Some("paths"), false, false),
1759            (Some("list,summary"), Some("tree"), false, false),
1760            (Some("list,summary"), Some("json"), true, false),
1761        ] {
1762            let spec = RequestSpec {
1763                read: ReadSpec { views: view, format, ..ReadSpec::new() },
1764                ..RequestSpec::new(Path::new("/absent"))
1765            };
1766            let request =
1767                Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("grammar");
1768            assert_eq!(request.validate().is_ok(), valid, "{view:?} {format:?}");
1769            if valid {
1770                assert_eq!(request.query.tree_for(request.query.views[0]), tree);
1771            }
1772        }
1773        let spec = RequestSpec::new(Path::new("/absent"));
1774        let request =
1775            Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("default");
1776        assert_eq!(request.query.views, [ViewSpec::List]);
1777        assert!(
1778            !request.query.needs_selection_walk(),
1779            "an ordinary tree must retain its bounded projection cost"
1780        );
1781        let mut flat = request.clone();
1782        flat.query.format = Format::Paths;
1783        assert!(flat.query.needs_selection_walk());
1784        assert_eq!(flat.query.limit_for(ViewSpec::List), Bound::All);
1785        assert_eq!(request.query.limit_for(ViewSpec::List), Bound::All);
1786        assert_eq!(request.query.depth_for(ViewSpec::List), Bound::Limit(5));
1787    }
1788
1789    #[test]
1790    fn an_empty_spec_builds_the_defaults_table() {
1791        let defaults = Request::DEFAULTS;
1792        assert_eq!(defaults.size, SizeMetric::Allocated);
1793        assert_eq!(defaults.words_per_page, 250);
1794        assert_eq!(defaults.content, AnalysisSet::NONE);
1795        assert!(defaults.read_controls);
1796        assert_eq!(defaults.control_limits, ControlLimits::default());
1797        // A watch serves no content -- `WatchContent` refuses one that names an analyzer
1798        // -- so its view is the report default for none, derived rather than declared.
1799        assert_eq!(defaults.report_view(AnalysisSet::NONE), ViewSpec::List);
1800        for content in [
1801            AnalysisSet::NONE,
1802            AnalysisSet::NONE.with_lines(),
1803            AnalysisSet::NONE.with_code(),
1804            AnalysisSet::NONE.with_words(),
1805            AnalysisSet::ALL,
1806        ] {
1807            assert_eq!(defaults.report_view(content), ViewSpec::default_for(content));
1808        }
1809
1810        let request = built(&RequestSpec::new(root()));
1811        assert_eq!(request.basis.root, root());
1812        assert_eq!(request.basis.content, defaults.content);
1813        assert_eq!(request.basis.scope.read_controls, defaults.read_controls);
1814        assert_eq!(request.basis.scope.control_limits, defaults.control_limits);
1815        assert_eq!(request.basis.scope.max_depth, None);
1816        assert!(!request.basis.scope.one_filesystem);
1817        assert_eq!(request.query.views, vec![defaults.report_view(defaults.content)]);
1818        assert!(request.query.omitted_views.is_empty());
1819        assert_eq!(request.query.words_per_page, defaults.words_per_page);
1820        assert_eq!(request.query.selection.size, defaults.size);
1821        assert!(request.query.selection.is_unfiltered());
1822        let selection = &request.query.selection;
1823        assert_eq!((selection.depth, selection.limit, selection.sort), (None, None, None));
1824        assert!(!selection.reverse);
1825        assert_eq!(*request.query.axes, AxisNames::FIELDS);
1826        assert_eq!(request.now, instant());
1827        request.validate().expect("the defaults are a valid request");
1828
1829        // The other homes of these defaults read the table rather than restating it.
1830        assert_eq!(SizeMetric::default(), defaults.size);
1831        assert_eq!(Selection::default().size, defaults.size);
1832        assert_eq!(Query::default().words_per_page, defaults.words_per_page);
1833        assert_eq!(ScanConfig::default().read_controls, defaults.read_controls);
1834        assert_eq!(ScanConfig::default().control_limits, defaults.control_limits);
1835        assert_eq!(AnalysisRequest::default().profile, defaults.content);
1836        assert_eq!(AnalysisSet::default(), defaults.content);
1837    }
1838
1839    #[test]
1840    fn every_axis_of_a_spec_reaches_its_typed_value() {
1841        let include = ["*.rs".to_string()];
1842        let exclude = ["target/**".to_string()];
1843        let spec = RequestSpec {
1844            scan_depth: Some("3"),
1845            one_filesystem: true,
1846            read_controls: Some(false),
1847            control_budget: Some("all"),
1848            control_line_limit: Some("64KiB"),
1849            analyze: Some("code"),
1850            read: ReadSpec {
1851                views: Some("languages,tree"),
1852                format: None,
1853                words_per_page: Some("300"),
1854                include: &include,
1855                exclude: &exclude,
1856                min_size: Some("1KiB"),
1857                modified_since: Some("@1700000000"),
1858                modified_before: Some("@1800000000"),
1859                kinds: Some("file,dir"),
1860                ignored: Some("include"),
1861                depth: Some("all"),
1862                min_share: Some("1%"),
1863                breadth: Some("all"),
1864                limit: Some("5"),
1865                sort: Some("name"),
1866                reverse: true,
1867                size: Some("apparent"),
1868            },
1869            ..RequestSpec::new(root())
1870        };
1871        let request = Request::build(&spec, instant(), &AxisNames::FLAGS).expect("parses");
1872        let scope = &request.basis.scope;
1873        assert_eq!(scope.max_depth, Some(3));
1874        assert!(scope.one_filesystem);
1875        assert!(!scope.read_controls);
1876        assert_eq!(scope.control_limits, ControlLimits { budget: None, line_limit: Some(65_536) });
1877        assert_eq!(request.basis.content, AnalysisSet::NONE.with_code());
1878        assert_eq!(request.query.views, vec![ViewSpec::Languages, ViewSpec::Tree]);
1879        assert_eq!(request.query.words_per_page, 300);
1880        assert_eq!(*request.query.axes, AxisNames::FLAGS);
1881        let selection = &request.query.selection;
1882        assert_eq!(selection.include.len(), 1);
1883        assert_eq!(selection.exclude.len(), 1);
1884        assert_eq!(selection.min_size, Some(1024));
1885        assert_eq!(selection.modified.since, Some(1_700_000_000_000_000_000));
1886        assert_eq!(selection.modified.before, Some(1_800_000_000_000_000_000));
1887        assert_eq!(selection.kinds, vec![EntryKind::File, EntryKind::Dir]);
1888        assert_eq!(selection.ignored, IgnoredEntries::Include);
1889        assert_eq!(selection.depth, Some(Bound::All));
1890        assert_eq!(selection.limit, Some(Bound::Limit(5)));
1891        assert_eq!(selection.sort, Some(SortKey::Name));
1892        assert!(selection.reverse);
1893        assert_eq!(selection.size, SizeMetric::Apparent);
1894    }
1895
1896    /// Relative windows are resolved once, against the request's own instant, so the
1897    /// selection a request carries is absolute and building it again at the same instant
1898    /// selects exactly the same entries however much later that happens.
1899    #[test]
1900    fn relative_windows_resolve_against_the_requests_instant() {
1901        let spec = reading(ReadSpec {
1902            modified_since: Some("2h"),
1903            modified_before: Some("now"),
1904            ..ReadSpec::new()
1905        });
1906        let request = built(&spec);
1907        let now = system_time_to_nanos(instant()).expect("representable");
1908        let two_hours = 2 * 60 * 60 * 1_000_000_000;
1909        assert_eq!(request.now, instant());
1910        assert_eq!(request.query.selection.modified.since, Some(now - two_hours));
1911        assert_eq!(request.query.selection.modified.before, Some(now));
1912
1913        let later = instant() + Duration::from_secs(60);
1914        let moved = Request::build(&spec, later, &AxisNames::FIELDS).expect("parses");
1915        assert_eq!(moved.query.selection.modified.since, Some(now - two_hours + 60_000_000_000));
1916        let again = built(&spec);
1917        assert_eq!(again.query.selection.modified.since, request.query.selection.modified.since);
1918    }
1919
1920    /// Each refusal `build` raises reads exactly as the path it replaces does today, in
1921    /// both vocabularies.
1922    #[test]
1923    fn build_refuses_each_axis_in_the_surfaces_words() {
1924        let spec = RequestSpec::new(root());
1925        let analyze = RequestSpec { analyze: Some("deep"), ..spec };
1926        for (axes, label) in [(&AxisNames::FLAGS, "--analyze"), (&AxisNames::FIELDS, "analyze")] {
1927            let today = AnalysisSet::parse_labeled("deep", label).expect_err("refused");
1928            assert_eq!(refusal(&analyze, axes), today);
1929        }
1930        for views in ["tree,tree", "tree,,types", "full,tree", "bogus"] {
1931            let spec = reading(ReadSpec { views: Some(views), ..ReadSpec::new() });
1932            for (axes, label) in [(&AxisNames::FLAGS, "--view"), (&AxisNames::FIELDS, "view")] {
1933                let today =
1934                    ViewSpec::resolve(Some(views), AnalysisSet::NONE, label).expect_err("refused");
1935                assert_eq!(refusal(&spec, axes), today);
1936            }
1937        }
1938
1939        let cases: [(RequestSpec<'_>, &str, &str); 7] = [
1940            (
1941                reading(ReadSpec { words_per_page: Some("0"), ..ReadSpec::new() }),
1942                "invalid --words-per-page \"0\": expected a positive integer",
1943                "invalid words_per_page \"0\": expected a positive integer",
1944            ),
1945            (
1946                RequestSpec { scan_depth: Some("deep"), ..spec },
1947                "invalid --scan-depth \"deep\": expected a whole number",
1948                "invalid max_depth \"deep\": expected a whole number",
1949            ),
1950            (
1951                RequestSpec { control_budget: Some("lots"), ..spec },
1952                "invalid --gitignore-budget \"lots\": expected a number before the unit, as in \
1953                 `10M`, or `all` for no bound",
1954                "invalid control_budget \"lots\": expected a number before the unit, as in \
1955                 `10M`, or `all` for no bound",
1956            ),
1957            (
1958                reading(ReadSpec { ignored: Some("maybe"), ..ReadSpec::new() }),
1959                "invalid --ignored \"maybe\": expected one of include, \
1960                 exclude, only",
1961                "invalid ignored \"maybe\": expected one of include, exclude, only",
1962            ),
1963            (
1964                reading(ReadSpec { kinds: Some("file,socket"), ..ReadSpec::new() }),
1965                "invalid --kind \"socket\": expected one of file, dir, symlink, other",
1966                "invalid kind \"socket\": expected one of file, dir, symlink, other",
1967            ),
1968            (
1969                reading(ReadSpec { min_size: Some("10X"), ..ReadSpec::new() }),
1970                "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
1971                 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
1972                "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
1973                 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
1974            ),
1975            (
1976                reading(ReadSpec {
1977                    modified_since: Some("2300-01-01T00:00:00Z"),
1978                    ..ReadSpec::new()
1979                }),
1980                "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
1981                 range fdu can represent (about 1677 to 2262)",
1982                "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
1983                 fdu can represent (about 1677 to 2262)",
1984            ),
1985        ];
1986        for (spec, flags, fields) in cases {
1987            assert_eq!(refusal(&spec, &AxisNames::FLAGS), flags);
1988            assert_eq!(refusal(&spec, &AxisNames::FIELDS), fields);
1989        }
1990    }
1991
1992    #[test]
1993    fn analyzer_names_in_view_axis_point_to_both_correct_axes() {
1994        for (token, analyzer, view) in
1995            [("LiNeS", "lines", "families"), ("words", "words", "documents")]
1996        {
1997            let spec = reading(ReadSpec { views: Some(token), ..ReadSpec::new() });
1998            for axes in [&AxisNames::FLAGS, &AxisNames::FIELDS] {
1999                let error = Request::build(&spec, instant(), axes).expect_err("not a view");
2000                assert!(matches!(error, RequestError::AnalyzerNamedAsView { .. }));
2001                assert_eq!(
2002                    error.message(axes),
2003                    format!(
2004                        "invalid {} {token:?}: {analyzer} is an analyzer; use {}={analyzer} with {}={view}",
2005                        axes.view, axes.analyze, axes.view
2006                    )
2007                );
2008            }
2009        }
2010        let combination = reading(ReadSpec { views: Some("full,words"), ..ReadSpec::new() });
2011        assert_eq!(
2012            refusal(&combination, &AxisNames::FLAGS),
2013            ViewSpec::resolve(Some("full,words"), AnalysisSet::NONE, "--view")
2014                .expect_err("full is exclusive")
2015        );
2016    }
2017
2018    /// The order `build` names axes in, when more than one of them is wrong.
2019    ///
2020    /// A contract, not an accident: every surface renders the first refusal and stops, so
2021    /// the order decides which mistake a caller is told about, and one that drifted would
2022    /// change what two doors say about the same command line. Pinned by fixing one axis at
2023    /// a time and watching the next one speak -- content, views, the selection, the page
2024    /// denominator, then scope, which is the order the doc comment publishes and the order
2025    /// the command line reads its flags.
2026    #[test]
2027    fn build_names_a_bad_axis_in_the_order_it_publishes() {
2028        let everything = RequestSpec {
2029            scan_depth: Some("deep"),
2030            analyze: Some("deep"),
2031            read: ReadSpec {
2032                views: Some("bogus"),
2033                depth: Some("two"),
2034                words_per_page: Some("0"),
2035                ..ReadSpec::new()
2036            },
2037            ..RequestSpec::new(root())
2038        };
2039        let steps: [(RequestSpec<'_>, &str); 5] = [
2040            (everything, "analyze"),
2041            (RequestSpec { analyze: None, ..everything }, "view"),
2042            (
2043                RequestSpec {
2044                    analyze: None,
2045                    read: ReadSpec { views: None, ..everything.read },
2046                    ..everything
2047                },
2048                "depth",
2049            ),
2050            (
2051                RequestSpec {
2052                    analyze: None,
2053                    read: ReadSpec { views: None, depth: None, ..everything.read },
2054                    ..everything
2055                },
2056                "words_per_page",
2057            ),
2058            (
2059                RequestSpec {
2060                    analyze: None,
2061                    read: ReadSpec {
2062                        views: None,
2063                        format: None,
2064                        depth: None,
2065                        words_per_page: None,
2066                        ..everything.read
2067                    },
2068                    ..everything
2069                },
2070                "max_depth",
2071            ),
2072        ];
2073        for (spec, axis) in steps {
2074            let refused = refusal(&spec, &AxisNames::FIELDS);
2075            assert!(
2076                refused.starts_with(&format!("invalid {axis} ")),
2077                "expected {axis} to speak next, got {refused}"
2078            );
2079        }
2080        // And the last step is the only thing still wrong, so fixing it parses.
2081        Request::build(
2082            &RequestSpec {
2083                scan_depth: None,
2084                analyze: None,
2085                read: ReadSpec::new(),
2086                ..RequestSpec::new(root())
2087            },
2088            instant(),
2089            &AxisNames::FIELDS,
2090        )
2091        .expect("nothing left to refuse");
2092    }
2093
2094    fn request_with(views: &[ViewSpec], selection: Selection, basis: Basis) -> Request {
2095        Request::new(
2096            basis,
2097            Query { selection, views: views.to_vec(), ..Query::default() },
2098            instant(),
2099        )
2100    }
2101
2102    fn basis(content: AnalysisSet, read_controls: bool) -> Basis {
2103        Basis {
2104            root: root().to_path_buf(),
2105            scope: Scope { read_controls, ..Scope::default() },
2106            content,
2107        }
2108    }
2109
2110    /// Moved from the report reader, where a surface-only check stood in for the model.
2111    #[test]
2112    fn language_grouping_is_metadata_only_while_documents_require_analysis() {
2113        let enabled = [
2114            AnalysisSet::NONE.with_lines(),
2115            AnalysisSet::NONE.with_code(),
2116            AnalysisSet::NONE.with_words(),
2117            AnalysisSet::ALL,
2118        ];
2119        for content in std::iter::once(AnalysisSet::NONE).chain(enabled) {
2120            request_with(&[ViewSpec::Languages], Selection::default(), basis(content, true))
2121                .validate()
2122                .expect("language grouping never requires content I/O");
2123        }
2124
2125        let documents = request_with(
2126            &[ViewSpec::Documents],
2127            Selection::default(),
2128            basis(AnalysisSet::NONE, true),
2129        );
2130        assert_eq!(documents.validate(), Err(RequestError::ViewNeedsContent(ViewSpec::Documents)));
2131        for content in enabled {
2132            request_with(&[ViewSpec::Documents], Selection::default(), basis(content, true))
2133                .validate()
2134                .expect("every enabled profile includes the basic document metrics");
2135        }
2136
2137        request_with(
2138            &[ViewSpec::Types, ViewSpec::Families],
2139            Selection::default(),
2140            basis(AnalysisSet::NONE, true),
2141        )
2142        .validate()
2143        .expect("metadata grouping never requires content I/O");
2144    }
2145
2146    #[test]
2147    fn code_view_and_metric_sort_require_their_registered_analyzer() {
2148        let plain = basis(AnalysisSet::NONE, true);
2149        let code = request_with(&[ViewSpec::Code], Selection::default(), plain.clone());
2150        assert_eq!(
2151            code.validate(),
2152            Err(RequestError::NeedsAnalyzer { item: "code view", analyzer: "code" })
2153        );
2154        let selection =
2155            Selection { sort: Some(SortKey::Metric("code_lines")), ..Selection::default() };
2156        let files = request_with(&[ViewSpec::Files], selection.clone(), plain);
2157        assert_eq!(
2158            files.validate(),
2159            Err(RequestError::NeedsAnalyzer { item: "code_lines", analyzer: "code" })
2160        );
2161        request_with(&[ViewSpec::Files], selection, basis(AnalysisSet::NONE.with_code(), true))
2162            .validate()
2163            .expect("code metrics are available with the code analyzer");
2164    }
2165
2166    #[test]
2167    fn extension_view_refuses_metric_sort_before_reading() {
2168        let held = basis(AnalysisSet::NONE.with_code(), true);
2169        let selection =
2170            Selection { sort: Some(SortKey::Metric("code_lines")), ..Selection::default() };
2171        let request = request_with(&[ViewSpec::Extensions], selection.clone(), held.clone());
2172        let expected = invalid(
2173            request.query.axes.sort,
2174            "code_lines",
2175            "extensions cannot sort by content metrics; use size, count, or name, or select files or another metric-capable view",
2176        );
2177        assert_eq!(request.validate(), Err(expected.clone()));
2178        assert_eq!(request.validate_read(&held), Err(expected));
2179        request_with(&[ViewSpec::Files], selection, held)
2180            .validate()
2181            .expect("files retain metric sorting");
2182    }
2183
2184    /// A scope this build cannot honour is refused by request validation itself.
2185    ///
2186    /// Both entry points, because they cover different routes: `validate` is what a
2187    /// one-shot report and both command lines ask, `validate_read` what a retained index,
2188    /// an opened root, and a watch session ask. Both weigh the scope the *request* names,
2189    /// not the one a holder retained, so the refusal cannot wait for a scan that a
2190    /// cache-only read never runs -- which is how one request came to name a snapshot miss
2191    /// under one delivery and a scope refusal under another.
2192    ///
2193    /// `follow_symlinks` is refused on every platform, which is how the `one_filesystem`
2194    /// rule -- refused only where the platform has no device identity -- is tested here.
2195    #[test]
2196    fn a_scope_this_build_cannot_honour_is_refused_by_request_validation() {
2197        let held = basis(AnalysisSet::NONE, true);
2198        let mut asked = held.clone();
2199        asked.scope.follow_symlinks = true;
2200        let request = request_with(&[ViewSpec::Summary], Selection::default(), asked);
2201        let refusal = RequestError::ScopeUnsupported {
2202            axis: ScopeAxis::FollowSymlinks,
2203            reason: ScopeAxis::FollowSymlinks.reason(),
2204        };
2205
2206        assert_eq!(request.validate(), Err(refusal.clone()));
2207        assert_eq!(request.validate_read(&held), Err(refusal));
2208        // The sentence is the one the capability rule states, not a copy of it kept here:
2209        // an engine-internal caller that never builds a request prints the same words.
2210        assert_eq!(
2211            ScanConfig { follow_symlinks: true, ..ScanConfig::default() }
2212                .unsupported_axis()
2213                .expect("no build follows symbolic links")
2214                .reason(),
2215            ScopeAxis::FollowSymlinks.reason()
2216        );
2217        request_with(&[ViewSpec::Summary], Selection::default(), held)
2218            .validate()
2219            .expect("a scope this build honours is not refused");
2220    }
2221
2222    /// Moved from the report reader: the refusal half of
2223    /// `an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by`. The
2224    /// reader keeps the library-path half, its typed refusal of an unvalidated query.
2225    #[test]
2226    fn a_scope_that_observes_no_control_state_refuses_selection_by_ignored_state() {
2227        let exclude = Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() };
2228        let only = Selection { ignored: IgnoredEntries::Only, ..Selection::default() };
2229        let blind = basis(AnalysisSet::NONE, false);
2230
2231        let refused = request_with(&[ViewSpec::Summary], exclude.clone(), blind.clone())
2232            .validate()
2233            .expect_err("no entry can be shown to be ignored");
2234        assert_eq!(
2235            refused.message(&AxisNames::FLAGS),
2236            "--ignored=exclude needs .gitignore classification, and --no-gitignore turned it \
2237             off; drop one of them"
2238        );
2239        let refused = request_with(&[ViewSpec::Summary], only, blind.clone())
2240            .validate()
2241            .expect_err("no entry can be shown to be ignored");
2242        assert_eq!(
2243            refused.message(&AxisNames::FIELDS),
2244            "ignored=only needs .gitignore classification, and read_controls turned it off; \
2245             drop one of them"
2246        );
2247        request_with(&[ViewSpec::Summary], exclude, basis(AnalysisSet::NONE, true))
2248            .validate()
2249            .expect("an observing scope can select by ignored state");
2250        request_with(&[ViewSpec::Summary], Selection::default(), blind)
2251            .validate()
2252            .expect("admitting every entry needs no classification");
2253    }
2254
2255    #[test]
2256    fn a_read_is_refused_by_a_holder_of_other_content() {
2257        let request = built(&RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) });
2258        let held = basis(AnalysisSet::NONE, true);
2259        assert_eq!(
2260            request.validate_read(&held),
2261            Err(RequestError::ContentMismatch {
2262                held: AnalysisSet::NONE,
2263                requested: AnalysisSet::NONE.with_lines(),
2264            })
2265        );
2266        // A wider store is refused too: serving a narrower request from it is a projection
2267        // this model does not define.
2268        assert_eq!(
2269            request.validate_read(&basis(AnalysisSet::ALL, true)),
2270            Err(RequestError::ContentMismatch {
2271                held: AnalysisSet::ALL,
2272                requested: AnalysisSet::NONE.with_lines(),
2273            })
2274        );
2275        request
2276            .validate_read(&basis(AnalysisSet::NONE.with_lines(), true))
2277            .expect("equal content serves");
2278
2279        // The remaining rules read what the holder observed, not what the request assumed.
2280        let exclude = built(&reading(ReadSpec { ignored: Some("exclude"), ..ReadSpec::new() }));
2281        assert_eq!(
2282            exclude.validate_read(&basis(AnalysisSet::NONE, false)),
2283            Err(RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude))
2284        );
2285        // Content equality comes first, so the refusal names the more fundamental mismatch.
2286        let documents = request_with(
2287            &[ViewSpec::Documents],
2288            Selection::default(),
2289            basis(AnalysisSet::NONE.with_words(), true),
2290        );
2291        assert!(matches!(
2292            documents.validate_read(&basis(AnalysisSet::NONE, true)),
2293            Err(RequestError::ContentMismatch { .. })
2294        ));
2295    }
2296
2297    /// What a read validates against is what the index observed, not the configuration that
2298    /// happened to make it: a `ScanConfig`'s delivery fields cannot be recovered from an
2299    /// index and no answer depends on them.
2300    #[test]
2301    fn the_basis_a_retained_index_holds_is_what_it_observed() {
2302        let blind =
2303            crate::Index::new_with_scope("/root", crate::test_support::not_observing_controls());
2304        let held = Basis::held_by(&blind);
2305        assert_eq!(held.root, Path::new("/root"));
2306        assert!(!held.scope.read_controls, "an index that read no rule says so");
2307        assert_eq!(held.content, AnalysisSet::NONE, "a metadata index holds no analyzer");
2308
2309        let observing =
2310            crate::Index::new_with_scope("/root", crate::test_support::observing_controls());
2311        assert!(Basis::held_by(&observing).scope.read_controls);
2312
2313        // The limits are the tier's own, not the table's: an index admitted its control
2314        // files under the limits it was built with, and a basis that restated the defaults
2315        // here would compare equal to one taken under any other budget.
2316        let tight = ControlLimits { budget: Some(4_096), line_limit: None };
2317        let narrow = crate::Index::new_with_config(
2318            "/root",
2319            &ScanConfig { read_controls: true, control_limits: tight, ..ScanConfig::default() },
2320        );
2321        assert_eq!(Basis::held_by(&narrow).scope.control_limits, tight);
2322        assert_ne!(tight, Request::DEFAULTS.control_limits, "the fixture must differ");
2323        assert_eq!(
2324            Basis::held_by(&blind).scope.control_limits,
2325            Request::DEFAULTS.control_limits,
2326            "a scan that read no rule applied none, and names the table's"
2327        );
2328
2329        // The scope a request would have to be built with to read this index.
2330        let request =
2331            built(&RequestSpec { read_controls: Some(false), ..RequestSpec::new(root()) });
2332        request
2333            .validate_read(&Basis::held_by(&blind))
2334            .expect("the index's own basis answers a request built the same way");
2335    }
2336
2337    /// Each rule in the order `validate_delivery` applies it, and each one only under a
2338    /// watch: every one of these is a legal one-shot request.
2339    #[test]
2340    fn a_watch_refuses_what_it_cannot_keep_current() {
2341        let one_shot = Delivery {
2342            stale_ok: false,
2343            cache: CachePolicy::Auto,
2344            cache_path: None,
2345            accept_partial: false,
2346            watch: None,
2347            workers: Workers::default(),
2348            batch_size: ScanConfig::default().batch_size,
2349            order: crate::ScanOrder::default(),
2350        };
2351        let watching = Delivery { watch: Some(WatchDelivery::default()), ..one_shot.clone() };
2352        let cases = [
2353            (
2354                RequestSpec { scan_depth: Some("2"), ..RequestSpec::new(root()) },
2355                RequestError::WatchScope,
2356            ),
2357            (
2358                RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) },
2359                RequestError::WatchScope,
2360            ),
2361            (
2362                RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) },
2363                RequestError::WatchContent,
2364            ),
2365        ];
2366        for (spec, expected) in cases {
2367            let request = built(&spec);
2368            assert_eq!(request.validate_delivery(&watching), Err(expected));
2369            request.validate_delivery(&one_shot).expect("a one-shot delivers all three");
2370        }
2371
2372        let plain = built(&RequestSpec::new(root()));
2373        plain.validate_delivery(&watching).expect("a full-scope metadata watch is deliverable");
2374        assert_eq!(
2375            plain.validate_delivery(&Delivery { stale_ok: true, ..watching.clone() }),
2376            Err(RequestError::WatchCacheOnly),
2377            "nothing verifies the window between the snapshot and the start of the watch"
2378        );
2379        plain
2380            .validate_delivery(&Delivery { stale_ok: true, ..one_shot })
2381            .expect("a one-shot report is exactly what a snapshot answers");
2382    }
2383
2384    /// The order the three watch rules speak in, when a request breaks more than one.
2385    ///
2386    /// The command line and the Python API both render the first refusal and stop, so this
2387    /// decides which of three true statements a caller is told, and a rule moved within
2388    /// `validate_delivery` would silently change that. Scope first, because it is a fact
2389    /// about what a watcher can observe at all; then content, which is about what stays
2390    /// current; then the cache policy, which is about the window before the watch started.
2391    #[test]
2392    fn the_watch_rules_speak_in_one_order() {
2393        let cache_only_watch = Delivery {
2394            cache: CachePolicy::Auto,
2395            stale_ok: true,
2396            cache_path: None,
2397            accept_partial: false,
2398            watch: Some(WatchDelivery::default()),
2399            workers: Workers::default(),
2400            batch_size: ScanConfig::default().batch_size,
2401            order: crate::ScanOrder::default(),
2402        };
2403        let everything = RequestSpec {
2404            scan_depth: Some("2"),
2405            analyze: Some("lines"),
2406            ..RequestSpec::new(root())
2407        };
2408        let steps = [
2409            (everything, RequestError::WatchScope),
2410            (RequestSpec { scan_depth: None, ..everything }, RequestError::WatchContent),
2411            (
2412                RequestSpec { scan_depth: None, analyze: None, ..everything },
2413                RequestError::WatchCacheOnly,
2414            ),
2415        ];
2416        for (spec, expected) in steps {
2417            assert_eq!(built(&spec).validate_delivery(&cache_only_watch), Err(expected));
2418        }
2419        built(&RequestSpec::new(root()))
2420            .validate_delivery(&Delivery { stale_ok: false, ..cache_only_watch })
2421            .expect("nothing left to refuse");
2422    }
2423
2424    /// The other half of the watch-scope rule, which the command-line golden cannot
2425    /// assert: where a build cannot honor `one_filesystem` at all, the request is refused
2426    /// for that reason first, so the message differs by platform.
2427    #[cfg(unix)]
2428    #[test]
2429    fn a_watch_refuses_one_filesystem_where_the_build_honors_it() {
2430        let watch = Delivery {
2431            stale_ok: false,
2432            cache: CachePolicy::Auto,
2433            cache_path: None,
2434            accept_partial: false,
2435            watch: Some(WatchDelivery::default()),
2436            workers: Workers::default(),
2437            batch_size: ScanConfig::default().batch_size,
2438            order: crate::ScanOrder::default(),
2439        };
2440        let spec = RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) };
2441        let request = built(&spec);
2442        request.validate().expect("one filesystem is honored on this build");
2443        assert_eq!(request.validate_delivery(&watch), Err(RequestError::WatchScope));
2444    }
2445
2446    #[test]
2447    fn a_request_is_refused_past_the_views_one_report_carries() {
2448        let mut request = built(&RequestSpec::new(root()));
2449        request.query.views = vec![ViewSpec::Summary; crate::MAX_REPORT_VIEWS];
2450        request.validate().expect("the limit itself is accepted");
2451        request.query.omitted_views = vec![ViewSpec::Documents];
2452        assert_eq!(
2453            request.validate(),
2454            Err(RequestError::ViewLimit {
2455                attempted: crate::MAX_REPORT_VIEWS + 1,
2456                limit: crate::MAX_REPORT_VIEWS,
2457            })
2458        );
2459    }
2460}