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, AutoCommitInfo, BuildfixApply, PlanRef},
247        ops::{OpKind, OpTarget, SafetyClass},
248        plan::{BuildfixPlan, PlanOp, PlanPolicy, PlanSummary, Rationale, SafetyCounts},
249        receipt::{Finding, ReceiptCapabilities, ReceiptEnvelope, RunInfo, ToolInfo, Verdict},
250    };
251    use chrono::Utc;
252
253    fn fixture_tool() -> ToolInfo {
254        ToolInfo {
255            name: "buildfix".to_string(),
256            version: Some("0.0.0".to_string()),
257            repo: None,
258            commit: None,
259        }
260    }
261
262    #[test]
263    fn capabilities_are_sorted_and_deduplicated() {
264        let receipts = vec![
265            LoadedReceipt {
266                path: "artifacts/second/report.json".into(),
267                sensor_id: "second".to_string(),
268                receipt: Ok(ReceiptEnvelope {
269                    schema: "sensor.report.v1".to_string(),
270                    tool: fixture_tool(),
271                    run: RunInfo {
272                        started_at: Some(Utc::now()),
273                        ended_at: Some(Utc::now()),
274                        git_head_sha: None,
275                    },
276                    verdict: Verdict::default(),
277                    findings: vec![Finding {
278                        severity: Default::default(),
279                        check_id: Some("b.check".to_string()),
280                        code: None,
281                        message: None,
282                        location: None,
283                        fingerprint: None,
284                        data: None,
285                        ..Default::default()
286                    }],
287                    capabilities: Some(ReceiptCapabilities {
288                        check_ids: vec!["z.check".to_string(), "a.check".to_string()],
289                        scopes: vec!["workspace".to_string(), "crate".to_string()],
290                        partial: false,
291                        reason: None,
292                    }),
293                    data: None,
294                }),
295            },
296            LoadedReceipt {
297                path: "artifacts/first/report.json".into(),
298                sensor_id: "first".to_string(),
299                receipt: Ok(ReceiptEnvelope {
300                    schema: "sensor.report.v1".to_string(),
301                    tool: fixture_tool(),
302                    run: RunInfo {
303                        started_at: Some(Utc::now()),
304                        ended_at: Some(Utc::now()),
305                        git_head_sha: None,
306                    },
307                    verdict: Verdict::default(),
308                    findings: vec![Finding {
309                        severity: Default::default(),
310                        check_id: Some("a.check".to_string()),
311                        code: None,
312                        message: None,
313                        location: None,
314                        fingerprint: None,
315                        data: None,
316                        ..Default::default()
317                    }],
318                    capabilities: None,
319                    data: None,
320                }),
321            },
322            LoadedReceipt {
323                path: "artifacts/error/report.json".into(),
324                sensor_id: "err".to_string(),
325                receipt: Err(ReceiptLoadError::Io {
326                    message: "boom".to_string(),
327                }),
328            },
329        ];
330
331        let caps = build_report_capabilities(&receipts);
332        assert_eq!(
333            caps.check_ids,
334            vec![
335                "a.check".to_string(),
336                "b.check".to_string(),
337                "z.check".to_string(),
338            ]
339        );
340        assert_eq!(
341            caps.scopes,
342            vec!["crate".to_string(), "workspace".to_string()]
343        );
344        assert_eq!(
345            caps.inputs_available,
346            vec![
347                "artifacts/first/report.json".to_string(),
348                "artifacts/second/report.json".to_string(),
349            ]
350        );
351        assert!(caps.partial);
352        assert_eq!(caps.inputs_failed.len(), 1);
353    }
354
355    #[test]
356    fn plan_report_marks_warning_when_inputs_fail() {
357        let mut plan = BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default());
358        plan.summary = PlanSummary {
359            ops_total: 0,
360            ops_blocked: 0,
361            files_touched: 0,
362            patch_bytes: None,
363            safety_counts: None,
364        };
365
366        let report = build_plan_report(
367            &plan,
368            fixture_tool(),
369            &[LoadedReceipt {
370                path: "artifacts/bad/report.json".into(),
371                sensor_id: "bad".to_string(),
372                receipt: Err(ReceiptLoadError::Io {
373                    message: "missing".to_string(),
374                }),
375            }],
376        );
377
378        assert_eq!(
379            report.verdict.status,
380            buildfix_types::report::ReportStatus::Warn
381        );
382        assert_eq!(report.findings[0].code, "receipt_load_failed");
383    }
384
385    #[test]
386    fn apply_report_status_rules() {
387        let mut apply = BuildfixApply::new(
388            fixture_tool(),
389            ApplyRepoInfo {
390                root: ".".to_string(),
391                head_sha_before: None,
392                head_sha_after: None,
393                dirty_before: None,
394                dirty_after: None,
395            },
396            PlanRef {
397                path: "plan.json".into(),
398                sha256: None,
399            },
400        );
401
402        assert_eq!(
403            build_apply_report(&apply, fixture_tool()).verdict.status,
404            buildfix_types::report::ReportStatus::Warn
405        );
406        apply.summary.failed = 1;
407        assert_eq!(
408            build_apply_report(&apply, fixture_tool()).verdict.status,
409            buildfix_types::report::ReportStatus::Fail
410        );
411        apply.summary.failed = 0;
412        apply.summary.blocked = 1;
413        assert_eq!(
414            build_apply_report(&apply, fixture_tool()).verdict.status,
415            buildfix_types::report::ReportStatus::Warn
416        );
417        apply.summary.blocked = 0;
418        apply.summary.applied = 1;
419        assert_eq!(
420            build_apply_report(&apply, fixture_tool()).verdict.status,
421            buildfix_types::report::ReportStatus::Pass
422        );
423    }
424
425    fn default_repo() -> buildfix_types::plan::RepoInfo {
426        buildfix_types::plan::RepoInfo {
427            root: ".".to_string(),
428            head_sha: None,
429            dirty: None,
430        }
431    }
432
433    #[test]
434    fn test_capabilities_empty_receipts() {
435        let caps = build_report_capabilities(&[]);
436        assert!(caps.check_ids.is_empty());
437        assert!(caps.scopes.is_empty());
438        assert!(!caps.partial);
439        assert!(caps.inputs_available.is_empty());
440        assert!(caps.inputs_failed.is_empty());
441        assert!(caps.reason.is_none());
442    }
443
444    #[test]
445    fn test_capabilities_all_failed() {
446        let receipts = vec![
447            LoadedReceipt {
448                path: "artifacts/fail1/report.json".into(),
449                sensor_id: "fail1".to_string(),
450                receipt: Err(ReceiptLoadError::Io {
451                    message: "not found".to_string(),
452                }),
453            },
454            LoadedReceipt {
455                path: "artifacts/fail2/report.json".into(),
456                sensor_id: "fail2".to_string(),
457                receipt: Err(ReceiptLoadError::Json {
458                    message: "invalid json".to_string(),
459                }),
460            },
461        ];
462
463        let caps = build_report_capabilities(&receipts);
464        assert!(caps.partial);
465        assert!(caps.inputs_available.is_empty());
466        assert_eq!(caps.inputs_failed.len(), 2);
467        assert!(caps.reason.is_some());
468        assert_eq!(caps.reason.unwrap(), "some receipts failed to load");
469    }
470
471    #[test]
472    fn test_capabilities_finds_check_ids_from_findings() {
473        let receipts = vec![LoadedReceipt {
474            path: "artifacts/sensor/report.json".into(),
475            sensor_id: "sensor".to_string(),
476            receipt: Ok(ReceiptEnvelope {
477                schema: "sensor.report.v1".to_string(),
478                tool: fixture_tool(),
479                run: RunInfo {
480                    started_at: Some(Utc::now()),
481                    ended_at: Some(Utc::now()),
482                    git_head_sha: None,
483                },
484                verdict: Verdict::default(),
485                findings: vec![
486                    Finding {
487                        severity: Default::default(),
488                        check_id: Some("rustc/W000".to_string()),
489                        code: Some("unused_crate".to_string()),
490                        message: Some("Unused crate".to_string()),
491                        location: None,
492                        fingerprint: None,
493                        data: None,
494                        ..Default::default()
495                    },
496                    Finding {
497                        severity: Default::default(),
498                        check_id: Some("clippy/DB01".to_string()),
499                        code: Some("derives".to_string()),
500                        message: Some("Derive issue".to_string()),
501                        location: None,
502                        fingerprint: None,
503                        data: None,
504                        ..Default::default()
505                    },
506                ],
507                capabilities: None,
508                data: None,
509            }),
510        }];
511
512        let caps = build_report_capabilities(&receipts);
513        assert!(caps.check_ids.contains(&"rustc/W000".to_string()));
514        assert!(caps.check_ids.contains(&"clippy/DB01".to_string()));
515    }
516
517    #[test]
518    fn test_plan_report_empty_plan_passes() {
519        let plan = BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default());
520        let report = build_plan_report(&plan, fixture_tool(), &[]);
521
522        assert_eq!(report.verdict.status, ReportStatus::Pass);
523        assert!(report.findings.is_empty());
524        assert!(report.capabilities.is_some());
525        let caps = report.capabilities.as_ref().unwrap();
526        assert!(caps.inputs_failed.is_empty());
527    }
528
529    #[test]
530    fn test_plan_report_with_ops_warns() {
531        let mut plan = BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default());
532        plan.ops.push(PlanOp {
533            id: "op1".to_string(),
534            safety: SafetyClass::Safe,
535            blocked: false,
536            blocked_reason: None,
537            blocked_reason_token: None,
538            target: OpTarget {
539                path: "Cargo.toml".to_string(),
540            },
541            kind: OpKind::TomlSet {
542                toml_path: vec!["workspace".to_string(), "members".to_string()],
543                value: serde_json::json!(["crate1"]),
544            },
545            rationale: Rationale {
546                fix_key: "unused-dependency".to_string(),
547                description: Some("Remove unused dependency".to_string()),
548                findings: vec![],
549            },
550            params_required: vec![],
551            preview: None,
552        });
553        plan.summary = PlanSummary {
554            ops_total: 1,
555            ops_blocked: 0,
556            files_touched: 1,
557            patch_bytes: Some(100),
558            safety_counts: Some(SafetyCounts {
559                safe: 1,
560                guarded: 0,
561                unsafe_count: 0,
562            }),
563        };
564
565        let report = build_plan_report(&plan, fixture_tool(), &[]);
566
567        assert_eq!(report.verdict.status, ReportStatus::Warn);
568        assert_eq!(report.verdict.counts.warn, 1);
569        let data = report.data.as_ref().unwrap();
570        let plan_data = &data["buildfix"]["plan"];
571        assert_eq!(plan_data["ops_total"], 1);
572        assert_eq!(plan_data["ops_applicable"], 1);
573        assert_eq!(plan_data["fix_available"], true);
574    }
575
576    #[test]
577    fn test_plan_report_with_blocked_ops() {
578        let mut plan = BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default());
579        plan.ops.push(PlanOp {
580            id: "op1".to_string(),
581            safety: SafetyClass::Unsafe,
582            blocked: true,
583            blocked_reason: Some("Missing parameters: version".to_string()),
584            blocked_reason_token: Some("missing_params".to_string()),
585            target: OpTarget {
586                path: "Cargo.toml".to_string(),
587            },
588            kind: OpKind::TomlSet {
589                toml_path: vec!["dependencies".to_string(), "foo".to_string()],
590                value: serde_json::json!({"version": "PARAM"}),
591            },
592            rationale: Rationale {
593                fix_key: "add-dependency".to_string(),
594                description: Some("Add missing dependency".to_string()),
595                findings: vec![],
596            },
597            params_required: vec!["version".to_string()],
598            preview: None,
599        });
600        plan.summary = PlanSummary {
601            ops_total: 1,
602            ops_blocked: 1,
603            files_touched: 1,
604            patch_bytes: Some(50),
605            safety_counts: Some(SafetyCounts {
606                safe: 0,
607                guarded: 0,
608                unsafe_count: 1,
609            }),
610        };
611
612        let report = build_plan_report(&plan, fixture_tool(), &[]);
613
614        assert_eq!(report.verdict.status, ReportStatus::Warn);
615        let data = report.data.as_ref().unwrap();
616        let plan_data = &data["buildfix"]["plan"];
617        assert_eq!(plan_data["ops_blocked"], 1);
618        assert_eq!(plan_data["ops_applicable"], 0);
619        assert_eq!(plan_data["fix_available"], false);
620        assert!(plan_data["blocked_reason_tokens_top"].is_array());
621    }
622
623    #[test]
624    fn test_plan_report_failed_inputs_overrides_pass() {
625        let mut plan = BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default());
626        plan.summary = PlanSummary::default();
627
628        let report = build_plan_report(
629            &plan,
630            fixture_tool(),
631            &[LoadedReceipt {
632                path: "artifacts/broken/report.json".into(),
633                sensor_id: "broken".to_string(),
634                receipt: Err(ReceiptLoadError::Io {
635                    message: "file missing".to_string(),
636                }),
637            }],
638        );
639
640        assert_eq!(report.verdict.status, ReportStatus::Warn);
641        assert!(
642            report
643                .verdict
644                .reasons
645                .contains(&"partial_inputs".to_string())
646        );
647        assert!(!report.findings.is_empty());
648    }
649
650    #[test]
651    fn test_plan_report_timestamp_format() {
652        let plan = BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default());
653        let report = build_plan_report(&plan, fixture_tool(), &[]);
654
655        assert!(!report.run.started_at.is_empty());
656        assert!(report.run.ended_at.is_some());
657        let ended = report.run.ended_at.as_ref().unwrap();
658        assert!(ended.contains('T'));
659        assert!(ended.ends_with('Z') || ended.ends_with("+00:00"));
660    }
661
662    #[test]
663    fn test_plan_report_with_safety_counts() {
664        let mut plan = BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default());
665        for i in 0..3 {
666            plan.ops.push(PlanOp {
667                id: format!("op{}", i),
668                safety: if i == 0 {
669                    SafetyClass::Safe
670                } else {
671                    SafetyClass::Guarded
672                },
673                blocked: false,
674                blocked_reason: None,
675                blocked_reason_token: None,
676                target: OpTarget {
677                    path: format!("Cargo{}.toml", i),
678                },
679                kind: OpKind::TomlSet {
680                    toml_path: vec!["package".to_string(), "version".to_string()],
681                    value: serde_json::json!("0.1.0"),
682                },
683                rationale: Rationale {
684                    fix_key: "test".to_string(),
685                    description: None,
686                    findings: vec![],
687                },
688                params_required: vec![],
689                preview: None,
690            });
691        }
692        plan.summary = PlanSummary {
693            ops_total: 3,
694            ops_blocked: 0,
695            files_touched: 3,
696            patch_bytes: Some(300),
697            safety_counts: Some(SafetyCounts {
698                safe: 1,
699                guarded: 2,
700                unsafe_count: 0,
701            }),
702        };
703
704        let report = build_plan_report(&plan, fixture_tool(), &[]);
705
706        let data = report.data.as_ref().unwrap();
707        let plan_data = &data["buildfix"]["plan"];
708        let safety = &plan_data["safety_counts"];
709        assert_eq!(safety["safe"], 1);
710        assert_eq!(safety["guarded"], 2);
711        assert_eq!(safety["unsafe"], 0);
712    }
713
714    #[test]
715    fn test_apply_report_empty_applies_warns() {
716        let apply = BuildfixApply::new(
717            fixture_tool(),
718            ApplyRepoInfo {
719                root: ".".to_string(),
720                head_sha_before: Some("abc123".to_string()),
721                head_sha_after: Some("abc123".to_string()),
722                dirty_before: Some(false),
723                dirty_after: Some(false),
724            },
725            PlanRef {
726                path: "plan.json".into(),
727                sha256: None,
728            },
729        );
730
731        let report = build_apply_report(&apply, fixture_tool());
732
733        assert_eq!(report.verdict.status, ReportStatus::Warn);
734        assert_eq!(report.verdict.counts.info, 0);
735        assert_eq!(report.verdict.counts.warn, 0);
736        assert_eq!(report.verdict.counts.error, 0);
737    }
738
739    #[test]
740    fn test_apply_report_with_failures_fails() {
741        let mut apply = BuildfixApply::new(
742            fixture_tool(),
743            ApplyRepoInfo {
744                root: ".".to_string(),
745                head_sha_before: Some("abc123".to_string()),
746                head_sha_after: Some("def456".to_string()),
747                dirty_before: Some(false),
748                dirty_after: Some(true),
749            },
750            PlanRef {
751                path: "plan.json".into(),
752                sha256: Some("hash".to_string()),
753            },
754        );
755        apply.summary.attempted = 5;
756        apply.summary.applied = 3;
757        apply.summary.blocked = 1;
758        apply.summary.failed = 1;
759        apply.summary.files_modified = 2;
760
761        let report = build_apply_report(&apply, fixture_tool());
762
763        assert_eq!(report.verdict.status, ReportStatus::Fail);
764        assert_eq!(report.verdict.counts.error, 1);
765    }
766
767    #[test]
768    fn test_apply_report_with_blocked_warns() {
769        let mut apply = BuildfixApply::new(
770            fixture_tool(),
771            ApplyRepoInfo {
772                root: ".".to_string(),
773                head_sha_before: None,
774                head_sha_after: None,
775                dirty_before: None,
776                dirty_after: None,
777            },
778            PlanRef {
779                path: "plan.json".into(),
780                sha256: None,
781            },
782        );
783        apply.summary.attempted = 3;
784        apply.summary.applied = 1;
785        apply.summary.blocked = 2;
786        apply.summary.failed = 0;
787        apply.summary.files_modified = 1;
788
789        let report = build_apply_report(&apply, fixture_tool());
790
791        assert_eq!(report.verdict.status, ReportStatus::Warn);
792        assert_eq!(report.verdict.counts.warn, 2);
793    }
794
795    #[test]
796    fn test_apply_report_passes_on_success() {
797        let mut apply = BuildfixApply::new(
798            fixture_tool(),
799            ApplyRepoInfo {
800                root: ".".to_string(),
801                head_sha_before: Some("abc123".to_string()),
802                head_sha_after: Some("def456".to_string()),
803                dirty_before: Some(false),
804                dirty_after: Some(true),
805            },
806            PlanRef {
807                path: "plan.json".into(),
808                sha256: Some("hash".to_string()),
809            },
810        );
811        apply.summary.attempted = 2;
812        apply.summary.applied = 2;
813        apply.summary.blocked = 0;
814        apply.summary.failed = 0;
815        apply.summary.files_modified = 2;
816
817        let report = build_apply_report(&apply, fixture_tool());
818
819        assert_eq!(report.verdict.status, ReportStatus::Pass);
820        assert_eq!(report.verdict.counts.info, 2);
821    }
822
823    #[test]
824    fn test_apply_report_auto_commit_info() {
825        let mut apply = BuildfixApply::new(
826            fixture_tool(),
827            ApplyRepoInfo {
828                root: ".".to_string(),
829                head_sha_before: None,
830                head_sha_after: None,
831                dirty_before: None,
832                dirty_after: None,
833            },
834            PlanRef {
835                path: "plan.json".to_string(),
836                sha256: None,
837            },
838        );
839        apply.summary.applied = 1;
840        apply.auto_commit = Some(AutoCommitInfo {
841            enabled: true,
842            attempted: true,
843            committed: true,
844            commit_sha: Some("abc123def".to_string()),
845            message: Some("chore: apply buildfix plan".to_string()),
846            skip_reason: None,
847        });
848
849        let report = build_apply_report(&apply, fixture_tool());
850
851        let data = report.data.as_ref().unwrap();
852        let apply_data = &data["buildfix"]["apply"];
853        assert_eq!(apply_data["auto_commit"]["enabled"], true);
854        assert_eq!(apply_data["auto_commit"]["attempted"], true);
855        assert_eq!(apply_data["auto_commit"]["committed"], true);
856        assert_eq!(apply_data["auto_commit"]["commit_sha"], "abc123def");
857    }
858
859    #[test]
860    fn test_apply_report_auto_commit_disabled() {
861        let mut apply = BuildfixApply::new(
862            fixture_tool(),
863            ApplyRepoInfo {
864                root: ".".to_string(),
865                head_sha_before: None,
866                head_sha_after: None,
867                dirty_before: None,
868                dirty_after: None,
869            },
870            PlanRef {
871                path: "plan.json".to_string(),
872                sha256: None,
873            },
874        );
875        apply.summary.applied = 1;
876        apply.auto_commit = Some(AutoCommitInfo {
877            enabled: false,
878            attempted: false,
879            committed: false,
880            commit_sha: None,
881            message: None,
882            skip_reason: Some("dirty working tree".to_string()),
883        });
884
885        let report = build_apply_report(&apply, fixture_tool());
886
887        let data = report.data.as_ref().unwrap();
888        let apply_data = &data["buildfix"]["apply"];
889        assert_eq!(apply_data["auto_commit"]["enabled"], false);
890        assert_eq!(
891            apply_data["auto_commit"]["skip_reason"],
892            "dirty working tree"
893        );
894    }
895
896    #[test]
897    fn test_apply_report_git_head_sha_tracking() {
898        let apply = BuildfixApply::new(
899            fixture_tool(),
900            ApplyRepoInfo {
901                root: ".".to_string(),
902                head_sha_before: Some("before_sha".to_string()),
903                head_sha_after: Some("after_sha".to_string()),
904                dirty_before: Some(false),
905                dirty_after: Some(false),
906            },
907            PlanRef {
908                path: "plan.json".to_string(),
909                sha256: None,
910            },
911        );
912
913        let report = build_apply_report(&apply, fixture_tool());
914
915        assert_eq!(report.run.git_head_sha, Some("after_sha".to_string()));
916    }
917
918    #[test]
919    fn test_plan_report_git_head_sha_tracking() {
920        let plan = BuildfixPlan::new(
921            fixture_tool(),
922            buildfix_types::plan::RepoInfo {
923                root: ".".to_string(),
924                head_sha: Some("test_sha".to_string()),
925                dirty: Some(false),
926            },
927            PlanPolicy::default(),
928        );
929
930        let report = build_plan_report(&plan, fixture_tool(), &[]);
931
932        assert_eq!(report.run.git_head_sha, Some("test_sha".to_string()));
933    }
934
935    #[test]
936    fn test_plan_report_artifacts_present() {
937        let plan = BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default());
938        let report = build_plan_report(&plan, fixture_tool(), &[]);
939
940        assert!(report.artifacts.is_some());
941        let artifacts = report.artifacts.as_ref().unwrap();
942        assert_eq!(artifacts.plan, Some("plan.json".to_string()));
943        assert_eq!(artifacts.patch, Some("patch.diff".to_string()));
944        assert_eq!(artifacts.comment, Some("comment.md".to_string()));
945        assert!(artifacts.apply.is_none());
946    }
947
948    #[test]
949    fn test_apply_report_artifacts_present() {
950        let apply = BuildfixApply::new(
951            fixture_tool(),
952            ApplyRepoInfo {
953                root: ".".to_string(),
954                head_sha_before: None,
955                head_sha_after: None,
956                dirty_before: None,
957                dirty_after: None,
958            },
959            PlanRef {
960                path: "plan.json".to_string(),
961                sha256: None,
962            },
963        );
964        let report = build_apply_report(&apply, fixture_tool());
965
966        assert!(report.artifacts.is_some());
967        let artifacts = report.artifacts.as_ref().unwrap();
968        assert_eq!(artifacts.plan, Some("plan.json".to_string()));
969        assert_eq!(artifacts.apply, Some("apply.json".to_string()));
970        assert_eq!(artifacts.patch, Some("patch.diff".to_string()));
971        assert!(artifacts.comment.is_none());
972    }
973
974    #[test]
975    fn test_plan_report_capabilities_partial_flag() {
976        let receipts = vec![
977            LoadedReceipt {
978                path: "artifacts/ok/report.json".into(),
979                sensor_id: "ok".to_string(),
980                receipt: Ok(ReceiptEnvelope {
981                    schema: "sensor.report.v1".to_string(),
982                    tool: fixture_tool(),
983                    run: RunInfo {
984                        started_at: Some(Utc::now()),
985                        ended_at: Some(Utc::now()),
986                        git_head_sha: None,
987                    },
988                    verdict: Verdict::default(),
989                    findings: vec![],
990                    capabilities: None,
991                    data: None,
992                }),
993            },
994            LoadedReceipt {
995                path: "artifacts/fail/report.json".into(),
996                sensor_id: "fail".to_string(),
997                receipt: Err(ReceiptLoadError::Io {
998                    message: "boom".to_string(),
999                }),
1000            },
1001        ];
1002
1003        let caps = build_report_capabilities(&receipts);
1004        assert!(caps.partial);
1005        assert_eq!(caps.inputs_available.len(), 1);
1006        assert_eq!(caps.inputs_failed.len(), 1);
1007    }
1008
1009    #[test]
1010    fn test_plan_report_inputs_sorted() {
1011        let receipts = vec![
1012            LoadedReceipt {
1013                path: "artifacts/z_report.json".into(),
1014                sensor_id: "z".to_string(),
1015                receipt: Ok(ReceiptEnvelope {
1016                    schema: "sensor.report.v1".to_string(),
1017                    tool: fixture_tool(),
1018                    run: RunInfo {
1019                        started_at: Some(Utc::now()),
1020                        ended_at: Some(Utc::now()),
1021                        git_head_sha: None,
1022                    },
1023                    verdict: Verdict::default(),
1024                    findings: vec![],
1025                    capabilities: None,
1026                    data: None,
1027                }),
1028            },
1029            LoadedReceipt {
1030                path: "artifacts/a_report.json".into(),
1031                sensor_id: "a".to_string(),
1032                receipt: Ok(ReceiptEnvelope {
1033                    schema: "sensor.report.v1".to_string(),
1034                    tool: fixture_tool(),
1035                    run: RunInfo {
1036                        started_at: Some(Utc::now()),
1037                        ended_at: Some(Utc::now()),
1038                        git_head_sha: None,
1039                    },
1040                    verdict: Verdict::default(),
1041                    findings: vec![],
1042                    capabilities: None,
1043                    data: None,
1044                }),
1045            },
1046        ];
1047
1048        let caps = build_report_capabilities(&receipts);
1049        assert_eq!(
1050            caps.inputs_available,
1051            vec![
1052                "artifacts/a_report.json".to_string(),
1053                "artifacts/z_report.json".to_string(),
1054            ]
1055        );
1056    }
1057
1058    #[test]
1059    fn test_plan_report_failed_inputs_sorted() {
1060        let receipts = vec![
1061            LoadedReceipt {
1062                path: "artifacts/z_fail.json".into(),
1063                sensor_id: "z".to_string(),
1064                receipt: Err(ReceiptLoadError::Io {
1065                    message: "error".to_string(),
1066                }),
1067            },
1068            LoadedReceipt {
1069                path: "artifacts/a_fail.json".into(),
1070                sensor_id: "a".to_string(),
1071                receipt: Err(ReceiptLoadError::Io {
1072                    message: "error".to_string(),
1073                }),
1074            },
1075        ];
1076
1077        let caps = build_report_capabilities(&receipts);
1078        assert_eq!(caps.inputs_failed.len(), 2);
1079        assert_eq!(caps.inputs_failed[0].path, "artifacts/a_fail.json");
1080        assert_eq!(caps.inputs_failed[1].path, "artifacts/z_fail.json");
1081    }
1082
1083    #[test]
1084    fn test_apply_report_data_structure() {
1085        let mut apply = BuildfixApply::new(
1086            fixture_tool(),
1087            ApplyRepoInfo {
1088                root: ".".to_string(),
1089                head_sha_before: None,
1090                head_sha_after: None,
1091                dirty_before: None,
1092                dirty_after: None,
1093            },
1094            PlanRef {
1095                path: "plan.json".to_string(),
1096                sha256: None,
1097            },
1098        );
1099        apply.summary.attempted = 10;
1100        apply.summary.applied = 7;
1101        apply.summary.blocked = 2;
1102        apply.summary.failed = 1;
1103        apply.summary.files_modified = 5;
1104
1105        let report = build_apply_report(&apply, fixture_tool());
1106
1107        let data = report.data.as_ref().unwrap();
1108        let apply_data = &data["buildfix"]["apply"];
1109        assert_eq!(apply_data["attempted"], 10);
1110        assert_eq!(apply_data["applied"], 7);
1111        assert_eq!(apply_data["blocked"], 2);
1112        assert_eq!(apply_data["failed"], 1);
1113        assert_eq!(apply_data["files_modified"], 5);
1114        assert_eq!(apply_data["apply_performed"], true);
1115    }
1116
1117    #[test]
1118    fn test_apply_report_no_apply_performed() {
1119        let mut apply = BuildfixApply::new(
1120            fixture_tool(),
1121            ApplyRepoInfo {
1122                root: ".".to_string(),
1123                head_sha_before: None,
1124                head_sha_after: None,
1125                dirty_before: None,
1126                dirty_after: None,
1127            },
1128            PlanRef {
1129                path: "plan.json".to_string(),
1130                sha256: None,
1131            },
1132        );
1133        apply.summary.attempted = 0;
1134        apply.summary.applied = 0;
1135
1136        let report = build_apply_report(&apply, fixture_tool());
1137
1138        let data = report.data.as_ref().unwrap();
1139        let apply_data = &data["buildfix"]["apply"];
1140        assert_eq!(apply_data["apply_performed"], false);
1141    }
1142
1143    #[test]
1144    fn test_plan_report_findings_fingerprint_format() {
1145        let receipts = vec![LoadedReceipt {
1146            path: "artifacts/test/report.json".into(),
1147            sensor_id: "test".to_string(),
1148            receipt: Err(ReceiptLoadError::Io {
1149                message: "file not found".to_string(),
1150            }),
1151        }];
1152
1153        let report = build_plan_report(
1154            &BuildfixPlan::new(fixture_tool(), default_repo(), PlanPolicy::default()),
1155            fixture_tool(),
1156            &receipts,
1157        );
1158
1159        assert!(!report.findings.is_empty());
1160        let finding = &report.findings[0];
1161        assert_eq!(finding.code, "receipt_load_failed");
1162        assert!(finding.fingerprint.is_some());
1163        let fp = finding.fingerprint.as_ref().unwrap();
1164        assert!(fp.starts_with("inputs/receipt_load_failed/"));
1165    }
1166}