1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CostMethod {
27 BilledMinutes,
29 YamlWeight,
31 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#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Amount {
48 pub micros: Micros,
49 pub method: CostMethod,
50}
51
52#[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#[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#[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
85pub 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
118pub 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
218pub(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
459fn 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#[derive(Debug, Clone, PartialEq, Eq)]
525pub(crate) struct RunnerPortion {
526 pub file: String,
527 pub runs_on_line: usize,
529 pub job_line: usize,
531 pub sku: String,
532 pub cells: u64,
533 pub minutes: Micros,
534 pub net: Micros,
535}
536
537pub(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}