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