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