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