1use 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#[derive(Clone, Debug)]
35#[allow(clippy::struct_excessive_bools)]
36pub struct Scope {
37 pub max_depth: Option<usize>,
39 pub follow_symlinks: bool,
41 pub one_filesystem: bool,
43 pub hidden: Option<std::sync::Arc<crate::admission::HiddenPolicy>>,
45 pub exclude_special: bool,
47 pub types: Option<std::sync::Arc<crate::classify::TypeRegistry>>,
49 pub read_controls: bool,
51 pub population: IgnoredEntries,
53 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 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 pub fn scope(&self) -> crate::ScanScope {
111 self.identity_config().scope()
112 }
113 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
124pub struct Workers {
125 pub scan: Option<usize>,
127 pub analysis: usize,
129}
130
131#[derive(Clone, Debug)]
136pub struct Basis {
137 pub root: PathBuf,
139 pub scope: Scope,
143 pub content: AnalysisSet,
145}
146
147impl Basis {
148 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 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 const UNOBSERVED_LIMITS: ControlLimits = Request::DEFAULTS.control_limits;
205}
206
207#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct Delivery {
217 pub cache: CachePolicy,
219 pub stale_ok: bool,
228 pub cache_path: Option<PathBuf>,
230 pub accept_partial: bool,
232 pub watch: Option<WatchDelivery>,
234 pub workers: Workers,
236 pub batch_size: usize,
238 pub order: crate::ScanOrder,
240}
241
242impl Delivery {
243 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 pub fn stale_ok(cache_path: Option<PathBuf>) -> Self {
259 Self { stale_ok: true, ..Self::new(CachePolicy::Auto, cache_path) }
260 }
261
262 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294pub struct WatchDelivery {
295 pub interval: Duration,
297}
298
299impl Default for WatchDelivery {
300 fn default() -> Self {
301 Self { interval: Self::DEFAULT_INTERVAL }
302 }
303}
304impl WatchDelivery {
305 pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(2);
307}
308
309#[derive(Clone, Debug)]
311pub struct Request {
312 pub basis: Basis,
314 pub query: Query,
316 pub now: SystemTime,
321}
322
323#[derive(Clone, Copy, Debug)]
332pub struct RequestSpec<'a> {
333 pub root: &'a Path,
335 pub scan_depth: Option<&'a str>,
337 pub one_filesystem: bool,
339 pub read_controls: Option<bool>,
341 pub control_budget: Option<&'a str>,
343 pub control_line_limit: Option<&'a str>,
345 pub analyze: Option<&'a str>,
347 pub read: ReadSpec<'a>,
349}
350
351#[derive(Clone, Copy, Debug)]
359pub struct ReadSpec<'a> {
360 pub views: Option<&'a str>,
362 pub format: Option<&'a str>,
364 pub words_per_page: Option<&'a str>,
366 pub include: &'a [String],
368 pub exclude: &'a [String],
370 pub min_size: Option<&'a str>,
372 pub modified_since: Option<&'a str>,
374 pub modified_before: Option<&'a str>,
376 pub kinds: Option<&'a str>,
378 pub ignored: Option<&'a str>,
380 pub depth: Option<&'a str>,
382 pub min_share: Option<&'a str>,
384 pub breadth: Option<&'a str>,
386 pub limit: Option<&'a str>,
388 pub sort: Option<&'a str>,
390 pub reverse: bool,
392 pub size: Option<&'a str>,
394}
395
396impl ReadSpec<'_> {
397 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
466pub struct RequestDefaults {
467 pub size: SizeMetric,
469 pub words_per_page: u64,
471 pub content: AnalysisSet,
473 pub read_controls: bool,
475 pub control_limits: ControlLimits,
477}
478
479impl RequestDefaults {
480 pub const fn report_view(self, content: AnalysisSet) -> ViewSpec {
483 ViewSpec::default_for(content)
484 }
485}
486
487impl Request {
488 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 pub const fn new(basis: Basis, query: Query, now: SystemTime) -> Self {
512 Self { basis, query, now }
513 }
514
515 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 pub fn build(
549 spec: &RequestSpec<'_>,
550 now: SystemTime,
551 axes: &'static AxisNames,
552 ) -> Result<Self, RequestError> {
553 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 pub fn validate(&self) -> Result<(), RequestError> {
569 self.validate_against(&self.basis)
570 }
571
572 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 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 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 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
732fn 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
752fn 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
785fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
886pub enum ScopeAxis {
887 FollowSymlinks,
889 OneFilesystem,
891}
892
893impl ScopeAxis {
894 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 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#[derive(Clone, Debug, PartialEq, Eq)]
923pub enum RequestError {
924 InvalidValue {
926 axis: &'static str,
928 value: String,
930 expected: String,
932 },
933 AnalyzerNamedAsView {
935 value: String,
937 analyzer: &'static str,
939 suggested_view: &'static str,
941 },
942 ViewNeedsContent(ViewSpec),
944 NeedsAnalyzer {
946 item: &'static str,
948 analyzer: &'static str,
950 },
951 IgnoredWithoutObservation(IgnoredEntries),
953 PopulationMismatch {
955 held: IgnoredEntries,
957 requested: IgnoredEntries,
959 },
960 ScopeUnsupported {
962 axis: ScopeAxis,
964 reason: &'static str,
966 },
967 ContentMismatch {
969 held: AnalysisSet,
971 requested: AnalysisSet,
973 },
974 WatchScope,
976 WatchContent,
978 WatchCacheOnly,
980 StaleOkCacheOff,
982 RootMismatch {
984 held: PathBuf,
986 requested: PathBuf,
988 },
989 DeliveryUnsupported {
991 route: &'static str,
993 reason: &'static str,
995 },
996 ViewLimit {
998 attempted: usize,
1000 limit: usize,
1002 },
1003}
1004
1005impl RequestError {
1006 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 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
1091impl 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
1101fn analysis_label(set: AnalysisSet) -> String {
1103 if set.is_enabled() { set.labels().join(",") } else { AnalysisSet::NONE_LABEL.to_string() }
1104}
1105
1106fn 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
1136pub(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
1166pub(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#[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 pub(crate) fn on(self, axis: &'static str) -> RequestError {
1203 RequestError::InvalidValue { axis, value: self.value, expected: self.expected }
1204 }
1205
1206 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
1224fn 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
1233fn 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
1242pub 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
1255pub 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
1277pub 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
1289pub 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
1309pub 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
1318pub 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
1334pub 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 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 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 #[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 #[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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 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 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 #[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 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 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 #[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 #[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 #[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}