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::{Bound, IgnoredEntries, Selection, SizeMetric, SortKey};
26use crate::query::query_values::{
27 parse_control_budget, parse_control_line_limit, parse_size, parse_when, system_time_to_nanos,
28};
29use crate::scan::ScanConfig;
30
31#[derive(Clone, Debug)]
33#[allow(clippy::struct_excessive_bools)]
34pub struct Scope {
35 pub max_depth: Option<usize>,
37 pub follow_symlinks: bool,
39 pub one_filesystem: bool,
41 pub hidden: Option<std::sync::Arc<crate::admission::HiddenPolicy>>,
43 pub exclude_special: bool,
45 pub types: Option<std::sync::Arc<crate::classify::TypeRegistry>>,
47 pub read_controls: bool,
49 pub control_limits: ControlLimits,
51}
52impl Default for Scope {
53 fn default() -> Self {
54 ScanConfig::default().into()
55 }
56}
57impl From<ScanConfig> for Scope {
58 fn from(scan: ScanConfig) -> Self {
59 Self {
60 max_depth: scan.max_depth,
61 follow_symlinks: scan.follow_symlinks,
62 one_filesystem: scan.one_filesystem,
63 hidden: scan.hidden,
64 exclude_special: scan.exclude_special,
65 types: scan.types,
66 read_controls: scan.read_controls,
67 control_limits: scan.control_limits,
68 }
69 }
70}
71impl Scope {
72 pub fn scan_config(&self, delivery: &Delivery) -> ScanConfig {
74 ScanConfig {
75 max_depth: self.max_depth,
76 follow_symlinks: self.follow_symlinks,
77 one_filesystem: self.one_filesystem,
78 hidden: self.hidden.clone(),
79 exclude_special: self.exclude_special,
80 types: self.types.clone(),
81 read_controls: self.read_controls,
82 control_limits: self.control_limits,
83 threads: delivery.workers.scan,
84 batch_size: delivery.batch_size,
85 order: delivery.order,
86 progress: None,
87 }
88 }
89 fn identity_config(&self) -> ScanConfig {
90 ScanConfig {
91 max_depth: self.max_depth,
92 follow_symlinks: self.follow_symlinks,
93 one_filesystem: self.one_filesystem,
94 hidden: self.hidden.clone(),
95 exclude_special: self.exclude_special,
96 types: self.types.clone(),
97 read_controls: self.read_controls,
98 control_limits: self.control_limits,
99 ..ScanConfig::default()
100 }
101 }
102 pub fn scope(&self) -> crate::ScanScope {
104 self.identity_config().scope()
105 }
106 pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
108 self.identity_config().snapshot_identity()
109 }
110 pub(crate) fn unsupported_axis(&self) -> Option<ScopeAxis> {
111 self.identity_config().unsupported_axis()
112 }
113}
114
115#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
117pub struct Workers {
118 pub scan: Option<usize>,
120 pub analysis: usize,
122}
123
124#[derive(Clone, Debug)]
129pub struct Basis {
130 pub root: PathBuf,
132 pub scope: Scope,
136 pub content: AnalysisSet,
138}
139
140impl Basis {
141 pub fn held_by(index: &crate::Index) -> Self {
154 let scope = index.scope();
155 let controls = index.control_identity();
156 Self {
157 root: index.root_path().to_path_buf(),
158 scope: Scope {
159 max_depth: scope.max_depth,
160 follow_symlinks: scope.follow_symlinks,
161 one_filesystem: scope.one_filesystem,
162 exclude_special: scope.exclude_special,
163 read_controls: controls.is_observed(),
164 control_limits: match controls {
165 crate::ControlTierIdentity::Observed { limits } => limits,
166 crate::ControlTierIdentity::NotObserved => Self::UNOBSERVED_LIMITS,
167 },
168 ..Scope::default()
169 },
170 content: index.content_set(),
171 }
172 }
173
174 pub fn build(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Self, RequestError> {
185 let content = parse_content(spec, axes)?;
186 let scope = parse_scope(spec, axes)?;
187 Ok(Self { root: spec.root.to_path_buf(), scope, content })
188 }
189
190 const UNOBSERVED_LIMITS: ControlLimits = Request::DEFAULTS.control_limits;
196}
197
198#[derive(Clone, Debug, PartialEq, Eq)]
207pub struct Delivery {
208 pub cache: CachePolicy,
210 pub cache_path: Option<PathBuf>,
212 pub accept_partial: bool,
214 pub watch: Option<WatchDelivery>,
216 pub workers: Workers,
218 pub batch_size: usize,
220 pub order: crate::ScanOrder,
222}
223
224impl Delivery {
225 pub fn new(cache: CachePolicy, cache_path: Option<PathBuf>) -> Self {
227 Self {
228 cache,
229 cache_path,
230 accept_partial: false,
231 watch: None,
232 workers: Workers::default(),
233 batch_size: ScanConfig::default().batch_size,
234 order: crate::ScanOrder::default(),
235 }
236 }
237
238 pub fn enumerate() -> impl Iterator<Item = Self> {
242 [
243 CachePolicy::Auto,
244 CachePolicy::Refresh,
245 CachePolicy::ReadOnly,
246 CachePolicy::Only,
247 CachePolicy::Off,
248 ]
249 .into_iter()
250 .flat_map(|cache| {
251 [false, true].into_iter().flat_map(move |accept_partial| {
252 [None, Some(WatchDelivery::default())].into_iter().map(move |watch| Self {
253 cache,
254 cache_path: Some(PathBuf::from("cache.fdu")),
255 accept_partial,
256 watch,
257 workers: Workers { analysis: 1, ..Workers::default() },
258 batch_size: ScanConfig::default().batch_size,
259 order: crate::ScanOrder::default(),
260 })
261 })
262 })
263 }
264}
265
266#[derive(Clone, Copy, Debug, PartialEq, Eq)]
268pub struct WatchDelivery {
269 pub interval: Duration,
271}
272
273impl Default for WatchDelivery {
274 fn default() -> Self {
275 Self { interval: Self::DEFAULT_INTERVAL }
276 }
277}
278impl WatchDelivery {
279 pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(2);
281}
282
283#[derive(Clone, Debug)]
285pub struct Request {
286 pub basis: Basis,
288 pub query: Query,
290 pub now: SystemTime,
295}
296
297#[derive(Clone, Copy, Debug)]
306pub struct RequestSpec<'a> {
307 pub root: &'a Path,
309 pub scan_depth: Option<&'a str>,
311 pub one_filesystem: bool,
313 pub read_controls: Option<bool>,
315 pub control_budget: Option<&'a str>,
317 pub control_line_limit: Option<&'a str>,
319 pub analyze: Option<&'a str>,
321 pub read: ReadSpec<'a>,
323}
324
325#[derive(Clone, Copy, Debug)]
333pub struct ReadSpec<'a> {
334 pub views: Option<&'a str>,
336 pub format: Option<&'a str>,
338 pub words_per_page: Option<&'a str>,
340 pub include: &'a [String],
342 pub exclude: &'a [String],
344 pub min_size: Option<&'a str>,
346 pub modified_since: Option<&'a str>,
348 pub modified_before: Option<&'a str>,
350 pub kinds: Option<&'a str>,
352 pub ignored: Option<&'a str>,
354 pub depth: Option<&'a str>,
356 pub limit: Option<&'a str>,
358 pub sort: Option<&'a str>,
360 pub reverse: bool,
362 pub size: Option<&'a str>,
364}
365
366impl ReadSpec<'_> {
367 pub const fn new() -> Self {
369 Self {
370 views: None,
371 format: None,
372 words_per_page: None,
373 include: &[],
374 exclude: &[],
375 min_size: None,
376 modified_since: None,
377 modified_before: None,
378 kinds: None,
379 ignored: None,
380 depth: None,
381 limit: None,
382 sort: None,
383 reverse: false,
384 size: None,
385 }
386 }
387}
388
389impl Default for ReadSpec<'_> {
390 fn default() -> Self {
391 Self::new()
392 }
393}
394
395impl<'a> RequestSpec<'a> {
396 pub const fn new(root: &'a Path) -> Self {
398 Self {
399 root,
400 scan_depth: None,
401 one_filesystem: false,
402 read_controls: None,
403 control_budget: None,
404 control_line_limit: None,
405 analyze: None,
406 read: ReadSpec::new(),
407 }
408 }
409}
410
411#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434pub struct RequestDefaults {
435 pub size: SizeMetric,
437 pub words_per_page: u64,
439 pub content: AnalysisSet,
441 pub read_controls: bool,
443 pub control_limits: ControlLimits,
445}
446
447impl RequestDefaults {
448 pub const fn report_view(self, content: AnalysisSet) -> ViewSpec {
451 ViewSpec::default_for(content)
452 }
453}
454
455impl Request {
456 pub const DEFAULTS: RequestDefaults = RequestDefaults {
458 size: SizeMetric::Allocated,
459 words_per_page: 250,
460 content: AnalysisSet::NONE,
461 read_controls: true,
462 control_limits: ControlLimits {
463 budget: Some(DEFAULT_CONTROL_BUDGET),
464 line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
465 },
466 };
467
468 pub const fn new(basis: Basis, query: Query, now: SystemTime) -> Self {
480 Self { basis, query, now }
481 }
482
483 pub fn read(
496 basis: Basis,
497 spec: &ReadSpec<'_>,
498 now: SystemTime,
499 axes: &'static AxisNames,
500 ) -> Result<Self, RequestError> {
501 let query = build_query(basis.content, spec, now, axes)?;
502 let request = Self::new(basis, query, now);
503 request.validate()?;
504 Ok(request)
505 }
506
507 pub fn build(
514 spec: &RequestSpec<'_>,
515 now: SystemTime,
516 axes: &'static AxisNames,
517 ) -> Result<Self, RequestError> {
518 let content = parse_content(spec, axes)?;
523 let query = build_query(content, &spec.read, now, axes)?;
524 let scope = parse_scope(spec, axes)?;
525 Ok(Self::new(Basis { root: spec.root.to_path_buf(), scope, content }, query, now))
526 }
527
528 pub fn validate(&self) -> Result<(), RequestError> {
533 self.validate_against(&self.basis)
534 }
535
536 pub fn validate_read(&self, held: &Basis) -> Result<(), RequestError> {
545 let entries = held.scope.snapshot_identity().entries;
546 let wanted = crate::ContentTierIdentity::for_request(entries, self.basis.content);
547 let stored = crate::ContentTierIdentity::for_request(entries, held.content);
548 if wanted.admit(&stored).is_none() {
549 return Err(RequestError::ContentMismatch {
550 held: held.content,
551 requested: self.basis.content,
552 });
553 }
554 self.validate_against(held)
555 }
556
557 pub fn validate_delivery(&self, delivery: &Delivery) -> Result<(), RequestError> {
575 if delivery.watch.is_none() {
576 return Ok(());
577 }
578 if self.basis.scope.max_depth.is_some() || self.basis.scope.one_filesystem {
579 return Err(RequestError::WatchScope);
580 }
581 if self.basis.content.is_enabled() {
582 return Err(RequestError::WatchContent);
583 }
584 if delivery.cache == CachePolicy::Only {
585 return Err(RequestError::WatchCacheOnly);
586 }
587 Ok(())
588 }
589
590 fn validate_against(&self, basis: &Basis) -> Result<(), RequestError> {
591 if let Some(axis) = self.basis.scope.unsupported_axis() {
596 return Err(RequestError::ScopeUnsupported { axis, reason: axis.reason() });
597 }
598 let views = self.query.views.len().saturating_add(self.query.omitted_views.len());
599 if views > crate::MAX_REPORT_VIEWS {
600 return Err(RequestError::ViewLimit {
601 attempted: views,
602 limit: crate::MAX_REPORT_VIEWS,
603 });
604 }
605 let format = self.query.format;
606 if matches!(
607 format,
608 crate::report_format::Format::Tree
609 | crate::report_format::Format::Paths
610 | crate::report_format::Format::Long
611 ) {
612 let compatible = self.query.views.len() == 1
613 && self.query.views.iter().all(|view| {
614 matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
615 || (format != crate::report_format::Format::Tree
616 && matches!(view, ViewSpec::Largest | ViewSpec::Recent))
617 });
618 if !compatible {
619 return Err(invalid(
620 self.query.axes.format,
621 format.label(),
622 "requires a single list view; use text or a machine format for aggregate/mixed views (largest/recent support paths and long)",
623 ));
624 }
625 }
626 check_views(&self.query.views, basis.content)?;
627 check_observation(self.query.selection.ignored, basis.scope.read_controls)
628 }
629}
630
631fn parse_content(
633 spec: &RequestSpec<'_>,
634 axes: &'static AxisNames,
635) -> Result<AnalysisSet, RequestError> {
636 spec.analyze.map_or(Ok(Request::DEFAULTS.content), |value| {
637 AnalysisSet::parse_rejecting(value).map_err(|rejection| rejection.on(axes.analyze))
638 })
639}
640
641fn parse_scope(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Scope, RequestError> {
647 let limits = Request::DEFAULTS.control_limits;
648 Ok(Scope {
649 max_depth: spec
650 .scan_depth
651 .map(|value| {
652 value
653 .trim()
654 .parse::<usize>()
655 .map_err(|_| invalid(axes.scan_depth, value, "expected a whole number"))
656 })
657 .transpose()?,
658 one_filesystem: spec.one_filesystem,
659 read_controls: spec.read_controls.unwrap_or(Request::DEFAULTS.read_controls),
660 control_limits: ControlLimits {
661 budget: spec.control_budget.map_or(Ok(limits.budget), |value| {
662 parse_control_budget(value)
663 .map_err(|error| named_refusal(error, axes.control_budget))
664 })?,
665 line_limit: spec.control_line_limit.map_or(Ok(limits.line_limit), |value| {
666 parse_control_line_limit(value)
667 .map_err(|error| named_refusal(error, axes.control_line_limit))
668 })?,
669 },
670 ..Scope::default()
671 })
672}
673
674fn build_query(
680 content: AnalysisSet,
681 spec: &ReadSpec<'_>,
682 now: SystemTime,
683 axes: &'static AxisNames,
684) -> Result<Query, RequestError> {
685 let (views, omitted_views) = ViewSpec::resolve_rejecting(spec.views, content)
686 .map_err(|rejection| rejection.on(axes.view))?;
687
688 let mut selection = Selection {
689 depth: spec.depth.map(|value| parse_bound(value, axes.depth)).transpose()?,
690 limit: spec.limit.map(|value| parse_bound(value, axes.limit)).transpose()?,
691 reverse: spec.reverse,
692 size: spec
693 .size
694 .map_or(Ok(Request::DEFAULTS.size), |value| parse_size_metric(value, axes.size))?,
695 ..Selection::default()
696 };
697 for pattern in spec.include {
698 selection.include.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
699 }
700 for pattern in spec.exclude {
701 selection.exclude.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
702 }
703 if let Some(value) = spec.min_size {
704 selection.min_size = Some(parse_size(value).map_err(grammar_refusal)?);
705 }
706 if let Some(value) = spec.modified_since {
707 let when = parse_when(value, now).map_err(grammar_refusal)?;
708 selection.modified.since = Some(bound_nanos(value, when, axes.modified_since)?);
709 }
710 if let Some(value) = spec.modified_before {
711 let when = parse_when(value, now).map_err(grammar_refusal)?;
712 selection.modified.before = Some(bound_nanos(value, when, axes.modified_before)?);
713 }
714 if let Some(value) = spec.kinds {
715 selection.kinds = parse_kinds(value, axes.kind)?;
716 }
717 if let Some(value) = spec.sort {
718 selection.sort = Some(parse_sort(value, axes.sort)?);
719 }
720 if let Some(value) = spec.ignored {
721 selection.ignored = IgnoredEntries::parse(value)
722 .map_err(|expected| Rejection::new(value, expected).on(axes.ignored))?;
723 }
724 let words_per_page =
725 spec.words_per_page.map_or(Ok(Request::DEFAULTS.words_per_page), |value| {
726 value
727 .trim()
728 .parse::<u64>()
729 .ok()
730 .filter(|words| *words > 0)
731 .ok_or_else(|| invalid(axes.words_per_page, value, "expected a positive integer"))
732 })?;
733
734 let format = spec.format.map_or(Ok(crate::report_format::Format::Text), |value| {
735 crate::report_format::Format::parse(value).ok_or_else(|| {
736 invalid(
737 axes.format,
738 value,
739 format!("expected one of {}", crate::report_format::Format::ALL.join(", ")),
740 )
741 })
742 })?;
743 Ok(Query { selection, views, format, omitted_views, axes, words_per_page })
744}
745
746#[derive(Clone, Copy, Debug, PartialEq, Eq)]
755pub enum ScopeAxis {
756 FollowSymlinks,
758 OneFilesystem,
760}
761
762impl ScopeAxis {
763 pub const fn reason(self) -> &'static str {
770 match self {
771 Self::FollowSymlinks => {
772 "follow_symlinks requires cycle, root-boundary, and filesystem-boundary semantics"
773 }
774 Self::OneFilesystem => "one_filesystem requires platform device identity",
775 }
776 }
777
778 const fn named(self, axes: &AxisNames) -> &'static str {
780 match self {
781 Self::FollowSymlinks => axes.follow_symlinks,
782 Self::OneFilesystem => axes.one_filesystem,
783 }
784 }
785}
786
787#[derive(Clone, Debug, PartialEq, Eq)]
792pub enum RequestError {
793 InvalidValue {
795 axis: &'static str,
797 value: String,
799 expected: String,
801 },
802 ViewNeedsContent(ViewSpec),
804 IgnoredWithoutObservation(IgnoredEntries),
806 ScopeUnsupported {
808 axis: ScopeAxis,
810 reason: &'static str,
812 },
813 ContentMismatch {
815 held: AnalysisSet,
817 requested: AnalysisSet,
819 },
820 WatchScope,
822 WatchContent,
824 WatchCacheOnly,
826 RootMismatch {
828 held: PathBuf,
830 requested: PathBuf,
832 },
833 DeliveryUnsupported {
835 route: &'static str,
837 reason: &'static str,
839 },
840 ViewLimit {
842 attempted: usize,
844 limit: usize,
846 },
847}
848
849impl RequestError {
850 pub fn message(&self, axes: &AxisNames) -> String {
855 match self {
856 Self::InvalidValue { axis, value, expected } => invalid_message(axis, value, expected),
857 Self::ViewNeedsContent(view) => format!(
858 "{} {} requires content analysis: add {} lines, code, words, or all; views never \
859 enable content analysis implicitly",
860 axes.view,
861 view.label(),
862 axes.analyze
863 ),
864 Self::IgnoredWithoutObservation(ignored) => format!(
865 "{} needs .gitignore classification, and {} turned it off; drop one of them",
866 match ignored {
867 IgnoredEntries::Exclude => axes.exclude_ignored,
868 IgnoredEntries::Only => axes.only_ignored,
869 IgnoredEntries::Include => axes.ignored,
870 },
871 axes.read_controls
872 ),
873 Self::ScopeUnsupported { axis, reason } => format!(
878 "unsupported scan configuration: {}",
879 reason.replacen(axis.named(&AxisNames::FIELDS), axis.named(axes), 1)
880 ),
881 Self::ContentMismatch { held, requested } => format!(
882 "{analyze} {requested} cannot be answered by an index built with {analyze} \
883 {held}; open the root again with {analyze} {requested}",
884 analyze = axes.analyze,
885 requested = analysis_label(*requested),
886 held = analysis_label(*held),
887 ),
888 Self::WatchScope => watch_scope_message(axes),
889 Self::RootMismatch { held, requested } => {
890 format!(
891 "requested root {} does not match retained root {}",
892 requested.display(),
893 held.display()
894 )
895 }
896 Self::DeliveryUnsupported { route, reason } => format!("{route}: {reason}"),
897 Self::WatchContent => format!(
898 "{} is not yet supported with {}; use a one-shot report",
899 axes.analyze, axes.watch
900 ),
901 Self::WatchCacheOnly => format!(
902 "{watch} cannot start from {cache} only: nothing verifies what changed between \
903 the snapshot and the start of the watch; use {cache} auto or read-only",
904 watch = axes.watch,
905 cache = axes.cache,
906 ),
907 Self::ViewLimit { attempted, limit } => {
908 format!("report request contains {attempted} views or omissions; limit is {limit}")
909 }
910 }
911 }
912}
913
914impl fmt::Display for RequestError {
917 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
918 formatter.write_str(&self.message(&AxisNames::FIELDS))
919 }
920}
921
922impl std::error::Error for RequestError {}
923
924fn analysis_label(set: AnalysisSet) -> String {
926 if set.is_enabled() { set.labels().join(",") } else { AnalysisSet::NONE_LABEL.to_string() }
927}
928
929fn watch_scope_message(axes: &AxisNames) -> String {
937 let fields = &AxisNames::FIELDS;
938 let vocabulary = [
939 (fields.scan_depth, axes.scan_depth),
940 (fields.one_filesystem, axes.one_filesystem),
941 (fields.modified_since, axes.modified_since),
942 (fields.depth, axes.depth),
943 (fields.include, axes.include),
944 ];
945 let is_word = |character: char| character.is_ascii_alphanumeric() || character == '_';
946 crate::scan::WATCH_SCOPE_GUIDANCE
947 .split_inclusive(|character: char| !is_word(character))
948 .map(|piece| {
949 let end = piece.find(|character: char| !is_word(character)).unwrap_or(piece.len());
950 let (word, tail) = piece.split_at(end);
951 match vocabulary.iter().find(|(field, _)| *field == word) {
952 Some((_, name)) => format!("{name}{tail}"),
953 None => piece.to_string(),
954 }
955 })
956 .collect()
957}
958
959pub(crate) fn check_views(views: &[ViewSpec], content: AnalysisSet) -> Result<(), RequestError> {
964 for view in views {
965 match view {
966 ViewSpec::Documents if !content.is_enabled() => {
967 return Err(RequestError::ViewNeedsContent(*view));
968 }
969 ViewSpec::List
970 | ViewSpec::Tree
971 | ViewSpec::Types
972 | ViewSpec::Extensions
973 | ViewSpec::Families
974 | ViewSpec::Languages
975 | ViewSpec::Documents
976 | ViewSpec::Files
977 | ViewSpec::Largest
978 | ViewSpec::Recent
979 | ViewSpec::Summary => {}
980 }
981 }
982 Ok(())
983}
984
985pub(crate) fn check_observation(
987 ignored: IgnoredEntries,
988 observes_controls: bool,
989) -> Result<(), RequestError> {
990 match ignored {
991 IgnoredEntries::Include => Ok(()),
992 IgnoredEntries::Exclude | IgnoredEntries::Only if observes_controls => Ok(()),
993 IgnoredEntries::Exclude | IgnoredEntries::Only => {
994 Err(RequestError::IgnoredWithoutObservation(ignored))
995 }
996 }
997}
998
999#[derive(Clone, Debug, PartialEq, Eq)]
1006pub(crate) struct Rejection {
1007 value: String,
1008 expected: String,
1009}
1010
1011impl Rejection {
1012 pub(crate) fn new(value: impl Into<String>, expected: impl Into<String>) -> Self {
1013 Self { value: value.into(), expected: expected.into() }
1014 }
1015
1016 pub(crate) fn on(self, axis: &'static str) -> RequestError {
1018 RequestError::InvalidValue { axis, value: self.value, expected: self.expected }
1019 }
1020
1021 pub(crate) fn labeled(&self, label: &str) -> String {
1023 invalid_message(label, &self.value, &self.expected)
1024 }
1025}
1026
1027fn invalid_message(axis: &str, value: &str, expected: &str) -> String {
1028 format!("invalid {axis} {value:?}: {expected}")
1029}
1030
1031fn invalid(
1032 axis: &'static str,
1033 value: impl Into<String>,
1034 expected: impl Into<String>,
1035) -> RequestError {
1036 Rejection::new(value, expected).on(axis)
1037}
1038
1039fn grammar_refusal(error: crate::Error) -> RequestError {
1042 match error {
1043 crate::Error::InvalidValue { kind, value, hint } => invalid(kind, value, hint),
1044 other => invalid("value", String::new(), other.to_string()),
1045 }
1046}
1047
1048fn named_refusal(error: crate::Error, axis: &'static str) -> RequestError {
1051 match error {
1052 crate::Error::InvalidValue { value, hint, .. } => invalid(axis, value, hint),
1053 other => invalid(axis, String::new(), other.to_string()),
1054 }
1055}
1056
1057pub fn parse_kind(value: &str, axis: &'static str) -> Result<EntryKind, RequestError> {
1061 match value.trim().to_ascii_lowercase().as_str() {
1062 "file" => Ok(EntryKind::File),
1063 "dir" => Ok(EntryKind::Dir),
1064 "symlink" => Ok(EntryKind::Symlink),
1065 "other" => Ok(EntryKind::Other),
1066 other => Err(invalid(axis, other, "expected one of file, dir, symlink, other")),
1067 }
1068}
1069
1070pub fn parse_kinds(list: &str, axis: &'static str) -> Result<Vec<EntryKind>, RequestError> {
1077 let mut kinds = Vec::new();
1078 for token in list.split(',') {
1079 let token = token.trim();
1080 if token.is_empty() {
1081 return Err(invalid(axis, list, "empty entry in the list"));
1082 }
1083 let kind = parse_kind(token, axis)?;
1084 if kinds.contains(&kind) {
1085 return Err(invalid(axis, list, format!("{token:?} appears more than once")));
1086 }
1087 kinds.push(kind);
1088 }
1089 Ok(kinds)
1090}
1091
1092pub fn parse_bound(value: &str, axis: &'static str) -> Result<Bound, RequestError> {
1094 let value = value.trim();
1095 if value.eq_ignore_ascii_case("all") {
1096 return Ok(Bound::All);
1097 }
1098 value
1099 .parse::<usize>()
1100 .map(Bound::Limit)
1101 .map_err(|_| invalid(axis, value, "expected a whole number or `all`"))
1102}
1103
1104pub fn parse_sort(value: &str, axis: &'static str) -> Result<SortKey, RequestError> {
1106 match value.trim().to_ascii_lowercase().as_str() {
1107 "size" => Ok(SortKey::Size),
1108 "count" => Ok(SortKey::Count),
1109 "mtime" => Ok(SortKey::Mtime),
1110 "name" => Ok(SortKey::Name),
1111 other => Err(invalid(axis, other, "expected one of size, count, mtime, name")),
1112 }
1113}
1114
1115pub fn parse_size_metric(value: &str, axis: &'static str) -> Result<SizeMetric, RequestError> {
1117 match value.trim().to_ascii_lowercase().as_str() {
1118 "allocated" => Ok(SizeMetric::Allocated),
1119 "apparent" => Ok(SizeMetric::Apparent),
1120 other => Err(invalid(axis, other, "expected allocated or apparent")),
1121 }
1122}
1123
1124pub fn bound_nanos(value: &str, when: SystemTime, axis: &'static str) -> Result<i64, RequestError> {
1131 system_time_to_nanos(when).ok_or_else(|| {
1132 invalid(
1133 axis,
1134 value,
1135 "that time is outside the range fdu can represent (about 1677 to 2262)",
1136 )
1137 })
1138}
1139
1140pub fn parse_cache_policy(value: &str, axis: &'static str) -> Result<CachePolicy, RequestError> {
1142 match value.trim().to_ascii_lowercase().as_str() {
1143 "auto" => Ok(CachePolicy::Auto),
1144 "refresh" => Ok(CachePolicy::Refresh),
1145 "read-only" => Ok(CachePolicy::ReadOnly),
1146 "only" => Ok(CachePolicy::Only),
1147 "off" => Ok(CachePolicy::Off),
1148 other => Err(invalid(axis, other, "expected one of auto, refresh, read-only, only, off")),
1149 }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154 use super::*;
1155
1156 #[test]
1157 fn each_grammar_names_its_axis_as_the_surface_spells_it() {
1158 let flags = &AxisNames::FLAGS;
1159 let fields = &AxisNames::FIELDS;
1160 let cases: [(&str, RequestError, RequestError); 6] = [
1161 (
1162 "kind",
1163 parse_kind(" Socket ", flags.kind).expect_err("unknown kind"),
1164 parse_kind(" Socket ", fields.kind).expect_err("unknown kind"),
1165 ),
1166 (
1167 "bound",
1168 parse_bound("two", flags.depth).expect_err("not a number"),
1169 parse_bound("two", fields.depth).expect_err("not a number"),
1170 ),
1171 (
1172 "sort",
1173 parse_sort("Newest", flags.sort).expect_err("unknown key"),
1174 parse_sort("Newest", fields.sort).expect_err("unknown key"),
1175 ),
1176 (
1177 "size",
1178 parse_size_metric("logical", flags.size).expect_err("unknown metric"),
1179 parse_size_metric("logical", fields.size).expect_err("unknown metric"),
1180 ),
1181 (
1182 "time",
1183 bound_nanos("2300-01-01T00:00:00Z", far_future(), flags.modified_since)
1184 .expect_err("unrepresentable"),
1185 bound_nanos("2300-01-01T00:00:00Z", far_future(), fields.modified_since)
1186 .expect_err("unrepresentable"),
1187 ),
1188 (
1189 "cache",
1190 parse_cache_policy("readonly", flags.cache).expect_err("unreleased alias"),
1191 parse_cache_policy("readonly", fields.cache).expect_err("unreleased alias"),
1192 ),
1193 ];
1194 let expected = [
1195 (
1196 "invalid --kind \"socket\": expected one of file, dir, symlink, other",
1197 "invalid kind \"socket\": expected one of file, dir, symlink, other",
1198 ),
1199 (
1200 "invalid --depth \"two\": expected a whole number or `all`",
1201 "invalid depth \"two\": expected a whole number or `all`",
1202 ),
1203 (
1204 "invalid --sort \"newest\": expected one of size, count, mtime, name",
1205 "invalid sort \"newest\": expected one of size, count, mtime, name",
1206 ),
1207 (
1208 "invalid --size \"logical\": expected allocated or apparent",
1209 "invalid size \"logical\": expected allocated or apparent",
1210 ),
1211 (
1212 "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
1213 range fdu can represent (about 1677 to 2262)",
1214 "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
1215 fdu can represent (about 1677 to 2262)",
1216 ),
1217 (
1218 "invalid --cache \"readonly\": expected one of auto, refresh, read-only, only, off",
1219 "invalid cache policy \"readonly\": expected one of auto, refresh, read-only, \
1220 only, off",
1221 ),
1222 ];
1223 for ((grammar, flag, field), (flag_text, field_text)) in cases.into_iter().zip(expected) {
1224 assert_eq!(flag.message(&AxisNames::FLAGS), flag_text, "{grammar}");
1225 assert_eq!(field.message(&AxisNames::FIELDS), field_text, "{grammar}");
1226 assert_eq!(flag.message(&AxisNames::FIELDS), flag_text, "{grammar}");
1228 }
1229 }
1230
1231 fn far_future() -> SystemTime {
1232 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10_000_000_000)
1233 }
1234
1235 #[test]
1236 fn the_grammars_accept_their_whole_vocabulary() {
1237 let axis = AxisNames::FIELDS.kind;
1238 for (spelling, kind) in [
1239 ("file", EntryKind::File),
1240 ("DIR", EntryKind::Dir),
1241 (" symlink", EntryKind::Symlink),
1242 ("other", EntryKind::Other),
1243 ] {
1244 assert_eq!(parse_kind(spelling, axis), Ok(kind));
1245 }
1246 assert_eq!(parse_kinds("file, dir", axis), Ok(vec![EntryKind::File, EntryKind::Dir]));
1247 assert_eq!(parse_bound(" ALL ", axis), Ok(Bound::All));
1248 assert_eq!(parse_bound("0", axis), Ok(Bound::Limit(0)));
1249 for (spelling, key) in [
1250 ("size", SortKey::Size),
1251 ("count", SortKey::Count),
1252 ("MTIME", SortKey::Mtime),
1253 ("name", SortKey::Name),
1254 ] {
1255 assert_eq!(parse_sort(spelling, axis), Ok(key));
1256 }
1257 assert_eq!(parse_size_metric("Allocated", axis), Ok(SizeMetric::Allocated));
1258 assert_eq!(parse_size_metric("apparent", axis), Ok(SizeMetric::Apparent));
1259 for (spelling, policy) in [
1260 ("auto", CachePolicy::Auto),
1261 ("refresh", CachePolicy::Refresh),
1262 ("read-only", CachePolicy::ReadOnly),
1263 ("ONLY", CachePolicy::Only),
1264 ("off", CachePolicy::Off),
1265 ] {
1266 assert_eq!(parse_cache_policy(spelling, axis), Ok(policy));
1267 }
1268 let epoch = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(2);
1269 assert_eq!(bound_nanos("@2", epoch, axis), Ok(2_000_000_000));
1270 }
1271
1272 #[test]
1273 fn a_kind_list_refuses_empty_and_repeated_entries() {
1274 let axis = AxisNames::FLAGS.kind;
1275 assert_eq!(
1276 parse_kinds("file,,dir", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1277 Err("invalid --kind \"file,,dir\": empty entry in the list".to_string())
1278 );
1279 assert_eq!(
1280 parse_kinds("file, FILE", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
1281 Err("invalid --kind \"file, FILE\": \"FILE\" appears more than once".to_string())
1282 );
1283 }
1284
1285 #[test]
1288 fn every_refusal_renders_in_flag_and_field_wording() {
1289 let cases = [
1290 (
1291 RequestError::ViewNeedsContent(ViewSpec::Documents),
1292 "--view documents requires content analysis: add --analyze lines, code, words, \
1293 or all; views never enable content analysis implicitly",
1294 "view documents requires content analysis: add analyze lines, code, words, or \
1295 all; views never enable content analysis implicitly",
1296 ),
1297 (
1298 RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude),
1299 "--exclude-ignored needs .gitignore classification, and --no-gitignore turned it \
1300 off; drop one of them",
1301 "ignored=exclude needs .gitignore classification, and read_controls turned it \
1302 off; drop one of them",
1303 ),
1304 (
1305 RequestError::IgnoredWithoutObservation(IgnoredEntries::Only),
1306 "--only-ignored needs .gitignore classification, and --no-gitignore turned it \
1307 off; drop one of them",
1308 "ignored=only needs .gitignore classification, and read_controls turned it off; \
1309 drop one of them",
1310 ),
1311 (
1312 RequestError::ContentMismatch {
1313 held: AnalysisSet::NONE,
1314 requested: AnalysisSet::NONE.with_code(),
1315 },
1316 "--analyze lines,code cannot be answered by an index built with --analyze none; \
1317 open the root again with --analyze lines,code",
1318 "analyze lines,code cannot be answered by an index built with analyze none; open \
1319 the root again with analyze lines,code",
1320 ),
1321 (
1322 RequestError::ScopeUnsupported {
1323 axis: ScopeAxis::OneFilesystem,
1324 reason: ScopeAxis::OneFilesystem.reason(),
1325 },
1326 "unsupported scan configuration: --one-filesystem requires platform device \
1327 identity",
1328 "unsupported scan configuration: one_filesystem requires platform device identity",
1329 ),
1330 (
1331 RequestError::ScopeUnsupported {
1332 axis: ScopeAxis::FollowSymlinks,
1333 reason: ScopeAxis::FollowSymlinks.reason(),
1334 },
1335 "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1336 and filesystem-boundary semantics",
1337 "unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
1338 and filesystem-boundary semantics",
1339 ),
1340 (
1341 RequestError::WatchContent,
1342 "--analyze is not yet supported with --watch; use a one-shot report",
1343 "analyze is not yet supported with watch; use a one-shot report",
1344 ),
1345 (
1346 RequestError::WatchCacheOnly,
1347 "--watch cannot start from --cache only: nothing verifies what changed between the \
1348 snapshot and the start of the watch; use --cache auto or read-only",
1349 "watch cannot start from cache policy only: nothing verifies what changed between \
1350 the snapshot and the start of the watch; use cache policy auto or read-only",
1351 ),
1352 (
1353 RequestError::ViewLimit { attempted: 17, limit: 16 },
1354 "report request contains 17 views or omissions; limit is 16",
1355 "report request contains 17 views or omissions; limit is 16",
1356 ),
1357 ];
1358 for (refusal, flags, fields) in cases {
1359 assert_eq!(refusal.message(&AxisNames::FLAGS), flags);
1360 assert_eq!(refusal.message(&AxisNames::FIELDS), fields);
1361 assert_eq!(refusal.to_string(), fields, "a refusal displays in the library's words");
1362 }
1363 }
1364
1365 #[test]
1371 fn the_watch_scope_refusal_substitutes_whole_words_only() {
1372 let source = crate::scan::WATCH_SCOPE_GUIDANCE;
1373 assert_eq!(RequestError::WatchScope.message(&AxisNames::FIELDS), source);
1374
1375 let text = RequestError::WatchScope.message(&AxisNames::FLAGS);
1376 assert!(!text.contains("---"), "{text} re-substituted a replacement");
1377
1378 let names_word = |haystack: &str, word: &str| {
1380 haystack
1381 .split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
1382 .any(|w| w == word)
1383 };
1384 let (fields, flags) = (&AxisNames::FIELDS, &AxisNames::FLAGS);
1385 let vocabulary = [
1386 (fields.scan_depth, flags.scan_depth),
1387 (fields.one_filesystem, flags.one_filesystem),
1388 (fields.modified_since, flags.modified_since),
1389 (fields.depth, flags.depth),
1390 (fields.include, flags.include),
1391 ];
1392 for (field, flag) in vocabulary {
1393 if names_word(source, field) {
1394 assert!(text.contains(flag), "{text} must name {flag} where the rule says {field}");
1395 }
1396 assert!(!names_word(&text, field), "{text} still names {field} untranslated");
1397 }
1398 let mut rebuilt = text.clone();
1399 for (field, flag) in vocabulary {
1400 rebuilt = rebuilt.replace(flag, field);
1401 }
1402 assert_eq!(rebuilt, source, "the flag wording must be the library's, knob names aside");
1403 }
1404
1405 #[test]
1406 fn documents_is_the_only_view_that_needs_content() {
1407 for content in [AnalysisSet::NONE, AnalysisSet::NONE.with_lines(), AnalysisSet::ALL] {
1408 for view in ViewSpec::ALL {
1409 let refused = check_views(&[view], content).is_err();
1410 assert_eq!(
1411 refused,
1412 view == ViewSpec::Documents && !content.is_enabled(),
1413 "{view:?}"
1414 );
1415 }
1416 }
1417 assert_eq!(check_observation(IgnoredEntries::Include, false), Ok(()));
1418 for ignored in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
1419 assert_eq!(check_observation(ignored, true), Ok(()));
1420 assert_eq!(
1421 check_observation(ignored, false),
1422 Err(RequestError::IgnoredWithoutObservation(ignored))
1423 );
1424 }
1425 }
1426
1427 use crate::content::AnalysisRequest;
1430
1431 fn root() -> &'static Path {
1432 Path::new("/tree")
1433 }
1434
1435 fn instant() -> SystemTime {
1436 SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
1437 }
1438
1439 fn reading(read: ReadSpec<'_>) -> RequestSpec<'_> {
1441 RequestSpec { read, ..RequestSpec::new(root()) }
1442 }
1443
1444 fn built(spec: &RequestSpec<'_>) -> Request {
1445 Request::build(spec, instant(), &AxisNames::FIELDS).expect("the spec parses")
1446 }
1447
1448 fn refusal(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> String {
1449 Request::build(spec, instant(), axes).expect_err("the spec is refused").message(axes)
1450 }
1451
1452 #[test]
1455 fn list_formats_are_resolved_and_validated_in_the_shared_request() {
1456 use crate::report_format::Format;
1457 for (view, format, valid, tree) in [
1458 (None, None, true, true),
1459 (Some("list"), Some("tree"), true, true),
1460 (Some("list"), Some("paths"), true, false),
1461 (None, Some("json"), true, false),
1462 (Some("files"), None, true, false),
1463 (Some("files"), Some("tree"), true, true),
1464 (Some("tree"), Some("long"), true, false),
1465 (Some("largest"), Some("long"), true, false),
1466 (Some("summary"), Some("long"), false, false),
1467 (Some("full"), Some("paths"), false, false),
1468 (Some("list,summary"), Some("tree"), false, false),
1469 (Some("list,summary"), Some("json"), true, false),
1470 ] {
1471 let spec = RequestSpec {
1472 read: ReadSpec { views: view, format, ..ReadSpec::new() },
1473 ..RequestSpec::new(Path::new("/absent"))
1474 };
1475 let request =
1476 Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("grammar");
1477 assert_eq!(request.validate().is_ok(), valid, "{view:?} {format:?}");
1478 if valid {
1479 assert_eq!(request.query.tree_for(request.query.views[0]), tree);
1480 }
1481 }
1482 let spec = RequestSpec::new(Path::new("/absent"));
1483 let request =
1484 Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("default");
1485 assert_eq!(request.query.views, [ViewSpec::List]);
1486 assert!(
1487 !request.query.needs_selection_walk(),
1488 "an ordinary tree must retain its bounded projection cost"
1489 );
1490 let mut flat = request.clone();
1491 flat.query.format = Format::Paths;
1492 assert!(flat.query.needs_selection_walk());
1493 assert_eq!(flat.query.limit_for(ViewSpec::List), Bound::All);
1494 assert_eq!(request.query.limit_for(ViewSpec::List), Bound::Limit(10));
1495 }
1496
1497 #[test]
1498 fn an_empty_spec_builds_the_defaults_table() {
1499 let defaults = Request::DEFAULTS;
1500 assert_eq!(defaults.size, SizeMetric::Allocated);
1501 assert_eq!(defaults.words_per_page, 250);
1502 assert_eq!(defaults.content, AnalysisSet::NONE);
1503 assert!(defaults.read_controls);
1504 assert_eq!(defaults.control_limits, ControlLimits::default());
1505 assert_eq!(defaults.report_view(AnalysisSet::NONE), ViewSpec::List);
1508 for content in [
1509 AnalysisSet::NONE,
1510 AnalysisSet::NONE.with_lines(),
1511 AnalysisSet::NONE.with_code(),
1512 AnalysisSet::NONE.with_words(),
1513 AnalysisSet::ALL,
1514 ] {
1515 assert_eq!(defaults.report_view(content), ViewSpec::default_for(content));
1516 }
1517
1518 let request = built(&RequestSpec::new(root()));
1519 assert_eq!(request.basis.root, root());
1520 assert_eq!(request.basis.content, defaults.content);
1521 assert_eq!(request.basis.scope.read_controls, defaults.read_controls);
1522 assert_eq!(request.basis.scope.control_limits, defaults.control_limits);
1523 assert_eq!(request.basis.scope.max_depth, None);
1524 assert!(!request.basis.scope.one_filesystem);
1525 assert_eq!(request.query.views, vec![defaults.report_view(defaults.content)]);
1526 assert!(request.query.omitted_views.is_empty());
1527 assert_eq!(request.query.words_per_page, defaults.words_per_page);
1528 assert_eq!(request.query.selection.size, defaults.size);
1529 assert!(request.query.selection.is_unfiltered());
1530 let selection = &request.query.selection;
1531 assert_eq!((selection.depth, selection.limit, selection.sort), (None, None, None));
1532 assert!(!selection.reverse);
1533 assert_eq!(*request.query.axes, AxisNames::FIELDS);
1534 assert_eq!(request.now, instant());
1535 request.validate().expect("the defaults are a valid request");
1536
1537 assert_eq!(SizeMetric::default(), defaults.size);
1539 assert_eq!(Selection::default().size, defaults.size);
1540 assert_eq!(Query::default().words_per_page, defaults.words_per_page);
1541 assert_eq!(ScanConfig::default().read_controls, defaults.read_controls);
1542 assert_eq!(ScanConfig::default().control_limits, defaults.control_limits);
1543 assert_eq!(AnalysisRequest::default().profile, defaults.content);
1544 assert_eq!(AnalysisSet::default(), defaults.content);
1545 }
1546
1547 #[test]
1548 fn every_axis_of_a_spec_reaches_its_typed_value() {
1549 let include = ["*.rs".to_string()];
1550 let exclude = ["target/**".to_string()];
1551 let spec = RequestSpec {
1552 scan_depth: Some("3"),
1553 one_filesystem: true,
1554 read_controls: Some(false),
1555 control_budget: Some("all"),
1556 control_line_limit: Some("64KiB"),
1557 analyze: Some("code"),
1558 read: ReadSpec {
1559 views: Some("languages,tree"),
1560 format: None,
1561 words_per_page: Some("300"),
1562 include: &include,
1563 exclude: &exclude,
1564 min_size: Some("1KiB"),
1565 modified_since: Some("@1700000000"),
1566 modified_before: Some("@1800000000"),
1567 kinds: Some("file,dir"),
1568 ignored: Some("include"),
1569 depth: Some("all"),
1570 limit: Some("5"),
1571 sort: Some("name"),
1572 reverse: true,
1573 size: Some("apparent"),
1574 },
1575 ..RequestSpec::new(root())
1576 };
1577 let request = Request::build(&spec, instant(), &AxisNames::FLAGS).expect("parses");
1578 let scope = &request.basis.scope;
1579 assert_eq!(scope.max_depth, Some(3));
1580 assert!(scope.one_filesystem);
1581 assert!(!scope.read_controls);
1582 assert_eq!(scope.control_limits, ControlLimits { budget: None, line_limit: Some(65_536) });
1583 assert_eq!(request.basis.content, AnalysisSet::NONE.with_code());
1584 assert_eq!(request.query.views, vec![ViewSpec::Languages, ViewSpec::Tree]);
1585 assert_eq!(request.query.words_per_page, 300);
1586 assert_eq!(*request.query.axes, AxisNames::FLAGS);
1587 let selection = &request.query.selection;
1588 assert_eq!(selection.include.len(), 1);
1589 assert_eq!(selection.exclude.len(), 1);
1590 assert_eq!(selection.min_size, Some(1024));
1591 assert_eq!(selection.modified.since, Some(1_700_000_000_000_000_000));
1592 assert_eq!(selection.modified.before, Some(1_800_000_000_000_000_000));
1593 assert_eq!(selection.kinds, vec![EntryKind::File, EntryKind::Dir]);
1594 assert_eq!(selection.ignored, IgnoredEntries::Include);
1595 assert_eq!(selection.depth, Some(Bound::All));
1596 assert_eq!(selection.limit, Some(Bound::Limit(5)));
1597 assert_eq!(selection.sort, Some(SortKey::Name));
1598 assert!(selection.reverse);
1599 assert_eq!(selection.size, SizeMetric::Apparent);
1600 }
1601
1602 #[test]
1606 fn relative_windows_resolve_against_the_requests_instant() {
1607 let spec = reading(ReadSpec {
1608 modified_since: Some("2h"),
1609 modified_before: Some("now"),
1610 ..ReadSpec::new()
1611 });
1612 let request = built(&spec);
1613 let now = system_time_to_nanos(instant()).expect("representable");
1614 let two_hours = 2 * 60 * 60 * 1_000_000_000;
1615 assert_eq!(request.now, instant());
1616 assert_eq!(request.query.selection.modified.since, Some(now - two_hours));
1617 assert_eq!(request.query.selection.modified.before, Some(now));
1618
1619 let later = instant() + Duration::from_secs(60);
1620 let moved = Request::build(&spec, later, &AxisNames::FIELDS).expect("parses");
1621 assert_eq!(moved.query.selection.modified.since, Some(now - two_hours + 60_000_000_000));
1622 let again = built(&spec);
1623 assert_eq!(again.query.selection.modified.since, request.query.selection.modified.since);
1624 }
1625
1626 #[test]
1629 fn build_refuses_each_axis_in_the_surfaces_words() {
1630 let spec = RequestSpec::new(root());
1631 let analyze = RequestSpec { analyze: Some("deep"), ..spec };
1632 for (axes, label) in [(&AxisNames::FLAGS, "--analyze"), (&AxisNames::FIELDS, "analyze")] {
1633 let today = AnalysisSet::parse_labeled("deep", label).expect_err("refused");
1634 assert_eq!(refusal(&analyze, axes), today);
1635 }
1636 for views in ["tree,tree", "tree,,types", "full,tree", "bogus"] {
1637 let spec = reading(ReadSpec { views: Some(views), ..ReadSpec::new() });
1638 for (axes, label) in [(&AxisNames::FLAGS, "--view"), (&AxisNames::FIELDS, "view")] {
1639 let today =
1640 ViewSpec::resolve(Some(views), AnalysisSet::NONE, label).expect_err("refused");
1641 assert_eq!(refusal(&spec, axes), today);
1642 }
1643 }
1644
1645 let cases: [(RequestSpec<'_>, &str, &str); 7] = [
1646 (
1647 reading(ReadSpec { words_per_page: Some("0"), ..ReadSpec::new() }),
1648 "invalid --words-per-page \"0\": expected a positive integer",
1649 "invalid words_per_page \"0\": expected a positive integer",
1650 ),
1651 (
1652 RequestSpec { scan_depth: Some("deep"), ..spec },
1653 "invalid --scan-depth \"deep\": expected a whole number",
1654 "invalid max_depth \"deep\": expected a whole number",
1655 ),
1656 (
1657 RequestSpec { control_budget: Some("lots"), ..spec },
1658 "invalid --gitignore-budget \"lots\": expected a number before the unit, as in \
1659 `10M`, or `all` for no bound",
1660 "invalid control_budget \"lots\": expected a number before the unit, as in \
1661 `10M`, or `all` for no bound",
1662 ),
1663 (
1664 reading(ReadSpec { ignored: Some("maybe"), ..ReadSpec::new() }),
1665 "invalid --exclude-ignored/--only-ignored \"maybe\": expected one of include, \
1666 exclude, only",
1667 "invalid ignored \"maybe\": expected one of include, exclude, only",
1668 ),
1669 (
1670 reading(ReadSpec { kinds: Some("file,socket"), ..ReadSpec::new() }),
1671 "invalid --kind \"socket\": expected one of file, dir, symlink, other",
1672 "invalid kind \"socket\": expected one of file, dir, symlink, other",
1673 ),
1674 (
1675 reading(ReadSpec { min_size: Some("10X"), ..ReadSpec::new() }),
1676 "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
1677 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
1678 "invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
1679 P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
1680 ),
1681 (
1682 reading(ReadSpec {
1683 modified_since: Some("2300-01-01T00:00:00Z"),
1684 ..ReadSpec::new()
1685 }),
1686 "invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
1687 range fdu can represent (about 1677 to 2262)",
1688 "invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
1689 fdu can represent (about 1677 to 2262)",
1690 ),
1691 ];
1692 for (spec, flags, fields) in cases {
1693 assert_eq!(refusal(&spec, &AxisNames::FLAGS), flags);
1694 assert_eq!(refusal(&spec, &AxisNames::FIELDS), fields);
1695 }
1696 }
1697
1698 #[test]
1707 fn build_names_a_bad_axis_in_the_order_it_publishes() {
1708 let everything = RequestSpec {
1709 scan_depth: Some("deep"),
1710 analyze: Some("deep"),
1711 read: ReadSpec {
1712 views: Some("bogus"),
1713 depth: Some("two"),
1714 words_per_page: Some("0"),
1715 ..ReadSpec::new()
1716 },
1717 ..RequestSpec::new(root())
1718 };
1719 let steps: [(RequestSpec<'_>, &str); 5] = [
1720 (everything, "analyze"),
1721 (RequestSpec { analyze: None, ..everything }, "view"),
1722 (
1723 RequestSpec {
1724 analyze: None,
1725 read: ReadSpec { views: None, ..everything.read },
1726 ..everything
1727 },
1728 "depth",
1729 ),
1730 (
1731 RequestSpec {
1732 analyze: None,
1733 read: ReadSpec { views: None, depth: None, ..everything.read },
1734 ..everything
1735 },
1736 "words_per_page",
1737 ),
1738 (
1739 RequestSpec {
1740 analyze: None,
1741 read: ReadSpec {
1742 views: None,
1743 format: None,
1744 depth: None,
1745 words_per_page: None,
1746 ..everything.read
1747 },
1748 ..everything
1749 },
1750 "max_depth",
1751 ),
1752 ];
1753 for (spec, axis) in steps {
1754 let refused = refusal(&spec, &AxisNames::FIELDS);
1755 assert!(
1756 refused.starts_with(&format!("invalid {axis} ")),
1757 "expected {axis} to speak next, got {refused}"
1758 );
1759 }
1760 Request::build(
1762 &RequestSpec {
1763 scan_depth: None,
1764 analyze: None,
1765 read: ReadSpec::new(),
1766 ..RequestSpec::new(root())
1767 },
1768 instant(),
1769 &AxisNames::FIELDS,
1770 )
1771 .expect("nothing left to refuse");
1772 }
1773
1774 fn request_with(views: &[ViewSpec], selection: Selection, basis: Basis) -> Request {
1775 Request::new(
1776 basis,
1777 Query { selection, views: views.to_vec(), ..Query::default() },
1778 instant(),
1779 )
1780 }
1781
1782 fn basis(content: AnalysisSet, read_controls: bool) -> Basis {
1783 Basis {
1784 root: root().to_path_buf(),
1785 scope: Scope { read_controls, ..Scope::default() },
1786 content,
1787 }
1788 }
1789
1790 #[test]
1792 fn language_grouping_is_metadata_only_while_documents_require_analysis() {
1793 let enabled = [
1794 AnalysisSet::NONE.with_lines(),
1795 AnalysisSet::NONE.with_code(),
1796 AnalysisSet::NONE.with_words(),
1797 AnalysisSet::ALL,
1798 ];
1799 for content in std::iter::once(AnalysisSet::NONE).chain(enabled) {
1800 request_with(&[ViewSpec::Languages], Selection::default(), basis(content, true))
1801 .validate()
1802 .expect("language grouping never requires content I/O");
1803 }
1804
1805 let documents = request_with(
1806 &[ViewSpec::Documents],
1807 Selection::default(),
1808 basis(AnalysisSet::NONE, true),
1809 );
1810 assert_eq!(documents.validate(), Err(RequestError::ViewNeedsContent(ViewSpec::Documents)));
1811 for content in enabled {
1812 request_with(&[ViewSpec::Documents], Selection::default(), basis(content, true))
1813 .validate()
1814 .expect("every enabled profile includes the basic document metrics");
1815 }
1816
1817 request_with(
1818 &[ViewSpec::Types, ViewSpec::Families],
1819 Selection::default(),
1820 basis(AnalysisSet::NONE, true),
1821 )
1822 .validate()
1823 .expect("metadata grouping never requires content I/O");
1824 }
1825
1826 #[test]
1838 fn a_scope_this_build_cannot_honour_is_refused_by_request_validation() {
1839 let held = basis(AnalysisSet::NONE, true);
1840 let mut asked = held.clone();
1841 asked.scope.follow_symlinks = true;
1842 let request = request_with(&[ViewSpec::Summary], Selection::default(), asked);
1843 let refusal = RequestError::ScopeUnsupported {
1844 axis: ScopeAxis::FollowSymlinks,
1845 reason: ScopeAxis::FollowSymlinks.reason(),
1846 };
1847
1848 assert_eq!(request.validate(), Err(refusal.clone()));
1849 assert_eq!(request.validate_read(&held), Err(refusal));
1850 assert_eq!(
1853 ScanConfig { follow_symlinks: true, ..ScanConfig::default() }
1854 .unsupported_axis()
1855 .expect("no build follows symbolic links")
1856 .reason(),
1857 ScopeAxis::FollowSymlinks.reason()
1858 );
1859 request_with(&[ViewSpec::Summary], Selection::default(), held)
1860 .validate()
1861 .expect("a scope this build honours is not refused");
1862 }
1863
1864 #[test]
1868 fn a_scope_that_observes_no_control_state_refuses_selection_by_ignored_state() {
1869 let exclude = Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() };
1870 let only = Selection { ignored: IgnoredEntries::Only, ..Selection::default() };
1871 let blind = basis(AnalysisSet::NONE, false);
1872
1873 let refused = request_with(&[ViewSpec::Summary], exclude.clone(), blind.clone())
1874 .validate()
1875 .expect_err("no entry can be shown to be ignored");
1876 assert_eq!(
1877 refused.message(&AxisNames::FLAGS),
1878 "--exclude-ignored needs .gitignore classification, and --no-gitignore turned it \
1879 off; drop one of them"
1880 );
1881 let refused = request_with(&[ViewSpec::Summary], only, blind.clone())
1882 .validate()
1883 .expect_err("no entry can be shown to be ignored");
1884 assert_eq!(
1885 refused.message(&AxisNames::FIELDS),
1886 "ignored=only needs .gitignore classification, and read_controls turned it off; \
1887 drop one of them"
1888 );
1889 request_with(&[ViewSpec::Summary], exclude, basis(AnalysisSet::NONE, true))
1890 .validate()
1891 .expect("an observing scope can select by ignored state");
1892 request_with(&[ViewSpec::Summary], Selection::default(), blind)
1893 .validate()
1894 .expect("admitting every entry needs no classification");
1895 }
1896
1897 #[test]
1898 fn a_read_is_refused_by_a_holder_of_other_content() {
1899 let request = built(&RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) });
1900 let held = basis(AnalysisSet::NONE, true);
1901 assert_eq!(
1902 request.validate_read(&held),
1903 Err(RequestError::ContentMismatch {
1904 held: AnalysisSet::NONE,
1905 requested: AnalysisSet::NONE.with_lines(),
1906 })
1907 );
1908 assert_eq!(
1911 request.validate_read(&basis(AnalysisSet::ALL, true)),
1912 Err(RequestError::ContentMismatch {
1913 held: AnalysisSet::ALL,
1914 requested: AnalysisSet::NONE.with_lines(),
1915 })
1916 );
1917 request
1918 .validate_read(&basis(AnalysisSet::NONE.with_lines(), true))
1919 .expect("equal content serves");
1920
1921 let exclude = built(&reading(ReadSpec { ignored: Some("exclude"), ..ReadSpec::new() }));
1923 assert_eq!(
1924 exclude.validate_read(&basis(AnalysisSet::NONE, false)),
1925 Err(RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude))
1926 );
1927 let documents = request_with(
1929 &[ViewSpec::Documents],
1930 Selection::default(),
1931 basis(AnalysisSet::NONE.with_words(), true),
1932 );
1933 assert!(matches!(
1934 documents.validate_read(&basis(AnalysisSet::NONE, true)),
1935 Err(RequestError::ContentMismatch { .. })
1936 ));
1937 }
1938
1939 #[test]
1943 fn the_basis_a_retained_index_holds_is_what_it_observed() {
1944 let blind =
1945 crate::Index::new_with_scope("/root", crate::test_support::not_observing_controls());
1946 let held = Basis::held_by(&blind);
1947 assert_eq!(held.root, Path::new("/root"));
1948 assert!(!held.scope.read_controls, "an index that read no rule says so");
1949 assert_eq!(held.content, AnalysisSet::NONE, "a metadata index holds no analyzer");
1950
1951 let observing =
1952 crate::Index::new_with_scope("/root", crate::test_support::observing_controls());
1953 assert!(Basis::held_by(&observing).scope.read_controls);
1954
1955 let tight = ControlLimits { budget: Some(4_096), line_limit: None };
1959 let narrow = crate::Index::new_with_config(
1960 "/root",
1961 &ScanConfig { read_controls: true, control_limits: tight, ..ScanConfig::default() },
1962 );
1963 assert_eq!(Basis::held_by(&narrow).scope.control_limits, tight);
1964 assert_ne!(tight, Request::DEFAULTS.control_limits, "the fixture must differ");
1965 assert_eq!(
1966 Basis::held_by(&blind).scope.control_limits,
1967 Request::DEFAULTS.control_limits,
1968 "a scan that read no rule applied none, and names the table's"
1969 );
1970
1971 let request =
1973 built(&RequestSpec { read_controls: Some(false), ..RequestSpec::new(root()) });
1974 request
1975 .validate_read(&Basis::held_by(&blind))
1976 .expect("the index's own basis answers a request built the same way");
1977 }
1978
1979 #[test]
1982 fn a_watch_refuses_what_it_cannot_keep_current() {
1983 let one_shot = Delivery {
1984 cache: CachePolicy::Auto,
1985 cache_path: None,
1986 accept_partial: false,
1987 watch: None,
1988 workers: Workers::default(),
1989 batch_size: ScanConfig::default().batch_size,
1990 order: crate::ScanOrder::default(),
1991 };
1992 let watching = Delivery { watch: Some(WatchDelivery::default()), ..one_shot.clone() };
1993 let cases = [
1994 (
1995 RequestSpec { scan_depth: Some("2"), ..RequestSpec::new(root()) },
1996 RequestError::WatchScope,
1997 ),
1998 (
1999 RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) },
2000 RequestError::WatchScope,
2001 ),
2002 (
2003 RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) },
2004 RequestError::WatchContent,
2005 ),
2006 ];
2007 for (spec, expected) in cases {
2008 let request = built(&spec);
2009 assert_eq!(request.validate_delivery(&watching), Err(expected));
2010 request.validate_delivery(&one_shot).expect("a one-shot delivers all three");
2011 }
2012
2013 let plain = built(&RequestSpec::new(root()));
2014 plain.validate_delivery(&watching).expect("a full-scope metadata watch is deliverable");
2015 assert_eq!(
2016 plain.validate_delivery(&Delivery { cache: CachePolicy::Only, ..watching.clone() }),
2017 Err(RequestError::WatchCacheOnly),
2018 "nothing verifies the window between the snapshot and the start of the watch"
2019 );
2020 plain
2021 .validate_delivery(&Delivery { cache: CachePolicy::Only, ..one_shot })
2022 .expect("a one-shot report is exactly what a snapshot answers");
2023 }
2024
2025 #[test]
2033 fn the_watch_rules_speak_in_one_order() {
2034 let cache_only_watch = Delivery {
2035 cache: CachePolicy::Only,
2036 cache_path: None,
2037 accept_partial: false,
2038 watch: Some(WatchDelivery::default()),
2039 workers: Workers::default(),
2040 batch_size: ScanConfig::default().batch_size,
2041 order: crate::ScanOrder::default(),
2042 };
2043 let everything = RequestSpec {
2044 scan_depth: Some("2"),
2045 analyze: Some("lines"),
2046 ..RequestSpec::new(root())
2047 };
2048 let steps = [
2049 (everything, RequestError::WatchScope),
2050 (RequestSpec { scan_depth: None, ..everything }, RequestError::WatchContent),
2051 (
2052 RequestSpec { scan_depth: None, analyze: None, ..everything },
2053 RequestError::WatchCacheOnly,
2054 ),
2055 ];
2056 for (spec, expected) in steps {
2057 assert_eq!(built(&spec).validate_delivery(&cache_only_watch), Err(expected));
2058 }
2059 built(&RequestSpec::new(root()))
2060 .validate_delivery(&Delivery { cache: CachePolicy::Auto, ..cache_only_watch })
2061 .expect("nothing left to refuse");
2062 }
2063
2064 #[cfg(unix)]
2068 #[test]
2069 fn a_watch_refuses_one_filesystem_where_the_build_honors_it() {
2070 let watch = Delivery {
2071 cache: CachePolicy::Auto,
2072 cache_path: None,
2073 accept_partial: false,
2074 watch: Some(WatchDelivery::default()),
2075 workers: Workers::default(),
2076 batch_size: ScanConfig::default().batch_size,
2077 order: crate::ScanOrder::default(),
2078 };
2079 let spec = RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) };
2080 let request = built(&spec);
2081 request.validate().expect("one filesystem is honored on this build");
2082 assert_eq!(request.validate_delivery(&watch), Err(RequestError::WatchScope));
2083 }
2084
2085 #[test]
2086 fn a_request_is_refused_past_the_views_one_report_carries() {
2087 let mut request = built(&RequestSpec::new(root()));
2088 request.query.views = vec![ViewSpec::Summary; crate::MAX_REPORT_VIEWS];
2089 request.validate().expect("the limit itself is accepted");
2090 request.query.omitted_views = vec![ViewSpec::Documents];
2091 assert_eq!(
2092 request.validate(),
2093 Err(RequestError::ViewLimit {
2094 attempted: crate::MAX_REPORT_VIEWS + 1,
2095 limit: crate::MAX_REPORT_VIEWS,
2096 })
2097 );
2098 }
2099}