Skip to main content

buildfix_report/
lib.rs

1//! Reporting projections for buildfix outcomes.
2
3use chrono::Utc;
4use std::collections::BTreeSet;
5
6use buildfix_receipts::LoadedReceipt;
7use buildfix_types::apply::BuildfixApply;
8use buildfix_types::plan::BuildfixPlan;
9use buildfix_types::receipt::ToolInfo;
10use buildfix_types::report::{
11    BuildfixReport, InputFailure, ReportArtifacts, ReportCapabilities, ReportCounts, ReportFinding,
12    ReportRunInfo, ReportSeverity, ReportStatus, ReportToolInfo, ReportVerdict,
13};
14
15pub fn build_report_capabilities(receipts: &[LoadedReceipt]) -> ReportCapabilities {
16    let mut inputs_available = Vec::new();
17    let mut inputs_failed = Vec::new();
18    let mut check_ids = BTreeSet::new();
19    let mut scopes = BTreeSet::new();
20
21    for r in receipts {
22        match &r.receipt {
23            Ok(receipt) => {
24                inputs_available.push(r.path.to_string());
25                if let Some(caps) = &receipt.capabilities {
26                    check_ids.extend(caps.check_ids.iter().cloned());
27                    scopes.extend(caps.scopes.iter().cloned());
28                }
29                for finding in &receipt.findings {
30                    if let Some(check_id) = finding.check_id.as_ref()
31                        && !check_id.is_empty()
32                    {
33                        check_ids.insert(check_id.clone());
34                    }
35                }
36            }
37            Err(e) => {
38                inputs_failed.push(InputFailure {
39                    path: r.path.to_string(),
40                    reason: e.to_string(),
41                });
42            }
43        }
44    }
45
46    inputs_available.sort();
47    inputs_failed.sort_by(|a, b| a.path.cmp(&b.path));
48
49    ReportCapabilities {
50        check_ids: check_ids.into_iter().collect(),
51        scopes: scopes.into_iter().collect(),
52        partial: !inputs_failed.is_empty(),
53        reason: if !inputs_failed.is_empty() {
54            Some("some receipts failed to load".to_string())
55        } else {
56            None
57        },
58        inputs_available,
59        inputs_failed,
60    }
61}
62
63pub fn build_plan_report(
64    plan: &BuildfixPlan,
65    tool: ToolInfo,
66    receipts: &[LoadedReceipt],
67) -> BuildfixReport {
68    let capabilities = build_report_capabilities(receipts);
69    let has_failed_inputs = !capabilities.inputs_failed.is_empty();
70
71    let status = if plan.ops.is_empty() && !has_failed_inputs {
72        ReportStatus::Pass
73    } else {
74        ReportStatus::Warn
75    };
76
77    let mut reasons = Vec::new();
78    if has_failed_inputs {
79        reasons.push("partial_inputs".to_string());
80    }
81
82    let findings: Vec<ReportFinding> = capabilities
83        .inputs_failed
84        .iter()
85        .map(|failure| ReportFinding {
86            severity: ReportSeverity::Warn,
87            check_id: Some("inputs".to_string()),
88            code: "receipt_load_failed".to_string(),
89            message: format!(
90                "Receipt failed to load: {} ({})",
91                failure.path, failure.reason
92            ),
93            location: None,
94            fingerprint: Some(format!("inputs/receipt_load_failed/{}", failure.path)),
95            data: None,
96        })
97        .collect();
98
99    let warn_count = plan.ops.len() as u64 + capabilities.inputs_failed.len() as u64;
100    let ops_applicable = plan
101        .summary
102        .ops_total
103        .saturating_sub(plan.summary.ops_blocked);
104    let fix_available = ops_applicable > 0;
105
106    let mut plan_data = serde_json::json!({
107        "ops_total": plan.summary.ops_total,
108        "ops_blocked": plan.summary.ops_blocked,
109        "ops_applicable": ops_applicable,
110        "fix_available": fix_available,
111        "files_touched": plan.summary.files_touched,
112        "patch_bytes": plan.summary.patch_bytes,
113        "plan_available": !plan.ops.is_empty(),
114    });
115
116    if let Some(sc) = &plan.summary.safety_counts {
117        plan_data["safety_counts"] = serde_json::json!({
118            "safe": sc.safe,
119            "guarded": sc.guarded,
120            "unsafe": sc.unsafe_count,
121        });
122    }
123
124    let tokens: BTreeSet<&str> = plan
125        .ops
126        .iter()
127        .filter_map(|o| o.blocked_reason_token.as_deref())
128        .collect();
129    let top: Vec<&str> = tokens.into_iter().take(5).collect();
130    if !top.is_empty() {
131        plan_data["blocked_reason_tokens_top"] = serde_json::json!(top);
132    }
133
134    BuildfixReport {
135        schema: buildfix_types::schema::SENSOR_REPORT_V1.to_string(),
136        tool: ReportToolInfo {
137            name: tool.name,
138            version: tool.version.unwrap_or_else(|| "unknown".to_string()),
139            commit: tool.commit,
140        },
141        run: ReportRunInfo {
142            started_at: Utc::now().to_rfc3339(),
143            ended_at: Some(Utc::now().to_rfc3339()),
144            duration_ms: Some(0),
145            git_head_sha: plan.repo.head_sha.clone(),
146        },
147        verdict: ReportVerdict {
148            status,
149            counts: ReportCounts {
150                info: 0,
151                warn: warn_count,
152                error: 0,
153            },
154            reasons,
155        },
156        findings,
157        capabilities: Some(capabilities),
158        artifacts: Some(ReportArtifacts {
159            plan: Some("plan.json".to_string()),
160            apply: None,
161            patch: Some("patch.diff".to_string()),
162            comment: Some("comment.md".to_string()),
163        }),
164        data: Some(serde_json::json!({
165            "buildfix": {
166                "plan": plan_data
167            }
168        })),
169    }
170}
171
172pub fn build_apply_report(apply: &BuildfixApply, tool: ToolInfo) -> BuildfixReport {
173    let status = if apply.summary.failed > 0 {
174        ReportStatus::Fail
175    } else if apply.summary.blocked > 0 {
176        ReportStatus::Warn
177    } else if apply.summary.applied > 0 {
178        ReportStatus::Pass
179    } else {
180        ReportStatus::Warn
181    };
182
183    let mut apply_data = serde_json::json!({
184        "attempted": apply.summary.attempted,
185        "applied": apply.summary.applied,
186        "blocked": apply.summary.blocked,
187        "failed": apply.summary.failed,
188        "files_modified": apply.summary.files_modified,
189        "apply_performed": apply.summary.applied > 0,
190    });
191
192    if let Some(auto_commit) = &apply.auto_commit {
193        apply_data["auto_commit"] = serde_json::json!({
194            "enabled": auto_commit.enabled,
195            "attempted": auto_commit.attempted,
196            "committed": auto_commit.committed,
197            "commit_sha": auto_commit.commit_sha,
198            "message": auto_commit.message,
199            "skip_reason": auto_commit.skip_reason,
200        });
201    }
202
203    BuildfixReport {
204        schema: buildfix_types::schema::SENSOR_REPORT_V1.to_string(),
205        tool: ReportToolInfo {
206            name: tool.name,
207            version: tool.version.unwrap_or_else(|| "unknown".to_string()),
208            commit: tool.commit,
209        },
210        run: ReportRunInfo {
211            started_at: Utc::now().to_rfc3339(),
212            ended_at: Some(Utc::now().to_rfc3339()),
213            duration_ms: Some(0),
214            git_head_sha: apply.repo.head_sha_after.clone(),
215        },
216        verdict: ReportVerdict {
217            status,
218            counts: ReportCounts {
219                info: apply.summary.applied,
220                warn: apply.summary.blocked,
221                error: apply.summary.failed,
222            },
223            reasons: vec![],
224        },
225        findings: vec![],
226        capabilities: None,
227        artifacts: Some(ReportArtifacts {
228            plan: Some("plan.json".to_string()),
229            apply: Some("apply.json".to_string()),
230            patch: Some("patch.diff".to_string()),
231            comment: None,
232        }),
233        data: Some(serde_json::json!({
234            "buildfix": {
235                "apply": apply_data
236            }
237        })),
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use buildfix_receipts::{LoadedReceipt, ReceiptLoadError};
245    use buildfix_types::{
246        apply::{ApplyRepoInfo, BuildfixApply, PlanRef},
247        plan::{BuildfixPlan, PlanPolicy, PlanSummary},
248        receipt::{Finding, ReceiptCapabilities, ReceiptEnvelope, RunInfo, ToolInfo, Verdict},
249    };
250    use chrono::Utc;
251
252    fn fixture_tool() -> ToolInfo {
253        ToolInfo {
254            name: "buildfix".to_string(),
255            version: Some("0.0.0".to_string()),
256            repo: None,
257            commit: None,
258        }
259    }
260
261    #[test]
262    fn capabilities_are_sorted_and_deduplicated() {
263        let receipts = vec![
264            LoadedReceipt {
265                path: "artifacts/second/report.json".into(),
266                sensor_id: "second".to_string(),
267                receipt: Ok(ReceiptEnvelope {
268                    schema: "sensor.report.v1".to_string(),
269                    tool: fixture_tool(),
270                    run: RunInfo {
271                        started_at: Some(Utc::now()),
272                        ended_at: Some(Utc::now()),
273                        git_head_sha: None,
274                    },
275                    verdict: Verdict::default(),
276                    findings: vec![Finding {
277                        severity: Default::default(),
278                        check_id: Some("b.check".to_string()),
279                        code: None,
280                        message: None,
281                        location: None,
282                        fingerprint: None,
283                        data: None,
284                    }],
285                    capabilities: Some(ReceiptCapabilities {
286                        check_ids: vec!["z.check".to_string(), "a.check".to_string()],
287                        scopes: vec!["workspace".to_string(), "crate".to_string()],
288                        partial: false,
289                        reason: None,
290                    }),
291                    data: None,
292                }),
293            },
294            LoadedReceipt {
295                path: "artifacts/first/report.json".into(),
296                sensor_id: "first".to_string(),
297                receipt: Ok(ReceiptEnvelope {
298                    schema: "sensor.report.v1".to_string(),
299                    tool: fixture_tool(),
300                    run: RunInfo {
301                        started_at: Some(Utc::now()),
302                        ended_at: Some(Utc::now()),
303                        git_head_sha: None,
304                    },
305                    verdict: Verdict::default(),
306                    findings: vec![Finding {
307                        severity: Default::default(),
308                        check_id: Some("a.check".to_string()),
309                        code: None,
310                        message: None,
311                        location: None,
312                        fingerprint: None,
313                        data: None,
314                    }],
315                    capabilities: None,
316                    data: None,
317                }),
318            },
319            LoadedReceipt {
320                path: "artifacts/error/report.json".into(),
321                sensor_id: "err".to_string(),
322                receipt: Err(ReceiptLoadError::Io {
323                    message: "boom".to_string(),
324                }),
325            },
326        ];
327
328        let caps = build_report_capabilities(&receipts);
329        assert_eq!(
330            caps.check_ids,
331            vec![
332                "a.check".to_string(),
333                "b.check".to_string(),
334                "z.check".to_string(),
335            ]
336        );
337        assert_eq!(
338            caps.scopes,
339            vec!["crate".to_string(), "workspace".to_string()]
340        );
341        assert_eq!(
342            caps.inputs_available,
343            vec![
344                "artifacts/first/report.json".to_string(),
345                "artifacts/second/report.json".to_string(),
346            ]
347        );
348        assert!(caps.partial);
349        assert_eq!(caps.inputs_failed.len(), 1);
350    }
351
352    #[test]
353    fn plan_report_marks_warning_when_inputs_fail() {
354        let mut plan = BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default());
355        plan.summary = PlanSummary {
356            ops_total: 0,
357            ops_blocked: 0,
358            files_touched: 0,
359            patch_bytes: None,
360            safety_counts: None,
361        };
362
363        let report = build_plan_report(
364            &plan,
365            fixture_tool(),
366            &[LoadedReceipt {
367                path: "artifacts/bad/report.json".into(),
368                sensor_id: "bad".to_string(),
369                receipt: Err(ReceiptLoadError::Io {
370                    message: "missing".to_string(),
371                }),
372            }],
373        );
374
375        assert_eq!(
376            report.verdict.status,
377            buildfix_types::report::ReportStatus::Warn
378        );
379        assert_eq!(report.findings[0].code, "receipt_load_failed");
380    }
381
382    #[test]
383    fn apply_report_status_rules() {
384        let mut apply = BuildfixApply::new(
385            fixture_tool(),
386            ApplyRepoInfo {
387                root: ".".to_string(),
388                head_sha_before: None,
389                head_sha_after: None,
390                dirty_before: None,
391                dirty_after: None,
392            },
393            PlanRef {
394                path: "plan.json".into(),
395                sha256: None,
396            },
397        );
398
399        assert_eq!(
400            build_apply_report(&apply, fixture_tool()).verdict.status,
401            buildfix_types::report::ReportStatus::Warn
402        );
403        apply.summary.failed = 1;
404        assert_eq!(
405            build_apply_report(&apply, fixture_tool()).verdict.status,
406            buildfix_types::report::ReportStatus::Fail
407        );
408        apply.summary.failed = 0;
409        apply.summary.blocked = 1;
410        assert_eq!(
411            build_apply_report(&apply, fixture_tool()).verdict.status,
412            buildfix_types::report::ReportStatus::Warn
413        );
414        apply.summary.blocked = 0;
415        apply.summary.applied = 1;
416        assert_eq!(
417            build_apply_report(&apply, fixture_tool()).verdict.status,
418            buildfix_types::report::ReportStatus::Pass
419        );
420    }
421
422    fn default_repo() -> buildfix_types::plan::RepoInfo {
423        buildfix_types::plan::RepoInfo {
424            root: ".".to_string(),
425            head_sha: None,
426            dirty: None,
427        }
428    }
429}