1use 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
22pub 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
103fn 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
141fn 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 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 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}