Skip to main content

haste_subscription/
export.rs

1//! Serializes [`crate::TIERS`] for the website's pricing page.
2//!
3//! The pricing page renders whatever this produces, so a published price or
4//! limit cannot disagree with what the server enforces. Written by
5//! `cargo run subscription export` and committed; CI re-runs it with `--check`.
6
7use crate::{
8    OPERATION_POINTS, OperationPoints, REQUEST_BUDGET_WINDOW_MINUTES, RequestBudget, ResourceLimit,
9    TIERS, TierLimits,
10};
11use serde::Serialize;
12
13/// What a tier's budget buys per window, in whole requests of one kind.
14///
15/// Derived rather than written down: a budget is only meaningful next to what it
16/// affords, and stating it in requests is how buyers think about it.
17#[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            // Unmetered has no meaningful example count.
28            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/// One tier as the pricing page consumes it: the enforced limits, plus the
44/// derived figures and prose the page would otherwise have to compute itself.
45#[derive(Debug, Serialize)]
46pub struct ExportedTier<'a> {
47    /// Every field of the tier definition, flattened so the page reads
48    /// `tier.price` rather than `tier.limits.price`.
49    #[serde(flatten)]
50    pub limits: &'a TierLimits,
51    /// The request budget rendered for display, e.g. `"500,000 points / 15 minutes"`.
52    pub request_budget_label: String,
53    /// The total resource cap rendered for display, e.g. `"250,000"`.
54    pub total_resources_label: String,
55    /// What the budget buys, for the explanatory line under it.
56    pub budget_examples: BudgetExamples,
57}
58
59/// The whole pricing table, as written to the website.
60#[derive(Debug, Serialize)]
61pub struct Export<'a> {
62    /// Warns anyone who opens the committed file not to edit it.
63    #[serde(rename = "$comment")]
64    pub comment: &'static str,
65    /// The cost of each kind of request, so the page can state it once.
66    pub operation_points: &'a OperationPoints,
67    /// How long a request budget lasts before it resets, e.g. `"15 minutes"`.
68    pub window: String,
69    pub tiers: Vec<ExportedTier<'a>>,
70}
71
72/// Formats a number with thousands separators: `25000` becomes `"25,000"`.
73fn 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
87/// The budget window for display: `"15 minutes"`.
88fn 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
112/// Builds the export from [`TIERS`].
113pub 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        // 500,000 points at 1 per read, 6 per search and 25 per write.
168        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    /// The page keys its cards off the claim value, so each tier must serialize
177    /// to the string the JWT carries.
178    #[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    /// The website's `ResourceLimit`/`RequestBudget` types narrow on these exact
192    /// shapes: an externally tagged object for a variant with a value, a bare
193    /// tag string for a unit variant. Changing either breaks the pricing page's
194    /// static render, so pin both here.
195    #[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        // Free is capped, so every limit is an object carrying its count.
201        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        // Uncapped limits are the bare tag string, not `{"unlimited": null}`.
211        assert_eq!(tiers[1]["total_resources"], serde_json::json!("unlimited"));
212        assert_eq!(tiers[3]["request_budget"], serde_json::json!("unmetered"));
213    }
214
215    /// `flatten` puts the tier's own fields at the top level; the page reads
216    /// them there.
217    #[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}