Skip to main content

ci_bill/
prices.rs

1//! Actions runner prices and the map from a workflow label to a SKU.
2//!
3//! Rates live in `prices/actions.json` and are loaded with `include_str`.
4//! A workflow's own usage rows (`net / quantity`) replace the table rate.
5//! `self-hosted` is decided before any OS label. `--runner-map` replaces
6//! those built-in decisions for the labels it names.
7
8use 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/// One SKU from the embedded price table.
21#[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/// Embedded Actions minute prices.
29#[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/// Label keys (lower case, comma-joined) to a canonical SKU.
51#[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/// Why a price request failed before a rate could be chosen.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum PriceFail {
65    /// Exit 2. `path` is the file that could not be used. Empty when there is no file.
66    Input { path: String, message: String },
67    /// Exit 3. The runner label is not in the built-in map or `--runner-map`.
68    UnknownLabel(String),
69    /// Exit 3. The flags disagree or a price flag is missing its partner.
70    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/// Where `usd_per_minute` came from.
94#[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/// The minute rate for one resolved runner or SKU.
110#[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
121/// Load the embedded table. A broken embed is an input error with no path.
122pub 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
133/// `ci-bill prices` output.
134pub 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, &quote))
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, &quote))
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
217/// Split a `runs-on` value into labels.
218///
219/// Accepts `ubuntu-latest`, `[self-hosted, macos]`, `self-hosted, macos`,
220/// and quoted pieces. Empty pieces are dropped.
221pub 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
253/// One matching pair of quotes around the whole string.
254fn 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
266/// Map key for a label list: lower case, commas, no spaces.
267pub fn label_key(labels: &[String]) -> String {
268    labels
269        .iter()
270        .map(|label| label.to_ascii_lowercase())
271        .collect::<Vec<_>>()
272        .join(",")
273}
274
275/// Resolve labels to a canonical SKU.
276///
277/// The exact `--runner-map` key wins, including a multi-label key. Otherwise
278/// a set that contains `self-hosted` is `self_hosted` (or that label's
279/// override). Every other label must be known. The first label supplies the SKU.
280pub 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
319/// Whether `sku` is charged on a public repository.
320///
321/// Standard runners are free there. Larger runners stay charged.
322/// An unknown SKU is not a larger runner.
323pub 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
330/// SKU for the labels on a finished or running job.
331///
332/// A label list that contains `self-hosted` is `self_hosted`. Otherwise each
333/// label is resolved on its own and labels the map does not name are ignored.
334/// When several labels resolve, the larger runner wins: a core size beats an
335/// arm, slim, large, or xlarge label, and those beat a standard runner. A tie
336/// keeps the earlier label. No known label yields `None`.
337pub 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
365/// Larger runners outrank the standard image they also advertise.
366fn 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
392/// Minute rate for `sku` on one repository workflow.
393///
394/// Rows are this workflow's own Actions rows for that SKU. `net / quantity`
395/// wins when the quantity sum is not zero. Otherwise the table rate is used.
396/// With no repository filter, two repositories that both have quantity is an
397/// input error.
398pub 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
503/// `net / quantity` in USD per minute, half away from zero at the micro.
504fn 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        // A core-sized label that is not in the table stays unknown.
519        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
549/// `ubuntu-24.04-16core` and the same shape for Windows, ARM, GPU, and advanced.
550fn 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
606/// CSV with header names `label` and `sku`, in any order.
607pub 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
618/// Parse a runner-map CSV already in memory, the same way [`load_runner_map`] does after
619/// reading the file.
620pub 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
690/// Parse a price table JSON document in the same shape as the embedded `prices/actions.json`.
691pub 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) = &quote.repository {
854            object.insert("repository".to_string(), Value::String(repository.clone()));
855        }
856        if let Some(workflow) = &quote.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) = &quote.repository {
871            out.push_str(&format!("repository: {repository}\n"));
872        }
873        if let Some(workflow) = &quote.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}