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, 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}