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 #[serde(default)]
323 pub services: u64,
324 pub unknown: u64,
326 pub lost: u64,
328 pub untimed: u64,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct PricedRunner {
335 pub sku: Sku,
337 pub jobs: u64,
339 pub minutes: u64,
341 pub cost: Usd,
343}
344
345enum Priced {
347 Billed {
349 sku: Sku,
350 minutes: u64,
351 },
352 Free,
353 Service,
354 Unknown,
355 Lost,
356 Untimed,
357}
358
359pub fn billed_minutes(started: u64, finished: u64) -> u64 {
362 finished.saturating_sub(started).div_ceil(60_000).max(1)
363}
364
365fn price(run: &PricedRun, history: &PricingHistory, pricing: &Pricing) -> Option<Priced> {
366 match run.state {
367 JobState::Completed => {}
368 JobState::Lost => return Some(Priced::Lost),
369 JobState::Queued | JobState::Running | JobState::Elsewhere => return None,
370 }
371 let (Some(started), Some(finished)) = (run.started_at, run.finished_at) else {
372 return Some(Priced::Untimed);
373 };
374 let sku = pricing.of(run.profile.as_deref());
375 if sku.standard() && run.named_like_service() {
376 return Some(Priced::Service);
377 }
378 Some(match history.visibility.get(&run.repository) {
379 None => Priced::Unknown,
380 Some(Visibility::Public) if sku.standard() => Priced::Free,
381 Some(_) => Priced::Billed {
382 sku,
383 minutes: billed_minutes(started, finished),
384 },
385 })
386}
387
388impl Savings {
389 pub fn of(history: &PricingHistory, pricing: &Pricing, now: u64) -> Savings {
391 let until = now / 1000;
392 Savings {
393 until,
394 month: SavedWindow::of(Period::Month, month_start(until), history, pricing, now),
395 last_30_days: SavedWindow::of(
396 Period::Last30Days,
397 until.saturating_sub(THIRTY_DAYS),
398 history,
399 pricing,
400 now,
401 ),
402 }
403 }
404}
405
406impl SavedWindow {
407 fn of(
409 period: Period,
410 start: u64,
411 history: &PricingHistory,
412 pricing: &Pricing,
413 now: u64,
414 ) -> SavedWindow {
415 let kept_since = history.kept_since / 1000;
416 let cut = kept_since > start;
417 let mut window = SavedWindow {
418 period,
419 since: start.max(kept_since),
420 cut,
421 jobs: 0,
422 minutes: 0,
423 cost: Usd(0),
424 runners: Vec::new(),
425 free: 0,
426 services: 0,
427 unknown: 0,
428 lost: 0,
429 untimed: 0,
430 };
431 let mut runners: BTreeMap<Sku, PricedRunner> = BTreeMap::new();
432 let from = start.saturating_mul(1000).max(history.kept_since);
433 for run in history
434 .runs
435 .iter()
436 .filter(|run| (from..=now).contains(&run.at()))
437 {
438 match price(run, history, pricing) {
439 None => {}
440 Some(Priced::Billed { sku, minutes }) => {
441 let cost =
442 minutes.saturating_mul(sku.rate().0.saturating_sub(SELF_HOSTED_RATE));
443 let runner = runners.entry(sku).or_insert(PricedRunner {
444 sku,
445 jobs: 0,
446 minutes: 0,
447 cost: Usd(0),
448 });
449 runner.jobs += 1;
450 runner.minutes += minutes;
451 runner.cost.0 += cost;
452 window.jobs += 1;
453 window.minutes += minutes;
454 window.cost.0 += cost;
455 }
456 Some(Priced::Free) => window.free += 1,
457 Some(Priced::Service) => window.services += 1,
458 Some(Priced::Unknown) => window.unknown += 1,
459 Some(Priced::Lost) => window.lost += 1,
460 Some(Priced::Untimed) => window.untimed += 1,
461 }
462 }
463 window.runners = runners.into_values().collect();
464 window
465 }
466
467 pub fn not_priced(&self) -> u64 {
469 self.free + self.services + self.unknown + self.lost + self.untimed
470 }
471
472 pub fn headline(&self) -> String {
476 let when = if self.cut {
477 format!("Since {}", short_date(self.since))
478 } else {
479 match self.period {
480 Period::Month => "This month".to_owned(),
481 Period::Last30Days => "Last 30 days".to_owned(),
482 }
483 };
484 if self.jobs == 0 && self.not_priced() == 0 {
485 return format!("{when}: no job-minutes here yet");
486 }
487 let rate = match self.runners.as_slice() {
488 [one] => format!("at GitHub's {} rate", one.sku.runner()),
489 [] => "at GitHub's rates".to_owned(),
490 _ => "at GitHub's rates for each profile's runner".to_owned(),
491 };
492 let mut line = format!(
493 "{when}: {} here, an estimated {} {rate}, before your plan's free minutes, by today's profiles and \
494 repository visibility",
495 count(self.minutes, "job-minute", "job-minutes"),
496 self.cost,
497 );
498 if self.not_priced() > 0 {
499 let _ = write!(
500 line,
501 " ({} not priced)",
502 count(self.not_priced(), "job", "jobs")
503 );
504 }
505 line
506 }
507
508 pub fn details(&self) -> Vec<String> {
511 let mut lines: Vec<String> = self
512 .runners
513 .iter()
514 .map(|runner| {
515 format!(
516 "{} of {} at {} ({}, {} a minute): {}",
517 count(runner.minutes, "minute", "minutes"),
518 count(runner.jobs, "job", "jobs"),
519 runner.sku.key(),
520 runner.sku.runner(),
521 rate(runner.sku.rate()),
522 runner.cost,
523 )
524 })
525 .collect();
526 for (jobs, why) in [
527 (
528 self.free,
529 "free on GitHub: on a standard runner, of a public repository",
530 ),
531 (
532 self.services,
533 "not priced: their workflow is named as GitHub's Dependabot or Pages workflow, free on a standard \
534 runner",
535 ),
536 (
537 self.unknown,
538 "not priced: GitHub has not said whether their repository is public",
539 ),
540 (
541 self.lost,
542 "not priced: lost, so how long they ran is not known",
543 ),
544 (self.untimed, "not priced: no start or end recorded"),
545 ] {
546 if jobs > 0 {
547 lines.push(format!("{} {why}", count(jobs, "job", "jobs")));
548 }
549 }
550 if self.cut {
551 lines.push(format!(
552 "The history keeps jobs since {}",
553 short_date(self.since)
554 ));
555 }
556 lines.push(
557 "An estimate from this machine's run times, at each profile's priced_as and each repository's \
558 visibility as they are now; on GitHub's runners the jobs would run longer or shorter"
559 .to_owned(),
560 );
561 lines
562 }
563}
564
565fn rate(rate: Usd) -> String {
567 let thousandths = rate.0 / 1000;
568 format!(
569 "${}.{:03}",
570 thousands(thousandths / 1000),
571 thousandths % 1000
572 )
573}
574
575fn count(number: u64, one: &str, many: &str) -> String {
577 format!(
578 "{} {}",
579 thousands(number),
580 if number == 1 { one } else { many }
581 )
582}
583
584fn thousands(number: u64) -> String {
586 let digits = number.to_string();
587 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
588 for (index, digit) in digits.chars().enumerate() {
589 if index > 0 && (digits.len() - index).is_multiple_of(3) {
590 out.push(',');
591 }
592 out.push(digit);
593 }
594 out
595}
596
597#[cfg(test)]
598mod tests;