Skip to main content

ci_bill/
report.rs

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/// Text report. Money is rounded to cents here. Totals are sums of raw micros.
13///
14/// `bill.total_net` and `bill.total_minutes` are the Actions bill. `workflows`
15/// is the full ranked list. `top` limits the table to that many rows; each
16/// share is still that workflow's part of the Actions net. Workflows past the
17/// limit are one line, `N more workflows (X USD)`, and their findings are
18/// listed under `Findings outside the top N`. Other products are one line,
19/// outside the Actions total.
20/// Each workflow row shows net, gross, and discount, so a storage row whose
21/// net is zero still shows a positive gross.
22/// Findings attach to a workflow when the YAML path ends in
23/// `.github/workflows/<file>` and that suffix matches `workflow_path`.
24/// Invoice findings attach by repository and workflow path instead.
25/// A callee finding with `workflow_path` set and no repository is listed on
26/// that caller. Its file and line stay on the callee.
27/// A finding with no usage row is listed under "Workflows without usage".
28#[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
38/// Text report with saved runs and scanned workflow files.
39///
40/// Each shown workflow lists measured list price, the CSV invoice, and the
41/// difference, then one line per reconciled job. Jobs hidden by `--top` stay
42/// out of this table; their amounts are in the JSON object.
43/// When `repo.json` says the repository is public, each job line also shows
44/// its invoice. A standard SKU invoices at 0.00 and keeps its list price.
45/// A larger runner keeps its charge. A CSV net replaces that invoice.
46pub 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/// Text report plus saved Actions runs and jobs.
58///
59/// Run paths are linked to a CSV workflow path: an `@ref` suffix and an
60/// `owner/repo/` prefix are removed. Finished jobs are listed under "Jobs",
61/// grouped by workflow and sorted by list price. Each job shows billed
62/// minutes and the runner list price. A job with no completion time is
63/// listed under "In progress" and bills nothing. A job on the latest attempt
64/// that started before the run started is an inherited copy and costs 0.00.
65#[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
154/// One JSON object. Money is a 6-decimal string. Minutes stay a JSON number when whole.
155///
156/// `workflows` lists only the rows `--top` keeps. When that limit omits
157/// workflows, `outside_top` holds their count, their net, and the same
158/// workflow objects, findings included. `unattached_findings` is only
159/// findings with no usage row. Every workflow object has `steps`, an array
160/// of `{name, line}` from the scanned file with the same
161/// `.github/workflows/<file>` name, in job order. A workflow with no scanned
162/// file has an empty `steps` array.
163pub 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
174/// JSON report plus `workflow_runs` and `in_progress`.
175///
176/// Finished jobs sit on their run. An in-progress job is only in
177/// `in_progress`, with `counts` false, `minutes` 0, and message "in progress".
178/// Each job has `minutes`, `sku`, `list_price`, and `usd_per_minute`.
179/// `list_price` is a 6-decimal USD string. `path` is the linked workflow path.
180/// An inherited copy has `inherited_copy` true, `billable` false,
181/// `minutes` 0, `list_price` `"0.000000"`, and `net_amount` `"0.000000"`.
182pub 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
264/// Status line when the Actions net total is above the budget. Amounts use cent rounding.
265pub 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    // The caller already lists this callee finding. Invoice rows carry a
328    // repository, so an unmatched invoice row stays in the unattached list.
329    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
355/// `.github/workflows/<file>` taken from the YAML path.
356fn 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
459/// Steps from every scanned file whose name is `.github/workflows/<file>`
460/// for this usage path. Matching ignores ASCII case. Jobs stay in file order.
461fn 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
522/// How many ranked workflows the table shows. `None` shows every workflow.
523fn 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
541/// Summary of workflows `--top` left out, then their findings.
542///
543/// The money is the sum of those workflows' nets, rounded to cents only here.
544/// A workflow with no findings still counts in the summary. The findings block
545/// is omitted when none of them have a finding.
546fn 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
917/// Finished jobs that add cost, then inherited copies, in report order.
918///
919/// A copy stays visible with its own cost. It is not part of `billable_jobs`.
920fn 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}