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::{OPERATION_POINTS, OperationPoints, RequestBudget, ResourceLimit, TIERS, TierLimits};
8use serde::Serialize;
9
10/// What a tier's daily budget buys, in whole requests of one kind.
11///
12/// Derived rather than written down: a budget is only meaningful next to what it
13/// affords, and stating it in requests is how buyers think about it.
14#[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            // Unmetered has no meaningful example count.
25            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/// One tier as the pricing page consumes it: the enforced limits, plus the
41/// derived figures and prose the page would otherwise have to compute itself.
42#[derive(Debug, Serialize)]
43pub struct ExportedTier<'a> {
44    /// Every field of the tier definition, flattened so the page reads
45    /// `tier.price` rather than `tier.limits.price`.
46    #[serde(flatten)]
47    pub limits: &'a TierLimits,
48    /// The request budget rendered for display, e.g. `"250,000 points / day"`.
49    pub request_budget_label: String,
50    /// The total resource cap rendered for display, e.g. `"250,000"`.
51    pub total_resources_label: String,
52    /// What the budget buys, for the explanatory line under it.
53    pub budget_examples: BudgetExamples,
54}
55
56/// The whole pricing table, as written to the website.
57#[derive(Debug, Serialize)]
58pub struct Export<'a> {
59    /// Warns anyone who opens the committed file not to edit it.
60    #[serde(rename = "$comment")]
61    pub comment: &'static str,
62    /// The cost of each kind of request, so the page can state it once.
63    pub operation_points: &'a OperationPoints,
64    /// How long a request budget lasts before it resets.
65    pub window: &'static str,
66    pub tiers: Vec<ExportedTier<'a>>,
67}
68
69/// Formats a number with thousands separators: `25000` becomes `"25,000"`.
70fn 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
100/// Builds the export from [`TIERS`].
101pub 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        // 250,000 points at 1 per read, 6 per search and 25 per write.
156        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    /// The page keys its cards off the claim value, so each tier must serialize
165    /// to the string the JWT carries.
166    #[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    /// The website's `ResourceLimit`/`RequestBudget` types narrow on these exact
180    /// shapes: an externally tagged object for a variant with a value, a bare
181    /// tag string for a unit variant. Changing either breaks the pricing page's
182    /// static render, so pin both here.
183    #[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        // Free is capped, so every limit is an object carrying its count.
189        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        // Uncapped limits are the bare tag string, not `{"unlimited": null}`.
199        assert_eq!(tiers[1]["total_resources"], serde_json::json!("unlimited"));
200        assert_eq!(tiers[3]["request_budget"], serde_json::json!("unmetered"));
201    }
202
203    /// `flatten` puts the tier's own fields at the top level; the page reads
204    /// them there.
205    #[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}