1use std::fmt::Write;
2use std::path::Path;
3
4use crate::api::{linked_workflow_path, ApiData, Job, WorkflowRun, IN_PROGRESS, UNKNOWN_WORKFLOW};
5use crate::display::{display_path, escape_display};
6use crate::findings::Finding;
7use crate::job_cost::bill_job;
8use crate::money::{percent_fixed, Micros};
9use crate::rank::{Bill, WorkflowTotal};
10use crate::reconcile::{self, Amount, CostMethod, Reconciliation, WorkflowCost};
11
12#[cfg(test)]
29pub fn render(
30 workflows: &[WorkflowTotal],
31 bill: &Bill,
32 findings: Option<&[Finding]>,
33 top: Option<usize>,
34) -> String {
35 render_report(workflows, bill, findings, None, top, &[])
36}
37
38pub fn render_text(
47 workflows: &[WorkflowTotal],
48 bill: &Bill,
49 findings: Option<&[Finding]>,
50 api: Option<&ApiData>,
51 top: Option<usize>,
52 scanned: &[crate::workflow::Workflow],
53) -> String {
54 render_report(workflows, bill, findings, api, top, scanned)
55}
56
57#[cfg(test)]
66pub fn render_with_runs(
67 workflows: &[WorkflowTotal],
68 bill: &Bill,
69 findings: Option<&[Finding]>,
70 api: &ApiData,
71 top: Option<usize>,
72) -> String {
73 render_report(workflows, bill, findings, Some(api), top, &[])
74}
75
76fn render_report(
77 workflows: &[WorkflowTotal],
78 bill: &Bill,
79 findings: Option<&[Finding]>,
80 api: Option<&ApiData>,
81 top: Option<usize>,
82 scanned: &[crate::workflow::Workflow],
83) -> String {
84 let limit = shown_limit(workflows.len(), top);
85 let attached = attach(workflows, findings);
86 let recon = reconcile::reconcile(workflows, api, scanned);
87 let shown = &workflows[..limit];
88 let omitted = &workflows[limit..];
89 let shown_findings = &attached.by_workflow[..limit];
90 let omitted_findings = &attached.by_workflow[limit..];
91 let mut out = String::new();
92 let _ = writeln!(out, "USD (from net_amount)");
93 let _ = writeln!(out, "{}", period_line(bill));
94 let _ = writeln!(out, "{}", repositories_line(bill));
95 let _ = writeln!(out);
96 let _ = writeln!(out, "Total net: {}", bill.total_net.format_cents());
97 let _ = writeln!(
98 out,
99 "Total minutes: {}",
100 bill.total_minutes.format_quantity()
101 );
102 let _ = writeln!(
103 out,
104 "Other products: {}",
105 bill.other_products_net.format_cents()
106 );
107 let _ = writeln!(out);
108
109 let blocks: Vec<Vec<Row>> = shown
110 .iter()
111 .map(|workflow| workflow_rows(workflow, bill.total_net))
112 .collect();
113 let widths = widths(&blocks);
114 emit_row(
115 &mut out,
116 &Row {
117 name: "Workflow".to_string(),
118 net: "Net".to_string(),
119 gross: "Gross".to_string(),
120 discount: "Discount".to_string(),
121 minutes: "Minutes".to_string(),
122 share: "Share".to_string(),
123 },
124 widths,
125 );
126 for (index, (rows, found)) in blocks.iter().zip(shown_findings).enumerate() {
127 for row in rows {
128 emit_row(&mut out, row, widths);
129 }
130 for line in reconcile::text_lines(&recon.workflows[index]) {
131 let _ = writeln!(out, "{line}");
132 }
133 for finding in found {
134 write_finding(&mut out, finding, " ");
135 }
136 }
137
138 if let Some(limit_n) = top.filter(|_| !omitted.is_empty()) {
139 append_outside(&mut out, omitted, omitted_findings, limit_n);
140 }
141 if !attached.unattached.is_empty() {
142 let _ = writeln!(out);
143 let _ = writeln!(out, "Workflows without usage");
144 for finding in &attached.unattached {
145 write_finding(&mut out, finding, "");
146 }
147 }
148 if let Some(api) = api {
149 append_api(&mut out, api, &recon);
150 }
151 out
152}
153
154pub fn render_json(
164 workflows: &[WorkflowTotal],
165 bill: &Bill,
166 findings: Option<&[Finding]>,
167 budget: Option<Micros>,
168 top: Option<usize>,
169 scanned: &[crate::workflow::Workflow],
170) -> String {
171 render_json_report(workflows, bill, findings, budget, None, top, scanned)
172}
173
174pub fn render_json_with_runs(
183 workflows: &[WorkflowTotal],
184 bill: &Bill,
185 findings: Option<&[Finding]>,
186 budget: Option<Micros>,
187 api: &ApiData,
188 top: Option<usize>,
189 scanned: &[crate::workflow::Workflow],
190) -> String {
191 render_json_report(workflows, bill, findings, budget, Some(api), top, scanned)
192}
193
194fn render_json_report(
195 workflows: &[WorkflowTotal],
196 bill: &Bill,
197 findings: Option<&[Finding]>,
198 budget: Option<Micros>,
199 api: Option<&ApiData>,
200 top: Option<usize>,
201 scanned: &[crate::workflow::Workflow],
202) -> String {
203 let limit = shown_limit(workflows.len(), top);
204 let attached = attach(workflows, findings);
205 let recon = reconcile::reconcile(workflows, api, scanned);
206 let shown = &workflows[..limit];
207 let omitted = &workflows[limit..];
208 let shown_findings = &attached.by_workflow[..limit];
209 let omitted_findings = &attached.by_workflow[limit..];
210 let shown_json: Vec<serde_json::Value> = shown
211 .iter()
212 .zip(shown_findings)
213 .zip(&recon.workflows)
214 .map(|((workflow, found), cost)| {
215 workflow_json(workflow, bill.total_net, found, scanned, cost)
216 })
217 .collect();
218 let unattached: Vec<serde_json::Value> = attached
219 .unattached
220 .iter()
221 .map(|finding| finding_json(finding))
222 .collect();
223 let mut value = serde_json::json!({
224 "schema_version": 2,
225 "currency": "USD",
226 "money_unit": "USD",
227 "period": period_json(bill),
228 "repositories": bill.repositories,
229 "total_net": bill.total_net.to_decimal_string(),
230 "total_minutes": minutes_json(bill.total_minutes),
231 "other_products_net": bill.other_products_net.to_decimal_string(),
232 "workflows": shown_json,
233 "unattached_findings": unattached,
234 });
235 if let Some(api) = api {
236 let (runs, in_progress) = api_json(api, &recon);
237 value["workflow_runs"] = runs;
238 value["in_progress"] = in_progress;
239 }
240 if !omitted.is_empty() {
241 let outside: Vec<serde_json::Value> = omitted
242 .iter()
243 .zip(omitted_findings)
244 .zip(&recon.workflows[limit..])
245 .map(|((workflow, found), cost)| {
246 workflow_json(workflow, bill.total_net, found, scanned, cost)
247 })
248 .collect();
249 value["outside_top"] = serde_json::json!({
250 "count": omitted.len(),
251 "net": omitted_net(omitted).to_decimal_string(),
252 "workflows": outside,
253 });
254 }
255 if let Some(amount) = budget {
256 value["budget"] = serde_json::Value::from(amount.to_decimal_string());
257 value["budget_exceeded"] = serde_json::Value::from(bill.total_net > amount);
258 }
259 let mut text = serde_json::to_string_pretty(&value).expect("report json");
260 text.push('\n');
261 text
262}
263
264pub fn budget_exceeded_line(total_net: Micros, budget: Micros) -> String {
266 format!(
267 "Budget exceeded: {} > {}",
268 total_net.format_cents(),
269 budget.format_cents()
270 )
271}
272
273pub(crate) fn period_line(bill: &Bill) -> String {
274 match (&bill.period_from, &bill.period_to) {
275 (Some(from), Some(to)) => format!("Period: {from} .. {to}"),
276 _ => "Period: (none)".to_string(),
277 }
278}
279
280pub(crate) fn repositories_line(bill: &Bill) -> String {
281 if bill.repositories.is_empty() {
282 "Repositories: (none)".to_string()
283 } else {
284 let names: Vec<String> = bill
285 .repositories
286 .iter()
287 .map(|repository| escape_display(repository))
288 .collect();
289 format!("Repositories: {}", names.join(", "))
290 }
291}
292
293fn period_json(bill: &Bill) -> serde_json::Value {
294 match (&bill.period_from, &bill.period_to) {
295 (Some(from), Some(to)) => serde_json::json!({ "from": from, "to": to }),
296 _ => serde_json::Value::Null,
297 }
298}
299
300fn minutes_json(value: Micros) -> serde_json::Value {
301 match value.whole_units() {
302 Some(whole) => serde_json::Value::from(whole),
303 None => serde_json::Value::String(value.to_decimal_string()),
304 }
305}
306
307struct Attached<'a> {
308 by_workflow: Vec<Vec<&'a Finding>>,
309 unattached: Vec<&'a Finding>,
310}
311
312fn attach<'a>(workflows: &[WorkflowTotal], findings: Option<&'a [Finding]>) -> Attached<'a> {
313 let mut by_workflow = vec![Vec::new(); workflows.len()];
314 let mut unattached = Vec::new();
315 if let Some(findings) = findings {
316 for finding in findings {
317 if let Some(index) = workflows
318 .iter()
319 .position(|workflow| belongs_to(workflow, finding))
320 {
321 by_workflow[index].push(finding);
322 } else {
323 unattached.push(finding);
324 }
325 }
326 }
327 unattached.retain(|finding| {
330 finding.repository.is_some()
331 || !by_workflow
332 .iter()
333 .flatten()
334 .any(|placed| same_spot(finding, placed))
335 });
336 let mut unique: Vec<&Finding> = Vec::new();
337 for finding in unattached {
338 if finding.repository.is_some() || !unique.iter().any(|kept| same_spot(finding, kept)) {
339 unique.push(finding);
340 }
341 }
342 Attached {
343 by_workflow,
344 unattached: unique,
345 }
346}
347
348fn same_spot(left: &Finding, right: &Finding) -> bool {
349 left.path == right.path
350 && left.line == right.line
351 && left.kind == right.kind
352 && left.message == right.message
353}
354
355fn workflow_key(path: &Path) -> Option<String> {
357 let name = path.file_name()?.to_str()?;
358 let workflows = path.parent()?;
359 if workflows.file_name()?.to_str()? != "workflows" {
360 return None;
361 }
362 if workflows.parent()?.file_name()?.to_str()? != ".github" {
363 return None;
364 }
365 Some(format!(".github/workflows/{name}"))
366}
367
368fn belongs_to(workflow: &WorkflowTotal, finding: &Finding) -> bool {
369 if let Some(workflow_path) = &finding.workflow_path {
370 let path_ok = workflow_path_matches(&workflow.workflow_path, workflow_path);
371 return match &finding.repository {
372 Some(repository) => path_ok && workflow.repository == *repository,
373 None => path_ok,
374 };
375 }
376 let Some(key) = workflow_key(&finding.path) else {
377 return false;
378 };
379 workflow_path_matches(&workflow.workflow_path, &key)
380}
381
382fn workflow_path_matches(workflow_path: &str, key: &str) -> bool {
383 workflow_path == key || workflow_path.ends_with(&format!("/{key}"))
384}
385
386fn workflow_json(
387 workflow: &WorkflowTotal,
388 total_net: Micros,
389 findings: &[&Finding],
390 scanned: &[crate::workflow::Workflow],
391 cost: &WorkflowCost,
392) -> serde_json::Value {
393 let skus: Vec<serde_json::Value> = workflow
394 .skus
395 .iter()
396 .map(|sku| {
397 serde_json::json!({
398 "sku": sku.sku,
399 "net": sku.net.to_decimal_string(),
400 "minutes": minutes_json(sku.minutes),
401 })
402 })
403 .collect();
404 let found: Vec<serde_json::Value> = findings.iter().copied().map(finding_json).collect();
405 let steps = step_values(&steps_for(scanned, &workflow.workflow_path));
406 serde_json::json!({
407 "repository": workflow.repository,
408 "path": workflow.workflow_path,
409 "net": workflow.net.to_decimal_string(),
410 "gross": workflow.gross.to_decimal_string(),
411 "discount": workflow.discount.to_decimal_string(),
412 "minutes": minutes_json(workflow.minutes),
413 "share": percent_fixed(workflow.net, total_net, 6),
414 "skus": skus,
415 "findings": found,
416 "steps": steps,
417 "measured": amount_json(&cost.measured),
418 "invoice": amount_json(&cost.invoice),
419 "difference": amount_json(&cost.difference),
420 "job_costs": job_cost_values(&cost.jobs),
421 })
422}
423
424fn amount_json(amount: &Amount) -> serde_json::Value {
425 serde_json::json!({
426 "amount": amount.micros.to_decimal_string(),
427 "method": amount.method.as_str(),
428 })
429}
430
431fn job_cost_values(jobs: &[crate::reconcile::JobCost]) -> Vec<serde_json::Value> {
432 jobs.iter()
433 .map(|job| {
434 serde_json::json!({
435 "amount": job.amount.micros.to_decimal_string(),
436 "file": job.file,
437 "job_id": job.job_id,
438 "line": job.line,
439 "method": job.amount.method.as_str(),
440 "name": job.name,
441 "sku": job.sku,
442 })
443 })
444 .collect()
445}
446
447fn step_values(steps: &[crate::workflow::Step]) -> Vec<serde_json::Value> {
448 steps
449 .iter()
450 .map(|step| {
451 serde_json::json!({
452 "name": step.name,
453 "line": step.line,
454 })
455 })
456 .collect()
457}
458
459fn steps_for(
462 scanned: &[crate::workflow::Workflow],
463 workflow_path: &str,
464) -> Vec<crate::workflow::Step> {
465 let mut steps = Vec::new();
466 for workflow in scanned {
467 let Some(file_name) = workflow.path.file_name().and_then(|name| name.to_str()) else {
468 continue;
469 };
470 if file_name.is_empty() || !same_workflow_file(workflow_path, file_name) {
471 continue;
472 }
473 for job in &workflow.jobs {
474 steps.extend(job.steps.iter().cloned());
475 }
476 }
477 steps
478}
479
480fn same_workflow_file(workflow_path: &str, file_name: &str) -> bool {
481 let key = format!(".github/workflows/{file_name}");
482 let normalized = workflow_path.replace('\\', "/");
483 if normalized.eq_ignore_ascii_case(&key) {
484 return true;
485 }
486 normalized.char_indices().any(|(index, separator)| {
487 separator == '/' && normalized[index + 1..].eq_ignore_ascii_case(&key)
488 })
489}
490
491fn finding_json(finding: &Finding) -> serde_json::Value {
492 serde_json::json!({
493 "id": finding.kind.code(),
494 "file": display_path(&finding.path),
495 "line": finding.line,
496 "message": finding.message,
497 "savings": finding.savings.to_decimal_string(),
498 "method": finding.method.as_str(),
499 })
500}
501
502fn finding_text(finding: &Finding) -> String {
503 format!(
504 "{} {}:{}: {}",
505 finding.kind.code(),
506 escape_display(&display_path(&finding.path)),
507 finding.line,
508 escape_display(&finding.message)
509 )
510}
511
512fn write_finding(out: &mut String, finding: &Finding, indent: &str) {
513 let _ = writeln!(out, "{indent}{}", finding_text(finding));
514 let _ = writeln!(
515 out,
516 "{indent} savings {} USD ({})",
517 finding.savings.format_cents(),
518 finding.method.as_str()
519 );
520}
521
522fn shown_limit(len: usize, top: Option<usize>) -> usize {
524 match top {
525 Some(limit) => limit.min(len),
526 None => len,
527 }
528}
529
530fn omitted_net(workflows: &[WorkflowTotal]) -> Micros {
531 workflows.iter().fold(Micros::ZERO, |total, workflow| {
532 Micros(total.0.saturating_add(workflow.net.0))
533 })
534}
535
536fn more_workflows_line(count: usize, net: Micros) -> String {
537 let noun = if count == 1 { "workflow" } else { "workflows" };
538 format!("{count} more {noun} ({} USD)", net.format_cents())
539}
540
541fn append_outside(
547 out: &mut String,
548 omitted: &[WorkflowTotal],
549 findings: &[Vec<&Finding>],
550 top: usize,
551) {
552 if omitted.is_empty() {
553 return;
554 }
555 let _ = writeln!(out);
556 let _ = writeln!(
557 out,
558 "{}",
559 more_workflows_line(omitted.len(), omitted_net(omitted))
560 );
561 if findings.iter().all(|group| group.is_empty()) {
562 return;
563 }
564 let _ = writeln!(out);
565 let _ = writeln!(out, "Findings outside the top {top}");
566 for (workflow, group) in omitted.iter().zip(findings) {
567 if group.is_empty() {
568 continue;
569 }
570 let _ = writeln!(out, "{}", workflow_label(workflow));
571 for finding in group {
572 write_finding(out, finding, " ");
573 }
574 }
575}
576
577struct Row {
578 name: String,
579 net: String,
580 gross: String,
581 discount: String,
582 minutes: String,
583 share: String,
584}
585
586#[derive(Clone, Copy)]
587struct Widths {
588 name: usize,
589 net: usize,
590 gross: usize,
591 discount: usize,
592 minutes: usize,
593 share: usize,
594}
595
596fn workflow_label(workflow: &WorkflowTotal) -> String {
597 let workflow_path = escape_display(&workflow.workflow_path);
598 if workflow.repository.is_empty() {
599 workflow_path
600 } else {
601 format!("{} {workflow_path}", escape_display(&workflow.repository))
602 }
603}
604
605fn workflow_rows(workflow: &WorkflowTotal, total_net: Micros) -> Vec<Row> {
606 let mut rows = vec![Row {
607 name: workflow_label(workflow),
608 net: workflow.net.format_cents(),
609 gross: workflow.gross.format_cents(),
610 discount: workflow.discount.format_cents(),
611 minutes: workflow.minutes.format_quantity(),
612 share: format_share(workflow.net, total_net),
613 }];
614 for sku in &workflow.skus {
615 rows.push(Row {
616 name: format!(" {}", escape_display(&sku.sku)),
617 net: sku.net.format_cents(),
618 gross: sku.gross.format_cents(),
619 discount: sku.discount.format_cents(),
620 minutes: sku.minutes.format_quantity(),
621 share: String::new(),
622 });
623 }
624 rows
625}
626
627fn widths(blocks: &[Vec<Row>]) -> Widths {
628 let mut widths = Widths {
629 name: "Workflow".len(),
630 net: "Net".len(),
631 gross: "Gross".len(),
632 discount: "Discount".len(),
633 minutes: "Minutes".len(),
634 share: "Share".len(),
635 };
636 for row in blocks.iter().flatten() {
637 widths.name = widths.name.max(row.name.len());
638 widths.net = widths.net.max(row.net.len());
639 widths.gross = widths.gross.max(row.gross.len());
640 widths.discount = widths.discount.max(row.discount.len());
641 widths.minutes = widths.minutes.max(row.minutes.len());
642 widths.share = widths.share.max(row.share.len());
643 }
644 widths
645}
646
647fn emit_row(out: &mut String, row: &Row, widths: Widths) {
648 let _ = writeln!(
649 out,
650 "{:<name_w$} {:>net_w$} {:>gross_w$} {:>discount_w$} {:>min_w$} {:>share_w$}",
651 row.name,
652 row.net,
653 row.gross,
654 row.discount,
655 row.minutes,
656 row.share,
657 name_w = widths.name,
658 net_w = widths.net,
659 gross_w = widths.gross,
660 discount_w = widths.discount,
661 min_w = widths.minutes,
662 share_w = widths.share,
663 );
664}
665
666fn format_share(net: Micros, total: Micros) -> String {
667 format!("{}%", percent_fixed(net, total, 2))
668}
669
670fn append_api(out: &mut String, api: &ApiData, recon: &Reconciliation) {
671 let _ = writeln!(out);
672 write_section(out, "Runs", &run_lines(api));
673 let _ = writeln!(out);
674 write_section(out, "Jobs", &counted_lines(api, recon));
675 let _ = writeln!(out);
676 write_section(out, "In progress", &progress_lines(api, recon));
677}
678
679fn write_section(out: &mut String, heading: &str, lines: &[String]) {
680 let _ = writeln!(out, "{heading}");
681 if lines.is_empty() {
682 let _ = writeln!(out, "(none)");
683 return;
684 }
685 for line in lines {
686 let _ = writeln!(out, "{line}");
687 }
688}
689
690fn run_lines(api: &ApiData) -> Vec<String> {
691 sorted_runs(api)
692 .into_iter()
693 .map(|run| {
694 let conclusion = run.conclusion.as_deref().unwrap_or(IN_PROGRESS);
695 let started = run.run_started_at.as_deref().unwrap_or("-");
696 let path = displayed_run_path(run);
697 format!(
698 "{id} {path} attempt {attempt} number {number} {event} {conclusion} {started}",
699 id = run.id,
700 attempt = run.run_attempt,
701 number = run.run_number,
702 event = run.event,
703 )
704 })
705 .collect()
706}
707
708fn counted_lines(api: &ApiData, recon: &Reconciliation) -> Vec<String> {
709 grouped_lines(api, finished_jobs(api), |api, job| {
710 let completed = job.completed_at.as_deref().unwrap_or("-");
711 let conclusion = job.conclusion.as_deref().unwrap_or("-");
712 format!(
713 "{} completed {completed} {conclusion}{}{}",
714 job_prefix(api, job),
715 cost_suffix(api, recon, job),
716 copy_suffix(api, job),
717 )
718 })
719}
720
721fn progress_lines(api: &ApiData, recon: &Reconciliation) -> Vec<String> {
722 grouped_lines(api, in_progress_jobs(api), |api, job| {
723 format!(
724 "{} {IN_PROGRESS}{}{}",
725 job_prefix(api, job),
726 cost_suffix(api, recon, job),
727 copy_suffix(api, job),
728 )
729 })
730}
731
732fn grouped_lines(
733 api: &ApiData,
734 jobs: Vec<&Job>,
735 mut line: impl FnMut(&ApiData, &Job) -> String,
736) -> Vec<String> {
737 let mut lines = Vec::new();
738 let mut current: Option<String> = None;
739 for job in jobs {
740 let path = displayed_run_path_for_id(api, job.run_id);
741 if current.as_deref() != Some(path.as_str()) {
742 lines.push(path.clone());
743 current = Some(path);
744 }
745 lines.push(line(api, job));
746 }
747 lines
748}
749
750fn cost_suffix(api: &ApiData, recon: &Reconciliation, job: &Job) -> String {
751 let bill = bill_job(api, job);
752 let sku = bill.sku.as_deref().unwrap_or("-");
753 let list = format!(
754 " {} min {sku} list {}",
755 bill.minutes,
756 bill.list_price.format_cents()
757 );
758 if !api.public {
759 return list;
760 }
761 let invoice = recon
762 .job(job.id)
763 .map(|amount| amount.micros)
764 .unwrap_or_else(|| {
765 reconcile::list_price_invoice(true, bill.sku.as_deref(), bill.list_price)
766 });
767 format!("{list} invoice {}", invoice.format_cents())
768}
769
770fn job_prefix(api: &ApiData, job: &Job) -> String {
771 let path = displayed_run_path_for_id(api, job.run_id);
772 let labels = if job.labels.is_empty() {
773 "-".to_string()
774 } else {
775 job.labels
776 .iter()
777 .map(|label| escape_display(label))
778 .collect::<Vec<_>>()
779 .join(", ")
780 };
781 let name = escape_display(&job.name);
782 let runner = job.runner_name.as_deref().unwrap_or("-");
783 let started = job.started_at.as_deref().unwrap_or("-");
784 format!(
785 "{id} {name} run {run_id} {path} attempt {attempt} labels {labels} runner {runner} started {started}",
786 id = job.id,
787 run_id = job.run_id,
788 attempt = job.run_attempt,
789 )
790}
791
792fn sorted_runs(api: &ApiData) -> Vec<&WorkflowRun> {
793 let mut runs: Vec<&WorkflowRun> = api.runs.iter().collect();
794 runs.sort_by(|left, right| {
795 displayed_run_path(left)
796 .cmp(&displayed_run_path(right))
797 .then(left.run_number.cmp(&right.run_number))
798 .then(left.run_attempt.cmp(&right.run_attempt))
799 .then(left.id.cmp(&right.id))
800 });
801 runs
802}
803
804fn in_progress_jobs(api: &ApiData) -> Vec<&Job> {
805 let mut jobs: Vec<&Job> = api.in_progress_jobs().collect();
806 sort_jobs(api, &mut jobs);
807 jobs
808}
809
810fn sort_jobs(api: &ApiData, jobs: &mut [&Job]) {
811 jobs.sort_by(|left, right| {
812 let left_path = displayed_run_path_for_id(api, left.run_id);
813 let right_path = displayed_run_path_for_id(api, right.run_id);
814 let left_bill = bill_job(api, left);
815 let right_bill = bill_job(api, right);
816 left_path
817 .cmp(&right_path)
818 .then(right_bill.list_price.cmp(&left_bill.list_price))
819 .then(right_bill.minutes.cmp(&left_bill.minutes))
820 .then(left.name.cmp(&right.name))
821 .then(left.run_id.cmp(&right.run_id))
822 .then(left.run_attempt.cmp(&right.run_attempt))
823 .then(left.id.cmp(&right.id))
824 });
825}
826
827fn displayed_run_path(run: &WorkflowRun) -> String {
828 let linked = linked_workflow_path(&run.path);
829 if linked.is_empty() || run.links_to(&linked) {
830 linked
831 } else {
832 run.path.clone()
833 }
834}
835
836fn displayed_run_path_for_id(api: &ApiData, run_id: u64) -> String {
837 match api.runs.iter().find(|run| run.id == run_id) {
838 Some(run) => displayed_run_path(run),
839 None => api.workflow_path(run_id),
840 }
841}
842
843fn copy_suffix(api: &ApiData, job: &Job) -> String {
844 match api.inherited_copy_net(job) {
845 Some(net) => format!(" inherited copy cost {}", net.format_cents()),
846 None => String::new(),
847 }
848}
849
850fn api_json(api: &ApiData, recon: &Reconciliation) -> (serde_json::Value, serde_json::Value) {
851 let mut runs: Vec<serde_json::Value> = sorted_runs(api)
852 .into_iter()
853 .map(|run| run_json(run, counted_job_values(api, recon, run.id)))
854 .collect();
855 let known: std::collections::HashSet<u64> = api.runs.iter().map(|run| run.id).collect();
856 let mut missing: Vec<u64> = Vec::new();
857 for job in &api.jobs {
858 if !known.contains(&job.run_id) && !missing.contains(&job.run_id) {
859 missing.push(job.run_id);
860 }
861 }
862 missing.sort_unstable();
863 for run_id in missing {
864 runs.push(serde_json::json!({
865 "id": run_id,
866 "path": UNKNOWN_WORKFLOW,
867 "run_attempt": null,
868 "run_number": null,
869 "event": null,
870 "conclusion": null,
871 "run_started_at": null,
872 "jobs": counted_job_values(api, recon, run_id),
873 }));
874 }
875 runs.sort_by(|left, right| run_sort_key(left).cmp(&run_sort_key(right)));
876 let in_progress: Vec<serde_json::Value> = in_progress_jobs(api)
877 .into_iter()
878 .map(|job| {
879 let mut value = job_json(api, recon, job, false);
880 value["workflow_path"] =
881 serde_json::Value::from(displayed_run_path_for_id(api, job.run_id));
882 value["message"] = serde_json::Value::from(IN_PROGRESS);
883 value
884 })
885 .collect();
886 (
887 serde_json::Value::Array(runs),
888 serde_json::Value::Array(in_progress),
889 )
890}
891
892fn counted_job_values(
893 api: &ApiData,
894 recon: &Reconciliation,
895 run_id: u64,
896) -> Vec<serde_json::Value> {
897 let mut jobs: Vec<&Job> = finished_jobs(api)
898 .into_iter()
899 .filter(|job| job.run_id == run_id)
900 .collect();
901 jobs.sort_by(|left, right| {
902 let left_bill = bill_job(api, left);
903 let right_bill = bill_job(api, right);
904 right_bill
905 .list_price
906 .cmp(&left_bill.list_price)
907 .then(right_bill.minutes.cmp(&left_bill.minutes))
908 .then(left.name.cmp(&right.name))
909 .then(left.run_attempt.cmp(&right.run_attempt))
910 .then(left.id.cmp(&right.id))
911 });
912 jobs.into_iter()
913 .map(|job| job_json(api, recon, job, true))
914 .collect()
915}
916
917fn finished_jobs(api: &ApiData) -> Vec<&Job> {
921 let copy_ids: Vec<u64> = api
922 .counted_jobs()
923 .filter(|job| api.is_inherited_copy(job))
924 .map(|job| job.id)
925 .collect();
926 let mut jobs: Vec<&Job> = api.billable_jobs().collect();
927 jobs.extend(api.counted_jobs().filter(|job| copy_ids.contains(&job.id)));
928 sort_jobs(api, &mut jobs);
929 jobs
930}
931
932fn run_json(run: &WorkflowRun, jobs: Vec<serde_json::Value>) -> serde_json::Value {
933 serde_json::json!({
934 "id": run.id,
935 "path": displayed_run_path(run),
936 "run_attempt": run.run_attempt,
937 "run_number": run.run_number,
938 "event": run.event,
939 "conclusion": run.conclusion,
940 "run_started_at": run.run_started_at,
941 "jobs": jobs,
942 })
943}
944
945fn run_sort_key(value: &serde_json::Value) -> (&str, u64, u64, u64) {
946 (
947 value["path"].as_str().unwrap_or(UNKNOWN_WORKFLOW),
948 value["run_number"].as_u64().unwrap_or(0),
949 value["run_attempt"].as_u64().unwrap_or(0),
950 value["id"].as_u64().unwrap_or(0),
951 )
952}
953
954fn job_json(api: &ApiData, recon: &Reconciliation, job: &Job, counts: bool) -> serde_json::Value {
955 let inherited = api.is_inherited_copy(job);
956 let mut value = serde_json::json!({
957 "id": job.id,
958 "run_id": job.run_id,
959 "run_attempt": job.run_attempt,
960 "name": job.name,
961 "labels": job.labels,
962 "started_at": job.started_at,
963 "completed_at": job.completed_at,
964 "conclusion": job.conclusion,
965 "runner_name": job.runner_name,
966 "counts": counts,
967 "inherited_copy": inherited,
968 "billable": counts && !inherited,
969 });
970 let bill = bill_job(api, job);
971 let priced = recon.job(job.id).cloned().unwrap_or_else(|| Amount {
972 micros: reconcile::list_price_invoice(api.public, bill.sku.as_deref(), bill.list_price),
973 method: CostMethod::ListPrice,
974 });
975 value["minutes"] = serde_json::Value::from(bill.minutes);
976 value["sku"] = match bill.sku {
977 Some(sku) => serde_json::Value::String(sku),
978 None => serde_json::Value::Null,
979 };
980 value["list_price"] = serde_json::Value::String(bill.list_price.to_decimal_string());
981 value["list_price_method"] = serde_json::Value::from(CostMethod::ListPrice.as_str());
982 value["invoice"] = serde_json::Value::String(priced.micros.to_decimal_string());
983 value["method"] = serde_json::Value::from(priced.method.as_str());
984 value["usd_per_minute"] = match bill.usd_per_minute {
985 Some(rate) => serde_json::Value::String(rate.to_decimal_string()),
986 None => serde_json::Value::Null,
987 };
988 if let Some(net) = api.inherited_copy_net(job) {
989 value["net_amount"] = serde_json::Value::String(net.to_decimal_string());
990 }
991 value
992}
993
994#[cfg(test)]
995mod tests {
996 use super::*;
997 use crate::findings::{Finding, FindingKind};
998 use crate::money::Micros;
999 use crate::rank::{Bill, SkuTotal, WorkflowTotal};
1000
1001 fn usd(text: &str) -> Micros {
1002 Micros::parse(text).unwrap()
1003 }
1004
1005 fn bill(net: &str, minutes: &str) -> Bill {
1006 Bill {
1007 total_net: usd(net),
1008 total_minutes: usd(minutes),
1009 other_products_net: Micros::ZERO,
1010 period_from: None,
1011 period_to: None,
1012 repositories: Vec::new(),
1013 }
1014 }
1015
1016 fn workflow(path: &str, net: &str, minutes: &str, sku: &str) -> WorkflowTotal {
1017 let net = usd(net);
1018 let minutes = usd(minutes);
1019 WorkflowTotal {
1020 repository: String::new(),
1021 workflow_path: path.to_string(),
1022 net,
1023 gross: net,
1024 discount: Micros::ZERO,
1025 minutes,
1026 skus: vec![SkuTotal {
1027 sku: sku.to_string(),
1028 net,
1029 gross: net,
1030 discount: Micros::ZERO,
1031 minutes,
1032 }],
1033 }
1034 }
1035
1036 fn finding(path: &str, line: usize, kind: FindingKind, message: &str) -> Finding {
1037 Finding::record(path, line, kind, message)
1038 }
1039
1040 #[test]
1041 fn amounts_round_half_up_to_two_decimals() {
1042 assert_eq!(usd("0.005").format_cents(), "0.01");
1043 assert_eq!(usd("2.112").format_cents(), "2.11");
1044 assert_eq!(usd("0").format_cents(), "0.00");
1045 }
1046
1047 #[test]
1048 fn control_characters_in_input_fields_are_escaped_not_raw() {
1049 let evil_path = "evil\u{1b}[31mRED\u{1b}[0m\nFAKE LINE: Budget exceeded: 0 > 0";
1050 let mut evil_workflow = workflow(evil_path, "1", "1", "actions_linux");
1051 evil_workflow.repository = "org/app\rwith\tcontrol".to_string();
1052 evil_workflow.skus[0].sku = "sku\u{7}bell\u{7f}del".to_string();
1053 let mut totals = bill("1", "1");
1054 totals.repositories = vec!["org/app\u{202e}bidi".to_string()];
1055 let findings = vec![finding(
1056 "file\r\nwith\u{7f}del.yml",
1057 1,
1058 FindingKind::MissingTimeout,
1059 "message\u{1b}with\u{202e}bidi",
1060 )];
1061 let text = render(&[evil_workflow], &totals, Some(&findings), None);
1062 for raw in ['\u{1b}', '\r', '\t', '\u{7f}', '\u{202e}', '\u{7}'] {
1063 assert!(!text.contains(raw), "{raw:?} leaked raw into {text:?}");
1064 }
1065 assert!(
1066 text.contains("evil\\x1b[31mRED\\x1b[0m\\nFAKE LINE: Budget exceeded: 0 > 0"),
1067 "{text}"
1068 );
1069 assert!(text.contains("org/app\\rwith\\tcontrol"), "{text}");
1070 assert!(text.contains("sku\\x07bell\\x7fdel"), "{text}");
1071 assert!(text.contains("org/app\\u{202e}bidi"), "{text}");
1072 assert!(text.contains("file\\r\\nwith\\x7fdel.yml"), "{text}");
1073 assert!(text.contains("message\\x1bwith\\u{202e}bidi"), "{text}");
1074 }
1075
1076 #[test]
1077 fn report_lists_total_share_skus_and_findings() {
1078 let workflows = vec![
1079 workflow("a.yml", "3", "10", "actions_linux"),
1080 workflow("b.yml", "1", "0", "actions_storage"),
1081 ];
1082 let findings = vec![finding(
1083 "demo.yml",
1084 4,
1085 FindingKind::MissingTimeout,
1086 "default timeout is 360 minutes",
1087 )];
1088 let text = render(&workflows, &bill("4", "10"), Some(&findings), None);
1089 assert!(text.starts_with("USD (from net_amount)\n"), "{text}");
1090 assert!(text.contains("Total net: 4.00\n"), "{text}");
1091 assert!(text.contains("Total minutes: 10\n"), "{text}");
1092 assert!(text.contains("75.00%"), "{text}");
1093 assert!(text.contains("25.00%"), "{text}");
1094 assert!(text.contains("actions_linux"), "{text}");
1095 assert!(text.contains("actions_storage"), "{text}");
1096 assert!(
1097 text.contains(
1098 "Workflows without usage\nF2 demo.yml:4: default timeout is 360 minutes\n"
1099 ),
1100 "{text}"
1101 );
1102 assert!(text.find("a.yml").unwrap() < text.find("Workflows without usage").unwrap());
1103 assert!(!text.contains("Sample"), "{text}");
1104 }
1105
1106 #[test]
1107 fn finding_is_listed_under_its_workflow() {
1108 let workflows = vec![
1109 workflow(".github/workflows/lint.yml", "3", "10", "actions_macos"),
1110 workflow(".github/workflows/ci.yml", "1", "4", "actions_linux"),
1111 ];
1112 let findings = vec![
1113 finding(
1114 "samples/.github/workflows/lint.yml",
1115 8,
1116 FindingKind::LintOnRunner,
1117 "lint on macOS",
1118 ),
1119 finding(
1120 "samples/.github/workflows/nightly.yml",
1121 2,
1122 FindingKind::MissingTimeout,
1123 "default timeout is 360 minutes",
1124 ),
1125 ];
1126 let text = render(&workflows, &bill("4", "14"), Some(&findings), None);
1127 let lint = text.find(".github/workflows/lint.yml").expect("lint row");
1128 let lint_finding = text
1129 .find(" F1 samples/.github/workflows/lint.yml:8: lint on macOS")
1130 .expect("lint finding");
1131 let ci = text.find(".github/workflows/ci.yml").expect("ci row");
1132 let heading = text
1133 .find("Workflows without usage")
1134 .expect("unattached heading");
1135 let nightly = text
1136 .find("F2 samples/.github/workflows/nightly.yml:2: default timeout is 360 minutes")
1137 .expect("nightly finding");
1138 assert!(lint < lint_finding && lint_finding < ci, "{text}");
1139 assert!(ci < heading && heading < nightly, "{text}");
1140 assert_eq!(text.matches("lint on macOS").count(), 1, "{text}");
1141 }
1142
1143 #[test]
1144 fn storage_row_shows_gross_and_discount_when_net_is_zero() {
1145 let storage = WorkflowTotal {
1146 repository: "example-org/app".to_string(),
1147 workflow_path: "(no workflow)".to_string(),
1148 net: usd("0"),
1149 gross: usd("0.005040"),
1150 discount: usd("0.005040"),
1151 minutes: usd("0"),
1152 skus: vec![SkuTotal {
1153 sku: "actions_storage".to_string(),
1154 net: usd("0"),
1155 gross: usd("0.005040"),
1156 discount: usd("0.005040"),
1157 minutes: usd("0"),
1158 }],
1159 };
1160 let mut totals = bill("0", "0");
1161 totals.repositories = vec!["example-org/app".to_string()];
1162 let text = render(&[storage], &totals, None, None);
1163 assert!(text.contains("Gross"), "{text}");
1164 assert!(text.contains("Discount"), "{text}");
1165 let storage_line = text
1166 .lines()
1167 .find(|line| line.contains("actions_storage"))
1168 .expect("storage sku");
1169 assert!(storage_line.contains("0.00"), "{storage_line}");
1170 assert_eq!(storage_line.matches("0.01").count(), 2, "{storage_line}");
1171 let workflow_line = text
1172 .lines()
1173 .find(|line| line.contains("(no workflow)"))
1174 .expect("workflow");
1175 assert_eq!(workflow_line.matches("0.01").count(), 2, "{workflow_line}");
1176 assert!(workflow_line.contains("0.00"), "{workflow_line}");
1177 assert!(!text.contains("F9"), "{text}");
1178 assert!(!text.contains("invoice row mismatch"), "{text}");
1179 }
1180
1181 #[test]
1182 fn invoice_finding_stays_on_its_repository_workflow() {
1183 let workflows = vec![
1184 WorkflowTotal {
1185 repository: "example-org/alpha".to_string(),
1186 workflow_path: ".github/workflows/ci.yml".to_string(),
1187 net: usd("7.316"),
1188 gross: usd("7.316"),
1189 discount: Micros::ZERO,
1190 minutes: usd("118"),
1191 skus: vec![SkuTotal {
1192 sku: "actions_macos".to_string(),
1193 net: usd("7.316"),
1194 gross: usd("7.316"),
1195 discount: Micros::ZERO,
1196 minutes: usd("118"),
1197 }],
1198 },
1199 WorkflowTotal {
1200 repository: "example-org/zeta".to_string(),
1201 workflow_path: ".github/workflows/ci.yml".to_string(),
1202 net: usd("7.316"),
1203 gross: usd("7.316"),
1204 discount: Micros::ZERO,
1205 minutes: usd("118"),
1206 skus: vec![SkuTotal {
1207 sku: "actions_macos".to_string(),
1208 net: usd("7.316"),
1209 gross: usd("7.316"),
1210 discount: Micros::ZERO,
1211 minutes: usd("118"),
1212 }],
1213 },
1214 ];
1215 let findings = vec![Finding::placed(
1216 "usage.csv",
1217 2,
1218 FindingKind::InvoiceMismatch,
1219 "invoice row mismatch: sku \"actions_macos\", date \"2026-09-01\", gross 7.316000 != quantity * applied_cost_per_quantity 9.440000",
1220 Some("example-org/alpha".to_string()),
1221 Some(".github/workflows/ci.yml".to_string()),
1222 )];
1223 let mut totals = bill("14.632", "236");
1224 totals.repositories = vec![
1225 "example-org/alpha".to_string(),
1226 "example-org/zeta".to_string(),
1227 ];
1228 let text = render(&workflows, &totals, Some(&findings), None);
1229 let alpha = text
1230 .find("example-org/alpha .github/workflows/ci.yml")
1231 .expect("alpha");
1232 let zeta = text
1233 .find("example-org/zeta .github/workflows/ci.yml")
1234 .expect("zeta");
1235 let f9 = text
1236 .find("F9 usage.csv:2: invoice row mismatch")
1237 .expect("f9");
1238 assert!(alpha < f9 && f9 < zeta, "{text}");
1239 assert_eq!(text.matches("invoice row mismatch").count(), 1, "{text}");
1240 assert!(!text.contains("Workflows without usage"), "{text}");
1241 }
1242
1243 #[test]
1244 fn share_uses_the_whole_bill() {
1245 let workflows = vec![workflow(
1246 ".github/workflows/lint.yml",
1247 "230.144",
1248 "3712",
1249 "actions_macos",
1250 )];
1251 let text = render(&workflows, &bill("284.208", "12208"), None, None);
1252 assert!(text.contains("Total net: 284.21\n"), "{text}");
1253 assert!(text.contains("80.98%"), "{text}");
1254 assert!(text.contains("230.14"), "{text}");
1255 assert!(!text.contains("230.144"), "{text}");
1256 assert!(!text.contains("284.208"), "{text}");
1257 assert!(!text.contains("100.00%"), "{text}");
1258 assert!(!text.contains("Findings"), "{text}");
1259 assert!(!text.contains("Workflows without usage"), "{text}");
1260 assert!(!text.contains("Sample"), "{text}");
1261 }
1262
1263 #[test]
1264 fn empty_scan_has_no_finding_section() {
1265 let text = render(&[], &bill("0", "0"), Some(&[]), None);
1266 assert!(text.contains("Total net: 0.00\n"), "{text}");
1267 assert!(!text.contains("Findings"), "{text}");
1268 assert!(!text.contains("Workflows without usage"), "{text}");
1269 assert!(!text.contains("Sample"), "{text}");
1270 }
1271
1272 #[test]
1273 fn json_keeps_unrounded_numbers_and_nests_findings() {
1274 let workflows = vec![
1275 workflow(".github/workflows/lint.yml", "2.112", "10", "actions_macos"),
1276 workflow(".github/workflows/ci.yml", "1", "0", "actions_linux"),
1277 ];
1278 let findings = vec![
1279 finding(
1280 "app/.github/workflows/lint.yml",
1281 8,
1282 FindingKind::LintOnRunner,
1283 "lint on macOS",
1284 ),
1285 finding(
1286 "app/.github/workflows/nightly.yml",
1287 3,
1288 FindingKind::MissingTimeout,
1289 "default timeout is 360 minutes",
1290 ),
1291 ];
1292 let text = render_json(
1293 &workflows,
1294 &bill("2.112", "10"),
1295 Some(&findings),
1296 None,
1297 None,
1298 &[],
1299 );
1300 assert!(!text.contains("USD (from net_amount)"), "{text}");
1301 assert!(!text.contains("0000000001"), "{text}");
1302 assert!(!text.contains("9999999"), "{text}");
1303 let value: serde_json::Value = serde_json::from_str(text.trim_end()).expect("json");
1304 assert_eq!(value["schema_version"], 2);
1305 assert_eq!(value["currency"], "USD");
1306 assert_eq!(value["money_unit"], "USD");
1307 assert_eq!(value["total_net"], "2.112000");
1308 assert_eq!(value["total_minutes"].as_i64(), Some(10));
1309 assert_eq!(value["other_products_net"], "0.000000");
1310 assert!(value["period"].is_null());
1311 assert_eq!(value["workflows"][0]["path"], ".github/workflows/lint.yml");
1312 assert_eq!(value["workflows"][0]["repository"], "");
1313 assert_eq!(value["workflows"][0]["net"], "2.112000");
1314 assert_eq!(value["workflows"][0]["gross"], "2.112000");
1315 assert_eq!(value["workflows"][0]["discount"], "0.000000");
1316 assert!(value["workflows"][0]["share"].is_string());
1317 assert_eq!(value["workflows"][0]["minutes"].as_i64(), Some(10));
1318 assert_eq!(value["workflows"][0]["skus"][0]["sku"], "actions_macos");
1319 assert_eq!(value["workflows"][0]["skus"][0]["net"], "2.112000");
1320 assert!(value["workflows"][0]["skus"][0].get("gross").is_none());
1321 assert_eq!(value["workflows"][0]["findings"][0]["id"], "F1");
1322 assert_eq!(
1323 value["workflows"][0]["findings"][0]["file"],
1324 "app/.github/workflows/lint.yml"
1325 );
1326 assert_eq!(value["workflows"][0]["findings"][0]["line"], 8);
1327 assert_eq!(
1328 value["workflows"][0]["findings"][0]["message"],
1329 "lint on macOS"
1330 );
1331 assert!(value["workflows"][1]["findings"]
1332 .as_array()
1333 .unwrap()
1334 .is_empty());
1335 assert_eq!(value["unattached_findings"][0]["id"], "F2");
1336 assert_eq!(
1337 value["unattached_findings"][0]["file"],
1338 "app/.github/workflows/nightly.yml"
1339 );
1340 assert_eq!(value["unattached_findings"][0]["line"], 3);
1341 assert_eq!(value["unattached_findings"].as_array().unwrap().len(), 1);
1342 assert!(value.get("budget").is_none(), "{value}");
1343 assert!(value.get("budget_exceeded").is_none(), "{value}");
1344 }
1345
1346 #[test]
1347 fn budget_exceeded_line_rounds_to_cents() {
1348 assert_eq!(
1349 budget_exceeded_line(usd("10"), usd("9.5")),
1350 "Budget exceeded: 10.00 > 9.50"
1351 );
1352 assert_eq!(
1353 budget_exceeded_line(usd("0.005"), usd("0")),
1354 "Budget exceeded: 0.01 > 0.00"
1355 );
1356 assert_eq!(
1357 budget_exceeded_line(usd("10.004"), usd("10")),
1358 "Budget exceeded: 10.00 > 10.00"
1359 );
1360 assert!(usd("10.004") > usd("10"));
1361 }
1362
1363 #[test]
1364 fn json_budget_fields_follow_the_net_total() {
1365 let workflows = vec![workflow("a.yml", "10", "1", "actions_linux")];
1366 let totals = bill("10", "1");
1367
1368 let under = render_json(&workflows, &totals, None, Some(usd("10.125")), None, &[]);
1369 let under_value: serde_json::Value = serde_json::from_str(under.trim_end()).unwrap();
1370 assert_eq!(under_value["budget"], "10.125000");
1371 assert_eq!(under_value["budget_exceeded"], false);
1372 assert!(under_value["budget"].is_string());
1373 assert_eq!(under_value["schema_version"], 2);
1374 assert_eq!(under_value["currency"], "USD");
1375 assert_eq!(under_value["money_unit"], "USD");
1376
1377 let equal = render_json(&workflows, &totals, None, Some(usd("10")), None, &[]);
1378 let equal_value: serde_json::Value = serde_json::from_str(equal.trim_end()).unwrap();
1379 assert_eq!(equal_value["budget_exceeded"], false);
1380 assert_eq!(equal_value["budget"], "10.000000");
1381 assert_eq!(equal_value["total_net"], "10.000000");
1382
1383 let over = render_json(&workflows, &totals, None, Some(usd("9.5")), None, &[]);
1384 let over_value: serde_json::Value = serde_json::from_str(over.trim_end()).unwrap();
1385 assert_eq!(over_value["budget_exceeded"], true);
1386 assert_eq!(over_value["budget"], "9.500000");
1387 assert!(over_value["budget_exceeded"].is_boolean());
1388 assert_eq!(over_value["workflows"][0]["net"], "10.000000");
1389
1390 let absent = render_json(&workflows, &totals, None, None, None, &[]);
1391 let absent_value: serde_json::Value = serde_json::from_str(absent.trim_end()).unwrap();
1392 assert!(absent_value.get("budget").is_none(), "{absent_value}");
1393 assert!(
1394 absent_value.get("budget_exceeded").is_none(),
1395 "{absent_value}"
1396 );
1397 }
1398
1399 fn sample_api() -> ApiData {
1400 ApiData {
1401 public: false,
1402 runs: vec![
1403 WorkflowRun {
1404 id: 1002,
1405 path: ".github/workflows/lint.yml".to_string(),
1406 repository: None,
1407 run_attempt: 2,
1408 run_number: 41,
1409 event: "pull_request".to_string(),
1410 conclusion: None,
1411 run_started_at: Some("2026-03-02T11:00:00Z".to_string()),
1412 head_branch: None,
1413 pull_requests: Vec::new(),
1414 },
1415 WorkflowRun {
1416 id: 1001,
1417 path: ".github/workflows/ci.yml".to_string(),
1418 repository: None,
1419 run_attempt: 1,
1420 run_number: 40,
1421 event: "push".to_string(),
1422 conclusion: Some("success".to_string()),
1423 run_started_at: Some("2026-03-01T10:00:00Z".to_string()),
1424 head_branch: None,
1425 pull_requests: Vec::new(),
1426 },
1427 ],
1428 jobs: vec![
1429 Job {
1430 id: 601,
1431 run_id: 1002,
1432 run_attempt: 2,
1433 name: "lint".to_string(),
1434 labels: vec!["macos-latest".to_string()],
1435 started_at: Some("2026-03-02T11:00:10Z".to_string()),
1436 completed_at: None,
1437 conclusion: None,
1438 runner_name: Some("GitHub Actions 90".to_string()),
1439 },
1440 Job {
1441 id: 501,
1442 run_id: 1001,
1443 run_attempt: 1,
1444 name: "build".to_string(),
1445 labels: vec!["ubuntu-latest".to_string()],
1446 started_at: Some("2026-03-01T10:00:05Z".to_string()),
1447 completed_at: Some("2026-03-01T10:02:05Z".to_string()),
1448 conclusion: Some("success".to_string()),
1449 runner_name: Some("GitHub Actions 88".to_string()),
1450 },
1451 Job {
1452 id: 77,
1453 run_id: 99,
1454 run_attempt: 1,
1455 name: "orphan".to_string(),
1456 labels: vec![],
1457 started_at: None,
1458 completed_at: Some("2026-03-04T00:01:00Z".to_string()),
1459 conclusion: None,
1460 runner_name: None,
1461 },
1462 ],
1463 }
1464 }
1465
1466 fn section<'a>(text: &'a str, heading: &str) -> &'a str {
1467 let marker = format!("\n{heading}\n");
1468 let start = text
1469 .find(&marker)
1470 .unwrap_or_else(|| panic!("missing {heading} in {text}"))
1471 + marker.len();
1472 let rest = &text[start..];
1473 let end = ["\nRuns\n", "\nJobs\n", "\nIn progress\n"]
1474 .into_iter()
1475 .filter_map(|item| rest.find(item))
1476 .min()
1477 .unwrap_or(rest.len());
1478 &rest[..end]
1479 }
1480
1481 #[test]
1482 fn runs_section_splits_finished_jobs_from_in_progress() {
1483 let workflows = vec![workflow(
1484 ".github/workflows/ci.yml",
1485 "3",
1486 "10",
1487 "actions_linux",
1488 )];
1489 let findings = vec![finding(
1490 "samples/.github/workflows/lint.yml",
1491 8,
1492 FindingKind::LintOnRunner,
1493 "lint on macOS",
1494 )];
1495 let api = sample_api();
1496 let text = render_with_runs(&workflows, &bill("3", "10"), Some(&findings), &api, None);
1497 assert!(text.contains("USD (from net_amount)\n"), "{text}");
1498 assert!(text.contains("Total net: 3.00\n"), "{text}");
1499 let finding_at = text.find("lint on macOS").unwrap();
1500 let runs_at = text.find("\nRuns\n").unwrap();
1501 assert!(finding_at < runs_at, "{text}");
1502
1503 let runs = section(&text, "Runs");
1504 assert!(
1505 runs.contains(
1506 "1001 .github/workflows/ci.yml attempt 1 number 40 push success 2026-03-01T10:00:00Z\n"
1507 ),
1508 "{runs}"
1509 );
1510 assert!(
1511 runs.contains(
1512 "1002 .github/workflows/lint.yml attempt 2 number 41 pull_request in progress 2026-03-02T11:00:00Z\n"
1513 ),
1514 "{runs}"
1515 );
1516 assert!(
1517 runs.find("1001").unwrap() < runs.find("1002").unwrap(),
1518 "{runs}"
1519 );
1520
1521 let jobs = section(&text, "Jobs");
1522 assert!(
1523 jobs.contains(
1524 "501 build run 1001 .github/workflows/ci.yml attempt 1 labels ubuntu-latest runner GitHub Actions 88 started 2026-03-01T10:00:05Z completed 2026-03-01T10:02:05Z success 2 min actions_linux list 0.01\n"
1525 ),
1526 "{jobs}"
1527 );
1528 assert!(
1529 jobs.contains(
1530 "77 orphan run 99 (unknown workflow) attempt 1 labels - runner - started - completed 2026-03-04T00:01:00Z - 1 min - list 0.00\n"
1531 ),
1532 "{jobs}"
1533 );
1534 assert!(!jobs.contains("\n601 "), "{jobs}");
1535 assert!(!jobs.contains("lint"), "{jobs}");
1536
1537 let running = section(&text, "In progress");
1538 assert_eq!(
1539 running,
1540 ".github/workflows/lint.yml\n601 lint run 1002 .github/workflows/lint.yml attempt 2 labels macos-latest runner GitHub Actions 90 started 2026-03-02T11:00:10Z in progress 0 min actions_macos list 0.00\n"
1541 );
1542 assert!(!running.contains("completed"), "{running}");
1543 assert_eq!(running.matches("in progress").count(), 1, "{running}");
1544 }
1545
1546 #[test]
1547 fn json_puts_in_progress_jobs_outside_the_counted_list() {
1548 let api = sample_api();
1549 let text = render_json_with_runs(
1550 &[],
1551 &bill("0", "0"),
1552 None,
1553 Some(usd("1.5")),
1554 &api,
1555 None,
1556 &[],
1557 );
1558 let value: serde_json::Value = serde_json::from_str(text.trim_end()).unwrap();
1559 assert_eq!(value["schema_version"], 2);
1560 assert_eq!(value["money_unit"], "USD");
1561 assert_eq!(value["budget"], "1.500000");
1562 assert_eq!(value["currency"], "USD");
1563 assert_eq!(value["budget_exceeded"], false);
1564
1565 let runs = value["workflow_runs"].as_array().unwrap();
1566 assert_eq!(runs.len(), 3);
1567 assert_eq!(runs[0]["id"], 99);
1568 assert_eq!(runs[0]["path"], "(unknown workflow)");
1569 assert!(runs[0]["event"].is_null());
1570 assert!(runs[0]["run_attempt"].is_null());
1571 assert_eq!(runs[0]["jobs"][0]["id"], 77);
1572 assert_eq!(runs[0]["jobs"][0]["counts"], true);
1573 assert_eq!(runs[0]["jobs"][0]["name"], "orphan");
1574
1575 assert_eq!(runs[1]["id"], 1001);
1576 assert_eq!(runs[1]["path"], ".github/workflows/ci.yml");
1577 assert_eq!(runs[1]["conclusion"], "success");
1578 assert_eq!(runs[1]["jobs"].as_array().unwrap().len(), 1);
1579 assert_eq!(runs[1]["jobs"][0]["id"], 501);
1580 assert_eq!(runs[1]["jobs"][0]["counts"], true);
1581 assert_eq!(runs[1]["jobs"][0]["name"], "build");
1582 assert!(runs[1]["jobs"][0]["completed_at"].is_string());
1583
1584 assert_eq!(runs[2]["id"], 1002);
1585 assert!(runs[2]["conclusion"].is_null());
1586 assert!(runs[2]["jobs"].as_array().unwrap().is_empty());
1587
1588 let running = value["in_progress"].as_array().unwrap();
1589 assert_eq!(running.len(), 1);
1590 assert_eq!(running[0]["id"], 601);
1591 assert_eq!(running[0]["run_id"], 1002);
1592 assert_eq!(running[0]["name"], "lint");
1593 assert_eq!(running[0]["counts"], false);
1594 assert_eq!(running[0]["message"], "in progress");
1595 assert!(running[0]["completed_at"].is_null());
1596 assert_eq!(running[0]["workflow_path"], ".github/workflows/lint.yml");
1597 assert!(running[0]["labels"]
1598 .as_array()
1599 .unwrap()
1600 .contains(&serde_json::json!("macos-latest")));
1601
1602 let counted_ids: Vec<u64> = runs
1603 .iter()
1604 .flat_map(|run| run["jobs"].as_array().unwrap())
1605 .map(|job| job["id"].as_u64().unwrap())
1606 .collect();
1607 assert!(!counted_ids.contains(&601));
1608 assert!(counted_ids.contains(&501));
1609 }
1610
1611 #[test]
1612 fn empty_runs_dir_prints_empty_sections() {
1613 let api = crate::api::ApiData {
1614 public: false,
1615 runs: Vec::new(),
1616 jobs: Vec::new(),
1617 };
1618 let text = render_with_runs(&[], &bill("0", "0"), None, &api, None);
1619 assert!(text.contains("\nRuns\n(none)\n"), "{text}");
1620 assert!(text.contains("\nJobs\n(none)\n"), "{text}");
1621 assert!(text.contains("\nIn progress\n(none)\n"), "{text}");
1622 }
1623
1624 #[test]
1625 fn report_without_runs_omits_actions_sections() {
1626 let text = render(&[], &bill("0", "0"), None, None);
1627 assert!(!text.contains("\nRuns\n"), "{text}");
1628 assert!(!text.contains("\nJobs\n"), "{text}");
1629 assert!(!text.contains("In progress"), "{text}");
1630 assert!(text.contains("Period: (none)\n"), "{text}");
1631 assert!(text.contains("Repositories: (none)\n"), "{text}");
1632
1633 let json = render_json(&[], &bill("0", "0"), None, None, None, &[]);
1634 let value: serde_json::Value = serde_json::from_str(json.trim_end()).unwrap();
1635 assert!(value.get("workflow_runs").is_none(), "{value}");
1636 assert!(value.get("in_progress").is_none(), "{value}");
1637 assert!(value.get("outside_top").is_none(), "{value}");
1638 }
1639
1640 fn named(repository: &str, path: &str, net: &str) -> WorkflowTotal {
1641 let mut workflow = workflow(path, net, "1", "actions_linux");
1642 workflow.repository = repository.to_string();
1643 workflow.gross = workflow.net;
1644 workflow
1645 }
1646
1647 fn outside_heading(text: &str) -> usize {
1648 text.find("Findings outside the top")
1649 .unwrap_or_else(|| panic!("missing outside heading in {text}"))
1650 }
1651
1652 #[test]
1653 fn top_one_keeps_an_omitted_finding_off_workflows_without_usage() {
1654 let workflows = vec![
1655 named("example-org/app", ".github/workflows/lint.yml", "10"),
1656 named("example-org/app", ".github/workflows/docs.yml", "2.112"),
1657 named("example-org/app", "(no workflow)", "0"),
1658 ];
1659 let findings = vec![
1660 finding(
1661 "samples/.github/workflows/lint.yml",
1662 8,
1663 FindingKind::LintOnRunner,
1664 "lint on macOS",
1665 ),
1666 finding(
1667 "samples/.github/workflows/docs.yml",
1668 4,
1669 FindingKind::MissingTimeout,
1670 "default timeout is 360 minutes",
1671 ),
1672 finding(
1673 "samples/.github/workflows/nightly.yml",
1674 3,
1675 FindingKind::MissingTimeout,
1676 "default timeout is 360 minutes",
1677 ),
1678 ];
1679 let totals = bill("12.112", "3");
1680 let text = render(&workflows, &totals, Some(&findings), Some(1));
1681 let outside = outside_heading(&text);
1682 let docs = text
1683 .find("F2 samples/.github/workflows/docs.yml:4: default timeout is 360 minutes")
1684 .expect("docs finding");
1685 let lint = text
1686 .find(" F1 samples/.github/workflows/lint.yml:8: lint on macOS")
1687 .expect("lint finding");
1688 let without = text
1689 .find("Workflows without usage")
1690 .expect("nightly has no usage row");
1691 let nightly = text
1692 .find("F2 samples/.github/workflows/nightly.yml:3: default timeout is 360 minutes")
1693 .expect("nightly");
1694 assert!(
1695 lint < outside && outside < docs && docs < without && without < nightly,
1696 "{text}"
1697 );
1698 assert!(text.contains("2 more workflows (2.11 USD)\n"), "{text}");
1699 assert!(text.contains("Findings outside the top 1\n"), "{text}");
1700 assert!(
1701 text.contains(
1702 "example-org/app .github/workflows/docs.yml\n F2 samples/.github/workflows/docs.yml:4: default timeout is 360 minutes\n"
1703 ),
1704 "{text}"
1705 );
1706 assert_eq!(text.matches("docs.yml:4").count(), 1, "{text}");
1707 let without_block = &text[without..];
1708 assert!(!without_block.contains("docs.yml"), "{text}");
1709 assert!(text.contains("10.00"), "{text}");
1710 assert!(text.contains("Total net: 12.11\n"), "{text}");
1711
1712 let full = render(&workflows, &totals, Some(&findings), None);
1713 assert!(!full.contains("more workflow"), "{full}");
1714 assert!(!full.contains("Findings outside"), "{full}");
1715 assert!(full.contains("Workflows without usage"), "{full}");
1716 assert!(full.find("docs.yml:4").unwrap() < full.find("Workflows without usage").unwrap());
1717 }
1718
1719 #[test]
1720 fn top_zero_puts_every_usage_finding_outside_the_table() {
1721 let workflows = vec![
1722 named("example-org/app", ".github/workflows/lint.yml", "10"),
1723 named("example-org/app", ".github/workflows/docs.yml", "2.112"),
1724 ];
1725 let findings = vec![
1726 finding(
1727 "app/.github/workflows/lint.yml",
1728 8,
1729 FindingKind::LintOnRunner,
1730 "lint on macOS",
1731 ),
1732 finding(
1733 "app/.github/workflows/docs.yml",
1734 4,
1735 FindingKind::MissingTimeout,
1736 "default timeout is 360 minutes",
1737 ),
1738 finding(
1739 "app/.github/workflows/nightly.yml",
1740 2,
1741 FindingKind::MissingTimeout,
1742 "default timeout is 360 minutes",
1743 ),
1744 ];
1745 let totals = bill("12.112", "2");
1746 let text = render(&workflows, &totals, Some(&findings), Some(0));
1747 assert!(text.contains("2 more workflows (12.11 USD)\n"), "{text}");
1748 assert!(text.contains("Findings outside the top 0\n"), "{text}");
1749 let outside = outside_heading(&text);
1750 let lint = text.find("lint on macOS").unwrap();
1751 let docs = text.find("docs.yml:4").unwrap();
1752 let without = text.find("Workflows without usage").unwrap();
1753 assert!(outside < lint && lint < docs && docs < without, "{text}");
1754 assert!(!text.contains("10.00"), "{text}");
1755 let table = &text[..outside];
1756 assert!(!table.contains("lint.yml"), "{text}");
1757 assert!(!table.contains("docs.yml"), "{text}");
1758 let without_block = &text[without..];
1759 assert!(without_block.contains("nightly.yml:2"), "{without_block}");
1760 assert!(!without_block.contains("docs.yml"), "{text}");
1761 assert!(!without_block.contains("lint.yml"), "{text}");
1762 }
1763
1764 #[test]
1765 fn top_at_or_above_the_workflow_count_matches_the_full_report() {
1766 let workflows = vec![
1767 named("example-org/app", ".github/workflows/lint.yml", "10"),
1768 named("example-org/app", ".github/workflows/docs.yml", "2.112"),
1769 named("", "(no workflow)", "0.004"),
1770 ];
1771 let findings = vec![finding(
1772 "repo/.github/workflows/docs.yml",
1773 4,
1774 FindingKind::MissingTimeout,
1775 "default timeout is 360 minutes",
1776 )];
1777 let totals = bill("12.116", "3");
1778 let full = render(&workflows, &totals, Some(&findings), None);
1779 let full_json = render_json(&workflows, &totals, Some(&findings), None, None, &[]);
1780 for limit in [3_usize, 4, 100] {
1781 let text = render(&workflows, &totals, Some(&findings), Some(limit));
1782 assert_eq!(text, full, "top {limit}");
1783 let json = render_json(&workflows, &totals, Some(&findings), None, Some(limit), &[]);
1784 assert_eq!(json, full_json, "top {limit}");
1785 }
1786 assert!(!full.contains("Workflows without usage"), "{full}");
1787 assert!(!full.contains("more workflow"), "{full}");
1788 assert!(!full.contains("Findings outside"), "{full}");
1789 assert!(full.contains("docs.yml:4"), "{full}");
1790 let value: serde_json::Value = serde_json::from_str(full_json.trim_end()).unwrap();
1791 assert!(value.get("outside_top").is_none(), "{value}");
1792 assert_eq!(value["workflows"].as_array().unwrap().len(), 3);
1793 assert_eq!(value["workflows"][1]["findings"][0]["line"], 4);
1794 }
1795
1796 #[test]
1797 fn one_omitted_workflow_uses_a_singular_summary() {
1798 let workflows = vec![
1799 named("example-org/app", ".github/workflows/lint.yml", "10"),
1800 named("example-org/app", ".github/workflows/docs.yml", "2.112"),
1801 ];
1802 let findings = vec![finding(
1803 "app/.github/workflows/docs.yml",
1804 4,
1805 FindingKind::MissingTimeout,
1806 "default timeout is 360 minutes",
1807 )];
1808 let text = render(&workflows, &bill("12.112", "2"), Some(&findings), Some(1));
1809 assert!(text.contains("1 more workflow (2.11 USD)\n"), "{text}");
1810 assert!(!text.contains("1 more workflows"), "{text}");
1811 assert!(text.contains("Findings outside the top 1\n"), "{text}");
1812 assert!(!text.contains("Workflows without usage"), "{text}");
1813 }
1814
1815 #[test]
1816 fn omitted_workflows_without_findings_skip_the_findings_block() {
1817 let workflows = vec![
1818 named("example-org/app", ".github/workflows/lint.yml", "3"),
1819 named("example-org/app", ".github/workflows/ci.yml", "1"),
1820 ];
1821 let text = render(&workflows, &bill("4", "2"), None, Some(1));
1822 assert!(text.contains("1 more workflow (1.00 USD)\n"), "{text}");
1823 assert!(!text.contains("Findings outside"), "{text}");
1824 assert!(!text.contains("Workflows without usage"), "{text}");
1825 }
1826
1827 #[test]
1828 fn outside_summary_rounds_the_unrounded_sum() {
1829 let workflows = vec![
1830 named("example-org/app", ".github/workflows/lint.yml", "10"),
1831 named("example-org/app", ".github/workflows/ci.yml", "0.004"),
1832 named("example-org/app", ".github/workflows/docs.yml", "0.004"),
1833 ];
1834 let text = render(&workflows, &bill("10.008", "3"), None, Some(1));
1835 assert!(text.contains("2 more workflows (0.01 USD)\n"), "{text}");
1836 assert!(!text.contains("0.00 USD"), "{text}");
1837
1838 let quiet = vec![
1839 named("example-org/app", ".github/workflows/lint.yml", "10"),
1840 named("example-org/app", ".github/workflows/ci.yml", "0.002"),
1841 named("example-org/app", ".github/workflows/docs.yml", "0.002"),
1842 ];
1843 let quiet_text = render(&quiet, &bill("10.004", "3"), None, Some(1));
1844 assert!(
1845 quiet_text.contains("2 more workflows (0.00 USD)\n"),
1846 "{quiet_text}"
1847 );
1848 }
1849
1850 #[test]
1851 fn invoice_finding_on_an_omitted_workflow_stays_with_that_workflow() {
1852 let workflows = vec![
1853 named("example-org/alpha", ".github/workflows/lint.yml", "10"),
1854 named("example-org/zeta", ".github/workflows/ci.yml", "2.112"),
1855 ];
1856 let findings = vec![Finding::placed(
1857 "usage.csv",
1858 3,
1859 FindingKind::InvoiceMismatch,
1860 "invoice row mismatch: sku \"actions_linux\", date \"2026-09-02\", gross 2.112000 != quantity * applied_cost_per_quantity 9.000000",
1861 Some("example-org/zeta".to_string()),
1862 Some(".github/workflows/ci.yml".to_string()),
1863 )];
1864 let text = render(&workflows, &bill("12.112", "2"), Some(&findings), Some(1));
1865 assert!(text.contains("1 more workflow (2.11 USD)\n"), "{text}");
1866 assert!(
1867 text.contains(
1868 "example-org/zeta .github/workflows/ci.yml\n F9 usage.csv:3: invoice row mismatch"
1869 ),
1870 "{text}"
1871 );
1872 assert!(!text.contains("Workflows without usage"), "{text}");
1873 let outside = outside_heading(&text);
1874 assert!(text.find("F9 usage.csv:3").unwrap() > outside, "{text}");
1875
1876 let value: serde_json::Value = serde_json::from_str(
1877 render_json(
1878 &workflows,
1879 &bill("12.112", "2"),
1880 Some(&findings),
1881 None,
1882 Some(1),
1883 &[],
1884 )
1885 .trim_end(),
1886 )
1887 .unwrap();
1888 assert!(value["unattached_findings"].as_array().unwrap().is_empty());
1889 assert_eq!(
1890 value["outside_top"]["workflows"][0]["repository"],
1891 "example-org/zeta"
1892 );
1893 assert_eq!(
1894 value["outside_top"]["workflows"][0]["findings"][0]["id"],
1895 "F9"
1896 );
1897 assert_eq!(value["workflows"].as_array().unwrap().len(), 1);
1898 assert!(value["workflows"][0]["findings"]
1899 .as_array()
1900 .unwrap()
1901 .is_empty());
1902 }
1903
1904 #[test]
1905 fn json_outside_top_keeps_findings_on_the_omitted_workflows() {
1906 let workflows = vec![
1907 named("example-org/app", ".github/workflows/lint.yml", "10"),
1908 named("example-org/app", ".github/workflows/docs.yml", "2.112"),
1909 named("example-org/app", "(no workflow)", "0"),
1910 ];
1911 let findings = vec![
1912 finding(
1913 "app/.github/workflows/lint.yml",
1914 8,
1915 FindingKind::LintOnRunner,
1916 "lint on macOS",
1917 ),
1918 finding(
1919 "app/.github/workflows/docs.yml",
1920 4,
1921 FindingKind::MissingTimeout,
1922 "default timeout is 360 minutes",
1923 ),
1924 finding(
1925 "app/.github/workflows/nightly.yml",
1926 2,
1927 FindingKind::MissingTimeout,
1928 "default timeout is 360 minutes",
1929 ),
1930 ];
1931 let totals = bill("12.112", "3");
1932 let text = render_json(
1933 &workflows,
1934 &totals,
1935 Some(&findings),
1936 Some(usd("1")),
1937 Some(1),
1938 &[],
1939 );
1940 assert!(!text.contains("0000000001"), "{text}");
1941 let value: serde_json::Value = serde_json::from_str(text.trim_end()).unwrap();
1942 assert_eq!(value["schema_version"], 2);
1943 assert_eq!(value["workflows"].as_array().unwrap().len(), 1);
1944 assert_eq!(value["workflows"][0]["path"], ".github/workflows/lint.yml");
1945 assert_eq!(value["workflows"][0]["findings"][0]["id"], "F1");
1946 assert_eq!(value["outside_top"]["count"], 2);
1947 assert_eq!(value["outside_top"]["net"], "2.112000");
1948 let omitted = value["outside_top"]["workflows"].as_array().unwrap();
1949 assert_eq!(omitted.len(), 2);
1950 assert_eq!(omitted[0]["path"], ".github/workflows/docs.yml");
1951 assert_eq!(omitted[0]["net"], "2.112000");
1952 assert_eq!(
1953 omitted[0]["findings"][0]["file"],
1954 "app/.github/workflows/docs.yml"
1955 );
1956 assert_eq!(omitted[0]["findings"][0]["line"], 4);
1957 assert_eq!(omitted[1]["path"], "(no workflow)");
1958 assert!(omitted[1]["findings"].as_array().unwrap().is_empty());
1959 assert_eq!(
1960 value["unattached_findings"][0]["file"],
1961 "app/.github/workflows/nightly.yml"
1962 );
1963 assert_eq!(value["unattached_findings"].as_array().unwrap().len(), 1);
1964 assert_eq!(value["budget"], "1.000000");
1965 assert_eq!(value["budget_exceeded"], true);
1966
1967 let zero = render_json(&workflows, &totals, Some(&findings), None, Some(0), &[]);
1968 let zero_value: serde_json::Value = serde_json::from_str(zero.trim_end()).unwrap();
1969 assert!(zero_value["workflows"].as_array().unwrap().is_empty());
1970 assert_eq!(zero_value["outside_top"]["count"], 3);
1971 assert_eq!(zero_value["outside_top"]["net"], "12.112000");
1972 assert_eq!(
1973 zero_value["outside_top"]["workflows"][0]["findings"][0]["id"],
1974 "F1"
1975 );
1976 assert_eq!(
1977 zero_value["outside_top"]["workflows"][1]["findings"][0]["id"],
1978 "F2"
1979 );
1980 assert!(zero_value["outside_top"]["workflows"][2]["findings"]
1981 .as_array()
1982 .unwrap()
1983 .is_empty());
1984 assert_eq!(
1985 zero_value["unattached_findings"].as_array().unwrap().len(),
1986 1
1987 );
1988 }
1989
1990 #[test]
1991 fn runs_section_stays_after_findings_outside_the_top() {
1992 let workflows = vec![
1993 named("example-org/app", ".github/workflows/ci.yml", "3"),
1994 named("example-org/app", ".github/workflows/docs.yml", "1"),
1995 ];
1996 let findings = vec![finding(
1997 "samples/.github/workflows/docs.yml",
1998 4,
1999 FindingKind::MissingTimeout,
2000 "default timeout is 360 minutes",
2001 )];
2002 let api = sample_api();
2003 let text = render_with_runs(&workflows, &bill("4", "2"), Some(&findings), &api, Some(1));
2004 let outside = outside_heading(&text);
2005 let runs = text.find("\nRuns\n").unwrap();
2006 assert!(outside < runs, "{text}");
2007 assert!(text.contains("1 more workflow (1.00 USD)\n"), "{text}");
2008 assert!(
2009 section(&text, "In progress").contains("601 lint"),
2010 "{text}"
2011 );
2012 assert!(section(&text, "Jobs").contains("501 build"), "{text}");
2013
2014 let json = render_json_with_runs(
2015 &workflows,
2016 &bill("4", "2"),
2017 Some(&findings),
2018 None,
2019 &api,
2020 Some(1),
2021 &[],
2022 );
2023 let value: serde_json::Value = serde_json::from_str(json.trim_end()).unwrap();
2024 assert_eq!(value["outside_top"]["count"], 1);
2025 assert!(value.get("workflow_runs").is_some(), "{value}");
2026 assert!(value.get("in_progress").is_some(), "{value}");
2027 assert_eq!(
2028 value["outside_top"]["workflows"][0]["findings"][0]["line"],
2029 4
2030 );
2031 }
2032
2033 #[test]
2034 fn json_steps_follow_the_workflow_file_and_survive_top() {
2035 let lint = crate::workflow::scan_yaml(
2036 std::path::Path::new("repo/.github/workflows/lint.yml"),
2037 "jobs:\n lint:\n steps:\n - name: Lint\n run: |\n echo one\n echo two\n - uses: actions/checkout@v4\n",
2038 )
2039 .unwrap();
2040 let docs = crate::workflow::scan_yaml(
2041 std::path::Path::new("repo/.github/workflows/docs.yml"),
2042 "jobs:\n docs:\n steps:\n - run: cargo doc --no-deps\n",
2043 )
2044 .unwrap();
2045 let scanned = [lint, docs];
2046 let workflows = vec![
2047 named("example-org/alpha", ".github/workflows/lint.yml", "10"),
2048 named("example-org/beta", ".GitHub/Workflows/Lint.YML", "10"),
2049 named("example-org/app", ".github/workflows/docs.yml", "2"),
2050 named("example-org/app", "(no workflow)", "0"),
2051 ];
2052 let text = render(&workflows, &bill("22", "4"), None, None);
2053 assert!(!text.contains("echo one"), "{text}");
2054 assert!(!text.contains("actions/checkout@v4"), "{text}");
2055 assert!(text.contains("Workflow"), "{text}");
2056 assert!(!text.contains("steps"), "{text}");
2057
2058 let json = render_json(&workflows, &bill("22", "4"), None, None, Some(1), &scanned);
2059 let value: serde_json::Value = serde_json::from_str(json.trim_end()).unwrap();
2060 let lint_steps = serde_json::json!([
2061 {"name": "Lint", "line": 4},
2062 {"name": "actions/checkout@v4", "line": 8}
2063 ]);
2064 assert_eq!(value["workflows"][0]["steps"], lint_steps);
2065 assert_eq!(value["workflows"][0]["path"], ".github/workflows/lint.yml");
2066 let outside = value["outside_top"]["workflows"].as_array().unwrap();
2067 assert_eq!(outside[0]["steps"], lint_steps);
2068 assert_eq!(outside[0]["path"], ".GitHub/Workflows/Lint.YML");
2069 assert_eq!(
2070 outside[1]["steps"],
2071 serde_json::json!([{"name": "cargo doc --no-deps", "line": 4}])
2072 );
2073 assert_eq!(outside[1]["path"], ".github/workflows/docs.yml");
2074 assert_eq!(outside[2]["steps"], serde_json::json!([]));
2075 assert_eq!(outside[2]["path"], "(no workflow)");
2076
2077 let slash = named("example-org/app", ".github\\workflows\\docs.yml", "2");
2078 let slash_json = render_json(&[slash], &bill("2", "1"), None, None, None, &scanned);
2079 let slash_value: serde_json::Value = serde_json::from_str(slash_json.trim_end()).unwrap();
2080 assert_eq!(
2081 slash_value["workflows"][0]["steps"][0]["name"],
2082 "cargo doc --no-deps"
2083 );
2084 assert_eq!(slash_value["workflows"][0]["steps"][0]["line"], 4);
2085
2086 let bare = render_json(&workflows, &bill("22", "4"), None, None, None, &[]);
2087 let bare_value: serde_json::Value = serde_json::from_str(bare.trim_end()).unwrap();
2088 assert_eq!(bare_value["workflows"][0]["steps"], serde_json::json!([]));
2089 assert!(!bare.contains("echo one"), "{bare}");
2090 }
2091
2092 fn sample_job(
2093 id: u64,
2094 attempt: u64,
2095 name: &str,
2096 started: &str,
2097 completed: Option<&str>,
2098 ) -> Job {
2099 Job {
2100 id,
2101 run_id: 1,
2102 run_attempt: attempt,
2103 name: name.to_string(),
2104 labels: vec!["ubuntu-latest".to_string()],
2105 started_at: Some(started.to_string()),
2106 completed_at: completed.map(str::to_string),
2107 conclusion: completed.map(|_| "success".to_string()),
2108 runner_name: Some("runner-a".to_string()),
2109 }
2110 }
2111
2112 #[test]
2113 fn linked_path_and_inherited_copy_show_zero_cost() {
2114 let api = ApiData {
2115 public: false,
2116 runs: vec![WorkflowRun {
2117 id: 1,
2118 path: "octocat/octo-repo/.github/workflows/ci.yml@refs/heads/feature/foo"
2119 .to_string(),
2120 repository: None,
2121 run_attempt: 2,
2122 run_number: 4,
2123 event: "push".to_string(),
2124 conclusion: Some("success".to_string()),
2125 run_started_at: Some("2026-04-01T12:00:00Z".to_string()),
2126 head_branch: None,
2127 pull_requests: Vec::new(),
2128 }],
2129 jobs: vec![
2130 sample_job(
2131 10,
2132 1,
2133 "build",
2134 "2026-04-01T10:00:00Z",
2135 Some("2026-04-01T10:05:00Z"),
2136 ),
2137 sample_job(
2138 11,
2139 2,
2140 "build",
2141 "2026-04-01T10:00:00Z",
2142 Some("2026-04-01T10:05:00Z"),
2143 ),
2144 sample_job(
2145 12,
2146 2,
2147 "lint",
2148 "2026-04-01T12:00:30Z",
2149 Some("2026-04-01T12:01:00Z"),
2150 ),
2151 sample_job(13, 2, "pack", "2026-04-01T11:00:00Z", None),
2152 ],
2153 };
2154 let text = render_with_runs(&[], &bill("0", "0"), None, &api, None);
2155 assert!(
2156 !text.to_ascii_lowercase().contains("not supported"),
2157 "{text}"
2158 );
2159 assert!(!text.to_ascii_lowercase().contains("skipped"), "{text}");
2160 assert!(!text.contains('@'), "{text}");
2161 assert!(!text.contains("octocat"), "{text}");
2162 assert!(!text.contains("refs/heads"), "{text}");
2163 let runs = section(&text, "Runs");
2164 assert!(
2165 runs.contains(
2166 "1 .github/workflows/ci.yml attempt 2 number 4 push success 2026-04-01T12:00:00Z\n"
2167 ),
2168 "{runs}"
2169 );
2170 let jobs = section(&text, "Jobs");
2171 assert!(
2172 jobs.contains(
2173 "10 build run 1 .github/workflows/ci.yml attempt 1 labels ubuntu-latest runner runner-a started 2026-04-01T10:00:00Z completed 2026-04-01T10:05:00Z success 5 min actions_linux list 0.03\n"
2174 ),
2175 "{jobs}"
2176 );
2177 assert!(
2178 jobs.contains(
2179 "11 build run 1 .github/workflows/ci.yml attempt 2 labels ubuntu-latest runner runner-a started 2026-04-01T10:00:00Z completed 2026-04-01T10:05:00Z success 0 min actions_linux list 0.00 inherited copy cost 0.00\n"
2180 ),
2181 "{jobs}"
2182 );
2183 assert!(
2184 jobs.contains(
2185 "12 lint run 1 .github/workflows/ci.yml attempt 2 labels ubuntu-latest runner runner-a started 2026-04-01T12:00:30Z completed 2026-04-01T12:01:00Z success 1 min actions_linux list 0.01\n"
2186 ),
2187 "{jobs}"
2188 );
2189 assert!(!jobs.contains("13 pack"), "{jobs}");
2190 let running = section(&text, "In progress");
2191 assert_eq!(
2192 running,
2193 ".github/workflows/ci.yml\n13 pack run 1 .github/workflows/ci.yml attempt 2 labels ubuntu-latest runner runner-a started 2026-04-01T11:00:00Z in progress 0 min actions_linux list 0.00 inherited copy cost 0.00\n"
2194 );
2195
2196 let json = render_json_with_runs(&[], &bill("0", "0"), None, None, &api, None, &[]);
2197 assert!(
2198 !json.to_ascii_lowercase().contains("not supported"),
2199 "{json}"
2200 );
2201 assert!(!json.to_ascii_lowercase().contains("skipped"), "{json}");
2202 let value: serde_json::Value = serde_json::from_str(json.trim_end()).unwrap();
2203 assert_eq!(
2204 value["workflow_runs"][0]["path"],
2205 ".github/workflows/ci.yml"
2206 );
2207 let listed = value["workflow_runs"][0]["jobs"].as_array().unwrap();
2208 assert_eq!(listed.len(), 3);
2209 let by_id = |id: u64| {
2210 listed
2211 .iter()
2212 .find(|job| job["id"].as_u64() == Some(id))
2213 .unwrap()
2214 };
2215 assert_eq!(by_id(10)["inherited_copy"], false);
2216 assert_eq!(by_id(10)["billable"], true);
2217 assert_eq!(by_id(10)["counts"], true);
2218 assert!(by_id(10).get("net_amount").is_none());
2219 assert_eq!(by_id(11)["inherited_copy"], true);
2220 assert_eq!(by_id(11)["billable"], false);
2221 assert_eq!(by_id(11)["counts"], true);
2222 assert_eq!(by_id(11)["net_amount"], "0.000000");
2223 assert_eq!(by_id(12)["billable"], true);
2224 assert!(by_id(12).get("net_amount").is_none());
2225 let progress = &value["in_progress"][0];
2226 assert_eq!(progress["id"], 13);
2227 assert_eq!(progress["workflow_path"], ".github/workflows/ci.yml");
2228 assert_eq!(progress["inherited_copy"], true);
2229 assert_eq!(progress["billable"], false);
2230 assert_eq!(progress["counts"], false);
2231 assert_eq!(progress["message"], "in progress");
2232 assert_eq!(progress["net_amount"], "0.000000");
2233 }
2234}