1use 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}