1use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use fallow_types::envelope::Meta;
5use serde::{Deserialize, Serialize};
6
7#[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#[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#[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#[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#[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 Improving,
72 Declining,
74 Stable,
76}
77
78#[derive(Debug, Clone, Serialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct TrendSummary {
82 pub direction: ImpactTrendDirection,
83 pub total_delta: i64,
85 pub previous_total: usize,
86 pub current_total: usize,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub enum ImpactReportSchemaVersion {
98 #[serde(rename = "1")]
100 V1,
101}
102
103#[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 pub schema_version: ImpactReportSchemaVersion,
112 pub enabled: bool,
113 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 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub latest_git_sha: Option<String>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub surfacing: Option<ImpactCounts>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub trend: Option<TrendSummary>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub project_surfacing: Option<ImpactCounts>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub project_trend: Option<TrendSummary>,
153 pub containment_count: usize,
154 pub recent_containment: Vec<ContainmentEvent>,
156 pub resolved_total: usize,
159 pub suppressed_total: usize,
162 pub recent_resolved: Vec<ResolutionEvent>,
164 pub attribution_active: bool,
168 pub onboarding_declined: bool,
172 pub explicit_decision: bool,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184pub enum CrossRepoImpactSchemaVersion {
185 #[serde(rename = "1")]
187 V1,
188}
189
190#[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 pub project_wide_issues: usize,
202 pub projects_with_baseline: usize,
203}
204
205#[derive(Debug, Clone, Serialize)]
207#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208pub struct CrossRepoProjectEntry {
209 pub project_key: String,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub label: Option<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub last_recorded: Option<String>,
220 pub report: ImpactReport,
223}
224
225#[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 pub project_count: usize,
237 pub tracked_count: usize,
240 pub unreadable_count: usize,
242 pub totals: CrossRepoTotals,
243 pub projects: Vec<CrossRepoProjectEntry>,
244}
245
246pub 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
261pub 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}