Skip to main content

ci_bill/
md.rs

1//! One-page Markdown teardown.
2//!
3//! Period, repositories, the Actions net total, the five costliest jobs,
4//! every finding with its savings, and a unified diff for the
5//! highest-savings finding this tool knows how to fix. A section with
6//! nothing to show (no jobs, no findings, no fixable finding) is left out.
7
8use std::fmt::Write as _;
9use std::path::Path;
10
11use crate::api::ApiData;
12use crate::display::{display_path, escape_display};
13use crate::findings::Finding;
14use crate::fix_diff;
15use crate::rank::{Bill, WorkflowTotal};
16use crate::reconcile::{self, JobCost};
17use crate::report::{period_line, repositories_line};
18use crate::workflow::Workflow;
19
20const TOP_JOBS: usize = 5;
21
22/// Render the Markdown teardown.
23///
24/// Job and finding tables come from the full ranked workflow list; `--top`
25/// only shortens the text and JSON tables, not this summary. `workflows_dir`
26/// is passed through to the diff so a lint finding raised through a local
27/// composite action still resolves to the caller's `runs-on` line.
28pub fn render(
29    workflows: &[WorkflowTotal],
30    bill: &Bill,
31    findings: Option<&[Finding]>,
32    api: Option<&ApiData>,
33    scanned: &[Workflow],
34    workflows_dir: Option<&Path>,
35) -> String {
36    let recon = reconcile::reconcile(workflows, api, scanned);
37    let mut out = String::new();
38    let _ = writeln!(out, "# ci-bill teardown");
39    let _ = writeln!(out);
40    let _ = writeln!(out, "{}", period_line(bill));
41    let _ = writeln!(out, "{}", repositories_line(bill));
42    let _ = writeln!(out);
43    let _ = writeln!(out, "Total net: {} USD", bill.total_net.format_cents());
44
45    write_top_jobs(&mut out, &recon);
46    write_findings(&mut out, findings, workflows_dir);
47    out
48}
49
50fn write_top_jobs(out: &mut String, recon: &reconcile::Reconciliation) {
51    let jobs = top_jobs(recon);
52    if jobs.is_empty() {
53        return;
54    }
55    let _ = writeln!(out);
56    let _ = writeln!(out, "## Top jobs");
57    let _ = writeln!(out);
58    let _ = writeln!(out, "| Job | File:Line | Amount (USD) |");
59    let _ = writeln!(out, "| --- | --- | --- |");
60    for job in &jobs {
61        let _ = writeln!(
62            out,
63            "| {} | {} | {} |",
64            escape_cell(job_name(job)),
65            escape_cell(&job_location(job)),
66            job.amount.micros.format_cents(),
67        );
68    }
69}
70
71fn write_findings(out: &mut String, findings: Option<&[Finding]>, workflows_dir: Option<&Path>) {
72    let Some(found) = findings.filter(|list| !list.is_empty()) else {
73        return;
74    };
75    let _ = writeln!(out);
76    let _ = writeln!(out, "## Findings");
77    let _ = writeln!(out);
78    let _ = writeln!(out, "| Finding | File:Line | Message | Savings (USD) |");
79    let _ = writeln!(out, "| --- | --- | --- | --- |");
80    for finding in found {
81        let _ = writeln!(
82            out,
83            "| {} | {} | {} | {} |",
84            finding.kind.code(),
85            escape_cell(&format!("{}:{}", display_path(&finding.path), finding.line)),
86            escape_cell(&finding.message),
87            finding.savings.format_cents(),
88        );
89    }
90    if let Some(diff) = fix_diff::render(found, workflows_dir) {
91        let _ = writeln!(out);
92        let _ = writeln!(out, "## Suggested fix");
93        let _ = writeln!(out);
94        let _ = writeln!(out, "```diff");
95        out.push_str(&diff);
96        if !diff.ends_with('\n') {
97            out.push('\n');
98        }
99        let _ = writeln!(out, "```");
100    }
101}
102
103/// The costliest jobs across every reconciled workflow, highest amount
104/// first. Ties break on file, then line, then name, so the order never
105/// depends on hash or scan order.
106fn top_jobs(recon: &reconcile::Reconciliation) -> Vec<&JobCost> {
107    let mut jobs: Vec<&JobCost> = recon.workflows.iter().flat_map(|cost| &cost.jobs).collect();
108    jobs.sort_by(|left, right| {
109        right
110            .amount
111            .micros
112            .cmp(&left.amount.micros)
113            .then_with(|| left.file.cmp(&right.file))
114            .then_with(|| left.line.cmp(&right.line))
115            .then_with(|| left.name.cmp(&right.name))
116    });
117    jobs.truncate(TOP_JOBS);
118    jobs
119}
120
121fn job_name(job: &JobCost) -> &str {
122    if job.name.is_empty() {
123        "job"
124    } else {
125        job.name.as_str()
126    }
127}
128
129fn job_location(job: &JobCost) -> String {
130    if !job.file.is_empty() {
131        if let Some(line) = job.line {
132            return format!("{}:{line}", job.file);
133        }
134    }
135    match job.job_id {
136        Some(id) => format!("job {id}"),
137        None => "-".to_string(),
138    }
139}
140
141/// Escape a Markdown table cell: a `|` would otherwise end the cell early.
142/// `escape_display` already turns a raw newline or other control character
143/// into a visible escape, which also keeps the row from ending early.
144fn escape_cell(text: &str) -> String {
145    escape_display(text).replace('|', "\\|")
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::findings::FindingKind;
152    use crate::money::Micros;
153    use crate::rank::SkuTotal;
154
155    fn usd(text: &str) -> Micros {
156        Micros::parse(text).unwrap()
157    }
158
159    fn bill(net: &str) -> Bill {
160        Bill {
161            total_net: usd(net),
162            total_minutes: Micros::ZERO,
163            other_products_net: Micros::ZERO,
164            period_from: None,
165            period_to: None,
166            repositories: Vec::new(),
167        }
168    }
169
170    fn workflow(path: &str, net: &str, minutes: &str, sku: &str) -> WorkflowTotal {
171        let net = usd(net);
172        let minutes = usd(minutes);
173        WorkflowTotal {
174            repository: String::new(),
175            workflow_path: path.to_string(),
176            net,
177            gross: net,
178            discount: Micros::ZERO,
179            minutes,
180            skus: vec![SkuTotal {
181                sku: sku.to_string(),
182                net,
183                gross: net,
184                discount: Micros::ZERO,
185                minutes,
186            }],
187        }
188    }
189
190    fn finding(
191        path: &str,
192        line: usize,
193        kind: FindingKind,
194        message: &str,
195        savings: &str,
196    ) -> Finding {
197        let mut item = Finding::record(path, line, kind, message);
198        item.savings = usd(savings);
199        item
200    }
201
202    #[test]
203    fn page_has_period_repositories_and_total() {
204        let mut totals = bill("10");
205        totals.period_from = Some("2026-09-01".to_string());
206        totals.period_to = Some("2026-09-30".to_string());
207        totals.repositories = vec!["example-org/app".to_string()];
208        let text = render(&[], &totals, None, None, &[], None);
209        assert!(text.starts_with("# ci-bill teardown\n"), "{text}");
210        assert!(
211            text.contains("Period: 2026-09-01 .. 2026-09-30\n"),
212            "{text}"
213        );
214        assert!(text.contains("Repositories: example-org/app\n"), "{text}");
215        assert!(text.contains("Total net: 10.00 USD\n"), "{text}");
216    }
217
218    #[test]
219    fn missing_period_and_repositories_still_render() {
220        let text = render(&[], &bill("0"), None, None, &[], None);
221        assert!(text.contains("Period: (none)\n"), "{text}");
222        assert!(text.contains("Repositories: (none)\n"), "{text}");
223    }
224
225    #[test]
226    fn no_workflows_or_findings_omits_their_sections() {
227        let text = render(&[], &bill("0"), None, None, &[], None);
228        assert!(!text.contains("## Top jobs"), "{text}");
229        assert!(!text.contains("## Findings"), "{text}");
230        assert!(!text.contains("## Suggested fix"), "{text}");
231    }
232
233    fn scanned_job(file: &str, net: &str) -> Workflow {
234        let path = std::path::PathBuf::from(format!(".github/workflows/{file}"));
235        let text = format!(
236            "name: {file}\non: push\njobs:\n  build-{net}:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
237        );
238        crate::workflow::scan_yaml(&path, &text).expect("scan fixture yaml")
239    }
240
241    #[test]
242    fn top_jobs_table_lists_the_five_costliest_jobs() {
243        let nets = ["1", "2", "3", "4", "5", "6"];
244        let workflows: Vec<WorkflowTotal> = nets
245            .iter()
246            .map(|net| {
247                workflow(
248                    &format!(".github/workflows/{net}.yml"),
249                    net,
250                    "10",
251                    "actions_linux",
252                )
253            })
254            .collect();
255        let scanned: Vec<Workflow> = nets
256            .iter()
257            .map(|net| scanned_job(&format!("{net}.yml"), net))
258            .collect();
259        let totals = bill("21");
260        let text = render(&workflows, &totals, None, None, &scanned, None);
261        assert!(text.contains("## Top jobs\n"), "{text}");
262        assert!(
263            text.contains("| build-6 | .github/workflows/6.yml:5 | 6.00 |"),
264            "{text}"
265        );
266        assert!(
267            text.contains("| build-2 | .github/workflows/2.yml:5 | 2.00 |"),
268            "{text}"
269        );
270        // Only the five costliest jobs are listed; the cheapest (net 1) is left out.
271        assert!(!text.contains("build-1 "), "{text}");
272        let jobs_section = text.split("## Top jobs").nth(1).unwrap();
273        let jobs_section = match jobs_section.split_once("##") {
274            Some((before, _)) => before,
275            None => jobs_section,
276        };
277        assert_eq!(jobs_section.matches("| build-").count(), 5, "{text}");
278    }
279
280    #[test]
281    fn findings_table_keeps_the_savings_sorted_order() {
282        let findings = vec![
283            finding(
284                "a.yml",
285                8,
286                FindingKind::LintOnRunner,
287                "lint on macOS",
288                "207.872",
289            ),
290            finding(
291                "a.yml",
292                7,
293                FindingKind::MissingTimeout,
294                "default timeout is 360 minutes",
295                "22.32",
296            ),
297        ];
298        let text = render(&[], &bill("0"), Some(&findings), None, &[], None);
299        let f1 = text.find("F1").expect("f1 row");
300        let f2 = text.find("F2").expect("f2 row");
301        assert!(f1 < f2, "{text}");
302        assert!(
303            text.contains("| F1 | a.yml:8 | lint on macOS | 207.87 |"),
304            "{text}"
305        );
306        assert!(
307            text.contains("| F2 | a.yml:7 | default timeout is 360 minutes | 22.32 |"),
308            "{text}"
309        );
310    }
311
312    #[test]
313    fn empty_findings_list_omits_the_section() {
314        let text = render(&[], &bill("0"), Some(&[]), None, &[], None);
315        assert!(!text.contains("## Findings"), "{text}");
316    }
317
318    #[test]
319    fn pipe_and_newline_in_message_are_escaped() {
320        let findings = vec![finding(
321            "a.yml",
322            1,
323            FindingKind::InvalidUses,
324            "job a|b uses c\nd",
325            "0",
326        )];
327        let text = render(&[], &bill("0"), Some(&findings), None, &[], None);
328        assert!(text.contains("job a\\|b uses c\\nd"), "{text}");
329        let row = text
330            .lines()
331            .find(|line| line.starts_with("| F11"))
332            .expect("row");
333        // 5 delimiters plus the one escaped `\|` inside the message cell.
334        assert_eq!(row.matches('|').count(), 6, "{row}");
335    }
336
337    #[test]
338    fn control_characters_and_pipes_in_file_paths_are_escaped() {
339        let findings = vec![finding(
340            "weird|path\r\nwith\tcontrol\u{1b}\u{7f}\u{202e}.yml",
341            1,
342            FindingKind::InvalidUses,
343            "fine",
344            "0",
345        )];
346        let text = render(&[], &bill("0"), Some(&findings), None, &[], None);
347        for raw in ['\r', '\t', '\u{1b}', '\u{7f}', '\u{202e}'] {
348            assert!(!text.contains(raw), "{raw:?} leaked raw into {text:?}");
349        }
350        assert!(
351            text.contains("weird\\|path\\r\\nwith\\tcontrol\\x1b\\x7f\\u{202e}.yml"),
352            "{text}"
353        );
354        let row = text
355            .lines()
356            .find(|line| line.starts_with("| F11"))
357            .expect("row");
358        assert_eq!(row.matches('|').count(), 6, "{row}");
359    }
360
361    #[test]
362    fn fixable_finding_adds_a_suggested_fix_diff() {
363        let dir = std::env::temp_dir().join("ci-bill-md-test-fix");
364        let _ = std::fs::remove_dir_all(&dir);
365        std::fs::create_dir_all(&dir).expect("scratch dir");
366        let path = dir.join("lint.yml");
367        std::fs::write(
368            &path,
369            "name: Lint\non:\n  push:\njobs:\n  lint:\n    runs-on: macos-latest\n    steps:\n      - run: cargo fmt --check\n",
370        )
371        .expect("write fixture");
372        let findings = vec![finding(
373            path.to_str().unwrap(),
374            6,
375            FindingKind::LintOnRunner,
376            "lint on macOS",
377            "100",
378        )];
379        let text = render(&[], &bill("0"), Some(&findings), None, &[], None);
380        assert!(text.contains("## Suggested fix\n"), "{text}");
381        assert!(text.contains("```diff\n"), "{text}");
382        assert!(text.contains("+    runs-on: ubuntu-latest\n"), "{text}");
383        assert!(text.trim_end().ends_with("```"), "{text}");
384    }
385
386    #[test]
387    fn unfixable_findings_omit_the_suggested_fix_section() {
388        let findings = vec![finding(
389            "a.yml",
390            1,
391            FindingKind::DuplicateKey,
392            "duplicate key name (line 1 and 2)",
393            "0",
394        )];
395        let text = render(&[], &bill("0"), Some(&findings), None, &[], None);
396        assert!(text.contains("## Findings"), "{text}");
397        assert!(!text.contains("## Suggested fix"), "{text}");
398    }
399}