1use std::borrow::Cow;
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21
22use crate::classify::{Classification, ContentFamily, DetectionConfidence, DetectionSource};
23use crate::content::{
24 AnalysisSet, CodeMetrics, ContentDetection, ContentProvenance, CoverageReason, FileAnalysis,
25 LogicalWordStats, MetricDef,
26};
27use crate::control::ControlCoverage;
28use crate::engine_contract::{EntryKind, ScanScope};
29use crate::index::{EntryId, ExtTally, Index, RollUpScalars};
30use crate::query::query_request::{Basis, Request};
31use crate::query::query_selection::{
32 Bound, IgnoredEntries, NameIdentity, Selection, ShareThreshold, SizeMetric, SortKey,
33};
34use crate::query::{Rejection, ReportProvenance, TreeStatus, query_subtrees};
35
36#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum ViewSpec {
39 List,
41 Tree,
43 Types,
45 Extensions,
47 Families,
49 Languages,
51 Code,
53 Documents,
55 Files,
57 Largest,
62 Recent,
66 Summary,
68}
69
70impl ViewSpec {
71 fn default_sort(self) -> SortKey {
73 match self {
74 Self::List
76 | Self::Tree
77 | Self::Types
78 | Self::Extensions
79 | Self::Families
80 | Self::Languages
81 | Self::Code
82 | Self::Documents
83 | Self::Summary
84 | Self::Largest => SortKey::Size,
87 Self::Files => SortKey::Name,
92 Self::Recent => SortKey::Mtime,
93 }
94 }
95
96 const fn default_limit(self) -> Bound {
104 match self {
105 Self::Largest | Self::Recent => Bound::Limit(20),
106 _ => Bound::All,
107 }
108 }
109
110 const fn default_depth(self) -> Bound {
116 match self {
117 Self::Tree => Bound::Limit(5),
118 _ => Bound::All,
119 }
120 }
121
122 const fn files_only(self) -> bool {
127 matches!(self, Self::Largest | Self::Recent)
128 }
129
130 pub const ALL: [Self; 12] = [
135 Self::List,
136 Self::Summary,
137 Self::Tree,
138 Self::Families,
139 Self::Types,
140 Self::Extensions,
141 Self::Languages,
142 Self::Code,
143 Self::Documents,
144 Self::Largest,
145 Self::Recent,
146 Self::Files,
147 ];
148
149 pub fn parse(value: &str) -> Result<Self, String> {
155 match value.trim().to_ascii_lowercase().as_str() {
156 "list" => Ok(Self::List),
157 "tree" => Ok(Self::Tree),
158 "types" => Ok(Self::Types),
159 "extensions" => Ok(Self::Extensions),
160 "families" => Ok(Self::Families),
161 "languages" => Ok(Self::Languages),
162 "code" => Ok(Self::Code),
163 "documents" => Ok(Self::Documents),
164 "largest" => Ok(Self::Largest),
165 "recent" => Ok(Self::Recent),
166 "files" => Ok(Self::Files),
167 "summary" => Ok(Self::Summary),
168 _ => Err(format!("expected one of {}", Self::vocabulary())),
171 }
172 }
173
174 pub fn vocabulary() -> String {
176 let mut names: Vec<&str> = Self::ALL.iter().map(|view| view.label()).collect();
177 names.push("full");
178 names.join(", ")
179 }
180
181 pub const fn label(self) -> &'static str {
183 match self {
184 Self::List => "list",
185 Self::Tree => "tree",
186 Self::Types => "types",
187 Self::Extensions => "extensions",
188 Self::Families => "families",
189 Self::Languages => "languages",
190 Self::Code => "code",
191 Self::Documents => "documents",
192 Self::Largest => "largest",
193 Self::Recent => "recent",
194 Self::Files => "files",
195 Self::Summary => "summary",
196 }
197 }
198
199 pub const fn implies(self) -> AnalysisSet {
213 match self {
214 Self::Code => AnalysisSet::CODE_ONLY,
215 Self::Documents => AnalysisSet::WORDS_ONLY,
216 Self::List
217 | Self::Tree
218 | Self::Types
219 | Self::Extensions
220 | Self::Families
221 | Self::Languages
222 | Self::Files
223 | Self::Largest
224 | Self::Recent
225 | Self::Summary => AnalysisSet::NONE,
226 }
227 }
228
229 pub const fn shows(self) -> AnalysisSet {
241 match self {
242 Self::Types | Self::Families | Self::Languages => AnalysisSet::ALL,
243 Self::Code => AnalysisSet::CODE_ONLY,
244 Self::Documents => AnalysisSet::WORDS_ONLY,
245 Self::List
246 | Self::Tree
247 | Self::Extensions
248 | Self::Files
249 | Self::Largest
250 | Self::Recent
251 | Self::Summary => AnalysisSet::NONE,
252 }
253 }
254
255 pub const fn default_for(analysis: AnalysisSet) -> Self {
263 match (analysis.includes_code(), analysis.includes_words()) {
264 (true, _) => Self::Code,
265 (false, true) => Self::Documents,
266 (false, false) if analysis.is_enabled() => Self::Families,
267 (false, false) => Self::List,
268 }
269 }
270
271 pub fn resolve(
286 spec: Option<&str>,
287 analysis: AnalysisSet,
288 label: &str,
289 ) -> Result<(Vec<Self>, Vec<Self>), String> {
290 Self::resolve_rejecting(spec, analysis).map_err(|rejection| rejection.labeled(label))
291 }
292
293 pub(crate) fn resolve_rejecting(
296 spec: Option<&str>,
297 analysis: AnalysisSet,
298 ) -> Result<(Vec<Self>, Vec<Self>), Rejection> {
299 ViewList::parse(spec).map(|views| views.resolve(analysis))
300 }
301
302 pub fn defaults_for(analysis: AnalysisSet) -> Vec<Self> {
305 if analysis.includes_code() && analysis.includes_words() {
306 vec![Self::Code, Self::Documents]
307 } else {
308 vec![Self::default_for(analysis)]
309 }
310 }
311
312 pub const FULL_IS_EXCLUSIVE: &'static str =
319 "it names the whole report and cannot be combined with another view";
320
321 pub fn full_report(analysis: AnalysisSet) -> (Vec<Self>, Vec<Self>) {
329 Self::ALL
330 .into_iter()
331 .filter(|view| {
332 view.is_summary_view()
333 && (!analysis.includes_code() || !matches!(view, Self::Languages))
334 })
335 .partition(|view| analysis.contains(view.implies()))
336 }
337
338 pub const fn is_summary_view(self) -> bool {
343 !matches!(self, Self::List | Self::Files)
344 }
345}
346
347#[derive(Clone, Debug, PartialEq, Eq)]
355pub(crate) enum ViewList {
356 Default,
358 Full,
360 Named(Vec<ViewSpec>),
362}
363
364impl ViewList {
365 pub(crate) fn parse(spec: Option<&str>) -> Result<Self, Rejection> {
367 let Some(spec) = spec else {
368 return Ok(Self::Default);
369 };
370 let mut parsed: Vec<ViewSpec> = Vec::new();
371 let mut full_seen = false;
372 for raw in spec.split(',') {
373 let token = raw.trim();
374 if token.is_empty() {
375 return Err(Rejection::new(spec, "empty entry in the list"));
376 }
377 if token.eq_ignore_ascii_case("full") {
378 if full_seen || !parsed.is_empty() {
379 return Err(Rejection::new("full", ViewSpec::FULL_IS_EXCLUSIVE));
380 }
381 full_seen = true;
382 continue;
383 }
384 if full_seen {
385 return Err(Rejection::new("full", ViewSpec::FULL_IS_EXCLUSIVE));
386 }
387 let view =
388 ViewSpec::parse(token).map_err(|expected| Rejection::new(token, expected))?;
389 if parsed.contains(&view) {
390 return Err(Rejection::new(spec, format!("{token:?} appears more than once")));
391 }
392 parsed.push(view);
393 }
394 Ok(if full_seen { Self::Full } else { Self::Named(parsed) })
395 }
396
397 pub(crate) fn implying(&self) -> Vec<ViewSpec> {
402 match self {
403 Self::Named(views) => {
404 views.iter().copied().filter(|view| view.implies().is_enabled()).collect()
405 }
406 Self::Default | Self::Full => Vec::new(),
407 }
408 }
409
410 pub(crate) fn implies(&self) -> AnalysisSet {
412 self.implying().into_iter().fold(AnalysisSet::NONE, |set, view| set.union(view.implies()))
413 }
414
415 pub(crate) fn resolve(self, analysis: AnalysisSet) -> (Vec<ViewSpec>, Vec<ViewSpec>) {
417 match self {
418 Self::Default => (ViewSpec::defaults_for(analysis), Vec::new()),
419 Self::Full => ViewSpec::full_report(analysis),
420 Self::Named(views) => (views, Vec::new()),
421 }
422 }
423}
424
425#[derive(Clone, Copy, Debug, PartialEq, Eq)]
444pub struct AxisNames {
445 pub view: &'static str,
447 pub format: &'static str,
449 pub analyze: &'static str,
451 pub control_budget: &'static str,
453 pub control_line_limit: &'static str,
455 pub exclude_ignored: &'static str,
457 pub only_ignored: &'static str,
459 pub read_controls: &'static str,
461 pub scan_depth: &'static str,
463 pub one_filesystem: &'static str,
465 pub follow_symlinks: &'static str,
467 pub include: &'static str,
469 pub modified_since: &'static str,
471 pub modified_before: &'static str,
473 pub kind: &'static str,
475 pub ignored: &'static str,
477 pub depth: &'static str,
479 pub min_share: &'static str,
481 pub breadth: &'static str,
483 pub limit: &'static str,
485 pub sort: &'static str,
487 pub size: &'static str,
489 pub words_per_page: &'static str,
491 pub cache: &'static str,
493 pub stale_ok: &'static str,
495 pub watch: &'static str,
497 pub setting_separator: &'static str,
500}
501
502impl AxisNames {
503 pub const FLAGS: Self = Self {
512 view: "--view",
513 format: "--format",
514 analyze: "--analyze",
515 control_budget: "--gitignore-budget",
516 control_line_limit: "--gitignore-line-limit",
517 exclude_ignored: "--ignored=exclude",
518 only_ignored: "--ignored=only",
519 read_controls: "--no-gitignore",
520 scan_depth: "--scan-depth",
521 one_filesystem: "--one-filesystem",
522 follow_symlinks: "follow_symlinks",
523 include: "--include",
524 modified_since: "--modified-since",
525 modified_before: "--modified-before",
526 kind: "--kind",
527 ignored: "--ignored",
528 depth: "--depth",
529 min_share: "--min-share",
530 breadth: "--breadth",
531 limit: "--limit",
532 sort: "--sort",
533 size: "--size",
534 words_per_page: "--words-per-page",
535 cache: "--cache",
536 stale_ok: "--stale-ok",
537 watch: "--watch",
538 setting_separator: " ",
539 };
540
541 pub const FIELDS: Self = Self {
553 view: "view",
554 format: "format",
555 analyze: "analyze",
556 control_budget: "control_budget",
557 control_line_limit: "control_line_limit",
558 exclude_ignored: "ignored=exclude",
559 only_ignored: "ignored=only",
560 read_controls: "read_controls",
561 scan_depth: "max_depth",
562 one_filesystem: "one_filesystem",
563 follow_symlinks: "follow_symlinks",
564 include: "include",
565 modified_since: "modified_since",
566 modified_before: "modified_before",
567 kind: "kind",
568 ignored: "ignored",
569 depth: "depth",
570 min_share: "min_share",
571 breadth: "breadth",
572 limit: "limit",
573 sort: "sort",
574 size: "size",
575 words_per_page: "words_per_page",
576 cache: "cache policy",
577 stale_ok: "stale_ok",
578 watch: "watch",
579 setting_separator: ", ",
580 };
581}
582
583impl Default for AxisNames {
584 fn default() -> Self {
585 Self::FIELDS
586 }
587}
588
589#[derive(Clone, Debug)]
591pub struct Query {
592 pub selection: Selection,
594 pub views: Vec<ViewSpec>,
596 pub format: crate::report_format::Format,
598 pub omitted_views: Vec<ViewSpec>,
604 pub axes: &'static AxisNames,
609 pub words_per_page: u64,
611}
612
613impl Default for Query {
614 fn default() -> Self {
615 Self {
616 selection: Selection::default(),
617 views: Vec::new(),
618 format: crate::report_format::Format::Text,
619 omitted_views: Vec::new(),
620 axes: &AxisNames::FIELDS,
621 words_per_page: crate::query::Request::DEFAULTS.words_per_page,
622 }
623 }
624}
625
626impl Query {
627 pub fn tree_for(&self, view: ViewSpec) -> bool {
629 use crate::report_format::Format;
630 match view {
631 ViewSpec::List => matches!(self.format, Format::Text | Format::Tree),
632 ViewSpec::Tree => !matches!(self.format, Format::Paths | Format::Long),
633 ViewSpec::Files => self.format == Format::Tree,
634 _ => false,
635 }
636 }
637
638 pub(crate) fn needs_selection_walk(&self) -> bool {
640 !self.selection.is_unfiltered()
641 || self.views.iter().any(|view| {
642 matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
643 && !self.tree_for(*view)
644 })
645 }
646
647 pub fn limit_for(&self, view: ViewSpec) -> Bound {
649 self.selection.limit.unwrap_or_else(|| {
650 if self.tree_for(view) {
651 ViewSpec::Tree.default_limit()
652 } else if view == ViewSpec::Tree {
653 Bound::All
654 } else {
655 view.default_limit()
656 }
657 })
658 }
659
660 pub fn depth_for(&self, view: ViewSpec) -> Bound {
662 self.selection.depth.unwrap_or_else(|| {
663 if self.tree_for(view) { ViewSpec::Tree.default_depth() } else { view.default_depth() }
664 })
665 }
666
667 pub fn breadth_for(&self) -> Bound {
669 self.selection.breadth.unwrap_or(Bound::All)
670 }
671
672 pub fn min_share_for(&self) -> ShareThreshold {
674 self.selection.min_share.clone().unwrap_or_else(ShareThreshold::one_percent)
675 }
676}
677
678#[derive(Clone, Copy, PartialEq, Eq, Debug)]
680pub enum ReportSource {
681 ColdScan,
683 WarmRevalidate,
685 CacheOnly,
687}
688
689#[derive(Clone, Debug)]
691pub struct TreeNode {
692 pub path: PathBuf,
694 pub name: String,
696 pub kind: EntryKind,
698 pub entry_ignored: Option<bool>,
701 pub bytes: u64,
703 pub allocated: u64,
705 pub files: u64,
707 pub dirs: u64,
709 pub ignored: Option<IgnoredTally>,
716 pub newest_mtime_ns: Option<i64>,
718 pub children: Vec<TreeNode>,
720 pub omissions: Vec<TreeOmission>,
722 pub truncated: bool,
724}
725
726#[derive(Clone, Copy, Debug, PartialEq, Eq)]
728pub enum TreeOmissionReason {
729 Share,
731 Breadth,
733 Depth,
735 Rows,
737}
738
739impl TreeOmissionReason {
740 pub const fn label(self) -> &'static str {
742 match self {
743 Self::Share => "share",
744 Self::Breadth => "breadth",
745 Self::Depth => "depth",
746 Self::Rows => "rows",
747 }
748 }
749}
750
751#[derive(Clone, Debug, PartialEq, Eq)]
753pub struct TreeOmission {
754 pub reason: TreeOmissionReason,
756 pub entries: usize,
758 pub files: Option<u64>,
760 pub bytes: Option<u64>,
762 pub allocated: Option<u64>,
764 pub ignored: Option<IgnoredSize>,
766}
767
768#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
771pub struct IgnoredSize {
772 pub bytes: u64,
774 pub allocated: u64,
776}
777
778impl IgnoredSize {
779 fn from_tally(tally: IgnoredTally) -> Self {
780 Self { bytes: tally.bytes, allocated: tally.allocated }
781 }
782
783 fn checked_add(self, other: Self) -> Option<Self> {
784 Some(Self {
785 bytes: self.bytes.checked_add(other.bytes)?,
786 allocated: self.allocated.checked_add(other.allocated)?,
787 })
788 }
789}
790
791#[derive(Clone, Debug, PartialEq, Eq)]
797pub struct TreeRemainder {
798 pub files: Option<u64>,
800 pub bytes: Option<u64>,
802 pub allocated: Option<u64>,
804 pub ignored: Option<IgnoredSize>,
807 pub reasons: Vec<TreeOmissionReason>,
809}
810
811impl TreeRemainder {
812 pub fn from_tree(root: Option<&TreeNode>, omissions: &[TreeOmission]) -> Option<Self> {
814 let mut result = Self {
815 files: Some(0),
816 bytes: Some(0),
817 allocated: Some(0),
818 ignored: Some(IgnoredSize::default()),
819 reasons: Vec::new(),
820 };
821 let mut add = |omission: &TreeOmission| {
822 result.files = result.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
823 result.bytes = result.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
824 result.allocated =
825 result.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
826 result.ignored =
827 result.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
828 if !result.reasons.contains(&omission.reason) {
829 result.reasons.push(omission.reason);
830 }
831 };
832 let boundary = root.map_or(omissions, |root| root.omissions.as_slice());
835 for omission in boundary {
836 add(omission);
837 }
838 result.reasons.sort_by_key(|why| match why {
839 TreeOmissionReason::Share => 0,
840 TreeOmissionReason::Depth => 1,
841 TreeOmissionReason::Breadth => 2,
842 TreeOmissionReason::Rows => 3,
843 });
844 (!result.reasons.is_empty()).then_some(result)
845 }
846}
847
848#[derive(Clone, Debug, PartialEq, Eq)]
850pub struct TreeDisplayLimits {
851 pub depth: Bound,
853 pub min_share: ShareThreshold,
855 pub breadth: Bound,
857 pub rows: Bound,
859}
860
861impl Drop for TreeNode {
862 fn drop(&mut self) {
869 let mut pending = std::mem::take(&mut self.children);
870 while let Some(mut node) = pending.pop() {
871 pending.extend(std::mem::take(&mut node.children));
872 }
873 }
874}
875
876#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
883pub struct IgnoredTally {
884 pub files: u64,
886 pub dirs: u64,
888 pub bytes: u64,
890 pub allocated: u64,
892}
893
894impl IgnoredTally {
895 pub(crate) fn between(all: RollUpScalars, unignored: RollUpScalars) -> Self {
897 Self {
898 files: all.files.saturating_sub(unignored.files),
899 dirs: all.dirs.saturating_sub(unignored.dirs),
900 bytes: all.bytes.saturating_sub(unignored.bytes),
901 allocated: all.allocated.saturating_sub(unignored.allocated),
902 }
903 }
904
905 fn add(&mut self, other: Self) {
906 self.files = self.files.saturating_add(other.files);
907 self.dirs = self.dirs.saturating_add(other.dirs);
908 self.bytes = self.bytes.saturating_add(other.bytes);
909 self.allocated = self.allocated.saturating_add(other.allocated);
910 }
911}
912
913#[derive(Clone, Debug)]
915pub struct TypeRow {
916 pub extension: String,
918 pub files: u64,
920 pub bytes: u64,
922 pub allocated: u64,
924 pub ignored: Option<IgnoredTally>,
926}
927
928#[derive(Clone, Copy, PartialEq, Eq, Debug)]
930pub enum MetricGroup {
931 Type,
933 Family,
935}
936
937#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
942pub struct MetricShare {
943 pub numerator: u64,
947 pub denominator: u64,
949}
950
951#[derive(Clone, Copy, PartialEq, Eq, Debug)]
953pub enum ShareMetric {
954 ApparentBytes,
956 AllocatedBytes,
958 CodeLines,
960 DocumentWords,
962 RawWords,
964}
965
966impl ShareMetric {
967 pub const fn as_str(self) -> &'static str {
969 match self {
970 Self::ApparentBytes => "apparent_bytes",
971 Self::AllocatedBytes => "allocated_bytes",
972 Self::CodeLines => "code_lines",
973 Self::DocumentWords => "document_words",
974 Self::RawWords => "raw_words",
975 }
976 }
977}
978
979#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
981pub struct ReportMetricValues {
982 pub physical_lines: Option<u64>,
984 pub blank_lines: Option<u64>,
986 pub nonblank_lines: Option<u64>,
988 pub raw_words: Option<u64>,
990 pub code_lines: Option<u64>,
992 pub comment_lines: Option<u64>,
994 pub code_blank_lines: Option<u64>,
996 pub logical_words: Option<u64>,
998 pub paragraphs: Option<u64>,
1000 pub visible_words: Option<u64>,
1002 pub visible_logical_words: Option<u64>,
1004 pub document_words: Option<u64>,
1006}
1007
1008impl ReportMetricValues {
1009 fn for_analysis(analysis: AnalysisSet) -> Self {
1010 let lines = analysis.is_enabled().then_some(0);
1011 let code = analysis.includes_code().then_some(0);
1012 let words = analysis.includes_words().then_some(0);
1013 Self {
1014 physical_lines: lines,
1015 blank_lines: lines,
1016 nonblank_lines: lines,
1017 raw_words: lines,
1018 code_lines: code,
1019 comment_lines: code,
1020 code_blank_lines: code,
1021 logical_words: words,
1022 paragraphs: words,
1023 visible_words: words,
1024 visible_logical_words: words,
1025 document_words: words,
1026 }
1027 }
1028
1029 fn add_assign(&mut self, other: &Self) {
1030 add_optional(&mut self.physical_lines, other.physical_lines);
1031 add_optional(&mut self.blank_lines, other.blank_lines);
1032 add_optional(&mut self.nonblank_lines, other.nonblank_lines);
1033 add_optional(&mut self.raw_words, other.raw_words);
1034 add_optional(&mut self.code_lines, other.code_lines);
1035 add_optional(&mut self.comment_lines, other.comment_lines);
1036 add_optional(&mut self.code_blank_lines, other.code_blank_lines);
1037 add_optional(&mut self.paragraphs, other.paragraphs);
1038 add_optional(&mut self.visible_words, other.visible_words);
1039 }
1040}
1041
1042fn add_optional(total: &mut Option<u64>, value: Option<u64>) {
1043 if let (Some(total), Some(value)) = (total, value) {
1044 *total = total.saturating_add(value);
1045 }
1046}
1047
1048#[derive(Clone, Debug)]
1050pub struct MetricRow {
1051 pub analysis: AnalysisSet,
1053 pub id: String,
1055 pub family: ContentFamily,
1057 pub files: u64,
1059 pub bytes: u64,
1061 pub allocated: u64,
1063 pub analyzed_files: u64,
1065 pub metrics: ReportMetricValues,
1067 pub(crate) logical_word_stats: LogicalWordStats,
1069 pub(crate) visible_logical_word_stats: LogicalWordStats,
1071 pub document_raw_words: u64,
1073 pub document_word_stats: LogicalWordStats,
1075 pub document_metric_files: u64,
1077 pub coverage: BTreeMap<CoverageReason, u64>,
1079 pub lines_coverage: BTreeMap<CoverageReason, u64>,
1081 pub code_coverage: Option<BTreeMap<CoverageReason, u64>>,
1083 pub words_coverage: Option<BTreeMap<CoverageReason, u64>>,
1085 pub detection_sources: BTreeMap<DetectionSource, u64>,
1087 pub detection_confidence: BTreeMap<DetectionConfidence, u64>,
1089 pub generated_files: u64,
1091 pub vendored_files: u64,
1093 pub documentation_files: u64,
1095 pub share: MetricShare,
1097}
1098
1099impl MetricRow {
1100 pub fn metric_value(&self, metric: &MetricDef) -> Option<u64> {
1102 if !self.analysis.contains(metric.owner) {
1103 return None;
1104 }
1105 match metric.name {
1106 "physical_lines" => self.metrics.physical_lines,
1107 "blank_lines" => self.metrics.blank_lines,
1108 "nonblank_lines" => self.metrics.nonblank_lines,
1109 "raw_words" => self.metrics.raw_words,
1110 "code_lines" => self.metrics.code_lines,
1111 "comment_lines" => self.metrics.comment_lines,
1112 "code_blank_lines" => self.metrics.code_blank_lines,
1113 "logical_words" => self.metrics.logical_words,
1114 "paragraphs" => self.metrics.paragraphs,
1115 "visible_words" => self.metrics.visible_words,
1116 "visible_logical_words" => self.metrics.visible_logical_words,
1117 "document_words" => self.metrics.document_words,
1118 _ => None,
1119 }
1120 }
1121
1122 fn finish_derived_metrics(&mut self) {
1123 if self.analysis.includes_words() {
1124 self.metrics.logical_words = Some(self.logical_word_stats.logical_words());
1125 self.metrics.visible_logical_words =
1126 Some(self.visible_logical_word_stats.logical_words());
1127 self.metrics.document_words = Some(self.document_word_stats.logical_words());
1128 }
1129 }
1130}
1131
1132#[derive(Clone, Debug)]
1134pub struct MetricSummary {
1135 pub group: MetricGroup,
1137 pub total: MetricRow,
1143 pub rows: Vec<MetricRow>,
1145 pub total_rows: usize,
1147 pub share_omitted: usize,
1149 pub share_metric: ShareMetric,
1151 pub words_per_page: u64,
1153}
1154
1155#[derive(Clone, Debug, Default)]
1157pub struct CodeTally {
1158 pub source_files: u64,
1160 pub analyzed_files: u64,
1162 pub metrics: CodeMetrics,
1164 pub coverage: BTreeMap<CoverageReason, u64>,
1166 pub missing_records: u64,
1168}
1169
1170impl CodeTally {
1171 fn add_file(&mut self, record: Option<&crate::content::FileAnalysis>) {
1172 self.source_files = self.source_files.saturating_add(1);
1173 match record.and_then(|record| record.code) {
1174 Some(outcome) => {
1175 *self.coverage.entry(outcome.coverage()).or_default() += 1;
1176 if let Some(value) = outcome.value() {
1177 self.analyzed_files = self.analyzed_files.saturating_add(1);
1178 self.metrics.code_lines =
1179 self.metrics.code_lines.saturating_add(value.code_lines);
1180 self.metrics.comment_lines =
1181 self.metrics.comment_lines.saturating_add(value.comment_lines);
1182 self.metrics.code_blank_lines =
1183 self.metrics.code_blank_lines.saturating_add(value.code_blank_lines);
1184 }
1185 }
1186 None => self.missing_records = self.missing_records.saturating_add(1),
1187 }
1188 }
1189}
1190
1191#[derive(Clone, Debug)]
1193pub struct CodeLanguageRow {
1194 pub language: String,
1196 pub selected: CodeTally,
1198 pub non_ignored: Option<CodeTally>,
1200 pub ignored: Option<CodeTally>,
1202 pub unknown: CodeTally,
1204 pub share: MetricShare,
1206}
1207
1208#[derive(Clone, Debug)]
1210pub struct CodeOverview {
1211 pub population: IgnoredEntries,
1213 pub selected: CodeTally,
1215 pub non_ignored: Option<CodeTally>,
1217 pub ignored: Option<CodeTally>,
1219 pub unknown: CodeTally,
1221 pub unclassified_files: u64,
1223 pub analyzed_languages: u64,
1225 pub total_languages: usize,
1227 pub share_omitted: usize,
1229 pub languages: Vec<CodeLanguageRow>,
1231 pub share_metric: ShareMetric,
1233}
1234
1235#[derive(Clone, Debug)]
1237pub struct ContentReportMetadata {
1238 pub profile: AnalysisSet,
1240 pub provenance: ContentProvenance,
1242}
1243
1244#[derive(Clone, Debug)]
1247pub struct FileRow {
1248 pub path: PathBuf,
1250 pub kind: EntryKind,
1252 pub bytes: u64,
1254 pub allocated: u64,
1256 pub mtime_ns: i64,
1258 pub files: Option<u64>,
1260 pub dirs: Option<u64>,
1262 pub complete: Option<bool>,
1266 pub age_ns: Option<i128>,
1269 pub ignored: Option<bool>,
1272 pub sort_value: Option<u64>,
1274 pub classification: Option<ContentDetection>,
1276}
1277
1278#[derive(Clone, Copy, Debug, Default)]
1280pub struct SummaryRow {
1281 pub files: u64,
1283 pub dirs: u64,
1285 pub bytes: u64,
1287 pub allocated: u64,
1289 pub ignored: Option<IgnoredTally>,
1292 pub newest_mtime_ns: Option<i64>,
1294}
1295
1296#[derive(Clone, Debug)]
1298pub enum Section {
1299 Code(Box<CodeOverview>),
1301 Tree {
1303 view: ViewSpec,
1305 limits: TreeDisplayLimits,
1307 root: Option<Box<TreeNode>>,
1309 omissions: Vec<TreeOmission>,
1311 },
1312 Extensions {
1314 rows: Vec<TypeRow>,
1316 total: usize,
1318 share_omitted: usize,
1320 },
1321 Metrics {
1323 view: ViewSpec,
1325 summary: Box<MetricSummary>,
1327 },
1328 Files {
1333 view: ViewSpec,
1335 rows: Vec<FileRow>,
1337 total: usize,
1343 },
1344 Summary(SummaryRow),
1346}
1347
1348impl Section {
1349 pub fn view(&self) -> ViewSpec {
1351 match self {
1352 Self::Code(_) => ViewSpec::Code,
1353 Self::Extensions { .. } => ViewSpec::Extensions,
1354 Self::Tree { view, .. } | Self::Metrics { view, .. } | Self::Files { view, .. } => {
1355 *view
1356 }
1357 Self::Summary(_) => ViewSpec::Summary,
1358 }
1359 }
1360}
1361
1362#[derive(Clone, Debug)]
1364pub struct Report {
1365 pub age_reference_ns: Option<i64>,
1367 pub format: crate::report_format::Format,
1369 pub status: TreeStatus,
1371 pub provenance: ReportProvenance,
1373 pub scope: ScanScope,
1378 pub requested_analysis: AnalysisSet,
1380 pub requested_views: Vec<ViewSpec>,
1382 pub omitted_views: Vec<ViewSpec>,
1384 pub root: PathBuf,
1386 pub notes: Vec<String>,
1393 pub tips: Vec<String>,
1395 pub axes: &'static AxisNames,
1397 pub size: SizeMetric,
1403 pub sort_metric: Option<&'static str>,
1405 pub analysis: Option<ContentReportMetadata>,
1407 pub ignored_entries: IgnoredEntries,
1413 pub ignore_rules: ControlCoverage,
1420 pub sections: Vec<Section>,
1422}
1423
1424pub(crate) fn display_notes(
1441 query: &Query,
1442 content: AnalysisSet,
1443 ignore_rules: &ControlCoverage,
1444) -> (Vec<String>, Vec<String>) {
1445 let mut notes = Vec::new();
1446 let mut tips = Vec::new();
1447 let labels = |views: &[ViewSpec]| views.iter().map(|view| view.label()).collect::<Vec<_>>();
1448 if !query.omitted_views.is_empty() {
1449 let needed = query
1450 .omitted_views
1451 .iter()
1452 .fold(AnalysisSet::NONE, |set, view| set.union(view.implies()));
1453 let held = content.labels();
1454 let missing: Vec<&str> =
1455 needed.named().into_iter().filter(|name| !held.contains(name)).collect();
1456 let without = if content.is_enabled() {
1457 format!("{} analysis", missing.join(" and "))
1458 } else {
1459 "analysis".to_owned()
1460 };
1461 notes.push(format!(
1462 "note: full omits {} without {without}",
1463 labels(&query.omitted_views).join(", ")
1464 ));
1465 tips.push(format!(
1466 "tip: include them: {} {}",
1467 query.axes.analyze,
1468 content.union(needed).request_label()
1469 ));
1470 }
1471 if let Some((note, tip)) = refused_controls_note(ignore_rules, query.axes) {
1472 notes.push(note);
1473 tips.extend(tip);
1474 }
1475 let ranked = match query.selection.sort {
1477 Some(SortKey::Metric(name)) => crate::content::METRICS
1478 .iter()
1479 .find(|metric| metric.name == name)
1480 .map_or(AnalysisSet::NONE, |metric| metric.owner),
1481 _ => AnalysisSet::NONE,
1482 };
1483 let shown = query.views.iter().fold(ranked, |set, view| set.union(view.shows()));
1484 let unshown = unshown_analysis(content, shown);
1485 if unshown.is_enabled() {
1486 notes.push(format!(
1487 "note: {} analysis not shown by {}",
1488 unshown.named().join(" and "),
1489 labels(&query.views).join(", ")
1490 ));
1491 let mut views = if shown.is_enabled() { query.views.clone() } else { Vec::new() };
1492 views.extend(ViewSpec::defaults_for(unshown));
1493 tips.push(format!("tip: show it: {} {}", query.axes.view, labels(&views).join(",")));
1494 }
1495 (notes, tips)
1496}
1497
1498fn unshown_analysis(content: AnalysisSet, shown: AnalysisSet) -> AnalysisSet {
1504 if !shown.is_enabled() {
1505 return content;
1506 }
1507 let mut unshown = AnalysisSet::NONE;
1508 if content.includes_code() && !shown.includes_code() {
1509 unshown = unshown.with_code();
1510 }
1511 if content.includes_words() && !shown.includes_words() {
1512 unshown = unshown.with_words();
1513 }
1514 unshown
1515}
1516
1517const REFUSED_DIRECTORIES_NAMED: usize = 5;
1519
1520fn refused_controls_note(
1529 ignore_rules: &ControlCoverage,
1530 axes: &AxisNames,
1531) -> Option<(String, Option<String>)> {
1532 use crate::control::ControlRefusalReason::{Budget, LineLimit};
1533
1534 let ControlCoverage::Observed(observed) = ignore_rules else {
1535 return None;
1536 };
1537 if observed.is_complete() {
1538 return None;
1539 }
1540 let every_listed = observed.lists_every_refusal();
1541 let listed =
1542 |reason| observed.refusals.iter().filter(|refusal| refusal.reason == reason).count();
1543 let fired: Vec<_> = [Budget, LineLimit]
1546 .into_iter()
1547 .filter(|reason| {
1548 listed(*reason) > 0 || (!every_listed && observed.limits.limit_for(*reason).is_some())
1549 })
1550 .collect();
1551 let size = |reason| {
1552 observed.limits.limit_for(reason).map(|bytes| {
1553 crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX))
1554 })
1555 };
1556 let over = |reason| {
1559 let (lead, noun) = match reason {
1560 Budget => ("over", "ignore-rule budget"),
1561 LineLimit => ("with a line over", "line limit"),
1562 };
1563 size(reason).map_or_else(
1564 || format!("{lead} the {noun}"),
1565 |size| format!("{lead} the {size} {noun}"),
1566 )
1567 };
1568 let why = if every_listed {
1569 let parts: Vec<String> =
1570 fired.iter().map(|reason| format!("{} {}", listed(*reason), over(*reason))).collect();
1571 parts.join(", ")
1572 } else {
1573 let parts: Vec<String> = fired.iter().map(|reason| over(*reason)).collect();
1574 parts.join(" or ")
1575 };
1576
1577 let shown = observed.refusals.len().min(REFUSED_DIRECTORIES_NAMED);
1578 let mut directories: Vec<String> = observed.refusals[..shown]
1579 .iter()
1580 .map(|refusal| match refusal.path.parent() {
1581 Some(parent) if !parent.as_os_str().is_empty() => parent.display().to_string(),
1582 _ => ".".to_string(),
1583 })
1584 .collect();
1585 let unnamed = observed.refused.saturating_sub(u64::try_from(shown).unwrap_or(u64::MAX));
1586 if unnamed > 0 {
1587 directories.push(format!("{} more", crate::report_format::human_count(unnamed)));
1588 }
1589
1590 let raises: Vec<String> = fired
1592 .iter()
1593 .filter_map(|reason| {
1594 let knob = match reason {
1595 Budget => axes.control_budget,
1596 LineLimit => axes.control_line_limit,
1597 };
1598 size(*reason).map(|size| format!("{knob} above {size}"))
1599 })
1600 .collect();
1601 let remedy = match raises.as_slice() {
1602 [] => None,
1603 [raise] => {
1604 Some(format!("tip: apply refused ignore files: raise {raise}, or set it to all"))
1605 }
1606 raises => Some(format!(
1607 "tip: apply refused ignore files: raise {}, or set them to all",
1608 raises.join(" and ")
1609 )),
1610 };
1611 let files = crate::report_format::human_count(observed.refused);
1612 let noun = if observed.refused == 1 { "file" } else { "files" };
1613 Some((
1614 format!(
1615 "note: ignore classification incomplete: {files} ignore {noun} not applied ({why}); affected: {}",
1616 directories.join(", ")
1617 ),
1618 remedy,
1619 ))
1620}
1621
1622pub fn report(
1636 index: &Index,
1637 request: &Request,
1638 generated_at: std::time::SystemTime,
1639) -> crate::Result<Report> {
1640 report_in(index, request, generated_at, NameIdentity::Native)
1641}
1642
1643pub(crate) fn report_in(
1648 index: &Index,
1649 request: &Request,
1650 generated_at: std::time::SystemTime,
1651 identity: NameIdentity,
1652) -> crate::Result<Report> {
1653 request.validate_read(&Basis::held_by(index)).map_err(crate::Error::InvalidRequest)?;
1657
1658 let query = &request.query;
1659 let content = request.basis.content;
1660 let needs_walk = query.needs_selection_walk();
1663 let needs_tree_measurements = query.views.iter().any(|view| query.tree_for(*view))
1664 && !query.min_share_for().admits(0, 1)
1665 && (index.state().coverage != crate::Coverage::Complete
1666 || index.scope().max_depth.is_some());
1667 let directories = (needs_tree_measurements
1668 || (needs_walk
1669 && (query.selection.kinds.is_empty()
1670 || query.selection.kinds.contains(&EntryKind::Dir))))
1671 .then(|| query_subtrees::measure(index, &query.selection, identity));
1672 let walked = needs_walk.then(|| walk(index, &query.selection, identity, directories.as_ref()));
1674 let tree_measurements = directories.as_ref().filter(|_| needs_tree_measurements);
1675 let row_consumers =
1680 query.views.iter().copied().filter(|view| needs_unfiltered_entry_rows(*view)).count();
1681 let unfiltered_rows = (walked.is_none() && row_consumers > 1).then(|| every_entry(index));
1682 let metric_consumers =
1683 query.views.iter().copied().filter(|view| needs_metric_resolution(*view)).count();
1684 let mut shared_metric_summaries = (walked.is_none() && metric_consumers > 1).then(|| {
1687 metric_summaries(
1688 &query.views,
1689 index,
1690 query,
1691 content,
1692 unfiltered_rows
1693 .as_deref()
1694 .expect("multiple metric views share their unfiltered entry rows"),
1695 )
1696 });
1697
1698 let mut sections: Vec<Section> = query
1699 .views
1700 .iter()
1701 .enumerate()
1702 .map(|(position, view)| {
1703 let shared_metric_summary =
1704 shared_metric_summaries.as_mut().and_then(|summaries| summaries[position].take());
1705 if let Some(summary) = shared_metric_summary {
1706 return Section::Metrics { view: *view, summary: Box::new(summary) };
1707 }
1708 build_section(
1709 *view,
1710 index,
1711 query,
1712 content,
1713 walked.as_ref(),
1714 unfiltered_rows.as_deref(),
1715 tree_measurements,
1716 )
1717 })
1718 .collect();
1719
1720 let age_reference_ns = crate::query::system_time_to_nanos(request.now);
1721 for section in &mut sections {
1722 if let Section::Files { rows, .. } = section {
1723 for row in rows {
1724 row.age_ns = match row.complete {
1728 Some(false) => None,
1729 Some(true) | None => {
1730 age_reference_ns.map(|now| i128::from(now) - i128::from(row.mtime_ns))
1731 }
1732 };
1733 }
1734 }
1735 }
1736 let ignore_rules = index.control_coverage();
1737 let (mut notes, mut tips) = display_notes(query, content, &ignore_rules);
1738 if content.includes_words() {
1739 let text_only = sections
1745 .iter()
1746 .filter_map(|section| match section {
1747 Section::Metrics { summary, .. } => summary
1748 .total
1749 .words_coverage
1750 .as_ref()?
1751 .get(&crate::content::CoverageReason::TextOnly)
1752 .copied(),
1753 _ => None,
1754 })
1755 .max()
1756 .unwrap_or(0);
1757 if text_only > 0 {
1758 let files = if text_only == 1 { "file" } else { "files" };
1759 notes.push(format!(
1760 "note: {text_only} Markdown {files} over {} MiB counted as plain text: every \
1761 word counted visible, paragraphs are blank-line runs",
1762 crate::content::MARKDOWN_EXACT_BYTES / (1024 * 1024)
1763 ));
1764 }
1765 }
1766 tips.extend(retained_refusals_tip(query, &ignore_rules));
1767 if tree_measurements.is_some_and(|values| values.values().any(|value| !value.complete)) {
1768 notes.push("note: incomplete subtrees remain visible below the size threshold".to_owned());
1769 }
1770 if index.observes_controls() && !index.ignored_classification_complete_below(Path::new("")) {
1771 notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
1772 }
1773 Ok(Report {
1774 age_reference_ns,
1775 format: query.format,
1776 notes,
1777 tips,
1778 axes: query.axes,
1779 status: TreeStatus::of(index, request),
1780 provenance: ReportProvenance::of(index, content, generated_at),
1781 scope: index.scope(),
1782 requested_analysis: content,
1783 requested_views: query.views.clone(),
1784 omitted_views: query.omitted_views.clone(),
1785 root: index.root_path().to_path_buf(),
1786 size: query.selection.size,
1787 sort_metric: match query.selection.sort {
1788 Some(SortKey::Metric(name)) => Some(name),
1789 _ => None,
1790 },
1791 analysis: index.content().and_then(|held| {
1792 let wanted = index.content_identity(content);
1793 let projected = held.admit(&wanted)?;
1794 Some(ContentReportMetadata {
1795 profile: projected.identity().analysis,
1796 provenance: projected.identity().record_provenance(),
1797 })
1798 }),
1799 ignored_entries: query.selection.ignored,
1800 ignore_rules,
1801 sections,
1802 })
1803}
1804
1805const UNVERIFIED_IGNORED_NOTE: &str =
1807 "note: gitignored subtotals are unavailable where governing rules could not be verified";
1808
1809fn retained_refusals_tip(query: &Query, ignore_rules: &ControlCoverage) -> Option<String> {
1812 matches!(ignore_rules, ControlCoverage::Observed(observed) if observed.refusals.len() > REFUSED_DIRECTORIES_NAMED)
1813 .then(|| format!("tip: show retained ignore-file details: {} json", query.axes.format))
1814}
1815
1816#[allow(clippy::too_many_arguments)]
1827pub(crate) fn report_summary(
1828 root: &Path,
1829 scope: ScanScope,
1830 request: &Request,
1831 summary: SummaryRow,
1832 ignore_rules: ControlCoverage,
1833 ignored_unverified: bool,
1834 status: TreeStatus,
1835 provenance: ReportProvenance,
1836) -> Report {
1837 let query = &request.query;
1838 let (mut notes, mut tips) = display_notes(query, request.basis.content, &ignore_rules);
1839 tips.extend(retained_refusals_tip(query, &ignore_rules));
1840 if ignored_unverified {
1841 notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
1842 }
1843 Report {
1844 age_reference_ns: crate::query::system_time_to_nanos(request.now),
1845 format: query.format,
1846 notes,
1847 tips,
1848 axes: query.axes,
1849 status,
1850 provenance,
1851 scope,
1852 requested_analysis: AnalysisSet::NONE,
1853 requested_views: query.views.clone(),
1854 omitted_views: query.omitted_views.clone(),
1855 root: root.to_path_buf(),
1856 size: query.selection.size,
1857 sort_metric: match query.selection.sort {
1858 Some(SortKey::Metric(name)) => Some(name),
1859 _ => None,
1860 },
1861 analysis: None,
1864 ignored_entries: query.selection.ignored,
1865 ignore_rules,
1866 sections: vec![Section::Summary(summary)],
1867 }
1868}
1869
1870struct Walked {
1872 observed: bool,
1874 per_directory: BTreeMap<EntryId, SummaryRow>,
1879 by_ext: BTreeMap<String, ExtTally>,
1881 ignored_by_ext: BTreeMap<String, ExtTally>,
1883 rows: Vec<FileRow>,
1885 members: Vec<FileRow>,
1887 visible: BTreeSet<EntryId>,
1889 visible_files: BTreeSet<EntryId>,
1890 unknown_ignored: BTreeSet<EntryId>,
1892}
1893
1894impl Walked {
1895 fn summary_of(&self, id: EntryId) -> SummaryRow {
1897 let mut row = self.per_directory.get(&id).copied().unwrap_or_default();
1898 row.ignored = (self.observed && !self.unknown_ignored.contains(&id))
1899 .then(|| row.ignored.unwrap_or_default());
1900 row
1901 }
1902}
1903
1904fn unfiltered_summary(index: &Index, id: EntryId, path: &Path) -> SummaryRow {
1907 let observed = index.observes_controls() && index.ignored_classification_complete_below(path);
1908 let Some((all, unignored)) = index.partition_scalars_of(id) else {
1909 return SummaryRow {
1910 ignored: observed.then(IgnoredTally::default),
1911 ..SummaryRow::default()
1912 };
1913 };
1914 SummaryRow {
1915 ignored: observed.then(|| IgnoredTally::between(all, unignored)),
1916 ..summary_from_scalars(all)
1917 }
1918}
1919
1920fn walk(
1925 index: &Index,
1926 selection: &Selection,
1927 identity: NameIdentity,
1928 directories: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
1929) -> Walked {
1930 debug_assert!(!index.is_folded(), "a folded index keeps too few files to be filtered");
1931 let observed = index.observes_controls();
1932 let mut walked = Walked {
1933 observed,
1934 per_directory: BTreeMap::new(),
1935 by_ext: BTreeMap::new(),
1936 ignored_by_ext: BTreeMap::new(),
1937 rows: Vec::new(),
1938 members: Vec::new(),
1939 visible: BTreeSet::new(),
1940 visible_files: BTreeSet::new(),
1941 unknown_ignored: BTreeSet::new(),
1942 };
1943 debug_assert!(
1946 observed || selection.ignored == IgnoredEntries::Include,
1947 "a selection by ignored state over an unobserving index is refused before the walk"
1948 );
1949
1950 let mut stack = vec![(EntryId::ROOT, PathBuf::new(), false, false)];
1952 while let Some((id, path, expanded, covered)) = stack.pop() {
1953 if expanded {
1954 let mut total = walked.per_directory.remove(&id).unwrap_or_default();
1959 if let Some(children) = index.children_of(id) {
1960 for (_, child) in children {
1961 if let Some(sub) = walked.per_directory.get(&child) {
1962 let sub = *sub;
1963 merge_summary(&mut total, &sub);
1964 if walked.unknown_ignored.contains(&child) {
1965 walked.unknown_ignored.insert(id);
1966 }
1967 }
1968 }
1969 }
1970 if total.files > 0 || total.dirs > 0 {
1971 walked.visible.insert(id);
1972 }
1973 walked.per_directory.insert(id, total);
1974 continue;
1975 }
1976
1977 stack.push((id, path.clone(), true, covered));
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
1984 for (child_path, child) in children {
1985 let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
1986 continue;
1987 };
1988 let file_name = child_path.file_name().unwrap_or_default();
1992 let classification = index.ignored_classification_of(&child_path, child);
1993 let ignored = classification.unwrap_or(false);
1994 let classification_admitted =
1995 classification.is_some() || selection.ignored == IgnoredEntries::Include;
1996 let mut measured = *attrs;
1997 let subtree = directories.and_then(|values| values.get(&child)).copied();
1998 if let Some(subtree) = subtree {
1999 measured.size = subtree.bytes;
2000 measured.allocated = subtree.allocated;
2001 measured.mtime_ns = subtree.mtime_ns;
2002 }
2003 let (pruned, matches) = query_subtrees::with_candidate(
2004 &child_path,
2005 kind,
2006 measured,
2007 ignored,
2008 identity,
2009 |candidate| {
2010 (query_subtrees::pruned(selection, &candidate), selection.admits(&candidate))
2011 },
2012 );
2013 if pruned {
2014 continue;
2015 }
2016 let matches = matches
2023 && classification_admitted
2024 && (selection.modified.is_unbounded()
2025 || subtree.is_none_or(|subtree| subtree.complete));
2026 let row = FileRow {
2027 path: child_path.clone(),
2028 kind,
2029 bytes: measured.size,
2030 allocated: measured.allocated,
2031 mtime_ns: measured.mtime_ns,
2032 files: subtree.map(|subtree| subtree.files),
2033 dirs: subtree.map(|subtree| subtree.dirs),
2034 complete: subtree.map(|subtree| subtree.complete),
2035 age_ns: None,
2036 ignored: classification,
2037 sort_value: None,
2038 classification: None,
2039 };
2040 if matches {
2041 walked.rows.push(row.clone());
2042 }
2043 if matches || (covered && classification_admitted && selection.ignored.admits(ignored))
2044 {
2045 if classification.is_none() {
2046 walked.unknown_ignored.insert(id);
2047 }
2048 if kind == EntryKind::File {
2049 walked.members.push(row);
2050 walked.visible_files.insert(child);
2051 } else if kind == EntryKind::Dir {
2052 walked.visible.insert(child);
2053 }
2054
2055 if kind == EntryKind::File {
2056 let own = walked.per_directory.entry(id).or_default();
2057 own.files += 1;
2058 own.bytes += attrs.size;
2059 own.allocated += attrs.allocated;
2060 own.newest_mtime_ns = Some(
2061 own.newest_mtime_ns.map_or(attrs.mtime_ns, |seen| seen.max(attrs.mtime_ns)),
2062 );
2063 let bucket = crate::classify::ext_bucket(file_name);
2064 if ignored {
2065 own.ignored.get_or_insert_with(IgnoredTally::default).add(IgnoredTally {
2066 files: 1,
2067 dirs: 0,
2068 bytes: attrs.size,
2069 allocated: attrs.allocated,
2070 });
2071 let tally = walked.ignored_by_ext.entry(bucket.clone()).or_default();
2072 tally.files += 1;
2073 tally.bytes += attrs.size;
2074 tally.allocated += attrs.allocated;
2075 }
2076 let tally = walked.by_ext.entry(bucket).or_default();
2077 tally.files += 1;
2078 tally.bytes += attrs.size;
2079 tally.allocated += attrs.allocated;
2080 } else if kind == EntryKind::Dir {
2081 let own = walked.per_directory.entry(id).or_default();
2087 own.dirs += 1;
2088 if ignored {
2089 own.ignored.get_or_insert_with(IgnoredTally::default).dirs += 1;
2090 }
2091 }
2092 }
2093
2094 if kind == EntryKind::Dir {
2095 stack.push((child, child_path, false, covered || matches));
2096 }
2097 }
2098 }
2099
2100 walked
2101}
2102
2103fn merge_summary(into: &mut SummaryRow, from: &SummaryRow) {
2105 into.files += from.files;
2106 into.dirs += from.dirs;
2107 into.bytes += from.bytes;
2108 into.allocated += from.allocated;
2109 into.newest_mtime_ns = match (into.newest_mtime_ns, from.newest_mtime_ns) {
2110 (Some(left), Some(right)) => Some(left.max(right)),
2111 (left, right) => left.or(right),
2112 };
2113 if let Some(share) = from.ignored {
2114 into.ignored.get_or_insert_with(IgnoredTally::default).add(share);
2115 }
2116}
2117
2118fn needs_unfiltered_entry_rows(view: ViewSpec) -> bool {
2120 matches!(
2121 view,
2122 ViewSpec::Types
2123 | ViewSpec::Families
2124 | ViewSpec::Languages
2125 | ViewSpec::Code
2126 | ViewSpec::Documents
2127 | ViewSpec::Files
2128 | ViewSpec::Largest
2129 | ViewSpec::Recent
2130 )
2131}
2132
2133fn needs_metric_resolution(view: ViewSpec) -> bool {
2135 matches!(view, ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents)
2136}
2137
2138fn entry_rows<'a>(
2141 index: &Index,
2142 walked: Option<&'a Walked>,
2143 unfiltered_rows: Option<&'a [FileRow]>,
2144) -> Cow<'a, [FileRow]> {
2145 match (walked, unfiltered_rows) {
2146 (Some(walked), _) => Cow::Borrowed(&walked.rows),
2147 (None, Some(rows)) => Cow::Borrowed(rows),
2148 (None, None) => Cow::Owned(every_entry(index)),
2149 }
2150}
2151
2152fn build_section(
2154 view: ViewSpec,
2155 index: &Index,
2156 query: &Query,
2157 content: AnalysisSet,
2158 walked: Option<&Walked>,
2159 unfiltered_rows: Option<&[FileRow]>,
2160 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
2161) -> Section {
2162 if query.tree_for(view) {
2163 let (root, omissions) = tree_node(index, query, content, walked, tree_measurements);
2164 let limits = TreeDisplayLimits {
2165 depth: query.depth_for(view),
2166 min_share: query.min_share_for(),
2167 breadth: query.breadth_for(),
2168 rows: query.limit_for(view),
2169 };
2170 return Section::Tree { view, limits, root: root.map(Box::new), omissions };
2171 }
2172 match view {
2173 ViewSpec::Code => {
2174 Section::Code(Box::new(code_overview(index, query, content, walked, unfiltered_rows)))
2175 }
2176 ViewSpec::Summary => Section::Summary(match walked {
2177 None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2178 Some(walked) => walked.summary_of(EntryId::ROOT),
2179 }),
2180 ViewSpec::Extensions => {
2181 let (rows, total, share_omitted) = extension_rows(index, query, walked);
2182 Section::Extensions { rows, total, share_omitted }
2183 }
2184 ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents => {
2185 Section::Metrics {
2186 view,
2187 summary: Box::new(metric_summary(
2188 view,
2189 index,
2190 query,
2191 content,
2192 walked,
2193 unfiltered_rows,
2194 )),
2195 }
2196 }
2197 ViewSpec::List
2198 | ViewSpec::Tree
2199 | ViewSpec::Files
2200 | ViewSpec::Largest
2201 | ViewSpec::Recent => {
2202 let (rows, total) = file_rows(view, index, query, content, walked, unfiltered_rows);
2203 Section::Files { view, rows, total }
2204 }
2205 }
2206}
2207
2208fn summary_from_scalars(rollup: RollUpScalars) -> SummaryRow {
2210 SummaryRow {
2211 files: rollup.files,
2212 dirs: rollup.dirs,
2213 bytes: rollup.bytes,
2214 allocated: rollup.allocated,
2215 ignored: None,
2216 newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
2217 }
2218}
2219
2220fn ignored_by_extension(
2222 all: &BTreeMap<String, ExtTally>,
2223 unignored: &BTreeMap<String, ExtTally>,
2224) -> BTreeMap<String, ExtTally> {
2225 all.iter()
2226 .filter_map(|(extension, tally)| {
2227 let kept = unignored.get(extension).copied().unwrap_or_default();
2228 let ignored = ExtTally {
2229 files: tally.files.saturating_sub(kept.files),
2230 bytes: tally.bytes.saturating_sub(kept.bytes),
2231 allocated: tally.allocated.saturating_sub(kept.allocated),
2232 };
2233 (ignored.files > 0).then(|| (extension.clone(), ignored))
2234 })
2235 .collect()
2236}
2237
2238fn extension_rows(
2240 index: &Index,
2241 query: &Query,
2242 walked: Option<&Walked>,
2243) -> (Vec<TypeRow>, usize, usize) {
2244 debug_assert!(!index.is_folded(), "a folded index keeps no extension tallies (H176)");
2245 let observed = match walked {
2248 Some(walked) => walked.observed && !walked.unknown_ignored.contains(&EntryId::ROOT),
2249 None => {
2250 index.observes_controls() && index.ignored_classification_complete_below(Path::new(""))
2251 }
2252 };
2253 let (tallies, ignored): (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>) = match walked
2254 {
2255 None => match index.partition_total() {
2256 Ok(partitions) => {
2257 let ignored =
2258 ignored_by_extension(&partitions.all.by_ext, &partitions.unignored.by_ext);
2259 (partitions.all.by_ext, ignored)
2260 }
2261 Err(_not_observed) => (index.total().by_ext, BTreeMap::new()),
2264 },
2265 Some(walked) => (walked.by_ext.clone(), walked.ignored_by_ext.clone()),
2266 };
2267
2268 let mut rows: Vec<TypeRow> = tallies
2269 .into_iter()
2270 .map(|(extension, tally)| {
2271 let share = ignored.get(&extension).copied().unwrap_or_default();
2272 TypeRow {
2273 files: tally.files,
2274 bytes: tally.bytes,
2275 allocated: tally.allocated,
2276 ignored: observed.then_some(IgnoredTally {
2277 files: share.files,
2278 dirs: 0,
2279 bytes: share.bytes,
2280 allocated: share.allocated,
2281 }),
2282 extension,
2283 }
2284 })
2285 .collect();
2286
2287 let before_share = rows.len();
2288 if let Some(threshold) = &query.selection.min_share {
2289 let root = match walked {
2290 None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2291 Some(walked) => walked.summary_of(EntryId::ROOT),
2292 };
2293 let denominator = match query.selection.size {
2294 SizeMetric::Apparent => root.bytes,
2295 SizeMetric::Allocated => root.allocated,
2296 };
2297 rows.retain(|row| {
2298 threshold.admits(
2299 match query.selection.size {
2300 SizeMetric::Apparent => row.bytes,
2301 SizeMetric::Allocated => row.allocated,
2302 },
2303 denominator,
2304 )
2305 });
2306 }
2307
2308 sort_rows(
2309 &mut rows,
2310 query,
2311 ViewSpec::Extensions,
2312 SortAccessors {
2313 size: |row: &TypeRow, metric| match metric {
2314 SizeMetric::Apparent => row.bytes,
2315 SizeMetric::Allocated => row.allocated,
2316 },
2317 count: |row: &TypeRow| row.files,
2318 mtime: |_: &TypeRow| None,
2319 name: borrowed_name(|row: &TypeRow| std::borrow::Cow::Borrowed(row.extension.as_str())),
2320 content_metric: |_: &TypeRow, _: &MetricDef| None,
2321 },
2322 );
2323 let total = truncate(&mut rows, query.limit_for(ViewSpec::Extensions));
2324 (rows, total, before_share - total)
2325}
2326
2327fn metric_summary(
2328 view: ViewSpec,
2329 index: &Index,
2330 query: &Query,
2331 content: AnalysisSet,
2332 walked: Option<&Walked>,
2333 unfiltered_rows: Option<&[FileRow]>,
2334) -> MetricSummary {
2335 let mut accumulator = MetricAccumulator::new(view);
2336 let files = walked.map_or_else(
2337 || entry_rows(index, None, unfiltered_rows),
2338 |walked| Cow::Borrowed(walked.members.as_slice()),
2339 );
2340 let wanted = index.content_identity(content);
2341 let held = index.content().and_then(|held| held.admit(&wanted));
2342 for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2343 let cached = held.and_then(|content| content.file(&file.path));
2344 let classification = index.classify(&file.path);
2345 accumulator.push(content, file, cached, &classification);
2346 }
2347 accumulator.finish(query, content)
2348}
2349
2350fn metric_summaries(
2352 views: &[ViewSpec],
2353 index: &Index,
2354 query: &Query,
2355 content: AnalysisSet,
2356 rows: &[FileRow],
2357) -> Vec<Option<MetricSummary>> {
2358 let mut accumulators = views
2359 .iter()
2360 .copied()
2361 .filter(|view| needs_metric_resolution(*view))
2362 .map(MetricAccumulator::new)
2363 .collect::<Vec<_>>();
2364 let wanted = index.content_identity(content);
2365 let held = index.content().and_then(|held| held.admit(&wanted));
2366 for file in rows.iter().filter(|row| row.kind == EntryKind::File) {
2367 let cached = held.and_then(|content| content.file(&file.path));
2368 let classification = index.classify(&file.path);
2369 for accumulator in &mut accumulators {
2370 accumulator.push(content, file, cached, &classification);
2371 }
2372 }
2373
2374 let mut finished =
2375 accumulators.into_iter().map(|accumulator| accumulator.finish(query, content));
2376 let summaries = views
2377 .iter()
2378 .map(|view| needs_metric_resolution(*view).then(|| finished.next().expect("metric view")))
2379 .collect();
2380 debug_assert!(finished.next().is_none());
2381 summaries
2382}
2383
2384struct MetricAccumulator {
2385 view: ViewSpec,
2386 group: MetricGroup,
2387 grouped: BTreeMap<String, MetricRow>,
2388}
2389
2390impl MetricAccumulator {
2391 fn new(view: ViewSpec) -> Self {
2392 debug_assert!(needs_metric_resolution(view));
2393 let group =
2394 if view == ViewSpec::Families { MetricGroup::Family } else { MetricGroup::Type };
2395 Self { view, group, grouped: BTreeMap::new() }
2396 }
2397
2398 fn push(
2399 &mut self,
2400 content: AnalysisSet,
2401 file: &FileRow,
2402 cached: Option<&FileAnalysis>,
2403 classification: &Classification,
2404 ) {
2405 let included = match self.view {
2406 ViewSpec::Languages => classification.family == ContentFamily::Code,
2407 ViewSpec::Documents => {
2408 matches!(classification.family, ContentFamily::Prose | ContentFamily::Markup)
2409 }
2410 ViewSpec::Types | ViewSpec::Families => true,
2411 ViewSpec::List
2412 | ViewSpec::Tree
2413 | ViewSpec::Extensions
2414 | ViewSpec::Code
2415 | ViewSpec::Files
2416 | ViewSpec::Largest
2417 | ViewSpec::Recent
2418 | ViewSpec::Summary => false,
2419 };
2420 if !included {
2421 return;
2422 }
2423 let id = match self.group {
2424 MetricGroup::Type => classification.file_type.as_str().to_string(),
2425 MetricGroup::Family => classification.family.as_str().to_string(),
2426 };
2427 let row = self.grouped.entry(id.clone()).or_insert_with(|| MetricRow {
2428 analysis: content,
2429 id,
2430 family: classification.family,
2431 files: 0,
2432 bytes: 0,
2433 allocated: 0,
2434 analyzed_files: 0,
2435 metrics: ReportMetricValues::for_analysis(content),
2436 logical_word_stats: LogicalWordStats::default(),
2437 visible_logical_word_stats: LogicalWordStats::default(),
2438 document_raw_words: 0,
2439 document_word_stats: LogicalWordStats::default(),
2440 document_metric_files: 0,
2441 coverage: BTreeMap::new(),
2442 lines_coverage: BTreeMap::new(),
2443 code_coverage: content.includes_code().then(BTreeMap::new),
2444 words_coverage: content.includes_words().then(BTreeMap::new),
2445 detection_sources: BTreeMap::new(),
2446 detection_confidence: BTreeMap::new(),
2447 generated_files: 0,
2448 vendored_files: 0,
2449 documentation_files: 0,
2450 share: MetricShare::default(),
2451 });
2452 row.files = row.files.saturating_add(1);
2453 row.bytes = row.bytes.saturating_add(file.bytes);
2454 row.allocated = row.allocated.saturating_add(file.allocated);
2455 let detection = cached.map_or(
2456 (classification.source, classification.confidence, classification.flags),
2457 |record| (record.detection.source, record.detection.confidence, record.detection.flags),
2458 );
2459 *row.detection_sources.entry(detection.0).or_default() += 1;
2460 *row.detection_confidence.entry(detection.1).or_default() += 1;
2461 row.generated_files = row.generated_files.saturating_add(u64::from(detection.2.generated));
2462 row.vendored_files = row.vendored_files.saturating_add(u64::from(detection.2.vendored));
2463 row.documentation_files =
2464 row.documentation_files.saturating_add(u64::from(detection.2.documentation));
2465 if let Some(record) = cached {
2466 *row.lines_coverage.entry(record.lines.coverage()).or_default() += 1;
2467 if let (Some(coverage), Some(outcome)) = (&mut row.code_coverage, record.code) {
2468 *coverage.entry(outcome.coverage()).or_default() += 1;
2469 }
2470 if let (Some(coverage), Some(outcome)) = (&mut row.words_coverage, record.words) {
2471 *coverage.entry(outcome.coverage()).or_default() += 1;
2472 }
2473 let selected = match self.view {
2474 ViewSpec::Languages if content.includes_code() => {
2475 record.code.map(|outcome| outcome.coverage())
2476 }
2477 ViewSpec::Documents if content.includes_words() => {
2478 record.words.map(|outcome| outcome.coverage())
2479 }
2480 _ => None,
2481 }
2482 .unwrap_or(record.lines.coverage());
2483 *row.coverage.entry(selected).or_default() += 1;
2484 if selected == CoverageReason::Analyzed {
2485 row.analyzed_files = row.analyzed_files.saturating_add(1);
2486 }
2487 if let Some(lines) = record.lines.value() {
2488 add_optional(&mut row.metrics.physical_lines, Some(lines.physical_lines));
2489 add_optional(&mut row.metrics.blank_lines, Some(lines.blank_lines));
2490 add_optional(&mut row.metrics.nonblank_lines, Some(lines.nonblank_lines));
2491 add_optional(&mut row.metrics.raw_words, Some(lines.raw_words));
2492 row.document_raw_words = row.document_raw_words.saturating_add(lines.raw_words);
2493 }
2494 if let Some(code_metrics) = record.code.and_then(crate::content::AnalyzerOutcome::value)
2495 {
2496 add_optional(&mut row.metrics.code_lines, Some(code_metrics.code_lines));
2497 add_optional(&mut row.metrics.comment_lines, Some(code_metrics.comment_lines));
2498 add_optional(
2499 &mut row.metrics.code_blank_lines,
2500 Some(code_metrics.code_blank_lines),
2501 );
2502 }
2503 if let Some(words) = record.words.and_then(crate::content::AnalyzerOutcome::value) {
2504 add_optional(&mut row.metrics.paragraphs, Some(words.paragraphs));
2505 add_optional(&mut row.metrics.visible_words, Some(words.visible_words));
2506 row.logical_word_stats.add_assign(words.logical_word_stats);
2507 row.visible_logical_word_stats.add_assign(words.visible_logical_word_stats);
2508 row.document_metric_files = row.document_metric_files.saturating_add(1);
2509 if classification.file_type.as_str() == "markdown" {
2510 row.document_raw_words = row
2511 .document_raw_words
2512 .saturating_sub(record.lines.value().map_or(0, |lines| lines.raw_words));
2513 row.document_raw_words =
2514 row.document_raw_words.saturating_add(words.visible_words);
2515 row.document_word_stats.add_assign(words.visible_logical_word_stats);
2516 } else {
2517 row.document_word_stats.add_assign(words.logical_word_stats);
2518 }
2519 }
2520 }
2521 }
2522
2523 fn finish(self, query: &Query, content: AnalysisSet) -> MetricSummary {
2524 let Self { view, group, mut grouped } = self;
2525
2526 for row in grouped.values_mut() {
2527 row.finish_derived_metrics();
2528 }
2529
2530 let mut total = MetricRow {
2531 analysis: content,
2532 id: "total".to_string(),
2533 family: ContentFamily::Unknown,
2534 files: 0,
2535 bytes: 0,
2536 allocated: 0,
2537 analyzed_files: 0,
2538 metrics: ReportMetricValues::for_analysis(content),
2539 logical_word_stats: LogicalWordStats::default(),
2540 visible_logical_word_stats: LogicalWordStats::default(),
2541 document_raw_words: 0,
2542 document_word_stats: LogicalWordStats::default(),
2543 document_metric_files: 0,
2544 coverage: BTreeMap::new(),
2545 lines_coverage: BTreeMap::new(),
2546 code_coverage: content.includes_code().then(BTreeMap::new),
2547 words_coverage: content.includes_words().then(BTreeMap::new),
2548 detection_sources: BTreeMap::new(),
2549 detection_confidence: BTreeMap::new(),
2550 generated_files: 0,
2551 vendored_files: 0,
2552 documentation_files: 0,
2553 share: MetricShare::default(),
2554 };
2555 for row in grouped.values() {
2556 total.files = total.files.saturating_add(row.files);
2557 total.bytes = total.bytes.saturating_add(row.bytes);
2558 total.allocated = total.allocated.saturating_add(row.allocated);
2559 total.analyzed_files = total.analyzed_files.saturating_add(row.analyzed_files);
2560 total.metrics.add_assign(&row.metrics);
2561 total.logical_word_stats.add_assign(row.logical_word_stats);
2562 total.visible_logical_word_stats.add_assign(row.visible_logical_word_stats);
2563 total.document_raw_words =
2564 total.document_raw_words.saturating_add(row.document_raw_words);
2565 total.document_word_stats.add_assign(row.document_word_stats);
2566 total.document_metric_files =
2567 total.document_metric_files.saturating_add(row.document_metric_files);
2568 for (reason, count) in &row.coverage {
2569 *total.coverage.entry(*reason).or_default() += count;
2570 }
2571 merge_coverage(&mut total.lines_coverage, &row.lines_coverage);
2572 if let (Some(total), Some(row)) = (&mut total.code_coverage, &row.code_coverage) {
2573 merge_coverage(total, row);
2574 }
2575 if let (Some(total), Some(row)) = (&mut total.words_coverage, &row.words_coverage) {
2576 merge_coverage(total, row);
2577 }
2578 for (source, count) in &row.detection_sources {
2579 *total.detection_sources.entry(*source).or_default() += count;
2580 }
2581 for (confidence, count) in &row.detection_confidence {
2582 *total.detection_confidence.entry(*confidence).or_default() += count;
2583 }
2584 total.generated_files = total.generated_files.saturating_add(row.generated_files);
2585 total.vendored_files = total.vendored_files.saturating_add(row.vendored_files);
2586 total.documentation_files =
2587 total.documentation_files.saturating_add(row.documentation_files);
2588 }
2589 total.finish_derived_metrics();
2590 let byte_share_metric = match query.selection.size {
2591 SizeMetric::Apparent => ShareMetric::ApparentBytes,
2592 SizeMetric::Allocated => ShareMetric::AllocatedBytes,
2593 };
2594 let share_metric = match view {
2595 ViewSpec::Languages if content.includes_code() => ShareMetric::CodeLines,
2598 ViewSpec::Documents if content.includes_words() => ShareMetric::DocumentWords,
2599 ViewSpec::Languages | ViewSpec::Documents if content.is_enabled() => {
2600 ShareMetric::RawWords
2601 }
2602 ViewSpec::Languages | ViewSpec::Documents | ViewSpec::Types | ViewSpec::Families => {
2603 byte_share_metric
2604 }
2605 ViewSpec::List
2606 | ViewSpec::Tree
2607 | ViewSpec::Extensions
2608 | ViewSpec::Code
2609 | ViewSpec::Files
2610 | ViewSpec::Largest
2611 | ViewSpec::Recent
2612 | ViewSpec::Summary => {
2613 unreachable!("only grouped views reach metric_summary")
2614 }
2615 };
2616 let mut rows = grouped.into_values().collect::<Vec<_>>();
2617 let denominator = share_denominator(&rows, share_metric);
2618 total.share = MetricShare { numerator: denominator, denominator };
2619 for row in &mut rows {
2620 row.share = MetricShare { numerator: share_value(row, share_metric), denominator };
2621 }
2622 sort_rows(
2623 &mut rows,
2624 query,
2625 view,
2626 SortAccessors {
2627 size: |row: &MetricRow, metric| match view {
2628 ViewSpec::Languages | ViewSpec::Documents => share_value(row, share_metric),
2629 _ => match metric {
2630 SizeMetric::Apparent => row.bytes,
2631 SizeMetric::Allocated => row.allocated,
2632 },
2633 },
2634 count: |row: &MetricRow| row.files,
2635 mtime: |_: &MetricRow| None,
2636 name: borrowed_name(|row: &MetricRow| std::borrow::Cow::Borrowed(row.id.as_str())),
2637 content_metric: MetricRow::metric_value,
2638 },
2639 );
2640 let before_share = rows.len();
2641 if let Some(threshold) = &query.selection.min_share {
2642 rows.retain(|row| threshold.admits(row.share.numerator, row.share.denominator));
2643 }
2644 let share_omitted = before_share - rows.len();
2645 let total_rows = truncate(&mut rows, query.limit_for(view));
2646 MetricSummary {
2647 group,
2648 total,
2649 rows,
2650 total_rows,
2651 share_omitted,
2652 share_metric,
2653 words_per_page: query.words_per_page.max(1),
2654 }
2655 }
2656}
2657
2658fn code_overview(
2659 index: &Index,
2660 query: &Query,
2661 content: AnalysisSet,
2662 walked: Option<&Walked>,
2663 unfiltered_rows: Option<&[FileRow]>,
2664) -> CodeOverview {
2665 #[derive(Default)]
2666 struct SortFacts {
2667 apparent: u64,
2668 allocated: u64,
2669 newest_mtime_ns: Option<i64>,
2670 metric: MetricAggregate,
2671 }
2672
2673 let files = walked.map_or_else(
2674 || entry_rows(index, None, unfiltered_rows),
2675 |walked| Cow::Borrowed(walked.members.as_slice()),
2676 );
2677 let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2678 let split = query.selection.ignored == IgnoredEntries::Include && index.observes_controls();
2679 let sort_key = query.selection.sort.unwrap_or(SortKey::Metric("code_lines"));
2680 let sort_metric = match sort_key {
2681 SortKey::Metric(name) => Some(name),
2682 _ => None,
2683 };
2684 let mut overview = CodeOverview {
2685 population: query.selection.ignored,
2686 selected: CodeTally::default(),
2687 non_ignored: split.then(CodeTally::default),
2688 ignored: split.then(CodeTally::default),
2689 unknown: CodeTally::default(),
2690 unclassified_files: 0,
2691 analyzed_languages: 0,
2692 total_languages: 0,
2693 share_omitted: 0,
2694 languages: Vec::new(),
2695 share_metric: ShareMetric::CodeLines,
2696 };
2697 let mut grouped = BTreeMap::<String, CodeLanguageRow>::new();
2698 let mut sort_facts = BTreeMap::<String, SortFacts>::new();
2699 for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2700 let record = held.and_then(|tier| tier.file(&file.path));
2701 let classification = record
2702 .map_or_else(|| index.classify(&file.path).into(), |record| record.detection.clone());
2703 if classification.family == ContentFamily::Unknown {
2704 overview.unclassified_files = overview.unclassified_files.saturating_add(1);
2705 }
2706 if classification.family != ContentFamily::Code {
2707 continue;
2708 }
2709 let language = classification.file_type.as_str().to_string();
2710 let facts = sort_facts.entry(language.clone()).or_default();
2711 facts.apparent = facts.apparent.saturating_add(file.bytes);
2712 facts.allocated = facts.allocated.saturating_add(file.allocated);
2713 facts.newest_mtime_ns =
2714 Some(facts.newest_mtime_ns.map_or(file.mtime_ns, |old| old.max(file.mtime_ns)));
2715 if let (Some(name), Some(record)) = (sort_metric, record) {
2716 if let Some(value) =
2717 measured_file_metric(record, classification.file_type.as_str(), name)
2718 {
2719 facts.metric.add(value);
2720 }
2721 }
2722 let row = grouped.entry(language.clone()).or_insert_with(|| CodeLanguageRow {
2723 language,
2724 selected: CodeTally::default(),
2725 non_ignored: split.then(CodeTally::default),
2726 ignored: split.then(CodeTally::default),
2727 unknown: CodeTally::default(),
2728 share: MetricShare::default(),
2729 });
2730 row.selected.add_file(record);
2731 overview.selected.add_file(record);
2732 match index.ignored_classification(&file.path) {
2733 Some(false) if split => {
2734 row.non_ignored.as_mut().expect("split initialized").add_file(record);
2735 overview.non_ignored.as_mut().expect("split initialized").add_file(record);
2736 }
2737 Some(true) if split => {
2738 row.ignored.as_mut().expect("split initialized").add_file(record);
2739 overview.ignored.as_mut().expect("split initialized").add_file(record);
2740 }
2741 None => {
2742 row.unknown.add_file(record);
2743 overview.unknown.add_file(record);
2744 }
2745 Some(_) => {}
2746 }
2747 }
2748 let denominator = overview.selected.metrics.code_lines;
2749 overview.analyzed_languages =
2750 grouped.values().filter(|row| row.selected.analyzed_files > 0).count() as u64;
2751 overview.languages = grouped.into_values().collect();
2752 for row in &mut overview.languages {
2753 row.share = MetricShare { numerator: row.selected.metrics.code_lines, denominator };
2754 }
2755 let normalized =
2756 matches!(sort_metric, Some("logical_words" | "visible_logical_words" | "document_words"));
2757 overview.languages.sort_by(|left, right| {
2758 let left_facts = &sort_facts[&left.language];
2759 let right_facts = &sort_facts[&right.language];
2760 let ordering = match sort_key {
2761 SortKey::Size => {
2762 let value = |facts: &SortFacts| match query.selection.size {
2763 SizeMetric::Apparent => facts.apparent,
2764 SizeMetric::Allocated => facts.allocated,
2765 };
2766 value(right_facts).cmp(&value(left_facts))
2767 }
2768 SortKey::Count => right.selected.source_files.cmp(&left.selected.source_files),
2769 SortKey::Mtime => right_facts.newest_mtime_ns.cmp(&left_facts.newest_mtime_ns),
2770 SortKey::Name => left.language.cmp(&right.language),
2771 SortKey::Metric(_) => {
2772 let left_value = left_facts.metric.value(normalized);
2773 let right_value = right_facts.metric.value(normalized);
2774 match (left_value, right_value) {
2775 (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
2776 (Some(left), Some(right)) => right.cmp(&left),
2777 (Some(_), None) => std::cmp::Ordering::Less,
2778 (None, Some(_)) => std::cmp::Ordering::Greater,
2779 (None, None) => std::cmp::Ordering::Equal,
2780 }
2781 }
2782 };
2783 let ordering = if query.selection.reverse && !matches!(sort_key, SortKey::Metric(_)) {
2784 ordering.reverse()
2785 } else {
2786 ordering
2787 };
2788 ordering.then_with(|| left.language.cmp(&right.language))
2789 });
2790 let before_share = overview.languages.len();
2791 if let Some(threshold) = &query.selection.min_share {
2792 overview.languages.retain(|row| threshold.admits(row.share.numerator, denominator));
2793 }
2794 overview.share_omitted = before_share - overview.languages.len();
2795 overview.total_languages = overview.languages.len();
2796 if let Some(limit) = query.limit_for(ViewSpec::Code).limit() {
2797 overview.languages.truncate(limit);
2798 }
2799 overview
2800}
2801
2802fn merge_coverage(total: &mut BTreeMap<CoverageReason, u64>, row: &BTreeMap<CoverageReason, u64>) {
2803 for (reason, count) in row {
2804 *total.entry(*reason).or_default() += count;
2805 }
2806}
2807
2808fn share_denominator(rows: &[MetricRow], metric: ShareMetric) -> u64 {
2819 rows.iter().fold(0_u64, |sum, row| sum.saturating_add(share_value(row, metric)))
2820}
2821
2822fn share_value(row: &MetricRow, metric: ShareMetric) -> u64 {
2823 match metric {
2824 ShareMetric::ApparentBytes => row.bytes,
2825 ShareMetric::AllocatedBytes => row.allocated,
2826 ShareMetric::CodeLines => row.metrics.code_lines.unwrap_or(0),
2827 ShareMetric::DocumentWords => document_words(row).unwrap_or(0),
2828 ShareMetric::RawWords => row.metrics.raw_words.unwrap_or(0),
2829 }
2830}
2831
2832pub fn document_words(row: &MetricRow) -> Option<u64> {
2834 row.metrics.document_words
2835}
2836
2837#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2839pub struct Pages {
2840 pub words: u64,
2842 pub words_per_page: u64,
2844}
2845
2846pub fn pages(row: &MetricRow, words_per_page: u64) -> Option<Pages> {
2848 document_words(row).map(|words| Pages { words, words_per_page: words_per_page.max(1) })
2849}
2850
2851fn file_rows(
2853 view: ViewSpec,
2854 index: &Index,
2855 query: &Query,
2856 content: AnalysisSet,
2857 walked: Option<&Walked>,
2858 unfiltered_rows: Option<&[FileRow]>,
2859) -> (Vec<FileRow>, usize) {
2860 let mut rows = entry_rows(index, walked, unfiltered_rows).into_owned();
2861 if view.files_only() {
2862 rows.retain(|row| row.kind == EntryKind::File);
2863 }
2864 if let Some(SortKey::Metric(name)) = query.selection.sort {
2865 let sources = walked.map_or(rows.as_slice(), |walked| walked.members.as_slice());
2866 let values = metric_sort_values(index, content, sources, name);
2867 for row in &mut rows {
2868 row.sort_value = values.get(&row.path).copied();
2869 }
2870 }
2871
2872 sort_rows(
2873 &mut rows,
2874 query,
2875 view,
2876 SortAccessors {
2877 size: |row: &FileRow, metric| match metric {
2878 SizeMetric::Apparent => row.bytes,
2879 SizeMetric::Allocated => row.allocated,
2880 },
2881 count: |row: &FileRow| row.files.unwrap_or(1),
2882 mtime: |row: &FileRow| Some(row.mtime_ns),
2883 name: borrowed_name(|row: &FileRow| row.path.to_string_lossy()),
2884 content_metric: |row: &FileRow, _: &MetricDef| row.sort_value,
2885 },
2886 );
2887 let total = truncate(&mut rows, query.limit_for(view));
2888 let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2889 for row in &mut rows {
2890 if row.kind == EntryKind::File {
2891 row.classification = Some(held.and_then(|tier| tier.file(&row.path)).map_or_else(
2892 || index.classify(&row.path).into(),
2893 |record| record.detection.clone(),
2894 ));
2895 }
2896 }
2897 (rows, total)
2898}
2899
2900#[derive(Clone, Copy)]
2901enum MetricMeasure {
2902 Additive(u64),
2903 Normalized(LogicalWordStats),
2904}
2905
2906#[derive(Default)]
2907struct MetricAggregate {
2908 additive: u64,
2909 normalized: LogicalWordStats,
2910 measured: bool,
2911}
2912
2913impl MetricAggregate {
2914 fn add(&mut self, measure: MetricMeasure) {
2915 self.measured = true;
2916 match measure {
2917 MetricMeasure::Additive(value) => {
2918 self.additive = self.additive.saturating_add(value);
2919 }
2920 MetricMeasure::Normalized(stats) => self.normalized.add_assign(stats),
2921 }
2922 }
2923
2924 fn value(&self, normalized: bool) -> Option<u64> {
2925 self.measured.then(
2926 || {
2927 if normalized { self.normalized.logical_words() } else { self.additive }
2928 },
2929 )
2930 }
2931}
2932
2933fn measured_file_metric(
2934 record: &crate::content::FileAnalysis,
2935 file_type: &str,
2936 metric_name: &str,
2937) -> Option<MetricMeasure> {
2938 let direct = |value| Some(MetricMeasure::Additive(value));
2939 match metric_name {
2940 "physical_lines" => direct(record.lines.value()?.physical_lines),
2941 "blank_lines" => direct(record.lines.value()?.blank_lines),
2942 "nonblank_lines" => direct(record.lines.value()?.nonblank_lines),
2943 "raw_words" => direct(record.lines.value()?.raw_words),
2944 "code_lines" => direct(record.code?.value()?.code_lines),
2945 "comment_lines" => direct(record.code?.value()?.comment_lines),
2946 "code_blank_lines" => direct(record.code?.value()?.code_blank_lines),
2947 "paragraphs" => direct(record.words?.value()?.paragraphs),
2948 "visible_words" => direct(record.words?.value()?.visible_words),
2949 "logical_words" => {
2950 Some(MetricMeasure::Normalized(record.words?.value()?.logical_word_stats))
2951 }
2952 "visible_logical_words" => {
2953 Some(MetricMeasure::Normalized(record.words?.value()?.visible_logical_word_stats))
2954 }
2955 "document_words" => {
2956 let words = record.words?.value()?;
2957 Some(MetricMeasure::Normalized(if file_type == "markdown" {
2958 words.visible_logical_word_stats
2959 } else {
2960 words.logical_word_stats
2961 }))
2962 }
2963 _ => None,
2964 }
2965}
2966
2967fn metric_sort_values(
2968 index: &Index,
2969 content: AnalysisSet,
2970 sources: &[FileRow],
2971 metric_name: &str,
2972) -> BTreeMap<PathBuf, u64> {
2973 let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2974 let mut aggregate = BTreeMap::<PathBuf, MetricAggregate>::new();
2975 for file in sources.iter().filter(|row| row.kind == EntryKind::File) {
2976 let Some(record) = held.and_then(|tier| tier.file(&file.path)) else { continue };
2977 let classification = index.classify(&file.path);
2978 let Some(measure) =
2979 measured_file_metric(record, classification.file_type.as_str(), metric_name)
2980 else {
2981 continue;
2982 };
2983 let mut path = Some(file.path.as_path());
2984 while let Some(current) = path {
2985 if current.as_os_str().is_empty() {
2986 break;
2987 }
2988 aggregate.entry(current.to_path_buf()).or_default().add(measure);
2989 path = current.parent();
2990 }
2991 }
2992 let normalized =
2993 matches!(metric_name, "logical_words" | "visible_logical_words" | "document_words");
2994 aggregate
2995 .into_iter()
2996 .filter_map(|(path, values)| values.value(normalized).map(|value| (path, value)))
2997 .collect()
2998}
2999
3000fn every_entry(index: &Index) -> Vec<FileRow> {
3002 debug_assert!(!index.is_folded(), "a folded index keeps too few files to list");
3003 let mut rows = Vec::new();
3004 let mut stack: Vec<(EntryId, PathBuf)> = vec![(EntryId::ROOT, PathBuf::new())];
3005 while let Some((id, path)) = stack.pop() {
3006 let Some(children) = index.children_of(id) else {
3007 continue;
3008 };
3009 let children: Vec<(PathBuf, EntryId)> =
3010 children.map(|(name, child)| (path.join(name), child)).collect();
3011 for (child_path, child) in children {
3012 let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
3013 continue;
3014 };
3015 rows.push(FileRow {
3016 path: child_path.clone(),
3017 kind,
3018 bytes: attrs.size,
3019 allocated: attrs.allocated,
3020 mtime_ns: attrs.mtime_ns,
3021 files: None,
3022 dirs: None,
3023 complete: None,
3024 age_ns: None,
3025 ignored: index.ignored_classification_of(&child_path, child),
3026 sort_value: None,
3027 classification: None,
3028 });
3029 if kind == EntryKind::Dir {
3030 stack.push((child, child_path));
3031 }
3032 }
3033 }
3034 rows
3035}
3036
3037fn tree_node(
3039 index: &Index,
3040 query: &Query,
3041 content: AnalysisSet,
3042 walked: Option<&Walked>,
3043 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
3044) -> (Option<TreeNode>, Vec<TreeOmission>) {
3045 let unfiltered = (walked.is_none() && matches!(query.selection.sort, Some(SortKey::Metric(_))))
3046 .then(|| every_entry(index));
3047 let metric_values = if let Some(SortKey::Metric(name)) = query.selection.sort {
3048 let sources = walked.map_or_else(
3049 || unfiltered.as_deref().unwrap_or(&[]),
3050 |walked| walked.members.as_slice(),
3051 );
3052 metric_sort_values(index, content, sources, name)
3053 } else {
3054 BTreeMap::new()
3055 };
3056 let root_summary = match walked {
3057 None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
3058 Some(walked) => walked.summary_of(EntryId::ROOT),
3059 };
3060
3061 let mut root = TreeNode {
3062 path: PathBuf::new(),
3063 name: ".".to_string(),
3064 kind: EntryKind::Dir,
3065 entry_ignored: index.ignored_classification_of(Path::new(""), EntryId::ROOT),
3066 bytes: root_summary.bytes,
3067 allocated: root_summary.allocated,
3068 files: root_summary.files,
3069 dirs: root_summary.dirs,
3070 ignored: root_summary.ignored,
3071 newest_mtime_ns: root_summary.newest_mtime_ns,
3072 children: Vec::new(),
3073 omissions: Vec::new(),
3074 truncated: false,
3075 };
3076 if query.limit_for(ViewSpec::Tree) == Bound::Limit(0) {
3077 let complete = index.state().coverage == crate::Coverage::Complete;
3078 let omitted = TreeOmission {
3079 reason: TreeOmissionReason::Rows,
3080 entries: 1,
3081 files: complete.then_some(root.files),
3082 bytes: complete.then_some(root.bytes),
3083 allocated: complete.then_some(root.allocated),
3084 ignored: complete.then_some(root.ignored).flatten().map(IgnoredSize::from_tally),
3085 };
3086 return (None, vec![omitted]);
3087 }
3088 expand(index, query, walked, &metric_values, tree_measurements, &mut root);
3089 if let Some(cap) = query.limit_for(ViewSpec::Tree).limit() {
3090 root = cap_tree_rows(root, cap, index.state().coverage == crate::Coverage::Complete);
3091 }
3092 (Some(root), Vec::new())
3093}
3094
3095#[derive(Clone, Copy)]
3098struct FoldedRows {
3099 entries: usize,
3100 files: u64,
3101 bytes: u64,
3102 allocated: u64,
3103 ignored: Option<IgnoredSize>,
3106}
3107
3108impl FoldedRows {
3109 const NONE: Self = Self {
3111 entries: 0,
3112 files: 0,
3113 bytes: 0,
3114 allocated: 0,
3115 ignored: Some(IgnoredSize { bytes: 0, allocated: 0 }),
3116 };
3117
3118 fn of(index: &Index, id: EntryId, path: &Path) -> Option<Self> {
3120 let folded = index.folded_children(id)?;
3121 Some(Self {
3122 entries: usize::try_from(folded.files).unwrap_or(usize::MAX),
3123 files: folded.files,
3124 bytes: folded.bytes,
3125 allocated: folded.allocated,
3126 ignored: index.children_classification_known(path).then_some(folded.ignored),
3127 })
3128 }
3129}
3130
3131#[derive(Clone, Copy)]
3135struct RowFacts {
3136 files: u64,
3137 bytes: u64,
3138 allocated: u64,
3139 ignored: Option<IgnoredTally>,
3140}
3141
3142impl From<&TreeNode> for RowFacts {
3143 fn from(node: &TreeNode) -> Self {
3144 Self {
3145 files: node.files,
3146 bytes: node.bytes,
3147 allocated: node.allocated,
3148 ignored: node.ignored,
3149 }
3150 }
3151}
3152
3153fn facts_of(rows: &[(TreeNode, EntryId)]) -> Vec<RowFacts> {
3154 rows.iter().map(|(row, _)| RowFacts::from(row)).collect()
3155}
3156
3157fn record_omission(
3158 node: &mut TreeNode,
3159 reason: TreeOmissionReason,
3160 rows: &[RowFacts],
3161 folded: Option<FoldedRows>,
3162 complete: bool,
3163) {
3164 if rows.is_empty() && folded.is_none() {
3165 return;
3166 }
3167 let seed = folded.unwrap_or(FoldedRows::NONE);
3170 let bytes = complete
3171 .then(|| rows.iter().try_fold(seed.bytes, |sum, row| sum.checked_add(row.bytes)))
3172 .flatten();
3173 let allocated = complete
3174 .then(|| rows.iter().try_fold(seed.allocated, |sum, row| sum.checked_add(row.allocated)))
3175 .flatten();
3176 let files = complete
3177 .then(|| rows.iter().try_fold(seed.files, |sum, row| sum.checked_add(row.files)))
3178 .flatten();
3179 let ignored = complete
3180 .then(|| {
3181 rows.iter().try_fold(seed.ignored?, |sum, row| {
3182 sum.checked_add(IgnoredSize::from_tally(row.ignored?))
3183 })
3184 })
3185 .flatten();
3186 node.omissions.push(TreeOmission {
3187 reason,
3188 entries: rows.len().saturating_add(seed.entries),
3189 files,
3190 bytes,
3191 allocated,
3192 ignored,
3193 });
3194 node.truncated = true;
3195}
3196
3197fn cap_tree_rows(root: TreeNode, cap: usize, complete: bool) -> TreeNode {
3198 struct Pending {
3199 node: TreeNode,
3200 parent: Option<usize>,
3201 }
3202 let mut pending = vec![Pending { node: root, parent: None }];
3203 let mut kept: Vec<Pending> = Vec::new();
3204 while let Some(mut item) = pending.pop() {
3205 if kept.len() == cap {
3206 let parent = item.parent.expect("root admitted by positive row cap");
3207 let omission = TreeOmission {
3208 reason: TreeOmissionReason::Rows,
3209 entries: 1,
3210 files: complete.then_some(item.node.files),
3211 bytes: complete.then_some(item.node.bytes),
3212 allocated: complete.then_some(item.node.allocated),
3213 ignored: complete
3214 .then_some(item.node.ignored)
3215 .flatten()
3216 .map(IgnoredSize::from_tally),
3217 };
3218 let owner = &mut kept[parent].node;
3219 if let Some(existing) = owner
3220 .omissions
3221 .iter_mut()
3222 .find(|existing| existing.reason == TreeOmissionReason::Rows)
3223 {
3224 existing.entries += 1;
3225 existing.files =
3226 existing.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
3227 existing.bytes =
3228 existing.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
3229 existing.allocated =
3230 existing.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
3231 existing.ignored =
3232 existing.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
3233 } else {
3234 owner.omissions.push(omission);
3235 }
3236 owner.truncated = true;
3237 continue;
3238 }
3239 let children = std::mem::take(&mut item.node.children);
3240 let current = kept.len();
3241 kept.push(item);
3242 for child in children.into_iter().rev() {
3243 pending.push(Pending { node: child, parent: Some(current) });
3244 }
3245 }
3246 for position in (1..kept.len()).rev() {
3247 let child = kept.remove(position);
3248 kept[child.parent.expect("only root lacks parent")].node.children.insert(0, child.node);
3249 }
3250 kept.pop().expect("positive cap admits root").node
3251}
3252
3253fn expand(
3260 index: &Index,
3261 query: &Query,
3262 walked: Option<&Walked>,
3263 metric_values: &BTreeMap<PathBuf, u64>,
3264 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
3265 node: &mut TreeNode,
3266) {
3267 struct Pending {
3269 node: TreeNode,
3270 id: EntryId,
3271 depth: usize,
3272 parent: Option<usize>,
3273 }
3274
3275 let mut built = vec![Pending {
3276 node: TreeNode {
3279 path: node.path.clone(),
3280 name: node.name.clone(),
3281 kind: node.kind,
3282 entry_ignored: node.entry_ignored,
3283 bytes: node.bytes,
3284 allocated: node.allocated,
3285 files: node.files,
3286 dirs: node.dirs,
3287 ignored: node.ignored,
3288 newest_mtime_ns: node.newest_mtime_ns,
3289 children: Vec::new(),
3290 omissions: Vec::new(),
3291 truncated: false,
3292 },
3293 id: EntryId::ROOT,
3294 depth: 0,
3295 parent: None,
3296 }];
3297
3298 let threshold = query.min_share_for();
3299 let grand = match query.selection.size {
3300 SizeMetric::Apparent => node.bytes,
3301 SizeMetric::Allocated => node.allocated,
3302 };
3303 let complete = index.state().coverage == crate::Coverage::Complete;
3304 let mut cursor = 0;
3305 while cursor < built.len() {
3306 let (id, depth) = (built[cursor].id, built[cursor].depth);
3307 let path = built[cursor].node.path.clone();
3308
3309 let (mut rows, below_share) = child_rows(
3310 index,
3311 query,
3312 walked,
3313 metric_values,
3314 tree_measurements,
3315 id,
3316 &path,
3317 &threshold,
3318 grand,
3319 );
3320 record_omission(
3323 &mut built[cursor].node,
3324 TreeOmissionReason::Share,
3325 &below_share,
3326 FoldedRows::of(index, id, &path),
3327 true,
3328 );
3329 if !query.depth_for(ViewSpec::Tree).admits(depth) {
3330 record_omission(
3331 &mut built[cursor].node,
3332 TreeOmissionReason::Depth,
3333 &facts_of(&rows),
3334 None,
3335 complete,
3336 );
3337 cursor += 1;
3338 continue;
3339 }
3340 if let Some(cap) = query.breadth_for().limit() {
3341 let hidden = rows.split_off(cap.min(rows.len()));
3342 record_omission(
3343 &mut built[cursor].node,
3344 TreeOmissionReason::Breadth,
3345 &facts_of(&hidden),
3346 None,
3347 complete,
3348 );
3349 }
3350
3351 for (child_node, child_id) in rows {
3352 built.push(Pending {
3353 node: child_node,
3354 id: child_id,
3355 depth: depth + 1,
3356 parent: Some(cursor),
3357 });
3358 }
3359 cursor += 1;
3360 }
3361
3362 for position in (1..built.len()).rev() {
3365 let child = built.remove(position);
3366 let parent = child.parent.expect("only the root has no parent");
3367 built[parent].node.children.insert(0, child.node);
3368 }
3369
3370 let mut root = built.pop().expect("the root is always present");
3371 node.children = std::mem::take(&mut root.node.children);
3372 node.omissions = std::mem::take(&mut root.node.omissions);
3373 node.truncated = root.node.truncated;
3374}
3375
3376#[allow(clippy::too_many_arguments)]
3386fn child_rows(
3387 index: &Index,
3388 query: &Query,
3389 walked: Option<&Walked>,
3390 metric_values: &BTreeMap<PathBuf, u64>,
3391 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
3392 id: EntryId,
3393 path: &Path,
3394 threshold: &ShareThreshold,
3395 grand: u64,
3396) -> (Vec<(TreeNode, EntryId)>, Vec<RowFacts>) {
3397 let Some(children) = index.children_of(id) else {
3398 return (Vec::new(), Vec::new());
3399 };
3400 let children_known = index.children_classification_known(path);
3402 let mut child_path = path.to_path_buf();
3403 let mut rows: Vec<(TreeNode, EntryId)> = Vec::new();
3404 let mut below_share: Vec<RowFacts> = Vec::new();
3405 for (name, child) in children {
3406 let Some(kind) = index.kind_of(child) else {
3407 continue;
3408 };
3409 if !matches!(kind, EntryKind::Dir | EntryKind::File) {
3410 continue;
3411 }
3412 if walked.is_some_and(|walked| match kind {
3413 EntryKind::Dir => !walked.visible.contains(&child),
3414 EntryKind::File => !walked.visible_files.contains(&child),
3415 EntryKind::Symlink | EntryKind::Other => true,
3416 }) {
3417 continue;
3418 }
3419 let entry_ignored = children_known.then(|| index.entry_ignored(child)).flatten();
3420 child_path.push(name);
3421 let summary = if kind == EntryKind::File {
3422 let attrs = index.attrs_of(child).expect("live child has attributes");
3423 let ignored = entry_ignored.map(|ignored| {
3424 if ignored {
3425 IgnoredTally {
3426 files: 1,
3427 dirs: 0,
3428 bytes: attrs.size,
3429 allocated: attrs.allocated,
3430 }
3431 } else {
3432 IgnoredTally::default()
3433 }
3434 });
3435 SummaryRow {
3436 files: 1,
3437 dirs: 0,
3438 bytes: attrs.size,
3439 allocated: attrs.allocated,
3440 ignored,
3441 newest_mtime_ns: Some(attrs.mtime_ns),
3442 }
3443 } else {
3444 match walked {
3445 None => unfiltered_summary(index, child, &child_path),
3446 Some(walked) => walked.summary_of(child),
3447 }
3448 };
3449 let value = match query.selection.size {
3450 SizeMetric::Apparent => summary.bytes,
3451 SizeMetric::Allocated => summary.allocated,
3452 };
3453 let child_complete = kind == EntryKind::File
3457 || tree_measurements
3458 .is_none_or(|values| values.get(&child).is_some_and(|subtree| subtree.complete));
3459 if child_complete && !threshold.admits(value, grand) {
3460 below_share.push(RowFacts {
3461 files: summary.files,
3462 bytes: summary.bytes,
3463 allocated: summary.allocated,
3464 ignored: summary.ignored,
3465 });
3466 child_path.pop();
3467 continue;
3468 }
3469 rows.push((
3470 TreeNode {
3471 path: child_path.clone(),
3472 name: name.to_string_lossy().into_owned(),
3473 kind,
3474 entry_ignored,
3475 bytes: summary.bytes,
3476 allocated: summary.allocated,
3477 files: summary.files,
3478 dirs: summary.dirs,
3479 ignored: summary.ignored,
3480 newest_mtime_ns: summary.newest_mtime_ns,
3481 children: Vec::new(),
3482 omissions: Vec::new(),
3483 truncated: false,
3484 },
3485 child,
3486 ));
3487 child_path.pop();
3488 }
3489
3490 sort_rows_by(
3491 &mut rows,
3492 query,
3493 ViewSpec::Tree,
3494 SortAccessors {
3495 size: |(row, _): &(TreeNode, EntryId), metric| match metric {
3496 SizeMetric::Apparent => row.bytes,
3497 SizeMetric::Allocated => row.allocated,
3498 },
3499 count: |(row, _): &(TreeNode, EntryId)| row.files,
3500 mtime: |(row, _): &(TreeNode, EntryId)| row.newest_mtime_ns,
3501 name: borrowed_name(|(row, _): &(TreeNode, EntryId)| {
3502 std::borrow::Cow::Borrowed(row.name.as_str())
3503 }),
3504 content_metric: |(row, _): &(TreeNode, EntryId), _: &MetricDef| {
3505 metric_values.get(&row.path).copied()
3506 },
3507 },
3508 );
3509 (rows, below_share)
3510}
3511
3512fn truncate<T>(rows: &mut Vec<T>, limit: Bound) -> usize {
3514 let total = rows.len();
3515 if let Some(limit) = limit.limit() {
3516 rows.truncate(limit);
3517 }
3518 total
3519}
3520
3521struct SortAccessors<S, C, M, N, V> {
3523 size: S,
3524 count: C,
3525 mtime: M,
3526 name: N,
3527 content_metric: V,
3528}
3529
3530fn sort_rows<T>(
3531 rows: &mut [T],
3532 query: &Query,
3533 view: ViewSpec,
3534 accessors: SortAccessors<
3535 impl Fn(&T, SizeMetric) -> u64,
3536 impl Fn(&T) -> u64,
3537 impl Fn(&T) -> Option<i64>,
3538 impl for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
3539 impl Fn(&T, &MetricDef) -> Option<u64>,
3540 >,
3541) {
3542 sort_rows_by(rows, query, view, accessors);
3543}
3544
3545fn borrowed_name<T, F>(name: F) -> F
3548where
3549 F: for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
3550{
3551 name
3552}
3553
3554fn sort_rows_by<T>(
3559 rows: &mut [T],
3560 query: &Query,
3561 view: ViewSpec,
3562 accessors: SortAccessors<
3563 impl Fn(&T, SizeMetric) -> u64,
3564 impl Fn(&T) -> u64,
3565 impl Fn(&T) -> Option<i64>,
3566 impl for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
3567 impl Fn(&T, &MetricDef) -> Option<u64>,
3568 >,
3569) {
3570 let SortAccessors { size, count, mtime, name, content_metric } = accessors;
3571 let key = query.selection.sort.unwrap_or_else(|| view.default_sort());
3572 let metric = query.selection.size;
3573
3574 rows.sort_by(|left, right| {
3575 let ordering = match key {
3576 SortKey::Size => size(right, metric).cmp(&size(left, metric)),
3578 SortKey::Count => count(right).cmp(&count(left)),
3579 SortKey::Mtime => mtime(right).cmp(&mtime(left)),
3580 SortKey::Name => name(left).cmp(&name(right)),
3581 SortKey::Metric(metric_name) => {
3582 let definition =
3583 crate::content::METRICS.iter().find(|entry| entry.name == metric_name);
3584 let left_value = definition.and_then(|definition| content_metric(left, definition));
3585 let right_value =
3586 definition.and_then(|definition| content_metric(right, definition));
3587 match (left_value, right_value) {
3588 (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
3589 (Some(left), Some(right)) => right.cmp(&left),
3590 (Some(_), None) => std::cmp::Ordering::Less,
3591 (None, Some(_)) => std::cmp::Ordering::Greater,
3592 (None, None) => std::cmp::Ordering::Equal,
3593 }
3594 }
3595 };
3596 ordering.then_with(|| name(left).cmp(&name(right)))
3599 });
3600
3601 if query.selection.reverse && !matches!(key, SortKey::Metric(_)) {
3602 rows.reverse();
3603 }
3604}
3605
3606#[cfg(test)]
3607mod tests {
3608 use super::*;
3609 use crate::engine_contract::{Attrs, Observation, Op};
3610 use crate::query::query_glob::Pattern;
3611 use crate::query::query_selection::ModifiedWindow;
3612 use std::fs;
3613 use std::time::{Duration, UNIX_EPOCH};
3614
3615 fn attrs(size: u64, mtime_ns: i64) -> Attrs {
3616 Attrs {
3617 size,
3618 allocated: size.div_ceil(512) * 512,
3619 mtime_ns,
3620 ctime_ns: mtime_ns,
3621 inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
3622 dev: 1,
3623 }
3624 }
3625
3626 fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
3627 Op::Upsert { path: PathBuf::from(path), kind, attrs }
3628 }
3629
3630 fn sample() -> Index {
3632 let mut index = Index::new("/root");
3633 index
3634 .apply(&Observation::new(vec![
3635 upsert("src", EntryKind::Dir, Attrs::default()),
3636 upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
3637 upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
3638 upsert("src/deep", EntryKind::Dir, Attrs::default()),
3639 upsert("src/deep/nested.rs", EntryKind::File, attrs(50, 40)),
3640 upsert("docs", EntryKind::Dir, Attrs::default()),
3641 upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
3642 upsert("notes.txt", EntryKind::File, attrs(7, 5)),
3643 ]))
3644 .expect("apply");
3645 index
3646 }
3647
3648 #[test]
3649 fn ages_use_one_signed_reference_and_unrepresentable_clocks_are_unknown() {
3650 let mut index = sample();
3651 index.apply_ok(&Observation::new(vec![
3652 upsert("past", EntryKind::File, attrs(1, -10)),
3653 upsert("future", EntryKind::File, attrs(1, i64::MAX)),
3654 ]));
3655 let query = query(
3656 &[ViewSpec::Files],
3657 Selection { include: vec![pattern("past"), pattern("future")], ..Selection::default() },
3658 );
3659 let mut request = Request::new(Basis::held_by(&index), query, UNIX_EPOCH);
3660 let answer = report(&index, &request, UNIX_EPOCH).expect("report");
3661 let rows = files_of(&answer);
3662 assert_eq!(answer.age_reference_ns, Some(0));
3663 assert_eq!(
3664 rows.iter().map(|r| r.age_ns).collect::<Vec<_>>(),
3665 [Some(-i128::from(i64::MAX)), Some(10)]
3666 );
3667 request.now = UNIX_EPOCH + Duration::from_secs(10_000_000_000);
3668 let answer = report(&index, &request, UNIX_EPOCH)
3669 .expect("out-of-range reference is representable as unknown age");
3670 assert_eq!(answer.age_reference_ns, None);
3671 assert!(files_of(&answer).iter().all(|row| row.age_ns.is_none()));
3672 }
3673
3674 #[test]
3675 fn matching_a_directory_selects_its_subtree_once() {
3676 let index = sample();
3677 let selection = Selection {
3678 include: vec![pattern("src"), pattern("deep")],
3679 kinds: vec![EntryKind::Dir],
3680 size: SizeMetric::Apparent,
3681 min_size: Some(40),
3682 ..Selection::default()
3683 };
3684 let report = run(
3685 &index,
3686 &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Extensions], selection),
3687 );
3688 let rows = files_of(&report);
3689 assert_eq!(rows.len(), 2);
3690 assert_eq!((rows[0].bytes, rows[0].mtime_ns), (350, 40));
3691 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3692 assert_eq!((summary.files, summary.dirs, summary.bytes), (3, 2, 350));
3693 let Section::Extensions { rows, .. } = &report.sections[2] else { panic!("extensions") };
3694 assert_eq!((rows[0].files, rows[0].bytes), (3, 350));
3695 }
3696
3697 #[test]
3698 fn a_selection_over_an_exactly_full_tree_sums_to_u64_max() {
3699 let exact =
3704 |size: u64, mtime_ns: i64| Attrs { size, allocated: size, ..attrs(1, mtime_ns) };
3705 let mut index = Index::new("/root");
3706 index.apply_ok(&Observation::new(vec![
3707 upsert("big", EntryKind::Dir, Attrs::default()),
3708 upsert("big/half.bin", EntryKind::File, exact(1 << 63, 1)),
3709 upsert("big/rest.bin", EntryKind::File, exact((1 << 63) - 1, 2)),
3710 upsert("empty.txt", EntryKind::File, exact(0, 3)),
3711 ]));
3712 assert_eq!(index.total().bytes, u64::MAX);
3713 let selection = Selection {
3714 include: vec![pattern("big")],
3715 size: SizeMetric::Apparent,
3716 min_size: Some(1),
3717 ..Selection::default()
3718 };
3719 let report = run(&index, &query(&[ViewSpec::Summary, ViewSpec::Files], selection));
3720 let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
3721 assert_eq!((summary.files, summary.dirs, summary.bytes), (2, 1, u64::MAX));
3722 let Section::Files { rows, .. } = &report.sections[1] else { panic!("files") };
3723 assert_eq!(rows.iter().map(|row| row.bytes).sum::<u64>(), u64::MAX);
3724 }
3725
3726 #[test]
3727 fn subtree_predicates_include_directory_and_symlink_activity_but_only_file_bytes() {
3728 let mut index = sample();
3729 index
3730 .apply(&Observation::new(vec![
3731 upsert("src", EntryKind::Dir, attrs(9999, 45)),
3732 upsert("src/empty", EntryKind::Dir, attrs(8888, 60)),
3733 upsert("src/link", EntryKind::Symlink, attrs(7777, 70)),
3734 upsert("empty", EntryKind::Dir, attrs(6666, -10)),
3735 ]))
3736 .expect("apply");
3737 let base = Selection {
3738 include: vec![pattern("src"), pattern("empty")],
3739 size: SizeMetric::Apparent,
3740 ..Selection::default()
3741 };
3742 let nested = PathBuf::from("src").join("empty").to_string_lossy().into_owned();
3745 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], base.clone())));
3746 assert_eq!(
3747 rows.iter()
3748 .map(|r| (r.path.to_string_lossy().into_owned(), r.bytes, r.mtime_ns))
3749 .collect::<Vec<_>>(),
3750 [("empty".into(), 0, -10), ("src".into(), 350, 70), (nested.clone(), 0, 60)]
3751 );
3752 for (before, since, expected) in [
3753 (70, 0, vec![nested.clone()]),
3754 (71, 70, vec!["src".to_owned()]),
3755 (0, -10, vec!["empty".to_owned()]),
3756 ] {
3757 let selection = Selection {
3758 modified: ModifiedWindow { before: Some(before), since: Some(since) },
3759 ..base.clone()
3760 };
3761 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3762 assert_eq!(
3763 rows.iter().map(|r| r.path.to_string_lossy().into_owned()).collect::<Vec<_>>(),
3764 expected
3765 );
3766 }
3767 for (size, minimum, expected) in [
3768 (SizeMetric::Apparent, 350, 1),
3769 (SizeMetric::Apparent, 351, 0),
3770 (SizeMetric::Allocated, 1536, 1),
3771 (SizeMetric::Allocated, 1537, 0),
3772 ] {
3773 let selection = Selection { size, min_size: Some(minimum), ..base.clone() };
3774 assert_eq!(
3775 files_of(&run(&index, &query(&[ViewSpec::Files], selection))).len(),
3776 expected
3777 );
3778 }
3779 }
3780
3781 #[test]
3782 fn exclusions_apply_before_subtree_bounds_and_selected_ancestor_coverage() {
3783 let index = classified_sample();
3784 for (ignored, exclude, bytes, newest) in [
3785 (IgnoredEntries::Include, vec![], 325, 70),
3786 (IgnoredEntries::Exclude, vec![], 300, 20),
3787 (IgnoredEntries::Include, vec![pattern("*.log")], 300, 20),
3788 (IgnoredEntries::Include, vec![pattern("lib.rs")], 125, 70),
3789 ] {
3790 let selection = Selection {
3791 include: vec![pattern("src")],
3792 kinds: vec![EntryKind::Dir],
3793 ignored,
3794 exclude,
3795 size: SizeMetric::Apparent,
3796 ..Selection::default()
3797 };
3798 let report = run(
3799 &index,
3800 &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Types], selection),
3801 );
3802 let row = &files_of(&report)[0];
3803 assert_eq!((row.bytes, row.mtime_ns), (bytes, newest));
3804 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3805 assert_eq!(summary.bytes, bytes);
3806 let Section::Metrics { summary, .. } = &report.sections[2] else { panic!("types") };
3807 assert_eq!(summary.rows.iter().map(|r| r.bytes).sum::<u64>(), bytes);
3808 }
3809 let selection = Selection {
3810 include: vec![pattern("build")],
3811 exclude: vec![pattern("cache")],
3812 kinds: vec![EntryKind::Dir],
3813 ..Selection::default()
3814 };
3815 let report = run(&index, &query(&[ViewSpec::Files, ViewSpec::Summary], selection));
3816 assert_eq!(files_of(&report)[0].bytes, 0);
3817 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3818 assert_eq!((summary.files, summary.dirs, summary.bytes), (0, 1, 0));
3819 let only = Selection {
3820 include: vec![pattern("cache")],
3821 kinds: vec![EntryKind::Dir],
3822 ignored: IgnoredEntries::Only,
3823 ..Selection::default()
3824 };
3825 assert_eq!(files_of(&run(&index, &query(&[ViewSpec::Files], only)))[0].bytes, 1000);
3826 }
3827
3828 #[test]
3832 fn an_incomplete_subtree_reports_lower_bounds_and_matches_no_time_bound() {
3833 let mut index = Index::new_with_scope(
3836 "/root",
3837 crate::ScanScope { max_depth: Some(2), ..crate::ScanScope::default() },
3838 );
3839 index.apply_ok(&Observation::new(vec![
3840 upsert("env", EntryKind::Dir, attrs(0, 5)),
3841 upsert("env/lib", EntryKind::Dir, attrs(0, 7)),
3842 upsert("env/a.bin", EntryKind::File, attrs(100, 40)),
3843 upsert("docs", EntryKind::Dir, attrs(0, 5)),
3844 upsert("docs/guide.md", EntryKind::File, attrs(30, 50)),
3845 ]));
3846 let directories = |selection: Selection| {
3847 let selection =
3848 Selection { kinds: vec![EntryKind::Dir], size: SizeMetric::Apparent, ..selection };
3849 files_of(&run(&index, &flat(selection)))
3850 .into_iter()
3851 .map(|row| {
3852 (row.path.to_string_lossy().into_owned(), row.complete, row.bytes, row.age_ns)
3853 })
3854 .collect::<Vec<_>>()
3855 };
3856 let env_lib = Path::new("env").join("lib").to_string_lossy().into_owned();
3858 assert_eq!(
3859 directories(Selection::default()),
3860 vec![
3861 ("env".to_string(), Some(false), 100, None),
3862 ("docs".to_string(), Some(true), 30, Some(-50)),
3863 (env_lib, Some(false), 0, None),
3864 ],
3865 "sizes are lower bounds and the age is unknown below the boundary"
3866 );
3867 for modified in [
3868 ModifiedWindow { since: None, before: Some(100) },
3869 ModifiedWindow { since: Some(0), before: None },
3870 ] {
3871 assert_eq!(
3872 directories(Selection { modified, ..Selection::default() }),
3873 vec![("docs".to_string(), Some(true), 30, Some(-50))],
3874 "an unknown age satisfies no bound, not even one its lower bound would prove"
3875 );
3876 }
3877 assert_eq!(
3878 directories(Selection { min_size: Some(100), ..Selection::default() }),
3879 vec![("env".to_string(), Some(false), 100, None)],
3880 "a lower bound at or above the minimum proves the true size is too"
3881 );
3882 assert!(directories(Selection { min_size: Some(101), ..Selection::default() }).is_empty());
3883 let files = files_of(&run(
3885 &index,
3886 &flat(Selection {
3887 kinds: vec![EntryKind::File],
3888 modified: ModifiedWindow { since: Some(45), before: None },
3889 ..Selection::default()
3890 }),
3891 ));
3892 assert_eq!(files.len(), 1);
3893 assert_eq!((files[0].complete, files[0].age_ns), (None, Some(-50)));
3894 }
3895
3896 #[test]
3900 fn an_unscoped_partial_marker_marks_every_directory_row_incomplete() {
3901 let directories = |index: &Index| {
3902 files_of(&run(
3903 index,
3904 &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3905 ))
3906 };
3907 let mut partial = sample();
3908 partial.set_initial_freshness(false);
3909 let rows = directories(&partial);
3910 assert_eq!(rows.len(), 3);
3911 assert!(rows.iter().all(|row| row.complete == Some(false) && row.age_ns.is_none()));
3912 let mut complete = sample();
3913 complete.set_initial_freshness(true);
3914 let rows = directories(&complete);
3915 assert_eq!(rows.len(), 3);
3916 assert!(rows.iter().all(|row| row.complete == Some(true) && row.age_ns.is_some()));
3917 }
3918
3919 #[test]
3922 fn an_opened_root_marks_a_directory_complete_only_once_discovery_listed_it() {
3923 let handle = crate::index::IndexHandle::new(Index::new("/root"));
3924 handle
3925 .transition_discovery(crate::index::DiscoveryTransition::Begin)
3926 .expect("begin discovery");
3927 handle
3928 .apply(&Observation::new(vec![
3929 upsert("known", EntryKind::Dir, attrs(0, 5)),
3930 upsert("pending", EntryKind::Dir, attrs(0, 5)),
3931 ]))
3932 .expect("seed directories");
3933 handle
3934 .apply_discovery(
3935 &Observation::new(Vec::new()),
3936 crate::index::DiscoveryCommit {
3937 directory_complete: Some(PathBuf::from("known")),
3938 transition: None,
3939 },
3940 )
3941 .expect("list one directory");
3942 let completeness = handle
3943 .read_with(|index| {
3944 files_of(&run(
3945 index,
3946 &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3947 ))
3948 .into_iter()
3949 .map(|row| (row.path.to_string_lossy().into_owned(), row.complete))
3950 .collect::<BTreeMap<_, _>>()
3951 })
3952 .expect("read");
3953 assert_eq!(
3954 completeness,
3955 BTreeMap::from([
3956 ("known".to_string(), Some(true)),
3957 ("pending".to_string(), Some(false))
3958 ])
3959 );
3960 }
3961
3962 #[test]
3963 fn a_filtered_tree_keeps_empty_matches_and_only_folds_visible_directories() {
3964 let mut index = sample();
3965 index
3966 .apply(&Observation::new(vec![upsert("src/empty", EntryKind::Dir, Attrs::default())]))
3967 .expect("apply");
3968 let selection = Selection {
3969 include: vec![pattern("empty")],
3970 depth: Some(Bound::All),
3971 min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3972 ..Selection::default()
3973 };
3974 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3975 assert_eq!(root.children.len(), 1);
3976 assert_eq!(root.children[0].name, "src");
3977 assert_eq!(root.children[0].children[0].name, "empty");
3978 let selection = Selection {
3979 include: vec![pattern("notes.txt")],
3980 depth: Some(Bound::Limit(0)),
3981 min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3982 ..Selection::default()
3983 };
3984 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3985 assert!(root.truncated, "a file leaf is hidden by depth zero");
3986 assert_eq!(root.bytes, 7);
3987 }
3988
3989 #[test]
3990 fn the_undocumented_docs_view_alias_is_rejected() {
3991 assert_eq!(
3992 ViewSpec::parse("documents").expect("the canonical view name parses"),
3993 ViewSpec::Documents
3994 );
3995 assert_eq!(
3996 ViewSpec::parse("docs").expect_err("an unreleased alias must not become a contract"),
3997 format!("expected one of {}", ViewSpec::vocabulary())
3998 );
3999 }
4000
4001 #[test]
4002 fn code_analysis_defaults_to_code_overview_and_keeps_document_projection() {
4003 assert_eq!(
4004 ViewSpec::resolve(None, AnalysisSet::NONE.with_code(), "view").expect("default").0,
4005 vec![ViewSpec::Code]
4006 );
4007 assert_eq!(
4008 ViewSpec::resolve(None, AnalysisSet::ALL, "view").expect("combined default").0,
4009 vec![ViewSpec::Code, ViewSpec::Documents]
4010 );
4011 assert_eq!(
4012 ViewSpec::resolve(Some("files"), AnalysisSet::ALL, "view").expect("explicit").0,
4013 vec![ViewSpec::Files]
4014 );
4015 }
4016
4017 #[test]
4021 fn a_view_implies_exactly_the_analyzers_that_choose_it() {
4022 for view in ViewSpec::ALL {
4023 let implied = view.implies();
4024 match view {
4025 ViewSpec::Code | ViewSpec::Documents => {
4026 assert!(implied.is_enabled(), "{view:?} has no metadata meaning");
4027 assert_eq!(ViewSpec::default_for(implied), view, "{view:?}");
4028 }
4029 _ => assert_eq!(implied, AnalysisSet::NONE, "{view:?} has a metadata meaning"),
4030 }
4031 }
4032 assert_eq!(ViewSpec::Code.implies(), AnalysisSet::CODE_ONLY);
4033 assert_eq!(ViewSpec::Documents.implies(), AnalysisSet::WORDS_ONLY);
4034 }
4035
4036 #[test]
4039 fn only_named_content_views_imply_analysis() {
4040 let implied = |spec| ViewList::parse(spec).expect("parses").implies();
4041 assert_eq!(implied(Some("code")), AnalysisSet::CODE_ONLY);
4042 assert_eq!(implied(Some("documents")), AnalysisSet::WORDS_ONLY);
4043 assert_eq!(implied(Some("tree,code,documents")), AnalysisSet::ALL);
4044 for spec in [None, Some("full"), Some("languages"), Some("types,families,summary")] {
4045 assert_eq!(implied(spec), AnalysisSet::NONE, "{spec:?}");
4046 }
4047 assert_eq!(
4048 ViewList::parse(Some("summary,documents,code")).expect("parses").implying(),
4049 [ViewSpec::Documents, ViewSpec::Code],
4050 "the caller's order"
4051 );
4052 }
4053
4054 #[test]
4057 fn full_keeps_a_view_only_when_its_analyzers_ran() {
4058 let omitted = |content| ViewSpec::full_report(content).1;
4059 assert_eq!(omitted(AnalysisSet::NONE), [ViewSpec::Code, ViewSpec::Documents]);
4060 assert_eq!(omitted(AnalysisSet::LINES_ONLY), [ViewSpec::Code, ViewSpec::Documents]);
4061 assert_eq!(omitted(AnalysisSet::CODE_ONLY), [ViewSpec::Documents]);
4062 assert_eq!(omitted(AnalysisSet::WORDS_ONLY), [ViewSpec::Code]);
4063 assert!(omitted(AnalysisSet::ALL).is_empty());
4064 }
4065
4066 fn generated_at() -> std::time::SystemTime {
4067 UNIX_EPOCH + Duration::from_secs(1_001)
4068 }
4069
4070 fn run(index: &Index, query: &Query) -> Report {
4071 report(index, &crate::test_support::read_of(index, query.clone()), generated_at())
4072 .expect("the query is answerable over this index")
4073 }
4074
4075 fn query(views: &[ViewSpec], selection: Selection) -> Query {
4076 Query { selection, views: views.to_vec(), ..Query::default() }
4077 }
4078
4079 fn flat(selection: Selection) -> Query {
4081 Query {
4082 selection,
4083 views: vec![ViewSpec::List],
4084 format: crate::report_format::Format::Paths,
4085 ..Query::default()
4086 }
4087 }
4088
4089 fn pattern(source: &str) -> Pattern {
4090 Pattern::parse(source).expect("pattern compiles")
4091 }
4092
4093 fn summary_of(report: &Report) -> SummaryRow {
4094 match report.sections.first().expect("a section") {
4095 Section::Summary(row) => *row,
4096 other => panic!("expected a summary, got {other:?}"),
4097 }
4098 }
4099
4100 fn files_of(report: &Report) -> Vec<FileRow> {
4101 match report.sections.first().expect("a section") {
4102 Section::Files { rows, .. } => rows.clone(),
4103 other => panic!("expected files, got {other:?}"),
4104 }
4105 }
4106
4107 fn types_of(report: &Report) -> Vec<TypeRow> {
4108 match report.sections.first().expect("a section") {
4109 Section::Extensions { rows, .. } => rows.clone(),
4110 other => panic!("expected types, got {other:?}"),
4111 }
4112 }
4113
4114 fn tree_of(report: &Report) -> TreeNode {
4115 match report.sections.first().expect("a section") {
4116 Section::Tree { root: Some(node), .. } => (**node).clone(),
4117 other => panic!("expected a tree, got {other:?}"),
4118 }
4119 }
4120
4121 #[test]
4122 fn an_unfiltered_summary_matches_the_precomputed_rollup() {
4123 let index = sample();
4124 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
4125 assert_eq!(row.files, 5);
4126 assert_eq!(row.dirs, 3);
4127 assert_eq!(row.bytes, 657);
4128 assert_eq!(row.newest_mtime_ns, Some(40));
4129 }
4130
4131 #[test]
4132 fn the_two_tiers_agree_on_the_same_question() {
4133 let index = sample();
4136 let fast = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
4137
4138 let admits_everything = Selection { min_size: Some(0), ..Selection::default() };
4140 assert!(!admits_everything.is_unfiltered());
4141 let slow = summary_of(&run(&index, &query(&[ViewSpec::Summary], admits_everything)));
4142
4143 assert_eq!(
4148 (fast.files, fast.dirs, fast.bytes, fast.allocated, fast.newest_mtime_ns),
4149 (slow.files, slow.dirs, slow.bytes, slow.allocated, slow.newest_mtime_ns)
4150 );
4151 }
4152
4153 #[test]
4154 fn extension_rows_account_for_every_file_in_both_tiers() {
4155 let mut index = sample();
4160 index
4161 .apply(&Observation::new(vec![
4162 upsert("Makefile", EntryKind::File, attrs(28, 50)),
4163 upsert(".gitignore", EntryKind::File, attrs(11, 51)),
4164 ]))
4165 .expect("apply");
4166
4167 for selection in [
4170 Selection::default(),
4171 Selection { min_size: Some(0), ..Selection::default() },
4172 Selection { kinds: vec![EntryKind::File], ..Selection::default() },
4173 ] {
4174 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
4175 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
4176 assert_eq!(
4177 rows.iter().map(|row| row.bytes).sum::<u64>(),
4178 summary.bytes,
4179 "bytes unaccounted for under {selection:?}: {rows:?}"
4180 );
4181 assert_eq!(
4182 rows.iter().map(|row| row.files).sum::<u64>(),
4183 summary.files,
4184 "files unaccounted for under {selection:?}: {rows:?}"
4185 );
4186 }
4187 }
4188
4189 #[test]
4190 fn names_without_an_extension_share_one_bucket() {
4191 let mut index = sample();
4195 index
4196 .apply(&Observation::new(vec![
4197 upsert("Makefile", EntryKind::File, attrs(28, 50)),
4198 upsert(".gitignore", EntryKind::File, attrs(11, 51)),
4199 ]))
4200 .expect("apply");
4201
4202 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
4203 let bucket = rows
4204 .iter()
4205 .find(|row| row.extension == crate::classify::NO_EXTENSION)
4206 .expect("a bucket for the extension-less names");
4207 assert_eq!(bucket.files, 2);
4208 assert_eq!(bucket.bytes, 39);
4209 assert!(rows.iter().any(|row| row.extension == ".rs"), "{rows:?}");
4211 }
4212
4213 #[test]
4214 fn a_summary_counts_the_union_of_listed_entries_and_directory_contents() {
4215 let index = sample();
4221 for selection in [
4222 Selection { kinds: vec![EntryKind::File], ..Selection::default() },
4223 Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
4224 Selection { include: vec![pattern("*.rs")], ..Selection::default() },
4225 Selection { exclude: vec![pattern("docs")], ..Selection::default() },
4226 Selection { min_size: Some(1_000_000), ..Selection::default() },
4227 Selection { min_size: Some(0), ..Selection::default() },
4228 ] {
4229 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
4230 let listed = files_of(&run(&index, &query(&[ViewSpec::Files], selection.clone())));
4231 let dirs = listed.iter().filter(|row| row.kind == EntryKind::Dir).count() as u64;
4232 let files = every_entry(&index)
4233 .iter()
4234 .filter(|entry| {
4235 entry.kind == EntryKind::File
4236 && listed.iter().any(|row| {
4237 entry.path == row.path
4238 || (row.kind == EntryKind::Dir && entry.path.starts_with(&row.path))
4239 })
4240 })
4241 .count() as u64;
4242 assert_eq!(summary.dirs, dirs, "directory counts disagree under {selection:?}");
4243 assert_eq!(summary.files, files, "file counts disagree under {selection:?}");
4244 }
4245 }
4246
4247 #[test]
4248 fn a_rejected_directory_is_still_descended_into() {
4249 let index = sample();
4252 let selection = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
4253 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
4254 assert_eq!(row.dirs, 0, "no directory was admitted");
4255 assert_eq!(row.files, 5, "including src/deep/nested.rs, two levels down");
4256 assert_eq!(row.bytes, 657, "and its bytes");
4257 }
4258
4259 #[test]
4260 fn nested_directory_counts_roll_up_through_every_level() {
4261 let index = sample();
4264 let selection = Selection { kinds: vec![EntryKind::Dir], ..Selection::default() };
4265 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4266 assert_eq!(root.dirs, 3, "src, src/deep, and docs");
4267 assert_eq!(root.files, 4, "matching directories cover their regular files");
4268 let src = root.children.iter().find(|node| node.name == "src").expect("src");
4269 assert_eq!(src.dirs, 1, "src/deep, counted for src as well as for the root");
4270 }
4271
4272 #[test]
4273 fn selection_narrows_a_summary_to_what_it_admits() {
4274 let index = sample();
4275 let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
4276 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
4277 assert_eq!(row.files, 3, "three .rs files");
4278 assert_eq!(row.bytes, 350);
4279 }
4280
4281 #[test]
4282 fn a_files_view_lists_matching_entries_in_name_order_by_default() {
4283 let index = sample();
4284 let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
4285 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4286 let paths: Vec<PathBuf> = rows.iter().map(|row| row.path.clone()).collect();
4290 let expected: Vec<PathBuf> = [["src", "deep", "nested.rs"].iter().collect::<PathBuf>()]
4291 .into_iter()
4292 .chain([["src", "lib.rs"].iter().collect::<PathBuf>()])
4293 .chain([["src", "main.rs"].iter().collect::<PathBuf>()])
4294 .collect();
4295 assert_eq!(paths, expected);
4296 }
4297
4298 #[test]
4299 fn sorting_and_limiting_compose_without_a_dedicated_view() {
4300 let index = sample();
4302 let selection = Selection {
4304 kinds: vec![EntryKind::File],
4305 sort: Some(SortKey::Size),
4306 limit: Some(Bound::Limit(2)),
4307 size: SizeMetric::Apparent,
4308 ..Selection::default()
4309 };
4310 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4311 assert_eq!(rows.len(), 2);
4312 assert_eq!(rows[0].bytes, 300, "largest first");
4313 assert_eq!(rows[1].bytes, 200);
4314 }
4315
4316 #[test]
4317 fn reverse_flips_whatever_order_is_in_effect() {
4318 let index = sample();
4319 let selection = Selection {
4320 kinds: vec![EntryKind::File],
4321 sort: Some(SortKey::Size),
4322 reverse: true,
4323 size: SizeMetric::Apparent,
4324 ..Selection::default()
4325 };
4326 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4327 assert_eq!(rows[0].bytes, 7, "smallest first once reversed");
4328 }
4329
4330 #[test]
4331 fn a_modified_window_selects_by_time() {
4332 let index = sample();
4333 let selection = Selection {
4334 kinds: vec![EntryKind::File],
4335 modified: ModifiedWindow { since: Some(20), before: Some(40) },
4336 sort: Some(SortKey::Mtime),
4337 ..Selection::default()
4338 };
4339 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4340 let mut times: Vec<i64> = rows.iter().map(|row| row.mtime_ns).collect();
4341 times.sort_unstable();
4342 assert_eq!(times, vec![20, 30], "inclusive start, exclusive end");
4343 }
4344
4345 #[test]
4346 fn a_types_view_reports_both_size_metrics_per_extension() {
4347 let index = sample();
4348 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
4349 let rs = rows.iter().find(|row| row.extension == ".rs").expect(".rs present");
4350 assert_eq!((rs.files, rs.bytes), (3, 350));
4351 assert_eq!(rs.allocated, 1536, "three files, one 512-byte block each");
4352 let order: Vec<&str> = rows.iter().map(|row| row.extension.as_str()).collect();
4354 assert_eq!(order, vec![".rs", ".md", ".txt"]);
4355 }
4356
4357 #[test]
4358 fn a_tree_view_reports_directories_with_their_subtree_totals() {
4359 let index = sample();
4360 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], Selection::default())));
4361 assert_eq!(tree.name, ".");
4362 assert_eq!(tree.bytes, 657);
4363 let names: Vec<&str> = tree.children.iter().map(|child| child.name.as_str()).collect();
4365 assert_eq!(names, vec!["src", "docs", "notes.txt"]);
4366 let src = &tree.children[0];
4367 assert_eq!(src.bytes, 350);
4368 let nested: Vec<&str> = src.children.iter().map(|child| child.name.as_str()).collect();
4369 assert!(nested.contains(&"deep"));
4370 }
4371
4372 #[test]
4373 fn depth_zero_keeps_dus_meaning_of_root_totals_only() {
4374 let index = sample();
4375 let selection = Selection { depth: Some(Bound::Limit(0)), ..Selection::default() };
4376 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4377 assert_eq!(tree.bytes, 657, "totals still cover the whole tree");
4378 assert!(tree.children.is_empty(), "but nothing below the root is listed");
4379 assert!(tree.truncated, "and the report says so rather than implying emptiness");
4380 }
4381
4382 #[test]
4387 fn a_dropped_view_is_named_on_the_report_rather_than_by_one_surface() {
4388 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
4389 .expect("full resolves without analyzers");
4390 assert!(omitted.contains(&ViewSpec::Code));
4391 assert!(omitted.contains(&ViewSpec::Documents));
4392
4393 let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
4394 let (notes, tips) = display_notes(&query, AnalysisSet::NONE, &ControlCoverage::NotObserved);
4395 assert_eq!(notes, ["note: full omits code, documents without analysis"]);
4396 assert_eq!(tips, ["tip: include them: analyze all"]);
4397
4398 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::ALL, "view")
4400 .expect("full resolves with analyzers");
4401 assert!(omitted.is_empty(), "every view is answerable with analysis enabled");
4402 let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
4403 assert!(
4404 display_notes(&query, AnalysisSet::ALL, &ControlCoverage::NotObserved).0.is_empty()
4405 );
4406 }
4407
4408 #[test]
4413 fn full_names_the_missing_analyzer_and_a_value_that_keeps_the_present_one() {
4414 for (content, omitted, note) in [
4415 (
4416 AnalysisSet::LINES_ONLY,
4417 "code, documents",
4418 "note: full omits code, documents without code and words analysis",
4419 ),
4420 (
4421 AnalysisSet::CODE_ONLY,
4422 "documents",
4423 "note: full omits documents without words analysis",
4424 ),
4425 (AnalysisSet::WORDS_ONLY, "code", "note: full omits code without code analysis"),
4426 ] {
4427 let (selected, dropped) =
4428 ViewSpec::resolve(Some("full"), content, "view").expect("full resolves");
4429 let labels: Vec<&str> = dropped.iter().map(|view| view.label()).collect();
4430 assert_eq!(labels.join(", "), omitted, "{content:?}");
4431 let query = Query {
4432 views: selected,
4433 omitted_views: dropped,
4434 axes: &AxisNames::FLAGS,
4435 ..Query::default()
4436 };
4437 let (notes, tips) = display_notes(&query, content, &ControlCoverage::NotObserved);
4438 assert_eq!(notes, [note], "{content:?}");
4439 assert_eq!(tips, ["tip: include them: --analyze all"], "{content:?}");
4440 }
4441 }
4442
4443 #[test]
4446 fn analysis_no_view_shows_names_the_view_that_would_show_it() {
4447 for (content, views, note, tip) in [
4448 (
4449 AnalysisSet::CODE_ONLY,
4450 vec![ViewSpec::Summary],
4451 "note: code analysis not shown by summary",
4452 "tip: show it: --view code",
4453 ),
4454 (
4455 AnalysisSet::ALL,
4456 vec![ViewSpec::Tree],
4457 "note: code and words analysis not shown by tree",
4458 "tip: show it: --view code,documents",
4459 ),
4460 (
4461 AnalysisSet::LINES_ONLY,
4462 vec![ViewSpec::Summary, ViewSpec::Files],
4463 "note: lines analysis not shown by summary, files",
4464 "tip: show it: --view families",
4465 ),
4466 (
4467 AnalysisSet::WORDS_ONLY,
4468 vec![ViewSpec::List],
4469 "note: words analysis not shown by list",
4470 "tip: show it: --view documents",
4471 ),
4472 ] {
4473 let query = Query { views, axes: &AxisNames::FLAGS, ..Query::default() };
4474 let (notes, tips) = display_notes(&query, content, &ControlCoverage::NotObserved);
4475 assert_eq!((notes, tips), (vec![note.to_owned()], vec![tip.to_owned()]));
4476 }
4477
4478 let shown = Query { views: vec![ViewSpec::Languages], ..Query::default() };
4480 let (notes, tips) =
4481 display_notes(&shown, AnalysisSet::CODE_ONLY, &ControlCoverage::NotObserved);
4482 assert!(notes.is_empty() && tips.is_empty(), "{notes:?} {tips:?}");
4483 let ranked = Query {
4484 views: vec![ViewSpec::Files],
4485 selection: Selection {
4486 sort: Some(SortKey::Metric("code_lines")),
4487 ..Selection::default()
4488 },
4489 ..Query::default()
4490 };
4491 let (notes, _) =
4492 display_notes(&ranked, AnalysisSet::CODE_ONLY, &ControlCoverage::NotObserved);
4493 assert!(notes.is_empty(), "{notes:?}");
4494 }
4495
4496 #[test]
4499 fn analysis_partly_shown_names_what_no_view_displays() {
4500 for (content, views, sort, note, tip) in [
4501 (
4502 AnalysisSet::ALL,
4503 vec![ViewSpec::Documents],
4504 None,
4505 "note: code analysis not shown by documents",
4506 "tip: show it: --view documents,code",
4507 ),
4508 (
4509 AnalysisSet::ALL,
4510 vec![ViewSpec::Summary, ViewSpec::Code],
4511 None,
4512 "note: words analysis not shown by summary, code",
4513 "tip: show it: --view summary,code,documents",
4514 ),
4515 (
4517 AnalysisSet::ALL,
4518 vec![ViewSpec::Files],
4519 Some(SortKey::Metric("code_lines")),
4520 "note: words analysis not shown by files",
4521 "tip: show it: --view files,documents",
4522 ),
4523 ] {
4524 let query = Query {
4525 views,
4526 selection: Selection { sort, ..Selection::default() },
4527 axes: &AxisNames::FLAGS,
4528 ..Query::default()
4529 };
4530 let (notes, tips) = display_notes(&query, content, &ControlCoverage::NotObserved);
4531 assert_eq!((notes, tips), (vec![note.to_owned()], vec![tip.to_owned()]));
4532 }
4533
4534 for view in [ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages] {
4536 let query = Query { views: vec![view], ..Query::default() };
4537 let (notes, tips) =
4538 display_notes(&query, AnalysisSet::ALL, &ControlCoverage::NotObserved);
4539 assert!(notes.is_empty() && tips.is_empty(), "{view:?}: {notes:?} {tips:?}");
4540 }
4541 }
4542
4543 #[test]
4547 fn every_view_shows_what_it_implies_and_the_defaults_show_everything() {
4548 for view in ViewSpec::ALL {
4549 assert!(view.shows().contains(view.implies()), "{view:?}");
4550 }
4551 for content in [
4552 AnalysisSet::LINES_ONLY,
4553 AnalysisSet::CODE_ONLY,
4554 AnalysisSet::WORDS_ONLY,
4555 AnalysisSet::ALL,
4556 ] {
4557 let shown = ViewSpec::defaults_for(content)
4558 .into_iter()
4559 .fold(AnalysisSet::NONE, |set, view| set.union(view.shows()));
4560 assert!(shown.contains(content), "{content:?}");
4561 let query = Query { views: ViewSpec::defaults_for(content), ..Query::default() };
4562 let (notes, _) = display_notes(&query, content, &ControlCoverage::NotObserved);
4563 assert!(notes.is_empty(), "{content:?}: {notes:?}");
4564 }
4565 }
4566
4567 #[test]
4577 fn the_refused_controls_note_bounds_its_list_and_matches_its_remedy_to_the_reasons() {
4578 use crate::control::{
4579 ControlLimits, ControlObservation, ControlRefusalReason, RefusedControl,
4580 };
4581 let defaults = ControlLimits::default();
4582 for (reason, wanted, unwanted) in [
4583 (ControlRefusalReason::Budget, "--gitignore-budget", "--gitignore-line-limit"),
4584 (ControlRefusalReason::LineLimit, "--gitignore-line-limit", "--gitignore-budget"),
4585 ] {
4586 let coverage = ControlCoverage::Observed(ControlObservation {
4587 limits: defaults,
4588 applied: 7,
4589 rules: 0,
4590 refused: 1,
4591 refusals: vec![RefusedControl {
4592 path: Path::new("vendor").join(".gitignore"),
4593 reason,
4594 }],
4595 });
4596 let (note, tip) = refused_controls_note(&coverage, &AxisNames::FLAGS).expect("refusal");
4597 assert!(
4598 note.starts_with(
4599 "note: ignore classification incomplete: 1 ignore file not applied"
4600 )
4601 );
4602 assert!(note.contains("vendor"));
4603 assert!(!note.contains("--"), "facts do not repeat flag advice");
4604 let tip = tip.expect("bounded refusal has a remedy");
4605 assert!(tip.starts_with("tip:"));
4606 assert!(tip.contains(wanted));
4607 assert!(!tip.contains(unwanted));
4608 }
4609 let coverage = |limits, refused| {
4610 ControlCoverage::Observed(ControlObservation {
4611 limits,
4612 applied: 7,
4613 rules: 0,
4614 refused,
4615 refusals: (0..crate::MAX_RETAINED_ISSUES)
4616 .map(|i| RefusedControl {
4617 path: Path::new(&format!("d{i:02}")).join(".gitignore"),
4618 reason: ControlRefusalReason::Budget,
4619 })
4620 .collect(),
4621 })
4622 };
4623 let (note, tip) =
4624 refused_controls_note(&coverage(defaults, 1000), &AxisNames::FLAGS).expect("truncated");
4625 assert!(note.contains("995 more"));
4626 assert!(!note.contains("d05"));
4627 let tip = tip.expect("both bounded limits may explain unlisted refusals");
4628 assert!(tip.contains("--gitignore-budget") && tip.contains("--gitignore-line-limit"));
4629 let (_, tip) = refused_controls_note(
4630 &coverage(ControlLimits { line_limit: None, ..defaults }, 1000),
4631 &AxisNames::FLAGS,
4632 )
4633 .expect("truncated");
4634 assert!(!tip.expect("budget remedy").contains("--gitignore-line-limit"));
4635 let (_, tip) = refused_controls_note(
4636 &coverage(ControlLimits { budget: None, line_limit: None }, 64),
4637 &AxisNames::FLAGS,
4638 )
4639 .expect("retained refusal");
4640 assert!(tip.is_none(), "do not suggest raising unbounded limits");
4641 assert!(refused_controls_note(&ControlCoverage::NotObserved, &AxisNames::FLAGS).is_none());
4642 }
4643
4644 #[test]
4645 fn a_diagnostic_names_the_axes_the_requesting_surface_uses() {
4646 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
4647 .expect("full resolves without analyzers");
4648
4649 for (axes, mine, theirs) in [
4650 (&AxisNames::FLAGS, "--analyze", "analyze"),
4651 (&AxisNames::FIELDS, "analyze", "--analyze"),
4652 ] {
4653 let query = Query {
4654 views: selected.clone(),
4655 omitted_views: omitted.clone(),
4656 axes,
4657 ..Query::default()
4658 };
4659 let tip =
4663 display_notes(&query, AnalysisSet::NONE, &ControlCoverage::NotObserved).1.remove(0);
4664 assert!(tip.contains(&format!(": {mine} ")), "{tip} must name {mine}");
4665 assert!(!tip.contains(&format!(": {theirs} ")), "{tip} must not name {theirs}");
4666 }
4667
4668 for (axes, view, analyze) in
4671 [(&AxisNames::FLAGS, "--view", "--analyze"), (&AxisNames::FIELDS, "view", "analyze")]
4672 {
4673 let error = crate::query::RequestError::ViewNeedsAnalyzer {
4674 view: ViewSpec::Documents,
4675 held: AnalysisSet::NONE,
4676 holder: crate::query::BasisHolder::Index,
4677 }
4678 .message(axes);
4679 assert!(error.starts_with(&format!("{view} documents")), "{error}");
4680 assert!(error.contains(&format!("with {analyze} ")), "{error}");
4681 let theirs = if analyze == "--analyze" { "analyze" } else { "--analyze" };
4682 assert!(!error.contains(&format!("with {theirs} ")), "{error}");
4683 }
4684 }
4685
4686 #[test]
4689 fn the_default_vocabulary_is_the_librarys_own() {
4690 assert_eq!(*Query::default().axes, AxisNames::FIELDS);
4691 }
4692
4693 #[test]
4694 fn a_depth_bound_marks_hidden_file_and_directory_rows_as_truncated() {
4695 let index = sample();
4696 let selection = Selection { depth: Some(Bound::Limit(1)), ..Selection::default() };
4697 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4698
4699 let src = tree.children.iter().find(|child| child.name == "src").expect("src");
4700 assert!(src.truncated, "src with a hidden directory child is truncated");
4701
4702 let docs = tree.children.iter().find(|child| child.name == "docs").expect("docs");
4703 assert!(docs.children.is_empty());
4704 assert!(docs.truncated, "significant file leaves are rows beyond the depth boundary");
4705 }
4706
4707 #[test]
4708 fn a_tree_limit_caps_section_rows_including_the_root() {
4709 let index = sample();
4710 let selection = Selection { limit: Some(Bound::Limit(1)), ..Selection::default() };
4711 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4712 assert_eq!(tree.children.len(), 0);
4713 assert!(tree.truncated);
4714 assert!(tree.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Rows));
4715 }
4716
4717 #[test]
4718 fn tree_share_is_root_relative_inclusive_and_does_not_hide_eleventh_sibling() {
4719 let mut index = Index::new("/root");
4720 let mut ops = Vec::new();
4721 for number in 0..11 {
4722 let directory = format!("d{number:02}");
4723 ops.push(upsert(&directory, EntryKind::Dir, Attrs::default()));
4724 ops.push(upsert(&format!("{directory}/file"), EntryKind::File, attrs(20, 1)));
4725 }
4726 ops.push(upsert("filler", EntryKind::File, attrs(780, 1)));
4727 index.apply(&Observation::new(ops)).expect("apply");
4728 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4729 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4730 assert_eq!(root.children.len(), 12, "eleven 2% directories plus the large file");
4731 assert!(root.children.iter().any(|child| child.name == "d10"));
4732 assert!(root.omissions.is_empty());
4733
4734 let selection = Selection {
4735 size: SizeMetric::Apparent,
4736 breadth: Some(Bound::Limit(10)),
4737 ..Selection::default()
4738 };
4739 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4740 assert_eq!(root.children.len(), 10);
4741 assert_eq!(root.omissions[0].reason, TreeOmissionReason::Breadth);
4742 assert_eq!(root.omissions[0].entries, 2);
4743 assert_eq!(root.omissions[0].bytes, Some(40));
4744 }
4745
4746 #[test]
4747 fn tree_threshold_keeps_exact_one_percent_file_leaf() {
4748 let mut index = Index::new("/root");
4749 index
4750 .apply(&Observation::new(vec![
4751 upsert("one-percent", EntryKind::File, attrs(10, 1)),
4752 upsert("below", EntryKind::File, attrs(9, 1)),
4753 upsert("rest", EntryKind::File, attrs(981, 1)),
4754 ]))
4755 .expect("apply");
4756 let root = tree_of(&run(
4757 &index,
4758 &query(
4759 &[ViewSpec::Tree],
4760 Selection { size: SizeMetric::Apparent, ..Selection::default() },
4761 ),
4762 ));
4763 assert_eq!(root.children.len(), 2);
4764 assert!(
4765 root.children
4766 .iter()
4767 .any(|child| child.name == "one-percent" && child.kind == EntryKind::File)
4768 );
4769 assert_eq!(root.omissions[0].reason, TreeOmissionReason::Share);
4770 assert_eq!(root.omissions[0].bytes, Some(9));
4771 }
4772
4773 fn mixed_remainder_index() -> Index {
4776 let mut index = Index::new("/root");
4777 index.apply_ok(&Observation::new(vec![
4778 upsert("A", EntryKind::Dir, attrs(0, 0)),
4779 upsert("A/a1", EntryKind::Dir, attrs(0, 0)),
4780 upsert("A/a1/x", EntryKind::Dir, attrs(0, 0)),
4781 upsert("A/a1/x/leaf", EntryKind::File, attrs(1960, 0)),
4782 upsert("A/a1/tiny", EntryKind::File, attrs(40, 0)),
4783 upsert("A/a2", EntryKind::Dir, attrs(0, 0)),
4784 upsert("A/a2/file", EntryKind::File, attrs(2000, 0)),
4785 upsert("B", EntryKind::Dir, attrs(0, 0)),
4786 upsert("B/b1", EntryKind::Dir, attrs(0, 0)),
4787 upsert("B/b1/file", EntryKind::File, attrs(2000, 0)),
4788 upsert("B/b2", EntryKind::Dir, attrs(0, 0)),
4789 upsert("B/b2/file", EntryKind::File, attrs(2000, 0)),
4790 upsert("C", EntryKind::Dir, attrs(0, 0)),
4791 upsert("C/c1", EntryKind::Dir, attrs(0, 0)),
4792 upsert("C/c1/first", EntryKind::File, attrs(1000, 0)),
4793 upsert("C/c1/second", EntryKind::File, attrs(1000, 0)),
4794 ]));
4795 index
4796 }
4797
4798 #[test]
4799 fn nested_mixed_omissions_have_one_exact_remainder_golden() {
4800 let index = mixed_remainder_index();
4801 let selection = Selection {
4802 size: SizeMetric::Apparent,
4803 depth: Some(Bound::Limit(3)),
4804 breadth: Some(Bound::Limit(2)),
4805 limit: Some(Bound::Limit(8)),
4806 min_share: Some(ShareThreshold::parse("0.5%").expect("share")),
4807 ..Selection::default()
4808 };
4809 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4810 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4811 panic!("expected tree")
4812 };
4813 let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("hidden content");
4814 assert_eq!((root.files, root.bytes, root.allocated), (7, 10_000, 10_752));
4815 assert_eq!(
4816 (remainder.files, remainder.bytes, remainder.allocated),
4817 (Some(2), Some(2_000), Some(2_048))
4818 );
4819 assert_eq!(remainder.reasons, vec![TreeOmissionReason::Breadth]);
4820 assert_eq!(
4821 crate::report_format::render(&report, crate::report_format::Format::Text, false)
4822 .expect("render"),
4823 include_str!("../../tests/golden/remainder-tree.txt"),
4824 );
4825 }
4826
4827 #[test]
4828 fn displayed_directories_represent_their_descendants_even_when_expansion_stops() {
4829 let index = mixed_remainder_index();
4830 let selection = Selection {
4831 size: SizeMetric::Apparent,
4832 depth: Some(Bound::Limit(1)),
4833 breadth: Some(Bound::All),
4834 limit: Some(Bound::All),
4835 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4836 ..Selection::default()
4837 };
4838 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4839 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4840 panic!("expected tree")
4841 };
4842 assert_eq!((root.files, root.bytes), (7, 10_000));
4843 assert_eq!(root.children.len(), 3);
4844 assert!(root.children.iter().all(|child| child.kind == EntryKind::Dir));
4845 assert!(root.children.iter().any(|child| !child.omissions.is_empty()));
4846 assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4847 let diagnostics = crate::report_format::diagnostic_lines(&report);
4848 assert!(!diagnostics.notes.iter().any(|note| note.contains("… and more")));
4849 assert!(diagnostics.notes.iter().any(|note| note.contains("depth 1")));
4850 assert!(diagnostics.tips.contains(&format!("tip: show more: {}=all", report.axes.depth)));
4851
4852 let depth_zero = run(
4853 &index,
4854 &query(
4855 &[ViewSpec::Tree],
4856 Selection {
4857 size: SizeMetric::Apparent,
4858 depth: Some(Bound::Limit(0)),
4859 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4860 ..Selection::default()
4861 },
4862 ),
4863 );
4864 let Section::Tree { root: Some(root), omissions, .. } = &depth_zero.sections[0] else {
4865 panic!("expected tree")
4866 };
4867 let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("root alone");
4868 assert_eq!((remainder.files, remainder.bytes), (Some(7), Some(10_000)));
4869 }
4870
4871 #[test]
4872 fn selected_leaf_ledger_conserves_every_top_level_tree_partition() {
4873 let files = [
4877 ("src/main.rs", 100, 512, false),
4878 ("src/lib.rs", 200, 512, false),
4879 ("src/debug.log", 25, 512, true),
4880 ("docs/guide.md", 300, 512, false),
4881 ("build/cache/out.bin", 1_000, 1_024, true),
4882 ];
4883 let dirs = [("src", false), ("docs", false), ("build", true), ("build/cache", true)];
4884 let index = classified_sample();
4885 let within = |path: &str, parent: &Path| {
4886 parent.as_os_str().is_empty() || Path::new(path).starts_with(parent)
4887 };
4888 for (population, excluded) in [
4889 (IgnoredEntries::Include, None),
4890 (IgnoredEntries::Exclude, None),
4891 (IgnoredEntries::Only, None),
4892 (IgnoredEntries::Include, Some("build")),
4893 (IgnoredEntries::Include, Some("*.rs")),
4894 ] {
4895 let admitted = |path: &str, ignored: bool| {
4896 population.admits(ignored)
4897 && !matches!(excluded, Some("build") if Path::new(path).starts_with("build"))
4898 && !matches!(excluded, Some("*.rs") if Path::new(path)
4899 .extension()
4900 .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")))
4901 };
4902 let selected_files: Vec<_> = files
4903 .iter()
4904 .copied()
4905 .filter(|(path, _, _, ignored)| admitted(path, *ignored))
4906 .collect();
4907 let selected_dirs: Vec<_> =
4908 dirs.iter().copied().filter(|(path, ignored)| admitted(path, *ignored)).collect();
4909 for size in [SizeMetric::Apparent, SizeMetric::Allocated] {
4910 for share in ["0%", "1%", "50%", "100%"] {
4911 for depth in [Bound::Limit(0), Bound::Limit(1), Bound::All] {
4912 for breadth in
4913 [Bound::Limit(0), Bound::Limit(1), Bound::Limit(2), Bound::All]
4914 {
4915 for limit in
4916 [Bound::Limit(0), Bound::Limit(1), Bound::Limit(3), Bound::All]
4917 {
4918 let selection = Selection {
4919 ignored: population,
4920 exclude: excluded
4921 .map_or_else(Vec::new, |name| vec![pattern(name)]),
4922 size,
4923 min_share: Some(ShareThreshold::parse(share).expect("share")),
4924 depth: Some(depth),
4925 breadth: Some(breadth),
4926 limit: Some(limit),
4927 ..Selection::default()
4928 };
4929 let report = run(
4930 &index,
4931 &query(&[ViewSpec::Summary, ViewSpec::Tree], selection.clone()),
4932 );
4933 let Section::Summary(summary) = &report.sections[0] else {
4934 panic!("summary")
4935 };
4936 let Section::Tree { root, omissions, .. } = &report.sections[1]
4937 else {
4938 panic!("tree")
4939 };
4940 let expected = (
4941 selected_files.len() as u64,
4942 selected_dirs.len() as u64,
4943 selected_files.iter().map(|file| file.1).sum::<u64>(),
4944 selected_files.iter().map(|file| file.2).sum::<u64>(),
4945 );
4946 let context = format!("{selection:?}");
4947 assert_eq!(
4948 (summary.files, summary.dirs, summary.bytes, summary.allocated),
4949 expected,
4950 "summary: {context}"
4951 );
4952 if let Some(root) = root {
4953 assert_eq!(
4954 (root.files, root.dirs, root.bytes, root.allocated),
4955 expected,
4956 "root: {context}"
4957 );
4958 let mut stack = vec![root.as_ref()];
4959 while let Some(node) = stack.pop() {
4960 let contained: Vec<_> = selected_files
4961 .iter()
4962 .filter(|file| within(file.0, &node.path))
4963 .collect();
4964 let contained_dirs = selected_dirs
4965 .iter()
4966 .filter(|dir| {
4967 within(dir.0, &node.path)
4968 && Path::new(dir.0) != node.path
4969 })
4970 .count()
4971 as u64;
4972 let ignored: Vec<_> =
4973 contained.iter().filter(|file| file.3).collect();
4974 let ignored_dirs = selected_dirs
4975 .iter()
4976 .filter(|dir| {
4977 dir.1
4978 && within(dir.0, &node.path)
4979 && Path::new(dir.0) != node.path
4980 })
4981 .count()
4982 as u64;
4983 assert_eq!(
4984 (node.files, node.dirs, node.bytes, node.allocated),
4985 (
4986 contained.len() as u64,
4987 contained_dirs,
4988 contained.iter().map(|file| file.1).sum::<u64>(),
4989 contained.iter().map(|file| file.2).sum::<u64>(),
4990 ),
4991 "node {:?}: {context}",
4992 node.path
4993 );
4994 assert_eq!(
4995 node.ignored.map(|part| (
4996 part.files,
4997 part.dirs,
4998 part.bytes,
4999 part.allocated
5000 )),
5001 Some((
5002 ignored.len() as u64,
5003 ignored_dirs,
5004 ignored.iter().map(|file| file.1).sum::<u64>(),
5005 ignored.iter().map(|file| file.2).sum::<u64>(),
5006 )),
5007 "ignored {:?}: {context}",
5008 node.path
5009 );
5010 stack.extend(node.children.iter());
5011 }
5012 }
5013 let represented: Vec<_> = root
5014 .iter()
5015 .flat_map(|root| root.children.iter().map(|child| &child.path))
5016 .collect();
5017 let uncovered: Vec<_> = selected_files
5018 .iter()
5019 .filter(|file| {
5020 !represented.iter().any(|path| within(file.0, path))
5021 })
5022 .collect();
5023 let remainder =
5024 TreeRemainder::from_tree(root.as_deref(), omissions);
5025 if let Some(remainder) = remainder {
5026 assert_eq!(
5027 (remainder.files, remainder.bytes, remainder.allocated),
5028 (
5029 Some(uncovered.len() as u64),
5030 Some(uncovered.iter().map(|file| file.1).sum::<u64>()),
5031 Some(uncovered.iter().map(|file| file.2).sum::<u64>()),
5032 ),
5033 "remainder: {context}"
5034 );
5035 assert_eq!(
5036 remainder.ignored.map(|part| (part.bytes, part.allocated)),
5037 Some((
5038 uncovered
5039 .iter()
5040 .filter(|file| file.3)
5041 .map(|file| file.1)
5042 .sum(),
5043 uncovered
5044 .iter()
5045 .filter(|file| file.3)
5046 .map(|file| file.2)
5047 .sum(),
5048 )),
5049 "remainder ignored: {context}"
5050 );
5051 } else {
5052 assert!(uncovered.is_empty(), "missing remainder: {context}");
5053 }
5054 }
5055 }
5056 }
5057 }
5058 }
5059 }
5060 }
5061
5062 #[test]
5063 fn empty_tree_and_unrepresentable_remainder_stay_honest() {
5064 let empty = Index::new_with_scope("/root", crate::test_support::observing_controls());
5065 let report = run(
5066 &empty,
5067 &query(
5068 &[ViewSpec::Tree],
5069 Selection {
5070 depth: Some(Bound::Limit(0)),
5071 min_share: Some(ShareThreshold::parse("1%").expect("share")),
5072 ..Selection::default()
5073 },
5074 ),
5075 );
5076 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5077 panic!("empty tree")
5078 };
5079 assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (0, 0, 0, 0));
5080 assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
5081
5082 let first = TreeOmission {
5083 reason: TreeOmissionReason::Share,
5084 entries: 1,
5085 files: Some(u64::MAX),
5086 bytes: Some(u64::MAX),
5087 allocated: Some(u64::MAX),
5088 ignored: Some(IgnoredSize { bytes: u64::MAX, allocated: u64::MAX }),
5089 };
5090 let second = TreeOmission {
5091 reason: TreeOmissionReason::Breadth,
5092 entries: 1,
5093 files: Some(1),
5094 bytes: Some(1),
5095 allocated: None,
5096 ignored: Some(IgnoredSize { bytes: 1, allocated: 1 }),
5097 };
5098 let remainder = TreeRemainder::from_tree(None, &[first, second]).expect("omissions");
5099 assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
5100 assert_eq!(remainder.ignored, None);
5101 }
5102
5103 #[test]
5104 fn unbounded_tree_expands_every_entry_without_a_remainder_or_diagnostics() {
5105 let index = mixed_remainder_index();
5106 let selection = Selection {
5107 size: SizeMetric::Apparent,
5108 depth: Some(Bound::All),
5109 breadth: Some(Bound::All),
5110 limit: Some(Bound::All),
5111 min_share: Some(ShareThreshold::parse("0%").expect("share")),
5112 ..Selection::default()
5113 };
5114 let report = run(&index, &query(&[ViewSpec::Tree], selection));
5115 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5116 panic!("expected tree")
5117 };
5118 let mut paths = std::collections::BTreeSet::new();
5119 let mut stack = vec![root.as_ref()];
5120 while let Some(node) = stack.pop() {
5121 assert!(node.omissions.is_empty() && !node.truncated);
5122 paths.insert(node.path.to_string_lossy().replace('\\', "/"));
5123 stack.extend(&node.children);
5124 }
5125 assert_eq!(
5126 paths,
5127 [
5128 "",
5129 "A",
5130 "A/a1",
5131 "A/a1/tiny",
5132 "A/a1/x",
5133 "A/a1/x/leaf",
5134 "A/a2",
5135 "A/a2/file",
5136 "B",
5137 "B/b1",
5138 "B/b1/file",
5139 "B/b2",
5140 "B/b2/file",
5141 "C",
5142 "C/c1",
5143 "C/c1/first",
5144 "C/c1/second"
5145 ]
5146 .into_iter()
5147 .map(str::to_owned)
5148 .collect()
5149 );
5150 assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (7, 9, 10_000, 10_752));
5151 assert!(omissions.is_empty());
5152 assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
5153 assert!(report.notes.is_empty() && report.tips.is_empty());
5154 }
5155
5156 #[test]
5157 fn an_unlisted_hidden_branch_makes_remainder_counts_and_sizes_unknown() {
5158 let mut index = Index::new("/root");
5159 index.apply_ok(&Observation::new(vec![
5160 upsert("known", EntryKind::File, attrs(100, 0)),
5161 upsert("denied", EntryKind::Dir, attrs(0, 0)),
5162 ]));
5163 index.set_initial_scan_freshness(&[crate::Error::io(
5164 Path::new("/root").join("denied"),
5165 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
5166 )]);
5167 let selection = Selection {
5168 size: SizeMetric::Apparent,
5169 depth: Some(Bound::Limit(0)),
5170 min_share: Some(ShareThreshold::parse("0%").expect("share")),
5171 ..Selection::default()
5172 };
5173 let report = run(&index, &query(&[ViewSpec::Tree], selection));
5174 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
5175 panic!("expected tree")
5176 };
5177 let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("depth bound");
5178 assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
5179 assert_eq!(remainder.reasons, vec![TreeOmissionReason::Depth]);
5180 let text = crate::report_format::render(&report, crate::report_format::Format::Text, false)
5181 .expect("render");
5182 assert!(text.contains("— unknown … and more files (count unknown)"));
5183 }
5184
5185 #[test]
5189 fn partial_tree_keeps_default_share_pruning_golden() {
5190 let mut index = Index::new("/root");
5191 index.apply_ok(&Observation::new(vec![
5192 upsert("large", EntryKind::File, attrs(9898, 0)),
5193 upsert("one-percent", EntryKind::File, attrs(100, 0)),
5194 upsert("tiny", EntryKind::File, attrs(1, 0)),
5195 upsert("zero", EntryKind::File, attrs(0, 0)),
5196 upsert("small", EntryKind::Dir, attrs(0, 0)),
5197 upsert("small/tiny", EntryKind::File, attrs(1, 0)),
5198 upsert("denied", EntryKind::Dir, attrs(0, 0)),
5199 ]));
5200 index.set_initial_scan_freshness(&[crate::Error::io(
5201 Path::new("/root").join("denied"),
5202 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
5203 )]);
5204 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5205 let report = run(&index, &query(&[ViewSpec::Tree], selection.clone()));
5206 assert!(!report.status.complete);
5207 assert_eq!(
5208 crate::report_format::render(&report, crate::report_format::Format::Text, false)
5209 .expect("render"),
5210 include_str!("../../tests/golden/partial-tree.txt"),
5211 );
5212 let filtered = run(
5215 &index,
5216 &query(
5217 &[ViewSpec::Tree],
5218 Selection {
5219 exclude: vec![pattern("large")],
5220 min_share: Some(ShareThreshold::parse("50%").expect("share")),
5221 ..selection.clone()
5222 },
5223 ),
5224 );
5225 let filtered_root = tree_of(&filtered);
5226 assert_eq!(filtered_root.children.len(), 2);
5227 assert_eq!(filtered_root.children[0].name, "one-percent");
5228 assert_eq!(filtered_root.children[1].name, "denied");
5229 let unbounded = run(
5230 &index,
5231 &query(
5232 &[ViewSpec::Tree],
5233 Selection {
5234 min_share: Some(ShareThreshold::parse("0%").expect("share")),
5235 ..selection
5236 },
5237 ),
5238 );
5239 assert_eq!(tree_of(&unbounded).children.len(), 6, "zero share lifts only share pruning");
5240 }
5241
5242 #[test]
5243 fn tree_zero_and_composed_caps_keep_typed_first_boundaries() {
5244 let index = sample();
5245 let selection = Selection { limit: Some(Bound::Limit(0)), ..Selection::default() };
5246 let report = run(&index, &query(&[ViewSpec::Tree], selection));
5247 let Section::Tree { root: None, omissions, .. } = &report.sections[0] else {
5248 panic!("zero row cap must omit the root")
5249 };
5250 assert_eq!(omissions[0].reason, TreeOmissionReason::Rows);
5251 assert_eq!(omissions[0].entries, 1);
5252
5253 let selection = Selection {
5254 size: SizeMetric::Apparent,
5255 depth: Some(Bound::Limit(1)),
5256 breadth: Some(Bound::Limit(1)),
5257 limit: Some(Bound::Limit(2)),
5258 min_share: Some(ShareThreshold::parse("1%").expect("share")),
5259 ..Selection::default()
5260 };
5261 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
5262 assert_eq!(root.children.len(), 1);
5263 assert!(
5264 root.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Breadth)
5265 );
5266 assert!(
5267 root.children[0]
5268 .omissions
5269 .iter()
5270 .any(|omission| omission.reason == TreeOmissionReason::Depth)
5271 );
5272 }
5273
5274 #[test]
5275 fn requesting_more_views_never_changes_another_views_answer() {
5276 let index = sample();
5278 let alone = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
5279 let together = run(
5280 &index,
5281 &query(
5282 &[ViewSpec::Extensions, ViewSpec::Tree, ViewSpec::Summary],
5283 Selection::default(),
5284 ),
5285 );
5286 let with_others = match &together.sections[0] {
5287 Section::Extensions { rows, .. } => rows.clone(),
5288 other => panic!("expected types first, got {other:?}"),
5289 };
5290
5291 assert_eq!(alone.len(), with_others.len());
5292 for (left, right) in alone.iter().zip(with_others.iter()) {
5293 assert_eq!(
5294 (&left.extension, left.files, left.bytes),
5295 (&right.extension, right.files, right.bytes)
5296 );
5297 }
5298 assert_eq!(together.sections.len(), 3, "one section per view, in request order");
5299 assert_eq!(together.sections[1].view(), ViewSpec::Tree);
5300 assert_eq!(together.sections[2].view(), ViewSpec::Summary);
5301 }
5302
5303 #[test]
5304 fn analyzed_unfiltered_views_together_match_independent_answers_and_each_view_alone() {
5305 const RUST: &str = "fn main() {\n println!(\"hi\");\n}\n";
5306 const MARKDOWN: &str = "# Guide\n\nA small useful guide.\n";
5307 const TEXT: &str = "plain notes here\n";
5308 let root = tempfile::tempdir().expect("root");
5309 fs::create_dir_all(root.path().join("src")).expect("src");
5310 fs::create_dir_all(root.path().join("docs")).expect("docs");
5311 fs::write(root.path().join("src/main.rs"), RUST).expect("rust");
5312 fs::write(root.path().join("docs/guide.md"), MARKDOWN).expect("markdown");
5313 fs::write(root.path().join("notes.txt"), TEXT).expect("text");
5314 for (path, seconds) in [("src/main.rs", 10), ("notes.txt", 20), ("docs/guide.md", 30)] {
5315 fs::File::options()
5316 .write(true)
5317 .open(root.path().join(path))
5318 .expect("open for timestamp")
5319 .set_times(
5320 fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(seconds)),
5321 )
5322 .expect("set timestamp");
5323 }
5324 let (mut index, _) = crate::scan::scan_into_index(
5325 root.path(),
5326 &crate::ScanConfig { read_controls: false, ..crate::ScanConfig::default() },
5327 )
5328 .expect("scan");
5329 crate::content::analyze_index(
5330 &mut index,
5331 crate::content::AnalysisRequest {
5332 profile: AnalysisSet::ALL,
5333 ..crate::content::AnalysisRequest::default()
5334 },
5335 );
5336
5337 let views = [
5338 ViewSpec::Types,
5339 ViewSpec::Families,
5340 ViewSpec::Languages,
5341 ViewSpec::Documents,
5342 ViewSpec::Files,
5343 ViewSpec::Largest,
5344 ViewSpec::Recent,
5345 ViewSpec::Summary,
5346 ViewSpec::Tree,
5347 ViewSpec::Extensions,
5348 ];
5349 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5350 let together = run(&index, &query(&views, selection.clone()));
5351 for (i, view) in views.iter().enumerate() {
5352 let alone = run(&index, &query(&[*view], selection.clone()));
5353 assert_eq!(
5354 format!("{:?}", together.sections[i]),
5355 format!("{:?}", alone.sections[0]),
5356 "{view:?} changed when requested with the other views"
5357 );
5358 }
5359
5360 for (at, view, files) in [(0, ViewSpec::Types, 3), (1, ViewSpec::Families, 3)] {
5361 let Section::Metrics { view: actual, summary } = &together.sections[at] else {
5362 panic!("expected {view:?} metrics")
5363 };
5364 assert_eq!(*actual, view);
5365 assert_eq!(summary.total.files, files);
5366 assert_eq!(summary.total.analyzed_files, files);
5367 }
5368 let Section::Metrics { summary: languages, .. } = &together.sections[2] else {
5369 panic!("languages")
5370 };
5371 assert_eq!(languages.total.files, 1);
5372 assert_eq!(languages.total.metrics.code_lines, Some(3));
5373 let Section::Metrics { summary: documents, .. } = &together.sections[3] else {
5374 panic!("documents")
5375 };
5376 assert_eq!(documents.total.files, 2);
5377 assert_eq!(documents.total.analyzed_files, 2);
5378 assert_eq!(documents.total.document_metric_files, 2);
5379 assert_eq!(documents.total.document_raw_words, 8);
5380 assert_eq!(documents.total.document_word_stats.logical_words(), 8);
5381 assert_eq!(documents.total.share, MetricShare { numerator: 8, denominator: 8 });
5382
5383 let Section::Files { rows: files, total, .. } = &together.sections[4] else {
5384 panic!("files")
5385 };
5386 assert_eq!(*total, 5);
5387 assert_eq!(
5388 files.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
5389 ["docs", "docs/guide.md", "notes.txt", "src", "src/main.rs"].map(Path::new).to_vec()
5390 );
5391 let Section::Files { rows: largest, total, .. } = &together.sections[5] else {
5392 panic!("largest")
5393 };
5394 assert_eq!(*total, 3);
5395 assert_eq!(
5396 largest.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
5397 ["src/main.rs", "docs/guide.md", "notes.txt"].map(Path::new).to_vec()
5398 );
5399 let Section::Files { rows: recent, total, .. } = &together.sections[6] else {
5400 panic!("recent")
5401 };
5402 assert_eq!(*total, 3);
5403 assert_eq!(
5404 recent.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
5405 ["docs/guide.md", "notes.txt", "src/main.rs"].map(Path::new).to_vec()
5406 );
5407 let Section::Summary(summary) = &together.sections[7] else { panic!("summary") };
5408 assert_eq!((summary.files, summary.dirs), (3, 2));
5409 assert_eq!(
5410 summary.bytes,
5411 u64::try_from(RUST.len() + MARKDOWN.len() + TEXT.len()).expect("fixture bytes")
5412 );
5413 let Section::Tree { root: Some(tree), .. } = &together.sections[8] else { panic!("tree") };
5414 assert_eq!((tree.files, tree.dirs), (3, 2));
5415 let Section::Extensions { rows, total, .. } = &together.sections[9] else {
5416 panic!("extensions")
5417 };
5418 assert_eq!((*total, rows.len()), (3, 3));
5419 }
5420
5421 #[test]
5422 fn one_pass_metric_summaries_match_independent_views() {
5423 let root = tempfile::tempdir().expect("root");
5424 fs::write(root.path().join("main.rs"), "fn main() {}\n").expect("rust");
5425 fs::write(root.path().join("guide.md"), "# Guide\n\nWords.\n").expect("markdown");
5426 fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
5427 fs::create_dir(root.path().join("generated")).expect("generated directory");
5428 fs::write(root.path().join("generated/app.js"), "const a = 1;\nconst b = 2;\n")
5429 .expect("ignored code");
5430 fs::write(root.path().join("script"), "#!/bin/sh\necho hello\n").expect("detected code");
5431 let (mut index, _) =
5432 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5433 crate::content::analyze_index(
5434 &mut index,
5435 crate::content::AnalysisRequest {
5436 profile: AnalysisSet::ALL,
5437 ..crate::content::AnalysisRequest::default()
5438 },
5439 );
5440
5441 let views = [
5442 ViewSpec::Types,
5443 ViewSpec::Summary,
5444 ViewSpec::Families,
5445 ViewSpec::Languages,
5446 ViewSpec::Documents,
5447 ViewSpec::Code,
5448 ];
5449 let request = query(&views, Selection::default());
5450 let rows = every_entry(&index);
5451 let summaries = metric_summaries(&views, &index, &request, AnalysisSet::ALL, &rows);
5452
5453 assert!(summaries[1].is_none(), "non-metric views keep their own projection");
5454 assert!(summaries[5].is_none(), "Code keeps its admitted-content classification");
5455 for (position, view) in
5456 views.iter().copied().enumerate().filter(|(_, view)| needs_metric_resolution(*view))
5457 {
5458 let independent =
5459 metric_summary(view, &index, &request, AnalysisSet::ALL, None, Some(&rows));
5460 assert_eq!(
5461 format!("{:?}", summaries[position].as_ref().expect("metric summary")),
5462 format!("{independent:?}"),
5463 "{view:?} changed in the one-pass multi-view aggregation"
5464 );
5465 }
5466
5467 let views = views.into_iter().filter(|view| *view != ViewSpec::Summary).collect::<Vec<_>>();
5472 for ignored in [IgnoredEntries::Include, IgnoredEntries::Exclude, IgnoredEntries::Only] {
5473 for sort in [None, Some(SortKey::Metric("code_lines")), Some(SortKey::Name)] {
5474 for min_share in [None, Some(ShareThreshold::parse("50%").expect("share"))] {
5475 let selection = Selection {
5476 ignored,
5477 sort,
5478 min_share,
5479 limit: Some(Bound::Limit(1)),
5480 ..Selection::default()
5481 };
5482 let together = run(&index, &query(&views, selection.clone()));
5483 for (position, view) in views.iter().enumerate() {
5484 let alone = run(&index, &query(&[*view], selection.clone()));
5485 assert_eq!(
5486 format!("{:?}", together.sections[position]),
5487 format!("{:?}", alone.sections[0]),
5488 "{view:?} differs for {selection:?}"
5489 );
5490 }
5491 }
5492 }
5493 }
5494 }
5495
5496 #[test]
5497 fn code_overview_keeps_a_complete_language_table_and_population_contributions() {
5498 let root = tempfile::tempdir().expect("root");
5499 fs::create_dir_all(root.path().join("generated")).expect("generated");
5500 fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
5501 fs::create_dir_all(root.path().join("src")).expect("src");
5502 fs::write(root.path().join("src/main.rs"), "fn main() {\n}\n// comment\n").expect("rust");
5503 fs::write(
5504 root.path().join("generated/app.js"),
5505 "// Code generated by fixture\nconst answer = 42;\n",
5506 )
5507 .expect("javascript");
5508 fs::write(root.path().join("README.md"), "# Guide\n").expect("documentation");
5509 fs::write(root.path().join("mystery.widget"), "opaque text\n").expect("unknown type");
5510 let (mut index, _) =
5511 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5512 crate::content::analyze_index(
5513 &mut index,
5514 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5515 );
5516 let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
5517 let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5518 assert_eq!(overview.selected.metrics.code_lines, 3);
5519 assert_eq!(overview.selected.metrics.comment_lines, 2);
5520 assert_eq!(overview.selected.analyzed_files, 2);
5521 assert_eq!(overview.analyzed_languages, 2);
5522 assert_eq!(overview.unclassified_files, 2); assert_eq!(overview.languages.len(), 2);
5524 assert_eq!(overview.languages[0].share.denominator, 3);
5525 assert_eq!(overview.non_ignored.as_ref().expect("classified").metrics.code_lines, 2);
5526 assert_eq!(overview.ignored.as_ref().expect("classified").metrics.code_lines, 1);
5527 assert_eq!(overview.unknown.source_files, 0);
5528
5529 for (population, expected_lines) in
5530 [(IgnoredEntries::Exclude, 2), (IgnoredEntries::Only, 1)]
5531 {
5532 let selected = run(
5533 &index,
5534 &query(
5535 &[ViewSpec::Code],
5536 Selection { ignored: population, ..Selection::default() },
5537 ),
5538 );
5539 let Section::Code(overview) = &selected.sections[0] else { panic!("population") };
5540 assert_eq!(overview.selected.metrics.code_lines, expected_lines);
5541 assert!(overview.non_ignored.is_none() && overview.ignored.is_none());
5542 assert_eq!(overview.unknown.source_files, 0);
5543 }
5544
5545 let threshold = run(
5546 &index,
5547 &query(
5548 &[ViewSpec::Code],
5549 Selection {
5550 min_share: Some(ShareThreshold::parse("50%").expect("share")),
5551 ..Selection::default()
5552 },
5553 ),
5554 );
5555 let Section::Code(overview) = &threshold.sections[0] else { panic!("threshold") };
5556 assert_eq!(overview.selected.metrics.code_lines, 3);
5557 assert_eq!(overview.total_languages, 1);
5558 assert_eq!(overview.share_omitted, 1);
5559 assert_eq!(overview.languages[0].language, "rust");
5560
5561 let bounded = run(
5562 &index,
5563 &query(
5564 &[ViewSpec::Code],
5565 Selection { limit: Some(Bound::Limit(0)), ..Selection::default() },
5566 ),
5567 );
5568 let Section::Code(overview) = &bounded.sections[0] else { panic!("bounded") };
5569 assert_eq!(overview.selected.metrics.code_lines, 3);
5570 assert_eq!(overview.total_languages, 2);
5571 assert_eq!(overview.share_omitted, 0);
5572 assert!(overview.languages.is_empty());
5573
5574 let code_and_extensions = run(
5575 &index,
5576 &query(
5577 &[ViewSpec::Languages, ViewSpec::Extensions],
5578 Selection {
5579 min_share: Some(ShareThreshold::parse("100%").expect("share")),
5580 ..Selection::default()
5581 },
5582 ),
5583 );
5584 let Section::Metrics { summary, .. } = &code_and_extensions.sections[0] else {
5585 panic!("languages")
5586 };
5587 let Section::Extensions { rows, total, share_omitted } = &code_and_extensions.sections[1]
5588 else {
5589 panic!("extensions")
5590 };
5591 assert_eq!(summary.total.metrics.code_lines, Some(3));
5592 assert!(summary.rows.is_empty() && summary.total_rows == 0);
5593 assert_eq!(summary.share_omitted, 2);
5594 assert!(rows.is_empty() && *total == 0);
5595 assert!(*share_omitted > 0);
5596
5597 let reversed_code = run(
5598 &index,
5599 &query(&[ViewSpec::Code], Selection { reverse: true, ..Selection::default() }),
5600 );
5601 let Section::Code(reversed) = &reversed_code.sections[0] else { panic!("reversed code") };
5602 assert_eq!(reversed.languages[0].language, "javascript");
5603 assert_eq!(reversed.languages[1].language, "rust");
5604
5605 let named_code = run(
5606 &index,
5607 &query(
5608 &[ViewSpec::Code],
5609 Selection { sort: Some(SortKey::Name), ..Selection::default() },
5610 ),
5611 );
5612 let Section::Code(named) = &named_code.sections[0] else { panic!("named code") };
5613 assert_eq!(named.languages[0].language, "javascript");
5614 assert_eq!(named.languages[1].language, "rust");
5615
5616 let sized_code = run(
5617 &index,
5618 &query(
5619 &[ViewSpec::Code],
5620 Selection {
5621 sort: Some(SortKey::Size),
5622 size: SizeMetric::Apparent,
5623 ..Selection::default()
5624 },
5625 ),
5626 );
5627 let Section::Code(sized) = &sized_code.sections[0] else { panic!("sized code") };
5628 assert_eq!(sized.languages[0].language, "javascript");
5629
5630 let files = |reverse| {
5631 let selection = Selection {
5632 kinds: vec![EntryKind::File],
5633 sort: Some(SortKey::Metric("code_lines")),
5634 reverse,
5635 ..Selection::default()
5636 };
5637 run(&index, &query(&[ViewSpec::Files], selection))
5638 };
5639 let descending = files(false);
5640 let Section::Files { rows, .. } = &descending.sections[0] else { panic!("files") };
5641 assert_eq!(rows[0].path, PathBuf::from("src/main.rs"));
5642 assert_eq!(rows[0].sort_value, Some(2));
5643 assert_eq!(rows[1].path, PathBuf::from("generated/app.js"));
5644 assert_eq!(rows[1].sort_value, Some(1));
5645 assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5646 let classification = rows[1].classification.as_ref().expect("file classification");
5647 assert!(classification.flags.generated);
5648
5649 let ascending = files(true);
5650 let Section::Files { rows, .. } = &ascending.sections[0] else { panic!("files") };
5651 assert_eq!((rows[0].sort_value, rows[1].sort_value), (Some(1), Some(2)));
5652 assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5653
5654 let mut dir_query = query(
5655 &[ViewSpec::List],
5656 Selection {
5657 kinds: vec![EntryKind::Dir],
5658 sort: Some(SortKey::Metric("code_lines")),
5659 ..Selection::default()
5660 },
5661 );
5662 dir_query.format = crate::report_format::Format::Json;
5663 let dirs = run(&index, &dir_query);
5664 let Section::Files { rows, .. } = &dirs.sections[0] else { panic!("directories") };
5665 assert_eq!((rows[0].path.as_path(), rows[0].sort_value), (Path::new("src"), Some(2)));
5666 assert_eq!((rows[1].path.as_path(), rows[1].sort_value), (Path::new("generated"), Some(1)));
5667 }
5668
5669 #[test]
5670 fn code_overview_counts_selected_directory_members_once() {
5671 let root = tempfile::tempdir().expect("root");
5672 fs::create_dir_all(root.path().join("src/deep")).expect("directories");
5673 fs::write(root.path().join("src/main.rs"), "fn main() {}\n").expect("main");
5674 fs::write(root.path().join("src/deep/keep.rs"), "fn keep() {}\n").expect("keep");
5675 fs::write(root.path().join("src/deep/skip.rs"), "fn skip() {}\n").expect("skip");
5676 fs::write(root.path().join("outside.rs"), "fn outside() {}\n").expect("outside");
5677 let (mut index, _) =
5678 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5679 crate::content::analyze_index(
5680 &mut index,
5681 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5682 );
5683 let selection = Selection {
5684 include: vec![pattern("src"), pattern("deep")],
5685 exclude: vec![pattern("skip.rs")],
5686 ..Selection::default()
5687 };
5688 let answer = run(&index, &query(&[ViewSpec::Code], selection));
5689 let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5690 assert_eq!(overview.selected.source_files, 2);
5691 assert_eq!(overview.selected.analyzed_files, 2);
5692 assert_eq!(overview.selected.metrics.code_lines, 2);
5693 assert_eq!(overview.languages.len(), 1);
5694 assert_eq!(overview.languages[0].selected.source_files, 2);
5695 }
5696
5697 #[test]
5698 fn code_overview_uses_retained_content_detection_for_ambiguous_sources() {
5699 let root = tempfile::tempdir().expect("root");
5700 fs::write(root.path().join("ambiguous.h"), "namespace demo { int value; }\n")
5701 .expect("header");
5702 fs::write(root.path().join("script.inc"), "# vim: set filetype=rust:\nfn main() {}\n")
5703 .expect("modeline");
5704 let (mut index, _) =
5705 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5706 crate::content::analyze_index(
5707 &mut index,
5708 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5709 );
5710 let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
5711 let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5712 assert_eq!(overview.selected.source_files, 2);
5713 assert_eq!(overview.selected.analyzed_files, 2);
5714 assert!(overview.languages.iter().any(|row| row.language == "cpp"));
5715 assert!(overview.languages.iter().any(|row| row.language == "rust"));
5716 }
5717
5718 #[test]
5719 fn refused_control_subtrees_do_not_enter_a_known_ignored_population() {
5720 let root = tempfile::tempdir().expect("root");
5721 fs::create_dir_all(root.path().join("guarded")).expect("directory");
5722 fs::write(root.path().join("known.rs"), "fn known() {}\n").expect("known source");
5723 fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("refused control");
5724 fs::write(root.path().join("guarded/uncertain.rs"), "fn uncertain() {}\n")
5725 .expect("uncertain source");
5726 let config = crate::ScanConfig {
5727 control_limits: crate::control::ControlLimits {
5728 line_limit: Some(1),
5729 ..crate::control::ControlLimits::default()
5730 },
5731 ..crate::ScanConfig::default()
5732 };
5733 let (index, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
5734 assert_eq!(index.ignored_classification(Path::new("known.rs")), Some(false));
5735 assert_eq!(index.ignored_classification(Path::new("guarded/uncertain.rs")), None);
5736 for selection in
5737 [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
5738 {
5739 let report = run(
5740 &index,
5741 &query(&[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions], selection),
5742 );
5743 let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
5744 let Section::Tree { root: Some(tree), .. } = &report.sections[1] else {
5745 panic!("tree")
5746 };
5747 let Section::Extensions { rows, .. } = &report.sections[2] else {
5748 panic!("extensions")
5749 };
5750 assert_eq!(summary.ignored, None);
5751 assert_eq!(tree.ignored, None);
5752 assert_eq!(
5753 tree.children
5754 .iter()
5755 .find(|node| node.path == Path::new("known.rs"))
5756 .and_then(|node| node.ignored),
5757 Some(IgnoredTally::default())
5758 );
5759 assert!(
5760 tree.children
5761 .iter()
5762 .find(|node| node.path == Path::new("guarded"))
5763 .is_some_and(|node| node.ignored.is_none())
5764 );
5765 assert!(rows.iter().all(|row| row.ignored.is_none()));
5766 assert!(
5767 report
5768 .notes
5769 .iter()
5770 .any(|note| note.contains("gitignored subtotals are unavailable"))
5771 );
5772 assert!(
5773 crate::report_format::report_notes(&report)
5774 .iter()
5775 .all(|note| !note.contains("include gitignored sizes"))
5776 );
5777 }
5778 let hidden_report = run(
5779 &index,
5780 &query(
5781 &[ViewSpec::Tree],
5782 Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
5783 ),
5784 );
5785 let Section::Tree { root, omissions, .. } = &hidden_report.sections[0] else {
5786 panic!("expected tree")
5787 };
5788 let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows");
5789 assert!(hidden.bytes.is_some() && hidden.ignored.is_none());
5790 for population in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
5791 let answer = run(
5792 &index,
5793 &query(
5794 &[ViewSpec::Files],
5795 Selection {
5796 kinds: vec![EntryKind::File],
5797 ignored: population,
5798 ..Selection::default()
5799 },
5800 ),
5801 );
5802 let Section::Files { rows, .. } = &answer.sections[0] else { panic!("files") };
5803 assert!(rows.iter().all(|row| !row.path.starts_with("guarded")));
5804 assert_eq!(
5805 rows.iter().any(|row| row.path == Path::new("known.rs")),
5806 population == IgnoredEntries::Exclude
5807 );
5808 }
5809 }
5810
5811 #[test]
5812 fn a_report_derives_provenance_from_its_index() {
5813 let index = sample();
5814 let report = run(&index, &query(&[ViewSpec::Summary], Selection::default()));
5815 assert_eq!(report.provenance.source, ReportSource::ColdScan);
5816 assert!(report.status.complete);
5817 assert!(report.provenance.scan_started_at.is_some());
5818 assert_eq!(report.provenance.generated_at, generated_at());
5819 assert_eq!(report.root, Path::new("/root"));
5820 }
5821
5822 #[test]
5823 fn reporting_is_pure_and_repeatable() {
5824 let index = sample();
5825 let request = query(&[ViewSpec::Tree, ViewSpec::Extensions], Selection::default());
5826 assert_eq!(format!("{:?}", run(&index, &request)), format!("{:?}", run(&index, &request)));
5827 }
5828
5829 #[test]
5830 fn metadata_grouping_views_use_the_generic_metric_projection() {
5831 let index = sample();
5832 let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5833 let report = run(
5834 &index,
5835 &query(&[ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages], apparent),
5836 );
5837 let Section::Metrics { summary: types, .. } = &report.sections[0] else {
5838 panic!("expected type metrics")
5839 };
5840 let rust = types.rows.iter().find(|row| row.id == "rust").expect("rust");
5841 assert_eq!((rust.files, rust.bytes), (3, 350));
5842 assert_eq!((rust.share.numerator, rust.share.denominator), (350, 657));
5843 assert_eq!(types.share_metric, ShareMetric::ApparentBytes);
5844
5845 let Section::Metrics { summary: families, .. } = &report.sections[1] else {
5846 panic!("expected family metrics")
5847 };
5848 assert!(families.rows.iter().any(|row| row.id == "code"));
5849 assert!(families.rows.iter().any(|row| row.id == "prose"));
5850 assert_eq!(families.share_metric, ShareMetric::ApparentBytes);
5851
5852 let Section::Metrics { summary: languages, .. } = &report.sections[2] else {
5853 panic!("expected language metrics")
5854 };
5855 let rust = languages.rows.iter().find(|row| row.id == "rust").expect("rust");
5856 assert_eq!((rust.files, rust.bytes), (3, 350));
5857 assert_eq!((rust.share.numerator, rust.share.denominator), (350, 350));
5858 assert_eq!(languages.share_metric, ShareMetric::ApparentBytes);
5859 }
5860
5861 const CONTROL: &str = ".gitignore";
5863
5864 fn classified_sample() -> Index {
5866 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5867 index
5868 .apply(&Observation::new(vec![
5869 Op::ControlUpsert {
5870 path: PathBuf::from(CONTROL),
5871 source: b"build/\n*.log\n".to_vec(),
5872 },
5873 upsert("src", EntryKind::Dir, Attrs::default()),
5874 upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
5875 upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
5876 upsert("src/debug.log", EntryKind::File, attrs(25, 70)),
5877 upsert("docs", EntryKind::Dir, Attrs::default()),
5878 upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
5879 upsert("build", EntryKind::Dir, Attrs::default()),
5880 upsert("build/cache", EntryKind::Dir, Attrs::default()),
5881 upsert("build/cache/out.bin", EntryKind::File, attrs(1_000, 60)),
5882 ]))
5883 .expect("apply");
5884 index
5885 }
5886
5887 #[test]
5892 fn child_rows_admit_from_roll_ups_and_sum_what_the_share_omits() {
5893 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5894 index.apply_ok(&Observation::new(vec![
5895 Op::ControlUpsert { path: PathBuf::from(CONTROL), source: b"*.log\n".to_vec() },
5896 upsert("big", EntryKind::Dir, Attrs::default()),
5897 upsert("big/a", EntryKind::File, attrs(1_000, 10)),
5898 upsert("small", EntryKind::Dir, Attrs::default()),
5899 upsert("small/x.log", EntryKind::File, attrs(5, 20)),
5900 upsert("tiny", EntryKind::Dir, Attrs::default()),
5901 upsert("tiny/t", EntryKind::File, attrs(3, 30)),
5902 upsert("f1", EntryKind::File, attrs(200, 40)),
5903 upsert("f2", EntryKind::File, attrs(2, 50)),
5904 ]));
5905 let id = |path: &str| index.lookup(Path::new(path)).expect("an indexed path");
5906 let measured = |bytes: u64, complete| query_subtrees::SubtreeValues {
5907 bytes,
5908 allocated: bytes.div_ceil(512) * 512,
5909 mtime_ns: 0,
5910 files: 1,
5911 dirs: 0,
5912 complete,
5913 };
5914 let measurements: BTreeMap<EntryId, query_subtrees::SubtreeValues> = [
5915 (id("big"), measured(1_000, true)),
5916 (id("small"), measured(5, true)),
5917 (id("tiny"), measured(3, false)),
5918 ]
5919 .into_iter()
5920 .collect();
5921 let query = query(
5924 &[ViewSpec::Tree],
5925 Selection { size: SizeMetric::Apparent, ..Selection::default() },
5926 );
5927 let grand = 1_000 + 5 + 3 + 200 + 2;
5928
5929 let (rows, omitted) = child_rows(
5930 &index,
5931 &query,
5932 None,
5933 &BTreeMap::new(),
5934 Some(&measurements),
5935 EntryId::ROOT,
5936 Path::new(""),
5937 &ShareThreshold::one_percent(),
5938 grand,
5939 );
5940 assert_eq!(
5943 rows.iter().map(|(row, id)| (row.name.as_str(), row.bytes, *id)).collect::<Vec<_>>(),
5944 [("big", 1_000, id("big")), ("f1", 200, id("f1")), ("tiny", 3, id("tiny"))]
5945 );
5946 assert!(rows.iter().all(|(row, _)| row.path.as_path() == Path::new(&row.name)));
5947 let mut facts: Vec<_> =
5950 omitted.iter().map(|row| (row.bytes, row.files, row.allocated, row.ignored)).collect();
5951 facts.sort_unstable_by_key(|(bytes, ..)| *bytes);
5952 assert_eq!(
5953 facts,
5954 [
5955 (2, 1, 512, Some(IgnoredTally::default())),
5956 (5, 1, 512, Some(IgnoredTally { files: 1, dirs: 0, bytes: 5, allocated: 512 })),
5957 ]
5958 );
5959
5960 for complete in [true, false] {
5961 let mut node = TreeNode {
5962 path: PathBuf::new(),
5963 name: String::new(),
5964 kind: EntryKind::Dir,
5965 entry_ignored: None,
5966 bytes: grand,
5967 allocated: 0,
5968 files: 5,
5969 dirs: 3,
5970 ignored: None,
5971 newest_mtime_ns: None,
5972 children: Vec::new(),
5973 omissions: Vec::new(),
5974 truncated: false,
5975 };
5976 record_omission(&mut node, TreeOmissionReason::Share, &omitted, None, complete);
5977 assert!(node.truncated);
5978 let [omission] = node.omissions.as_slice() else {
5979 panic!("one Share omission, complete = {complete}: {:?}", node.omissions)
5980 };
5981 assert_eq!(omission.reason, TreeOmissionReason::Share);
5982 assert_eq!(omission.entries, 2);
5983 let sums = (omission.files, omission.bytes, omission.allocated, omission.ignored);
5984 let expected = if complete {
5985 (Some(2), Some(7), Some(1_024), Some(IgnoredSize { bytes: 5, allocated: 512 }))
5986 } else {
5987 (None, None, None, None)
5988 };
5989 assert_eq!(sums, expected, "complete = {complete}");
5990 }
5991 }
5992
5993 #[test]
5994 fn tree_entry_classification_is_independent_of_selected_subtree_tallies() {
5995 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5996 index.apply_ok(&Observation::new(vec![
5997 Op::ControlUpsert {
5998 path: PathBuf::from(CONTROL),
5999 source: b"empty/\nzero.txt\n*.log\n".to_vec(),
6000 },
6001 upsert("empty", EntryKind::Dir, Attrs::default()),
6002 upsert("zero.txt", EntryKind::File, attrs(0, 0)),
6003 upsert("mixed", EntryKind::Dir, Attrs::default()),
6004 upsert("mixed/ignored.log", EntryKind::File, attrs(5, 5)),
6005 upsert("mixed/ordinary.rs", EntryKind::File, attrs(7, 7)),
6006 ]));
6007 let selection = |ignored| Selection {
6008 ignored,
6009 depth: Some(Bound::All),
6010 breadth: Some(Bound::All),
6011 limit: Some(Bound::All),
6012 min_share: Some(ShareThreshold::parse("0%").expect("share")),
6013 ..Selection::default()
6014 };
6015 let included =
6016 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include))));
6017 assert_eq!(included.entry_ignored, Some(false));
6018 let child = |name: &str| included.children.iter().find(|row| row.name == name).expect(name);
6019 let empty = child("empty");
6020 assert_eq!((empty.kind, empty.bytes, empty.entry_ignored), (EntryKind::Dir, 0, Some(true)));
6021 assert_eq!(child("zero.txt").entry_ignored, Some(true));
6022 let mixed = child("mixed");
6023 assert_eq!(mixed.entry_ignored, Some(false));
6024 assert_eq!(mixed.ignored.expect("known").bytes, 5);
6025 assert_eq!(
6026 mixed
6027 .children
6028 .iter()
6029 .find(|row| row.name == "ignored.log")
6030 .expect("ignored leaf")
6031 .entry_ignored,
6032 Some(true)
6033 );
6034
6035 let only =
6036 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Only))));
6037 assert_eq!(only.entry_ignored, Some(false));
6038 let mixed_only = only.children.iter().find(|row| row.name == "mixed").expect("ancestor");
6039 assert_eq!(mixed_only.entry_ignored, Some(false));
6040 assert_eq!(mixed_only.ignored.expect("known").bytes, 5);
6041
6042 let mut unobserved =
6043 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6044 unobserved.apply_ok(&Observation::new(vec![upsert(
6045 "plain",
6046 EntryKind::Dir,
6047 Attrs::default(),
6048 )]));
6049 let unknown = tree_of(&run(
6050 &unobserved,
6051 &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include)),
6052 ));
6053 assert_eq!(unknown.entry_ignored, None);
6054 assert_eq!(unknown.children[0].entry_ignored, None);
6055 }
6056
6057 #[test]
6058 fn ignored_size_interpretation_note_appears_once_only_when_relevant() {
6059 let classified = classified_sample();
6060 for (population, expected) in
6061 [(IgnoredEntries::Include, 1), (IgnoredEntries::Exclude, 0), (IgnoredEntries::Only, 0)]
6062 {
6063 let report = run(
6064 &classified,
6065 &query(
6066 &[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions],
6067 Selection {
6068 ignored: population,
6069 depth: Some(Bound::All),
6070 min_share: Some(ShareThreshold::parse("0%").expect("share")),
6071 ..Selection::default()
6072 },
6073 ),
6074 );
6075 let notes = crate::report_format::report_notes(&report);
6076 assert_eq!(
6077 notes.iter().filter(|note| note.contains("include gitignored sizes")).count(),
6078 expected,
6079 "{population:?}: {notes:?}"
6080 );
6081 }
6082 let mut unobserved =
6083 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6084 unobserved.apply_ok(&Observation::new(vec![upsert(
6085 "plain.rs",
6086 EntryKind::File,
6087 attrs(10, 0),
6088 )]));
6089 let blind =
6090 run(&unobserved, &query(&[ViewSpec::Summary, ViewSpec::Tree], Selection::default()));
6091 assert!(
6092 crate::report_format::report_notes(&blind)
6093 .iter()
6094 .all(|note| !note.contains("include gitignored sizes"))
6095 );
6096 }
6097
6098 #[test]
6099 fn tree_remainder_counts_only_unrepresented_root_children() {
6100 let index = classified_sample();
6101 let remainder = |selection| {
6102 let report = run(&index, &query(&[ViewSpec::Tree], selection));
6103 let Section::Tree { root, omissions, .. } = &report.sections[0] else {
6104 panic!("expected tree")
6105 };
6106 TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows")
6107 };
6108 let mixed = remainder(Selection {
6109 size: SizeMetric::Apparent,
6110 depth: Some(Bound::Limit(1)),
6111 breadth: Some(Bound::Limit(2)),
6112 min_share: Some(ShareThreshold::parse("5%").expect("share")),
6113 ..Selection::default()
6114 });
6115 let usage = |ignored: Option<IgnoredSize>| ignored.map(|part| (part.bytes, part.allocated));
6116 assert_eq!(
6117 (mixed.files, mixed.bytes, usage(mixed.ignored)),
6118 (Some(1), Some(300), Some((0, 0)))
6119 );
6120 assert_eq!(mixed.reasons, vec![TreeOmissionReason::Breadth]);
6121
6122 for limit in [Bound::Limit(1), Bound::Limit(0)] {
6123 let rows = remainder(Selection { limit: Some(limit), ..Selection::default() });
6124 assert_eq!((rows.bytes, usage(rows.ignored)), (Some(1_625), Some((1_025, 1_536))));
6125 }
6126 let excluded = remainder(Selection {
6127 depth: Some(Bound::Limit(0)),
6128 ignored: IgnoredEntries::Exclude,
6129 ..Selection::default()
6130 });
6131 assert_eq!((excluded.bytes, excluded.ignored), (Some(600), Some(IgnoredSize::default())));
6132 let only = remainder(Selection {
6133 depth: Some(Bound::Limit(0)),
6134 ignored: IgnoredEntries::Only,
6135 ..Selection::default()
6136 });
6137 assert_eq!((only.bytes, usage(only.ignored)), (Some(1_025), Some((1_025, 1_536))));
6138 }
6139
6140 #[test]
6141 fn colored_tree_populations_share_one_golden() {
6142 let classified = classified_sample();
6143 let mut unobserved =
6144 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6145 unobserved.apply_ok(&Observation::new(vec![upsert(
6146 "plain.rs",
6147 EntryKind::File,
6148 attrs(10, 0),
6149 )]));
6150
6151 let root = tempfile::tempdir().expect("root");
6152 fs::create_dir(root.path().join("guarded")).expect("directory");
6153 fs::write(root.path().join("known.rs"), "K").expect("known file");
6154 fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("control");
6155 fs::write(root.path().join("guarded/uncertain.rs"), "U").expect("uncertain file");
6156 let config = crate::ScanConfig {
6157 control_limits: crate::control::ControlLimits {
6158 line_limit: Some(1),
6159 ..crate::control::ControlLimits::default()
6160 },
6161 ..crate::ScanConfig::default()
6162 };
6163 let (refused, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
6164 assert_eq!(refused.ignored_classification(Path::new("known.rs")), Some(false));
6165 assert_eq!(refused.ignored_classification(Path::new("guarded/uncertain.rs")), None);
6166
6167 let selection = |ignored| Selection {
6168 size: SizeMetric::Apparent,
6169 depth: Some(Bound::Limit(0)),
6170 ignored,
6171 ..Selection::default()
6172 };
6173 let cases = [
6174 ("INCLUDE", &classified, selection(IgnoredEntries::Include)),
6175 (
6176 "INCLUDE EXPANDED",
6177 &classified,
6178 Selection {
6179 depth: Some(Bound::Limit(2)),
6180 min_share: Some(ShareThreshold::parse("0%").expect("share")),
6181 ..selection(IgnoredEntries::Include)
6182 },
6183 ),
6184 ("EXCLUDE", &classified, selection(IgnoredEntries::Exclude)),
6185 ("ONLY", &classified, selection(IgnoredEntries::Only)),
6186 ("NO CONTROLS", &unobserved, selection(IgnoredEntries::Include)),
6187 ("REFUSED CONTROL", &refused, selection(IgnoredEntries::Include)),
6188 ];
6189 let mut actual = String::new();
6190 for (label, index, selection) in cases {
6191 let report = run(index, &query(&[ViewSpec::Tree], selection));
6192 actual.push_str(label);
6193 actual.push('\n');
6194 actual.push_str(
6195 &crate::report_format::render(&report, crate::report_format::Format::Text, true)
6196 .expect("colored tree")
6197 .replace('\u{1b}', "<ESC>"),
6198 );
6199 actual.push('\n');
6200 }
6201 assert_eq!(actual, include_str!("../../tests/golden/tree-populations.txt"));
6202 }
6203
6204 #[test]
6205 fn tree_remainder_ignored_share_is_unknown_without_control_observation() {
6206 let mut index =
6207 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6208 index.apply_ok(&Observation::new(vec![upsert("file", EntryKind::File, attrs(10, 0))]));
6209 let report = run(
6210 &index,
6211 &query(
6212 &[ViewSpec::Tree],
6213 Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
6214 ),
6215 );
6216 let Section::Tree { root, omissions, .. } = &report.sections[0] else {
6217 panic!("expected tree")
6218 };
6219 let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden file");
6220 assert_eq!((hidden.bytes, hidden.ignored), (Some(10), None));
6221 }
6222
6223 fn ignored_of(row: &SummaryRow) -> IgnoredTally {
6224 row.ignored.expect("an observing index reports an ignored share")
6225 }
6226
6227 #[test]
6231 fn the_two_tiers_agree_on_the_ignored_share() {
6232 let index = classified_sample();
6233 let expected = IgnoredTally { files: 2, dirs: 2, bytes: 1_025, allocated: 1_024 + 512 };
6234 for selection in
6235 [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
6236 {
6237 let unfiltered = selection.is_unfiltered();
6238 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
6239 assert_eq!(ignored_of(&summary), expected, "unfiltered: {unfiltered}");
6240
6241 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection.clone())));
6242 assert_eq!(tree.ignored, Some(expected), "unfiltered: {unfiltered}");
6243 let child = |name: &str| {
6244 tree.children.iter().find(|node| node.name == name).expect(name).ignored
6245 };
6246 assert_eq!(
6247 child("build"),
6248 Some(IgnoredTally { files: 1, dirs: 1, bytes: 1_000, allocated: 1_024 }),
6249 "an ignored directory is wholly ignored below it, unfiltered: {unfiltered}"
6250 );
6251 assert_eq!(
6252 child("src"),
6253 Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
6254 "unfiltered: {unfiltered}"
6255 );
6256 assert_eq!(child("docs"), Some(IgnoredTally::default()), "observed, nothing ignored");
6257
6258 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
6259 let row = |extension: &str| {
6260 rows.iter().find(|row| row.extension == extension).expect(extension).ignored
6261 };
6262 assert_eq!(
6263 row(".log"),
6264 Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
6265 "unfiltered: {unfiltered}"
6266 );
6267 assert_eq!(row(".rs"), Some(IgnoredTally::default()), "unfiltered: {unfiltered}");
6268
6269 let files = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
6270 let flag =
6271 |path: PathBuf| files.iter().find(|row| row.path == path).map(|row| row.ignored);
6272 assert_eq!(flag(PathBuf::from("build")), Some(Some(true)));
6273 assert_eq!(flag(PathBuf::from("src")), Some(Some(false)));
6274 assert_eq!(flag(["src", "debug.log"].iter().collect()), Some(Some(true)));
6275 assert_eq!(flag(["build", "cache", "out.bin"].iter().collect()), Some(Some(true)));
6276 }
6277 }
6278
6279 #[test]
6282 fn ignored_entries_partition_the_tree_and_rank_by_what_they_select() {
6283 let index = classified_sample();
6284 let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
6287 let with = |ignored| Selection { ignored, ..apparent.clone() };
6288 let summary = |selection| summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
6289 let total = summary(apparent.clone());
6290 let kept = summary(with(IgnoredEntries::Exclude));
6291 let only = summary(with(IgnoredEntries::Only));
6292 assert_eq!(
6293 (kept.files + only.files, kept.dirs + only.dirs, kept.bytes + only.bytes),
6294 (total.files, total.dirs, total.bytes)
6295 );
6296 assert_eq!(ignored_of(&kept), IgnoredTally::default());
6297 let whole = ignored_of(&only);
6298 assert_eq!((whole.files, whole.dirs, whole.bytes), (only.files, only.dirs, only.bytes));
6299
6300 let ranked = |selection| {
6301 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)))
6302 .children
6303 .iter()
6304 .map(|node| (node.name.clone(), node.bytes))
6305 .collect::<Vec<_>>()
6306 };
6307 let row = |name: &str, bytes: u64| (name.to_string(), bytes);
6308 assert_eq!(
6309 ranked(apparent.clone()),
6310 [row("build", 1_000), row("src", 325), row("docs", 300)]
6311 );
6312 assert_eq!(
6313 ranked(with(IgnoredEntries::Exclude)),
6314 [row("docs", 300), row("src", 300)],
6315 "unignored sizes rank the rows, with the name breaking the tie"
6316 );
6317 assert_eq!(ranked(with(IgnoredEntries::Only)), [row("build", 1_000), row("src", 25)]);
6318 }
6319
6320 #[test]
6323 fn an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by() {
6324 let mut index =
6325 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
6326 index
6327 .apply(&Observation::new(vec![
6328 upsert("build", EntryKind::Dir, Attrs::default()),
6329 upsert("build/out.bin", EntryKind::File, attrs(1_000, 60)),
6330 ]))
6331 .expect("apply");
6332 let views = [ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
6333 let report = run(&index, &query(&views, Selection::default()));
6334 let Section::Summary(summary) = &report.sections[0] else { panic!("a summary") };
6335 let Section::Tree { root: Some(tree), .. } = &report.sections[1] else { panic!("a tree") };
6336 let Section::Extensions { rows: extensions, .. } = &report.sections[2] else {
6337 panic!("extensions")
6338 };
6339 let Section::Files { rows: files, .. } = &report.sections[3] else { panic!("files") };
6340 assert_eq!(summary.ignored, None);
6341 assert_eq!(tree.ignored, None);
6342 assert!(tree.children.iter().all(|node| node.ignored.is_none()));
6343 assert!(extensions.iter().all(|row| row.ignored.is_none()));
6344 assert!(files.iter().all(|row| row.ignored.is_none()));
6345
6346 let exclude = Query {
6347 selection: Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() },
6348 views: vec![ViewSpec::Summary],
6349 ..Query::default()
6350 };
6351 let only = Query {
6352 selection: Selection { ignored: IgnoredEntries::Only, ..Selection::default() },
6353 ..exclude.clone()
6354 };
6355 for refused in [exclude, only] {
6360 assert!(
6361 matches!(
6362 super::report(
6363 &index,
6364 &crate::test_support::read_of(&index, refused),
6365 generated_at()
6366 ),
6367 Err(crate::Error::InvalidRequest(
6368 crate::query::RequestError::IgnoredWithoutObservation(_)
6369 ))
6370 ),
6371 "a selection by ignored state over an unobserving index is refused"
6372 );
6373 }
6374 assert_eq!(summary_of(&run(&index, &query(&views, Selection::default()))).files, 1);
6375 }
6376}