Skip to main content

ci_bill/
reconcile.rs

1//! Fit job costs to the usage-report net.
2//!
3//! For one repository, workflow, and SKU, billable job costs are scaled so
4//! they add up to that SKU's CSV net. The weight is billed minutes. Any
5//! leftover micros go to the job with the most minutes.
6//! A workflow with no saved jobs uses its YAML jobs instead. The weight is
7//! the matrix cell count times the runner's minute rate (`yaml_weight`).
8//! A job with no CSV row keeps its list price (`list_price`).
9//! On a public repository that invoice is zero for a standard SKU, while the
10//! list price stays on the job. A larger runner stays on its list price.
11//! A CSV net replaces either amount.
12
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15
16use crate::api::{repository_from_run_path, workflow_paths_match, ApiData, Job as ApiJob};
17use crate::display::{display_path, escape_display};
18use crate::job_cost::bill_job;
19use crate::money::Micros;
20use crate::prices::{billed_on_public, embedded_table, sku_for_labels};
21use crate::rank::WorkflowTotal;
22use crate::workflow::{ReusableSource, Workflow};
23
24/// How an amount was chosen.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CostMethod {
27    /// Share of the CSV net, weighted by billed minutes.
28    BilledMinutes,
29    /// Share of the CSV net, weighted by matrix cells times the minute rate.
30    YamlWeight,
31    /// Minutes times the runner table rate.
32    ListPrice,
33}
34
35impl CostMethod {
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Self::BilledMinutes => "billed_minutes",
39            Self::YamlWeight => "yaml_weight",
40            Self::ListPrice => "list_price",
41        }
42    }
43}
44
45/// One USD amount and the rule that produced it.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Amount {
48    pub micros: Micros,
49    pub method: CostMethod,
50}
51
52/// One job's reconciled cost.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct JobCost {
55    pub name: String,
56    pub file: String,
57    pub line: Option<usize>,
58    pub job_id: Option<u64>,
59    pub sku: Option<String>,
60    pub amount: Amount,
61}
62
63/// Measured list price, invoice net, and their difference for one workflow.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct WorkflowCost {
66    pub measured: Amount,
67    pub invoice: Amount,
68    pub difference: Amount,
69    pub jobs: Vec<JobCost>,
70}
71
72/// Reconciled costs aligned with the ranked workflow slice.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Reconciliation {
75    pub workflows: Vec<WorkflowCost>,
76    jobs: HashMap<u64, Amount>,
77}
78
79impl Reconciliation {
80    pub fn job(&self, id: u64) -> Option<&Amount> {
81        self.jobs.get(&id)
82    }
83}
84
85/// Scale saved jobs onto the CSV, or spread the CSV across YAML jobs.
86pub fn reconcile(
87    workflows: &[WorkflowTotal],
88    api: Option<&ApiData>,
89    scanned: &[Workflow],
90) -> Reconciliation {
91    let linked = link_jobs(workflows, api);
92    let public_repo = api.is_some_and(|data| data.public);
93    let mut jobs = HashMap::new();
94    let mut workflow_costs = Vec::with_capacity(workflows.len());
95    for (index, workflow) in workflows.iter().enumerate() {
96        workflow_costs.push(reconcile_workflow(
97            index,
98            workflow,
99            &linked,
100            scanned,
101            api.is_some(),
102            public_repo,
103            &mut jobs,
104        ));
105    }
106    for job in &linked {
107        jobs.entry(job.id).or_insert_with(|| Amount {
108            micros: list_price_invoice(public_repo, job.sku.as_deref(), job.list_price),
109            method: CostMethod::ListPrice,
110        });
111    }
112    Reconciliation {
113        workflows: workflow_costs,
114        jobs,
115    }
116}
117
118/// Text lines for the workflow table: totals, then one line per job.
119pub fn text_lines(cost: &WorkflowCost) -> Vec<String> {
120    let mut lines = vec![format!(
121        "  measured {}  invoice {}  difference {}",
122        cost.measured.micros.format_cents(),
123        cost.invoice.micros.format_cents(),
124        cost.difference.micros.format_cents(),
125    )];
126    for job in &cost.jobs {
127        lines.push(job_text(job));
128    }
129    lines
130}
131
132fn job_text(job: &JobCost) -> String {
133    let amount = job.amount.micros.format_cents();
134    let name = if job.name.is_empty() {
135        "job".to_string()
136    } else {
137        escape_display(&job.name)
138    };
139    if !job.file.is_empty() {
140        if let Some(line) = job.line {
141            let file = escape_display(&job.file);
142            return format!("  {name}  {file}:{line} = {amount}");
143        }
144    }
145    if let Some(id) = job.job_id {
146        return format!("  {name}  job {id} = {amount}");
147    }
148    format!("  {name} = {amount}")
149}
150
151struct LinkedJob {
152    id: u64,
153    name: String,
154    sku: Option<String>,
155    minutes: u64,
156    list_price: Micros,
157    workflow: Option<usize>,
158    billable: bool,
159}
160
161fn link_jobs(workflows: &[WorkflowTotal], api: Option<&ApiData>) -> Vec<LinkedJob> {
162    let Some(api) = api else {
163        return Vec::new();
164    };
165    api.jobs
166        .iter()
167        .map(|job| link_job(workflows, api, job))
168        .collect()
169}
170
171fn link_job(workflows: &[WorkflowTotal], api: &ApiData, job: &ApiJob) -> LinkedJob {
172    let bill = bill_job(api, job);
173    let path = api.workflow_path(job.run_id);
174    let repo = api
175        .runs
176        .iter()
177        .find(|run| run.id == job.run_id)
178        .and_then(repository_of_run);
179    let billable = job.counts() && !api.is_inherited_copy(job);
180    LinkedJob {
181        id: job.id,
182        name: job.name.clone(),
183        sku: bill.sku,
184        minutes: bill.minutes,
185        list_price: bill.list_price,
186        workflow: workflow_index(workflows, &path, repo.as_deref()),
187        billable,
188    }
189}
190
191fn repository_of_run(run: &crate::api::WorkflowRun) -> Option<String> {
192    run.repository
193        .clone()
194        .or_else(|| repository_from_run_path(&run.path))
195}
196
197fn workflow_index(workflows: &[WorkflowTotal], path: &str, repo: Option<&str>) -> Option<usize> {
198    let mut hits = Vec::new();
199    for (index, workflow) in workflows.iter().enumerate() {
200        if !workflow_paths_match(&workflow.workflow_path, path) {
201            continue;
202        }
203        if let Some(repo) = repo {
204            if workflow.repository.eq_ignore_ascii_case(repo) {
205                hits.push(index);
206            }
207        } else {
208            hits.push(index);
209        }
210    }
211    if hits.len() == 1 {
212        Some(hits[0])
213    } else {
214        None
215    }
216}
217
218/// Invoice for a job that has no CSV row.
219///
220/// A public repository invoices a standard SKU at zero. The list price is
221/// unchanged. A larger runner (`billed_on_public`) keeps the list price.
222pub(crate) fn list_price_invoice(
223    public_repo: bool,
224    sku: Option<&str>,
225    list_price: Micros,
226) -> Micros {
227    if public_repo && !sku.is_some_and(billed_on_public) {
228        Micros::ZERO
229    } else {
230        list_price
231    }
232}
233
234fn reconcile_workflow(
235    index: usize,
236    workflow: &WorkflowTotal,
237    linked: &[LinkedJob],
238    scanned: &[Workflow],
239    had_api: bool,
240    public_repo: bool,
241    amounts: &mut HashMap<u64, Amount>,
242) -> WorkflowCost {
243    let slots = yaml_slots(scanned, &workflow.workflow_path);
244    let mut used_jobs = HashSet::new();
245    let mut used_slots = HashSet::new();
246    let mut costs = Vec::new();
247
248    for sku in &workflow.skus {
249        let api_indexes = linked
250            .iter()
251            .enumerate()
252            .filter(|(_, job)| {
253                job.workflow == Some(index)
254                    && job.billable
255                    && !used_jobs.contains(&job.id)
256                    && job
257                        .sku
258                        .as_ref()
259                        .is_some_and(|name| name.eq_ignore_ascii_case(&sku.sku))
260            })
261            .map(|(slot, _)| slot)
262            .collect::<Vec<_>>();
263        if !api_indexes.is_empty() {
264            assign_api(
265                sku.net,
266                linked,
267                &api_indexes,
268                &mut used_jobs,
269                &mut costs,
270                amounts,
271            );
272            continue;
273        }
274        let slot_indexes = slots
275            .iter()
276            .enumerate()
277            .filter(|(slot_index, slot)| {
278                !used_slots.contains(slot_index) && slot.sku.eq_ignore_ascii_case(&sku.sku)
279            })
280            .map(|(slot_index, _)| slot_index)
281            .collect::<Vec<_>>();
282        if slot_indexes.is_empty() {
283            continue;
284        }
285        assign_yaml(sku.net, &slots, &slot_indexes, &mut used_slots, &mut costs);
286    }
287
288    for job in linked.iter().filter(|job| job.workflow == Some(index)) {
289        if used_jobs.contains(&job.id) {
290            continue;
291        }
292        let amount = Amount {
293            micros: list_price_invoice(public_repo, job.sku.as_deref(), job.list_price),
294            method: CostMethod::ListPrice,
295        };
296        amounts.insert(job.id, amount.clone());
297        if amount.micros != Micros::ZERO {
298            costs.push(api_cost(job, amount));
299        }
300    }
301    for (slot_index, slot) in slots.iter().enumerate() {
302        if used_slots.contains(&slot_index) {
303            continue;
304        }
305        costs.push(yaml_list_cost(slot));
306    }
307
308    costs.sort_by(|left, right| {
309        left.file
310            .cmp(&right.file)
311            .then(left.line.cmp(&right.line))
312            .then(left.name.cmp(&right.name))
313            .then(left.sku.cmp(&right.sku))
314            .then(left.job_id.cmp(&right.job_id))
315    });
316
317    let measured = linked
318        .iter()
319        .filter(|job| job.workflow == Some(index))
320        .fold(Micros::ZERO, |total, job| add_micros(total, job.list_price));
321    let invoice = workflow.net;
322    let invoice_method = invoice_method(&costs, had_api, !slots.is_empty());
323    WorkflowCost {
324        measured: Amount {
325            micros: measured,
326            method: CostMethod::ListPrice,
327        },
328        invoice: Amount {
329            micros: invoice,
330            method: invoice_method,
331        },
332        difference: Amount {
333            micros: sub_micros(invoice, measured),
334            method: invoice_method,
335        },
336        jobs: costs,
337    }
338}
339
340fn invoice_method(jobs: &[JobCost], had_api: bool, had_yaml: bool) -> CostMethod {
341    if jobs
342        .iter()
343        .any(|job| job.amount.method == CostMethod::BilledMinutes)
344    {
345        CostMethod::BilledMinutes
346    } else if jobs
347        .iter()
348        .any(|job| job.amount.method == CostMethod::YamlWeight)
349    {
350        CostMethod::YamlWeight
351    } else if had_api {
352        CostMethod::BilledMinutes
353    } else if had_yaml {
354        CostMethod::YamlWeight
355    } else {
356        CostMethod::ListPrice
357    }
358}
359
360fn assign_api(
361    net: Micros,
362    linked: &[LinkedJob],
363    indexes: &[usize],
364    used: &mut HashSet<u64>,
365    costs: &mut Vec<JobCost>,
366    amounts: &mut HashMap<u64, Amount>,
367) {
368    let parts = indexes
369        .iter()
370        .map(|index| {
371            let job = &linked[*index];
372            Part {
373                weight: i128::from(job.minutes),
374                rank: i128::from(job.list_price.0),
375                tie: i64::try_from(job.id).unwrap_or(i64::MAX),
376            }
377        })
378        .collect::<Vec<_>>();
379    let shares = allocate(net.0, &parts);
380    for (index, share) in indexes.iter().zip(shares) {
381        let job = &linked[*index];
382        used.insert(job.id);
383        let amount = Amount {
384            micros: Micros(share),
385            method: CostMethod::BilledMinutes,
386        };
387        amounts.insert(job.id, amount.clone());
388        costs.push(api_cost(job, amount));
389    }
390}
391
392fn assign_yaml(
393    net: Micros,
394    slots: &[YamlSlot],
395    indexes: &[usize],
396    used: &mut HashSet<usize>,
397    costs: &mut Vec<JobCost>,
398) {
399    let parts = indexes
400        .iter()
401        .map(|index| {
402            let slot = &slots[*index];
403            Part {
404                weight: slot.weight,
405                rank: slot.weight,
406                tie: i64::try_from(slot.line).unwrap_or(i64::MAX),
407            }
408        })
409        .collect::<Vec<_>>();
410    let shares = allocate(net.0, &parts);
411    for (index, share) in indexes.iter().zip(shares) {
412        let slot = &slots[*index];
413        used.insert(*index);
414        costs.push(JobCost {
415            name: slot.name.clone(),
416            file: slot.file.clone(),
417            line: Some(slot.line),
418            job_id: None,
419            sku: Some(slot.sku.clone()),
420            amount: Amount {
421                micros: Micros(share),
422                method: CostMethod::YamlWeight,
423            },
424        });
425    }
426}
427
428fn api_cost(job: &LinkedJob, amount: Amount) -> JobCost {
429    JobCost {
430        name: job.name.clone(),
431        file: String::new(),
432        line: None,
433        job_id: Some(job.id),
434        sku: job.sku.clone(),
435        amount,
436    }
437}
438
439fn yaml_list_cost(slot: &YamlSlot) -> JobCost {
440    JobCost {
441        name: slot.name.clone(),
442        file: slot.file.clone(),
443        line: Some(slot.line),
444        job_id: None,
445        sku: Some(slot.sku.clone()),
446        amount: Amount {
447            micros: Micros::ZERO,
448            method: CostMethod::ListPrice,
449        },
450    }
451}
452
453struct Part {
454    weight: i128,
455    rank: i128,
456    tie: i64,
457}
458
459/// Split `total` by weight. The remainder micros go to the largest part.
460fn allocate(total: i64, parts: &[Part]) -> Vec<i64> {
461    let mut shares = vec![0; parts.len()];
462    if parts.is_empty() {
463        return shares;
464    }
465    let weight_sum: i128 = parts.iter().map(|part| part.weight.max(0)).sum();
466    if weight_sum == 0 {
467        shares[largest(parts)] = total;
468        return shares;
469    }
470    let mut assigned = 0i128;
471    for (index, part) in parts.iter().enumerate() {
472        let weight = part.weight.max(0);
473        let share = i128::from(total).saturating_mul(weight) / weight_sum;
474        shares[index] = clamp_i64(share);
475        assigned += share;
476    }
477    let remainder = i128::from(total) - assigned;
478    let index = largest(parts);
479    shares[index] = clamp_i64(i128::from(shares[index]) + remainder);
480    shares
481}
482
483fn largest(parts: &[Part]) -> usize {
484    parts
485        .iter()
486        .enumerate()
487        .max_by(|(_, left), (_, right)| {
488            left.weight
489                .cmp(&right.weight)
490                .then(left.rank.cmp(&right.rank))
491                .then(right.tie.cmp(&left.tie))
492        })
493        .map(|(index, _)| index)
494        .unwrap_or(0)
495}
496
497fn clamp_i64(value: i128) -> i64 {
498    i64::try_from(value).unwrap_or(if value > 0 { i64::MAX } else { i64::MIN })
499}
500
501fn add_micros(left: Micros, right: Micros) -> Micros {
502    Micros(left.0.saturating_add(right.0))
503}
504
505fn sub_micros(left: Micros, right: Micros) -> Micros {
506    match left.0.checked_sub(right.0) {
507        Some(value) => Micros(value),
508        None if left.0 >= right.0 => Micros(i64::MAX),
509        None => Micros(i64::MIN),
510    }
511}
512
513struct YamlSlot {
514    name: String,
515    file: String,
516    line: usize,
517    job_line: usize,
518    sku: String,
519    cells: u64,
520    weight: i128,
521}
522
523/// One runner share of a workflow, after the CSV minutes and net are split.
524#[derive(Debug, Clone, PartialEq, Eq)]
525pub(crate) struct RunnerPortion {
526    pub file: String,
527    /// Line of `runs-on`.
528    pub runs_on_line: usize,
529    /// Line of the job key.
530    pub job_line: usize,
531    pub sku: String,
532    pub cells: u64,
533    pub minutes: Micros,
534    pub net: Micros,
535}
536
537/// YAML jobs for `workflow`, with that workflow's CSV minutes and net attached.
538///
539/// Jobs that share a SKU split both figures by matrix cells times the table
540/// rate, the same weights as `yaml_weight`. A job with no CSV row keeps zero
541/// minutes and zero net; its cell count stays, so a stuck-run price can still
542/// use the table rate.
543pub(crate) fn runner_portions(
544    workflow: &WorkflowTotal,
545    scanned: &[Workflow],
546) -> Vec<RunnerPortion> {
547    let slots = yaml_slots(scanned, &workflow.workflow_path);
548    let mut portions: Vec<RunnerPortion> = slots
549        .iter()
550        .map(|slot| RunnerPortion {
551            file: slot.file.clone(),
552            runs_on_line: slot.line,
553            job_line: slot.job_line,
554            sku: slot.sku.clone(),
555            cells: slot.cells,
556            minutes: Micros::ZERO,
557            net: Micros::ZERO,
558        })
559        .collect();
560    for sku in &workflow.skus {
561        let indexes: Vec<usize> = slots
562            .iter()
563            .enumerate()
564            .filter(|(_, slot)| slot.sku.eq_ignore_ascii_case(&sku.sku))
565            .map(|(index, _)| index)
566            .collect();
567        if indexes.is_empty() {
568            continue;
569        }
570        let parts: Vec<Part> = indexes
571            .iter()
572            .map(|index| {
573                let slot = &slots[*index];
574                Part {
575                    weight: slot.weight,
576                    rank: slot.weight,
577                    tie: i64::try_from(slot.line).unwrap_or(i64::MAX),
578                }
579            })
580            .collect();
581        let minute_shares = allocate(sku.minutes.0, &parts);
582        let net_shares = allocate(sku.net.0, &parts);
583        for (index, (minutes, net)) in indexes
584            .iter()
585            .zip(minute_shares.into_iter().zip(net_shares))
586        {
587            portions[*index].minutes = Micros(minutes);
588            portions[*index].net = Micros(net);
589        }
590    }
591    portions
592}
593
594fn yaml_slots(scanned: &[Workflow], workflow_path: &str) -> Vec<YamlSlot> {
595    let Some(workflow) = scanned.iter().find(|workflow| {
596        workflow
597            .path
598            .file_name()
599            .and_then(|name| name.to_str())
600            .is_some_and(|name| same_workflow_file(workflow_path, name))
601    }) else {
602        return Vec::new();
603    };
604    let mut slots = Vec::new();
605    let mut stack = Vec::new();
606    collect_slots(workflow, scanned, 1, &mut stack, &mut slots);
607    slots
608}
609
610fn collect_slots(
611    workflow: &Workflow,
612    scanned: &[Workflow],
613    multiplier: u64,
614    stack: &mut Vec<std::path::PathBuf>,
615    slots: &mut Vec<YamlSlot>,
616) {
617    if stack.iter().any(|path| path == &workflow.path) {
618        return;
619    }
620    stack.push(workflow.path.clone());
621    for job in &workflow.jobs {
622        if let Some(call) = &job.uses {
623            if matches!(call.source, ReusableSource::Local) {
624                if let Some(callee) = find_callee(scanned, &call.file_name) {
625                    let next = multiplier.saturating_mul(cell_count(job)).max(1);
626                    collect_slots(callee, scanned, next, stack, slots);
627                }
628            }
629            continue;
630        }
631        slots.extend(slots_for_job(job, &workflow.path, multiplier));
632    }
633    stack.pop();
634}
635
636fn find_callee<'a>(scanned: &'a [Workflow], file_name: &str) -> Option<&'a Workflow> {
637    scanned.iter().find(|workflow| {
638        workflow
639            .path
640            .file_name()
641            .and_then(|name| name.to_str())
642            .is_some_and(|name| name.eq_ignore_ascii_case(file_name))
643    })
644}
645
646fn slots_for_job(job: &crate::workflow::Job, path: &Path, multiplier: u64) -> Vec<YamlSlot> {
647    let Some(runs_on) = &job.runs_on else {
648        return Vec::new();
649    };
650    let file = display_path(path);
651    let cells = cell_count(job).saturating_mul(multiplier).max(1);
652    let mut names = axis_names(&runs_on.value);
653    for label in &runs_on.labels {
654        for name in axis_names(label) {
655            push_unique(&mut names, name);
656        }
657    }
658    if names.is_empty() {
659        let labels = concrete_labels(&runs_on.labels);
660        let Some(sku) = sku_for_labels(&labels) else {
661            return Vec::new();
662        };
663        return vec![yaml_slot(job, &file, runs_on.line, &sku, cells)];
664    }
665    let Some(matrix) = &job.matrix else {
666        return Vec::new();
667    };
668    for name in &names {
669        let Some(axis) = matrix
670            .axes
671            .iter()
672            .find(|axis| axis.name.eq_ignore_ascii_case(name))
673        else {
674            continue;
675        };
676        let counts = sku_value_counts(axis);
677        if counts.is_empty() {
678            continue;
679        }
680        return split_cells(cells, &counts)
681            .into_iter()
682            .filter(|(_, count)| *count > 0)
683            .map(|(sku, count)| yaml_slot(job, &file, runs_on.line, &sku, count))
684            .collect();
685    }
686    Vec::new()
687}
688
689fn yaml_slot(
690    job: &crate::workflow::Job,
691    file: &str,
692    line: usize,
693    sku: &str,
694    cells: u64,
695) -> YamlSlot {
696    let rate = table_rate(sku);
697    YamlSlot {
698        name: job.name.clone(),
699        file: file.to_string(),
700        line,
701        job_line: job.line,
702        sku: sku.to_string(),
703        cells,
704        weight: i128::from(cells).saturating_mul(i128::from(rate.0)).max(0),
705    }
706}
707
708fn cell_count(job: &crate::workflow::Job) -> u64 {
709    let Some(matrix) = &job.matrix else {
710        return 1;
711    };
712    let upper = matrix.jobs_hi.unwrap_or(matrix.jobs);
713    matrix.jobs.max(upper).max(1)
714}
715
716fn concrete_labels(labels: &[String]) -> Vec<String> {
717    labels
718        .iter()
719        .filter(|label| !label.contains("${{"))
720        .cloned()
721        .collect()
722}
723
724struct SkuCount {
725    sku: String,
726    count: u64,
727    rate: Micros,
728}
729
730fn sku_value_counts(axis: &crate::workflow::MatrixAxis) -> Vec<SkuCount> {
731    let mut seen = HashSet::new();
732    let mut counts: Vec<SkuCount> = Vec::new();
733    for labels in &axis.values {
734        let key = labels
735            .iter()
736            .map(|label| label.to_ascii_lowercase())
737            .collect::<Vec<_>>()
738            .join("\u{0}");
739        if !seen.insert(key) {
740            continue;
741        }
742        let Some(sku) = sku_for_labels(labels) else {
743            continue;
744        };
745        if let Some(entry) = counts
746            .iter_mut()
747            .find(|entry| entry.sku.eq_ignore_ascii_case(&sku))
748        {
749            entry.count += 1;
750        } else {
751            let rate = table_rate(&sku);
752            counts.push(SkuCount {
753                sku,
754                count: 1,
755                rate,
756            });
757        }
758    }
759    counts
760}
761
762fn split_cells(total: u64, counts: &[SkuCount]) -> Vec<(String, u64)> {
763    let value_sum: u64 = counts.iter().map(|entry| entry.count).sum();
764    if value_sum == 0 {
765        return Vec::new();
766    }
767    let mut shares = Vec::with_capacity(counts.len());
768    let mut assigned = 0u64;
769    for entry in counts {
770        let share =
771            u64::try_from(u128::from(total) * u128::from(entry.count) / u128::from(value_sum))
772                .unwrap_or(u64::MAX);
773        assigned = assigned.saturating_add(share);
774        shares.push((entry.sku.clone(), share));
775    }
776    let remainder = total.saturating_sub(assigned);
777    if remainder > 0 {
778        let index = counts
779            .iter()
780            .enumerate()
781            .max_by(|(_, left), (_, right)| {
782                left.count
783                    .cmp(&right.count)
784                    .then(left.rate.cmp(&right.rate))
785                    .then(right.sku.cmp(&left.sku))
786            })
787            .map(|(index, _)| index)
788            .unwrap_or(0);
789        shares[index].1 = shares[index].1.saturating_add(remainder);
790    }
791    shares
792}
793
794fn table_rate(sku: &str) -> Micros {
795    embedded_table()
796        .ok()
797        .and_then(|table| table.get(sku).map(|price| price.usd_per_minute))
798        .unwrap_or(Micros::ZERO)
799}
800
801fn same_workflow_file(workflow_path: &str, file_name: &str) -> bool {
802    let key = format!(".github/workflows/{file_name}");
803    let normalized = workflow_path.replace('\\', "/");
804    if normalized.eq_ignore_ascii_case(&key) {
805        return true;
806    }
807    normalized.char_indices().any(|(index, separator)| {
808        separator == '/' && normalized[index + 1..].eq_ignore_ascii_case(&key)
809    })
810}
811
812fn axis_names(text: &str) -> Vec<String> {
813    let chars: Vec<char> = text.chars().collect();
814    let mut names = Vec::new();
815    let mut index = 0;
816    while index < chars.len() {
817        if !starts_with_matrix(&chars, index) {
818            index += 1;
819            continue;
820        }
821        let boundary = index == 0 || !is_name_char(chars[index - 1]);
822        if !boundary {
823            index += 1;
824            continue;
825        }
826        let after = index + MATRIX.len();
827        if let Some(end) = dotted_axis_end(&chars, after) {
828            push_unique(&mut names, chars[after + 1..end].iter().collect());
829            index = end;
830            continue;
831        }
832        if let Some((name, end)) = bracket_axis(&chars, after) {
833            push_unique(&mut names, name);
834            index = end;
835            continue;
836        }
837        index += 1;
838    }
839    names
840}
841
842const MATRIX: &[char] = &['m', 'a', 't', 'r', 'i', 'x'];
843
844fn starts_with_matrix(chars: &[char], index: usize) -> bool {
845    if index + MATRIX.len() > chars.len() {
846        return false;
847    }
848    chars[index..index + MATRIX.len()]
849        .iter()
850        .zip(MATRIX)
851        .all(|(char, expected)| char.to_ascii_lowercase() == *expected)
852}
853
854fn is_name_char(character: char) -> bool {
855    character.is_ascii_alphanumeric() || character == '_'
856}
857
858fn dotted_axis_end(chars: &[char], after_matrix: usize) -> Option<usize> {
859    if after_matrix >= chars.len() || chars[after_matrix] != '.' {
860        return None;
861    }
862    let start = after_matrix + 1;
863    if start >= chars.len() || !(chars[start].is_ascii_alphabetic() || chars[start] == '_') {
864        return None;
865    }
866    let mut end = start + 1;
867    while end < chars.len() && (is_name_char(chars[end]) || chars[end] == '-') {
868        end += 1;
869    }
870    while end > start && chars[end - 1] == '-' {
871        end -= 1;
872    }
873    if end == start {
874        None
875    } else {
876        Some(end)
877    }
878}
879
880fn bracket_axis(chars: &[char], after_matrix: usize) -> Option<(String, usize)> {
881    let mut index = skip_ws(chars, after_matrix);
882    if index >= chars.len() || chars[index] != '[' {
883        return None;
884    }
885    index = skip_ws(chars, index + 1);
886    if index >= chars.len() {
887        return None;
888    }
889    let quote = chars[index];
890    if quote != '\'' && quote != '"' {
891        return None;
892    }
893    index += 1;
894    let start = index;
895    while index < chars.len() && chars[index] != quote {
896        index += 1;
897    }
898    if index >= chars.len() {
899        return None;
900    }
901    let name: String = chars[start..index].iter().collect();
902    index = skip_ws(chars, index + 1);
903    if name.is_empty() || index >= chars.len() || chars[index] != ']' {
904        return None;
905    }
906    Some((name, index + 1))
907}
908
909fn skip_ws(chars: &[char], mut index: usize) -> usize {
910    while index < chars.len() && chars[index].is_whitespace() {
911        index += 1;
912    }
913    index
914}
915
916fn push_unique(names: &mut Vec<String>, name: String) {
917    if !names.iter().any(|item| item.eq_ignore_ascii_case(&name)) {
918        names.push(name);
919    }
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use crate::rank::SkuTotal;
926    use crate::workflow::scan_yaml;
927    use std::path::Path;
928    fn usd(text: &str) -> Micros {
929        Micros::parse(text).unwrap()
930    }
931
932    fn part(weight: i128, rank: i128, tie: i64) -> Part {
933        Part { weight, rank, tie }
934    }
935
936    fn workflow(path: &str, repo: &str, net: &str, sku: &str) -> WorkflowTotal {
937        let net = usd(net);
938        WorkflowTotal {
939            repository: repo.to_string(),
940            workflow_path: path.to_string(),
941            net,
942            gross: net,
943            discount: Micros::ZERO,
944            minutes: usd("1"),
945            skus: vec![SkuTotal {
946                sku: sku.to_string(),
947                net,
948                gross: net,
949                discount: Micros::ZERO,
950                minutes: usd("1"),
951            }],
952        }
953    }
954
955    #[test]
956    fn a_public_repository_invoices_standard_skus_at_zero() {
957        let macos = usd("0.062");
958        assert_eq!(
959            list_price_invoice(true, Some("actions_macos"), macos),
960            Micros::ZERO
961        );
962        assert_eq!(
963            list_price_invoice(true, Some("ACTIONS_LINUX"), usd("0.006")),
964            Micros::ZERO
965        );
966        assert_eq!(
967            list_price_invoice(true, Some("actions_windows"), usd("0.010")),
968            Micros::ZERO
969        );
970        assert_eq!(
971            list_price_invoice(true, Some("actions_linux_arm"), usd("0.005")),
972            Micros::ZERO
973        );
974        assert_eq!(
975            list_price_invoice(true, Some("actions_windows_arm"), usd("0.010")),
976            Micros::ZERO
977        );
978        assert_eq!(
979            list_price_invoice(true, Some("actions_linux_slim"), usd("0.002")),
980            Micros::ZERO
981        );
982        assert_eq!(list_price_invoice(true, None, usd("1")), Micros::ZERO);
983        assert_eq!(
984            list_price_invoice(true, Some("actions_linux_4_core"), usd("0.012")),
985            usd("0.012")
986        );
987        assert_eq!(
988            list_price_invoice(true, Some("Actions_Macos_L"), usd("0.077")),
989            usd("0.077")
990        );
991        assert_eq!(
992            list_price_invoice(true, Some("actions_macos_xl"), usd("0.102")),
993            usd("0.102")
994        );
995        assert_eq!(
996            list_price_invoice(false, Some("actions_macos"), macos),
997            macos
998        );
999        assert_eq!(
1000            list_price_invoice(false, Some("actions_linux_4_core"), usd("0.012")),
1001            usd("0.012")
1002        );
1003    }
1004
1005    #[test]
1006    fn remainder_micros_go_to_the_largest_weight() {
1007        let parts = vec![part(1, 1, 2), part(1, 1, 1), part(3, 3, 3)];
1008        let shares = allocate(100_001, &parts);
1009        assert_eq!(shares, vec![20_000, 20_000, 60_001]);
1010        assert_eq!(shares.iter().sum::<i64>(), 100_001);
1011
1012        let exact = allocate(100_000, &parts);
1013        assert_eq!(exact, vec![20_000, 20_000, 60_000]);
1014        assert_eq!(exact.iter().sum::<i64>(), 100_000);
1015
1016        let negative = allocate(-8, &parts);
1017        assert_eq!(negative, vec![-1, -1, -6]);
1018        assert_eq!(negative.iter().sum::<i64>(), -8);
1019    }
1020
1021    #[test]
1022    fn zero_weights_give_the_whole_net_to_the_largest_tie_break() {
1023        let parts = vec![part(0, 0, 5), part(0, 4, 9), part(0, 4, 2)];
1024        let shares = allocate(10, &parts);
1025        assert_eq!(shares, vec![0, 0, 10]);
1026        assert_eq!(shares.iter().sum::<i64>(), 10);
1027
1028        let mixed = allocate(10, &[part(0, 0, 1), part(5, 5, 2)]);
1029        assert_eq!(mixed, vec![0, 10]);
1030    }
1031
1032    #[test]
1033    fn yaml_weight_gives_macos_lint_and_linux_test_their_own_sku_net() {
1034        let text = "\
1035name: CI
1036on: push
1037jobs:
1038  lint:
1039    runs-on: macos-latest
1040    steps:
1041      - run: cargo clippy -- -D warnings
1042  test:
1043    runs-on: ubuntu-latest
1044    steps:
1045      - run: cargo test
1046";
1047        let scanned = vec![scan_yaml(Path::new("repo/.github/workflows/ci.yml"), text).unwrap()];
1048        assert_eq!(scanned[0].jobs[0].runs_on.as_ref().unwrap().line, 5);
1049        assert_eq!(scanned[0].jobs[1].runs_on.as_ref().unwrap().line, 9);
1050        let workflows = vec![WorkflowTotal {
1051            repository: "acme/app".to_string(),
1052            workflow_path: ".github/workflows/ci.yml".to_string(),
1053            net: usd("1.620001"),
1054            gross: usd("1.620001"),
1055            discount: Micros::ZERO,
1056            minutes: usd("11"),
1057            skus: vec![
1058                SkuTotal {
1059                    sku: "Actions_MacOS".to_string(),
1060                    net: usd("0.620001"),
1061                    gross: usd("0.620001"),
1062                    discount: Micros::ZERO,
1063                    minutes: usd("10"),
1064                },
1065                SkuTotal {
1066                    sku: "actions_linux".to_string(),
1067                    net: usd("1.000000"),
1068                    gross: usd("1.000000"),
1069                    discount: Micros::ZERO,
1070                    minutes: usd("1"),
1071                },
1072            ],
1073        }];
1074        let got = reconcile(&workflows, None, &scanned);
1075        let jobs = &got.workflows[0].jobs;
1076        assert_eq!(jobs.len(), 2, "{jobs:?}");
1077        let lint = jobs.iter().find(|job| job.name == "lint").unwrap();
1078        let test = jobs.iter().find(|job| job.name == "test").unwrap();
1079        assert_eq!(lint.line, Some(5));
1080        assert_eq!(lint.amount.method, CostMethod::YamlWeight);
1081        assert_eq!(lint.amount.micros, usd("0.620001"));
1082        assert_eq!(lint.sku.as_deref(), Some("actions_macos"));
1083        assert_eq!(test.line, Some(9));
1084        assert_eq!(test.amount.method, CostMethod::YamlWeight);
1085        assert_eq!(test.amount.micros, usd("1.000000"));
1086        assert_eq!(test.sku.as_deref(), Some("actions_linux"));
1087        let sum = lint.amount.micros.0 + test.amount.micros.0;
1088        assert_eq!(sum, workflows[0].net.0);
1089        assert_eq!(got.workflows[0].invoice.method, CostMethod::YamlWeight);
1090        assert_eq!(got.workflows[0].measured.method, CostMethod::ListPrice);
1091        assert_eq!(got.workflows[0].measured.micros, Micros::ZERO);
1092        assert_eq!(got.workflows[0].difference.micros, workflows[0].net);
1093    }
1094
1095    #[test]
1096    fn yaml_weight_scales_matrix_cells_and_puts_remainder_on_the_wider_job() {
1097        let text = "\
1098name: CI
1099on: push
1100jobs:
1101  one:
1102    runs-on: ubuntu-latest
1103    steps:
1104      - run: echo one
1105  many:
1106    runs-on: ubuntu-latest
1107    strategy:
1108      matrix:
1109        node: [18, 20]
1110    steps:
1111      - run: echo many
1112";
1113        let scanned = vec![scan_yaml(Path::new("app/.github/workflows/ci.yml"), text).unwrap()];
1114        let workflows = vec![workflow(
1115            ".github/workflows/ci.yml",
1116            "acme/app",
1117            "0.000010",
1118            "actions_linux",
1119        )];
1120        let got = reconcile(&workflows, None, &scanned);
1121        let jobs = &got.workflows[0].jobs;
1122        let one = jobs.iter().find(|job| job.name == "one").unwrap();
1123        let many = jobs.iter().find(|job| job.name == "many").unwrap();
1124        assert_eq!(one.amount.method, CostMethod::YamlWeight);
1125        assert_eq!(many.amount.method, CostMethod::YamlWeight);
1126        assert_eq!(one.amount.micros.0, 3);
1127        assert_eq!(many.amount.micros.0, 7);
1128        assert_eq!(one.amount.micros.0 + many.amount.micros.0, 10);
1129    }
1130
1131    #[test]
1132    fn a_job_without_a_csv_sku_keeps_a_zero_list_price_until_minutes_exist() {
1133        let text = "\
1134jobs:
1135  lint:
1136    runs-on: macos-latest
1137    steps:
1138      - run: cargo clippy
1139  extra:
1140    runs-on: ubuntu-latest
1141    steps:
1142      - run: echo extra
1143";
1144        let scanned = vec![scan_yaml(Path::new(".github/workflows/lint.yml"), text).unwrap()];
1145        let workflows = vec![workflow(
1146            ".github/workflows/lint.yml",
1147            "acme/app",
1148            "0.062",
1149            "actions_macos",
1150        )];
1151        let got = reconcile(&workflows, None, &scanned);
1152        let extra = got.workflows[0]
1153            .jobs
1154            .iter()
1155            .find(|job| job.name == "extra")
1156            .unwrap();
1157        assert_eq!(extra.amount.method, CostMethod::ListPrice);
1158        assert_eq!(extra.amount.micros, Micros::ZERO);
1159        let lint = got.workflows[0]
1160            .jobs
1161            .iter()
1162            .find(|job| job.name == "lint")
1163            .unwrap();
1164        assert_eq!(lint.amount.micros, usd("0.062"));
1165        assert_eq!(lint.amount.method, CostMethod::YamlWeight);
1166        assert!(lint.file.ends_with("lint.yml"));
1167    }
1168
1169    #[test]
1170    fn uppercase_crlf_and_flow_style_still_split_by_sku() {
1171        let text = "name: CI\r\non: push\r\njobs: {lint: {runs-on: MACOS-LATEST, steps: [{run: cargo clippy}]}, test: {runs-on: UBUNTU-LATEST, steps: [{run: cargo test}]}}\r\n";
1172        let scanned = vec![scan_yaml(Path::new("wf/.github/workflows/ci.yml"), text).unwrap()];
1173        let workflows = vec![WorkflowTotal {
1174            repository: "ACME/APP".to_string(),
1175            workflow_path: ".GitHub/Workflows/CI.yml".to_string(),
1176            net: usd("0.068"),
1177            gross: usd("0.068"),
1178            discount: Micros::ZERO,
1179            minutes: usd("2"),
1180            skus: vec![
1181                SkuTotal {
1182                    sku: "ACTIONS_MACOS".to_string(),
1183                    net: usd("0.062"),
1184                    gross: usd("0.062"),
1185                    discount: Micros::ZERO,
1186                    minutes: usd("1"),
1187                },
1188                SkuTotal {
1189                    sku: "ACTIONS_LINUX".to_string(),
1190                    net: usd("0.006"),
1191                    gross: usd("0.006"),
1192                    discount: Micros::ZERO,
1193                    minutes: usd("1"),
1194                },
1195            ],
1196        }];
1197        let got = reconcile(&workflows, None, &scanned);
1198        let jobs = &got.workflows[0].jobs;
1199        assert_eq!(jobs.len(), 2, "{jobs:?}");
1200        let sum = jobs.iter().map(|job| job.amount.micros.0).sum::<i64>();
1201        assert_eq!(sum, usd("0.068").0);
1202        assert!(jobs
1203            .iter()
1204            .all(|job| job.amount.method == CostMethod::YamlWeight));
1205        assert_eq!(
1206            jobs.iter()
1207                .find(|job| job.name == "lint")
1208                .unwrap()
1209                .amount
1210                .micros,
1211            usd("0.062")
1212        );
1213        assert_eq!(
1214            jobs.iter()
1215                .find(|job| job.name == "test")
1216                .unwrap()
1217                .amount
1218                .micros,
1219            usd("0.006")
1220        );
1221    }
1222}