Skip to main content

big_code_analysis/wire/
vcs.rs

1//! VCS (change-history) wire structs and their projections — the
2//! `vcs-git`-gated arm of the wire shape (see the parent [`super`]
3//! module doc for the single-source-of-truth rationale).
4
5use super::*;
6
7/// Wire form of [`crate::vcs::Stats`] — per-file change-history metrics.
8///
9/// Always-slim by design (issue #635): the row carries only the metrics
10/// that vary per file. The four constant stamps that hold across every
11/// row of a single response — `vcs_schema_version`, `risk_score_version`,
12/// `long_window_days`, `recent_window_days` — live exactly once in the
13/// enclosing envelope (`bca vcs`'s `Report`, `POST /vcs`'s response, and
14/// the `/vcs/trend` document), never repeated per row or per trend point.
15///
16/// The remaining field names are the nested `vcs` object's output keys
17/// verbatim (issue #684). All scores are ordinal. `hotspot_score` and
18/// `author_ids` are elided when absent (no AST metrics alongside, and
19/// `--emit-author-details` off, respectively). Gated behind the
20/// `vcs-git` backend feature.
21#[cfg(feature = "vcs-git")]
22#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
23pub struct Vcs {
24    /// Distinct commits in the long window.
25    pub commits_long: u32,
26    /// Distinct commits in the recent window.
27    pub commits_recent: u32,
28    /// Σ(added + deleted) lines in the long window.
29    pub churn_long: u64,
30    /// Σ(added + deleted) lines in the recent window.
31    pub churn_recent: u64,
32    /// Distinct authors in the long window.
33    pub authors_long: u32,
34    /// Distinct authors in the recent window.
35    pub authors_recent: u32,
36    /// Top-author edit share in `[0, 1]`.
37    #[serde(default = "nan_default", with = "non_finite")]
38    pub ownership_top_share: f64,
39    /// `commits_recent / commits_long`, clamped to `[0, 1]`.
40    #[serde(default = "nan_default", with = "non_finite")]
41    pub burst: f64,
42    /// Long-window bug-fix commit count.
43    pub bug_fix_commits: u32,
44    /// Long-window security-fix commit count.
45    pub security_fix_commits: u32,
46    /// Long-window revert commit count.
47    pub revert_commits: u32,
48    /// Days since the file's first in-window commit (capped at window).
49    pub age_days: u32,
50    /// Days since the file's most recent in-window commit.
51    pub last_modified_days: u32,
52    /// Change entropy (bits) over the long window — Hassan 2009 History
53    /// Complexity Metric; higher means more scattered changes.
54    #[serde(default = "nan_default", with = "non_finite")]
55    pub change_entropy_long: f64,
56    /// Change entropy (bits) over the recent window.
57    #[serde(default = "nan_default", with = "non_finite")]
58    pub change_entropy_recent: f64,
59    /// Co-change graph entropy (bits) over the long window — arXiv
60    /// 2504.18511; higher means changes ripple across more partners.
61    /// `0.0` is computed (no co-changes), not missing.
62    #[serde(default = "nan_default", with = "non_finite")]
63    pub cochange_entropy_long: f64,
64    /// Co-change graph entropy (bits) over the recent window.
65    #[serde(default = "nan_default", with = "non_finite")]
66    pub cochange_entropy_recent: f64,
67    /// Ordinal composite risk score.
68    #[serde(default = "nan_default", with = "non_finite")]
69    pub risk_score: f64,
70    /// Complexity × recent-churn hotspot score, when AST metrics were
71    /// computed alongside the history.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub hotspot_score: Option<f64>,
74    /// SHA-256-hashed canonical author identities, under
75    /// `--emit-author-details`.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub author_ids: Option<Vec<String>>,
78}
79
80#[cfg(feature = "vcs-git")]
81impl From<&crate::vcs::Stats> for Vcs {
82    fn from(s: &crate::vcs::Stats) -> Self {
83        Self {
84            commits_long: s.commits_long,
85            commits_recent: s.commits_recent,
86            churn_long: s.churn_long,
87            churn_recent: s.churn_recent,
88            authors_long: s.authors_long,
89            authors_recent: s.authors_recent,
90            ownership_top_share: s.ownership_top_share,
91            burst: s.burst,
92            bug_fix_commits: s.bug_fix_commits,
93            security_fix_commits: s.security_fix_commits,
94            revert_commits: s.revert_commits,
95            age_days: s.age_days,
96            last_modified_days: s.last_modified_days,
97            change_entropy_long: s.change_entropy_long,
98            change_entropy_recent: s.change_entropy_recent,
99            cochange_entropy_long: s.cochange_entropy_long,
100            cochange_entropy_recent: s.cochange_entropy_recent,
101            risk_score: s.risk_score,
102            hotspot_score: s.hotspot_score,
103            author_ids: s.author_ids.clone(),
104        }
105    }
106}
107
108/// Wire form of one ranked file in a [`VcsReport`]: its repository-
109/// relative path plus the always-slim [`Vcs`] block, nested under a
110/// `vcs` key like every other metric group (issue #684).
111// Serialize-only (no `Deserialize`): `VcsAggregate` and its bus-factor
112// family are serialize-only upstream, so `VcsReport` cannot round-trip
113// through `Deserialize` the way [`Vcs`] / [`VcsTrend`] do. The only
114// consumer (`bca vcs` / `POST /vcs` / Python `vcs.rank()`) serializes
115// outward; nothing reads a report back in.
116#[cfg(feature = "vcs-git")]
117#[derive(Debug, Clone, PartialEq, Serialize)]
118pub struct VcsReportFile {
119    /// Repository-relative path.
120    pub path: String,
121    /// The file's change-history metrics.
122    pub vcs: Vcs,
123}
124
125/// Wire form of the file-ranking change-history report (issue #328) —
126/// the single serialized shape shared by `bca vcs`, `POST /vcs`, and the
127/// Python `vcs.rank()` (#664).
128///
129/// The four constant stamps (`long_window_days`, `recent_window_days`,
130/// `risk_score_version`, `vcs_schema_version`) sit once at the top level
131/// rather than per row (issue #635); each `files` row carries only the
132/// per-file metrics under a nested `vcs` key (issue #684). `vcs_aggregate`
133/// is the directory-/repo-level bus-factor summary, omitted when not
134/// computed.
135#[cfg(feature = "vcs-git")]
136#[derive(Debug, Clone, PartialEq, Serialize)]
137pub struct VcsReport {
138    /// Long window length, in days (constant across rows).
139    pub long_window_days: u32,
140    /// Recent window length, in days (constant across rows).
141    pub recent_window_days: u32,
142    /// Composite-formula version.
143    pub risk_score_version: u32,
144    /// Per-row metric-block shape version.
145    pub vcs_schema_version: u32,
146    /// Whether the history came from a shallow clone.
147    pub truncated_shallow_clone: bool,
148    /// Directory-/repo-level bus-factor aggregate, when computed.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub vcs_aggregate: Option<crate::vcs::VcsAggregate>,
151    /// Files ranked by descending `vcs.risk_score`.
152    pub files: Vec<VcsReportFile>,
153}
154
155/// Wire form of one sampled point in a historical metric trend (issue
156/// #333): the sample timestamp plus the file's VCS block at that moment.
157/// `as_of` leads; the metrics sit under a nested `vcs` key (issue #684),
158/// the same always-slim [`Vcs`] row every other endpoint emits. The four
159/// constant stamps are carried once on the enclosing [`VcsTrend`], never
160/// repeated per point (issue #635).
161#[cfg(feature = "vcs-git")]
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct VcsTrendPoint {
164    /// Unix-second timestamp this point was sampled at.
165    pub as_of: i64,
166    /// The file's change-history metrics at `as_of`.
167    pub vcs: Vcs,
168}
169
170/// Wire form of one file's risk-score movement across the trend.
171#[cfg(feature = "vcs-git")]
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct VcsTrendDelta {
174    /// Repository-relative path.
175    pub path: String,
176    /// Timestamp of the file's earliest present point.
177    pub first_as_of: i64,
178    /// Timestamp of the file's latest present point.
179    pub last_as_of: i64,
180    /// `risk_score` at the earliest present point.
181    pub first_risk_score: f64,
182    /// `risk_score` at the latest present point.
183    pub last_risk_score: f64,
184    /// `last_risk_score - first_risk_score`; negative means improved.
185    pub delta: f64,
186}
187
188#[cfg(feature = "vcs-git")]
189impl VcsTrendDelta {
190    /// Project a compute-side [`crate::vcs::TrendDelta`], dropping a
191    /// non-UTF-8 path (which cannot be a JSON key) by returning `None` —
192    /// the same path policy the file map uses.
193    fn from_delta(d: &crate::vcs::TrendDelta) -> Option<Self> {
194        Some(Self {
195            path: d.path.to_str()?.to_owned(),
196            first_as_of: d.first_as_of,
197            last_as_of: d.last_as_of,
198            first_risk_score: d.first_risk_score,
199            last_risk_score: d.last_risk_score,
200            delta: d.delta,
201        })
202    }
203}
204
205/// Wire form of the improving / regressing delta summary.
206#[cfg(feature = "vcs-git")]
207#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
208pub struct VcsTrendDeltas {
209    /// Files whose risk fell, most-improved first.
210    pub improved: Vec<VcsTrendDelta>,
211    /// Files whose risk rose, most-regressed first.
212    pub regressed: Vec<VcsTrendDelta>,
213}
214
215/// Wire form of a historical metric trend (issue #333) — the single
216/// serialized shape shared by `bca vcs trend`, `POST /vcs/trend`, and the
217/// Python `vcs_trend()`.
218///
219/// `as_of_points` lists the sample timestamps oldest-first; every file's
220/// array in `files` aligns to it 1:1, with a `null` element where the file
221/// did not exist at that point. `files` is keyed by repository-relative
222/// path and ordered lexicographically for deterministic output.
223#[cfg(feature = "vcs-git")]
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub struct VcsTrend {
226    /// Trend-document shape version
227    /// ([`crate::vcs::TREND_SCHEMA_VERSION`]).
228    pub trend_schema_version: u32,
229    /// Per-point metric-block shape version.
230    pub vcs_schema_version: u32,
231    /// Composite-formula version.
232    pub risk_score_version: u32,
233    /// Long window length, in days (constant across points).
234    pub long_window_days: u32,
235    /// Recent window length, in days (constant across points).
236    pub recent_window_days: u32,
237    /// Whether any sampled snapshot came from a shallow clone.
238    pub truncated_shallow_clone: bool,
239    /// Sample timestamps, oldest-first.
240    pub as_of_points: Vec<i64>,
241    /// Per-file time series; `null` marks a point where the file was
242    /// absent.
243    pub files: std::collections::BTreeMap<String, Vec<Option<VcsTrendPoint>>>,
244    /// The most-improved / most-regressed files by risk delta.
245    pub deltas: VcsTrendDeltas,
246}
247
248#[cfg(feature = "vcs-git")]
249impl VcsTrend {
250    /// Project a compute-side [`crate::vcs::Trend`] into the wire shape,
251    /// keeping the `top_files` highest-risk files (by their most-recent
252    /// present `risk_score`; `0` keeps all) and the `top_deltas`
253    /// strongest movers in each delta list.
254    #[must_use]
255    pub fn from_trend(trend: &crate::vcs::Trend, top_files: usize, top_deltas: usize) -> Self {
256        let as_of_points = trend.as_of_points().to_vec();
257
258        // Rank files by most-recent present risk so `top_files` keeps the
259        // currently-riskiest. Reuse the shared `rank_by_risk` so the
260        // descending-risk + path tie-break and the `top` truncation match
261        // `bca vcs` / `POST /vcs` exactly (a non-UTF-8 path sorts as "" and
262        // is dropped below).
263        let mut ranked: Vec<(&std::path::PathBuf, &[Option<crate::vcs::Stats>], f64)> = trend
264            .iter()
265            .map(|(path, points)| (path, points, latest_present_risk(points)))
266            .collect();
267        crate::vcs::rank_by_risk(&mut ranked, top_files, |entry| {
268            (entry.0.to_str().unwrap_or(""), entry.2)
269        });
270
271        let files = ranked
272            .into_iter()
273            .filter_map(|(path, points, _)| {
274                // A non-UTF-8 path cannot be a JSON object key; drop it,
275                // matching the per-file endpoints' policy.
276                let key = path.to_str()?.to_owned();
277                let series = points
278                    .iter()
279                    .zip(&as_of_points)
280                    .map(|(stats, &as_of)| {
281                        stats.as_ref().map(|s| VcsTrendPoint {
282                            as_of,
283                            vcs: Vcs::from(s),
284                        })
285                    })
286                    .collect();
287                Some((key, series))
288            })
289            .collect();
290
291        let compute_deltas = trend.deltas(top_deltas);
292        let deltas = VcsTrendDeltas {
293            improved: compute_deltas
294                .improved
295                .iter()
296                .filter_map(VcsTrendDelta::from_delta)
297                .collect(),
298            regressed: compute_deltas
299                .regressed
300                .iter()
301                .filter_map(VcsTrendDelta::from_delta)
302                .collect(),
303        };
304
305        Self {
306            trend_schema_version: crate::vcs::TREND_SCHEMA_VERSION,
307            vcs_schema_version: crate::vcs::stats::VCS_SCHEMA_VERSION,
308            risk_score_version: crate::vcs::score::RISK_SCORE_VERSION,
309            long_window_days: trend.long_window_days(),
310            recent_window_days: trend.recent_window_days(),
311            truncated_shallow_clone: trend.truncated_shallow_clone(),
312            as_of_points,
313            files,
314            deltas,
315        }
316    }
317}