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