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, ViewList, ViewSpec};
25use crate::query::query_selection::{
26 Bound, IgnoredEntries, Selection, ShareThreshold, SizeMetric, SortKey,
27};
28use crate::query::query_values::{
29 parse_control_budget, parse_control_line_limit, parse_size, parse_when, system_time_to_nanos,
30};
31use crate::scan::ScanConfig;
32
33#[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)]
316pub struct Request {
317 pub basis: Basis,
319 pub query: Query,
321 pub now: SystemTime,
326 origin: Origin,
333}
334
335#[derive(Clone, Debug, PartialEq, Eq)]
342enum Origin {
343 Supplied,
346 Built {
348 named: AnalysisSet,
350 implied_by: Vec<ViewSpec>,
352 },
353}
354
355#[derive(Clone, Copy, Debug, PartialEq, Eq)]
364#[non_exhaustive]
365pub enum BasisHolder {
366 Built,
368 Supplied,
370 Index,
372 OpenedRoot,
374}
375
376impl BasisHolder {
377 fn remedy(self, needed: AnalysisSet, held: AnalysisSet, axes: &AxisNames) -> String {
382 let analyze = axes.analyze;
383 match self {
384 Self::Built => format!(": add {analyze} {}", needed.request_label()),
385 Self::Supplied => format!("; its basis holds {analyze} {}", held.request_label()),
386 Self::Index => {
387 format!("; this index was opened with {analyze} {}", held.request_label())
388 }
389 Self::OpenedRoot => format!(
390 ", which an opened root never runs; use a one-shot report, or an index opened \
391 with {analyze} {}",
392 needed.request_label()
393 ),
394 }
395 }
396}
397
398#[derive(Clone, Copy, Debug)]
407pub struct RequestSpec<'a> {
408 pub root: &'a Path,
410 pub scan_depth: Option<&'a str>,
412 pub one_filesystem: bool,
414 pub read_controls: Option<bool>,
416 pub control_budget: Option<&'a str>,
418 pub control_line_limit: Option<&'a str>,
420 pub analyze: Option<&'a str>,
422 pub read: ReadSpec<'a>,
424}
425
426#[derive(Clone, Copy, Debug)]
434pub struct ReadSpec<'a> {
435 pub views: Option<&'a str>,
437 pub format: Option<&'a str>,
439 pub words_per_page: Option<&'a str>,
441 pub include: &'a [String],
443 pub exclude: &'a [String],
445 pub min_size: Option<&'a str>,
447 pub modified_since: Option<&'a str>,
449 pub modified_before: Option<&'a str>,
451 pub kinds: Option<&'a str>,
453 pub ignored: Option<&'a str>,
455 pub depth: Option<&'a str>,
457 pub min_share: Option<&'a str>,
459 pub breadth: Option<&'a str>,
461 pub limit: Option<&'a str>,
463 pub sort: Option<&'a str>,
465 pub reverse: bool,
467 pub size: Option<&'a str>,
469}
470
471impl ReadSpec<'_> {
472 pub const fn new() -> Self {
474 Self {
475 views: None,
476 format: None,
477 words_per_page: None,
478 include: &[],
479 exclude: &[],
480 min_size: None,
481 modified_since: None,
482 modified_before: None,
483 kinds: None,
484 ignored: None,
485 depth: None,
486 min_share: None,
487 breadth: None,
488 limit: None,
489 sort: None,
490 reverse: false,
491 size: None,
492 }
493 }
494}
495
496impl Default for ReadSpec<'_> {
497 fn default() -> Self {
498 Self::new()
499 }
500}
501
502impl<'a> RequestSpec<'a> {
503 pub const fn new(root: &'a Path) -> Self {
505 Self {
506 root,
507 scan_depth: None,
508 one_filesystem: false,
509 read_controls: None,
510 control_budget: None,
511 control_line_limit: None,
512 analyze: None,
513 read: ReadSpec::new(),
514 }
515 }
516}
517
518#[derive(Clone, Copy, Debug, PartialEq, Eq)]
546pub struct RequestDefaults {
547 pub size: SizeMetric,
549 pub words_per_page: u64,
551 pub content: AnalysisSet,
553 pub read_controls: bool,
555 pub control_limits: ControlLimits,
557}
558
559impl RequestDefaults {
560 pub const fn report_view(self, content: AnalysisSet) -> ViewSpec {
563 ViewSpec::default_for(content)
564 }
565}
566
567impl Request {
568 pub const DEFAULTS: RequestDefaults = RequestDefaults {
570 size: SizeMetric::Allocated,
571 words_per_page: 250,
572 content: AnalysisSet::NONE,
573 read_controls: true,
574 control_limits: ControlLimits {
575 budget: Some(DEFAULT_CONTROL_BUDGET),
576 line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
577 },
578 };
579
580 pub const fn new(basis: Basis, query: Query, now: SystemTime) -> Self {
596 Self { basis, query, now, origin: Origin::Supplied }
597 }
598
599 pub fn implied_by(&self) -> &[ViewSpec] {
606 match &self.origin {
607 Origin::Built { implied_by, .. } => implied_by.as_slice(),
608 Origin::Supplied => &[],
609 }
610 }
611
612 const fn holder(&self) -> BasisHolder {
614 match self.origin {
615 Origin::Built { .. } => BasisHolder::Built,
616 Origin::Supplied => BasisHolder::Supplied,
617 }
618 }
619
620 pub fn read(
639 basis: Basis,
640 spec: &ReadSpec<'_>,
641 now: SystemTime,
642 axes: &'static AxisNames,
643 ) -> Result<Self, RequestError> {
644 Self::read_held(basis, spec, now, axes, BasisHolder::Index)
645 }
646
647 pub fn read_opened(
658 spec: &ReadSpec<'_>,
659 now: SystemTime,
660 axes: &'static AxisNames,
661 ) -> Result<Self, RequestError> {
662 Self::read_held(crate::OpenedIndex::basis(), spec, now, axes, BasisHolder::OpenedRoot)
663 }
664
665 fn read_held(
667 basis: Basis,
668 spec: &ReadSpec<'_>,
669 now: SystemTime,
670 axes: &'static AxisNames,
671 holder: BasisHolder,
672 ) -> Result<Self, RequestError> {
673 let views = parse_views(spec.views, axes)?;
674 let mut query = build_query(basis.content, views, spec, now, axes)?;
675 if spec.ignored.is_none() {
676 query.selection.ignored = basis.scope.population;
677 }
678 let request = Self::new(basis, query, now);
679 request.validate_against(&request.basis, holder)?;
680 Ok(request)
681 }
682
683 pub fn build(
697 spec: &RequestSpec<'_>,
698 now: SystemTime,
699 axes: &'static AxisNames,
700 ) -> Result<Self, RequestError> {
701 let named = parse_content(spec, axes)?;
707 let views = parse_views(spec.read.views, axes)?;
708 let implied_by = views.implying();
709 let content = named.union(views.implies());
710 let query = build_query(content, views, &spec.read, now, axes)?;
711 let mut scope = parse_scope(spec, axes)?;
712 scope.population = query.selection.ignored;
713 Ok(Self {
714 origin: Origin::Built { named, implied_by },
715 ..Self::new(Basis { root: spec.root.to_path_buf(), scope, content }, query, now)
716 })
717 }
718
719 pub fn validate(&self) -> Result<(), RequestError> {
726 self.validate_against(&self.basis, self.holder())
727 }
728
729 pub fn validate_read(&self, held: &Basis) -> Result<(), RequestError> {
738 let entries = held.scope.snapshot_identity().entries;
739 let wanted = crate::ContentTierIdentity::for_request(entries, self.basis.content);
740 let stored = crate::ContentTierIdentity::for_request(entries, held.content);
741 if wanted.admit(&stored).is_none() {
742 return Err(RequestError::ContentMismatch {
743 held: held.content,
744 requested: self.basis.content,
745 });
746 }
747 self.validate_against(held, BasisHolder::Index)
748 }
749
750 pub(crate) fn validate_opened(&self) -> Result<(), RequestError> {
754 self.validate_against(&self.basis, BasisHolder::OpenedRoot)
755 }
756
757 pub fn validate_delivery(&self, delivery: &Delivery) -> Result<(), RequestError> {
780 if delivery.stale_ok && delivery.cache == CachePolicy::Off {
781 return Err(RequestError::StaleOkCacheOff);
782 }
783 if delivery.watch.is_none() {
784 return Ok(());
785 }
786 if self.basis.scope.max_depth.is_some() || self.basis.scope.one_filesystem {
787 return Err(RequestError::WatchScope);
788 }
789 if self.basis.content.is_enabled() {
790 return Err(match &self.origin {
791 Origin::Built { named, implied_by } => {
792 RequestError::WatchContent { named: *named, views: implied_by.clone() }
793 }
794 Origin::Supplied => {
797 RequestError::WatchContent { named: self.basis.content, views: Vec::new() }
798 }
799 });
800 }
801 if delivery.stale_ok {
802 return Err(RequestError::WatchCacheOnly);
803 }
804 Ok(())
805 }
806
807 fn validate_against(&self, basis: &Basis, holder: BasisHolder) -> Result<(), RequestError> {
810 if let Some(axis) = self.basis.scope.unsupported_axis() {
815 return Err(RequestError::ScopeUnsupported { axis, reason: axis.reason() });
816 }
817 let views = self.query.views.len().saturating_add(self.query.omitted_views.len());
818 if views > crate::MAX_REPORT_VIEWS {
819 return Err(RequestError::ViewLimit {
820 attempted: views,
821 limit: crate::MAX_REPORT_VIEWS,
822 });
823 }
824 let format = self.query.format;
825 if matches!(
826 format,
827 crate::report_format::Format::Tree
828 | crate::report_format::Format::Paths
829 | crate::report_format::Format::Long
830 ) {
831 let compatible = self.query.views.len() == 1
832 && self.query.views.iter().all(|view| {
833 matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
834 || (format != crate::report_format::Format::Tree
835 && matches!(view, ViewSpec::Largest | ViewSpec::Recent))
836 });
837 if !compatible {
838 return Err(invalid(
839 self.query.axes.format,
840 format.label(),
841 "requires a single list view; use text or a machine format for aggregate/mixed views (largest/recent support paths and long)",
842 ));
843 }
844 }
845 check_views(&self.query.views, basis.content, holder)?;
846 if let Some(SortKey::Metric(name)) = self.query.selection.sort {
847 let metric =
848 crate::content::METRICS.iter().find(|metric| metric.name == name).ok_or_else(
849 || invalid(self.query.axes.sort, name, "expected a registered numeric metric"),
850 )?;
851 if self.query.views.contains(&ViewSpec::Extensions) {
852 return Err(invalid(
853 self.query.axes.sort,
854 name,
855 "extensions cannot sort by content metrics; use size, count, or name, or select files or another metric-capable view",
856 ));
857 }
858 if !basis.content.contains(metric.owner) {
861 return Err(RequestError::SortNeedsAnalyzer {
862 metric: metric.name,
863 analyzer: metric.owner,
864 held: basis.content,
865 holder,
866 });
867 }
868 }
869 let hierarchy = self.query.views.iter().any(|view| self.query.tree_for(*view));
870 if matches!(self.query.selection.depth, Some(Bound::Limit(_))) && !hierarchy {
873 return Err(invalid(self.query.axes.depth, "", "requires a hierarchical view"));
874 }
875 if matches!(self.query.selection.breadth, Some(Bound::Limit(_))) && !hierarchy {
876 return Err(invalid(self.query.axes.breadth, "", "requires a hierarchical view"));
877 }
878 let additive = self.query.views.iter().any(|view| {
879 self.query.tree_for(*view)
880 || matches!(
881 view,
882 ViewSpec::Extensions
883 | ViewSpec::Types
884 | ViewSpec::Families
885 | ViewSpec::Languages
886 | ViewSpec::Documents
887 | ViewSpec::Code
888 )
889 });
890 if self.query.selection.min_share.as_ref().is_some_and(|share| !share.admits(0, 1))
891 && !additive
892 {
893 return Err(invalid(self.query.axes.min_share, "", "requires an additive view"));
894 }
895 check_observation(basis.scope.population, basis.scope.read_controls)?;
896 check_observation(self.query.selection.ignored, basis.scope.read_controls)?;
897 if basis.scope.population != IgnoredEntries::Include
898 && self.query.selection.ignored != basis.scope.population
899 {
900 return Err(RequestError::PopulationMismatch {
901 held: basis.scope.population,
902 requested: self.query.selection.ignored,
903 });
904 }
905 Ok(())
906 }
907}
908
909fn parse_content(
911 spec: &RequestSpec<'_>,
912 axes: &'static AxisNames,
913) -> Result<AnalysisSet, RequestError> {
914 spec.analyze.map_or(Ok(Request::DEFAULTS.content), |value| {
915 AnalysisSet::parse_rejecting(value).map_err(|rejection| rejection.on(axes.analyze))
916 })
917}
918
919fn parse_population(
920 value: Option<&str>,
921 axes: &'static AxisNames,
922) -> Result<IgnoredEntries, RequestError> {
923 value.map_or(Ok(IgnoredEntries::Include), |value| {
924 IgnoredEntries::parse(value)
925 .map_err(|expected| Rejection::new(value, expected).on(axes.ignored))
926 })
927}
928
929fn parse_scope(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Scope, RequestError> {
935 let limits = Request::DEFAULTS.control_limits;
936 Ok(Scope {
937 max_depth: spec
938 .scan_depth
939 .map(|value| {
940 value
941 .trim()
942 .parse::<usize>()
943 .map_err(|_| invalid(axes.scan_depth, value, "expected a whole number"))
944 })
945 .transpose()?,
946 one_filesystem: spec.one_filesystem,
947 read_controls: spec.read_controls.unwrap_or(Request::DEFAULTS.read_controls),
948 control_limits: ControlLimits {
949 budget: spec.control_budget.map_or(Ok(limits.budget), |value| {
950 parse_control_budget(value)
951 .map_err(|error| named_refusal(error, axes.control_budget))
952 })?,
953 line_limit: spec.control_line_limit.map_or(Ok(limits.line_limit), |value| {
954 parse_control_line_limit(value)
955 .map_err(|error| named_refusal(error, axes.control_line_limit))
956 })?,
957 },
958 ..Scope::default()
959 })
960}
961
962fn parse_views(spec: Option<&str>, axes: &'static AxisNames) -> Result<ViewList, RequestError> {
967 ViewList::parse(spec).map_err(|rejection| {
968 let suggestion = match rejection.value().to_ascii_lowercase().as_str() {
969 "lines" => Some(("lines", ViewSpec::Families)),
970 "words" => Some(("words", ViewSpec::Documents)),
971 _ => None,
972 };
973 match suggestion {
974 Some((analyzer, suggested_view)) => RequestError::AnalyzerNamedAsView {
975 value: rejection.value().to_string(),
976 analyzer,
977 suggested_view,
978 },
979 None => rejection.on(axes.view),
980 }
981 })
982}
983
984fn build_query(
992 content: AnalysisSet,
993 views: ViewList,
994 spec: &ReadSpec<'_>,
995 now: SystemTime,
996 axes: &'static AxisNames,
997) -> Result<Query, RequestError> {
998 let (views, omitted_views) = views.resolve(content);
999
1000 let mut selection = Selection {
1001 depth: spec.depth.map(|value| parse_bound(value, axes.depth)).transpose()?,
1002 min_share: spec
1003 .min_share
1004 .map(|value| {
1005 ShareThreshold::parse(value).ok_or_else(|| {
1006 invalid(axes.min_share, value, "expected a percentage from 0% through 100%")
1007 })
1008 })
1009 .transpose()?,
1010 breadth: spec.breadth.map(|value| parse_bound(value, axes.breadth)).transpose()?,
1011 limit: spec.limit.map(|value| parse_bound(value, axes.limit)).transpose()?,
1012 reverse: spec.reverse,
1013 size: spec
1014 .size
1015 .map_or(Ok(Request::DEFAULTS.size), |value| parse_size_metric(value, axes.size))?,
1016 ..Selection::default()
1017 };
1018 for pattern in spec.include {
1019 selection.include.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
1020 }
1021 for pattern in spec.exclude {
1022 selection.exclude.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
1023 }
1024 if let Some(value) = spec.min_size {
1025 selection.min_size = Some(parse_size(value).map_err(grammar_refusal)?);
1026 }
1027 if let Some(value) = spec.modified_since {
1028 let when = parse_when(value, now).map_err(grammar_refusal)?;
1029 selection.modified.since = Some(bound_nanos(value, when, axes.modified_since)?);
1030 }
1031 if let Some(value) = spec.modified_before {
1032 let when = parse_when(value, now).map_err(grammar_refusal)?;
1033 selection.modified.before = Some(bound_nanos(value, when, axes.modified_before)?);
1034 }
1035 if let Some(value) = spec.kinds {
1036 selection.kinds = parse_kinds(value, axes.kind)?;
1037 }
1038 if let Some(value) = spec.sort {
1039 selection.sort = Some(parse_sort(value, axes.sort)?);
1040 }
1041 selection.ignored = parse_population(spec.ignored, axes)?;
1042 let words_per_page =
1043 spec.words_per_page.map_or(Ok(Request::DEFAULTS.words_per_page), |value| {
1044 value
1045 .trim()
1046 .parse::<u64>()
1047 .ok()
1048 .filter(|words| *words > 0)
1049 .ok_or_else(|| invalid(axes.words_per_page, value, "expected a positive integer"))
1050 })?;
1051
1052 let format = spec.format.map_or(Ok(crate::report_format::Format::Text), |value| {
1053 crate::report_format::Format::parse(value).ok_or_else(|| {
1054 invalid(
1055 axes.format,
1056 value,
1057 format!("expected one of {}", crate::report_format::Format::ALL.join(", ")),
1058 )
1059 })
1060 })?;
1061 Ok(Query { selection, views, format, omitted_views, axes, words_per_page })
1062}
1063
1064#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1073pub enum ScopeAxis {
1074 FollowSymlinks,
1076 OneFilesystem,
1078}
1079
1080impl ScopeAxis {
1081 pub const fn reason(self) -> &'static str {
1088 match self {
1089 Self::FollowSymlinks => {
1090 "follow_symlinks requires cycle, root-boundary, and filesystem-boundary semantics"
1091 }
1092 Self::OneFilesystem => "one_filesystem requires platform device identity",
1093 }
1094 }
1095
1096 const fn named(self, axes: &AxisNames) -> &'static str {
1098 match self {
1099 Self::FollowSymlinks => axes.follow_symlinks,
1100 Self::OneFilesystem => axes.one_filesystem,
1101 }
1102 }
1103}
1104
1105#[derive(Clone, Debug, PartialEq, Eq)]
1116#[non_exhaustive]
1117pub enum RequestError {
1118 InvalidValue {
1120 axis: &'static str,
1122 value: String,
1124 expected: String,
1126 },
1127 #[non_exhaustive]
1129 AnalyzerNamedAsView {
1130 value: String,
1132 analyzer: &'static str,
1134 suggested_view: ViewSpec,
1136 },
1137 #[non_exhaustive]
1143 ViewNeedsAnalyzer {
1144 view: ViewSpec,
1146 held: AnalysisSet,
1148 holder: BasisHolder,
1150 },
1151 #[non_exhaustive]
1156 SortNeedsAnalyzer {
1157 metric: &'static str,
1159 analyzer: AnalysisSet,
1161 held: AnalysisSet,
1163 holder: BasisHolder,
1165 },
1166 IgnoredWithoutObservation(IgnoredEntries),
1168 PopulationMismatch {
1170 held: IgnoredEntries,
1172 requested: IgnoredEntries,
1174 },
1175 ScopeUnsupported {
1177 axis: ScopeAxis,
1179 reason: &'static str,
1181 },
1182 ContentMismatch {
1184 held: AnalysisSet,
1186 requested: AnalysisSet,
1188 },
1189 WatchScope,
1191 #[non_exhaustive]
1196 WatchContent {
1197 named: AnalysisSet,
1200 views: Vec<ViewSpec>,
1202 },
1203 WatchCacheOnly,
1205 StaleOkCacheOff,
1207 RootMismatch {
1209 held: PathBuf,
1211 requested: PathBuf,
1213 },
1214 DeliveryUnsupported {
1216 route: &'static str,
1218 reason: &'static str,
1220 },
1221 ViewLimit {
1223 attempted: usize,
1225 limit: usize,
1227 },
1228}
1229
1230impl RequestError {
1231 pub fn message(&self, axes: &AxisNames) -> String {
1236 match self {
1237 Self::InvalidValue { axis, value, expected } => invalid_message(axis, value, expected),
1238 Self::AnalyzerNamedAsView { value, analyzer, suggested_view } => {
1241 let shown = if suggested_view.implies().is_enabled() {
1242 String::new()
1243 } else {
1244 format!(", with {} {analyzer}", axes.analyze)
1245 };
1246 format!(
1247 "invalid {} {value:?}: {analyzer} is an analyzer; its view is {}{shown}",
1248 axes.view,
1249 suggested_view.label()
1250 )
1251 }
1252 Self::ViewNeedsAnalyzer { view, held, holder } => format!(
1253 "{} {} needs {} analysis{}",
1254 axes.view,
1255 view.label(),
1256 view.implies().named().join(" and "),
1257 holder.remedy(view.implies(), *held, axes)
1258 ),
1259 Self::SortNeedsAnalyzer { metric, analyzer, held, holder } => format!(
1260 "{} {metric} needs {} analysis{}",
1261 axes.sort,
1262 analyzer.named().join(" and "),
1263 holder.remedy(*analyzer, *held, axes)
1264 ),
1265 Self::IgnoredWithoutObservation(ignored) => format!(
1266 "{} needs .gitignore classification, and {} turned it off; drop one of them",
1267 match ignored {
1268 IgnoredEntries::Exclude => axes.exclude_ignored,
1269 IgnoredEntries::Only => axes.only_ignored,
1270 IgnoredEntries::Include => axes.ignored,
1271 },
1272 axes.read_controls
1273 ),
1274 Self::PopulationMismatch { held, requested } => format!(
1275 "{}={} cannot be read from retained {}={} scope",
1276 axes.ignored,
1277 requested.label(),
1278 axes.ignored,
1279 held.label()
1280 ),
1281 Self::ScopeUnsupported { axis, reason } => format!(
1286 "unsupported scan configuration: {}",
1287 reason.replacen(axis.named(&AxisNames::FIELDS), axis.named(axes), 1)
1288 ),
1289 Self::ContentMismatch { held, requested } => format!(
1290 "{analyze} {requested} cannot be answered by an index built with {analyze} \
1291 {held}; open the root again with {analyze} {requested}",
1292 analyze = axes.analyze,
1293 requested = analysis_label(*requested),
1294 held = analysis_label(*held),
1295 ),
1296 Self::WatchScope => watch_scope_message(axes),
1297 Self::RootMismatch { held, requested } => {
1298 format!(
1299 "requested root {} does not match retained root {}",
1300 requested.display(),
1301 held.display()
1302 )
1303 }
1304 Self::DeliveryUnsupported { route, reason } => format!("{route}: {reason}"),
1305 Self::WatchContent { views, .. } if views.is_empty() => format!(
1306 "{} is not yet supported with {}; use a one-shot report",
1307 axes.analyze, axes.watch
1308 ),
1309 Self::WatchContent { named, views } => watch_content_message(*named, views, axes),
1310 Self::WatchCacheOnly => format!(
1311 "{watch} cannot start from a {stale_ok} answer: nothing verifies what changed \
1312 between the snapshot and the start of the watch; drop {stale_ok}",
1313 watch = axes.watch,
1314 stale_ok = axes.stale_ok,
1315 ),
1316 Self::StaleOkCacheOff => format!(
1317 "{stale_ok} answers from the snapshot, which {cache} off never reads; drop one \
1318 of them",
1319 stale_ok = axes.stale_ok,
1320 cache = axes.cache,
1321 ),
1322 Self::ViewLimit { attempted, limit } => {
1323 format!("report request contains {attempted} views or omissions; limit is {limit}")
1324 }
1325 }
1326 }
1327}
1328
1329impl fmt::Display for RequestError {
1332 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1333 formatter.write_str(&self.message(&AxisNames::FIELDS))
1334 }
1335}
1336
1337impl std::error::Error for RequestError {}
1338
1339fn watch_content_message(named: AnalysisSet, views: &[ViewSpec], axes: &AxisNames) -> String {
1345 let mut analyzers = named.named();
1346 for name in views.iter().flat_map(|view| view.implies().named()) {
1347 if !analyzers.contains(&name) {
1348 analyzers.push(name);
1349 }
1350 }
1351 let views = views.iter().map(|view| view.label()).collect::<Vec<_>>().join(",");
1352 let subject = if named.is_enabled() {
1353 format!("{} {} and {} {views} need", axes.analyze, named.request_label(), axes.view)
1354 } else {
1355 format!("{} {views} needs", axes.view)
1356 };
1357 format!(
1358 "{subject} {} analysis, which {} cannot keep current; use a one-shot report",
1359 analyzers.join(" and "),
1360 axes.watch
1361 )
1362}
1363
1364fn analysis_label(set: AnalysisSet) -> String {
1366 if set.is_enabled() { set.labels().join(",") } else { AnalysisSet::NONE_LABEL.to_string() }
1367}
1368
1369fn watch_scope_message(axes: &AxisNames) -> String {
1377 let fields = &AxisNames::FIELDS;
1378 let vocabulary = [
1379 (fields.scan_depth, axes.scan_depth),
1380 (fields.one_filesystem, axes.one_filesystem),
1381 (fields.modified_since, axes.modified_since),
1382 (fields.depth, axes.depth),
1383 (fields.include, axes.include),
1384 ];
1385 let is_word = |character: char| character.is_ascii_alphanumeric() || character == '_';
1386 crate::scan::WATCH_SCOPE_GUIDANCE
1387 .split_inclusive(|character: char| !is_word(character))
1388 .map(|piece| {
1389 let end = piece.find(|character: char| !is_word(character)).unwrap_or(piece.len());
1390 let (word, tail) = piece.split_at(end);
1391 match vocabulary.iter().find(|(field, _)| *field == word) {
1392 Some((_, name)) => format!("{name}{tail}"),
1393 None => piece.to_string(),
1394 }
1395 })
1396 .collect()
1397}
1398
1399fn check_views(
1406 views: &[ViewSpec],
1407 content: AnalysisSet,
1408 holder: BasisHolder,
1409) -> Result<(), RequestError> {
1410 match views.iter().find(|view| !content.contains(view.implies())) {
1411 Some(view) => Err(RequestError::ViewNeedsAnalyzer { view: *view, held: content, holder }),
1412 None => Ok(()),
1413 }
1414}
1415
1416pub(crate) fn check_observation(
1418 ignored: IgnoredEntries,
1419 observes_controls: bool,
1420) -> Result<(), RequestError> {
1421 match ignored {
1422 IgnoredEntries::Include => Ok(()),
1423 IgnoredEntries::Exclude | IgnoredEntries::Only if observes_controls => Ok(()),
1424 IgnoredEntries::Exclude | IgnoredEntries::Only => {
1425 Err(RequestError::IgnoredWithoutObservation(ignored))
1426 }
1427 }
1428}
1429
1430#[derive(Clone, Debug, PartialEq, Eq)]
1437pub(crate) struct Rejection {
1438 value: String,
1439 expected: String,
1440}
1441
1442impl Rejection {
1443 pub(crate) fn new(value: impl Into<String>, expected: impl Into<String>) -> Self {
1444 Self { value: value.into(), expected: expected.into() }
1445 }
1446
1447 pub(crate) fn value(&self) -> &str {
1448 &self.value
1449 }
1450
1451 pub(crate) fn on(self, axis: &'static str) -> RequestError {
1453 RequestError::InvalidValue { axis, value: self.value, expected: self.expected }
1454 }
1455
1456 pub(crate) fn labeled(&self, label: &str) -> String {
1458 invalid_message(label, &self.value, &self.expected)
1459 }
1460}
1461
1462fn invalid_message(axis: &str, value: &str, expected: &str) -> String {
1463 format!("invalid {axis} {value:?}: {expected}")
1464}
1465
1466fn invalid(
1467 axis: &'static str,
1468 value: impl Into<String>,
1469 expected: impl Into<String>,
1470) -> RequestError {
1471 Rejection::new(value, expected).on(axis)
1472}
1473
1474fn grammar_refusal(error: crate::Error) -> RequestError {
1477 match error {
1478 crate::Error::InvalidValue { kind, value, hint } => invalid(kind, value, hint),
1479 other => invalid("value", String::new(), other.to_string()),
1480 }
1481}
1482
1483fn named_refusal(error: crate::Error, axis: &'static str) -> RequestError {
1486 match error {
1487 crate::Error::InvalidValue { value, hint, .. } => invalid(axis, value, hint),
1488 other => invalid(axis, String::new(), other.to_string()),
1489 }
1490}
1491
1492pub fn parse_kind(value: &str, axis: &'static str) -> Result<EntryKind, RequestError> {
1496 match value.trim().to_ascii_lowercase().as_str() {
1497 "file" => Ok(EntryKind::File),
1498 "dir" => Ok(EntryKind::Dir),
1499 "symlink" => Ok(EntryKind::Symlink),
1500 "other" => Ok(EntryKind::Other),
1501 other => Err(invalid(axis, other, "expected one of file, dir, symlink, other")),
1502 }
1503}
1504
1505pub fn parse_kinds(list: &str, axis: &'static str) -> Result<Vec<EntryKind>, RequestError> {
1512 let mut kinds = Vec::new();
1513 for token in list.split(',') {
1514 let token = token.trim();
1515 if token.is_empty() {
1516 return Err(invalid(axis, list, "empty entry in the list"));
1517 }
1518 let kind = parse_kind(token, axis)?;
1519 if kinds.contains(&kind) {
1520 return Err(invalid(axis, list, format!("{token:?} appears more than once")));
1521 }
1522 kinds.push(kind);
1523 }
1524 Ok(kinds)
1525}
1526
1527pub fn parse_bound(value: &str, axis: &'static str) -> Result<Bound, RequestError> {
1529 let value = value.trim();
1530 if value.eq_ignore_ascii_case("all") {
1531 return Ok(Bound::All);
1532 }
1533 value
1534 .parse::<usize>()
1535 .map(Bound::Limit)
1536 .map_err(|_| invalid(axis, value, "expected a whole number or `all`"))
1537}
1538
1539pub fn parse_sort(value: &str, axis: &'static str) -> Result<SortKey, RequestError> {
1541 match value.trim().to_ascii_lowercase().as_str() {
1542 "size" => Ok(SortKey::Size),
1543 "count" => Ok(SortKey::Count),
1544 "mtime" => Ok(SortKey::Mtime),
1545 "name" => Ok(SortKey::Name),
1546 other => crate::content::METRICS.iter().find(|metric| metric.name == other).map_or_else(
1547 || {
1548 Err(invalid(
1549 axis,
1550 other,
1551 "expected size, count, mtime, name, or a registered numeric metric",
1552 ))
1553 },
1554 |metric| Ok(SortKey::Metric(metric.name)),
1555 ),
1556 }
1557}
1558
1559pub fn parse_size_metric(value: &str, axis: &'static str) -> Result<SizeMetric, RequestError> {
1561 match value.trim().to_ascii_lowercase().as_str() {
1562 "allocated" => Ok(SizeMetric::Allocated),
1563 "apparent" => Ok(SizeMetric::Apparent),
1564 other => Err(invalid(axis, other, "expected allocated or apparent")),
1565 }
1566}
1567
1568pub fn bound_nanos(value: &str, when: SystemTime, axis: &'static str) -> Result<i64, RequestError> {
1575 system_time_to_nanos(when).ok_or_else(|| {
1576 invalid(
1577 axis,
1578 value,
1579 "that time is outside the range fdu can represent (about 1677 to 2262)",
1580 )
1581 })
1582}
1583
1584pub fn parse_cache_policy(value: &str, axis: &'static str) -> Result<CachePolicy, RequestError> {
1590 let stale_ok = if axis == AxisNames::FLAGS.cache {
1591 AxisNames::FLAGS.stale_ok
1592 } else {
1593 AxisNames::FIELDS.stale_ok
1594 };
1595 match value.trim().to_ascii_lowercase().as_str() {
1596 "auto" => Ok(CachePolicy::Auto),
1597 "on" => Ok(CachePolicy::On),
1598 "off" => Ok(CachePolicy::Off),
1599 "only" => Err(invalid(
1600 axis,
1601 "only",
1602 format!("answering from the snapshot alone is now {stale_ok}"),
1603 )),
1604 "refresh" => Err(invalid(
1605 axis,
1606 "refresh",
1607 "removed; use on, which also writes after a one-shot report",
1608 )),
1609 "read-only" => Err(invalid(
1610 axis,
1611 "read-only",
1612 "removed; auto no longer writes after a one-shot metadata report, and off reads nothing",
1613 )),
1614 other => Err(invalid(axis, other, "expected one of auto, on, off")),
1615 }
1616}
1617
1618#[cfg(test)]
1619mod tests {
1620 use super::*;
1621
1622 #[test]
1623 fn each_grammar_names_its_axis_as_the_surface_spells_it() {
1624 let flags = &AxisNames::FLAGS;
1625 let fields = &AxisNames::FIELDS;
1626 let cases: [(&str, RequestError, RequestError); 6] = [
1627 (
1628 "kind",
1629 parse_kind(" Socket ", flags.kind).expect_err("unknown kind"),
1630 parse_kind(" Socket ", fields.kind).expect_err("unknown kind"),
1631 ),
1632 (
1633 "bound",
1634 parse_bound("two", flags.depth).expect_err("not a number"),
1635 parse_bound("two", fields.depth).expect_err("not a number"),
1636 ),
1637 (
1638 "sort",
1639 parse_sort("Newest", flags.sort).expect_err("unknown key"),
1640 parse_sort("Newest", fields.sort).expect_err("unknown key"),
1641 ),
1642 (
1643 "size",
1644 parse_size_metric("logical", flags.size).expect_err("unknown metric"),
1645 parse_size_metric("logical", fields.size).expect_err("unknown metric"),
1646 ),
1647 (
1648 "time",
1649 bound_nanos("2300-01-01T00:00:00Z", far_future(), flags.modified_since)
1650 .expect_err("unrepresentable"),
1651 bound_nanos("2300-01-01T00:00:00Z", far_future(), fields.modified_since)
1652 .expect_err("unrepresentable"),
1653 ),
1654 (
1655 "cache",
1656 parse_cache_policy("readonly", flags.cache).expect_err("unreleased alias"),
1657 parse_cache_policy("readonly", fields.cache).expect_err("unreleased alias"),
1658 ),
1659 ];
1660 let expected = [
1661 (
1662 "invalid --kind \"socket\": expected one of file, dir, symlink, other",
1663 "invalid kind \"socket\": expected one of file, dir, symlink, other",
1664 ),
1665 (
1666 "invalid --depth \"two\": expected a whole number or `all`",
1667 "invalid depth \"two\": expected a whole number or `all`",
1668 ),
1669 (
1670 "invalid --sort \"newest\": expected size, count, mtime, name, or a registered numeric metric",
1671 "invalid sort \"newest\": expected size, count, mtime, name, or a registered numeric metric",
1672 ),
1673 (
1674 "invalid --size \"logical\": expected allocated or apparent",
1675 "invalid size \"logical\": expected allocated or apparent",
1676 ),
1677 (
1678 "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
1679 range fdu can represent (about 1677 to 2262)",
1680 "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
1681 fdu can represent (about 1677 to 2262)",
1682 ),
1683 (
1684 "invalid --cache \"readonly\": expected one of auto, on, off",
1685 "invalid cache policy \"readonly\": expected one of auto, on, off",
1686 ),
1687 ];
1688 for ((grammar, flag, field), (flag_text, field_text)) in cases.into_iter().zip(expected) {
1689 assert_eq!(flag.message(&AxisNames::FLAGS), flag_text, "{grammar}");
1690 assert_eq!(field.message(&AxisNames::FIELDS), field_text, "{grammar}");
1691 assert_eq!(flag.message(&AxisNames::FIELDS), flag_text, "{grammar}");
1693 }
1694 }
1695
1696 fn far_future() -> SystemTime {
1697 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10_000_000_000)
1698 }
1699
1700 #[test]
1701 fn the_grammars_accept_their_whole_vocabulary() {
1702 let axis = AxisNames::FIELDS.kind;
1703 for (spelling, kind) in [
1704 ("file", EntryKind::File),
1705 ("DIR", EntryKind::Dir),
1706 (" symlink", EntryKind::Symlink),
1707 ("other", EntryKind::Other),
1708 ] {
1709 assert_eq!(parse_kind(spelling, axis), Ok(kind));
1710 }
1711 assert_eq!(parse_kinds("file, dir", axis), Ok(vec![EntryKind::File, EntryKind::Dir]));
1712 assert_eq!(parse_bound(" ALL ", axis), Ok(Bound::All));
1713 assert_eq!(parse_bound("0", axis), Ok(Bound::Limit(0)));
1714 for (spelling, key) in [
1715 ("size", SortKey::Size),
1716 ("count", SortKey::Count),
1717 ("MTIME", SortKey::Mtime),
1718 ("name", SortKey::Name),
1719 ("CODE_LINES", SortKey::Metric("code_lines")),
1720 ] {
1721 assert_eq!(parse_sort(spelling, axis), Ok(key));
1722 }
1723 assert_eq!(parse_size_metric("Allocated", axis), Ok(SizeMetric::Allocated));
1724 assert_eq!(parse_size_metric("apparent", axis), Ok(SizeMetric::Apparent));
1725 for (spelling, policy) in
1726 [("auto", CachePolicy::Auto), ("ON", CachePolicy::On), ("off", CachePolicy::Off)]
1727 {
1728 assert_eq!(parse_cache_policy(spelling, axis), Ok(policy));
1729 }
1730 for (spelling, flags, fields) in [
1732 (
1733 "only",
1734 "invalid --cache \"only\": answering from the snapshot alone is now --stale-ok",
1735 "invalid cache policy \"only\": answering from the snapshot alone is now stale_ok",
1736 ),
1737 (
1738 "refresh",
1739 "invalid --cache \"refresh\": removed; use on, which also writes after a one-shot \
1740 report",
1741 "invalid cache policy \"refresh\": removed; use on, which also writes after a \
1742 one-shot report",
1743 ),
1744 (
1745 "read-only",
1746 "invalid --cache \"read-only\": removed; auto no longer writes after a one-shot \
1747 metadata report, and off reads nothing",
1748 "invalid cache policy \"read-only\": removed; auto no longer writes after a \
1749 one-shot metadata report, and off reads nothing",
1750 ),
1751 ] {
1752 let message = |axis| {
1753 parse_cache_policy(spelling, axis).expect_err("retired").message(&AxisNames::FLAGS)
1754 };
1755 assert_eq!(message(AxisNames::FLAGS.cache), flags);
1756 assert_eq!(message(AxisNames::FIELDS.cache), fields);
1757 }
1758 let epoch = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(2);
1759 assert_eq!(bound_nanos("@2", epoch, axis), Ok(2_000_000_000));
1760 }
1761
1762 #[test]
1763 fn a_kind_list_refuses_empty_and_repeated_entries() {
1764 let axis = AxisNames::FLAGS.kind;
1765 assert_eq!(
1766 parse_kinds("file,,dir", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1767 Err("invalid --kind \"file,,dir\": empty entry in the list".to_string())
1768 );
1769 assert_eq!(
1770 parse_kinds("file, FILE", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1771 Err("invalid --kind \"file, FILE\": \"FILE\" appears more than once".to_string())
1772 );
1773 }
1774
1775 #[test]
1778 fn every_refusal_renders_in_flag_and_field_wording() {
1779 let cases = [
1780 (
1781 RequestError::ViewNeedsAnalyzer {
1782 view: ViewSpec::Code,
1783 held: AnalysisSet::NONE,
1784 holder: BasisHolder::Index,
1785 },
1786 "--view code needs code analysis; this index was opened with --analyze none",
1787 "view code needs code analysis; this index was opened with analyze none",
1788 ),
1789 (
1790 RequestError::ViewNeedsAnalyzer {
1791 view: ViewSpec::Documents,
1792 held: AnalysisSet::CODE_ONLY,
1793 holder: BasisHolder::Index,
1794 },
1795 "--view documents needs words analysis; this index was opened with --analyze code",
1796 "view documents needs words analysis; this index was opened with analyze code",
1797 ),
1798 (
1801 RequestError::ViewNeedsAnalyzer {
1802 view: ViewSpec::Documents,
1803 held: AnalysisSet::NONE,
1804 holder: BasisHolder::OpenedRoot,
1805 },
1806 "--view documents needs words analysis, which an opened root never runs; use a \
1807 one-shot report, or an index opened with --analyze words",
1808 "view documents needs words analysis, which an opened root never runs; use a \
1809 one-shot report, or an index opened with analyze words",
1810 ),
1811 (
1812 RequestError::ViewNeedsAnalyzer {
1813 view: ViewSpec::Code,
1814 held: AnalysisSet::WORDS_ONLY,
1815 holder: BasisHolder::Supplied,
1816 },
1817 "--view code needs code analysis; its basis holds --analyze words",
1818 "view code needs code analysis; its basis holds analyze words",
1819 ),
1820 (
1821 RequestError::SortNeedsAnalyzer {
1822 metric: "code_lines",
1823 analyzer: AnalysisSet::CODE_ONLY,
1824 held: AnalysisSet::NONE,
1825 holder: BasisHolder::Built,
1826 },
1827 "--sort code_lines needs code analysis: add --analyze code",
1828 "sort code_lines needs code analysis: add analyze code",
1829 ),
1830 (
1831 RequestError::SortNeedsAnalyzer {
1832 metric: "code_lines",
1833 analyzer: AnalysisSet::CODE_ONLY,
1834 held: AnalysisSet::LINES_ONLY,
1835 holder: BasisHolder::Index,
1836 },
1837 "--sort code_lines needs code analysis; this index was opened with --analyze \
1838 lines",
1839 "sort code_lines needs code analysis; this index was opened with analyze lines",
1840 ),
1841 (
1842 RequestError::SortNeedsAnalyzer {
1843 metric: "document_words",
1844 analyzer: AnalysisSet::WORDS_ONLY,
1845 held: AnalysisSet::NONE,
1846 holder: BasisHolder::OpenedRoot,
1847 },
1848 "--sort document_words needs words analysis, which an opened root never runs; \
1849 use a one-shot report, or an index opened with --analyze words",
1850 "sort document_words needs words analysis, which an opened root never runs; use \
1851 a one-shot report, or an index opened with analyze words",
1852 ),
1853 (
1854 RequestError::SortNeedsAnalyzer {
1855 metric: "code_lines",
1856 analyzer: AnalysisSet::CODE_ONLY,
1857 held: AnalysisSet::NONE,
1858 holder: BasisHolder::Supplied,
1859 },
1860 "--sort code_lines needs code analysis; its basis holds --analyze none",
1861 "sort code_lines needs code analysis; its basis holds analyze none",
1862 ),
1863 (
1864 RequestError::AnalyzerNamedAsView {
1865 value: "words".to_owned(),
1866 analyzer: "words",
1867 suggested_view: ViewSpec::Documents,
1868 },
1869 "invalid --view \"words\": words is an analyzer; its view is documents",
1870 "invalid view \"words\": words is an analyzer; its view is documents",
1871 ),
1872 (
1873 RequestError::AnalyzerNamedAsView {
1874 value: "lines".to_owned(),
1875 analyzer: "lines",
1876 suggested_view: ViewSpec::Families,
1877 },
1878 "invalid --view \"lines\": lines is an analyzer; its view is families, with \
1879 --analyze lines",
1880 "invalid view \"lines\": lines is an analyzer; its view is families, with \
1881 analyze lines",
1882 ),
1883 (
1884 RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude),
1885 "--ignored=exclude needs .gitignore classification, and --no-gitignore turned it \
1886 off; drop one of them",
1887 "ignored=exclude needs .gitignore classification, and read_controls turned it \
1888 off; drop one of them",
1889 ),
1890 (
1891 RequestError::IgnoredWithoutObservation(IgnoredEntries::Only),
1892 "--ignored=only needs .gitignore classification, and --no-gitignore turned it \
1893 off; drop one of them",
1894 "ignored=only needs .gitignore classification, and read_controls turned it off; \
1895 drop one of them",
1896 ),
1897 (
1898 RequestError::ContentMismatch {
1899 held: AnalysisSet::NONE,
1900 requested: AnalysisSet::NONE.with_code(),
1901 },
1902 "--analyze lines,code cannot be answered by an index built with --analyze none; \
1903 open the root again with --analyze lines,code",
1904 "analyze lines,code cannot be answered by an index built with analyze none; open \
1905 the root again with analyze lines,code",
1906 ),
1907 (
1908 RequestError::ScopeUnsupported {
1909 axis: ScopeAxis::OneFilesystem,
1910 reason: ScopeAxis::OneFilesystem.reason(),
1911 },
1912 "unsupported scan configuration: --one-filesystem requires platform device \
1913 identity",
1914 "unsupported scan configuration: one_filesystem requires platform device identity",
1915 ),
1916 (
1917 RequestError::ScopeUnsupported {
1918 axis: ScopeAxis::FollowSymlinks,
1919 reason: ScopeAxis::FollowSymlinks.reason(),
1920 },
1921 "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1922 and filesystem-boundary semantics",
1923 "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1924 and filesystem-boundary semantics",
1925 ),
1926 (
1927 RequestError::WatchContent { named: AnalysisSet::LINES_ONLY, views: Vec::new() },
1928 "--analyze is not yet supported with --watch; use a one-shot report",
1929 "analyze is not yet supported with watch; use a one-shot report",
1930 ),
1931 (
1932 RequestError::WatchContent {
1933 named: AnalysisSet::NONE,
1934 views: vec![ViewSpec::Code],
1935 },
1936 "--view code needs code analysis, which --watch cannot keep current; use a \
1937 one-shot report",
1938 "view code needs code analysis, which watch cannot keep current; use a one-shot \
1939 report",
1940 ),
1941 (
1943 RequestError::WatchContent {
1944 named: AnalysisSet::NONE,
1945 views: vec![ViewSpec::Documents, ViewSpec::Code],
1946 },
1947 "--view documents,code needs words and code analysis, which --watch cannot keep \
1948 current; use a one-shot report",
1949 "view documents,code needs words and code analysis, which watch cannot keep \
1950 current; use a one-shot report",
1951 ),
1952 (
1954 RequestError::WatchContent {
1955 named: AnalysisSet::WORDS_ONLY,
1956 views: vec![ViewSpec::Code],
1957 },
1958 "--analyze words and --view code need words and code analysis, which --watch \
1959 cannot keep current; use a one-shot report",
1960 "analyze words and view code need words and code analysis, which watch cannot \
1961 keep current; use a one-shot report",
1962 ),
1963 (
1964 RequestError::WatchCacheOnly,
1965 "--watch cannot start from a --stale-ok answer: nothing verifies what changed \
1966 between the snapshot and the start of the watch; drop --stale-ok",
1967 "watch cannot start from a stale_ok answer: nothing verifies what changed between \
1968 the snapshot and the start of the watch; drop stale_ok",
1969 ),
1970 (
1971 RequestError::StaleOkCacheOff,
1972 "--stale-ok answers from the snapshot, which --cache off never reads; drop one of \
1973 them",
1974 "stale_ok answers from the snapshot, which cache policy off never reads; drop one \
1975 of them",
1976 ),
1977 (
1978 RequestError::ViewLimit { attempted: 17, limit: 16 },
1979 "report request contains 17 views or omissions; limit is 16",
1980 "report request contains 17 views or omissions; limit is 16",
1981 ),
1982 ];
1983 for (refusal, flags, fields) in cases {
1984 assert_eq!(refusal.message(&AxisNames::FLAGS), flags);
1985 assert_eq!(refusal.message(&AxisNames::FIELDS), fields);
1986 assert_eq!(refusal.to_string(), fields, "a refusal displays in the library's words");
1987 }
1988 }
1989
1990 #[test]
1996 fn the_watch_scope_refusal_substitutes_whole_words_only() {
1997 let source = crate::scan::WATCH_SCOPE_GUIDANCE;
1998 assert_eq!(RequestError::WatchScope.message(&AxisNames::FIELDS), source);
1999
2000 let text = RequestError::WatchScope.message(&AxisNames::FLAGS);
2001 assert!(!text.contains("---"), "{text} re-substituted a replacement");
2002
2003 let names_word = |haystack: &str, word: &str| {
2005 haystack
2006 .split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
2007 .any(|w| w == word)
2008 };
2009 let (fields, flags) = (&AxisNames::FIELDS, &AxisNames::FLAGS);
2010 let vocabulary = [
2011 (fields.scan_depth, flags.scan_depth),
2012 (fields.one_filesystem, flags.one_filesystem),
2013 (fields.modified_since, flags.modified_since),
2014 (fields.depth, flags.depth),
2015 (fields.include, flags.include),
2016 ];
2017 for (field, flag) in vocabulary {
2018 if names_word(source, field) {
2019 assert!(text.contains(flag), "{text} must name {flag} where the rule says {field}");
2020 }
2021 assert!(!names_word(&text, field), "{text} still names {field} untranslated");
2022 }
2023 let mut rebuilt = text.clone();
2024 for (field, flag) in vocabulary {
2025 rebuilt = rebuilt.replace(flag, field);
2026 }
2027 assert_eq!(rebuilt, source, "the flag wording must be the library's, knob names aside");
2028 }
2029
2030 #[test]
2034 fn documents_and_code_are_the_views_that_need_content() {
2035 for content in [
2036 AnalysisSet::NONE,
2037 AnalysisSet::LINES_ONLY,
2038 AnalysisSet::CODE_ONLY,
2039 AnalysisSet::WORDS_ONLY,
2040 AnalysisSet::ALL,
2041 ] {
2042 for view in ViewSpec::ALL {
2043 let refused = check_views(&[view], content, BasisHolder::Index).is_err();
2044 assert_eq!(
2045 refused,
2046 (view == ViewSpec::Documents && !content.includes_words())
2047 || (view == ViewSpec::Code && !content.includes_code()),
2048 "{view:?} over {content:?}"
2049 );
2050 }
2051 }
2052 assert_eq!(check_observation(IgnoredEntries::Include, false), Ok(()));
2053 for ignored in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
2054 assert_eq!(check_observation(ignored, true), Ok(()));
2055 assert_eq!(
2056 check_observation(ignored, false),
2057 Err(RequestError::IgnoredWithoutObservation(ignored))
2058 );
2059 }
2060 }
2061
2062 use crate::content::AnalysisRequest;
2065
2066 fn root() -> &'static Path {
2067 Path::new("/tree")
2068 }
2069
2070 fn instant() -> SystemTime {
2071 SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
2072 }
2073
2074 #[allow(clippy::large_types_passed_by_value)]
2076 fn reading(read: ReadSpec<'_>) -> RequestSpec<'_> {
2077 RequestSpec { read, ..RequestSpec::new(root()) }
2078 }
2079
2080 fn built(spec: &RequestSpec<'_>) -> Request {
2081 Request::build(spec, instant(), &AxisNames::FIELDS).expect("the spec parses")
2082 }
2083
2084 #[test]
2085 fn retained_population_allows_narrower_reads_only_from_include() {
2086 let read = |basis: Basis, ignored| {
2087 Request::read(
2088 basis,
2089 &ReadSpec { ignored, ..ReadSpec::default() },
2090 instant(),
2091 &AxisNames::FIELDS,
2092 )
2093 };
2094 let include = Basis {
2095 root: root().to_path_buf(),
2096 scope: Scope::default(),
2097 content: AnalysisSet::NONE,
2098 };
2099 assert_eq!(
2100 read(include.clone(), Some("exclude")).expect("narrow exclude").query.selection.ignored,
2101 IgnoredEntries::Exclude
2102 );
2103 assert_eq!(
2104 read(include, Some("only")).expect("narrow only").query.selection.ignored,
2105 IgnoredEntries::Only
2106 );
2107
2108 let excluded = Basis {
2109 root: root().to_path_buf(),
2110 scope: Scope { population: IgnoredEntries::Exclude, ..Scope::default() },
2111 content: AnalysisSet::NONE,
2112 };
2113 assert_eq!(
2114 read(excluded.clone(), None).expect("basis default").query.selection.ignored,
2115 IgnoredEntries::Exclude
2116 );
2117 assert!(matches!(
2118 read(excluded, Some("only")),
2119 Err(RequestError::PopulationMismatch {
2120 held: IgnoredEntries::Exclude,
2121 requested: IgnoredEntries::Only
2122 })
2123 ));
2124 }
2125
2126 fn refusal(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> String {
2127 Request::build(spec, instant(), axes).expect_err("the spec is refused").message(axes)
2128 }
2129
2130 #[test]
2133 fn list_formats_are_resolved_and_validated_in_the_shared_request() {
2134 use crate::report_format::Format;
2135 for (view, format, valid, tree) in [
2136 (None, None, true, true),
2137 (Some("list"), Some("tree"), true, true),
2138 (Some("list"), Some("paths"), true, false),
2139 (None, Some("json"), true, false),
2140 (Some("files"), None, true, false),
2141 (Some("files"), Some("tree"), true, true),
2142 (Some("tree"), Some("long"), true, false),
2143 (Some("largest"), Some("long"), true, false),
2144 (Some("summary"), Some("long"), false, false),
2145 (Some("full"), Some("paths"), false, false),
2146 (Some("list,summary"), Some("tree"), false, false),
2147 (Some("list,summary"), Some("json"), true, false),
2148 ] {
2149 let spec = RequestSpec {
2150 read: ReadSpec { views: view, format, ..ReadSpec::new() },
2151 ..RequestSpec::new(Path::new("/absent"))
2152 };
2153 let request =
2154 Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("grammar");
2155 assert_eq!(request.validate().is_ok(), valid, "{view:?} {format:?}");
2156 if valid {
2157 assert_eq!(request.query.tree_for(request.query.views[0]), tree);
2158 }
2159 }
2160 let spec = RequestSpec::new(Path::new("/absent"));
2161 let request =
2162 Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("default");
2163 assert_eq!(request.query.views, [ViewSpec::List]);
2164 assert!(
2165 !request.query.needs_selection_walk(),
2166 "an ordinary tree must retain its bounded projection cost"
2167 );
2168 let mut flat = request.clone();
2169 flat.query.format = Format::Paths;
2170 assert!(flat.query.needs_selection_walk());
2171 assert_eq!(flat.query.limit_for(ViewSpec::List), Bound::All);
2172 assert_eq!(request.query.limit_for(ViewSpec::List), Bound::All);
2173 assert_eq!(request.query.depth_for(ViewSpec::List), Bound::Limit(5));
2174 }
2175
2176 #[test]
2177 fn an_empty_spec_builds_the_defaults_table() {
2178 let defaults = Request::DEFAULTS;
2179 assert_eq!(defaults.size, SizeMetric::Allocated);
2180 assert_eq!(defaults.words_per_page, 250);
2181 assert_eq!(defaults.content, AnalysisSet::NONE);
2182 assert!(defaults.read_controls);
2183 assert_eq!(defaults.control_limits, ControlLimits::default());
2184 assert_eq!(defaults.report_view(AnalysisSet::NONE), ViewSpec::List);
2187 for content in [
2188 AnalysisSet::NONE,
2189 AnalysisSet::NONE.with_lines(),
2190 AnalysisSet::NONE.with_code(),
2191 AnalysisSet::NONE.with_words(),
2192 AnalysisSet::ALL,
2193 ] {
2194 assert_eq!(defaults.report_view(content), ViewSpec::default_for(content));
2195 }
2196
2197 let request = built(&RequestSpec::new(root()));
2198 assert_eq!(request.basis.root, root());
2199 assert_eq!(request.basis.content, defaults.content);
2200 assert_eq!(request.basis.scope.read_controls, defaults.read_controls);
2201 assert_eq!(request.basis.scope.control_limits, defaults.control_limits);
2202 assert_eq!(request.basis.scope.max_depth, None);
2203 assert!(!request.basis.scope.one_filesystem);
2204 assert_eq!(request.query.views, vec![defaults.report_view(defaults.content)]);
2205 assert!(request.query.omitted_views.is_empty());
2206 assert_eq!(request.query.words_per_page, defaults.words_per_page);
2207 assert_eq!(request.query.selection.size, defaults.size);
2208 assert!(request.query.selection.is_unfiltered());
2209 let selection = &request.query.selection;
2210 assert_eq!((selection.depth, selection.limit, selection.sort), (None, None, None));
2211 assert!(!selection.reverse);
2212 assert_eq!(*request.query.axes, AxisNames::FIELDS);
2213 assert_eq!(request.now, instant());
2214 request.validate().expect("the defaults are a valid request");
2215
2216 assert_eq!(SizeMetric::default(), defaults.size);
2218 assert_eq!(Selection::default().size, defaults.size);
2219 assert_eq!(Query::default().words_per_page, defaults.words_per_page);
2220 assert_eq!(ScanConfig::default().read_controls, defaults.read_controls);
2221 assert_eq!(ScanConfig::default().control_limits, defaults.control_limits);
2222 assert_eq!(AnalysisRequest::default().profile, defaults.content);
2223 assert_eq!(AnalysisSet::default(), defaults.content);
2224 }
2225
2226 #[test]
2227 fn every_axis_of_a_spec_reaches_its_typed_value() {
2228 let include = ["*.rs".to_string()];
2229 let exclude = ["target/**".to_string()];
2230 let spec = RequestSpec {
2231 scan_depth: Some("3"),
2232 one_filesystem: true,
2233 read_controls: Some(false),
2234 control_budget: Some("all"),
2235 control_line_limit: Some("64KiB"),
2236 analyze: Some("code"),
2237 read: ReadSpec {
2238 views: Some("languages,tree"),
2239 format: None,
2240 words_per_page: Some("300"),
2241 include: &include,
2242 exclude: &exclude,
2243 min_size: Some("1KiB"),
2244 modified_since: Some("@1700000000"),
2245 modified_before: Some("@1800000000"),
2246 kinds: Some("file,dir"),
2247 ignored: Some("include"),
2248 depth: Some("all"),
2249 min_share: Some("1%"),
2250 breadth: Some("all"),
2251 limit: Some("5"),
2252 sort: Some("name"),
2253 reverse: true,
2254 size: Some("apparent"),
2255 },
2256 ..RequestSpec::new(root())
2257 };
2258 let request = Request::build(&spec, instant(), &AxisNames::FLAGS).expect("parses");
2259 let scope = &request.basis.scope;
2260 assert_eq!(scope.max_depth, Some(3));
2261 assert!(scope.one_filesystem);
2262 assert!(!scope.read_controls);
2263 assert_eq!(scope.control_limits, ControlLimits { budget: None, line_limit: Some(65_536) });
2264 assert_eq!(request.basis.content, AnalysisSet::NONE.with_code());
2265 assert_eq!(request.query.views, vec![ViewSpec::Languages, ViewSpec::Tree]);
2266 assert_eq!(request.query.words_per_page, 300);
2267 assert_eq!(*request.query.axes, AxisNames::FLAGS);
2268 let selection = &request.query.selection;
2269 assert_eq!(selection.include.len(), 1);
2270 assert_eq!(selection.exclude.len(), 1);
2271 assert_eq!(selection.min_size, Some(1024));
2272 assert_eq!(selection.modified.since, Some(1_700_000_000_000_000_000));
2273 assert_eq!(selection.modified.before, Some(1_800_000_000_000_000_000));
2274 assert_eq!(selection.kinds, vec![EntryKind::File, EntryKind::Dir]);
2275 assert_eq!(selection.ignored, IgnoredEntries::Include);
2276 assert_eq!(selection.depth, Some(Bound::All));
2277 assert_eq!(selection.limit, Some(Bound::Limit(5)));
2278 assert_eq!(selection.sort, Some(SortKey::Name));
2279 assert!(selection.reverse);
2280 assert_eq!(selection.size, SizeMetric::Apparent);
2281 }
2282
2283 #[test]
2287 fn relative_windows_resolve_against_the_requests_instant() {
2288 let spec = reading(ReadSpec {
2289 modified_since: Some("2h"),
2290 modified_before: Some("now"),
2291 ..ReadSpec::new()
2292 });
2293 let request = built(&spec);
2294 let now = system_time_to_nanos(instant()).expect("representable");
2295 let two_hours = 2 * 60 * 60 * 1_000_000_000;
2296 assert_eq!(request.now, instant());
2297 assert_eq!(request.query.selection.modified.since, Some(now - two_hours));
2298 assert_eq!(request.query.selection.modified.before, Some(now));
2299
2300 let later = instant() + Duration::from_secs(60);
2301 let moved = Request::build(&spec, later, &AxisNames::FIELDS).expect("parses");
2302 assert_eq!(moved.query.selection.modified.since, Some(now - two_hours + 60_000_000_000));
2303 let again = built(&spec);
2304 assert_eq!(again.query.selection.modified.since, request.query.selection.modified.since);
2305 }
2306
2307 #[test]
2310 fn build_refuses_each_axis_in_the_surfaces_words() {
2311 let spec = RequestSpec::new(root());
2312 let analyze = RequestSpec { analyze: Some("deep"), ..spec };
2313 for (axes, label) in [(&AxisNames::FLAGS, "--analyze"), (&AxisNames::FIELDS, "analyze")] {
2314 let today = AnalysisSet::parse_labeled("deep", label).expect_err("refused");
2315 assert_eq!(refusal(&analyze, axes), today);
2316 }
2317 for views in ["tree,tree", "tree,,types", "full,tree", "bogus"] {
2318 let spec = reading(ReadSpec { views: Some(views), ..ReadSpec::new() });
2319 for (axes, label) in [(&AxisNames::FLAGS, "--view"), (&AxisNames::FIELDS, "view")] {
2320 let today =
2321 ViewSpec::resolve(Some(views), AnalysisSet::NONE, label).expect_err("refused");
2322 assert_eq!(refusal(&spec, axes), today);
2323 }
2324 }
2325
2326 let cases: [(RequestSpec<'_>, &str, &str); 7] = [
2327 (
2328 reading(ReadSpec { words_per_page: Some("0"), ..ReadSpec::new() }),
2329 "invalid --words-per-page \"0\": expected a positive integer",
2330 "invalid words_per_page \"0\": expected a positive integer",
2331 ),
2332 (
2333 RequestSpec { scan_depth: Some("deep"), ..spec },
2334 "invalid --scan-depth \"deep\": expected a whole number",
2335 "invalid max_depth \"deep\": expected a whole number",
2336 ),
2337 (
2338 RequestSpec { control_budget: Some("lots"), ..spec },
2339 "invalid --gitignore-budget \"lots\": expected a number before the unit, as in \
2340 `10M`, or `all` for no bound",
2341 "invalid control_budget \"lots\": expected a number before the unit, as in \
2342 `10M`, or `all` for no bound",
2343 ),
2344 (
2345 reading(ReadSpec { ignored: Some("maybe"), ..ReadSpec::new() }),
2346 "invalid --ignored \"maybe\": expected one of include, \
2347 exclude, only",
2348 "invalid ignored \"maybe\": expected one of include, exclude, only",
2349 ),
2350 (
2351 reading(ReadSpec { kinds: Some("file,socket"), ..ReadSpec::new() }),
2352 "invalid --kind \"socket\": expected one of file, dir, symlink, other",
2353 "invalid kind \"socket\": expected one of file, dir, symlink, other",
2354 ),
2355 (
2356 reading(ReadSpec { min_size: Some("10X"), ..ReadSpec::new() }),
2357 "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
2358 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
2359 "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
2360 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
2361 ),
2362 (
2363 reading(ReadSpec {
2364 modified_since: Some("2300-01-01T00:00:00Z"),
2365 ..ReadSpec::new()
2366 }),
2367 "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
2368 range fdu can represent (about 1677 to 2262)",
2369 "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
2370 fdu can represent (about 1677 to 2262)",
2371 ),
2372 ];
2373 for (spec, flags, fields) in cases {
2374 assert_eq!(refusal(&spec, &AxisNames::FLAGS), flags);
2375 assert_eq!(refusal(&spec, &AxisNames::FIELDS), fields);
2376 }
2377 }
2378
2379 #[test]
2382 fn analyzer_names_in_view_axis_point_to_both_correct_axes() {
2383 for (token, flags, fields) in [
2384 (
2385 "LiNeS",
2386 "invalid --view \"LiNeS\": lines is an analyzer; its view is families, with \
2387 --analyze lines",
2388 "invalid view \"LiNeS\": lines is an analyzer; its view is families, with analyze \
2389 lines",
2390 ),
2391 (
2392 "words",
2393 "invalid --view \"words\": words is an analyzer; its view is documents",
2394 "invalid view \"words\": words is an analyzer; its view is documents",
2395 ),
2396 ] {
2397 let spec = reading(ReadSpec { views: Some(token), ..ReadSpec::new() });
2398 for (axes, expected) in [(&AxisNames::FLAGS, flags), (&AxisNames::FIELDS, fields)] {
2399 let error = Request::build(&spec, instant(), axes).expect_err("not a view");
2400 assert!(matches!(error, RequestError::AnalyzerNamedAsView { .. }));
2401 assert_eq!(error.message(axes), expected);
2402 }
2403 }
2404 let combination = reading(ReadSpec { views: Some("full,words"), ..ReadSpec::new() });
2405 assert_eq!(
2406 refusal(&combination, &AxisNames::FLAGS),
2407 ViewSpec::resolve(Some("full,words"), AnalysisSet::NONE, "--view")
2408 .expect_err("full is exclusive")
2409 );
2410 }
2411
2412 #[test]
2415 fn a_built_basis_enables_the_analyzers_its_views_imply() {
2416 type Case = (Option<&'static str>, Option<&'static str>, AnalysisSet, &'static [ViewSpec]);
2418 let cases: [Case; 10] = [
2419 (None, Some("code"), AnalysisSet::CODE_ONLY, &[ViewSpec::Code]),
2420 (None, Some("documents"), AnalysisSet::WORDS_ONLY, &[ViewSpec::Documents]),
2421 (
2422 None,
2423 Some("code,documents"),
2424 AnalysisSet::ALL,
2425 &[ViewSpec::Code, ViewSpec::Documents],
2426 ),
2427 (None, Some("full"), AnalysisSet::NONE, &[]),
2428 (None, Some("languages"), AnalysisSet::NONE, &[]),
2429 (None, Some("types,families,tree"), AnalysisSet::NONE, &[]),
2430 (Some("lines"), Some("code"), AnalysisSet::CODE_ONLY, &[ViewSpec::Code]),
2431 (Some("lines"), Some("documents"), AnalysisSet::WORDS_ONLY, &[ViewSpec::Documents]),
2432 (Some("none"), Some("code"), AnalysisSet::CODE_ONLY, &[ViewSpec::Code]),
2434 (Some("words"), Some("summary,code"), AnalysisSet::ALL, &[ViewSpec::Code]),
2435 ];
2436 for (analyze, views, content, implied_by) in cases {
2437 let spec = RequestSpec {
2438 analyze,
2439 read: ReadSpec { views, ..ReadSpec::new() },
2440 ..RequestSpec::new(root())
2441 };
2442 let request = built(&spec);
2443 assert_eq!(request.basis.content, content, "{analyze:?} {views:?}");
2444 assert_eq!(request.implied_by(), implied_by, "{analyze:?} {views:?}");
2445 request.validate().expect("a built basis answers its own views");
2446 }
2447
2448 for (view, analyzer) in
2450 [("code", "code"), ("documents", "words"), ("code,documents", "all")]
2451 {
2452 let by_view = built(&reading(ReadSpec { views: Some(view), ..ReadSpec::new() }));
2453 let by_analyzer =
2454 built(&RequestSpec { analyze: Some(analyzer), ..RequestSpec::new(root()) });
2455 assert_eq!(by_view.basis.content, by_analyzer.basis.content, "{view}");
2456 assert_eq!(by_view.query.views, by_analyzer.query.views, "{view}");
2457 assert!(by_analyzer.implied_by().is_empty(), "{analyzer} implied nothing");
2458 }
2459
2460 let full = built(&reading(ReadSpec { views: Some("full"), ..ReadSpec::new() }));
2463 assert_eq!(full.query.omitted_views, [ViewSpec::Code, ViewSpec::Documents]);
2464 }
2465
2466 #[test]
2470 fn a_held_basis_refuses_a_view_it_cannot_answer() {
2471 let read = |held, views| {
2472 Request::read(
2473 basis(held, true),
2474 &ReadSpec { views: Some(views), ..ReadSpec::new() },
2475 instant(),
2476 &AxisNames::FIELDS,
2477 )
2478 };
2479 let refused = read(AnalysisSet::NONE, "code").expect_err("nothing analyzed code");
2480 assert_eq!(
2481 refused,
2482 RequestError::ViewNeedsAnalyzer {
2483 view: ViewSpec::Code,
2484 held: AnalysisSet::NONE,
2485 holder: BasisHolder::Index,
2486 }
2487 );
2488 assert_eq!(
2489 refused.message(&AxisNames::FIELDS),
2490 "view code needs code analysis; this index was opened with analyze none"
2491 );
2492 assert_eq!(
2493 refused.message(&AxisNames::FLAGS),
2494 "--view code needs code analysis; this index was opened with --analyze none"
2495 );
2496 let refused = read(AnalysisSet::LINES_ONLY, "summary,documents").expect_err("no words");
2497 assert_eq!(
2498 refused.message(&AxisNames::FIELDS),
2499 "view documents needs words analysis; this index was opened with analyze lines"
2500 );
2501 let held = read(AnalysisSet::WORDS_ONLY, "documents").expect("words answers documents");
2502 assert_eq!(held.basis.content, AnalysisSet::WORDS_ONLY, "a read never widens a basis");
2503 assert!(held.implied_by().is_empty(), "a read implies nothing");
2504 }
2505
2506 #[test]
2512 fn a_refusal_for_missing_analysis_names_its_routes_remedy() {
2513 let documents = |holder| {
2514 let request = request_with(
2515 &[ViewSpec::Documents],
2516 Selection::default(),
2517 basis(AnalysisSet::NONE, true),
2518 );
2519 match holder {
2520 BasisHolder::Index => request.validate_read(&basis(AnalysisSet::NONE, true)),
2521 BasisHolder::OpenedRoot => request.validate_opened(),
2522 _ => request.validate(),
2523 }
2524 .expect_err("nothing analyzed words")
2525 .message(&AxisNames::FIELDS)
2526 };
2527 assert_eq!(
2528 documents(BasisHolder::Index),
2529 "view documents needs words analysis; this index was opened with analyze none"
2530 );
2531 assert_eq!(
2532 documents(BasisHolder::OpenedRoot),
2533 "view documents needs words analysis, which an opened root never runs; use a \
2534 one-shot report, or an index opened with analyze words"
2535 );
2536 assert_eq!(
2537 documents(BasisHolder::Supplied),
2538 "view documents needs words analysis; its basis holds analyze none"
2539 );
2540 assert_eq!(
2542 Request::read_opened(
2543 &ReadSpec { views: Some("documents"), ..ReadSpec::new() },
2544 instant(),
2545 &AxisNames::FIELDS,
2546 )
2547 .map_err(|error| error.message(&AxisNames::FIELDS))
2548 .map(|_| ()),
2549 Err(documents(BasisHolder::OpenedRoot))
2550 );
2551
2552 let sort = |analyze| RequestSpec {
2555 analyze,
2556 read: ReadSpec { views: Some("files"), sort: Some("code_lines"), ..ReadSpec::new() },
2557 ..RequestSpec::new(root())
2558 };
2559 assert_eq!(
2560 built(&sort(None)).validate().map_err(|error| error.message(&AxisNames::FLAGS)),
2561 Err("--sort code_lines needs code analysis: add --analyze code".to_owned())
2562 );
2563 let held = Request::read(
2564 basis(AnalysisSet::WORDS_ONLY, true),
2565 &ReadSpec { views: Some("files"), sort: Some("code_lines"), ..ReadSpec::new() },
2566 instant(),
2567 &AxisNames::FIELDS,
2568 )
2569 .expect_err("an index without code cannot sort by it");
2570 assert_eq!(
2571 held.message(&AxisNames::FIELDS),
2572 "sort code_lines needs code analysis; this index was opened with analyze words"
2573 );
2574 }
2575
2576 #[test]
2585 fn build_names_a_bad_axis_in_the_order_it_publishes() {
2586 let everything = RequestSpec {
2587 scan_depth: Some("deep"),
2588 analyze: Some("deep"),
2589 read: ReadSpec {
2590 views: Some("bogus"),
2591 depth: Some("two"),
2592 words_per_page: Some("0"),
2593 ..ReadSpec::new()
2594 },
2595 ..RequestSpec::new(root())
2596 };
2597 let steps: [(RequestSpec<'_>, &str); 5] = [
2598 (everything, "analyze"),
2599 (RequestSpec { analyze: None, ..everything }, "view"),
2600 (
2601 RequestSpec {
2602 analyze: None,
2603 read: ReadSpec { views: None, ..everything.read },
2604 ..everything
2605 },
2606 "depth",
2607 ),
2608 (
2609 RequestSpec {
2610 analyze: None,
2611 read: ReadSpec { views: None, depth: None, ..everything.read },
2612 ..everything
2613 },
2614 "words_per_page",
2615 ),
2616 (
2617 RequestSpec {
2618 analyze: None,
2619 read: ReadSpec {
2620 views: None,
2621 format: None,
2622 depth: None,
2623 words_per_page: None,
2624 ..everything.read
2625 },
2626 ..everything
2627 },
2628 "max_depth",
2629 ),
2630 ];
2631 for (spec, axis) in steps {
2632 let refused = refusal(&spec, &AxisNames::FIELDS);
2633 assert!(
2634 refused.starts_with(&format!("invalid {axis} ")),
2635 "expected {axis} to speak next, got {refused}"
2636 );
2637 }
2638 Request::build(
2640 &RequestSpec {
2641 scan_depth: None,
2642 analyze: None,
2643 read: ReadSpec::new(),
2644 ..RequestSpec::new(root())
2645 },
2646 instant(),
2647 &AxisNames::FIELDS,
2648 )
2649 .expect("nothing left to refuse");
2650 }
2651
2652 fn request_with(views: &[ViewSpec], selection: Selection, basis: Basis) -> Request {
2653 Request::new(
2654 basis,
2655 Query { selection, views: views.to_vec(), ..Query::default() },
2656 instant(),
2657 )
2658 }
2659
2660 fn basis(content: AnalysisSet, read_controls: bool) -> Basis {
2661 Basis {
2662 root: root().to_path_buf(),
2663 scope: Scope { read_controls, ..Scope::default() },
2664 content,
2665 }
2666 }
2667
2668 #[test]
2670 fn language_grouping_is_metadata_only_while_documents_require_analysis() {
2671 let enabled = [
2672 AnalysisSet::NONE.with_lines(),
2673 AnalysisSet::NONE.with_code(),
2674 AnalysisSet::NONE.with_words(),
2675 AnalysisSet::ALL,
2676 ];
2677 for content in std::iter::once(AnalysisSet::NONE).chain(enabled) {
2678 request_with(&[ViewSpec::Languages], Selection::default(), basis(content, true))
2679 .validate()
2680 .expect("language grouping never requires content I/O");
2681 }
2682
2683 for held in [AnalysisSet::NONE, AnalysisSet::LINES_ONLY, AnalysisSet::CODE_ONLY] {
2686 let documents =
2687 request_with(&[ViewSpec::Documents], Selection::default(), basis(held, true));
2688 assert_eq!(
2689 documents.validate(),
2690 Err(RequestError::ViewNeedsAnalyzer {
2691 view: ViewSpec::Documents,
2692 held,
2693 holder: BasisHolder::Supplied,
2694 })
2695 );
2696 }
2697 for content in [AnalysisSet::WORDS_ONLY, AnalysisSet::ALL] {
2698 request_with(&[ViewSpec::Documents], Selection::default(), basis(content, true))
2699 .validate()
2700 .expect("a basis with words answers the document view");
2701 }
2702
2703 request_with(
2704 &[ViewSpec::Types, ViewSpec::Families],
2705 Selection::default(),
2706 basis(AnalysisSet::NONE, true),
2707 )
2708 .validate()
2709 .expect("metadata grouping never requires content I/O");
2710 }
2711
2712 #[test]
2713 fn code_view_and_metric_sort_require_their_registered_analyzer() {
2714 let plain = basis(AnalysisSet::NONE, true);
2715 let code = request_with(&[ViewSpec::Code], Selection::default(), plain.clone());
2716 assert_eq!(
2717 code.validate(),
2718 Err(RequestError::ViewNeedsAnalyzer {
2719 view: ViewSpec::Code,
2720 held: AnalysisSet::NONE,
2721 holder: BasisHolder::Supplied,
2722 })
2723 );
2724 let selection =
2725 Selection { sort: Some(SortKey::Metric("code_lines")), ..Selection::default() };
2726 let files = request_with(&[ViewSpec::Files], selection.clone(), plain);
2727 assert_eq!(
2728 files.validate(),
2729 Err(RequestError::SortNeedsAnalyzer {
2730 metric: "code_lines",
2731 analyzer: AnalysisSet::CODE_ONLY,
2732 held: AnalysisSet::NONE,
2733 holder: BasisHolder::Supplied,
2734 })
2735 );
2736 request_with(&[ViewSpec::Files], selection, basis(AnalysisSet::NONE.with_code(), true))
2737 .validate()
2738 .expect("code metrics are available with the code analyzer");
2739 }
2740
2741 #[test]
2742 fn extension_view_refuses_metric_sort_before_reading() {
2743 let held = basis(AnalysisSet::NONE.with_code(), true);
2744 let selection =
2745 Selection { sort: Some(SortKey::Metric("code_lines")), ..Selection::default() };
2746 let request = request_with(&[ViewSpec::Extensions], selection.clone(), held.clone());
2747 let expected = invalid(
2748 request.query.axes.sort,
2749 "code_lines",
2750 "extensions cannot sort by content metrics; use size, count, or name, or select files or another metric-capable view",
2751 );
2752 assert_eq!(request.validate(), Err(expected.clone()));
2753 assert_eq!(request.validate_read(&held), Err(expected));
2754 request_with(&[ViewSpec::Files], selection, held)
2755 .validate()
2756 .expect("files retain metric sorting");
2757 }
2758
2759 #[test]
2771 fn a_scope_this_build_cannot_honour_is_refused_by_request_validation() {
2772 let held = basis(AnalysisSet::NONE, true);
2773 let mut asked = held.clone();
2774 asked.scope.follow_symlinks = true;
2775 let request = request_with(&[ViewSpec::Summary], Selection::default(), asked);
2776 let refusal = RequestError::ScopeUnsupported {
2777 axis: ScopeAxis::FollowSymlinks,
2778 reason: ScopeAxis::FollowSymlinks.reason(),
2779 };
2780
2781 assert_eq!(request.validate(), Err(refusal.clone()));
2782 assert_eq!(request.validate_read(&held), Err(refusal));
2783 assert_eq!(
2786 ScanConfig { follow_symlinks: true, ..ScanConfig::default() }
2787 .unsupported_axis()
2788 .expect("no build follows symbolic links")
2789 .reason(),
2790 ScopeAxis::FollowSymlinks.reason()
2791 );
2792 request_with(&[ViewSpec::Summary], Selection::default(), held)
2793 .validate()
2794 .expect("a scope this build honours is not refused");
2795 }
2796
2797 #[test]
2801 fn a_scope_that_observes_no_control_state_refuses_selection_by_ignored_state() {
2802 let exclude = Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() };
2803 let only = Selection { ignored: IgnoredEntries::Only, ..Selection::default() };
2804 let blind = basis(AnalysisSet::NONE, false);
2805
2806 let refused = request_with(&[ViewSpec::Summary], exclude.clone(), blind.clone())
2807 .validate()
2808 .expect_err("no entry can be shown to be ignored");
2809 assert_eq!(
2810 refused.message(&AxisNames::FLAGS),
2811 "--ignored=exclude needs .gitignore classification, and --no-gitignore turned it \
2812 off; drop one of them"
2813 );
2814 let refused = request_with(&[ViewSpec::Summary], only, blind.clone())
2815 .validate()
2816 .expect_err("no entry can be shown to be ignored");
2817 assert_eq!(
2818 refused.message(&AxisNames::FIELDS),
2819 "ignored=only needs .gitignore classification, and read_controls turned it off; \
2820 drop one of them"
2821 );
2822 request_with(&[ViewSpec::Summary], exclude, basis(AnalysisSet::NONE, true))
2823 .validate()
2824 .expect("an observing scope can select by ignored state");
2825 request_with(&[ViewSpec::Summary], Selection::default(), blind)
2826 .validate()
2827 .expect("admitting every entry needs no classification");
2828 }
2829
2830 #[test]
2831 fn a_read_is_refused_by_a_holder_of_other_content() {
2832 let request = built(&RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) });
2833 let held = basis(AnalysisSet::NONE, true);
2834 assert_eq!(
2835 request.validate_read(&held),
2836 Err(RequestError::ContentMismatch {
2837 held: AnalysisSet::NONE,
2838 requested: AnalysisSet::NONE.with_lines(),
2839 })
2840 );
2841 assert_eq!(
2844 request.validate_read(&basis(AnalysisSet::ALL, true)),
2845 Err(RequestError::ContentMismatch {
2846 held: AnalysisSet::ALL,
2847 requested: AnalysisSet::NONE.with_lines(),
2848 })
2849 );
2850 request
2851 .validate_read(&basis(AnalysisSet::NONE.with_lines(), true))
2852 .expect("equal content serves");
2853
2854 let exclude = built(&reading(ReadSpec { ignored: Some("exclude"), ..ReadSpec::new() }));
2856 assert_eq!(
2857 exclude.validate_read(&basis(AnalysisSet::NONE, false)),
2858 Err(RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude))
2859 );
2860 let documents = request_with(
2862 &[ViewSpec::Documents],
2863 Selection::default(),
2864 basis(AnalysisSet::NONE.with_words(), true),
2865 );
2866 assert!(matches!(
2867 documents.validate_read(&basis(AnalysisSet::NONE, true)),
2868 Err(RequestError::ContentMismatch { .. })
2869 ));
2870 }
2871
2872 #[test]
2876 fn the_basis_a_retained_index_holds_is_what_it_observed() {
2877 let blind =
2878 crate::Index::new_with_scope("/root", crate::test_support::not_observing_controls());
2879 let held = Basis::held_by(&blind);
2880 assert_eq!(held.root, Path::new("/root"));
2881 assert!(!held.scope.read_controls, "an index that read no rule says so");
2882 assert_eq!(held.content, AnalysisSet::NONE, "a metadata index holds no analyzer");
2883
2884 let observing =
2885 crate::Index::new_with_scope("/root", crate::test_support::observing_controls());
2886 assert!(Basis::held_by(&observing).scope.read_controls);
2887
2888 let tight = ControlLimits { budget: Some(4_096), line_limit: None };
2892 let narrow = crate::Index::new_with_config(
2893 "/root",
2894 &ScanConfig { read_controls: true, control_limits: tight, ..ScanConfig::default() },
2895 );
2896 assert_eq!(Basis::held_by(&narrow).scope.control_limits, tight);
2897 assert_ne!(tight, Request::DEFAULTS.control_limits, "the fixture must differ");
2898 assert_eq!(
2899 Basis::held_by(&blind).scope.control_limits,
2900 Request::DEFAULTS.control_limits,
2901 "a scan that read no rule applied none, and names the table's"
2902 );
2903
2904 let request =
2906 built(&RequestSpec { read_controls: Some(false), ..RequestSpec::new(root()) });
2907 request
2908 .validate_read(&Basis::held_by(&blind))
2909 .expect("the index's own basis answers a request built the same way");
2910 }
2911
2912 #[test]
2915 fn a_watch_refuses_what_it_cannot_keep_current() {
2916 let one_shot = Delivery {
2917 stale_ok: false,
2918 cache: CachePolicy::Auto,
2919 cache_path: None,
2920 accept_partial: false,
2921 watch: None,
2922 workers: Workers::default(),
2923 batch_size: ScanConfig::default().batch_size,
2924 order: crate::ScanOrder::default(),
2925 };
2926 let watching = Delivery { watch: Some(WatchDelivery::default()), ..one_shot.clone() };
2927 let cases = [
2928 (
2929 RequestSpec { scan_depth: Some("2"), ..RequestSpec::new(root()) },
2930 RequestError::WatchScope,
2931 ),
2932 (
2933 RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) },
2934 RequestError::WatchScope,
2935 ),
2936 (
2937 RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) },
2938 RequestError::WatchContent { named: AnalysisSet::LINES_ONLY, views: Vec::new() },
2939 ),
2940 (
2943 reading(ReadSpec { views: Some("summary,code"), ..ReadSpec::new() }),
2944 RequestError::WatchContent {
2945 named: AnalysisSet::NONE,
2946 views: vec![ViewSpec::Code],
2947 },
2948 ),
2949 (
2951 RequestSpec {
2952 analyze: Some("none"),
2953 read: ReadSpec { views: Some("code"), ..ReadSpec::new() },
2954 ..RequestSpec::new(root())
2955 },
2956 RequestError::WatchContent {
2957 named: AnalysisSet::NONE,
2958 views: vec![ViewSpec::Code],
2959 },
2960 ),
2961 (
2963 RequestSpec {
2964 analyze: Some("lines"),
2965 read: ReadSpec { views: Some("documents"), ..ReadSpec::new() },
2966 ..RequestSpec::new(root())
2967 },
2968 RequestError::WatchContent {
2969 named: AnalysisSet::LINES_ONLY,
2970 views: vec![ViewSpec::Documents],
2971 },
2972 ),
2973 ];
2974 for (spec, expected) in cases {
2975 let request = built(&spec);
2976 assert_eq!(request.validate_delivery(&watching), Err(expected));
2977 request.validate_delivery(&one_shot).expect("a one-shot delivers every one");
2978 }
2979 assert_eq!(
2980 built(&reading(ReadSpec { views: Some("code"), ..ReadSpec::new() }))
2981 .validate_delivery(&watching)
2982 .map_err(|error| error.message(&AxisNames::FLAGS)),
2983 Err("--view code needs code analysis, which --watch cannot keep current; use a \
2984 one-shot report"
2985 .to_owned())
2986 );
2987 let both = RequestSpec {
2990 analyze: Some("words"),
2991 read: ReadSpec { views: Some("code"), ..ReadSpec::new() },
2992 ..RequestSpec::new(root())
2993 };
2994 assert_eq!(
2995 built(&both)
2996 .validate_delivery(&watching)
2997 .map_err(|error| error.message(&AxisNames::FLAGS)),
2998 Err("--analyze words and --view code need words and code analysis, which --watch \
2999 cannot keep current; use a one-shot report"
3000 .to_owned())
3001 );
3002 for views in [None, Some("full")] {
3005 let spec = RequestSpec {
3006 analyze: Some("all"),
3007 read: ReadSpec { views, ..ReadSpec::new() },
3008 ..RequestSpec::new(root())
3009 };
3010 assert_eq!(
3011 built(&spec).validate_delivery(&watching),
3012 Err(RequestError::WatchContent { named: AnalysisSet::ALL, views: Vec::new() }),
3013 "{views:?}"
3014 );
3015 }
3016 assert_eq!(
3018 request_with(
3019 &[ViewSpec::Tree],
3020 Selection::default(),
3021 basis(AnalysisSet::CODE_ONLY, true)
3022 )
3023 .validate_delivery(&watching),
3024 Err(RequestError::WatchContent { named: AnalysisSet::CODE_ONLY, views: Vec::new() })
3025 );
3026
3027 let implied = built(&reading(ReadSpec { views: Some("code"), ..ReadSpec::new() }));
3030 implied
3031 .validate_delivery(&Delivery { stale_ok: true, ..one_shot.clone() })
3032 .expect("a stale answer needs only a compatible sidecar");
3033
3034 let plain = built(&RequestSpec::new(root()));
3035 plain.validate_delivery(&watching).expect("a full-scope metadata watch is deliverable");
3036 assert_eq!(
3037 plain.validate_delivery(&Delivery { stale_ok: true, ..watching.clone() }),
3038 Err(RequestError::WatchCacheOnly),
3039 "nothing verifies the window between the snapshot and the start of the watch"
3040 );
3041 plain
3042 .validate_delivery(&Delivery { stale_ok: true, ..one_shot })
3043 .expect("a one-shot report is exactly what a snapshot answers");
3044 }
3045
3046 #[test]
3054 fn the_watch_rules_speak_in_one_order() {
3055 let cache_only_watch = Delivery {
3056 cache: CachePolicy::Auto,
3057 stale_ok: true,
3058 cache_path: None,
3059 accept_partial: false,
3060 watch: Some(WatchDelivery::default()),
3061 workers: Workers::default(),
3062 batch_size: ScanConfig::default().batch_size,
3063 order: crate::ScanOrder::default(),
3064 };
3065 let everything = RequestSpec {
3066 scan_depth: Some("2"),
3067 analyze: Some("lines"),
3068 ..RequestSpec::new(root())
3069 };
3070 let steps = [
3071 (everything, RequestError::WatchScope),
3072 (
3073 RequestSpec { scan_depth: None, ..everything },
3074 RequestError::WatchContent { named: AnalysisSet::LINES_ONLY, views: Vec::new() },
3075 ),
3076 (
3077 RequestSpec { scan_depth: None, analyze: None, ..everything },
3078 RequestError::WatchCacheOnly,
3079 ),
3080 ];
3081 for (spec, expected) in steps {
3082 assert_eq!(built(&spec).validate_delivery(&cache_only_watch), Err(expected));
3083 }
3084 built(&RequestSpec::new(root()))
3085 .validate_delivery(&Delivery { stale_ok: false, ..cache_only_watch })
3086 .expect("nothing left to refuse");
3087 }
3088
3089 #[cfg(unix)]
3093 #[test]
3094 fn a_watch_refuses_one_filesystem_where_the_build_honors_it() {
3095 let watch = Delivery {
3096 stale_ok: false,
3097 cache: CachePolicy::Auto,
3098 cache_path: None,
3099 accept_partial: false,
3100 watch: Some(WatchDelivery::default()),
3101 workers: Workers::default(),
3102 batch_size: ScanConfig::default().batch_size,
3103 order: crate::ScanOrder::default(),
3104 };
3105 let spec = RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) };
3106 let request = built(&spec);
3107 request.validate().expect("one filesystem is honored on this build");
3108 assert_eq!(request.validate_delivery(&watch), Err(RequestError::WatchScope));
3109 }
3110
3111 #[test]
3112 fn a_request_is_refused_past_the_views_one_report_carries() {
3113 let mut request = built(&RequestSpec::new(root()));
3114 request.query.views = vec![ViewSpec::Summary; crate::MAX_REPORT_VIEWS];
3115 request.validate().expect("the limit itself is accepted");
3116 request.query.omitted_views = vec![ViewSpec::Documents];
3117 assert_eq!(
3118 request.validate(),
3119 Err(RequestError::ViewLimit {
3120 attempted: crate::MAX_REPORT_VIEWS + 1,
3121 limit: crate::MAX_REPORT_VIEWS,
3122 })
3123 );
3124 }
3125}