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 default_for(analysis: AnalysisSet) -> Self {
206 match (analysis.includes_code(), analysis.includes_words()) {
207 (true, _) => Self::Code,
208 (false, true) => Self::Documents,
209 (false, false) if analysis.is_enabled() => Self::Families,
210 (false, false) => Self::List,
211 }
212 }
213
214 pub fn resolve(
229 spec: Option<&str>,
230 analysis: AnalysisSet,
231 label: &str,
232 ) -> Result<(Vec<Self>, Vec<Self>), String> {
233 Self::resolve_rejecting(spec, analysis).map_err(|rejection| rejection.labeled(label))
234 }
235
236 pub(crate) fn resolve_rejecting(
239 spec: Option<&str>,
240 analysis: AnalysisSet,
241 ) -> Result<(Vec<Self>, Vec<Self>), Rejection> {
242 let Some(spec) = spec else {
243 return Ok((
244 if analysis.includes_code() && analysis.includes_words() {
245 vec![Self::Code, Self::Documents]
246 } else {
247 vec![Self::default_for(analysis)]
248 },
249 Vec::new(),
250 ));
251 };
252
253 let mut parsed: Vec<Self> = Vec::new();
254 let mut full_seen = false;
255 for raw in spec.split(',') {
256 let token = raw.trim();
257 if token.is_empty() {
258 return Err(Rejection::new(spec, "empty entry in the list"));
259 }
260 if token.eq_ignore_ascii_case("full") {
261 if full_seen || !parsed.is_empty() {
262 return Err(Rejection::new("full", Self::FULL_IS_EXCLUSIVE));
263 }
264 full_seen = true;
265 continue;
266 }
267 if full_seen {
268 return Err(Rejection::new("full", Self::FULL_IS_EXCLUSIVE));
269 }
270 let view = Self::parse(token).map_err(|expected| Rejection::new(token, expected))?;
271 if parsed.contains(&view) {
272 return Err(Rejection::new(spec, format!("{token:?} appears more than once")));
273 }
274 parsed.push(view);
275 }
276
277 if full_seen {
278 return Ok(Self::full_report(analysis));
279 }
280 Ok((parsed, Vec::new()))
281 }
282
283 pub const FULL_IS_EXCLUSIVE: &'static str =
290 "it names the whole report and cannot be combined with another view";
291
292 pub fn full_report(analysis: AnalysisSet) -> (Vec<Self>, Vec<Self>) {
297 Self::ALL
298 .into_iter()
299 .filter(|view| {
300 view.is_summary_view()
301 && (!analysis.includes_code() || !matches!(view, Self::Languages))
302 })
303 .partition(|view| {
304 (!matches!(view, Self::Documents) || analysis.is_enabled())
305 && (!matches!(view, Self::Code) || analysis.includes_code())
306 })
307 }
308
309 pub const fn is_summary_view(self) -> bool {
314 !matches!(self, Self::List | Self::Files)
315 }
316}
317
318#[derive(Clone, Copy, Debug, PartialEq, Eq)]
337pub struct AxisNames {
338 pub view: &'static str,
340 pub format: &'static str,
342 pub analyze: &'static str,
344 pub control_budget: &'static str,
346 pub control_line_limit: &'static str,
348 pub exclude_ignored: &'static str,
350 pub only_ignored: &'static str,
352 pub read_controls: &'static str,
354 pub scan_depth: &'static str,
356 pub one_filesystem: &'static str,
358 pub follow_symlinks: &'static str,
360 pub include: &'static str,
362 pub modified_since: &'static str,
364 pub modified_before: &'static str,
366 pub kind: &'static str,
368 pub ignored: &'static str,
370 pub depth: &'static str,
372 pub min_share: &'static str,
374 pub breadth: &'static str,
376 pub limit: &'static str,
378 pub sort: &'static str,
380 pub size: &'static str,
382 pub words_per_page: &'static str,
384 pub cache: &'static str,
386 pub stale_ok: &'static str,
388 pub watch: &'static str,
390}
391
392impl AxisNames {
393 pub const FLAGS: Self = Self {
402 view: "--view",
403 format: "--format",
404 analyze: "--analyze",
405 control_budget: "--gitignore-budget",
406 control_line_limit: "--gitignore-line-limit",
407 exclude_ignored: "--ignored=exclude",
408 only_ignored: "--ignored=only",
409 read_controls: "--no-gitignore",
410 scan_depth: "--scan-depth",
411 one_filesystem: "--one-filesystem",
412 follow_symlinks: "follow_symlinks",
413 include: "--include",
414 modified_since: "--modified-since",
415 modified_before: "--modified-before",
416 kind: "--kind",
417 ignored: "--ignored",
418 depth: "--depth",
419 min_share: "--min-share",
420 breadth: "--breadth",
421 limit: "--limit",
422 sort: "--sort",
423 size: "--size",
424 words_per_page: "--words-per-page",
425 cache: "--cache",
426 stale_ok: "--stale-ok",
427 watch: "--watch",
428 };
429
430 pub const FIELDS: Self = Self {
442 view: "view",
443 format: "format",
444 analyze: "analyze",
445 control_budget: "control_budget",
446 control_line_limit: "control_line_limit",
447 exclude_ignored: "ignored=exclude",
448 only_ignored: "ignored=only",
449 read_controls: "read_controls",
450 scan_depth: "max_depth",
451 one_filesystem: "one_filesystem",
452 follow_symlinks: "follow_symlinks",
453 include: "include",
454 modified_since: "modified_since",
455 modified_before: "modified_before",
456 kind: "kind",
457 ignored: "ignored",
458 depth: "depth",
459 min_share: "min_share",
460 breadth: "breadth",
461 limit: "limit",
462 sort: "sort",
463 size: "size",
464 words_per_page: "words_per_page",
465 cache: "cache policy",
466 stale_ok: "stale_ok",
467 watch: "watch",
468 };
469}
470
471impl Default for AxisNames {
472 fn default() -> Self {
473 Self::FIELDS
474 }
475}
476
477#[derive(Clone, Debug)]
479pub struct Query {
480 pub selection: Selection,
482 pub views: Vec<ViewSpec>,
484 pub format: crate::report_format::Format,
486 pub omitted_views: Vec<ViewSpec>,
492 pub axes: &'static AxisNames,
497 pub words_per_page: u64,
499}
500
501impl Default for Query {
502 fn default() -> Self {
503 Self {
504 selection: Selection::default(),
505 views: Vec::new(),
506 format: crate::report_format::Format::Text,
507 omitted_views: Vec::new(),
508 axes: &AxisNames::FIELDS,
509 words_per_page: crate::query::Request::DEFAULTS.words_per_page,
510 }
511 }
512}
513
514impl Query {
515 pub fn tree_for(&self, view: ViewSpec) -> bool {
517 use crate::report_format::Format;
518 match view {
519 ViewSpec::List => matches!(self.format, Format::Text | Format::Tree),
520 ViewSpec::Tree => !matches!(self.format, Format::Paths | Format::Long),
521 ViewSpec::Files => self.format == Format::Tree,
522 _ => false,
523 }
524 }
525
526 pub(crate) fn needs_selection_walk(&self) -> bool {
528 !self.selection.is_unfiltered()
529 || self.views.iter().any(|view| {
530 matches!(view, ViewSpec::List | ViewSpec::Tree | ViewSpec::Files)
531 && !self.tree_for(*view)
532 })
533 }
534
535 pub fn limit_for(&self, view: ViewSpec) -> Bound {
537 self.selection.limit.unwrap_or_else(|| {
538 if self.tree_for(view) {
539 ViewSpec::Tree.default_limit()
540 } else if view == ViewSpec::Tree {
541 Bound::All
542 } else {
543 view.default_limit()
544 }
545 })
546 }
547
548 pub fn depth_for(&self, view: ViewSpec) -> Bound {
550 self.selection.depth.unwrap_or_else(|| {
551 if self.tree_for(view) { ViewSpec::Tree.default_depth() } else { view.default_depth() }
552 })
553 }
554
555 pub fn breadth_for(&self) -> Bound {
557 self.selection.breadth.unwrap_or(Bound::All)
558 }
559
560 pub fn min_share_for(&self) -> ShareThreshold {
562 self.selection.min_share.clone().unwrap_or_else(ShareThreshold::one_percent)
563 }
564}
565
566#[derive(Clone, Copy, PartialEq, Eq, Debug)]
568pub enum ReportSource {
569 ColdScan,
571 WarmRevalidate,
573 CacheOnly,
575}
576
577#[derive(Clone, Debug)]
579pub struct TreeNode {
580 pub path: PathBuf,
582 pub name: String,
584 pub kind: EntryKind,
586 pub entry_ignored: Option<bool>,
589 pub bytes: u64,
591 pub allocated: u64,
593 pub files: u64,
595 pub dirs: u64,
597 pub ignored: Option<IgnoredTally>,
604 pub newest_mtime_ns: Option<i64>,
606 pub children: Vec<TreeNode>,
608 pub omissions: Vec<TreeOmission>,
610 pub truncated: bool,
612}
613
614#[derive(Clone, Copy, Debug, PartialEq, Eq)]
616pub enum TreeOmissionReason {
617 Share,
619 Breadth,
621 Depth,
623 Rows,
625}
626
627impl TreeOmissionReason {
628 pub const fn label(self) -> &'static str {
630 match self {
631 Self::Share => "share",
632 Self::Breadth => "breadth",
633 Self::Depth => "depth",
634 Self::Rows => "rows",
635 }
636 }
637}
638
639#[derive(Clone, Debug, PartialEq, Eq)]
641pub struct TreeOmission {
642 pub reason: TreeOmissionReason,
644 pub entries: usize,
646 pub files: Option<u64>,
648 pub bytes: Option<u64>,
650 pub allocated: Option<u64>,
652 pub ignored: Option<IgnoredSize>,
654}
655
656#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
659pub struct IgnoredSize {
660 pub bytes: u64,
662 pub allocated: u64,
664}
665
666impl IgnoredSize {
667 fn from_tally(tally: IgnoredTally) -> Self {
668 Self { bytes: tally.bytes, allocated: tally.allocated }
669 }
670
671 fn checked_add(self, other: Self) -> Option<Self> {
672 Some(Self {
673 bytes: self.bytes.checked_add(other.bytes)?,
674 allocated: self.allocated.checked_add(other.allocated)?,
675 })
676 }
677}
678
679#[derive(Clone, Debug, PartialEq, Eq)]
685pub struct TreeRemainder {
686 pub files: Option<u64>,
688 pub bytes: Option<u64>,
690 pub allocated: Option<u64>,
692 pub ignored: Option<IgnoredSize>,
695 pub reasons: Vec<TreeOmissionReason>,
697}
698
699impl TreeRemainder {
700 pub fn from_tree(root: Option<&TreeNode>, omissions: &[TreeOmission]) -> Option<Self> {
702 let mut result = Self {
703 files: Some(0),
704 bytes: Some(0),
705 allocated: Some(0),
706 ignored: Some(IgnoredSize::default()),
707 reasons: Vec::new(),
708 };
709 let mut add = |omission: &TreeOmission| {
710 result.files = result.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
711 result.bytes = result.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
712 result.allocated =
713 result.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
714 result.ignored =
715 result.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
716 if !result.reasons.contains(&omission.reason) {
717 result.reasons.push(omission.reason);
718 }
719 };
720 let boundary = root.map_or(omissions, |root| root.omissions.as_slice());
723 for omission in boundary {
724 add(omission);
725 }
726 result.reasons.sort_by_key(|why| match why {
727 TreeOmissionReason::Share => 0,
728 TreeOmissionReason::Depth => 1,
729 TreeOmissionReason::Breadth => 2,
730 TreeOmissionReason::Rows => 3,
731 });
732 (!result.reasons.is_empty()).then_some(result)
733 }
734}
735
736#[derive(Clone, Debug, PartialEq, Eq)]
738pub struct TreeDisplayLimits {
739 pub depth: Bound,
741 pub min_share: ShareThreshold,
743 pub breadth: Bound,
745 pub rows: Bound,
747}
748
749impl Drop for TreeNode {
750 fn drop(&mut self) {
757 let mut pending = std::mem::take(&mut self.children);
758 while let Some(mut node) = pending.pop() {
759 pending.extend(std::mem::take(&mut node.children));
760 }
761 }
762}
763
764#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
771pub struct IgnoredTally {
772 pub files: u64,
774 pub dirs: u64,
776 pub bytes: u64,
778 pub allocated: u64,
780}
781
782impl IgnoredTally {
783 pub(crate) fn between(all: RollUpScalars, unignored: RollUpScalars) -> Self {
785 Self {
786 files: all.files.saturating_sub(unignored.files),
787 dirs: all.dirs.saturating_sub(unignored.dirs),
788 bytes: all.bytes.saturating_sub(unignored.bytes),
789 allocated: all.allocated.saturating_sub(unignored.allocated),
790 }
791 }
792
793 fn add(&mut self, other: Self) {
794 self.files = self.files.saturating_add(other.files);
795 self.dirs = self.dirs.saturating_add(other.dirs);
796 self.bytes = self.bytes.saturating_add(other.bytes);
797 self.allocated = self.allocated.saturating_add(other.allocated);
798 }
799}
800
801#[derive(Clone, Debug)]
803pub struct TypeRow {
804 pub extension: String,
806 pub files: u64,
808 pub bytes: u64,
810 pub allocated: u64,
812 pub ignored: Option<IgnoredTally>,
814}
815
816#[derive(Clone, Copy, PartialEq, Eq, Debug)]
818pub enum MetricGroup {
819 Type,
821 Family,
823}
824
825#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
827pub struct MetricShare {
828 pub numerator: u64,
830 pub denominator: u64,
832}
833
834#[derive(Clone, Copy, PartialEq, Eq, Debug)]
836pub enum ShareMetric {
837 ApparentBytes,
839 AllocatedBytes,
841 CodeLines,
843 DocumentWords,
845 RawWords,
847}
848
849impl ShareMetric {
850 pub const fn as_str(self) -> &'static str {
852 match self {
853 Self::ApparentBytes => "apparent_bytes",
854 Self::AllocatedBytes => "allocated_bytes",
855 Self::CodeLines => "code_lines",
856 Self::DocumentWords => "document_words",
857 Self::RawWords => "raw_words",
858 }
859 }
860}
861
862#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
864pub struct ReportMetricValues {
865 pub physical_lines: Option<u64>,
867 pub blank_lines: Option<u64>,
869 pub nonblank_lines: Option<u64>,
871 pub raw_words: Option<u64>,
873 pub code_lines: Option<u64>,
875 pub comment_lines: Option<u64>,
877 pub code_blank_lines: Option<u64>,
879 pub logical_words: Option<u64>,
881 pub paragraphs: Option<u64>,
883 pub visible_words: Option<u64>,
885 pub visible_logical_words: Option<u64>,
887 pub document_words: Option<u64>,
889}
890
891impl ReportMetricValues {
892 fn for_analysis(analysis: AnalysisSet) -> Self {
893 let lines = analysis.is_enabled().then_some(0);
894 let code = analysis.includes_code().then_some(0);
895 let words = analysis.includes_words().then_some(0);
896 Self {
897 physical_lines: lines,
898 blank_lines: lines,
899 nonblank_lines: lines,
900 raw_words: lines,
901 code_lines: code,
902 comment_lines: code,
903 code_blank_lines: code,
904 logical_words: words,
905 paragraphs: words,
906 visible_words: words,
907 visible_logical_words: words,
908 document_words: words,
909 }
910 }
911
912 fn add_assign(&mut self, other: &Self) {
913 add_optional(&mut self.physical_lines, other.physical_lines);
914 add_optional(&mut self.blank_lines, other.blank_lines);
915 add_optional(&mut self.nonblank_lines, other.nonblank_lines);
916 add_optional(&mut self.raw_words, other.raw_words);
917 add_optional(&mut self.code_lines, other.code_lines);
918 add_optional(&mut self.comment_lines, other.comment_lines);
919 add_optional(&mut self.code_blank_lines, other.code_blank_lines);
920 add_optional(&mut self.paragraphs, other.paragraphs);
921 add_optional(&mut self.visible_words, other.visible_words);
922 }
923}
924
925fn add_optional(total: &mut Option<u64>, value: Option<u64>) {
926 if let (Some(total), Some(value)) = (total, value) {
927 *total = total.saturating_add(value);
928 }
929}
930
931#[derive(Clone, Debug)]
933pub struct MetricRow {
934 pub analysis: AnalysisSet,
936 pub id: String,
938 pub family: ContentFamily,
940 pub files: u64,
942 pub bytes: u64,
944 pub allocated: u64,
946 pub analyzed_files: u64,
948 pub metrics: ReportMetricValues,
950 pub(crate) logical_word_stats: LogicalWordStats,
952 pub(crate) visible_logical_word_stats: LogicalWordStats,
954 pub document_raw_words: u64,
956 pub document_word_stats: LogicalWordStats,
958 pub document_metric_files: u64,
960 pub coverage: BTreeMap<CoverageReason, u64>,
962 pub lines_coverage: BTreeMap<CoverageReason, u64>,
964 pub code_coverage: Option<BTreeMap<CoverageReason, u64>>,
966 pub words_coverage: Option<BTreeMap<CoverageReason, u64>>,
968 pub detection_sources: BTreeMap<DetectionSource, u64>,
970 pub detection_confidence: BTreeMap<DetectionConfidence, u64>,
972 pub generated_files: u64,
974 pub vendored_files: u64,
976 pub documentation_files: u64,
978 pub share: MetricShare,
980}
981
982impl MetricRow {
983 pub fn metric_value(&self, metric: &MetricDef) -> Option<u64> {
985 if !self.analysis.contains(metric.owner) {
986 return None;
987 }
988 match metric.name {
989 "physical_lines" => self.metrics.physical_lines,
990 "blank_lines" => self.metrics.blank_lines,
991 "nonblank_lines" => self.metrics.nonblank_lines,
992 "raw_words" => self.metrics.raw_words,
993 "code_lines" => self.metrics.code_lines,
994 "comment_lines" => self.metrics.comment_lines,
995 "code_blank_lines" => self.metrics.code_blank_lines,
996 "logical_words" => self.metrics.logical_words,
997 "paragraphs" => self.metrics.paragraphs,
998 "visible_words" => self.metrics.visible_words,
999 "visible_logical_words" => self.metrics.visible_logical_words,
1000 "document_words" => self.metrics.document_words,
1001 _ => None,
1002 }
1003 }
1004
1005 fn finish_derived_metrics(&mut self) {
1006 if self.analysis.includes_words() {
1007 self.metrics.logical_words = Some(self.logical_word_stats.logical_words());
1008 self.metrics.visible_logical_words =
1009 Some(self.visible_logical_word_stats.logical_words());
1010 self.metrics.document_words = Some(self.document_word_stats.logical_words());
1011 }
1012 }
1013}
1014
1015#[derive(Clone, Debug)]
1017pub struct MetricSummary {
1018 pub group: MetricGroup,
1020 pub total: MetricRow,
1022 pub rows: Vec<MetricRow>,
1024 pub total_rows: usize,
1026 pub share_omitted: usize,
1028 pub share_metric: ShareMetric,
1030 pub words_per_page: u64,
1032}
1033
1034#[derive(Clone, Debug, Default)]
1036pub struct CodeTally {
1037 pub source_files: u64,
1039 pub analyzed_files: u64,
1041 pub metrics: CodeMetrics,
1043 pub coverage: BTreeMap<CoverageReason, u64>,
1045 pub missing_records: u64,
1047}
1048
1049impl CodeTally {
1050 fn add_file(&mut self, record: Option<&crate::content::FileAnalysis>) {
1051 self.source_files = self.source_files.saturating_add(1);
1052 match record.and_then(|record| record.code) {
1053 Some(outcome) => {
1054 *self.coverage.entry(outcome.coverage()).or_default() += 1;
1055 if let Some(value) = outcome.value() {
1056 self.analyzed_files = self.analyzed_files.saturating_add(1);
1057 self.metrics.code_lines =
1058 self.metrics.code_lines.saturating_add(value.code_lines);
1059 self.metrics.comment_lines =
1060 self.metrics.comment_lines.saturating_add(value.comment_lines);
1061 self.metrics.code_blank_lines =
1062 self.metrics.code_blank_lines.saturating_add(value.code_blank_lines);
1063 }
1064 }
1065 None => self.missing_records = self.missing_records.saturating_add(1),
1066 }
1067 }
1068}
1069
1070#[derive(Clone, Debug)]
1072pub struct CodeLanguageRow {
1073 pub language: String,
1075 pub selected: CodeTally,
1077 pub non_ignored: Option<CodeTally>,
1079 pub ignored: Option<CodeTally>,
1081 pub unknown: CodeTally,
1083 pub share: MetricShare,
1085}
1086
1087#[derive(Clone, Debug)]
1089pub struct CodeOverview {
1090 pub population: IgnoredEntries,
1092 pub selected: CodeTally,
1094 pub non_ignored: Option<CodeTally>,
1096 pub ignored: Option<CodeTally>,
1098 pub unknown: CodeTally,
1100 pub unclassified_files: u64,
1102 pub analyzed_languages: u64,
1104 pub total_languages: usize,
1106 pub share_omitted: usize,
1108 pub languages: Vec<CodeLanguageRow>,
1110 pub share_metric: ShareMetric,
1112}
1113
1114#[derive(Clone, Debug)]
1116pub struct ContentReportMetadata {
1117 pub profile: AnalysisSet,
1119 pub provenance: ContentProvenance,
1121}
1122
1123#[derive(Clone, Debug)]
1126pub struct FileRow {
1127 pub path: PathBuf,
1129 pub kind: EntryKind,
1131 pub bytes: u64,
1133 pub allocated: u64,
1135 pub mtime_ns: i64,
1137 pub files: Option<u64>,
1139 pub dirs: Option<u64>,
1141 pub complete: Option<bool>,
1145 pub age_ns: Option<i128>,
1148 pub ignored: Option<bool>,
1151 pub sort_value: Option<u64>,
1153 pub classification: Option<ContentDetection>,
1155}
1156
1157#[derive(Clone, Copy, Debug, Default)]
1159pub struct SummaryRow {
1160 pub files: u64,
1162 pub dirs: u64,
1164 pub bytes: u64,
1166 pub allocated: u64,
1168 pub ignored: Option<IgnoredTally>,
1171 pub newest_mtime_ns: Option<i64>,
1173}
1174
1175#[derive(Clone, Debug)]
1177pub enum Section {
1178 Code(Box<CodeOverview>),
1180 Tree {
1182 view: ViewSpec,
1184 limits: TreeDisplayLimits,
1186 root: Option<Box<TreeNode>>,
1188 omissions: Vec<TreeOmission>,
1190 },
1191 Extensions {
1193 rows: Vec<TypeRow>,
1195 total: usize,
1197 share_omitted: usize,
1199 },
1200 Metrics {
1202 view: ViewSpec,
1204 summary: Box<MetricSummary>,
1206 },
1207 Files {
1212 view: ViewSpec,
1214 rows: Vec<FileRow>,
1216 total: usize,
1222 },
1223 Summary(SummaryRow),
1225}
1226
1227impl Section {
1228 pub fn view(&self) -> ViewSpec {
1230 match self {
1231 Self::Code(_) => ViewSpec::Code,
1232 Self::Extensions { .. } => ViewSpec::Extensions,
1233 Self::Tree { view, .. } | Self::Metrics { view, .. } | Self::Files { view, .. } => {
1234 *view
1235 }
1236 Self::Summary(_) => ViewSpec::Summary,
1237 }
1238 }
1239}
1240
1241#[derive(Clone, Debug)]
1243pub struct Report {
1244 pub age_reference_ns: Option<i64>,
1246 pub format: crate::report_format::Format,
1248 pub status: TreeStatus,
1250 pub provenance: ReportProvenance,
1252 pub scope: ScanScope,
1257 pub requested_analysis: AnalysisSet,
1259 pub requested_views: Vec<ViewSpec>,
1261 pub omitted_views: Vec<ViewSpec>,
1263 pub root: PathBuf,
1265 pub notes: Vec<String>,
1272 pub tips: Vec<String>,
1274 pub axes: &'static AxisNames,
1276 pub size: SizeMetric,
1282 pub sort_metric: Option<&'static str>,
1284 pub analysis: Option<ContentReportMetadata>,
1286 pub ignored_entries: IgnoredEntries,
1292 pub ignore_rules: ControlCoverage,
1299 pub sections: Vec<Section>,
1301}
1302
1303pub(crate) fn display_notes(
1310 query: &Query,
1311 ignore_rules: &ControlCoverage,
1312) -> (Vec<String>, Vec<String>) {
1313 let mut notes = Vec::new();
1314 let mut tips = Vec::new();
1315 if !query.omitted_views.is_empty() {
1316 let names: Vec<&str> = query.omitted_views.iter().map(|view| view.label()).collect();
1317 notes.push(format!("note: omitted {}: content analysis required", names.join(", ")));
1318 let analysis = if query.omitted_views.contains(&ViewSpec::Code) { "code" } else { "lines" };
1319 tips.push(format!("tip: include omitted views: add {} {analysis}", query.axes.analyze));
1320 }
1321 if let Some((note, tip)) = refused_controls_note(ignore_rules, query.axes) {
1322 notes.push(note);
1323 tips.extend(tip);
1324 }
1325 (notes, tips)
1326}
1327
1328const REFUSED_DIRECTORIES_NAMED: usize = 5;
1330
1331fn refused_controls_note(
1340 ignore_rules: &ControlCoverage,
1341 axes: &AxisNames,
1342) -> Option<(String, Option<String>)> {
1343 use crate::control::ControlRefusalReason::{Budget, LineLimit};
1344
1345 let ControlCoverage::Observed(observed) = ignore_rules else {
1346 return None;
1347 };
1348 if observed.is_complete() {
1349 return None;
1350 }
1351 let every_listed = observed.lists_every_refusal();
1352 let listed =
1353 |reason| observed.refusals.iter().filter(|refusal| refusal.reason == reason).count();
1354 let fired: Vec<_> = [Budget, LineLimit]
1357 .into_iter()
1358 .filter(|reason| {
1359 listed(*reason) > 0 || (!every_listed && observed.limits.limit_for(*reason).is_some())
1360 })
1361 .collect();
1362 let size = |reason| {
1363 observed.limits.limit_for(reason).map(|bytes| {
1364 crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX))
1365 })
1366 };
1367 let over = |reason| {
1370 let (lead, noun) = match reason {
1371 Budget => ("over", "ignore-rule budget"),
1372 LineLimit => ("with a line over", "line limit"),
1373 };
1374 size(reason).map_or_else(
1375 || format!("{lead} the {noun}"),
1376 |size| format!("{lead} the {size} {noun}"),
1377 )
1378 };
1379 let why = if every_listed {
1380 let parts: Vec<String> =
1381 fired.iter().map(|reason| format!("{} {}", listed(*reason), over(*reason))).collect();
1382 parts.join(", ")
1383 } else {
1384 let parts: Vec<String> = fired.iter().map(|reason| over(*reason)).collect();
1385 parts.join(" or ")
1386 };
1387
1388 let shown = observed.refusals.len().min(REFUSED_DIRECTORIES_NAMED);
1389 let mut directories: Vec<String> = observed.refusals[..shown]
1390 .iter()
1391 .map(|refusal| match refusal.path.parent() {
1392 Some(parent) if !parent.as_os_str().is_empty() => parent.display().to_string(),
1393 _ => ".".to_string(),
1394 })
1395 .collect();
1396 let unnamed = observed.refused.saturating_sub(u64::try_from(shown).unwrap_or(u64::MAX));
1397 if unnamed > 0 {
1398 directories.push(format!("{} more", crate::report_format::human_count(unnamed)));
1399 }
1400
1401 let raises: Vec<String> = fired
1403 .iter()
1404 .filter_map(|reason| {
1405 let knob = match reason {
1406 Budget => axes.control_budget,
1407 LineLimit => axes.control_line_limit,
1408 };
1409 size(*reason).map(|size| format!("{knob} above {size}"))
1410 })
1411 .collect();
1412 let remedy = match raises.as_slice() {
1413 [] => None,
1414 [raise] => {
1415 Some(format!("tip: apply refused ignore files: raise {raise}, or set it to all"))
1416 }
1417 raises => Some(format!(
1418 "tip: apply refused ignore files: raise {}, or set them to all",
1419 raises.join(" and ")
1420 )),
1421 };
1422 let files = crate::report_format::human_count(observed.refused);
1423 let noun = if observed.refused == 1 { "file" } else { "files" };
1424 Some((
1425 format!(
1426 "note: ignore classification incomplete: {files} ignore {noun} not applied ({why}); affected: {}",
1427 directories.join(", ")
1428 ),
1429 remedy,
1430 ))
1431}
1432
1433pub fn report(
1447 index: &Index,
1448 request: &Request,
1449 generated_at: std::time::SystemTime,
1450) -> crate::Result<Report> {
1451 report_in(index, request, generated_at, NameIdentity::Native)
1452}
1453
1454pub(crate) fn report_in(
1459 index: &Index,
1460 request: &Request,
1461 generated_at: std::time::SystemTime,
1462 identity: NameIdentity,
1463) -> crate::Result<Report> {
1464 request.validate_read(&Basis::held_by(index)).map_err(crate::Error::InvalidRequest)?;
1468
1469 let query = &request.query;
1470 let content = request.basis.content;
1471 let needs_walk = query.needs_selection_walk();
1474 let needs_tree_measurements = query.views.iter().any(|view| query.tree_for(*view))
1475 && !query.min_share_for().admits(0, 1)
1476 && (index.state().coverage != crate::Coverage::Complete
1477 || index.scope().max_depth.is_some());
1478 let directories = (needs_tree_measurements
1479 || (needs_walk
1480 && (query.selection.kinds.is_empty()
1481 || query.selection.kinds.contains(&EntryKind::Dir))))
1482 .then(|| query_subtrees::measure(index, &query.selection, identity));
1483 let walked = needs_walk.then(|| walk(index, &query.selection, identity, directories.as_ref()));
1485 let tree_measurements = directories.as_ref().filter(|_| needs_tree_measurements);
1486 let row_consumers =
1491 query.views.iter().copied().filter(|view| needs_unfiltered_entry_rows(*view)).count();
1492 let unfiltered_rows = (walked.is_none() && row_consumers > 1).then(|| every_entry(index));
1493 let metric_consumers =
1494 query.views.iter().copied().filter(|view| needs_metric_resolution(*view)).count();
1495 let mut shared_metric_summaries = (walked.is_none() && metric_consumers > 1).then(|| {
1498 metric_summaries(
1499 &query.views,
1500 index,
1501 query,
1502 content,
1503 unfiltered_rows
1504 .as_deref()
1505 .expect("multiple metric views share their unfiltered entry rows"),
1506 )
1507 });
1508
1509 let mut sections: Vec<Section> = query
1510 .views
1511 .iter()
1512 .enumerate()
1513 .map(|(position, view)| {
1514 let shared_metric_summary =
1515 shared_metric_summaries.as_mut().and_then(|summaries| summaries[position].take());
1516 if let Some(summary) = shared_metric_summary {
1517 return Section::Metrics { view: *view, summary: Box::new(summary) };
1518 }
1519 build_section(
1520 *view,
1521 index,
1522 query,
1523 content,
1524 walked.as_ref(),
1525 unfiltered_rows.as_deref(),
1526 tree_measurements,
1527 )
1528 })
1529 .collect();
1530
1531 let age_reference_ns = crate::query::system_time_to_nanos(request.now);
1532 for section in &mut sections {
1533 if let Section::Files { rows, .. } = section {
1534 for row in rows {
1535 row.age_ns = match row.complete {
1539 Some(false) => None,
1540 Some(true) | None => {
1541 age_reference_ns.map(|now| i128::from(now) - i128::from(row.mtime_ns))
1542 }
1543 };
1544 }
1545 }
1546 }
1547 let ignore_rules = index.control_coverage();
1548 let (mut notes, mut tips) = display_notes(query, &ignore_rules);
1549 if content.is_enabled()
1550 && query.selection.sort.is_none_or(|sort| !matches!(sort, SortKey::Metric(_)))
1551 && !query.views.iter().any(|view| {
1552 matches!(
1553 view,
1554 ViewSpec::Types
1555 | ViewSpec::Families
1556 | ViewSpec::Languages
1557 | ViewSpec::Code
1558 | ViewSpec::Documents
1559 )
1560 })
1561 {
1562 notes.push("note: requested analysis is not displayed by the selected views".to_owned());
1563 tips.push(format!("tip: show analysis: {} families, languages, or full", query.axes.view));
1564 }
1565 tips.extend(retained_refusals_tip(query, &ignore_rules));
1566 if tree_measurements.is_some_and(|values| values.values().any(|value| !value.complete)) {
1567 notes.push("note: incomplete subtrees remain visible below the size threshold".to_owned());
1568 }
1569 if index.observes_controls() && !index.ignored_classification_complete_below(Path::new("")) {
1570 notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
1571 }
1572 Ok(Report {
1573 age_reference_ns,
1574 format: query.format,
1575 notes,
1576 tips,
1577 axes: query.axes,
1578 status: TreeStatus::of(index, request),
1579 provenance: ReportProvenance::of(index, content, generated_at),
1580 scope: index.scope(),
1581 requested_analysis: content,
1582 requested_views: query.views.clone(),
1583 omitted_views: query.omitted_views.clone(),
1584 root: index.root_path().to_path_buf(),
1585 size: query.selection.size,
1586 sort_metric: match query.selection.sort {
1587 Some(SortKey::Metric(name)) => Some(name),
1588 _ => None,
1589 },
1590 analysis: index.content().and_then(|held| {
1591 let wanted = index.content_identity(content);
1592 let projected = held.admit(&wanted)?;
1593 Some(ContentReportMetadata {
1594 profile: projected.identity().analysis,
1595 provenance: projected.identity().record_provenance(),
1596 })
1597 }),
1598 ignored_entries: query.selection.ignored,
1599 ignore_rules,
1600 sections,
1601 })
1602}
1603
1604const UNVERIFIED_IGNORED_NOTE: &str =
1606 "note: gitignored subtotals are unavailable where governing rules could not be verified";
1607
1608fn retained_refusals_tip(query: &Query, ignore_rules: &ControlCoverage) -> Option<String> {
1611 matches!(ignore_rules, ControlCoverage::Observed(observed) if observed.refusals.len() > REFUSED_DIRECTORIES_NAMED)
1612 .then(|| format!("tip: show retained ignore-file details: {} json", query.axes.format))
1613}
1614
1615#[allow(clippy::too_many_arguments)]
1626pub(crate) fn report_summary(
1627 root: &Path,
1628 scope: ScanScope,
1629 request: &Request,
1630 summary: SummaryRow,
1631 ignore_rules: ControlCoverage,
1632 ignored_unverified: bool,
1633 status: TreeStatus,
1634 provenance: ReportProvenance,
1635) -> Report {
1636 let query = &request.query;
1637 let (mut notes, mut tips) = display_notes(query, &ignore_rules);
1638 tips.extend(retained_refusals_tip(query, &ignore_rules));
1639 if ignored_unverified {
1640 notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
1641 }
1642 Report {
1643 age_reference_ns: crate::query::system_time_to_nanos(request.now),
1644 format: query.format,
1645 notes,
1646 tips,
1647 axes: query.axes,
1648 status,
1649 provenance,
1650 scope,
1651 requested_analysis: AnalysisSet::NONE,
1652 requested_views: query.views.clone(),
1653 omitted_views: query.omitted_views.clone(),
1654 root: root.to_path_buf(),
1655 size: query.selection.size,
1656 sort_metric: match query.selection.sort {
1657 Some(SortKey::Metric(name)) => Some(name),
1658 _ => None,
1659 },
1660 analysis: None,
1663 ignored_entries: query.selection.ignored,
1664 ignore_rules,
1665 sections: vec![Section::Summary(summary)],
1666 }
1667}
1668
1669struct Walked {
1671 observed: bool,
1673 per_directory: BTreeMap<EntryId, SummaryRow>,
1678 by_ext: BTreeMap<String, ExtTally>,
1680 ignored_by_ext: BTreeMap<String, ExtTally>,
1682 rows: Vec<FileRow>,
1684 members: Vec<FileRow>,
1686 visible: BTreeSet<EntryId>,
1688 visible_files: BTreeSet<EntryId>,
1689 unknown_ignored: BTreeSet<EntryId>,
1691}
1692
1693impl Walked {
1694 fn summary_of(&self, id: EntryId) -> SummaryRow {
1696 let mut row = self.per_directory.get(&id).copied().unwrap_or_default();
1697 row.ignored = (self.observed && !self.unknown_ignored.contains(&id))
1698 .then(|| row.ignored.unwrap_or_default());
1699 row
1700 }
1701}
1702
1703fn unfiltered_summary(index: &Index, id: EntryId, path: &Path) -> SummaryRow {
1706 let observed = index.observes_controls() && index.ignored_classification_complete_below(path);
1707 let Some((all, unignored)) = index.partition_scalars_of(id) else {
1708 return SummaryRow {
1709 ignored: observed.then(IgnoredTally::default),
1710 ..SummaryRow::default()
1711 };
1712 };
1713 SummaryRow {
1714 ignored: observed.then(|| IgnoredTally::between(all, unignored)),
1715 ..summary_from_scalars(all)
1716 }
1717}
1718
1719fn walk(
1724 index: &Index,
1725 selection: &Selection,
1726 identity: NameIdentity,
1727 directories: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
1728) -> Walked {
1729 let observed = index.observes_controls();
1730 let mut walked = Walked {
1731 observed,
1732 per_directory: BTreeMap::new(),
1733 by_ext: BTreeMap::new(),
1734 ignored_by_ext: BTreeMap::new(),
1735 rows: Vec::new(),
1736 members: Vec::new(),
1737 visible: BTreeSet::new(),
1738 visible_files: BTreeSet::new(),
1739 unknown_ignored: BTreeSet::new(),
1740 };
1741 debug_assert!(
1744 observed || selection.ignored == IgnoredEntries::Include,
1745 "a selection by ignored state over an unobserving index is refused before the walk"
1746 );
1747
1748 let mut stack = vec![(EntryId::ROOT, PathBuf::new(), false, false)];
1750 while let Some((id, path, expanded, covered)) = stack.pop() {
1751 if expanded {
1752 let mut total = walked.per_directory.remove(&id).unwrap_or_default();
1757 if let Some(children) = index.children_of(id) {
1758 for (_, child) in children {
1759 if let Some(sub) = walked.per_directory.get(&child) {
1760 let sub = *sub;
1761 merge_summary(&mut total, &sub);
1762 if walked.unknown_ignored.contains(&child) {
1763 walked.unknown_ignored.insert(id);
1764 }
1765 }
1766 }
1767 }
1768 if total.files > 0 || total.dirs > 0 {
1769 walked.visible.insert(id);
1770 }
1771 walked.per_directory.insert(id, total);
1772 continue;
1773 }
1774
1775 stack.push((id, path.clone(), true, covered));
1776 let Some(children) = index.children_of(id) else {
1777 continue;
1778 };
1779 let children: Vec<(PathBuf, EntryId)> =
1780 children.map(|(name, child)| (path.join(name), child)).collect();
1781
1782 for (child_path, child) in children {
1783 let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
1784 continue;
1785 };
1786 let file_name = child_path.file_name().unwrap_or_default();
1790 let classification = index.ignored_classification_of(&child_path, child);
1791 let ignored = classification.unwrap_or(false);
1792 let classification_admitted =
1793 classification.is_some() || selection.ignored == IgnoredEntries::Include;
1794 let mut measured = *attrs;
1795 let subtree = directories.and_then(|values| values.get(&child)).copied();
1796 if let Some(subtree) = subtree {
1797 measured.size = subtree.bytes;
1798 measured.allocated = subtree.allocated;
1799 measured.mtime_ns = subtree.mtime_ns;
1800 }
1801 let (pruned, matches) = query_subtrees::with_candidate(
1802 &child_path,
1803 kind,
1804 measured,
1805 ignored,
1806 identity,
1807 |candidate| {
1808 (query_subtrees::pruned(selection, &candidate), selection.admits(&candidate))
1809 },
1810 );
1811 if pruned {
1812 continue;
1813 }
1814 let matches = matches
1821 && classification_admitted
1822 && (selection.modified.is_unbounded()
1823 || subtree.is_none_or(|subtree| subtree.complete));
1824 let row = FileRow {
1825 path: child_path.clone(),
1826 kind,
1827 bytes: measured.size,
1828 allocated: measured.allocated,
1829 mtime_ns: measured.mtime_ns,
1830 files: subtree.map(|subtree| subtree.files),
1831 dirs: subtree.map(|subtree| subtree.dirs),
1832 complete: subtree.map(|subtree| subtree.complete),
1833 age_ns: None,
1834 ignored: classification,
1835 sort_value: None,
1836 classification: None,
1837 };
1838 if matches {
1839 walked.rows.push(row.clone());
1840 }
1841 if matches || (covered && classification_admitted && selection.ignored.admits(ignored))
1842 {
1843 if classification.is_none() {
1844 walked.unknown_ignored.insert(id);
1845 }
1846 if kind == EntryKind::File {
1847 walked.members.push(row);
1848 walked.visible_files.insert(child);
1849 } else if kind == EntryKind::Dir {
1850 walked.visible.insert(child);
1851 }
1852
1853 if kind == EntryKind::File {
1854 let own = walked.per_directory.entry(id).or_default();
1855 own.files += 1;
1856 own.bytes += attrs.size;
1857 own.allocated += attrs.allocated;
1858 own.newest_mtime_ns = Some(
1859 own.newest_mtime_ns.map_or(attrs.mtime_ns, |seen| seen.max(attrs.mtime_ns)),
1860 );
1861 let bucket = crate::classify::ext_bucket(file_name);
1862 if ignored {
1863 own.ignored.get_or_insert_with(IgnoredTally::default).add(IgnoredTally {
1864 files: 1,
1865 dirs: 0,
1866 bytes: attrs.size,
1867 allocated: attrs.allocated,
1868 });
1869 let tally = walked.ignored_by_ext.entry(bucket.clone()).or_default();
1870 tally.files += 1;
1871 tally.bytes += attrs.size;
1872 tally.allocated += attrs.allocated;
1873 }
1874 let tally = walked.by_ext.entry(bucket).or_default();
1875 tally.files += 1;
1876 tally.bytes += attrs.size;
1877 tally.allocated += attrs.allocated;
1878 } else if kind == EntryKind::Dir {
1879 let own = walked.per_directory.entry(id).or_default();
1885 own.dirs += 1;
1886 if ignored {
1887 own.ignored.get_or_insert_with(IgnoredTally::default).dirs += 1;
1888 }
1889 }
1890 }
1891
1892 if kind == EntryKind::Dir {
1893 stack.push((child, child_path, false, covered || matches));
1894 }
1895 }
1896 }
1897
1898 walked
1899}
1900
1901fn merge_summary(into: &mut SummaryRow, from: &SummaryRow) {
1903 into.files += from.files;
1904 into.dirs += from.dirs;
1905 into.bytes += from.bytes;
1906 into.allocated += from.allocated;
1907 into.newest_mtime_ns = match (into.newest_mtime_ns, from.newest_mtime_ns) {
1908 (Some(left), Some(right)) => Some(left.max(right)),
1909 (left, right) => left.or(right),
1910 };
1911 if let Some(share) = from.ignored {
1912 into.ignored.get_or_insert_with(IgnoredTally::default).add(share);
1913 }
1914}
1915
1916fn needs_unfiltered_entry_rows(view: ViewSpec) -> bool {
1918 matches!(
1919 view,
1920 ViewSpec::Types
1921 | ViewSpec::Families
1922 | ViewSpec::Languages
1923 | ViewSpec::Code
1924 | ViewSpec::Documents
1925 | ViewSpec::Files
1926 | ViewSpec::Largest
1927 | ViewSpec::Recent
1928 )
1929}
1930
1931fn needs_metric_resolution(view: ViewSpec) -> bool {
1933 matches!(view, ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents)
1934}
1935
1936fn entry_rows<'a>(
1939 index: &Index,
1940 walked: Option<&'a Walked>,
1941 unfiltered_rows: Option<&'a [FileRow]>,
1942) -> Cow<'a, [FileRow]> {
1943 match (walked, unfiltered_rows) {
1944 (Some(walked), _) => Cow::Borrowed(&walked.rows),
1945 (None, Some(rows)) => Cow::Borrowed(rows),
1946 (None, None) => Cow::Owned(every_entry(index)),
1947 }
1948}
1949
1950fn build_section(
1952 view: ViewSpec,
1953 index: &Index,
1954 query: &Query,
1955 content: AnalysisSet,
1956 walked: Option<&Walked>,
1957 unfiltered_rows: Option<&[FileRow]>,
1958 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
1959) -> Section {
1960 if query.tree_for(view) {
1961 let (root, omissions) = tree_node(index, query, content, walked, tree_measurements);
1962 let limits = TreeDisplayLimits {
1963 depth: query.depth_for(view),
1964 min_share: query.min_share_for(),
1965 breadth: query.breadth_for(),
1966 rows: query.limit_for(view),
1967 };
1968 return Section::Tree { view, limits, root: root.map(Box::new), omissions };
1969 }
1970 match view {
1971 ViewSpec::Code => {
1972 Section::Code(Box::new(code_overview(index, query, content, walked, unfiltered_rows)))
1973 }
1974 ViewSpec::Summary => Section::Summary(match walked {
1975 None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
1976 Some(walked) => walked.summary_of(EntryId::ROOT),
1977 }),
1978 ViewSpec::Extensions => {
1979 let (rows, total, share_omitted) = extension_rows(index, query, walked);
1980 Section::Extensions { rows, total, share_omitted }
1981 }
1982 ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents => {
1983 Section::Metrics {
1984 view,
1985 summary: Box::new(metric_summary(
1986 view,
1987 index,
1988 query,
1989 content,
1990 walked,
1991 unfiltered_rows,
1992 )),
1993 }
1994 }
1995 ViewSpec::List
1996 | ViewSpec::Tree
1997 | ViewSpec::Files
1998 | ViewSpec::Largest
1999 | ViewSpec::Recent => {
2000 let (rows, total) = file_rows(view, index, query, content, walked, unfiltered_rows);
2001 Section::Files { view, rows, total }
2002 }
2003 }
2004}
2005
2006fn summary_from_scalars(rollup: RollUpScalars) -> SummaryRow {
2008 SummaryRow {
2009 files: rollup.files,
2010 dirs: rollup.dirs,
2011 bytes: rollup.bytes,
2012 allocated: rollup.allocated,
2013 ignored: None,
2014 newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
2015 }
2016}
2017
2018fn ignored_by_extension(
2020 all: &BTreeMap<String, ExtTally>,
2021 unignored: &BTreeMap<String, ExtTally>,
2022) -> BTreeMap<String, ExtTally> {
2023 all.iter()
2024 .filter_map(|(extension, tally)| {
2025 let kept = unignored.get(extension).copied().unwrap_or_default();
2026 let ignored = ExtTally {
2027 files: tally.files.saturating_sub(kept.files),
2028 bytes: tally.bytes.saturating_sub(kept.bytes),
2029 allocated: tally.allocated.saturating_sub(kept.allocated),
2030 };
2031 (ignored.files > 0).then(|| (extension.clone(), ignored))
2032 })
2033 .collect()
2034}
2035
2036fn extension_rows(
2038 index: &Index,
2039 query: &Query,
2040 walked: Option<&Walked>,
2041) -> (Vec<TypeRow>, usize, usize) {
2042 let observed = match walked {
2045 Some(walked) => walked.observed && !walked.unknown_ignored.contains(&EntryId::ROOT),
2046 None => {
2047 index.observes_controls() && index.ignored_classification_complete_below(Path::new(""))
2048 }
2049 };
2050 let (tallies, ignored): (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>) = match walked
2051 {
2052 None => match index.partition_total() {
2053 Ok(partitions) => {
2054 let ignored =
2055 ignored_by_extension(&partitions.all.by_ext, &partitions.unignored.by_ext);
2056 (partitions.all.by_ext, ignored)
2057 }
2058 Err(_not_observed) => (index.total().by_ext, BTreeMap::new()),
2061 },
2062 Some(walked) => (walked.by_ext.clone(), walked.ignored_by_ext.clone()),
2063 };
2064
2065 let mut rows: Vec<TypeRow> = tallies
2066 .into_iter()
2067 .map(|(extension, tally)| {
2068 let share = ignored.get(&extension).copied().unwrap_or_default();
2069 TypeRow {
2070 files: tally.files,
2071 bytes: tally.bytes,
2072 allocated: tally.allocated,
2073 ignored: observed.then_some(IgnoredTally {
2074 files: share.files,
2075 dirs: 0,
2076 bytes: share.bytes,
2077 allocated: share.allocated,
2078 }),
2079 extension,
2080 }
2081 })
2082 .collect();
2083
2084 let before_share = rows.len();
2085 if let Some(threshold) = &query.selection.min_share {
2086 let root = match walked {
2087 None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2088 Some(walked) => walked.summary_of(EntryId::ROOT),
2089 };
2090 let denominator = match query.selection.size {
2091 SizeMetric::Apparent => root.bytes,
2092 SizeMetric::Allocated => root.allocated,
2093 };
2094 rows.retain(|row| {
2095 threshold.admits(
2096 match query.selection.size {
2097 SizeMetric::Apparent => row.bytes,
2098 SizeMetric::Allocated => row.allocated,
2099 },
2100 denominator,
2101 )
2102 });
2103 }
2104
2105 sort_rows(
2106 &mut rows,
2107 query,
2108 ViewSpec::Extensions,
2109 SortAccessors {
2110 size: |row: &TypeRow, metric| match metric {
2111 SizeMetric::Apparent => row.bytes,
2112 SizeMetric::Allocated => row.allocated,
2113 },
2114 count: |row: &TypeRow| row.files,
2115 mtime: |_: &TypeRow| None,
2116 name: |row: &TypeRow| row.extension.clone(),
2117 content_metric: |_: &TypeRow, _: &MetricDef| None,
2118 },
2119 );
2120 let total = truncate(&mut rows, query.limit_for(ViewSpec::Extensions));
2121 (rows, total, before_share - total)
2122}
2123
2124fn metric_summary(
2125 view: ViewSpec,
2126 index: &Index,
2127 query: &Query,
2128 content: AnalysisSet,
2129 walked: Option<&Walked>,
2130 unfiltered_rows: Option<&[FileRow]>,
2131) -> MetricSummary {
2132 let mut accumulator = MetricAccumulator::new(view);
2133 let files = walked.map_or_else(
2134 || entry_rows(index, None, unfiltered_rows),
2135 |walked| Cow::Borrowed(walked.members.as_slice()),
2136 );
2137 let wanted = index.content_identity(content);
2138 let held = index.content().and_then(|held| held.admit(&wanted));
2139 for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2140 let cached = held.and_then(|content| content.file(&file.path));
2141 let classification = index.classify(&file.path);
2142 accumulator.push(content, file, cached, &classification);
2143 }
2144 accumulator.finish(query, content)
2145}
2146
2147fn metric_summaries(
2149 views: &[ViewSpec],
2150 index: &Index,
2151 query: &Query,
2152 content: AnalysisSet,
2153 rows: &[FileRow],
2154) -> Vec<Option<MetricSummary>> {
2155 let mut accumulators = views
2156 .iter()
2157 .copied()
2158 .filter(|view| needs_metric_resolution(*view))
2159 .map(MetricAccumulator::new)
2160 .collect::<Vec<_>>();
2161 let wanted = index.content_identity(content);
2162 let held = index.content().and_then(|held| held.admit(&wanted));
2163 for file in rows.iter().filter(|row| row.kind == EntryKind::File) {
2164 let cached = held.and_then(|content| content.file(&file.path));
2165 let classification = index.classify(&file.path);
2166 for accumulator in &mut accumulators {
2167 accumulator.push(content, file, cached, &classification);
2168 }
2169 }
2170
2171 let mut finished =
2172 accumulators.into_iter().map(|accumulator| accumulator.finish(query, content));
2173 let summaries = views
2174 .iter()
2175 .map(|view| needs_metric_resolution(*view).then(|| finished.next().expect("metric view")))
2176 .collect();
2177 debug_assert!(finished.next().is_none());
2178 summaries
2179}
2180
2181struct MetricAccumulator {
2182 view: ViewSpec,
2183 group: MetricGroup,
2184 grouped: BTreeMap<String, MetricRow>,
2185}
2186
2187impl MetricAccumulator {
2188 fn new(view: ViewSpec) -> Self {
2189 debug_assert!(needs_metric_resolution(view));
2190 let group =
2191 if view == ViewSpec::Families { MetricGroup::Family } else { MetricGroup::Type };
2192 Self { view, group, grouped: BTreeMap::new() }
2193 }
2194
2195 fn push(
2196 &mut self,
2197 content: AnalysisSet,
2198 file: &FileRow,
2199 cached: Option<&FileAnalysis>,
2200 classification: &Classification,
2201 ) {
2202 let included = match self.view {
2203 ViewSpec::Languages => classification.family == ContentFamily::Code,
2204 ViewSpec::Documents => {
2205 matches!(classification.family, ContentFamily::Prose | ContentFamily::Markup)
2206 }
2207 ViewSpec::Types | ViewSpec::Families => true,
2208 ViewSpec::List
2209 | ViewSpec::Tree
2210 | ViewSpec::Extensions
2211 | ViewSpec::Code
2212 | ViewSpec::Files
2213 | ViewSpec::Largest
2214 | ViewSpec::Recent
2215 | ViewSpec::Summary => false,
2216 };
2217 if !included {
2218 return;
2219 }
2220 let id = match self.group {
2221 MetricGroup::Type => classification.file_type.as_str().to_string(),
2222 MetricGroup::Family => classification.family.as_str().to_string(),
2223 };
2224 let row = self.grouped.entry(id.clone()).or_insert_with(|| MetricRow {
2225 analysis: content,
2226 id,
2227 family: classification.family,
2228 files: 0,
2229 bytes: 0,
2230 allocated: 0,
2231 analyzed_files: 0,
2232 metrics: ReportMetricValues::for_analysis(content),
2233 logical_word_stats: LogicalWordStats::default(),
2234 visible_logical_word_stats: LogicalWordStats::default(),
2235 document_raw_words: 0,
2236 document_word_stats: LogicalWordStats::default(),
2237 document_metric_files: 0,
2238 coverage: BTreeMap::new(),
2239 lines_coverage: BTreeMap::new(),
2240 code_coverage: content.includes_code().then(BTreeMap::new),
2241 words_coverage: content.includes_words().then(BTreeMap::new),
2242 detection_sources: BTreeMap::new(),
2243 detection_confidence: BTreeMap::new(),
2244 generated_files: 0,
2245 vendored_files: 0,
2246 documentation_files: 0,
2247 share: MetricShare::default(),
2248 });
2249 row.files = row.files.saturating_add(1);
2250 row.bytes = row.bytes.saturating_add(file.bytes);
2251 row.allocated = row.allocated.saturating_add(file.allocated);
2252 let detection = cached.map_or(
2253 (classification.source, classification.confidence, classification.flags),
2254 |record| (record.detection.source, record.detection.confidence, record.detection.flags),
2255 );
2256 *row.detection_sources.entry(detection.0).or_default() += 1;
2257 *row.detection_confidence.entry(detection.1).or_default() += 1;
2258 row.generated_files = row.generated_files.saturating_add(u64::from(detection.2.generated));
2259 row.vendored_files = row.vendored_files.saturating_add(u64::from(detection.2.vendored));
2260 row.documentation_files =
2261 row.documentation_files.saturating_add(u64::from(detection.2.documentation));
2262 if let Some(record) = cached {
2263 *row.lines_coverage.entry(record.lines.coverage()).or_default() += 1;
2264 if let (Some(coverage), Some(outcome)) = (&mut row.code_coverage, record.code) {
2265 *coverage.entry(outcome.coverage()).or_default() += 1;
2266 }
2267 if let (Some(coverage), Some(outcome)) = (&mut row.words_coverage, record.words) {
2268 *coverage.entry(outcome.coverage()).or_default() += 1;
2269 }
2270 let selected = match self.view {
2271 ViewSpec::Languages if content.includes_code() => {
2272 record.code.map(|outcome| outcome.coverage())
2273 }
2274 ViewSpec::Documents if content.includes_words() => {
2275 record.words.map(|outcome| outcome.coverage())
2276 }
2277 _ => None,
2278 }
2279 .unwrap_or(record.lines.coverage());
2280 *row.coverage.entry(selected).or_default() += 1;
2281 if selected == CoverageReason::Analyzed {
2282 row.analyzed_files = row.analyzed_files.saturating_add(1);
2283 }
2284 if let Some(lines) = record.lines.value() {
2285 add_optional(&mut row.metrics.physical_lines, Some(lines.physical_lines));
2286 add_optional(&mut row.metrics.blank_lines, Some(lines.blank_lines));
2287 add_optional(&mut row.metrics.nonblank_lines, Some(lines.nonblank_lines));
2288 add_optional(&mut row.metrics.raw_words, Some(lines.raw_words));
2289 row.document_raw_words = row.document_raw_words.saturating_add(lines.raw_words);
2290 }
2291 if let Some(code_metrics) = record.code.and_then(crate::content::AnalyzerOutcome::value)
2292 {
2293 add_optional(&mut row.metrics.code_lines, Some(code_metrics.code_lines));
2294 add_optional(&mut row.metrics.comment_lines, Some(code_metrics.comment_lines));
2295 add_optional(
2296 &mut row.metrics.code_blank_lines,
2297 Some(code_metrics.code_blank_lines),
2298 );
2299 }
2300 if let Some(words) = record.words.and_then(crate::content::AnalyzerOutcome::value) {
2301 add_optional(&mut row.metrics.paragraphs, Some(words.paragraphs));
2302 add_optional(&mut row.metrics.visible_words, Some(words.visible_words));
2303 row.logical_word_stats.add_assign(words.logical_word_stats);
2304 row.visible_logical_word_stats.add_assign(words.visible_logical_word_stats);
2305 row.document_metric_files = row.document_metric_files.saturating_add(1);
2306 if classification.file_type.as_str() == "markdown" {
2307 row.document_raw_words = row
2308 .document_raw_words
2309 .saturating_sub(record.lines.value().map_or(0, |lines| lines.raw_words));
2310 row.document_raw_words =
2311 row.document_raw_words.saturating_add(words.visible_words);
2312 row.document_word_stats.add_assign(words.visible_logical_word_stats);
2313 } else {
2314 row.document_word_stats.add_assign(words.logical_word_stats);
2315 }
2316 }
2317 }
2318 }
2319
2320 fn finish(self, query: &Query, content: AnalysisSet) -> MetricSummary {
2321 let Self { view, group, mut grouped } = self;
2322
2323 for row in grouped.values_mut() {
2324 row.finish_derived_metrics();
2325 }
2326
2327 let mut total = MetricRow {
2328 analysis: content,
2329 id: "total".to_string(),
2330 family: ContentFamily::Unknown,
2331 files: 0,
2332 bytes: 0,
2333 allocated: 0,
2334 analyzed_files: 0,
2335 metrics: ReportMetricValues::for_analysis(content),
2336 logical_word_stats: LogicalWordStats::default(),
2337 visible_logical_word_stats: LogicalWordStats::default(),
2338 document_raw_words: 0,
2339 document_word_stats: LogicalWordStats::default(),
2340 document_metric_files: 0,
2341 coverage: BTreeMap::new(),
2342 lines_coverage: BTreeMap::new(),
2343 code_coverage: content.includes_code().then(BTreeMap::new),
2344 words_coverage: content.includes_words().then(BTreeMap::new),
2345 detection_sources: BTreeMap::new(),
2346 detection_confidence: BTreeMap::new(),
2347 generated_files: 0,
2348 vendored_files: 0,
2349 documentation_files: 0,
2350 share: MetricShare::default(),
2351 };
2352 for row in grouped.values() {
2353 total.files = total.files.saturating_add(row.files);
2354 total.bytes = total.bytes.saturating_add(row.bytes);
2355 total.allocated = total.allocated.saturating_add(row.allocated);
2356 total.analyzed_files = total.analyzed_files.saturating_add(row.analyzed_files);
2357 total.metrics.add_assign(&row.metrics);
2358 total.logical_word_stats.add_assign(row.logical_word_stats);
2359 total.visible_logical_word_stats.add_assign(row.visible_logical_word_stats);
2360 total.document_raw_words =
2361 total.document_raw_words.saturating_add(row.document_raw_words);
2362 total.document_word_stats.add_assign(row.document_word_stats);
2363 total.document_metric_files =
2364 total.document_metric_files.saturating_add(row.document_metric_files);
2365 for (reason, count) in &row.coverage {
2366 *total.coverage.entry(*reason).or_default() += count;
2367 }
2368 merge_coverage(&mut total.lines_coverage, &row.lines_coverage);
2369 if let (Some(total), Some(row)) = (&mut total.code_coverage, &row.code_coverage) {
2370 merge_coverage(total, row);
2371 }
2372 if let (Some(total), Some(row)) = (&mut total.words_coverage, &row.words_coverage) {
2373 merge_coverage(total, row);
2374 }
2375 for (source, count) in &row.detection_sources {
2376 *total.detection_sources.entry(*source).or_default() += count;
2377 }
2378 for (confidence, count) in &row.detection_confidence {
2379 *total.detection_confidence.entry(*confidence).or_default() += count;
2380 }
2381 total.generated_files = total.generated_files.saturating_add(row.generated_files);
2382 total.vendored_files = total.vendored_files.saturating_add(row.vendored_files);
2383 total.documentation_files =
2384 total.documentation_files.saturating_add(row.documentation_files);
2385 }
2386 total.finish_derived_metrics();
2387 let byte_share_metric = match query.selection.size {
2388 SizeMetric::Apparent => ShareMetric::ApparentBytes,
2389 SizeMetric::Allocated => ShareMetric::AllocatedBytes,
2390 };
2391 let share_metric = match view {
2392 ViewSpec::Languages if content.includes_code() => ShareMetric::CodeLines,
2395 ViewSpec::Documents if content.includes_words() => ShareMetric::DocumentWords,
2396 ViewSpec::Languages | ViewSpec::Documents if content.is_enabled() => {
2397 ShareMetric::RawWords
2398 }
2399 ViewSpec::Languages | ViewSpec::Documents | ViewSpec::Types | ViewSpec::Families => {
2400 byte_share_metric
2401 }
2402 ViewSpec::List
2403 | ViewSpec::Tree
2404 | ViewSpec::Extensions
2405 | ViewSpec::Code
2406 | ViewSpec::Files
2407 | ViewSpec::Largest
2408 | ViewSpec::Recent
2409 | ViewSpec::Summary => {
2410 unreachable!("only grouped views reach metric_summary")
2411 }
2412 };
2413 let denominator = share_value(&total, share_metric);
2414 total.share = MetricShare { numerator: denominator, denominator };
2415 let mut rows = grouped.into_values().collect::<Vec<_>>();
2416 for row in &mut rows {
2417 row.share = MetricShare { numerator: share_value(row, share_metric), denominator };
2418 }
2419 sort_rows(
2420 &mut rows,
2421 query,
2422 view,
2423 SortAccessors {
2424 size: |row: &MetricRow, metric| match view {
2425 ViewSpec::Languages | ViewSpec::Documents => share_value(row, share_metric),
2426 _ => match metric {
2427 SizeMetric::Apparent => row.bytes,
2428 SizeMetric::Allocated => row.allocated,
2429 },
2430 },
2431 count: |row: &MetricRow| row.files,
2432 mtime: |_: &MetricRow| None,
2433 name: |row: &MetricRow| row.id.clone(),
2434 content_metric: MetricRow::metric_value,
2435 },
2436 );
2437 let before_share = rows.len();
2438 if let Some(threshold) = &query.selection.min_share {
2439 rows.retain(|row| threshold.admits(row.share.numerator, row.share.denominator));
2440 }
2441 let share_omitted = before_share - rows.len();
2442 let total_rows = truncate(&mut rows, query.limit_for(view));
2443 MetricSummary {
2444 group,
2445 total,
2446 rows,
2447 total_rows,
2448 share_omitted,
2449 share_metric,
2450 words_per_page: query.words_per_page.max(1),
2451 }
2452 }
2453}
2454
2455fn code_overview(
2456 index: &Index,
2457 query: &Query,
2458 content: AnalysisSet,
2459 walked: Option<&Walked>,
2460 unfiltered_rows: Option<&[FileRow]>,
2461) -> CodeOverview {
2462 #[derive(Default)]
2463 struct SortFacts {
2464 apparent: u64,
2465 allocated: u64,
2466 newest_mtime_ns: Option<i64>,
2467 metric: MetricAggregate,
2468 }
2469
2470 let files = walked.map_or_else(
2471 || entry_rows(index, None, unfiltered_rows),
2472 |walked| Cow::Borrowed(walked.members.as_slice()),
2473 );
2474 let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2475 let split = query.selection.ignored == IgnoredEntries::Include && index.observes_controls();
2476 let sort_key = query.selection.sort.unwrap_or(SortKey::Metric("code_lines"));
2477 let sort_metric = match sort_key {
2478 SortKey::Metric(name) => Some(name),
2479 _ => None,
2480 };
2481 let mut overview = CodeOverview {
2482 population: query.selection.ignored,
2483 selected: CodeTally::default(),
2484 non_ignored: split.then(CodeTally::default),
2485 ignored: split.then(CodeTally::default),
2486 unknown: CodeTally::default(),
2487 unclassified_files: 0,
2488 analyzed_languages: 0,
2489 total_languages: 0,
2490 share_omitted: 0,
2491 languages: Vec::new(),
2492 share_metric: ShareMetric::CodeLines,
2493 };
2494 let mut grouped = BTreeMap::<String, CodeLanguageRow>::new();
2495 let mut sort_facts = BTreeMap::<String, SortFacts>::new();
2496 for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2497 let record = held.and_then(|tier| tier.file(&file.path));
2498 let classification = record
2499 .map_or_else(|| index.classify(&file.path).into(), |record| record.detection.clone());
2500 if classification.family == ContentFamily::Unknown {
2501 overview.unclassified_files = overview.unclassified_files.saturating_add(1);
2502 }
2503 if classification.family != ContentFamily::Code {
2504 continue;
2505 }
2506 let language = classification.file_type.as_str().to_string();
2507 let facts = sort_facts.entry(language.clone()).or_default();
2508 facts.apparent = facts.apparent.saturating_add(file.bytes);
2509 facts.allocated = facts.allocated.saturating_add(file.allocated);
2510 facts.newest_mtime_ns =
2511 Some(facts.newest_mtime_ns.map_or(file.mtime_ns, |old| old.max(file.mtime_ns)));
2512 if let (Some(name), Some(record)) = (sort_metric, record) {
2513 if let Some(value) =
2514 measured_file_metric(record, classification.file_type.as_str(), name)
2515 {
2516 facts.metric.add(value);
2517 }
2518 }
2519 let row = grouped.entry(language.clone()).or_insert_with(|| CodeLanguageRow {
2520 language,
2521 selected: CodeTally::default(),
2522 non_ignored: split.then(CodeTally::default),
2523 ignored: split.then(CodeTally::default),
2524 unknown: CodeTally::default(),
2525 share: MetricShare::default(),
2526 });
2527 row.selected.add_file(record);
2528 overview.selected.add_file(record);
2529 match index.ignored_classification(&file.path) {
2530 Some(false) if split => {
2531 row.non_ignored.as_mut().expect("split initialized").add_file(record);
2532 overview.non_ignored.as_mut().expect("split initialized").add_file(record);
2533 }
2534 Some(true) if split => {
2535 row.ignored.as_mut().expect("split initialized").add_file(record);
2536 overview.ignored.as_mut().expect("split initialized").add_file(record);
2537 }
2538 None => {
2539 row.unknown.add_file(record);
2540 overview.unknown.add_file(record);
2541 }
2542 Some(_) => {}
2543 }
2544 }
2545 let denominator = overview.selected.metrics.code_lines;
2546 overview.analyzed_languages =
2547 grouped.values().filter(|row| row.selected.analyzed_files > 0).count() as u64;
2548 overview.languages = grouped.into_values().collect();
2549 for row in &mut overview.languages {
2550 row.share = MetricShare { numerator: row.selected.metrics.code_lines, denominator };
2551 }
2552 let normalized =
2553 matches!(sort_metric, Some("logical_words" | "visible_logical_words" | "document_words"));
2554 overview.languages.sort_by(|left, right| {
2555 let left_facts = &sort_facts[&left.language];
2556 let right_facts = &sort_facts[&right.language];
2557 let ordering = match sort_key {
2558 SortKey::Size => {
2559 let value = |facts: &SortFacts| match query.selection.size {
2560 SizeMetric::Apparent => facts.apparent,
2561 SizeMetric::Allocated => facts.allocated,
2562 };
2563 value(right_facts).cmp(&value(left_facts))
2564 }
2565 SortKey::Count => right.selected.source_files.cmp(&left.selected.source_files),
2566 SortKey::Mtime => right_facts.newest_mtime_ns.cmp(&left_facts.newest_mtime_ns),
2567 SortKey::Name => left.language.cmp(&right.language),
2568 SortKey::Metric(_) => {
2569 let left_value = left_facts.metric.value(normalized);
2570 let right_value = right_facts.metric.value(normalized);
2571 match (left_value, right_value) {
2572 (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
2573 (Some(left), Some(right)) => right.cmp(&left),
2574 (Some(_), None) => std::cmp::Ordering::Less,
2575 (None, Some(_)) => std::cmp::Ordering::Greater,
2576 (None, None) => std::cmp::Ordering::Equal,
2577 }
2578 }
2579 };
2580 let ordering = if query.selection.reverse && !matches!(sort_key, SortKey::Metric(_)) {
2581 ordering.reverse()
2582 } else {
2583 ordering
2584 };
2585 ordering.then_with(|| left.language.cmp(&right.language))
2586 });
2587 let before_share = overview.languages.len();
2588 if let Some(threshold) = &query.selection.min_share {
2589 overview.languages.retain(|row| threshold.admits(row.share.numerator, denominator));
2590 }
2591 overview.share_omitted = before_share - overview.languages.len();
2592 overview.total_languages = overview.languages.len();
2593 if let Some(limit) = query.limit_for(ViewSpec::Code).limit() {
2594 overview.languages.truncate(limit);
2595 }
2596 overview
2597}
2598
2599fn merge_coverage(total: &mut BTreeMap<CoverageReason, u64>, row: &BTreeMap<CoverageReason, u64>) {
2600 for (reason, count) in row {
2601 *total.entry(*reason).or_default() += count;
2602 }
2603}
2604
2605fn share_value(row: &MetricRow, metric: ShareMetric) -> u64 {
2606 match metric {
2607 ShareMetric::ApparentBytes => row.bytes,
2608 ShareMetric::AllocatedBytes => row.allocated,
2609 ShareMetric::CodeLines => row.metrics.code_lines.unwrap_or(0),
2610 ShareMetric::DocumentWords => document_words(row).unwrap_or(0),
2611 ShareMetric::RawWords => row.metrics.raw_words.unwrap_or(0),
2612 }
2613}
2614
2615pub fn document_words(row: &MetricRow) -> Option<u64> {
2617 row.metrics.document_words
2618}
2619
2620#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2622pub struct Pages {
2623 pub words: u64,
2625 pub words_per_page: u64,
2627}
2628
2629pub fn pages(row: &MetricRow, words_per_page: u64) -> Option<Pages> {
2631 document_words(row).map(|words| Pages { words, words_per_page: words_per_page.max(1) })
2632}
2633
2634fn file_rows(
2636 view: ViewSpec,
2637 index: &Index,
2638 query: &Query,
2639 content: AnalysisSet,
2640 walked: Option<&Walked>,
2641 unfiltered_rows: Option<&[FileRow]>,
2642) -> (Vec<FileRow>, usize) {
2643 let mut rows = entry_rows(index, walked, unfiltered_rows).into_owned();
2644 if view.files_only() {
2645 rows.retain(|row| row.kind == EntryKind::File);
2646 }
2647 if let Some(SortKey::Metric(name)) = query.selection.sort {
2648 let sources = walked.map_or(rows.as_slice(), |walked| walked.members.as_slice());
2649 let values = metric_sort_values(index, content, sources, name);
2650 for row in &mut rows {
2651 row.sort_value = values.get(&row.path).copied();
2652 }
2653 }
2654
2655 sort_rows(
2656 &mut rows,
2657 query,
2658 view,
2659 SortAccessors {
2660 size: |row: &FileRow, metric| match metric {
2661 SizeMetric::Apparent => row.bytes,
2662 SizeMetric::Allocated => row.allocated,
2663 },
2664 count: |row: &FileRow| row.files.unwrap_or(1),
2665 mtime: |row: &FileRow| Some(row.mtime_ns),
2666 name: |row: &FileRow| row.path.to_string_lossy().into_owned(),
2667 content_metric: |row: &FileRow, _: &MetricDef| row.sort_value,
2668 },
2669 );
2670 let total = truncate(&mut rows, query.limit_for(view));
2671 let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2672 for row in &mut rows {
2673 if row.kind == EntryKind::File {
2674 row.classification = Some(held.and_then(|tier| tier.file(&row.path)).map_or_else(
2675 || index.classify(&row.path).into(),
2676 |record| record.detection.clone(),
2677 ));
2678 }
2679 }
2680 (rows, total)
2681}
2682
2683#[derive(Clone, Copy)]
2684enum MetricMeasure {
2685 Additive(u64),
2686 Normalized(LogicalWordStats),
2687}
2688
2689#[derive(Default)]
2690struct MetricAggregate {
2691 additive: u64,
2692 normalized: LogicalWordStats,
2693 measured: bool,
2694}
2695
2696impl MetricAggregate {
2697 fn add(&mut self, measure: MetricMeasure) {
2698 self.measured = true;
2699 match measure {
2700 MetricMeasure::Additive(value) => {
2701 self.additive = self.additive.saturating_add(value);
2702 }
2703 MetricMeasure::Normalized(stats) => self.normalized.add_assign(stats),
2704 }
2705 }
2706
2707 fn value(&self, normalized: bool) -> Option<u64> {
2708 self.measured.then(
2709 || {
2710 if normalized { self.normalized.logical_words() } else { self.additive }
2711 },
2712 )
2713 }
2714}
2715
2716fn measured_file_metric(
2717 record: &crate::content::FileAnalysis,
2718 file_type: &str,
2719 metric_name: &str,
2720) -> Option<MetricMeasure> {
2721 let direct = |value| Some(MetricMeasure::Additive(value));
2722 match metric_name {
2723 "physical_lines" => direct(record.lines.value()?.physical_lines),
2724 "blank_lines" => direct(record.lines.value()?.blank_lines),
2725 "nonblank_lines" => direct(record.lines.value()?.nonblank_lines),
2726 "raw_words" => direct(record.lines.value()?.raw_words),
2727 "code_lines" => direct(record.code?.value()?.code_lines),
2728 "comment_lines" => direct(record.code?.value()?.comment_lines),
2729 "code_blank_lines" => direct(record.code?.value()?.code_blank_lines),
2730 "paragraphs" => direct(record.words?.value()?.paragraphs),
2731 "visible_words" => direct(record.words?.value()?.visible_words),
2732 "logical_words" => {
2733 Some(MetricMeasure::Normalized(record.words?.value()?.logical_word_stats))
2734 }
2735 "visible_logical_words" => {
2736 Some(MetricMeasure::Normalized(record.words?.value()?.visible_logical_word_stats))
2737 }
2738 "document_words" => {
2739 let words = record.words?.value()?;
2740 Some(MetricMeasure::Normalized(if file_type == "markdown" {
2741 words.visible_logical_word_stats
2742 } else {
2743 words.logical_word_stats
2744 }))
2745 }
2746 _ => None,
2747 }
2748}
2749
2750fn metric_sort_values(
2751 index: &Index,
2752 content: AnalysisSet,
2753 sources: &[FileRow],
2754 metric_name: &str,
2755) -> BTreeMap<PathBuf, u64> {
2756 let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2757 let mut aggregate = BTreeMap::<PathBuf, MetricAggregate>::new();
2758 for file in sources.iter().filter(|row| row.kind == EntryKind::File) {
2759 let Some(record) = held.and_then(|tier| tier.file(&file.path)) else { continue };
2760 let classification = index.classify(&file.path);
2761 let Some(measure) =
2762 measured_file_metric(record, classification.file_type.as_str(), metric_name)
2763 else {
2764 continue;
2765 };
2766 let mut path = Some(file.path.as_path());
2767 while let Some(current) = path {
2768 if current.as_os_str().is_empty() {
2769 break;
2770 }
2771 aggregate.entry(current.to_path_buf()).or_default().add(measure);
2772 path = current.parent();
2773 }
2774 }
2775 let normalized =
2776 matches!(metric_name, "logical_words" | "visible_logical_words" | "document_words");
2777 aggregate
2778 .into_iter()
2779 .filter_map(|(path, values)| values.value(normalized).map(|value| (path, value)))
2780 .collect()
2781}
2782
2783fn every_entry(index: &Index) -> Vec<FileRow> {
2785 let mut rows = Vec::new();
2786 let mut stack: Vec<(EntryId, PathBuf)> = vec![(EntryId::ROOT, PathBuf::new())];
2787 while let Some((id, path)) = stack.pop() {
2788 let Some(children) = index.children_of(id) else {
2789 continue;
2790 };
2791 let children: Vec<(PathBuf, EntryId)> =
2792 children.map(|(name, child)| (path.join(name), child)).collect();
2793 for (child_path, child) in children {
2794 let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
2795 continue;
2796 };
2797 rows.push(FileRow {
2798 path: child_path.clone(),
2799 kind,
2800 bytes: attrs.size,
2801 allocated: attrs.allocated,
2802 mtime_ns: attrs.mtime_ns,
2803 files: None,
2804 dirs: None,
2805 complete: None,
2806 age_ns: None,
2807 ignored: index.ignored_classification_of(&child_path, child),
2808 sort_value: None,
2809 classification: None,
2810 });
2811 if kind == EntryKind::Dir {
2812 stack.push((child, child_path));
2813 }
2814 }
2815 }
2816 rows
2817}
2818
2819fn tree_node(
2821 index: &Index,
2822 query: &Query,
2823 content: AnalysisSet,
2824 walked: Option<&Walked>,
2825 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
2826) -> (Option<TreeNode>, Vec<TreeOmission>) {
2827 let unfiltered = (walked.is_none() && matches!(query.selection.sort, Some(SortKey::Metric(_))))
2828 .then(|| every_entry(index));
2829 let metric_values = if let Some(SortKey::Metric(name)) = query.selection.sort {
2830 let sources = walked.map_or_else(
2831 || unfiltered.as_deref().unwrap_or(&[]),
2832 |walked| walked.members.as_slice(),
2833 );
2834 metric_sort_values(index, content, sources, name)
2835 } else {
2836 BTreeMap::new()
2837 };
2838 let root_summary = match walked {
2839 None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2840 Some(walked) => walked.summary_of(EntryId::ROOT),
2841 };
2842
2843 let mut root = TreeNode {
2844 path: PathBuf::new(),
2845 name: ".".to_string(),
2846 kind: EntryKind::Dir,
2847 entry_ignored: index.ignored_classification_of(Path::new(""), EntryId::ROOT),
2848 bytes: root_summary.bytes,
2849 allocated: root_summary.allocated,
2850 files: root_summary.files,
2851 dirs: root_summary.dirs,
2852 ignored: root_summary.ignored,
2853 newest_mtime_ns: root_summary.newest_mtime_ns,
2854 children: Vec::new(),
2855 omissions: Vec::new(),
2856 truncated: false,
2857 };
2858 if query.limit_for(ViewSpec::Tree) == Bound::Limit(0) {
2859 let complete = index.state().coverage == crate::Coverage::Complete;
2860 let omitted = TreeOmission {
2861 reason: TreeOmissionReason::Rows,
2862 entries: 1,
2863 files: complete.then_some(root.files),
2864 bytes: complete.then_some(root.bytes),
2865 allocated: complete.then_some(root.allocated),
2866 ignored: complete.then_some(root.ignored).flatten().map(IgnoredSize::from_tally),
2867 };
2868 return (None, vec![omitted]);
2869 }
2870 expand(index, query, walked, &metric_values, tree_measurements, &mut root);
2871 if let Some(cap) = query.limit_for(ViewSpec::Tree).limit() {
2872 root = cap_tree_rows(root, cap, index.state().coverage == crate::Coverage::Complete);
2873 }
2874 (Some(root), Vec::new())
2875}
2876
2877fn record_omission(
2878 node: &mut TreeNode,
2879 reason: TreeOmissionReason,
2880 rows: &[(TreeNode, EntryId)],
2881 complete: bool,
2882) {
2883 if rows.is_empty() {
2884 return;
2885 }
2886 let bytes = complete
2887 .then(|| rows.iter().try_fold(0u64, |sum, (row, _)| sum.checked_add(row.bytes)))
2888 .flatten();
2889 let allocated = complete
2890 .then(|| rows.iter().try_fold(0u64, |sum, (row, _)| sum.checked_add(row.allocated)))
2891 .flatten();
2892 let files = complete
2893 .then(|| rows.iter().try_fold(0u64, |sum, (row, _)| sum.checked_add(row.files)))
2894 .flatten();
2895 let ignored = complete
2896 .then(|| {
2897 rows.iter().try_fold(IgnoredSize::default(), |sum, (row, _)| {
2898 sum.checked_add(IgnoredSize::from_tally(row.ignored?))
2899 })
2900 })
2901 .flatten();
2902 node.omissions.push(TreeOmission {
2903 reason,
2904 entries: rows.len(),
2905 files,
2906 bytes,
2907 allocated,
2908 ignored,
2909 });
2910 node.truncated = true;
2911}
2912
2913fn cap_tree_rows(root: TreeNode, cap: usize, complete: bool) -> TreeNode {
2914 struct Pending {
2915 node: TreeNode,
2916 parent: Option<usize>,
2917 }
2918 let mut pending = vec![Pending { node: root, parent: None }];
2919 let mut kept: Vec<Pending> = Vec::new();
2920 while let Some(mut item) = pending.pop() {
2921 if kept.len() == cap {
2922 let parent = item.parent.expect("root admitted by positive row cap");
2923 let omission = TreeOmission {
2924 reason: TreeOmissionReason::Rows,
2925 entries: 1,
2926 files: complete.then_some(item.node.files),
2927 bytes: complete.then_some(item.node.bytes),
2928 allocated: complete.then_some(item.node.allocated),
2929 ignored: complete
2930 .then_some(item.node.ignored)
2931 .flatten()
2932 .map(IgnoredSize::from_tally),
2933 };
2934 let owner = &mut kept[parent].node;
2935 if let Some(existing) = owner
2936 .omissions
2937 .iter_mut()
2938 .find(|existing| existing.reason == TreeOmissionReason::Rows)
2939 {
2940 existing.entries += 1;
2941 existing.files =
2942 existing.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
2943 existing.bytes =
2944 existing.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
2945 existing.allocated =
2946 existing.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
2947 existing.ignored =
2948 existing.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
2949 } else {
2950 owner.omissions.push(omission);
2951 }
2952 owner.truncated = true;
2953 continue;
2954 }
2955 let children = std::mem::take(&mut item.node.children);
2956 let current = kept.len();
2957 kept.push(item);
2958 for child in children.into_iter().rev() {
2959 pending.push(Pending { node: child, parent: Some(current) });
2960 }
2961 }
2962 for position in (1..kept.len()).rev() {
2963 let child = kept.remove(position);
2964 kept[child.parent.expect("only root lacks parent")].node.children.insert(0, child.node);
2965 }
2966 kept.pop().expect("positive cap admits root").node
2967}
2968
2969fn expand(
2976 index: &Index,
2977 query: &Query,
2978 walked: Option<&Walked>,
2979 metric_values: &BTreeMap<PathBuf, u64>,
2980 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
2981 node: &mut TreeNode,
2982) {
2983 struct Pending {
2985 node: TreeNode,
2986 id: EntryId,
2987 depth: usize,
2988 parent: Option<usize>,
2989 }
2990
2991 let mut built = vec![Pending {
2992 node: TreeNode {
2995 path: node.path.clone(),
2996 name: node.name.clone(),
2997 kind: node.kind,
2998 entry_ignored: node.entry_ignored,
2999 bytes: node.bytes,
3000 allocated: node.allocated,
3001 files: node.files,
3002 dirs: node.dirs,
3003 ignored: node.ignored,
3004 newest_mtime_ns: node.newest_mtime_ns,
3005 children: Vec::new(),
3006 omissions: Vec::new(),
3007 truncated: false,
3008 },
3009 id: EntryId::ROOT,
3010 depth: 0,
3011 parent: None,
3012 }];
3013
3014 let threshold = query.min_share_for();
3015 let grand = match query.selection.size {
3016 SizeMetric::Apparent => node.bytes,
3017 SizeMetric::Allocated => node.allocated,
3018 };
3019 let complete = index.state().coverage == crate::Coverage::Complete;
3020 let mut cursor = 0;
3021 while cursor < built.len() {
3022 let (id, depth) = (built[cursor].id, built[cursor].depth);
3023 let path = built[cursor].node.path.clone();
3024
3025 let mut rows = child_rows(index, query, walked, metric_values, id, &path);
3026 let mut below_share = Vec::new();
3027 rows.retain(|(row, child)| {
3028 let value = match query.selection.size {
3029 SizeMetric::Apparent => row.bytes,
3030 SizeMetric::Allocated => row.allocated,
3031 };
3032 let child_complete = row.kind == EntryKind::File
3036 || tree_measurements
3037 .is_none_or(|values| values.get(child).is_some_and(|subtree| subtree.complete));
3038 let eligible = !child_complete || threshold.admits(value, grand);
3039 if !eligible {
3040 below_share.push((row.clone(), *child));
3041 }
3042 eligible
3043 });
3044 record_omission(&mut built[cursor].node, TreeOmissionReason::Share, &below_share, true);
3045 if !query.depth_for(ViewSpec::Tree).admits(depth) {
3046 record_omission(&mut built[cursor].node, TreeOmissionReason::Depth, &rows, complete);
3047 cursor += 1;
3048 continue;
3049 }
3050 if let Some(cap) = query.breadth_for().limit() {
3051 let hidden = rows.split_off(cap.min(rows.len()));
3052 record_omission(
3053 &mut built[cursor].node,
3054 TreeOmissionReason::Breadth,
3055 &hidden,
3056 complete,
3057 );
3058 }
3059
3060 for (child_node, child_id) in rows {
3061 built.push(Pending {
3062 node: child_node,
3063 id: child_id,
3064 depth: depth + 1,
3065 parent: Some(cursor),
3066 });
3067 }
3068 cursor += 1;
3069 }
3070
3071 for position in (1..built.len()).rev() {
3074 let child = built.remove(position);
3075 let parent = child.parent.expect("only the root has no parent");
3076 built[parent].node.children.insert(0, child.node);
3077 }
3078
3079 let mut root = built.pop().expect("the root is always present");
3080 node.children = std::mem::take(&mut root.node.children);
3081 node.omissions = std::mem::take(&mut root.node.omissions);
3082 node.truncated = root.node.truncated;
3083}
3084
3085fn child_rows(
3087 index: &Index,
3088 query: &Query,
3089 walked: Option<&Walked>,
3090 metric_values: &BTreeMap<PathBuf, u64>,
3091 id: EntryId,
3092 path: &Path,
3093) -> Vec<(TreeNode, EntryId)> {
3094 let Some(children) = index.children_of(id) else {
3095 return Vec::new();
3096 };
3097 let children: Vec<(PathBuf, EntryId)> =
3098 children.map(|(name, child)| (path.join(name), child)).collect();
3099
3100 let mut rows: Vec<(TreeNode, EntryId)> = Vec::new();
3101 for (child_path, child) in children {
3102 let Some(kind) = index.kind_of(child) else {
3103 continue;
3104 };
3105 if !matches!(kind, EntryKind::Dir | EntryKind::File) {
3106 continue;
3107 }
3108 if walked.is_some_and(|walked| match kind {
3109 EntryKind::Dir => !walked.visible.contains(&child),
3110 EntryKind::File => !walked.visible_files.contains(&child),
3111 EntryKind::Symlink | EntryKind::Other => true,
3112 }) {
3113 continue;
3114 }
3115 let entry_ignored = index.ignored_classification_of(&child_path, child);
3116 let summary = if kind == EntryKind::File {
3117 let attrs = index.attrs_of(child).expect("live child has attributes");
3118 let ignored = entry_ignored.map(|ignored| {
3119 if ignored {
3120 IgnoredTally {
3121 files: 1,
3122 dirs: 0,
3123 bytes: attrs.size,
3124 allocated: attrs.allocated,
3125 }
3126 } else {
3127 IgnoredTally::default()
3128 }
3129 });
3130 SummaryRow {
3131 files: 1,
3132 dirs: 0,
3133 bytes: attrs.size,
3134 allocated: attrs.allocated,
3135 ignored,
3136 newest_mtime_ns: Some(attrs.mtime_ns),
3137 }
3138 } else {
3139 match walked {
3140 None => unfiltered_summary(index, child, &child_path),
3141 Some(walked) => walked.summary_of(child),
3142 }
3143 };
3144 let name = child_path
3145 .file_name()
3146 .map(|name| name.to_string_lossy().into_owned())
3147 .unwrap_or_default();
3148 rows.push((
3149 TreeNode {
3150 path: child_path,
3151 name,
3152 kind,
3153 entry_ignored,
3154 bytes: summary.bytes,
3155 allocated: summary.allocated,
3156 files: summary.files,
3157 dirs: summary.dirs,
3158 ignored: summary.ignored,
3159 newest_mtime_ns: summary.newest_mtime_ns,
3160 children: Vec::new(),
3161 omissions: Vec::new(),
3162 truncated: false,
3163 },
3164 child,
3165 ));
3166 }
3167
3168 sort_rows_by(
3169 &mut rows,
3170 query,
3171 ViewSpec::Tree,
3172 SortAccessors {
3173 size: |(row, _): &(TreeNode, EntryId), metric| match metric {
3174 SizeMetric::Apparent => row.bytes,
3175 SizeMetric::Allocated => row.allocated,
3176 },
3177 count: |(row, _): &(TreeNode, EntryId)| row.files,
3178 mtime: |(row, _): &(TreeNode, EntryId)| row.newest_mtime_ns,
3179 name: |(row, _): &(TreeNode, EntryId)| row.name.clone(),
3180 content_metric: |(row, _): &(TreeNode, EntryId), _: &MetricDef| {
3181 metric_values.get(&row.path).copied()
3182 },
3183 },
3184 );
3185 rows
3186}
3187
3188fn truncate<T>(rows: &mut Vec<T>, limit: Bound) -> usize {
3190 let total = rows.len();
3191 if let Some(limit) = limit.limit() {
3192 rows.truncate(limit);
3193 }
3194 total
3195}
3196
3197struct SortAccessors<S, C, M, N, V> {
3199 size: S,
3200 count: C,
3201 mtime: M,
3202 name: N,
3203 content_metric: V,
3204}
3205
3206fn sort_rows<T>(
3207 rows: &mut [T],
3208 query: &Query,
3209 view: ViewSpec,
3210 accessors: SortAccessors<
3211 impl Fn(&T, SizeMetric) -> u64,
3212 impl Fn(&T) -> u64,
3213 impl Fn(&T) -> Option<i64>,
3214 impl Fn(&T) -> String,
3215 impl Fn(&T, &MetricDef) -> Option<u64>,
3216 >,
3217) {
3218 sort_rows_by(rows, query, view, accessors);
3219}
3220
3221fn sort_rows_by<T>(
3223 rows: &mut [T],
3224 query: &Query,
3225 view: ViewSpec,
3226 accessors: SortAccessors<
3227 impl Fn(&T, SizeMetric) -> u64,
3228 impl Fn(&T) -> u64,
3229 impl Fn(&T) -> Option<i64>,
3230 impl Fn(&T) -> String,
3231 impl Fn(&T, &MetricDef) -> Option<u64>,
3232 >,
3233) {
3234 let SortAccessors { size, count, mtime, name, content_metric } = accessors;
3235 let key = query.selection.sort.unwrap_or_else(|| view.default_sort());
3236 let metric = query.selection.size;
3237
3238 rows.sort_by(|left, right| {
3239 let ordering = match key {
3240 SortKey::Size => size(right, metric).cmp(&size(left, metric)),
3242 SortKey::Count => count(right).cmp(&count(left)),
3243 SortKey::Mtime => mtime(right).cmp(&mtime(left)),
3244 SortKey::Name => name(left).cmp(&name(right)),
3245 SortKey::Metric(metric_name) => {
3246 let definition =
3247 crate::content::METRICS.iter().find(|entry| entry.name == metric_name);
3248 let left_value = definition.and_then(|definition| content_metric(left, definition));
3249 let right_value =
3250 definition.and_then(|definition| content_metric(right, definition));
3251 match (left_value, right_value) {
3252 (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
3253 (Some(left), Some(right)) => right.cmp(&left),
3254 (Some(_), None) => std::cmp::Ordering::Less,
3255 (None, Some(_)) => std::cmp::Ordering::Greater,
3256 (None, None) => std::cmp::Ordering::Equal,
3257 }
3258 }
3259 };
3260 ordering.then_with(|| name(left).cmp(&name(right)))
3263 });
3264
3265 if query.selection.reverse && !matches!(key, SortKey::Metric(_)) {
3266 rows.reverse();
3267 }
3268}
3269
3270#[cfg(test)]
3271mod tests {
3272 use super::*;
3273 use crate::engine_contract::{Attrs, Observation, Op};
3274 use crate::query::query_glob::Pattern;
3275 use crate::query::query_selection::ModifiedWindow;
3276 use std::fs;
3277 use std::time::{Duration, UNIX_EPOCH};
3278
3279 fn attrs(size: u64, mtime_ns: i64) -> Attrs {
3280 Attrs {
3281 size,
3282 allocated: size.div_ceil(512) * 512,
3283 mtime_ns,
3284 ctime_ns: mtime_ns,
3285 inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
3286 dev: 1,
3287 }
3288 }
3289
3290 fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
3291 Op::Upsert { path: PathBuf::from(path), kind, attrs }
3292 }
3293
3294 fn sample() -> Index {
3296 let mut index = Index::new("/root");
3297 index
3298 .apply(&Observation::new(vec![
3299 upsert("src", EntryKind::Dir, Attrs::default()),
3300 upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
3301 upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
3302 upsert("src/deep", EntryKind::Dir, Attrs::default()),
3303 upsert("src/deep/nested.rs", EntryKind::File, attrs(50, 40)),
3304 upsert("docs", EntryKind::Dir, Attrs::default()),
3305 upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
3306 upsert("notes.txt", EntryKind::File, attrs(7, 5)),
3307 ]))
3308 .expect("apply");
3309 index
3310 }
3311
3312 #[test]
3313 fn ages_use_one_signed_reference_and_unrepresentable_clocks_are_unknown() {
3314 let mut index = sample();
3315 index.apply_ok(&Observation::new(vec![
3316 upsert("past", EntryKind::File, attrs(1, -10)),
3317 upsert("future", EntryKind::File, attrs(1, i64::MAX)),
3318 ]));
3319 let query = query(
3320 &[ViewSpec::Files],
3321 Selection { include: vec![pattern("past"), pattern("future")], ..Selection::default() },
3322 );
3323 let mut request = Request::new(Basis::held_by(&index), query, UNIX_EPOCH);
3324 let answer = report(&index, &request, UNIX_EPOCH).expect("report");
3325 let rows = files_of(&answer);
3326 assert_eq!(answer.age_reference_ns, Some(0));
3327 assert_eq!(
3328 rows.iter().map(|r| r.age_ns).collect::<Vec<_>>(),
3329 [Some(-i128::from(i64::MAX)), Some(10)]
3330 );
3331 request.now = UNIX_EPOCH + Duration::from_secs(10_000_000_000);
3332 let answer = report(&index, &request, UNIX_EPOCH)
3333 .expect("out-of-range reference is representable as unknown age");
3334 assert_eq!(answer.age_reference_ns, None);
3335 assert!(files_of(&answer).iter().all(|row| row.age_ns.is_none()));
3336 }
3337
3338 #[test]
3339 fn matching_a_directory_selects_its_subtree_once() {
3340 let index = sample();
3341 let selection = Selection {
3342 include: vec![pattern("src"), pattern("deep")],
3343 kinds: vec![EntryKind::Dir],
3344 size: SizeMetric::Apparent,
3345 min_size: Some(40),
3346 ..Selection::default()
3347 };
3348 let report = run(
3349 &index,
3350 &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Extensions], selection),
3351 );
3352 let rows = files_of(&report);
3353 assert_eq!(rows.len(), 2);
3354 assert_eq!((rows[0].bytes, rows[0].mtime_ns), (350, 40));
3355 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3356 assert_eq!((summary.files, summary.dirs, summary.bytes), (3, 2, 350));
3357 let Section::Extensions { rows, .. } = &report.sections[2] else { panic!("extensions") };
3358 assert_eq!((rows[0].files, rows[0].bytes), (3, 350));
3359 }
3360
3361 #[test]
3362 fn subtree_predicates_include_directory_and_symlink_activity_but_only_file_bytes() {
3363 let mut index = sample();
3364 index
3365 .apply(&Observation::new(vec![
3366 upsert("src", EntryKind::Dir, attrs(9999, 45)),
3367 upsert("src/empty", EntryKind::Dir, attrs(8888, 60)),
3368 upsert("src/link", EntryKind::Symlink, attrs(7777, 70)),
3369 upsert("empty", EntryKind::Dir, attrs(6666, -10)),
3370 ]))
3371 .expect("apply");
3372 let base = Selection {
3373 include: vec![pattern("src"), pattern("empty")],
3374 size: SizeMetric::Apparent,
3375 ..Selection::default()
3376 };
3377 let nested = PathBuf::from("src").join("empty").to_string_lossy().into_owned();
3380 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], base.clone())));
3381 assert_eq!(
3382 rows.iter()
3383 .map(|r| (r.path.to_string_lossy().into_owned(), r.bytes, r.mtime_ns))
3384 .collect::<Vec<_>>(),
3385 [("empty".into(), 0, -10), ("src".into(), 350, 70), (nested.clone(), 0, 60)]
3386 );
3387 for (before, since, expected) in [
3388 (70, 0, vec![nested.clone()]),
3389 (71, 70, vec!["src".to_owned()]),
3390 (0, -10, vec!["empty".to_owned()]),
3391 ] {
3392 let selection = Selection {
3393 modified: ModifiedWindow { before: Some(before), since: Some(since) },
3394 ..base.clone()
3395 };
3396 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3397 assert_eq!(
3398 rows.iter().map(|r| r.path.to_string_lossy().into_owned()).collect::<Vec<_>>(),
3399 expected
3400 );
3401 }
3402 for (size, minimum, expected) in [
3403 (SizeMetric::Apparent, 350, 1),
3404 (SizeMetric::Apparent, 351, 0),
3405 (SizeMetric::Allocated, 1536, 1),
3406 (SizeMetric::Allocated, 1537, 0),
3407 ] {
3408 let selection = Selection { size, min_size: Some(minimum), ..base.clone() };
3409 assert_eq!(
3410 files_of(&run(&index, &query(&[ViewSpec::Files], selection))).len(),
3411 expected
3412 );
3413 }
3414 }
3415
3416 #[test]
3417 fn exclusions_apply_before_subtree_bounds_and_selected_ancestor_coverage() {
3418 let index = classified_sample();
3419 for (ignored, exclude, bytes, newest) in [
3420 (IgnoredEntries::Include, vec![], 325, 70),
3421 (IgnoredEntries::Exclude, vec![], 300, 20),
3422 (IgnoredEntries::Include, vec![pattern("*.log")], 300, 20),
3423 (IgnoredEntries::Include, vec![pattern("lib.rs")], 125, 70),
3424 ] {
3425 let selection = Selection {
3426 include: vec![pattern("src")],
3427 kinds: vec![EntryKind::Dir],
3428 ignored,
3429 exclude,
3430 size: SizeMetric::Apparent,
3431 ..Selection::default()
3432 };
3433 let report = run(
3434 &index,
3435 &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Types], selection),
3436 );
3437 let row = &files_of(&report)[0];
3438 assert_eq!((row.bytes, row.mtime_ns), (bytes, newest));
3439 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3440 assert_eq!(summary.bytes, bytes);
3441 let Section::Metrics { summary, .. } = &report.sections[2] else { panic!("types") };
3442 assert_eq!(summary.rows.iter().map(|r| r.bytes).sum::<u64>(), bytes);
3443 }
3444 let selection = Selection {
3445 include: vec![pattern("build")],
3446 exclude: vec![pattern("cache")],
3447 kinds: vec![EntryKind::Dir],
3448 ..Selection::default()
3449 };
3450 let report = run(&index, &query(&[ViewSpec::Files, ViewSpec::Summary], selection));
3451 assert_eq!(files_of(&report)[0].bytes, 0);
3452 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3453 assert_eq!((summary.files, summary.dirs, summary.bytes), (0, 1, 0));
3454 let only = Selection {
3455 include: vec![pattern("cache")],
3456 kinds: vec![EntryKind::Dir],
3457 ignored: IgnoredEntries::Only,
3458 ..Selection::default()
3459 };
3460 assert_eq!(files_of(&run(&index, &query(&[ViewSpec::Files], only)))[0].bytes, 1000);
3461 }
3462
3463 #[test]
3467 fn an_incomplete_subtree_reports_lower_bounds_and_matches_no_time_bound() {
3468 let mut index = Index::new_with_scope(
3471 "/root",
3472 crate::ScanScope { max_depth: Some(2), ..crate::ScanScope::default() },
3473 );
3474 index.apply_ok(&Observation::new(vec![
3475 upsert("env", EntryKind::Dir, attrs(0, 5)),
3476 upsert("env/lib", EntryKind::Dir, attrs(0, 7)),
3477 upsert("env/a.bin", EntryKind::File, attrs(100, 40)),
3478 upsert("docs", EntryKind::Dir, attrs(0, 5)),
3479 upsert("docs/guide.md", EntryKind::File, attrs(30, 50)),
3480 ]));
3481 let directories = |selection: Selection| {
3482 let selection =
3483 Selection { kinds: vec![EntryKind::Dir], size: SizeMetric::Apparent, ..selection };
3484 files_of(&run(&index, &flat(selection)))
3485 .into_iter()
3486 .map(|row| {
3487 (row.path.to_string_lossy().into_owned(), row.complete, row.bytes, row.age_ns)
3488 })
3489 .collect::<Vec<_>>()
3490 };
3491 let env_lib = Path::new("env").join("lib").to_string_lossy().into_owned();
3493 assert_eq!(
3494 directories(Selection::default()),
3495 vec![
3496 ("env".to_string(), Some(false), 100, None),
3497 ("docs".to_string(), Some(true), 30, Some(-50)),
3498 (env_lib, Some(false), 0, None),
3499 ],
3500 "sizes are lower bounds and the age is unknown below the boundary"
3501 );
3502 for modified in [
3503 ModifiedWindow { since: None, before: Some(100) },
3504 ModifiedWindow { since: Some(0), before: None },
3505 ] {
3506 assert_eq!(
3507 directories(Selection { modified, ..Selection::default() }),
3508 vec![("docs".to_string(), Some(true), 30, Some(-50))],
3509 "an unknown age satisfies no bound, not even one its lower bound would prove"
3510 );
3511 }
3512 assert_eq!(
3513 directories(Selection { min_size: Some(100), ..Selection::default() }),
3514 vec![("env".to_string(), Some(false), 100, None)],
3515 "a lower bound at or above the minimum proves the true size is too"
3516 );
3517 assert!(directories(Selection { min_size: Some(101), ..Selection::default() }).is_empty());
3518 let files = files_of(&run(
3520 &index,
3521 &flat(Selection {
3522 kinds: vec![EntryKind::File],
3523 modified: ModifiedWindow { since: Some(45), before: None },
3524 ..Selection::default()
3525 }),
3526 ));
3527 assert_eq!(files.len(), 1);
3528 assert_eq!((files[0].complete, files[0].age_ns), (None, Some(-50)));
3529 }
3530
3531 #[test]
3535 fn an_unscoped_partial_marker_marks_every_directory_row_incomplete() {
3536 let directories = |index: &Index| {
3537 files_of(&run(
3538 index,
3539 &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3540 ))
3541 };
3542 let mut partial = sample();
3543 partial.set_initial_freshness(false);
3544 let rows = directories(&partial);
3545 assert_eq!(rows.len(), 3);
3546 assert!(rows.iter().all(|row| row.complete == Some(false) && row.age_ns.is_none()));
3547 let mut complete = sample();
3548 complete.set_initial_freshness(true);
3549 let rows = directories(&complete);
3550 assert_eq!(rows.len(), 3);
3551 assert!(rows.iter().all(|row| row.complete == Some(true) && row.age_ns.is_some()));
3552 }
3553
3554 #[test]
3557 fn an_opened_root_marks_a_directory_complete_only_once_discovery_listed_it() {
3558 let handle = crate::index::IndexHandle::new(Index::new("/root"));
3559 handle
3560 .transition_discovery(crate::index::DiscoveryTransition::Begin)
3561 .expect("begin discovery");
3562 handle
3563 .apply(&Observation::new(vec![
3564 upsert("known", EntryKind::Dir, attrs(0, 5)),
3565 upsert("pending", EntryKind::Dir, attrs(0, 5)),
3566 ]))
3567 .expect("seed directories");
3568 handle
3569 .apply_discovery(
3570 &Observation::new(Vec::new()),
3571 crate::index::DiscoveryCommit {
3572 directory_complete: Some(PathBuf::from("known")),
3573 transition: None,
3574 },
3575 )
3576 .expect("list one directory");
3577 let completeness = handle
3578 .read_with(|index| {
3579 files_of(&run(
3580 index,
3581 &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3582 ))
3583 .into_iter()
3584 .map(|row| (row.path.to_string_lossy().into_owned(), row.complete))
3585 .collect::<BTreeMap<_, _>>()
3586 })
3587 .expect("read");
3588 assert_eq!(
3589 completeness,
3590 BTreeMap::from([
3591 ("known".to_string(), Some(true)),
3592 ("pending".to_string(), Some(false))
3593 ])
3594 );
3595 }
3596
3597 #[test]
3598 fn a_filtered_tree_keeps_empty_matches_and_only_folds_visible_directories() {
3599 let mut index = sample();
3600 index
3601 .apply(&Observation::new(vec![upsert("src/empty", EntryKind::Dir, Attrs::default())]))
3602 .expect("apply");
3603 let selection = Selection {
3604 include: vec![pattern("empty")],
3605 depth: Some(Bound::All),
3606 min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3607 ..Selection::default()
3608 };
3609 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3610 assert_eq!(root.children.len(), 1);
3611 assert_eq!(root.children[0].name, "src");
3612 assert_eq!(root.children[0].children[0].name, "empty");
3613 let selection = Selection {
3614 include: vec![pattern("notes.txt")],
3615 depth: Some(Bound::Limit(0)),
3616 min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3617 ..Selection::default()
3618 };
3619 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3620 assert!(root.truncated, "a file leaf is hidden by depth zero");
3621 assert_eq!(root.bytes, 7);
3622 }
3623
3624 #[test]
3625 fn the_undocumented_docs_view_alias_is_rejected() {
3626 assert_eq!(
3627 ViewSpec::parse("documents").expect("the canonical view name parses"),
3628 ViewSpec::Documents
3629 );
3630 assert_eq!(
3631 ViewSpec::parse("docs").expect_err("an unreleased alias must not become a contract"),
3632 format!("expected one of {}", ViewSpec::vocabulary())
3633 );
3634 }
3635
3636 #[test]
3637 fn code_analysis_defaults_to_code_overview_and_keeps_document_projection() {
3638 assert_eq!(
3639 ViewSpec::resolve(None, AnalysisSet::NONE.with_code(), "view").expect("default").0,
3640 vec![ViewSpec::Code]
3641 );
3642 assert_eq!(
3643 ViewSpec::resolve(None, AnalysisSet::ALL, "view").expect("combined default").0,
3644 vec![ViewSpec::Code, ViewSpec::Documents]
3645 );
3646 assert_eq!(
3647 ViewSpec::resolve(Some("files"), AnalysisSet::ALL, "view").expect("explicit").0,
3648 vec![ViewSpec::Files]
3649 );
3650 }
3651
3652 fn generated_at() -> std::time::SystemTime {
3653 UNIX_EPOCH + Duration::from_secs(1_001)
3654 }
3655
3656 fn run(index: &Index, query: &Query) -> Report {
3657 report(index, &crate::test_support::read_of(index, query.clone()), generated_at())
3658 .expect("the query is answerable over this index")
3659 }
3660
3661 fn query(views: &[ViewSpec], selection: Selection) -> Query {
3662 Query { selection, views: views.to_vec(), ..Query::default() }
3663 }
3664
3665 fn flat(selection: Selection) -> Query {
3667 Query {
3668 selection,
3669 views: vec![ViewSpec::List],
3670 format: crate::report_format::Format::Paths,
3671 ..Query::default()
3672 }
3673 }
3674
3675 fn pattern(source: &str) -> Pattern {
3676 Pattern::parse(source).expect("pattern compiles")
3677 }
3678
3679 fn summary_of(report: &Report) -> SummaryRow {
3680 match report.sections.first().expect("a section") {
3681 Section::Summary(row) => *row,
3682 other => panic!("expected a summary, got {other:?}"),
3683 }
3684 }
3685
3686 fn files_of(report: &Report) -> Vec<FileRow> {
3687 match report.sections.first().expect("a section") {
3688 Section::Files { rows, .. } => rows.clone(),
3689 other => panic!("expected files, got {other:?}"),
3690 }
3691 }
3692
3693 fn types_of(report: &Report) -> Vec<TypeRow> {
3694 match report.sections.first().expect("a section") {
3695 Section::Extensions { rows, .. } => rows.clone(),
3696 other => panic!("expected types, got {other:?}"),
3697 }
3698 }
3699
3700 fn tree_of(report: &Report) -> TreeNode {
3701 match report.sections.first().expect("a section") {
3702 Section::Tree { root: Some(node), .. } => (**node).clone(),
3703 other => panic!("expected a tree, got {other:?}"),
3704 }
3705 }
3706
3707 #[test]
3708 fn an_unfiltered_summary_matches_the_precomputed_rollup() {
3709 let index = sample();
3710 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
3711 assert_eq!(row.files, 5);
3712 assert_eq!(row.dirs, 3);
3713 assert_eq!(row.bytes, 657);
3714 assert_eq!(row.newest_mtime_ns, Some(40));
3715 }
3716
3717 #[test]
3718 fn the_two_tiers_agree_on_the_same_question() {
3719 let index = sample();
3722 let fast = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
3723
3724 let admits_everything = Selection { min_size: Some(0), ..Selection::default() };
3726 assert!(!admits_everything.is_unfiltered());
3727 let slow = summary_of(&run(&index, &query(&[ViewSpec::Summary], admits_everything)));
3728
3729 assert_eq!(
3734 (fast.files, fast.dirs, fast.bytes, fast.allocated, fast.newest_mtime_ns),
3735 (slow.files, slow.dirs, slow.bytes, slow.allocated, slow.newest_mtime_ns)
3736 );
3737 }
3738
3739 #[test]
3740 fn extension_rows_account_for_every_file_in_both_tiers() {
3741 let mut index = sample();
3746 index
3747 .apply(&Observation::new(vec![
3748 upsert("Makefile", EntryKind::File, attrs(28, 50)),
3749 upsert(".gitignore", EntryKind::File, attrs(11, 51)),
3750 ]))
3751 .expect("apply");
3752
3753 for selection in [
3756 Selection::default(),
3757 Selection { min_size: Some(0), ..Selection::default() },
3758 Selection { kinds: vec![EntryKind::File], ..Selection::default() },
3759 ] {
3760 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
3761 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
3762 assert_eq!(
3763 rows.iter().map(|row| row.bytes).sum::<u64>(),
3764 summary.bytes,
3765 "bytes unaccounted for under {selection:?}: {rows:?}"
3766 );
3767 assert_eq!(
3768 rows.iter().map(|row| row.files).sum::<u64>(),
3769 summary.files,
3770 "files unaccounted for under {selection:?}: {rows:?}"
3771 );
3772 }
3773 }
3774
3775 #[test]
3776 fn names_without_an_extension_share_one_bucket() {
3777 let mut index = sample();
3781 index
3782 .apply(&Observation::new(vec![
3783 upsert("Makefile", EntryKind::File, attrs(28, 50)),
3784 upsert(".gitignore", EntryKind::File, attrs(11, 51)),
3785 ]))
3786 .expect("apply");
3787
3788 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
3789 let bucket = rows
3790 .iter()
3791 .find(|row| row.extension == crate::classify::NO_EXTENSION)
3792 .expect("a bucket for the extension-less names");
3793 assert_eq!(bucket.files, 2);
3794 assert_eq!(bucket.bytes, 39);
3795 assert!(rows.iter().any(|row| row.extension == ".rs"), "{rows:?}");
3797 }
3798
3799 #[test]
3800 fn a_summary_counts_the_union_of_listed_entries_and_directory_contents() {
3801 let index = sample();
3807 for selection in [
3808 Selection { kinds: vec![EntryKind::File], ..Selection::default() },
3809 Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
3810 Selection { include: vec![pattern("*.rs")], ..Selection::default() },
3811 Selection { exclude: vec![pattern("docs")], ..Selection::default() },
3812 Selection { min_size: Some(1_000_000), ..Selection::default() },
3813 Selection { min_size: Some(0), ..Selection::default() },
3814 ] {
3815 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
3816 let listed = files_of(&run(&index, &query(&[ViewSpec::Files], selection.clone())));
3817 let dirs = listed.iter().filter(|row| row.kind == EntryKind::Dir).count() as u64;
3818 let files = every_entry(&index)
3819 .iter()
3820 .filter(|entry| {
3821 entry.kind == EntryKind::File
3822 && listed.iter().any(|row| {
3823 entry.path == row.path
3824 || (row.kind == EntryKind::Dir && entry.path.starts_with(&row.path))
3825 })
3826 })
3827 .count() as u64;
3828 assert_eq!(summary.dirs, dirs, "directory counts disagree under {selection:?}");
3829 assert_eq!(summary.files, files, "file counts disagree under {selection:?}");
3830 }
3831 }
3832
3833 #[test]
3834 fn a_rejected_directory_is_still_descended_into() {
3835 let index = sample();
3838 let selection = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
3839 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
3840 assert_eq!(row.dirs, 0, "no directory was admitted");
3841 assert_eq!(row.files, 5, "including src/deep/nested.rs, two levels down");
3842 assert_eq!(row.bytes, 657, "and its bytes");
3843 }
3844
3845 #[test]
3846 fn nested_directory_counts_roll_up_through_every_level() {
3847 let index = sample();
3850 let selection = Selection { kinds: vec![EntryKind::Dir], ..Selection::default() };
3851 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3852 assert_eq!(root.dirs, 3, "src, src/deep, and docs");
3853 assert_eq!(root.files, 4, "matching directories cover their regular files");
3854 let src = root.children.iter().find(|node| node.name == "src").expect("src");
3855 assert_eq!(src.dirs, 1, "src/deep, counted for src as well as for the root");
3856 }
3857
3858 #[test]
3859 fn selection_narrows_a_summary_to_what_it_admits() {
3860 let index = sample();
3861 let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
3862 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
3863 assert_eq!(row.files, 3, "three .rs files");
3864 assert_eq!(row.bytes, 350);
3865 }
3866
3867 #[test]
3868 fn a_files_view_lists_matching_entries_in_name_order_by_default() {
3869 let index = sample();
3870 let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
3871 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3872 let paths: Vec<PathBuf> = rows.iter().map(|row| row.path.clone()).collect();
3876 let expected: Vec<PathBuf> = [["src", "deep", "nested.rs"].iter().collect::<PathBuf>()]
3877 .into_iter()
3878 .chain([["src", "lib.rs"].iter().collect::<PathBuf>()])
3879 .chain([["src", "main.rs"].iter().collect::<PathBuf>()])
3880 .collect();
3881 assert_eq!(paths, expected);
3882 }
3883
3884 #[test]
3885 fn sorting_and_limiting_compose_without_a_dedicated_view() {
3886 let index = sample();
3888 let selection = Selection {
3890 kinds: vec![EntryKind::File],
3891 sort: Some(SortKey::Size),
3892 limit: Some(Bound::Limit(2)),
3893 size: SizeMetric::Apparent,
3894 ..Selection::default()
3895 };
3896 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3897 assert_eq!(rows.len(), 2);
3898 assert_eq!(rows[0].bytes, 300, "largest first");
3899 assert_eq!(rows[1].bytes, 200);
3900 }
3901
3902 #[test]
3903 fn reverse_flips_whatever_order_is_in_effect() {
3904 let index = sample();
3905 let selection = Selection {
3906 kinds: vec![EntryKind::File],
3907 sort: Some(SortKey::Size),
3908 reverse: true,
3909 size: SizeMetric::Apparent,
3910 ..Selection::default()
3911 };
3912 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3913 assert_eq!(rows[0].bytes, 7, "smallest first once reversed");
3914 }
3915
3916 #[test]
3917 fn a_modified_window_selects_by_time() {
3918 let index = sample();
3919 let selection = Selection {
3920 kinds: vec![EntryKind::File],
3921 modified: ModifiedWindow { since: Some(20), before: Some(40) },
3922 sort: Some(SortKey::Mtime),
3923 ..Selection::default()
3924 };
3925 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3926 let mut times: Vec<i64> = rows.iter().map(|row| row.mtime_ns).collect();
3927 times.sort_unstable();
3928 assert_eq!(times, vec![20, 30], "inclusive start, exclusive end");
3929 }
3930
3931 #[test]
3932 fn a_types_view_reports_both_size_metrics_per_extension() {
3933 let index = sample();
3934 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
3935 let rs = rows.iter().find(|row| row.extension == ".rs").expect(".rs present");
3936 assert_eq!((rs.files, rs.bytes), (3, 350));
3937 assert_eq!(rs.allocated, 1536, "three files, one 512-byte block each");
3938 let order: Vec<&str> = rows.iter().map(|row| row.extension.as_str()).collect();
3940 assert_eq!(order, vec![".rs", ".md", ".txt"]);
3941 }
3942
3943 #[test]
3944 fn a_tree_view_reports_directories_with_their_subtree_totals() {
3945 let index = sample();
3946 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], Selection::default())));
3947 assert_eq!(tree.name, ".");
3948 assert_eq!(tree.bytes, 657);
3949 let names: Vec<&str> = tree.children.iter().map(|child| child.name.as_str()).collect();
3951 assert_eq!(names, vec!["src", "docs", "notes.txt"]);
3952 let src = &tree.children[0];
3953 assert_eq!(src.bytes, 350);
3954 let nested: Vec<&str> = src.children.iter().map(|child| child.name.as_str()).collect();
3955 assert!(nested.contains(&"deep"));
3956 }
3957
3958 #[test]
3959 fn depth_zero_keeps_dus_meaning_of_root_totals_only() {
3960 let index = sample();
3961 let selection = Selection { depth: Some(Bound::Limit(0)), ..Selection::default() };
3962 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3963 assert_eq!(tree.bytes, 657, "totals still cover the whole tree");
3964 assert!(tree.children.is_empty(), "but nothing below the root is listed");
3965 assert!(tree.truncated, "and the report says so rather than implying emptiness");
3966 }
3967
3968 #[test]
3973 fn a_dropped_view_is_named_on_the_report_rather_than_by_one_surface() {
3974 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
3975 .expect("full resolves without analyzers");
3976 assert!(omitted.contains(&ViewSpec::Code));
3977 assert!(omitted.contains(&ViewSpec::Documents));
3978
3979 let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
3980 let (notes, _) = display_notes(&query, &ControlCoverage::NotObserved);
3981 assert_eq!(notes.len(), 1, "{notes:?}");
3982 assert!(notes[0].contains("code") && notes[0].contains("documents"), "{notes:?}");
3983
3984 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::ALL, "view")
3986 .expect("full resolves with analyzers");
3987 assert!(omitted.is_empty(), "every view is answerable with analysis enabled");
3988 let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
3989 assert!(display_notes(&query, &ControlCoverage::NotObserved).0.is_empty());
3990 }
3991
3992 #[test]
4002 fn the_refused_controls_note_bounds_its_list_and_matches_its_remedy_to_the_reasons() {
4003 use crate::control::{
4004 ControlLimits, ControlObservation, ControlRefusalReason, RefusedControl,
4005 };
4006 let defaults = ControlLimits::default();
4007 for (reason, wanted, unwanted) in [
4008 (ControlRefusalReason::Budget, "--gitignore-budget", "--gitignore-line-limit"),
4009 (ControlRefusalReason::LineLimit, "--gitignore-line-limit", "--gitignore-budget"),
4010 ] {
4011 let coverage = ControlCoverage::Observed(ControlObservation {
4012 limits: defaults,
4013 applied: 7,
4014 rules: 0,
4015 refused: 1,
4016 refusals: vec![RefusedControl {
4017 path: Path::new("vendor").join(".gitignore"),
4018 reason,
4019 }],
4020 });
4021 let (note, tip) = refused_controls_note(&coverage, &AxisNames::FLAGS).expect("refusal");
4022 assert!(
4023 note.starts_with(
4024 "note: ignore classification incomplete: 1 ignore file not applied"
4025 )
4026 );
4027 assert!(note.contains("vendor"));
4028 assert!(!note.contains("--"), "facts do not repeat flag advice");
4029 let tip = tip.expect("bounded refusal has a remedy");
4030 assert!(tip.starts_with("tip:"));
4031 assert!(tip.contains(wanted));
4032 assert!(!tip.contains(unwanted));
4033 }
4034 let coverage = |limits, refused| {
4035 ControlCoverage::Observed(ControlObservation {
4036 limits,
4037 applied: 7,
4038 rules: 0,
4039 refused,
4040 refusals: (0..crate::MAX_RETAINED_ISSUES)
4041 .map(|i| RefusedControl {
4042 path: Path::new(&format!("d{i:02}")).join(".gitignore"),
4043 reason: ControlRefusalReason::Budget,
4044 })
4045 .collect(),
4046 })
4047 };
4048 let (note, tip) =
4049 refused_controls_note(&coverage(defaults, 1000), &AxisNames::FLAGS).expect("truncated");
4050 assert!(note.contains("995 more"));
4051 assert!(!note.contains("d05"));
4052 let tip = tip.expect("both bounded limits may explain unlisted refusals");
4053 assert!(tip.contains("--gitignore-budget") && tip.contains("--gitignore-line-limit"));
4054 let (_, tip) = refused_controls_note(
4055 &coverage(ControlLimits { line_limit: None, ..defaults }, 1000),
4056 &AxisNames::FLAGS,
4057 )
4058 .expect("truncated");
4059 assert!(!tip.expect("budget remedy").contains("--gitignore-line-limit"));
4060 let (_, tip) = refused_controls_note(
4061 &coverage(ControlLimits { budget: None, line_limit: None }, 64),
4062 &AxisNames::FLAGS,
4063 )
4064 .expect("retained refusal");
4065 assert!(tip.is_none(), "do not suggest raising unbounded limits");
4066 assert!(refused_controls_note(&ControlCoverage::NotObserved, &AxisNames::FLAGS).is_none());
4067 }
4068
4069 #[test]
4070 fn a_diagnostic_names_the_axes_the_requesting_surface_uses() {
4071 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
4072 .expect("full resolves without analyzers");
4073
4074 for (axes, mine, theirs) in [
4075 (&AxisNames::FLAGS, "--analyze", "analyze"),
4076 (&AxisNames::FIELDS, "analyze", "--analyze"),
4077 ] {
4078 let query = Query {
4079 views: selected.clone(),
4080 omitted_views: omitted.clone(),
4081 axes,
4082 ..Query::default()
4083 };
4084 let note = display_notes(&query, &ControlCoverage::NotObserved).1.remove(0);
4088 assert!(note.contains(&format!("add {mine} ")), "{note} must name {mine}");
4089 assert!(!note.contains(&format!("add {theirs} ")), "{note} must not name {theirs}");
4090 }
4091
4092 for (axes, view, analyze) in
4095 [(&AxisNames::FLAGS, "--view", "--analyze"), (&AxisNames::FIELDS, "view", "analyze")]
4096 {
4097 let error =
4098 crate::query::RequestError::ViewNeedsContent(ViewSpec::Documents).message(axes);
4099 assert!(error.starts_with(&format!("{view} documents")), "{error}");
4100 assert!(error.contains(&format!("add {analyze} ")), "{error}");
4101 let theirs = if analyze == "--analyze" { "analyze" } else { "--analyze" };
4102 assert!(!error.contains(&format!("add {theirs} ")), "{error}");
4103 }
4104 }
4105
4106 #[test]
4109 fn the_default_vocabulary_is_the_librarys_own() {
4110 assert_eq!(*Query::default().axes, AxisNames::FIELDS);
4111 }
4112
4113 #[test]
4114 fn a_depth_bound_marks_hidden_file_and_directory_rows_as_truncated() {
4115 let index = sample();
4116 let selection = Selection { depth: Some(Bound::Limit(1)), ..Selection::default() };
4117 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4118
4119 let src = tree.children.iter().find(|child| child.name == "src").expect("src");
4120 assert!(src.truncated, "src with a hidden directory child is truncated");
4121
4122 let docs = tree.children.iter().find(|child| child.name == "docs").expect("docs");
4123 assert!(docs.children.is_empty());
4124 assert!(docs.truncated, "significant file leaves are rows beyond the depth boundary");
4125 }
4126
4127 #[test]
4128 fn a_tree_limit_caps_section_rows_including_the_root() {
4129 let index = sample();
4130 let selection = Selection { limit: Some(Bound::Limit(1)), ..Selection::default() };
4131 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4132 assert_eq!(tree.children.len(), 0);
4133 assert!(tree.truncated);
4134 assert!(tree.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Rows));
4135 }
4136
4137 #[test]
4138 fn tree_share_is_root_relative_inclusive_and_does_not_hide_eleventh_sibling() {
4139 let mut index = Index::new("/root");
4140 let mut ops = Vec::new();
4141 for number in 0..11 {
4142 let directory = format!("d{number:02}");
4143 ops.push(upsert(&directory, EntryKind::Dir, Attrs::default()));
4144 ops.push(upsert(&format!("{directory}/file"), EntryKind::File, attrs(20, 1)));
4145 }
4146 ops.push(upsert("filler", EntryKind::File, attrs(780, 1)));
4147 index.apply(&Observation::new(ops)).expect("apply");
4148 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4149 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4150 assert_eq!(root.children.len(), 12, "eleven 2% directories plus the large file");
4151 assert!(root.children.iter().any(|child| child.name == "d10"));
4152 assert!(root.omissions.is_empty());
4153
4154 let selection = Selection {
4155 size: SizeMetric::Apparent,
4156 breadth: Some(Bound::Limit(10)),
4157 ..Selection::default()
4158 };
4159 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4160 assert_eq!(root.children.len(), 10);
4161 assert_eq!(root.omissions[0].reason, TreeOmissionReason::Breadth);
4162 assert_eq!(root.omissions[0].entries, 2);
4163 assert_eq!(root.omissions[0].bytes, Some(40));
4164 }
4165
4166 #[test]
4167 fn tree_threshold_keeps_exact_one_percent_file_leaf() {
4168 let mut index = Index::new("/root");
4169 index
4170 .apply(&Observation::new(vec![
4171 upsert("one-percent", EntryKind::File, attrs(10, 1)),
4172 upsert("below", EntryKind::File, attrs(9, 1)),
4173 upsert("rest", EntryKind::File, attrs(981, 1)),
4174 ]))
4175 .expect("apply");
4176 let root = tree_of(&run(
4177 &index,
4178 &query(
4179 &[ViewSpec::Tree],
4180 Selection { size: SizeMetric::Apparent, ..Selection::default() },
4181 ),
4182 ));
4183 assert_eq!(root.children.len(), 2);
4184 assert!(
4185 root.children
4186 .iter()
4187 .any(|child| child.name == "one-percent" && child.kind == EntryKind::File)
4188 );
4189 assert_eq!(root.omissions[0].reason, TreeOmissionReason::Share);
4190 assert_eq!(root.omissions[0].bytes, Some(9));
4191 }
4192
4193 fn mixed_remainder_index() -> Index {
4196 let mut index = Index::new("/root");
4197 index.apply_ok(&Observation::new(vec![
4198 upsert("A", EntryKind::Dir, attrs(0, 0)),
4199 upsert("A/a1", EntryKind::Dir, attrs(0, 0)),
4200 upsert("A/a1/x", EntryKind::Dir, attrs(0, 0)),
4201 upsert("A/a1/x/leaf", EntryKind::File, attrs(1960, 0)),
4202 upsert("A/a1/tiny", EntryKind::File, attrs(40, 0)),
4203 upsert("A/a2", EntryKind::Dir, attrs(0, 0)),
4204 upsert("A/a2/file", EntryKind::File, attrs(2000, 0)),
4205 upsert("B", EntryKind::Dir, attrs(0, 0)),
4206 upsert("B/b1", EntryKind::Dir, attrs(0, 0)),
4207 upsert("B/b1/file", EntryKind::File, attrs(2000, 0)),
4208 upsert("B/b2", EntryKind::Dir, attrs(0, 0)),
4209 upsert("B/b2/file", EntryKind::File, attrs(2000, 0)),
4210 upsert("C", EntryKind::Dir, attrs(0, 0)),
4211 upsert("C/c1", EntryKind::Dir, attrs(0, 0)),
4212 upsert("C/c1/first", EntryKind::File, attrs(1000, 0)),
4213 upsert("C/c1/second", EntryKind::File, attrs(1000, 0)),
4214 ]));
4215 index
4216 }
4217
4218 #[test]
4219 fn nested_mixed_omissions_have_one_exact_remainder_golden() {
4220 let index = mixed_remainder_index();
4221 let selection = Selection {
4222 size: SizeMetric::Apparent,
4223 depth: Some(Bound::Limit(3)),
4224 breadth: Some(Bound::Limit(2)),
4225 limit: Some(Bound::Limit(8)),
4226 min_share: Some(ShareThreshold::parse("0.5%").expect("share")),
4227 ..Selection::default()
4228 };
4229 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4230 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4231 panic!("expected tree")
4232 };
4233 let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("hidden content");
4234 assert_eq!((root.files, root.bytes, root.allocated), (7, 10_000, 10_752));
4235 assert_eq!(
4236 (remainder.files, remainder.bytes, remainder.allocated),
4237 (Some(2), Some(2_000), Some(2_048))
4238 );
4239 assert_eq!(remainder.reasons, vec![TreeOmissionReason::Breadth]);
4240 assert_eq!(
4241 crate::report_format::render(&report, crate::report_format::Format::Text, false)
4242 .expect("render"),
4243 include_str!("../../tests/golden/remainder-tree.txt"),
4244 );
4245 }
4246
4247 #[test]
4248 fn displayed_directories_represent_their_descendants_even_when_expansion_stops() {
4249 let index = mixed_remainder_index();
4250 let selection = Selection {
4251 size: SizeMetric::Apparent,
4252 depth: Some(Bound::Limit(1)),
4253 breadth: Some(Bound::All),
4254 limit: Some(Bound::All),
4255 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4256 ..Selection::default()
4257 };
4258 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4259 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4260 panic!("expected tree")
4261 };
4262 assert_eq!((root.files, root.bytes), (7, 10_000));
4263 assert_eq!(root.children.len(), 3);
4264 assert!(root.children.iter().all(|child| child.kind == EntryKind::Dir));
4265 assert!(root.children.iter().any(|child| !child.omissions.is_empty()));
4266 assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4267 let diagnostics = crate::report_format::diagnostic_lines(&report);
4268 assert!(!diagnostics.notes.iter().any(|note| note.contains("more covers")));
4269 assert!(diagnostics.notes.iter().any(|note| note.contains("depth 1")));
4270 assert!(
4271 diagnostics.tips.contains(&format!("tip: expand deeper: {}=all", report.axes.depth))
4272 );
4273
4274 let depth_zero = run(
4275 &index,
4276 &query(
4277 &[ViewSpec::Tree],
4278 Selection {
4279 size: SizeMetric::Apparent,
4280 depth: Some(Bound::Limit(0)),
4281 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4282 ..Selection::default()
4283 },
4284 ),
4285 );
4286 let Section::Tree { root: Some(root), omissions, .. } = &depth_zero.sections[0] else {
4287 panic!("expected tree")
4288 };
4289 let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("root alone");
4290 assert_eq!((remainder.files, remainder.bytes), (Some(7), Some(10_000)));
4291 }
4292
4293 #[test]
4294 fn selected_leaf_ledger_conserves_every_top_level_tree_partition() {
4295 let files = [
4299 ("src/main.rs", 100, 512, false),
4300 ("src/lib.rs", 200, 512, false),
4301 ("src/debug.log", 25, 512, true),
4302 ("docs/guide.md", 300, 512, false),
4303 ("build/cache/out.bin", 1_000, 1_024, true),
4304 ];
4305 let dirs = [("src", false), ("docs", false), ("build", true), ("build/cache", true)];
4306 let index = classified_sample();
4307 let within = |path: &str, parent: &Path| {
4308 parent.as_os_str().is_empty() || Path::new(path).starts_with(parent)
4309 };
4310 for (population, excluded) in [
4311 (IgnoredEntries::Include, None),
4312 (IgnoredEntries::Exclude, None),
4313 (IgnoredEntries::Only, None),
4314 (IgnoredEntries::Include, Some("build")),
4315 (IgnoredEntries::Include, Some("*.rs")),
4316 ] {
4317 let admitted = |path: &str, ignored: bool| {
4318 population.admits(ignored)
4319 && !matches!(excluded, Some("build") if Path::new(path).starts_with("build"))
4320 && !matches!(excluded, Some("*.rs") if Path::new(path)
4321 .extension()
4322 .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")))
4323 };
4324 let selected_files: Vec<_> = files
4325 .iter()
4326 .copied()
4327 .filter(|(path, _, _, ignored)| admitted(path, *ignored))
4328 .collect();
4329 let selected_dirs: Vec<_> =
4330 dirs.iter().copied().filter(|(path, ignored)| admitted(path, *ignored)).collect();
4331 for size in [SizeMetric::Apparent, SizeMetric::Allocated] {
4332 for share in ["0%", "1%", "50%", "100%"] {
4333 for depth in [Bound::Limit(0), Bound::Limit(1), Bound::All] {
4334 for breadth in
4335 [Bound::Limit(0), Bound::Limit(1), Bound::Limit(2), Bound::All]
4336 {
4337 for limit in
4338 [Bound::Limit(0), Bound::Limit(1), Bound::Limit(3), Bound::All]
4339 {
4340 let selection = Selection {
4341 ignored: population,
4342 exclude: excluded
4343 .map_or_else(Vec::new, |name| vec![pattern(name)]),
4344 size,
4345 min_share: Some(ShareThreshold::parse(share).expect("share")),
4346 depth: Some(depth),
4347 breadth: Some(breadth),
4348 limit: Some(limit),
4349 ..Selection::default()
4350 };
4351 let report = run(
4352 &index,
4353 &query(&[ViewSpec::Summary, ViewSpec::Tree], selection.clone()),
4354 );
4355 let Section::Summary(summary) = &report.sections[0] else {
4356 panic!("summary")
4357 };
4358 let Section::Tree { root, omissions, .. } = &report.sections[1]
4359 else {
4360 panic!("tree")
4361 };
4362 let expected = (
4363 selected_files.len() as u64,
4364 selected_dirs.len() as u64,
4365 selected_files.iter().map(|file| file.1).sum::<u64>(),
4366 selected_files.iter().map(|file| file.2).sum::<u64>(),
4367 );
4368 let context = format!("{selection:?}");
4369 assert_eq!(
4370 (summary.files, summary.dirs, summary.bytes, summary.allocated),
4371 expected,
4372 "summary: {context}"
4373 );
4374 if let Some(root) = root {
4375 assert_eq!(
4376 (root.files, root.dirs, root.bytes, root.allocated),
4377 expected,
4378 "root: {context}"
4379 );
4380 let mut stack = vec![root.as_ref()];
4381 while let Some(node) = stack.pop() {
4382 let contained: Vec<_> = selected_files
4383 .iter()
4384 .filter(|file| within(file.0, &node.path))
4385 .collect();
4386 let contained_dirs = selected_dirs
4387 .iter()
4388 .filter(|dir| {
4389 within(dir.0, &node.path)
4390 && Path::new(dir.0) != node.path
4391 })
4392 .count()
4393 as u64;
4394 let ignored: Vec<_> =
4395 contained.iter().filter(|file| file.3).collect();
4396 let ignored_dirs = selected_dirs
4397 .iter()
4398 .filter(|dir| {
4399 dir.1
4400 && within(dir.0, &node.path)
4401 && Path::new(dir.0) != node.path
4402 })
4403 .count()
4404 as u64;
4405 assert_eq!(
4406 (node.files, node.dirs, node.bytes, node.allocated),
4407 (
4408 contained.len() as u64,
4409 contained_dirs,
4410 contained.iter().map(|file| file.1).sum::<u64>(),
4411 contained.iter().map(|file| file.2).sum::<u64>(),
4412 ),
4413 "node {:?}: {context}",
4414 node.path
4415 );
4416 assert_eq!(
4417 node.ignored.map(|part| (
4418 part.files,
4419 part.dirs,
4420 part.bytes,
4421 part.allocated
4422 )),
4423 Some((
4424 ignored.len() as u64,
4425 ignored_dirs,
4426 ignored.iter().map(|file| file.1).sum::<u64>(),
4427 ignored.iter().map(|file| file.2).sum::<u64>(),
4428 )),
4429 "ignored {:?}: {context}",
4430 node.path
4431 );
4432 stack.extend(node.children.iter());
4433 }
4434 }
4435 let represented: Vec<_> = root
4436 .iter()
4437 .flat_map(|root| root.children.iter().map(|child| &child.path))
4438 .collect();
4439 let uncovered: Vec<_> = selected_files
4440 .iter()
4441 .filter(|file| {
4442 !represented.iter().any(|path| within(file.0, path))
4443 })
4444 .collect();
4445 let remainder =
4446 TreeRemainder::from_tree(root.as_deref(), omissions);
4447 if let Some(remainder) = remainder {
4448 assert_eq!(
4449 (remainder.files, remainder.bytes, remainder.allocated),
4450 (
4451 Some(uncovered.len() as u64),
4452 Some(uncovered.iter().map(|file| file.1).sum::<u64>()),
4453 Some(uncovered.iter().map(|file| file.2).sum::<u64>()),
4454 ),
4455 "remainder: {context}"
4456 );
4457 assert_eq!(
4458 remainder.ignored.map(|part| (part.bytes, part.allocated)),
4459 Some((
4460 uncovered
4461 .iter()
4462 .filter(|file| file.3)
4463 .map(|file| file.1)
4464 .sum(),
4465 uncovered
4466 .iter()
4467 .filter(|file| file.3)
4468 .map(|file| file.2)
4469 .sum(),
4470 )),
4471 "remainder ignored: {context}"
4472 );
4473 } else {
4474 assert!(uncovered.is_empty(), "missing remainder: {context}");
4475 }
4476 }
4477 }
4478 }
4479 }
4480 }
4481 }
4482 }
4483
4484 #[test]
4485 fn empty_tree_and_unrepresentable_remainder_stay_honest() {
4486 let empty = Index::new_with_scope("/root", crate::test_support::observing_controls());
4487 let report = run(
4488 &empty,
4489 &query(
4490 &[ViewSpec::Tree],
4491 Selection {
4492 depth: Some(Bound::Limit(0)),
4493 min_share: Some(ShareThreshold::parse("1%").expect("share")),
4494 ..Selection::default()
4495 },
4496 ),
4497 );
4498 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4499 panic!("empty tree")
4500 };
4501 assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (0, 0, 0, 0));
4502 assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4503
4504 let first = TreeOmission {
4505 reason: TreeOmissionReason::Share,
4506 entries: 1,
4507 files: Some(u64::MAX),
4508 bytes: Some(u64::MAX),
4509 allocated: Some(u64::MAX),
4510 ignored: Some(IgnoredSize { bytes: u64::MAX, allocated: u64::MAX }),
4511 };
4512 let second = TreeOmission {
4513 reason: TreeOmissionReason::Breadth,
4514 entries: 1,
4515 files: Some(1),
4516 bytes: Some(1),
4517 allocated: None,
4518 ignored: Some(IgnoredSize { bytes: 1, allocated: 1 }),
4519 };
4520 let remainder = TreeRemainder::from_tree(None, &[first, second]).expect("omissions");
4521 assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
4522 assert_eq!(remainder.ignored, None);
4523 }
4524
4525 #[test]
4526 fn unbounded_tree_expands_every_entry_without_a_remainder_or_diagnostics() {
4527 let index = mixed_remainder_index();
4528 let selection = Selection {
4529 size: SizeMetric::Apparent,
4530 depth: Some(Bound::All),
4531 breadth: Some(Bound::All),
4532 limit: Some(Bound::All),
4533 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4534 ..Selection::default()
4535 };
4536 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4537 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4538 panic!("expected tree")
4539 };
4540 let mut paths = std::collections::BTreeSet::new();
4541 let mut stack = vec![root.as_ref()];
4542 while let Some(node) = stack.pop() {
4543 assert!(node.omissions.is_empty() && !node.truncated);
4544 paths.insert(node.path.to_string_lossy().replace('\\', "/"));
4545 stack.extend(&node.children);
4546 }
4547 assert_eq!(
4548 paths,
4549 [
4550 "",
4551 "A",
4552 "A/a1",
4553 "A/a1/tiny",
4554 "A/a1/x",
4555 "A/a1/x/leaf",
4556 "A/a2",
4557 "A/a2/file",
4558 "B",
4559 "B/b1",
4560 "B/b1/file",
4561 "B/b2",
4562 "B/b2/file",
4563 "C",
4564 "C/c1",
4565 "C/c1/first",
4566 "C/c1/second"
4567 ]
4568 .into_iter()
4569 .map(str::to_owned)
4570 .collect()
4571 );
4572 assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (7, 9, 10_000, 10_752));
4573 assert!(omissions.is_empty());
4574 assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4575 assert!(report.notes.is_empty() && report.tips.is_empty());
4576 }
4577
4578 #[test]
4579 fn an_unlisted_hidden_branch_makes_remainder_counts_and_sizes_unknown() {
4580 let mut index = Index::new("/root");
4581 index.apply_ok(&Observation::new(vec![
4582 upsert("known", EntryKind::File, attrs(100, 0)),
4583 upsert("denied", EntryKind::Dir, attrs(0, 0)),
4584 ]));
4585 index.set_initial_scan_freshness(&[crate::Error::io(
4586 Path::new("/root").join("denied"),
4587 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
4588 )]);
4589 let selection = Selection {
4590 size: SizeMetric::Apparent,
4591 depth: Some(Bound::Limit(0)),
4592 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4593 ..Selection::default()
4594 };
4595 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4596 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4597 panic!("expected tree")
4598 };
4599 let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("depth bound");
4600 assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
4601 assert_eq!(remainder.reasons, vec![TreeOmissionReason::Depth]);
4602 let text = crate::report_format::render(&report, crate::report_format::Format::Text, false)
4603 .expect("render");
4604 assert!(text.contains("— unknown … and more files (count unknown)"));
4605 }
4606
4607 #[test]
4611 fn partial_tree_keeps_default_share_pruning_golden() {
4612 let mut index = Index::new("/root");
4613 index.apply_ok(&Observation::new(vec![
4614 upsert("large", EntryKind::File, attrs(9898, 0)),
4615 upsert("one-percent", EntryKind::File, attrs(100, 0)),
4616 upsert("tiny", EntryKind::File, attrs(1, 0)),
4617 upsert("zero", EntryKind::File, attrs(0, 0)),
4618 upsert("small", EntryKind::Dir, attrs(0, 0)),
4619 upsert("small/tiny", EntryKind::File, attrs(1, 0)),
4620 upsert("denied", EntryKind::Dir, attrs(0, 0)),
4621 ]));
4622 index.set_initial_scan_freshness(&[crate::Error::io(
4623 Path::new("/root").join("denied"),
4624 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
4625 )]);
4626 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4627 let report = run(&index, &query(&[ViewSpec::Tree], selection.clone()));
4628 assert!(!report.status.complete);
4629 assert_eq!(
4630 crate::report_format::render(&report, crate::report_format::Format::Text, false)
4631 .expect("render"),
4632 include_str!("../../tests/golden/partial-tree.txt"),
4633 );
4634 let filtered = run(
4637 &index,
4638 &query(
4639 &[ViewSpec::Tree],
4640 Selection {
4641 exclude: vec![pattern("large")],
4642 min_share: Some(ShareThreshold::parse("50%").expect("share")),
4643 ..selection.clone()
4644 },
4645 ),
4646 );
4647 let filtered_root = tree_of(&filtered);
4648 assert_eq!(filtered_root.children.len(), 2);
4649 assert_eq!(filtered_root.children[0].name, "one-percent");
4650 assert_eq!(filtered_root.children[1].name, "denied");
4651 let unbounded = run(
4652 &index,
4653 &query(
4654 &[ViewSpec::Tree],
4655 Selection {
4656 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4657 ..selection
4658 },
4659 ),
4660 );
4661 assert_eq!(tree_of(&unbounded).children.len(), 6, "zero share lifts only share pruning");
4662 }
4663
4664 #[test]
4665 fn tree_zero_and_composed_caps_keep_typed_first_boundaries() {
4666 let index = sample();
4667 let selection = Selection { limit: Some(Bound::Limit(0)), ..Selection::default() };
4668 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4669 let Section::Tree { root: None, omissions, .. } = &report.sections[0] else {
4670 panic!("zero row cap must omit the root")
4671 };
4672 assert_eq!(omissions[0].reason, TreeOmissionReason::Rows);
4673 assert_eq!(omissions[0].entries, 1);
4674
4675 let selection = Selection {
4676 size: SizeMetric::Apparent,
4677 depth: Some(Bound::Limit(1)),
4678 breadth: Some(Bound::Limit(1)),
4679 limit: Some(Bound::Limit(2)),
4680 min_share: Some(ShareThreshold::parse("1%").expect("share")),
4681 ..Selection::default()
4682 };
4683 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4684 assert_eq!(root.children.len(), 1);
4685 assert!(
4686 root.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Breadth)
4687 );
4688 assert!(
4689 root.children[0]
4690 .omissions
4691 .iter()
4692 .any(|omission| omission.reason == TreeOmissionReason::Depth)
4693 );
4694 }
4695
4696 #[test]
4697 fn requesting_more_views_never_changes_another_views_answer() {
4698 let index = sample();
4700 let alone = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
4701 let together = run(
4702 &index,
4703 &query(
4704 &[ViewSpec::Extensions, ViewSpec::Tree, ViewSpec::Summary],
4705 Selection::default(),
4706 ),
4707 );
4708 let with_others = match &together.sections[0] {
4709 Section::Extensions { rows, .. } => rows.clone(),
4710 other => panic!("expected types first, got {other:?}"),
4711 };
4712
4713 assert_eq!(alone.len(), with_others.len());
4714 for (left, right) in alone.iter().zip(with_others.iter()) {
4715 assert_eq!(
4716 (&left.extension, left.files, left.bytes),
4717 (&right.extension, right.files, right.bytes)
4718 );
4719 }
4720 assert_eq!(together.sections.len(), 3, "one section per view, in request order");
4721 assert_eq!(together.sections[1].view(), ViewSpec::Tree);
4722 assert_eq!(together.sections[2].view(), ViewSpec::Summary);
4723 }
4724
4725 #[test]
4726 fn analyzed_unfiltered_views_together_match_independent_answers_and_each_view_alone() {
4727 const RUST: &str = "fn main() {\n println!(\"hi\");\n}\n";
4728 const MARKDOWN: &str = "# Guide\n\nA small useful guide.\n";
4729 const TEXT: &str = "plain notes here\n";
4730 let root = tempfile::tempdir().expect("root");
4731 fs::create_dir_all(root.path().join("src")).expect("src");
4732 fs::create_dir_all(root.path().join("docs")).expect("docs");
4733 fs::write(root.path().join("src/main.rs"), RUST).expect("rust");
4734 fs::write(root.path().join("docs/guide.md"), MARKDOWN).expect("markdown");
4735 fs::write(root.path().join("notes.txt"), TEXT).expect("text");
4736 for (path, seconds) in [("src/main.rs", 10), ("notes.txt", 20), ("docs/guide.md", 30)] {
4737 fs::File::options()
4738 .write(true)
4739 .open(root.path().join(path))
4740 .expect("open for timestamp")
4741 .set_times(
4742 fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(seconds)),
4743 )
4744 .expect("set timestamp");
4745 }
4746 let (mut index, _) = crate::scan::scan_into_index(
4747 root.path(),
4748 &crate::ScanConfig { read_controls: false, ..crate::ScanConfig::default() },
4749 )
4750 .expect("scan");
4751 crate::content::analyze_index(
4752 &mut index,
4753 crate::content::AnalysisRequest {
4754 profile: AnalysisSet::ALL,
4755 ..crate::content::AnalysisRequest::default()
4756 },
4757 );
4758
4759 let views = [
4760 ViewSpec::Types,
4761 ViewSpec::Families,
4762 ViewSpec::Languages,
4763 ViewSpec::Documents,
4764 ViewSpec::Files,
4765 ViewSpec::Largest,
4766 ViewSpec::Recent,
4767 ViewSpec::Summary,
4768 ViewSpec::Tree,
4769 ViewSpec::Extensions,
4770 ];
4771 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4772 let together = run(&index, &query(&views, selection.clone()));
4773 for (i, view) in views.iter().enumerate() {
4774 let alone = run(&index, &query(&[*view], selection.clone()));
4775 assert_eq!(
4776 format!("{:?}", together.sections[i]),
4777 format!("{:?}", alone.sections[0]),
4778 "{view:?} changed when requested with the other views"
4779 );
4780 }
4781
4782 for (at, view, files) in [(0, ViewSpec::Types, 3), (1, ViewSpec::Families, 3)] {
4783 let Section::Metrics { view: actual, summary } = &together.sections[at] else {
4784 panic!("expected {view:?} metrics")
4785 };
4786 assert_eq!(*actual, view);
4787 assert_eq!(summary.total.files, files);
4788 assert_eq!(summary.total.analyzed_files, files);
4789 }
4790 let Section::Metrics { summary: languages, .. } = &together.sections[2] else {
4791 panic!("languages")
4792 };
4793 assert_eq!(languages.total.files, 1);
4794 assert_eq!(languages.total.metrics.code_lines, Some(3));
4795 let Section::Metrics { summary: documents, .. } = &together.sections[3] else {
4796 panic!("documents")
4797 };
4798 assert_eq!(documents.total.files, 2);
4799 assert_eq!(documents.total.analyzed_files, 2);
4800 assert_eq!(documents.total.document_metric_files, 2);
4801 assert_eq!(documents.total.document_raw_words, 8);
4802 assert_eq!(documents.total.document_word_stats.logical_words(), 8);
4803 assert_eq!(documents.total.share, MetricShare { numerator: 8, denominator: 8 });
4804
4805 let Section::Files { rows: files, total, .. } = &together.sections[4] else {
4806 panic!("files")
4807 };
4808 assert_eq!(*total, 5);
4809 assert_eq!(
4810 files.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
4811 ["docs", "docs/guide.md", "notes.txt", "src", "src/main.rs"].map(Path::new).to_vec()
4812 );
4813 let Section::Files { rows: largest, total, .. } = &together.sections[5] else {
4814 panic!("largest")
4815 };
4816 assert_eq!(*total, 3);
4817 assert_eq!(
4818 largest.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
4819 ["src/main.rs", "docs/guide.md", "notes.txt"].map(Path::new).to_vec()
4820 );
4821 let Section::Files { rows: recent, total, .. } = &together.sections[6] else {
4822 panic!("recent")
4823 };
4824 assert_eq!(*total, 3);
4825 assert_eq!(
4826 recent.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
4827 ["docs/guide.md", "notes.txt", "src/main.rs"].map(Path::new).to_vec()
4828 );
4829 let Section::Summary(summary) = &together.sections[7] else { panic!("summary") };
4830 assert_eq!((summary.files, summary.dirs), (3, 2));
4831 assert_eq!(
4832 summary.bytes,
4833 u64::try_from(RUST.len() + MARKDOWN.len() + TEXT.len()).expect("fixture bytes")
4834 );
4835 let Section::Tree { root: Some(tree), .. } = &together.sections[8] else { panic!("tree") };
4836 assert_eq!((tree.files, tree.dirs), (3, 2));
4837 let Section::Extensions { rows, total, .. } = &together.sections[9] else {
4838 panic!("extensions")
4839 };
4840 assert_eq!((*total, rows.len()), (3, 3));
4841 }
4842
4843 #[test]
4844 fn one_pass_metric_summaries_match_independent_views() {
4845 let root = tempfile::tempdir().expect("root");
4846 fs::write(root.path().join("main.rs"), "fn main() {}\n").expect("rust");
4847 fs::write(root.path().join("guide.md"), "# Guide\n\nWords.\n").expect("markdown");
4848 fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
4849 fs::create_dir(root.path().join("generated")).expect("generated directory");
4850 fs::write(root.path().join("generated/app.js"), "const a = 1;\nconst b = 2;\n")
4851 .expect("ignored code");
4852 fs::write(root.path().join("script"), "#!/bin/sh\necho hello\n").expect("detected code");
4853 let (mut index, _) =
4854 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
4855 crate::content::analyze_index(
4856 &mut index,
4857 crate::content::AnalysisRequest {
4858 profile: AnalysisSet::ALL,
4859 ..crate::content::AnalysisRequest::default()
4860 },
4861 );
4862
4863 let views = [
4864 ViewSpec::Types,
4865 ViewSpec::Summary,
4866 ViewSpec::Families,
4867 ViewSpec::Languages,
4868 ViewSpec::Documents,
4869 ViewSpec::Code,
4870 ];
4871 let request = query(&views, Selection::default());
4872 let rows = every_entry(&index);
4873 let summaries = metric_summaries(&views, &index, &request, AnalysisSet::ALL, &rows);
4874
4875 assert!(summaries[1].is_none(), "non-metric views keep their own projection");
4876 assert!(summaries[5].is_none(), "Code keeps its admitted-content classification");
4877 for (position, view) in
4878 views.iter().copied().enumerate().filter(|(_, view)| needs_metric_resolution(*view))
4879 {
4880 let independent =
4881 metric_summary(view, &index, &request, AnalysisSet::ALL, None, Some(&rows));
4882 assert_eq!(
4883 format!("{:?}", summaries[position].as_ref().expect("metric summary")),
4884 format!("{independent:?}"),
4885 "{view:?} changed in the one-pass multi-view aggregation"
4886 );
4887 }
4888
4889 let views = views.into_iter().filter(|view| *view != ViewSpec::Summary).collect::<Vec<_>>();
4894 for ignored in [IgnoredEntries::Include, IgnoredEntries::Exclude, IgnoredEntries::Only] {
4895 for sort in [None, Some(SortKey::Metric("code_lines")), Some(SortKey::Name)] {
4896 for min_share in [None, Some(ShareThreshold::parse("50%").expect("share"))] {
4897 let selection = Selection {
4898 ignored,
4899 sort,
4900 min_share,
4901 limit: Some(Bound::Limit(1)),
4902 ..Selection::default()
4903 };
4904 let together = run(&index, &query(&views, selection.clone()));
4905 for (position, view) in views.iter().enumerate() {
4906 let alone = run(&index, &query(&[*view], selection.clone()));
4907 assert_eq!(
4908 format!("{:?}", together.sections[position]),
4909 format!("{:?}", alone.sections[0]),
4910 "{view:?} differs for {selection:?}"
4911 );
4912 }
4913 }
4914 }
4915 }
4916 }
4917
4918 #[test]
4919 fn code_overview_keeps_a_complete_language_table_and_population_contributions() {
4920 let root = tempfile::tempdir().expect("root");
4921 fs::create_dir_all(root.path().join("generated")).expect("generated");
4922 fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
4923 fs::create_dir_all(root.path().join("src")).expect("src");
4924 fs::write(root.path().join("src/main.rs"), "fn main() {\n}\n// comment\n").expect("rust");
4925 fs::write(
4926 root.path().join("generated/app.js"),
4927 "// Code generated by fixture\nconst answer = 42;\n",
4928 )
4929 .expect("javascript");
4930 fs::write(root.path().join("README.md"), "# Guide\n").expect("documentation");
4931 fs::write(root.path().join("mystery.widget"), "opaque text\n").expect("unknown type");
4932 let (mut index, _) =
4933 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
4934 crate::content::analyze_index(
4935 &mut index,
4936 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
4937 );
4938 let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
4939 let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
4940 assert_eq!(overview.selected.metrics.code_lines, 3);
4941 assert_eq!(overview.selected.metrics.comment_lines, 2);
4942 assert_eq!(overview.selected.analyzed_files, 2);
4943 assert_eq!(overview.analyzed_languages, 2);
4944 assert_eq!(overview.unclassified_files, 2); assert_eq!(overview.languages.len(), 2);
4946 assert_eq!(overview.languages[0].share.denominator, 3);
4947 assert_eq!(overview.non_ignored.as_ref().expect("classified").metrics.code_lines, 2);
4948 assert_eq!(overview.ignored.as_ref().expect("classified").metrics.code_lines, 1);
4949 assert_eq!(overview.unknown.source_files, 0);
4950
4951 for (population, expected_lines) in
4952 [(IgnoredEntries::Exclude, 2), (IgnoredEntries::Only, 1)]
4953 {
4954 let selected = run(
4955 &index,
4956 &query(
4957 &[ViewSpec::Code],
4958 Selection { ignored: population, ..Selection::default() },
4959 ),
4960 );
4961 let Section::Code(overview) = &selected.sections[0] else { panic!("population") };
4962 assert_eq!(overview.selected.metrics.code_lines, expected_lines);
4963 assert!(overview.non_ignored.is_none() && overview.ignored.is_none());
4964 assert_eq!(overview.unknown.source_files, 0);
4965 }
4966
4967 let threshold = run(
4968 &index,
4969 &query(
4970 &[ViewSpec::Code],
4971 Selection {
4972 min_share: Some(ShareThreshold::parse("50%").expect("share")),
4973 ..Selection::default()
4974 },
4975 ),
4976 );
4977 let Section::Code(overview) = &threshold.sections[0] else { panic!("threshold") };
4978 assert_eq!(overview.selected.metrics.code_lines, 3);
4979 assert_eq!(overview.total_languages, 1);
4980 assert_eq!(overview.share_omitted, 1);
4981 assert_eq!(overview.languages[0].language, "rust");
4982
4983 let bounded = run(
4984 &index,
4985 &query(
4986 &[ViewSpec::Code],
4987 Selection { limit: Some(Bound::Limit(0)), ..Selection::default() },
4988 ),
4989 );
4990 let Section::Code(overview) = &bounded.sections[0] else { panic!("bounded") };
4991 assert_eq!(overview.selected.metrics.code_lines, 3);
4992 assert_eq!(overview.total_languages, 2);
4993 assert_eq!(overview.share_omitted, 0);
4994 assert!(overview.languages.is_empty());
4995
4996 let code_and_extensions = run(
4997 &index,
4998 &query(
4999 &[ViewSpec::Languages, ViewSpec::Extensions],
5000 Selection {
5001 min_share: Some(ShareThreshold::parse("100%").expect("share")),
5002 ..Selection::default()
5003 },
5004 ),
5005 );
5006 let Section::Metrics { summary, .. } = &code_and_extensions.sections[0] else {
5007 panic!("languages")
5008 };
5009 let Section::Extensions { rows, total, share_omitted } = &code_and_extensions.sections[1]
5010 else {
5011 panic!("extensions")
5012 };
5013 assert_eq!(summary.total.metrics.code_lines, Some(3));
5014 assert!(summary.rows.is_empty() && summary.total_rows == 0);
5015 assert_eq!(summary.share_omitted, 2);
5016 assert!(rows.is_empty() && *total == 0);
5017 assert!(*share_omitted > 0);
5018
5019 let reversed_code = run(
5020 &index,
5021 &query(&[ViewSpec::Code], Selection { reverse: true, ..Selection::default() }),
5022 );
5023 let Section::Code(reversed) = &reversed_code.sections[0] else { panic!("reversed code") };
5024 assert_eq!(reversed.languages[0].language, "javascript");
5025 assert_eq!(reversed.languages[1].language, "rust");
5026
5027 let named_code = run(
5028 &index,
5029 &query(
5030 &[ViewSpec::Code],
5031 Selection { sort: Some(SortKey::Name), ..Selection::default() },
5032 ),
5033 );
5034 let Section::Code(named) = &named_code.sections[0] else { panic!("named code") };
5035 assert_eq!(named.languages[0].language, "javascript");
5036 assert_eq!(named.languages[1].language, "rust");
5037
5038 let sized_code = run(
5039 &index,
5040 &query(
5041 &[ViewSpec::Code],
5042 Selection {
5043 sort: Some(SortKey::Size),
5044 size: SizeMetric::Apparent,
5045 ..Selection::default()
5046 },
5047 ),
5048 );
5049 let Section::Code(sized) = &sized_code.sections[0] else { panic!("sized code") };
5050 assert_eq!(sized.languages[0].language, "javascript");
5051
5052 let files = |reverse| {
5053 let selection = Selection {
5054 kinds: vec![EntryKind::File],
5055 sort: Some(SortKey::Metric("code_lines")),
5056 reverse,
5057 ..Selection::default()
5058 };
5059 run(&index, &query(&[ViewSpec::Files], selection))
5060 };
5061 let descending = files(false);
5062 let Section::Files { rows, .. } = &descending.sections[0] else { panic!("files") };
5063 assert_eq!(rows[0].path, PathBuf::from("src/main.rs"));
5064 assert_eq!(rows[0].sort_value, Some(2));
5065 assert_eq!(rows[1].path, PathBuf::from("generated/app.js"));
5066 assert_eq!(rows[1].sort_value, Some(1));
5067 assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5068 let classification = rows[1].classification.as_ref().expect("file classification");
5069 assert!(classification.flags.generated);
5070
5071 let ascending = files(true);
5072 let Section::Files { rows, .. } = &ascending.sections[0] else { panic!("files") };
5073 assert_eq!((rows[0].sort_value, rows[1].sort_value), (Some(1), Some(2)));
5074 assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5075
5076 let mut dir_query = query(
5077 &[ViewSpec::List],
5078 Selection {
5079 kinds: vec![EntryKind::Dir],
5080 sort: Some(SortKey::Metric("code_lines")),
5081 ..Selection::default()
5082 },
5083 );
5084 dir_query.format = crate::report_format::Format::Json;
5085 let dirs = run(&index, &dir_query);
5086 let Section::Files { rows, .. } = &dirs.sections[0] else { panic!("directories") };
5087 assert_eq!((rows[0].path.as_path(), rows[0].sort_value), (Path::new("src"), Some(2)));
5088 assert_eq!((rows[1].path.as_path(), rows[1].sort_value), (Path::new("generated"), Some(1)));
5089 }
5090
5091 #[test]
5092 fn code_overview_counts_selected_directory_members_once() {
5093 let root = tempfile::tempdir().expect("root");
5094 fs::create_dir_all(root.path().join("src/deep")).expect("directories");
5095 fs::write(root.path().join("src/main.rs"), "fn main() {}\n").expect("main");
5096 fs::write(root.path().join("src/deep/keep.rs"), "fn keep() {}\n").expect("keep");
5097 fs::write(root.path().join("src/deep/skip.rs"), "fn skip() {}\n").expect("skip");
5098 fs::write(root.path().join("outside.rs"), "fn outside() {}\n").expect("outside");
5099 let (mut index, _) =
5100 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5101 crate::content::analyze_index(
5102 &mut index,
5103 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5104 );
5105 let selection = Selection {
5106 include: vec![pattern("src"), pattern("deep")],
5107 exclude: vec![pattern("skip.rs")],
5108 ..Selection::default()
5109 };
5110 let answer = run(&index, &query(&[ViewSpec::Code], selection));
5111 let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5112 assert_eq!(overview.selected.source_files, 2);
5113 assert_eq!(overview.selected.analyzed_files, 2);
5114 assert_eq!(overview.selected.metrics.code_lines, 2);
5115 assert_eq!(overview.languages.len(), 1);
5116 assert_eq!(overview.languages[0].selected.source_files, 2);
5117 }
5118
5119 #[test]
5120 fn code_overview_uses_retained_content_detection_for_ambiguous_sources() {
5121 let root = tempfile::tempdir().expect("root");
5122 fs::write(root.path().join("ambiguous.h"), "namespace demo { int value; }\n")
5123 .expect("header");
5124 fs::write(root.path().join("script.inc"), "# vim: set filetype=rust:\nfn main() {}\n")
5125 .expect("modeline");
5126 let (mut index, _) =
5127 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5128 crate::content::analyze_index(
5129 &mut index,
5130 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5131 );
5132 let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
5133 let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5134 assert_eq!(overview.selected.source_files, 2);
5135 assert_eq!(overview.selected.analyzed_files, 2);
5136 assert!(overview.languages.iter().any(|row| row.language == "cpp"));
5137 assert!(overview.languages.iter().any(|row| row.language == "rust"));
5138 }
5139
5140 #[test]
5141 fn refused_control_subtrees_do_not_enter_a_known_ignored_population() {
5142 let root = tempfile::tempdir().expect("root");
5143 fs::create_dir_all(root.path().join("guarded")).expect("directory");
5144 fs::write(root.path().join("known.rs"), "fn known() {}\n").expect("known source");
5145 fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("refused control");
5146 fs::write(root.path().join("guarded/uncertain.rs"), "fn uncertain() {}\n")
5147 .expect("uncertain source");
5148 let config = crate::ScanConfig {
5149 control_limits: crate::control::ControlLimits {
5150 line_limit: Some(1),
5151 ..crate::control::ControlLimits::default()
5152 },
5153 ..crate::ScanConfig::default()
5154 };
5155 let (index, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
5156 assert_eq!(index.ignored_classification(Path::new("known.rs")), Some(false));
5157 assert_eq!(index.ignored_classification(Path::new("guarded/uncertain.rs")), None);
5158 for selection in
5159 [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
5160 {
5161 let report = run(
5162 &index,
5163 &query(&[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions], selection),
5164 );
5165 let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
5166 let Section::Tree { root: Some(tree), .. } = &report.sections[1] else {
5167 panic!("tree")
5168 };
5169 let Section::Extensions { rows, .. } = &report.sections[2] else {
5170 panic!("extensions")
5171 };
5172 assert_eq!(summary.ignored, None);
5173 assert_eq!(tree.ignored, None);
5174 assert_eq!(
5175 tree.children
5176 .iter()
5177 .find(|node| node.path == Path::new("known.rs"))
5178 .and_then(|node| node.ignored),
5179 Some(IgnoredTally::default())
5180 );
5181 assert!(
5182 tree.children
5183 .iter()
5184 .find(|node| node.path == Path::new("guarded"))
5185 .is_some_and(|node| node.ignored.is_none())
5186 );
5187 assert!(rows.iter().all(|row| row.ignored.is_none()));
5188 assert!(
5189 report
5190 .notes
5191 .iter()
5192 .any(|note| note.contains("gitignored subtotals are unavailable"))
5193 );
5194 assert!(
5195 crate::report_format::report_notes(&report)
5196 .iter()
5197 .all(|note| !note.contains("gitignored sizes are included"))
5198 );
5199 }
5200 let hidden_report = run(
5201 &index,
5202 &query(
5203 &[ViewSpec::Tree],
5204 Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
5205 ),
5206 );
5207 let Section::Tree { root, omissions, .. } = &hidden_report.sections[0] else {
5208 panic!("expected tree")
5209 };
5210 let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows");
5211 assert!(hidden.bytes.is_some() && hidden.ignored.is_none());
5212 for population in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
5213 let answer = run(
5214 &index,
5215 &query(
5216 &[ViewSpec::Files],
5217 Selection {
5218 kinds: vec![EntryKind::File],
5219 ignored: population,
5220 ..Selection::default()
5221 },
5222 ),
5223 );
5224 let Section::Files { rows, .. } = &answer.sections[0] else { panic!("files") };
5225 assert!(rows.iter().all(|row| !row.path.starts_with("guarded")));
5226 assert_eq!(
5227 rows.iter().any(|row| row.path == Path::new("known.rs")),
5228 population == IgnoredEntries::Exclude
5229 );
5230 }
5231 }
5232
5233 #[test]
5234 fn a_report_derives_provenance_from_its_index() {
5235 let index = sample();
5236 let report = run(&index, &query(&[ViewSpec::Summary], Selection::default()));
5237 assert_eq!(report.provenance.source, ReportSource::ColdScan);
5238 assert!(report.status.complete);
5239 assert!(report.provenance.scan_started_at.is_some());
5240 assert_eq!(report.provenance.generated_at, generated_at());
5241 assert_eq!(report.root, Path::new("/root"));
5242 }
5243
5244 #[test]
5245 fn reporting_is_pure_and_repeatable() {
5246 let index = sample();
5247 let request = query(&[ViewSpec::Tree, ViewSpec::Extensions], Selection::default());
5248 assert_eq!(format!("{:?}", run(&index, &request)), format!("{:?}", run(&index, &request)));
5249 }
5250
5251 #[test]
5252 fn metadata_grouping_views_use_the_generic_metric_projection() {
5253 let index = sample();
5254 let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5255 let report = run(
5256 &index,
5257 &query(&[ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages], apparent),
5258 );
5259 let Section::Metrics { summary: types, .. } = &report.sections[0] else {
5260 panic!("expected type metrics")
5261 };
5262 let rust = types.rows.iter().find(|row| row.id == "rust").expect("rust");
5263 assert_eq!((rust.files, rust.bytes), (3, 350));
5264 assert_eq!((rust.share.numerator, rust.share.denominator), (350, 657));
5265 assert_eq!(types.share_metric, ShareMetric::ApparentBytes);
5266
5267 let Section::Metrics { summary: families, .. } = &report.sections[1] else {
5268 panic!("expected family metrics")
5269 };
5270 assert!(families.rows.iter().any(|row| row.id == "code"));
5271 assert!(families.rows.iter().any(|row| row.id == "prose"));
5272 assert_eq!(families.share_metric, ShareMetric::ApparentBytes);
5273
5274 let Section::Metrics { summary: languages, .. } = &report.sections[2] else {
5275 panic!("expected language metrics")
5276 };
5277 let rust = languages.rows.iter().find(|row| row.id == "rust").expect("rust");
5278 assert_eq!((rust.files, rust.bytes), (3, 350));
5279 assert_eq!((rust.share.numerator, rust.share.denominator), (350, 350));
5280 assert_eq!(languages.share_metric, ShareMetric::ApparentBytes);
5281 }
5282
5283 const CONTROL: &str = ".gitignore";
5285
5286 fn classified_sample() -> Index {
5288 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5289 index
5290 .apply(&Observation::new(vec![
5291 Op::ControlUpsert {
5292 path: PathBuf::from(CONTROL),
5293 source: b"build/\n*.log\n".to_vec(),
5294 },
5295 upsert("src", EntryKind::Dir, Attrs::default()),
5296 upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
5297 upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
5298 upsert("src/debug.log", EntryKind::File, attrs(25, 70)),
5299 upsert("docs", EntryKind::Dir, Attrs::default()),
5300 upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
5301 upsert("build", EntryKind::Dir, Attrs::default()),
5302 upsert("build/cache", EntryKind::Dir, Attrs::default()),
5303 upsert("build/cache/out.bin", EntryKind::File, attrs(1_000, 60)),
5304 ]))
5305 .expect("apply");
5306 index
5307 }
5308
5309 #[test]
5310 fn tree_entry_classification_is_independent_of_selected_subtree_tallies() {
5311 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5312 index.apply_ok(&Observation::new(vec![
5313 Op::ControlUpsert {
5314 path: PathBuf::from(CONTROL),
5315 source: b"empty/\nzero.txt\n*.log\n".to_vec(),
5316 },
5317 upsert("empty", EntryKind::Dir, Attrs::default()),
5318 upsert("zero.txt", EntryKind::File, attrs(0, 0)),
5319 upsert("mixed", EntryKind::Dir, Attrs::default()),
5320 upsert("mixed/ignored.log", EntryKind::File, attrs(5, 5)),
5321 upsert("mixed/ordinary.rs", EntryKind::File, attrs(7, 7)),
5322 ]));
5323 let selection = |ignored| Selection {
5324 ignored,
5325 depth: Some(Bound::All),
5326 breadth: Some(Bound::All),
5327 limit: Some(Bound::All),
5328 min_share: Some(ShareThreshold::parse("0%").expect("share")),
5329 ..Selection::default()
5330 };
5331 let included =
5332 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include))));
5333 assert_eq!(included.entry_ignored, Some(false));
5334 let child = |name: &str| included.children.iter().find(|row| row.name == name).expect(name);
5335 let empty = child("empty");
5336 assert_eq!((empty.kind, empty.bytes, empty.entry_ignored), (EntryKind::Dir, 0, Some(true)));
5337 assert_eq!(child("zero.txt").entry_ignored, Some(true));
5338 let mixed = child("mixed");
5339 assert_eq!(mixed.entry_ignored, Some(false));
5340 assert_eq!(mixed.ignored.expect("known").bytes, 5);
5341 assert_eq!(
5342 mixed
5343 .children
5344 .iter()
5345 .find(|row| row.name == "ignored.log")
5346 .expect("ignored leaf")
5347 .entry_ignored,
5348 Some(true)
5349 );
5350
5351 let only =
5352 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Only))));
5353 assert_eq!(only.entry_ignored, Some(false));
5354 let mixed_only = only.children.iter().find(|row| row.name == "mixed").expect("ancestor");
5355 assert_eq!(mixed_only.entry_ignored, Some(false));
5356 assert_eq!(mixed_only.ignored.expect("known").bytes, 5);
5357
5358 let mut unobserved =
5359 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5360 unobserved.apply_ok(&Observation::new(vec![upsert(
5361 "plain",
5362 EntryKind::Dir,
5363 Attrs::default(),
5364 )]));
5365 let unknown = tree_of(&run(
5366 &unobserved,
5367 &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include)),
5368 ));
5369 assert_eq!(unknown.entry_ignored, None);
5370 assert_eq!(unknown.children[0].entry_ignored, None);
5371 }
5372
5373 #[test]
5374 fn ignored_size_interpretation_note_appears_once_only_when_relevant() {
5375 let classified = classified_sample();
5376 for (population, expected) in
5377 [(IgnoredEntries::Include, 1), (IgnoredEntries::Exclude, 0), (IgnoredEntries::Only, 0)]
5378 {
5379 let report = run(
5380 &classified,
5381 &query(
5382 &[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions],
5383 Selection {
5384 ignored: population,
5385 depth: Some(Bound::All),
5386 min_share: Some(ShareThreshold::parse("0%").expect("share")),
5387 ..Selection::default()
5388 },
5389 ),
5390 );
5391 let notes = crate::report_format::report_notes(&report);
5392 assert_eq!(
5393 notes.iter().filter(|note| note.contains("gitignored sizes are included")).count(),
5394 expected,
5395 "{population:?}: {notes:?}"
5396 );
5397 }
5398 let mut unobserved =
5399 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5400 unobserved.apply_ok(&Observation::new(vec![upsert(
5401 "plain.rs",
5402 EntryKind::File,
5403 attrs(10, 0),
5404 )]));
5405 let blind =
5406 run(&unobserved, &query(&[ViewSpec::Summary, ViewSpec::Tree], Selection::default()));
5407 assert!(
5408 crate::report_format::report_notes(&blind)
5409 .iter()
5410 .all(|note| !note.contains("gitignored sizes are included"))
5411 );
5412 }
5413
5414 #[test]
5415 fn tree_remainder_counts_only_unrepresented_root_children() {
5416 let index = classified_sample();
5417 let remainder = |selection| {
5418 let report = run(&index, &query(&[ViewSpec::Tree], selection));
5419 let Section::Tree { root, omissions, .. } = &report.sections[0] else {
5420 panic!("expected tree")
5421 };
5422 TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows")
5423 };
5424 let mixed = remainder(Selection {
5425 size: SizeMetric::Apparent,
5426 depth: Some(Bound::Limit(1)),
5427 breadth: Some(Bound::Limit(2)),
5428 min_share: Some(ShareThreshold::parse("5%").expect("share")),
5429 ..Selection::default()
5430 });
5431 let usage = |ignored: Option<IgnoredSize>| ignored.map(|part| (part.bytes, part.allocated));
5432 assert_eq!(
5433 (mixed.files, mixed.bytes, usage(mixed.ignored)),
5434 (Some(1), Some(300), Some((0, 0)))
5435 );
5436 assert_eq!(mixed.reasons, vec![TreeOmissionReason::Breadth]);
5437
5438 for limit in [Bound::Limit(1), Bound::Limit(0)] {
5439 let rows = remainder(Selection { limit: Some(limit), ..Selection::default() });
5440 assert_eq!((rows.bytes, usage(rows.ignored)), (Some(1_625), Some((1_025, 1_536))));
5441 }
5442 let excluded = remainder(Selection {
5443 depth: Some(Bound::Limit(0)),
5444 ignored: IgnoredEntries::Exclude,
5445 ..Selection::default()
5446 });
5447 assert_eq!((excluded.bytes, excluded.ignored), (Some(600), Some(IgnoredSize::default())));
5448 let only = remainder(Selection {
5449 depth: Some(Bound::Limit(0)),
5450 ignored: IgnoredEntries::Only,
5451 ..Selection::default()
5452 });
5453 assert_eq!((only.bytes, usage(only.ignored)), (Some(1_025), Some((1_025, 1_536))));
5454 }
5455
5456 #[test]
5457 fn colored_tree_populations_share_one_golden() {
5458 let classified = classified_sample();
5459 let mut unobserved =
5460 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5461 unobserved.apply_ok(&Observation::new(vec![upsert(
5462 "plain.rs",
5463 EntryKind::File,
5464 attrs(10, 0),
5465 )]));
5466
5467 let root = tempfile::tempdir().expect("root");
5468 fs::create_dir(root.path().join("guarded")).expect("directory");
5469 fs::write(root.path().join("known.rs"), "K").expect("known file");
5470 fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("control");
5471 fs::write(root.path().join("guarded/uncertain.rs"), "U").expect("uncertain file");
5472 let config = crate::ScanConfig {
5473 control_limits: crate::control::ControlLimits {
5474 line_limit: Some(1),
5475 ..crate::control::ControlLimits::default()
5476 },
5477 ..crate::ScanConfig::default()
5478 };
5479 let (refused, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
5480 assert_eq!(refused.ignored_classification(Path::new("known.rs")), Some(false));
5481 assert_eq!(refused.ignored_classification(Path::new("guarded/uncertain.rs")), None);
5482
5483 let selection = |ignored| Selection {
5484 size: SizeMetric::Apparent,
5485 depth: Some(Bound::Limit(0)),
5486 ignored,
5487 ..Selection::default()
5488 };
5489 let cases = [
5490 ("INCLUDE", &classified, selection(IgnoredEntries::Include)),
5491 (
5492 "INCLUDE EXPANDED",
5493 &classified,
5494 Selection {
5495 depth: Some(Bound::Limit(2)),
5496 min_share: Some(ShareThreshold::parse("0%").expect("share")),
5497 ..selection(IgnoredEntries::Include)
5498 },
5499 ),
5500 ("EXCLUDE", &classified, selection(IgnoredEntries::Exclude)),
5501 ("ONLY", &classified, selection(IgnoredEntries::Only)),
5502 ("NO CONTROLS", &unobserved, selection(IgnoredEntries::Include)),
5503 ("REFUSED CONTROL", &refused, selection(IgnoredEntries::Include)),
5504 ];
5505 let mut actual = String::new();
5506 for (label, index, selection) in cases {
5507 let report = run(index, &query(&[ViewSpec::Tree], selection));
5508 actual.push_str(label);
5509 actual.push('\n');
5510 actual.push_str(
5511 &crate::report_format::render(&report, crate::report_format::Format::Text, true)
5512 .expect("colored tree")
5513 .replace('\u{1b}', "<ESC>"),
5514 );
5515 actual.push('\n');
5516 }
5517 assert_eq!(actual, include_str!("../../tests/golden/tree-populations.txt"));
5518 }
5519
5520 #[test]
5521 fn tree_remainder_ignored_share_is_unknown_without_control_observation() {
5522 let mut index =
5523 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5524 index.apply_ok(&Observation::new(vec![upsert("file", EntryKind::File, attrs(10, 0))]));
5525 let report = run(
5526 &index,
5527 &query(
5528 &[ViewSpec::Tree],
5529 Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
5530 ),
5531 );
5532 let Section::Tree { root, omissions, .. } = &report.sections[0] else {
5533 panic!("expected tree")
5534 };
5535 let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden file");
5536 assert_eq!((hidden.bytes, hidden.ignored), (Some(10), None));
5537 }
5538
5539 fn ignored_of(row: &SummaryRow) -> IgnoredTally {
5540 row.ignored.expect("an observing index reports an ignored share")
5541 }
5542
5543 #[test]
5547 fn the_two_tiers_agree_on_the_ignored_share() {
5548 let index = classified_sample();
5549 let expected = IgnoredTally { files: 2, dirs: 2, bytes: 1_025, allocated: 1_024 + 512 };
5550 for selection in
5551 [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
5552 {
5553 let unfiltered = selection.is_unfiltered();
5554 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
5555 assert_eq!(ignored_of(&summary), expected, "unfiltered: {unfiltered}");
5556
5557 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection.clone())));
5558 assert_eq!(tree.ignored, Some(expected), "unfiltered: {unfiltered}");
5559 let child = |name: &str| {
5560 tree.children.iter().find(|node| node.name == name).expect(name).ignored
5561 };
5562 assert_eq!(
5563 child("build"),
5564 Some(IgnoredTally { files: 1, dirs: 1, bytes: 1_000, allocated: 1_024 }),
5565 "an ignored directory is wholly ignored below it, unfiltered: {unfiltered}"
5566 );
5567 assert_eq!(
5568 child("src"),
5569 Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
5570 "unfiltered: {unfiltered}"
5571 );
5572 assert_eq!(child("docs"), Some(IgnoredTally::default()), "observed, nothing ignored");
5573
5574 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
5575 let row = |extension: &str| {
5576 rows.iter().find(|row| row.extension == extension).expect(extension).ignored
5577 };
5578 assert_eq!(
5579 row(".log"),
5580 Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
5581 "unfiltered: {unfiltered}"
5582 );
5583 assert_eq!(row(".rs"), Some(IgnoredTally::default()), "unfiltered: {unfiltered}");
5584
5585 let files = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
5586 let flag =
5587 |path: PathBuf| files.iter().find(|row| row.path == path).map(|row| row.ignored);
5588 assert_eq!(flag(PathBuf::from("build")), Some(Some(true)));
5589 assert_eq!(flag(PathBuf::from("src")), Some(Some(false)));
5590 assert_eq!(flag(["src", "debug.log"].iter().collect()), Some(Some(true)));
5591 assert_eq!(flag(["build", "cache", "out.bin"].iter().collect()), Some(Some(true)));
5592 }
5593 }
5594
5595 #[test]
5598 fn ignored_entries_partition_the_tree_and_rank_by_what_they_select() {
5599 let index = classified_sample();
5600 let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5603 let with = |ignored| Selection { ignored, ..apparent.clone() };
5604 let summary = |selection| summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
5605 let total = summary(apparent.clone());
5606 let kept = summary(with(IgnoredEntries::Exclude));
5607 let only = summary(with(IgnoredEntries::Only));
5608 assert_eq!(
5609 (kept.files + only.files, kept.dirs + only.dirs, kept.bytes + only.bytes),
5610 (total.files, total.dirs, total.bytes)
5611 );
5612 assert_eq!(ignored_of(&kept), IgnoredTally::default());
5613 let whole = ignored_of(&only);
5614 assert_eq!((whole.files, whole.dirs, whole.bytes), (only.files, only.dirs, only.bytes));
5615
5616 let ranked = |selection| {
5617 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)))
5618 .children
5619 .iter()
5620 .map(|node| (node.name.clone(), node.bytes))
5621 .collect::<Vec<_>>()
5622 };
5623 let row = |name: &str, bytes: u64| (name.to_string(), bytes);
5624 assert_eq!(
5625 ranked(apparent.clone()),
5626 [row("build", 1_000), row("src", 325), row("docs", 300)]
5627 );
5628 assert_eq!(
5629 ranked(with(IgnoredEntries::Exclude)),
5630 [row("docs", 300), row("src", 300)],
5631 "unignored sizes rank the rows, with the name breaking the tie"
5632 );
5633 assert_eq!(ranked(with(IgnoredEntries::Only)), [row("build", 1_000), row("src", 25)]);
5634 }
5635
5636 #[test]
5639 fn an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by() {
5640 let mut index =
5641 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5642 index
5643 .apply(&Observation::new(vec![
5644 upsert("build", EntryKind::Dir, Attrs::default()),
5645 upsert("build/out.bin", EntryKind::File, attrs(1_000, 60)),
5646 ]))
5647 .expect("apply");
5648 let views = [ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
5649 let report = run(&index, &query(&views, Selection::default()));
5650 let Section::Summary(summary) = &report.sections[0] else { panic!("a summary") };
5651 let Section::Tree { root: Some(tree), .. } = &report.sections[1] else { panic!("a tree") };
5652 let Section::Extensions { rows: extensions, .. } = &report.sections[2] else {
5653 panic!("extensions")
5654 };
5655 let Section::Files { rows: files, .. } = &report.sections[3] else { panic!("files") };
5656 assert_eq!(summary.ignored, None);
5657 assert_eq!(tree.ignored, None);
5658 assert!(tree.children.iter().all(|node| node.ignored.is_none()));
5659 assert!(extensions.iter().all(|row| row.ignored.is_none()));
5660 assert!(files.iter().all(|row| row.ignored.is_none()));
5661
5662 let exclude = Query {
5663 selection: Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() },
5664 views: vec![ViewSpec::Summary],
5665 ..Query::default()
5666 };
5667 let only = Query {
5668 selection: Selection { ignored: IgnoredEntries::Only, ..Selection::default() },
5669 ..exclude.clone()
5670 };
5671 for refused in [exclude, only] {
5676 assert!(
5677 matches!(
5678 super::report(
5679 &index,
5680 &crate::test_support::read_of(&index, refused),
5681 generated_at()
5682 ),
5683 Err(crate::Error::InvalidRequest(
5684 crate::query::RequestError::IgnoredWithoutObservation(_)
5685 ))
5686 ),
5687 "a selection by ignored state over an unobserving index is refused"
5688 );
5689 }
5690 assert_eq!(summary_of(&run(&index, &query(&views, Selection::default()))).files, 1);
5691 }
5692}