Skip to main content

fallow_output/
health_grouped.rs

1//! Per-group health output for `--group-by`.
2//!
3//! When health is invoked with `--group-by package` (or any other grouping
4//! mode), the orchestrator partitions the project's files by the resolver and
5//! emits one [`HealthGroup`] per bucket. Each group carries its own
6//! [`VitalSigns`] and [`HealthScore`] computed from the files in that group
7//! alone, plus the per-file output (findings, file scores, hotspots, large
8//! functions, refactoring targets) restricted to the same subset. A group
9//! carries a per-file list only when the project report shows that list.
10
11use serde::Serialize;
12
13use crate::{
14    CoverageSourceConsistency, FileHealthScore, HealthActionsMeta, HealthFinding, HealthScore,
15    HealthTrend, HotspotFinding, LargeFunctionEntry, RefactoringTargetFinding, VitalSigns,
16};
17
18/// A health report scoped to a single group.
19///
20/// `key` is the group label produced by the resolver (workspace package name,
21/// CODEOWNERS owner, directory, or section). `owners` is populated only for
22/// `--group-by section` (mirrors dead-code grouped output).
23///
24/// Per-group `vital_signs` and `health_score` are recomputed from the
25/// files in the group, so they answer "what is the health of workspace X" in
26/// a single invocation. `files_analyzed` and `functions_above_threshold`
27/// summarise the subset for parity with the project-level
28/// project-level health summary.
29///
30/// A group carries a per-file list (`findings`, `file_scores`, `hotspots`,
31/// `large_functions`, `targets`) only when the project report shows the same
32/// list. A `--score` run keeps the score and the counts of each group and
33/// omits the lists.
34#[derive(Debug, Clone, Serialize)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36pub struct HealthGroup {
37    /// Group identifier produced by the resolver. For 'package' grouping:
38    /// workspace package name (e.g. '@scope/app-a') or '(root)' for files
39    /// outside any workspace. For 'owner' grouping: the CODEOWNERS team. For
40    /// 'directory' grouping: the top-level directory prefix. For 'section'
41    /// grouping: the GitLab CODEOWNERS section name, or '(no section)' /
42    /// '(unowned)' for unmatched files.
43    pub key: String,
44    /// Section default owners (GitLab CODEOWNERS `[Section] @owner1 @owner2`).
45    /// Present only when grouped_by is 'section'.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub owners: Option<Vec<String>>,
48    /// Files participating in this group after workspace and ignore filters.
49    pub files_analyzed: usize,
50    /// Number of findings in this group, mirroring the project-level
51    /// `summary.functions_above_threshold` semantics post-baseline /
52    /// post-`--top` truncation. When `--top` was supplied this reflects the
53    /// rendered finding count of the group, not the un-truncated total.
54    pub functions_above_threshold: usize,
55    /// Number of critical-severity findings in this group, after the baseline
56    /// filter and before `--top`. The project `summary.severity_critical_count`
57    /// counts before the baseline filter, so with `--baseline` the group
58    /// counts can add up to less.
59    pub severity_critical_count: usize,
60    /// Number of high-severity findings in this group, after the baseline
61    /// filter and before `--top`. The project `summary.severity_high_count`
62    /// counts before the baseline filter, so with `--baseline` the group
63    /// counts can add up to less.
64    pub severity_high_count: usize,
65    /// Number of moderate-severity findings in this group, after the baseline
66    /// filter and before `--top`. The project `summary.severity_moderate_count`
67    /// counts before the baseline filter, so with `--baseline` the group
68    /// counts can add up to less.
69    pub severity_moderate_count: usize,
70    /// Number of ranked hotspot entries in this group, before `--top`. This
71    /// is the length of the group's ranked hotspot list. It is not
72    /// `vital_signs.hotspot_count`, which counts only the files with a
73    /// hotspot score of 50 or more and feeds the health score.
74    pub hotspot_count: usize,
75    /// Whether CRAP findings in this group share a single coverage-source kind
76    /// (`uniform`) or combine Istanbul / estimated / inherited sources
77    /// (`mixed`). Absent when no grouped finding carries CRAP source data.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub coverage_source_consistency: Option<CoverageSourceConsistency>,
80    /// Per-group vital signs recomputed from the files in this group. Absent
81    /// when --score-only suppressed top-level vital signs.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub vital_signs: Option<VitalSigns>,
84    /// Per-group health score recomputed from the per-group vital signs. Absent
85    /// when --score was not requested. The duplication penalty counts each
86    /// clone group with two or more instances in total and one or more in
87    /// this group, and counts only the lines of the instances in this group.
88    /// A clone that spans two groups thus lowers the score of each group.
89    /// `dupes --group-by` assigns each clone group to one owner instead.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub health_score: Option<HealthScore>,
92    /// Trend of this group against the same group in the baseline snapshot.
93    /// Present only when `--trend` or `--trend-from` was requested and the
94    /// baseline holds this group with the same `grouped_by` mode.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub trend: Option<HealthTrend>,
97    /// Why `trend` is present or absent. Present only when a trend was
98    /// requested and a baseline snapshot was loaded.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub trend_status: Option<GroupTrendStatus>,
101    /// Findings restricted to files in this group. Each entry is the typed
102    /// [`HealthFinding`] wrapper around a
103    /// `ComplexityViolation`
104    /// payload.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub findings: Vec<HealthFinding>,
107    /// File scores restricted to files in this group.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub file_scores: Vec<FileHealthScore>,
110    /// Hotspots restricted to files in this group. Each entry is the typed
111    /// [`HotspotFinding`] wrapper around a
112    /// `HotspotEntry` payload.
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub hotspots: Vec<HotspotFinding>,
115    /// Large functions in files belonging to this group.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub large_functions: Vec<LargeFunctionEntry>,
118    /// Refactoring targets in files belonging to this group. Each entry is
119    /// the typed [`RefactoringTargetFinding`] wrapper around a
120    /// `RefactoringTarget`
121    /// payload.
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub targets: Vec<RefactoringTargetFinding>,
124    /// Auditable breadcrumb recording why `suppress-line` action hints
125    /// were omitted from this group's findings. Mirrors the project-level
126    /// `HealthReport.actions_meta`; populated at construction time when the
127    /// per-group `HealthActionContext`
128    /// suppresses inline hints.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub actions_meta: Option<HealthActionsMeta>,
131}
132
133/// Group label of the files that no CODEOWNERS rule matches.
134const UNOWNED_GROUP_KEY: &str = "(unowned)";
135
136/// The groups in display order for the per-group summary tables.
137///
138/// With scores, the order is score ascending (worst first), with the
139/// unowned group last. Without scores, the resolver order is kept (file count
140/// descending, unowned last). The human block, the Markdown table and the
141/// GitHub job summary use this order.
142#[must_use]
143pub fn health_groups_in_display_order(groups: &[HealthGroup]) -> Vec<&HealthGroup> {
144    let mut ordered: Vec<&HealthGroup> = groups.iter().collect();
145    if groups.iter().any(|group| group.health_score.is_some()) {
146        ordered.sort_by(|a, b| {
147            let unowned = (a.key == UNOWNED_GROUP_KEY).cmp(&(b.key == UNOWNED_GROUP_KEY));
148            let a_score = a.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
149            let b_score = b.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
150            unowned.then(
151                a_score
152                    .partial_cmp(&b_score)
153                    .unwrap_or(std::cmp::Ordering::Equal),
154            )
155        });
156    }
157    ordered
158}
159
160/// Short label for the score change of a group against the trend baseline.
161///
162/// `+2.3 \u{2191}` for a compared group with a score metric, `new` for a group
163/// that the baseline does not hold, and `-` otherwise.
164#[must_use]
165pub fn group_score_delta_label(group: &HealthGroup) -> String {
166    match group.trend_status {
167        Some(GroupTrendStatus::NewGroup) => "new".to_owned(),
168        Some(GroupTrendStatus::Compared) => group
169            .trend
170            .as_ref()
171            .and_then(|trend| trend.metrics.iter().find(|metric| metric.name == "score"))
172            .map_or_else(
173                || "-".to_owned(),
174                |metric| format!("{:+.1} {}", metric.delta, metric.direction.arrow()),
175            ),
176        _ => "-".to_owned(),
177    }
178}
179
180/// What the group trend compared, for one group.
181///
182/// The value set is open: read an unknown value as "no trend for this group".
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[serde(rename_all = "snake_case")]
186pub enum GroupTrendStatus {
187    /// The baseline holds this group, and `trend` compares the two.
188    Compared,
189    /// The baseline holds group data, but not for this group key.
190    NewGroup,
191    /// The baseline did not measure this group. Either the baseline holds no
192    /// group data for this `grouped_by` mode, and a
193    /// `trend-group-baseline-unavailable` diagnostic says why, or the
194    /// `--group` selection of the baseline run left this group key out.
195    NoGroupBaseline,
196}
197
198/// Wrapper carrying the resolver mode label alongside the partitioned groups.
199///
200/// Stored on `crate::health::HealthResult` when `--group-by` is active and
201/// consumed by formatters that either render grouped data directly or annotate
202/// per-finding machine output with the group key.
203#[derive(Debug, Clone)]
204pub struct HealthGrouping {
205    /// Resolver mode label (`"package"`, `"owner"`, `"directory"`, `"section"`).
206    pub mode: &'static str,
207    /// Groups in the same order the resolver produced them.
208    pub groups: Vec<HealthGroup>,
209    /// The `--group` selector patterns, as the user gave them. `None` when no
210    /// selector was given.
211    pub filter: Option<Vec<String>>,
212    /// The positive `--group` patterns that matched no group key.
213    pub unmatched_filters: Vec<String>,
214}