1use buildfix_types::apply::{ApplyStatus, BuildfixApply};
4use buildfix_types::ops::SafetyClass;
5use buildfix_types::plan::BuildfixPlan;
6
7pub fn render_plan_md(plan: &BuildfixPlan) -> String {
8 let mut out = String::new();
9 out.push_str("# buildfix plan\n\n");
10 out.push_str(&format!(
11 "- Ops: {} (blocked {})\n",
12 plan.summary.ops_total, plan.summary.ops_blocked
13 ));
14 out.push_str(&format!(
15 "- Files touched: {}\n",
16 plan.summary.files_touched
17 ));
18 if let Some(bytes) = plan.summary.patch_bytes {
19 out.push_str(&format!("- Patch bytes: {}\n", bytes));
20 }
21 if let Some(sc) = &plan.summary.safety_counts {
22 out.push_str(&format!(
23 "- Safety: {} safe, {} guarded, {} unsafe\n",
24 sc.safe, sc.guarded, sc.unsafe_count
25 ));
26 }
27 out.push_str(&format!("- Inputs: {}\n\n", plan.inputs.len()));
28
29 out.push_str("## Ops\n\n");
30 if plan.ops.is_empty() {
31 out.push_str("_No ops planned._\n");
32 return out;
33 }
34
35 for (i, op) in plan.ops.iter().enumerate() {
36 out.push_str(&format!("### {}. {}\n\n", i + 1, op.id));
37 out.push_str(&format!("- Safety: `{}`\n", safety_label(op.safety)));
38 out.push_str(&format!("- Blocked: `{}`\n", op.blocked));
39 out.push_str(&format!("- Target: `{}`\n", op.target.path));
40 out.push_str(&format!(
41 "- Kind: `{}`\n",
42 match &op.kind {
43 buildfix_types::ops::OpKind::TomlSet { .. } => "toml_set",
44 buildfix_types::ops::OpKind::TomlRemove { .. } => "toml_remove",
45 buildfix_types::ops::OpKind::JsonSet { .. } => "json_set",
46 buildfix_types::ops::OpKind::JsonRemove { .. } => "json_remove",
47 buildfix_types::ops::OpKind::YamlSet { .. } => "yaml_set",
48 buildfix_types::ops::OpKind::YamlRemove { .. } => "yaml_remove",
49 buildfix_types::ops::OpKind::TomlTransform { rule_id, .. } => rule_id,
50 buildfix_types::ops::OpKind::TextReplaceAnchored { .. } => "text_replace_anchored",
51 }
52 ));
53 if let Some(reason) = &op.blocked_reason {
54 out.push_str(&format!("- Blocked reason: {}\n", reason));
55 }
56 if let Some(desc) = &op.rationale.description {
57 out.push_str(&format!("\n{}\n", desc));
58 }
59
60 if !op.params_required.is_empty() {
61 out.push_str(&format!(
62 "- Params required: {}\n",
63 op.params_required.join(", ")
64 ));
65 }
66
67 if !op.rationale.findings.is_empty() {
68 out.push_str("\n**Findings**\n\n");
69 for f in &op.rationale.findings {
70 let check = f.check_id.clone().unwrap_or_else(|| "-".to_string());
71 let loc = f
72 .path
73 .as_ref()
74 .map(|p| format!("{}:{}", p, f.line.unwrap_or(0)))
75 .unwrap_or_else(|| "-".to_string());
76 out.push_str(&format!(
77 "- `{}/{}` `{}` at {}\n",
78 f.source, check, f.code, loc
79 ));
80 }
81 }
82
83 out.push('\n');
84 }
85
86 out
87}
88
89pub fn render_apply_md(apply: &BuildfixApply) -> String {
90 let mut out = String::new();
91 out.push_str("# buildfix apply\n\n");
92 out.push_str(&format!(
93 "- Attempted: {}\n- Applied: {}\n- Blocked: {}\n- Failed: {}\n- Files modified: {}\n\n",
94 apply.summary.attempted,
95 apply.summary.applied,
96 apply.summary.blocked,
97 apply.summary.failed,
98 apply.summary.files_modified
99 ));
100
101 out.push_str("## Results\n\n");
102 if apply.results.is_empty() {
103 out.push_str("_No results._\n");
104 return out;
105 }
106
107 for (i, r) in apply.results.iter().enumerate() {
108 out.push_str(&format!("### {}. {}\n\n", i + 1, r.op_id));
109 out.push_str(&format!("- Status: `{}`\n", status_label(&r.status)));
110 if let Some(msg) = &r.message {
111 out.push_str(&format!("- Message: {}\n", msg));
112 }
113 if let Some(reason) = &r.blocked_reason {
114 out.push_str(&format!("- Blocked reason: {}\n", reason));
115 }
116 if !r.files.is_empty() {
117 out.push_str("\n**Files changed**\n\n");
118 for fc in &r.files {
119 let before = fc.sha256_before.as_deref().unwrap_or("-");
120 let after = fc.sha256_after.as_deref().unwrap_or("-");
121 out.push_str(&format!("- `{}` {} → {}\n", fc.path, before, after));
122 }
123 }
124 out.push('\n');
125 }
126
127 out
128}
129
130pub fn render_comment_md(plan: &BuildfixPlan) -> String {
132 let mut out = String::new();
133
134 let ops_applicable = plan
135 .summary
136 .ops_total
137 .saturating_sub(plan.summary.ops_blocked);
138 let fix_available = ops_applicable > 0;
139
140 if fix_available {
141 out.push_str("**buildfix**: fix available\n\n");
142 } else if plan.ops.is_empty() {
143 out.push_str("**buildfix**: no fixes needed\n\n");
144 } else {
145 out.push_str("**buildfix**: all ops blocked\n\n");
146 }
147
148 if let Some(sc) = &plan.summary.safety_counts {
149 out.push_str("| Safety | Count |\n|--------|-------|\n");
150 if sc.safe > 0 {
151 out.push_str(&format!("| safe | {} |\n", sc.safe));
152 }
153 if sc.guarded > 0 {
154 out.push_str(&format!("| guarded | {} |\n", sc.guarded));
155 }
156 if sc.unsafe_count > 0 {
157 out.push_str(&format!("| unsafe | {} |\n", sc.unsafe_count));
158 }
159 out.push('\n');
160 }
161
162 let tokens: std::collections::BTreeSet<&str> = plan
163 .ops
164 .iter()
165 .filter_map(|o| o.blocked_reason_token.as_deref())
166 .collect();
167 if !tokens.is_empty() {
168 out.push_str("**Blocked reasons**: ");
169 let top: Vec<&str> = tokens.into_iter().take(5).collect();
170 out.push_str(&top.join(", "));
171 out.push_str("\n\n");
172 }
173
174 out.push_str("Artifacts: [plan.md](plan.md) · [patch.diff](patch.diff)\n");
175
176 out
177}
178
179fn safety_label(s: SafetyClass) -> &'static str {
180 match s {
181 SafetyClass::Safe => "safe",
182 SafetyClass::Guarded => "guarded",
183 SafetyClass::Unsafe => "unsafe",
184 }
185}
186
187fn status_label(s: &ApplyStatus) -> &'static str {
188 match s {
189 ApplyStatus::Applied => "applied",
190 ApplyStatus::Blocked => "blocked",
191 ApplyStatus::Failed => "failed",
192 ApplyStatus::Skipped => "skipped",
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use buildfix_types::apply::{
200 ApplyFile, ApplyRepoInfo, ApplyResult, ApplyStatus, ApplySummary, BuildfixApply, PlanRef,
201 };
202 use buildfix_types::ops::{OpKind, OpTarget};
203 use buildfix_types::plan::{
204 FindingRef, PlanOp, PlanPolicy, PlanSummary, Rationale, RepoInfo, SafetyCounts,
205 };
206 use buildfix_types::receipt::ToolInfo;
207
208 fn tool() -> ToolInfo {
209 ToolInfo {
210 name: "buildfix".into(),
211 version: Some("0.0.0".into()),
212 repo: None,
213 commit: None,
214 }
215 }
216
217 fn make_plan(ops: Vec<PlanOp>, safety_counts: Option<SafetyCounts>) -> BuildfixPlan {
218 let mut plan = BuildfixPlan::new(
219 tool(),
220 RepoInfo {
221 root: ".".into(),
222 head_sha: None,
223 dirty: None,
224 },
225 PlanPolicy::default(),
226 );
227 let ops_total = ops.len() as u64;
228 let ops_blocked = ops.iter().filter(|o| o.blocked).count() as u64;
229 plan.summary = PlanSummary {
230 ops_total,
231 ops_blocked,
232 files_touched: 1,
233 patch_bytes: Some(0),
234 safety_counts,
235 };
236 plan.ops = ops;
237 plan
238 }
239
240 fn make_op(safety: SafetyClass, blocked: bool, token: Option<&str>) -> PlanOp {
241 PlanOp {
242 id: "test-op".into(),
243 safety,
244 blocked,
245 blocked_reason: if blocked {
246 Some("blocked".into())
247 } else {
248 None
249 },
250 blocked_reason_token: token.map(|s| s.to_string()),
251 target: OpTarget {
252 path: "Cargo.toml".into(),
253 },
254 kind: OpKind::TomlSet {
255 toml_path: vec!["workspace".into(), "resolver".into()],
256 value: serde_json::json!("2"),
257 },
258 rationale: Rationale {
259 fix_key: "test".into(),
260 description: None,
261 findings: vec![],
262 },
263 params_required: vec![],
264 preview: None,
265 }
266 }
267
268 #[test]
269 fn comment_md_no_ops() {
270 let plan = make_plan(vec![], None);
271 let md = render_comment_md(&plan);
272 assert!(md.contains("no fixes needed"));
273 assert!(md.contains("plan.md"));
274 assert!(md.contains("patch.diff"));
275 }
276
277 #[test]
278 fn comment_md_with_ops() {
279 let plan = make_plan(
280 vec![make_op(SafetyClass::Safe, false, None)],
281 Some(SafetyCounts {
282 safe: 1,
283 guarded: 0,
284 unsafe_count: 0,
285 }),
286 );
287 let md = render_comment_md(&plan);
288 assert!(md.contains("fix available"));
289 assert!(md.contains("| safe | 1 |"));
290 }
291
292 #[test]
293 fn comment_md_all_blocked() {
294 let plan = make_plan(
295 vec![make_op(SafetyClass::Safe, true, Some("denylist"))],
296 Some(SafetyCounts {
297 safe: 1,
298 guarded: 0,
299 unsafe_count: 0,
300 }),
301 );
302 let md = render_comment_md(&plan);
303 assert!(md.contains("all ops blocked"));
304 assert!(md.contains("denylist"));
305 }
306
307 #[test]
308 fn comment_md_artifact_links() {
309 let plan = make_plan(vec![], None);
310 let md = render_comment_md(&plan);
311 assert!(md.contains("[plan.md](plan.md)"));
312 assert!(md.contains("[patch.diff](patch.diff)"));
313 }
314
315 #[test]
316 fn plan_md_includes_details_and_findings() {
317 let mut op = make_op(SafetyClass::Guarded, true, Some("denylist"));
318 op.blocked_reason = Some("denied by policy".to_string());
319 op.rationale.description = Some("Normalize resolver".to_string());
320 op.params_required = vec!["version".to_string()];
321 op.rationale.findings.push(FindingRef {
322 source: "builddiag".to_string(),
323 check_id: Some("workspace.resolver_v2".to_string()),
324 code: "RESOLVER".to_string(),
325 path: Some("Cargo.toml".to_string()),
326 line: Some(1),
327 fingerprint: None,
328 });
329
330 let plan = make_plan(
331 vec![op],
332 Some(SafetyCounts {
333 safe: 0,
334 guarded: 1,
335 unsafe_count: 0,
336 }),
337 );
338 let md = render_plan_md(&plan);
339 assert!(md.contains("# buildfix plan"));
340 assert!(md.contains("Ops: 1 (blocked 1)"));
341 assert!(md.contains("Safety: `guarded`"));
342 assert!(md.contains("Blocked: `true`"));
343 assert!(md.contains("Blocked reason: denied by policy"));
344 assert!(md.contains("Normalize resolver"));
345 assert!(md.contains("Params required: version"));
346 assert!(md.contains("Findings"));
347 assert!(md.contains("builddiag/workspace.resolver_v2"));
348 assert!(md.contains("Cargo.toml:1"));
349 }
350
351 #[test]
352 fn plan_md_handles_no_ops() {
353 let plan = make_plan(vec![], None);
354 let md = render_plan_md(&plan);
355 assert!(md.contains("_No ops planned._"));
356 }
357
358 #[test]
359 fn apply_md_includes_results_and_files() {
360 let mut apply = BuildfixApply::new(
361 tool(),
362 ApplyRepoInfo {
363 root: ".".into(),
364 head_sha_before: None,
365 head_sha_after: None,
366 dirty_before: None,
367 dirty_after: None,
368 },
369 PlanRef {
370 path: "plan.json".into(),
371 sha256: None,
372 },
373 );
374 apply.summary = ApplySummary {
375 attempted: 1,
376 applied: 1,
377 blocked: 0,
378 failed: 0,
379 files_modified: 1,
380 };
381 apply.results.push(ApplyResult {
382 op_id: "op1".to_string(),
383 status: ApplyStatus::Applied,
384 message: Some("ok".to_string()),
385 blocked_reason: None,
386 blocked_reason_token: None,
387 files: vec![ApplyFile {
388 path: "Cargo.toml".to_string(),
389 sha256_before: Some("before".to_string()),
390 sha256_after: Some("after".to_string()),
391 backup_path: None,
392 }],
393 });
394
395 let md = render_apply_md(&apply);
396 assert!(md.contains("# buildfix apply"));
397 assert!(md.contains("Attempted: 1"));
398 assert!(md.contains("Applied: 1"));
399 assert!(md.contains("Status: `applied`"));
400 assert!(md.contains("Message: ok"));
401 assert!(md.contains("Files changed"));
402 assert!(md.contains("Cargo.toml"));
403 assert!(md.contains("before → after"));
404 }
405
406 #[test]
407 fn apply_md_handles_no_results() {
408 let apply = BuildfixApply::new(
409 tool(),
410 ApplyRepoInfo {
411 root: ".".into(),
412 head_sha_before: None,
413 head_sha_after: None,
414 dirty_before: None,
415 dirty_after: None,
416 },
417 PlanRef {
418 path: "plan.json".into(),
419 sha256: None,
420 },
421 );
422 let md = render_apply_md(&apply);
423 assert!(md.contains("_No results._"));
424 }
425
426 #[test]
427 fn plan_md_renders_remove_and_transform_kinds() {
428 let mut remove_op = make_op(SafetyClass::Safe, false, None);
429 remove_op.kind = OpKind::TomlRemove {
430 toml_path: vec!["package".to_string(), "name".to_string()],
431 };
432 remove_op.id = "remove".to_string();
433
434 let mut transform_op = make_op(SafetyClass::Safe, false, None);
435 transform_op.kind = OpKind::TomlTransform {
436 rule_id: "custom_rule".to_string(),
437 args: None,
438 };
439 transform_op.id = "transform".to_string();
440
441 let mut json_set_op = make_op(SafetyClass::Safe, false, None);
442 json_set_op.kind = OpKind::JsonSet {
443 json_path: vec!["tool".to_string(), "version".to_string()],
444 value: serde_json::json!("1.0.0"),
445 };
446 json_set_op.id = "json_set".to_string();
447
448 let mut yaml_remove_op = make_op(SafetyClass::Safe, false, None);
449 yaml_remove_op.kind = OpKind::YamlRemove {
450 yaml_path: vec!["tool".to_string(), "name".to_string()],
451 };
452 yaml_remove_op.id = "yaml_remove".to_string();
453
454 let plan = make_plan(
455 vec![remove_op, transform_op, json_set_op, yaml_remove_op],
456 None,
457 );
458 let md = render_plan_md(&plan);
459 assert!(md.contains("Kind: `toml_remove`"));
460 assert!(md.contains("Kind: `custom_rule`"));
461 assert!(md.contains("Kind: `json_set`"));
462 assert!(md.contains("Kind: `yaml_remove`"));
463 }
464
465 #[test]
466 fn apply_md_renders_all_statuses_and_reasons() {
467 let mut apply = BuildfixApply::new(
468 tool(),
469 ApplyRepoInfo {
470 root: ".".into(),
471 head_sha_before: None,
472 head_sha_after: None,
473 dirty_before: None,
474 dirty_after: None,
475 },
476 PlanRef {
477 path: "plan.json".into(),
478 sha256: None,
479 },
480 );
481 apply.summary = ApplySummary {
482 attempted: 4,
483 applied: 1,
484 blocked: 1,
485 failed: 1,
486 files_modified: 1,
487 };
488 apply.results.push(ApplyResult {
489 op_id: "applied".to_string(),
490 status: ApplyStatus::Applied,
491 message: None,
492 blocked_reason: None,
493 blocked_reason_token: None,
494 files: vec![],
495 });
496 apply.results.push(ApplyResult {
497 op_id: "blocked".to_string(),
498 status: ApplyStatus::Blocked,
499 message: Some("blocked".to_string()),
500 blocked_reason: Some("reason".to_string()),
501 blocked_reason_token: None,
502 files: vec![],
503 });
504 apply.results.push(ApplyResult {
505 op_id: "failed".to_string(),
506 status: ApplyStatus::Failed,
507 message: Some("failed".to_string()),
508 blocked_reason: None,
509 blocked_reason_token: None,
510 files: vec![],
511 });
512 apply.results.push(ApplyResult {
513 op_id: "skipped".to_string(),
514 status: ApplyStatus::Skipped,
515 message: Some("skipped".to_string()),
516 blocked_reason: None,
517 blocked_reason_token: None,
518 files: vec![],
519 });
520
521 let md = render_apply_md(&apply);
522 assert!(md.contains("Status: `applied`"));
523 assert!(md.contains("Status: `blocked`"));
524 assert!(md.contains("Status: `failed`"));
525 assert!(md.contains("Status: `skipped`"));
526 assert!(md.contains("Blocked reason: reason"));
527 }
528}