1use std::collections::BTreeMap;
6use std::fmt::{self, Write as _};
7
8use serde::{Deserialize, Serialize};
9
10use super::JobState;
11use super::units::{civil_from_days, days_from_civil, short_date};
12
13pub const SELF_HOSTED_RATE: u64 = 0;
16
17const THIRTY_DAYS: u64 = 30 * 86_400;
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct Usd(pub u64);
24
25impl fmt::Display for Usd {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 let cents = self.0 / 10_000;
29 write!(f, "${}.{:02}", thousands(cents / 100), cents % 100)
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36pub enum Sku {
37 #[serde(rename = "actions_linux_slim")]
39 ActionsLinuxSlim,
40 #[serde(rename = "actions_linux")]
42 ActionsLinux,
43 #[serde(rename = "actions_linux_arm")]
45 ActionsLinuxArm,
46 #[serde(rename = "linux_2_core_advanced")]
48 Linux2CoreAdvanced,
49 #[serde(rename = "linux_4_core")]
51 Linux4Core,
52 #[serde(rename = "linux_8_core")]
54 Linux8Core,
55 #[serde(rename = "linux_16_core")]
57 Linux16Core,
58 #[serde(rename = "linux_32_core")]
60 Linux32Core,
61 #[serde(rename = "linux_64_core")]
63 Linux64Core,
64 #[serde(rename = "linux_96_core")]
66 Linux96Core,
67 #[serde(rename = "linux_2_core_arm")]
69 Linux2CoreArm,
70 #[serde(rename = "linux_4_core_arm")]
72 Linux4CoreArm,
73 #[serde(rename = "linux_8_core_arm")]
75 Linux8CoreArm,
76 #[serde(rename = "linux_16_core_arm")]
78 Linux16CoreArm,
79 #[serde(rename = "linux_32_core_arm")]
81 Linux32CoreArm,
82 #[serde(rename = "linux_64_core_arm")]
84 Linux64CoreArm,
85}
86
87impl Sku {
88 pub fn standard_for(arch: &str) -> Sku {
91 if arch == "aarch64" {
92 Sku::ActionsLinuxArm
93 } else {
94 Sku::ActionsLinux
95 }
96 }
97
98 pub fn rate(self) -> Usd {
100 Usd(match self {
101 Sku::ActionsLinuxSlim => 2_000,
102 Sku::ActionsLinux | Sku::Linux2CoreAdvanced => 6_000,
103 Sku::ActionsLinuxArm | Sku::Linux2CoreArm => 5_000,
104 Sku::Linux4Core => 12_000,
105 Sku::Linux8Core => 22_000,
106 Sku::Linux16Core => 42_000,
107 Sku::Linux32Core => 82_000,
108 Sku::Linux64Core => 162_000,
109 Sku::Linux96Core => 252_000,
110 Sku::Linux4CoreArm => 8_000,
111 Sku::Linux8CoreArm => 14_000,
112 Sku::Linux16CoreArm => 26_000,
113 Sku::Linux32CoreArm => 50_000,
114 Sku::Linux64CoreArm => 98_000,
115 })
116 }
117
118 pub fn standard(self) -> bool {
121 matches!(
122 self,
123 Sku::ActionsLinuxSlim | Sku::ActionsLinux | Sku::ActionsLinuxArm
124 )
125 }
126
127 pub fn key(self) -> &'static str {
129 match self {
130 Sku::ActionsLinuxSlim => "actions_linux_slim",
131 Sku::ActionsLinux => "actions_linux",
132 Sku::ActionsLinuxArm => "actions_linux_arm",
133 Sku::Linux2CoreAdvanced => "linux_2_core_advanced",
134 Sku::Linux4Core => "linux_4_core",
135 Sku::Linux8Core => "linux_8_core",
136 Sku::Linux16Core => "linux_16_core",
137 Sku::Linux32Core => "linux_32_core",
138 Sku::Linux64Core => "linux_64_core",
139 Sku::Linux96Core => "linux_96_core",
140 Sku::Linux2CoreArm => "linux_2_core_arm",
141 Sku::Linux4CoreArm => "linux_4_core_arm",
142 Sku::Linux8CoreArm => "linux_8_core_arm",
143 Sku::Linux16CoreArm => "linux_16_core_arm",
144 Sku::Linux32CoreArm => "linux_32_core_arm",
145 Sku::Linux64CoreArm => "linux_64_core_arm",
146 }
147 }
148
149 pub fn runner(self) -> &'static str {
151 match self {
152 Sku::ActionsLinuxSlim => "1-core Linux",
153 Sku::ActionsLinux => "2-core Linux",
154 Sku::ActionsLinuxArm => "2-core Linux arm64",
155 Sku::Linux2CoreAdvanced => "Advanced 2-core Linux",
156 Sku::Linux4Core => "4-core Linux",
157 Sku::Linux8Core => "8-core Linux",
158 Sku::Linux16Core => "16-core Linux",
159 Sku::Linux32Core => "32-core Linux",
160 Sku::Linux64Core => "64-core Linux",
161 Sku::Linux96Core => "96-core Linux",
162 Sku::Linux2CoreArm => "2-core Linux arm64 (larger runner)",
163 Sku::Linux4CoreArm => "4-core Linux arm64",
164 Sku::Linux8CoreArm => "8-core Linux arm64",
165 Sku::Linux16CoreArm => "16-core Linux arm64",
166 Sku::Linux32CoreArm => "32-core Linux arm64",
167 Sku::Linux64CoreArm => "64-core Linux arm64",
168 }
169 }
170}
171
172impl fmt::Display for Sku {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 f.write_str(self.key())
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "kebab-case")]
181pub enum Visibility {
182 Public,
184 Private,
186 Internal,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct Pricing {
193 pub profiles: BTreeMap<String, Sku>,
195 pub default_sku: Sku,
197}
198
199impl Pricing {
200 pub fn of(&self, profile: Option<&str>) -> Sku {
202 profile
203 .and_then(|profile| self.profiles.get(profile))
204 .copied()
205 .unwrap_or(self.default_sku)
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct PricedRun {
213 pub repository: String,
215 pub workflow: Option<String>,
217 pub labels: Vec<String>,
219 pub state: JobState,
221 pub profile: Option<String>,
223 pub queued_at: u64,
225 pub started_at: Option<u64>,
227 pub finished_at: Option<u64>,
229}
230
231impl PricedRun {
232 pub fn at(&self) -> u64 {
235 self.finished_at
236 .or(self.started_at)
237 .unwrap_or(self.queued_at)
238 }
239
240 fn named_like_service(&self) -> bool {
244 self.workflow.as_deref().is_some_and(|workflow| {
245 let workflow = workflow.to_ascii_lowercase();
246 workflow == "dependabot updates"
247 || workflow == "pages build and deployment"
248 || workflow.starts_with("dynamic/dependabot/")
249 || workflow.starts_with("dynamic/pages/")
250 })
251 }
252}
253
254#[derive(Debug, Clone, Default, PartialEq, Eq)]
256pub struct PricingHistory {
257 pub runs: Vec<PricedRun>,
259 pub visibility: BTreeMap<String, Visibility>,
261 pub kept_since: u64,
264}
265
266pub fn reads_since(now: u64) -> u64 {
269 month_start(now / 1000)
270 .min((now / 1000).saturating_sub(THIRTY_DAYS))
271 .saturating_mul(1000)
272}
273
274fn month_start(at: u64) -> u64 {
276 let (year, month, _) = civil_from_days(at / 86_400);
277 days_from_civil(year, month, 1) * 86_400
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct Savings {
283 pub until: u64,
285 pub month: SavedWindow,
287 pub last_30_days: SavedWindow,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "kebab-case")]
294pub enum Period {
295 Month,
297 Last30Days,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct SavedWindow {
304 pub period: Period,
306 pub since: u64,
308 pub cut: bool,
310 pub jobs: u64,
312 pub minutes: u64,
314 pub cost: Usd,
316 pub runners: Vec<PricedRunner>,
318 pub free: u64,
320 pub services: u64,
323 pub unknown: u64,
325 pub lost: u64,
327 pub untimed: u64,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct PricedRunner {
334 pub sku: Sku,
336 pub jobs: u64,
338 pub minutes: u64,
340 pub cost: Usd,
342}
343
344enum Priced {
346 Billed {
348 sku: Sku,
349 minutes: u64,
350 },
351 Free,
352 Service,
353 Unknown,
354 Lost,
355 Untimed,
356}
357
358pub fn billed_minutes(started: u64, finished: u64) -> u64 {
361 finished.saturating_sub(started).div_ceil(60_000).max(1)
362}
363
364fn price(run: &PricedRun, history: &PricingHistory, pricing: &Pricing) -> Option<Priced> {
365 match run.state {
366 JobState::Completed => {}
367 JobState::Lost => return Some(Priced::Lost),
368 JobState::Queued | JobState::Running | JobState::Elsewhere => return None,
369 }
370 let (Some(started), Some(finished)) = (run.started_at, run.finished_at) else {
371 return Some(Priced::Untimed);
372 };
373 let sku = pricing.of(run.profile.as_deref());
374 if sku.standard() && run.named_like_service() {
375 return Some(Priced::Service);
376 }
377 Some(match history.visibility.get(&run.repository) {
378 None => Priced::Unknown,
379 Some(Visibility::Public) if sku.standard() => Priced::Free,
380 Some(_) => Priced::Billed {
381 sku,
382 minutes: billed_minutes(started, finished),
383 },
384 })
385}
386
387impl Savings {
388 pub fn of(history: &PricingHistory, pricing: &Pricing, now: u64) -> Savings {
390 let until = now / 1000;
391 Savings {
392 until,
393 month: SavedWindow::of(Period::Month, month_start(until), history, pricing, now),
394 last_30_days: SavedWindow::of(
395 Period::Last30Days,
396 until.saturating_sub(THIRTY_DAYS),
397 history,
398 pricing,
399 now,
400 ),
401 }
402 }
403 pub fn windows(&self) -> Vec<&SavedWindow> {
406 if self.last_30_days.since == self.month.since {
407 vec![&self.month]
408 } else {
409 vec![&self.month, &self.last_30_days]
410 }
411 }
412}
413
414impl SavedWindow {
415 fn of(
417 period: Period,
418 start: u64,
419 history: &PricingHistory,
420 pricing: &Pricing,
421 now: u64,
422 ) -> SavedWindow {
423 let kept_since = history.kept_since / 1000;
424 let cut = kept_since > start;
425 let mut window = SavedWindow {
426 period,
427 since: start.max(kept_since),
428 cut,
429 jobs: 0,
430 minutes: 0,
431 cost: Usd(0),
432 runners: Vec::new(),
433 free: 0,
434 services: 0,
435 unknown: 0,
436 lost: 0,
437 untimed: 0,
438 };
439 let mut runners: BTreeMap<Sku, PricedRunner> = BTreeMap::new();
440 let from = start.saturating_mul(1000).max(history.kept_since);
441 for run in history
442 .runs
443 .iter()
444 .filter(|run| (from..=now).contains(&run.at()))
445 {
446 match price(run, history, pricing) {
447 None => {}
448 Some(Priced::Billed { sku, minutes }) => {
449 let cost =
450 minutes.saturating_mul(sku.rate().0.saturating_sub(SELF_HOSTED_RATE));
451 let runner = runners.entry(sku).or_insert(PricedRunner {
452 sku,
453 jobs: 0,
454 minutes: 0,
455 cost: Usd(0),
456 });
457 runner.jobs += 1;
458 runner.minutes += minutes;
459 runner.cost.0 += cost;
460 window.jobs += 1;
461 window.minutes += minutes;
462 window.cost.0 += cost;
463 }
464 Some(Priced::Free) => window.free += 1,
465 Some(Priced::Service) => window.services += 1,
466 Some(Priced::Unknown) => window.unknown += 1,
467 Some(Priced::Lost) => window.lost += 1,
468 Some(Priced::Untimed) => window.untimed += 1,
469 }
470 }
471 window.runners = runners.into_values().collect();
472 window
473 }
474
475 pub fn not_priced(&self) -> u64 {
477 self.free + self.services + self.unknown + self.lost + self.untimed
478 }
479
480 pub fn headline(&self) -> String {
484 let when = if self.cut {
485 format!("Since {}", short_date(self.since))
486 } else {
487 match self.period {
488 Period::Month => "This month".to_owned(),
489 Period::Last30Days => "Last 30 days".to_owned(),
490 }
491 };
492 if self.jobs == 0 && self.not_priced() == 0 {
493 return format!("{when}: no job-minutes here yet");
494 }
495 let rate = match self.runners.as_slice() {
496 [one] => format!("at GitHub's {} rate", one.sku.runner()),
497 [] => "at GitHub's rates".to_owned(),
498 _ => "at GitHub's rates for each profile's runner".to_owned(),
499 };
500 let mut line = format!(
501 "{when}: {} here, an estimated {} {rate}, before your plan's free minutes, by today's profiles and \
502 repository visibility",
503 count(self.minutes, "job-minute", "job-minutes"),
504 self.cost,
505 );
506 if self.not_priced() > 0 {
507 let _ = write!(
508 line,
509 " ({} not priced)",
510 count(self.not_priced(), "job", "jobs")
511 );
512 }
513 line
514 }
515
516 pub fn details(&self) -> Vec<String> {
519 let mut lines: Vec<String> = self
520 .runners
521 .iter()
522 .map(|runner| {
523 format!(
524 "{} of {} at {} ({}, {} a minute): {}",
525 count(runner.minutes, "minute", "minutes"),
526 count(runner.jobs, "job", "jobs"),
527 runner.sku.key(),
528 runner.sku.runner(),
529 rate(runner.sku.rate()),
530 runner.cost,
531 )
532 })
533 .collect();
534 for (jobs, why) in [
535 (
536 self.free,
537 "free on GitHub: on a standard runner, of a public repository",
538 ),
539 (
540 self.services,
541 "not priced: their workflow is named as GitHub's Dependabot or Pages workflow, free on a standard \
542 runner",
543 ),
544 (
545 self.unknown,
546 "not priced: GitHub has not said whether their repository is public",
547 ),
548 (
549 self.lost,
550 "not priced: lost, so how long they ran is not known",
551 ),
552 (self.untimed, "not priced: no start or end recorded"),
553 ] {
554 if jobs > 0 {
555 lines.push(format!("{} {why}", count(jobs, "job", "jobs")));
556 }
557 }
558 if self.cut {
559 lines.push(format!(
560 "The history keeps jobs since {}",
561 short_date(self.since)
562 ));
563 }
564 lines.push(
565 "An estimate from this machine's run times, at each profile's priced_as and each repository's \
566 visibility as they are now; on GitHub's runners the jobs would run longer or shorter"
567 .to_owned(),
568 );
569 lines
570 }
571}
572
573fn rate(rate: Usd) -> String {
575 let thousandths = rate.0 / 1000;
576 format!(
577 "${}.{:03}",
578 thousands(thousandths / 1000),
579 thousandths % 1000
580 )
581}
582
583fn count(number: u64, one: &str, many: &str) -> String {
585 format!(
586 "{} {}",
587 thousands(number),
588 if number == 1 { one } else { many }
589 )
590}
591
592fn thousands(number: u64) -> String {
594 let digits = number.to_string();
595 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
596 for (index, digit) in digits.chars().enumerate() {
597 if index > 0 && (digits.len() - index).is_multiple_of(3) {
598 out.push(',');
599 }
600 out.push(digit);
601 }
602 out
603}
604
605#[cfg(test)]
606mod tests;