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 if content.includes_words() {
1566 let text_only = sections
1572 .iter()
1573 .filter_map(|section| match section {
1574 Section::Metrics { summary, .. } => summary
1575 .total
1576 .words_coverage
1577 .as_ref()?
1578 .get(&crate::content::CoverageReason::TextOnly)
1579 .copied(),
1580 _ => None,
1581 })
1582 .max()
1583 .unwrap_or(0);
1584 if text_only > 0 {
1585 let files = if text_only == 1 { "file" } else { "files" };
1586 notes.push(format!(
1587 "note: {text_only} Markdown {files} over {} MiB counted as plain text: every \
1588 word counted visible, paragraphs are blank-line runs",
1589 crate::content::MARKDOWN_EXACT_BYTES / (1024 * 1024)
1590 ));
1591 }
1592 }
1593 tips.extend(retained_refusals_tip(query, &ignore_rules));
1594 if tree_measurements.is_some_and(|values| values.values().any(|value| !value.complete)) {
1595 notes.push("note: incomplete subtrees remain visible below the size threshold".to_owned());
1596 }
1597 if index.observes_controls() && !index.ignored_classification_complete_below(Path::new("")) {
1598 notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
1599 }
1600 Ok(Report {
1601 age_reference_ns,
1602 format: query.format,
1603 notes,
1604 tips,
1605 axes: query.axes,
1606 status: TreeStatus::of(index, request),
1607 provenance: ReportProvenance::of(index, content, generated_at),
1608 scope: index.scope(),
1609 requested_analysis: content,
1610 requested_views: query.views.clone(),
1611 omitted_views: query.omitted_views.clone(),
1612 root: index.root_path().to_path_buf(),
1613 size: query.selection.size,
1614 sort_metric: match query.selection.sort {
1615 Some(SortKey::Metric(name)) => Some(name),
1616 _ => None,
1617 },
1618 analysis: index.content().and_then(|held| {
1619 let wanted = index.content_identity(content);
1620 let projected = held.admit(&wanted)?;
1621 Some(ContentReportMetadata {
1622 profile: projected.identity().analysis,
1623 provenance: projected.identity().record_provenance(),
1624 })
1625 }),
1626 ignored_entries: query.selection.ignored,
1627 ignore_rules,
1628 sections,
1629 })
1630}
1631
1632const UNVERIFIED_IGNORED_NOTE: &str =
1634 "note: gitignored subtotals are unavailable where governing rules could not be verified";
1635
1636fn retained_refusals_tip(query: &Query, ignore_rules: &ControlCoverage) -> Option<String> {
1639 matches!(ignore_rules, ControlCoverage::Observed(observed) if observed.refusals.len() > REFUSED_DIRECTORIES_NAMED)
1640 .then(|| format!("tip: show retained ignore-file details: {} json", query.axes.format))
1641}
1642
1643#[allow(clippy::too_many_arguments)]
1654pub(crate) fn report_summary(
1655 root: &Path,
1656 scope: ScanScope,
1657 request: &Request,
1658 summary: SummaryRow,
1659 ignore_rules: ControlCoverage,
1660 ignored_unverified: bool,
1661 status: TreeStatus,
1662 provenance: ReportProvenance,
1663) -> Report {
1664 let query = &request.query;
1665 let (mut notes, mut tips) = display_notes(query, &ignore_rules);
1666 tips.extend(retained_refusals_tip(query, &ignore_rules));
1667 if ignored_unverified {
1668 notes.push(UNVERIFIED_IGNORED_NOTE.to_owned());
1669 }
1670 Report {
1671 age_reference_ns: crate::query::system_time_to_nanos(request.now),
1672 format: query.format,
1673 notes,
1674 tips,
1675 axes: query.axes,
1676 status,
1677 provenance,
1678 scope,
1679 requested_analysis: AnalysisSet::NONE,
1680 requested_views: query.views.clone(),
1681 omitted_views: query.omitted_views.clone(),
1682 root: root.to_path_buf(),
1683 size: query.selection.size,
1684 sort_metric: match query.selection.sort {
1685 Some(SortKey::Metric(name)) => Some(name),
1686 _ => None,
1687 },
1688 analysis: None,
1691 ignored_entries: query.selection.ignored,
1692 ignore_rules,
1693 sections: vec![Section::Summary(summary)],
1694 }
1695}
1696
1697struct Walked {
1699 observed: bool,
1701 per_directory: BTreeMap<EntryId, SummaryRow>,
1706 by_ext: BTreeMap<String, ExtTally>,
1708 ignored_by_ext: BTreeMap<String, ExtTally>,
1710 rows: Vec<FileRow>,
1712 members: Vec<FileRow>,
1714 visible: BTreeSet<EntryId>,
1716 visible_files: BTreeSet<EntryId>,
1717 unknown_ignored: BTreeSet<EntryId>,
1719}
1720
1721impl Walked {
1722 fn summary_of(&self, id: EntryId) -> SummaryRow {
1724 let mut row = self.per_directory.get(&id).copied().unwrap_or_default();
1725 row.ignored = (self.observed && !self.unknown_ignored.contains(&id))
1726 .then(|| row.ignored.unwrap_or_default());
1727 row
1728 }
1729}
1730
1731fn unfiltered_summary(index: &Index, id: EntryId, path: &Path) -> SummaryRow {
1734 let observed = index.observes_controls() && index.ignored_classification_complete_below(path);
1735 let Some((all, unignored)) = index.partition_scalars_of(id) else {
1736 return SummaryRow {
1737 ignored: observed.then(IgnoredTally::default),
1738 ..SummaryRow::default()
1739 };
1740 };
1741 SummaryRow {
1742 ignored: observed.then(|| IgnoredTally::between(all, unignored)),
1743 ..summary_from_scalars(all)
1744 }
1745}
1746
1747fn walk(
1752 index: &Index,
1753 selection: &Selection,
1754 identity: NameIdentity,
1755 directories: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
1756) -> Walked {
1757 debug_assert!(!index.is_folded(), "a folded index keeps too few files to be filtered");
1758 let observed = index.observes_controls();
1759 let mut walked = Walked {
1760 observed,
1761 per_directory: BTreeMap::new(),
1762 by_ext: BTreeMap::new(),
1763 ignored_by_ext: BTreeMap::new(),
1764 rows: Vec::new(),
1765 members: Vec::new(),
1766 visible: BTreeSet::new(),
1767 visible_files: BTreeSet::new(),
1768 unknown_ignored: BTreeSet::new(),
1769 };
1770 debug_assert!(
1773 observed || selection.ignored == IgnoredEntries::Include,
1774 "a selection by ignored state over an unobserving index is refused before the walk"
1775 );
1776
1777 let mut stack = vec![(EntryId::ROOT, PathBuf::new(), false, false)];
1779 while let Some((id, path, expanded, covered)) = stack.pop() {
1780 if expanded {
1781 let mut total = walked.per_directory.remove(&id).unwrap_or_default();
1786 if let Some(children) = index.children_of(id) {
1787 for (_, child) in children {
1788 if let Some(sub) = walked.per_directory.get(&child) {
1789 let sub = *sub;
1790 merge_summary(&mut total, &sub);
1791 if walked.unknown_ignored.contains(&child) {
1792 walked.unknown_ignored.insert(id);
1793 }
1794 }
1795 }
1796 }
1797 if total.files > 0 || total.dirs > 0 {
1798 walked.visible.insert(id);
1799 }
1800 walked.per_directory.insert(id, total);
1801 continue;
1802 }
1803
1804 stack.push((id, path.clone(), true, covered));
1805 let Some(children) = index.children_of(id) else {
1806 continue;
1807 };
1808 let children: Vec<(PathBuf, EntryId)> =
1809 children.map(|(name, child)| (path.join(name), child)).collect();
1810
1811 for (child_path, child) in children {
1812 let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
1813 continue;
1814 };
1815 let file_name = child_path.file_name().unwrap_or_default();
1819 let classification = index.ignored_classification_of(&child_path, child);
1820 let ignored = classification.unwrap_or(false);
1821 let classification_admitted =
1822 classification.is_some() || selection.ignored == IgnoredEntries::Include;
1823 let mut measured = *attrs;
1824 let subtree = directories.and_then(|values| values.get(&child)).copied();
1825 if let Some(subtree) = subtree {
1826 measured.size = subtree.bytes;
1827 measured.allocated = subtree.allocated;
1828 measured.mtime_ns = subtree.mtime_ns;
1829 }
1830 let (pruned, matches) = query_subtrees::with_candidate(
1831 &child_path,
1832 kind,
1833 measured,
1834 ignored,
1835 identity,
1836 |candidate| {
1837 (query_subtrees::pruned(selection, &candidate), selection.admits(&candidate))
1838 },
1839 );
1840 if pruned {
1841 continue;
1842 }
1843 let matches = matches
1850 && classification_admitted
1851 && (selection.modified.is_unbounded()
1852 || subtree.is_none_or(|subtree| subtree.complete));
1853 let row = FileRow {
1854 path: child_path.clone(),
1855 kind,
1856 bytes: measured.size,
1857 allocated: measured.allocated,
1858 mtime_ns: measured.mtime_ns,
1859 files: subtree.map(|subtree| subtree.files),
1860 dirs: subtree.map(|subtree| subtree.dirs),
1861 complete: subtree.map(|subtree| subtree.complete),
1862 age_ns: None,
1863 ignored: classification,
1864 sort_value: None,
1865 classification: None,
1866 };
1867 if matches {
1868 walked.rows.push(row.clone());
1869 }
1870 if matches || (covered && classification_admitted && selection.ignored.admits(ignored))
1871 {
1872 if classification.is_none() {
1873 walked.unknown_ignored.insert(id);
1874 }
1875 if kind == EntryKind::File {
1876 walked.members.push(row);
1877 walked.visible_files.insert(child);
1878 } else if kind == EntryKind::Dir {
1879 walked.visible.insert(child);
1880 }
1881
1882 if kind == EntryKind::File {
1883 let own = walked.per_directory.entry(id).or_default();
1884 own.files += 1;
1885 own.bytes += attrs.size;
1886 own.allocated += attrs.allocated;
1887 own.newest_mtime_ns = Some(
1888 own.newest_mtime_ns.map_or(attrs.mtime_ns, |seen| seen.max(attrs.mtime_ns)),
1889 );
1890 let bucket = crate::classify::ext_bucket(file_name);
1891 if ignored {
1892 own.ignored.get_or_insert_with(IgnoredTally::default).add(IgnoredTally {
1893 files: 1,
1894 dirs: 0,
1895 bytes: attrs.size,
1896 allocated: attrs.allocated,
1897 });
1898 let tally = walked.ignored_by_ext.entry(bucket.clone()).or_default();
1899 tally.files += 1;
1900 tally.bytes += attrs.size;
1901 tally.allocated += attrs.allocated;
1902 }
1903 let tally = walked.by_ext.entry(bucket).or_default();
1904 tally.files += 1;
1905 tally.bytes += attrs.size;
1906 tally.allocated += attrs.allocated;
1907 } else if kind == EntryKind::Dir {
1908 let own = walked.per_directory.entry(id).or_default();
1914 own.dirs += 1;
1915 if ignored {
1916 own.ignored.get_or_insert_with(IgnoredTally::default).dirs += 1;
1917 }
1918 }
1919 }
1920
1921 if kind == EntryKind::Dir {
1922 stack.push((child, child_path, false, covered || matches));
1923 }
1924 }
1925 }
1926
1927 walked
1928}
1929
1930fn merge_summary(into: &mut SummaryRow, from: &SummaryRow) {
1932 into.files += from.files;
1933 into.dirs += from.dirs;
1934 into.bytes += from.bytes;
1935 into.allocated += from.allocated;
1936 into.newest_mtime_ns = match (into.newest_mtime_ns, from.newest_mtime_ns) {
1937 (Some(left), Some(right)) => Some(left.max(right)),
1938 (left, right) => left.or(right),
1939 };
1940 if let Some(share) = from.ignored {
1941 into.ignored.get_or_insert_with(IgnoredTally::default).add(share);
1942 }
1943}
1944
1945fn needs_unfiltered_entry_rows(view: ViewSpec) -> bool {
1947 matches!(
1948 view,
1949 ViewSpec::Types
1950 | ViewSpec::Families
1951 | ViewSpec::Languages
1952 | ViewSpec::Code
1953 | ViewSpec::Documents
1954 | ViewSpec::Files
1955 | ViewSpec::Largest
1956 | ViewSpec::Recent
1957 )
1958}
1959
1960fn needs_metric_resolution(view: ViewSpec) -> bool {
1962 matches!(view, ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents)
1963}
1964
1965fn entry_rows<'a>(
1968 index: &Index,
1969 walked: Option<&'a Walked>,
1970 unfiltered_rows: Option<&'a [FileRow]>,
1971) -> Cow<'a, [FileRow]> {
1972 match (walked, unfiltered_rows) {
1973 (Some(walked), _) => Cow::Borrowed(&walked.rows),
1974 (None, Some(rows)) => Cow::Borrowed(rows),
1975 (None, None) => Cow::Owned(every_entry(index)),
1976 }
1977}
1978
1979fn build_section(
1981 view: ViewSpec,
1982 index: &Index,
1983 query: &Query,
1984 content: AnalysisSet,
1985 walked: Option<&Walked>,
1986 unfiltered_rows: Option<&[FileRow]>,
1987 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
1988) -> Section {
1989 if query.tree_for(view) {
1990 let (root, omissions) = tree_node(index, query, content, walked, tree_measurements);
1991 let limits = TreeDisplayLimits {
1992 depth: query.depth_for(view),
1993 min_share: query.min_share_for(),
1994 breadth: query.breadth_for(),
1995 rows: query.limit_for(view),
1996 };
1997 return Section::Tree { view, limits, root: root.map(Box::new), omissions };
1998 }
1999 match view {
2000 ViewSpec::Code => {
2001 Section::Code(Box::new(code_overview(index, query, content, walked, unfiltered_rows)))
2002 }
2003 ViewSpec::Summary => Section::Summary(match walked {
2004 None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2005 Some(walked) => walked.summary_of(EntryId::ROOT),
2006 }),
2007 ViewSpec::Extensions => {
2008 let (rows, total, share_omitted) = extension_rows(index, query, walked);
2009 Section::Extensions { rows, total, share_omitted }
2010 }
2011 ViewSpec::Types | ViewSpec::Families | ViewSpec::Languages | ViewSpec::Documents => {
2012 Section::Metrics {
2013 view,
2014 summary: Box::new(metric_summary(
2015 view,
2016 index,
2017 query,
2018 content,
2019 walked,
2020 unfiltered_rows,
2021 )),
2022 }
2023 }
2024 ViewSpec::List
2025 | ViewSpec::Tree
2026 | ViewSpec::Files
2027 | ViewSpec::Largest
2028 | ViewSpec::Recent => {
2029 let (rows, total) = file_rows(view, index, query, content, walked, unfiltered_rows);
2030 Section::Files { view, rows, total }
2031 }
2032 }
2033}
2034
2035fn summary_from_scalars(rollup: RollUpScalars) -> SummaryRow {
2037 SummaryRow {
2038 files: rollup.files,
2039 dirs: rollup.dirs,
2040 bytes: rollup.bytes,
2041 allocated: rollup.allocated,
2042 ignored: None,
2043 newest_mtime_ns: (rollup.files > 0).then_some(rollup.newest_mtime_ns),
2044 }
2045}
2046
2047fn ignored_by_extension(
2049 all: &BTreeMap<String, ExtTally>,
2050 unignored: &BTreeMap<String, ExtTally>,
2051) -> BTreeMap<String, ExtTally> {
2052 all.iter()
2053 .filter_map(|(extension, tally)| {
2054 let kept = unignored.get(extension).copied().unwrap_or_default();
2055 let ignored = ExtTally {
2056 files: tally.files.saturating_sub(kept.files),
2057 bytes: tally.bytes.saturating_sub(kept.bytes),
2058 allocated: tally.allocated.saturating_sub(kept.allocated),
2059 };
2060 (ignored.files > 0).then(|| (extension.clone(), ignored))
2061 })
2062 .collect()
2063}
2064
2065fn extension_rows(
2067 index: &Index,
2068 query: &Query,
2069 walked: Option<&Walked>,
2070) -> (Vec<TypeRow>, usize, usize) {
2071 debug_assert!(!index.is_folded(), "a folded index keeps no extension tallies (H176)");
2072 let observed = match walked {
2075 Some(walked) => walked.observed && !walked.unknown_ignored.contains(&EntryId::ROOT),
2076 None => {
2077 index.observes_controls() && index.ignored_classification_complete_below(Path::new(""))
2078 }
2079 };
2080 let (tallies, ignored): (BTreeMap<String, ExtTally>, BTreeMap<String, ExtTally>) = match walked
2081 {
2082 None => match index.partition_total() {
2083 Ok(partitions) => {
2084 let ignored =
2085 ignored_by_extension(&partitions.all.by_ext, &partitions.unignored.by_ext);
2086 (partitions.all.by_ext, ignored)
2087 }
2088 Err(_not_observed) => (index.total().by_ext, BTreeMap::new()),
2091 },
2092 Some(walked) => (walked.by_ext.clone(), walked.ignored_by_ext.clone()),
2093 };
2094
2095 let mut rows: Vec<TypeRow> = tallies
2096 .into_iter()
2097 .map(|(extension, tally)| {
2098 let share = ignored.get(&extension).copied().unwrap_or_default();
2099 TypeRow {
2100 files: tally.files,
2101 bytes: tally.bytes,
2102 allocated: tally.allocated,
2103 ignored: observed.then_some(IgnoredTally {
2104 files: share.files,
2105 dirs: 0,
2106 bytes: share.bytes,
2107 allocated: share.allocated,
2108 }),
2109 extension,
2110 }
2111 })
2112 .collect();
2113
2114 let before_share = rows.len();
2115 if let Some(threshold) = &query.selection.min_share {
2116 let root = match walked {
2117 None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2118 Some(walked) => walked.summary_of(EntryId::ROOT),
2119 };
2120 let denominator = match query.selection.size {
2121 SizeMetric::Apparent => root.bytes,
2122 SizeMetric::Allocated => root.allocated,
2123 };
2124 rows.retain(|row| {
2125 threshold.admits(
2126 match query.selection.size {
2127 SizeMetric::Apparent => row.bytes,
2128 SizeMetric::Allocated => row.allocated,
2129 },
2130 denominator,
2131 )
2132 });
2133 }
2134
2135 sort_rows(
2136 &mut rows,
2137 query,
2138 ViewSpec::Extensions,
2139 SortAccessors {
2140 size: |row: &TypeRow, metric| match metric {
2141 SizeMetric::Apparent => row.bytes,
2142 SizeMetric::Allocated => row.allocated,
2143 },
2144 count: |row: &TypeRow| row.files,
2145 mtime: |_: &TypeRow| None,
2146 name: borrowed_name(|row: &TypeRow| std::borrow::Cow::Borrowed(row.extension.as_str())),
2147 content_metric: |_: &TypeRow, _: &MetricDef| None,
2148 },
2149 );
2150 let total = truncate(&mut rows, query.limit_for(ViewSpec::Extensions));
2151 (rows, total, before_share - total)
2152}
2153
2154fn metric_summary(
2155 view: ViewSpec,
2156 index: &Index,
2157 query: &Query,
2158 content: AnalysisSet,
2159 walked: Option<&Walked>,
2160 unfiltered_rows: Option<&[FileRow]>,
2161) -> MetricSummary {
2162 let mut accumulator = MetricAccumulator::new(view);
2163 let files = walked.map_or_else(
2164 || entry_rows(index, None, unfiltered_rows),
2165 |walked| Cow::Borrowed(walked.members.as_slice()),
2166 );
2167 let wanted = index.content_identity(content);
2168 let held = index.content().and_then(|held| held.admit(&wanted));
2169 for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2170 let cached = held.and_then(|content| content.file(&file.path));
2171 let classification = index.classify(&file.path);
2172 accumulator.push(content, file, cached, &classification);
2173 }
2174 accumulator.finish(query, content)
2175}
2176
2177fn metric_summaries(
2179 views: &[ViewSpec],
2180 index: &Index,
2181 query: &Query,
2182 content: AnalysisSet,
2183 rows: &[FileRow],
2184) -> Vec<Option<MetricSummary>> {
2185 let mut accumulators = views
2186 .iter()
2187 .copied()
2188 .filter(|view| needs_metric_resolution(*view))
2189 .map(MetricAccumulator::new)
2190 .collect::<Vec<_>>();
2191 let wanted = index.content_identity(content);
2192 let held = index.content().and_then(|held| held.admit(&wanted));
2193 for file in rows.iter().filter(|row| row.kind == EntryKind::File) {
2194 let cached = held.and_then(|content| content.file(&file.path));
2195 let classification = index.classify(&file.path);
2196 for accumulator in &mut accumulators {
2197 accumulator.push(content, file, cached, &classification);
2198 }
2199 }
2200
2201 let mut finished =
2202 accumulators.into_iter().map(|accumulator| accumulator.finish(query, content));
2203 let summaries = views
2204 .iter()
2205 .map(|view| needs_metric_resolution(*view).then(|| finished.next().expect("metric view")))
2206 .collect();
2207 debug_assert!(finished.next().is_none());
2208 summaries
2209}
2210
2211struct MetricAccumulator {
2212 view: ViewSpec,
2213 group: MetricGroup,
2214 grouped: BTreeMap<String, MetricRow>,
2215}
2216
2217impl MetricAccumulator {
2218 fn new(view: ViewSpec) -> Self {
2219 debug_assert!(needs_metric_resolution(view));
2220 let group =
2221 if view == ViewSpec::Families { MetricGroup::Family } else { MetricGroup::Type };
2222 Self { view, group, grouped: BTreeMap::new() }
2223 }
2224
2225 fn push(
2226 &mut self,
2227 content: AnalysisSet,
2228 file: &FileRow,
2229 cached: Option<&FileAnalysis>,
2230 classification: &Classification,
2231 ) {
2232 let included = match self.view {
2233 ViewSpec::Languages => classification.family == ContentFamily::Code,
2234 ViewSpec::Documents => {
2235 matches!(classification.family, ContentFamily::Prose | ContentFamily::Markup)
2236 }
2237 ViewSpec::Types | ViewSpec::Families => true,
2238 ViewSpec::List
2239 | ViewSpec::Tree
2240 | ViewSpec::Extensions
2241 | ViewSpec::Code
2242 | ViewSpec::Files
2243 | ViewSpec::Largest
2244 | ViewSpec::Recent
2245 | ViewSpec::Summary => false,
2246 };
2247 if !included {
2248 return;
2249 }
2250 let id = match self.group {
2251 MetricGroup::Type => classification.file_type.as_str().to_string(),
2252 MetricGroup::Family => classification.family.as_str().to_string(),
2253 };
2254 let row = self.grouped.entry(id.clone()).or_insert_with(|| MetricRow {
2255 analysis: content,
2256 id,
2257 family: classification.family,
2258 files: 0,
2259 bytes: 0,
2260 allocated: 0,
2261 analyzed_files: 0,
2262 metrics: ReportMetricValues::for_analysis(content),
2263 logical_word_stats: LogicalWordStats::default(),
2264 visible_logical_word_stats: LogicalWordStats::default(),
2265 document_raw_words: 0,
2266 document_word_stats: LogicalWordStats::default(),
2267 document_metric_files: 0,
2268 coverage: BTreeMap::new(),
2269 lines_coverage: BTreeMap::new(),
2270 code_coverage: content.includes_code().then(BTreeMap::new),
2271 words_coverage: content.includes_words().then(BTreeMap::new),
2272 detection_sources: BTreeMap::new(),
2273 detection_confidence: BTreeMap::new(),
2274 generated_files: 0,
2275 vendored_files: 0,
2276 documentation_files: 0,
2277 share: MetricShare::default(),
2278 });
2279 row.files = row.files.saturating_add(1);
2280 row.bytes = row.bytes.saturating_add(file.bytes);
2281 row.allocated = row.allocated.saturating_add(file.allocated);
2282 let detection = cached.map_or(
2283 (classification.source, classification.confidence, classification.flags),
2284 |record| (record.detection.source, record.detection.confidence, record.detection.flags),
2285 );
2286 *row.detection_sources.entry(detection.0).or_default() += 1;
2287 *row.detection_confidence.entry(detection.1).or_default() += 1;
2288 row.generated_files = row.generated_files.saturating_add(u64::from(detection.2.generated));
2289 row.vendored_files = row.vendored_files.saturating_add(u64::from(detection.2.vendored));
2290 row.documentation_files =
2291 row.documentation_files.saturating_add(u64::from(detection.2.documentation));
2292 if let Some(record) = cached {
2293 *row.lines_coverage.entry(record.lines.coverage()).or_default() += 1;
2294 if let (Some(coverage), Some(outcome)) = (&mut row.code_coverage, record.code) {
2295 *coverage.entry(outcome.coverage()).or_default() += 1;
2296 }
2297 if let (Some(coverage), Some(outcome)) = (&mut row.words_coverage, record.words) {
2298 *coverage.entry(outcome.coverage()).or_default() += 1;
2299 }
2300 let selected = match self.view {
2301 ViewSpec::Languages if content.includes_code() => {
2302 record.code.map(|outcome| outcome.coverage())
2303 }
2304 ViewSpec::Documents if content.includes_words() => {
2305 record.words.map(|outcome| outcome.coverage())
2306 }
2307 _ => None,
2308 }
2309 .unwrap_or(record.lines.coverage());
2310 *row.coverage.entry(selected).or_default() += 1;
2311 if selected == CoverageReason::Analyzed {
2312 row.analyzed_files = row.analyzed_files.saturating_add(1);
2313 }
2314 if let Some(lines) = record.lines.value() {
2315 add_optional(&mut row.metrics.physical_lines, Some(lines.physical_lines));
2316 add_optional(&mut row.metrics.blank_lines, Some(lines.blank_lines));
2317 add_optional(&mut row.metrics.nonblank_lines, Some(lines.nonblank_lines));
2318 add_optional(&mut row.metrics.raw_words, Some(lines.raw_words));
2319 row.document_raw_words = row.document_raw_words.saturating_add(lines.raw_words);
2320 }
2321 if let Some(code_metrics) = record.code.and_then(crate::content::AnalyzerOutcome::value)
2322 {
2323 add_optional(&mut row.metrics.code_lines, Some(code_metrics.code_lines));
2324 add_optional(&mut row.metrics.comment_lines, Some(code_metrics.comment_lines));
2325 add_optional(
2326 &mut row.metrics.code_blank_lines,
2327 Some(code_metrics.code_blank_lines),
2328 );
2329 }
2330 if let Some(words) = record.words.and_then(crate::content::AnalyzerOutcome::value) {
2331 add_optional(&mut row.metrics.paragraphs, Some(words.paragraphs));
2332 add_optional(&mut row.metrics.visible_words, Some(words.visible_words));
2333 row.logical_word_stats.add_assign(words.logical_word_stats);
2334 row.visible_logical_word_stats.add_assign(words.visible_logical_word_stats);
2335 row.document_metric_files = row.document_metric_files.saturating_add(1);
2336 if classification.file_type.as_str() == "markdown" {
2337 row.document_raw_words = row
2338 .document_raw_words
2339 .saturating_sub(record.lines.value().map_or(0, |lines| lines.raw_words));
2340 row.document_raw_words =
2341 row.document_raw_words.saturating_add(words.visible_words);
2342 row.document_word_stats.add_assign(words.visible_logical_word_stats);
2343 } else {
2344 row.document_word_stats.add_assign(words.logical_word_stats);
2345 }
2346 }
2347 }
2348 }
2349
2350 fn finish(self, query: &Query, content: AnalysisSet) -> MetricSummary {
2351 let Self { view, group, mut grouped } = self;
2352
2353 for row in grouped.values_mut() {
2354 row.finish_derived_metrics();
2355 }
2356
2357 let mut total = MetricRow {
2358 analysis: content,
2359 id: "total".to_string(),
2360 family: ContentFamily::Unknown,
2361 files: 0,
2362 bytes: 0,
2363 allocated: 0,
2364 analyzed_files: 0,
2365 metrics: ReportMetricValues::for_analysis(content),
2366 logical_word_stats: LogicalWordStats::default(),
2367 visible_logical_word_stats: LogicalWordStats::default(),
2368 document_raw_words: 0,
2369 document_word_stats: LogicalWordStats::default(),
2370 document_metric_files: 0,
2371 coverage: BTreeMap::new(),
2372 lines_coverage: BTreeMap::new(),
2373 code_coverage: content.includes_code().then(BTreeMap::new),
2374 words_coverage: content.includes_words().then(BTreeMap::new),
2375 detection_sources: BTreeMap::new(),
2376 detection_confidence: BTreeMap::new(),
2377 generated_files: 0,
2378 vendored_files: 0,
2379 documentation_files: 0,
2380 share: MetricShare::default(),
2381 };
2382 for row in grouped.values() {
2383 total.files = total.files.saturating_add(row.files);
2384 total.bytes = total.bytes.saturating_add(row.bytes);
2385 total.allocated = total.allocated.saturating_add(row.allocated);
2386 total.analyzed_files = total.analyzed_files.saturating_add(row.analyzed_files);
2387 total.metrics.add_assign(&row.metrics);
2388 total.logical_word_stats.add_assign(row.logical_word_stats);
2389 total.visible_logical_word_stats.add_assign(row.visible_logical_word_stats);
2390 total.document_raw_words =
2391 total.document_raw_words.saturating_add(row.document_raw_words);
2392 total.document_word_stats.add_assign(row.document_word_stats);
2393 total.document_metric_files =
2394 total.document_metric_files.saturating_add(row.document_metric_files);
2395 for (reason, count) in &row.coverage {
2396 *total.coverage.entry(*reason).or_default() += count;
2397 }
2398 merge_coverage(&mut total.lines_coverage, &row.lines_coverage);
2399 if let (Some(total), Some(row)) = (&mut total.code_coverage, &row.code_coverage) {
2400 merge_coverage(total, row);
2401 }
2402 if let (Some(total), Some(row)) = (&mut total.words_coverage, &row.words_coverage) {
2403 merge_coverage(total, row);
2404 }
2405 for (source, count) in &row.detection_sources {
2406 *total.detection_sources.entry(*source).or_default() += count;
2407 }
2408 for (confidence, count) in &row.detection_confidence {
2409 *total.detection_confidence.entry(*confidence).or_default() += count;
2410 }
2411 total.generated_files = total.generated_files.saturating_add(row.generated_files);
2412 total.vendored_files = total.vendored_files.saturating_add(row.vendored_files);
2413 total.documentation_files =
2414 total.documentation_files.saturating_add(row.documentation_files);
2415 }
2416 total.finish_derived_metrics();
2417 let byte_share_metric = match query.selection.size {
2418 SizeMetric::Apparent => ShareMetric::ApparentBytes,
2419 SizeMetric::Allocated => ShareMetric::AllocatedBytes,
2420 };
2421 let share_metric = match view {
2422 ViewSpec::Languages if content.includes_code() => ShareMetric::CodeLines,
2425 ViewSpec::Documents if content.includes_words() => ShareMetric::DocumentWords,
2426 ViewSpec::Languages | ViewSpec::Documents if content.is_enabled() => {
2427 ShareMetric::RawWords
2428 }
2429 ViewSpec::Languages | ViewSpec::Documents | ViewSpec::Types | ViewSpec::Families => {
2430 byte_share_metric
2431 }
2432 ViewSpec::List
2433 | ViewSpec::Tree
2434 | ViewSpec::Extensions
2435 | ViewSpec::Code
2436 | ViewSpec::Files
2437 | ViewSpec::Largest
2438 | ViewSpec::Recent
2439 | ViewSpec::Summary => {
2440 unreachable!("only grouped views reach metric_summary")
2441 }
2442 };
2443 let denominator = share_value(&total, share_metric);
2444 total.share = MetricShare { numerator: denominator, denominator };
2445 let mut rows = grouped.into_values().collect::<Vec<_>>();
2446 for row in &mut rows {
2447 row.share = MetricShare { numerator: share_value(row, share_metric), denominator };
2448 }
2449 sort_rows(
2450 &mut rows,
2451 query,
2452 view,
2453 SortAccessors {
2454 size: |row: &MetricRow, metric| match view {
2455 ViewSpec::Languages | ViewSpec::Documents => share_value(row, share_metric),
2456 _ => match metric {
2457 SizeMetric::Apparent => row.bytes,
2458 SizeMetric::Allocated => row.allocated,
2459 },
2460 },
2461 count: |row: &MetricRow| row.files,
2462 mtime: |_: &MetricRow| None,
2463 name: borrowed_name(|row: &MetricRow| std::borrow::Cow::Borrowed(row.id.as_str())),
2464 content_metric: MetricRow::metric_value,
2465 },
2466 );
2467 let before_share = rows.len();
2468 if let Some(threshold) = &query.selection.min_share {
2469 rows.retain(|row| threshold.admits(row.share.numerator, row.share.denominator));
2470 }
2471 let share_omitted = before_share - rows.len();
2472 let total_rows = truncate(&mut rows, query.limit_for(view));
2473 MetricSummary {
2474 group,
2475 total,
2476 rows,
2477 total_rows,
2478 share_omitted,
2479 share_metric,
2480 words_per_page: query.words_per_page.max(1),
2481 }
2482 }
2483}
2484
2485fn code_overview(
2486 index: &Index,
2487 query: &Query,
2488 content: AnalysisSet,
2489 walked: Option<&Walked>,
2490 unfiltered_rows: Option<&[FileRow]>,
2491) -> CodeOverview {
2492 #[derive(Default)]
2493 struct SortFacts {
2494 apparent: u64,
2495 allocated: u64,
2496 newest_mtime_ns: Option<i64>,
2497 metric: MetricAggregate,
2498 }
2499
2500 let files = walked.map_or_else(
2501 || entry_rows(index, None, unfiltered_rows),
2502 |walked| Cow::Borrowed(walked.members.as_slice()),
2503 );
2504 let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2505 let split = query.selection.ignored == IgnoredEntries::Include && index.observes_controls();
2506 let sort_key = query.selection.sort.unwrap_or(SortKey::Metric("code_lines"));
2507 let sort_metric = match sort_key {
2508 SortKey::Metric(name) => Some(name),
2509 _ => None,
2510 };
2511 let mut overview = CodeOverview {
2512 population: query.selection.ignored,
2513 selected: CodeTally::default(),
2514 non_ignored: split.then(CodeTally::default),
2515 ignored: split.then(CodeTally::default),
2516 unknown: CodeTally::default(),
2517 unclassified_files: 0,
2518 analyzed_languages: 0,
2519 total_languages: 0,
2520 share_omitted: 0,
2521 languages: Vec::new(),
2522 share_metric: ShareMetric::CodeLines,
2523 };
2524 let mut grouped = BTreeMap::<String, CodeLanguageRow>::new();
2525 let mut sort_facts = BTreeMap::<String, SortFacts>::new();
2526 for file in files.iter().filter(|row| row.kind == EntryKind::File) {
2527 let record = held.and_then(|tier| tier.file(&file.path));
2528 let classification = record
2529 .map_or_else(|| index.classify(&file.path).into(), |record| record.detection.clone());
2530 if classification.family == ContentFamily::Unknown {
2531 overview.unclassified_files = overview.unclassified_files.saturating_add(1);
2532 }
2533 if classification.family != ContentFamily::Code {
2534 continue;
2535 }
2536 let language = classification.file_type.as_str().to_string();
2537 let facts = sort_facts.entry(language.clone()).or_default();
2538 facts.apparent = facts.apparent.saturating_add(file.bytes);
2539 facts.allocated = facts.allocated.saturating_add(file.allocated);
2540 facts.newest_mtime_ns =
2541 Some(facts.newest_mtime_ns.map_or(file.mtime_ns, |old| old.max(file.mtime_ns)));
2542 if let (Some(name), Some(record)) = (sort_metric, record) {
2543 if let Some(value) =
2544 measured_file_metric(record, classification.file_type.as_str(), name)
2545 {
2546 facts.metric.add(value);
2547 }
2548 }
2549 let row = grouped.entry(language.clone()).or_insert_with(|| CodeLanguageRow {
2550 language,
2551 selected: CodeTally::default(),
2552 non_ignored: split.then(CodeTally::default),
2553 ignored: split.then(CodeTally::default),
2554 unknown: CodeTally::default(),
2555 share: MetricShare::default(),
2556 });
2557 row.selected.add_file(record);
2558 overview.selected.add_file(record);
2559 match index.ignored_classification(&file.path) {
2560 Some(false) if split => {
2561 row.non_ignored.as_mut().expect("split initialized").add_file(record);
2562 overview.non_ignored.as_mut().expect("split initialized").add_file(record);
2563 }
2564 Some(true) if split => {
2565 row.ignored.as_mut().expect("split initialized").add_file(record);
2566 overview.ignored.as_mut().expect("split initialized").add_file(record);
2567 }
2568 None => {
2569 row.unknown.add_file(record);
2570 overview.unknown.add_file(record);
2571 }
2572 Some(_) => {}
2573 }
2574 }
2575 let denominator = overview.selected.metrics.code_lines;
2576 overview.analyzed_languages =
2577 grouped.values().filter(|row| row.selected.analyzed_files > 0).count() as u64;
2578 overview.languages = grouped.into_values().collect();
2579 for row in &mut overview.languages {
2580 row.share = MetricShare { numerator: row.selected.metrics.code_lines, denominator };
2581 }
2582 let normalized =
2583 matches!(sort_metric, Some("logical_words" | "visible_logical_words" | "document_words"));
2584 overview.languages.sort_by(|left, right| {
2585 let left_facts = &sort_facts[&left.language];
2586 let right_facts = &sort_facts[&right.language];
2587 let ordering = match sort_key {
2588 SortKey::Size => {
2589 let value = |facts: &SortFacts| match query.selection.size {
2590 SizeMetric::Apparent => facts.apparent,
2591 SizeMetric::Allocated => facts.allocated,
2592 };
2593 value(right_facts).cmp(&value(left_facts))
2594 }
2595 SortKey::Count => right.selected.source_files.cmp(&left.selected.source_files),
2596 SortKey::Mtime => right_facts.newest_mtime_ns.cmp(&left_facts.newest_mtime_ns),
2597 SortKey::Name => left.language.cmp(&right.language),
2598 SortKey::Metric(_) => {
2599 let left_value = left_facts.metric.value(normalized);
2600 let right_value = right_facts.metric.value(normalized);
2601 match (left_value, right_value) {
2602 (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
2603 (Some(left), Some(right)) => right.cmp(&left),
2604 (Some(_), None) => std::cmp::Ordering::Less,
2605 (None, Some(_)) => std::cmp::Ordering::Greater,
2606 (None, None) => std::cmp::Ordering::Equal,
2607 }
2608 }
2609 };
2610 let ordering = if query.selection.reverse && !matches!(sort_key, SortKey::Metric(_)) {
2611 ordering.reverse()
2612 } else {
2613 ordering
2614 };
2615 ordering.then_with(|| left.language.cmp(&right.language))
2616 });
2617 let before_share = overview.languages.len();
2618 if let Some(threshold) = &query.selection.min_share {
2619 overview.languages.retain(|row| threshold.admits(row.share.numerator, denominator));
2620 }
2621 overview.share_omitted = before_share - overview.languages.len();
2622 overview.total_languages = overview.languages.len();
2623 if let Some(limit) = query.limit_for(ViewSpec::Code).limit() {
2624 overview.languages.truncate(limit);
2625 }
2626 overview
2627}
2628
2629fn merge_coverage(total: &mut BTreeMap<CoverageReason, u64>, row: &BTreeMap<CoverageReason, u64>) {
2630 for (reason, count) in row {
2631 *total.entry(*reason).or_default() += count;
2632 }
2633}
2634
2635fn share_value(row: &MetricRow, metric: ShareMetric) -> u64 {
2636 match metric {
2637 ShareMetric::ApparentBytes => row.bytes,
2638 ShareMetric::AllocatedBytes => row.allocated,
2639 ShareMetric::CodeLines => row.metrics.code_lines.unwrap_or(0),
2640 ShareMetric::DocumentWords => document_words(row).unwrap_or(0),
2641 ShareMetric::RawWords => row.metrics.raw_words.unwrap_or(0),
2642 }
2643}
2644
2645pub fn document_words(row: &MetricRow) -> Option<u64> {
2647 row.metrics.document_words
2648}
2649
2650#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2652pub struct Pages {
2653 pub words: u64,
2655 pub words_per_page: u64,
2657}
2658
2659pub fn pages(row: &MetricRow, words_per_page: u64) -> Option<Pages> {
2661 document_words(row).map(|words| Pages { words, words_per_page: words_per_page.max(1) })
2662}
2663
2664fn file_rows(
2666 view: ViewSpec,
2667 index: &Index,
2668 query: &Query,
2669 content: AnalysisSet,
2670 walked: Option<&Walked>,
2671 unfiltered_rows: Option<&[FileRow]>,
2672) -> (Vec<FileRow>, usize) {
2673 let mut rows = entry_rows(index, walked, unfiltered_rows).into_owned();
2674 if view.files_only() {
2675 rows.retain(|row| row.kind == EntryKind::File);
2676 }
2677 if let Some(SortKey::Metric(name)) = query.selection.sort {
2678 let sources = walked.map_or(rows.as_slice(), |walked| walked.members.as_slice());
2679 let values = metric_sort_values(index, content, sources, name);
2680 for row in &mut rows {
2681 row.sort_value = values.get(&row.path).copied();
2682 }
2683 }
2684
2685 sort_rows(
2686 &mut rows,
2687 query,
2688 view,
2689 SortAccessors {
2690 size: |row: &FileRow, metric| match metric {
2691 SizeMetric::Apparent => row.bytes,
2692 SizeMetric::Allocated => row.allocated,
2693 },
2694 count: |row: &FileRow| row.files.unwrap_or(1),
2695 mtime: |row: &FileRow| Some(row.mtime_ns),
2696 name: borrowed_name(|row: &FileRow| row.path.to_string_lossy()),
2697 content_metric: |row: &FileRow, _: &MetricDef| row.sort_value,
2698 },
2699 );
2700 let total = truncate(&mut rows, query.limit_for(view));
2701 let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2702 for row in &mut rows {
2703 if row.kind == EntryKind::File {
2704 row.classification = Some(held.and_then(|tier| tier.file(&row.path)).map_or_else(
2705 || index.classify(&row.path).into(),
2706 |record| record.detection.clone(),
2707 ));
2708 }
2709 }
2710 (rows, total)
2711}
2712
2713#[derive(Clone, Copy)]
2714enum MetricMeasure {
2715 Additive(u64),
2716 Normalized(LogicalWordStats),
2717}
2718
2719#[derive(Default)]
2720struct MetricAggregate {
2721 additive: u64,
2722 normalized: LogicalWordStats,
2723 measured: bool,
2724}
2725
2726impl MetricAggregate {
2727 fn add(&mut self, measure: MetricMeasure) {
2728 self.measured = true;
2729 match measure {
2730 MetricMeasure::Additive(value) => {
2731 self.additive = self.additive.saturating_add(value);
2732 }
2733 MetricMeasure::Normalized(stats) => self.normalized.add_assign(stats),
2734 }
2735 }
2736
2737 fn value(&self, normalized: bool) -> Option<u64> {
2738 self.measured.then(
2739 || {
2740 if normalized { self.normalized.logical_words() } else { self.additive }
2741 },
2742 )
2743 }
2744}
2745
2746fn measured_file_metric(
2747 record: &crate::content::FileAnalysis,
2748 file_type: &str,
2749 metric_name: &str,
2750) -> Option<MetricMeasure> {
2751 let direct = |value| Some(MetricMeasure::Additive(value));
2752 match metric_name {
2753 "physical_lines" => direct(record.lines.value()?.physical_lines),
2754 "blank_lines" => direct(record.lines.value()?.blank_lines),
2755 "nonblank_lines" => direct(record.lines.value()?.nonblank_lines),
2756 "raw_words" => direct(record.lines.value()?.raw_words),
2757 "code_lines" => direct(record.code?.value()?.code_lines),
2758 "comment_lines" => direct(record.code?.value()?.comment_lines),
2759 "code_blank_lines" => direct(record.code?.value()?.code_blank_lines),
2760 "paragraphs" => direct(record.words?.value()?.paragraphs),
2761 "visible_words" => direct(record.words?.value()?.visible_words),
2762 "logical_words" => {
2763 Some(MetricMeasure::Normalized(record.words?.value()?.logical_word_stats))
2764 }
2765 "visible_logical_words" => {
2766 Some(MetricMeasure::Normalized(record.words?.value()?.visible_logical_word_stats))
2767 }
2768 "document_words" => {
2769 let words = record.words?.value()?;
2770 Some(MetricMeasure::Normalized(if file_type == "markdown" {
2771 words.visible_logical_word_stats
2772 } else {
2773 words.logical_word_stats
2774 }))
2775 }
2776 _ => None,
2777 }
2778}
2779
2780fn metric_sort_values(
2781 index: &Index,
2782 content: AnalysisSet,
2783 sources: &[FileRow],
2784 metric_name: &str,
2785) -> BTreeMap<PathBuf, u64> {
2786 let held = index.content().and_then(|tier| tier.admit(&index.content_identity(content)));
2787 let mut aggregate = BTreeMap::<PathBuf, MetricAggregate>::new();
2788 for file in sources.iter().filter(|row| row.kind == EntryKind::File) {
2789 let Some(record) = held.and_then(|tier| tier.file(&file.path)) else { continue };
2790 let classification = index.classify(&file.path);
2791 let Some(measure) =
2792 measured_file_metric(record, classification.file_type.as_str(), metric_name)
2793 else {
2794 continue;
2795 };
2796 let mut path = Some(file.path.as_path());
2797 while let Some(current) = path {
2798 if current.as_os_str().is_empty() {
2799 break;
2800 }
2801 aggregate.entry(current.to_path_buf()).or_default().add(measure);
2802 path = current.parent();
2803 }
2804 }
2805 let normalized =
2806 matches!(metric_name, "logical_words" | "visible_logical_words" | "document_words");
2807 aggregate
2808 .into_iter()
2809 .filter_map(|(path, values)| values.value(normalized).map(|value| (path, value)))
2810 .collect()
2811}
2812
2813fn every_entry(index: &Index) -> Vec<FileRow> {
2815 debug_assert!(!index.is_folded(), "a folded index keeps too few files to list");
2816 let mut rows = Vec::new();
2817 let mut stack: Vec<(EntryId, PathBuf)> = vec![(EntryId::ROOT, PathBuf::new())];
2818 while let Some((id, path)) = stack.pop() {
2819 let Some(children) = index.children_of(id) else {
2820 continue;
2821 };
2822 let children: Vec<(PathBuf, EntryId)> =
2823 children.map(|(name, child)| (path.join(name), child)).collect();
2824 for (child_path, child) in children {
2825 let (Some(kind), Some(attrs)) = (index.kind_of(child), index.attrs_of(child)) else {
2826 continue;
2827 };
2828 rows.push(FileRow {
2829 path: child_path.clone(),
2830 kind,
2831 bytes: attrs.size,
2832 allocated: attrs.allocated,
2833 mtime_ns: attrs.mtime_ns,
2834 files: None,
2835 dirs: None,
2836 complete: None,
2837 age_ns: None,
2838 ignored: index.ignored_classification_of(&child_path, child),
2839 sort_value: None,
2840 classification: None,
2841 });
2842 if kind == EntryKind::Dir {
2843 stack.push((child, child_path));
2844 }
2845 }
2846 }
2847 rows
2848}
2849
2850fn tree_node(
2852 index: &Index,
2853 query: &Query,
2854 content: AnalysisSet,
2855 walked: Option<&Walked>,
2856 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
2857) -> (Option<TreeNode>, Vec<TreeOmission>) {
2858 let unfiltered = (walked.is_none() && matches!(query.selection.sort, Some(SortKey::Metric(_))))
2859 .then(|| every_entry(index));
2860 let metric_values = if let Some(SortKey::Metric(name)) = query.selection.sort {
2861 let sources = walked.map_or_else(
2862 || unfiltered.as_deref().unwrap_or(&[]),
2863 |walked| walked.members.as_slice(),
2864 );
2865 metric_sort_values(index, content, sources, name)
2866 } else {
2867 BTreeMap::new()
2868 };
2869 let root_summary = match walked {
2870 None => unfiltered_summary(index, EntryId::ROOT, Path::new("")),
2871 Some(walked) => walked.summary_of(EntryId::ROOT),
2872 };
2873
2874 let mut root = TreeNode {
2875 path: PathBuf::new(),
2876 name: ".".to_string(),
2877 kind: EntryKind::Dir,
2878 entry_ignored: index.ignored_classification_of(Path::new(""), EntryId::ROOT),
2879 bytes: root_summary.bytes,
2880 allocated: root_summary.allocated,
2881 files: root_summary.files,
2882 dirs: root_summary.dirs,
2883 ignored: root_summary.ignored,
2884 newest_mtime_ns: root_summary.newest_mtime_ns,
2885 children: Vec::new(),
2886 omissions: Vec::new(),
2887 truncated: false,
2888 };
2889 if query.limit_for(ViewSpec::Tree) == Bound::Limit(0) {
2890 let complete = index.state().coverage == crate::Coverage::Complete;
2891 let omitted = TreeOmission {
2892 reason: TreeOmissionReason::Rows,
2893 entries: 1,
2894 files: complete.then_some(root.files),
2895 bytes: complete.then_some(root.bytes),
2896 allocated: complete.then_some(root.allocated),
2897 ignored: complete.then_some(root.ignored).flatten().map(IgnoredSize::from_tally),
2898 };
2899 return (None, vec![omitted]);
2900 }
2901 expand(index, query, walked, &metric_values, tree_measurements, &mut root);
2902 if let Some(cap) = query.limit_for(ViewSpec::Tree).limit() {
2903 root = cap_tree_rows(root, cap, index.state().coverage == crate::Coverage::Complete);
2904 }
2905 (Some(root), Vec::new())
2906}
2907
2908#[derive(Clone, Copy)]
2911struct FoldedRows {
2912 entries: usize,
2913 files: u64,
2914 bytes: u64,
2915 allocated: u64,
2916 ignored: Option<IgnoredSize>,
2919}
2920
2921impl FoldedRows {
2922 const NONE: Self = Self {
2924 entries: 0,
2925 files: 0,
2926 bytes: 0,
2927 allocated: 0,
2928 ignored: Some(IgnoredSize { bytes: 0, allocated: 0 }),
2929 };
2930
2931 fn of(index: &Index, id: EntryId, path: &Path) -> Option<Self> {
2933 let folded = index.folded_children(id)?;
2934 Some(Self {
2935 entries: usize::try_from(folded.files).unwrap_or(usize::MAX),
2936 files: folded.files,
2937 bytes: folded.bytes,
2938 allocated: folded.allocated,
2939 ignored: index.children_classification_known(path).then_some(folded.ignored),
2940 })
2941 }
2942}
2943
2944#[derive(Clone, Copy)]
2948struct RowFacts {
2949 files: u64,
2950 bytes: u64,
2951 allocated: u64,
2952 ignored: Option<IgnoredTally>,
2953}
2954
2955impl From<&TreeNode> for RowFacts {
2956 fn from(node: &TreeNode) -> Self {
2957 Self {
2958 files: node.files,
2959 bytes: node.bytes,
2960 allocated: node.allocated,
2961 ignored: node.ignored,
2962 }
2963 }
2964}
2965
2966fn facts_of(rows: &[(TreeNode, EntryId)]) -> Vec<RowFacts> {
2967 rows.iter().map(|(row, _)| RowFacts::from(row)).collect()
2968}
2969
2970fn record_omission(
2971 node: &mut TreeNode,
2972 reason: TreeOmissionReason,
2973 rows: &[RowFacts],
2974 folded: Option<FoldedRows>,
2975 complete: bool,
2976) {
2977 if rows.is_empty() && folded.is_none() {
2978 return;
2979 }
2980 let seed = folded.unwrap_or(FoldedRows::NONE);
2983 let bytes = complete
2984 .then(|| rows.iter().try_fold(seed.bytes, |sum, row| sum.checked_add(row.bytes)))
2985 .flatten();
2986 let allocated = complete
2987 .then(|| rows.iter().try_fold(seed.allocated, |sum, row| sum.checked_add(row.allocated)))
2988 .flatten();
2989 let files = complete
2990 .then(|| rows.iter().try_fold(seed.files, |sum, row| sum.checked_add(row.files)))
2991 .flatten();
2992 let ignored = complete
2993 .then(|| {
2994 rows.iter().try_fold(seed.ignored?, |sum, row| {
2995 sum.checked_add(IgnoredSize::from_tally(row.ignored?))
2996 })
2997 })
2998 .flatten();
2999 node.omissions.push(TreeOmission {
3000 reason,
3001 entries: rows.len().saturating_add(seed.entries),
3002 files,
3003 bytes,
3004 allocated,
3005 ignored,
3006 });
3007 node.truncated = true;
3008}
3009
3010fn cap_tree_rows(root: TreeNode, cap: usize, complete: bool) -> TreeNode {
3011 struct Pending {
3012 node: TreeNode,
3013 parent: Option<usize>,
3014 }
3015 let mut pending = vec![Pending { node: root, parent: None }];
3016 let mut kept: Vec<Pending> = Vec::new();
3017 while let Some(mut item) = pending.pop() {
3018 if kept.len() == cap {
3019 let parent = item.parent.expect("root admitted by positive row cap");
3020 let omission = TreeOmission {
3021 reason: TreeOmissionReason::Rows,
3022 entries: 1,
3023 files: complete.then_some(item.node.files),
3024 bytes: complete.then_some(item.node.bytes),
3025 allocated: complete.then_some(item.node.allocated),
3026 ignored: complete
3027 .then_some(item.node.ignored)
3028 .flatten()
3029 .map(IgnoredSize::from_tally),
3030 };
3031 let owner = &mut kept[parent].node;
3032 if let Some(existing) = owner
3033 .omissions
3034 .iter_mut()
3035 .find(|existing| existing.reason == TreeOmissionReason::Rows)
3036 {
3037 existing.entries += 1;
3038 existing.files =
3039 existing.files.zip(omission.files).and_then(|(a, b)| a.checked_add(b));
3040 existing.bytes =
3041 existing.bytes.zip(omission.bytes).and_then(|(a, b)| a.checked_add(b));
3042 existing.allocated =
3043 existing.allocated.zip(omission.allocated).and_then(|(a, b)| a.checked_add(b));
3044 existing.ignored =
3045 existing.ignored.zip(omission.ignored).and_then(|(a, b)| a.checked_add(b));
3046 } else {
3047 owner.omissions.push(omission);
3048 }
3049 owner.truncated = true;
3050 continue;
3051 }
3052 let children = std::mem::take(&mut item.node.children);
3053 let current = kept.len();
3054 kept.push(item);
3055 for child in children.into_iter().rev() {
3056 pending.push(Pending { node: child, parent: Some(current) });
3057 }
3058 }
3059 for position in (1..kept.len()).rev() {
3060 let child = kept.remove(position);
3061 kept[child.parent.expect("only root lacks parent")].node.children.insert(0, child.node);
3062 }
3063 kept.pop().expect("positive cap admits root").node
3064}
3065
3066fn expand(
3073 index: &Index,
3074 query: &Query,
3075 walked: Option<&Walked>,
3076 metric_values: &BTreeMap<PathBuf, u64>,
3077 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
3078 node: &mut TreeNode,
3079) {
3080 struct Pending {
3082 node: TreeNode,
3083 id: EntryId,
3084 depth: usize,
3085 parent: Option<usize>,
3086 }
3087
3088 let mut built = vec![Pending {
3089 node: TreeNode {
3092 path: node.path.clone(),
3093 name: node.name.clone(),
3094 kind: node.kind,
3095 entry_ignored: node.entry_ignored,
3096 bytes: node.bytes,
3097 allocated: node.allocated,
3098 files: node.files,
3099 dirs: node.dirs,
3100 ignored: node.ignored,
3101 newest_mtime_ns: node.newest_mtime_ns,
3102 children: Vec::new(),
3103 omissions: Vec::new(),
3104 truncated: false,
3105 },
3106 id: EntryId::ROOT,
3107 depth: 0,
3108 parent: None,
3109 }];
3110
3111 let threshold = query.min_share_for();
3112 let grand = match query.selection.size {
3113 SizeMetric::Apparent => node.bytes,
3114 SizeMetric::Allocated => node.allocated,
3115 };
3116 let complete = index.state().coverage == crate::Coverage::Complete;
3117 let mut cursor = 0;
3118 while cursor < built.len() {
3119 let (id, depth) = (built[cursor].id, built[cursor].depth);
3120 let path = built[cursor].node.path.clone();
3121
3122 let (mut rows, below_share) = child_rows(
3123 index,
3124 query,
3125 walked,
3126 metric_values,
3127 tree_measurements,
3128 id,
3129 &path,
3130 &threshold,
3131 grand,
3132 );
3133 record_omission(
3136 &mut built[cursor].node,
3137 TreeOmissionReason::Share,
3138 &below_share,
3139 FoldedRows::of(index, id, &path),
3140 true,
3141 );
3142 if !query.depth_for(ViewSpec::Tree).admits(depth) {
3143 record_omission(
3144 &mut built[cursor].node,
3145 TreeOmissionReason::Depth,
3146 &facts_of(&rows),
3147 None,
3148 complete,
3149 );
3150 cursor += 1;
3151 continue;
3152 }
3153 if let Some(cap) = query.breadth_for().limit() {
3154 let hidden = rows.split_off(cap.min(rows.len()));
3155 record_omission(
3156 &mut built[cursor].node,
3157 TreeOmissionReason::Breadth,
3158 &facts_of(&hidden),
3159 None,
3160 complete,
3161 );
3162 }
3163
3164 for (child_node, child_id) in rows {
3165 built.push(Pending {
3166 node: child_node,
3167 id: child_id,
3168 depth: depth + 1,
3169 parent: Some(cursor),
3170 });
3171 }
3172 cursor += 1;
3173 }
3174
3175 for position in (1..built.len()).rev() {
3178 let child = built.remove(position);
3179 let parent = child.parent.expect("only the root has no parent");
3180 built[parent].node.children.insert(0, child.node);
3181 }
3182
3183 let mut root = built.pop().expect("the root is always present");
3184 node.children = std::mem::take(&mut root.node.children);
3185 node.omissions = std::mem::take(&mut root.node.omissions);
3186 node.truncated = root.node.truncated;
3187}
3188
3189#[allow(clippy::too_many_arguments)]
3199fn child_rows(
3200 index: &Index,
3201 query: &Query,
3202 walked: Option<&Walked>,
3203 metric_values: &BTreeMap<PathBuf, u64>,
3204 tree_measurements: Option<&BTreeMap<EntryId, query_subtrees::SubtreeValues>>,
3205 id: EntryId,
3206 path: &Path,
3207 threshold: &ShareThreshold,
3208 grand: u64,
3209) -> (Vec<(TreeNode, EntryId)>, Vec<RowFacts>) {
3210 let Some(children) = index.children_of(id) else {
3211 return (Vec::new(), Vec::new());
3212 };
3213 let children_known = index.children_classification_known(path);
3215 let mut child_path = path.to_path_buf();
3216 let mut rows: Vec<(TreeNode, EntryId)> = Vec::new();
3217 let mut below_share: Vec<RowFacts> = Vec::new();
3218 for (name, child) in children {
3219 let Some(kind) = index.kind_of(child) else {
3220 continue;
3221 };
3222 if !matches!(kind, EntryKind::Dir | EntryKind::File) {
3223 continue;
3224 }
3225 if walked.is_some_and(|walked| match kind {
3226 EntryKind::Dir => !walked.visible.contains(&child),
3227 EntryKind::File => !walked.visible_files.contains(&child),
3228 EntryKind::Symlink | EntryKind::Other => true,
3229 }) {
3230 continue;
3231 }
3232 let entry_ignored = children_known.then(|| index.entry_ignored(child)).flatten();
3233 child_path.push(name);
3234 let summary = if kind == EntryKind::File {
3235 let attrs = index.attrs_of(child).expect("live child has attributes");
3236 let ignored = entry_ignored.map(|ignored| {
3237 if ignored {
3238 IgnoredTally {
3239 files: 1,
3240 dirs: 0,
3241 bytes: attrs.size,
3242 allocated: attrs.allocated,
3243 }
3244 } else {
3245 IgnoredTally::default()
3246 }
3247 });
3248 SummaryRow {
3249 files: 1,
3250 dirs: 0,
3251 bytes: attrs.size,
3252 allocated: attrs.allocated,
3253 ignored,
3254 newest_mtime_ns: Some(attrs.mtime_ns),
3255 }
3256 } else {
3257 match walked {
3258 None => unfiltered_summary(index, child, &child_path),
3259 Some(walked) => walked.summary_of(child),
3260 }
3261 };
3262 let value = match query.selection.size {
3263 SizeMetric::Apparent => summary.bytes,
3264 SizeMetric::Allocated => summary.allocated,
3265 };
3266 let child_complete = kind == EntryKind::File
3270 || tree_measurements
3271 .is_none_or(|values| values.get(&child).is_some_and(|subtree| subtree.complete));
3272 if child_complete && !threshold.admits(value, grand) {
3273 below_share.push(RowFacts {
3274 files: summary.files,
3275 bytes: summary.bytes,
3276 allocated: summary.allocated,
3277 ignored: summary.ignored,
3278 });
3279 child_path.pop();
3280 continue;
3281 }
3282 rows.push((
3283 TreeNode {
3284 path: child_path.clone(),
3285 name: name.to_string_lossy().into_owned(),
3286 kind,
3287 entry_ignored,
3288 bytes: summary.bytes,
3289 allocated: summary.allocated,
3290 files: summary.files,
3291 dirs: summary.dirs,
3292 ignored: summary.ignored,
3293 newest_mtime_ns: summary.newest_mtime_ns,
3294 children: Vec::new(),
3295 omissions: Vec::new(),
3296 truncated: false,
3297 },
3298 child,
3299 ));
3300 child_path.pop();
3301 }
3302
3303 sort_rows_by(
3304 &mut rows,
3305 query,
3306 ViewSpec::Tree,
3307 SortAccessors {
3308 size: |(row, _): &(TreeNode, EntryId), metric| match metric {
3309 SizeMetric::Apparent => row.bytes,
3310 SizeMetric::Allocated => row.allocated,
3311 },
3312 count: |(row, _): &(TreeNode, EntryId)| row.files,
3313 mtime: |(row, _): &(TreeNode, EntryId)| row.newest_mtime_ns,
3314 name: borrowed_name(|(row, _): &(TreeNode, EntryId)| {
3315 std::borrow::Cow::Borrowed(row.name.as_str())
3316 }),
3317 content_metric: |(row, _): &(TreeNode, EntryId), _: &MetricDef| {
3318 metric_values.get(&row.path).copied()
3319 },
3320 },
3321 );
3322 (rows, below_share)
3323}
3324
3325fn truncate<T>(rows: &mut Vec<T>, limit: Bound) -> usize {
3327 let total = rows.len();
3328 if let Some(limit) = limit.limit() {
3329 rows.truncate(limit);
3330 }
3331 total
3332}
3333
3334struct SortAccessors<S, C, M, N, V> {
3336 size: S,
3337 count: C,
3338 mtime: M,
3339 name: N,
3340 content_metric: V,
3341}
3342
3343fn sort_rows<T>(
3344 rows: &mut [T],
3345 query: &Query,
3346 view: ViewSpec,
3347 accessors: SortAccessors<
3348 impl Fn(&T, SizeMetric) -> u64,
3349 impl Fn(&T) -> u64,
3350 impl Fn(&T) -> Option<i64>,
3351 impl for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
3352 impl Fn(&T, &MetricDef) -> Option<u64>,
3353 >,
3354) {
3355 sort_rows_by(rows, query, view, accessors);
3356}
3357
3358fn borrowed_name<T, F>(name: F) -> F
3361where
3362 F: for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
3363{
3364 name
3365}
3366
3367fn sort_rows_by<T>(
3372 rows: &mut [T],
3373 query: &Query,
3374 view: ViewSpec,
3375 accessors: SortAccessors<
3376 impl Fn(&T, SizeMetric) -> u64,
3377 impl Fn(&T) -> u64,
3378 impl Fn(&T) -> Option<i64>,
3379 impl for<'r> Fn(&'r T) -> std::borrow::Cow<'r, str>,
3380 impl Fn(&T, &MetricDef) -> Option<u64>,
3381 >,
3382) {
3383 let SortAccessors { size, count, mtime, name, content_metric } = accessors;
3384 let key = query.selection.sort.unwrap_or_else(|| view.default_sort());
3385 let metric = query.selection.size;
3386
3387 rows.sort_by(|left, right| {
3388 let ordering = match key {
3389 SortKey::Size => size(right, metric).cmp(&size(left, metric)),
3391 SortKey::Count => count(right).cmp(&count(left)),
3392 SortKey::Mtime => mtime(right).cmp(&mtime(left)),
3393 SortKey::Name => name(left).cmp(&name(right)),
3394 SortKey::Metric(metric_name) => {
3395 let definition =
3396 crate::content::METRICS.iter().find(|entry| entry.name == metric_name);
3397 let left_value = definition.and_then(|definition| content_metric(left, definition));
3398 let right_value =
3399 definition.and_then(|definition| content_metric(right, definition));
3400 match (left_value, right_value) {
3401 (Some(left), Some(right)) if query.selection.reverse => left.cmp(&right),
3402 (Some(left), Some(right)) => right.cmp(&left),
3403 (Some(_), None) => std::cmp::Ordering::Less,
3404 (None, Some(_)) => std::cmp::Ordering::Greater,
3405 (None, None) => std::cmp::Ordering::Equal,
3406 }
3407 }
3408 };
3409 ordering.then_with(|| name(left).cmp(&name(right)))
3412 });
3413
3414 if query.selection.reverse && !matches!(key, SortKey::Metric(_)) {
3415 rows.reverse();
3416 }
3417}
3418
3419#[cfg(test)]
3420mod tests {
3421 use super::*;
3422 use crate::engine_contract::{Attrs, Observation, Op};
3423 use crate::query::query_glob::Pattern;
3424 use crate::query::query_selection::ModifiedWindow;
3425 use std::fs;
3426 use std::time::{Duration, UNIX_EPOCH};
3427
3428 fn attrs(size: u64, mtime_ns: i64) -> Attrs {
3429 Attrs {
3430 size,
3431 allocated: size.div_ceil(512) * 512,
3432 mtime_ns,
3433 ctime_ns: mtime_ns,
3434 inode: size.wrapping_mul(31).wrapping_add(mtime_ns.unsigned_abs()),
3435 dev: 1,
3436 }
3437 }
3438
3439 fn upsert(path: &str, kind: EntryKind, attrs: Attrs) -> Op {
3440 Op::Upsert { path: PathBuf::from(path), kind, attrs }
3441 }
3442
3443 fn sample() -> Index {
3445 let mut index = Index::new("/root");
3446 index
3447 .apply(&Observation::new(vec![
3448 upsert("src", EntryKind::Dir, Attrs::default()),
3449 upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
3450 upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
3451 upsert("src/deep", EntryKind::Dir, Attrs::default()),
3452 upsert("src/deep/nested.rs", EntryKind::File, attrs(50, 40)),
3453 upsert("docs", EntryKind::Dir, Attrs::default()),
3454 upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
3455 upsert("notes.txt", EntryKind::File, attrs(7, 5)),
3456 ]))
3457 .expect("apply");
3458 index
3459 }
3460
3461 #[test]
3462 fn ages_use_one_signed_reference_and_unrepresentable_clocks_are_unknown() {
3463 let mut index = sample();
3464 index.apply_ok(&Observation::new(vec![
3465 upsert("past", EntryKind::File, attrs(1, -10)),
3466 upsert("future", EntryKind::File, attrs(1, i64::MAX)),
3467 ]));
3468 let query = query(
3469 &[ViewSpec::Files],
3470 Selection { include: vec![pattern("past"), pattern("future")], ..Selection::default() },
3471 );
3472 let mut request = Request::new(Basis::held_by(&index), query, UNIX_EPOCH);
3473 let answer = report(&index, &request, UNIX_EPOCH).expect("report");
3474 let rows = files_of(&answer);
3475 assert_eq!(answer.age_reference_ns, Some(0));
3476 assert_eq!(
3477 rows.iter().map(|r| r.age_ns).collect::<Vec<_>>(),
3478 [Some(-i128::from(i64::MAX)), Some(10)]
3479 );
3480 request.now = UNIX_EPOCH + Duration::from_secs(10_000_000_000);
3481 let answer = report(&index, &request, UNIX_EPOCH)
3482 .expect("out-of-range reference is representable as unknown age");
3483 assert_eq!(answer.age_reference_ns, None);
3484 assert!(files_of(&answer).iter().all(|row| row.age_ns.is_none()));
3485 }
3486
3487 #[test]
3488 fn matching_a_directory_selects_its_subtree_once() {
3489 let index = sample();
3490 let selection = Selection {
3491 include: vec![pattern("src"), pattern("deep")],
3492 kinds: vec![EntryKind::Dir],
3493 size: SizeMetric::Apparent,
3494 min_size: Some(40),
3495 ..Selection::default()
3496 };
3497 let report = run(
3498 &index,
3499 &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Extensions], selection),
3500 );
3501 let rows = files_of(&report);
3502 assert_eq!(rows.len(), 2);
3503 assert_eq!((rows[0].bytes, rows[0].mtime_ns), (350, 40));
3504 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3505 assert_eq!((summary.files, summary.dirs, summary.bytes), (3, 2, 350));
3506 let Section::Extensions { rows, .. } = &report.sections[2] else { panic!("extensions") };
3507 assert_eq!((rows[0].files, rows[0].bytes), (3, 350));
3508 }
3509
3510 #[test]
3511 fn a_selection_over_an_exactly_full_tree_sums_to_u64_max() {
3512 let exact =
3517 |size: u64, mtime_ns: i64| Attrs { size, allocated: size, ..attrs(1, mtime_ns) };
3518 let mut index = Index::new("/root");
3519 index.apply_ok(&Observation::new(vec![
3520 upsert("big", EntryKind::Dir, Attrs::default()),
3521 upsert("big/half.bin", EntryKind::File, exact(1 << 63, 1)),
3522 upsert("big/rest.bin", EntryKind::File, exact((1 << 63) - 1, 2)),
3523 upsert("empty.txt", EntryKind::File, exact(0, 3)),
3524 ]));
3525 assert_eq!(index.total().bytes, u64::MAX);
3526 let selection = Selection {
3527 include: vec![pattern("big")],
3528 size: SizeMetric::Apparent,
3529 min_size: Some(1),
3530 ..Selection::default()
3531 };
3532 let report = run(&index, &query(&[ViewSpec::Summary, ViewSpec::Files], selection));
3533 let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
3534 assert_eq!((summary.files, summary.dirs, summary.bytes), (2, 1, u64::MAX));
3535 let Section::Files { rows, .. } = &report.sections[1] else { panic!("files") };
3536 assert_eq!(rows.iter().map(|row| row.bytes).sum::<u64>(), u64::MAX);
3537 }
3538
3539 #[test]
3540 fn subtree_predicates_include_directory_and_symlink_activity_but_only_file_bytes() {
3541 let mut index = sample();
3542 index
3543 .apply(&Observation::new(vec![
3544 upsert("src", EntryKind::Dir, attrs(9999, 45)),
3545 upsert("src/empty", EntryKind::Dir, attrs(8888, 60)),
3546 upsert("src/link", EntryKind::Symlink, attrs(7777, 70)),
3547 upsert("empty", EntryKind::Dir, attrs(6666, -10)),
3548 ]))
3549 .expect("apply");
3550 let base = Selection {
3551 include: vec![pattern("src"), pattern("empty")],
3552 size: SizeMetric::Apparent,
3553 ..Selection::default()
3554 };
3555 let nested = PathBuf::from("src").join("empty").to_string_lossy().into_owned();
3558 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], base.clone())));
3559 assert_eq!(
3560 rows.iter()
3561 .map(|r| (r.path.to_string_lossy().into_owned(), r.bytes, r.mtime_ns))
3562 .collect::<Vec<_>>(),
3563 [("empty".into(), 0, -10), ("src".into(), 350, 70), (nested.clone(), 0, 60)]
3564 );
3565 for (before, since, expected) in [
3566 (70, 0, vec![nested.clone()]),
3567 (71, 70, vec!["src".to_owned()]),
3568 (0, -10, vec!["empty".to_owned()]),
3569 ] {
3570 let selection = Selection {
3571 modified: ModifiedWindow { before: Some(before), since: Some(since) },
3572 ..base.clone()
3573 };
3574 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
3575 assert_eq!(
3576 rows.iter().map(|r| r.path.to_string_lossy().into_owned()).collect::<Vec<_>>(),
3577 expected
3578 );
3579 }
3580 for (size, minimum, expected) in [
3581 (SizeMetric::Apparent, 350, 1),
3582 (SizeMetric::Apparent, 351, 0),
3583 (SizeMetric::Allocated, 1536, 1),
3584 (SizeMetric::Allocated, 1537, 0),
3585 ] {
3586 let selection = Selection { size, min_size: Some(minimum), ..base.clone() };
3587 assert_eq!(
3588 files_of(&run(&index, &query(&[ViewSpec::Files], selection))).len(),
3589 expected
3590 );
3591 }
3592 }
3593
3594 #[test]
3595 fn exclusions_apply_before_subtree_bounds_and_selected_ancestor_coverage() {
3596 let index = classified_sample();
3597 for (ignored, exclude, bytes, newest) in [
3598 (IgnoredEntries::Include, vec![], 325, 70),
3599 (IgnoredEntries::Exclude, vec![], 300, 20),
3600 (IgnoredEntries::Include, vec![pattern("*.log")], 300, 20),
3601 (IgnoredEntries::Include, vec![pattern("lib.rs")], 125, 70),
3602 ] {
3603 let selection = Selection {
3604 include: vec![pattern("src")],
3605 kinds: vec![EntryKind::Dir],
3606 ignored,
3607 exclude,
3608 size: SizeMetric::Apparent,
3609 ..Selection::default()
3610 };
3611 let report = run(
3612 &index,
3613 &query(&[ViewSpec::Files, ViewSpec::Summary, ViewSpec::Types], selection),
3614 );
3615 let row = &files_of(&report)[0];
3616 assert_eq!((row.bytes, row.mtime_ns), (bytes, newest));
3617 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3618 assert_eq!(summary.bytes, bytes);
3619 let Section::Metrics { summary, .. } = &report.sections[2] else { panic!("types") };
3620 assert_eq!(summary.rows.iter().map(|r| r.bytes).sum::<u64>(), bytes);
3621 }
3622 let selection = Selection {
3623 include: vec![pattern("build")],
3624 exclude: vec![pattern("cache")],
3625 kinds: vec![EntryKind::Dir],
3626 ..Selection::default()
3627 };
3628 let report = run(&index, &query(&[ViewSpec::Files, ViewSpec::Summary], selection));
3629 assert_eq!(files_of(&report)[0].bytes, 0);
3630 let Section::Summary(summary) = &report.sections[1] else { panic!("summary") };
3631 assert_eq!((summary.files, summary.dirs, summary.bytes), (0, 1, 0));
3632 let only = Selection {
3633 include: vec![pattern("cache")],
3634 kinds: vec![EntryKind::Dir],
3635 ignored: IgnoredEntries::Only,
3636 ..Selection::default()
3637 };
3638 assert_eq!(files_of(&run(&index, &query(&[ViewSpec::Files], only)))[0].bytes, 1000);
3639 }
3640
3641 #[test]
3645 fn an_incomplete_subtree_reports_lower_bounds_and_matches_no_time_bound() {
3646 let mut index = Index::new_with_scope(
3649 "/root",
3650 crate::ScanScope { max_depth: Some(2), ..crate::ScanScope::default() },
3651 );
3652 index.apply_ok(&Observation::new(vec![
3653 upsert("env", EntryKind::Dir, attrs(0, 5)),
3654 upsert("env/lib", EntryKind::Dir, attrs(0, 7)),
3655 upsert("env/a.bin", EntryKind::File, attrs(100, 40)),
3656 upsert("docs", EntryKind::Dir, attrs(0, 5)),
3657 upsert("docs/guide.md", EntryKind::File, attrs(30, 50)),
3658 ]));
3659 let directories = |selection: Selection| {
3660 let selection =
3661 Selection { kinds: vec![EntryKind::Dir], size: SizeMetric::Apparent, ..selection };
3662 files_of(&run(&index, &flat(selection)))
3663 .into_iter()
3664 .map(|row| {
3665 (row.path.to_string_lossy().into_owned(), row.complete, row.bytes, row.age_ns)
3666 })
3667 .collect::<Vec<_>>()
3668 };
3669 let env_lib = Path::new("env").join("lib").to_string_lossy().into_owned();
3671 assert_eq!(
3672 directories(Selection::default()),
3673 vec![
3674 ("env".to_string(), Some(false), 100, None),
3675 ("docs".to_string(), Some(true), 30, Some(-50)),
3676 (env_lib, Some(false), 0, None),
3677 ],
3678 "sizes are lower bounds and the age is unknown below the boundary"
3679 );
3680 for modified in [
3681 ModifiedWindow { since: None, before: Some(100) },
3682 ModifiedWindow { since: Some(0), before: None },
3683 ] {
3684 assert_eq!(
3685 directories(Selection { modified, ..Selection::default() }),
3686 vec![("docs".to_string(), Some(true), 30, Some(-50))],
3687 "an unknown age satisfies no bound, not even one its lower bound would prove"
3688 );
3689 }
3690 assert_eq!(
3691 directories(Selection { min_size: Some(100), ..Selection::default() }),
3692 vec![("env".to_string(), Some(false), 100, None)],
3693 "a lower bound at or above the minimum proves the true size is too"
3694 );
3695 assert!(directories(Selection { min_size: Some(101), ..Selection::default() }).is_empty());
3696 let files = files_of(&run(
3698 &index,
3699 &flat(Selection {
3700 kinds: vec![EntryKind::File],
3701 modified: ModifiedWindow { since: Some(45), before: None },
3702 ..Selection::default()
3703 }),
3704 ));
3705 assert_eq!(files.len(), 1);
3706 assert_eq!((files[0].complete, files[0].age_ns), (None, Some(-50)));
3707 }
3708
3709 #[test]
3713 fn an_unscoped_partial_marker_marks_every_directory_row_incomplete() {
3714 let directories = |index: &Index| {
3715 files_of(&run(
3716 index,
3717 &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3718 ))
3719 };
3720 let mut partial = sample();
3721 partial.set_initial_freshness(false);
3722 let rows = directories(&partial);
3723 assert_eq!(rows.len(), 3);
3724 assert!(rows.iter().all(|row| row.complete == Some(false) && row.age_ns.is_none()));
3725 let mut complete = sample();
3726 complete.set_initial_freshness(true);
3727 let rows = directories(&complete);
3728 assert_eq!(rows.len(), 3);
3729 assert!(rows.iter().all(|row| row.complete == Some(true) && row.age_ns.is_some()));
3730 }
3731
3732 #[test]
3735 fn an_opened_root_marks_a_directory_complete_only_once_discovery_listed_it() {
3736 let handle = crate::index::IndexHandle::new(Index::new("/root"));
3737 handle
3738 .transition_discovery(crate::index::DiscoveryTransition::Begin)
3739 .expect("begin discovery");
3740 handle
3741 .apply(&Observation::new(vec![
3742 upsert("known", EntryKind::Dir, attrs(0, 5)),
3743 upsert("pending", EntryKind::Dir, attrs(0, 5)),
3744 ]))
3745 .expect("seed directories");
3746 handle
3747 .apply_discovery(
3748 &Observation::new(Vec::new()),
3749 crate::index::DiscoveryCommit {
3750 directory_complete: Some(PathBuf::from("known")),
3751 transition: None,
3752 },
3753 )
3754 .expect("list one directory");
3755 let completeness = handle
3756 .read_with(|index| {
3757 files_of(&run(
3758 index,
3759 &flat(Selection { kinds: vec![EntryKind::Dir], ..Selection::default() }),
3760 ))
3761 .into_iter()
3762 .map(|row| (row.path.to_string_lossy().into_owned(), row.complete))
3763 .collect::<BTreeMap<_, _>>()
3764 })
3765 .expect("read");
3766 assert_eq!(
3767 completeness,
3768 BTreeMap::from([
3769 ("known".to_string(), Some(true)),
3770 ("pending".to_string(), Some(false))
3771 ])
3772 );
3773 }
3774
3775 #[test]
3776 fn a_filtered_tree_keeps_empty_matches_and_only_folds_visible_directories() {
3777 let mut index = sample();
3778 index
3779 .apply(&Observation::new(vec![upsert("src/empty", EntryKind::Dir, Attrs::default())]))
3780 .expect("apply");
3781 let selection = Selection {
3782 include: vec![pattern("empty")],
3783 depth: Some(Bound::All),
3784 min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3785 ..Selection::default()
3786 };
3787 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3788 assert_eq!(root.children.len(), 1);
3789 assert_eq!(root.children[0].name, "src");
3790 assert_eq!(root.children[0].children[0].name, "empty");
3791 let selection = Selection {
3792 include: vec![pattern("notes.txt")],
3793 depth: Some(Bound::Limit(0)),
3794 min_share: Some(ShareThreshold::parse("0%").expect("valid share")),
3795 ..Selection::default()
3796 };
3797 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
3798 assert!(root.truncated, "a file leaf is hidden by depth zero");
3799 assert_eq!(root.bytes, 7);
3800 }
3801
3802 #[test]
3803 fn the_undocumented_docs_view_alias_is_rejected() {
3804 assert_eq!(
3805 ViewSpec::parse("documents").expect("the canonical view name parses"),
3806 ViewSpec::Documents
3807 );
3808 assert_eq!(
3809 ViewSpec::parse("docs").expect_err("an unreleased alias must not become a contract"),
3810 format!("expected one of {}", ViewSpec::vocabulary())
3811 );
3812 }
3813
3814 #[test]
3815 fn code_analysis_defaults_to_code_overview_and_keeps_document_projection() {
3816 assert_eq!(
3817 ViewSpec::resolve(None, AnalysisSet::NONE.with_code(), "view").expect("default").0,
3818 vec![ViewSpec::Code]
3819 );
3820 assert_eq!(
3821 ViewSpec::resolve(None, AnalysisSet::ALL, "view").expect("combined default").0,
3822 vec![ViewSpec::Code, ViewSpec::Documents]
3823 );
3824 assert_eq!(
3825 ViewSpec::resolve(Some("files"), AnalysisSet::ALL, "view").expect("explicit").0,
3826 vec![ViewSpec::Files]
3827 );
3828 }
3829
3830 fn generated_at() -> std::time::SystemTime {
3831 UNIX_EPOCH + Duration::from_secs(1_001)
3832 }
3833
3834 fn run(index: &Index, query: &Query) -> Report {
3835 report(index, &crate::test_support::read_of(index, query.clone()), generated_at())
3836 .expect("the query is answerable over this index")
3837 }
3838
3839 fn query(views: &[ViewSpec], selection: Selection) -> Query {
3840 Query { selection, views: views.to_vec(), ..Query::default() }
3841 }
3842
3843 fn flat(selection: Selection) -> Query {
3845 Query {
3846 selection,
3847 views: vec![ViewSpec::List],
3848 format: crate::report_format::Format::Paths,
3849 ..Query::default()
3850 }
3851 }
3852
3853 fn pattern(source: &str) -> Pattern {
3854 Pattern::parse(source).expect("pattern compiles")
3855 }
3856
3857 fn summary_of(report: &Report) -> SummaryRow {
3858 match report.sections.first().expect("a section") {
3859 Section::Summary(row) => *row,
3860 other => panic!("expected a summary, got {other:?}"),
3861 }
3862 }
3863
3864 fn files_of(report: &Report) -> Vec<FileRow> {
3865 match report.sections.first().expect("a section") {
3866 Section::Files { rows, .. } => rows.clone(),
3867 other => panic!("expected files, got {other:?}"),
3868 }
3869 }
3870
3871 fn types_of(report: &Report) -> Vec<TypeRow> {
3872 match report.sections.first().expect("a section") {
3873 Section::Extensions { rows, .. } => rows.clone(),
3874 other => panic!("expected types, got {other:?}"),
3875 }
3876 }
3877
3878 fn tree_of(report: &Report) -> TreeNode {
3879 match report.sections.first().expect("a section") {
3880 Section::Tree { root: Some(node), .. } => (**node).clone(),
3881 other => panic!("expected a tree, got {other:?}"),
3882 }
3883 }
3884
3885 #[test]
3886 fn an_unfiltered_summary_matches_the_precomputed_rollup() {
3887 let index = sample();
3888 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
3889 assert_eq!(row.files, 5);
3890 assert_eq!(row.dirs, 3);
3891 assert_eq!(row.bytes, 657);
3892 assert_eq!(row.newest_mtime_ns, Some(40));
3893 }
3894
3895 #[test]
3896 fn the_two_tiers_agree_on_the_same_question() {
3897 let index = sample();
3900 let fast = summary_of(&run(&index, &query(&[ViewSpec::Summary], Selection::default())));
3901
3902 let admits_everything = Selection { min_size: Some(0), ..Selection::default() };
3904 assert!(!admits_everything.is_unfiltered());
3905 let slow = summary_of(&run(&index, &query(&[ViewSpec::Summary], admits_everything)));
3906
3907 assert_eq!(
3912 (fast.files, fast.dirs, fast.bytes, fast.allocated, fast.newest_mtime_ns),
3913 (slow.files, slow.dirs, slow.bytes, slow.allocated, slow.newest_mtime_ns)
3914 );
3915 }
3916
3917 #[test]
3918 fn extension_rows_account_for_every_file_in_both_tiers() {
3919 let mut index = sample();
3924 index
3925 .apply(&Observation::new(vec![
3926 upsert("Makefile", EntryKind::File, attrs(28, 50)),
3927 upsert(".gitignore", EntryKind::File, attrs(11, 51)),
3928 ]))
3929 .expect("apply");
3930
3931 for selection in [
3934 Selection::default(),
3935 Selection { min_size: Some(0), ..Selection::default() },
3936 Selection { kinds: vec![EntryKind::File], ..Selection::default() },
3937 ] {
3938 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
3939 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
3940 assert_eq!(
3941 rows.iter().map(|row| row.bytes).sum::<u64>(),
3942 summary.bytes,
3943 "bytes unaccounted for under {selection:?}: {rows:?}"
3944 );
3945 assert_eq!(
3946 rows.iter().map(|row| row.files).sum::<u64>(),
3947 summary.files,
3948 "files unaccounted for under {selection:?}: {rows:?}"
3949 );
3950 }
3951 }
3952
3953 #[test]
3954 fn names_without_an_extension_share_one_bucket() {
3955 let mut index = sample();
3959 index
3960 .apply(&Observation::new(vec![
3961 upsert("Makefile", EntryKind::File, attrs(28, 50)),
3962 upsert(".gitignore", EntryKind::File, attrs(11, 51)),
3963 ]))
3964 .expect("apply");
3965
3966 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
3967 let bucket = rows
3968 .iter()
3969 .find(|row| row.extension == crate::classify::NO_EXTENSION)
3970 .expect("a bucket for the extension-less names");
3971 assert_eq!(bucket.files, 2);
3972 assert_eq!(bucket.bytes, 39);
3973 assert!(rows.iter().any(|row| row.extension == ".rs"), "{rows:?}");
3975 }
3976
3977 #[test]
3978 fn a_summary_counts_the_union_of_listed_entries_and_directory_contents() {
3979 let index = sample();
3985 for selection in [
3986 Selection { kinds: vec![EntryKind::File], ..Selection::default() },
3987 Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
3988 Selection { include: vec![pattern("*.rs")], ..Selection::default() },
3989 Selection { exclude: vec![pattern("docs")], ..Selection::default() },
3990 Selection { min_size: Some(1_000_000), ..Selection::default() },
3991 Selection { min_size: Some(0), ..Selection::default() },
3992 ] {
3993 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
3994 let listed = files_of(&run(&index, &query(&[ViewSpec::Files], selection.clone())));
3995 let dirs = listed.iter().filter(|row| row.kind == EntryKind::Dir).count() as u64;
3996 let files = every_entry(&index)
3997 .iter()
3998 .filter(|entry| {
3999 entry.kind == EntryKind::File
4000 && listed.iter().any(|row| {
4001 entry.path == row.path
4002 || (row.kind == EntryKind::Dir && entry.path.starts_with(&row.path))
4003 })
4004 })
4005 .count() as u64;
4006 assert_eq!(summary.dirs, dirs, "directory counts disagree under {selection:?}");
4007 assert_eq!(summary.files, files, "file counts disagree under {selection:?}");
4008 }
4009 }
4010
4011 #[test]
4012 fn a_rejected_directory_is_still_descended_into() {
4013 let index = sample();
4016 let selection = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
4017 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
4018 assert_eq!(row.dirs, 0, "no directory was admitted");
4019 assert_eq!(row.files, 5, "including src/deep/nested.rs, two levels down");
4020 assert_eq!(row.bytes, 657, "and its bytes");
4021 }
4022
4023 #[test]
4024 fn nested_directory_counts_roll_up_through_every_level() {
4025 let index = sample();
4028 let selection = Selection { kinds: vec![EntryKind::Dir], ..Selection::default() };
4029 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4030 assert_eq!(root.dirs, 3, "src, src/deep, and docs");
4031 assert_eq!(root.files, 4, "matching directories cover their regular files");
4032 let src = root.children.iter().find(|node| node.name == "src").expect("src");
4033 assert_eq!(src.dirs, 1, "src/deep, counted for src as well as for the root");
4034 }
4035
4036 #[test]
4037 fn selection_narrows_a_summary_to_what_it_admits() {
4038 let index = sample();
4039 let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
4040 let row = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
4041 assert_eq!(row.files, 3, "three .rs files");
4042 assert_eq!(row.bytes, 350);
4043 }
4044
4045 #[test]
4046 fn a_files_view_lists_matching_entries_in_name_order_by_default() {
4047 let index = sample();
4048 let selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
4049 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4050 let paths: Vec<PathBuf> = rows.iter().map(|row| row.path.clone()).collect();
4054 let expected: Vec<PathBuf> = [["src", "deep", "nested.rs"].iter().collect::<PathBuf>()]
4055 .into_iter()
4056 .chain([["src", "lib.rs"].iter().collect::<PathBuf>()])
4057 .chain([["src", "main.rs"].iter().collect::<PathBuf>()])
4058 .collect();
4059 assert_eq!(paths, expected);
4060 }
4061
4062 #[test]
4063 fn sorting_and_limiting_compose_without_a_dedicated_view() {
4064 let index = sample();
4066 let selection = Selection {
4068 kinds: vec![EntryKind::File],
4069 sort: Some(SortKey::Size),
4070 limit: Some(Bound::Limit(2)),
4071 size: SizeMetric::Apparent,
4072 ..Selection::default()
4073 };
4074 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4075 assert_eq!(rows.len(), 2);
4076 assert_eq!(rows[0].bytes, 300, "largest first");
4077 assert_eq!(rows[1].bytes, 200);
4078 }
4079
4080 #[test]
4081 fn reverse_flips_whatever_order_is_in_effect() {
4082 let index = sample();
4083 let selection = Selection {
4084 kinds: vec![EntryKind::File],
4085 sort: Some(SortKey::Size),
4086 reverse: true,
4087 size: SizeMetric::Apparent,
4088 ..Selection::default()
4089 };
4090 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4091 assert_eq!(rows[0].bytes, 7, "smallest first once reversed");
4092 }
4093
4094 #[test]
4095 fn a_modified_window_selects_by_time() {
4096 let index = sample();
4097 let selection = Selection {
4098 kinds: vec![EntryKind::File],
4099 modified: ModifiedWindow { since: Some(20), before: Some(40) },
4100 sort: Some(SortKey::Mtime),
4101 ..Selection::default()
4102 };
4103 let rows = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
4104 let mut times: Vec<i64> = rows.iter().map(|row| row.mtime_ns).collect();
4105 times.sort_unstable();
4106 assert_eq!(times, vec![20, 30], "inclusive start, exclusive end");
4107 }
4108
4109 #[test]
4110 fn a_types_view_reports_both_size_metrics_per_extension() {
4111 let index = sample();
4112 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
4113 let rs = rows.iter().find(|row| row.extension == ".rs").expect(".rs present");
4114 assert_eq!((rs.files, rs.bytes), (3, 350));
4115 assert_eq!(rs.allocated, 1536, "three files, one 512-byte block each");
4116 let order: Vec<&str> = rows.iter().map(|row| row.extension.as_str()).collect();
4118 assert_eq!(order, vec![".rs", ".md", ".txt"]);
4119 }
4120
4121 #[test]
4122 fn a_tree_view_reports_directories_with_their_subtree_totals() {
4123 let index = sample();
4124 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], Selection::default())));
4125 assert_eq!(tree.name, ".");
4126 assert_eq!(tree.bytes, 657);
4127 let names: Vec<&str> = tree.children.iter().map(|child| child.name.as_str()).collect();
4129 assert_eq!(names, vec!["src", "docs", "notes.txt"]);
4130 let src = &tree.children[0];
4131 assert_eq!(src.bytes, 350);
4132 let nested: Vec<&str> = src.children.iter().map(|child| child.name.as_str()).collect();
4133 assert!(nested.contains(&"deep"));
4134 }
4135
4136 #[test]
4137 fn depth_zero_keeps_dus_meaning_of_root_totals_only() {
4138 let index = sample();
4139 let selection = Selection { depth: Some(Bound::Limit(0)), ..Selection::default() };
4140 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4141 assert_eq!(tree.bytes, 657, "totals still cover the whole tree");
4142 assert!(tree.children.is_empty(), "but nothing below the root is listed");
4143 assert!(tree.truncated, "and the report says so rather than implying emptiness");
4144 }
4145
4146 #[test]
4151 fn a_dropped_view_is_named_on_the_report_rather_than_by_one_surface() {
4152 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
4153 .expect("full resolves without analyzers");
4154 assert!(omitted.contains(&ViewSpec::Code));
4155 assert!(omitted.contains(&ViewSpec::Documents));
4156
4157 let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
4158 let (notes, _) = display_notes(&query, &ControlCoverage::NotObserved);
4159 assert_eq!(notes.len(), 1, "{notes:?}");
4160 assert!(notes[0].contains("code") && notes[0].contains("documents"), "{notes:?}");
4161
4162 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::ALL, "view")
4164 .expect("full resolves with analyzers");
4165 assert!(omitted.is_empty(), "every view is answerable with analysis enabled");
4166 let query = Query { views: selected, omitted_views: omitted, ..Query::default() };
4167 assert!(display_notes(&query, &ControlCoverage::NotObserved).0.is_empty());
4168 }
4169
4170 #[test]
4180 fn the_refused_controls_note_bounds_its_list_and_matches_its_remedy_to_the_reasons() {
4181 use crate::control::{
4182 ControlLimits, ControlObservation, ControlRefusalReason, RefusedControl,
4183 };
4184 let defaults = ControlLimits::default();
4185 for (reason, wanted, unwanted) in [
4186 (ControlRefusalReason::Budget, "--gitignore-budget", "--gitignore-line-limit"),
4187 (ControlRefusalReason::LineLimit, "--gitignore-line-limit", "--gitignore-budget"),
4188 ] {
4189 let coverage = ControlCoverage::Observed(ControlObservation {
4190 limits: defaults,
4191 applied: 7,
4192 rules: 0,
4193 refused: 1,
4194 refusals: vec![RefusedControl {
4195 path: Path::new("vendor").join(".gitignore"),
4196 reason,
4197 }],
4198 });
4199 let (note, tip) = refused_controls_note(&coverage, &AxisNames::FLAGS).expect("refusal");
4200 assert!(
4201 note.starts_with(
4202 "note: ignore classification incomplete: 1 ignore file not applied"
4203 )
4204 );
4205 assert!(note.contains("vendor"));
4206 assert!(!note.contains("--"), "facts do not repeat flag advice");
4207 let tip = tip.expect("bounded refusal has a remedy");
4208 assert!(tip.starts_with("tip:"));
4209 assert!(tip.contains(wanted));
4210 assert!(!tip.contains(unwanted));
4211 }
4212 let coverage = |limits, refused| {
4213 ControlCoverage::Observed(ControlObservation {
4214 limits,
4215 applied: 7,
4216 rules: 0,
4217 refused,
4218 refusals: (0..crate::MAX_RETAINED_ISSUES)
4219 .map(|i| RefusedControl {
4220 path: Path::new(&format!("d{i:02}")).join(".gitignore"),
4221 reason: ControlRefusalReason::Budget,
4222 })
4223 .collect(),
4224 })
4225 };
4226 let (note, tip) =
4227 refused_controls_note(&coverage(defaults, 1000), &AxisNames::FLAGS).expect("truncated");
4228 assert!(note.contains("995 more"));
4229 assert!(!note.contains("d05"));
4230 let tip = tip.expect("both bounded limits may explain unlisted refusals");
4231 assert!(tip.contains("--gitignore-budget") && tip.contains("--gitignore-line-limit"));
4232 let (_, tip) = refused_controls_note(
4233 &coverage(ControlLimits { line_limit: None, ..defaults }, 1000),
4234 &AxisNames::FLAGS,
4235 )
4236 .expect("truncated");
4237 assert!(!tip.expect("budget remedy").contains("--gitignore-line-limit"));
4238 let (_, tip) = refused_controls_note(
4239 &coverage(ControlLimits { budget: None, line_limit: None }, 64),
4240 &AxisNames::FLAGS,
4241 )
4242 .expect("retained refusal");
4243 assert!(tip.is_none(), "do not suggest raising unbounded limits");
4244 assert!(refused_controls_note(&ControlCoverage::NotObserved, &AxisNames::FLAGS).is_none());
4245 }
4246
4247 #[test]
4248 fn a_diagnostic_names_the_axes_the_requesting_surface_uses() {
4249 let (selected, omitted) = ViewSpec::resolve(Some("full"), AnalysisSet::NONE, "view")
4250 .expect("full resolves without analyzers");
4251
4252 for (axes, mine, theirs) in [
4253 (&AxisNames::FLAGS, "--analyze", "analyze"),
4254 (&AxisNames::FIELDS, "analyze", "--analyze"),
4255 ] {
4256 let query = Query {
4257 views: selected.clone(),
4258 omitted_views: omitted.clone(),
4259 axes,
4260 ..Query::default()
4261 };
4262 let note = display_notes(&query, &ControlCoverage::NotObserved).1.remove(0);
4266 assert!(note.contains(&format!("add {mine} ")), "{note} must name {mine}");
4267 assert!(!note.contains(&format!("add {theirs} ")), "{note} must not name {theirs}");
4268 }
4269
4270 for (axes, view, analyze) in
4273 [(&AxisNames::FLAGS, "--view", "--analyze"), (&AxisNames::FIELDS, "view", "analyze")]
4274 {
4275 let error =
4276 crate::query::RequestError::ViewNeedsContent(ViewSpec::Documents).message(axes);
4277 assert!(error.starts_with(&format!("{view} documents")), "{error}");
4278 assert!(error.contains(&format!("add {analyze} ")), "{error}");
4279 let theirs = if analyze == "--analyze" { "analyze" } else { "--analyze" };
4280 assert!(!error.contains(&format!("add {theirs} ")), "{error}");
4281 }
4282 }
4283
4284 #[test]
4287 fn the_default_vocabulary_is_the_librarys_own() {
4288 assert_eq!(*Query::default().axes, AxisNames::FIELDS);
4289 }
4290
4291 #[test]
4292 fn a_depth_bound_marks_hidden_file_and_directory_rows_as_truncated() {
4293 let index = sample();
4294 let selection = Selection { depth: Some(Bound::Limit(1)), ..Selection::default() };
4295 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4296
4297 let src = tree.children.iter().find(|child| child.name == "src").expect("src");
4298 assert!(src.truncated, "src with a hidden directory child is truncated");
4299
4300 let docs = tree.children.iter().find(|child| child.name == "docs").expect("docs");
4301 assert!(docs.children.is_empty());
4302 assert!(docs.truncated, "significant file leaves are rows beyond the depth boundary");
4303 }
4304
4305 #[test]
4306 fn a_tree_limit_caps_section_rows_including_the_root() {
4307 let index = sample();
4308 let selection = Selection { limit: Some(Bound::Limit(1)), ..Selection::default() };
4309 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4310 assert_eq!(tree.children.len(), 0);
4311 assert!(tree.truncated);
4312 assert!(tree.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Rows));
4313 }
4314
4315 #[test]
4316 fn tree_share_is_root_relative_inclusive_and_does_not_hide_eleventh_sibling() {
4317 let mut index = Index::new("/root");
4318 let mut ops = Vec::new();
4319 for number in 0..11 {
4320 let directory = format!("d{number:02}");
4321 ops.push(upsert(&directory, EntryKind::Dir, Attrs::default()));
4322 ops.push(upsert(&format!("{directory}/file"), EntryKind::File, attrs(20, 1)));
4323 }
4324 ops.push(upsert("filler", EntryKind::File, attrs(780, 1)));
4325 index.apply(&Observation::new(ops)).expect("apply");
4326 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4327 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4328 assert_eq!(root.children.len(), 12, "eleven 2% directories plus the large file");
4329 assert!(root.children.iter().any(|child| child.name == "d10"));
4330 assert!(root.omissions.is_empty());
4331
4332 let selection = Selection {
4333 size: SizeMetric::Apparent,
4334 breadth: Some(Bound::Limit(10)),
4335 ..Selection::default()
4336 };
4337 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4338 assert_eq!(root.children.len(), 10);
4339 assert_eq!(root.omissions[0].reason, TreeOmissionReason::Breadth);
4340 assert_eq!(root.omissions[0].entries, 2);
4341 assert_eq!(root.omissions[0].bytes, Some(40));
4342 }
4343
4344 #[test]
4345 fn tree_threshold_keeps_exact_one_percent_file_leaf() {
4346 let mut index = Index::new("/root");
4347 index
4348 .apply(&Observation::new(vec![
4349 upsert("one-percent", EntryKind::File, attrs(10, 1)),
4350 upsert("below", EntryKind::File, attrs(9, 1)),
4351 upsert("rest", EntryKind::File, attrs(981, 1)),
4352 ]))
4353 .expect("apply");
4354 let root = tree_of(&run(
4355 &index,
4356 &query(
4357 &[ViewSpec::Tree],
4358 Selection { size: SizeMetric::Apparent, ..Selection::default() },
4359 ),
4360 ));
4361 assert_eq!(root.children.len(), 2);
4362 assert!(
4363 root.children
4364 .iter()
4365 .any(|child| child.name == "one-percent" && child.kind == EntryKind::File)
4366 );
4367 assert_eq!(root.omissions[0].reason, TreeOmissionReason::Share);
4368 assert_eq!(root.omissions[0].bytes, Some(9));
4369 }
4370
4371 fn mixed_remainder_index() -> Index {
4374 let mut index = Index::new("/root");
4375 index.apply_ok(&Observation::new(vec![
4376 upsert("A", EntryKind::Dir, attrs(0, 0)),
4377 upsert("A/a1", EntryKind::Dir, attrs(0, 0)),
4378 upsert("A/a1/x", EntryKind::Dir, attrs(0, 0)),
4379 upsert("A/a1/x/leaf", EntryKind::File, attrs(1960, 0)),
4380 upsert("A/a1/tiny", EntryKind::File, attrs(40, 0)),
4381 upsert("A/a2", EntryKind::Dir, attrs(0, 0)),
4382 upsert("A/a2/file", EntryKind::File, attrs(2000, 0)),
4383 upsert("B", EntryKind::Dir, attrs(0, 0)),
4384 upsert("B/b1", EntryKind::Dir, attrs(0, 0)),
4385 upsert("B/b1/file", EntryKind::File, attrs(2000, 0)),
4386 upsert("B/b2", EntryKind::Dir, attrs(0, 0)),
4387 upsert("B/b2/file", EntryKind::File, attrs(2000, 0)),
4388 upsert("C", EntryKind::Dir, attrs(0, 0)),
4389 upsert("C/c1", EntryKind::Dir, attrs(0, 0)),
4390 upsert("C/c1/first", EntryKind::File, attrs(1000, 0)),
4391 upsert("C/c1/second", EntryKind::File, attrs(1000, 0)),
4392 ]));
4393 index
4394 }
4395
4396 #[test]
4397 fn nested_mixed_omissions_have_one_exact_remainder_golden() {
4398 let index = mixed_remainder_index();
4399 let selection = Selection {
4400 size: SizeMetric::Apparent,
4401 depth: Some(Bound::Limit(3)),
4402 breadth: Some(Bound::Limit(2)),
4403 limit: Some(Bound::Limit(8)),
4404 min_share: Some(ShareThreshold::parse("0.5%").expect("share")),
4405 ..Selection::default()
4406 };
4407 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4408 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4409 panic!("expected tree")
4410 };
4411 let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("hidden content");
4412 assert_eq!((root.files, root.bytes, root.allocated), (7, 10_000, 10_752));
4413 assert_eq!(
4414 (remainder.files, remainder.bytes, remainder.allocated),
4415 (Some(2), Some(2_000), Some(2_048))
4416 );
4417 assert_eq!(remainder.reasons, vec![TreeOmissionReason::Breadth]);
4418 assert_eq!(
4419 crate::report_format::render(&report, crate::report_format::Format::Text, false)
4420 .expect("render"),
4421 include_str!("../../tests/golden/remainder-tree.txt"),
4422 );
4423 }
4424
4425 #[test]
4426 fn displayed_directories_represent_their_descendants_even_when_expansion_stops() {
4427 let index = mixed_remainder_index();
4428 let selection = Selection {
4429 size: SizeMetric::Apparent,
4430 depth: Some(Bound::Limit(1)),
4431 breadth: Some(Bound::All),
4432 limit: Some(Bound::All),
4433 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4434 ..Selection::default()
4435 };
4436 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4437 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4438 panic!("expected tree")
4439 };
4440 assert_eq!((root.files, root.bytes), (7, 10_000));
4441 assert_eq!(root.children.len(), 3);
4442 assert!(root.children.iter().all(|child| child.kind == EntryKind::Dir));
4443 assert!(root.children.iter().any(|child| !child.omissions.is_empty()));
4444 assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4445 let diagnostics = crate::report_format::diagnostic_lines(&report);
4446 assert!(!diagnostics.notes.iter().any(|note| note.contains("more covers")));
4447 assert!(diagnostics.notes.iter().any(|note| note.contains("depth 1")));
4448 assert!(
4449 diagnostics.tips.contains(&format!("tip: expand deeper: {}=all", report.axes.depth))
4450 );
4451
4452 let depth_zero = run(
4453 &index,
4454 &query(
4455 &[ViewSpec::Tree],
4456 Selection {
4457 size: SizeMetric::Apparent,
4458 depth: Some(Bound::Limit(0)),
4459 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4460 ..Selection::default()
4461 },
4462 ),
4463 );
4464 let Section::Tree { root: Some(root), omissions, .. } = &depth_zero.sections[0] else {
4465 panic!("expected tree")
4466 };
4467 let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("root alone");
4468 assert_eq!((remainder.files, remainder.bytes), (Some(7), Some(10_000)));
4469 }
4470
4471 #[test]
4472 fn selected_leaf_ledger_conserves_every_top_level_tree_partition() {
4473 let files = [
4477 ("src/main.rs", 100, 512, false),
4478 ("src/lib.rs", 200, 512, false),
4479 ("src/debug.log", 25, 512, true),
4480 ("docs/guide.md", 300, 512, false),
4481 ("build/cache/out.bin", 1_000, 1_024, true),
4482 ];
4483 let dirs = [("src", false), ("docs", false), ("build", true), ("build/cache", true)];
4484 let index = classified_sample();
4485 let within = |path: &str, parent: &Path| {
4486 parent.as_os_str().is_empty() || Path::new(path).starts_with(parent)
4487 };
4488 for (population, excluded) in [
4489 (IgnoredEntries::Include, None),
4490 (IgnoredEntries::Exclude, None),
4491 (IgnoredEntries::Only, None),
4492 (IgnoredEntries::Include, Some("build")),
4493 (IgnoredEntries::Include, Some("*.rs")),
4494 ] {
4495 let admitted = |path: &str, ignored: bool| {
4496 population.admits(ignored)
4497 && !matches!(excluded, Some("build") if Path::new(path).starts_with("build"))
4498 && !matches!(excluded, Some("*.rs") if Path::new(path)
4499 .extension()
4500 .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")))
4501 };
4502 let selected_files: Vec<_> = files
4503 .iter()
4504 .copied()
4505 .filter(|(path, _, _, ignored)| admitted(path, *ignored))
4506 .collect();
4507 let selected_dirs: Vec<_> =
4508 dirs.iter().copied().filter(|(path, ignored)| admitted(path, *ignored)).collect();
4509 for size in [SizeMetric::Apparent, SizeMetric::Allocated] {
4510 for share in ["0%", "1%", "50%", "100%"] {
4511 for depth in [Bound::Limit(0), Bound::Limit(1), Bound::All] {
4512 for breadth in
4513 [Bound::Limit(0), Bound::Limit(1), Bound::Limit(2), Bound::All]
4514 {
4515 for limit in
4516 [Bound::Limit(0), Bound::Limit(1), Bound::Limit(3), Bound::All]
4517 {
4518 let selection = Selection {
4519 ignored: population,
4520 exclude: excluded
4521 .map_or_else(Vec::new, |name| vec![pattern(name)]),
4522 size,
4523 min_share: Some(ShareThreshold::parse(share).expect("share")),
4524 depth: Some(depth),
4525 breadth: Some(breadth),
4526 limit: Some(limit),
4527 ..Selection::default()
4528 };
4529 let report = run(
4530 &index,
4531 &query(&[ViewSpec::Summary, ViewSpec::Tree], selection.clone()),
4532 );
4533 let Section::Summary(summary) = &report.sections[0] else {
4534 panic!("summary")
4535 };
4536 let Section::Tree { root, omissions, .. } = &report.sections[1]
4537 else {
4538 panic!("tree")
4539 };
4540 let expected = (
4541 selected_files.len() as u64,
4542 selected_dirs.len() as u64,
4543 selected_files.iter().map(|file| file.1).sum::<u64>(),
4544 selected_files.iter().map(|file| file.2).sum::<u64>(),
4545 );
4546 let context = format!("{selection:?}");
4547 assert_eq!(
4548 (summary.files, summary.dirs, summary.bytes, summary.allocated),
4549 expected,
4550 "summary: {context}"
4551 );
4552 if let Some(root) = root {
4553 assert_eq!(
4554 (root.files, root.dirs, root.bytes, root.allocated),
4555 expected,
4556 "root: {context}"
4557 );
4558 let mut stack = vec![root.as_ref()];
4559 while let Some(node) = stack.pop() {
4560 let contained: Vec<_> = selected_files
4561 .iter()
4562 .filter(|file| within(file.0, &node.path))
4563 .collect();
4564 let contained_dirs = selected_dirs
4565 .iter()
4566 .filter(|dir| {
4567 within(dir.0, &node.path)
4568 && Path::new(dir.0) != node.path
4569 })
4570 .count()
4571 as u64;
4572 let ignored: Vec<_> =
4573 contained.iter().filter(|file| file.3).collect();
4574 let ignored_dirs = selected_dirs
4575 .iter()
4576 .filter(|dir| {
4577 dir.1
4578 && within(dir.0, &node.path)
4579 && Path::new(dir.0) != node.path
4580 })
4581 .count()
4582 as u64;
4583 assert_eq!(
4584 (node.files, node.dirs, node.bytes, node.allocated),
4585 (
4586 contained.len() as u64,
4587 contained_dirs,
4588 contained.iter().map(|file| file.1).sum::<u64>(),
4589 contained.iter().map(|file| file.2).sum::<u64>(),
4590 ),
4591 "node {:?}: {context}",
4592 node.path
4593 );
4594 assert_eq!(
4595 node.ignored.map(|part| (
4596 part.files,
4597 part.dirs,
4598 part.bytes,
4599 part.allocated
4600 )),
4601 Some((
4602 ignored.len() as u64,
4603 ignored_dirs,
4604 ignored.iter().map(|file| file.1).sum::<u64>(),
4605 ignored.iter().map(|file| file.2).sum::<u64>(),
4606 )),
4607 "ignored {:?}: {context}",
4608 node.path
4609 );
4610 stack.extend(node.children.iter());
4611 }
4612 }
4613 let represented: Vec<_> = root
4614 .iter()
4615 .flat_map(|root| root.children.iter().map(|child| &child.path))
4616 .collect();
4617 let uncovered: Vec<_> = selected_files
4618 .iter()
4619 .filter(|file| {
4620 !represented.iter().any(|path| within(file.0, path))
4621 })
4622 .collect();
4623 let remainder =
4624 TreeRemainder::from_tree(root.as_deref(), omissions);
4625 if let Some(remainder) = remainder {
4626 assert_eq!(
4627 (remainder.files, remainder.bytes, remainder.allocated),
4628 (
4629 Some(uncovered.len() as u64),
4630 Some(uncovered.iter().map(|file| file.1).sum::<u64>()),
4631 Some(uncovered.iter().map(|file| file.2).sum::<u64>()),
4632 ),
4633 "remainder: {context}"
4634 );
4635 assert_eq!(
4636 remainder.ignored.map(|part| (part.bytes, part.allocated)),
4637 Some((
4638 uncovered
4639 .iter()
4640 .filter(|file| file.3)
4641 .map(|file| file.1)
4642 .sum(),
4643 uncovered
4644 .iter()
4645 .filter(|file| file.3)
4646 .map(|file| file.2)
4647 .sum(),
4648 )),
4649 "remainder ignored: {context}"
4650 );
4651 } else {
4652 assert!(uncovered.is_empty(), "missing remainder: {context}");
4653 }
4654 }
4655 }
4656 }
4657 }
4658 }
4659 }
4660 }
4661
4662 #[test]
4663 fn empty_tree_and_unrepresentable_remainder_stay_honest() {
4664 let empty = Index::new_with_scope("/root", crate::test_support::observing_controls());
4665 let report = run(
4666 &empty,
4667 &query(
4668 &[ViewSpec::Tree],
4669 Selection {
4670 depth: Some(Bound::Limit(0)),
4671 min_share: Some(ShareThreshold::parse("1%").expect("share")),
4672 ..Selection::default()
4673 },
4674 ),
4675 );
4676 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4677 panic!("empty tree")
4678 };
4679 assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (0, 0, 0, 0));
4680 assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4681
4682 let first = TreeOmission {
4683 reason: TreeOmissionReason::Share,
4684 entries: 1,
4685 files: Some(u64::MAX),
4686 bytes: Some(u64::MAX),
4687 allocated: Some(u64::MAX),
4688 ignored: Some(IgnoredSize { bytes: u64::MAX, allocated: u64::MAX }),
4689 };
4690 let second = TreeOmission {
4691 reason: TreeOmissionReason::Breadth,
4692 entries: 1,
4693 files: Some(1),
4694 bytes: Some(1),
4695 allocated: None,
4696 ignored: Some(IgnoredSize { bytes: 1, allocated: 1 }),
4697 };
4698 let remainder = TreeRemainder::from_tree(None, &[first, second]).expect("omissions");
4699 assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
4700 assert_eq!(remainder.ignored, None);
4701 }
4702
4703 #[test]
4704 fn unbounded_tree_expands_every_entry_without_a_remainder_or_diagnostics() {
4705 let index = mixed_remainder_index();
4706 let selection = Selection {
4707 size: SizeMetric::Apparent,
4708 depth: Some(Bound::All),
4709 breadth: Some(Bound::All),
4710 limit: Some(Bound::All),
4711 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4712 ..Selection::default()
4713 };
4714 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4715 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4716 panic!("expected tree")
4717 };
4718 let mut paths = std::collections::BTreeSet::new();
4719 let mut stack = vec![root.as_ref()];
4720 while let Some(node) = stack.pop() {
4721 assert!(node.omissions.is_empty() && !node.truncated);
4722 paths.insert(node.path.to_string_lossy().replace('\\', "/"));
4723 stack.extend(&node.children);
4724 }
4725 assert_eq!(
4726 paths,
4727 [
4728 "",
4729 "A",
4730 "A/a1",
4731 "A/a1/tiny",
4732 "A/a1/x",
4733 "A/a1/x/leaf",
4734 "A/a2",
4735 "A/a2/file",
4736 "B",
4737 "B/b1",
4738 "B/b1/file",
4739 "B/b2",
4740 "B/b2/file",
4741 "C",
4742 "C/c1",
4743 "C/c1/first",
4744 "C/c1/second"
4745 ]
4746 .into_iter()
4747 .map(str::to_owned)
4748 .collect()
4749 );
4750 assert_eq!((root.files, root.dirs, root.bytes, root.allocated), (7, 9, 10_000, 10_752));
4751 assert!(omissions.is_empty());
4752 assert!(TreeRemainder::from_tree(Some(root), omissions).is_none());
4753 assert!(report.notes.is_empty() && report.tips.is_empty());
4754 }
4755
4756 #[test]
4757 fn an_unlisted_hidden_branch_makes_remainder_counts_and_sizes_unknown() {
4758 let mut index = Index::new("/root");
4759 index.apply_ok(&Observation::new(vec![
4760 upsert("known", EntryKind::File, attrs(100, 0)),
4761 upsert("denied", EntryKind::Dir, attrs(0, 0)),
4762 ]));
4763 index.set_initial_scan_freshness(&[crate::Error::io(
4764 Path::new("/root").join("denied"),
4765 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
4766 )]);
4767 let selection = Selection {
4768 size: SizeMetric::Apparent,
4769 depth: Some(Bound::Limit(0)),
4770 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4771 ..Selection::default()
4772 };
4773 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4774 let Section::Tree { root: Some(root), omissions, .. } = &report.sections[0] else {
4775 panic!("expected tree")
4776 };
4777 let remainder = TreeRemainder::from_tree(Some(root), omissions).expect("depth bound");
4778 assert_eq!((remainder.files, remainder.bytes, remainder.allocated), (None, None, None));
4779 assert_eq!(remainder.reasons, vec![TreeOmissionReason::Depth]);
4780 let text = crate::report_format::render(&report, crate::report_format::Format::Text, false)
4781 .expect("render");
4782 assert!(text.contains("— unknown … and more files (count unknown)"));
4783 }
4784
4785 #[test]
4789 fn partial_tree_keeps_default_share_pruning_golden() {
4790 let mut index = Index::new("/root");
4791 index.apply_ok(&Observation::new(vec![
4792 upsert("large", EntryKind::File, attrs(9898, 0)),
4793 upsert("one-percent", EntryKind::File, attrs(100, 0)),
4794 upsert("tiny", EntryKind::File, attrs(1, 0)),
4795 upsert("zero", EntryKind::File, attrs(0, 0)),
4796 upsert("small", EntryKind::Dir, attrs(0, 0)),
4797 upsert("small/tiny", EntryKind::File, attrs(1, 0)),
4798 upsert("denied", EntryKind::Dir, attrs(0, 0)),
4799 ]));
4800 index.set_initial_scan_freshness(&[crate::Error::io(
4801 Path::new("/root").join("denied"),
4802 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed listing"),
4803 )]);
4804 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4805 let report = run(&index, &query(&[ViewSpec::Tree], selection.clone()));
4806 assert!(!report.status.complete);
4807 assert_eq!(
4808 crate::report_format::render(&report, crate::report_format::Format::Text, false)
4809 .expect("render"),
4810 include_str!("../../tests/golden/partial-tree.txt"),
4811 );
4812 let filtered = run(
4815 &index,
4816 &query(
4817 &[ViewSpec::Tree],
4818 Selection {
4819 exclude: vec![pattern("large")],
4820 min_share: Some(ShareThreshold::parse("50%").expect("share")),
4821 ..selection.clone()
4822 },
4823 ),
4824 );
4825 let filtered_root = tree_of(&filtered);
4826 assert_eq!(filtered_root.children.len(), 2);
4827 assert_eq!(filtered_root.children[0].name, "one-percent");
4828 assert_eq!(filtered_root.children[1].name, "denied");
4829 let unbounded = run(
4830 &index,
4831 &query(
4832 &[ViewSpec::Tree],
4833 Selection {
4834 min_share: Some(ShareThreshold::parse("0%").expect("share")),
4835 ..selection
4836 },
4837 ),
4838 );
4839 assert_eq!(tree_of(&unbounded).children.len(), 6, "zero share lifts only share pruning");
4840 }
4841
4842 #[test]
4843 fn tree_zero_and_composed_caps_keep_typed_first_boundaries() {
4844 let index = sample();
4845 let selection = Selection { limit: Some(Bound::Limit(0)), ..Selection::default() };
4846 let report = run(&index, &query(&[ViewSpec::Tree], selection));
4847 let Section::Tree { root: None, omissions, .. } = &report.sections[0] else {
4848 panic!("zero row cap must omit the root")
4849 };
4850 assert_eq!(omissions[0].reason, TreeOmissionReason::Rows);
4851 assert_eq!(omissions[0].entries, 1);
4852
4853 let selection = Selection {
4854 size: SizeMetric::Apparent,
4855 depth: Some(Bound::Limit(1)),
4856 breadth: Some(Bound::Limit(1)),
4857 limit: Some(Bound::Limit(2)),
4858 min_share: Some(ShareThreshold::parse("1%").expect("share")),
4859 ..Selection::default()
4860 };
4861 let root = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)));
4862 assert_eq!(root.children.len(), 1);
4863 assert!(
4864 root.omissions.iter().any(|omission| omission.reason == TreeOmissionReason::Breadth)
4865 );
4866 assert!(
4867 root.children[0]
4868 .omissions
4869 .iter()
4870 .any(|omission| omission.reason == TreeOmissionReason::Depth)
4871 );
4872 }
4873
4874 #[test]
4875 fn requesting_more_views_never_changes_another_views_answer() {
4876 let index = sample();
4878 let alone = types_of(&run(&index, &query(&[ViewSpec::Extensions], Selection::default())));
4879 let together = run(
4880 &index,
4881 &query(
4882 &[ViewSpec::Extensions, ViewSpec::Tree, ViewSpec::Summary],
4883 Selection::default(),
4884 ),
4885 );
4886 let with_others = match &together.sections[0] {
4887 Section::Extensions { rows, .. } => rows.clone(),
4888 other => panic!("expected types first, got {other:?}"),
4889 };
4890
4891 assert_eq!(alone.len(), with_others.len());
4892 for (left, right) in alone.iter().zip(with_others.iter()) {
4893 assert_eq!(
4894 (&left.extension, left.files, left.bytes),
4895 (&right.extension, right.files, right.bytes)
4896 );
4897 }
4898 assert_eq!(together.sections.len(), 3, "one section per view, in request order");
4899 assert_eq!(together.sections[1].view(), ViewSpec::Tree);
4900 assert_eq!(together.sections[2].view(), ViewSpec::Summary);
4901 }
4902
4903 #[test]
4904 fn analyzed_unfiltered_views_together_match_independent_answers_and_each_view_alone() {
4905 const RUST: &str = "fn main() {\n println!(\"hi\");\n}\n";
4906 const MARKDOWN: &str = "# Guide\n\nA small useful guide.\n";
4907 const TEXT: &str = "plain notes here\n";
4908 let root = tempfile::tempdir().expect("root");
4909 fs::create_dir_all(root.path().join("src")).expect("src");
4910 fs::create_dir_all(root.path().join("docs")).expect("docs");
4911 fs::write(root.path().join("src/main.rs"), RUST).expect("rust");
4912 fs::write(root.path().join("docs/guide.md"), MARKDOWN).expect("markdown");
4913 fs::write(root.path().join("notes.txt"), TEXT).expect("text");
4914 for (path, seconds) in [("src/main.rs", 10), ("notes.txt", 20), ("docs/guide.md", 30)] {
4915 fs::File::options()
4916 .write(true)
4917 .open(root.path().join(path))
4918 .expect("open for timestamp")
4919 .set_times(
4920 fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(seconds)),
4921 )
4922 .expect("set timestamp");
4923 }
4924 let (mut index, _) = crate::scan::scan_into_index(
4925 root.path(),
4926 &crate::ScanConfig { read_controls: false, ..crate::ScanConfig::default() },
4927 )
4928 .expect("scan");
4929 crate::content::analyze_index(
4930 &mut index,
4931 crate::content::AnalysisRequest {
4932 profile: AnalysisSet::ALL,
4933 ..crate::content::AnalysisRequest::default()
4934 },
4935 );
4936
4937 let views = [
4938 ViewSpec::Types,
4939 ViewSpec::Families,
4940 ViewSpec::Languages,
4941 ViewSpec::Documents,
4942 ViewSpec::Files,
4943 ViewSpec::Largest,
4944 ViewSpec::Recent,
4945 ViewSpec::Summary,
4946 ViewSpec::Tree,
4947 ViewSpec::Extensions,
4948 ];
4949 let selection = Selection { size: SizeMetric::Apparent, ..Selection::default() };
4950 let together = run(&index, &query(&views, selection.clone()));
4951 for (i, view) in views.iter().enumerate() {
4952 let alone = run(&index, &query(&[*view], selection.clone()));
4953 assert_eq!(
4954 format!("{:?}", together.sections[i]),
4955 format!("{:?}", alone.sections[0]),
4956 "{view:?} changed when requested with the other views"
4957 );
4958 }
4959
4960 for (at, view, files) in [(0, ViewSpec::Types, 3), (1, ViewSpec::Families, 3)] {
4961 let Section::Metrics { view: actual, summary } = &together.sections[at] else {
4962 panic!("expected {view:?} metrics")
4963 };
4964 assert_eq!(*actual, view);
4965 assert_eq!(summary.total.files, files);
4966 assert_eq!(summary.total.analyzed_files, files);
4967 }
4968 let Section::Metrics { summary: languages, .. } = &together.sections[2] else {
4969 panic!("languages")
4970 };
4971 assert_eq!(languages.total.files, 1);
4972 assert_eq!(languages.total.metrics.code_lines, Some(3));
4973 let Section::Metrics { summary: documents, .. } = &together.sections[3] else {
4974 panic!("documents")
4975 };
4976 assert_eq!(documents.total.files, 2);
4977 assert_eq!(documents.total.analyzed_files, 2);
4978 assert_eq!(documents.total.document_metric_files, 2);
4979 assert_eq!(documents.total.document_raw_words, 8);
4980 assert_eq!(documents.total.document_word_stats.logical_words(), 8);
4981 assert_eq!(documents.total.share, MetricShare { numerator: 8, denominator: 8 });
4982
4983 let Section::Files { rows: files, total, .. } = &together.sections[4] else {
4984 panic!("files")
4985 };
4986 assert_eq!(*total, 5);
4987 assert_eq!(
4988 files.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
4989 ["docs", "docs/guide.md", "notes.txt", "src", "src/main.rs"].map(Path::new).to_vec()
4990 );
4991 let Section::Files { rows: largest, total, .. } = &together.sections[5] else {
4992 panic!("largest")
4993 };
4994 assert_eq!(*total, 3);
4995 assert_eq!(
4996 largest.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
4997 ["src/main.rs", "docs/guide.md", "notes.txt"].map(Path::new).to_vec()
4998 );
4999 let Section::Files { rows: recent, total, .. } = &together.sections[6] else {
5000 panic!("recent")
5001 };
5002 assert_eq!(*total, 3);
5003 assert_eq!(
5004 recent.iter().map(|row| row.path.as_path()).collect::<Vec<_>>(),
5005 ["docs/guide.md", "notes.txt", "src/main.rs"].map(Path::new).to_vec()
5006 );
5007 let Section::Summary(summary) = &together.sections[7] else { panic!("summary") };
5008 assert_eq!((summary.files, summary.dirs), (3, 2));
5009 assert_eq!(
5010 summary.bytes,
5011 u64::try_from(RUST.len() + MARKDOWN.len() + TEXT.len()).expect("fixture bytes")
5012 );
5013 let Section::Tree { root: Some(tree), .. } = &together.sections[8] else { panic!("tree") };
5014 assert_eq!((tree.files, tree.dirs), (3, 2));
5015 let Section::Extensions { rows, total, .. } = &together.sections[9] else {
5016 panic!("extensions")
5017 };
5018 assert_eq!((*total, rows.len()), (3, 3));
5019 }
5020
5021 #[test]
5022 fn one_pass_metric_summaries_match_independent_views() {
5023 let root = tempfile::tempdir().expect("root");
5024 fs::write(root.path().join("main.rs"), "fn main() {}\n").expect("rust");
5025 fs::write(root.path().join("guide.md"), "# Guide\n\nWords.\n").expect("markdown");
5026 fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
5027 fs::create_dir(root.path().join("generated")).expect("generated directory");
5028 fs::write(root.path().join("generated/app.js"), "const a = 1;\nconst b = 2;\n")
5029 .expect("ignored code");
5030 fs::write(root.path().join("script"), "#!/bin/sh\necho hello\n").expect("detected code");
5031 let (mut index, _) =
5032 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5033 crate::content::analyze_index(
5034 &mut index,
5035 crate::content::AnalysisRequest {
5036 profile: AnalysisSet::ALL,
5037 ..crate::content::AnalysisRequest::default()
5038 },
5039 );
5040
5041 let views = [
5042 ViewSpec::Types,
5043 ViewSpec::Summary,
5044 ViewSpec::Families,
5045 ViewSpec::Languages,
5046 ViewSpec::Documents,
5047 ViewSpec::Code,
5048 ];
5049 let request = query(&views, Selection::default());
5050 let rows = every_entry(&index);
5051 let summaries = metric_summaries(&views, &index, &request, AnalysisSet::ALL, &rows);
5052
5053 assert!(summaries[1].is_none(), "non-metric views keep their own projection");
5054 assert!(summaries[5].is_none(), "Code keeps its admitted-content classification");
5055 for (position, view) in
5056 views.iter().copied().enumerate().filter(|(_, view)| needs_metric_resolution(*view))
5057 {
5058 let independent =
5059 metric_summary(view, &index, &request, AnalysisSet::ALL, None, Some(&rows));
5060 assert_eq!(
5061 format!("{:?}", summaries[position].as_ref().expect("metric summary")),
5062 format!("{independent:?}"),
5063 "{view:?} changed in the one-pass multi-view aggregation"
5064 );
5065 }
5066
5067 let views = views.into_iter().filter(|view| *view != ViewSpec::Summary).collect::<Vec<_>>();
5072 for ignored in [IgnoredEntries::Include, IgnoredEntries::Exclude, IgnoredEntries::Only] {
5073 for sort in [None, Some(SortKey::Metric("code_lines")), Some(SortKey::Name)] {
5074 for min_share in [None, Some(ShareThreshold::parse("50%").expect("share"))] {
5075 let selection = Selection {
5076 ignored,
5077 sort,
5078 min_share,
5079 limit: Some(Bound::Limit(1)),
5080 ..Selection::default()
5081 };
5082 let together = run(&index, &query(&views, selection.clone()));
5083 for (position, view) in views.iter().enumerate() {
5084 let alone = run(&index, &query(&[*view], selection.clone()));
5085 assert_eq!(
5086 format!("{:?}", together.sections[position]),
5087 format!("{:?}", alone.sections[0]),
5088 "{view:?} differs for {selection:?}"
5089 );
5090 }
5091 }
5092 }
5093 }
5094 }
5095
5096 #[test]
5097 fn code_overview_keeps_a_complete_language_table_and_population_contributions() {
5098 let root = tempfile::tempdir().expect("root");
5099 fs::create_dir_all(root.path().join("generated")).expect("generated");
5100 fs::write(root.path().join(".gitignore"), "generated/\n").expect("ignore rules");
5101 fs::create_dir_all(root.path().join("src")).expect("src");
5102 fs::write(root.path().join("src/main.rs"), "fn main() {\n}\n// comment\n").expect("rust");
5103 fs::write(
5104 root.path().join("generated/app.js"),
5105 "// Code generated by fixture\nconst answer = 42;\n",
5106 )
5107 .expect("javascript");
5108 fs::write(root.path().join("README.md"), "# Guide\n").expect("documentation");
5109 fs::write(root.path().join("mystery.widget"), "opaque text\n").expect("unknown type");
5110 let (mut index, _) =
5111 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5112 crate::content::analyze_index(
5113 &mut index,
5114 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5115 );
5116 let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
5117 let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5118 assert_eq!(overview.selected.metrics.code_lines, 3);
5119 assert_eq!(overview.selected.metrics.comment_lines, 2);
5120 assert_eq!(overview.selected.analyzed_files, 2);
5121 assert_eq!(overview.analyzed_languages, 2);
5122 assert_eq!(overview.unclassified_files, 2); assert_eq!(overview.languages.len(), 2);
5124 assert_eq!(overview.languages[0].share.denominator, 3);
5125 assert_eq!(overview.non_ignored.as_ref().expect("classified").metrics.code_lines, 2);
5126 assert_eq!(overview.ignored.as_ref().expect("classified").metrics.code_lines, 1);
5127 assert_eq!(overview.unknown.source_files, 0);
5128
5129 for (population, expected_lines) in
5130 [(IgnoredEntries::Exclude, 2), (IgnoredEntries::Only, 1)]
5131 {
5132 let selected = run(
5133 &index,
5134 &query(
5135 &[ViewSpec::Code],
5136 Selection { ignored: population, ..Selection::default() },
5137 ),
5138 );
5139 let Section::Code(overview) = &selected.sections[0] else { panic!("population") };
5140 assert_eq!(overview.selected.metrics.code_lines, expected_lines);
5141 assert!(overview.non_ignored.is_none() && overview.ignored.is_none());
5142 assert_eq!(overview.unknown.source_files, 0);
5143 }
5144
5145 let threshold = run(
5146 &index,
5147 &query(
5148 &[ViewSpec::Code],
5149 Selection {
5150 min_share: Some(ShareThreshold::parse("50%").expect("share")),
5151 ..Selection::default()
5152 },
5153 ),
5154 );
5155 let Section::Code(overview) = &threshold.sections[0] else { panic!("threshold") };
5156 assert_eq!(overview.selected.metrics.code_lines, 3);
5157 assert_eq!(overview.total_languages, 1);
5158 assert_eq!(overview.share_omitted, 1);
5159 assert_eq!(overview.languages[0].language, "rust");
5160
5161 let bounded = run(
5162 &index,
5163 &query(
5164 &[ViewSpec::Code],
5165 Selection { limit: Some(Bound::Limit(0)), ..Selection::default() },
5166 ),
5167 );
5168 let Section::Code(overview) = &bounded.sections[0] else { panic!("bounded") };
5169 assert_eq!(overview.selected.metrics.code_lines, 3);
5170 assert_eq!(overview.total_languages, 2);
5171 assert_eq!(overview.share_omitted, 0);
5172 assert!(overview.languages.is_empty());
5173
5174 let code_and_extensions = run(
5175 &index,
5176 &query(
5177 &[ViewSpec::Languages, ViewSpec::Extensions],
5178 Selection {
5179 min_share: Some(ShareThreshold::parse("100%").expect("share")),
5180 ..Selection::default()
5181 },
5182 ),
5183 );
5184 let Section::Metrics { summary, .. } = &code_and_extensions.sections[0] else {
5185 panic!("languages")
5186 };
5187 let Section::Extensions { rows, total, share_omitted } = &code_and_extensions.sections[1]
5188 else {
5189 panic!("extensions")
5190 };
5191 assert_eq!(summary.total.metrics.code_lines, Some(3));
5192 assert!(summary.rows.is_empty() && summary.total_rows == 0);
5193 assert_eq!(summary.share_omitted, 2);
5194 assert!(rows.is_empty() && *total == 0);
5195 assert!(*share_omitted > 0);
5196
5197 let reversed_code = run(
5198 &index,
5199 &query(&[ViewSpec::Code], Selection { reverse: true, ..Selection::default() }),
5200 );
5201 let Section::Code(reversed) = &reversed_code.sections[0] else { panic!("reversed code") };
5202 assert_eq!(reversed.languages[0].language, "javascript");
5203 assert_eq!(reversed.languages[1].language, "rust");
5204
5205 let named_code = run(
5206 &index,
5207 &query(
5208 &[ViewSpec::Code],
5209 Selection { sort: Some(SortKey::Name), ..Selection::default() },
5210 ),
5211 );
5212 let Section::Code(named) = &named_code.sections[0] else { panic!("named code") };
5213 assert_eq!(named.languages[0].language, "javascript");
5214 assert_eq!(named.languages[1].language, "rust");
5215
5216 let sized_code = run(
5217 &index,
5218 &query(
5219 &[ViewSpec::Code],
5220 Selection {
5221 sort: Some(SortKey::Size),
5222 size: SizeMetric::Apparent,
5223 ..Selection::default()
5224 },
5225 ),
5226 );
5227 let Section::Code(sized) = &sized_code.sections[0] else { panic!("sized code") };
5228 assert_eq!(sized.languages[0].language, "javascript");
5229
5230 let files = |reverse| {
5231 let selection = Selection {
5232 kinds: vec![EntryKind::File],
5233 sort: Some(SortKey::Metric("code_lines")),
5234 reverse,
5235 ..Selection::default()
5236 };
5237 run(&index, &query(&[ViewSpec::Files], selection))
5238 };
5239 let descending = files(false);
5240 let Section::Files { rows, .. } = &descending.sections[0] else { panic!("files") };
5241 assert_eq!(rows[0].path, PathBuf::from("src/main.rs"));
5242 assert_eq!(rows[0].sort_value, Some(2));
5243 assert_eq!(rows[1].path, PathBuf::from("generated/app.js"));
5244 assert_eq!(rows[1].sort_value, Some(1));
5245 assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5246 let classification = rows[1].classification.as_ref().expect("file classification");
5247 assert!(classification.flags.generated);
5248
5249 let ascending = files(true);
5250 let Section::Files { rows, .. } = &ascending.sections[0] else { panic!("files") };
5251 assert_eq!((rows[0].sort_value, rows[1].sort_value), (Some(1), Some(2)));
5252 assert!(rows[2..].iter().all(|row| row.sort_value.is_none()));
5253
5254 let mut dir_query = query(
5255 &[ViewSpec::List],
5256 Selection {
5257 kinds: vec![EntryKind::Dir],
5258 sort: Some(SortKey::Metric("code_lines")),
5259 ..Selection::default()
5260 },
5261 );
5262 dir_query.format = crate::report_format::Format::Json;
5263 let dirs = run(&index, &dir_query);
5264 let Section::Files { rows, .. } = &dirs.sections[0] else { panic!("directories") };
5265 assert_eq!((rows[0].path.as_path(), rows[0].sort_value), (Path::new("src"), Some(2)));
5266 assert_eq!((rows[1].path.as_path(), rows[1].sort_value), (Path::new("generated"), Some(1)));
5267 }
5268
5269 #[test]
5270 fn code_overview_counts_selected_directory_members_once() {
5271 let root = tempfile::tempdir().expect("root");
5272 fs::create_dir_all(root.path().join("src/deep")).expect("directories");
5273 fs::write(root.path().join("src/main.rs"), "fn main() {}\n").expect("main");
5274 fs::write(root.path().join("src/deep/keep.rs"), "fn keep() {}\n").expect("keep");
5275 fs::write(root.path().join("src/deep/skip.rs"), "fn skip() {}\n").expect("skip");
5276 fs::write(root.path().join("outside.rs"), "fn outside() {}\n").expect("outside");
5277 let (mut index, _) =
5278 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5279 crate::content::analyze_index(
5280 &mut index,
5281 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5282 );
5283 let selection = Selection {
5284 include: vec![pattern("src"), pattern("deep")],
5285 exclude: vec![pattern("skip.rs")],
5286 ..Selection::default()
5287 };
5288 let answer = run(&index, &query(&[ViewSpec::Code], selection));
5289 let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5290 assert_eq!(overview.selected.source_files, 2);
5291 assert_eq!(overview.selected.analyzed_files, 2);
5292 assert_eq!(overview.selected.metrics.code_lines, 2);
5293 assert_eq!(overview.languages.len(), 1);
5294 assert_eq!(overview.languages[0].selected.source_files, 2);
5295 }
5296
5297 #[test]
5298 fn code_overview_uses_retained_content_detection_for_ambiguous_sources() {
5299 let root = tempfile::tempdir().expect("root");
5300 fs::write(root.path().join("ambiguous.h"), "namespace demo { int value; }\n")
5301 .expect("header");
5302 fs::write(root.path().join("script.inc"), "# vim: set filetype=rust:\nfn main() {}\n")
5303 .expect("modeline");
5304 let (mut index, _) =
5305 crate::scan::scan_into_index(root.path(), &crate::ScanConfig::default()).expect("scan");
5306 crate::content::analyze_index(
5307 &mut index,
5308 crate::content::AnalysisRequest { profile: AnalysisSet::NONE.with_code(), workers: 1 },
5309 );
5310 let answer = run(&index, &query(&[ViewSpec::Code], Selection::default()));
5311 let Section::Code(overview) = &answer.sections[0] else { panic!("code overview") };
5312 assert_eq!(overview.selected.source_files, 2);
5313 assert_eq!(overview.selected.analyzed_files, 2);
5314 assert!(overview.languages.iter().any(|row| row.language == "cpp"));
5315 assert!(overview.languages.iter().any(|row| row.language == "rust"));
5316 }
5317
5318 #[test]
5319 fn refused_control_subtrees_do_not_enter_a_known_ignored_population() {
5320 let root = tempfile::tempdir().expect("root");
5321 fs::create_dir_all(root.path().join("guarded")).expect("directory");
5322 fs::write(root.path().join("known.rs"), "fn known() {}\n").expect("known source");
5323 fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("refused control");
5324 fs::write(root.path().join("guarded/uncertain.rs"), "fn uncertain() {}\n")
5325 .expect("uncertain source");
5326 let config = crate::ScanConfig {
5327 control_limits: crate::control::ControlLimits {
5328 line_limit: Some(1),
5329 ..crate::control::ControlLimits::default()
5330 },
5331 ..crate::ScanConfig::default()
5332 };
5333 let (index, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
5334 assert_eq!(index.ignored_classification(Path::new("known.rs")), Some(false));
5335 assert_eq!(index.ignored_classification(Path::new("guarded/uncertain.rs")), None);
5336 for selection in
5337 [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
5338 {
5339 let report = run(
5340 &index,
5341 &query(&[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions], selection),
5342 );
5343 let Section::Summary(summary) = &report.sections[0] else { panic!("summary") };
5344 let Section::Tree { root: Some(tree), .. } = &report.sections[1] else {
5345 panic!("tree")
5346 };
5347 let Section::Extensions { rows, .. } = &report.sections[2] else {
5348 panic!("extensions")
5349 };
5350 assert_eq!(summary.ignored, None);
5351 assert_eq!(tree.ignored, None);
5352 assert_eq!(
5353 tree.children
5354 .iter()
5355 .find(|node| node.path == Path::new("known.rs"))
5356 .and_then(|node| node.ignored),
5357 Some(IgnoredTally::default())
5358 );
5359 assert!(
5360 tree.children
5361 .iter()
5362 .find(|node| node.path == Path::new("guarded"))
5363 .is_some_and(|node| node.ignored.is_none())
5364 );
5365 assert!(rows.iter().all(|row| row.ignored.is_none()));
5366 assert!(
5367 report
5368 .notes
5369 .iter()
5370 .any(|note| note.contains("gitignored subtotals are unavailable"))
5371 );
5372 assert!(
5373 crate::report_format::report_notes(&report)
5374 .iter()
5375 .all(|note| !note.contains("gitignored sizes are included"))
5376 );
5377 }
5378 let hidden_report = run(
5379 &index,
5380 &query(
5381 &[ViewSpec::Tree],
5382 Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
5383 ),
5384 );
5385 let Section::Tree { root, omissions, .. } = &hidden_report.sections[0] else {
5386 panic!("expected tree")
5387 };
5388 let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows");
5389 assert!(hidden.bytes.is_some() && hidden.ignored.is_none());
5390 for population in [IgnoredEntries::Exclude, IgnoredEntries::Only] {
5391 let answer = run(
5392 &index,
5393 &query(
5394 &[ViewSpec::Files],
5395 Selection {
5396 kinds: vec![EntryKind::File],
5397 ignored: population,
5398 ..Selection::default()
5399 },
5400 ),
5401 );
5402 let Section::Files { rows, .. } = &answer.sections[0] else { panic!("files") };
5403 assert!(rows.iter().all(|row| !row.path.starts_with("guarded")));
5404 assert_eq!(
5405 rows.iter().any(|row| row.path == Path::new("known.rs")),
5406 population == IgnoredEntries::Exclude
5407 );
5408 }
5409 }
5410
5411 #[test]
5412 fn a_report_derives_provenance_from_its_index() {
5413 let index = sample();
5414 let report = run(&index, &query(&[ViewSpec::Summary], Selection::default()));
5415 assert_eq!(report.provenance.source, ReportSource::ColdScan);
5416 assert!(report.status.complete);
5417 assert!(report.provenance.scan_started_at.is_some());
5418 assert_eq!(report.provenance.generated_at, generated_at());
5419 assert_eq!(report.root, Path::new("/root"));
5420 }
5421
5422 #[test]
5423 fn reporting_is_pure_and_repeatable() {
5424 let index = sample();
5425 let request = query(&[ViewSpec::Tree, ViewSpec::Extensions], Selection::default());
5426 assert_eq!(format!("{:?}", run(&index, &request)), format!("{:?}", run(&index, &request)));
5427 }
5428
5429 #[test]
5430 fn metadata_grouping_views_use_the_generic_metric_projection() {
5431 let index = sample();
5432 let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5433 let report = run(
5434 &index,
5435 &query(&[ViewSpec::Types, ViewSpec::Families, ViewSpec::Languages], apparent),
5436 );
5437 let Section::Metrics { summary: types, .. } = &report.sections[0] else {
5438 panic!("expected type metrics")
5439 };
5440 let rust = types.rows.iter().find(|row| row.id == "rust").expect("rust");
5441 assert_eq!((rust.files, rust.bytes), (3, 350));
5442 assert_eq!((rust.share.numerator, rust.share.denominator), (350, 657));
5443 assert_eq!(types.share_metric, ShareMetric::ApparentBytes);
5444
5445 let Section::Metrics { summary: families, .. } = &report.sections[1] else {
5446 panic!("expected family metrics")
5447 };
5448 assert!(families.rows.iter().any(|row| row.id == "code"));
5449 assert!(families.rows.iter().any(|row| row.id == "prose"));
5450 assert_eq!(families.share_metric, ShareMetric::ApparentBytes);
5451
5452 let Section::Metrics { summary: languages, .. } = &report.sections[2] else {
5453 panic!("expected language metrics")
5454 };
5455 let rust = languages.rows.iter().find(|row| row.id == "rust").expect("rust");
5456 assert_eq!((rust.files, rust.bytes), (3, 350));
5457 assert_eq!((rust.share.numerator, rust.share.denominator), (350, 350));
5458 assert_eq!(languages.share_metric, ShareMetric::ApparentBytes);
5459 }
5460
5461 const CONTROL: &str = ".gitignore";
5463
5464 fn classified_sample() -> Index {
5466 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5467 index
5468 .apply(&Observation::new(vec![
5469 Op::ControlUpsert {
5470 path: PathBuf::from(CONTROL),
5471 source: b"build/\n*.log\n".to_vec(),
5472 },
5473 upsert("src", EntryKind::Dir, Attrs::default()),
5474 upsert("src/main.rs", EntryKind::File, attrs(100, 10)),
5475 upsert("src/lib.rs", EntryKind::File, attrs(200, 20)),
5476 upsert("src/debug.log", EntryKind::File, attrs(25, 70)),
5477 upsert("docs", EntryKind::Dir, Attrs::default()),
5478 upsert("docs/guide.md", EntryKind::File, attrs(300, 30)),
5479 upsert("build", EntryKind::Dir, Attrs::default()),
5480 upsert("build/cache", EntryKind::Dir, Attrs::default()),
5481 upsert("build/cache/out.bin", EntryKind::File, attrs(1_000, 60)),
5482 ]))
5483 .expect("apply");
5484 index
5485 }
5486
5487 #[test]
5492 fn child_rows_admit_from_roll_ups_and_sum_what_the_share_omits() {
5493 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5494 index.apply_ok(&Observation::new(vec![
5495 Op::ControlUpsert { path: PathBuf::from(CONTROL), source: b"*.log\n".to_vec() },
5496 upsert("big", EntryKind::Dir, Attrs::default()),
5497 upsert("big/a", EntryKind::File, attrs(1_000, 10)),
5498 upsert("small", EntryKind::Dir, Attrs::default()),
5499 upsert("small/x.log", EntryKind::File, attrs(5, 20)),
5500 upsert("tiny", EntryKind::Dir, Attrs::default()),
5501 upsert("tiny/t", EntryKind::File, attrs(3, 30)),
5502 upsert("f1", EntryKind::File, attrs(200, 40)),
5503 upsert("f2", EntryKind::File, attrs(2, 50)),
5504 ]));
5505 let id = |path: &str| index.lookup(Path::new(path)).expect("an indexed path");
5506 let measured = |bytes: u64, complete| query_subtrees::SubtreeValues {
5507 bytes,
5508 allocated: bytes.div_ceil(512) * 512,
5509 mtime_ns: 0,
5510 files: 1,
5511 dirs: 0,
5512 complete,
5513 };
5514 let measurements: BTreeMap<EntryId, query_subtrees::SubtreeValues> = [
5515 (id("big"), measured(1_000, true)),
5516 (id("small"), measured(5, true)),
5517 (id("tiny"), measured(3, false)),
5518 ]
5519 .into_iter()
5520 .collect();
5521 let query = query(
5524 &[ViewSpec::Tree],
5525 Selection { size: SizeMetric::Apparent, ..Selection::default() },
5526 );
5527 let grand = 1_000 + 5 + 3 + 200 + 2;
5528
5529 let (rows, omitted) = child_rows(
5530 &index,
5531 &query,
5532 None,
5533 &BTreeMap::new(),
5534 Some(&measurements),
5535 EntryId::ROOT,
5536 Path::new(""),
5537 &ShareThreshold::one_percent(),
5538 grand,
5539 );
5540 assert_eq!(
5543 rows.iter().map(|(row, id)| (row.name.as_str(), row.bytes, *id)).collect::<Vec<_>>(),
5544 [("big", 1_000, id("big")), ("f1", 200, id("f1")), ("tiny", 3, id("tiny"))]
5545 );
5546 assert!(rows.iter().all(|(row, _)| row.path.as_path() == Path::new(&row.name)));
5547 let mut facts: Vec<_> =
5550 omitted.iter().map(|row| (row.bytes, row.files, row.allocated, row.ignored)).collect();
5551 facts.sort_unstable_by_key(|(bytes, ..)| *bytes);
5552 assert_eq!(
5553 facts,
5554 [
5555 (2, 1, 512, Some(IgnoredTally::default())),
5556 (5, 1, 512, Some(IgnoredTally { files: 1, dirs: 0, bytes: 5, allocated: 512 })),
5557 ]
5558 );
5559
5560 for complete in [true, false] {
5561 let mut node = TreeNode {
5562 path: PathBuf::new(),
5563 name: String::new(),
5564 kind: EntryKind::Dir,
5565 entry_ignored: None,
5566 bytes: grand,
5567 allocated: 0,
5568 files: 5,
5569 dirs: 3,
5570 ignored: None,
5571 newest_mtime_ns: None,
5572 children: Vec::new(),
5573 omissions: Vec::new(),
5574 truncated: false,
5575 };
5576 record_omission(&mut node, TreeOmissionReason::Share, &omitted, None, complete);
5577 assert!(node.truncated);
5578 let [omission] = node.omissions.as_slice() else {
5579 panic!("one Share omission, complete = {complete}: {:?}", node.omissions)
5580 };
5581 assert_eq!(omission.reason, TreeOmissionReason::Share);
5582 assert_eq!(omission.entries, 2);
5583 let sums = (omission.files, omission.bytes, omission.allocated, omission.ignored);
5584 let expected = if complete {
5585 (Some(2), Some(7), Some(1_024), Some(IgnoredSize { bytes: 5, allocated: 512 }))
5586 } else {
5587 (None, None, None, None)
5588 };
5589 assert_eq!(sums, expected, "complete = {complete}");
5590 }
5591 }
5592
5593 #[test]
5594 fn tree_entry_classification_is_independent_of_selected_subtree_tallies() {
5595 let mut index = Index::new_with_scope("/root", crate::test_support::observing_controls());
5596 index.apply_ok(&Observation::new(vec![
5597 Op::ControlUpsert {
5598 path: PathBuf::from(CONTROL),
5599 source: b"empty/\nzero.txt\n*.log\n".to_vec(),
5600 },
5601 upsert("empty", EntryKind::Dir, Attrs::default()),
5602 upsert("zero.txt", EntryKind::File, attrs(0, 0)),
5603 upsert("mixed", EntryKind::Dir, Attrs::default()),
5604 upsert("mixed/ignored.log", EntryKind::File, attrs(5, 5)),
5605 upsert("mixed/ordinary.rs", EntryKind::File, attrs(7, 7)),
5606 ]));
5607 let selection = |ignored| Selection {
5608 ignored,
5609 depth: Some(Bound::All),
5610 breadth: Some(Bound::All),
5611 limit: Some(Bound::All),
5612 min_share: Some(ShareThreshold::parse("0%").expect("share")),
5613 ..Selection::default()
5614 };
5615 let included =
5616 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include))));
5617 assert_eq!(included.entry_ignored, Some(false));
5618 let child = |name: &str| included.children.iter().find(|row| row.name == name).expect(name);
5619 let empty = child("empty");
5620 assert_eq!((empty.kind, empty.bytes, empty.entry_ignored), (EntryKind::Dir, 0, Some(true)));
5621 assert_eq!(child("zero.txt").entry_ignored, Some(true));
5622 let mixed = child("mixed");
5623 assert_eq!(mixed.entry_ignored, Some(false));
5624 assert_eq!(mixed.ignored.expect("known").bytes, 5);
5625 assert_eq!(
5626 mixed
5627 .children
5628 .iter()
5629 .find(|row| row.name == "ignored.log")
5630 .expect("ignored leaf")
5631 .entry_ignored,
5632 Some(true)
5633 );
5634
5635 let only =
5636 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection(IgnoredEntries::Only))));
5637 assert_eq!(only.entry_ignored, Some(false));
5638 let mixed_only = only.children.iter().find(|row| row.name == "mixed").expect("ancestor");
5639 assert_eq!(mixed_only.entry_ignored, Some(false));
5640 assert_eq!(mixed_only.ignored.expect("known").bytes, 5);
5641
5642 let mut unobserved =
5643 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5644 unobserved.apply_ok(&Observation::new(vec![upsert(
5645 "plain",
5646 EntryKind::Dir,
5647 Attrs::default(),
5648 )]));
5649 let unknown = tree_of(&run(
5650 &unobserved,
5651 &query(&[ViewSpec::Tree], selection(IgnoredEntries::Include)),
5652 ));
5653 assert_eq!(unknown.entry_ignored, None);
5654 assert_eq!(unknown.children[0].entry_ignored, None);
5655 }
5656
5657 #[test]
5658 fn ignored_size_interpretation_note_appears_once_only_when_relevant() {
5659 let classified = classified_sample();
5660 for (population, expected) in
5661 [(IgnoredEntries::Include, 1), (IgnoredEntries::Exclude, 0), (IgnoredEntries::Only, 0)]
5662 {
5663 let report = run(
5664 &classified,
5665 &query(
5666 &[ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions],
5667 Selection {
5668 ignored: population,
5669 depth: Some(Bound::All),
5670 min_share: Some(ShareThreshold::parse("0%").expect("share")),
5671 ..Selection::default()
5672 },
5673 ),
5674 );
5675 let notes = crate::report_format::report_notes(&report);
5676 assert_eq!(
5677 notes.iter().filter(|note| note.contains("gitignored sizes are included")).count(),
5678 expected,
5679 "{population:?}: {notes:?}"
5680 );
5681 }
5682 let mut unobserved =
5683 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5684 unobserved.apply_ok(&Observation::new(vec![upsert(
5685 "plain.rs",
5686 EntryKind::File,
5687 attrs(10, 0),
5688 )]));
5689 let blind =
5690 run(&unobserved, &query(&[ViewSpec::Summary, ViewSpec::Tree], Selection::default()));
5691 assert!(
5692 crate::report_format::report_notes(&blind)
5693 .iter()
5694 .all(|note| !note.contains("gitignored sizes are included"))
5695 );
5696 }
5697
5698 #[test]
5699 fn tree_remainder_counts_only_unrepresented_root_children() {
5700 let index = classified_sample();
5701 let remainder = |selection| {
5702 let report = run(&index, &query(&[ViewSpec::Tree], selection));
5703 let Section::Tree { root, omissions, .. } = &report.sections[0] else {
5704 panic!("expected tree")
5705 };
5706 TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden rows")
5707 };
5708 let mixed = remainder(Selection {
5709 size: SizeMetric::Apparent,
5710 depth: Some(Bound::Limit(1)),
5711 breadth: Some(Bound::Limit(2)),
5712 min_share: Some(ShareThreshold::parse("5%").expect("share")),
5713 ..Selection::default()
5714 });
5715 let usage = |ignored: Option<IgnoredSize>| ignored.map(|part| (part.bytes, part.allocated));
5716 assert_eq!(
5717 (mixed.files, mixed.bytes, usage(mixed.ignored)),
5718 (Some(1), Some(300), Some((0, 0)))
5719 );
5720 assert_eq!(mixed.reasons, vec![TreeOmissionReason::Breadth]);
5721
5722 for limit in [Bound::Limit(1), Bound::Limit(0)] {
5723 let rows = remainder(Selection { limit: Some(limit), ..Selection::default() });
5724 assert_eq!((rows.bytes, usage(rows.ignored)), (Some(1_625), Some((1_025, 1_536))));
5725 }
5726 let excluded = remainder(Selection {
5727 depth: Some(Bound::Limit(0)),
5728 ignored: IgnoredEntries::Exclude,
5729 ..Selection::default()
5730 });
5731 assert_eq!((excluded.bytes, excluded.ignored), (Some(600), Some(IgnoredSize::default())));
5732 let only = remainder(Selection {
5733 depth: Some(Bound::Limit(0)),
5734 ignored: IgnoredEntries::Only,
5735 ..Selection::default()
5736 });
5737 assert_eq!((only.bytes, usage(only.ignored)), (Some(1_025), Some((1_025, 1_536))));
5738 }
5739
5740 #[test]
5741 fn colored_tree_populations_share_one_golden() {
5742 let classified = classified_sample();
5743 let mut unobserved =
5744 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5745 unobserved.apply_ok(&Observation::new(vec![upsert(
5746 "plain.rs",
5747 EntryKind::File,
5748 attrs(10, 0),
5749 )]));
5750
5751 let root = tempfile::tempdir().expect("root");
5752 fs::create_dir(root.path().join("guarded")).expect("directory");
5753 fs::write(root.path().join("known.rs"), "K").expect("known file");
5754 fs::write(root.path().join("guarded/.gitignore"), "*.rs\n").expect("control");
5755 fs::write(root.path().join("guarded/uncertain.rs"), "U").expect("uncertain file");
5756 let config = crate::ScanConfig {
5757 control_limits: crate::control::ControlLimits {
5758 line_limit: Some(1),
5759 ..crate::control::ControlLimits::default()
5760 },
5761 ..crate::ScanConfig::default()
5762 };
5763 let (refused, _) = crate::scan::scan_into_index(root.path(), &config).expect("scan");
5764 assert_eq!(refused.ignored_classification(Path::new("known.rs")), Some(false));
5765 assert_eq!(refused.ignored_classification(Path::new("guarded/uncertain.rs")), None);
5766
5767 let selection = |ignored| Selection {
5768 size: SizeMetric::Apparent,
5769 depth: Some(Bound::Limit(0)),
5770 ignored,
5771 ..Selection::default()
5772 };
5773 let cases = [
5774 ("INCLUDE", &classified, selection(IgnoredEntries::Include)),
5775 (
5776 "INCLUDE EXPANDED",
5777 &classified,
5778 Selection {
5779 depth: Some(Bound::Limit(2)),
5780 min_share: Some(ShareThreshold::parse("0%").expect("share")),
5781 ..selection(IgnoredEntries::Include)
5782 },
5783 ),
5784 ("EXCLUDE", &classified, selection(IgnoredEntries::Exclude)),
5785 ("ONLY", &classified, selection(IgnoredEntries::Only)),
5786 ("NO CONTROLS", &unobserved, selection(IgnoredEntries::Include)),
5787 ("REFUSED CONTROL", &refused, selection(IgnoredEntries::Include)),
5788 ];
5789 let mut actual = String::new();
5790 for (label, index, selection) in cases {
5791 let report = run(index, &query(&[ViewSpec::Tree], selection));
5792 actual.push_str(label);
5793 actual.push('\n');
5794 actual.push_str(
5795 &crate::report_format::render(&report, crate::report_format::Format::Text, true)
5796 .expect("colored tree")
5797 .replace('\u{1b}', "<ESC>"),
5798 );
5799 actual.push('\n');
5800 }
5801 assert_eq!(actual, include_str!("../../tests/golden/tree-populations.txt"));
5802 }
5803
5804 #[test]
5805 fn tree_remainder_ignored_share_is_unknown_without_control_observation() {
5806 let mut index =
5807 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5808 index.apply_ok(&Observation::new(vec![upsert("file", EntryKind::File, attrs(10, 0))]));
5809 let report = run(
5810 &index,
5811 &query(
5812 &[ViewSpec::Tree],
5813 Selection { depth: Some(Bound::Limit(0)), ..Selection::default() },
5814 ),
5815 );
5816 let Section::Tree { root, omissions, .. } = &report.sections[0] else {
5817 panic!("expected tree")
5818 };
5819 let hidden = TreeRemainder::from_tree(root.as_deref(), omissions).expect("hidden file");
5820 assert_eq!((hidden.bytes, hidden.ignored), (Some(10), None));
5821 }
5822
5823 fn ignored_of(row: &SummaryRow) -> IgnoredTally {
5824 row.ignored.expect("an observing index reports an ignored share")
5825 }
5826
5827 #[test]
5831 fn the_two_tiers_agree_on_the_ignored_share() {
5832 let index = classified_sample();
5833 let expected = IgnoredTally { files: 2, dirs: 2, bytes: 1_025, allocated: 1_024 + 512 };
5834 for selection in
5835 [Selection::default(), Selection { min_size: Some(0), ..Selection::default() }]
5836 {
5837 let unfiltered = selection.is_unfiltered();
5838 let summary = summary_of(&run(&index, &query(&[ViewSpec::Summary], selection.clone())));
5839 assert_eq!(ignored_of(&summary), expected, "unfiltered: {unfiltered}");
5840
5841 let tree = tree_of(&run(&index, &query(&[ViewSpec::Tree], selection.clone())));
5842 assert_eq!(tree.ignored, Some(expected), "unfiltered: {unfiltered}");
5843 let child = |name: &str| {
5844 tree.children.iter().find(|node| node.name == name).expect(name).ignored
5845 };
5846 assert_eq!(
5847 child("build"),
5848 Some(IgnoredTally { files: 1, dirs: 1, bytes: 1_000, allocated: 1_024 }),
5849 "an ignored directory is wholly ignored below it, unfiltered: {unfiltered}"
5850 );
5851 assert_eq!(
5852 child("src"),
5853 Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
5854 "unfiltered: {unfiltered}"
5855 );
5856 assert_eq!(child("docs"), Some(IgnoredTally::default()), "observed, nothing ignored");
5857
5858 let rows = types_of(&run(&index, &query(&[ViewSpec::Extensions], selection.clone())));
5859 let row = |extension: &str| {
5860 rows.iter().find(|row| row.extension == extension).expect(extension).ignored
5861 };
5862 assert_eq!(
5863 row(".log"),
5864 Some(IgnoredTally { files: 1, dirs: 0, bytes: 25, allocated: 512 }),
5865 "unfiltered: {unfiltered}"
5866 );
5867 assert_eq!(row(".rs"), Some(IgnoredTally::default()), "unfiltered: {unfiltered}");
5868
5869 let files = files_of(&run(&index, &query(&[ViewSpec::Files], selection)));
5870 let flag =
5871 |path: PathBuf| files.iter().find(|row| row.path == path).map(|row| row.ignored);
5872 assert_eq!(flag(PathBuf::from("build")), Some(Some(true)));
5873 assert_eq!(flag(PathBuf::from("src")), Some(Some(false)));
5874 assert_eq!(flag(["src", "debug.log"].iter().collect()), Some(Some(true)));
5875 assert_eq!(flag(["build", "cache", "out.bin"].iter().collect()), Some(Some(true)));
5876 }
5877 }
5878
5879 #[test]
5882 fn ignored_entries_partition_the_tree_and_rank_by_what_they_select() {
5883 let index = classified_sample();
5884 let apparent = Selection { size: SizeMetric::Apparent, ..Selection::default() };
5887 let with = |ignored| Selection { ignored, ..apparent.clone() };
5888 let summary = |selection| summary_of(&run(&index, &query(&[ViewSpec::Summary], selection)));
5889 let total = summary(apparent.clone());
5890 let kept = summary(with(IgnoredEntries::Exclude));
5891 let only = summary(with(IgnoredEntries::Only));
5892 assert_eq!(
5893 (kept.files + only.files, kept.dirs + only.dirs, kept.bytes + only.bytes),
5894 (total.files, total.dirs, total.bytes)
5895 );
5896 assert_eq!(ignored_of(&kept), IgnoredTally::default());
5897 let whole = ignored_of(&only);
5898 assert_eq!((whole.files, whole.dirs, whole.bytes), (only.files, only.dirs, only.bytes));
5899
5900 let ranked = |selection| {
5901 tree_of(&run(&index, &query(&[ViewSpec::Tree], selection)))
5902 .children
5903 .iter()
5904 .map(|node| (node.name.clone(), node.bytes))
5905 .collect::<Vec<_>>()
5906 };
5907 let row = |name: &str, bytes: u64| (name.to_string(), bytes);
5908 assert_eq!(
5909 ranked(apparent.clone()),
5910 [row("build", 1_000), row("src", 325), row("docs", 300)]
5911 );
5912 assert_eq!(
5913 ranked(with(IgnoredEntries::Exclude)),
5914 [row("docs", 300), row("src", 300)],
5915 "unignored sizes rank the rows, with the name breaking the tie"
5916 );
5917 assert_eq!(ranked(with(IgnoredEntries::Only)), [row("build", 1_000), row("src", 25)]);
5918 }
5919
5920 #[test]
5923 fn an_index_that_observed_no_control_state_has_no_ignored_share_to_select_by() {
5924 let mut index =
5925 Index::new_with_scope("/root", crate::test_support::not_observing_controls());
5926 index
5927 .apply(&Observation::new(vec![
5928 upsert("build", EntryKind::Dir, Attrs::default()),
5929 upsert("build/out.bin", EntryKind::File, attrs(1_000, 60)),
5930 ]))
5931 .expect("apply");
5932 let views = [ViewSpec::Summary, ViewSpec::Tree, ViewSpec::Extensions, ViewSpec::Files];
5933 let report = run(&index, &query(&views, Selection::default()));
5934 let Section::Summary(summary) = &report.sections[0] else { panic!("a summary") };
5935 let Section::Tree { root: Some(tree), .. } = &report.sections[1] else { panic!("a tree") };
5936 let Section::Extensions { rows: extensions, .. } = &report.sections[2] else {
5937 panic!("extensions")
5938 };
5939 let Section::Files { rows: files, .. } = &report.sections[3] else { panic!("files") };
5940 assert_eq!(summary.ignored, None);
5941 assert_eq!(tree.ignored, None);
5942 assert!(tree.children.iter().all(|node| node.ignored.is_none()));
5943 assert!(extensions.iter().all(|row| row.ignored.is_none()));
5944 assert!(files.iter().all(|row| row.ignored.is_none()));
5945
5946 let exclude = Query {
5947 selection: Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() },
5948 views: vec![ViewSpec::Summary],
5949 ..Query::default()
5950 };
5951 let only = Query {
5952 selection: Selection { ignored: IgnoredEntries::Only, ..Selection::default() },
5953 ..exclude.clone()
5954 };
5955 for refused in [exclude, only] {
5960 assert!(
5961 matches!(
5962 super::report(
5963 &index,
5964 &crate::test_support::read_of(&index, refused),
5965 generated_at()
5966 ),
5967 Err(crate::Error::InvalidRequest(
5968 crate::query::RequestError::IgnoredWithoutObservation(_)
5969 ))
5970 ),
5971 "a selection by ignored state over an unobserving index is refused"
5972 );
5973 }
5974 assert_eq!(summary_of(&run(&index, &query(&views, Selection::default()))).files, 1);
5975 }
5976}