1use std::collections::{BTreeSet, HashMap};
2
3use crate::money::Micros;
4use crate::usage::{is_actions_product, UsageError, UsageErrorKind, UsageRow};
5
6#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Ranking {
45 pub workflows: Vec<WorkflowTotal>,
46 pub bill: Bill,
47}
48
49pub 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 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
109fn 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 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 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 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}