Skip to main content

buildfix_render/
lib.rs

1//! Rendering helpers (markdown) for human-readable artifacts.
2
3use 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
130/// Render a short cockpit-friendly comment summary.
131pub 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}