Skip to main content

fallow_output/
impact.rs

1//! Impact report output contracts.
2
3use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use fallow_types::envelope::Meta;
5use serde::{Deserialize, Serialize};
6
7/// Per-category issue counts captured at a recorded run.
8#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10pub struct ImpactCounts {
11    /// Sum of the category counts.
12    pub total_issues: usize,
13    /// Dead-code findings.
14    pub dead_code: usize,
15    /// Complexity findings.
16    pub complexity: usize,
17    /// Duplication findings.
18    pub duplication: usize,
19}
20
21impl ImpactCounts {
22    /// Counts with `total_issues` derived as the sum of the categories.
23    #[must_use]
24    pub fn from_combined(dead_code: usize, complexity: usize, duplication: usize) -> Self {
25        Self {
26            total_issues: dead_code + complexity + duplication,
27            dead_code,
28            complexity,
29            duplication,
30        }
31    }
32}
33
34/// Recorded gate runs grouped by the gate that produced them. Local
35/// provenance only: the store never leaves the machine, so this answers "where
36/// do my gate runs come from", never "how widely is fallow adopted".
37///
38/// Counted over the recorded runs the store still holds, which is the same
39/// window `record_count` reports. The store keeps a bounded number of runs and
40/// drops the oldest, so on a long-lived project these are the shape of recent
41/// gate activity, not a lifetime total: read them as a floor. Absent when no
42/// run in that window carries a gate source, which is not the same as "no gate
43/// ever ran here".
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46pub struct GateRunCounts {
47    /// Runs recorded by the agent gate (`--gate-marker agent`).
48    pub agent: usize,
49    /// Runs recorded by the git pre-commit hook (`--gate-marker pre-commit`).
50    pub pre_commit: usize,
51    /// Runs recorded by a CI gate (`--gate-marker ci`).
52    pub ci: usize,
53    /// Gate runs whose marker this build does not recognise, plus every gate
54    /// run recorded before the store kept its source (store schema 6 and older).
55    pub unknown: usize,
56}
57
58impl GateRunCounts {
59    /// Whether any gate run was recorded at all.
60    #[must_use]
61    pub fn is_empty(&self) -> bool {
62        self.agent == 0 && self.pre_commit == 0 && self.ci == 0 && self.unknown == 0
63    }
64}
65
66/// A commit-gate containment event recorded by `fallow impact`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69pub struct ContainmentEvent {
70    /// Timestamp when the commit gate blocked the commit.
71    pub blocked_at: String,
72    /// Timestamp when a later run passed clean.
73    pub cleared_at: String,
74    /// Abbreviated SHA of the cleared commit, when in a git repo.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub git_sha: Option<String>,
77    /// Finding counts at the moment the gate blocked.
78    pub blocked_counts: ImpactCounts,
79}
80
81/// A resolved or suppressed finding attribution event.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
84pub struct ResolutionEvent {
85    /// Finding kind that was resolved or suppressed, e.g. `unused-export`.
86    pub kind: String,
87    /// Root-relative path of the resolved finding.
88    pub path: String,
89    /// Symbol name, for symbol-level findings.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub symbol: Option<String>,
92    /// Abbreviated SHA of the resolving commit, when in a git repo.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub git_sha: Option<String>,
95    /// Timestamp the resolution was recorded.
96    pub timestamp: String,
97}
98
99/// Why Impact tracking is (or is not) active for a project. `Project` = an
100/// explicit per-repo `enable`; `User` = the user-global default with no per-repo
101/// decision; `Default` = off (no per-repo decision and no global default).
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[serde(rename_all = "lowercase")]
105pub enum EnabledSource {
106    /// Explicit per-repo enable/disable decision.
107    Project,
108    /// User-global default with no per-repo decision.
109    User,
110    /// Off: no per-repo decision and no global default.
111    Default,
112}
113
114/// Direction of a count trend between two recorded runs.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117#[serde(rename_all = "snake_case")]
118pub enum ImpactTrendDirection {
119    /// Issue count went down.
120    Improving,
121    /// Issue count went up.
122    Declining,
123    /// Within tolerance.
124    Stable,
125}
126
127/// A computed trend between the two most recent records.
128#[derive(Debug, Clone, Serialize)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130pub struct TrendSummary {
131    /// Trend direction between the two runs.
132    pub direction: ImpactTrendDirection,
133    /// Signed delta in total issues, current minus previous.
134    pub total_delta: i64,
135    /// Total issues in the earlier run.
136    pub previous_total: usize,
137    /// Total issues in the later run.
138    pub current_total: usize,
139}
140
141/// Wire-version discriminator for [`ImpactReport`]. Independent from the global
142/// `SchemaVersion` (the impact report versions on its own cadence) and from the
143/// on-disk `STORE_SCHEMA_VERSION` (the persisted store shape versions
144/// separately). Serializes as a string `const` so JSON consumers can switch on
145/// it, matching the other independently-versioned envelopes (e.g.
146/// `CoverageAnalyzeSchemaVersion`).
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
148#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
149pub enum ImpactReportSchemaVersion {
150    /// First release of the `fallow impact --format json` shape.
151    #[serde(rename = "1")]
152    V1,
153    /// Expands the required semantic omission reason-code enum.
154    #[serde(rename = "2")]
155    V2,
156}
157
158/// The rendered impact report, derived purely from the store.
159#[derive(Debug, Clone, Serialize)]
160#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
161#[cfg_attr(feature = "schema", schemars(title = "fallow impact --format json"))]
162pub struct ImpactReport {
163    /// Output-shape version for this report, so JSON consumers have a
164    /// forward-compat signal independent of the on-disk store version. Always
165    /// present; bumped only on a breaking change to this report's wire shape.
166    pub schema_version: ImpactReportSchemaVersion,
167    /// Whether impact tracking is active for this project.
168    pub enabled: bool,
169    /// WHY tracking is on or off: `project` (an explicit per-repo enable/disable
170    /// decision), `user` (the user-global default with no per-repo decision), or
171    /// `default` (off, no per-repo decision and no global default). Combine with
172    /// `explicit_decision` to tell a never-asked off-state (`enabled:false`,
173    /// `explicit_decision:false`, offer to enable) from a declined-here one
174    /// (`enabled:false`, `explicit_decision:true`, do not nag).
175    pub enabled_source: EnabledSource,
176    /// Number of recorded runs in the store.
177    pub record_count: usize,
178    /// `_meta` block with docs and field definitions, when `--explain` was
179    /// passed.
180    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
181    pub meta: Option<Meta>,
182    /// Timestamp of the earliest recorded run; absent with no records.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub first_recorded: Option<String>,
185    /// Git SHA of the most recent recorded run, so a consumer can tell which
186    /// commit the `surfacing` counts belong to. This is an ABBREVIATED SHA
187    /// (`git rev-parse --short`), so it is for display/correlation only and will
188    /// not match a full 40-character SHA from `$GITHUB_SHA` or the git API
189    /// without expansion. None when the latest run had no SHA (not a git repo)
190    /// or there are no records yet.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub latest_git_sha: Option<String>,
193    /// Counts from the most recent recorded run. These are CHANGED-FILE scoped
194    /// (each record comes from a `fallow audit` run, whose default `new-only`
195    /// gate counts only findings in the changed files of that run), NOT a
196    /// whole-project total.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub surfacing: Option<ImpactCounts>,
199    /// Trend between the two most recent records. None until two records exist.
200    /// Trend between the two most recent changed-file records. None until two
201    /// records exist.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub trend: Option<TrendSummary>,
204    /// Counts from the most recent whole-project `fallow` run. WHOLE-PROJECT
205    /// scope (not changed-file), so this is the current issue total across the
206    /// whole repo, context next to the actionable changed-file `surfacing`
207    /// count. None until a full `fallow` run has been recorded. v1.6.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub project_surfacing: Option<ImpactCounts>,
210    /// Trend between the two most recent whole-project records. Comparable over
211    /// time (same whole-project denominator every run), unlike the changed-file
212    /// `trend`. None until two full `fallow` runs exist. v1.6.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub project_trend: Option<TrendSummary>,
215    /// Recorded gate runs grouped by source, over the same bounded window of
216    /// recorded runs `record_count` reports. A floor, not a lifetime total, and
217    /// absent when no run in that window carries a gate source. Local
218    /// provenance, never an adoption metric.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub gate_runs: Option<GateRunCounts>,
221    /// Lifetime count of commit-gate containment events.
222    pub containment_count: usize,
223    /// Most recent containment events (newest last), capped for display.
224    pub recent_containment: Vec<ContainmentEvent>,
225    /// Lifetime count of findings fallow credits as genuinely resolved (code
226    /// removed or refactored, never a `fallow-ignore`). v1.5.
227    pub resolved_total: usize,
228    /// Lifetime count of findings silenced by a newly-added `fallow-ignore`.
229    /// Reported as honest context, never as a win. v1.5.
230    pub suppressed_total: usize,
231    /// Most recent resolution events (newest last), capped for display. v1.5.
232    pub recent_resolved: Vec<ResolutionEvent>,
233    /// Whether per-finding attribution has a baseline yet. False on a freshly
234    /// upgraded v1 store (no frontier captured), which the renderer uses to show
235    /// "resolution tracking starts from your next run" instead of a bare zero.
236    pub attribution_active: bool,
237    /// Whether the local agent onboarding prompt has been explicitly declined.
238    /// Stored in the user config dir (per project) so agents avoid cross-session
239    /// nags without writing into the repo.
240    pub onboarding_declined: bool,
241    /// Whether the user ever made an explicit enable/disable decision for
242    /// Impact tracking. `enabled: false` with `explicit_decision: false` means
243    /// "never asked"; with `true` it means "asked and declined". Agents use
244    /// this to offer the impact opt-in exactly once per project.
245    pub explicit_decision: bool,
246}
247
248/// Independent wire-version for the cross-repo report, on its own cadence (it
249/// versions separately from the per-project `ImpactReportSchemaVersion` and the
250/// on-disk `STORE_SCHEMA_VERSION`).
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
253pub enum CrossRepoImpactSchemaVersion {
254    /// First release of the `fallow impact --all --format json` shape.
255    #[serde(rename = "1")]
256    V1,
257    /// Expands the required semantic omission reason-code enum in embedded reports.
258    #[serde(rename = "2")]
259    V2,
260}
261
262/// Grand totals across every tracked project (including repos whose directory no
263/// longer exists on disk: their past wins still count toward lifetime impact).
264#[derive(Debug, Clone, Default, Serialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266pub struct CrossRepoTotals {
267    /// Lifetime genuinely-resolved findings across projects.
268    pub resolved_total: usize,
269    /// Lifetime `fallow-ignore` suppressions across projects.
270    pub suppressed_total: usize,
271    /// Lifetime commit-gate containment events across projects.
272    pub containment_count: usize,
273    /// Sum of whole-project issue totals across projects that have a full-run
274    /// baseline, as of EACH project's last full `fallow` run (not a simultaneous
275    /// snapshot).
276    pub project_wide_issues: usize,
277    /// Projects that have recorded at least one full `fallow` run.
278    pub projects_with_baseline: usize,
279}
280
281/// One project's row in the cross-repo roll-up.
282#[derive(Debug, Clone, Serialize)]
283#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
284pub struct CrossRepoProjectEntry {
285    /// Stable, non-reversible project key (the store filename stem); the
286    /// cross-tool/cross-run JOIN key. NEVER a path.
287    pub project_key: String,
288    /// Repo basename for display (never a full path). Absent on pre-v5 stores
289    /// (the row falls back to the short key).
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub label: Option<String>,
292    /// Timestamp of the project's most recent recorded run (changed-file or
293    /// whole-project), for the LAST RUN column and the default `recent` sort.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub last_recorded: Option<String>,
296    /// The full per-project report (identical shape to `fallow impact --format
297    /// json`), reused verbatim so the per-project wire contract is the sub-shape.
298    pub report: ImpactReport,
299}
300
301/// The cross-repo aggregate report, `fallow impact --all --format json`.
302#[derive(Debug, Clone, Serialize)]
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304#[cfg_attr(
305    feature = "schema",
306    schemars(title = "fallow impact --all --format json")
307)]
308pub struct CrossRepoImpactReport {
309    /// Cross-repo output schema version; currently serialized as the string `"2"`.
310    pub schema_version: CrossRepoImpactSchemaVersion,
311    /// Per-project stores successfully parsed (add `unreadable_count` for the
312    /// total number of store files found in the user config dir).
313    pub project_count: usize,
314    /// Stores with recorded history (the rows in `projects`); excludes
315    /// enabled-but-empty stores, which are still counted in `project_count`.
316    pub tracked_count: usize,
317    /// Stores that failed to parse and were skipped (corrupt or newer-schema).
318    pub unreadable_count: usize,
319    /// Grand totals across every tracked project.
320    pub totals: CrossRepoTotals,
321    /// Per-project rows, one for each store with recorded history.
322    pub projects: Vec<CrossRepoProjectEntry>,
323}
324
325/// Serialize the `fallow impact --format json` envelope.
326///
327/// # Errors
328///
329/// Returns a serde error when the report cannot be converted to JSON.
330pub fn serialize_impact_json_output(
331    report: ImpactReport,
332    mode: RootEnvelopeMode,
333    analysis_run_id: Option<&str>,
334) -> Result<serde_json::Value, serde_json::Error> {
335    let mut value = serialize_named_json_output(report, "impact", mode)?;
336    attach_telemetry_meta(&mut value, analysis_run_id);
337    Ok(value)
338}
339
340/// Serialize the `fallow impact --all --format json` envelope.
341///
342/// # Errors
343///
344/// Returns a serde error when the report cannot be converted to JSON.
345pub fn serialize_cross_repo_impact_json_output(
346    report: CrossRepoImpactReport,
347    mode: RootEnvelopeMode,
348    analysis_run_id: Option<&str>,
349) -> Result<serde_json::Value, serde_json::Error> {
350    let mut value = serialize_named_json_output(report, "impact-cross-repo", mode)?;
351    attach_telemetry_meta(&mut value, analysis_run_id);
352    Ok(value)
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn impact_report() -> ImpactReport {
360        ImpactReport {
361            schema_version: ImpactReportSchemaVersion::V2,
362            enabled: true,
363            enabled_source: EnabledSource::Project,
364            record_count: 0,
365            meta: None,
366            first_recorded: None,
367            latest_git_sha: None,
368            surfacing: None,
369            trend: None,
370            project_surfacing: None,
371            project_trend: None,
372            gate_runs: None,
373            containment_count: 0,
374            recent_containment: Vec::new(),
375            resolved_total: 0,
376            suppressed_total: 0,
377            recent_resolved: Vec::new(),
378            attribution_active: false,
379            onboarding_declined: false,
380            explicit_decision: true,
381        }
382    }
383
384    #[test]
385    fn impact_json_output_uses_named_root_contract() {
386        let value =
387            serialize_impact_json_output(impact_report(), RootEnvelopeMode::Tagged, Some("run-1"))
388                .expect("impact report should serialize");
389
390        assert_eq!(value["kind"], "impact");
391        assert_eq!(value["schema_version"], "2");
392        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-1");
393    }
394
395    #[test]
396    fn cross_repo_impact_json_output_uses_named_root_contract() {
397        let report = CrossRepoImpactReport {
398            schema_version: CrossRepoImpactSchemaVersion::V2,
399            project_count: 1,
400            tracked_count: 1,
401            unreadable_count: 0,
402            totals: CrossRepoTotals::default(),
403            projects: vec![CrossRepoProjectEntry {
404                project_key: "demo".to_string(),
405                label: None,
406                last_recorded: None,
407                report: impact_report(),
408            }],
409        };
410
411        let value = serialize_cross_repo_impact_json_output(
412            report,
413            RootEnvelopeMode::Tagged,
414            Some("run-2"),
415        )
416        .expect("cross-repo impact report should serialize");
417
418        assert_eq!(value["kind"], "impact-cross-repo");
419        assert_eq!(value["schema_version"], "2");
420        assert_eq!(value["project_count"], 1);
421        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-2");
422    }
423}