Skip to main content

ci_bill/
rank.rs

1use std::collections::{BTreeSet, HashMap};
2
3use crate::money::Micros;
4use crate::usage::{is_actions_product, UsageError, UsageErrorKind, UsageRow};
5
6/// Cost of one SKU inside a repository workflow.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct SkuTotal {
9    pub sku: String,
10    pub net: Micros,
11    pub gross: Micros,
12    pub discount: Micros,
13    pub minutes: Micros,
14}
15
16/// Cost of one repository + workflow path, with a total per SKU.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct WorkflowTotal {
19    pub repository: String,
20    pub workflow_path: String,
21    pub net: Micros,
22    pub gross: Micros,
23    pub discount: Micros,
24    pub minutes: Micros,
25    pub skus: Vec<SkuTotal>,
26}
27
28/// Actions totals plus the rest of the bill that the report still has to show.
29///
30/// `total_net` and `total_minutes` count product `actions` only. Other products
31/// are summed into `other_products_net` and stay outside the Actions total.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Bill {
34    pub total_net: Micros,
35    pub total_minutes: Micros,
36    pub other_products_net: Micros,
37    pub period_from: Option<String>,
38    pub period_to: Option<String>,
39    pub repositories: Vec<String>,
40}
41
42/// Ranked Actions workflows plus the bill those rows came from.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Ranking {
45    pub workflows: Vec<WorkflowTotal>,
46    pub bill: Bill,
47}
48
49/// Totals per repository and workflow path, highest Actions net first.
50///
51/// Equal net sorts by repository, then by path, A to Z. SKUs under a workflow
52/// use the same money order, then SKU name. `top` keeps that many workflows.
53/// The bill totals always cover every Actions row, including ones `top` hides.
54///
55/// Minutes add `quantity` only when `unit_type` is `minutes`, ignoring case.
56/// Other unit types still add to net and gross. Product names other than
57/// `actions` (any ASCII case) do not enter the workflow list.
58///
59/// Every sum is checked: a row that would push a per-workflow, per-SKU, or
60/// grand total out of `i64` micros is a clean input error naming that row's
61/// line, the same as any other bad amount in the CSV.
62pub fn rank(rows: &[UsageRow], top: Option<usize>) -> Result<Ranking, UsageError> {
63    let mut totals: HashMap<(String, String), WorkflowAcc> = HashMap::new();
64    let mut bill = Bill {
65        total_net: Micros::ZERO,
66        total_minutes: Micros::ZERO,
67        other_products_net: Micros::ZERO,
68        period_from: None,
69        period_to: None,
70        repositories: Vec::new(),
71    };
72    let mut repositories = BTreeSet::new();
73
74    for row in rows {
75        track_date(&mut bill.period_from, &mut bill.period_to, &row.date);
76        if !row.repository.is_empty() {
77            repositories.insert(row.repository.clone());
78        }
79        if !is_actions_product(&row.product) {
80            bill.other_products_net =
81                checked_total(bill.other_products_net, row.net, row.line, "net_amount")?;
82            continue;
83        }
84        let key = (row.repository.clone(), row.workflow_path.clone());
85        add(totals.entry(key).or_default(), row)?;
86    }
87
88    let mut workflows: Vec<WorkflowTotal> = totals.into_iter().map(finish_workflow).collect();
89    workflows.sort_by(|left, right| {
90        right
91            .net
92            .cmp(&left.net)
93            .then_with(|| left.repository.cmp(&right.repository))
94            .then_with(|| left.workflow_path.cmp(&right.workflow_path))
95    });
96    for workflow in &workflows {
97        // Each `workflow.net`/`workflow.minutes` is already a sum over many rows, so
98        // no single line is to blame if adding it to the grand total overflows.
99        bill.total_net = checked_total(bill.total_net, workflow.net, 0, "net_amount")?;
100        bill.total_minutes = checked_total(bill.total_minutes, workflow.minutes, 0, "quantity")?;
101    }
102    if let Some(limit) = top {
103        workflows.truncate(limit);
104    }
105    bill.repositories = repositories.into_iter().collect();
106    Ok(Ranking { workflows, bill })
107}
108
109/// `total + amount`, or an `AmountOverflow` input error naming `line` and `column`.
110fn checked_total(
111    total: Micros,
112    amount: Micros,
113    line: u64,
114    column: &'static str,
115) -> Result<Micros, UsageError> {
116    total.checked_add(amount).ok_or(UsageError {
117        line,
118        kind: UsageErrorKind::AmountOverflow(column),
119    })
120}
121
122#[derive(Default)]
123struct WorkflowAcc {
124    net: Micros,
125    gross: Micros,
126    discount: Micros,
127    minutes: Micros,
128    skus: HashMap<String, SkuAcc>,
129}
130
131#[derive(Default)]
132struct SkuAcc {
133    net: Micros,
134    gross: Micros,
135    discount: Micros,
136    minutes: Micros,
137}
138
139fn add(acc: &mut WorkflowAcc, row: &UsageRow) -> Result<(), UsageError> {
140    let minutes = if row.unit_type.eq_ignore_ascii_case("minutes") {
141        row.quantity
142    } else {
143        Micros::ZERO
144    };
145    acc.net = checked_total(acc.net, row.net, row.line, "net_amount")?;
146    acc.gross = checked_total(acc.gross, row.gross, row.line, "gross_amount")?;
147    acc.discount = checked_total(acc.discount, row.discount, row.line, "discount_amount")?;
148    acc.minutes = checked_total(acc.minutes, minutes, row.line, "quantity")?;
149    let sku = acc.skus.entry(row.sku.clone()).or_default();
150    sku.net = checked_total(sku.net, row.net, row.line, "net_amount")?;
151    sku.gross = checked_total(sku.gross, row.gross, row.line, "gross_amount")?;
152    sku.discount = checked_total(sku.discount, row.discount, row.line, "discount_amount")?;
153    sku.minutes = checked_total(sku.minutes, minutes, row.line, "quantity")?;
154    Ok(())
155}
156
157fn finish_workflow(
158    ((repository, workflow_path), acc): ((String, String), WorkflowAcc),
159) -> WorkflowTotal {
160    let mut skus: Vec<SkuTotal> = acc
161        .skus
162        .into_iter()
163        .map(|(sku, sku_acc)| SkuTotal {
164            sku,
165            net: sku_acc.net,
166            gross: sku_acc.gross,
167            discount: sku_acc.discount,
168            minutes: sku_acc.minutes,
169        })
170        .collect();
171    skus.sort_by(|left, right| {
172        right
173            .net
174            .cmp(&left.net)
175            .then_with(|| left.sku.cmp(&right.sku))
176    });
177    WorkflowTotal {
178        repository,
179        workflow_path,
180        net: acc.net,
181        gross: acc.gross,
182        discount: acc.discount,
183        minutes: acc.minutes,
184        skus,
185    }
186}
187
188fn track_date(min_date: &mut Option<String>, max_date: &mut Option<String>, date: &str) {
189    if date.is_empty() {
190        return;
191    }
192    match min_date {
193        Some(current) if date.as_bytes() < current.as_bytes() => *current = date.to_string(),
194        None => *min_date = Some(date.to_string()),
195        Some(_) => {}
196    }
197    match max_date {
198        Some(current) if date.as_bytes() > current.as_bytes() => *current = date.to_string(),
199        None => *max_date = Some(date.to_string()),
200        Some(_) => {}
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn usd(text: &str) -> Micros {
209        Micros::parse(text).unwrap()
210    }
211
212    fn row(
213        repository: &str,
214        workflow_path: &str,
215        sku: &str,
216        quantity: &str,
217        unit_type: &str,
218        net_amount: &str,
219        gross_amount: &str,
220    ) -> UsageRow {
221        UsageRow {
222            line: 2,
223            date: "2026-09-01".to_string(),
224            product: "actions".to_string(),
225            sku: sku.to_string(),
226            quantity: usd(quantity),
227            unit_type: unit_type.to_string(),
228            applied_cost_per_quantity: Micros::ZERO,
229            gross: usd(gross_amount),
230            discount: Micros::ZERO,
231            net: usd(net_amount),
232            username: String::new(),
233            organization: String::new(),
234            repository: repository.to_string(),
235            workflow_path: workflow_path.to_string(),
236        }
237    }
238
239    fn sku(name: &str, net: &str, gross: &str, minutes: &str) -> SkuTotal {
240        SkuTotal {
241            sku: name.to_string(),
242            net: usd(net),
243            gross: usd(gross),
244            discount: Micros::ZERO,
245            minutes: usd(minutes),
246        }
247    }
248
249    fn workflow(
250        path: &str,
251        net: &str,
252        gross: &str,
253        minutes: &str,
254        skus: Vec<SkuTotal>,
255    ) -> WorkflowTotal {
256        WorkflowTotal {
257            repository: String::new(),
258            workflow_path: path.to_string(),
259            net: usd(net),
260            gross: usd(gross),
261            discount: Micros::ZERO,
262            minutes: usd(minutes),
263            skus,
264        }
265    }
266
267    /// Three workflows, two SKUs. Storage money counts; storage quantity does not.
268    ///
269    /// ci.yml
270    ///   actions_linux    20 minutes          net 1.00  gross 1.00
271    ///   actions_storage   2 gigabyte-hours   net 4.00  gross 4.00
272    ///   actions_linux     5 Minutes          net 0.25  gross 0.25
273    ///   → net 5.25, gross 5.25, minutes 25
274    ///     actions_storage net 4.00 gross 4.00 minutes 0
275    ///     actions_linux   net 1.25 gross 1.25 minutes 25
276    ///
277    /// lint.yml
278    ///   actions_linux   100 minutes          net 2.00  gross 3.00
279    ///   actions_linux    50 minutes          net 1.00  gross 1.00
280    ///   actions_storage   8 gigabyte-hours   net 0.50  gross 0.50
281    ///   → net 3.50, gross 4.50, minutes 150
282    ///     actions_linux   net 3.00 gross 4.00 minutes 150
283    ///     actions_storage net 0.50 gross 0.50 minutes 0
284    ///
285    /// release.yml
286    ///   actions_storage 100 gigabyte-hours   net 0.25  gross 1.00
287    ///   actions_linux     1 MINUTES          net 0.25  gross 0.75
288    ///   → net 0.50, gross 1.75, minutes 1
289    ///     actions_linux   net 0.25 gross 0.75 minutes 1
290    ///     actions_storage net 0.25 gross 1.00 minutes 0
291    fn three_workflows() -> Vec<UsageRow> {
292        vec![
293            row(
294                "",
295                ".github/workflows/release.yml",
296                "actions_storage",
297                "100",
298                "gigabyte-hours",
299                "0.25",
300                "1",
301            ),
302            row(
303                "",
304                ".github/workflows/release.yml",
305                "actions_linux",
306                "1",
307                "MINUTES",
308                "0.25",
309                "0.75",
310            ),
311            row(
312                "",
313                ".github/workflows/lint.yml",
314                "actions_linux",
315                "100",
316                "minutes",
317                "2",
318                "3",
319            ),
320            row(
321                "",
322                ".github/workflows/lint.yml",
323                "actions_linux",
324                "50",
325                "minutes",
326                "1",
327                "1",
328            ),
329            row(
330                "",
331                ".github/workflows/lint.yml",
332                "actions_storage",
333                "8",
334                "gigabyte-hours",
335                "0.5",
336                "0.5",
337            ),
338            row(
339                "",
340                ".github/workflows/ci.yml",
341                "actions_linux",
342                "20",
343                "minutes",
344                "1",
345                "1",
346            ),
347            row(
348                "",
349                ".github/workflows/ci.yml",
350                "actions_storage",
351                "2",
352                "gigabyte-hours",
353                "4",
354                "4",
355            ),
356            row(
357                "",
358                ".github/workflows/ci.yml",
359                "actions_linux",
360                "5",
361                "Minutes",
362                "0.25",
363                "0.25",
364            ),
365        ]
366    }
367
368    fn expected_three() -> Vec<WorkflowTotal> {
369        vec![
370            workflow(
371                ".github/workflows/ci.yml",
372                "5.25",
373                "5.25",
374                "25",
375                vec![
376                    sku("actions_storage", "4", "4", "0"),
377                    sku("actions_linux", "1.25", "1.25", "25"),
378                ],
379            ),
380            workflow(
381                ".github/workflows/lint.yml",
382                "3.5",
383                "4.5",
384                "150",
385                vec![
386                    sku("actions_linux", "3", "4", "150"),
387                    sku("actions_storage", "0.5", "0.5", "0"),
388                ],
389            ),
390            workflow(
391                ".github/workflows/release.yml",
392                "0.5",
393                "1.75",
394                "1",
395                vec![
396                    sku("actions_linux", "0.25", "0.75", "1"),
397                    sku("actions_storage", "0.25", "1", "0"),
398                ],
399            ),
400        ]
401    }
402
403    #[test]
404    fn ranks_three_workflows_and_two_skus() {
405        let ranking = rank(&three_workflows(), None).unwrap();
406        assert_eq!(ranking.workflows, expected_three());
407        assert_eq!(ranking.bill.total_net, usd("9.25"));
408        assert_eq!(ranking.bill.total_minutes, usd("176"));
409        assert_eq!(ranking.bill.other_products_net, Micros::ZERO);
410        assert_eq!(ranking.bill.period_from.as_deref(), Some("2026-09-01"));
411        assert_eq!(ranking.bill.period_to.as_deref(), Some("2026-09-01"));
412    }
413
414    #[test]
415    fn equal_net_sorts_by_repository_then_path() {
416        // b.yml has the higher gross, so path order is not gross order.
417        let rows = vec![
418            row("", "b.yml", "actions_linux", "1", "minutes", "5", "9"),
419            row("", "a.yml", "actions_linux", "2", "minutes", "5", "1"),
420            row(
421                "",
422                "c.yml",
423                "actions_storage",
424                "3",
425                "gigabyte-hours",
426                "1",
427                "1",
428            ),
429        ];
430        let ranked = rank(&rows, None).unwrap();
431        assert_eq!(
432            ranked
433                .workflows
434                .iter()
435                .map(|item| item.workflow_path.as_str())
436                .collect::<Vec<_>>(),
437            vec!["a.yml", "b.yml", "c.yml"]
438        );
439        assert_eq!(ranked.workflows[0].net, usd("5"));
440        assert_eq!(ranked.workflows[1].net, usd("5"));
441        assert_eq!(ranked.workflows[0].minutes, usd("2"));
442        assert_eq!(ranked.workflows[2].minutes, Micros::ZERO);
443        assert_eq!(ranked.workflows[2].net, usd("1"));
444    }
445
446    #[test]
447    fn same_workflow_path_in_two_repositories_stays_two_rows() {
448        let mut first = row(
449            "example-org/alpha",
450            ".github/workflows/ci.yml",
451            "actions_linux",
452            "10",
453            "minutes",
454            "4",
455            "4",
456        );
457        first.date = "2026-09-30".to_string();
458        first.product = "ACTIONS".to_string();
459        let mut second = row(
460            "example-org/beta",
461            ".github/workflows/ci.yml",
462            "actions_linux",
463            "3",
464            "MINUTES",
465            "4",
466            "9",
467        );
468        second.date = "2026-09-01".to_string();
469        second.product = "Actions".to_string();
470        let mut copilot = row(
471            "example-org/beta",
472            ".github/workflows/ci.yml",
473            "copilot_premium_request",
474            "2",
475            "requests",
476            "7",
477            "7",
478        );
479        copilot.product = "Copilot".to_string();
480        copilot.date = String::new();
481
482        let ranking = rank(&[first, second, copilot], None).unwrap();
483        assert_eq!(ranking.workflows.len(), 2);
484        assert_eq!(ranking.workflows[0].repository, "example-org/alpha");
485        assert_eq!(ranking.workflows[1].repository, "example-org/beta");
486        assert!(ranking
487            .workflows
488            .iter()
489            .all(|item| item.workflow_path == ".github/workflows/ci.yml"));
490        assert_eq!(ranking.workflows[0].net, usd("4"));
491        assert_eq!(ranking.workflows[1].net, usd("4"));
492        assert_eq!(ranking.workflows[0].minutes, usd("10"));
493        assert_eq!(ranking.workflows[1].minutes, usd("3"));
494        assert!(ranking.workflows.iter().all(|item| item.skus.len() == 1));
495        assert_eq!(ranking.bill.total_net, usd("8"));
496        assert_eq!(ranking.bill.total_minutes, usd("13"));
497        assert_eq!(ranking.bill.other_products_net, usd("7"));
498        assert_eq!(ranking.bill.period_from.as_deref(), Some("2026-09-01"));
499        assert_eq!(ranking.bill.period_to.as_deref(), Some("2026-09-30"));
500        assert_eq!(
501            ranking.bill.repositories,
502            vec![
503                "example-org/alpha".to_string(),
504                "example-org/beta".to_string()
505            ]
506        );
507    }
508
509    #[test]
510    fn top_n_keeps_highest_net_and_full_totals() {
511        let rows = three_workflows();
512        let expected = expected_three();
513        assert_eq!(rank(&rows, Some(2)).unwrap().workflows, expected[..2]);
514        assert_eq!(rank(&rows, Some(1)).unwrap().workflows, expected[..1]);
515        assert_eq!(rank(&rows, Some(0)).unwrap().workflows, []);
516        assert_eq!(rank(&rows, Some(10)).unwrap().workflows, expected);
517        assert_eq!(rank(&rows, None).unwrap().workflows, expected);
518        assert_eq!(rank(&rows, Some(1)).unwrap().bill.total_net, usd("9.25"));
519        assert_eq!(rank(&rows, Some(0)).unwrap().bill.total_minutes, usd("176"));
520    }
521
522    fn huge(
523        line: u64,
524        workflow_path: &str,
525        quantity: &str,
526        unit_type: &str,
527        net: &str,
528        gross: &str,
529        discount: &str,
530    ) -> UsageRow {
531        UsageRow {
532            line,
533            date: "2026-09-01".to_string(),
534            product: "actions".to_string(),
535            sku: "actions_linux".to_string(),
536            quantity: usd(quantity),
537            unit_type: unit_type.to_string(),
538            applied_cost_per_quantity: Micros::ZERO,
539            gross: usd(gross),
540            discount: usd(discount),
541            net: usd(net),
542            username: String::new(),
543            organization: String::new(),
544            repository: String::new(),
545            workflow_path: workflow_path.to_string(),
546        }
547    }
548
549    /// Each amount on its own fits in `i64` micros; summed twice it does not.
550    const HUGE: &str = "9000000000000";
551
552    #[test]
553    fn net_amount_overflow_in_one_workflow_is_a_clean_error() {
554        let rows = vec![
555            huge(2, "a.yml", "1", "minutes", HUGE, "0", "0"),
556            huge(3, "a.yml", "1", "minutes", HUGE, "0", "0"),
557        ];
558        let err = rank(&rows, None).unwrap_err();
559        assert_eq!(err.line, 3);
560        assert_eq!(err.kind, UsageErrorKind::AmountOverflow("net_amount"));
561        assert_eq!(err.to_string(), "line 3: net_amount total is out of range");
562    }
563
564    #[test]
565    fn gross_amount_overflow_in_one_workflow_is_a_clean_error() {
566        let rows = vec![
567            huge(2, "a.yml", "1", "minutes", "0", HUGE, "0"),
568            huge(3, "a.yml", "1", "minutes", "0", HUGE, "0"),
569        ];
570        let err = rank(&rows, None).unwrap_err();
571        assert_eq!(err.line, 3);
572        assert_eq!(err.kind, UsageErrorKind::AmountOverflow("gross_amount"));
573    }
574
575    #[test]
576    fn discount_amount_overflow_in_one_workflow_is_a_clean_error() {
577        let rows = vec![
578            huge(2, "a.yml", "1", "minutes", "0", "0", HUGE),
579            huge(3, "a.yml", "1", "minutes", "0", "0", HUGE),
580        ];
581        let err = rank(&rows, None).unwrap_err();
582        assert_eq!(err.line, 3);
583        assert_eq!(err.kind, UsageErrorKind::AmountOverflow("discount_amount"));
584    }
585
586    #[test]
587    fn quantity_overflow_in_one_workflow_is_a_clean_error() {
588        let rows = vec![
589            huge(2, "a.yml", HUGE, "minutes", "0", "0", "0"),
590            huge(3, "a.yml", HUGE, "minutes", "0", "0", "0"),
591        ];
592        let err = rank(&rows, None).unwrap_err();
593        assert_eq!(err.line, 3);
594        assert_eq!(err.kind, UsageErrorKind::AmountOverflow("quantity"));
595        assert_eq!(err.to_string(), "line 3: quantity total is out of range");
596    }
597
598    #[test]
599    fn grand_total_net_overflow_across_workflows_is_a_clean_error() {
600        let rows = vec![
601            huge(2, "a.yml", "1", "minutes", HUGE, "0", "0"),
602            huge(3, "b.yml", "1", "minutes", HUGE, "0", "0"),
603        ];
604        let err = rank(&rows, None).unwrap_err();
605        assert_eq!(err.line, 0);
606        assert_eq!(err.kind, UsageErrorKind::AmountOverflow("net_amount"));
607        assert_eq!(
608            err.to_string(),
609            "net_amount total is out of range (sum of all rows)"
610        );
611    }
612
613    #[test]
614    fn grand_total_quantity_overflow_across_workflows_is_a_clean_error() {
615        let rows = vec![
616            huge(2, "a.yml", HUGE, "minutes", "0", "0", "0"),
617            huge(3, "b.yml", HUGE, "minutes", "0", "0", "0"),
618        ];
619        let err = rank(&rows, None).unwrap_err();
620        assert_eq!(err.line, 0);
621        assert_eq!(err.kind, UsageErrorKind::AmountOverflow("quantity"));
622        assert_eq!(
623            err.to_string(),
624            "quantity total is out of range (sum of all rows)"
625        );
626    }
627
628    #[test]
629    fn no_overflow_error_message_prints_line_zero() {
630        let rows = vec![
631            huge(2, "a.yml", "1", "minutes", HUGE, "0", "0"),
632            huge(3, "b.yml", "1", "minutes", HUGE, "0", "0"),
633        ];
634        let err = rank(&rows, None).unwrap_err();
635        assert!(!err.to_string().contains("line 0"), "{err}");
636    }
637
638    #[test]
639    fn other_products_net_overflow_is_a_clean_error() {
640        let mut first = huge(2, "", "0", "requests", HUGE, "0", "0");
641        first.product = "copilot".to_string();
642        let mut second = huge(3, "", "0", "requests", HUGE, "0", "0");
643        second.product = "copilot".to_string();
644        let err = rank(&[first, second], None).unwrap_err();
645        assert_eq!(err.line, 3);
646        assert_eq!(err.kind, UsageErrorKind::AmountOverflow("net_amount"));
647    }
648
649    #[test]
650    fn negative_net_overflow_in_one_workflow_is_a_clean_error() {
651        let rows = vec![
652            huge(2, "a.yml", "1", "minutes", "-9000000000000", "0", "0"),
653            huge(3, "a.yml", "1", "minutes", "-9000000000000", "0", "0"),
654        ];
655        let err = rank(&rows, None).unwrap_err();
656        assert_eq!(err.line, 3);
657        assert_eq!(err.kind, UsageErrorKind::AmountOverflow("net_amount"));
658    }
659
660    #[test]
661    fn negative_grand_total_overflow_across_workflows_is_a_clean_error() {
662        let rows = vec![
663            huge(2, "a.yml", "1", "minutes", "-9000000000000", "0", "0"),
664            huge(3, "b.yml", "1", "minutes", "-9000000000000", "0", "0"),
665        ];
666        let err = rank(&rows, None).unwrap_err();
667        assert_eq!(err.line, 0);
668        assert_eq!(err.kind, UsageErrorKind::AmountOverflow("net_amount"));
669    }
670}