1use std::collections::{BTreeMap, HashMap};
9use std::path::Path;
10use std::sync::OnceLock;
11
12use serde_json::{Map, Value};
13
14use crate::money::{div_half_away, DecimalError, Micros};
15use crate::usage::{is_actions_product, UsageError, UsageRow};
16
17const ACTIONS_JSON: &str = include_str!("../prices/actions.json");
18const NO_WORKFLOW: &str = "(no workflow)";
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SkuPrice {
23 pub sku: String,
24 pub usd_per_minute: Micros,
25 pub billed_on_public: bool,
26}
27
28#[derive(Debug, Clone)]
30pub struct PriceTable {
31 pub retrieved: String,
32 pub effective_from: String,
33 pub sources: Vec<String>,
34 skus: BTreeMap<String, SkuPrice>,
35 by_lower: HashMap<String, String>,
36}
37
38impl PriceTable {
39 pub fn get(&self, sku: &str) -> Option<&SkuPrice> {
40 self.by_lower
41 .get(&sku.to_ascii_lowercase())
42 .and_then(|name| self.skus.get(name))
43 }
44
45 pub fn iter(&self) -> impl Iterator<Item = &SkuPrice> {
46 self.skus.values()
47 }
48}
49
50#[derive(Debug, Clone, Default)]
52pub struct RunnerMap {
53 entries: HashMap<String, String>,
54}
55
56impl RunnerMap {
57 pub fn get(&self, key: &str) -> Option<&str> {
58 self.entries.get(key).map(String::as_str)
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum PriceFail {
65 Input { path: String, message: String },
67 UnknownLabel(String),
69 Usage(String),
71}
72
73impl PriceFail {
74 pub fn exit_code(&self) -> u8 {
75 match self {
76 Self::UnknownLabel(_) | Self::Usage(_) => 3,
77 Self::Input { .. } => 2,
78 }
79 }
80}
81
82impl std::fmt::Display for PriceFail {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 Self::Input { path, message } if path.is_empty() => write!(f, "{message}"),
86 Self::Input { path, message } => write!(f, "{path}: {message}"),
87 Self::UnknownLabel(label) => write!(f, "unknown runner label: {label}"),
88 Self::Usage(message) => f.write_str(message),
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum RateSource {
96 Table,
97 Csv,
98}
99
100impl RateSource {
101 pub fn as_str(self) -> &'static str {
102 match self {
103 Self::Table => "table",
104 Self::Csv => "csv",
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct Quote {
112 pub labels: Vec<String>,
113 pub sku: String,
114 pub usd_per_minute: Micros,
115 pub billed_on_public: bool,
116 pub source: RateSource,
117 pub repository: Option<String>,
118 pub workflow_path: Option<String>,
119}
120
121pub fn embedded_table() -> Result<&'static PriceTable, PriceFail> {
123 static CELL: OnceLock<Result<PriceTable, String>> = OnceLock::new();
124 match CELL.get_or_init(|| parse_table_json(ACTIONS_JSON).map_err(|err| err.to_string())) {
125 Ok(table) => Ok(table),
126 Err(message) => Err(PriceFail::Input {
127 path: "prices/actions.json".to_string(),
128 message: message.clone(),
129 }),
130 }
131}
132
133pub fn render(
135 json: bool,
136 runner_map: Option<&Path>,
137 label: Option<&str>,
138 sku: Option<&str>,
139 usage: Option<&Path>,
140 repository: Option<&str>,
141 workflow: Option<&str>,
142) -> Result<String, PriceFail> {
143 if workflow.is_some() && usage.is_none() {
144 return Err(PriceFail::Usage("--workflow requires --usage".to_string()));
145 }
146 if repository.is_some() && usage.is_none() {
147 return Err(PriceFail::Usage(
148 "--repository requires --usage".to_string(),
149 ));
150 }
151 if usage.is_some() && label.is_none() && sku.is_none() {
152 return Err(PriceFail::Usage(
153 "--usage requires --label or --sku".to_string(),
154 ));
155 }
156
157 let table = embedded_table()?;
158 let map = match runner_map {
159 Some(path) => load_runner_map(path, table)?,
160 None => RunnerMap::default(),
161 };
162
163 match label {
164 Some(raw) => {
165 let labels = parse_runner_labels(raw);
166 let resolved = resolve_runner(&labels, &map, table)
167 .map_err(|unknown| PriceFail::UnknownLabel(unknown_label_text(raw, &unknown)))?;
168 if let Some(requested) = sku {
169 let wanted = table.get(requested).ok_or_else(|| PriceFail::Input {
170 path: String::new(),
171 message: format!("unknown sku {requested}"),
172 })?;
173 if !wanted.sku.eq_ignore_ascii_case(&resolved) {
174 return Err(PriceFail::Usage(format!(
175 "label {raw} resolves to {resolved}, not {}",
176 wanted.sku
177 )));
178 }
179 }
180 let rows = read_rows(usage)?;
181 let quote = quote_sku(table, &rows, repository, workflow, &resolved, labels)?;
182 Ok(render_quote(json, raw, "e))
183 }
184 None => match sku {
185 Some(requested) => {
186 let price = table.get(requested).ok_or_else(|| PriceFail::Input {
187 path: String::new(),
188 message: format!("unknown sku {requested}"),
189 })?;
190 let rows = read_rows(usage)?;
191 let quote = quote_sku(table, &rows, repository, workflow, &price.sku, Vec::new())?;
192 Ok(render_quote(json, requested, "e))
193 }
194 None => Ok(if json {
195 render_table_json(table)
196 } else {
197 render_table_text(table)
198 }),
199 },
200 }
201}
202
203fn read_rows(usage: Option<&Path>) -> Result<Vec<UsageRow>, PriceFail> {
204 match usage {
205 Some(path) => crate::usage::read_usage(path).map_err(|err| usage_fail(path, err)),
206 None => Ok(Vec::new()),
207 }
208}
209
210fn usage_fail(path: &Path, err: UsageError) -> PriceFail {
211 PriceFail::Input {
212 path: path.display().to_string(),
213 message: err.to_string(),
214 }
215}
216
217pub fn parse_runner_labels(raw: &str) -> Vec<String> {
222 let mut text = raw.trim();
223 if let Some(unquoted) = unquote(text) {
224 text = unquoted.trim();
225 }
226 let bracketed = text.starts_with('[') && text.ends_with(']') && text.len() >= 2;
227 let inner = if bracketed {
228 text[1..text.len() - 1].trim()
229 } else {
230 text
231 };
232 if bracketed || inner.contains(',') {
233 inner
234 .split(',')
235 .map(clean_piece)
236 .filter(|piece| !piece.is_empty())
237 .collect()
238 } else {
239 let one = clean_piece(inner);
240 if one.is_empty() {
241 Vec::new()
242 } else {
243 vec![one]
244 }
245 }
246}
247
248fn clean_piece(raw: &str) -> String {
249 let text = raw.trim();
250 unquote(text).unwrap_or(text).trim().to_string()
251}
252
253fn unquote(text: &str) -> Option<&str> {
255 let bytes = text.as_bytes();
256 if text.len() >= 2 {
257 let open = bytes[0];
258 let close = bytes[text.len() - 1];
259 if (open == b'"' && close == b'"') || (open == b'\'' && close == b'\'') {
260 return Some(&text[1..text.len() - 1]);
261 }
262 }
263 None
264}
265
266pub fn label_key(labels: &[String]) -> String {
268 labels
269 .iter()
270 .map(|label| label.to_ascii_lowercase())
271 .collect::<Vec<_>>()
272 .join(",")
273}
274
275pub fn resolve_runner(
281 labels: &[String],
282 map: &RunnerMap,
283 table: &PriceTable,
284) -> Result<String, String> {
285 if labels.is_empty() {
286 return Err(String::new());
287 }
288 let key = label_key(labels);
289 if let Some(sku) = map.get(&key) {
290 return Ok(sku.to_string());
291 }
292 if labels
293 .iter()
294 .any(|label| label.eq_ignore_ascii_case("self-hosted"))
295 {
296 let sku = map.get("self-hosted").unwrap_or("self_hosted");
297 return table
298 .get(sku)
299 .map(|price| price.sku.clone())
300 .ok_or_else(|| "self-hosted".to_string());
301 }
302 let mut chosen: Option<String> = None;
303 for label in labels {
304 let lower = label.to_ascii_lowercase();
305 let sku = if let Some(overridden) = map.get(&lower) {
306 overridden.to_string()
307 } else if let Some(builtin) = builtin_sku(&lower, table) {
308 builtin
309 } else {
310 return Err(label.clone());
311 };
312 if chosen.is_none() {
313 chosen = Some(sku);
314 }
315 }
316 chosen.ok_or_else(|| labels[0].clone())
317}
318
319pub fn billed_on_public(sku: &str) -> bool {
324 embedded_table()
325 .ok()
326 .and_then(|table| table.get(sku).map(|price| price.billed_on_public))
327 .unwrap_or(false)
328}
329
330pub fn sku_for_labels(labels: &[String]) -> Option<String> {
338 let table = embedded_table().ok()?;
339 let map = RunnerMap::default();
340 let cleaned: Vec<String> = labels
341 .iter()
342 .map(|label| label.trim().to_string())
343 .filter(|label| !label.is_empty())
344 .collect();
345 if cleaned
346 .iter()
347 .any(|label| label.eq_ignore_ascii_case("self-hosted"))
348 {
349 return resolve_runner(&["self-hosted".to_string()], &map, table).ok();
350 }
351 let mut best: Option<(u8, String)> = None;
352 for label in &cleaned {
353 let one = [label.clone()];
354 let Ok(sku) = resolve_runner(&one, &map, table) else {
355 continue;
356 };
357 let score = label_specificity(&sku);
358 if best.as_ref().is_none_or(|pair| score > pair.0) {
359 best = Some((score, sku));
360 }
361 }
362 best.map(|(_, sku)| sku)
363}
364
365fn label_specificity(sku: &str) -> u8 {
367 if sku.contains("_core") {
368 3
369 } else if sku.ends_with("_arm")
370 || sku.ends_with("_slim")
371 || sku.ends_with("_xl")
372 || sku.ends_with("_l")
373 {
374 2
375 } else {
376 1
377 }
378}
379
380fn unknown_label_text(raw: &str, unknown: &str) -> String {
381 if !unknown.is_empty() {
382 return unknown.to_string();
383 }
384 let trimmed = raw.trim();
385 if trimmed.is_empty() {
386 "\"\"".to_string()
387 } else {
388 trimmed.to_string()
389 }
390}
391
392pub fn quote_sku(
399 table: &PriceTable,
400 rows: &[UsageRow],
401 repository: Option<&str>,
402 workflow: Option<&str>,
403 sku: &str,
404 labels: Vec<String>,
405) -> Result<Quote, PriceFail> {
406 let price = table.get(sku).ok_or_else(|| PriceFail::Input {
407 path: String::new(),
408 message: format!("unknown sku {sku}"),
409 })?;
410 let mut quote = Quote {
411 labels,
412 sku: price.sku.clone(),
413 usd_per_minute: price.usd_per_minute,
414 billed_on_public: price.billed_on_public,
415 source: RateSource::Table,
416 repository: None,
417 workflow_path: None,
418 };
419 let Some(workflow) = workflow else {
420 return Ok(quote);
421 };
422 let wanted_workflow = if workflow.is_empty() {
423 NO_WORKFLOW
424 } else {
425 workflow
426 };
427 let mut totals: BTreeMap<String, RepoTotal> = BTreeMap::new();
428 for row in rows {
429 if !row_matches(row, &price.sku, repository, wanted_workflow) {
430 continue;
431 }
432 let key = row.repository.to_ascii_lowercase();
433 let entry = totals.entry(key).or_insert_with(|| RepoTotal {
434 display: row.repository.clone(),
435 net: Micros::ZERO,
436 quantity: Micros::ZERO,
437 });
438 entry.net = entry
439 .net
440 .checked_add(row.net)
441 .ok_or_else(|| PriceFail::Input {
442 path: String::new(),
443 message: format!("line {}: net_amount total is out of range", row.line),
444 })?;
445 entry.quantity =
446 entry
447 .quantity
448 .checked_add(row.quantity)
449 .ok_or_else(|| PriceFail::Input {
450 path: String::new(),
451 message: format!("line {}: quantity total is out of range", row.line),
452 })?;
453 }
454 let mut active: Vec<&RepoTotal> = totals
455 .values()
456 .filter(|total| total.quantity.0 != 0)
457 .collect();
458 if repository.is_none() && active.len() > 1 {
459 active.sort_by(|left, right| left.display.cmp(&right.display));
460 let names = active
461 .iter()
462 .map(|total| total.display.as_str())
463 .collect::<Vec<_>>()
464 .join(", ");
465 return Err(PriceFail::Input {
466 path: String::new(),
467 message: format!(
468 "workflow {wanted_workflow} matches more than one repository: {names}"
469 ),
470 });
471 }
472 if let Some(total) = active.first() {
473 let rate = per_minute_rate(total.net, total.quantity).ok_or_else(|| PriceFail::Input {
474 path: String::new(),
475 message: format!("price for {} does not fit", price.sku),
476 })?;
477 quote.usd_per_minute = rate;
478 quote.source = RateSource::Csv;
479 quote.repository = Some(total.display.clone());
480 quote.workflow_path = Some(wanted_workflow.to_string());
481 }
482 Ok(quote)
483}
484
485struct RepoTotal {
486 display: String,
487 net: Micros,
488 quantity: Micros,
489}
490
491fn row_matches(row: &UsageRow, sku: &str, repository: Option<&str>, workflow: &str) -> bool {
492 if !is_actions_product(&row.product) || !row.sku.eq_ignore_ascii_case(sku) {
493 return false;
494 }
495 if let Some(repository) = repository {
496 if !row.repository.eq_ignore_ascii_case(repository) {
497 return false;
498 }
499 }
500 row.workflow_path.eq_ignore_ascii_case(workflow)
501}
502
503fn per_minute_rate(net: Micros, quantity: Micros) -> Option<Micros> {
505 if quantity.0 == 0 {
506 return None;
507 }
508 let num = i128::from(net.0).checked_mul(i128::from(Micros::SCALE))?;
509 let den = i128::from(quantity.0);
510 i64::try_from(div_half_away(num, den)).ok().map(Micros)
511}
512
513fn builtin_sku(label: &str, table: &PriceTable) -> Option<String> {
514 if label == "ubuntu-slim" {
515 return canonical(table, "actions_linux_slim");
516 }
517 if let Some(sku) = larger_core_sku(label) {
518 return canonical(table, &sku);
520 }
521 if label.starts_with("ubuntu-") && label.ends_with("-arm") {
522 return canonical(table, "actions_linux_arm");
523 }
524 if label.starts_with("windows-") && label.ends_with("-arm") {
525 return canonical(table, "actions_windows_arm");
526 }
527 if (label.starts_with("macos-") || label.starts_with("xcode-")) && label.ends_with("-xlarge") {
528 return canonical(table, "actions_macos_xl");
529 }
530 if label.starts_with("macos-") && label.ends_with("-large") {
531 return canonical(table, "actions_macos_l");
532 }
533 if label.starts_with("ubuntu-") {
534 return canonical(table, "actions_linux");
535 }
536 if label.starts_with("macos-") || label.starts_with("xcode-") {
537 return canonical(table, "actions_macos");
538 }
539 if label.starts_with("windows-") {
540 return canonical(table, "actions_windows");
541 }
542 None
543}
544
545fn canonical(table: &PriceTable, sku: &str) -> Option<String> {
546 table.get(sku).map(|price| price.sku.clone())
547}
548
549fn larger_core_sku(label: &str) -> Option<String> {
551 let (family, rest) = if let Some(rest) = label.strip_prefix("ubuntu-") {
552 ("linux", rest)
553 } else {
554 let rest = label.strip_prefix("windows-")?;
555 ("windows", rest)
556 };
557 let (body, kind) = strip_machine_kind(rest);
558 let body = strip_core_word(body)?;
559 let (image, digits) = body.rsplit_once('-')?;
560 if image.is_empty()
561 || digits.is_empty()
562 || digits.starts_with('0')
563 || !digits.bytes().all(|byte| byte.is_ascii_digit())
564 {
565 return None;
566 }
567 let cores: u32 = digits.parse().ok()?;
568 let suffix = match kind {
569 MachineKind::Plain => "",
570 MachineKind::Arm => "_arm",
571 MachineKind::Gpu => "_gpu",
572 MachineKind::Advanced => "_advanced",
573 };
574 Some(format!("actions_{family}_{cores}_core{suffix}"))
575}
576
577#[derive(Clone, Copy)]
578enum MachineKind {
579 Plain,
580 Arm,
581 Gpu,
582 Advanced,
583}
584
585fn strip_machine_kind(rest: &str) -> (&str, MachineKind) {
586 if let Some(body) = rest.strip_suffix("-arm") {
587 (body, MachineKind::Arm)
588 } else if let Some(body) = rest.strip_suffix("-gpu") {
589 (body, MachineKind::Gpu)
590 } else if let Some(body) = rest.strip_suffix("-advanced") {
591 (body, MachineKind::Advanced)
592 } else {
593 (rest, MachineKind::Plain)
594 }
595}
596
597fn strip_core_word(body: &str) -> Option<&str> {
598 let stripped = if let Some(prefix) = body.strip_suffix("cores") {
599 prefix
600 } else {
601 body.strip_suffix("core")?
602 };
603 Some(stripped.strip_suffix('-').unwrap_or(stripped))
604}
605
606pub fn load_runner_map(path: &Path, table: &PriceTable) -> Result<RunnerMap, PriceFail> {
608 let bytes = std::fs::read(path).map_err(|_| PriceFail::Input {
609 path: path.display().to_string(),
610 message: "line 0: cannot read file".to_string(),
611 })?;
612 parse_runner_map(&bytes, table).map_err(|message| PriceFail::Input {
613 path: path.display().to_string(),
614 message,
615 })
616}
617
618pub fn parse_runner_map(bytes: &[u8], table: &PriceTable) -> Result<RunnerMap, String> {
621 let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
622 let mut reader = csv::ReaderBuilder::new()
623 .has_headers(true)
624 .trim(csv::Trim::All)
625 .from_reader(bytes);
626 let headers = reader
627 .headers()
628 .map_err(|err| csv_message(&err, bytes))?
629 .clone();
630 let header_line = record_line(&headers, bytes);
631 let label_index = column_index(&headers, "label", header_line)?;
632 let sku_index = column_index(&headers, "sku", header_line)?;
633 let mut entries = HashMap::new();
634 for result in reader.records() {
635 let record = result.map_err(|err| csv_message(&err, bytes))?;
636 let line = record_line(&record, bytes);
637 let label_cell = record.get(label_index).unwrap_or("");
638 let sku_cell = record.get(sku_index).unwrap_or("");
639 if label_cell.is_empty() {
640 return Err(format!("line {line}: empty label"));
641 }
642 if sku_cell.is_empty() {
643 return Err(format!("line {line}: empty sku"));
644 }
645 let price = table
646 .get(sku_cell)
647 .ok_or_else(|| format!("line {line}: unknown sku {sku_cell}"))?;
648 let labels = parse_runner_labels(label_cell);
649 if labels.is_empty() {
650 return Err(format!("line {line}: empty label"));
651 }
652 entries.insert(label_key(&labels), price.sku.clone());
653 }
654 Ok(RunnerMap { entries })
655}
656
657fn column_index(headers: &csv::StringRecord, name: &str, line: u64) -> Result<usize, String> {
658 headers
659 .iter()
660 .position(|header| header.eq_ignore_ascii_case(name))
661 .ok_or_else(|| format!("line {line}: missing column {name}"))
662}
663
664fn record_line(record: &csv::StringRecord, bytes: &[u8]) -> u64 {
665 record
666 .position()
667 .map(|position| physical_line(position.line(), position.byte(), bytes))
668 .unwrap_or(1)
669}
670
671fn physical_line(line: u64, offset: u64, bytes: &[u8]) -> u64 {
672 let Some(index) = usize::try_from(offset).ok() else {
673 return line;
674 };
675 if index > 0 && bytes.get(index) == Some(&b'\n') && bytes.get(index - 1) == Some(&b'\r') {
676 line.saturating_add(1)
677 } else {
678 line
679 }
680}
681
682fn csv_message(err: &csv::Error, bytes: &[u8]) -> String {
683 let line = err
684 .position()
685 .map(|position| physical_line(position.line(), position.byte(), bytes))
686 .unwrap_or(1);
687 format!("line {line}: invalid CSV")
688}
689
690pub fn parse_table_json(text: &str) -> Result<PriceTable, String> {
692 let value: Value = serde_json::from_str(text).map_err(|_| "invalid JSON".to_string())?;
693 let object = value.as_object().ok_or("expected an object")?;
694 let retrieved = required_string(object, "retrieved")?;
695 let effective_from = required_string(object, "effective_from")?;
696 let sources_value = object.get("sources").ok_or("missing sources")?;
697 let sources_array = sources_value.as_array().ok_or("sources must be an array")?;
698 if sources_array.is_empty() {
699 return Err("sources is empty".to_string());
700 }
701 let mut sources = Vec::with_capacity(sources_array.len());
702 for source in sources_array {
703 let text = source.as_str().ok_or("sources must be strings")?;
704 if text.is_empty() {
705 return Err("sources contains an empty URL".to_string());
706 }
707 sources.push(text.to_string());
708 }
709 let skus_value = object.get("skus").ok_or("missing skus")?;
710 let skus_array = skus_value.as_array().ok_or("skus must be an array")?;
711 if skus_array.is_empty() {
712 return Err("skus is empty".to_string());
713 }
714 let mut skus = BTreeMap::new();
715 let mut by_lower = HashMap::new();
716 for entry in skus_array {
717 let sku_object = entry.as_object().ok_or("sku entry must be an object")?;
718 let sku = required_string(sku_object, "sku")?;
719 if sku.is_empty() {
720 return Err("sku name is empty".to_string());
721 }
722 let rate_text = sku_object
723 .get("usd_per_minute")
724 .and_then(Value::as_str)
725 .ok_or_else(|| format!("sku {sku}: usd_per_minute must be a decimal string"))?;
726 let usd_per_minute = Micros::parse(rate_text).map_err(|err| match err {
727 DecimalError::TooManyDecimals => {
728 format!("sku {sku}: usd_per_minute has more than 6 decimal places")
729 }
730 DecimalError::NotANumber => format!("sku {sku}: usd_per_minute is not a number"),
731 })?;
732 if usd_per_minute.0 < 0 {
733 return Err(format!("sku {sku}: usd_per_minute is negative"));
734 }
735 let billed_on_public = sku_object
736 .get("billed_on_public")
737 .and_then(Value::as_bool)
738 .ok_or_else(|| format!("sku {sku}: billed_on_public must be a boolean"))?;
739 let lower = sku.to_ascii_lowercase();
740 if by_lower.contains_key(&lower) {
741 return Err(format!("duplicate sku {sku}"));
742 }
743 by_lower.insert(lower, sku.clone());
744 skus.insert(
745 sku.clone(),
746 SkuPrice {
747 sku,
748 usd_per_minute,
749 billed_on_public,
750 },
751 );
752 }
753 Ok(PriceTable {
754 retrieved,
755 effective_from,
756 sources,
757 skus,
758 by_lower,
759 })
760}
761
762fn required_string(object: &Map<String, Value>, key: &str) -> Result<String, String> {
763 object
764 .get(key)
765 .and_then(Value::as_str)
766 .filter(|text| !text.is_empty())
767 .map(str::to_string)
768 .ok_or_else(|| format!("missing {key}"))
769}
770
771fn render_table_text(table: &PriceTable) -> String {
772 let width = table
773 .iter()
774 .map(|sku| sku.sku.len())
775 .max()
776 .unwrap_or(3)
777 .max(3);
778 let mut out = String::new();
779 out.push_str("Actions runner prices\n");
780 out.push_str(&format!("Retrieved: {}\n", table.retrieved));
781 out.push_str(&format!("Effective from: {}\n", table.effective_from));
782 out.push_str("Currency: USD per minute\n");
783 out.push_str("Sources:\n");
784 for source in &table.sources {
785 out.push_str(&format!(" {source}\n"));
786 }
787 out.push('\n');
788 out.push_str(&format!(
789 "{:width$} {:>10} Billed on public\n",
790 "SKU",
791 "USD/min",
792 width = width
793 ));
794 for sku in table.iter() {
795 let billed = if sku.billed_on_public { "yes" } else { "no" };
796 out.push_str(&format!(
797 "{:width$} {:>10} {billed}\n",
798 sku.sku,
799 sku.usd_per_minute.to_decimal_string(),
800 width = width
801 ));
802 }
803 out
804}
805
806fn render_table_json(table: &PriceTable) -> String {
807 let skus: Vec<Value> = table
808 .iter()
809 .map(|sku| {
810 serde_json::json!({
811 "sku": sku.sku,
812 "usd_per_minute": sku.usd_per_minute.to_decimal_string(),
813 "billed_on_public": sku.billed_on_public,
814 })
815 })
816 .collect();
817 let value = serde_json::json!({
818 "schema_version": 1,
819 "retrieved": table.retrieved,
820 "effective_from": table.effective_from,
821 "currency": "USD",
822 "unit": "minute",
823 "sources": table.sources,
824 "skus": skus,
825 });
826 pretty_json(&value)
827}
828
829fn render_quote(json: bool, raw_label: &str, quote: &Quote) -> String {
830 if json {
831 let mut object = Map::new();
832 object.insert(
833 "label".to_string(),
834 Value::String(raw_label.trim().to_string()),
835 );
836 object.insert(
837 "labels".to_string(),
838 Value::Array(quote.labels.iter().cloned().map(Value::String).collect()),
839 );
840 object.insert("sku".to_string(), Value::String(quote.sku.clone()));
841 object.insert(
842 "usd_per_minute".to_string(),
843 Value::String(quote.usd_per_minute.to_decimal_string()),
844 );
845 object.insert(
846 "billed_on_public".to_string(),
847 Value::Bool(quote.billed_on_public),
848 );
849 object.insert(
850 "source".to_string(),
851 Value::String(quote.source.as_str().to_string()),
852 );
853 if let Some(repository) = "e.repository {
854 object.insert("repository".to_string(), Value::String(repository.clone()));
855 }
856 if let Some(workflow) = "e.workflow_path {
857 object.insert("workflow_path".to_string(), Value::String(workflow.clone()));
858 }
859 pretty_json(&Value::Object(object))
860 } else {
861 let mut out = format!(
862 "label: {}\nlabels: {}\nsku: {}\nusd_per_minute: {}\nbilled_on_public: {}\nsource: {}\n",
863 raw_label.trim(),
864 quote.labels.join(", "),
865 quote.sku,
866 quote.usd_per_minute.to_decimal_string(),
867 if quote.billed_on_public { "yes" } else { "no" },
868 quote.source.as_str()
869 );
870 if let Some(repository) = "e.repository {
871 out.push_str(&format!("repository: {repository}\n"));
872 }
873 if let Some(workflow) = "e.workflow_path {
874 out.push_str(&format!("workflow_path: {workflow}\n"));
875 }
876 out
877 }
878}
879
880fn pretty_json(value: &Value) -> String {
881 match serde_json::to_string_pretty(value) {
882 Ok(text) => format!("{text}\n"),
883 Err(_) => "{}\n".to_string(),
884 }
885}
886
887#[cfg(test)]
888mod tests {
889 use super::{
890 embedded_table, parse_runner_labels, parse_runner_map, parse_table_json, quote_sku,
891 resolve_runner, PriceFail, PriceTable, RateSource, RunnerMap,
892 };
893 use crate::money::Micros;
894 use crate::usage::UsageRow;
895
896 fn table() -> &'static PriceTable {
897 embedded_table().unwrap_or_else(|err| panic!("embedded table: {err:?}"))
898 }
899
900 fn sku_of(raw: &str) -> String {
901 let labels = parse_runner_labels(raw);
902 resolve_runner(&labels, &RunnerMap::default(), table())
903 .unwrap_or_else(|label| panic!("label {raw} should resolve, unknown {label}"))
904 }
905
906 #[test]
907 fn self_hosted_beats_an_os_label_and_comes_first() {
908 assert_eq!(sku_of("self-hosted"), "self_hosted");
909 assert_eq!(sku_of("[self-hosted, macos]"), "self_hosted");
910 assert_eq!(sku_of("[macos, self-hosted]"), "self_hosted");
911 assert_eq!(sku_of("self-hosted, macos"), "self_hosted");
912 assert_eq!(sku_of("[\"self-hosted\", \"macos\"]"), "self_hosted");
913 assert_eq!(sku_of("[SELF-HOSTED, MACOS]"), "self_hosted");
914 assert!(resolve_runner(
915 &parse_runner_labels("macos"),
916 &RunnerMap::default(),
917 table()
918 )
919 .is_err());
920 }
921
922 #[test]
923 fn specific_shapes_win_over_the_os_prefix() {
924 assert_eq!(sku_of("ubuntu-slim"), "actions_linux_slim");
925 assert_eq!(sku_of("UBUNTU-SLIM"), "actions_linux_slim");
926 assert_eq!(sku_of("ubuntu-latest"), "actions_linux");
927 assert_eq!(sku_of("ubuntu-22.04-arm"), "actions_linux_arm");
928 assert_eq!(sku_of("ubuntu-24.04-arm"), "actions_linux_arm");
929 assert_eq!(sku_of("ubuntu-26.04-arm"), "actions_linux_arm");
930 assert_eq!(sku_of("windows-11-arm"), "actions_windows_arm");
931 assert_eq!(sku_of("macos-14-xlarge"), "actions_macos_xl");
932 assert_eq!(sku_of("macos-14-large"), "actions_macos_l");
933 assert_eq!(sku_of("xcode-27-xlarge"), "actions_macos_xl");
934 assert_eq!(sku_of("xcode-27"), "actions_macos");
935 assert_eq!(sku_of("ubuntu-24.04-16core"), "actions_linux_16_core");
936 assert_eq!(sku_of("UBUNTU-24.04-16CORE"), "actions_linux_16_core");
937 assert_eq!(sku_of("ubuntu-24.04-4core-arm"), "actions_linux_4_core_arm");
938 assert_eq!(
939 sku_of("ubuntu-24.04-2core-advanced"),
940 "actions_linux_2_core_advanced"
941 );
942 assert_eq!(
943 sku_of("windows-2022-4core-gpu"),
944 "actions_windows_4_core_gpu"
945 );
946 let unknown = resolve_runner(
947 &parse_runner_labels("ubuntu-24.04-3core"),
948 &RunnerMap::default(),
949 table(),
950 )
951 .unwrap_err();
952 assert_eq!(unknown, "ubuntu-24.04-3core");
953 }
954
955 #[test]
956 fn runner_map_overrides_a_built_in_label_and_keeps_self_hosted_first() {
957 let bytes = b"sku,label\r\nactions_macos,ubuntu-latest\r\nactions_linux,macos\r\n";
958 let map = parse_runner_map(bytes, table()).unwrap();
959 let overridden =
960 resolve_runner(&parse_runner_labels("UBUNTU-LATEST"), &map, table()).unwrap();
961 assert_eq!(overridden, "actions_macos");
962 let hosted =
963 resolve_runner(&parse_runner_labels("[self-hosted, macos]"), &map, table()).unwrap();
964 assert_eq!(hosted, "self_hosted");
965 }
966
967 #[test]
968 fn csv_quantity_rate_replaces_the_table_rate() {
969 let price = table().get("actions_linux").unwrap();
970 let quantity = Micros(10 * Micros::SCALE);
971 let net = Micros(price.usd_per_minute.0 * 20);
972 let row = UsageRow {
973 line: 2,
974 date: "2026-09-01".to_string(),
975 product: "Actions".to_string(),
976 sku: "ACTIONS_LINUX".to_string(),
977 quantity,
978 unit_type: "Minutes".to_string(),
979 applied_cost_per_quantity: price.usd_per_minute,
980 gross: net,
981 discount: Micros::ZERO,
982 net,
983 username: String::new(),
984 organization: "Example-Org".to_string(),
985 repository: "Example-Org/Sample-App".to_string(),
986 workflow_path: ".GitHub/Workflows/CI.yml".to_string(),
987 };
988 let other = UsageRow {
989 repository: "other/app".to_string(),
990 workflow_path: ".github/workflows/ci.yml".to_string(),
991 sku: "actions_linux".to_string(),
992 product: "actions".to_string(),
993 net: price.usd_per_minute,
994 quantity: Micros(Micros::SCALE),
995 ..row.clone()
996 };
997 let quote = quote_sku(
998 table(),
999 &[row, other],
1000 Some("example-org/sample-app"),
1001 Some(".github/workflows/ci.yml"),
1002 "actions_linux",
1003 vec!["ubuntu-latest".to_string()],
1004 )
1005 .unwrap();
1006 assert_eq!(quote.source, RateSource::Csv);
1007 assert_eq!(quote.usd_per_minute, Micros(price.usd_per_minute.0 * 2));
1008 assert_ne!(quote.usd_per_minute, price.usd_per_minute);
1009 assert_eq!(quote.repository.as_deref(), Some("Example-Org/Sample-App"));
1010
1011 let untouched = quote_sku(
1012 table(),
1013 &[],
1014 Some("example-org/sample-app"),
1015 Some(".github/workflows/ci.yml"),
1016 "actions_linux",
1017 Vec::new(),
1018 )
1019 .unwrap();
1020 assert_eq!(untouched.source, RateSource::Table);
1021 assert_eq!(untouched.usd_per_minute, price.usd_per_minute);
1022 }
1023
1024 #[test]
1025 fn zero_quantity_keeps_the_table_and_two_repos_are_an_error() {
1026 let price = table().get("actions_macos").unwrap();
1027 let base = UsageRow {
1028 line: 2,
1029 date: "2026-09-01".to_string(),
1030 product: "actions".to_string(),
1031 sku: "actions_macos".to_string(),
1032 quantity: Micros::ZERO,
1033 unit_type: "minutes".to_string(),
1034 applied_cost_per_quantity: price.usd_per_minute,
1035 gross: Micros::ZERO,
1036 discount: Micros::ZERO,
1037 net: Micros::ZERO,
1038 username: String::new(),
1039 organization: "org".to_string(),
1040 repository: "org/one".to_string(),
1041 workflow_path: ".github/workflows/lint.yml".to_string(),
1042 };
1043 let quote = quote_sku(
1044 table(),
1045 std::slice::from_ref(&base),
1046 None,
1047 Some(".github/workflows/lint.yml"),
1048 "actions_macos",
1049 Vec::new(),
1050 )
1051 .unwrap();
1052 assert_eq!(quote.source, RateSource::Table);
1053
1054 let mut second = base;
1055 second.repository = "org/two".to_string();
1056 second.quantity = Micros(Micros::SCALE);
1057 second.net = price.usd_per_minute;
1058 let mut first = second.clone();
1059 first.repository = "org/one".to_string();
1060 first.net = Micros(price.usd_per_minute.0 * 3);
1061 let err = quote_sku(
1062 table(),
1063 &[first, second],
1064 None,
1065 Some(".github/workflows/lint.yml"),
1066 "actions_macos",
1067 Vec::new(),
1068 )
1069 .unwrap_err();
1070 match err {
1071 PriceFail::Input { message, .. } => {
1072 assert!(message.contains("org/one"), "{message}");
1073 assert!(message.contains("org/two"), "{message}");
1074 assert!(message.contains(".github/workflows/lint.yml"), "{message}");
1075 }
1076 other => panic!("{other:?}"),
1077 }
1078 }
1079
1080 #[test]
1081 fn runner_map_rejects_empty_fields_missing_columns_and_unknown_skus() {
1082 let err = parse_runner_map(b"label,sku\r\n,actions_linux\r\n", table()).unwrap_err();
1083 assert!(err.contains("line 2"), "{err}");
1084 assert!(err.contains("empty label"), "{err}");
1085
1086 let err = parse_runner_map(b"sku,label\n,my-box\n", table()).unwrap_err();
1087 assert!(err.contains("line 2"), "{err}");
1088 assert!(err.contains("empty sku"), "{err}");
1089 assert!(!err.contains("my-box"), "{err}");
1090
1091 let err = parse_runner_map(b"label,sku\nmy-box,\n", table()).unwrap_err();
1092 assert!(err.contains("empty sku"), "{err}");
1093 assert!(!err.contains("my-box"), "{err}");
1094
1095 let err = parse_runner_map(b"label,name\nmy-box,actions_linux\n", table()).unwrap_err();
1096 assert!(err.contains("missing column sku"), "{err}");
1097
1098 let err = parse_runner_map(b"label,sku\nmy-box,actions_not_real\n", table()).unwrap_err();
1099 assert!(err.contains("line 2"), "{err}");
1100 assert!(err.contains("actions_not_real"), "{err}");
1101
1102 let mut bytes = vec![0xEF, 0xBB, 0xBF];
1103 bytes.extend(b"LABEL,SKU\r\nUBUNTU-LATEST,ACTIONS_MACOS\r\n");
1104 let map = parse_runner_map(&bytes, table()).unwrap();
1105 assert_eq!(map.get("ubuntu-latest"), Some("actions_macos"));
1106 }
1107
1108 #[test]
1109 fn table_json_rejects_a_seventh_decimal_and_a_duplicate() {
1110 let err = parse_table_json(
1111 r#"{"retrieved":"2026-10-17","effective_from":"2026-01-01","sources":["https://example.test"],"skus":[{"sku":"actions_linux","usd_per_minute":"0.0000001","billed_on_public":false}]}"#,
1112 )
1113 .unwrap_err();
1114 assert!(err.contains("more than 6 decimal places"), "{err}");
1115
1116 let err = parse_table_json(
1117 r#"{"retrieved":"2026-10-17","effective_from":"2026-01-01","sources":["https://example.test"],"skus":[{"sku":"actions_linux","usd_per_minute":"0","billed_on_public":false},{"sku":"Actions_Linux","usd_per_minute":"0","billed_on_public":true}]}"#,
1118 )
1119 .unwrap_err();
1120 assert!(err.contains("duplicate sku"), "{err}");
1121 }
1122}