1use std::borrow::Cow;
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21
22use crate::classify::{ContentFamily, DetectionConfidence, DetectionSource};
23use crate::content::{AnalysisSet, ContentProvenance, CoverageReason, LogicalWordStats, MetricDef};
24use crate::control::ControlCoverage;
25use crate::engine_contract::{EntryKind, ScanScope};
26use crate::index::{EntryId, ExtTally, Index, RollUpScalars};
27use crate::query::query_request::{Basis, Request};
28use crate::query::query_selection::{
29 Bound, IgnoredEntries, NameIdentity, Selection, SizeMetric, SortKey,
30};
31use crate::query::{Rejection, ReportProvenance, TreeStatus, query_subtrees};
32
33#[derive(Clone, Copy, PartialEq, Eq, Debug)]
35pub enum ViewSpec {
36 List,
38 Tree,
40 Types,
42 Extensions,
44 Families,
46 Languages,
48 Documents,
50 Files,
52 Largest,
57 Recent,
61 Summary,
63}
64
65impl ViewSpec {
66 fn default_sort(self) -> SortKey {
68 match self {
69 Self::List
71 | Self::Tree
72 | Self::Types
73 | Self::Extensions
74 | Self::Families
75 | Self::Languages
76 | Self::Documents
77 | Self::Summary
78 | Self::Largest => SortKey::Size,
81 Self::Files => SortKey::Name,
86 Self::Recent => SortKey::Mtime,
87 }
88 }
89
90 const fn default_limit(self) -> Bound {
98 match self {
99 Self::List | Self::Files => Bound::All,
100 Self::Largest | Self::Recent => Bound::Limit(20),
101 _ => Bound::Limit(10),
102 }
103 }
104
105 const fn default_depth(self) -> Bound {
111 match self {
112 Self::Tree => Bound::Limit(2),
113 _ => Bound::All,
114 }
115 }
116
117 const fn files_only(self) -> bool {
122 matches!(self, Self::Largest | Self::Recent)
123 }
124
125 pub const ALL: [Self; 11] = [
130 Self::List,
131 Self::Summary,
132 Self::Tree,
133 Self::Families,
134 Self::Types,
135 Self::Extensions,
136 Self::Languages,
137 Self::Documents,
138 Self::Largest,
139 Self::Recent,
140 Self::Files,
141 ];
142
143 pub fn parse(value: &str) -> Result<Self, String> {
149 match value.trim().to_ascii_lowercase().as_str() {
150 "list" => Ok(Self::List),
151 "tree" => Ok(Self::Tree),
152 "types" => Ok(Self::Types),
153 "extensions" => Ok(Self::Extensions),
154 "families" => Ok(Self::Families),
155 "languages" => Ok(Self::Languages),
156 "documents" => Ok(Self::Documents),
157 "largest" => Ok(Self::Largest),
158 "recent" => Ok(Self::Recent),
159 "files" => Ok(Self::Files),
160 "summary" => Ok(Self::Summary),
161 _ => Err(format!("expected one of {}", Self::vocabulary())),
164 }
165 }
166
167 pub fn vocabulary() -> String {
169 let mut names: Vec<&str> = Self::ALL.iter().map(|view| view.label()).collect();
170 names.push("full");
171 names.join(", ")
172 }
173
174 pub const fn label(self) -> &'static str {
176 match self {
177 Self::List => "list",
178 Self::Tree => "tree",
179 Self::Types => "types",
180 Self::Extensions => "extensions",
181 Self::Families => "families",
182 Self::Languages => "languages",
183 Self::Documents => "documents",
184 Self::Largest => "largest",
185 Self::Recent => "recent",
186 Self::Files => "files",
187 Self::Summary => "summary",
188 }
189 }
190
191 pub const fn default_for(analysis: AnalysisSet) -> Self {
198 match (analysis.includes_code(), analysis.includes_words()) {
199 (true, true) => Self::Families,
200 (true, false) => Self::Languages,
201 (false, true) => Self::Documents,
202 (false, false) if analysis.is_enabled() => Self::Families,
203 (false, false) => Self::List,
204 }
205 }
206
207 pub fn resolve(
222 spec: Option<&str>,
223 analysis: AnalysisSet,
224 label: &str,
225 ) -> Result<(Vec<Self>, Vec<Self>), String> {
226 Self::resolve_rejecting(spec, analysis).map_err(|rejection| rejection.labeled(label))
227 }
228
229 pub(crate) fn resolve_rejecting(
232 spec: Option<&str>,
233 analysis: AnalysisSet,
234 ) -> Result<(Vec<Self>, Vec<Self>), Rejection> {
235 let Some(spec) = spec else {
236 return Ok((vec![Self::default_for(analysis)], Vec::new()));
237 };
238
239 let mut parsed: Vec<Self> = Vec::new();
240 let mut full_seen = false;
241 for raw in spec.split(',') {
242 let token = raw.trim();
243 if token.is_empty() {
244 return Err(Rejection::new(spec, "empty entry in the list"));
245 }
246 if token.eq_ignore_ascii_case("full") {
247 if full_seen || !parsed.is_empty() {
248 return Err(Rejection::new("full", Self::FULL_IS_EXCLUSIVE));
249 }
250 full_seen = true;
251 continue;
252 }
253 if full_seen {
254 return Err(Rejection::new("full", Self::FULL_IS_EXCLUSIVE));
255 }
256 let view = Self::parse(token).map_err(|expected| Rejection::new(token, expected))?;
257 if parsed.contains(&view) {
258 return Err(Rejection::new(spec, format!("{token:?} appears more than once")));
259 }
260 parsed.push(view);
261 }
262
263 if full_seen {
264 return Ok(Self::full_report(analysis));
265 }
266 Ok((parsed, Vec::new()))
267 }
268
269 pub const FULL_IS_EXCLUSIVE: &'static str =
276 "it names the whole report and cannot be combined with another view";
277
278 pub fn full_report(analysis: AnalysisSet) -> (Vec<Self>, Vec<Self>) {
283 Self::ALL
284 .into_iter()
285 .filter(|view| view.is_summary_view())
286 .partition(|view| !matches!(view, Self::Documents) || analysis.is_enabled())
287 }
288
289 pub const fn is_summary_view(self) -> bool {
294 !matches!(self, Self::List | Self::Files)
295 }
296}
297
298#[derive(Clone, Copy, Debug, PartialEq, Eq)]
317pub struct AxisNames {
318 pub view: &'static str,
320 pub format: &'static str,
322 pub analyze: &'static str,
324 pub control_budget: &'static str,
326 pub control_line_limit: &'static str,
328 pub exclude_ignored: &'static str,
330 pub only_ignored: &'static str,
332 pub read_controls: &'static str,
334 pub scan_depth: &'static str,
336 pub one_filesystem: &'static str,
338 pub follow_symlinks: &'static str,
340 pub include: &'static str,
342 pub modified_since: &'static str,
344 pub modified_before: &'static str,
346 pub kind: &'static str,
348 pub ignored: &'static str,
350 pub depth: &'static str,
352 pub limit: &'static str,
354 pub sort: &'static str,
356 pub size: &'static str,
358 pub words_per_page: &'static str,
360 pub cache: &'static str,
362 pub watch: &'static str,
364}
365
366impl AxisNames {
367 pub const FLAGS: Self = Self {
376 view: "--view",
377 format: "--format",
378 analyze: "--analyze",
379 control_budget: "--gitignore-budget",
380 control_line_limit: "--gitignore-line-limit",
381 exclude_ignored: "--exclude-ignored",
382 only_ignored: "--only-ignored",
383 read_controls: "--no-gitignore",
384 scan_depth: "--scan-depth",
385 one_filesystem: "--one-filesystem",
386 follow_symlinks: "follow_symlinks",
387 include: "--include",
388 modified_since: "--modified-since",
389 modified_before: "--modified-before",
390 kind: "--kind",
391 ignored: "--exclude-ignored/--only-ignored",
392 depth: "--depth",
393 limit: "--limit",
394 sort: "--sort",
395 size: "--size",
396 words_per_page: "--words-per-page",
397 cache: "--cache",
398 watch: "--watch",
399 };
400
401 pub const FIELDS: Self = Self {
413 view: "view",
414 format: "format",
415 analyze: "analyze",
416 control_budget: "control_budget",
417 control_line_limit: "control_line_limit",
418 exclude_ignored: "ignored=exclude",
419 only_ignored: "ignored=only",
420 read_controls: "read_controls",
421 scan_depth: "max_depth",
422 one_filesystem: "one_filesystem",
423 follow_symlinks: "follow_symlinks",
424 include: "include",
425 modified_since: "modified_since",
426 modified_before: "modified_before",
427 kind: "kind",
428 ignored: "ignored",
429 depth: "depth",
430 limit: "limit",
431 sort: "sort",
432 size: "size",
433 words_per_page: "words_per_page",
434 cache: "cache policy",
435 watch: "watch",
436 };
437}
438
439impl Default for AxisNames {
440 fn default() -> Self {
441 Self::FIELDS
442 }
443}
444
445#[derive(Clone, Debug)]
447pub struct Query {
448 pub selection: Selection,
450 pub views: Vec<ViewSpec>,
452 pub format: crate::report_format::Format,
454 pub omitted_views: Vec<ViewSpec>,
460 pub axes: &'static AxisNames,
465 pub words_per_page: u64,
467}
468
469impl Default for Query {
470 fn default() -> Self {
471 Self {
472 selection: Selection::default(),
473 views: Vec::new(),
474 format: crate::report_format::Format::Text,
475 omitted_views: Vec::new(),
476 axes: &AxisNames::FIELDS,
477 words_per_page: crate::query::Request::DEFAULTS.words_per_page,
478 }
479 }
480}
481
482impl Query {
483 pub fn tree_for(&self, view: ViewSpec) -> bool {
485 use crate::report_format::Format;
486 match view {
487 ViewSpec::List => matches!(self.format, Format::Text | Format::Tree),
488 ViewSpec::Tree => !matches!(self.format, Format::Paths | Format::Long),
489 ViewSpec::Files => self.format == Format::Tree,
490 _ => false,
491 }
492 }
493
494 pub(crate) fn needs_selection_walk(&self) -> bool {
496 !self.selection.is_unfiltered()
497 || self.views.iter().any(|view| {
498 matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
499 && !self.tree_for(*view)
500 })
501 }
502
503 pub fn limit_for(&self, view: ViewSpec) -> Bound {
505 self.selection.limit.unwrap_or_else(|| {
506 if self.tree_for(view) {
507 ViewSpec::Tree.default_limit()
508 } else if view == ViewSpec::Tree {
509 Bound::All
510 } else {
511 view.default_limit()
512 }
513 })
514 }
515
516 pub fn depth_for(&self, view: ViewSpec) -> Bound {
518 self.selection.depth.unwrap_or_else(|| {
519 if self.tree_for(view) { ViewSpec::Tree.default_depth() } else { view.default_depth() }
520 })
521 }
522}
523
524#[derive(Clone, Copy, PartialEq, Eq, Debug)]
526pub enum ReportSource {
527 ColdScan,
529 WarmRevalidate,
531 CacheOnly,
533}
534
535#[derive(Clone, Debug)]
537pub struct TreeNode {
538 pub path: PathBuf,
540 pub name: String,
542 pub kind: EntryKind,
544 pub bytes: u64,
546 pub allocated: u64,
548 pub files: u64,
550 pub dirs: u64,
552 pub ignored: Option<IgnoredTally>,
559 pub newest_mtime_ns: Option<i64>,
561 pub children: Vec<TreeNode>,
563 pub truncated: bool,
565}
566
567impl Drop for TreeNode {
568 fn drop(&mut self) {
575 let mut pending = std::mem::take(&mut self.children);
576 while let Some(mut node) = pending.pop() {
577 pending.extend(std::mem::take(&mut node.children));
578 }
579 }
580}
581
582#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
589pub struct IgnoredTally {
590 pub files: u64,
592 pub dirs: u64,
594 pub bytes: u64,
596 pub allocated: u64,
598}
599
600impl IgnoredTally {
601 pub(crate) fn between(all: RollUpScalars, unignored: RollUpScalars) -> Self {
603 Self {
604 files: all.files.saturating_sub(unignored.files),
605 dirs: all.dirs.saturating_sub(unignored.dirs),
606 bytes: all.bytes.saturating_sub(unignored.bytes),
607 allocated: all.allocated.saturating_sub(unignored.allocated),
608 }
609 }
610
611 fn add(&mut self, other: Self) {
612 self.files = self.files.saturating_add(other.files);
613 self.dirs = self.dirs.saturating_add(other.dirs);
614 self.bytes = self.bytes.saturating_add(other.bytes);
615 self.allocated = self.allocated.saturating_add(other.allocated);
616 }
617}
618
619#[derive(Clone, Debug)]
621pub struct TypeRow {
622 pub extension: String,
624 pub files: u64,
626 pub bytes: u64,
628 pub allocated: u64,
630 pub ignored: Option<IgnoredTally>,
632}
633
634#[derive(Clone, Copy, PartialEq, Eq, Debug)]
636pub enum MetricGroup {
637 Type,
639 Family,
641}
642
643#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
645pub struct MetricShare {
646 pub numerator: u64,
648 pub denominator: u64,
650}
651
652#[derive(Clone, Copy, PartialEq, Eq, Debug)]
654pub enum ShareMetric {
655 ApparentBytes,
657 AllocatedBytes,
659 CodeLines,
661 DocumentWords,
663 RawWords,
665}
666
667impl ShareMetric {
668 pub const fn as_str(self) -> &'static str {
670 match self {
671 Self::ApparentBytes => "apparent_bytes",
672 Self::AllocatedBytes => "allocated_bytes",
673 Self::CodeLines => "code_lines",
674 Self::DocumentWords => "document_words",
675 Self::RawWords => "raw_words",
676 }
677 }
678}
679
680#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
682pub struct ReportMetricValues {
683 pub physical_lines: Option<u64>,
685 pub blank_lines: Option<u64>,
687 pub nonblank_lines: Option<u64>,
689 pub raw_words: Option<u64>,
691 pub code_lines: Option<u64>,
693 pub comment_lines: Option<u64>,
695 pub code_blank_lines: Option<u64>,
697 pub logical_words: Option<u64>,
699 pub paragraphs: Option<u64>,
701 pub visible_words: Option<u64>,
703 pub visible_logical_words: Option<u64>,
705 pub document_words: Option<u64>,
707}
708
709impl ReportMetricValues {
710 fn for_analysis(analysis: AnalysisSet) -> Self {
711 let lines = analysis.is_enabled().then_some(0);
712 let code = analysis.includes_code().then_some(0);
713 let words = analysis.includes_words().then_some(0);
714 Self {
715 physical_lines: lines,
716 blank_lines: lines,
717 nonblank_lines: lines,
718 raw_words: lines,
719 code_lines: code,
720 comment_lines: code,
721 code_blank_lines: code,
722 logical_words: words,
723 paragraphs: words,
724 visible_words: words,
725 visible_logical_words: words,
726 document_words: words,
727 }
728 }
729
730 fn add_assign(&mut self, other: &Self) {
731 add_optional(&mut self.physical_lines, other.physical_lines);
732 add_optional(&mut self.blank_lines, other.blank_lines);
733 add_optional(&mut self.nonblank_lines, other.nonblank_lines);
734 add_optional(&mut self.raw_words, other.raw_words);
735 add_optional(&mut self.code_lines, other.code_lines);
736 add_optional(&mut self.comment_lines, other.comment_lines);
737 add_optional(&mut self.code_blank_lines, other.code_blank_lines);
738 add_optional(&mut self.paragraphs, other.paragraphs);
739 add_optional(&mut self.visible_words, other.visible_words);
740 }
741}
742
743fn add_optional(total: &mut Option<u64>, value: Option<u64>) {
744 if let (Some(total), Some(value)) = (total, value) {
745 *total = total.saturating_add(value);
746 }
747}
748
749#[derive(Clone, Debug)]
751pub struct MetricRow {
752 pub analysis: AnalysisSet,
754 pub id: String,
756 pub family: ContentFamily,
758 pub files: u64,
760 pub bytes: u64,
762 pub allocated: u64,
764 pub analyzed_files: u64,
766 pub metrics: ReportMetricValues,
768 pub(crate) logical_word_stats: LogicalWordStats,
770 pub(crate) visible_logical_word_stats: LogicalWordStats,
772 pub document_raw_words: u64,
774 pub document_word_stats: LogicalWordStats,
776 pub document_metric_files: u64,
778 pub coverage: BTreeMap<CoverageReason, u64>,
780 pub lines_coverage: BTreeMap<CoverageReason, u64>,
782 pub code_coverage: Option<BTreeMap<CoverageReason, u64>>,
784 pub words_coverage: Option<BTreeMap<CoverageReason, u64>>,
786 pub detection_sources: BTreeMap<DetectionSource, u64>,
788 pub detection_confidence: BTreeMap<DetectionConfidence, u64>,
790 pub generated_files: u64,
792 pub vendored_files: u64,
794 pub documentation_files: u64,
796 pub share: MetricShare,
798}
799
800impl MetricRow {
801 pub fn metric_value(&self, metric: &MetricDef) -> Option<u64> {
803 if !self.analysis.contains(metric.owner) {
804 return None;
805 }
806 match metric.name {
807 "physical_lines" => self.metrics.physical_lines,
808 "blank_lines" => self.metrics.blank_lines,
809 "nonblank_lines" => self.metrics.nonblank_lines,
810 "raw_words" => self.metrics.raw_words,
811 "code_lines" => self.metrics.code_lines,
812 "comment_lines" => self.metrics.comment_lines,
813 "code_blank_lines" => self.metrics.code_blank_lines,
814 "logical_words" => self.metrics.logical_words,
815 "paragraphs" => self.metrics.paragraphs,
816 "visible_words" => self.metrics.visible_words,
817 "visible_logical_words" => self.metrics.visible_logical_words,
818 "document_words" => self.metrics.document_words,
819 _ => None,
820 }
821 }
822
823 fn finish_derived_metrics(&mut self) {
824 if self.analysis.includes_words() {
825 self.metrics.logical_words = Some(self.logical_word_stats.logical_words());
826 self.metrics.visible_logical_words =
827 Some(self.visible_logical_word_stats.logical_words());
828 self.metrics.document_words = Some(self.document_word_stats.logical_words());
829 }
830 }
831}
832
833#[derive(Clone, Debug)]
835pub struct MetricSummary {
836 pub group: MetricGroup,
838 pub total: MetricRow,
840 pub rows: Vec<MetricRow>,
842 pub total_rows: usize,
844 pub share_metric: ShareMetric,
846 pub words_per_page: u64,
848}
849
850#[derive(Clone, Debug)]
852pub struct ContentReportMetadata {
853 pub profile: AnalysisSet,
855 pub provenance: ContentProvenance,
857}
858
859#[derive(Clone, Debug)]
862pub struct FileRow {
863 pub path: PathBuf,
865 pub kind: EntryKind,
867 pub bytes: u64,
869 pub allocated: u64,
871 pub mtime_ns: i64,
873 pub files: Option<u64>,
875 pub dirs: Option<u64>,
877 pub complete: Option<bool>,
881 pub age_ns: Option<i128>,
884 pub ignored: Option<bool>,
887}
888
889#[derive(Clone, Copy, Debug, Default)]
891pub struct SummaryRow {
892 pub files: u64,
894 pub dirs: u64,
896 pub bytes: u64,
898 pub allocated: u64,
900 pub ignored: Option<IgnoredTally>,
903 pub newest_mtime_ns: Option<i64>,
905}
906
907#[derive(Clone, Debug)]
909pub enum Section {
910 Tree {
912 view: ViewSpec,
914 root: TreeNode,
916 },
917 Extensions {
919 rows: Vec<TypeRow>,
921 total: usize,
923 },
924 Metrics {
926 view: ViewSpec,
928 summary: Box<MetricSummary>,
930 },
931 Files {
936 view: ViewSpec,
938 rows: Vec<FileRow>,
940 total: usize,
946 },
947 Summary(SummaryRow),
949}
950
951impl Section {
952 pub fn view(&self) -> ViewSpec {
954 match self {
955 Self::Extensions { .. } => ViewSpec::Extensions,
956 Self::Tree { view, .. } | Self::Metrics { view, .. } | Self::Files { view, .. } => {
957 *view
958 }
959 Self::Summary(_) => ViewSpec::Summary,
960 }
961 }
962}
963
964#[derive(Clone, Debug)]
966pub struct Report {
967 pub age_reference_ns: Option<i64>,
969 pub format: crate::report_format::Format,
971 pub status: TreeStatus,
973 pub provenance: ReportProvenance,
975 pub scope: ScanScope,
980 pub requested_analysis: AnalysisSet,
982 pub requested_views: Vec<ViewSpec>,
984 pub omitted_views: Vec<ViewSpec>,
986 pub root: PathBuf,
988 pub notes: Vec<String>,
995 pub size: SizeMetric,
1001 pub analysis: Option<ContentReportMetadata>,
1003 pub ignored_entries: IgnoredEntries,
1009 pub ignore_rules: ControlCoverage,
1016 pub sections: Vec<Section>,
1018}
1019
1020pub(crate) fn display_notes(query: &Query, ignore_rules: &ControlCoverage) -> Vec<String> {
1027 let mut notes = Vec::new();
1028 if !query.omitted_views.is_empty() {
1029 let names: Vec<&str> = query.omitted_views.iter().map(|view| view.label()).collect();
1030 notes.push(format!(
1031 "note: omitted {} — requires content analysis: add {} lines, code, words, or all",
1032 names.join(", "),
1033 query.axes.analyze
1034 ));
1035 }
1036 notes.extend(refused_controls_note(ignore_rules, query.axes));
1037 notes
1038}
1039
1040const REFUSED_DIRECTORIES_NAMED: usize = 5;
1042
1043fn refused_controls_note(ignore_rules: &ControlCoverage, axes: &AxisNames) -> Option<String> {
1052 use crate::control::ControlRefusalReason::{Budget, LineLimit};
1053
1054 let ControlCoverage::Observed(observed) = ignore_rules else {
1055 return None;
1056 };
1057 if observed.is_complete() {
1058 return None;
1059 }
1060 let every_listed = observed.lists_every_refusal();
1061 let listed =
1062 |reason| observed.refusals.iter().filter(|refusal| refusal.reason == reason).count();
1063 let fired: Vec<_> = [Budget, LineLimit]
1066 .into_iter()
1067 .filter(|reason| {
1068 listed(*reason) > 0 || (!every_listed && observed.limits.limit_for(*reason).is_some())
1069 })
1070 .collect();
1071 let size = |reason| {
1072 observed.limits.limit_for(reason).map(|bytes| {
1073 crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX))
1074 })
1075 };
1076 let over = |reason| {
1079 let (lead, noun) = match reason {
1080 Budget => ("over", "ignore-rule budget"),
1081 LineLimit => ("with a line over", "line limit"),
1082 };
1083 size(reason).map_or_else(
1084 || format!("{lead} the {noun}"),
1085 |size| format!("{lead} the {size} {noun}"),
1086 )
1087 };
1088 let why = if every_listed {
1089 let parts: Vec<String> =
1090 fired.iter().map(|reason| format!("{} {}", listed(*reason), over(*reason))).collect();
1091 parts.join(", ")
1092 } else {
1093 let parts: Vec<String> = fired.iter().map(|reason| over(*reason)).collect();
1094 parts.join(" or ")
1095 };
1096
1097 let shown = observed.refusals.len().min(REFUSED_DIRECTORIES_NAMED);
1098 let mut directories: Vec<String> = observed.refusals[..shown]
1099 .iter()
1100 .map(|refusal| match refusal.path.parent() {
1101 Some(parent) if !parent.as_os_str().is_empty() => parent.display().to_string(),
1102 _ => ".".to_string(),
1103 })
1104 .collect();
1105 let unnamed = observed.refused.saturating_sub(u64::try_from(shown).unwrap_or(u64::MAX));
1106 if unnamed > 0 {
1107 directories.push(format!("{} more", crate::report_format::human_count(unnamed)));
1108 }
1109
1110 let raises: Vec<String> = fired
1112 .iter()
1113 .filter_map(|reason| {
1114 let knob = match reason {
1115 Budget => axes.control_budget,
1116 LineLimit => axes.control_line_limit,
1117 };
1118 size(*reason).map(|size| format!("{knob} above {size}"))
1119 })
1120 .collect();
1121 let remedy = match raises.as_slice() {
1122 [] => String::new(),
1123 [raise] => format!(" To apply them, raise {raise}, or set it to all"),
1124 raises => format!(" To apply them, raise {}, or set them to all", raises.join(" and ")),
1125 };
1126 let files = crate::report_format::human_count(observed.refused);
1127 let noun = if observed.refused == 1 { "file" } else { "files" };
1128 Some(format!(
1129 "note: {files} .gitignore {noun} not applied ({why}), so ignored shares under {} are \
1130 not exact; sizes are.{remedy}",
1131 directories.join(", ")
1132 ))
1133}
1134
1135pub fn report(
1149 index: &Index,
1150 request: &Request,
1151 generated_at: std::time::SystemTime,
1152) -> crate::Result<Report> {
1153 report_in(index, request, generated_at, NameIdentity::Native)
1154}
1155
1156pub(crate) fn report_in(
1161 index: &Index,
1162 request: &Request,
1163 generated_at: std::time::SystemTime,
1164 identity: NameIdentity,
1165) -> crate::Result<Report> {
1166 request.validate_read(&Basis::held_by(index)).map_err(crate::Error::InvalidRequest)?;
1170
1171 let query = &request.query;
1172 let content = request.basis.content;
1173 let walked = query.needs_selection_walk().then(|| walk(index, &query.selection, identity));
1176 let row_consumers =
1181 query.views.iter().copied().filter(|view| needs_unfiltered_entry_rows(*view)).count();
1182 let unfiltered_rows = (walked.is_none() && row_consumers > 1).then(|| every_entry(index));
1183
1184 let mut sections: Vec<Section> = query
1185 .views
1186 .iter()
1187 .map(|view| {
1188 build_section(*view, index, query, content, walked.as_ref(), unfiltered_rows.as_deref())
1189 })
1190 .collect();
1191
1192 let age_reference_ns = crate::query::system_time_to_nanos(request.now);
1193 for section in &mut sections {
1194 if let Section::Files { rows, .. } = section {
1195 for row in rows {
1196 row.age_ns = match row.complete {
1200 Some(false) => None,
1201 Some(true) | None => {
1202 age_reference_ns.map(|now| i128::from(now) - i128::from(row.mtime_ns))
1203 }
1204 };
1205 }
1206 }
1207 }
1208 let ignore_rules = index.control_coverage();
1209 Ok(Report {
1210 age_reference_ns,
1211 format: query.format,
1212 notes: display_notes(query, &ignore_rules),
1213 status: TreeStatus::of(index, request),
1214 provenance: ReportProvenance::of(index, content, generated_at),
1215 scope: index.scope(),
1216 requested_analysis: content,
1217 requested_views: query.views.clone(),
1218 omitted_views: query.omitted_views.clone(),
1219 root: index.root_path().to_path_buf(),
1220 size: query.selection.size,
1221 analysis: index.content().and_then(|held| {
1222 let wanted = index.content_identity(content);
1223 let projected = held.admit(&wanted)?;
1224 Some(ContentReportMetadata {
1225 profile: projected.identity().analysis,
1226 provenance: projected.identity().record_provenance(),
1227 })
1228 }),
1229 ignored_entries: query.selection.ignored,
1230 ignore_rules,
1231 sections,
1232 })
1233}
1234
1235pub(crate) fn report_summary(
1241 root: &Path,
1242 scope: ScanScope,
1243 request: &Request,
1244 summary: SummaryRow,
1245 status: TreeStatus,
1246 provenance: ReportProvenance,
1247) -> Report {
1248 Report {
1249 age_reference_ns: crate::query::system_time_to_nanos(request.now),
1250 format: request.query.format,
1251 notes: Vec::new(),
1253 status,
1254 provenance,
1255 scope,
1256 requested_analysis: AnalysisSet::NONE,
1257 requested_views: vec![ViewSpec::Summary],
1258 omitted_views: Vec::new(),
1259 root: root.to_path_buf(),
1260 size: request.query.selection.size,
1261 analysis: None,
1264 ignored_entries: IgnoredEntries::Include,
1266 ignore_rules: ControlCoverage::NotObserved,
1268 sections: vec![Section::Summary(SummaryRow { ignored: None, ..summary })],
1269 }
1270}
1271
1272struct Walked {
1274 observed: bool,
1276 per_directory: BTreeMap<EntryId, SummaryRow>,
1281 by_ext: BTreeMap<String, ExtTally>,
1283 ignored_by_ext: BTreeMap<String, ExtTally>,
1285 rows: Vec<FileRow>,
1287 members: Vec<FileRow>,
1289 visible: BTreeSet<EntryId>,
1291}
1292
1293impl Walked {
1294 fn summary_of(&self, id: EntryId) -> SummaryRow {
1296 let mut row = self.per_directory.get(&id).copied().unwrap_or_default();
1297 row.ignored = self.observed.then(|| row.ignored.unwrap_or_default());
1298 row
1299 }
1300}
1301
1302fn unfiltered_summary(index: &Index, id: EntryId) -> SummaryRow {
1305 let observed = index.observes_controls();
1306 let Some((all, unignored)) = index.partition_scalars_of(id) else {
1307 return SummaryRow {
1308 ignored: observed.then(IgnoredTally::default),
1309 ..SummaryRow::default()
1310 };
1311 };
1312 SummaryRow {
1313 ignored: observed.then(|| IgnoredTally::between(all, unignored)),
1314 ..summary_from_scalars(all)
1315 }
1316}
1317
1318fn walk(index: &Index, selection: &Selection, identity: NameIdentity) -> Walked {
1323 let observed = index.observes_controls();
1324 let mut walked = Walked {
1325 observed,
1326 per_directory: BTreeMap::new(),
1327 by_ext: BTreeMap::new(),
1328 ignored_by_ext: BTreeMap::new(),
1329 rows: Vec::new(),
1330 members: Vec::new(),
1331 visible: BTreeSet::new(),
1332 };
1333 debug_assert!(
1336 observed || selection.ignored == IgnoredEntries::Include,
1337 "a selection by ignored state over an unobserving index is refused before the walk"
1338 );
1339
1340 let directories = (selection.kinds.is_empty() || selection.kinds.contains(&EntryKind::Dir))
1343 .then(|| query_subtrees::measure(index, selection, identity));
1344 let mut stack = vec![(EntryId::ROOT, PathBuf::new(), false, false)];
1346 while let Some((id, path, expanded, covered)) = stack.pop() {
1347 if expanded {
1348 let mut total = walked.per_directory.remove(&id).unwrap_or_default();
1353 if let Some(children) = index.children_of(id) {
1354 for (_, child) in children {
1355 if let Some(sub) = walked.per_directory.get(&child) {
1356 let sub = *sub;
1357 merge_summary(&mut total, &sub);
1358 }
1359 }
1360 }
1361 if total.files > 0 || total.dirs > 0 {
1362 walked.visible.insert(id);
1363 }
1364 walked.per_directory.insert(id, total);
1365 continue;
1366 }
1367
1368 stack.push((id, path.clone(), true, covered));
1369 let Some(children) = index.children_of(id) else {
1370 continue;
1371 };
1372 let children: Vec<(PathBuf, EntryId)> =
1373 children.map(|(name, child)| (path.join(name), child)).collect();
1374
1375 for (child_path, child) in children {
1376 let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
1377 continue;
1378 };
1379 let file_name = child_path.file_name().unwrap_or_default();
1383 let ignored = index.ignored_bit_of(child).unwrap_or(false);
1384 let mut measured = *attrs;
1385 let subtree = directories.as_ref().and_then(|values| values.get(&child)).copied();
1386 if let Some(subtree) = subtree {
1387 measured.size = subtree.bytes;
1388 measured.allocated = subtree.allocated;
1389 measured.mtime_ns = subtree.mtime_ns;
1390 }
1391 let (pruned, matches) = query_subtrees::with_candidate(
1392 &child_path,
1393 kind,
1394 measured,
1395 ignored,
1396 identity,
1397 |candidate| {
1398 (query_subtrees::pruned(selection, &candidate), selection.admits(&candidate))
1399 },
1400 );
1401 if pruned {
1402 continue;
1403 }
1404 let matches = matches
1411 && (selection.modified.is_unbounded()
1412 || subtree.is_none_or(|subtree| subtree.complete));
1413 let row = FileRow {
1414 path: child_path.clone(),
1415 kind,
1416 bytes: measured.size,
1417 allocated: measured.allocated,
1418 mtime_ns: measured.mtime_ns,
1419 files: subtree.map(|subtree| subtree.files),
1420 dirs: subtree.map(|subtree| subtree.dirs),
1421 complete: subtree.map(|subtree| subtree.complete),
1422 age_ns: None,
1423 ignored: observed.then_some(ignored),
1424 };
1425 if matches {
1426 walked.rows.push(row.clone());
1427 }
1428 if matches || (covered && selection.ignored.admits(ignored)) {
1429 if kind == EntryKind::File {
1430 walked.members.push(row);
1431 } else if kind == EntryKind::Dir {
1432 walked.visible.insert(child);
1433 }
1434
1435 if kind == EntryKind::File {
1436 let own = walked.per_directory.entry(id).or_default();
1437 own.files += 1;
1438 own.bytes += attrs.size;
1439 own.allocated += attrs.allocated;
1440 own.newest_mtime_ns = Some(
1441 own.newest_mtime_ns.map_or(attrs.mtime_ns, |seen| seen.max(attrs.mtime_ns)),
1442 );
1443 let bucket = crate::classify::ext_bucket(file_name);
1444 if ignored {
1445 own.ignored.get_or_insert_with(IgnoredTally::default).add(IgnoredTally {
1446 files: 1,
1447 dirs: 0,
1448 bytes: attrs.size,
1449 allocated: attrs.allocated,
1450 });
1451 let tally = walked.ignored_by_ext.entry(bucket.clone()).or_default();
1452 tally.files += 1;
1453 tally.bytes += attrs.size;
1454 tally.allocated += attrs.allocated;
1455 }
1456 let tally = walked.by_ext.entry(bucket).or_default();
1457 tally.files += 1;
1458 tally.bytes += attrs.size;
1459 tally.allocated += attrs.allocated;
1460 } else if kind == EntryKind::Dir {
1461 let own = walked.per_directory.entry(id).or_default();
1467 own.dirs += 1;
1468 if ignored {
1469 own.ignored.get_or_insert_with(IgnoredTally::default).dirs += 1;
1470 }
1471 }
1472 }
1473
1474 if kind == EntryKind::Dir {
1475 stack.push((child, child_path, false, covered || matches));
1476 }
1477 }
1478 }
1479
1480 walked
1481}
1482
1483fn merge_summary(into: &mut SummaryRow, from: &SummaryRow) {
1485 into.files += from.files;
1486 into.dirs += from.dirs;
1487 into.bytes += from.bytes;
1488 into.allocated += from.allocated;
1489 into.newest_mtime_ns = match (into.newest_mtime_ns, from.newest_mtime_ns) {
1490 (Some(left), Some(right)) => Some(left.max(right)),
1491 (left, right) => left.or(right),
1492 };
1493 if let Some(share) = from.ignored {
1494 into.ignored.get_or_insert_with(IgnoredTally::default).add(share);
1495 }
1496}
1497
1498fn needs_unfiltered_entry_rows(view: ViewSpec) -> bool {
1500 matches!(
1501 view,
1502 ViewSpec::Types
1503 | ViewSpec::Families
1504 | ViewSpec::Languages
1505 | ViewSpec::Documents
1506 | ViewSpec::Files
1507 | ViewSpec::Largest
1508 | ViewSpec::Recent
1509 )
1510}
1511
1512fn entry_rows<'a>(
1515 index: &Index,
1516 walked: Option<&'a Walked>,
1517 unfiltered_rows: Option<&'a [FileRow]>,
1518) -> Cow<'a, [FileRow]> {
1519 match (walked, unfiltered_rows) {
1520 (Some(walked), _) => Cow::Borrowed(&walked.rows),
1521 (None, Some(rows)) => Cow::Borrowed(rows),
1522 (None, None) => Cow::Owned(every_entry(index)),
1523 }
1524}
1525
1526fn build_section(
1528 view: ViewSpec,
1529 index: &Index,
1530 query: &Query,
1531 content: AnalysisSet,
1532 walked: Option<&Walked>,
1533 unfiltered_rows: Option<&[FileRow]>,
1534) -> Section {
1535 if query.tree_for(view) {
1536 return Section::Tree { view, root: tree_node(index, query, walked) };
1537 }
1538 match view {
1539 ViewSpec::Summary => Section::Summary(match walked {
1540 None => unfiltered_summary(index, EntryId::ROOT),
1541 Some(walked) => walked.summary_of(EntryId::ROOT),
1542 }),
1543 ViewSpec::Extensions => {
1544 let (rows, total) = extension_rows(index, query, walked);
1545 Section::Extensions { rows, total }
1546 }
1547 ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents => {
1548 Section::Metrics {
1549 view,
1550 summary: Box::new(metric_summary(
1551 view,
1552 index,
1553 query,
1554 content,
1555 walked,
1556 unfiltered_rows,
1557 )),
1558 }
1559 }
1560 ViewSpec::List
1561 | ViewSpec::Tree
1562 | ViewSpec::Files
1563 | ViewSpec::Largest
1564 | ViewSpec::Recent => {
1565 let (rows, total) = file_rows(view, index, query, walked, unfiltered_rows);
1566 Section::Files { view, rows, total }
1567 }
1568 }
1569}
1570
1571fn summary_from_scalars(rollup: RollUpScalars) -> SummaryRow {
1573 SummaryRow {
1574 files: rollup.files,
1575 dirs: rollup.dirs,
1576 bytes: rollup.bytes,
1577 allocated: rollup.allocated,
1578 ignored: None,
1579 newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
1580 }
1581}
1582
1583fn ignored_by_extension(
1585 all: &BTreeMap<String, ExtTally>,
1586 unignored: &BTreeMap<String, ExtTally>,
1587) -> BTreeMap<String, ExtTally> {
1588 all.iter()
1589 .filter_map(|(extension, tally)| {
1590 let kept = unignored.get(extension).copied().unwrap_or_default();
1591 let ignored = ExtTally {
1592 files: tally.files.saturating_sub(kept.files),
1593 bytes: tally.bytes.saturating_sub(kept.bytes),
1594 allocated: tally.allocated.saturating_sub(kept.allocated),
1595 };
1596 (ignored.files > 0).then(|| (extension.clone(), ignored))
1597 })
1598 .collect()
1599}
1600
1601fn extension_rows(index: &Index, query: &Query, walked: Option<&Walked>) -> (Vec<TypeRow>, usize) {
1603 let observed = index.observes_controls();
1604 let (tallies, ignored): (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>) = match walked
1605 {
1606 None => match index.partition_total() {
1607 Ok(partitions) => {
1608 let ignored =
1609 ignored_by_extension(&partitions.all.by_ext, &partitions.unignored.by_ext);
1610 (partitions.all.by_ext, ignored)
1611 }
1612 Err(_not_observed) => (index.total().by_ext, BTreeMap::new()),
1615 },
1616 Some(walked) => (walked.by_ext.clone(), walked.ignored_by_ext.clone()),
1617 };
1618
1619 let mut rows: Vec<TypeRow> = tallies
1620 .into_iter()
1621 .map(|(extension, tally)| {
1622 let share = ignored.get(&extension).copied().unwrap_or_default();
1623 TypeRow {
1624 files: tally.files,
1625 bytes: tally.bytes,
1626 allocated: tally.allocated,
1627 ignored: observed.then_some(IgnoredTally {
1628 files: share.files,
1629 dirs: 0,
1630 bytes: share.bytes,
1631 allocated: share.allocated,
1632 }),
1633 extension,
1634 }
1635 })
1636 .collect();
1637
1638 sort_rows(
1639 &mut rows,
1640 query,
1641 ViewSpec::Extensions,
1642 |row, metric| match metric {
1643 SizeMetric::Apparent => row.bytes,
1644 SizeMetric::Allocated => row.allocated,
1645 },
1646 |row| row.files,
1647 |_| None,
1648 |row| row.extension.clone(),
1649 );
1650 let total = truncate(&mut rows, query.limit_for(ViewSpec::Extensions));
1651 (rows, total)
1652}
1653
1654fn metric_summary(
1655 view: ViewSpec,
1656 index: &Index,
1657 query: &Query,
1658 content: AnalysisSet,
1659 walked: Option<&Walked>,
1660 unfiltered_rows: Option<&[FileRow]>,
1661) -> MetricSummary {
1662 let group = if view == ViewSpec::Families { MetricGroup::Family } else { MetricGroup::Type };
1663 let files = walked.map_or_else(
1664 || entry_rows(index, None, unfiltered_rows),
1665 |walked| Cow::Borrowed(walked.members.as_slice()),
1666 );
1667 let mut grouped = BTreeMap::<String, MetricRow>::new();
1668 let wanted = index.content_identity(content);
1669 let held = index.content().and_then(|held| held.admit(&wanted));
1670 for file in files.iter().filter(|row| row.kind == EntryKind::File) {
1671 let cached = held.and_then(|content| content.file(&file.path));
1672 let classification = index.classify(&file.path);
1673 let included = match view {
1674 ViewSpec::Languages => classification.family == ContentFamily::Code,
1675 ViewSpec::Documents => {
1676 matches!(classification.family, ContentFamily::Prose | ContentFamily::Markup)
1677 }
1678 ViewSpec::Types | ViewSpec::Families => true,
1679 ViewSpec::List
1680 | ViewSpec::Tree
1681 | ViewSpec::Extensions
1682 | ViewSpec::Files
1683 | ViewSpec::Largest
1684 | ViewSpec::Recent
1685 | ViewSpec::Summary => false,
1686 };
1687 if !included {
1688 continue;
1689 }
1690 let id = match group {
1691 MetricGroup::Type => classification.file_type.as_str().to_string(),
1692 MetricGroup::Family => classification.family.as_str().to_string(),
1693 };
1694 let row = grouped.entry(id.clone()).or_insert_with(|| MetricRow {
1695 analysis: content,
1696 id,
1697 family: classification.family,
1698 files: 0,
1699 bytes: 0,
1700 allocated: 0,
1701 analyzed_files: 0,
1702 metrics: ReportMetricValues::for_analysis(content),
1703 logical_word_stats: LogicalWordStats::default(),
1704 visible_logical_word_stats: LogicalWordStats::default(),
1705 document_raw_words: 0,
1706 document_word_stats: LogicalWordStats::default(),
1707 document_metric_files: 0,
1708 coverage: BTreeMap::new(),
1709 lines_coverage: BTreeMap::new(),
1710 code_coverage: content.includes_code().then(BTreeMap::new),
1711 words_coverage: content.includes_words().then(BTreeMap::new),
1712 detection_sources: BTreeMap::new(),
1713 detection_confidence: BTreeMap::new(),
1714 generated_files: 0,
1715 vendored_files: 0,
1716 documentation_files: 0,
1717 share: MetricShare::default(),
1718 });
1719 row.files = row.files.saturating_add(1);
1720 row.bytes = row.bytes.saturating_add(file.bytes);
1721 row.allocated = row.allocated.saturating_add(file.allocated);
1722 let detection = cached.map_or(
1723 (classification.source, classification.confidence, classification.flags),
1724 |record| (record.detection.source, record.detection.confidence, record.detection.flags),
1725 );
1726 *row.detection_sources.entry(detection.0).or_default() += 1;
1727 *row.detection_confidence.entry(detection.1).or_default() += 1;
1728 row.generated_files = row.generated_files.saturating_add(u64::from(detection.2.generated));
1729 row.vendored_files = row.vendored_files.saturating_add(u64::from(detection.2.vendored));
1730 row.documentation_files =
1731 row.documentation_files.saturating_add(u64::from(detection.2.documentation));
1732 if let Some(record) = cached {
1733 *row.lines_coverage.entry(record.lines.coverage()).or_default() += 1;
1734 if let (Some(coverage), Some(outcome)) = (&mut row.code_coverage, record.code) {
1735 *coverage.entry(outcome.coverage()).or_default() += 1;
1736 }
1737 if let (Some(coverage), Some(outcome)) = (&mut row.words_coverage, record.words) {
1738 *coverage.entry(outcome.coverage()).or_default() += 1;
1739 }
1740 let selected = match view {
1741 ViewSpec::Languages if content.includes_code() => {
1742 record.code.map(|outcome| outcome.coverage())
1743 }
1744 ViewSpec::Documents if content.includes_words() => {
1745 record.words.map(|outcome| outcome.coverage())
1746 }
1747 _ => None,
1748 }
1749 .unwrap_or(record.lines.coverage());
1750 *row.coverage.entry(selected).or_default() += 1;
1751 if selected == CoverageReason::Analyzed {
1752 row.analyzed_files = row.analyzed_files.saturating_add(1);
1753 }
1754 if let Some(lines) = record.lines.value() {
1755 add_optional(&mut row.metrics.physical_lines, Some(lines.physical_lines));
1756 add_optional(&mut row.metrics.blank_lines, Some(lines.blank_lines));
1757 add_optional(&mut row.metrics.nonblank_lines, Some(lines.nonblank_lines));
1758 add_optional(&mut row.metrics.raw_words, Some(lines.raw_words));
1759 row.document_raw_words = row.document_raw_words.saturating_add(lines.raw_words);
1760 }
1761 if let Some(code_metrics) = record.code.and_then(crate::content::AnalyzerOutcome::value)
1762 {
1763 add_optional(&mut row.metrics.code_lines, Some(code_metrics.code_lines));
1764 add_optional(&mut row.metrics.comment_lines, Some(code_metrics.comment_lines));
1765 add_optional(
1766 &mut row.metrics.code_blank_lines,
1767 Some(code_metrics.code_blank_lines),
1768 );
1769 }
1770 if let Some(words) = record.words.and_then(crate::content::AnalyzerOutcome::value) {
1771 add_optional(&mut row.metrics.paragraphs, Some(words.paragraphs));
1772 add_optional(&mut row.metrics.visible_words, Some(words.visible_words));
1773 row.logical_word_stats.add_assign(words.logical_word_stats);
1774 row.visible_logical_word_stats.add_assign(words.visible_logical_word_stats);
1775 row.document_metric_files = row.document_metric_files.saturating_add(1);
1776 if classification.file_type.as_str() == "markdown" {
1777 row.document_raw_words = row
1778 .document_raw_words
1779 .saturating_sub(record.lines.value().map_or(0, |lines| lines.raw_words));
1780 row.document_raw_words =
1781 row.document_raw_words.saturating_add(words.visible_words);
1782 row.document_word_stats.add_assign(words.visible_logical_word_stats);
1783 } else {
1784 row.document_word_stats.add_assign(words.logical_word_stats);
1785 }
1786 }
1787 }
1788 }
1789
1790 for row in grouped.values_mut() {
1791 row.finish_derived_metrics();
1792 }
1793
1794 let mut total = MetricRow {
1795 analysis: content,
1796 id: "total".to_string(),
1797 family: ContentFamily::Unknown,
1798 files: 0,
1799 bytes: 0,
1800 allocated: 0,
1801 analyzed_files: 0,
1802 metrics: ReportMetricValues::for_analysis(content),
1803 logical_word_stats: LogicalWordStats::default(),
1804 visible_logical_word_stats: LogicalWordStats::default(),
1805 document_raw_words: 0,
1806 document_word_stats: LogicalWordStats::default(),
1807 document_metric_files: 0,
1808 coverage: BTreeMap::new(),
1809 lines_coverage: BTreeMap::new(),
1810 code_coverage: content.includes_code().then(BTreeMap::new),
1811 words_coverage: content.includes_words().then(BTreeMap::new),
1812 detection_sources: BTreeMap::new(),
1813 detection_confidence: BTreeMap::new(),
1814 generated_files: 0,
1815 vendored_files: 0,
1816 documentation_files: 0,
1817 share: MetricShare::default(),
1818 };
1819 for row in grouped.values() {
1820 total.files = total.files.saturating_add(row.files);
1821 total.bytes = total.bytes.saturating_add(row.bytes);
1822 total.allocated = total.allocated.saturating_add(row.allocated);
1823 total.analyzed_files = total.analyzed_files.saturating_add(row.analyzed_files);
1824 total.metrics.add_assign(&row.metrics);
1825 total.logical_word_stats.add_assign(row.logical_word_stats);
1826 total.visible_logical_word_stats.add_assign(row.visible_logical_word_stats);
1827 total.document_raw_words = total.document_raw_words.saturating_add(row.document_raw_words);
1828 total.document_word_stats.add_assign(row.document_word_stats);
1829 total.document_metric_files =
1830 total.document_metric_files.saturating_add(row.document_metric_files);
1831 for (reason, count) in &row.coverage {
1832 *total.coverage.entry(*reason).or_default() += count;
1833 }
1834 merge_coverage(&mut total.lines_coverage, &row.lines_coverage);
1835 if let (Some(total), Some(row)) = (&mut total.code_coverage, &row.code_coverage) {
1836 merge_coverage(total, row);
1837 }
1838 if let (Some(total), Some(row)) = (&mut total.words_coverage, &row.words_coverage) {
1839 merge_coverage(total, row);
1840 }
1841 for (source, count) in &row.detection_sources {
1842 *total.detection_sources.entry(*source).or_default() += count;
1843 }
1844 for (confidence, count) in &row.detection_confidence {
1845 *total.detection_confidence.entry(*confidence).or_default() += count;
1846 }
1847 total.generated_files = total.generated_files.saturating_add(row.generated_files);
1848 total.vendored_files = total.vendored_files.saturating_add(row.vendored_files);
1849 total.documentation_files =
1850 total.documentation_files.saturating_add(row.documentation_files);
1851 }
1852 total.finish_derived_metrics();
1853 let byte_share_metric = match query.selection.size {
1854 SizeMetric::Apparent => ShareMetric::ApparentBytes,
1855 SizeMetric::Allocated => ShareMetric::AllocatedBytes,
1856 };
1857 let share_metric = match view {
1858 ViewSpec::Languages if content.includes_code() => ShareMetric::CodeLines,
1861 ViewSpec::Documents if content.includes_words() => ShareMetric::DocumentWords,
1862 ViewSpec::Languages | ViewSpec::Documents if content.is_enabled() => ShareMetric::RawWords,
1863 ViewSpec::Languages | ViewSpec::Documents | ViewSpec::Types | ViewSpec::Families => {
1864 byte_share_metric
1865 }
1866 ViewSpec::List
1867 | ViewSpec::Tree
1868 | ViewSpec::Extensions
1869 | ViewSpec::Files
1870 | ViewSpec::Largest
1871 | ViewSpec::Recent
1872 | ViewSpec::Summary => {
1873 unreachable!("only grouped views reach metric_summary")
1874 }
1875 };
1876 let denominator = share_value(&total, share_metric);
1877 total.share = MetricShare { numerator: denominator, denominator };
1878 let mut rows = grouped.into_values().collect::<Vec<_>>();
1879 for row in &mut rows {
1880 row.share = MetricShare { numerator: share_value(row, share_metric), denominator };
1881 }
1882 sort_rows(
1883 &mut rows,
1884 query,
1885 view,
1886 |row, metric| match view {
1887 ViewSpec::Languages | ViewSpec::Documents => share_value(row, share_metric),
1888 _ => match metric {
1889 SizeMetric::Apparent => row.bytes,
1890 SizeMetric::Allocated => row.allocated,
1891 },
1892 },
1893 |row| row.files,
1894 |_| None,
1895 |row| row.id.clone(),
1896 );
1897 let total_rows = truncate(&mut rows, query.limit_for(view));
1898 MetricSummary {
1899 group,
1900 total,
1901 rows,
1902 total_rows,
1903 share_metric,
1904 words_per_page: query.words_per_page.max(1),
1905 }
1906}
1907
1908fn merge_coverage(total: &mut BTreeMap<CoverageReason, u64>, row: &BTreeMap<CoverageReason, u64>) {
1909 for (reason, count) in row {
1910 *total.entry(*reason).or_default() += count;
1911 }
1912}
1913
1914fn share_value(row: &MetricRow, metric: ShareMetric) -> u64 {
1915 match metric {
1916 ShareMetric::ApparentBytes => row.bytes,
1917 ShareMetric::AllocatedBytes => row.allocated,
1918 ShareMetric::CodeLines => row.metrics.code_lines.unwrap_or(0),
1919 ShareMetric::DocumentWords => document_words(row).unwrap_or(0),
1920 ShareMetric::RawWords => row.metrics.raw_words.unwrap_or(0),
1921 }
1922}
1923
1924pub fn document_words(row: &MetricRow) -> Option<u64> {
1926 row.metrics.document_words
1927}
1928
1929#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1931pub struct Pages {
1932 pub words: u64,
1934 pub words_per_page: u64,
1936}
1937
1938pub fn pages(row: &MetricRow, words_per_page: u64) -> Option<Pages> {
1940 document_words(row).map(|words| Pages { words, words_per_page: words_per_page.max(1) })
1941}
1942
1943fn file_rows(
1945 view: ViewSpec,
1946 index: &Index,
1947 query: &Query,
1948 walked: Option<&Walked>,
1949 unfiltered_rows: Option<&[FileRow]>,
1950) -> (Vec<FileRow>, usize) {
1951 let mut rows = entry_rows(index, walked, unfiltered_rows).into_owned();
1952 if view.files_only() {
1953 rows.retain(|row| row.kind == EntryKind::File);
1954 }
1955
1956 sort_rows(
1957 &mut rows,
1958 query,
1959 view,
1960 |row, metric| match metric {
1961 SizeMetric::Apparent => row.bytes,
1962 SizeMetric::Allocated => row.allocated,
1963 },
1964 |row| row.files.unwrap_or(1),
1965 |row| Some(row.mtime_ns),
1966 |row| row.path.to_string_lossy().into_owned(),
1967 );
1968 let total = truncate(&mut rows, query.limit_for(view));
1969 (rows, total)
1970}
1971
1972fn every_entry(index: &Index) -> Vec<FileRow> {
1974 let observed = index.observes_controls();
1975 let mut rows = Vec::new();
1976 let mut stack: Vec<(EntryId, PathBuf)> = vec![(EntryId::ROOT, PathBuf::new())];
1977 while let Some((id, path)) = stack.pop() {
1978 let Some(children) = index.children_of(id) else {
1979 continue;
1980 };
1981 let children: Vec<(PathBuf, EntryId)> =
1982 children.map(|(name, child)| (path.join(name), child)).collect();
1983 for (child_path, child) in children {
1984 let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
1985 continue;
1986 };
1987 rows.push(FileRow {
1988 path: child_path.clone(),
1989 kind,
1990 bytes: attrs.size,
1991 allocated: attrs.allocated,
1992 mtime_ns: attrs.mtime_ns,
1993 files: None,
1994 dirs: None,
1995 complete: None,
1996 age_ns: None,
1997 ignored: observed.then(|| index.ignored_bit_of(child).unwrap_or(false)),
1998 });
1999 if kind == EntryKind::Dir {
2000 stack.push((child, child_path));
2001 }
2002 }
2003 }
2004 rows
2005}
2006
2007fn tree_node(index: &Index, query: &Query, walked: Option<&Walked>) -> TreeNode {
2009 let root_summary = match walked {
2010 None => unfiltered_summary(index, EntryId::ROOT),
2011 Some(walked) => walked.summary_of(EntryId::ROOT),
2012 };
2013
2014 let mut root = TreeNode {
2015 path: PathBuf::new(),
2016 name: ".".to_string(),
2017 kind: EntryKind::Dir,
2018 bytes: root_summary.bytes,
2019 allocated: root_summary.allocated,
2020 files: root_summary.files,
2021 dirs: root_summary.dirs,
2022 ignored: root_summary.ignored,
2023 newest_mtime_ns: root_summary.newest_mtime_ns,
2024 children: Vec::new(),
2025 truncated: false,
2026 };
2027 expand(index, query, walked, EntryId::ROOT, &PathBuf::new(), &mut root, 0);
2028 root
2029}
2030
2031fn expand(
2038 index: &Index,
2039 query: &Query,
2040 walked: Option<&Walked>,
2041 root_id: EntryId,
2042 root_path: &Path,
2043 node: &mut TreeNode,
2044 start_depth: usize,
2045) {
2046 struct Pending {
2048 node: TreeNode,
2049 id: EntryId,
2050 depth: usize,
2051 parent: Option<usize>,
2052 }
2053
2054 let mut built = vec![Pending {
2055 node: TreeNode {
2058 path: root_path.to_path_buf(),
2059 name: node.name.clone(),
2060 kind: node.kind,
2061 bytes: node.bytes,
2062 allocated: node.allocated,
2063 files: node.files,
2064 dirs: node.dirs,
2065 ignored: node.ignored,
2066 newest_mtime_ns: node.newest_mtime_ns,
2067 children: Vec::new(),
2068 truncated: false,
2069 },
2070 id: root_id,
2071 depth: start_depth,
2072 parent: None,
2073 }];
2074
2075 let mut cursor = 0;
2076 while cursor < built.len() {
2077 let (id, depth) = (built[cursor].id, built[cursor].depth);
2078 let path = built[cursor].node.path.clone();
2079
2080 if !query.selection.depth.unwrap_or(ViewSpec::Tree.default_depth()).admits(depth) {
2081 built[cursor].node.truncated = index.children_of(id).is_some_and(|mut children| {
2086 children.any(|(_, child)| {
2087 index.kind_of(child) == Some(EntryKind::Dir)
2088 && walked.is_none_or(|walked| walked.visible.contains(&child))
2089 })
2090 });
2091 cursor += 1;
2092 continue;
2093 }
2094
2095 let mut rows = child_rows(index, query, walked, id, &path);
2096 let kept = query
2097 .selection
2098 .limit
2099 .unwrap_or(ViewSpec::Tree.default_limit())
2100 .limit()
2101 .unwrap_or(rows.len())
2102 .min(rows.len());
2103 built[cursor].node.truncated = kept < rows.len();
2104 rows.truncate(kept);
2105
2106 for (child_node, child_id) in rows {
2107 built.push(Pending {
2108 node: child_node,
2109 id: child_id,
2110 depth: depth + 1,
2111 parent: Some(cursor),
2112 });
2113 }
2114 cursor += 1;
2115 }
2116
2117 for position in (1..built.len()).rev() {
2120 let child = built.remove(position);
2121 let parent = child.parent.expect("only the root has no parent");
2122 built[parent].node.children.insert(0, child.node);
2123 }
2124
2125 let mut root = built.pop().expect("the root is always present");
2126 node.children = std::mem::take(&mut root.node.children);
2127 node.truncated = root.node.truncated;
2128}
2129
2130fn child_rows(
2132 index: &Index,
2133 query: &Query,
2134 walked: Option<&Walked>,
2135 id: EntryId,
2136 path: &Path,
2137) -> Vec<(TreeNode, EntryId)> {
2138 let Some(children) = index.children_of(id) else {
2139 return Vec::new();
2140 };
2141 let children: Vec<(PathBuf, EntryId)> =
2142 children.map(|(name, child)| (path.join(name), child)).collect();
2143
2144 let mut rows: Vec<(TreeNode, EntryId)> = Vec::new();
2145 for (child_path, child) in children {
2146 let Some(kind) = index.kind_of(child) else {
2147 continue;
2148 };
2149 if kind != EntryKind::Dir {
2152 continue;
2153 }
2154 if walked.is_some_and(|walked| !walked.visible.contains(&child)) {
2155 continue;
2156 }
2157 let summary = match walked {
2158 None => unfiltered_summary(index, child),
2159 Some(walked) => walked.summary_of(child),
2160 };
2161 let name = child_path
2162 .file_name()
2163 .map(|name| name.to_string_lossy().into_owned())
2164 .unwrap_or_default();
2165 rows.push((
2166 TreeNode {
2167 path: child_path,
2168 name,
2169 kind,
2170 bytes: summary.bytes,
2171 allocated: summary.allocated,
2172 files: summary.files,
2173 dirs: summary.dirs,
2174 ignored: summary.ignored,
2175 newest_mtime_ns: summary.newest_mtime_ns,
2176 children: Vec::new(),
2177 truncated: false,
2178 },
2179 child,
2180 ));
2181 }
2182
2183 sort_rows_by(
2184 &mut rows,
2185 query,
2186 ViewSpec::Tree,
2187 |(row, _), metric| match metric {
2188 SizeMetric::Apparent => row.bytes,
2189 SizeMetric::Allocated => row.allocated,
2190 },
2191 |(row, _)| row.files,
2192 |(row, _)| row.newest_mtime_ns,
2193 |(row, _)| row.name.clone(),
2194 );
2195 rows
2196}
2197
2198fn truncate<T>(rows: &mut Vec<T>, limit: Bound) -> usize {
2200 let total = rows.len();
2201 if let Some(limit) = limit.limit() {
2202 rows.truncate(limit);
2203 }
2204 total
2205}
2206
2207fn sort_rows<T>(
2209 rows: &mut [T],
2210 query: &Query,
2211 view: ViewSpec,
2212 size: impl Fn(&T, SizeMetric) -> u64,
2213 count: impl Fn(&T) -> u64,
2214 mtime: impl Fn(&T) -> Option<i64>,
2215 name: impl Fn(&T) -> String,
2216) {
2217 sort_rows_by(rows, query, view, size, count, mtime, name);
2218}
2219
2220fn sort_rows_by<T>(
2222 rows: &mut [T],
2223 query: &Query,
2224 view: ViewSpec,
2225 size: impl Fn(&T, SizeMetric) -> u64,
2226 count: impl Fn(&T) -> u64,
2227 mtime: impl Fn(&T) -> Option<i64>,
2228 name: impl Fn(&T) -> String,
2229) {
2230 let key = query.selection.sort.unwrap_or_else(|| view.default_sort());
2231 let metric = query.selection.size;
2232
2233 rows.sort_by(|left, right| {
2234 let ordering = match key {
2235 SortKey::Size => size(right, metric).cmp(&size(left, metric)),
2237 SortKey::Count => count(right).cmp(&count(left)),
2238 SortKey::Mtime => mtime(right).cmp(&mtime(left)),
2239 SortKey::Name => name(left).cmp(&name(right)),
2240 };
2241 ordering.then_with(|| name(left).cmp(&name(right)))
2244 });
2245
2246 if query.selection.reverse {
2247 rows.reverse();
2248 }
2249}
2250
2251#[cfg(test)]
2252mod tests {
2253 use super::*;
2254 use crate::engine_contract::{Attrs, Observation, Op};
2255 use crate::query::query_glob::Pattern;
2256 use crate::query::query_selection::ModifiedWindow;
2257 use std::fs;
2258 use std::time::{Duration, UNIX_EPOCH};
2259
2260 fn attrs(size: u64, mtime_ns: i64) -> Attrs {
2261 Attrs {
2262 size,
2263 allocated: size.div_ceil(512) * 512,
2264 mtime_ns,
2265 ctime_ns: mtime_ns,
2266 inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
2267 dev: 1,
2268 }
2269 }
2270
2271 fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
2272 Op::Upsert { path: PathBuf::from(path), kind, attrs }
2273 }
2274
2275 fn sample() -> Index {
2277 let mut index = Index::new("/root");
2278 index
2279 .apply(&Observation::new(vec![
2280 upsert("src", EntryKind::Dir, Attrs::default()),
2281 upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
2282 upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
2283 upsert("src/deep", EntryKind::Dir, Attrs::default()),
2284 upsert("src/deep/nested.rs", EntryKind::File, attrs(50, 40)),
2285 upsert("docs", EntryKind::Dir, Attrs::default()),
2286 upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
2287 upsert("notes.txt", EntryKind::File, attrs(7, 5)),
2288 ]))
2289 .expect("apply");
2290 index
2291 }
2292
2293 #[test]
2294 fn ages_use_one_signed_reference_and_unrepresentable_clocks_are_unknown() {
2295 let mut index = sample();
2296 index.apply_ok(&Observation::new(vec![
2297 upsert("past", EntryKind::File, attrs(1, -10)),
2298 upsert("future", EntryKind::File, attrs(1, i64::MAX)),
2299 ]));
2300 let query = query(
2301 &[ViewSpec::Files],
2302 Selection { include: vec![pattern("past"), pattern("future")], ..Selection::default() },
2303 );
2304 let mut request = Request::new(Basis::held_by(&index), query, UNIX_EPOCH);
2305 let answer = report(&index, &request, UNIX_EPOCH).expect("report");
2306 let rows = files_of(&answer);
2307 assert_eq!(answer.age_reference_ns, Some(0));
2308 assert_eq!(
2309 rows.iter().map(|r| r.age_ns).collect::<Vec<_>>(),
2310 [Some(-i128::from(i64::MAX)), Some(10)]
2311 );
2312 request.now = UNIX_EPOCH + Duration::from_secs(10_000_000_000);
2313 let answer = report(&index, &request, UNIX_EPOCH)
2314 .expect("out-of-range reference is representable as unknown age");
2315 assert_eq!(answer.age_reference_ns, None);
2316 assert!(files_of(&answer).iter().all(|row| row.age_ns.is_none()));
2317 }
2318
2319 #[test]
2320 fn matching_a_directory_selects_its_subtree_once() {
2321 let index = sample();
2322 let selection = Selection {
2323 include: vec![pattern("src"), pattern("deep")],
2324 kinds: vec![EntryKind::Dir],
2325 size: SizeMetric::Apparent,
2326 min_size: Some(40),
2327 ..Selection::default()
2328 };
2329 let report = run(
2330 &index,
2331 &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Extensions], selection),
2332 );
2333 let rows = files_of(&report);
2334 assert_eq!(rows.len(), 2);
2335 assert_eq!((rows[0].bytes, rows[0].mtime_ns), (350, 40));
2336 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
2337 assert_eq!((summary.files, summary.dirs, summary.bytes), (3, 2, 350));
2338 let Section::Extensions { rows, .. } = &report.sections[2] else { panic!("extensions") };
2339 assert_eq!((rows[0].files, rows[0].bytes), (3, 350));
2340 }
2341
2342 #[test]
2343 fn subtree_predicates_include_directory_and_symlink_activity_but_only_file_bytes() {
2344 let mut index = sample();
2345 index
2346 .apply(&Observation::new(vec![
2347 upsert("src", EntryKind::Dir, attrs(9999, 45)),
2348 upsert("src/empty", EntryKind::Dir, attrs(8888, 60)),
2349 upsert("src/link", EntryKind::Symlink, attrs(7777, 70)),
2350 upsert("empty", EntryKind::Dir, attrs(6666, -10)),
2351 ]))
2352 .expect("apply");
2353 let base = Selection {
2354 include: vec![pattern("src"), pattern("empty")],
2355 size: SizeMetric::Apparent,
2356 ..Selection::default()
2357 };
2358 let nested = PathBuf::from("src").join("empty").to_string_lossy().into_owned();
2361 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], base.clone())));
2362 assert_eq!(
2363 rows.iter()
2364 .map(|r| (r.path.to_string_lossy().into_owned(), r.bytes, r.mtime_ns))
2365 .collect::<Vec<_>>(),
2366 [("empty".into(), 0, -10), ("src".into(), 350, 70), (nested.clone(), 0, 60)]
2367 );
2368 for (before, since, expected) in [
2369 (70, 0, vec![nested.clone()]),
2370 (71, 70, vec!["src".to_owned()]),
2371 (0, -10, vec!["empty".to_owned()]),
2372 ] {
2373 let selection = Selection {
2374 modified: ModifiedWindow { before: Some(before), since: Some(since) },
2375 ..base.clone()
2376 };
2377 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2378 assert_eq!(
2379 rows.iter().map(|r| r.path.to_string_lossy().into_owned()).collect::<Vec<_>>(),
2380 expected
2381 );
2382 }
2383 for (size, minimum, expected) in [
2384 (SizeMetric::Apparent, 350, 1),
2385 (SizeMetric::Apparent, 351, 0),
2386 (SizeMetric::Allocated, 1536, 1),
2387 (SizeMetric::Allocated, 1537, 0),
2388 ] {
2389 let selection = Selection { size, min_size: Some(minimum), ..base.clone() };
2390 assert_eq!(
2391 files_of(&run(&index, &query(&[ViewSpec::Files], selection))).len(),
2392 expected
2393 );
2394 }
2395 }
2396
2397 #[test]
2398 fn exclusions_apply_before_subtree_bounds_and_selected_ancestor_coverage() {
2399 let index = classified_sample();
2400 for (ignored, exclude, bytes, newest) in [
2401 (IgnoredEntries::Include, vec![], 325, 70),
2402 (IgnoredEntries::Exclude, vec![], 300, 20),
2403 (IgnoredEntries::Include, vec![pattern("*.log")], 300, 20),
2404 (IgnoredEntries::Include, vec![pattern("lib.rs")], 125, 70),
2405 ] {
2406 let selection = Selection {
2407 include: vec![pattern("src")],
2408 kinds: vec![EntryKind::Dir],
2409 ignored,
2410 exclude,
2411 size: SizeMetric::Apparent,
2412 ..Selection::default()
2413 };
2414 let report = run(
2415 &index,
2416 &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Types], selection),
2417 );
2418 let row = &files_of(&report)[0];
2419 assert_eq!((row.bytes, row.mtime_ns), (bytes, newest));
2420 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
2421 assert_eq!(summary.bytes, bytes);
2422 let Section::Metrics { summary, .. } = &report.sections[2] else { panic!("types") };
2423 assert_eq!(summary.rows.iter().map(|r| r.bytes).sum::<u64>(), bytes);
2424 }
2425 let selection = Selection {
2426 include: vec![pattern("build")],
2427 exclude: vec![pattern("cache")],
2428 kinds: vec![EntryKind::Dir],
2429 ..Selection::default()
2430 };
2431 let report = run(&index, &query(&[ViewSpec::Files, ViewSpec::Summary], selection));
2432 assert_eq!(files_of(&report)[0].bytes, 0);
2433 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
2434 assert_eq!((summary.files, summary.dirs, summary.bytes), (0, 1, 0));
2435 let only = Selection {
2436 include: vec![pattern("cache")],
2437 kinds: vec![EntryKind::Dir],
2438 ignored: IgnoredEntries::Only,
2439 ..Selection::default()
2440 };
2441 assert_eq!(files_of(&run(&index, &query(&[ViewSpec::Files], only)))[0].bytes, 1000);
2442 }
2443
2444 #[test]
2448 fn an_incomplete_subtree_reports_lower_bounds_and_matches_no_time_bound() {
2449 let mut index = Index::new_with_scope(
2452 "/root",
2453 crate::ScanScope { max_depth: Some(2), ..crate::ScanScope::default() },
2454 );
2455 index.apply_ok(&Observation::new(vec![
2456 upsert("env", EntryKind::Dir, attrs(0, 5)),
2457 upsert("env/lib", EntryKind::Dir, attrs(0, 7)),
2458 upsert("env/a.bin", EntryKind::File, attrs(100, 40)),
2459 upsert("docs", EntryKind::Dir, attrs(0, 5)),
2460 upsert("docs/guide.md", EntryKind::File, attrs(30, 50)),
2461 ]));
2462 let directories = |selection: Selection| {
2463 let selection =
2464 Selection { kinds: vec![EntryKind::Dir], size: SizeMetric::Apparent, ..selection };
2465 files_of(&run(&index, &flat(selection)))
2466 .into_iter()
2467 .map(|row| {
2468 (row.path.to_string_lossy().into_owned(), row.complete, row.bytes, row.age_ns)
2469 })
2470 .collect::<Vec<_>>()
2471 };
2472 let env_lib = Path::new("env").join("lib").to_string_lossy().into_owned();
2474 assert_eq!(
2475 directories(Selection::default()),
2476 vec![
2477 ("env".to_string(), Some(false), 100, None),
2478 ("docs".to_string(), Some(true), 30, Some(-50)),
2479 (env_lib, Some(false), 0, None),
2480 ],
2481 "sizes are lower bounds and the age is unknown below the boundary"
2482 );
2483 for modified in [
2484 ModifiedWindow { since: None, before: Some(100) },
2485 ModifiedWindow { since: Some(0), before: None },
2486 ] {
2487 assert_eq!(
2488 directories(Selection { modified, ..Selection::default() }),
2489 vec![("docs".to_string(), Some(true), 30, Some(-50))],
2490 "an unknown age satisfies no bound, not even one its lower bound would prove"
2491 );
2492 }
2493 assert_eq!(
2494 directories(Selection { min_size: Some(100), ..Selection::default() }),
2495 vec![("env".to_string(), Some(false), 100, None)],
2496 "a lower bound at or above the minimum proves the true size is too"
2497 );
2498 assert!(directories(Selection { min_size: Some(101), ..Selection::default() }).is_empty());
2499 let files = files_of(&run(
2501 &index,
2502 &flat(Selection {
2503 kinds: vec![EntryKind::File],
2504 modified: ModifiedWindow { since: Some(45), before: None },
2505 ..Selection::default()
2506 }),
2507 ));
2508 assert_eq!(files.len(), 1);
2509 assert_eq!((files[0].complete, files[0].age_ns), (None, Some(-50)));
2510 }
2511
2512 #[test]
2516 fn an_unscoped_partial_marker_marks_every_directory_row_incomplete() {
2517 let directories = |index: &Index| {
2518 files_of(&run(
2519 index,
2520 &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
2521 ))
2522 };
2523 let mut partial = sample();
2524 partial.set_initial_freshness(false);
2525 let rows = directories(&partial);
2526 assert_eq!(rows.len(), 3);
2527 assert!(rows.iter().all(|row| row.complete == Some(false) && row.age_ns.is_none()));
2528 let mut complete = sample();
2529 complete.set_initial_freshness(true);
2530 let rows = directories(&complete);
2531 assert_eq!(rows.len(), 3);
2532 assert!(rows.iter().all(|row| row.complete == Some(true) && row.age_ns.is_some()));
2533 }
2534
2535 #[test]
2538 fn an_opened_root_marks_a_directory_complete_only_once_discovery_listed_it() {
2539 let handle = crate::index::IndexHandle::new(Index::new("/root"));
2540 handle
2541 .transition_discovery(crate::index::DiscoveryTransition::Begin)
2542 .expect("begin discovery");
2543 handle
2544 .apply(&Observation::new(vec![
2545 upsert("known", EntryKind::Dir, attrs(0, 5)),
2546 upsert("pending", EntryKind::Dir, attrs(0, 5)),
2547 ]))
2548 .expect("seed directories");
2549 handle
2550 .apply_discovery(
2551 &Observation::new(Vec::new()),
2552 crate::index::DiscoveryCommit {
2553 directory_complete: Some(PathBuf::from("known")),
2554 transition: None,
2555 },
2556 )
2557 .expect("list one directory");
2558 let completeness = handle
2559 .read_with(|index| {
2560 files_of(&run(
2561 index,
2562 &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
2563 ))
2564 .into_iter()
2565 .map(|row| (row.path.to_string_lossy().into_owned(), row.complete))
2566 .collect::<BTreeMap<_, _>>()
2567 })
2568 .expect("read");
2569 assert_eq!(
2570 completeness,
2571 BTreeMap::from([
2572 ("known".to_string(), Some(true)),
2573 ("pending".to_string(), Some(false))
2574 ])
2575 );
2576 }
2577
2578 #[test]
2579 fn a_filtered_tree_keeps_empty_matches_and_only_folds_visible_directories() {
2580 let mut index = sample();
2581 index
2582 .apply(&Observation::new(vec![upsert("src/empty", EntryKind::Dir, Attrs::default())]))
2583 .expect("apply");
2584 let selection = Selection {
2585 include: vec![pattern("empty")],
2586 depth: Some(Bound::All),
2587 ..Selection::default()
2588 };
2589 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
2590 assert_eq!(root.children.len(), 1);
2591 assert_eq!(root.children[0].name, "src");
2592 assert_eq!(root.children[0].children[0].name, "empty");
2593 let selection = Selection {
2594 include: vec![pattern("notes.txt")],
2595 depth: Some(Bound::Limit(0)),
2596 ..Selection::default()
2597 };
2598 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
2599 assert!(!root.truncated);
2600 assert_eq!(root.bytes, 7);
2601 }
2602
2603 #[test]
2604 fn the_undocumented_docs_view_alias_is_rejected() {
2605 assert_eq!(
2606 ViewSpec::parse("documents").expect("the canonical view name parses"),
2607 ViewSpec::Documents
2608 );
2609 assert_eq!(
2610 ViewSpec::parse("docs").expect_err("an unreleased alias must not become a contract"),
2611 format!("expected one of {}", ViewSpec::vocabulary())
2612 );
2613 }
2614
2615 fn generated_at() -> std::time::SystemTime {
2616 UNIX_EPOCH + Duration::from_secs(1_001)
2617 }
2618
2619 fn run(index: &Index, query: &Query) -> Report {
2620 report(index, &crate::test_support::read_of(index, query.clone()), generated_at())
2621 .expect("the query is answerable over this index")
2622 }
2623
2624 fn query(views: &[ViewSpec], selection: Selection) -> Query {
2625 Query { selection, views: views.to_vec(), ..Query::default() }
2626 }
2627
2628 fn flat(selection: Selection) -> Query {
2630 Query {
2631 selection,
2632 views: vec![ViewSpec::List],
2633 format: crate::report_format::Format::Paths,
2634 ..Query::default()
2635 }
2636 }
2637
2638 fn pattern(source: &str) -> Pattern {
2639 Pattern::parse(source).expect("pattern compiles")
2640 }
2641
2642 fn summary_of(report: &Report) -> SummaryRow {
2643 match report.sections.first().expect("a section") {
2644 Section::Summary(row) => *row,
2645 other => panic!("expected a summary, got {other:?}"),
2646 }
2647 }
2648
2649 fn files_of(report: &Report) -> Vec<FileRow> {
2650 match report.sections.first().expect("a section") {
2651 Section::Files { rows, .. } => rows.clone(),
2652 other => panic!("expected files, got {other:?}"),
2653 }
2654 }
2655
2656 fn types_of(report: &Report) -> Vec<TypeRow> {
2657 match report.sections.first().expect("a section") {
2658 Section::Extensions { rows, .. } => rows.clone(),
2659 other => panic!("expected types, got {other:?}"),
2660 }
2661 }
2662
2663 fn tree_of(report: &Report) -> TreeNode {
2664 match report.sections.first().expect("a section") {
2665 Section::Tree { root: node, .. } => node.clone(),
2666 other => panic!("expected a tree, got {other:?}"),
2667 }
2668 }
2669
2670 #[test]
2671 fn an_unfiltered_summary_matches_the_precomputed_rollup() {
2672 let index = sample();
2673 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
2674 assert_eq!(row.files, 5);
2675 assert_eq!(row.dirs, 3);
2676 assert_eq!(row.bytes, 657);
2677 assert_eq!(row.newest_mtime_ns, Some(40));
2678 }
2679
2680 #[test]
2681 fn the_two_tiers_agree_on_the_same_question() {
2682 let index = sample();
2685 let fast = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
2686
2687 let admits_everything = Selection { min_size: Some(0), ..Selection::default() };
2689 assert!(!admits_everything.is_unfiltered());
2690 let slow = summary_of(&run(&index, &query(&[ViewSpec::Summary], admits_everything)));
2691
2692 assert_eq!(
2697 (fast.files, fast.dirs, fast.bytes, fast.allocated, fast.newest_mtime_ns),
2698 (slow.files, slow.dirs, slow.bytes, slow.allocated, slow.newest_mtime_ns)
2699 );
2700 }
2701
2702 #[test]
2703 fn extension_rows_account_for_every_file_in_both_tiers() {
2704 let mut index = sample();
2709 index
2710 .apply(&Observation::new(vec![
2711 upsert("Makefile", EntryKind::File, attrs(28, 50)),
2712 upsert(".gitignore", EntryKind::File, attrs(11, 51)),
2713 ]))
2714 .expect("apply");
2715
2716 for selection in [
2719 Selection::default(),
2720 Selection { min_size: Some(0), ..Selection::default() },
2721 Selection { kinds: vec![EntryKind::File], ..Selection::default() },
2722 ] {
2723 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
2724 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
2725 assert_eq!(
2726 rows.iter().map(|row| row.bytes).sum::<u64>(),
2727 summary.bytes,
2728 "bytes unaccounted for under {selection:?}: {rows:?}"
2729 );
2730 assert_eq!(
2731 rows.iter().map(|row| row.files).sum::<u64>(),
2732 summary.files,
2733 "files unaccounted for under {selection:?}: {rows:?}"
2734 );
2735 }
2736 }
2737
2738 #[test]
2739 fn names_without_an_extension_share_one_bucket() {
2740 let mut index = sample();
2744 index
2745 .apply(&Observation::new(vec![
2746 upsert("Makefile", EntryKind::File, attrs(28, 50)),
2747 upsert(".gitignore", EntryKind::File, attrs(11, 51)),
2748 ]))
2749 .expect("apply");
2750
2751 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
2752 let bucket = rows
2753 .iter()
2754 .find(|row| row.extension == crate::classify::NO_EXTENSION)
2755 .expect("a bucket for the extension-less names");
2756 assert_eq!(bucket.files, 2);
2757 assert_eq!(bucket.bytes, 39);
2758 assert!(rows.iter().any(|row| row.extension == ".rs"), "{rows:?}");
2760 }
2761
2762 #[test]
2763 fn a_summary_counts_the_union_of_listed_entries_and_directory_contents() {
2764 let index = sample();
2770 for selection in [
2771 Selection { kinds: vec![EntryKind::File], ..Selection::default() },
2772 Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
2773 Selection { include: vec![pattern("*.rs")], ..Selection::default() },
2774 Selection { exclude: vec![pattern("docs")], ..Selection::default() },
2775 Selection { min_size: Some(1_000_000), ..Selection::default() },
2776 Selection { min_size: Some(0), ..Selection::default() },
2777 ] {
2778 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
2779 let listed = files_of(&run(&index, &query(&[ViewSpec::Files], selection.clone())));
2780 let dirs = listed.iter().filter(|row| row.kind == EntryKind::Dir).count() as u64;
2781 let files = every_entry(&index)
2782 .iter()
2783 .filter(|entry| {
2784 entry.kind == EntryKind::File
2785 && listed.iter().any(|row| {
2786 entry.path == row.path
2787 || (row.kind == EntryKind::Dir && entry.path.starts_with(&row.path))
2788 })
2789 })
2790 .count() as u64;
2791 assert_eq!(summary.dirs, dirs, "directory counts disagree under {selection:?}");
2792 assert_eq!(summary.files, files, "file counts disagree under {selection:?}");
2793 }
2794 }
2795
2796 #[test]
2797 fn a_rejected_directory_is_still_descended_into() {
2798 let index = sample();
2801 let selection = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
2802 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
2803 assert_eq!(row.dirs, 0, "no directory was admitted");
2804 assert_eq!(row.files, 5, "including src/deep/nested.rs, two levels down");
2805 assert_eq!(row.bytes, 657, "and its bytes");
2806 }
2807
2808 #[test]
2809 fn nested_directory_counts_roll_up_through_every_level() {
2810 let index = sample();
2813 let selection = Selection { kinds: vec![EntryKind::Dir], ..Selection::default() };
2814 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
2815 assert_eq!(root.dirs, 3, "src, src/deep, and docs");
2816 assert_eq!(root.files, 4, "matching directories cover their regular files");
2817 let src = root.children.iter().find(|node| node.name == "src").expect("src");
2818 assert_eq!(src.dirs, 1, "src/deep, counted for src as well as for the root");
2819 }
2820
2821 #[test]
2822 fn selection_narrows_a_summary_to_what_it_admits() {
2823 let index = sample();
2824 let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
2825 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
2826 assert_eq!(row.files, 3, "three .rs files");
2827 assert_eq!(row.bytes, 350);
2828 }
2829
2830 #[test]
2831 fn a_files_view_lists_matching_entries_in_name_order_by_default() {
2832 let index = sample();
2833 let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
2834 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2835 let paths: Vec<PathBuf> = rows.iter().map(|row| row.path.clone()).collect();
2839 let expected: Vec<PathBuf> = [["src", "deep", "nested.rs"].iter().collect::<PathBuf>()]
2840 .into_iter()
2841 .chain([["src", "lib.rs"].iter().collect::<PathBuf>()])
2842 .chain([["src", "main.rs"].iter().collect::<PathBuf>()])
2843 .collect();
2844 assert_eq!(paths, expected);
2845 }
2846
2847 #[test]
2848 fn sorting_and_limiting_compose_without_a_dedicated_view() {
2849 let index = sample();
2851 let selection = Selection {
2853 kinds: vec![EntryKind::File],
2854 sort: Some(SortKey::Size),
2855 limit: Some(Bound::Limit(2)),
2856 size: SizeMetric::Apparent,
2857 ..Selection::default()
2858 };
2859 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2860 assert_eq!(rows.len(), 2);
2861 assert_eq!(rows[0].bytes, 300, "largest first");
2862 assert_eq!(rows[1].bytes, 200);
2863 }
2864
2865 #[test]
2866 fn reverse_flips_whatever_order_is_in_effect() {
2867 let index = sample();
2868 let selection = Selection {
2869 kinds: vec![EntryKind::File],
2870 sort: Some(SortKey::Size),
2871 reverse: true,
2872 size: SizeMetric::Apparent,
2873 ..Selection::default()
2874 };
2875 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2876 assert_eq!(rows[0].bytes, 7, "smallest first once reversed");
2877 }
2878
2879 #[test]
2880 fn a_modified_window_selects_by_time() {
2881 let index = sample();
2882 let selection = Selection {
2883 kinds: vec![EntryKind::File],
2884 modified: ModifiedWindow { since: Some(20), before: Some(40) },
2885 sort: Some(SortKey::Mtime),
2886 ..Selection::default()
2887 };
2888 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
2889 let mut times: Vec<i64> = rows.iter().map(|row| row.mtime_ns).collect();
2890 times.sort_unstable();
2891 assert_eq!(times, vec![20, 30], "inclusive start, exclusive end");
2892 }
2893
2894 #[test]
2895 fn a_types_view_reports_both_size_metrics_per_extension() {
2896 let index = sample();
2897 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
2898 let rs = rows.iter().find(|row| row.extension == ".rs").expect(".rs present");
2899 assert_eq!((rs.files, rs.bytes), (3, 350));
2900 assert_eq!(rs.allocated, 1536, "three files, one 512-byte block each");
2901 let order: Vec<&str> = rows.iter().map(|row| row.extension.as_str()).collect();
2903 assert_eq!(order, vec![".rs", ".md", ".txt"]);
2904 }
2905
2906 #[test]
2907 fn a_tree_view_reports_directories_with_their_subtree_totals() {
2908 let index = sample();
2909 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], Selection::default())));
2910 assert_eq!(tree.name, ".");
2911 assert_eq!(tree.bytes, 657);
2912 let names: Vec<&str> = tree.children.iter().map(|child| child.name.as_str()).collect();
2914 assert_eq!(names, vec!["src", "docs"]);
2915 let src = &tree.children[0];
2916 assert_eq!(src.bytes, 350);
2917 let nested: Vec<&str> = src.children.iter().map(|child| child.name.as_str()).collect();
2918 assert_eq!(nested, vec!["deep"]);
2919 }
2920
2921 #[test]
2922 fn depth_zero_keeps_dus_meaning_of_root_totals_only() {
2923 let index = sample();
2924 let selection = Selection { depth: Some(Bound::Limit(0)), ..Selection::default() };
2925 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
2926 assert_eq!(tree.bytes, 657, "totals still cover the whole tree");
2927 assert!(tree.children.is_empty(), "but nothing below the root is listed");
2928 assert!(tree.truncated, "and the report says so rather than implying emptiness");
2929 }
2930
2931 #[test]
2936 fn a_dropped_view_is_named_on_the_report_rather_than_by_one_surface() {
2937 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
2938 .expect("full resolves without analyzers");
2939 assert!(!omitted.is_empty(), "documents needs analysis and must be dropped");
2940
2941 let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
2942 let notes = display_notes(&query, &ControlCoverage::NotObserved);
2943 assert_eq!(notes.len(), 1, "{notes:?}");
2944 assert!(notes[0].contains("omitted documents"), "{notes:?}");
2945
2946 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::ALL, "view")
2948 .expect("full resolves with analyzers");
2949 assert!(omitted.is_empty(), "every view is answerable with analysis enabled");
2950 let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
2951 assert!(display_notes(&query, &ControlCoverage::NotObserved).is_empty());
2952 }
2953
2954 #[test]
2964 fn the_refused_controls_note_bounds_its_list_and_matches_its_remedy_to_the_reasons() {
2965 use crate::control::{
2966 ControlLimits, ControlObservation, ControlRefusalReason, RefusedControl,
2967 };
2968
2969 let refused = |directory: &str, reason| RefusedControl {
2970 path: Path::new(directory).join(".gitignore"),
2971 reason,
2972 };
2973 let note = |limits, refusals: Vec<RefusedControl>, count: u64| {
2974 let coverage = ControlCoverage::Observed(ControlObservation {
2975 limits,
2976 applied: 7,
2977 refused: count,
2978 refusals,
2979 });
2980 refused_controls_note(&coverage, &AxisNames::FLAGS).expect("a refusal is noted")
2981 };
2982 let defaults = ControlLimits::default();
2983 let (budget, line_limit) = (ControlRefusalReason::Budget, ControlRefusalReason::LineLimit);
2984
2985 assert_eq!(
2986 note(defaults, vec![refused("", budget), refused("pkg/a", budget)], 2),
2987 "note: 2 .gitignore files not applied (2 over the 4.0 MiB ignore-rule budget), so \
2988 ignored shares under ., pkg/a are not exact; sizes are. To apply them, raise \
2989 --gitignore-budget above 4.0 MiB, or set it to all"
2990 );
2991 assert_eq!(
2992 note(defaults, vec![refused("vendor", line_limit)], 1),
2993 "note: 1 .gitignore file not applied (1 with a line over the 16 KiB line limit), so \
2994 ignored shares under vendor are not exact; sizes are. To apply them, raise \
2995 --gitignore-line-limit above 16 KiB, or set it to all"
2996 );
2997 assert_eq!(
2998 note(defaults, vec![refused("a", budget), refused("b", line_limit)], 2),
2999 "note: 2 .gitignore files not applied (1 over the 4.0 MiB ignore-rule budget, 1 with \
3000 a line over the 16 KiB line limit), so ignored shares under a, b are not exact; \
3001 sizes are. To apply them, raise --gitignore-budget above 4.0 MiB and \
3002 --gitignore-line-limit above 16 KiB, or set them to all"
3003 );
3004
3005 let listed: Vec<_> = (0..crate::MAX_RETAINED_ISSUES)
3008 .map(|index| refused(&format!("d{index:02}"), budget))
3009 .collect();
3010 assert_eq!(
3011 note(defaults, listed.clone(), 1_000),
3012 "note: 1,000 .gitignore files not applied (over the 4.0 MiB ignore-rule budget or \
3013 with a line over the 16 KiB line limit), so ignored shares under d00, d01, d02, \
3014 d03, d04, 995 more are not exact; sizes are. To apply them, raise \
3015 --gitignore-budget above 4.0 MiB and --gitignore-line-limit above 16 KiB, or set \
3016 them to all"
3017 );
3018 assert_eq!(
3019 note(ControlLimits { line_limit: None, ..defaults }, listed, 1_000),
3020 "note: 1,000 .gitignore files not applied (over the 4.0 MiB ignore-rule budget), so \
3021 ignored shares under d00, d01, d02, d03, d04, 995 more are not exact; sizes are. \
3022 To apply them, raise --gitignore-budget above 4.0 MiB, or set it to all"
3023 );
3024
3025 assert_eq!(
3029 note(ControlLimits { budget: None, ..defaults }, vec![refused("a", budget)], 1),
3030 "note: 1 .gitignore file not applied (1 over the ignore-rule budget), so ignored \
3031 shares under a are not exact; sizes are."
3032 );
3033
3034 let complete = ControlCoverage::Observed(ControlObservation {
3035 limits: defaults,
3036 applied: 3,
3037 refused: 0,
3038 refusals: Vec::new(),
3039 });
3040 assert_eq!(refused_controls_note(&complete, &AxisNames::FLAGS), None);
3041 assert_eq!(refused_controls_note(&ControlCoverage::NotObserved, &AxisNames::FLAGS), None);
3042 }
3043
3044 #[test]
3045 fn a_diagnostic_names_the_axes_the_requesting_surface_uses() {
3046 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
3047 .expect("full resolves without analyzers");
3048
3049 for (axes, mine, theirs) in [
3050 (&AxisNames::FLAGS, "--analyze", "analyze"),
3051 (&AxisNames::FIELDS, "analyze", "--analyze"),
3052 ] {
3053 let query = Query {
3054 views: selected.clone(),
3055 omitted_views: omitted.clone(),
3056 axes,
3057 ..Query::default()
3058 };
3059 let note = display_notes(&query, &ControlCoverage::NotObserved).remove(0);
3063 assert!(note.contains(&format!("add {mine} ")), "{note} must name {mine}");
3064 assert!(!note.contains(&format!("add {theirs} ")), "{note} must not name {theirs}");
3065 }
3066
3067 for (axes, view, analyze) in
3070 [(&AxisNames::FLAGS, "--view", "--analyze"), (&AxisNames::FIELDS, "view", "analyze")]
3071 {
3072 let error =
3073 crate::query::RequestError::ViewNeedsContent(ViewSpec::Documents).message(axes);
3074 assert!(error.starts_with(&format!("{view} documents")), "{error}");
3075 assert!(error.contains(&format!("add {analyze} ")), "{error}");
3076 let theirs = if analyze == "--analyze" { "analyze" } else { "--analyze" };
3077 assert!(!error.contains(&format!("add {theirs} ")), "{error}");
3078 }
3079 }
3080
3081 #[test]
3084 fn the_default_vocabulary_is_the_librarys_own() {
3085 assert_eq!(*Query::default().axes, AxisNames::FIELDS);
3086 }
3087
3088 #[test]
3089 fn a_depth_bound_marks_only_hidden_directory_rows_as_truncated() {
3090 let index = sample();
3091 let selection = Selection { depth: Some(Bound::Limit(1)), ..Selection::default() };
3092 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3093
3094 let src = tree.children.iter().find(|child| child.name == "src").expect("src");
3095 assert!(src.truncated, "src with a hidden directory child is truncated");
3096
3097 let docs = tree.children.iter().find(|child| child.name == "docs").expect("docs");
3098 assert!(docs.children.is_empty());
3099 assert!(
3100 !docs.truncated,
3101 "file children contribute to a directory row; they are not hidden tree rows"
3102 );
3103 }
3104
3105 #[test]
3106 fn a_tree_limit_bounds_entries_per_directory_and_marks_truncation() {
3107 let index = sample();
3108 let selection = Selection { limit: Some(Bound::Limit(1)), ..Selection::default() };
3109 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3110 assert_eq!(tree.children.len(), 1);
3111 assert!(tree.truncated);
3112 }
3113
3114 #[test]
3115 fn requesting_more_views_never_changes_another_views_answer() {
3116 let index = sample();
3118 let alone = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
3119 let together = run(
3120 &index,
3121 &query(
3122 &[ViewSpec::Extensions, ViewSpec::Tree, ViewSpec::Summary],
3123 Selection::default(),
3124 ),
3125 );
3126 let with_others = match &together.sections[0] {
3127 Section::Extensions { rows, .. } => rows.clone(),
3128 other => panic!("expected types first, got {other:?}"),
3129 };
3130
3131 assert_eq!(alone.len(), with_others.len());
3132 for (left, right) in alone.iter().zip(with_others.iter()) {
3133 assert_eq!(
3134 (&left.extension, left.files, left.bytes),
3135 (&right.extension, right.files, right.bytes)
3136 );
3137 }
3138 assert_eq!(together.sections.len(), 3, "one section per view, in request order");
3139 assert_eq!(together.sections[1].view(), ViewSpec::Tree);
3140 assert_eq!(together.sections[2].view(), ViewSpec::Summary);
3141 }
3142
3143 #[test]
3144 fn analyzed_unfiltered_views_together_match_independent_answers_and_each_view_alone() {
3145 const RUST: &str = "fn main() {\n println!(\"hi\");\n}\n";
3146 const MARKDOWN: &str = "# Guide\n\nA small useful guide.\n";
3147 const TEXT: &str = "plain notes here\n";
3148 let root = tempfile::tempdir().expect("root");
3149 fs::create_dir_all(root.path().join("src")).expect("src");
3150 fs::create_dir_all(root.path().join("docs")).expect("docs");
3151 fs::write(root.path().join("src/main.rs"), RUST).expect("rust");
3152 fs::write(root.path().join("docs/guide.md"), MARKDOWN).expect("markdown");
3153 fs::write(root.path().join("notes.txt"), TEXT).expect("text");
3154 for (path, seconds) in [("src/main.rs", 10), ("notes.txt", 20), ("docs/guide.md", 30)] {
3155 fs::File::options()
3156 .write(true)
3157 .open(root.path().join(path))
3158 .expect("open for timestamp")
3159 .set_times(
3160 fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(seconds)),
3161 )
3162 .expect("set timestamp");
3163 }
3164 let (mut index, _) = crate::scan::scan_into_index(
3165 root.path(),
3166 &crate::ScanConfig { read_controls: false, ..crate::ScanConfig::default() },
3167 )
3168 .expect("scan");
3169 crate::content::analyze_index(
3170 &mut index,
3171 crate::content::AnalysisRequest {
3172 profile: AnalysisSet::ALL,
3173 ..crate::content::AnalysisRequest::default()
3174 },
3175 );
3176
3177 let views = [
3178 ViewSpec::Types,
3179 ViewSpec::Families,
3180 ViewSpec::Languages,
3181 ViewSpec::Documents,
3182 ViewSpec::Files,
3183 ViewSpec::Largest,
3184 ViewSpec::Recent,
3185 ViewSpec::Summary,
3186 ViewSpec::Tree,
3187 ViewSpec::Extensions,
3188 ];
3189 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
3190 let together = run(&index, &query(&views, selection.clone()));
3191 for (i, view) in views.iter().enumerate() {
3192 let alone = run(&index, &query(&[*view], selection.clone()));
3193 assert_eq!(
3194 format!("{:?}", together.sections[i]),
3195 format!("{:?}", alone.sections[0]),
3196 "{view:?} changed when requested with the other views"
3197 );
3198 }
3199
3200 for (at, view, files) in [(0, ViewSpec::Types, 3), (1, ViewSpec::Families, 3)] {
3201 let Section::Metrics { view: actual, summary } = &together.sections[at] else {
3202 panic!("expected {view:?} metrics")
3203 };
3204 assert_eq!(*actual, view);
3205 assert_eq!(summary.total.files, files);
3206 assert_eq!(summary.total.analyzed_files, files);
3207 }
3208 let Section::Metrics { summary: languages, .. } = &together.sections[2] else {
3209 panic!("languages")
3210 };
3211 assert_eq!(languages.total.files, 1);
3212 assert_eq!(languages.total.metrics.code_lines, Some(3));
3213 let Section::Metrics { summary: documents, .. } = &together.sections[3] else {
3214 panic!("documents")
3215 };
3216 assert_eq!(documents.total.files, 2);
3217 assert_eq!(documents.total.analyzed_files, 2);
3218 assert_eq!(documents.total.document_metric_files, 2);
3219 assert_eq!(documents.total.document_raw_words, 8);
3220 assert_eq!(documents.total.document_word_stats.logical_words(), 8);
3221 assert_eq!(documents.total.share, MetricShare { numerator: 8, denominator: 8 });
3222
3223 let Section::Files { rows: files, total, .. } = &together.sections[4] else {
3224 panic!("files")
3225 };
3226 assert_eq!(*total, 5);
3227 assert_eq!(
3228 files.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
3229 ["docs", "docs/guide.md", "notes.txt", "src", "src/main.rs"].map(Path::new).to_vec()
3230 );
3231 let Section::Files { rows: largest, total, .. } = &together.sections[5] else {
3232 panic!("largest")
3233 };
3234 assert_eq!(*total, 3);
3235 assert_eq!(
3236 largest.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
3237 ["src/main.rs", "docs/guide.md", "notes.txt"].map(Path::new).to_vec()
3238 );
3239 let Section::Files { rows: recent, total, .. } = &together.sections[6] else {
3240 panic!("recent")
3241 };
3242 assert_eq!(*total, 3);
3243 assert_eq!(
3244 recent.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
3245 ["docs/guide.md", "notes.txt", "src/main.rs"].map(Path::new).to_vec()
3246 );
3247 let Section::Summary(summary) = &together.sections[7] else { panic!("summary") };
3248 assert_eq!((summary.files, summary.dirs), (3, 2));
3249 assert_eq!(
3250 summary.bytes,
3251 u64::try_from(RUST.len() + MARKDOWN.len() + TEXT.len()).expect("fixture bytes")
3252 );
3253 let Section::Tree { root: tree, .. } = &together.sections[8] else { panic!("tree") };
3254 assert_eq!((tree.files, tree.dirs), (3, 2));
3255 let Section::Extensions { rows, total } = &together.sections[9] else {
3256 panic!("extensions")
3257 };
3258 assert_eq!((*total, rows.len()), (3, 3));
3259 }
3260
3261 #[test]
3262 fn a_report_derives_provenance_from_its_index() {
3263 let index = sample();
3264 let report = run(&index, &query(&[ViewSpec::Summary], Selection::default()));
3265 assert_eq!(report.provenance.source, ReportSource::ColdScan);
3266 assert!(report.status.complete);
3267 assert!(report.provenance.scan_started_at.is_some());
3268 assert_eq!(report.provenance.generated_at, generated_at());
3269 assert_eq!(report.root, Path::new("/root"));
3270 }
3271
3272 #[test]
3273 fn reporting_is_pure_and_repeatable() {
3274 let index = sample();
3275 let request = query(&[ViewSpec::Tree, ViewSpec::Extensions], Selection::default());
3276 assert_eq!(format!("{:?}", run(&index, &request)), format!("{:?}", run(&index, &request)));
3277 }
3278
3279 #[test]
3280 fn metadata_grouping_views_use_the_generic_metric_projection() {
3281 let index = sample();
3282 let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
3283 let report = run(
3284 &index,
3285 &query(&[ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages], apparent),
3286 );
3287 let Section::Metrics { summary: types, .. } = &report.sections[0] else {
3288 panic!("expected type metrics")
3289 };
3290 let rust = types.rows.iter().find(|row| row.id == "rust").expect("rust");
3291 assert_eq!((rust.files, rust.bytes), (3, 350));
3292 assert_eq!((rust.share.numerator, rust.share.denominator), (350, 657));
3293 assert_eq!(types.share_metric, ShareMetric::ApparentBytes);
3294
3295 let Section::Metrics { summary: families, .. } = &report.sections[1] else {
3296 panic!("expected family metrics")
3297 };
3298 assert!(families.rows.iter().any(|row| row.id == "code"));
3299 assert!(families.rows.iter().any(|row| row.id == "prose"));
3300 assert_eq!(families.share_metric, ShareMetric::ApparentBytes);
3301
3302 let Section::Metrics { summary: languages, .. } = &report.sections[2] else {
3303 panic!("expected language metrics")
3304 };
3305 let rust = languages.rows.iter().find(|row| row.id == "rust").expect("rust");
3306 assert_eq!((rust.files, rust.bytes), (3, 350));
3307 assert_eq!((rust.share.numerator, rust.share.denominator), (350, 350));
3308 assert_eq!(languages.share_metric, ShareMetric::ApparentBytes);
3309 }
3310
3311 const CONTROL: &str = ".gitignore";
3313
3314 fn classified_sample() -> Index {
3316 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
3317 index
3318 .apply(&Observation::new(vec![
3319 Op::ControlUpsert {
3320 path: PathBuf::from(CONTROL),
3321 source: b"build/\n*.log\n".to_vec(),
3322 },
3323 upsert("src", EntryKind::Dir, Attrs::default()),
3324 upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
3325 upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
3326 upsert("src/debug.log", EntryKind::File, attrs(25, 70)),
3327 upsert("docs", EntryKind::Dir, Attrs::default()),
3328 upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
3329 upsert("build", EntryKind::Dir, Attrs::default()),
3330 upsert("build/cache", EntryKind::Dir, Attrs::default()),
3331 upsert("build/cache/out.bin", EntryKind::File, attrs(1_000, 60)),
3332 ]))
3333 .expect("apply");
3334 index
3335 }
3336
3337 fn ignored_of(row: &SummaryRow) -> IgnoredTally {
3338 row.ignored.expect("an observing index reports an ignored share")
3339 }
3340
3341 #[test]
3345 fn the_two_tiers_agree_on_the_ignored_share() {
3346 let index = classified_sample();
3347 let expected = IgnoredTally { files: 2, dirs: 2, bytes: 1_025, allocated: 1_024 + 512 };
3348 for selection in
3349 [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
3350 {
3351 let unfiltered = selection.is_unfiltered();
3352 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
3353 assert_eq!(ignored_of(&summary), expected, "unfiltered: {unfiltered}");
3354
3355 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection.clone())));
3356 assert_eq!(tree.ignored, Some(expected), "unfiltered: {unfiltered}");
3357 let child = |name: &str| {
3358 tree.children.iter().find(|node| node.name == name).expect(name).ignored
3359 };
3360 assert_eq!(
3361 child("build"),
3362 Some(IgnoredTally { files: 1, dirs: 1, bytes: 1_000, allocated: 1_024 }),
3363 "an ignored directory is wholly ignored below it, unfiltered: {unfiltered}"
3364 );
3365 assert_eq!(
3366 child("src"),
3367 Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
3368 "unfiltered: {unfiltered}"
3369 );
3370 assert_eq!(child("docs"), Some(IgnoredTally::default()), "observed, nothing ignored");
3371
3372 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
3373 let row = |extension: &str| {
3374 rows.iter().find(|row| row.extension == extension).expect(extension).ignored
3375 };
3376 assert_eq!(
3377 row(".log"),
3378 Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
3379 "unfiltered: {unfiltered}"
3380 );
3381 assert_eq!(row(".rs"), Some(IgnoredTally::default()), "unfiltered: {unfiltered}");
3382
3383 let files = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3384 let flag =
3385 |path: PathBuf| files.iter().find(|row| row.path == path).map(|row| row.ignored);
3386 assert_eq!(flag(PathBuf::from("build")), Some(Some(true)));
3387 assert_eq!(flag(PathBuf::from("src")), Some(Some(false)));
3388 assert_eq!(flag(["src", "debug.log"].iter().collect()), Some(Some(true)));
3389 assert_eq!(flag(["build", "cache", "out.bin"].iter().collect()), Some(Some(true)));
3390 }
3391 }
3392
3393 #[test]
3396 fn ignored_entries_partition_the_tree_and_rank_by_what_they_select() {
3397 let index = classified_sample();
3398 let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
3401 let with = |ignored| Selection { ignored, ..apparent.clone() };
3402 let summary = |selection| summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
3403 let total = summary(apparent.clone());
3404 let kept = summary(with(IgnoredEntries::Exclude));
3405 let only = summary(with(IgnoredEntries::Only));
3406 assert_eq!(
3407 (kept.files + only.files, kept.dirs + only.dirs, kept.bytes + only.bytes),
3408 (total.files, total.dirs, total.bytes)
3409 );
3410 assert_eq!(ignored_of(&kept), IgnoredTally::default());
3411 let whole = ignored_of(&only);
3412 assert_eq!((whole.files, whole.dirs, whole.bytes), (only.files, only.dirs, only.bytes));
3413
3414 let ranked = |selection| {
3415 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)))
3416 .children
3417 .iter()
3418 .map(|node| (node.name.clone(), node.bytes))
3419 .collect::<Vec<_>>()
3420 };
3421 let row = |name: &str, bytes: u64| (name.to_string(), bytes);
3422 assert_eq!(
3423 ranked(apparent.clone()),
3424 [row("build", 1_000), row("src", 325), row("docs", 300)]
3425 );
3426 assert_eq!(
3427 ranked(with(IgnoredEntries::Exclude)),
3428 [row("docs", 300), row("src", 300)],
3429 "unignored sizes rank the rows, with the name breaking the tie"
3430 );
3431 assert_eq!(ranked(with(IgnoredEntries::Only)), [row("build", 1_000), row("src", 25)]);
3432 }
3433
3434 #[test]
3437 fn an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by() {
3438 let mut index =
3439 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
3440 index
3441 .apply(&Observation::new(vec![
3442 upsert("build", EntryKind::Dir, Attrs::default()),
3443 upsert("build/out.bin", EntryKind::File, attrs(1_000, 60)),
3444 ]))
3445 .expect("apply");
3446 let views = [ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
3447 let report = run(&index, &query(&views, Selection::default()));
3448 let Section::Summary(summary) = &report.sections[0] else { panic!("a summary") };
3449 let Section::Tree { root: tree, .. } = &report.sections[1] else { panic!("a tree") };
3450 let Section::Extensions { rows: extensions, .. } = &report.sections[2] else {
3451 panic!("extensions")
3452 };
3453 let Section::Files { rows: files, .. } = &report.sections[3] else { panic!("files") };
3454 assert_eq!(summary.ignored, None);
3455 assert_eq!(tree.ignored, None);
3456 assert!(tree.children.iter().all(|node| node.ignored.is_none()));
3457 assert!(extensions.iter().all(|row| row.ignored.is_none()));
3458 assert!(files.iter().all(|row| row.ignored.is_none()));
3459
3460 let exclude = Query {
3461 selection: Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() },
3462 views: vec![ViewSpec::Summary],
3463 ..Query::default()
3464 };
3465 let only = Query {
3466 selection: Selection { ignored: IgnoredEntries::Only, ..Selection::default() },
3467 ..exclude.clone()
3468 };
3469 for refused in [exclude, only] {
3474 assert!(
3475 matches!(
3476 super::report(
3477 &index,
3478 &crate::test_support::read_of(&index, refused),
3479 generated_at()
3480 ),
3481 Err(crate::Error::InvalidRequest(
3482 crate::query::RequestError::IgnoredWithoutObservation(_)
3483 ))
3484 ),
3485 "a selection by ignored state over an unobserving index is refused"
3486 );
3487 }
3488 assert_eq!(summary_of(&run(&index, &query(&views, Selection::default()))).files, 1);
3489 }
3490}