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, PlanInput, 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
529 #[test]
530 fn plan_md_various_configurations() {
531 let mut plan = make_plan(vec![], None);
532 plan.summary.files_touched = 5;
533 plan.summary.patch_bytes = Some(1024);
534 plan.inputs = vec![PlanInput {
535 path: "artifacts/builddiag/report.json".into(),
536 schema: None,
537 tool: None,
538 }];
539
540 let md = render_plan_md(&plan);
541 assert!(md.contains("Files touched: 5"));
542 assert!(md.contains("Patch bytes: 1024"));
543 assert!(md.contains("Inputs: 1"));
544 }
545
546 #[test]
547 fn plan_md_empty_ops() {
548 let plan = make_plan(vec![], None);
549 let md = render_plan_md(&plan);
550 assert!(md.contains("_No ops planned._"));
551 assert!(!md.contains("### 1."));
552 }
553
554 #[test]
555 fn plan_md_many_ops() {
556 let ops: Vec<PlanOp> = (0..20)
557 .map(|i| {
558 let mut op = make_op(SafetyClass::Safe, false, None);
559 op.id = format!("op-{}", i);
560 op
561 })
562 .collect();
563 let plan = make_plan(ops, None);
564 let md = render_plan_md(&plan);
565 assert!(md.contains("Ops: 20 (blocked 0)"));
566 for i in 1..=20 {
567 assert!(md.contains(&format!("### {}. op-{}", i, i - 1)));
568 }
569 }
570
571 #[test]
572 fn plan_md_all_safety_classes() {
573 let safe_op = {
574 let mut op = make_op(SafetyClass::Safe, false, None);
575 op.id = "safe-op".to_string();
576 op
577 };
578 let guarded_op = {
579 let mut op = make_op(SafetyClass::Guarded, false, None);
580 op.id = "guarded-op".to_string();
581 op
582 };
583 let unsafe_op = {
584 let mut op = make_op(SafetyClass::Unsafe, true, Some("needs_param"));
585 op.id = "unsafe-op".to_string();
586 op
587 };
588
589 let plan = make_plan(
590 vec![safe_op, guarded_op, unsafe_op],
591 Some(SafetyCounts {
592 safe: 1,
593 guarded: 1,
594 unsafe_count: 1,
595 }),
596 );
597 let md = render_plan_md(&plan);
598 assert!(md.contains("Safety: `safe`"));
599 assert!(md.contains("Safety: `guarded`"));
600 assert!(md.contains("Safety: `unsafe`"));
601 }
602
603 #[test]
604 fn plan_md_blocked_ops() {
605 let mut blocked_op = make_op(SafetyClass::Safe, true, Some("policy_deny"));
606 blocked_op.blocked_reason = Some("Operation denied by policy".to_string());
607 blocked_op.rationale.findings.push(FindingRef {
608 source: "sensor".to_string(),
609 check_id: Some("check1".to_string()),
610 code: "CODE1".to_string(),
611 path: Some("file.toml".to_string()),
612 line: Some(10),
613 fingerprint: None,
614 });
615
616 let plan = make_plan(vec![blocked_op], None);
617 let md = render_plan_md(&plan);
618 assert!(md.contains("Blocked: `true`"));
619 assert!(md.contains("Blocked reason: Operation denied by policy"));
620 }
621
622 #[test]
623 fn comment_md_multiple_blocked_reasons() {
624 let tokens = vec![
625 "reason1", "reason2", "reason3", "reason4", "reason5", "reason6",
626 ];
627 let mut ops = vec![];
628 for token in &tokens {
629 let mut op = make_op(SafetyClass::Safe, true, Some(*token));
630 op.blocked_reason_token = Some(token.to_string());
631 ops.push(op);
632 }
633
634 let mut plan = make_plan(
635 ops,
636 Some(SafetyCounts {
637 safe: 6,
638 guarded: 0,
639 unsafe_count: 0,
640 }),
641 );
642 plan.summary.ops_blocked = 6;
643
644 let md = render_comment_md(&plan);
645 assert!(md.contains("all ops blocked"));
646 assert!(md.contains("**Blocked reasons**"));
647 assert!(md.contains("reason1"));
648 assert!(md.contains("reason2"));
649 }
650
651 #[test]
652 fn plan_md_all_op_kinds() {
653 let ops = vec![
654 {
655 let mut op = make_op(SafetyClass::Safe, false, None);
656 op.kind = OpKind::TomlSet {
657 toml_path: vec!["a".into(), "b".into()],
658 value: serde_json::json!("val"),
659 };
660 op.id = "toml_set".to_string();
661 op
662 },
663 {
664 let mut op = make_op(SafetyClass::Safe, false, None);
665 op.kind = OpKind::JsonSet {
666 json_path: vec!["x".into(), "y".into()],
667 value: serde_json::json!(123),
668 };
669 op.id = "json_set".to_string();
670 op
671 },
672 {
673 let mut op = make_op(SafetyClass::Safe, false, None);
674 op.kind = OpKind::YamlSet {
675 yaml_path: vec!["p".into(), "q".into()],
676 value: serde_json::json!(true),
677 };
678 op.id = "yaml_set".to_string();
679 op
680 },
681 {
682 let mut op = make_op(SafetyClass::Safe, false, None);
683 op.kind = OpKind::JsonRemove {
684 json_path: vec!["old".into()],
685 };
686 op.id = "json_remove".to_string();
687 op
688 },
689 {
690 let mut op = make_op(SafetyClass::Safe, false, None);
691 op.kind = OpKind::TextReplaceAnchored {
692 find: "old".to_string(),
693 replace: "new".to_string(),
694 anchor_before: vec![],
695 anchor_after: vec![],
696 max_replacements: None,
697 };
698 op.id = "text_replace".to_string();
699 op
700 },
701 ];
702
703 let plan = make_plan(ops, None);
704 let md = render_plan_md(&plan);
705 assert!(md.contains("Kind: `toml_set`"));
706 assert!(md.contains("Kind: `json_set`"));
707 assert!(md.contains("Kind: `yaml_set`"));
708 assert!(md.contains("Kind: `json_remove`"));
709 assert!(md.contains("Kind: `text_replace_anchored`"));
710 }
711
712 #[test]
713 fn comment_md_empty_inputs() {
714 let plan = make_plan(vec![], None);
715 let md = render_comment_md(&plan);
716 assert!(md.contains("no fixes needed"));
717 assert!(md.contains("Artifacts:"));
718 }
719
720 #[test]
721 fn comment_md_mixed_safety() {
722 let plan = make_plan(
723 vec![],
724 Some(SafetyCounts {
725 safe: 2,
726 guarded: 3,
727 unsafe_count: 1,
728 }),
729 );
730 let md = render_comment_md(&plan);
731 assert!(md.contains("| safe | 2 |"));
732 assert!(md.contains("| guarded | 3 |"));
733 assert!(md.contains("| unsafe | 1 |"));
734 }
735
736 #[test]
737 fn apply_md_multiple_files() {
738 let mut apply = BuildfixApply::new(
739 tool(),
740 ApplyRepoInfo {
741 root: ".".into(),
742 head_sha_before: None,
743 head_sha_after: None,
744 dirty_before: None,
745 dirty_after: None,
746 },
747 PlanRef {
748 path: "plan.json".into(),
749 sha256: None,
750 },
751 );
752 apply.summary = ApplySummary {
753 attempted: 1,
754 applied: 1,
755 blocked: 0,
756 failed: 0,
757 files_modified: 2,
758 };
759 apply.results.push(ApplyResult {
760 op_id: "multi-file".to_string(),
761 status: ApplyStatus::Applied,
762 message: None,
763 blocked_reason: None,
764 blocked_reason_token: None,
765 files: vec![
766 ApplyFile {
767 path: "Cargo.toml".to_string(),
768 sha256_before: Some("sha1".to_string()),
769 sha256_after: Some("sha2".to_string()),
770 backup_path: None,
771 },
772 ApplyFile {
773 path: "src/main.rs".to_string(),
774 sha256_before: Some("sha3".to_string()),
775 sha256_after: Some("sha4".to_string()),
776 backup_path: None,
777 },
778 ],
779 });
780
781 let md = render_apply_md(&apply);
782 assert!(md.contains("Files modified: 2"));
783 assert!(md.contains("Cargo.toml"));
784 assert!(md.contains("src/main.rs"));
785 }
786
787 #[test]
788 fn apply_md_no_sha256() {
789 let mut apply = BuildfixApply::new(
790 tool(),
791 ApplyRepoInfo {
792 root: ".".into(),
793 head_sha_before: None,
794 head_sha_after: None,
795 dirty_before: None,
796 dirty_after: None,
797 },
798 PlanRef {
799 path: "plan.json".into(),
800 sha256: None,
801 },
802 );
803 apply.summary = ApplySummary {
804 attempted: 1,
805 applied: 1,
806 blocked: 0,
807 failed: 0,
808 files_modified: 1,
809 };
810 apply.results.push(ApplyResult {
811 op_id: "op1".to_string(),
812 status: ApplyStatus::Applied,
813 message: None,
814 blocked_reason: None,
815 blocked_reason_token: None,
816 files: vec![ApplyFile {
817 path: "test.toml".to_string(),
818 sha256_before: None,
819 sha256_after: None,
820 backup_path: None,
821 }],
822 });
823
824 let md = render_apply_md(&apply);
825 assert!(md.contains("test.toml"));
826 assert!(md.contains("- โ -"));
827 }
828
829 #[test]
830 fn plan_md_with_multiple_findings() {
831 let mut op = make_op(SafetyClass::Safe, false, None);
832 op.rationale.findings = vec![
833 FindingRef {
834 source: "sensor1".to_string(),
835 check_id: Some("check1".to_string()),
836 code: "CODE1".to_string(),
837 path: Some("file1.toml".to_string()),
838 line: Some(1),
839 fingerprint: None,
840 },
841 FindingRef {
842 source: "sensor2".to_string(),
843 check_id: Some("check2".to_string()),
844 code: "CODE2".to_string(),
845 path: Some("file2.rs".to_string()),
846 line: Some(42),
847 fingerprint: None,
848 },
849 FindingRef {
850 source: "sensor3".to_string(),
851 check_id: None,
852 code: "CODE3".to_string(),
853 path: None,
854 line: None,
855 fingerprint: None,
856 },
857 ];
858
859 let plan = make_plan(vec![op], None);
860 let md = render_plan_md(&plan);
861 assert!(md.contains("Findings"));
862 assert!(md.contains("sensor1/check1"));
863 assert!(md.contains("file1.toml:1"));
864 assert!(md.contains("sensor2/check2"));
865 assert!(md.contains("file2.rs:42"));
866 assert!(md.contains("sensor3/-"));
867 assert!(md.contains("CODE3"));
868 assert!(md.contains("-"));
869 }
870
871 #[test]
872 fn plan_md_description_only() {
873 let mut op = make_op(SafetyClass::Guarded, false, None);
874 op.rationale.description =
875 Some("This is a test fix that does something useful".to_string());
876 op.rationale.findings = vec![];
877
878 let plan = make_plan(vec![op], None);
879 let md = render_plan_md(&plan);
880 assert!(md.contains("This is a test fix"));
881 }
882
883 #[test]
884 fn plan_md_no_patch_bytes() {
885 let mut plan = make_plan(vec![], None);
886 plan.summary.patch_bytes = None;
887
888 let md = render_plan_md(&plan);
889 assert!(!md.contains("Patch bytes:"));
890 }
891
892 #[test]
893 fn apply_md_failed_with_message() {
894 let mut apply = BuildfixApply::new(
895 tool(),
896 ApplyRepoInfo {
897 root: ".".into(),
898 head_sha_before: None,
899 head_sha_after: None,
900 dirty_before: None,
901 dirty_after: None,
902 },
903 PlanRef {
904 path: "plan.json".into(),
905 sha256: None,
906 },
907 );
908 apply.summary = ApplySummary {
909 attempted: 1,
910 applied: 0,
911 blocked: 0,
912 failed: 1,
913 files_modified: 0,
914 };
915 apply.results.push(ApplyResult {
916 op_id: "failed-op".to_string(),
917 status: ApplyStatus::Failed,
918 message: Some("IO error: cannot write to file".to_string()),
919 blocked_reason: None,
920 blocked_reason_token: None,
921 files: vec![],
922 });
923
924 let md = render_apply_md(&apply);
925 assert!(md.contains("Failed: 1"));
926 assert!(md.contains("Status: `failed`"));
927 assert!(md.contains("Message: IO error"));
928 }
929}