use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::classify::{Classification, ContentFamily, DetectionConfidence, DetectionSource};
use crate::content::{
AnalysisSet, CodeMetrics, ContentDetection, ContentProvenance, CoverageReason, FileAnalysis,
LogicalWordStats, MetricDef,
};
use crate::control::ControlCoverage;
use crate::engine_contract::{EntryKind, ScanScope};
use crate::index::{EntryId, ExtTally, Index, RollUpScalars};
use crate::query::query_request::{Basis, Request};
use crate::query::query_selection::{
Bound, IgnoredEntries, NameIdentity, Selection, ShareThreshold, SizeMetric, SortKey,
};
use crate::query::{Rejection, ReportProvenance, TreeStatus, query_subtrees};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ViewSpec {
List,
Tree,
Types,
Extensions,
Families,
Languages,
Code,
Documents,
Files,
Largest,
Recent,
Summary,
}
impl ViewSpec {
fn default_sort(self) -> SortKey {
match self {
Self::List
| Self::Tree
| Self::Types
| Self::Extensions
| Self::Families
| Self::Languages
| Self::Code
| Self::Documents
| Self::Summary
| Self::Largest => SortKey::Size,
Self::Files => SortKey::Name,
Self::Recent => SortKey::Mtime,
}
}
const fn default_limit(self) -> Bound {
match self {
Self::Largest | Self::Recent => Bound::Limit(20),
_ => Bound::All,
}
}
const fn default_depth(self) -> Bound {
match self {
Self::Tree => Bound::Limit(5),
_ => Bound::All,
}
}
const fn files_only(self) -> bool {
matches!(self, Self::Largest | Self::Recent)
}
pub const ALL: [Self; 12] = [
Self::List,
Self::Summary,
Self::Tree,
Self::Families,
Self::Types,
Self::Extensions,
Self::Languages,
Self::Code,
Self::Documents,
Self::Largest,
Self::Recent,
Self::Files,
];
pub fn parse(value: &str) -> Result<Self, String> {
match value.trim().to_ascii_lowercase().as_str() {
"list" => Ok(Self::List),
"tree" => Ok(Self::Tree),
"types" => Ok(Self::Types),
"extensions" => Ok(Self::Extensions),
"families" => Ok(Self::Families),
"languages" => Ok(Self::Languages),
"code" => Ok(Self::Code),
"documents" => Ok(Self::Documents),
"largest" => Ok(Self::Largest),
"recent" => Ok(Self::Recent),
"files" => Ok(Self::Files),
"summary" => Ok(Self::Summary),
_ => Err(format!("expected one of {}", Self::vocabulary())),
}
}
pub fn vocabulary() -> String {
let mut names: Vec<&str> = Self::ALL.iter().map(|view| view.label()).collect();
names.push("full");
names.join(", ")
}
pub const fn label(self) -> &'static str {
match self {
Self::List => "list",
Self::Tree => "tree",
Self::Types => "types",
Self::Extensions => "extensions",
Self::Families => "families",
Self::Languages => "languages",
Self::Code => "code",
Self::Documents => "documents",
Self::Largest => "largest",
Self::Recent => "recent",
Self::Files => "files",
Self::Summary => "summary",
}
}
pub const fn default_for(analysis: AnalysisSet) -> Self {
match (analysis.includes_code(), analysis.includes_words()) {
(true, _) => Self::Code,
(false, true) => Self::Documents,
(false, false) if analysis.is_enabled() => Self::Families,
(false, false) => Self::List,
}
}
pub fn resolve(
spec: Option<&str>,
analysis: AnalysisSet,
label: &str,
) -> Result<(Vec<Self>, Vec<Self>), String> {
Self::resolve_rejecting(spec, analysis).map_err(|rejection| rejection.labeled(label))
}
pub(crate) fn resolve_rejecting(
spec: Option<&str>,
analysis: AnalysisSet,
) -> Result<(Vec<Self>, Vec<Self>), Rejection> {
let Some(spec) = spec else {
return Ok((
if analysis.includes_code() && analysis.includes_words() {
vec![Self::Code, Self::Documents]
} else {
vec![Self::default_for(analysis)]
},
Vec::new(),
));
};
let mut parsed: Vec<Self> = Vec::new();
let mut full_seen = false;
for raw in spec.split(',') {
let token = raw.trim();
if token.is_empty() {
return Err(Rejection::new(spec, "empty entry in the list"));
}
if token.eq_ignore_ascii_case("full") {
if full_seen || !parsed.is_empty() {
return Err(Rejection::new("full", Self::FULL_IS_EXCLUSIVE));
}
full_seen = true;
continue;
}
if full_seen {
return Err(Rejection::new("full", Self::FULL_IS_EXCLUSIVE));
}
let view = Self::parse(token).map_err(|expected| Rejection::new(token, expected))?;
if parsed.contains(&view) {
return Err(Rejection::new(spec, format!("{token:?} appears more than once")));
}
parsed.push(view);
}
if full_seen {
return Ok(Self::full_report(analysis));
}
Ok((parsed, Vec::new()))
}
pub const FULL_IS_EXCLUSIVE: &'static str =
"it names the whole report and cannot be combined with another view";
pub fn full_report(analysis: AnalysisSet) -> (Vec<Self>, Vec<Self>) {
Self::ALL
.into_iter()
.filter(|view| {
view.is_summary_view()
&& (!analysis.includes_code() || !matches!(view, Self::Languages))
})
.partition(|view| {
(!matches!(view, Self::Documents) || analysis.is_enabled())
&& (!matches!(view, Self::Code) || analysis.includes_code())
})
}
pub const fn is_summary_view(self) -> bool {
!matches!(self, Self::List | Self::Files)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AxisNames {
pub view: &'static str,
pub format: &'static str,
pub analyze: &'static str,
pub control_budget: &'static str,
pub control_line_limit: &'static str,
pub exclude_ignored: &'static str,
pub only_ignored: &'static str,
pub read_controls: &'static str,
pub scan_depth: &'static str,
pub one_filesystem: &'static str,
pub follow_symlinks: &'static str,
pub include: &'static str,
pub modified_since: &'static str,
pub modified_before: &'static str,
pub kind: &'static str,
pub ignored: &'static str,
pub depth: &'static str,
pub min_share: &'static str,
pub breadth: &'static str,
pub limit: &'static str,
pub sort: &'static str,
pub size: &'static str,
pub words_per_page: &'static str,
pub cache: &'static str,
pub stale_ok: &'static str,
pub watch: &'static str,
}
impl AxisNames {
pub const FLAGS: Self = Self {
view: "--view",
format: "--format",
analyze: "--analyze",
control_budget: "--gitignore-budget",
control_line_limit: "--gitignore-line-limit",
exclude_ignored: "--ignored=exclude",
only_ignored: "--ignored=only",
read_controls: "--no-gitignore",
scan_depth: "--scan-depth",
one_filesystem: "--one-filesystem",
follow_symlinks: "follow_symlinks",
include: "--include",
modified_since: "--modified-since",
modified_before: "--modified-before",
kind: "--kind",
ignored: "--ignored",
depth: "--depth",
min_share: "--min-share",
breadth: "--breadth",
limit: "--limit",
sort: "--sort",
size: "--size",
words_per_page: "--words-per-page",
cache: "--cache",
stale_ok: "--stale-ok",
watch: "--watch",
};
pub const FIELDS: Self = Self {
view: "view",
format: "format",
analyze: "analyze",
control_budget: "control_budget",
control_line_limit: "control_line_limit",
exclude_ignored: "ignored=exclude",
only_ignored: "ignored=only",
read_controls: "read_controls",
scan_depth: "max_depth",
one_filesystem: "one_filesystem",
follow_symlinks: "follow_symlinks",
include: "include",
modified_since: "modified_since",
modified_before: "modified_before",
kind: "kind",
ignored: "ignored",
depth: "depth",
min_share: "min_share",
breadth: "breadth",
limit: "limit",
sort: "sort",
size: "size",
words_per_page: "words_per_page",
cache: "cache policy",
stale_ok: "stale_ok",
watch: "watch",
};
}
impl Default for AxisNames {
fn default() -> Self {
Self::FIELDS
}
}
#[derive(Clone, Debug)]
pub struct Query {
pub selection: Selection,
pub views: Vec<ViewSpec>,
pub format: crate::report_format::Format,
pub omitted_views: Vec<ViewSpec>,
pub axes: &'static AxisNames,
pub words_per_page: u64,
}
impl Default for Query {
fn default() -> Self {
Self {
selection: Selection::default(),
views: Vec::new(),
format: crate::report_format::Format::Text,
omitted_views: Vec::new(),
axes: &AxisNames::FIELDS,
words_per_page: crate::query::Request::DEFAULTS.words_per_page,
}
}
}
impl Query {
pub fn tree_for(&self, view: ViewSpec) -> bool {
use crate::report_format::Format;
match view {
ViewSpec::List => matches!(self.format, Format::Text | Format::Tree),
ViewSpec::Tree => !matches!(self.format, Format::Paths | Format::Long),
ViewSpec::Files => self.format == Format::Tree,
_ => false,
}
}
pub(crate) fn needs_selection_walk(&self) -> bool {
!self.selection.is_unfiltered()
|| self.views.iter().any(|view| {
matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
&& !self.tree_for(*view)
})
}
pub fn limit_for(&self, view: ViewSpec) -> Bound {
self.selection.limit.unwrap_or_else(|| {
if self.tree_for(view) {
ViewSpec::Tree.default_limit()
} else if view == ViewSpec::Tree {
Bound::All
} else {
view.default_limit()
}
})
}
pub fn depth_for(&self, view: ViewSpec) -> Bound {
self.selection.depth.unwrap_or_else(|| {
if self.tree_for(view) { ViewSpec::Tree.default_depth() } else { view.default_depth() }
})
}
pub fn breadth_for(&self) -> Bound {
self.selection.breadth.unwrap_or(Bound::All)
}
pub fn min_share_for(&self) -> ShareThreshold {
self.selection.min_share.clone().unwrap_or_else(ShareThreshold::one_percent)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ReportSource {
ColdScan,
WarmRevalidate,
CacheOnly,
}
#[derive(Clone, Debug)]
pub struct TreeNode {
pub path: PathBuf,
pub name: String,
pub kind: EntryKind,
pub entry_ignored: Option<bool>,
pub bytes: u64,
pub allocated: u64,
pub files: u64,
pub dirs: u64,
pub ignored: Option<IgnoredTally>,
pub newest_mtime_ns: Option<i64>,
pub children: Vec<TreeNode>,
pub omissions: Vec<TreeOmission>,
pub truncated: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TreeOmissionReason {
Share,
Breadth,
Depth,
Rows,
}
impl TreeOmissionReason {
pub const fn label(self) -> &'static str {
match self {
Self::Share => "share",
Self::Breadth => "breadth",
Self::Depth => "depth",
Self::Rows => "rows",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeOmission {
pub reason: TreeOmissionReason,
pub entries: usize,
pub files: Option<u64>,
pub bytes: Option<u64>,
pub allocated: Option<u64>,
pub ignored: Option<IgnoredSize>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct IgnoredSize {
pub bytes: u64,
pub allocated: u64,
}
impl IgnoredSize {
fn from_tally(tally: IgnoredTally) -> Self {
Self { bytes: tally.bytes, allocated: tally.allocated }
}
fn checked_add(self, other: Self) -> Option<Self> {
Some(Self {
bytes: self.bytes.checked_add(other.bytes)?,
allocated: self.allocated.checked_add(other.allocated)?,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRemainder {
pub files: Option<u64>,
pub bytes: Option<u64>,
pub allocated: Option<u64>,
pub ignored: Option<IgnoredSize>,
pub reasons: Vec<TreeOmissionReason>,
}
impl TreeRemainder {
pub fn from_tree(root: Option<&TreeNode>, omissions: &[TreeOmission]) -> Option<Self> {
let mut result = Self {
files: Some(0),
bytes: Some(0),
allocated: Some(0),
ignored: Some(IgnoredSize::default()),
reasons: Vec::new(),
};
let mut add = |omission: &TreeOmission| {
result.files = result.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
result.bytes = result.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
result.allocated =
result.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
result.ignored =
result.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
if !result.reasons.contains(&omission.reason) {
result.reasons.push(omission.reason);
}
};
let boundary = root.map_or(omissions, |root| root.omissions.as_slice());
for omission in boundary {
add(omission);
}
result.reasons.sort_by_key(|why| match why {
TreeOmissionReason::Share => 0,
TreeOmissionReason::Depth => 1,
TreeOmissionReason::Breadth => 2,
TreeOmissionReason::Rows => 3,
});
(!result.reasons.is_empty()).then_some(result)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeDisplayLimits {
pub depth: Bound,
pub min_share: ShareThreshold,
pub breadth: Bound,
pub rows: Bound,
}
impl Drop for TreeNode {
fn drop(&mut self) {
let mut pending = std::mem::take(&mut self.children);
while let Some(mut node) = pending.pop() {
pending.extend(std::mem::take(&mut node.children));
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct IgnoredTally {
pub files: u64,
pub dirs: u64,
pub bytes: u64,
pub allocated: u64,
}
impl IgnoredTally {
pub(crate) fn between(all: RollUpScalars, unignored: RollUpScalars) -> Self {
Self {
files: all.files.saturating_sub(unignored.files),
dirs: all.dirs.saturating_sub(unignored.dirs),
bytes: all.bytes.saturating_sub(unignored.bytes),
allocated: all.allocated.saturating_sub(unignored.allocated),
}
}
fn add(&mut self, other: Self) {
self.files = self.files.saturating_add(other.files);
self.dirs = self.dirs.saturating_add(other.dirs);
self.bytes = self.bytes.saturating_add(other.bytes);
self.allocated = self.allocated.saturating_add(other.allocated);
}
}
#[derive(Clone, Debug)]
pub struct TypeRow {
pub extension: String,
pub files: u64,
pub bytes: u64,
pub allocated: u64,
pub ignored: Option<IgnoredTally>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MetricGroup {
Type,
Family,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct MetricShare {
pub numerator: u64,
pub denominator: u64,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ShareMetric {
ApparentBytes,
AllocatedBytes,
CodeLines,
DocumentWords,
RawWords,
}
impl ShareMetric {
pub const fn as_str(self) -> &'static str {
match self {
Self::ApparentBytes => "apparent_bytes",
Self::AllocatedBytes => "allocated_bytes",
Self::CodeLines => "code_lines",
Self::DocumentWords => "document_words",
Self::RawWords => "raw_words",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct ReportMetricValues {
pub physical_lines: Option<u64>,
pub blank_lines: Option<u64>,
pub nonblank_lines: Option<u64>,
pub raw_words: Option<u64>,
pub code_lines: Option<u64>,
pub comment_lines: Option<u64>,
pub code_blank_lines: Option<u64>,
pub logical_words: Option<u64>,
pub paragraphs: Option<u64>,
pub visible_words: Option<u64>,
pub visible_logical_words: Option<u64>,
pub document_words: Option<u64>,
}
impl ReportMetricValues {
fn for_analysis(analysis: AnalysisSet) -> Self {
let lines = analysis.is_enabled().then_some(0);
let code = analysis.includes_code().then_some(0);
let words = analysis.includes_words().then_some(0);
Self {
physical_lines: lines,
blank_lines: lines,
nonblank_lines: lines,
raw_words: lines,
code_lines: code,
comment_lines: code,
code_blank_lines: code,
logical_words: words,
paragraphs: words,
visible_words: words,
visible_logical_words: words,
document_words: words,
}
}
fn add_assign(&mut self, other: &Self) {
add_optional(&mut self.physical_lines, other.physical_lines);
add_optional(&mut self.blank_lines, other.blank_lines);
add_optional(&mut self.nonblank_lines, other.nonblank_lines);
add_optional(&mut self.raw_words, other.raw_words);
add_optional(&mut self.code_lines, other.code_lines);
add_optional(&mut self.comment_lines, other.comment_lines);
add_optional(&mut self.code_blank_lines, other.code_blank_lines);
add_optional(&mut self.paragraphs, other.paragraphs);
add_optional(&mut self.visible_words, other.visible_words);
}
}
fn add_optional(total: &mut Option<u64>, value: Option<u64>) {
if let (Some(total), Some(value)) = (total, value) {
*total = total.saturating_add(value);
}
}
#[derive(Clone, Debug)]
pub struct MetricRow {
pub analysis: AnalysisSet,
pub id: String,
pub family: ContentFamily,
pub files: u64,
pub bytes: u64,
pub allocated: u64,
pub analyzed_files: u64,
pub metrics: ReportMetricValues,
pub(crate) logical_word_stats: LogicalWordStats,
pub(crate) visible_logical_word_stats: LogicalWordStats,
pub document_raw_words: u64,
pub document_word_stats: LogicalWordStats,
pub document_metric_files: u64,
pub coverage: BTreeMap<CoverageReason, u64>,
pub lines_coverage: BTreeMap<CoverageReason, u64>,
pub code_coverage: Option<BTreeMap<CoverageReason, u64>>,
pub words_coverage: Option<BTreeMap<CoverageReason, u64>>,
pub detection_sources: BTreeMap<DetectionSource, u64>,
pub detection_confidence: BTreeMap<DetectionConfidence, u64>,
pub generated_files: u64,
pub vendored_files: u64,
pub documentation_files: u64,
pub share: MetricShare,
}
impl MetricRow {
pub fn metric_value(&self, metric: &MetricDef) -> Option<u64> {
if !self.analysis.contains(metric.owner) {
return None;
}
match metric.name {
"physical_lines" => self.metrics.physical_lines,
"blank_lines" => self.metrics.blank_lines,
"nonblank_lines" => self.metrics.nonblank_lines,
"raw_words" => self.metrics.raw_words,
"code_lines" => self.metrics.code_lines,
"comment_lines" => self.metrics.comment_lines,
"code_blank_lines" => self.metrics.code_blank_lines,
"logical_words" => self.metrics.logical_words,
"paragraphs" => self.metrics.paragraphs,
"visible_words" => self.metrics.visible_words,
"visible_logical_words" => self.metrics.visible_logical_words,
"document_words" => self.metrics.document_words,
_ => None,
}
}
fn finish_derived_metrics(&mut self) {
if self.analysis.includes_words() {
self.metrics.logical_words = Some(self.logical_word_stats.logical_words());
self.metrics.visible_logical_words =
Some(self.visible_logical_word_stats.logical_words());
self.metrics.document_words = Some(self.document_word_stats.logical_words());
}
}
}
#[derive(Clone, Debug)]
pub struct MetricSummary {
pub group: MetricGroup,
pub total: MetricRow,
pub rows: Vec<MetricRow>,
pub total_rows: usize,
pub share_omitted: usize,
pub share_metric: ShareMetric,
pub words_per_page: u64,
}
#[derive(Clone, Debug, Default)]
pub struct CodeTally {
pub source_files: u64,
pub analyzed_files: u64,
pub metrics: CodeMetrics,
pub coverage: BTreeMap<CoverageReason, u64>,
pub missing_records: u64,
}
impl CodeTally {
fn add_file(&mut self, record: Option<&crate::content::FileAnalysis>) {
self.source_files = self.source_files.saturating_add(1);
match record.and_then(|record| record.code) {
Some(outcome) => {
*self.coverage.entry(outcome.coverage()).or_default() += 1;
if let Some(value) = outcome.value() {
self.analyzed_files = self.analyzed_files.saturating_add(1);
self.metrics.code_lines =
self.metrics.code_lines.saturating_add(value.code_lines);
self.metrics.comment_lines =
self.metrics.comment_lines.saturating_add(value.comment_lines);
self.metrics.code_blank_lines =
self.metrics.code_blank_lines.saturating_add(value.code_blank_lines);
}
}
None => self.missing_records = self.missing_records.saturating_add(1),
}
}
}
#[derive(Clone, Debug)]
pub struct CodeLanguageRow {
pub language: String,
pub selected: CodeTally,
pub non_ignored: Option<CodeTally>,
pub ignored: Option<CodeTally>,
pub unknown: CodeTally,
pub share: MetricShare,
}
#[derive(Clone, Debug)]
pub struct CodeOverview {
pub population: IgnoredEntries,
pub selected: CodeTally,
pub non_ignored: Option<CodeTally>,
pub ignored: Option<CodeTally>,
pub unknown: CodeTally,
pub unclassified_files: u64,
pub analyzed_languages: u64,
pub total_languages: usize,
pub share_omitted: usize,
pub languages: Vec<CodeLanguageRow>,
pub share_metric: ShareMetric,
}
#[derive(Clone, Debug)]
pub struct ContentReportMetadata {
pub profile: AnalysisSet,
pub provenance: ContentProvenance,
}
#[derive(Clone, Debug)]
pub struct FileRow {
pub path: PathBuf,
pub kind: EntryKind,
pub bytes: u64,
pub allocated: u64,
pub mtime_ns: i64,
pub files: Option<u64>,
pub dirs: Option<u64>,
pub complete: Option<bool>,
pub age_ns: Option<i128>,
pub ignored: Option<bool>,
pub sort_value: Option<u64>,
pub classification: Option<ContentDetection>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SummaryRow {
pub files: u64,
pub dirs: u64,
pub bytes: u64,
pub allocated: u64,
pub ignored: Option<IgnoredTally>,
pub newest_mtime_ns: Option<i64>,
}
#[derive(Clone, Debug)]
pub enum Section {
Code(Box<CodeOverview>),
Tree {
view: ViewSpec,
limits: TreeDisplayLimits,
root: Option<Box<TreeNode>>,
omissions: Vec<TreeOmission>,
},
Extensions {
rows: Vec<TypeRow>,
total: usize,
share_omitted: usize,
},
Metrics {
view: ViewSpec,
summary: Box<MetricSummary>,
},
Files {
view: ViewSpec,
rows: Vec<FileRow>,
total: usize,
},
Summary(SummaryRow),
}
impl Section {
pub fn view(&self) -> ViewSpec {
match self {
Self::Code(_) => ViewSpec::Code,
Self::Extensions { .. } => ViewSpec::Extensions,
Self::Tree { view, .. } | Self::Metrics { view, .. } | Self::Files { view, .. } => {
*view
}
Self::Summary(_) => ViewSpec::Summary,
}
}
}
#[derive(Clone, Debug)]
pub struct Report {
pub age_reference_ns: Option<i64>,
pub format: crate::report_format::Format,
pub status: TreeStatus,
pub provenance: ReportProvenance,
pub scope: ScanScope,
pub requested_analysis: AnalysisSet,
pub requested_views: Vec<ViewSpec>,
pub omitted_views: Vec<ViewSpec>,
pub root: PathBuf,
pub notes: Vec<String>,
pub tips: Vec<String>,
pub axes: &'static AxisNames,
pub size: SizeMetric,
pub sort_metric: Option<&'static str>,
pub analysis: Option<ContentReportMetadata>,
pub ignored_entries: IgnoredEntries,
pub ignore_rules: ControlCoverage,
pub sections: Vec<Section>,
}
pub(crate) fn display_notes(
query: &Query,
ignore_rules: &ControlCoverage,
) -> (Vec<String>, Vec<String>) {
let mut notes = Vec::new();
let mut tips = Vec::new();
if !query.omitted_views.is_empty() {
let names: Vec<&str> = query.omitted_views.iter().map(|view| view.label()).collect();
notes.push(format!("note: omitted {}: content analysis required", names.join(", ")));
let analysis = if query.omitted_views.contains(&ViewSpec::Code) { "code" } else { "lines" };
tips.push(format!("tip: include omitted views: add {} {analysis}", query.axes.analyze));
}
if let Some((note, tip)) = refused_controls_note(ignore_rules, query.axes) {
notes.push(note);
tips.extend(tip);
}
(notes, tips)
}
const REFUSED_DIRECTORIES_NAMED: usize = 5;
fn refused_controls_note(
ignore_rules: &ControlCoverage,
axes: &AxisNames,
) -> Option<(String, Option<String>)> {
use crate::control::ControlRefusalReason::{Budget, LineLimit};
let ControlCoverage::Observed(observed) = ignore_rules else {
return None;
};
if observed.is_complete() {
return None;
}
let every_listed = observed.lists_every_refusal();
let listed =
|reason| observed.refusals.iter().filter(|refusal| refusal.reason == reason).count();
let fired: Vec<_> = [Budget, LineLimit]
.into_iter()
.filter(|reason| {
listed(*reason) > 0 || (!every_listed && observed.limits.limit_for(*reason).is_some())
})
.collect();
let size = |reason| {
observed.limits.limit_for(reason).map(|bytes| {
crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX))
})
};
let over = |reason| {
let (lead, noun) = match reason {
Budget => ("over", "ignore-rule budget"),
LineLimit => ("with a line over", "line limit"),
};
size(reason).map_or_else(
|| format!("{lead} the {noun}"),
|size| format!("{lead} the {size} {noun}"),
)
};
let why = if every_listed {
let parts: Vec<String> =
fired.iter().map(|reason| format!("{} {}", listed(*reason), over(*reason))).collect();
parts.join(", ")
} else {
let parts: Vec<String> = fired.iter().map(|reason| over(*reason)).collect();
parts.join(" or ")
};
let shown = observed.refusals.len().min(REFUSED_DIRECTORIES_NAMED);
let mut directories: Vec<String> = observed.refusals[..shown]
.iter()
.map(|refusal| match refusal.path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.display().to_string(),
_ => ".".to_string(),
})
.collect();
let unnamed = observed.refused.saturating_sub(u64::try_from(shown).unwrap_or(u64::MAX));
if unnamed > 0 {
directories.push(format!("{} more", crate::report_format::human_count(unnamed)));
}
let raises: Vec<String> = fired
.iter()
.filter_map(|reason| {
let knob = match reason {
Budget => axes.control_budget,
LineLimit => axes.control_line_limit,
};
size(*reason).map(|size| format!("{knob} above {size}"))
})
.collect();
let remedy = match raises.as_slice() {
[] => None,
[raise] => {
Some(format!("tip: apply refused ignore files: raise {raise}, or set it to all"))
}
raises => Some(format!(
"tip: apply refused ignore files: raise {}, or set them to all",
raises.join(" and ")
)),
};
let files = crate::report_format::human_count(observed.refused);
let noun = if observed.refused == 1 { "file" } else { "files" };
Some((
format!(
"note: ignore classification incomplete: {files} ignore {noun} not applied ({why}); affected: {}",
directories.join(", ")
),
remedy,
))
}
pub fn report(
index: &Index,
request: &Request,
generated_at: std::time::SystemTime,
) -> crate::Result<Report> {
report_in(index, request, generated_at, NameIdentity::Native)
}
pub(crate) fn report_in(
index: &Index,
request: &Request,
generated_at: std::time::SystemTime,
identity: NameIdentity,
) -> crate::Result<Report> {
request.validate_read(&Basis::held_by(index)).map_err(crate::Error::InvalidRequest)?;
let query = &request.query;
let content = request.basis.content;
let needs_walk = query.needs_selection_walk();
let needs_tree_measurements = query.views.iter().any(|view| query.tree_for(*view))
&& !query.min_share_for().admits(0, 1)
&& (index.state().coverage != crate::Coverage::Complete
|| index.scope().max_depth.is_some());
let directories = (needs_tree_measurements
|| (needs_walk
&& (query.selection.kinds.is_empty()
|| query.selection.kinds.contains(&EntryKind::Dir))))
.then(|| query_subtrees::measure(index, &query.selection, identity));
let walked = needs_walk.then(|| walk(index, &query.selection, identity, directories.as_ref()));
let tree_measurements = directories.as_ref().filter(|_| needs_tree_measurements);
let row_consumers =
query.views.iter().copied().filter(|view| needs_unfiltered_entry_rows(*view)).count();
let unfiltered_rows = (walked.is_none() && row_consumers > 1).then(|| every_entry(index));
let metric_consumers =
query.views.iter().copied().filter(|view| needs_metric_resolution(*view)).count();
let mut shared_metric_summaries = (walked.is_none() && metric_consumers > 1).then(|| {
metric_summaries(
&query.views,
index,
query,
content,
unfiltered_rows
.as_deref()
.expect("multiple metric views share their unfiltered entry rows"),
)
});
let mut sections: Vec<Section> = query
.views
.iter()
.enumerate()
.map(|(position, view)| {
let shared_metric_summary =
shared_metric_summaries.as_mut().and_then(|summaries| summaries[position].take());
if let Some(summary) = shared_metric_summary {
return Section::Metrics { view: *view, summary: Box::new(summary) };
}
build_section(
*view,
index,
query,
content,
walked.as_ref(),
unfiltered_rows.as_deref(),
tree_measurements,
)
})
.collect();
let age_reference_ns = crate::query::system_time_to_nanos(request.now);
for section in &mut sections {
if let Section::Files { rows, .. } = section {
for row in rows {
row.age_ns = match row.complete {
Some(false) => None,
Some(true) | None => {
age_reference_ns.map(|now| i128::from(now) - i128::from(row.mtime_ns))
}
};
}
}
}
let ignore_rules = index.control_coverage();
let (mut notes, mut tips) = display_notes(query, &ignore_rules);
if content.is_enabled()
&& query.selection.sort.is_none_or(|sort| !matches!(sort, SortKey::Metric(_)))
&& !query.views.iter().any(|view| {
matches!(
view,
ViewSpec::Types
| ViewSpec::Families
| ViewSpec::Languages
| ViewSpec::Code
| ViewSpec::Documents
)
})
{
notes.push("note: requested analysis is not displayed by the selected views".to_owned());
tips.push(format!("tip: show analysis: {} families, languages, or full", query.axes.view));
}
if content.includes_words() {
let text_only = sections
.iter()
.filter_map(|section| match section {
Section::Metrics { summary, .. } => summary
.total
.words_coverage
.as_ref()?
.get(&crate::content::CoverageReason::TextOnly)
.copied(),
_ => None,
})
.max()
.unwrap_or(0);
if text_only > 0 {
let files = if text_only == 1 { "file" } else { "files" };
notes.push(format!(
"note: {text_only} Markdown {files} over {} MiB counted as plain text: every \
word counted visible, paragraphs are blank-line runs",
crate::content::MARKDOWN_EXACT_BYTES / (1024 * 1024)
));
}
}
tips.extend(retained_refusals_tip(query, &ignore_rules));
if tree_measurements.is_some_and(|values| values.values().any(|value| !value.complete)) {
notes.push("note: incomplete subtrees remain visible below the size threshold".to_owned());
}
if index.observes_controls() && !index.ignored_classification_complete_below(Path::new("")) {
notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
}
Ok(Report {
age_reference_ns,
format: query.format,
notes,
tips,
axes: query.axes,
status: TreeStatus::of(index, request),
provenance: ReportProvenance::of(index, content, generated_at),
scope: index.scope(),
requested_analysis: content,
requested_views: query.views.clone(),
omitted_views: query.omitted_views.clone(),
root: index.root_path().to_path_buf(),
size: query.selection.size,
sort_metric: match query.selection.sort {
Some(SortKey::Metric(name)) => Some(name),
_ => None,
},
analysis: index.content().and_then(|held| {
let wanted = index.content_identity(content);
let projected = held.admit(&wanted)?;
Some(ContentReportMetadata {
profile: projected.identity().analysis,
provenance: projected.identity().record_provenance(),
})
}),
ignored_entries: query.selection.ignored,
ignore_rules,
sections,
})
}
const UNVERIFIED_IGNORED_NOTE: &str =
"note: gitignored subtotals are unavailable where governing rules could not be verified";
fn retained_refusals_tip(query: &Query, ignore_rules: &ControlCoverage) -> Option<String> {
matches!(ignore_rules, ControlCoverage::Observed(observed) if observed.refusals.len() > REFUSED_DIRECTORIES_NAMED)
.then(|| format!("tip: show retained ignore-file details: {} json", query.axes.format))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn report_summary(
root: &Path,
scope: ScanScope,
request: &Request,
summary: SummaryRow,
ignore_rules: ControlCoverage,
ignored_unverified: bool,
status: TreeStatus,
provenance: ReportProvenance,
) -> Report {
let query = &request.query;
let (mut notes, mut tips) = display_notes(query, &ignore_rules);
tips.extend(retained_refusals_tip(query, &ignore_rules));
if ignored_unverified {
notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
}
Report {
age_reference_ns: crate::query::system_time_to_nanos(request.now),
format: query.format,
notes,
tips,
axes: query.axes,
status,
provenance,
scope,
requested_analysis: AnalysisSet::NONE,
requested_views: query.views.clone(),
omitted_views: query.omitted_views.clone(),
root: root.to_path_buf(),
size: query.selection.size,
sort_metric: match query.selection.sort {
Some(SortKey::Metric(name)) => Some(name),
_ => None,
},
analysis: None,
ignored_entries: query.selection.ignored,
ignore_rules,
sections: vec![Section::Summary(summary)],
}
}
struct Walked {
observed: bool,
per_directory: BTreeMap<EntryId, SummaryRow>,
by_ext: BTreeMap<String, ExtTally>,
ignored_by_ext: BTreeMap<String, ExtTally>,
rows: Vec<FileRow>,
members: Vec<FileRow>,
visible: BTreeSet<EntryId>,
visible_files: BTreeSet<EntryId>,
unknown_ignored: BTreeSet<EntryId>,
}
impl Walked {
fn summary_of(&self, id: EntryId) -> SummaryRow {
let mut row = self.per_directory.get(&id).copied().unwrap_or_default();
row.ignored = (self.observed && !self.unknown_ignored.contains(&id))
.then(|| row.ignored.unwrap_or_default());
row
}
}
fn unfiltered_summary(index: &Index, id: EntryId, path: &Path) -> SummaryRow {
let observed = index.observes_controls() && index.ignored_classification_complete_below(path);
let Some((all, unignored)) = index.partition_scalars_of(id) else {
return SummaryRow {
ignored: observed.then(IgnoredTally::default),
..SummaryRow::default()
};
};
SummaryRow {
ignored: observed.then(|| IgnoredTally::between(all, unignored)),
..summary_from_scalars(all)
}
}
fn walk(
index: &Index,
selection: &Selection,
identity: NameIdentity,
directories: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
) -> Walked {
debug_assert!(!index.is_folded(), "a folded index keeps too few files to be filtered");
let observed = index.observes_controls();
let mut walked = Walked {
observed,
per_directory: BTreeMap::new(),
by_ext: BTreeMap::new(),
ignored_by_ext: BTreeMap::new(),
rows: Vec::new(),
members: Vec::new(),
visible: BTreeSet::new(),
visible_files: BTreeSet::new(),
unknown_ignored: BTreeSet::new(),
};
debug_assert!(
observed || selection.ignored == IgnoredEntries::Include,
"a selection by ignored state over an unobserving index is refused before the walk"
);
let mut stack = vec![(EntryId::ROOT, PathBuf::new(), false, false)];
while let Some((id, path, expanded, covered)) = stack.pop() {
if expanded {
let mut total = walked.per_directory.remove(&id).unwrap_or_default();
if let Some(children) = index.children_of(id) {
for (_, child) in children {
if let Some(sub) = walked.per_directory.get(&child) {
let sub = *sub;
merge_summary(&mut total, &sub);
if walked.unknown_ignored.contains(&child) {
walked.unknown_ignored.insert(id);
}
}
}
}
if total.files > 0 || total.dirs > 0 {
walked.visible.insert(id);
}
walked.per_directory.insert(id, total);
continue;
}
stack.push((id, path.clone(), true, covered));
let Some(children) = index.children_of(id) else {
continue;
};
let children: Vec<(PathBuf, EntryId)> =
children.map(|(name, child)| (path.join(name), child)).collect();
for (child_path, child) in children {
let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
continue;
};
let file_name = child_path.file_name().unwrap_or_default();
let classification = index.ignored_classification_of(&child_path, child);
let ignored = classification.unwrap_or(false);
let classification_admitted =
classification.is_some() || selection.ignored == IgnoredEntries::Include;
let mut measured = *attrs;
let subtree = directories.and_then(|values| values.get(&child)).copied();
if let Some(subtree) = subtree {
measured.size = subtree.bytes;
measured.allocated = subtree.allocated;
measured.mtime_ns = subtree.mtime_ns;
}
let (pruned, matches) = query_subtrees::with_candidate(
&child_path,
kind,
measured,
ignored,
identity,
|candidate| {
(query_subtrees::pruned(selection, &candidate), selection.admits(&candidate))
},
);
if pruned {
continue;
}
let matches = matches
&& classification_admitted
&& (selection.modified.is_unbounded()
|| subtree.is_none_or(|subtree| subtree.complete));
let row = FileRow {
path: child_path.clone(),
kind,
bytes: measured.size,
allocated: measured.allocated,
mtime_ns: measured.mtime_ns,
files: subtree.map(|subtree| subtree.files),
dirs: subtree.map(|subtree| subtree.dirs),
complete: subtree.map(|subtree| subtree.complete),
age_ns: None,
ignored: classification,
sort_value: None,
classification: None,
};
if matches {
walked.rows.push(row.clone());
}
if matches || (covered && classification_admitted && selection.ignored.admits(ignored))
{
if classification.is_none() {
walked.unknown_ignored.insert(id);
}
if kind == EntryKind::File {
walked.members.push(row);
walked.visible_files.insert(child);
} else if kind == EntryKind::Dir {
walked.visible.insert(child);
}
if kind == EntryKind::File {
let own = walked.per_directory.entry(id).or_default();
own.files += 1;
own.bytes += attrs.size;
own.allocated += attrs.allocated;
own.newest_mtime_ns = Some(
own.newest_mtime_ns.map_or(attrs.mtime_ns, |seen| seen.max(attrs.mtime_ns)),
);
let bucket = crate::classify::ext_bucket(file_name);
if ignored {
own.ignored.get_or_insert_with(IgnoredTally::default).add(IgnoredTally {
files: 1,
dirs: 0,
bytes: attrs.size,
allocated: attrs.allocated,
});
let tally = walked.ignored_by_ext.entry(bucket.clone()).or_default();
tally.files += 1;
tally.bytes += attrs.size;
tally.allocated += attrs.allocated;
}
let tally = walked.by_ext.entry(bucket).or_default();
tally.files += 1;
tally.bytes += attrs.size;
tally.allocated += attrs.allocated;
} else if kind == EntryKind::Dir {
let own = walked.per_directory.entry(id).or_default();
own.dirs += 1;
if ignored {
own.ignored.get_or_insert_with(IgnoredTally::default).dirs += 1;
}
}
}
if kind == EntryKind::Dir {
stack.push((child, child_path, false, covered || matches));
}
}
}
walked
}
fn merge_summary(into: &mut SummaryRow, from: &SummaryRow) {
into.files += from.files;
into.dirs += from.dirs;
into.bytes += from.bytes;
into.allocated += from.allocated;
into.newest_mtime_ns = match (into.newest_mtime_ns, from.newest_mtime_ns) {
(Some(left), Some(right)) => Some(left.max(right)),
(left, right) => left.or(right),
};
if let Some(share) = from.ignored {
into.ignored.get_or_insert_with(IgnoredTally::default).add(share);
}
}
fn needs_unfiltered_entry_rows(view: ViewSpec) -> bool {
matches!(
view,
ViewSpec::Types
| ViewSpec::Families
| ViewSpec::Languages
| ViewSpec::Code
| ViewSpec::Documents
| ViewSpec::Files
| ViewSpec::Largest
| ViewSpec::Recent
)
}
fn needs_metric_resolution(view: ViewSpec) -> bool {
matches!(view, ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents)
}
fn entry_rows<'a>(
index: &Index,
walked: Option<&'a Walked>,
unfiltered_rows: Option<&'a [FileRow]>,
) -> Cow<'a, [FileRow]> {
match (walked, unfiltered_rows) {
(Some(walked), _) => Cow::Borrowed(&walked.rows),
(None, Some(rows)) => Cow::Borrowed(rows),
(None, None) => Cow::Owned(every_entry(index)),
}
}
fn build_section(
view: ViewSpec,
index: &Index,
query: &Query,
content: AnalysisSet,
walked: Option<&Walked>,
unfiltered_rows: Option<&[FileRow]>,
tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
) -> Section {
if query.tree_for(view) {
let (root, omissions) = tree_node(index, query, content, walked, tree_measurements);
let limits = TreeDisplayLimits {
depth: query.depth_for(view),
min_share: query.min_share_for(),
breadth: query.breadth_for(),
rows: query.limit_for(view),
};
return Section::Tree { view, limits, root: root.map(Box::new), omissions };
}
match view {
ViewSpec::Code => {
Section::Code(Box::new(code_overview(index, query, content, walked, unfiltered_rows)))
}
ViewSpec::Summary => Section::Summary(match walked {
None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
Some(walked) => walked.summary_of(EntryId::ROOT),
}),
ViewSpec::Extensions => {
let (rows, total, share_omitted) = extension_rows(index, query, walked);
Section::Extensions { rows, total, share_omitted }
}
ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents => {
Section::Metrics {
view,
summary: Box::new(metric_summary(
view,
index,
query,
content,
walked,
unfiltered_rows,
)),
}
}
ViewSpec::List
| ViewSpec::Tree
| ViewSpec::Files
| ViewSpec::Largest
| ViewSpec::Recent => {
let (rows, total) = file_rows(view, index, query, content, walked, unfiltered_rows);
Section::Files { view, rows, total }
}
}
}
fn summary_from_scalars(rollup: RollUpScalars) -> SummaryRow {
SummaryRow {
files: rollup.files,
dirs: rollup.dirs,
bytes: rollup.bytes,
allocated: rollup.allocated,
ignored: None,
newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
}
}
fn ignored_by_extension(
all: &BTreeMap<String, ExtTally>,
unignored: &BTreeMap<String, ExtTally>,
) -> BTreeMap<String, ExtTally> {
all.iter()
.filter_map(|(extension, tally)| {
let kept = unignored.get(extension).copied().unwrap_or_default();
let ignored = ExtTally {
files: tally.files.saturating_sub(kept.files),
bytes: tally.bytes.saturating_sub(kept.bytes),
allocated: tally.allocated.saturating_sub(kept.allocated),
};
(ignored.files > 0).then(|| (extension.clone(), ignored))
})
.collect()
}
fn extension_rows(
index: &Index,
query: &Query,
walked: Option<&Walked>,
) -> (Vec<TypeRow>, usize, usize) {
debug_assert!(!index.is_folded(), "a folded index keeps no extension tallies (H176)");
let observed = match walked {
Some(walked) => walked.observed && !walked.unknown_ignored.contains(&EntryId::ROOT),
None => {
index.observes_controls() && index.ignored_classification_complete_below(Path::new(""))
}
};
let (tallies, ignored): (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>) = match walked
{
None => match index.partition_total() {
Ok(partitions) => {
let ignored =
ignored_by_extension(&partitions.all.by_ext, &partitions.unignored.by_ext);
(partitions.all.by_ext, ignored)
}
Err(_not_observed) => (index.total().by_ext, BTreeMap::new()),
},
Some(walked) => (walked.by_ext.clone(), walked.ignored_by_ext.clone()),
};
let mut rows: Vec<TypeRow> = tallies
.into_iter()
.map(|(extension, tally)| {
let share = ignored.get(&extension).copied().unwrap_or_default();
TypeRow {
files: tally.files,
bytes: tally.bytes,
allocated: tally.allocated,
ignored: observed.then_some(IgnoredTally {
files: share.files,
dirs: 0,
bytes: share.bytes,
allocated: share.allocated,
}),
extension,
}
})
.collect();
let before_share = rows.len();
if let Some(threshold) = &query.selection.min_share {
let root = match walked {
None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
Some(walked) => walked.summary_of(EntryId::ROOT),
};
let denominator = match query.selection.size {
SizeMetric::Apparent => root.bytes,
SizeMetric::Allocated => root.allocated,
};
rows.retain(|row| {
threshold.admits(
match query.selection.size {
SizeMetric::Apparent => row.bytes,
SizeMetric::Allocated => row.allocated,
},
denominator,
)
});
}
sort_rows(
&mut rows,
query,
ViewSpec::Extensions,
SortAccessors {
size: |row: &TypeRow, metric| match metric {
SizeMetric::Apparent => row.bytes,
SizeMetric::Allocated => row.allocated,
},
count: |row: &TypeRow| row.files,
mtime: |_: &TypeRow| None,
name: borrowed_name(|row: &TypeRow| std::borrow::Cow::Borrowed(row.extension.as_str())),
content_metric: |_: &TypeRow, _: &MetricDef| None,
},
);
let total = truncate(&mut rows, query.limit_for(ViewSpec::Extensions));
(rows, total, before_share - total)
}
fn metric_summary(
view: ViewSpec,
index: &Index,
query: &Query,
content: AnalysisSet,
walked: Option<&Walked>,
unfiltered_rows: Option<&[FileRow]>,
) -> MetricSummary {
let mut accumulator = MetricAccumulator::new(view);
let files = walked.map_or_else(
|| entry_rows(index, None, unfiltered_rows),
|walked| Cow::Borrowed(walked.members.as_slice()),
);
let wanted = index.content_identity(content);
let held = index.content().and_then(|held| held.admit(&wanted));
for file in files.iter().filter(|row| row.kind == EntryKind::File) {
let cached = held.and_then(|content| content.file(&file.path));
let classification = index.classify(&file.path);
accumulator.push(content, file, cached, &classification);
}
accumulator.finish(query, content)
}
fn metric_summaries(
views: &[ViewSpec],
index: &Index,
query: &Query,
content: AnalysisSet,
rows: &[FileRow],
) -> Vec<Option<MetricSummary>> {
let mut accumulators = views
.iter()
.copied()
.filter(|view| needs_metric_resolution(*view))
.map(MetricAccumulator::new)
.collect::<Vec<_>>();
let wanted = index.content_identity(content);
let held = index.content().and_then(|held| held.admit(&wanted));
for file in rows.iter().filter(|row| row.kind == EntryKind::File) {
let cached = held.and_then(|content| content.file(&file.path));
let classification = index.classify(&file.path);
for accumulator in &mut accumulators {
accumulator.push(content, file, cached, &classification);
}
}
let mut finished =
accumulators.into_iter().map(|accumulator| accumulator.finish(query, content));
let summaries = views
.iter()
.map(|view| needs_metric_resolution(*view).then(|| finished.next().expect("metric view")))
.collect();
debug_assert!(finished.next().is_none());
summaries
}
struct MetricAccumulator {
view: ViewSpec,
group: MetricGroup,
grouped: BTreeMap<String, MetricRow>,
}
impl MetricAccumulator {
fn new(view: ViewSpec) -> Self {
debug_assert!(needs_metric_resolution(view));
let group =
if view == ViewSpec::Families { MetricGroup::Family } else { MetricGroup::Type };
Self { view, group, grouped: BTreeMap::new() }
}
fn push(
&mut self,
content: AnalysisSet,
file: &FileRow,
cached: Option<&FileAnalysis>,
classification: &Classification,
) {
let included = match self.view {
ViewSpec::Languages => classification.family == ContentFamily::Code,
ViewSpec::Documents => {
matches!(classification.family, ContentFamily::Prose | ContentFamily::Markup)
}
ViewSpec::Types | ViewSpec::Families => true,
ViewSpec::List
| ViewSpec::Tree
| ViewSpec::Extensions
| ViewSpec::Code
| ViewSpec::Files
| ViewSpec::Largest
| ViewSpec::Recent
| ViewSpec::Summary => false,
};
if !included {
return;
}
let id = match self.group {
MetricGroup::Type => classification.file_type.as_str().to_string(),
MetricGroup::Family => classification.family.as_str().to_string(),
};
let row = self.grouped.entry(id.clone()).or_insert_with(|| MetricRow {
analysis: content,
id,
family: classification.family,
files: 0,
bytes: 0,
allocated: 0,
analyzed_files: 0,
metrics: ReportMetricValues::for_analysis(content),
logical_word_stats: LogicalWordStats::default(),
visible_logical_word_stats: LogicalWordStats::default(),
document_raw_words: 0,
document_word_stats: LogicalWordStats::default(),
document_metric_files: 0,
coverage: BTreeMap::new(),
lines_coverage: BTreeMap::new(),
code_coverage: content.includes_code().then(BTreeMap::new),
words_coverage: content.includes_words().then(BTreeMap::new),
detection_sources: BTreeMap::new(),
detection_confidence: BTreeMap::new(),
generated_files: 0,
vendored_files: 0,
documentation_files: 0,
share: MetricShare::default(),
});
row.files = row.files.saturating_add(1);
row.bytes = row.bytes.saturating_add(file.bytes);
row.allocated = row.allocated.saturating_add(file.allocated);
let detection = cached.map_or(
(classification.source, classification.confidence, classification.flags),
|record| (record.detection.source, record.detection.confidence, record.detection.flags),
);
*row.detection_sources.entry(detection.0).or_default() += 1;
*row.detection_confidence.entry(detection.1).or_default() += 1;
row.generated_files = row.generated_files.saturating_add(u64::from(detection.2.generated));
row.vendored_files = row.vendored_files.saturating_add(u64::from(detection.2.vendored));
row.documentation_files =
row.documentation_files.saturating_add(u64::from(detection.2.documentation));
if let Some(record) = cached {
*row.lines_coverage.entry(record.lines.coverage()).or_default() += 1;
if let (Some(coverage), Some(outcome)) = (&mut row.code_coverage, record.code) {
*coverage.entry(outcome.coverage()).or_default() += 1;
}
if let (Some(coverage), Some(outcome)) = (&mut row.words_coverage, record.words) {
*coverage.entry(outcome.coverage()).or_default() += 1;
}
let selected = match self.view {
ViewSpec::Languages if content.includes_code() => {
record.code.map(|outcome| outcome.coverage())
}
ViewSpec::Documents if content.includes_words() => {
record.words.map(|outcome| outcome.coverage())
}
_ => None,
}
.unwrap_or(record.lines.coverage());
*row.coverage.entry(selected).or_default() += 1;
if selected == CoverageReason::Analyzed {
row.analyzed_files = row.analyzed_files.saturating_add(1);
}
if let Some(lines) = record.lines.value() {
add_optional(&mut row.metrics.physical_lines, Some(lines.physical_lines));
add_optional(&mut row.metrics.blank_lines, Some(lines.blank_lines));
add_optional(&mut row.metrics.nonblank_lines, Some(lines.nonblank_lines));
add_optional(&mut row.metrics.raw_words, Some(lines.raw_words));
row.document_raw_words = row.document_raw_words.saturating_add(lines.raw_words);
}
if let Some(code_metrics) = record.code.and_then(crate::content::AnalyzerOutcome::value)
{
add_optional(&mut row.metrics.code_lines, Some(code_metrics.code_lines));
add_optional(&mut row.metrics.comment_lines, Some(code_metrics.comment_lines));
add_optional(
&mut row.metrics.code_blank_lines,
Some(code_metrics.code_blank_lines),
);
}
if let Some(words) = record.words.and_then(crate::content::AnalyzerOutcome::value) {
add_optional(&mut row.metrics.paragraphs, Some(words.paragraphs));
add_optional(&mut row.metrics.visible_words, Some(words.visible_words));
row.logical_word_stats.add_assign(words.logical_word_stats);
row.visible_logical_word_stats.add_assign(words.visible_logical_word_stats);
row.document_metric_files = row.document_metric_files.saturating_add(1);
if classification.file_type.as_str() == "markdown" {
row.document_raw_words = row
.document_raw_words
.saturating_sub(record.lines.value().map_or(0, |lines| lines.raw_words));
row.document_raw_words =
row.document_raw_words.saturating_add(words.visible_words);
row.document_word_stats.add_assign(words.visible_logical_word_stats);
} else {
row.document_word_stats.add_assign(words.logical_word_stats);
}
}
}
}
fn finish(self, query: &Query, content: AnalysisSet) -> MetricSummary {
let Self { view, group, mut grouped } = self;
for row in grouped.values_mut() {
row.finish_derived_metrics();
}
let mut total = MetricRow {
analysis: content,
id: "total".to_string(),
family: ContentFamily::Unknown,
files: 0,
bytes: 0,
allocated: 0,
analyzed_files: 0,
metrics: ReportMetricValues::for_analysis(content),
logical_word_stats: LogicalWordStats::default(),
visible_logical_word_stats: LogicalWordStats::default(),
document_raw_words: 0,
document_word_stats: LogicalWordStats::default(),
document_metric_files: 0,
coverage: BTreeMap::new(),
lines_coverage: BTreeMap::new(),
code_coverage: content.includes_code().then(BTreeMap::new),
words_coverage: content.includes_words().then(BTreeMap::new),
detection_sources: BTreeMap::new(),
detection_confidence: BTreeMap::new(),
generated_files: 0,
vendored_files: 0,
documentation_files: 0,
share: MetricShare::default(),
};
for row in grouped.values() {
total.files = total.files.saturating_add(row.files);
total.bytes = total.bytes.saturating_add(row.bytes);
total.allocated = total.allocated.saturating_add(row.allocated);
total.analyzed_files = total.analyzed_files.saturating_add(row.analyzed_files);
total.metrics.add_assign(&row.metrics);
total.logical_word_stats.add_assign(row.logical_word_stats);
total.visible_logical_word_stats.add_assign(row.visible_logical_word_stats);
total.document_raw_words =
total.document_raw_words.saturating_add(row.document_raw_words);
total.document_word_stats.add_assign(row.document_word_stats);
total.document_metric_files =
total.document_metric_files.saturating_add(row.document_metric_files);
for (reason, count) in &row.coverage {
*total.coverage.entry(*reason).or_default() += count;
}
merge_coverage(&mut total.lines_coverage, &row.lines_coverage);
if let (Some(total), Some(row)) = (&mut total.code_coverage, &row.code_coverage) {
merge_coverage(total, row);
}
if let (Some(total), Some(row)) = (&mut total.words_coverage, &row.words_coverage) {
merge_coverage(total, row);
}
for (source, count) in &row.detection_sources {
*total.detection_sources.entry(*source).or_default() += count;
}
for (confidence, count) in &row.detection_confidence {
*total.detection_confidence.entry(*confidence).or_default() += count;
}
total.generated_files = total.generated_files.saturating_add(row.generated_files);
total.vendored_files = total.vendored_files.saturating_add(row.vendored_files);
total.documentation_files =
total.documentation_files.saturating_add(row.documentation_files);
}
total.finish_derived_metrics();
let byte_share_metric = match query.selection.size {
SizeMetric::Apparent => ShareMetric::ApparentBytes,
SizeMetric::Allocated => ShareMetric::AllocatedBytes,
};
let share_metric = match view {
ViewSpec::Languages if content.includes_code() => ShareMetric::CodeLines,
ViewSpec::Documents if content.includes_words() => ShareMetric::DocumentWords,
ViewSpec::Languages | ViewSpec::Documents if content.is_enabled() => {
ShareMetric::RawWords
}
ViewSpec::Languages | ViewSpec::Documents | ViewSpec::Types | ViewSpec::Families => {
byte_share_metric
}
ViewSpec::List
| ViewSpec::Tree
| ViewSpec::Extensions
| ViewSpec::Code
| ViewSpec::Files
| ViewSpec::Largest
| ViewSpec::Recent
| ViewSpec::Summary => {
unreachable!("only grouped views reach metric_summary")
}
};
let denominator = share_value(&total, share_metric);
total.share = MetricShare { numerator: denominator, denominator };
let mut rows = grouped.into_values().collect::<Vec<_>>();
for row in &mut rows {
row.share = MetricShare { numerator: share_value(row, share_metric), denominator };
}
sort_rows(
&mut rows,
query,
view,
SortAccessors {
size: |row: &MetricRow, metric| match view {
ViewSpec::Languages | ViewSpec::Documents => share_value(row, share_metric),
_ => match metric {
SizeMetric::Apparent => row.bytes,
SizeMetric::Allocated => row.allocated,
},
},
count: |row: &MetricRow| row.files,
mtime: |_: &MetricRow| None,
name: borrowed_name(|row: &MetricRow| std::borrow::Cow::Borrowed(row.id.as_str())),
content_metric: MetricRow::metric_value,
},
);
let before_share = rows.len();
if let Some(threshold) = &query.selection.min_share {
rows.retain(|row| threshold.admits(row.share.numerator, row.share.denominator));
}
let share_omitted = before_share - rows.len();
let total_rows = truncate(&mut rows, query.limit_for(view));
MetricSummary {
group,
total,
rows,
total_rows,
share_omitted,
share_metric,
words_per_page: query.words_per_page.max(1),
}
}
}
fn code_overview(
index: &Index,
query: &Query,
content: AnalysisSet,
walked: Option<&Walked>,
unfiltered_rows: Option<&[FileRow]>,
) -> CodeOverview {
#[derive(Default)]
struct SortFacts {
apparent: u64,
allocated: u64,
newest_mtime_ns: Option<i64>,
metric: MetricAggregate,
}
let files = walked.map_or_else(
|| entry_rows(index, None, unfiltered_rows),
|walked| Cow::Borrowed(walked.members.as_slice()),
);
let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
let split = query.selection.ignored == IgnoredEntries::Include && index.observes_controls();
let sort_key = query.selection.sort.unwrap_or(SortKey::Metric("code_lines"));
let sort_metric = match sort_key {
SortKey::Metric(name) => Some(name),
_ => None,
};
let mut overview = CodeOverview {
population: query.selection.ignored,
selected: CodeTally::default(),
non_ignored: split.then(CodeTally::default),
ignored: split.then(CodeTally::default),
unknown: CodeTally::default(),
unclassified_files: 0,
analyzed_languages: 0,
total_languages: 0,
share_omitted: 0,
languages: Vec::new(),
share_metric: ShareMetric::CodeLines,
};
let mut grouped = BTreeMap::<String, CodeLanguageRow>::new();
let mut sort_facts = BTreeMap::<String, SortFacts>::new();
for file in files.iter().filter(|row| row.kind == EntryKind::File) {
let record = held.and_then(|tier| tier.file(&file.path));
let classification = record
.map_or_else(|| index.classify(&file.path).into(), |record| record.detection.clone());
if classification.family == ContentFamily::Unknown {
overview.unclassified_files = overview.unclassified_files.saturating_add(1);
}
if classification.family != ContentFamily::Code {
continue;
}
let language = classification.file_type.as_str().to_string();
let facts = sort_facts.entry(language.clone()).or_default();
facts.apparent = facts.apparent.saturating_add(file.bytes);
facts.allocated = facts.allocated.saturating_add(file.allocated);
facts.newest_mtime_ns =
Some(facts.newest_mtime_ns.map_or(file.mtime_ns, |old| old.max(file.mtime_ns)));
if let (Some(name), Some(record)) = (sort_metric, record) {
if let Some(value) =
measured_file_metric(record, classification.file_type.as_str(), name)
{
facts.metric.add(value);
}
}
let row = grouped.entry(language.clone()).or_insert_with(|| CodeLanguageRow {
language,
selected: CodeTally::default(),
non_ignored: split.then(CodeTally::default),
ignored: split.then(CodeTally::default),
unknown: CodeTally::default(),
share: MetricShare::default(),
});
row.selected.add_file(record);
overview.selected.add_file(record);
match index.ignored_classification(&file.path) {
Some(false) if split => {
row.non_ignored.as_mut().expect("split initialized").add_file(record);
overview.non_ignored.as_mut().expect("split initialized").add_file(record);
}
Some(true) if split => {
row.ignored.as_mut().expect("split initialized").add_file(record);
overview.ignored.as_mut().expect("split initialized").add_file(record);
}
None => {
row.unknown.add_file(record);
overview.unknown.add_file(record);
}
Some(_) => {}
}
}
let denominator = overview.selected.metrics.code_lines;
overview.analyzed_languages =
grouped.values().filter(|row| row.selected.analyzed_files > 0).count() as u64;
overview.languages = grouped.into_values().collect();
for row in &mut overview.languages {
row.share = MetricShare { numerator: row.selected.metrics.code_lines, denominator };
}
let normalized =
matches!(sort_metric, Some("logical_words" | "visible_logical_words" | "document_words"));
overview.languages.sort_by(|left, right| {
let left_facts = &sort_facts[&left.language];
let right_facts = &sort_facts[&right.language];
let ordering = match sort_key {
SortKey::Size => {
let value = |facts: &SortFacts| match query.selection.size {
SizeMetric::Apparent => facts.apparent,
SizeMetric::Allocated => facts.allocated,
};
value(right_facts).cmp(&value(left_facts))
}
SortKey::Count => right.selected.source_files.cmp(&left.selected.source_files),
SortKey::Mtime => right_facts.newest_mtime_ns.cmp(&left_facts.newest_mtime_ns),
SortKey::Name => left.language.cmp(&right.language),
SortKey::Metric(_) => {
let left_value = left_facts.metric.value(normalized);
let right_value = right_facts.metric.value(normalized);
match (left_value, right_value) {
(Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
(Some(left), Some(right)) => right.cmp(&left),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
};
let ordering = if query.selection.reverse && !matches!(sort_key, SortKey::Metric(_)) {
ordering.reverse()
} else {
ordering
};
ordering.then_with(|| left.language.cmp(&right.language))
});
let before_share = overview.languages.len();
if let Some(threshold) = &query.selection.min_share {
overview.languages.retain(|row| threshold.admits(row.share.numerator, denominator));
}
overview.share_omitted = before_share - overview.languages.len();
overview.total_languages = overview.languages.len();
if let Some(limit) = query.limit_for(ViewSpec::Code).limit() {
overview.languages.truncate(limit);
}
overview
}
fn merge_coverage(total: &mut BTreeMap<CoverageReason, u64>, row: &BTreeMap<CoverageReason, u64>) {
for (reason, count) in row {
*total.entry(*reason).or_default() += count;
}
}
fn share_value(row: &MetricRow, metric: ShareMetric) -> u64 {
match metric {
ShareMetric::ApparentBytes => row.bytes,
ShareMetric::AllocatedBytes => row.allocated,
ShareMetric::CodeLines => row.metrics.code_lines.unwrap_or(0),
ShareMetric::DocumentWords => document_words(row).unwrap_or(0),
ShareMetric::RawWords => row.metrics.raw_words.unwrap_or(0),
}
}
pub fn document_words(row: &MetricRow) -> Option<u64> {
row.metrics.document_words
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Pages {
pub words: u64,
pub words_per_page: u64,
}
pub fn pages(row: &MetricRow, words_per_page: u64) -> Option<Pages> {
document_words(row).map(|words| Pages { words, words_per_page: words_per_page.max(1) })
}
fn file_rows(
view: ViewSpec,
index: &Index,
query: &Query,
content: AnalysisSet,
walked: Option<&Walked>,
unfiltered_rows: Option<&[FileRow]>,
) -> (Vec<FileRow>, usize) {
let mut rows = entry_rows(index, walked, unfiltered_rows).into_owned();
if view.files_only() {
rows.retain(|row| row.kind == EntryKind::File);
}
if let Some(SortKey::Metric(name)) = query.selection.sort {
let sources = walked.map_or(rows.as_slice(), |walked| walked.members.as_slice());
let values = metric_sort_values(index, content, sources, name);
for row in &mut rows {
row.sort_value = values.get(&row.path).copied();
}
}
sort_rows(
&mut rows,
query,
view,
SortAccessors {
size: |row: &FileRow, metric| match metric {
SizeMetric::Apparent => row.bytes,
SizeMetric::Allocated => row.allocated,
},
count: |row: &FileRow| row.files.unwrap_or(1),
mtime: |row: &FileRow| Some(row.mtime_ns),
name: borrowed_name(|row: &FileRow| row.path.to_string_lossy()),
content_metric: |row: &FileRow, _: &MetricDef| row.sort_value,
},
);
let total = truncate(&mut rows, query.limit_for(view));
let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
for row in &mut rows {
if row.kind == EntryKind::File {
row.classification = Some(held.and_then(|tier| tier.file(&row.path)).map_or_else(
|| index.classify(&row.path).into(),
|record| record.detection.clone(),
));
}
}
(rows, total)
}
#[derive(Clone, Copy)]
enum MetricMeasure {
Additive(u64),
Normalized(LogicalWordStats),
}
#[derive(Default)]
struct MetricAggregate {
additive: u64,
normalized: LogicalWordStats,
measured: bool,
}
impl MetricAggregate {
fn add(&mut self, measure: MetricMeasure) {
self.measured = true;
match measure {
MetricMeasure::Additive(value) => {
self.additive = self.additive.saturating_add(value);
}
MetricMeasure::Normalized(stats) => self.normalized.add_assign(stats),
}
}
fn value(&self, normalized: bool) -> Option<u64> {
self.measured.then(
|| {
if normalized { self.normalized.logical_words() } else { self.additive }
},
)
}
}
fn measured_file_metric(
record: &crate::content::FileAnalysis,
file_type: &str,
metric_name: &str,
) -> Option<MetricMeasure> {
let direct = |value| Some(MetricMeasure::Additive(value));
match metric_name {
"physical_lines" => direct(record.lines.value()?.physical_lines),
"blank_lines" => direct(record.lines.value()?.blank_lines),
"nonblank_lines" => direct(record.lines.value()?.nonblank_lines),
"raw_words" => direct(record.lines.value()?.raw_words),
"code_lines" => direct(record.code?.value()?.code_lines),
"comment_lines" => direct(record.code?.value()?.comment_lines),
"code_blank_lines" => direct(record.code?.value()?.code_blank_lines),
"paragraphs" => direct(record.words?.value()?.paragraphs),
"visible_words" => direct(record.words?.value()?.visible_words),
"logical_words" => {
Some(MetricMeasure::Normalized(record.words?.value()?.logical_word_stats))
}
"visible_logical_words" => {
Some(MetricMeasure::Normalized(record.words?.value()?.visible_logical_word_stats))
}
"document_words" => {
let words = record.words?.value()?;
Some(MetricMeasure::Normalized(if file_type == "markdown" {
words.visible_logical_word_stats
} else {
words.logical_word_stats
}))
}
_ => None,
}
}
fn metric_sort_values(
index: &Index,
content: AnalysisSet,
sources: &[FileRow],
metric_name: &str,
) -> BTreeMap<PathBuf, u64> {
let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
let mut aggregate = BTreeMap::<PathBuf, MetricAggregate>::new();
for file in sources.iter().filter(|row| row.kind == EntryKind::File) {
let Some(record) = held.and_then(|tier| tier.file(&file.path)) else { continue };
let classification = index.classify(&file.path);
let Some(measure) =
measured_file_metric(record, classification.file_type.as_str(), metric_name)
else {
continue;
};
let mut path = Some(file.path.as_path());
while let Some(current) = path {
if current.as_os_str().is_empty() {
break;
}
aggregate.entry(current.to_path_buf()).or_default().add(measure);
path = current.parent();
}
}
let normalized =
matches!(metric_name, "logical_words" | "visible_logical_words" | "document_words");
aggregate
.into_iter()
.filter_map(|(path, values)| values.value(normalized).map(|value| (path, value)))
.collect()
}
fn every_entry(index: &Index) -> Vec<FileRow> {
debug_assert!(!index.is_folded(), "a folded index keeps too few files to list");
let mut rows = Vec::new();
let mut stack: Vec<(EntryId, PathBuf)> = vec![(EntryId::ROOT, PathBuf::new())];
while let Some((id, path)) = stack.pop() {
let Some(children) = index.children_of(id) else {
continue;
};
let children: Vec<(PathBuf, EntryId)> =
children.map(|(name, child)| (path.join(name), child)).collect();
for (child_path, child) in children {
let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
continue;
};
rows.push(FileRow {
path: child_path.clone(),
kind,
bytes: attrs.size,
allocated: attrs.allocated,
mtime_ns: attrs.mtime_ns,
files: None,
dirs: None,
complete: None,
age_ns: None,
ignored: index.ignored_classification_of(&child_path, child),
sort_value: None,
classification: None,
});
if kind == EntryKind::Dir {
stack.push((child, child_path));
}
}
}
rows
}
fn tree_node(
index: &Index,
query: &Query,
content: AnalysisSet,
walked: Option<&Walked>,
tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
) -> (Option<TreeNode>, Vec<TreeOmission>) {
let unfiltered = (walked.is_none() && matches!(query.selection.sort, Some(SortKey::Metric(_))))
.then(|| every_entry(index));
let metric_values = if let Some(SortKey::Metric(name)) = query.selection.sort {
let sources = walked.map_or_else(
|| unfiltered.as_deref().unwrap_or(&[]),
|walked| walked.members.as_slice(),
);
metric_sort_values(index, content, sources, name)
} else {
BTreeMap::new()
};
let root_summary = match walked {
None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
Some(walked) => walked.summary_of(EntryId::ROOT),
};
let mut root = TreeNode {
path: PathBuf::new(),
name: ".".to_string(),
kind: EntryKind::Dir,
entry_ignored: index.ignored_classification_of(Path::new(""), EntryId::ROOT),
bytes: root_summary.bytes,
allocated: root_summary.allocated,
files: root_summary.files,
dirs: root_summary.dirs,
ignored: root_summary.ignored,
newest_mtime_ns: root_summary.newest_mtime_ns,
children: Vec::new(),
omissions: Vec::new(),
truncated: false,
};
if query.limit_for(ViewSpec::Tree) == Bound::Limit(0) {
let complete = index.state().coverage == crate::Coverage::Complete;
let omitted = TreeOmission {
reason: TreeOmissionReason::Rows,
entries: 1,
files: complete.then_some(root.files),
bytes: complete.then_some(root.bytes),
allocated: complete.then_some(root.allocated),
ignored: complete.then_some(root.ignored).flatten().map(IgnoredSize::from_tally),
};
return (None, vec![omitted]);
}
expand(index, query, walked, &metric_values, tree_measurements, &mut root);
if let Some(cap) = query.limit_for(ViewSpec::Tree).limit() {
root = cap_tree_rows(root, cap, index.state().coverage == crate::Coverage::Complete);
}
(Some(root), Vec::new())
}
#[derive(Clone, Copy)]
struct FoldedRows {
entries: usize,
files: u64,
bytes: u64,
allocated: u64,
ignored: Option<IgnoredSize>,
}
impl FoldedRows {
const NONE: Self = Self {
entries: 0,
files: 0,
bytes: 0,
allocated: 0,
ignored: Some(IgnoredSize { bytes: 0, allocated: 0 }),
};
fn of(index: &Index, id: EntryId, path: &Path) -> Option<Self> {
let folded = index.folded_children(id)?;
Some(Self {
entries: usize::try_from(folded.files).unwrap_or(usize::MAX),
files: folded.files,
bytes: folded.bytes,
allocated: folded.allocated,
ignored: index.children_classification_known(path).then_some(folded.ignored),
})
}
}
#[derive(Clone, Copy)]
struct RowFacts {
files: u64,
bytes: u64,
allocated: u64,
ignored: Option<IgnoredTally>,
}
impl From<&TreeNode> for RowFacts {
fn from(node: &TreeNode) -> Self {
Self {
files: node.files,
bytes: node.bytes,
allocated: node.allocated,
ignored: node.ignored,
}
}
}
fn facts_of(rows: &[(TreeNode, EntryId)]) -> Vec<RowFacts> {
rows.iter().map(|(row, _)| RowFacts::from(row)).collect()
}
fn record_omission(
node: &mut TreeNode,
reason: TreeOmissionReason,
rows: &[RowFacts],
folded: Option<FoldedRows>,
complete: bool,
) {
if rows.is_empty() && folded.is_none() {
return;
}
let seed = folded.unwrap_or(FoldedRows::NONE);
let bytes = complete
.then(|| rows.iter().try_fold(seed.bytes, |sum, row| sum.checked_add(row.bytes)))
.flatten();
let allocated = complete
.then(|| rows.iter().try_fold(seed.allocated, |sum, row| sum.checked_add(row.allocated)))
.flatten();
let files = complete
.then(|| rows.iter().try_fold(seed.files, |sum, row| sum.checked_add(row.files)))
.flatten();
let ignored = complete
.then(|| {
rows.iter().try_fold(seed.ignored?, |sum, row| {
sum.checked_add(IgnoredSize::from_tally(row.ignored?))
})
})
.flatten();
node.omissions.push(TreeOmission {
reason,
entries: rows.len().saturating_add(seed.entries),
files,
bytes,
allocated,
ignored,
});
node.truncated = true;
}
fn cap_tree_rows(root: TreeNode, cap: usize, complete: bool) -> TreeNode {
struct Pending {
node: TreeNode,
parent: Option<usize>,
}
let mut pending = vec![Pending { node: root, parent: None }];
let mut kept: Vec<Pending> = Vec::new();
while let Some(mut item) = pending.pop() {
if kept.len() == cap {
let parent = item.parent.expect("root admitted by positive row cap");
let omission = TreeOmission {
reason: TreeOmissionReason::Rows,
entries: 1,
files: complete.then_some(item.node.files),
bytes: complete.then_some(item.node.bytes),
allocated: complete.then_some(item.node.allocated),
ignored: complete
.then_some(item.node.ignored)
.flatten()
.map(IgnoredSize::from_tally),
};
let owner = &mut kept[parent].node;
if let Some(existing) = owner
.omissions
.iter_mut()
.find(|existing| existing.reason == TreeOmissionReason::Rows)
{
existing.entries += 1;
existing.files =
existing.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
existing.bytes =
existing.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
existing.allocated =
existing.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
existing.ignored =
existing.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
} else {
owner.omissions.push(omission);
}
owner.truncated = true;
continue;
}
let children = std::mem::take(&mut item.node.children);
let current = kept.len();
kept.push(item);
for child in children.into_iter().rev() {
pending.push(Pending { node: child, parent: Some(current) });
}
}
for position in (1..kept.len()).rev() {
let child = kept.remove(position);
kept[child.parent.expect("only root lacks parent")].node.children.insert(0, child.node);
}
kept.pop().expect("positive cap admits root").node
}
fn expand(
index: &Index,
query: &Query,
walked: Option<&Walked>,
metric_values: &BTreeMap<PathBuf, u64>,
tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
node: &mut TreeNode,
) {
struct Pending {
node: TreeNode,
id: EntryId,
depth: usize,
parent: Option<usize>,
}
let mut built = vec![Pending {
node: TreeNode {
path: node.path.clone(),
name: node.name.clone(),
kind: node.kind,
entry_ignored: node.entry_ignored,
bytes: node.bytes,
allocated: node.allocated,
files: node.files,
dirs: node.dirs,
ignored: node.ignored,
newest_mtime_ns: node.newest_mtime_ns,
children: Vec::new(),
omissions: Vec::new(),
truncated: false,
},
id: EntryId::ROOT,
depth: 0,
parent: None,
}];
let threshold = query.min_share_for();
let grand = match query.selection.size {
SizeMetric::Apparent => node.bytes,
SizeMetric::Allocated => node.allocated,
};
let complete = index.state().coverage == crate::Coverage::Complete;
let mut cursor = 0;
while cursor < built.len() {
let (id, depth) = (built[cursor].id, built[cursor].depth);
let path = built[cursor].node.path.clone();
let (mut rows, below_share) = child_rows(
index,
query,
walked,
metric_values,
tree_measurements,
id,
&path,
&threshold,
grand,
);
record_omission(
&mut built[cursor].node,
TreeOmissionReason::Share,
&below_share,
FoldedRows::of(index, id, &path),
true,
);
if !query.depth_for(ViewSpec::Tree).admits(depth) {
record_omission(
&mut built[cursor].node,
TreeOmissionReason::Depth,
&facts_of(&rows),
None,
complete,
);
cursor += 1;
continue;
}
if let Some(cap) = query.breadth_for().limit() {
let hidden = rows.split_off(cap.min(rows.len()));
record_omission(
&mut built[cursor].node,
TreeOmissionReason::Breadth,
&facts_of(&hidden),
None,
complete,
);
}
for (child_node, child_id) in rows {
built.push(Pending {
node: child_node,
id: child_id,
depth: depth + 1,
parent: Some(cursor),
});
}
cursor += 1;
}
for position in (1..built.len()).rev() {
let child = built.remove(position);
let parent = child.parent.expect("only the root has no parent");
built[parent].node.children.insert(0, child.node);
}
let mut root = built.pop().expect("the root is always present");
node.children = std::mem::take(&mut root.node.children);
node.omissions = std::mem::take(&mut root.node.omissions);
node.truncated = root.node.truncated;
}
#[allow(clippy::too_many_arguments)]
fn child_rows(
index: &Index,
query: &Query,
walked: Option<&Walked>,
metric_values: &BTreeMap<PathBuf, u64>,
tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
id: EntryId,
path: &Path,
threshold: &ShareThreshold,
grand: u64,
) -> (Vec<(TreeNode, EntryId)>, Vec<RowFacts>) {
let Some(children) = index.children_of(id) else {
return (Vec::new(), Vec::new());
};
let children_known = index.children_classification_known(path);
let mut child_path = path.to_path_buf();
let mut rows: Vec<(TreeNode, EntryId)> = Vec::new();
let mut below_share: Vec<RowFacts> = Vec::new();
for (name, child) in children {
let Some(kind) = index.kind_of(child) else {
continue;
};
if !matches!(kind, EntryKind::Dir | EntryKind::File) {
continue;
}
if walked.is_some_and(|walked| match kind {
EntryKind::Dir => !walked.visible.contains(&child),
EntryKind::File => !walked.visible_files.contains(&child),
EntryKind::Symlink | EntryKind::Other => true,
}) {
continue;
}
let entry_ignored = children_known.then(|| index.entry_ignored(child)).flatten();
child_path.push(name);
let summary = if kind == EntryKind::File {
let attrs = index.attrs_of(child).expect("live child has attributes");
let ignored = entry_ignored.map(|ignored| {
if ignored {
IgnoredTally {
files: 1,
dirs: 0,
bytes: attrs.size,
allocated: attrs.allocated,
}
} else {
IgnoredTally::default()
}
});
SummaryRow {
files: 1,
dirs: 0,
bytes: attrs.size,
allocated: attrs.allocated,
ignored,
newest_mtime_ns: Some(attrs.mtime_ns),
}
} else {
match walked {
None => unfiltered_summary(index, child, &child_path),
Some(walked) => walked.summary_of(child),
}
};
let value = match query.selection.size {
SizeMetric::Apparent => summary.bytes,
SizeMetric::Allocated => summary.allocated,
};
let child_complete = kind == EntryKind::File
|| tree_measurements
.is_none_or(|values| values.get(&child).is_some_and(|subtree| subtree.complete));
if child_complete && !threshold.admits(value, grand) {
below_share.push(RowFacts {
files: summary.files,
bytes: summary.bytes,
allocated: summary.allocated,
ignored: summary.ignored,
});
child_path.pop();
continue;
}
rows.push((
TreeNode {
path: child_path.clone(),
name: name.to_string_lossy().into_owned(),
kind,
entry_ignored,
bytes: summary.bytes,
allocated: summary.allocated,
files: summary.files,
dirs: summary.dirs,
ignored: summary.ignored,
newest_mtime_ns: summary.newest_mtime_ns,
children: Vec::new(),
omissions: Vec::new(),
truncated: false,
},
child,
));
child_path.pop();
}
sort_rows_by(
&mut rows,
query,
ViewSpec::Tree,
SortAccessors {
size: |(row, _): &(TreeNode, EntryId), metric| match metric {
SizeMetric::Apparent => row.bytes,
SizeMetric::Allocated => row.allocated,
},
count: |(row, _): &(TreeNode, EntryId)| row.files,
mtime: |(row, _): &(TreeNode, EntryId)| row.newest_mtime_ns,
name: borrowed_name(|(row, _): &(TreeNode, EntryId)| {
std::borrow::Cow::Borrowed(row.name.as_str())
}),
content_metric: |(row, _): &(TreeNode, EntryId), _: &MetricDef| {
metric_values.get(&row.path).copied()
},
},
);
(rows, below_share)
}
fn truncate<T>(rows: &mut Vec<T>, limit: Bound) -> usize {
let total = rows.len();
if let Some(limit) = limit.limit() {
rows.truncate(limit);
}
total
}
struct SortAccessors<S, C, M, N, V> {
size: S,
count: C,
mtime: M,
name: N,
content_metric: V,
}
fn sort_rows<T>(
rows: &mut [T],
query: &Query,
view: ViewSpec,
accessors: SortAccessors<
impl Fn(&T, SizeMetric) -> u64,
impl Fn(&T) -> u64,
impl Fn(&T) -> Option<i64>,
impl for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
impl Fn(&T, &MetricDef) -> Option<u64>,
>,
) {
sort_rows_by(rows, query, view, accessors);
}
fn borrowed_name<T, F>(name: F) -> F
where
F: for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
{
name
}
fn sort_rows_by<T>(
rows: &mut [T],
query: &Query,
view: ViewSpec,
accessors: SortAccessors<
impl Fn(&T, SizeMetric) -> u64,
impl Fn(&T) -> u64,
impl Fn(&T) -> Option<i64>,
impl for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
impl Fn(&T, &MetricDef) -> Option<u64>,
>,
) {
let SortAccessors { size, count, mtime, name, content_metric } = accessors;
let key = query.selection.sort.unwrap_or_else(|| view.default_sort());
let metric = query.selection.size;
rows.sort_by(|left, right| {
let ordering = match key {
SortKey::Size => size(right, metric).cmp(&size(left, metric)),
SortKey::Count => count(right).cmp(&count(left)),
SortKey::Mtime => mtime(right).cmp(&mtime(left)),
SortKey::Name => name(left).cmp(&name(right)),
SortKey::Metric(metric_name) => {
let definition =
crate::content::METRICS.iter().find(|entry| entry.name == metric_name);
let left_value = definition.and_then(|definition| content_metric(left, definition));
let right_value =
definition.and_then(|definition| content_metric(right, definition));
match (left_value, right_value) {
(Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
(Some(left), Some(right)) => right.cmp(&left),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
};
ordering.then_with(|| name(left).cmp(&name(right)))
});
if query.selection.reverse && !matches!(key, SortKey::Metric(_)) {
rows.reverse();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine_contract::{Attrs, Observation, Op};
use crate::query::query_glob::Pattern;
use crate::query::query_selection::ModifiedWindow;
use std::fs;
use std::time::{Duration, UNIX_EPOCH};
fn attrs(size: u64, mtime_ns: i64) -> Attrs {
Attrs {
size,
allocated: size.div_ceil(512) * 512,
mtime_ns,
ctime_ns: mtime_ns,
inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
dev: 1,
}
}
fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
Op::Upsert { path: PathBuf::from(path), kind, attrs }
}
fn sample() -> Index {
let mut index = Index::new("/root");
index
.apply(&Observation::new(vec![
upsert("src", EntryKind::Dir, Attrs::default()),
upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
upsert("src/deep", EntryKind::Dir, Attrs::default()),
upsert("src/deep/nested.rs", EntryKind::File, attrs(50, 40)),
upsert("docs", EntryKind::Dir, Attrs::default()),
upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
upsert("notes.txt", EntryKind::File, attrs(7, 5)),
]))
.expect("apply");
index
}
#[test]
fn ages_use_one_signed_reference_and_unrepresentable_clocks_are_unknown() {
let mut index = sample();
index.apply_ok(&Observation::new(vec![
upsert("past", EntryKind::File, attrs(1, -10)),
upsert("future", EntryKind::File, attrs(1, i64::MAX)),
]));
let query = query(
&[ViewSpec::Files],
Selection { include: vec![pattern("past"), pattern("future")], ..Selection::default() },
);
let mut request = Request::new(Basis::held_by(&index), query, UNIX_EPOCH);
let answer = report(&index, &request, UNIX_EPOCH).expect("report");
let rows = files_of(&answer);
assert_eq!(answer.age_reference_ns, Some(0));
assert_eq!(
rows.iter().map(|r| r.age_ns).collect::<Vec<_>>(),
[Some(-i128::from(i64::MAX)), Some(10)]
);
request.now = UNIX_EPOCH + Duration::from_secs(10_000_000_000);
let answer = report(&index, &request, UNIX_EPOCH)
.expect("out-of-range reference is representable as unknown age");
assert_eq!(answer.age_reference_ns, None);
assert!(files_of(&answer).iter().all(|row| row.age_ns.is_none()));
}
#[test]
fn matching_a_directory_selects_its_subtree_once() {
let index = sample();
let selection = Selection {
include: vec![pattern("src"), pattern("deep")],
kinds: vec![EntryKind::Dir],
size: SizeMetric::Apparent,
min_size: Some(40),
..Selection::default()
};
let report = run(
&index,
&query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Extensions], selection),
);
let rows = files_of(&report);
assert_eq!(rows.len(), 2);
assert_eq!((rows[0].bytes, rows[0].mtime_ns), (350, 40));
let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
assert_eq!((summary.files, summary.dirs, summary.bytes), (3, 2, 350));
let Section::Extensions { rows, .. } = &report.sections[2] else { panic!("extensions") };
assert_eq!((rows[0].files, rows[0].bytes), (3, 350));
}
#[test]
fn a_selection_over_an_exactly_full_tree_sums_to_u64_max() {
let exact =
|size: u64, mtime_ns: i64| Attrs { size, allocated: size, ..attrs(1, mtime_ns) };
let mut index = Index::new("/root");
index.apply_ok(&Observation::new(vec![
upsert("big", EntryKind::Dir, Attrs::default()),
upsert("big/half.bin", EntryKind::File, exact(1 << 63, 1)),
upsert("big/rest.bin", EntryKind::File, exact((1 << 63) - 1, 2)),
upsert("empty.txt", EntryKind::File, exact(0, 3)),
]));
assert_eq!(index.total().bytes, u64::MAX);
let selection = Selection {
include: vec![pattern("big")],
size: SizeMetric::Apparent,
min_size: Some(1),
..Selection::default()
};
let report = run(&index, &query(&[ViewSpec::Summary, ViewSpec::Files], selection));
let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
assert_eq!((summary.files, summary.dirs, summary.bytes), (2, 1, u64::MAX));
let Section::Files { rows, .. } = &report.sections[1] else { panic!("files") };
assert_eq!(rows.iter().map(|row| row.bytes).sum::<u64>(), u64::MAX);
}
#[test]
fn subtree_predicates_include_directory_and_symlink_activity_but_only_file_bytes() {
let mut index = sample();
index
.apply(&Observation::new(vec![
upsert("src", EntryKind::Dir, attrs(9999, 45)),
upsert("src/empty", EntryKind::Dir, attrs(8888, 60)),
upsert("src/link", EntryKind::Symlink, attrs(7777, 70)),
upsert("empty", EntryKind::Dir, attrs(6666, -10)),
]))
.expect("apply");
let base = Selection {
include: vec![pattern("src"), pattern("empty")],
size: SizeMetric::Apparent,
..Selection::default()
};
let nested = PathBuf::from("src").join("empty").to_string_lossy().into_owned();
let rows = files_of(&run(&index, &query(&[ViewSpec::Files], base.clone())));
assert_eq!(
rows.iter()
.map(|r| (r.path.to_string_lossy().into_owned(), r.bytes, r.mtime_ns))
.collect::<Vec<_>>(),
[("empty".into(), 0, -10), ("src".into(), 350, 70), (nested.clone(), 0, 60)]
);
for (before, since, expected) in [
(70, 0, vec![nested.clone()]),
(71, 70, vec!["src".to_owned()]),
(0, -10, vec!["empty".to_owned()]),
] {
let selection = Selection {
modified: ModifiedWindow { before: Some(before), since: Some(since) },
..base.clone()
};
let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
assert_eq!(
rows.iter().map(|r| r.path.to_string_lossy().into_owned()).collect::<Vec<_>>(),
expected
);
}
for (size, minimum, expected) in [
(SizeMetric::Apparent, 350, 1),
(SizeMetric::Apparent, 351, 0),
(SizeMetric::Allocated, 1536, 1),
(SizeMetric::Allocated, 1537, 0),
] {
let selection = Selection { size, min_size: Some(minimum), ..base.clone() };
assert_eq!(
files_of(&run(&index, &query(&[ViewSpec::Files], selection))).len(),
expected
);
}
}
#[test]
fn exclusions_apply_before_subtree_bounds_and_selected_ancestor_coverage() {
let index = classified_sample();
for (ignored, exclude, bytes, newest) in [
(IgnoredEntries::Include, vec![], 325, 70),
(IgnoredEntries::Exclude, vec![], 300, 20),
(IgnoredEntries::Include, vec![pattern("*.log")], 300, 20),
(IgnoredEntries::Include, vec![pattern("lib.rs")], 125, 70),
] {
let selection = Selection {
include: vec![pattern("src")],
kinds: vec![EntryKind::Dir],
ignored,
exclude,
size: SizeMetric::Apparent,
..Selection::default()
};
let report = run(
&index,
&query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Types], selection),
);
let row = &files_of(&report)[0];
assert_eq!((row.bytes, row.mtime_ns), (bytes, newest));
let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
assert_eq!(summary.bytes, bytes);
let Section::Metrics { summary, .. } = &report.sections[2] else { panic!("types") };
assert_eq!(summary.rows.iter().map(|r| r.bytes).sum::<u64>(), bytes);
}
let selection = Selection {
include: vec![pattern("build")],
exclude: vec![pattern("cache")],
kinds: vec![EntryKind::Dir],
..Selection::default()
};
let report = run(&index, &query(&[ViewSpec::Files, ViewSpec::Summary], selection));
assert_eq!(files_of(&report)[0].bytes, 0);
let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
assert_eq!((summary.files, summary.dirs, summary.bytes), (0, 1, 0));
let only = Selection {
include: vec![pattern("cache")],
kinds: vec![EntryKind::Dir],
ignored: IgnoredEntries::Only,
..Selection::default()
};
assert_eq!(files_of(&run(&index, &query(&[ViewSpec::Files], only)))[0].bytes, 1000);
}
#[test]
fn an_incomplete_subtree_reports_lower_bounds_and_matches_no_time_bound() {
let mut index = Index::new_with_scope(
"/root",
crate::ScanScope { max_depth: Some(2), ..crate::ScanScope::default() },
);
index.apply_ok(&Observation::new(vec![
upsert("env", EntryKind::Dir, attrs(0, 5)),
upsert("env/lib", EntryKind::Dir, attrs(0, 7)),
upsert("env/a.bin", EntryKind::File, attrs(100, 40)),
upsert("docs", EntryKind::Dir, attrs(0, 5)),
upsert("docs/guide.md", EntryKind::File, attrs(30, 50)),
]));
let directories = |selection: Selection| {
let selection =
Selection { kinds: vec![EntryKind::Dir], size: SizeMetric::Apparent, ..selection };
files_of(&run(&index, &flat(selection)))
.into_iter()
.map(|row| {
(row.path.to_string_lossy().into_owned(), row.complete, row.bytes, row.age_ns)
})
.collect::<Vec<_>>()
};
let env_lib = Path::new("env").join("lib").to_string_lossy().into_owned();
assert_eq!(
directories(Selection::default()),
vec![
("env".to_string(), Some(false), 100, None),
("docs".to_string(), Some(true), 30, Some(-50)),
(env_lib, Some(false), 0, None),
],
"sizes are lower bounds and the age is unknown below the boundary"
);
for modified in [
ModifiedWindow { since: None, before: Some(100) },
ModifiedWindow { since: Some(0), before: None },
] {
assert_eq!(
directories(Selection { modified, ..Selection::default() }),
vec![("docs".to_string(), Some(true), 30, Some(-50))],
"an unknown age satisfies no bound, not even one its lower bound would prove"
);
}
assert_eq!(
directories(Selection { min_size: Some(100), ..Selection::default() }),
vec![("env".to_string(), Some(false), 100, None)],
"a lower bound at or above the minimum proves the true size is too"
);
assert!(directories(Selection { min_size: Some(101), ..Selection::default() }).is_empty());
let files = files_of(&run(
&index,
&flat(Selection {
kinds: vec![EntryKind::File],
modified: ModifiedWindow { since: Some(45), before: None },
..Selection::default()
}),
));
assert_eq!(files.len(), 1);
assert_eq!((files[0].complete, files[0].age_ns), (None, Some(-50)));
}
#[test]
fn an_unscoped_partial_marker_marks_every_directory_row_incomplete() {
let directories = |index: &Index| {
files_of(&run(
index,
&flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
))
};
let mut partial = sample();
partial.set_initial_freshness(false);
let rows = directories(&partial);
assert_eq!(rows.len(), 3);
assert!(rows.iter().all(|row| row.complete == Some(false) && row.age_ns.is_none()));
let mut complete = sample();
complete.set_initial_freshness(true);
let rows = directories(&complete);
assert_eq!(rows.len(), 3);
assert!(rows.iter().all(|row| row.complete == Some(true) && row.age_ns.is_some()));
}
#[test]
fn an_opened_root_marks_a_directory_complete_only_once_discovery_listed_it() {
let handle = crate::index::IndexHandle::new(Index::new("/root"));
handle
.transition_discovery(crate::index::DiscoveryTransition::Begin)
.expect("begin discovery");
handle
.apply(&Observation::new(vec![
upsert("known", EntryKind::Dir, attrs(0, 5)),
upsert("pending", EntryKind::Dir, attrs(0, 5)),
]))
.expect("seed directories");
handle
.apply_discovery(
&Observation::new(Vec::new()),
crate::index::DiscoveryCommit {
directory_complete: Some(PathBuf::from("known")),
transition: None,
},
)
.expect("list one directory");
let completeness = handle
.read_with(|index| {
files_of(&run(
index,
&flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
))
.into_iter()
.map(|row| (row.path.to_string_lossy().into_owned(), row.complete))
.collect::<BTreeMap<_, _>>()
})
.expect("read");
assert_eq!(
completeness,
BTreeMap::from([
("known".to_string(), Some(true)),
("pending".to_string(), Some(false))
])
);
}
#[test]
fn a_filtered_tree_keeps_empty_matches_and_only_folds_visible_directories() {
let mut index = sample();
index
.apply(&Observation::new(vec![upsert("src/empty", EntryKind::Dir, Attrs::default())]))
.expect("apply");
let selection = Selection {
include: vec![pattern("empty")],
depth: Some(Bound::All),
min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
..Selection::default()
};
let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].name, "src");
assert_eq!(root.children[0].children[0].name, "empty");
let selection = Selection {
include: vec![pattern("notes.txt")],
depth: Some(Bound::Limit(0)),
min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
..Selection::default()
};
let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
assert!(root.truncated, "a file leaf is hidden by depth zero");
assert_eq!(root.bytes, 7);
}
#[test]
fn the_undocumented_docs_view_alias_is_rejected() {
assert_eq!(
ViewSpec::parse("documents").expect("the canonical view name parses"),
ViewSpec::Documents
);
assert_eq!(
ViewSpec::parse("docs").expect_err("an unreleased alias must not become a contract"),
format!("expected one of {}", ViewSpec::vocabulary())
);
}
#[test]
fn code_analysis_defaults_to_code_overview_and_keeps_document_projection() {
assert_eq!(
ViewSpec::resolve(None, AnalysisSet::NONE.with_code(), "view").expect("default").0,
vec![ViewSpec::Code]
);
assert_eq!(
ViewSpec::resolve(None, AnalysisSet::ALL, "view").expect("combined default").0,
vec![ViewSpec::Code, ViewSpec::Documents]
);
assert_eq!(
ViewSpec::resolve(Some("files"), AnalysisSet::ALL, "view").expect("explicit").0,
vec![ViewSpec::Files]
);
}
fn generated_at() -> std::time::SystemTime {
UNIX_EPOCH + Duration::from_secs(1_001)
}
fn run(index: &Index, query: &Query) -> Report {
report(index, &crate::test_support::read_of(index, query.clone()), generated_at())
.expect("the query is answerable over this index")
}
fn query(views: &[ViewSpec], selection: Selection) -> Query {
Query { selection, views: views.to_vec(), ..Query::default() }
}
fn flat(selection: Selection) -> Query {
Query {
selection,
views: vec![ViewSpec::List],
format: crate::report_format::Format::Paths,
..Query::default()
}
}
fn pattern(source: &str) -> Pattern {
Pattern::parse(source).expect("pattern compiles")
}
fn summary_of(report: &Report) -> SummaryRow {
match report.sections.first().expect("a section") {
Section::Summary(row) => *row,
other => panic!("expected a summary, got {other:?}"),
}
}
fn files_of(report: &Report) -> Vec<FileRow> {
match report.sections.first().expect("a section") {
Section::Files { rows, .. } => rows.clone(),
other => panic!("expected files, got {other:?}"),
}
}
fn types_of(report: &Report) -> Vec<TypeRow> {
match report.sections.first().expect("a section") {
Section::Extensions { rows, .. } => rows.clone(),
other => panic!("expected types, got {other:?}"),
}
}
fn tree_of(report: &Report) -> TreeNode {
match report.sections.first().expect("a section") {
Section::Tree { root: Some(node), .. } => (**node).clone(),
other => panic!("expected a tree, got {other:?}"),
}
}
#[test]
fn an_unfiltered_summary_matches_the_precomputed_rollup() {
let index = sample();
let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
assert_eq!(row.files, 5);
assert_eq!(row.dirs, 3);
assert_eq!(row.bytes, 657);
assert_eq!(row.newest_mtime_ns, Some(40));
}
#[test]
fn the_two_tiers_agree_on_the_same_question() {
let index = sample();
let fast = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
let admits_everything = Selection { min_size: Some(0), ..Selection::default() };
assert!(!admits_everything.is_unfiltered());
let slow = summary_of(&run(&index, &query(&[ViewSpec::Summary], admits_everything)));
assert_eq!(
(fast.files, fast.dirs, fast.bytes, fast.allocated, fast.newest_mtime_ns),
(slow.files, slow.dirs, slow.bytes, slow.allocated, slow.newest_mtime_ns)
);
}
#[test]
fn extension_rows_account_for_every_file_in_both_tiers() {
let mut index = sample();
index
.apply(&Observation::new(vec![
upsert("Makefile", EntryKind::File, attrs(28, 50)),
upsert(".gitignore", EntryKind::File, attrs(11, 51)),
]))
.expect("apply");
for selection in [
Selection::default(),
Selection { min_size: Some(0), ..Selection::default() },
Selection { kinds: vec![EntryKind::File], ..Selection::default() },
] {
let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
assert_eq!(
rows.iter().map(|row| row.bytes).sum::<u64>(),
summary.bytes,
"bytes unaccounted for under {selection:?}: {rows:?}"
);
assert_eq!(
rows.iter().map(|row| row.files).sum::<u64>(),
summary.files,
"files unaccounted for under {selection:?}: {rows:?}"
);
}
}
#[test]
fn names_without_an_extension_share_one_bucket() {
let mut index = sample();
index
.apply(&Observation::new(vec![
upsert("Makefile", EntryKind::File, attrs(28, 50)),
upsert(".gitignore", EntryKind::File, attrs(11, 51)),
]))
.expect("apply");
let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
let bucket = rows
.iter()
.find(|row| row.extension == crate::classify::NO_EXTENSION)
.expect("a bucket for the extension-less names");
assert_eq!(bucket.files, 2);
assert_eq!(bucket.bytes, 39);
assert!(rows.iter().any(|row| row.extension == ".rs"), "{rows:?}");
}
#[test]
fn a_summary_counts_the_union_of_listed_entries_and_directory_contents() {
let index = sample();
for selection in [
Selection { kinds: vec![EntryKind::File], ..Selection::default() },
Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
Selection { include: vec![pattern("*.rs")], ..Selection::default() },
Selection { exclude: vec![pattern("docs")], ..Selection::default() },
Selection { min_size: Some(1_000_000), ..Selection::default() },
Selection { min_size: Some(0), ..Selection::default() },
] {
let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
let listed = files_of(&run(&index, &query(&[ViewSpec::Files], selection.clone())));
let dirs = listed.iter().filter(|row| row.kind == EntryKind::Dir).count() as u64;
let files = every_entry(&index)
.iter()
.filter(|entry| {
entry.kind == EntryKind::File
&& listed.iter().any(|row| {
entry.path == row.path
|| (row.kind == EntryKind::Dir && entry.path.starts_with(&row.path))
})
})
.count() as u64;
assert_eq!(summary.dirs, dirs, "directory counts disagree under {selection:?}");
assert_eq!(summary.files, files, "file counts disagree under {selection:?}");
}
}
#[test]
fn a_rejected_directory_is_still_descended_into() {
let index = sample();
let selection = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
assert_eq!(row.dirs, 0, "no directory was admitted");
assert_eq!(row.files, 5, "including src/deep/nested.rs, two levels down");
assert_eq!(row.bytes, 657, "and its bytes");
}
#[test]
fn nested_directory_counts_roll_up_through_every_level() {
let index = sample();
let selection = Selection { kinds: vec![EntryKind::Dir], ..Selection::default() };
let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
assert_eq!(root.dirs, 3, "src, src/deep, and docs");
assert_eq!(root.files, 4, "matching directories cover their regular files");
let src = root.children.iter().find(|node| node.name == "src").expect("src");
assert_eq!(src.dirs, 1, "src/deep, counted for src as well as for the root");
}
#[test]
fn selection_narrows_a_summary_to_what_it_admits() {
let index = sample();
let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
assert_eq!(row.files, 3, "three .rs files");
assert_eq!(row.bytes, 350);
}
#[test]
fn a_files_view_lists_matching_entries_in_name_order_by_default() {
let index = sample();
let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
let paths: Vec<PathBuf> = rows.iter().map(|row| row.path.clone()).collect();
let expected: Vec<PathBuf> = [["src", "deep", "nested.rs"].iter().collect::<PathBuf>()]
.into_iter()
.chain([["src", "lib.rs"].iter().collect::<PathBuf>()])
.chain([["src", "main.rs"].iter().collect::<PathBuf>()])
.collect();
assert_eq!(paths, expected);
}
#[test]
fn sorting_and_limiting_compose_without_a_dedicated_view() {
let index = sample();
let selection = Selection {
kinds: vec![EntryKind::File],
sort: Some(SortKey::Size),
limit: Some(Bound::Limit(2)),
size: SizeMetric::Apparent,
..Selection::default()
};
let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].bytes, 300, "largest first");
assert_eq!(rows[1].bytes, 200);
}
#[test]
fn reverse_flips_whatever_order_is_in_effect() {
let index = sample();
let selection = Selection {
kinds: vec![EntryKind::File],
sort: Some(SortKey::Size),
reverse: true,
size: SizeMetric::Apparent,
..Selection::default()
};
let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
assert_eq!(rows[0].bytes, 7, "smallest first once reversed");
}
#[test]
fn a_modified_window_selects_by_time() {
let index = sample();
let selection = Selection {
kinds: vec![EntryKind::File],
modified: ModifiedWindow { since: Some(20), before: Some(40) },
sort: Some(SortKey::Mtime),
..Selection::default()
};
let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
let mut times: Vec<i64> = rows.iter().map(|row| row.mtime_ns).collect();
times.sort_unstable();
assert_eq!(times, vec![20, 30], "inclusive start, exclusive end");
}
#[test]
fn a_types_view_reports_both_size_metrics_per_extension() {
let index = sample();
let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
let rs = rows.iter().find(|row| row.extension == ".rs").expect(".rs present");
assert_eq!((rs.files, rs.bytes), (3, 350));
assert_eq!(rs.allocated, 1536, "three files, one 512-byte block each");
let order: Vec<&str> = rows.iter().map(|row| row.extension.as_str()).collect();
assert_eq!(order, vec![".rs", ".md", ".txt"]);
}
#[test]
fn a_tree_view_reports_directories_with_their_subtree_totals() {
let index = sample();
let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], Selection::default())));
assert_eq!(tree.name, ".");
assert_eq!(tree.bytes, 657);
let names: Vec<&str> = tree.children.iter().map(|child| child.name.as_str()).collect();
assert_eq!(names, vec!["src", "docs", "notes.txt"]);
let src = &tree.children[0];
assert_eq!(src.bytes, 350);
let nested: Vec<&str> = src.children.iter().map(|child| child.name.as_str()).collect();
assert!(nested.contains(&"deep"));
}
#[test]
fn depth_zero_keeps_dus_meaning_of_root_totals_only() {
let index = sample();
let selection = Selection { depth: Some(Bound::Limit(0)), ..Selection::default() };
let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
assert_eq!(tree.bytes, 657, "totals still cover the whole tree");
assert!(tree.children.is_empty(), "but nothing below the root is listed");
assert!(tree.truncated, "and the report says so rather than implying emptiness");
}
#[test]
fn a_dropped_view_is_named_on_the_report_rather_than_by_one_surface() {
let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
.expect("full resolves without analyzers");
assert!(omitted.contains(&ViewSpec::Code));
assert!(omitted.contains(&ViewSpec::Documents));
let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
let (notes, _) = display_notes(&query, &ControlCoverage::NotObserved);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("code") && notes[0].contains("documents"), "{notes:?}");
let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::ALL, "view")
.expect("full resolves with analyzers");
assert!(omitted.is_empty(), "every view is answerable with analysis enabled");
let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
assert!(display_notes(&query, &ControlCoverage::NotObserved).0.is_empty());
}
#[test]
fn the_refused_controls_note_bounds_its_list_and_matches_its_remedy_to_the_reasons() {
use crate::control::{
ControlLimits, ControlObservation, ControlRefusalReason, RefusedControl,
};
let defaults = ControlLimits::default();
for (reason, wanted, unwanted) in [
(ControlRefusalReason::Budget, "--gitignore-budget", "--gitignore-line-limit"),
(ControlRefusalReason::LineLimit, "--gitignore-line-limit", "--gitignore-budget"),
] {
let coverage = ControlCoverage::Observed(ControlObservation {
limits: defaults,
applied: 7,
rules: 0,
refused: 1,
refusals: vec![RefusedControl {
path: Path::new("vendor").join(".gitignore"),
reason,
}],
});
let (note, tip) = refused_controls_note(&coverage, &AxisNames::FLAGS).expect("refusal");
assert!(
note.starts_with(
"note: ignore classification incomplete: 1 ignore file not applied"
)
);
assert!(note.contains("vendor"));
assert!(!note.contains("--"), "facts do not repeat flag advice");
let tip = tip.expect("bounded refusal has a remedy");
assert!(tip.starts_with("tip:"));
assert!(tip.contains(wanted));
assert!(!tip.contains(unwanted));
}
let coverage = |limits, refused| {
ControlCoverage::Observed(ControlObservation {
limits,
applied: 7,
rules: 0,
refused,
refusals: (0..crate::MAX_RETAINED_ISSUES)
.map(|i| RefusedControl {
path: Path::new(&format!("d{i:02}")).join(".gitignore"),
reason: ControlRefusalReason::Budget,
})
.collect(),
})
};
let (note, tip) =
refused_controls_note(&coverage(defaults, 1000), &AxisNames::FLAGS).expect("truncated");
assert!(note.contains("995 more"));
assert!(!note.contains("d05"));
let tip = tip.expect("both bounded limits may explain unlisted refusals");
assert!(tip.contains("--gitignore-budget") && tip.contains("--gitignore-line-limit"));
let (_, tip) = refused_controls_note(
&coverage(ControlLimits { line_limit: None, ..defaults }, 1000),
&AxisNames::FLAGS,
)
.expect("truncated");
assert!(!tip.expect("budget remedy").contains("--gitignore-line-limit"));
let (_, tip) = refused_controls_note(
&coverage(ControlLimits { budget: None, line_limit: None }, 64),
&AxisNames::FLAGS,
)
.expect("retained refusal");
assert!(tip.is_none(), "do not suggest raising unbounded limits");
assert!(refused_controls_note(&ControlCoverage::NotObserved, &AxisNames::FLAGS).is_none());
}
#[test]
fn a_diagnostic_names_the_axes_the_requesting_surface_uses() {
let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
.expect("full resolves without analyzers");
for (axes, mine, theirs) in [
(&AxisNames::FLAGS, "--analyze", "analyze"),
(&AxisNames::FIELDS, "analyze", "--analyze"),
] {
let query = Query {
views: selected.clone(),
omitted_views: omitted.clone(),
axes,
..Query::default()
};
let note = display_notes(&query, &ControlCoverage::NotObserved).1.remove(0);
assert!(note.contains(&format!("add {mine} ")), "{note} must name {mine}");
assert!(!note.contains(&format!("add {theirs} ")), "{note} must not name {theirs}");
}
for (axes, view, analyze) in
[(&AxisNames::FLAGS, "--view", "--analyze"), (&AxisNames::FIELDS, "view", "analyze")]
{
let error =
crate::query::RequestError::ViewNeedsContent(ViewSpec::Documents).message(axes);
assert!(error.starts_with(&format!("{view} documents")), "{error}");
assert!(error.contains(&format!("add {analyze} ")), "{error}");
let theirs = if analyze == "--analyze" { "analyze" } else { "--analyze" };
assert!(!error.contains(&format!("add {theirs} ")), "{error}");
}
}
#[test]
fn the_default_vocabulary_is_the_librarys_own() {
assert_eq!(*Query::default().axes, AxisNames::FIELDS);
}
#[test]
fn a_depth_bound_marks_hidden_file_and_directory_rows_as_truncated() {
let index = sample();
let selection = Selection { depth: Some(Bound::Limit(1)), ..Selection::default() };
let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
let src = tree.children.iter().find(|child| child.name == "src").expect("src");
assert!(src.truncated, "src with a hidden directory child is truncated");
let docs = tree.children.iter().find(|child| child.name == "docs").expect("docs");
assert!(docs.children.is_empty());
assert!(docs.truncated, "significant file leaves are rows beyond the depth boundary");
}
#[test]
fn a_tree_limit_caps_section_rows_including_the_root() {
let index = sample();
let selection = Selection { limit: Some(Bound::Limit(1)), ..Selection::default() };
let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
assert_eq!(tree.children.len(), 0);
assert!(tree.truncated);
assert!(tree.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Rows));
}
#[test]
fn tree_share_is_root_relative_inclusive_and_does_not_hide_eleventh_sibling() {
let mut index = Index::new("/root");
let mut ops = Vec::new();
for number in 0..11 {
let directory = format!("d{number:02}");
ops.push(upsert(&directory, EntryKind::Dir, Attrs::default()));
ops.push(upsert(&format!("{directory}/file"), EntryKind::File, attrs(20, 1)));
}
ops.push(upsert("filler", EntryKind::File, attrs(780, 1)));
index.apply(&Observation::new(ops)).expect("apply");
let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
assert_eq!(root.children.len(), 12, "eleven 2% directories plus the large file");
assert!(root.children.iter().any(|child| child.name == "d10"));
assert!(root.omissions.is_empty());
let selection = Selection {
size: SizeMetric::Apparent,
breadth: Some(Bound::Limit(10)),
..Selection::default()
};
let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
assert_eq!(root.children.len(), 10);
assert_eq!(root.omissions[0].reason, TreeOmissionReason::Breadth);
assert_eq!(root.omissions[0].entries, 2);
assert_eq!(root.omissions[0].bytes, Some(40));
}
#[test]
fn tree_threshold_keeps_exact_one_percent_file_leaf() {
let mut index = Index::new("/root");
index
.apply(&Observation::new(vec![
upsert("one-percent", EntryKind::File, attrs(10, 1)),
upsert("below", EntryKind::File, attrs(9, 1)),
upsert("rest", EntryKind::File, attrs(981, 1)),
]))
.expect("apply");
let root = tree_of(&run(
&index,
&query(
&[ViewSpec::Tree],
Selection { size: SizeMetric::Apparent, ..Selection::default() },
),
));
assert_eq!(root.children.len(), 2);
assert!(
root.children
.iter()
.any(|child| child.name == "one-percent" && child.kind == EntryKind::File)
);
assert_eq!(root.omissions[0].reason, TreeOmissionReason::Share);
assert_eq!(root.omissions[0].bytes, Some(9));
}
fn mixed_remainder_index() -> Index {
let mut index = Index::new("/root");
index.apply_ok(&Observation::new(vec![
upsert("A", EntryKind::Dir, attrs(0, 0)),
upsert("A/a1", EntryKind::Dir, attrs(0, 0)),
upsert("A/a1/x", EntryKind::Dir, attrs(0, 0)),
upsert("A/a1/x/leaf", EntryKind::File, attrs(1960, 0)),
upsert("A/a1/tiny", EntryKind::File, attrs(40, 0)),
upsert("A/a2", EntryKind::Dir, attrs(0, 0)),
upsert("A/a2/file", EntryKind::File, attrs(2000, 0)),
upsert("B", EntryKind::Dir, attrs(0, 0)),
upsert("B/b1", EntryKind::Dir, attrs(0, 0)),
upsert("B/b1/file", EntryKind::File, attrs(2000, 0)),
upsert("B/b2", EntryKind::Dir, attrs(0, 0)),
upsert("B/b2/file", EntryKind::File, attrs(2000, 0)),
upsert("C", EntryKind::Dir, attrs(0, 0)),
upsert("C/c1", EntryKind::Dir, attrs(0, 0)),
upsert("C/c1/first", EntryKind::File, attrs(1000, 0)),
upsert("C/c1/second", EntryKind::File, attrs(1000, 0)),
]));
index
}
#[test]
fn nested_mixed_omissions_have_one_exact_remainder_golden() {
let index = mixed_remainder_index();
let selection = Selection {
size: SizeMetric::Apparent,
depth: Some(Bound::Limit(3)),
breadth: Some(Bound::Limit(2)),
limit: Some(Bound::Limit(8)),
min_share: Some(ShareThreshold::parse("0.5%").expect("share")),
..Selection::default()
};
let report = run(&index, &query(&[ViewSpec::Tree], selection));
let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
panic!("expected tree")
};
let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("hidden content");
assert_eq!((root.files, root.bytes, root.allocated), (7, 10_000, 10_752));
assert_eq!(
(remainder.files, remainder.bytes, remainder.allocated),
(Some(2), Some(2_000), Some(2_048))
);
assert_eq!(remainder.reasons, vec![TreeOmissionReason::Breadth]);
assert_eq!(
crate::report_format::render(&report, crate::report_format::Format::Text, false)
.expect("render"),
include_str!("../../tests/golden/remainder-tree.txt"),
);
}
#[test]
fn displayed_directories_represent_their_descendants_even_when_expansion_stops() {
let index = mixed_remainder_index();
let selection = Selection {
size: SizeMetric::Apparent,
depth: Some(Bound::Limit(1)),
breadth: Some(Bound::All),
limit: Some(Bound::All),
min_share: Some(ShareThreshold::parse("0%").expect("share")),
..Selection::default()
};
let report = run(&index, &query(&[ViewSpec::Tree], selection));
let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
panic!("expected tree")
};
assert_eq!((root.files, root.bytes), (7, 10_000));
assert_eq!(root.children.len(), 3);
assert!(root.children.iter().all(|child| child.kind == EntryKind::Dir));
assert!(root.children.iter().any(|child| !child.omissions.is_empty()));
assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
let diagnostics = crate::report_format::diagnostic_lines(&report);
assert!(!diagnostics.notes.iter().any(|note| note.contains("more covers")));
assert!(diagnostics.notes.iter().any(|note| note.contains("depth 1")));
assert!(
diagnostics.tips.contains(&format!("tip: expand deeper: {}=all", report.axes.depth))
);
let depth_zero = run(
&index,
&query(
&[ViewSpec::Tree],
Selection {
size: SizeMetric::Apparent,
depth: Some(Bound::Limit(0)),
min_share: Some(ShareThreshold::parse("0%").expect("share")),
..Selection::default()
},
),
);
let Section::Tree { root: Some(root), omissions, .. } = &depth_zero.sections[0] else {
panic!("expected tree")
};
let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("root alone");
assert_eq!((remainder.files, remainder.bytes), (Some(7), Some(10_000)));
}
#[test]
fn selected_leaf_ledger_conserves_every_top_level_tree_partition() {
let files = [
("src/main.rs", 100, 512, false),
("src/lib.rs", 200, 512, false),
("src/debug.log", 25, 512, true),
("docs/guide.md", 300, 512, false),
("build/cache/out.bin", 1_000, 1_024, true),
];
let dirs = [("src", false), ("docs", false), ("build", true), ("build/cache", true)];
let index = classified_sample();
let within = |path: &str, parent: &Path| {
parent.as_os_str().is_empty() || Path::new(path).starts_with(parent)
};
for (population, excluded) in [
(IgnoredEntries::Include, None),
(IgnoredEntries::Exclude, None),
(IgnoredEntries::Only, None),
(IgnoredEntries::Include, Some("build")),
(IgnoredEntries::Include, Some("*.rs")),
] {
let admitted = |path: &str, ignored: bool| {
population.admits(ignored)
&& !matches!(excluded, Some("build") if Path::new(path).starts_with("build"))
&& !matches!(excluded, Some("*.rs") if Path::new(path)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("rs")))
};
let selected_files: Vec<_> = files
.iter()
.copied()
.filter(|(path, _, _, ignored)| admitted(path, *ignored))
.collect();
let selected_dirs: Vec<_> =
dirs.iter().copied().filter(|(path, ignored)| admitted(path, *ignored)).collect();
for size in [SizeMetric::Apparent, SizeMetric::Allocated] {
for share in ["0%", "1%", "50%", "100%"] {
for depth in [Bound::Limit(0), Bound::Limit(1), Bound::All] {
for breadth in
[Bound::Limit(0), Bound::Limit(1), Bound::Limit(2), Bound::All]
{
for limit in
[Bound::Limit(0), Bound::Limit(1), Bound::Limit(3), Bound::All]
{
let selection = Selection {
ignored: population,
exclude: excluded
.map_or_else(Vec::new, |name| vec![pattern(name)]),
size,
min_share: Some(ShareThreshold::parse(share).expect("share")),
depth: Some(depth),
breadth: Some(breadth),
limit: Some(limit),
..Selection::default()
};
let report = run(
&index,
&query(&[ViewSpec::Summary, ViewSpec::Tree], selection.clone()),
);
let Section::Summary(summary) = &report.sections[0] else {
panic!("summary")
};
let Section::Tree { root, omissions, .. } = &report.sections[1]
else {
panic!("tree")
};
let expected = (
selected_files.len() as u64,
selected_dirs.len() as u64,
selected_files.iter().map(|file| file.1).sum::<u64>(),
selected_files.iter().map(|file| file.2).sum::<u64>(),
);
let context = format!("{selection:?}");
assert_eq!(
(summary.files, summary.dirs, summary.bytes, summary.allocated),
expected,
"summary: {context}"
);
if let Some(root) = root {
assert_eq!(
(root.files, root.dirs, root.bytes, root.allocated),
expected,
"root: {context}"
);
let mut stack = vec![root.as_ref()];
while let Some(node) = stack.pop() {
let contained: Vec<_> = selected_files
.iter()
.filter(|file| within(file.0, &node.path))
.collect();
let contained_dirs = selected_dirs
.iter()
.filter(|dir| {
within(dir.0, &node.path)
&& Path::new(dir.0) != node.path
})
.count()
as u64;
let ignored: Vec<_> =
contained.iter().filter(|file| file.3).collect();
let ignored_dirs = selected_dirs
.iter()
.filter(|dir| {
dir.1
&& within(dir.0, &node.path)
&& Path::new(dir.0) != node.path
})
.count()
as u64;
assert_eq!(
(node.files, node.dirs, node.bytes, node.allocated),
(
contained.len() as u64,
contained_dirs,
contained.iter().map(|file| file.1).sum::<u64>(),
contained.iter().map(|file| file.2).sum::<u64>(),
),
"node {:?}: {context}",
node.path
);
assert_eq!(
node.ignored.map(|part| (
part.files,
part.dirs,
part.bytes,
part.allocated
)),
Some((
ignored.len() as u64,
ignored_dirs,
ignored.iter().map(|file| file.1).sum::<u64>(),
ignored.iter().map(|file| file.2).sum::<u64>(),
)),
"ignored {:?}: {context}",
node.path
);
stack.extend(node.children.iter());
}
}
let represented: Vec<_> = root
.iter()
.flat_map(|root| root.children.iter().map(|child| &child.path))
.collect();
let uncovered: Vec<_> = selected_files
.iter()
.filter(|file| {
!represented.iter().any(|path| within(file.0, path))
})
.collect();
let remainder =
TreeRemainder::from_tree(root.as_deref(), omissions);
if let Some(remainder) = remainder {
assert_eq!(
(remainder.files, remainder.bytes, remainder.allocated),
(
Some(uncovered.len() as u64),
Some(uncovered.iter().map(|file| file.1).sum::<u64>()),
Some(uncovered.iter().map(|file| file.2).sum::<u64>()),
),
"remainder: {context}"
);
assert_eq!(
remainder.ignored.map(|part| (part.bytes, part.allocated)),
Some((
uncovered
.iter()
.filter(|file| file.3)
.map(|file| file.1)
.sum(),
uncovered
.iter()
.filter(|file| file.3)
.map(|file| file.2)
.sum(),
)),
"remainder ignored: {context}"
);
} else {
assert!(uncovered.is_empty(), "missing remainder: {context}");
}
}
}
}
}
}
}
}
#[test]
fn empty_tree_and_unrepresentable_remainder_stay_honest() {
let empty = Index::new_with_scope("/root", crate::test_support::observing_controls());
let report = run(
&empty,
&query(
&[ViewSpec::Tree],
Selection {
depth: Some(Bound::Limit(0)),
min_share: Some(ShareThreshold::parse("1%").expect("share")),
..Selection::default()
},
),
);
let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
panic!("empty tree")
};
assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (0, 0, 0, 0));
assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
let first = TreeOmission {
reason: TreeOmissionReason::Share,
entries: 1,
files: Some(u64::MAX),
bytes: Some(u64::MAX),
allocated: Some(u64::MAX),
ignored: Some(IgnoredSize { bytes: u64::MAX, allocated: u64::MAX }),
};
let second = TreeOmission {
reason: TreeOmissionReason::Breadth,
entries: 1,
files: Some(1),
bytes: Some(1),
allocated: None,
ignored: Some(IgnoredSize { bytes: 1, allocated: 1 }),
};
let remainder = TreeRemainder::from_tree(None, &[first, second]).expect("omissions");
assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
assert_eq!(remainder.ignored, None);
}
#[test]
fn unbounded_tree_expands_every_entry_without_a_remainder_or_diagnostics() {
let index = mixed_remainder_index();
let selection = Selection {
size: SizeMetric::Apparent,
depth: Some(Bound::All),
breadth: Some(Bound::All),
limit: Some(Bound::All),
min_share: Some(ShareThreshold::parse("0%").expect("share")),
..Selection::default()
};
let report = run(&index, &query(&[ViewSpec::Tree], selection));
let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
panic!("expected tree")
};
let mut paths = std::collections::BTreeSet::new();
let mut stack = vec![root.as_ref()];
while let Some(node) = stack.pop() {
assert!(node.omissions.is_empty() && !node.truncated);
paths.insert(node.path.to_string_lossy().replace('\\', "/"));
stack.extend(&node.children);
}
assert_eq!(
paths,
[
"",
"A",
"A/a1",
"A/a1/tiny",
"A/a1/x",
"A/a1/x/leaf",
"A/a2",
"A/a2/file",
"B",
"B/b1",
"B/b1/file",
"B/b2",
"B/b2/file",
"C",
"C/c1",
"C/c1/first",
"C/c1/second"
]
.into_iter()
.map(str::to_owned)
.collect()
);
assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (7, 9, 10_000, 10_752));
assert!(omissions.is_empty());
assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
assert!(report.notes.is_empty() && report.tips.is_empty());
}
#[test]
fn an_unlisted_hidden_branch_makes_remainder_counts_and_sizes_unknown() {
let mut index = Index::new("/root");
index.apply_ok(&Observation::new(vec![
upsert("known", EntryKind::File, attrs(100, 0)),
upsert("denied", EntryKind::Dir, attrs(0, 0)),
]));
index.set_initial_scan_freshness(&[crate::Error::io(
Path::new("/root").join("denied"),
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
)]);
let selection = Selection {
size: SizeMetric::Apparent,
depth: Some(Bound::Limit(0)),
min_share: Some(ShareThreshold::parse("0%").expect("share")),
..Selection::default()
};
let report = run(&index, &query(&[ViewSpec::Tree], selection));
let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
panic!("expected tree")
};
let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("depth bound");
assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
assert_eq!(remainder.reasons, vec![TreeOmissionReason::Depth]);
let text = crate::report_format::render(&report, crate::report_format::Format::Text, false)
.expect("render");
assert!(text.contains("— unknown … and more files (count unknown)"));
}
#[test]
fn partial_tree_keeps_default_share_pruning_golden() {
let mut index = Index::new("/root");
index.apply_ok(&Observation::new(vec![
upsert("large", EntryKind::File, attrs(9898, 0)),
upsert("one-percent", EntryKind::File, attrs(100, 0)),
upsert("tiny", EntryKind::File, attrs(1, 0)),
upsert("zero", EntryKind::File, attrs(0, 0)),
upsert("small", EntryKind::Dir, attrs(0, 0)),
upsert("small/tiny", EntryKind::File, attrs(1, 0)),
upsert("denied", EntryKind::Dir, attrs(0, 0)),
]));
index.set_initial_scan_freshness(&[crate::Error::io(
Path::new("/root").join("denied"),
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
)]);
let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
let report = run(&index, &query(&[ViewSpec::Tree], selection.clone()));
assert!(!report.status.complete);
assert_eq!(
crate::report_format::render(&report, crate::report_format::Format::Text, false)
.expect("render"),
include_str!("../../tests/golden/partial-tree.txt"),
);
let filtered = run(
&index,
&query(
&[ViewSpec::Tree],
Selection {
exclude: vec![pattern("large")],
min_share: Some(ShareThreshold::parse("50%").expect("share")),
..selection.clone()
},
),
);
let filtered_root = tree_of(&filtered);
assert_eq!(filtered_root.children.len(), 2);
assert_eq!(filtered_root.children[0].name, "one-percent");
assert_eq!(filtered_root.children[1].name, "denied");
let unbounded = run(
&index,
&query(
&[ViewSpec::Tree],
Selection {
min_share: Some(ShareThreshold::parse("0%").expect("share")),
..selection
},
),
);
assert_eq!(tree_of(&unbounded).children.len(), 6, "zero share lifts only share pruning");
}
#[test]
fn tree_zero_and_composed_caps_keep_typed_first_boundaries() {
let index = sample();
let selection = Selection { limit: Some(Bound::Limit(0)), ..Selection::default() };
let report = run(&index, &query(&[ViewSpec::Tree], selection));
let Section::Tree { root: None, omissions, .. } = &report.sections[0] else {
panic!("zero row cap must omit the root")
};
assert_eq!(omissions[0].reason, TreeOmissionReason::Rows);
assert_eq!(omissions[0].entries, 1);
let selection = Selection {
size: SizeMetric::Apparent,
depth: Some(Bound::Limit(1)),
breadth: Some(Bound::Limit(1)),
limit: Some(Bound::Limit(2)),
min_share: Some(ShareThreshold::parse("1%").expect("share")),
..Selection::default()
};
let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
assert_eq!(root.children.len(), 1);
assert!(
root.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Breadth)
);
assert!(
root.children[0]
.omissions
.iter()
.any(|omission| omission.reason == TreeOmissionReason::Depth)
);
}
#[test]
fn requesting_more_views_never_changes_another_views_answer() {
let index = sample();
let alone = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
let together = run(
&index,
&query(
&[ViewSpec::Extensions, ViewSpec::Tree, ViewSpec::Summary],
Selection::default(),
),
);
let with_others = match &together.sections[0] {
Section::Extensions { rows, .. } => rows.clone(),
other => panic!("expected types first, got {other:?}"),
};
assert_eq!(alone.len(), with_others.len());
for (left, right) in alone.iter().zip(with_others.iter()) {
assert_eq!(
(&left.extension, left.files, left.bytes),
(&right.extension, right.files, right.bytes)
);
}
assert_eq!(together.sections.len(), 3, "one section per view, in request order");
assert_eq!(together.sections[1].view(), ViewSpec::Tree);
assert_eq!(together.sections[2].view(), ViewSpec::Summary);
}
#[test]
fn analyzed_unfiltered_views_together_match_independent_answers_and_each_view_alone() {
const RUST: &str = "fn main() {\n println!(\"hi\");\n}\n";
const MARKDOWN: &str = "# Guide\n\nA small useful guide.\n";
const TEXT: &str = "plain notes here\n";
let root = tempfile::tempdir().expect("root");
fs::create_dir_all(root.path().join("src")).expect("src");
fs::create_dir_all(root.path().join("docs")).expect("docs");
fs::write(root.path().join("src/main.rs"), RUST).expect("rust");
fs::write(root.path().join("docs/guide.md"), MARKDOWN).expect("markdown");
fs::write(root.path().join("notes.txt"), TEXT).expect("text");
for (path, seconds) in [("src/main.rs", 10), ("notes.txt", 20), ("docs/guide.md", 30)] {
fs::File::options()
.write(true)
.open(root.path().join(path))
.expect("open for timestamp")
.set_times(
fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(seconds)),
)
.expect("set timestamp");
}
let (mut index, _) = crate::scan::scan_into_index(
root.path(),
&crate::ScanConfig { read_controls: false, ..crate::ScanConfig::default() },
)
.expect("scan");
crate::content::analyze_index(
&mut index,
crate::content::AnalysisRequest {
profile: AnalysisSet::ALL,
..crate::content::AnalysisRequest::default()
},
);
let views = [
ViewSpec::Types,
ViewSpec::Families,
ViewSpec::Languages,
ViewSpec::Documents,
ViewSpec::Files,
ViewSpec::Largest,
ViewSpec::Recent,
ViewSpec::Summary,
ViewSpec::Tree,
ViewSpec::Extensions,
];
let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
let together = run(&index, &query(&views, selection.clone()));
for (i, view) in views.iter().enumerate() {
let alone = run(&index, &query(&[*view], selection.clone()));
assert_eq!(
format!("{:?}", together.sections[i]),
format!("{:?}", alone.sections[0]),
"{view:?} changed when requested with the other views"
);
}
for (at, view, files) in [(0, ViewSpec::Types, 3), (1, ViewSpec::Families, 3)] {
let Section::Metrics { view: actual, summary } = &together.sections[at] else {
panic!("expected {view:?} metrics")
};
assert_eq!(*actual, view);
assert_eq!(summary.total.files, files);
assert_eq!(summary.total.analyzed_files, files);
}
let Section::Metrics { summary: languages, .. } = &together.sections[2] else {
panic!("languages")
};
assert_eq!(languages.total.files, 1);
assert_eq!(languages.total.metrics.code_lines, Some(3));
let Section::Metrics { summary: documents, .. } = &together.sections[3] else {
panic!("documents")
};
assert_eq!(documents.total.files, 2);
assert_eq!(documents.total.analyzed_files, 2);
assert_eq!(documents.total.document_metric_files, 2);
assert_eq!(documents.total.document_raw_words, 8);
assert_eq!(documents.total.document_word_stats.logical_words(), 8);
assert_eq!(documents.total.share, MetricShare { numerator: 8, denominator: 8 });
let Section::Files { rows: files, total, .. } = &together.sections[4] else {
panic!("files")
};
assert_eq!(*total, 5);
assert_eq!(
files.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
["docs", "docs/guide.md", "notes.txt", "src", "src/main.rs"].map(Path::new).to_vec()
);
let Section::Files { rows: largest, total, .. } = &together.sections[5] else {
panic!("largest")
};
assert_eq!(*total, 3);
assert_eq!(
largest.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
["src/main.rs", "docs/guide.md", "notes.txt"].map(Path::new).to_vec()
);
let Section::Files { rows: recent, total, .. } = &together.sections[6] else {
panic!("recent")
};
assert_eq!(*total, 3);
assert_eq!(
recent.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
["docs/guide.md", "notes.txt", "src/main.rs"].map(Path::new).to_vec()
);
let Section::Summary(summary) = &together.sections[7] else { panic!("summary") };
assert_eq!((summary.files, summary.dirs), (3, 2));
assert_eq!(
summary.bytes,
u64::try_from(RUST.len() + MARKDOWN.len() + TEXT.len()).expect("fixture bytes")
);
let Section::Tree { root: Some(tree), .. } = &together.sections[8] else { panic!("tree") };
assert_eq!((tree.files, tree.dirs), (3, 2));
let Section::Extensions { rows, total, .. } = &together.sections[9] else {
panic!("extensions")
};
assert_eq!((*total, rows.len()), (3, 3));
}
#[test]
fn one_pass_metric_summaries_match_independent_views() {
let root = tempfile::tempdir().expect("root");
fs::write(root.path().join("main.rs"), "fn main() {}\n").expect("rust");
fs::write(root.path().join("guide.md"), "# Guide\n\nWords.\n").expect("markdown");
fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
fs::create_dir(root.path().join("generated")).expect("generated directory");
fs::write(root.path().join("generated/app.js"), "const a = 1;\nconst b = 2;\n")
.expect("ignored code");
fs::write(root.path().join("script"), "#!/bin/sh\necho hello\n").expect("detected code");
let (mut index, _) =
crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
crate::content::analyze_index(
&mut index,
crate::content::AnalysisRequest {
profile: AnalysisSet::ALL,
..crate::content::AnalysisRequest::default()
},
);
let views = [
ViewSpec::Types,
ViewSpec::Summary,
ViewSpec::Families,
ViewSpec::Languages,
ViewSpec::Documents,
ViewSpec::Code,
];
let request = query(&views, Selection::default());
let rows = every_entry(&index);
let summaries = metric_summaries(&views, &index, &request, AnalysisSet::ALL, &rows);
assert!(summaries[1].is_none(), "non-metric views keep their own projection");
assert!(summaries[5].is_none(), "Code keeps its admitted-content classification");
for (position, view) in
views.iter().copied().enumerate().filter(|(_, view)| needs_metric_resolution(*view))
{
let independent =
metric_summary(view, &index, &request, AnalysisSet::ALL, None, Some(&rows));
assert_eq!(
format!("{:?}", summaries[position].as_ref().expect("metric summary")),
format!("{independent:?}"),
"{view:?} changed in the one-pass multi-view aggregation"
);
}
let views = views.into_iter().filter(|view| *view != ViewSpec::Summary).collect::<Vec<_>>();
for ignored in [IgnoredEntries::Include, IgnoredEntries::Exclude, IgnoredEntries::Only] {
for sort in [None, Some(SortKey::Metric("code_lines")), Some(SortKey::Name)] {
for min_share in [None, Some(ShareThreshold::parse("50%").expect("share"))] {
let selection = Selection {
ignored,
sort,
min_share,
limit: Some(Bound::Limit(1)),
..Selection::default()
};
let together = run(&index, &query(&views, selection.clone()));
for (position, view) in views.iter().enumerate() {
let alone = run(&index, &query(&[*view], selection.clone()));
assert_eq!(
format!("{:?}", together.sections[position]),
format!("{:?}", alone.sections[0]),
"{view:?} differs for {selection:?}"
);
}
}
}
}
}
#[test]
fn code_overview_keeps_a_complete_language_table_and_population_contributions() {
let root = tempfile::tempdir().expect("root");
fs::create_dir_all(root.path().join("generated")).expect("generated");
fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
fs::create_dir_all(root.path().join("src")).expect("src");
fs::write(root.path().join("src/main.rs"), "fn main() {\n}\n// comment\n").expect("rust");
fs::write(
root.path().join("generated/app.js"),
"// Code generated by fixture\nconst answer = 42;\n",
)
.expect("javascript");
fs::write(root.path().join("README.md"), "# Guide\n").expect("documentation");
fs::write(root.path().join("mystery.widget"), "opaque text\n").expect("unknown type");
let (mut index, _) =
crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
crate::content::analyze_index(
&mut index,
crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
);
let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
assert_eq!(overview.selected.metrics.code_lines, 3);
assert_eq!(overview.selected.metrics.comment_lines, 2);
assert_eq!(overview.selected.analyzed_files, 2);
assert_eq!(overview.analyzed_languages, 2);
assert_eq!(overview.unclassified_files, 2); assert_eq!(overview.languages.len(), 2);
assert_eq!(overview.languages[0].share.denominator, 3);
assert_eq!(overview.non_ignored.as_ref().expect("classified").metrics.code_lines, 2);
assert_eq!(overview.ignored.as_ref().expect("classified").metrics.code_lines, 1);
assert_eq!(overview.unknown.source_files, 0);
for (population, expected_lines) in
[(IgnoredEntries::Exclude, 2), (IgnoredEntries::Only, 1)]
{
let selected = run(
&index,
&query(
&[ViewSpec::Code],
Selection { ignored: population, ..Selection::default() },
),
);
let Section::Code(overview) = &selected.sections[0] else { panic!("population") };
assert_eq!(overview.selected.metrics.code_lines, expected_lines);
assert!(overview.non_ignored.is_none() && overview.ignored.is_none());
assert_eq!(overview.unknown.source_files, 0);
}
let threshold = run(
&index,
&query(
&[ViewSpec::Code],
Selection {
min_share: Some(ShareThreshold::parse("50%").expect("share")),
..Selection::default()
},
),
);
let Section::Code(overview) = &threshold.sections[0] else { panic!("threshold") };
assert_eq!(overview.selected.metrics.code_lines, 3);
assert_eq!(overview.total_languages, 1);
assert_eq!(overview.share_omitted, 1);
assert_eq!(overview.languages[0].language, "rust");
let bounded = run(
&index,
&query(
&[ViewSpec::Code],
Selection { limit: Some(Bound::Limit(0)), ..Selection::default() },
),
);
let Section::Code(overview) = &bounded.sections[0] else { panic!("bounded") };
assert_eq!(overview.selected.metrics.code_lines, 3);
assert_eq!(overview.total_languages, 2);
assert_eq!(overview.share_omitted, 0);
assert!(overview.languages.is_empty());
let code_and_extensions = run(
&index,
&query(
&[ViewSpec::Languages, ViewSpec::Extensions],
Selection {
min_share: Some(ShareThreshold::parse("100%").expect("share")),
..Selection::default()
},
),
);
let Section::Metrics { summary, .. } = &code_and_extensions.sections[0] else {
panic!("languages")
};
let Section::Extensions { rows, total, share_omitted } = &code_and_extensions.sections[1]
else {
panic!("extensions")
};
assert_eq!(summary.total.metrics.code_lines, Some(3));
assert!(summary.rows.is_empty() && summary.total_rows == 0);
assert_eq!(summary.share_omitted, 2);
assert!(rows.is_empty() && *total == 0);
assert!(*share_omitted > 0);
let reversed_code = run(
&index,
&query(&[ViewSpec::Code], Selection { reverse: true, ..Selection::default() }),
);
let Section::Code(reversed) = &reversed_code.sections[0] else { panic!("reversed code") };
assert_eq!(reversed.languages[0].language, "javascript");
assert_eq!(reversed.languages[1].language, "rust");
let named_code = run(
&index,
&query(
&[ViewSpec::Code],
Selection { sort: Some(SortKey::Name), ..Selection::default() },
),
);
let Section::Code(named) = &named_code.sections[0] else { panic!("named code") };
assert_eq!(named.languages[0].language, "javascript");
assert_eq!(named.languages[1].language, "rust");
let sized_code = run(
&index,
&query(
&[ViewSpec::Code],
Selection {
sort: Some(SortKey::Size),
size: SizeMetric::Apparent,
..Selection::default()
},
),
);
let Section::Code(sized) = &sized_code.sections[0] else { panic!("sized code") };
assert_eq!(sized.languages[0].language, "javascript");
let files = |reverse| {
let selection = Selection {
kinds: vec![EntryKind::File],
sort: Some(SortKey::Metric("code_lines")),
reverse,
..Selection::default()
};
run(&index, &query(&[ViewSpec::Files], selection))
};
let descending = files(false);
let Section::Files { rows, .. } = &descending.sections[0] else { panic!("files") };
assert_eq!(rows[0].path, PathBuf::from("src/main.rs"));
assert_eq!(rows[0].sort_value, Some(2));
assert_eq!(rows[1].path, PathBuf::from("generated/app.js"));
assert_eq!(rows[1].sort_value, Some(1));
assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
let classification = rows[1].classification.as_ref().expect("file classification");
assert!(classification.flags.generated);
let ascending = files(true);
let Section::Files { rows, .. } = &ascending.sections[0] else { panic!("files") };
assert_eq!((rows[0].sort_value, rows[1].sort_value), (Some(1), Some(2)));
assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
let mut dir_query = query(
&[ViewSpec::List],
Selection {
kinds: vec![EntryKind::Dir],
sort: Some(SortKey::Metric("code_lines")),
..Selection::default()
},
);
dir_query.format = crate::report_format::Format::Json;
let dirs = run(&index, &dir_query);
let Section::Files { rows, .. } = &dirs.sections[0] else { panic!("directories") };
assert_eq!((rows[0].path.as_path(), rows[0].sort_value), (Path::new("src"), Some(2)));
assert_eq!((rows[1].path.as_path(), rows[1].sort_value), (Path::new("generated"), Some(1)));
}
#[test]
fn code_overview_counts_selected_directory_members_once() {
let root = tempfile::tempdir().expect("root");
fs::create_dir_all(root.path().join("src/deep")).expect("directories");
fs::write(root.path().join("src/main.rs"), "fn main() {}\n").expect("main");
fs::write(root.path().join("src/deep/keep.rs"), "fn keep() {}\n").expect("keep");
fs::write(root.path().join("src/deep/skip.rs"), "fn skip() {}\n").expect("skip");
fs::write(root.path().join("outside.rs"), "fn outside() {}\n").expect("outside");
let (mut index, _) =
crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
crate::content::analyze_index(
&mut index,
crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
);
let selection = Selection {
include: vec![pattern("src"), pattern("deep")],
exclude: vec![pattern("skip.rs")],
..Selection::default()
};
let answer = run(&index, &query(&[ViewSpec::Code], selection));
let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
assert_eq!(overview.selected.source_files, 2);
assert_eq!(overview.selected.analyzed_files, 2);
assert_eq!(overview.selected.metrics.code_lines, 2);
assert_eq!(overview.languages.len(), 1);
assert_eq!(overview.languages[0].selected.source_files, 2);
}
#[test]
fn code_overview_uses_retained_content_detection_for_ambiguous_sources() {
let root = tempfile::tempdir().expect("root");
fs::write(root.path().join("ambiguous.h"), "namespace demo { int value; }\n")
.expect("header");
fs::write(root.path().join("script.inc"), "# vim: set filetype=rust:\nfn main() {}\n")
.expect("modeline");
let (mut index, _) =
crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
crate::content::analyze_index(
&mut index,
crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
);
let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
assert_eq!(overview.selected.source_files, 2);
assert_eq!(overview.selected.analyzed_files, 2);
assert!(overview.languages.iter().any(|row| row.language == "cpp"));
assert!(overview.languages.iter().any(|row| row.language == "rust"));
}
#[test]
fn refused_control_subtrees_do_not_enter_a_known_ignored_population() {
let root = tempfile::tempdir().expect("root");
fs::create_dir_all(root.path().join("guarded")).expect("directory");
fs::write(root.path().join("known.rs"), "fn known() {}\n").expect("known source");
fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("refused control");
fs::write(root.path().join("guarded/uncertain.rs"), "fn uncertain() {}\n")
.expect("uncertain source");
let config = crate::ScanConfig {
control_limits: crate::control::ControlLimits {
line_limit: Some(1),
..crate::control::ControlLimits::default()
},
..crate::ScanConfig::default()
};
let (index, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
assert_eq!(index.ignored_classification(Path::new("known.rs")), Some(false));
assert_eq!(index.ignored_classification(Path::new("guarded/uncertain.rs")), None);
for selection in
[Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
{
let report = run(
&index,
&query(&[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions], selection),
);
let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
let Section::Tree { root: Some(tree), .. } = &report.sections[1] else {
panic!("tree")
};
let Section::Extensions { rows, .. } = &report.sections[2] else {
panic!("extensions")
};
assert_eq!(summary.ignored, None);
assert_eq!(tree.ignored, None);
assert_eq!(
tree.children
.iter()
.find(|node| node.path == Path::new("known.rs"))
.and_then(|node| node.ignored),
Some(IgnoredTally::default())
);
assert!(
tree.children
.iter()
.find(|node| node.path == Path::new("guarded"))
.is_some_and(|node| node.ignored.is_none())
);
assert!(rows.iter().all(|row| row.ignored.is_none()));
assert!(
report
.notes
.iter()
.any(|note| note.contains("gitignored subtotals are unavailable"))
);
assert!(
crate::report_format::report_notes(&report)
.iter()
.all(|note| !note.contains("gitignored sizes are included"))
);
}
let hidden_report = run(
&index,
&query(
&[ViewSpec::Tree],
Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
),
);
let Section::Tree { root, omissions, .. } = &hidden_report.sections[0] else {
panic!("expected tree")
};
let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows");
assert!(hidden.bytes.is_some() && hidden.ignored.is_none());
for population in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
let answer = run(
&index,
&query(
&[ViewSpec::Files],
Selection {
kinds: vec![EntryKind::File],
ignored: population,
..Selection::default()
},
),
);
let Section::Files { rows, .. } = &answer.sections[0] else { panic!("files") };
assert!(rows.iter().all(|row| !row.path.starts_with("guarded")));
assert_eq!(
rows.iter().any(|row| row.path == Path::new("known.rs")),
population == IgnoredEntries::Exclude
);
}
}
#[test]
fn a_report_derives_provenance_from_its_index() {
let index = sample();
let report = run(&index, &query(&[ViewSpec::Summary], Selection::default()));
assert_eq!(report.provenance.source, ReportSource::ColdScan);
assert!(report.status.complete);
assert!(report.provenance.scan_started_at.is_some());
assert_eq!(report.provenance.generated_at, generated_at());
assert_eq!(report.root, Path::new("/root"));
}
#[test]
fn reporting_is_pure_and_repeatable() {
let index = sample();
let request = query(&[ViewSpec::Tree, ViewSpec::Extensions], Selection::default());
assert_eq!(format!("{:?}", run(&index, &request)), format!("{:?}", run(&index, &request)));
}
#[test]
fn metadata_grouping_views_use_the_generic_metric_projection() {
let index = sample();
let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
let report = run(
&index,
&query(&[ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages], apparent),
);
let Section::Metrics { summary: types, .. } = &report.sections[0] else {
panic!("expected type metrics")
};
let rust = types.rows.iter().find(|row| row.id == "rust").expect("rust");
assert_eq!((rust.files, rust.bytes), (3, 350));
assert_eq!((rust.share.numerator, rust.share.denominator), (350, 657));
assert_eq!(types.share_metric, ShareMetric::ApparentBytes);
let Section::Metrics { summary: families, .. } = &report.sections[1] else {
panic!("expected family metrics")
};
assert!(families.rows.iter().any(|row| row.id == "code"));
assert!(families.rows.iter().any(|row| row.id == "prose"));
assert_eq!(families.share_metric, ShareMetric::ApparentBytes);
let Section::Metrics { summary: languages, .. } = &report.sections[2] else {
panic!("expected language metrics")
};
let rust = languages.rows.iter().find(|row| row.id == "rust").expect("rust");
assert_eq!((rust.files, rust.bytes), (3, 350));
assert_eq!((rust.share.numerator, rust.share.denominator), (350, 350));
assert_eq!(languages.share_metric, ShareMetric::ApparentBytes);
}
const CONTROL: &str = ".gitignore";
fn classified_sample() -> Index {
let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
index
.apply(&Observation::new(vec![
Op::ControlUpsert {
path: PathBuf::from(CONTROL),
source: b"build/\n*.log\n".to_vec(),
},
upsert("src", EntryKind::Dir, Attrs::default()),
upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
upsert("src/debug.log", EntryKind::File, attrs(25, 70)),
upsert("docs", EntryKind::Dir, Attrs::default()),
upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
upsert("build", EntryKind::Dir, Attrs::default()),
upsert("build/cache", EntryKind::Dir, Attrs::default()),
upsert("build/cache/out.bin", EntryKind::File, attrs(1_000, 60)),
]))
.expect("apply");
index
}
#[test]
fn child_rows_admit_from_roll_ups_and_sum_what_the_share_omits() {
let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
index.apply_ok(&Observation::new(vec![
Op::ControlUpsert { path: PathBuf::from(CONTROL), source: b"*.log\n".to_vec() },
upsert("big", EntryKind::Dir, Attrs::default()),
upsert("big/a", EntryKind::File, attrs(1_000, 10)),
upsert("small", EntryKind::Dir, Attrs::default()),
upsert("small/x.log", EntryKind::File, attrs(5, 20)),
upsert("tiny", EntryKind::Dir, Attrs::default()),
upsert("tiny/t", EntryKind::File, attrs(3, 30)),
upsert("f1", EntryKind::File, attrs(200, 40)),
upsert("f2", EntryKind::File, attrs(2, 50)),
]));
let id = |path: &str| index.lookup(Path::new(path)).expect("an indexed path");
let measured = |bytes: u64, complete| query_subtrees::SubtreeValues {
bytes,
allocated: bytes.div_ceil(512) * 512,
mtime_ns: 0,
files: 1,
dirs: 0,
complete,
};
let measurements: BTreeMap<EntryId, query_subtrees::SubtreeValues> = [
(id("big"), measured(1_000, true)),
(id("small"), measured(5, true)),
(id("tiny"), measured(3, false)),
]
.into_iter()
.collect();
let query = query(
&[ViewSpec::Tree],
Selection { size: SizeMetric::Apparent, ..Selection::default() },
);
let grand = 1_000 + 5 + 3 + 200 + 2;
let (rows, omitted) = child_rows(
&index,
&query,
None,
&BTreeMap::new(),
Some(&measurements),
EntryId::ROOT,
Path::new(""),
&ShareThreshold::one_percent(),
grand,
);
assert_eq!(
rows.iter().map(|(row, id)| (row.name.as_str(), row.bytes, *id)).collect::<Vec<_>>(),
[("big", 1_000, id("big")), ("f1", 200, id("f1")), ("tiny", 3, id("tiny"))]
);
assert!(rows.iter().all(|(row, _)| row.path.as_path() == Path::new(&row.name)));
let mut facts: Vec<_> =
omitted.iter().map(|row| (row.bytes, row.files, row.allocated, row.ignored)).collect();
facts.sort_unstable_by_key(|(bytes, ..)| *bytes);
assert_eq!(
facts,
[
(2, 1, 512, Some(IgnoredTally::default())),
(5, 1, 512, Some(IgnoredTally { files: 1, dirs: 0, bytes: 5, allocated: 512 })),
]
);
for complete in [true, false] {
let mut node = TreeNode {
path: PathBuf::new(),
name: String::new(),
kind: EntryKind::Dir,
entry_ignored: None,
bytes: grand,
allocated: 0,
files: 5,
dirs: 3,
ignored: None,
newest_mtime_ns: None,
children: Vec::new(),
omissions: Vec::new(),
truncated: false,
};
record_omission(&mut node, TreeOmissionReason::Share, &omitted, None, complete);
assert!(node.truncated);
let [omission] = node.omissions.as_slice() else {
panic!("one Share omission, complete = {complete}: {:?}", node.omissions)
};
assert_eq!(omission.reason, TreeOmissionReason::Share);
assert_eq!(omission.entries, 2);
let sums = (omission.files, omission.bytes, omission.allocated, omission.ignored);
let expected = if complete {
(Some(2), Some(7), Some(1_024), Some(IgnoredSize { bytes: 5, allocated: 512 }))
} else {
(None, None, None, None)
};
assert_eq!(sums, expected, "complete = {complete}");
}
}
#[test]
fn tree_entry_classification_is_independent_of_selected_subtree_tallies() {
let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
index.apply_ok(&Observation::new(vec![
Op::ControlUpsert {
path: PathBuf::from(CONTROL),
source: b"empty/\nzero.txt\n*.log\n".to_vec(),
},
upsert("empty", EntryKind::Dir, Attrs::default()),
upsert("zero.txt", EntryKind::File, attrs(0, 0)),
upsert("mixed", EntryKind::Dir, Attrs::default()),
upsert("mixed/ignored.log", EntryKind::File, attrs(5, 5)),
upsert("mixed/ordinary.rs", EntryKind::File, attrs(7, 7)),
]));
let selection = |ignored| Selection {
ignored,
depth: Some(Bound::All),
breadth: Some(Bound::All),
limit: Some(Bound::All),
min_share: Some(ShareThreshold::parse("0%").expect("share")),
..Selection::default()
};
let included =
tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include))));
assert_eq!(included.entry_ignored, Some(false));
let child = |name: &str| included.children.iter().find(|row| row.name == name).expect(name);
let empty = child("empty");
assert_eq!((empty.kind, empty.bytes, empty.entry_ignored), (EntryKind::Dir, 0, Some(true)));
assert_eq!(child("zero.txt").entry_ignored, Some(true));
let mixed = child("mixed");
assert_eq!(mixed.entry_ignored, Some(false));
assert_eq!(mixed.ignored.expect("known").bytes, 5);
assert_eq!(
mixed
.children
.iter()
.find(|row| row.name == "ignored.log")
.expect("ignored leaf")
.entry_ignored,
Some(true)
);
let only =
tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Only))));
assert_eq!(only.entry_ignored, Some(false));
let mixed_only = only.children.iter().find(|row| row.name == "mixed").expect("ancestor");
assert_eq!(mixed_only.entry_ignored, Some(false));
assert_eq!(mixed_only.ignored.expect("known").bytes, 5);
let mut unobserved =
Index::new_with_scope("/root", crate::test_support::not_observing_controls());
unobserved.apply_ok(&Observation::new(vec![upsert(
"plain",
EntryKind::Dir,
Attrs::default(),
)]));
let unknown = tree_of(&run(
&unobserved,
&query(&[ViewSpec::Tree], selection(IgnoredEntries::Include)),
));
assert_eq!(unknown.entry_ignored, None);
assert_eq!(unknown.children[0].entry_ignored, None);
}
#[test]
fn ignored_size_interpretation_note_appears_once_only_when_relevant() {
let classified = classified_sample();
for (population, expected) in
[(IgnoredEntries::Include, 1), (IgnoredEntries::Exclude, 0), (IgnoredEntries::Only, 0)]
{
let report = run(
&classified,
&query(
&[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions],
Selection {
ignored: population,
depth: Some(Bound::All),
min_share: Some(ShareThreshold::parse("0%").expect("share")),
..Selection::default()
},
),
);
let notes = crate::report_format::report_notes(&report);
assert_eq!(
notes.iter().filter(|note| note.contains("gitignored sizes are included")).count(),
expected,
"{population:?}: {notes:?}"
);
}
let mut unobserved =
Index::new_with_scope("/root", crate::test_support::not_observing_controls());
unobserved.apply_ok(&Observation::new(vec![upsert(
"plain.rs",
EntryKind::File,
attrs(10, 0),
)]));
let blind =
run(&unobserved, &query(&[ViewSpec::Summary, ViewSpec::Tree], Selection::default()));
assert!(
crate::report_format::report_notes(&blind)
.iter()
.all(|note| !note.contains("gitignored sizes are included"))
);
}
#[test]
fn tree_remainder_counts_only_unrepresented_root_children() {
let index = classified_sample();
let remainder = |selection| {
let report = run(&index, &query(&[ViewSpec::Tree], selection));
let Section::Tree { root, omissions, .. } = &report.sections[0] else {
panic!("expected tree")
};
TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows")
};
let mixed = remainder(Selection {
size: SizeMetric::Apparent,
depth: Some(Bound::Limit(1)),
breadth: Some(Bound::Limit(2)),
min_share: Some(ShareThreshold::parse("5%").expect("share")),
..Selection::default()
});
let usage = |ignored: Option<IgnoredSize>| ignored.map(|part| (part.bytes, part.allocated));
assert_eq!(
(mixed.files, mixed.bytes, usage(mixed.ignored)),
(Some(1), Some(300), Some((0, 0)))
);
assert_eq!(mixed.reasons, vec![TreeOmissionReason::Breadth]);
for limit in [Bound::Limit(1), Bound::Limit(0)] {
let rows = remainder(Selection { limit: Some(limit), ..Selection::default() });
assert_eq!((rows.bytes, usage(rows.ignored)), (Some(1_625), Some((1_025, 1_536))));
}
let excluded = remainder(Selection {
depth: Some(Bound::Limit(0)),
ignored: IgnoredEntries::Exclude,
..Selection::default()
});
assert_eq!((excluded.bytes, excluded.ignored), (Some(600), Some(IgnoredSize::default())));
let only = remainder(Selection {
depth: Some(Bound::Limit(0)),
ignored: IgnoredEntries::Only,
..Selection::default()
});
assert_eq!((only.bytes, usage(only.ignored)), (Some(1_025), Some((1_025, 1_536))));
}
#[test]
fn colored_tree_populations_share_one_golden() {
let classified = classified_sample();
let mut unobserved =
Index::new_with_scope("/root", crate::test_support::not_observing_controls());
unobserved.apply_ok(&Observation::new(vec![upsert(
"plain.rs",
EntryKind::File,
attrs(10, 0),
)]));
let root = tempfile::tempdir().expect("root");
fs::create_dir(root.path().join("guarded")).expect("directory");
fs::write(root.path().join("known.rs"), "K").expect("known file");
fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("control");
fs::write(root.path().join("guarded/uncertain.rs"), "U").expect("uncertain file");
let config = crate::ScanConfig {
control_limits: crate::control::ControlLimits {
line_limit: Some(1),
..crate::control::ControlLimits::default()
},
..crate::ScanConfig::default()
};
let (refused, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
assert_eq!(refused.ignored_classification(Path::new("known.rs")), Some(false));
assert_eq!(refused.ignored_classification(Path::new("guarded/uncertain.rs")), None);
let selection = |ignored| Selection {
size: SizeMetric::Apparent,
depth: Some(Bound::Limit(0)),
ignored,
..Selection::default()
};
let cases = [
("INCLUDE", &classified, selection(IgnoredEntries::Include)),
(
"INCLUDE EXPANDED",
&classified,
Selection {
depth: Some(Bound::Limit(2)),
min_share: Some(ShareThreshold::parse("0%").expect("share")),
..selection(IgnoredEntries::Include)
},
),
("EXCLUDE", &classified, selection(IgnoredEntries::Exclude)),
("ONLY", &classified, selection(IgnoredEntries::Only)),
("NO CONTROLS", &unobserved, selection(IgnoredEntries::Include)),
("REFUSED CONTROL", &refused, selection(IgnoredEntries::Include)),
];
let mut actual = String::new();
for (label, index, selection) in cases {
let report = run(index, &query(&[ViewSpec::Tree], selection));
actual.push_str(label);
actual.push('\n');
actual.push_str(
&crate::report_format::render(&report, crate::report_format::Format::Text, true)
.expect("colored tree")
.replace('\u{1b}', "<ESC>"),
);
actual.push('\n');
}
assert_eq!(actual, include_str!("../../tests/golden/tree-populations.txt"));
}
#[test]
fn tree_remainder_ignored_share_is_unknown_without_control_observation() {
let mut index =
Index::new_with_scope("/root", crate::test_support::not_observing_controls());
index.apply_ok(&Observation::new(vec![upsert("file", EntryKind::File, attrs(10, 0))]));
let report = run(
&index,
&query(
&[ViewSpec::Tree],
Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
),
);
let Section::Tree { root, omissions, .. } = &report.sections[0] else {
panic!("expected tree")
};
let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden file");
assert_eq!((hidden.bytes, hidden.ignored), (Some(10), None));
}
fn ignored_of(row: &SummaryRow) -> IgnoredTally {
row.ignored.expect("an observing index reports an ignored share")
}
#[test]
fn the_two_tiers_agree_on_the_ignored_share() {
let index = classified_sample();
let expected = IgnoredTally { files: 2, dirs: 2, bytes: 1_025, allocated: 1_024 + 512 };
for selection in
[Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
{
let unfiltered = selection.is_unfiltered();
let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
assert_eq!(ignored_of(&summary), expected, "unfiltered: {unfiltered}");
let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection.clone())));
assert_eq!(tree.ignored, Some(expected), "unfiltered: {unfiltered}");
let child = |name: &str| {
tree.children.iter().find(|node| node.name == name).expect(name).ignored
};
assert_eq!(
child("build"),
Some(IgnoredTally { files: 1, dirs: 1, bytes: 1_000, allocated: 1_024 }),
"an ignored directory is wholly ignored below it, unfiltered: {unfiltered}"
);
assert_eq!(
child("src"),
Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
"unfiltered: {unfiltered}"
);
assert_eq!(child("docs"), Some(IgnoredTally::default()), "observed, nothing ignored");
let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
let row = |extension: &str| {
rows.iter().find(|row| row.extension == extension).expect(extension).ignored
};
assert_eq!(
row(".log"),
Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
"unfiltered: {unfiltered}"
);
assert_eq!(row(".rs"), Some(IgnoredTally::default()), "unfiltered: {unfiltered}");
let files = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
let flag =
|path: PathBuf| files.iter().find(|row| row.path == path).map(|row| row.ignored);
assert_eq!(flag(PathBuf::from("build")), Some(Some(true)));
assert_eq!(flag(PathBuf::from("src")), Some(Some(false)));
assert_eq!(flag(["src", "debug.log"].iter().collect()), Some(Some(true)));
assert_eq!(flag(["build", "cache", "out.bin"].iter().collect()), Some(Some(true)));
}
}
#[test]
fn ignored_entries_partition_the_tree_and_rank_by_what_they_select() {
let index = classified_sample();
let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
let with = |ignored| Selection { ignored, ..apparent.clone() };
let summary = |selection| summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
let total = summary(apparent.clone());
let kept = summary(with(IgnoredEntries::Exclude));
let only = summary(with(IgnoredEntries::Only));
assert_eq!(
(kept.files + only.files, kept.dirs + only.dirs, kept.bytes + only.bytes),
(total.files, total.dirs, total.bytes)
);
assert_eq!(ignored_of(&kept), IgnoredTally::default());
let whole = ignored_of(&only);
assert_eq!((whole.files, whole.dirs, whole.bytes), (only.files, only.dirs, only.bytes));
let ranked = |selection| {
tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)))
.children
.iter()
.map(|node| (node.name.clone(), node.bytes))
.collect::<Vec<_>>()
};
let row = |name: &str, bytes: u64| (name.to_string(), bytes);
assert_eq!(
ranked(apparent.clone()),
[row("build", 1_000), row("src", 325), row("docs", 300)]
);
assert_eq!(
ranked(with(IgnoredEntries::Exclude)),
[row("docs", 300), row("src", 300)],
"unignored sizes rank the rows, with the name breaking the tie"
);
assert_eq!(ranked(with(IgnoredEntries::Only)), [row("build", 1_000), row("src", 25)]);
}
#[test]
fn an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by() {
let mut index =
Index::new_with_scope("/root", crate::test_support::not_observing_controls());
index
.apply(&Observation::new(vec![
upsert("build", EntryKind::Dir, Attrs::default()),
upsert("build/out.bin", EntryKind::File, attrs(1_000, 60)),
]))
.expect("apply");
let views = [ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
let report = run(&index, &query(&views, Selection::default()));
let Section::Summary(summary) = &report.sections[0] else { panic!("a summary") };
let Section::Tree { root: Some(tree), .. } = &report.sections[1] else { panic!("a tree") };
let Section::Extensions { rows: extensions, .. } = &report.sections[2] else {
panic!("extensions")
};
let Section::Files { rows: files, .. } = &report.sections[3] else { panic!("files") };
assert_eq!(summary.ignored, None);
assert_eq!(tree.ignored, None);
assert!(tree.children.iter().all(|node| node.ignored.is_none()));
assert!(extensions.iter().all(|row| row.ignored.is_none()));
assert!(files.iter().all(|row| row.ignored.is_none()));
let exclude = Query {
selection: Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() },
views: vec![ViewSpec::Summary],
..Query::default()
};
let only = Query {
selection: Selection { ignored: IgnoredEntries::Only, ..Selection::default() },
..exclude.clone()
};
for refused in [exclude, only] {
assert!(
matches!(
super::report(
&index,
&crate::test_support::read_of(&index, refused),
generated_at()
),
Err(crate::Error::InvalidRequest(
crate::query::RequestError::IgnoredWithoutObservation(_)
))
),
"a selection by ignored state over an unobserving index is refused"
);
}
assert_eq!(summary_of(&run(&index, &query(&views, Selection::default()))).files, 1);
}
}