use std::fmt;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use crate::CachePolicy;
use crate::content::AnalysisSet;
use crate::control::{ControlLimits, DEFAULT_CONTROL_BUDGET, DEFAULT_CONTROL_LINE_LIMIT};
use crate::engine_contract::EntryKind;
use crate::query::query_glob::Pattern;
use crate::query::query_report::{AxisNames, Query, ViewSpec};
use crate::query::query_selection::{
Bound, IgnoredEntries, Selection, ShareThreshold, SizeMetric, SortKey,
};
use crate::query::query_values::{
parse_control_budget, parse_control_line_limit, parse_size, parse_when, system_time_to_nanos,
};
use crate::scan::ScanConfig;
#[derive(Clone, Debug)]
#[allow(clippy::struct_excessive_bools)]
pub struct Scope {
pub max_depth: Option<usize>,
pub follow_symlinks: bool,
pub one_filesystem: bool,
pub hidden: Option<std::sync::Arc<crate::admission::HiddenPolicy>>,
pub exclude_special: bool,
pub types: Option<std::sync::Arc<crate::classify::TypeRegistry>>,
pub read_controls: bool,
pub population: IgnoredEntries,
pub control_limits: ControlLimits,
}
impl Default for Scope {
fn default() -> Self {
ScanConfig::default().into()
}
}
impl From<ScanConfig> for Scope {
fn from(scan: ScanConfig) -> Self {
Self {
max_depth: scan.max_depth,
follow_symlinks: scan.follow_symlinks,
one_filesystem: scan.one_filesystem,
hidden: scan.hidden,
exclude_special: scan.exclude_special,
types: scan.types,
read_controls: scan.read_controls,
population: scan.population,
control_limits: scan.control_limits,
}
}
}
impl Scope {
pub fn scan_config(&self, delivery: &Delivery) -> ScanConfig {
ScanConfig {
max_depth: self.max_depth,
follow_symlinks: self.follow_symlinks,
one_filesystem: self.one_filesystem,
hidden: self.hidden.clone(),
exclude_special: self.exclude_special,
types: self.types.clone(),
read_controls: self.read_controls,
population: self.population,
control_limits: self.control_limits,
threads: delivery.workers.scan,
batch_size: delivery.batch_size,
order: delivery.order,
progress: None,
}
}
fn identity_config(&self) -> ScanConfig {
ScanConfig {
max_depth: self.max_depth,
follow_symlinks: self.follow_symlinks,
one_filesystem: self.one_filesystem,
hidden: self.hidden.clone(),
exclude_special: self.exclude_special,
types: self.types.clone(),
read_controls: self.read_controls,
population: self.population,
control_limits: self.control_limits,
..ScanConfig::default()
}
}
pub fn scope(&self) -> crate::ScanScope {
self.identity_config().scope()
}
pub fn snapshot_identity(&self) -> crate::SnapshotIdentity {
self.identity_config().snapshot_identity()
}
pub(crate) fn unsupported_axis(&self) -> Option<ScopeAxis> {
self.identity_config().unsupported_axis()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Workers {
pub scan: Option<usize>,
pub analysis: usize,
}
#[derive(Clone, Debug)]
pub struct Basis {
pub root: PathBuf,
pub scope: Scope,
pub content: AnalysisSet,
}
impl Basis {
pub fn held_by(index: &crate::Index) -> Self {
let scope = index.scope();
let controls = index.control_identity();
Self {
root: index.root_path().to_path_buf(),
scope: Scope {
max_depth: scope.max_depth,
follow_symlinks: scope.follow_symlinks,
one_filesystem: scope.one_filesystem,
exclude_special: scope.exclude_special,
read_controls: controls.is_observed(),
population: scope.population,
control_limits: match controls {
crate::ControlTierIdentity::Observed { limits } => limits,
crate::ControlTierIdentity::NotObserved => Self::UNOBSERVED_LIMITS,
},
..Scope::default()
},
content: index.content_set(),
}
}
pub fn build(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Self, RequestError> {
let content = parse_content(spec, axes)?;
let mut scope = parse_scope(spec, axes)?;
scope.population = parse_population(spec.read.ignored, axes)?;
Ok(Self { root: spec.root.to_path_buf(), scope, content })
}
const UNOBSERVED_LIMITS: ControlLimits = Request::DEFAULTS.control_limits;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Delivery {
pub cache: CachePolicy,
pub stale_ok: bool,
pub cache_path: Option<PathBuf>,
pub accept_partial: bool,
pub watch: Option<WatchDelivery>,
pub workers: Workers,
pub batch_size: usize,
pub order: crate::ScanOrder,
}
impl Delivery {
pub fn new(cache: CachePolicy, cache_path: Option<PathBuf>) -> Self {
Self {
cache,
stale_ok: false,
cache_path,
accept_partial: false,
watch: None,
workers: Workers::default(),
batch_size: ScanConfig::default().batch_size,
order: crate::ScanOrder::default(),
}
}
pub fn stale_ok(cache_path: Option<PathBuf>) -> Self {
Self { stale_ok: true, ..Self::new(CachePolicy::Auto, cache_path) }
}
pub fn enumerate() -> impl Iterator<Item = Self> {
[
(CachePolicy::Auto, false),
(CachePolicy::On, false),
(CachePolicy::Off, false),
(CachePolicy::Auto, true),
(CachePolicy::On, true),
(CachePolicy::Off, true),
]
.into_iter()
.flat_map(|(cache, stale_ok)| {
[false, true].into_iter().flat_map(move |accept_partial| {
[None, Some(WatchDelivery::default())].into_iter().map(move |watch| Self {
cache,
stale_ok,
cache_path: Some(PathBuf::from("cache.fdu")),
accept_partial,
watch,
workers: Workers { analysis: 1, ..Workers::default() },
batch_size: ScanConfig::default().batch_size,
order: crate::ScanOrder::default(),
})
})
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct WatchDelivery {
pub interval: Duration,
}
impl Default for WatchDelivery {
fn default() -> Self {
Self { interval: Self::DEFAULT_INTERVAL }
}
}
impl WatchDelivery {
pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(2);
}
#[derive(Clone, Debug)]
pub struct Request {
pub basis: Basis,
pub query: Query,
pub now: SystemTime,
}
#[derive(Clone, Copy, Debug)]
pub struct RequestSpec<'a> {
pub root: &'a Path,
pub scan_depth: Option<&'a str>,
pub one_filesystem: bool,
pub read_controls: Option<bool>,
pub control_budget: Option<&'a str>,
pub control_line_limit: Option<&'a str>,
pub analyze: Option<&'a str>,
pub read: ReadSpec<'a>,
}
#[derive(Clone, Copy, Debug)]
pub struct ReadSpec<'a> {
pub views: Option<&'a str>,
pub format: Option<&'a str>,
pub words_per_page: Option<&'a str>,
pub include: &'a [String],
pub exclude: &'a [String],
pub min_size: Option<&'a str>,
pub modified_since: Option<&'a str>,
pub modified_before: Option<&'a str>,
pub kinds: Option<&'a str>,
pub ignored: Option<&'a str>,
pub depth: Option<&'a str>,
pub min_share: Option<&'a str>,
pub breadth: Option<&'a str>,
pub limit: Option<&'a str>,
pub sort: Option<&'a str>,
pub reverse: bool,
pub size: Option<&'a str>,
}
impl ReadSpec<'_> {
pub const fn new() -> Self {
Self {
views: None,
format: None,
words_per_page: None,
include: &[],
exclude: &[],
min_size: None,
modified_since: None,
modified_before: None,
kinds: None,
ignored: None,
depth: None,
min_share: None,
breadth: None,
limit: None,
sort: None,
reverse: false,
size: None,
}
}
}
impl Default for ReadSpec<'_> {
fn default() -> Self {
Self::new()
}
}
impl<'a> RequestSpec<'a> {
pub const fn new(root: &'a Path) -> Self {
Self {
root,
scan_depth: None,
one_filesystem: false,
read_controls: None,
control_budget: None,
control_line_limit: None,
analyze: None,
read: ReadSpec::new(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RequestDefaults {
pub size: SizeMetric,
pub words_per_page: u64,
pub content: AnalysisSet,
pub read_controls: bool,
pub control_limits: ControlLimits,
}
impl RequestDefaults {
pub const fn report_view(self, content: AnalysisSet) -> ViewSpec {
ViewSpec::default_for(content)
}
}
impl Request {
pub const DEFAULTS: RequestDefaults = RequestDefaults {
size: SizeMetric::Allocated,
words_per_page: 250,
content: AnalysisSet::NONE,
read_controls: true,
control_limits: ControlLimits {
budget: Some(DEFAULT_CONTROL_BUDGET),
line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
},
};
pub const fn new(basis: Basis, query: Query, now: SystemTime) -> Self {
Self { basis, query, now }
}
pub fn read(
basis: Basis,
spec: &ReadSpec<'_>,
now: SystemTime,
axes: &'static AxisNames,
) -> Result<Self, RequestError> {
let mut query = build_query(basis.content, spec, now, axes)?;
if spec.ignored.is_none() {
query.selection.ignored = basis.scope.population;
}
let request = Self::new(basis, query, now);
request.validate()?;
Ok(request)
}
pub fn build(
spec: &RequestSpec<'_>,
now: SystemTime,
axes: &'static AxisNames,
) -> Result<Self, RequestError> {
let content = parse_content(spec, axes)?;
let query = build_query(content, &spec.read, now, axes)?;
let mut scope = parse_scope(spec, axes)?;
scope.population = query.selection.ignored;
Ok(Self::new(Basis { root: spec.root.to_path_buf(), scope, content }, query, now))
}
pub fn validate(&self) -> Result<(), RequestError> {
self.validate_against(&self.basis)
}
pub fn validate_read(&self, held: &Basis) -> Result<(), RequestError> {
let entries = held.scope.snapshot_identity().entries;
let wanted = crate::ContentTierIdentity::for_request(entries, self.basis.content);
let stored = crate::ContentTierIdentity::for_request(entries, held.content);
if wanted.admit(&stored).is_none() {
return Err(RequestError::ContentMismatch {
held: held.content,
requested: self.basis.content,
});
}
self.validate_against(held)
}
pub fn validate_delivery(&self, delivery: &Delivery) -> Result<(), RequestError> {
if delivery.stale_ok && delivery.cache == CachePolicy::Off {
return Err(RequestError::StaleOkCacheOff);
}
if delivery.watch.is_none() {
return Ok(());
}
if self.basis.scope.max_depth.is_some() || self.basis.scope.one_filesystem {
return Err(RequestError::WatchScope);
}
if self.basis.content.is_enabled() {
return Err(RequestError::WatchContent);
}
if delivery.stale_ok {
return Err(RequestError::WatchCacheOnly);
}
Ok(())
}
fn validate_against(&self, basis: &Basis) -> Result<(), RequestError> {
if let Some(axis) = self.basis.scope.unsupported_axis() {
return Err(RequestError::ScopeUnsupported { axis, reason: axis.reason() });
}
let views = self.query.views.len().saturating_add(self.query.omitted_views.len());
if views > crate::MAX_REPORT_VIEWS {
return Err(RequestError::ViewLimit {
attempted: views,
limit: crate::MAX_REPORT_VIEWS,
});
}
let format = self.query.format;
if matches!(
format,
crate::report_format::Format::Tree
| crate::report_format::Format::Paths
| crate::report_format::Format::Long
) {
let compatible = self.query.views.len() == 1
&& self.query.views.iter().all(|view| {
matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
|| (format != crate::report_format::Format::Tree
&& matches!(view, ViewSpec::Largest | ViewSpec::Recent))
});
if !compatible {
return Err(invalid(
self.query.axes.format,
format.label(),
"requires a single list view; use text or a machine format for aggregate/mixed views (largest/recent support paths and long)",
));
}
}
check_views(&self.query.views, basis.content)?;
if let Some(SortKey::Metric(name)) = self.query.selection.sort {
let metric =
crate::content::METRICS.iter().find(|metric| metric.name == name).ok_or_else(
|| invalid(self.query.axes.sort, name, "expected a registered numeric metric"),
)?;
if self.query.views.contains(&ViewSpec::Extensions) {
return Err(invalid(
self.query.axes.sort,
name,
"extensions cannot sort by content metrics; use size, count, or name, or select files or another metric-capable view",
));
}
if !basis.content.contains(metric.owner) {
let analyzer = if metric.owner.includes_code() {
"code"
} else if metric.owner.includes_words() {
"words"
} else {
"lines"
};
return Err(RequestError::NeedsAnalyzer { item: metric.name, analyzer });
}
}
let hierarchy = self.query.views.iter().any(|view| self.query.tree_for(*view));
if matches!(self.query.selection.depth, Some(Bound::Limit(_))) && !hierarchy {
return Err(invalid(self.query.axes.depth, "", "requires a hierarchical view"));
}
if matches!(self.query.selection.breadth, Some(Bound::Limit(_))) && !hierarchy {
return Err(invalid(self.query.axes.breadth, "", "requires a hierarchical view"));
}
let additive = self.query.views.iter().any(|view| {
self.query.tree_for(*view)
|| matches!(
view,
ViewSpec::Extensions
| ViewSpec::Types
| ViewSpec::Families
| ViewSpec::Languages
| ViewSpec::Documents
| ViewSpec::Code
)
});
if self.query.selection.min_share.as_ref().is_some_and(|share| !share.admits(0, 1))
&& !additive
{
return Err(invalid(self.query.axes.min_share, "", "requires an additive view"));
}
check_observation(basis.scope.population, basis.scope.read_controls)?;
check_observation(self.query.selection.ignored, basis.scope.read_controls)?;
if basis.scope.population != IgnoredEntries::Include
&& self.query.selection.ignored != basis.scope.population
{
return Err(RequestError::PopulationMismatch {
held: basis.scope.population,
requested: self.query.selection.ignored,
});
}
Ok(())
}
}
fn parse_content(
spec: &RequestSpec<'_>,
axes: &'static AxisNames,
) -> Result<AnalysisSet, RequestError> {
spec.analyze.map_or(Ok(Request::DEFAULTS.content), |value| {
AnalysisSet::parse_rejecting(value).map_err(|rejection| rejection.on(axes.analyze))
})
}
fn parse_population(
value: Option<&str>,
axes: &'static AxisNames,
) -> Result<IgnoredEntries, RequestError> {
value.map_or(Ok(IgnoredEntries::Include), |value| {
IgnoredEntries::parse(value)
.map_err(|expected| Rejection::new(value, expected).on(axes.ignored))
})
}
fn parse_scope(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> Result<Scope, RequestError> {
let limits = Request::DEFAULTS.control_limits;
Ok(Scope {
max_depth: spec
.scan_depth
.map(|value| {
value
.trim()
.parse::<usize>()
.map_err(|_| invalid(axes.scan_depth, value, "expected a whole number"))
})
.transpose()?,
one_filesystem: spec.one_filesystem,
read_controls: spec.read_controls.unwrap_or(Request::DEFAULTS.read_controls),
control_limits: ControlLimits {
budget: spec.control_budget.map_or(Ok(limits.budget), |value| {
parse_control_budget(value)
.map_err(|error| named_refusal(error, axes.control_budget))
})?,
line_limit: spec.control_line_limit.map_or(Ok(limits.line_limit), |value| {
parse_control_line_limit(value)
.map_err(|error| named_refusal(error, axes.control_line_limit))
})?,
},
..Scope::default()
})
}
fn build_query(
content: AnalysisSet,
spec: &ReadSpec<'_>,
now: SystemTime,
axes: &'static AxisNames,
) -> Result<Query, RequestError> {
let (views, omitted_views) =
ViewSpec::resolve_rejecting(spec.views, content).map_err(|rejection| {
let suggestion = match rejection.value().to_ascii_lowercase().as_str() {
"lines" => Some(("lines", "families")),
"words" => Some(("words", "documents")),
_ => None,
};
match suggestion {
Some((analyzer, suggested_view)) => RequestError::AnalyzerNamedAsView {
value: rejection.value().to_string(),
analyzer,
suggested_view,
},
None => rejection.on(axes.view),
}
})?;
let mut selection = Selection {
depth: spec.depth.map(|value| parse_bound(value, axes.depth)).transpose()?,
min_share: spec
.min_share
.map(|value| {
ShareThreshold::parse(value).ok_or_else(|| {
invalid(axes.min_share, value, "expected a percentage from 0% through 100%")
})
})
.transpose()?,
breadth: spec.breadth.map(|value| parse_bound(value, axes.breadth)).transpose()?,
limit: spec.limit.map(|value| parse_bound(value, axes.limit)).transpose()?,
reverse: spec.reverse,
size: spec
.size
.map_or(Ok(Request::DEFAULTS.size), |value| parse_size_metric(value, axes.size))?,
..Selection::default()
};
for pattern in spec.include {
selection.include.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
}
for pattern in spec.exclude {
selection.exclude.push(Pattern::parse(pattern).map_err(grammar_refusal)?);
}
if let Some(value) = spec.min_size {
selection.min_size = Some(parse_size(value).map_err(grammar_refusal)?);
}
if let Some(value) = spec.modified_since {
let when = parse_when(value, now).map_err(grammar_refusal)?;
selection.modified.since = Some(bound_nanos(value, when, axes.modified_since)?);
}
if let Some(value) = spec.modified_before {
let when = parse_when(value, now).map_err(grammar_refusal)?;
selection.modified.before = Some(bound_nanos(value, when, axes.modified_before)?);
}
if let Some(value) = spec.kinds {
selection.kinds = parse_kinds(value, axes.kind)?;
}
if let Some(value) = spec.sort {
selection.sort = Some(parse_sort(value, axes.sort)?);
}
selection.ignored = parse_population(spec.ignored, axes)?;
let words_per_page =
spec.words_per_page.map_or(Ok(Request::DEFAULTS.words_per_page), |value| {
value
.trim()
.parse::<u64>()
.ok()
.filter(|words| *words > 0)
.ok_or_else(|| invalid(axes.words_per_page, value, "expected a positive integer"))
})?;
let format = spec.format.map_or(Ok(crate::report_format::Format::Text), |value| {
crate::report_format::Format::parse(value).ok_or_else(|| {
invalid(
axes.format,
value,
format!("expected one of {}", crate::report_format::Format::ALL.join(", ")),
)
})
})?;
Ok(Query { selection, views, format, omitted_views, axes, words_per_page })
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScopeAxis {
FollowSymlinks,
OneFilesystem,
}
impl ScopeAxis {
pub const fn reason(self) -> &'static str {
match self {
Self::FollowSymlinks => {
"follow_symlinks requires cycle, root-boundary, and filesystem-boundary semantics"
}
Self::OneFilesystem => "one_filesystem requires platform device identity",
}
}
const fn named(self, axes: &AxisNames) -> &'static str {
match self {
Self::FollowSymlinks => axes.follow_symlinks,
Self::OneFilesystem => axes.one_filesystem,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RequestError {
InvalidValue {
axis: &'static str,
value: String,
expected: String,
},
AnalyzerNamedAsView {
value: String,
analyzer: &'static str,
suggested_view: &'static str,
},
ViewNeedsContent(ViewSpec),
NeedsAnalyzer {
item: &'static str,
analyzer: &'static str,
},
IgnoredWithoutObservation(IgnoredEntries),
PopulationMismatch {
held: IgnoredEntries,
requested: IgnoredEntries,
},
ScopeUnsupported {
axis: ScopeAxis,
reason: &'static str,
},
ContentMismatch {
held: AnalysisSet,
requested: AnalysisSet,
},
WatchScope,
WatchContent,
WatchCacheOnly,
StaleOkCacheOff,
RootMismatch {
held: PathBuf,
requested: PathBuf,
},
DeliveryUnsupported {
route: &'static str,
reason: &'static str,
},
ViewLimit {
attempted: usize,
limit: usize,
},
}
impl RequestError {
pub fn message(&self, axes: &AxisNames) -> String {
match self {
Self::InvalidValue { axis, value, expected } => invalid_message(axis, value, expected),
Self::AnalyzerNamedAsView { value, analyzer, suggested_view } => format!(
"invalid {} {value:?}: {analyzer} is an analyzer; use {}={analyzer} with {}={suggested_view}",
axes.view, axes.analyze, axes.view
),
Self::ViewNeedsContent(view) => format!(
"{} {} requires content analysis: add {} lines, code, words, or all; views never \
enable content analysis implicitly",
axes.view,
view.label(),
axes.analyze
),
Self::NeedsAnalyzer { item, analyzer } => format!(
"{item} requires {analyzer} analysis: add {} {analyzer}; views and sorts never enable analysis implicitly",
axes.analyze
),
Self::IgnoredWithoutObservation(ignored) => format!(
"{} needs .gitignore classification, and {} turned it off; drop one of them",
match ignored {
IgnoredEntries::Exclude => axes.exclude_ignored,
IgnoredEntries::Only => axes.only_ignored,
IgnoredEntries::Include => axes.ignored,
},
axes.read_controls
),
Self::PopulationMismatch { held, requested } => format!(
"{}={} cannot be read from retained {}={} scope",
axes.ignored,
requested.label(),
axes.ignored,
held.label()
),
Self::ScopeUnsupported { axis, reason } => format!(
"unsupported scan configuration: {}",
reason.replacen(axis.named(&AxisNames::FIELDS), axis.named(axes), 1)
),
Self::ContentMismatch { held, requested } => format!(
"{analyze} {requested} cannot be answered by an index built with {analyze} \
{held}; open the root again with {analyze} {requested}",
analyze = axes.analyze,
requested = analysis_label(*requested),
held = analysis_label(*held),
),
Self::WatchScope => watch_scope_message(axes),
Self::RootMismatch { held, requested } => {
format!(
"requested root {} does not match retained root {}",
requested.display(),
held.display()
)
}
Self::DeliveryUnsupported { route, reason } => format!("{route}: {reason}"),
Self::WatchContent => format!(
"{} is not yet supported with {}; use a one-shot report",
axes.analyze, axes.watch
),
Self::WatchCacheOnly => format!(
"{watch} cannot start from a {stale_ok} answer: nothing verifies what changed \
between the snapshot and the start of the watch; drop {stale_ok}",
watch = axes.watch,
stale_ok = axes.stale_ok,
),
Self::StaleOkCacheOff => format!(
"{stale_ok} answers from the snapshot, which {cache} off never reads; drop one \
of them",
stale_ok = axes.stale_ok,
cache = axes.cache,
),
Self::ViewLimit { attempted, limit } => {
format!("report request contains {attempted} views or omissions; limit is {limit}")
}
}
}
}
impl fmt::Display for RequestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message(&AxisNames::FIELDS))
}
}
impl std::error::Error for RequestError {}
fn analysis_label(set: AnalysisSet) -> String {
if set.is_enabled() { set.labels().join(",") } else { AnalysisSet::NONE_LABEL.to_string() }
}
fn watch_scope_message(axes: &AxisNames) -> String {
let fields = &AxisNames::FIELDS;
let vocabulary = [
(fields.scan_depth, axes.scan_depth),
(fields.one_filesystem, axes.one_filesystem),
(fields.modified_since, axes.modified_since),
(fields.depth, axes.depth),
(fields.include, axes.include),
];
let is_word = |character: char| character.is_ascii_alphanumeric() || character == '_';
crate::scan::WATCH_SCOPE_GUIDANCE
.split_inclusive(|character: char| !is_word(character))
.map(|piece| {
let end = piece.find(|character: char| !is_word(character)).unwrap_or(piece.len());
let (word, tail) = piece.split_at(end);
match vocabulary.iter().find(|(field, _)| *field == word) {
Some((_, name)) => format!("{name}{tail}"),
None => piece.to_string(),
}
})
.collect()
}
pub(crate) fn check_views(views: &[ViewSpec], content: AnalysisSet) -> Result<(), RequestError> {
for view in views {
match view {
ViewSpec::Code if !content.includes_code() => {
return Err(RequestError::NeedsAnalyzer { item: "code view", analyzer: "code" });
}
ViewSpec::Documents if !content.is_enabled() => {
return Err(RequestError::ViewNeedsContent(*view));
}
ViewSpec::List
| ViewSpec::Tree
| ViewSpec::Types
| ViewSpec::Extensions
| ViewSpec::Families
| ViewSpec::Languages
| ViewSpec::Code
| ViewSpec::Documents
| ViewSpec::Files
| ViewSpec::Largest
| ViewSpec::Recent
| ViewSpec::Summary => {}
}
}
Ok(())
}
pub(crate) fn check_observation(
ignored: IgnoredEntries,
observes_controls: bool,
) -> Result<(), RequestError> {
match ignored {
IgnoredEntries::Include => Ok(()),
IgnoredEntries::Exclude | IgnoredEntries::Only if observes_controls => Ok(()),
IgnoredEntries::Exclude | IgnoredEntries::Only => {
Err(RequestError::IgnoredWithoutObservation(ignored))
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Rejection {
value: String,
expected: String,
}
impl Rejection {
pub(crate) fn new(value: impl Into<String>, expected: impl Into<String>) -> Self {
Self { value: value.into(), expected: expected.into() }
}
pub(crate) fn value(&self) -> &str {
&self.value
}
pub(crate) fn on(self, axis: &'static str) -> RequestError {
RequestError::InvalidValue { axis, value: self.value, expected: self.expected }
}
pub(crate) fn labeled(&self, label: &str) -> String {
invalid_message(label, &self.value, &self.expected)
}
}
fn invalid_message(axis: &str, value: &str, expected: &str) -> String {
format!("invalid {axis} {value:?}: {expected}")
}
fn invalid(
axis: &'static str,
value: impl Into<String>,
expected: impl Into<String>,
) -> RequestError {
Rejection::new(value, expected).on(axis)
}
fn grammar_refusal(error: crate::Error) -> RequestError {
match error {
crate::Error::InvalidValue { kind, value, hint } => invalid(kind, value, hint),
other => invalid("value", String::new(), other.to_string()),
}
}
fn named_refusal(error: crate::Error, axis: &'static str) -> RequestError {
match error {
crate::Error::InvalidValue { value, hint, .. } => invalid(axis, value, hint),
other => invalid(axis, String::new(), other.to_string()),
}
}
pub fn parse_kind(value: &str, axis: &'static str) -> Result<EntryKind, RequestError> {
match value.trim().to_ascii_lowercase().as_str() {
"file" => Ok(EntryKind::File),
"dir" => Ok(EntryKind::Dir),
"symlink" => Ok(EntryKind::Symlink),
"other" => Ok(EntryKind::Other),
other => Err(invalid(axis, other, "expected one of file, dir, symlink, other")),
}
}
pub fn parse_kinds(list: &str, axis: &'static str) -> Result<Vec<EntryKind>, RequestError> {
let mut kinds = Vec::new();
for token in list.split(',') {
let token = token.trim();
if token.is_empty() {
return Err(invalid(axis, list, "empty entry in the list"));
}
let kind = parse_kind(token, axis)?;
if kinds.contains(&kind) {
return Err(invalid(axis, list, format!("{token:?} appears more than once")));
}
kinds.push(kind);
}
Ok(kinds)
}
pub fn parse_bound(value: &str, axis: &'static str) -> Result<Bound, RequestError> {
let value = value.trim();
if value.eq_ignore_ascii_case("all") {
return Ok(Bound::All);
}
value
.parse::<usize>()
.map(Bound::Limit)
.map_err(|_| invalid(axis, value, "expected a whole number or `all`"))
}
pub fn parse_sort(value: &str, axis: &'static str) -> Result<SortKey, RequestError> {
match value.trim().to_ascii_lowercase().as_str() {
"size" => Ok(SortKey::Size),
"count" => Ok(SortKey::Count),
"mtime" => Ok(SortKey::Mtime),
"name" => Ok(SortKey::Name),
other => crate::content::METRICS.iter().find(|metric| metric.name == other).map_or_else(
|| {
Err(invalid(
axis,
other,
"expected size, count, mtime, name, or a registered numeric metric",
))
},
|metric| Ok(SortKey::Metric(metric.name)),
),
}
}
pub fn parse_size_metric(value: &str, axis: &'static str) -> Result<SizeMetric, RequestError> {
match value.trim().to_ascii_lowercase().as_str() {
"allocated" => Ok(SizeMetric::Allocated),
"apparent" => Ok(SizeMetric::Apparent),
other => Err(invalid(axis, other, "expected allocated or apparent")),
}
}
pub fn bound_nanos(value: &str, when: SystemTime, axis: &'static str) -> Result<i64, RequestError> {
system_time_to_nanos(when).ok_or_else(|| {
invalid(
axis,
value,
"that time is outside the range fdu can represent (about 1677 to 2262)",
)
})
}
pub fn parse_cache_policy(value: &str, axis: &'static str) -> Result<CachePolicy, RequestError> {
let stale_ok = if axis == AxisNames::FLAGS.cache {
AxisNames::FLAGS.stale_ok
} else {
AxisNames::FIELDS.stale_ok
};
match value.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(CachePolicy::Auto),
"on" => Ok(CachePolicy::On),
"off" => Ok(CachePolicy::Off),
"only" => Err(invalid(
axis,
"only",
format!("answering from the snapshot alone is now {stale_ok}"),
)),
"refresh" => Err(invalid(
axis,
"refresh",
"removed; use on, which also writes after a one-shot report",
)),
"read-only" => Err(invalid(
axis,
"read-only",
"removed; auto no longer writes after a one-shot metadata report, and off reads nothing",
)),
other => Err(invalid(axis, other, "expected one of auto, on, off")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_grammar_names_its_axis_as_the_surface_spells_it() {
let flags = &AxisNames::FLAGS;
let fields = &AxisNames::FIELDS;
let cases: [(&str, RequestError, RequestError); 6] = [
(
"kind",
parse_kind(" Socket ", flags.kind).expect_err("unknown kind"),
parse_kind(" Socket ", fields.kind).expect_err("unknown kind"),
),
(
"bound",
parse_bound("two", flags.depth).expect_err("not a number"),
parse_bound("two", fields.depth).expect_err("not a number"),
),
(
"sort",
parse_sort("Newest", flags.sort).expect_err("unknown key"),
parse_sort("Newest", fields.sort).expect_err("unknown key"),
),
(
"size",
parse_size_metric("logical", flags.size).expect_err("unknown metric"),
parse_size_metric("logical", fields.size).expect_err("unknown metric"),
),
(
"time",
bound_nanos("2300-01-01T00:00:00Z", far_future(), flags.modified_since)
.expect_err("unrepresentable"),
bound_nanos("2300-01-01T00:00:00Z", far_future(), fields.modified_since)
.expect_err("unrepresentable"),
),
(
"cache",
parse_cache_policy("readonly", flags.cache).expect_err("unreleased alias"),
parse_cache_policy("readonly", fields.cache).expect_err("unreleased alias"),
),
];
let expected = [
(
"invalid --kind \"socket\": expected one of file, dir, symlink, other",
"invalid kind \"socket\": expected one of file, dir, symlink, other",
),
(
"invalid --depth \"two\": expected a whole number or `all`",
"invalid depth \"two\": expected a whole number or `all`",
),
(
"invalid --sort \"newest\": expected size, count, mtime, name, or a registered numeric metric",
"invalid sort \"newest\": expected size, count, mtime, name, or a registered numeric metric",
),
(
"invalid --size \"logical\": expected allocated or apparent",
"invalid size \"logical\": expected allocated or apparent",
),
(
"invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
range fdu can represent (about 1677 to 2262)",
"invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
fdu can represent (about 1677 to 2262)",
),
(
"invalid --cache \"readonly\": expected one of auto, on, off",
"invalid cache policy \"readonly\": expected one of auto, on, off",
),
];
for ((grammar, flag, field), (flag_text, field_text)) in cases.into_iter().zip(expected) {
assert_eq!(flag.message(&AxisNames::FLAGS), flag_text, "{grammar}");
assert_eq!(field.message(&AxisNames::FIELDS), field_text, "{grammar}");
assert_eq!(flag.message(&AxisNames::FIELDS), flag_text, "{grammar}");
}
}
fn far_future() -> SystemTime {
SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(10_000_000_000)
}
#[test]
fn the_grammars_accept_their_whole_vocabulary() {
let axis = AxisNames::FIELDS.kind;
for (spelling, kind) in [
("file", EntryKind::File),
("DIR", EntryKind::Dir),
(" symlink", EntryKind::Symlink),
("other", EntryKind::Other),
] {
assert_eq!(parse_kind(spelling, axis), Ok(kind));
}
assert_eq!(parse_kinds("file, dir", axis), Ok(vec![EntryKind::File, EntryKind::Dir]));
assert_eq!(parse_bound(" ALL ", axis), Ok(Bound::All));
assert_eq!(parse_bound("0", axis), Ok(Bound::Limit(0)));
for (spelling, key) in [
("size", SortKey::Size),
("count", SortKey::Count),
("MTIME", SortKey::Mtime),
("name", SortKey::Name),
("CODE_LINES", SortKey::Metric("code_lines")),
] {
assert_eq!(parse_sort(spelling, axis), Ok(key));
}
assert_eq!(parse_size_metric("Allocated", axis), Ok(SizeMetric::Allocated));
assert_eq!(parse_size_metric("apparent", axis), Ok(SizeMetric::Apparent));
for (spelling, policy) in
[("auto", CachePolicy::Auto), ("ON", CachePolicy::On), ("off", CachePolicy::Off)]
{
assert_eq!(parse_cache_policy(spelling, axis), Ok(policy));
}
for (spelling, flags, fields) in [
(
"only",
"invalid --cache \"only\": answering from the snapshot alone is now --stale-ok",
"invalid cache policy \"only\": answering from the snapshot alone is now stale_ok",
),
(
"refresh",
"invalid --cache \"refresh\": removed; use on, which also writes after a one-shot \
report",
"invalid cache policy \"refresh\": removed; use on, which also writes after a \
one-shot report",
),
(
"read-only",
"invalid --cache \"read-only\": removed; auto no longer writes after a one-shot \
metadata report, and off reads nothing",
"invalid cache policy \"read-only\": removed; auto no longer writes after a \
one-shot metadata report, and off reads nothing",
),
] {
let message = |axis| {
parse_cache_policy(spelling, axis).expect_err("retired").message(&AxisNames::FLAGS)
};
assert_eq!(message(AxisNames::FLAGS.cache), flags);
assert_eq!(message(AxisNames::FIELDS.cache), fields);
}
let epoch = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(2);
assert_eq!(bound_nanos("@2", epoch, axis), Ok(2_000_000_000));
}
#[test]
fn a_kind_list_refuses_empty_and_repeated_entries() {
let axis = AxisNames::FLAGS.kind;
assert_eq!(
parse_kinds("file,,dir", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
Err("invalid --kind \"file,,dir\": empty entry in the list".to_string())
);
assert_eq!(
parse_kinds("file, FILE", axis).map_err(|error| error.message(&AxisNames::FLAGS)),
Err("invalid --kind \"file, FILE\": \"FILE\" appears more than once".to_string())
);
}
#[test]
fn every_refusal_renders_in_flag_and_field_wording() {
let cases = [
(
RequestError::ViewNeedsContent(ViewSpec::Documents),
"--view documents requires content analysis: add --analyze lines, code, words, \
or all; views never enable content analysis implicitly",
"view documents requires content analysis: add analyze lines, code, words, or \
all; views never enable content analysis implicitly",
),
(
RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude),
"--ignored=exclude needs .gitignore classification, and --no-gitignore turned it \
off; drop one of them",
"ignored=exclude needs .gitignore classification, and read_controls turned it \
off; drop one of them",
),
(
RequestError::IgnoredWithoutObservation(IgnoredEntries::Only),
"--ignored=only needs .gitignore classification, and --no-gitignore turned it \
off; drop one of them",
"ignored=only needs .gitignore classification, and read_controls turned it off; \
drop one of them",
),
(
RequestError::ContentMismatch {
held: AnalysisSet::NONE,
requested: AnalysisSet::NONE.with_code(),
},
"--analyze lines,code cannot be answered by an index built with --analyze none; \
open the root again with --analyze lines,code",
"analyze lines,code cannot be answered by an index built with analyze none; open \
the root again with analyze lines,code",
),
(
RequestError::ScopeUnsupported {
axis: ScopeAxis::OneFilesystem,
reason: ScopeAxis::OneFilesystem.reason(),
},
"unsupported scan configuration: --one-filesystem requires platform device \
identity",
"unsupported scan configuration: one_filesystem requires platform device identity",
),
(
RequestError::ScopeUnsupported {
axis: ScopeAxis::FollowSymlinks,
reason: ScopeAxis::FollowSymlinks.reason(),
},
"unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
and filesystem-boundary semantics",
"unsupported scan configuration: follow_symlinks requires cycle, root-boundary, \
and filesystem-boundary semantics",
),
(
RequestError::WatchContent,
"--analyze is not yet supported with --watch; use a one-shot report",
"analyze is not yet supported with watch; use a one-shot report",
),
(
RequestError::WatchCacheOnly,
"--watch cannot start from a --stale-ok answer: nothing verifies what changed \
between the snapshot and the start of the watch; drop --stale-ok",
"watch cannot start from a stale_ok answer: nothing verifies what changed between \
the snapshot and the start of the watch; drop stale_ok",
),
(
RequestError::StaleOkCacheOff,
"--stale-ok answers from the snapshot, which --cache off never reads; drop one of \
them",
"stale_ok answers from the snapshot, which cache policy off never reads; drop one \
of them",
),
(
RequestError::ViewLimit { attempted: 17, limit: 16 },
"report request contains 17 views or omissions; limit is 16",
"report request contains 17 views or omissions; limit is 16",
),
];
for (refusal, flags, fields) in cases {
assert_eq!(refusal.message(&AxisNames::FLAGS), flags);
assert_eq!(refusal.message(&AxisNames::FIELDS), fields);
assert_eq!(refusal.to_string(), fields, "a refusal displays in the library's words");
}
}
#[test]
fn the_watch_scope_refusal_substitutes_whole_words_only() {
let source = crate::scan::WATCH_SCOPE_GUIDANCE;
assert_eq!(RequestError::WatchScope.message(&AxisNames::FIELDS), source);
let text = RequestError::WatchScope.message(&AxisNames::FLAGS);
assert!(!text.contains("---"), "{text} re-substituted a replacement");
let names_word = |haystack: &str, word: &str| {
haystack
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
.any(|w| w == word)
};
let (fields, flags) = (&AxisNames::FIELDS, &AxisNames::FLAGS);
let vocabulary = [
(fields.scan_depth, flags.scan_depth),
(fields.one_filesystem, flags.one_filesystem),
(fields.modified_since, flags.modified_since),
(fields.depth, flags.depth),
(fields.include, flags.include),
];
for (field, flag) in vocabulary {
if names_word(source, field) {
assert!(text.contains(flag), "{text} must name {flag} where the rule says {field}");
}
assert!(!names_word(&text, field), "{text} still names {field} untranslated");
}
let mut rebuilt = text.clone();
for (field, flag) in vocabulary {
rebuilt = rebuilt.replace(flag, field);
}
assert_eq!(rebuilt, source, "the flag wording must be the library's, knob names aside");
}
#[test]
fn documents_and_code_are_the_views_that_need_content() {
for content in [AnalysisSet::NONE, AnalysisSet::NONE.with_lines(), AnalysisSet::ALL] {
for view in ViewSpec::ALL {
let refused = check_views(&[view], content).is_err();
assert_eq!(
refused,
(view == ViewSpec::Documents && !content.is_enabled())
|| (view == ViewSpec::Code && !content.includes_code()),
"{view:?}"
);
}
}
assert_eq!(check_observation(IgnoredEntries::Include, false), Ok(()));
for ignored in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
assert_eq!(check_observation(ignored, true), Ok(()));
assert_eq!(
check_observation(ignored, false),
Err(RequestError::IgnoredWithoutObservation(ignored))
);
}
}
use crate::content::AnalysisRequest;
fn root() -> &'static Path {
Path::new("/tree")
}
fn instant() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
}
#[allow(clippy::large_types_passed_by_value)]
fn reading(read: ReadSpec<'_>) -> RequestSpec<'_> {
RequestSpec { read, ..RequestSpec::new(root()) }
}
fn built(spec: &RequestSpec<'_>) -> Request {
Request::build(spec, instant(), &AxisNames::FIELDS).expect("the spec parses")
}
#[test]
fn retained_population_allows_narrower_reads_only_from_include() {
let read = |basis: Basis, ignored| {
Request::read(
basis,
&ReadSpec { ignored, ..ReadSpec::default() },
instant(),
&AxisNames::FIELDS,
)
};
let include = Basis {
root: root().to_path_buf(),
scope: Scope::default(),
content: AnalysisSet::NONE,
};
assert_eq!(
read(include.clone(), Some("exclude")).expect("narrow exclude").query.selection.ignored,
IgnoredEntries::Exclude
);
assert_eq!(
read(include, Some("only")).expect("narrow only").query.selection.ignored,
IgnoredEntries::Only
);
let excluded = Basis {
root: root().to_path_buf(),
scope: Scope { population: IgnoredEntries::Exclude, ..Scope::default() },
content: AnalysisSet::NONE,
};
assert_eq!(
read(excluded.clone(), None).expect("basis default").query.selection.ignored,
IgnoredEntries::Exclude
);
assert!(matches!(
read(excluded, Some("only")),
Err(RequestError::PopulationMismatch {
held: IgnoredEntries::Exclude,
requested: IgnoredEntries::Only
})
));
}
fn refusal(spec: &RequestSpec<'_>, axes: &'static AxisNames) -> String {
Request::build(spec, instant(), axes).expect_err("the spec is refused").message(axes)
}
#[test]
fn list_formats_are_resolved_and_validated_in_the_shared_request() {
use crate::report_format::Format;
for (view, format, valid, tree) in [
(None, None, true, true),
(Some("list"), Some("tree"), true, true),
(Some("list"), Some("paths"), true, false),
(None, Some("json"), true, false),
(Some("files"), None, true, false),
(Some("files"), Some("tree"), true, true),
(Some("tree"), Some("long"), true, false),
(Some("largest"), Some("long"), true, false),
(Some("summary"), Some("long"), false, false),
(Some("full"), Some("paths"), false, false),
(Some("list,summary"), Some("tree"), false, false),
(Some("list,summary"), Some("json"), true, false),
] {
let spec = RequestSpec {
read: ReadSpec { views: view, format, ..ReadSpec::new() },
..RequestSpec::new(Path::new("/absent"))
};
let request =
Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("grammar");
assert_eq!(request.validate().is_ok(), valid, "{view:?} {format:?}");
if valid {
assert_eq!(request.query.tree_for(request.query.views[0]), tree);
}
}
let spec = RequestSpec::new(Path::new("/absent"));
let request =
Request::build(&spec, SystemTime::UNIX_EPOCH, &AxisNames::FIELDS).expect("default");
assert_eq!(request.query.views, [ViewSpec::List]);
assert!(
!request.query.needs_selection_walk(),
"an ordinary tree must retain its bounded projection cost"
);
let mut flat = request.clone();
flat.query.format = Format::Paths;
assert!(flat.query.needs_selection_walk());
assert_eq!(flat.query.limit_for(ViewSpec::List), Bound::All);
assert_eq!(request.query.limit_for(ViewSpec::List), Bound::All);
assert_eq!(request.query.depth_for(ViewSpec::List), Bound::Limit(5));
}
#[test]
fn an_empty_spec_builds_the_defaults_table() {
let defaults = Request::DEFAULTS;
assert_eq!(defaults.size, SizeMetric::Allocated);
assert_eq!(defaults.words_per_page, 250);
assert_eq!(defaults.content, AnalysisSet::NONE);
assert!(defaults.read_controls);
assert_eq!(defaults.control_limits, ControlLimits::default());
assert_eq!(defaults.report_view(AnalysisSet::NONE), ViewSpec::List);
for content in [
AnalysisSet::NONE,
AnalysisSet::NONE.with_lines(),
AnalysisSet::NONE.with_code(),
AnalysisSet::NONE.with_words(),
AnalysisSet::ALL,
] {
assert_eq!(defaults.report_view(content), ViewSpec::default_for(content));
}
let request = built(&RequestSpec::new(root()));
assert_eq!(request.basis.root, root());
assert_eq!(request.basis.content, defaults.content);
assert_eq!(request.basis.scope.read_controls, defaults.read_controls);
assert_eq!(request.basis.scope.control_limits, defaults.control_limits);
assert_eq!(request.basis.scope.max_depth, None);
assert!(!request.basis.scope.one_filesystem);
assert_eq!(request.query.views, vec![defaults.report_view(defaults.content)]);
assert!(request.query.omitted_views.is_empty());
assert_eq!(request.query.words_per_page, defaults.words_per_page);
assert_eq!(request.query.selection.size, defaults.size);
assert!(request.query.selection.is_unfiltered());
let selection = &request.query.selection;
assert_eq!((selection.depth, selection.limit, selection.sort), (None, None, None));
assert!(!selection.reverse);
assert_eq!(*request.query.axes, AxisNames::FIELDS);
assert_eq!(request.now, instant());
request.validate().expect("the defaults are a valid request");
assert_eq!(SizeMetric::default(), defaults.size);
assert_eq!(Selection::default().size, defaults.size);
assert_eq!(Query::default().words_per_page, defaults.words_per_page);
assert_eq!(ScanConfig::default().read_controls, defaults.read_controls);
assert_eq!(ScanConfig::default().control_limits, defaults.control_limits);
assert_eq!(AnalysisRequest::default().profile, defaults.content);
assert_eq!(AnalysisSet::default(), defaults.content);
}
#[test]
fn every_axis_of_a_spec_reaches_its_typed_value() {
let include = ["*.rs".to_string()];
let exclude = ["target/**".to_string()];
let spec = RequestSpec {
scan_depth: Some("3"),
one_filesystem: true,
read_controls: Some(false),
control_budget: Some("all"),
control_line_limit: Some("64KiB"),
analyze: Some("code"),
read: ReadSpec {
views: Some("languages,tree"),
format: None,
words_per_page: Some("300"),
include: &include,
exclude: &exclude,
min_size: Some("1KiB"),
modified_since: Some("@1700000000"),
modified_before: Some("@1800000000"),
kinds: Some("file,dir"),
ignored: Some("include"),
depth: Some("all"),
min_share: Some("1%"),
breadth: Some("all"),
limit: Some("5"),
sort: Some("name"),
reverse: true,
size: Some("apparent"),
},
..RequestSpec::new(root())
};
let request = Request::build(&spec, instant(), &AxisNames::FLAGS).expect("parses");
let scope = &request.basis.scope;
assert_eq!(scope.max_depth, Some(3));
assert!(scope.one_filesystem);
assert!(!scope.read_controls);
assert_eq!(scope.control_limits, ControlLimits { budget: None, line_limit: Some(65_536) });
assert_eq!(request.basis.content, AnalysisSet::NONE.with_code());
assert_eq!(request.query.views, vec![ViewSpec::Languages, ViewSpec::Tree]);
assert_eq!(request.query.words_per_page, 300);
assert_eq!(*request.query.axes, AxisNames::FLAGS);
let selection = &request.query.selection;
assert_eq!(selection.include.len(), 1);
assert_eq!(selection.exclude.len(), 1);
assert_eq!(selection.min_size, Some(1024));
assert_eq!(selection.modified.since, Some(1_700_000_000_000_000_000));
assert_eq!(selection.modified.before, Some(1_800_000_000_000_000_000));
assert_eq!(selection.kinds, vec![EntryKind::File, EntryKind::Dir]);
assert_eq!(selection.ignored, IgnoredEntries::Include);
assert_eq!(selection.depth, Some(Bound::All));
assert_eq!(selection.limit, Some(Bound::Limit(5)));
assert_eq!(selection.sort, Some(SortKey::Name));
assert!(selection.reverse);
assert_eq!(selection.size, SizeMetric::Apparent);
}
#[test]
fn relative_windows_resolve_against_the_requests_instant() {
let spec = reading(ReadSpec {
modified_since: Some("2h"),
modified_before: Some("now"),
..ReadSpec::new()
});
let request = built(&spec);
let now = system_time_to_nanos(instant()).expect("representable");
let two_hours = 2 * 60 * 60 * 1_000_000_000;
assert_eq!(request.now, instant());
assert_eq!(request.query.selection.modified.since, Some(now - two_hours));
assert_eq!(request.query.selection.modified.before, Some(now));
let later = instant() + Duration::from_secs(60);
let moved = Request::build(&spec, later, &AxisNames::FIELDS).expect("parses");
assert_eq!(moved.query.selection.modified.since, Some(now - two_hours + 60_000_000_000));
let again = built(&spec);
assert_eq!(again.query.selection.modified.since, request.query.selection.modified.since);
}
#[test]
fn build_refuses_each_axis_in_the_surfaces_words() {
let spec = RequestSpec::new(root());
let analyze = RequestSpec { analyze: Some("deep"), ..spec };
for (axes, label) in [(&AxisNames::FLAGS, "--analyze"), (&AxisNames::FIELDS, "analyze")] {
let today = AnalysisSet::parse_labeled("deep", label).expect_err("refused");
assert_eq!(refusal(&analyze, axes), today);
}
for views in ["tree,tree", "tree,,types", "full,tree", "bogus"] {
let spec = reading(ReadSpec { views: Some(views), ..ReadSpec::new() });
for (axes, label) in [(&AxisNames::FLAGS, "--view"), (&AxisNames::FIELDS, "view")] {
let today =
ViewSpec::resolve(Some(views), AnalysisSet::NONE, label).expect_err("refused");
assert_eq!(refusal(&spec, axes), today);
}
}
let cases: [(RequestSpec<'_>, &str, &str); 7] = [
(
reading(ReadSpec { words_per_page: Some("0"), ..ReadSpec::new() }),
"invalid --words-per-page \"0\": expected a positive integer",
"invalid words_per_page \"0\": expected a positive integer",
),
(
RequestSpec { scan_depth: Some("deep"), ..spec },
"invalid --scan-depth \"deep\": expected a whole number",
"invalid max_depth \"deep\": expected a whole number",
),
(
RequestSpec { control_budget: Some("lots"), ..spec },
"invalid --gitignore-budget \"lots\": expected a number before the unit, as in \
`10M`, or `all` for no bound",
"invalid control_budget \"lots\": expected a number before the unit, as in \
`10M`, or `all` for no bound",
),
(
reading(ReadSpec { ignored: Some("maybe"), ..ReadSpec::new() }),
"invalid --ignored \"maybe\": expected one of include, \
exclude, only",
"invalid ignored \"maybe\": expected one of include, exclude, only",
),
(
reading(ReadSpec { kinds: Some("file,socket"), ..ReadSpec::new() }),
"invalid --kind \"socket\": expected one of file, dir, symlink, other",
"invalid kind \"socket\": expected one of file, dir, symlink, other",
),
(
reading(ReadSpec { min_size: Some("10X"), ..ReadSpec::new() }),
"invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
"invalid size \"10X\": unknown size unit \"X\"; use B, K/KB, M/MB, G/GB, T/TB, \
P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB",
),
(
reading(ReadSpec {
modified_since: Some("2300-01-01T00:00:00Z"),
..ReadSpec::new()
}),
"invalid --modified-since \"2300-01-01T00:00:00Z\": that time is outside the \
range fdu can represent (about 1677 to 2262)",
"invalid modified_since \"2300-01-01T00:00:00Z\": that time is outside the range \
fdu can represent (about 1677 to 2262)",
),
];
for (spec, flags, fields) in cases {
assert_eq!(refusal(&spec, &AxisNames::FLAGS), flags);
assert_eq!(refusal(&spec, &AxisNames::FIELDS), fields);
}
}
#[test]
fn analyzer_names_in_view_axis_point_to_both_correct_axes() {
for (token, analyzer, view) in
[("LiNeS", "lines", "families"), ("words", "words", "documents")]
{
let spec = reading(ReadSpec { views: Some(token), ..ReadSpec::new() });
for axes in [&AxisNames::FLAGS, &AxisNames::FIELDS] {
let error = Request::build(&spec, instant(), axes).expect_err("not a view");
assert!(matches!(error, RequestError::AnalyzerNamedAsView { .. }));
assert_eq!(
error.message(axes),
format!(
"invalid {} {token:?}: {analyzer} is an analyzer; use {}={analyzer} with {}={view}",
axes.view, axes.analyze, axes.view
)
);
}
}
let combination = reading(ReadSpec { views: Some("full,words"), ..ReadSpec::new() });
assert_eq!(
refusal(&combination, &AxisNames::FLAGS),
ViewSpec::resolve(Some("full,words"), AnalysisSet::NONE, "--view")
.expect_err("full is exclusive")
);
}
#[test]
fn build_names_a_bad_axis_in_the_order_it_publishes() {
let everything = RequestSpec {
scan_depth: Some("deep"),
analyze: Some("deep"),
read: ReadSpec {
views: Some("bogus"),
depth: Some("two"),
words_per_page: Some("0"),
..ReadSpec::new()
},
..RequestSpec::new(root())
};
let steps: [(RequestSpec<'_>, &str); 5] = [
(everything, "analyze"),
(RequestSpec { analyze: None, ..everything }, "view"),
(
RequestSpec {
analyze: None,
read: ReadSpec { views: None, ..everything.read },
..everything
},
"depth",
),
(
RequestSpec {
analyze: None,
read: ReadSpec { views: None, depth: None, ..everything.read },
..everything
},
"words_per_page",
),
(
RequestSpec {
analyze: None,
read: ReadSpec {
views: None,
format: None,
depth: None,
words_per_page: None,
..everything.read
},
..everything
},
"max_depth",
),
];
for (spec, axis) in steps {
let refused = refusal(&spec, &AxisNames::FIELDS);
assert!(
refused.starts_with(&format!("invalid {axis} ")),
"expected {axis} to speak next, got {refused}"
);
}
Request::build(
&RequestSpec {
scan_depth: None,
analyze: None,
read: ReadSpec::new(),
..RequestSpec::new(root())
},
instant(),
&AxisNames::FIELDS,
)
.expect("nothing left to refuse");
}
fn request_with(views: &[ViewSpec], selection: Selection, basis: Basis) -> Request {
Request::new(
basis,
Query { selection, views: views.to_vec(), ..Query::default() },
instant(),
)
}
fn basis(content: AnalysisSet, read_controls: bool) -> Basis {
Basis {
root: root().to_path_buf(),
scope: Scope { read_controls, ..Scope::default() },
content,
}
}
#[test]
fn language_grouping_is_metadata_only_while_documents_require_analysis() {
let enabled = [
AnalysisSet::NONE.with_lines(),
AnalysisSet::NONE.with_code(),
AnalysisSet::NONE.with_words(),
AnalysisSet::ALL,
];
for content in std::iter::once(AnalysisSet::NONE).chain(enabled) {
request_with(&[ViewSpec::Languages], Selection::default(), basis(content, true))
.validate()
.expect("language grouping never requires content I/O");
}
let documents = request_with(
&[ViewSpec::Documents],
Selection::default(),
basis(AnalysisSet::NONE, true),
);
assert_eq!(documents.validate(), Err(RequestError::ViewNeedsContent(ViewSpec::Documents)));
for content in enabled {
request_with(&[ViewSpec::Documents], Selection::default(), basis(content, true))
.validate()
.expect("every enabled profile includes the basic document metrics");
}
request_with(
&[ViewSpec::Types, ViewSpec::Families],
Selection::default(),
basis(AnalysisSet::NONE, true),
)
.validate()
.expect("metadata grouping never requires content I/O");
}
#[test]
fn code_view_and_metric_sort_require_their_registered_analyzer() {
let plain = basis(AnalysisSet::NONE, true);
let code = request_with(&[ViewSpec::Code], Selection::default(), plain.clone());
assert_eq!(
code.validate(),
Err(RequestError::NeedsAnalyzer { item: "code view", analyzer: "code" })
);
let selection =
Selection { sort: Some(SortKey::Metric("code_lines")), ..Selection::default() };
let files = request_with(&[ViewSpec::Files], selection.clone(), plain);
assert_eq!(
files.validate(),
Err(RequestError::NeedsAnalyzer { item: "code_lines", analyzer: "code" })
);
request_with(&[ViewSpec::Files], selection, basis(AnalysisSet::NONE.with_code(), true))
.validate()
.expect("code metrics are available with the code analyzer");
}
#[test]
fn extension_view_refuses_metric_sort_before_reading() {
let held = basis(AnalysisSet::NONE.with_code(), true);
let selection =
Selection { sort: Some(SortKey::Metric("code_lines")), ..Selection::default() };
let request = request_with(&[ViewSpec::Extensions], selection.clone(), held.clone());
let expected = invalid(
request.query.axes.sort,
"code_lines",
"extensions cannot sort by content metrics; use size, count, or name, or select files or another metric-capable view",
);
assert_eq!(request.validate(), Err(expected.clone()));
assert_eq!(request.validate_read(&held), Err(expected));
request_with(&[ViewSpec::Files], selection, held)
.validate()
.expect("files retain metric sorting");
}
#[test]
fn a_scope_this_build_cannot_honour_is_refused_by_request_validation() {
let held = basis(AnalysisSet::NONE, true);
let mut asked = held.clone();
asked.scope.follow_symlinks = true;
let request = request_with(&[ViewSpec::Summary], Selection::default(), asked);
let refusal = RequestError::ScopeUnsupported {
axis: ScopeAxis::FollowSymlinks,
reason: ScopeAxis::FollowSymlinks.reason(),
};
assert_eq!(request.validate(), Err(refusal.clone()));
assert_eq!(request.validate_read(&held), Err(refusal));
assert_eq!(
ScanConfig { follow_symlinks: true, ..ScanConfig::default() }
.unsupported_axis()
.expect("no build follows symbolic links")
.reason(),
ScopeAxis::FollowSymlinks.reason()
);
request_with(&[ViewSpec::Summary], Selection::default(), held)
.validate()
.expect("a scope this build honours is not refused");
}
#[test]
fn a_scope_that_observes_no_control_state_refuses_selection_by_ignored_state() {
let exclude = Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() };
let only = Selection { ignored: IgnoredEntries::Only, ..Selection::default() };
let blind = basis(AnalysisSet::NONE, false);
let refused = request_with(&[ViewSpec::Summary], exclude.clone(), blind.clone())
.validate()
.expect_err("no entry can be shown to be ignored");
assert_eq!(
refused.message(&AxisNames::FLAGS),
"--ignored=exclude needs .gitignore classification, and --no-gitignore turned it \
off; drop one of them"
);
let refused = request_with(&[ViewSpec::Summary], only, blind.clone())
.validate()
.expect_err("no entry can be shown to be ignored");
assert_eq!(
refused.message(&AxisNames::FIELDS),
"ignored=only needs .gitignore classification, and read_controls turned it off; \
drop one of them"
);
request_with(&[ViewSpec::Summary], exclude, basis(AnalysisSet::NONE, true))
.validate()
.expect("an observing scope can select by ignored state");
request_with(&[ViewSpec::Summary], Selection::default(), blind)
.validate()
.expect("admitting every entry needs no classification");
}
#[test]
fn a_read_is_refused_by_a_holder_of_other_content() {
let request = built(&RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) });
let held = basis(AnalysisSet::NONE, true);
assert_eq!(
request.validate_read(&held),
Err(RequestError::ContentMismatch {
held: AnalysisSet::NONE,
requested: AnalysisSet::NONE.with_lines(),
})
);
assert_eq!(
request.validate_read(&basis(AnalysisSet::ALL, true)),
Err(RequestError::ContentMismatch {
held: AnalysisSet::ALL,
requested: AnalysisSet::NONE.with_lines(),
})
);
request
.validate_read(&basis(AnalysisSet::NONE.with_lines(), true))
.expect("equal content serves");
let exclude = built(&reading(ReadSpec { ignored: Some("exclude"), ..ReadSpec::new() }));
assert_eq!(
exclude.validate_read(&basis(AnalysisSet::NONE, false)),
Err(RequestError::IgnoredWithoutObservation(IgnoredEntries::Exclude))
);
let documents = request_with(
&[ViewSpec::Documents],
Selection::default(),
basis(AnalysisSet::NONE.with_words(), true),
);
assert!(matches!(
documents.validate_read(&basis(AnalysisSet::NONE, true)),
Err(RequestError::ContentMismatch { .. })
));
}
#[test]
fn the_basis_a_retained_index_holds_is_what_it_observed() {
let blind =
crate::Index::new_with_scope("/root", crate::test_support::not_observing_controls());
let held = Basis::held_by(&blind);
assert_eq!(held.root, Path::new("/root"));
assert!(!held.scope.read_controls, "an index that read no rule says so");
assert_eq!(held.content, AnalysisSet::NONE, "a metadata index holds no analyzer");
let observing =
crate::Index::new_with_scope("/root", crate::test_support::observing_controls());
assert!(Basis::held_by(&observing).scope.read_controls);
let tight = ControlLimits { budget: Some(4_096), line_limit: None };
let narrow = crate::Index::new_with_config(
"/root",
&ScanConfig { read_controls: true, control_limits: tight, ..ScanConfig::default() },
);
assert_eq!(Basis::held_by(&narrow).scope.control_limits, tight);
assert_ne!(tight, Request::DEFAULTS.control_limits, "the fixture must differ");
assert_eq!(
Basis::held_by(&blind).scope.control_limits,
Request::DEFAULTS.control_limits,
"a scan that read no rule applied none, and names the table's"
);
let request =
built(&RequestSpec { read_controls: Some(false), ..RequestSpec::new(root()) });
request
.validate_read(&Basis::held_by(&blind))
.expect("the index's own basis answers a request built the same way");
}
#[test]
fn a_watch_refuses_what_it_cannot_keep_current() {
let one_shot = Delivery {
stale_ok: false,
cache: CachePolicy::Auto,
cache_path: None,
accept_partial: false,
watch: None,
workers: Workers::default(),
batch_size: ScanConfig::default().batch_size,
order: crate::ScanOrder::default(),
};
let watching = Delivery { watch: Some(WatchDelivery::default()), ..one_shot.clone() };
let cases = [
(
RequestSpec { scan_depth: Some("2"), ..RequestSpec::new(root()) },
RequestError::WatchScope,
),
(
RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) },
RequestError::WatchScope,
),
(
RequestSpec { analyze: Some("lines"), ..RequestSpec::new(root()) },
RequestError::WatchContent,
),
];
for (spec, expected) in cases {
let request = built(&spec);
assert_eq!(request.validate_delivery(&watching), Err(expected));
request.validate_delivery(&one_shot).expect("a one-shot delivers all three");
}
let plain = built(&RequestSpec::new(root()));
plain.validate_delivery(&watching).expect("a full-scope metadata watch is deliverable");
assert_eq!(
plain.validate_delivery(&Delivery { stale_ok: true, ..watching.clone() }),
Err(RequestError::WatchCacheOnly),
"nothing verifies the window between the snapshot and the start of the watch"
);
plain
.validate_delivery(&Delivery { stale_ok: true, ..one_shot })
.expect("a one-shot report is exactly what a snapshot answers");
}
#[test]
fn the_watch_rules_speak_in_one_order() {
let cache_only_watch = Delivery {
cache: CachePolicy::Auto,
stale_ok: true,
cache_path: None,
accept_partial: false,
watch: Some(WatchDelivery::default()),
workers: Workers::default(),
batch_size: ScanConfig::default().batch_size,
order: crate::ScanOrder::default(),
};
let everything = RequestSpec {
scan_depth: Some("2"),
analyze: Some("lines"),
..RequestSpec::new(root())
};
let steps = [
(everything, RequestError::WatchScope),
(RequestSpec { scan_depth: None, ..everything }, RequestError::WatchContent),
(
RequestSpec { scan_depth: None, analyze: None, ..everything },
RequestError::WatchCacheOnly,
),
];
for (spec, expected) in steps {
assert_eq!(built(&spec).validate_delivery(&cache_only_watch), Err(expected));
}
built(&RequestSpec::new(root()))
.validate_delivery(&Delivery { stale_ok: false, ..cache_only_watch })
.expect("nothing left to refuse");
}
#[cfg(unix)]
#[test]
fn a_watch_refuses_one_filesystem_where_the_build_honors_it() {
let watch = Delivery {
stale_ok: false,
cache: CachePolicy::Auto,
cache_path: None,
accept_partial: false,
watch: Some(WatchDelivery::default()),
workers: Workers::default(),
batch_size: ScanConfig::default().batch_size,
order: crate::ScanOrder::default(),
};
let spec = RequestSpec { one_filesystem: true, ..RequestSpec::new(root()) };
let request = built(&spec);
request.validate().expect("one filesystem is honored on this build");
assert_eq!(request.validate_delivery(&watch), Err(RequestError::WatchScope));
}
#[test]
fn a_request_is_refused_past_the_views_one_report_carries() {
let mut request = built(&RequestSpec::new(root()));
request.query.views = vec![ViewSpec::Summary; crate::MAX_REPORT_VIEWS];
request.validate().expect("the limit itself is accepted");
request.query.omitted_views = vec![ViewSpec::Documents];
assert_eq!(
request.validate(),
Err(RequestError::ViewLimit {
attempted: crate::MAX_REPORT_VIEWS + 1,
limit: crate::MAX_REPORT_VIEWS,
})
);
}
}