haste_subscription/
export.rs1use crate::{
8 OPERATION_POINTS, OperationPoints, REQUEST_BUDGET_WINDOW_MINUTES, RequestBudget, ResourceLimit,
9 TIERS, TierLimits,
10};
11use serde::Serialize;
12
13#[derive(Debug, Serialize)]
18pub struct BudgetExamples {
19 pub reads_per_window: Option<u64>,
20 pub writes_per_window: Option<u64>,
21 pub searches_per_window: Option<u64>,
22}
23
24impl BudgetExamples {
25 fn for_budget(budget: &RequestBudget, points: &OperationPoints) -> Self {
26 let per_window = |cost: u32| match budget {
27 RequestBudget::Unmetered => None,
29 RequestBudget::Points(available) if cost > 0 => {
30 Some(*available as u64 / u64::from(cost))
31 }
32 RequestBudget::Points(_) => None,
33 };
34
35 Self {
36 reads_per_window: per_window(points.read),
37 writes_per_window: per_window(points.write),
38 searches_per_window: per_window(points.search),
39 }
40 }
41}
42
43#[derive(Debug, Serialize)]
46pub struct ExportedTier<'a> {
47 #[serde(flatten)]
50 pub limits: &'a TierLimits,
51 pub request_budget_label: String,
53 pub total_resources_label: String,
55 pub budget_examples: BudgetExamples,
57}
58
59#[derive(Debug, Serialize)]
61pub struct Export<'a> {
62 #[serde(rename = "$comment")]
64 pub comment: &'static str,
65 pub operation_points: &'a OperationPoints,
67 pub window: String,
69 pub tiers: Vec<ExportedTier<'a>>,
70}
71
72fn with_separators(value: u64) -> String {
74 let digits = value.to_string();
75 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
76
77 for (index, digit) in digits.chars().enumerate() {
78 if index > 0 && (digits.len() - index) % 3 == 0 {
79 out.push(',');
80 }
81 out.push(digit);
82 }
83
84 out
85}
86
87fn window_label() -> String {
89 format!("{REQUEST_BUDGET_WINDOW_MINUTES} minutes")
90}
91
92fn budget_label(budget: &RequestBudget) -> String {
93 match budget {
94 RequestBudget::Points(points) => {
95 format!(
96 "{} points / {}",
97 with_separators(*points as u64),
98 window_label()
99 )
100 }
101 RequestBudget::Unmetered => "Unmetered".to_string(),
102 }
103}
104
105fn resource_label(limit: &ResourceLimit) -> String {
106 match limit {
107 ResourceLimit::Count(count) => with_separators(*count),
108 ResourceLimit::Unlimited => "Unlimited".to_string(),
109 }
110}
111
112pub fn build() -> Export<'static> {
114 Export {
115 comment: "Generated from backend/crates/subscription by `cargo run subscription export`. Do not edit by hand.",
116 operation_points: &OPERATION_POINTS,
117 window: window_label(),
118 tiers: TIERS
119 .iter()
120 .map(|limits| ExportedTier {
121 limits,
122 request_budget_label: budget_label(&limits.request_budget),
123 total_resources_label: resource_label(&limits.total_resources),
124 budget_examples: BudgetExamples::for_budget(
125 &limits.request_budget,
126 &OPERATION_POINTS,
127 ),
128 })
129 .collect(),
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use haste_jwt::claims::SubscriptionTier;
137
138 #[test]
139 fn formats_thousands_separators() {
140 assert_eq!(with_separators(0), "0");
141 assert_eq!(with_separators(50), "50");
142 assert_eq!(with_separators(999), "999");
143 assert_eq!(with_separators(1_000), "1,000");
144 assert_eq!(with_separators(25_000), "25,000");
145 assert_eq!(with_separators(5_000_000), "5,000,000");
146 }
147
148 #[test]
149 fn labels_the_enforced_numbers() {
150 let export = build();
151 let free = &export.tiers[0];
152
153 assert_eq!(free.limits.tier, SubscriptionTier::Free);
154 assert_eq!(free.request_budget_label, "500,000 points / 15 minutes");
155 assert_eq!(free.total_resources_label, "250,000");
156
157 let self_hosted = export.tiers.last().expect("tiers are not empty");
158 assert_eq!(self_hosted.request_budget_label, "Unmetered");
159 assert_eq!(self_hosted.total_resources_label, "Unlimited");
160 }
161
162 #[test]
163 fn derives_what_the_budget_buys() {
164 let export = build();
165 let free = &export.tiers[0];
166
167 assert_eq!(free.budget_examples.reads_per_window, Some(500_000));
169 assert_eq!(free.budget_examples.searches_per_window, Some(83_333));
170 assert_eq!(free.budget_examples.writes_per_window, Some(20_000));
171
172 let self_hosted = export.tiers.last().expect("tiers are not empty");
173 assert_eq!(self_hosted.budget_examples.reads_per_window, None);
174 }
175
176 #[test]
179 fn serializes_the_tier_claim_value() {
180 let json = serde_json::to_value(build()).expect("export serializes");
181 let tiers = json["tiers"].as_array().expect("tiers is an array");
182
183 let ids: Vec<&str> = tiers
184 .iter()
185 .map(|tier| tier["tier"].as_str().expect("tier is a string"))
186 .collect();
187
188 assert_eq!(ids, ["free", "professional", "team", "unlimited"]);
189 }
190
191 #[test]
196 fn limits_keep_the_shape_the_website_narrows_on() {
197 let json = serde_json::to_value(build()).expect("export serializes");
198 let tiers = &json["tiers"];
199
200 assert_eq!(
202 tiers[0]["total_resources"],
203 serde_json::json!({"count": 250_000})
204 );
205 assert_eq!(
206 tiers[0]["request_budget"],
207 serde_json::json!({"points": 500_000})
208 );
209
210 assert_eq!(tiers[1]["total_resources"], serde_json::json!("unlimited"));
212 assert_eq!(tiers[3]["request_budget"], serde_json::json!("unmetered"));
213 }
214
215 #[test]
218 fn flattens_tier_fields_to_the_top_level() {
219 let json = serde_json::to_value(build()).expect("export serializes");
220 let free = &json["tiers"][0];
221
222 assert_eq!(free["display_name"], "Developer");
223 assert_eq!(free["price"], "$0");
224 assert_eq!(free["support"], "Community");
225 assert_eq!(free["tenant_customization"], false);
226 assert_eq!(free["projects"]["count"], 2);
227 }
228}