Skip to main content

max / makenotwork

Split db/enums.rs into one module per domain 2245 lines, 41 enums, 27 banner comments: a file that grew by appending. The banners were already the split, unwritten. Eight domain modules plus str_enum, which holds the `impl_str_enum!` macro every enum here is built with. `macro_rules!` is textually scoped, so siblings cannot see it by being siblings; it reaches them through a `pub(super) use` and a named import, never `#[macro_export]`, which would put it at the crate root. Each domain is re-exported flat, so `crate::db::ItemType` resolves exactly as before and no call site changes. The test module does not split. Its whole-set test names all 34 serde-deriving enums in one list and is tied to the Postgres CHECK registry in tests/workflows/enum_drift.rs; nine per-domain lists could each silently omit an enum, which is the drift that test exists to catch. Same 62 tests, all passing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-05 02:41 UTC
Signed with PGP, not checked
Commit: 315b8a0d63f2ba6ff616fca670cc5a8cdc2d32e0
Parent: 958ec41
12 files changed, +1953 insertions, -500 deletions
@@ -1,2245 +1,0 @@
1 - //! Strongly-typed domain enums that replace stringly-typed database columns.
2 - //!
3 - //! Each enum uses manual sqlx `Type`/`Encode`/`Decode` impls (via `String`)
4 - //! so it works with both VARCHAR and TEXT columns. Plus `Serialize`/`Deserialize`
5 - //! for JSON and form parsing.
6 -
7 - use serde::{Deserialize, Serialize};
8 -
9 - /// Generate `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode` impls
10 - /// for a simple enum ↔ string mapping. The sqlx impls delegate to `String`
11 - /// so the enum is compatible with any text-like column (TEXT, VARCHAR, etc.).
12 - macro_rules! impl_str_enum {
13 - ($enum_name:ident { $($variant:ident => $str:literal),+ $(,)? }) => {
14 - impl std::fmt::Display for $enum_name {
15 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 - let s = match self {
17 - $( Self::$variant => $str, )+
18 - };
19 - f.write_str(s)
20 - }
21 - }
22 -
23 - impl std::str::FromStr for $enum_name {
24 - type Err = String;
25 -
26 - fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
27 - match s {
28 - $( $str => Ok(Self::$variant), )+
29 - other => Err(format!("invalid {}: {other}", stringify!($enum_name))),
30 - }
31 - }
32 - }
33 -
34 - // sqlx Type: delegate to String so it's compatible with TEXT/VARCHAR.
35 - impl sqlx::Type<sqlx::Postgres> for $enum_name {
36 - fn type_info() -> sqlx::postgres::PgTypeInfo {
37 - <String as sqlx::Type<sqlx::Postgres>>::type_info()
38 - }
39 -
40 - fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
41 - <String as sqlx::Type<sqlx::Postgres>>::compatible(ty)
42 - }
43 - }
44 -
45 - // sqlx Encode: write the Display string.
46 - impl sqlx::Encode<'_, sqlx::Postgres> for $enum_name {
47 - fn encode_by_ref(
48 - &self,
49 - buf: &mut sqlx::postgres::PgArgumentBuffer,
50 - ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
51 - <String as sqlx::Encode<'_, sqlx::Postgres>>::encode(self.to_string(), buf)
52 - }
53 - }
54 -
55 - // sqlx Decode: parse the string value via FromStr.
56 - impl sqlx::Decode<'_, sqlx::Postgres> for $enum_name {
57 - fn decode(
58 - value: sqlx::postgres::PgValueRef<'_>,
59 - ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
60 - let s = <String as sqlx::Decode<'_, sqlx::Postgres>>::decode(value)?;
61 - Ok(s.parse::<Self>()?)
62 - }
63 - }
64 -
65 - // Allow comparison with string slices (useful in Askama templates).
66 - impl PartialEq<&str> for $enum_name {
67 - fn eq(&self, other: &&str) -> bool {
68 - let s: &str = match self {
69 - $( Self::$variant => $str, )+
70 - };
71 - s == *other
72 - }
73 - }
74 -
75 - impl PartialEq<str> for $enum_name {
76 - fn eq(&self, other: &str) -> bool {
77 - let s: &str = match self {
78 - $( Self::$variant => $str, )+
79 - };
80 - s == other
81 - }
82 - }
83 -
84 - impl $enum_name {
85 - /// Every wire/DB string this enum maps to, the single source of
86 - /// truth for the variant set. For each enum *registered* in the
87 - /// enum-drift integration test (`tests/workflows/enum_drift.rs`),
88 - /// this set is asserted equal to the Postgres `CHECK (... IN (...))`
89 - /// list on its backing column, so a variant added here without
90 - /// widening the DB constraint (or vice versa) fails at test time
91 - /// rather than at the first read of a poisoned row. Coverage is that
92 - /// registry, not every enum automatically: add a `(enum, table,
93 - /// column)` row there when a new CHECK-constrained column lands.
94 - pub const VARIANTS: &'static [&'static str] = &[$($str),+];
95 - }
96 - };
97 - }
98 -
99 - // --- Discount codes ---
100 -
101 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102 - #[serde(rename_all = "lowercase")]
103 - pub enum DiscountType {
104 - Percentage,
105 - Fixed,
106 - }
107 -
108 - impl_str_enum!(DiscountType {
109 - Percentage => "percentage",
110 - Fixed => "fixed",
111 - });
112 -
113 - // --- Promo codes ---
114 -
115 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116 - #[serde(rename_all = "snake_case")]
117 - pub enum CodePurpose {
118 - Discount,
119 - FreeAccess,
120 - FreeTrial,
121 - }
122 -
123 - impl_str_enum!(CodePurpose {
124 - Discount => "discount",
125 - FreeAccess => "free_access",
126 - FreeTrial => "free_trial",
127 - });
128 -
129 - // --- Waitlist ---
130 -
131 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132 - #[serde(rename_all = "lowercase")]
133 - pub enum WaitlistStatus {
134 - Pending,
135 - Approved,
136 - Spam,
137 - }
138 -
139 - impl_str_enum!(WaitlistStatus {
140 - Pending => "pending",
141 - Approved => "approved",
142 - Spam => "spam",
143 - });
144 -
145 - impl WaitlistStatus {
146 - /// Badge vocabulary (charter: `docs/design-system.md`).
147 - pub fn badge_status(self) -> crate::types::BadgeStatus {
148 - use crate::types::BadgeStatus;
149 - match self {
150 - Self::Approved => BadgeStatus::Live,
151 - Self::Pending => BadgeStatus::Pending,
152 - Self::Spam => BadgeStatus::Failed,
153 - }
154 - }
155 - }
156 -
157 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158 - pub enum SelectionMethod {
159 - #[serde(rename = "hand_picked")]
160 - HandPicked,
161 - #[serde(rename = "lottery")]
162 - Lottery,
163 - #[serde(rename = "invited")]
164 - Invited,
165 - }
166 -
167 - impl_str_enum!(SelectionMethod {
168 - HandPicked => "hand_picked",
169 - Lottery => "lottery",
170 - Invited => "invited",
171 - });
172 -
173 - // --- Transactions ---
174 -
175 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176 - #[serde(rename_all = "lowercase")]
177 - pub enum TransactionStatus {
178 - Pending,
179 - Completed,
180 - /// In-flight: a refund has been claimed (`completed -> refunding`) and sent to
181 - /// Stripe, but the `refund.created` webhook has not yet finalized it. Guards
182 - /// against double-submit on shared-cart PaymentIntents.
183 - Refunding,
184 - Refunded,
185 - /// Present in the DB `CHECK` since the initial schema but never written by
186 - /// the app (stale pending transactions are deleted, not failed). Kept as a
187 - /// variant so the enum can decode any legacy/manual `'failed'` row instead of
188 - /// fail-closed-poisoning the whole query, and so the enum-drift test's
189 - /// variant set matches the column constraint.
190 - Failed,
191 - }
192 -
193 - impl_str_enum!(TransactionStatus {
194 - Pending => "pending",
195 - Completed => "completed",
196 - Refunding => "refunding",
197 - Refunded => "refunded",
198 - Failed => "failed",
199 - });
200 -
201 - impl TransactionStatus {
202 - /// Badge vocabulary (charter: `docs/design-system.md`). A refund is over
203 - /// and needs nobody, so it is neutral rather than red.
204 - pub fn badge_status(self) -> crate::types::BadgeStatus {
205 - use crate::types::BadgeStatus;
206 - match self {
207 - Self::Completed => BadgeStatus::Live,
208 - Self::Pending | Self::Refunding => BadgeStatus::Pending,
209 - Self::Failed => BadgeStatus::Failed,
210 - Self::Refunded => BadgeStatus::Ended,
211 - }
212 - }
213 - }
214 -
215 - // --- Follows ---
216 -
217 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218 - #[serde(rename_all = "lowercase")]
219 - pub enum FollowTargetType {
220 - User,
221 - Project,
222 - Tag,
223 - }
224 -
225 - impl_str_enum!(FollowTargetType {
226 - User => "user",
227 - Project => "project",
228 - Tag => "tag",
229 - });
230 -
231 - // --- Subscriptions ---
232 -
233 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234 - pub enum SubscriptionStatus {
235 - #[serde(rename = "active")]
236 - Active,
237 - #[serde(rename = "trialing")]
238 - Trialing,
239 - #[serde(rename = "incomplete")]
240 - Incomplete,
241 - #[serde(rename = "incomplete_expired")]
242 - IncompleteExpired,
243 - #[serde(rename = "past_due")]
244 - PastDue,
245 - #[serde(rename = "canceled")]
246 - Canceled,
247 - #[serde(rename = "unpaid")]
248 - Unpaid,
249 - }
250 -
251 - impl_str_enum!(SubscriptionStatus {
252 - Active => "active",
253 - Trialing => "trialing",
254 - Incomplete => "incomplete",
255 - IncompleteExpired => "incomplete_expired",
256 - PastDue => "past_due",
257 - Canceled => "canceled",
258 - Unpaid => "unpaid",
259 - });
260 -
261 - impl SubscriptionStatus {
262 - /// Badge vocabulary (charter: `docs/design-system.md`). A trial is live
263 - /// because the subscriber has access; a cancellation is over and needs
264 - /// nobody, so it is neutral rather than red.
265 - pub fn badge_status(self) -> crate::types::BadgeStatus {
266 - use crate::types::BadgeStatus;
267 - match self {
268 - Self::Active | Self::Trialing => BadgeStatus::Live,
269 - Self::Incomplete => BadgeStatus::Pending,
270 - Self::IncompleteExpired | Self::PastDue | Self::Unpaid => BadgeStatus::Failed,
271 - Self::Canceled => BadgeStatus::Ended,
272 - }
273 - }
274 - }
275 -
276 - // --- SyncKit developer billing ---
277 -
278 - /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status`
279 - /// TEXT column, CHECK-constrained in migration 117). Replaces the raw string the
280 - /// `DbSyncAppBilling` model used to carry, so a status comparison can't drift from the
281 - /// CHECK set.
282 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283 - pub enum SyncBillingStatus {
284 - #[serde(rename = "draft")]
285 - Draft,
286 - #[serde(rename = "active")]
287 - Active,
288 - #[serde(rename = "suspended_unpaid")]
289 - SuspendedUnpaid,
290 - #[serde(rename = "canceled")]
291 - Canceled,
292 - }
293 -
294 - impl_str_enum!(SyncBillingStatus {
295 - Draft => "draft",
296 - Active => "active",
297 - SuspendedUnpaid => "suspended_unpaid",
298 - Canceled => "canceled",
299 - });
300 -
301 - /// How a SyncKit developer app's storage billing is enforced (the
302 - /// `sync_apps.enforcement_mode` TEXT column, CHECK-constrained to `('per_key','bulk')`
303 - /// in migration 118). Replaces the raw string the `DbSyncAppBilling` model used to
304 - /// carry. Lifting this to an enum makes `monthly_price_cents` match exhaustively, so an
305 - /// unrecognized mode is no longer silently priced at the floor (Pay-S2). The historical
306 - /// `app_wide` value was renamed to `bulk` in migration 118; only these two are live.
307 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308 - pub enum SyncEnforcementMode {
309 - #[serde(rename = "per_key")]
310 - PerKey,
311 - #[serde(rename = "bulk")]
312 - Bulk,
313 - }
314 -
315 - impl_str_enum!(SyncEnforcementMode {
316 - PerKey => "per_key",
317 - Bulk => "bulk",
318 - });
319 -
320 - // --- Git repository visibility ---
321 -
322 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323 - #[serde(rename_all = "lowercase")]
324 - pub enum Visibility {
325 - Public,
326 - Unlisted,
327 - Private,
328 - }
329 -
330 - impl_str_enum!(Visibility {
331 - Public => "public",
332 - Unlisted => "unlisted",
333 - Private => "private",
334 - });
335 -
336 - // --- Git repository kind ---
337 -
338 - /// What a repository is for.
339 - ///
340 - /// `Source` is every repository a creator makes. `Annotations` is the one
341 - /// per-account repository holding nothing but `refs/notes/*`, which is private
342 - /// permanently.
343 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344 - #[serde(rename_all = "lowercase")]
345 - pub enum GitRepoKind {
346 - Source,
347 - Annotations,
348 - }
349 -
350 - impl_str_enum!(GitRepoKind {
351 - Source => "source",
352 - Annotations => "annotations",
353 - });
354 -
355 - // --- Project member roles ---
356 -
357 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
358 - #[serde(rename_all = "lowercase")]
359 - pub enum ProjectRole {
360 - Owner,
361 - Member,
362 - }
363 -
364 - impl_str_enum!(ProjectRole {
365 - Owner => "owner",
366 - Member => "member",
367 - });
368 -
369 - // --- SyncKit ---
370 -
371 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372 - pub enum SyncOperation {
373 - #[serde(rename = "INSERT")]
374 - Insert,
375 - #[serde(rename = "UPDATE")]
376 - Update,
377 - #[serde(rename = "DELETE")]
378 - Delete,
379 - }
380 -
381 - impl_str_enum!(SyncOperation {
382 - Insert => "INSERT",
383 - Update => "UPDATE",
384 - Delete => "DELETE",
385 - });
386 -
387 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388 - #[serde(rename_all = "lowercase")]
389 - pub enum SyncPlatform {
390 - Macos,
391 - Ios,
392 - Android,
393 - Windows,
394 - Linux,
395 - Web,
396 - }
397 -
398 - impl_str_enum!(SyncPlatform {
399 - Macos => "macos",
400 - Ios => "ios",
401 - Android => "android",
402 - Windows => "windows",
403 - Linux => "linux",
404 - Web => "web",
405 - });
406 -
407 - // --- File scanning ---
408 -
409 - /// Status of an uploaded file in the scan pipeline.
410 - ///
411 - /// `Pending`, accepted, waiting in `scan_jobs` queue for a worker.
412 - /// `Scanning`, worker has claimed the job and is running the pipeline.
413 - /// `Clean`, pipeline completed, no Fail verdicts, no fail-closed Errors.
414 - /// `HeldForReview`, pipeline completed with a fail-closed Error, OR the
415 - /// uploader is untrusted (every untrusted upload routes to admin review).
416 - /// `Quarantined`, pipeline returned a Fail verdict on at least one layer.
417 - /// `Error`, pipeline itself crashed (worker exception, S3 fetch failed, etc.).
418 - ///
419 - /// Transitions are driven by `crate::scanning::final_status` and applied by
420 - /// `crate::scanning::worker`; `Pending` is the only entry state.
421 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
422 - #[serde(rename_all = "snake_case")]
423 - pub enum FileScanStatus {
424 - Pending,
425 - Scanning,
426 - Clean,
427 - Quarantined,
428 - HeldForReview,
429 - Error,
430 - }
431 -
432 - impl_str_enum!(FileScanStatus {
433 - Pending => "pending",
434 - Scanning => "scanning",
435 - Clean => "clean",
436 - Quarantined => "quarantined",
437 - HeldForReview => "held_for_review",
438 - Error => "error",
439 - });
440 -
441 - // --- Content Insertions ---
442 -
443 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444 - #[serde(rename_all = "snake_case")]
445 - pub enum InsertionPosition {
446 - PreRoll,
447 - MidRoll,
448 - PostRoll,
449 - }
450 -
451 - impl_str_enum!(InsertionPosition {
452 - PreRoll => "pre_roll",
453 - MidRoll => "mid_roll",
454 - PostRoll => "post_roll",
455 - });
456 -
457 - // --- Appeals ---
458 -
459 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
460 - #[serde(rename_all = "lowercase")]
461 - pub enum AppealDecision {
462 - Approved,
463 - Denied,
464 - }
465 -
466 - impl_str_enum!(AppealDecision {
467 - Approved => "approved",
468 - Denied => "denied",
469 - });
470 -
471 - // --- Discover sorting ---
472 -
473 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
474 - #[serde(rename_all = "snake_case")]
475 - pub enum DiscoverSort {
476 - Newest,
477 - MostSold,
478 - PriceAsc,
479 - PriceDesc,
480 - }
481 -
482 - impl_str_enum!(DiscoverSort {
483 - Newest => "newest",
484 - MostSold => "most_sold",
485 - PriceAsc => "price_asc",
486 - PriceDesc => "price_desc",
487 - });
488 -
489 - // --- Items ---
490 -
491 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
492 - #[serde(rename_all = "lowercase")]
493 - pub enum ItemType {
494 - Audio,
495 - Text,
496 - Video,
497 - Image,
498 - Plugin,
499 - Preset,
500 - Sample,
Lines truncated
@@ -1,0 +1,237 @@
1 + //! People and the standing they hold: waitlist and selection, project roles,
2 + //! creator tiers, and the reporting and moderation vocabulary.
3 +
4 + use super::str_enum::impl_str_enum;
5 + use serde::{Deserialize, Serialize};
6 +
7 + // --- Waitlist ---
8 +
9 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10 + #[serde(rename_all = "lowercase")]
11 + pub enum WaitlistStatus {
12 + Pending,
13 + Approved,
14 + Spam,
15 + }
16 +
17 + impl_str_enum!(WaitlistStatus {
18 + Pending => "pending",
19 + Approved => "approved",
20 + Spam => "spam",
21 + });
22 +
23 + impl WaitlistStatus {
24 + /// Badge vocabulary (charter: `docs/design-system.md`).
25 + pub fn badge_status(self) -> crate::types::BadgeStatus {
26 + use crate::types::BadgeStatus;
27 + match self {
28 + Self::Approved => BadgeStatus::Live,
29 + Self::Pending => BadgeStatus::Pending,
30 + Self::Spam => BadgeStatus::Failed,
31 + }
32 + }
33 + }
34 +
35 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36 + pub enum SelectionMethod {
37 + #[serde(rename = "hand_picked")]
38 + HandPicked,
39 + #[serde(rename = "lottery")]
40 + Lottery,
41 + #[serde(rename = "invited")]
42 + Invited,
43 + }
44 +
45 + impl_str_enum!(SelectionMethod {
46 + HandPicked => "hand_picked",
47 + Lottery => "lottery",
48 + Invited => "invited",
49 + });
50 +
51 + // --- Follows ---
52 +
53 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54 + #[serde(rename_all = "lowercase")]
55 + pub enum FollowTargetType {
56 + User,
57 + Project,
58 + Tag,
59 + }
60 +
61 + impl_str_enum!(FollowTargetType {
62 + User => "user",
63 + Project => "project",
64 + Tag => "tag",
65 + });
66 +
67 + // --- Project member roles ---
68 +
69 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70 + #[serde(rename_all = "lowercase")]
71 + pub enum ProjectRole {
72 + Owner,
73 + Member,
74 + }
75 +
76 + impl_str_enum!(ProjectRole {
77 + Owner => "owner",
78 + Member => "member",
79 + });
80 +
81 + // --- Appeals ---
82 +
83 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84 + #[serde(rename_all = "lowercase")]
85 + pub enum AppealDecision {
86 + Approved,
87 + Denied,
88 + }
89 +
90 + impl_str_enum!(AppealDecision {
91 + Approved => "approved",
92 + Denied => "denied",
93 + });
94 +
95 + // --- Reports ---
96 +
97 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98 + #[serde(rename_all = "lowercase")]
99 + pub enum ReportTargetType {
100 + Project,
101 + Item,
102 + }
103 +
104 + impl_str_enum!(ReportTargetType {
105 + Project => "project",
106 + Item => "item",
107 + });
108 +
109 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110 + #[serde(rename_all = "lowercase")]
111 + pub enum ReportType {
112 + Mislabeled,
113 + Spam,
114 + Abuse,
115 + Infringement,
116 + Other,
117 + }
118 +
119 + impl_str_enum!(ReportType {
120 + Mislabeled => "mislabeled",
121 + Spam => "spam",
122 + Abuse => "abuse",
123 + Infringement => "infringement",
124 + Other => "other",
125 + });
126 +
127 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128 + #[serde(rename_all = "lowercase")]
129 + pub enum ReportStatus {
130 + Open,
131 + Resolved,
132 + Dismissed,
133 + }
134 +
135 + impl_str_enum!(ReportStatus {
136 + Open => "open",
137 + Resolved => "resolved",
138 + Dismissed => "dismissed",
139 + });
140 +
141 + // --- Creator Tiers ---
142 +
143 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
144 + #[serde(rename_all = "snake_case")]
145 + pub enum CreatorTier {
146 + Basic,
147 + SmallFiles,
148 + BigFiles,
149 + Everything,
150 + }
151 +
152 + impl_str_enum!(CreatorTier {
153 + Basic => "basic",
154 + SmallFiles => "small_files",
155 + BigFiles => "big_files",
156 + Everything => "everything",
157 + });
158 +
159 + impl CreatorTier {
160 + /// Human-readable label for display.
161 + pub fn label(&self) -> &'static str {
162 + match self {
163 + Self::Basic => "Basic",
164 + Self::SmallFiles => "Small Files",
165 + Self::BigFiles => "Big Files",
166 + Self::Everything => "Everything",
167 + }
168 + }
169 +
170 + /// Monthly standard price in cents. Reads from the process-global
171 + /// `TierPrices` installed at startup from `assumptions.toml`. See
172 + /// `crate::tier_prices` for the OnceLock and test-setup helper.
173 + pub fn price_cents(&self) -> i32 {
174 + crate::tier_prices::TierPrices::global().price_cents_for(*self)
175 + }
176 +
177 + /// Maximum per-file upload size in bytes. Reads from the global
178 + /// `TierPrices` (see `price_cents`).
179 + pub fn max_file_bytes(&self) -> i64 {
180 + crate::tier_prices::TierPrices::global().max_file_bytes_for(*self)
181 + }
182 +
183 + /// Maximum total storage in bytes. Reads from the global `TierPrices`
184 + /// (see `price_cents`).
185 + pub fn max_storage_bytes(&self) -> i64 {
186 + crate::tier_prices::TierPrices::global().max_storage_bytes_for(*self)
187 + }
188 +
189 + /// Whether this tier allows non-cover file uploads (audio, downloads, insertions).
190 + /// Basic is text-only; covers are always allowed regardless of tier.
191 + pub fn allows_file_uploads(&self) -> bool {
192 + !matches!(self, Self::Basic)
193 + }
194 +
195 + /// Capability strings exposed to external OAuth implementers via `/oauth/userinfo`.
196 + ///
197 + /// Implementers gate features on these strings rather than tier names so the
198 + /// tier lineup can change without breaking callers. Only ship strings backed by
199 + /// live behavior; new capabilities are added when they actually launch.
200 + pub fn features(&self) -> &'static [&'static str] {
201 + match self {
202 + Self::Basic => &[],
203 + Self::SmallFiles => &["file_uploads"],
204 + Self::BigFiles => &["file_uploads", "large_files"],
205 + Self::Everything => &["file_uploads", "large_files"],
206 + }
207 + }
208 + }
209 +
210 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211 + #[serde(rename_all = "snake_case")]
212 + pub enum ModerationActionType {
213 + Warning,
214 + Suspension,
215 + Termination,
216 + ContentRemoval,
217 + }
218 +
219 + impl_str_enum!(ModerationActionType {
220 + Warning => "warning",
221 + Suspension => "suspension",
222 + Termination => "termination",
223 + ContentRemoval => "content_removal",
224 + });
225 +
226 + // Checkout types (Stripe metadata)
227 +
228 + impl ModerationActionType {
229 + pub fn label(&self) -> &'static str {
230 + match self {
231 + Self::Warning => "Warning",
232 + Self::Suspension => "Suspension",
233 + Self::Termination => "Termination",
234 + Self::ContentRemoval => "Content Removal",
235 + }
236 + }
237 + }
@@ -1,0 +1,443 @@
1 + //! What a creator publishes: item and project kinds, the features a project
2 + //! can switch on, the AI-disclosure tiers, and how discover sorts it all.
3 +
4 + use super::str_enum::impl_str_enum;
5 + use serde::{Deserialize, Serialize};
6 +
7 + // --- Content Insertions ---
8 +
9 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10 + #[serde(rename_all = "snake_case")]
11 + pub enum InsertionPosition {
12 + PreRoll,
13 + MidRoll,
14 + PostRoll,
15 + }
16 +
17 + impl_str_enum!(InsertionPosition {
18 + PreRoll => "pre_roll",
19 + MidRoll => "mid_roll",
20 + PostRoll => "post_roll",
21 + });
22 +
23 + // --- Discover sorting ---
24 +
25 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26 + #[serde(rename_all = "snake_case")]
27 + pub enum DiscoverSort {
28 + Newest,
29 + MostSold,
30 + PriceAsc,
31 + PriceDesc,
32 + }
33 +
34 + impl_str_enum!(DiscoverSort {
35 + Newest => "newest",
36 + MostSold => "most_sold",
37 + PriceAsc => "price_asc",
38 + PriceDesc => "price_desc",
39 + });
40 +
41 + // --- Items ---
42 +
43 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
44 + #[serde(rename_all = "lowercase")]
45 + pub enum ItemType {
46 + Audio,
47 + Text,
48 + Video,
49 + Image,
50 + Plugin,
51 + Preset,
52 + Sample,
53 + Course,
54 + Template,
55 + Digital,
56 + Bundle,
57 + }
58 +
59 + impl_str_enum!(ItemType {
60 + Audio => "audio",
61 + Text => "text",
62 + Video => "video",
63 + Image => "image",
64 + Plugin => "plugin",
65 + Preset => "preset",
66 + Sample => "sample",
67 + Course => "course",
68 + Template => "template",
69 + Digital => "digital",
70 + Bundle => "bundle",
71 + });
72 +
73 + impl ItemType {
74 + /// Short human-readable label for display (replaces `helpers::get_item_type_label`).
75 + pub fn label(&self) -> &'static str {
76 + match self {
77 + Self::Audio => "Audio",
78 + Self::Text => "Text",
79 + Self::Video => "Video",
80 + Self::Image => "Image",
81 + Self::Plugin => "Plugin",
82 + Self::Preset => "Preset",
83 + Self::Sample => "Sample",
84 + Self::Course => "Course",
85 + Self::Template => "Template",
86 + Self::Digital => "Digital",
87 + Self::Bundle => "Bundle",
88 + }
89 + }
90 +
91 + /// Which wizard content-input group this type belongs to.
92 + ///
93 + /// Determines what the content step looks like:
94 + /// - `"text"` → Markdown editor
95 + /// - `"audio"` → Audio file upload
96 + /// - `"video"` → Video file upload
97 + /// - `"bundle"` → Item picker for bundle contents
98 + /// - `"file"` → Generic file upload
99 + pub fn wizard_group(&self) -> &'static str {
100 + match self {
101 + Self::Text => "text",
102 + Self::Audio => "audio",
103 + Self::Video => "video",
104 + Self::Bundle => "bundle",
105 + _ => "file",
106 + }
107 + }
108 + }
109 +
110 + // --- AI Tiers ---
111 +
112 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
113 + #[serde(rename_all = "snake_case")]
114 + pub enum AiTier {
115 + Handmade,
116 + Assisted,
117 + Generated,
118 + }
119 +
120 + impl_str_enum!(AiTier {
121 + Handmade => "handmade",
122 + Assisted => "assisted",
123 + Generated => "generated",
124 + });
125 +
126 + impl AiTier {
127 + pub fn label(&self) -> &'static str {
128 + match self {
129 + Self::Handmade => "Handmade",
130 + Self::Assisted => "Assisted",
131 + Self::Generated => "Generated",
132 + }
133 + }
134 +
135 + /// The badge modifier for this tier. A disclosure level is not lifecycle,
136 + /// so it keeps its own names rather than joining the status set, but the
137 + /// class still comes from here rather than from the serialized value.
138 + pub fn css_class(&self) -> &'static str {
139 + match self {
140 + Self::Handmade => "ai-tier-handmade",
141 + Self::Assisted => "ai-tier-assisted",
142 + Self::Generated => "ai-tier-generated",
143 + }
144 + }
145 + }
146 +
147 + /// Discover-page filter shape per `about/generative-ai.md` § "How Fans
148 + /// Use This". Distinct from `AiTier` because this is a *filter*, not a
149 + /// per-item value: `HumanLed` aggregates the Handmade + Assisted tiers.
150 + /// `None` on `DiscoverFilters.ai_tier` means "Everything", no
151 + /// restriction.
152 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
153 + pub enum AiTierFilter {
154 + HandmadeOnly,
155 + HumanLed,
156 + }
157 +
158 + impl_str_enum!(AiTierFilter {
159 + HandmadeOnly => "handmade_only",
160 + HumanLed => "human_led",
161 + });
162 +
163 + impl AiTierFilter {
164 + pub fn label(&self) -> &'static str {
165 + match self {
166 + Self::HandmadeOnly => "Handmade only",
167 + Self::HumanLed => "Human-led",
168 + }
169 + }
170 + }
171 +
172 + // --- Project Features ---
173 +
174 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
175 + #[serde(rename_all = "snake_case")]
176 + pub enum ProjectFeature {
177 + Audio,
178 + Downloads,
179 + Text,
180 + Blog,
181 + Subscriptions,
182 + LicenseKeys,
183 + SourceCode,
184 + CloudSync,
185 + }
186 +
187 + impl_str_enum!(ProjectFeature {
188 + Audio => "audio",
189 + Downloads => "downloads",
190 + Text => "text",
191 + Blog => "blog",
192 + Subscriptions => "subscriptions",
193 + LicenseKeys => "license_keys",
194 + SourceCode => "source_code",
195 + CloudSync => "cloud_sync",
196 + });
197 +
198 + impl ProjectFeature {
199 + /// Human-readable label for display.
200 + pub fn label(&self) -> &'static str {
201 + match self {
202 + Self::Audio => "Audio",
203 + Self::Downloads => "Downloads",
204 + Self::Text => "Text",
205 + Self::Blog => "Blog",
206 + Self::Subscriptions => "Subscriptions",
207 + Self::LicenseKeys => "License Keys",
208 + Self::SourceCode => "Source Code",
209 + Self::CloudSync => "Cloud Sync",
210 + }
211 + }
212 +
213 + /// One-line description of what this feature enables.
214 + pub fn description(&self) -> &'static str {
215 + match self {
216 + Self::Audio => "Upload and stream audio files. Player with chapters.",
217 + Self::Downloads => "Host file downloads with versioned releases.",
218 + Self::Text => "Write and publish text content with markdown.",
219 + Self::Blog => "Project blog with RSS feed.",
220 + Self::Subscriptions => "Monthly subscriber tiers.",
221 + Self::LicenseKeys => "Software license management with activation API.",
222 + Self::SourceCode => "Git repository with source browser.",
223 + Self::CloudSync => "E2E encrypted cloud sync for desktop and mobile apps.",
224 + }
225 + }
226 +
227 + /// All features as (value, label, description) tuples for form rendering.
228 + pub fn all() -> &'static [(&'static str, &'static str, &'static str)] {
229 + &[
230 + (
231 + "audio",
232 + "Audio",
233 + "Upload and stream audio files. Player with chapters.",
234 + ),
235 + (
236 + "downloads",
237 + "Downloads",
238 + "Host file downloads with versioned releases.",
239 + ),
240 + (
241 + "text",
242 + "Text",
243 + "Write and publish text content with markdown.",
244 + ),
245 + ("blog", "Blog", "Project blog with RSS feed."),
246 + (
247 + "subscriptions",
248 + "Subscriptions",
249 + "Monthly subscriber tiers.",
250 + ),
251 + (
252 + "license_keys",
253 + "License Keys",
254 + "Software license management with activation API.",
255 + ),
256 + (
257 + "source_code",
258 + "Source Code",
259 + "Git repository with source browser.",
260 + ),
261 + (
262 + "cloud_sync",
263 + "Cloud Sync",
264 + "E2E encrypted cloud sync for desktop and mobile apps.",
265 + ),
266 + ]
267 + }
268 +
269 + /// Derive the best-fit project type from a set of features.
270 + pub fn derive_project_type(features: &[String]) -> ProjectType {
271 + if features.iter().any(|f| f == "audio") {
272 + return ProjectType::Music;
273 + }
274 + if features.iter().any(|f| f == "text") && !features.iter().any(|f| f == "downloads") {
275 + return ProjectType::Blog;
276 + }
277 + if features.iter().any(|f| f == "downloads") {
278 + return ProjectType::Software;
279 + }
280 + ProjectType::General
281 + }
282 +
283 + /// Which item types a feature unlocks.
284 + pub fn allowed_item_types(&self) -> &'static [ItemType] {
285 + match self {
286 + Self::Audio => &[ItemType::Audio, ItemType::Sample, ItemType::Preset],
287 + Self::Downloads => &[
288 + ItemType::Digital,
289 + ItemType::Plugin,
290 + ItemType::Template,
291 + ItemType::Course,
292 + ItemType::Image,
293 + ItemType::Video,
294 + ],
295 + Self::Text => &[ItemType::Text],
296 + // Non-content features don't gate item types
297 + Self::Blog
298 + | Self::Subscriptions
299 + | Self::LicenseKeys
300 + | Self::SourceCode
301 + | Self::CloudSync => &[],
302 + }
303 + }
304 +
305 + /// Compute the set of item types allowed by a project's feature list.
306 + /// If no content features are enabled, all types are allowed (permissive default).
307 + pub fn allowed_item_type_cards(
308 + features: &[String],
309 + ) -> Vec<(&'static str, &'static str, &'static str)> {
310 + let allowed: std::collections::HashSet<ItemType> = features
311 + .iter()
312 + .filter_map(|f| f.parse::<ProjectFeature>().ok())
313 + .flat_map(|f| f.allowed_item_types().iter().copied())
314 + .collect();
315 +
316 + // If no content features enabled, show all types (backwards compat)
317 + if allowed.is_empty() {
318 + return Self::all_item_type_cards().to_vec();
319 + }
320 +
321 + Self::all_item_type_cards()
322 + .iter()
323 + .filter(|(value, _, _)| {
324 + value
325 + .parse::<ItemType>()
326 + .is_ok_and(|t| t == ItemType::Bundle || allowed.contains(&t))
327 + })
328 + .copied()
329 + .collect()
330 + }
331 +
332 + /// All item type cards: (value, label, description) tuples for form rendering.
333 + pub fn all_item_type_cards() -> &'static [(&'static str, &'static str, &'static str)] {
334 + &[
335 + ("audio", "Audio", "Podcast, music, sound effects"),
336 + ("text", "Text", "Articles, posts, essays, guides"),
337 + ("digital", "Digital Download", "Files, archives, documents"),
338 + ("video", "Video", "Tutorials, films, recordings"),
339 + ("course", "Course", "Multi-part lessons, curricula"),
340 + ("plugin", "Plugin", "Software extensions, add-ons"),
341 + ("sample", "Sample Pack", "Audio samples, loops, one-shots"),
342 + ("preset", "Preset Pack", "Synth presets, effect chains"),
343 + ("template", "Template", "Design templates, starter kits"),
344 + ("image", "Image", "Photos, artwork, graphics"),
345 + ("bundle", "Bundle", "Collection of other items"),
346 + ]
347 + }
348 +
349 + /// Item type cards filtered to one per distinct wizard behavior group.
350 + ///
351 + /// The wizard only needs a type selector when the allowed types produce
352 + /// different content-step UIs (text editor vs audio upload vs file upload).
353 + /// Returns one card per group, using the first allowed type as the value.
354 + /// If all types share one group, returns a single card (caller should skip
355 + /// the type step entirely).
356 + pub fn wizard_type_cards(
357 + features: &[String],
358 + ) -> Vec<(&'static str, &'static str, &'static str)> {
359 + let allowed = Self::allowed_item_type_cards(features);
360 + let mut seen_groups = std::collections::HashSet::new();
361 + let mut cards = Vec::new();
362 +
363 + for (value, _, _) in &allowed {
364 + let Ok(item_type) = value.parse::<ItemType>() else {
365 + continue;
366 + };
367 + let group = item_type.wizard_group();
368 + if seen_groups.insert(group) {
369 + let (label, desc) = match group {
370 + "text" => ("Text", "Write in the editor"),
371 + "audio" => ("Audio", "Upload audio files"),
372 + "video" => ("Video", "Upload video files"),
373 + "bundle" => ("Bundle", "Collection of other items"),
374 + _ => ("File", "Upload any file"),
375 + };
376 + cards.push((*value, label, desc));
377 + }
378 + }
379 +
380 + cards
381 + }
382 + }
383 +
384 + // --- Projects ---
385 +
386 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
387 + #[serde(rename_all = "lowercase")]
388 + pub enum ProjectType {
389 + Blog,
390 + Book,
391 + Podcast,
392 + Course,
393 + Music,
394 + Software,
395 + Art,
396 + Writing,
397 + #[default]
398 + General,
399 + }
400 +
401 + impl_str_enum!(ProjectType {
402 + Blog => "blog",
403 + Book => "book",
404 + Podcast => "podcast",
405 + Course => "course",
406 + Music => "music",
407 + Software => "software",
408 + Art => "art",
409 + Writing => "writing",
410 + General => "general",
411 + });
412 +
413 + impl ProjectType {
414 + /// Human-readable label for display.
415 + pub fn label(&self) -> &'static str {
416 + match self {
417 + Self::Blog => "Blog",
418 + Self::Book => "Book",
419 + Self::Podcast => "Podcast",
420 + Self::Course => "Course",
421 + Self::Music => "Music",
422 + Self::Software => "Software",
423 + Self::Art => "Art",
424 + Self::Writing => "Writing",
425 + Self::General => "General",
426 + }
427 + }
428 +
429 + /// All valid project types as (value, label) pairs for form rendering.
430 + pub fn all() -> &'static [(&'static str, &'static str)] {
431 + &[
432 + ("blog", "Blog"),
433 + ("book", "Book"),
434 + ("podcast", "Podcast"),
435 + ("course", "Course"),
436 + ("music", "Music"),
437 + ("software", "Software"),
438 + ("art", "Art"),
439 + ("writing", "Writing"),
440 + ("general", "General"),
441 + ]
442 + }
443 + }
@@ -1,0 +1,164 @@
1 + //! Money moving: what a discount is, what a promo code is for, and the state
2 + //! machines a transaction and a subscription each walk.
3 +
4 + use super::str_enum::impl_str_enum;
5 + use serde::{Deserialize, Serialize};
6 +
7 + // --- Discount codes ---
8 +
9 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10 + #[serde(rename_all = "lowercase")]
11 + pub enum DiscountType {
12 + Percentage,
13 + Fixed,
14 + }
15 +
16 + impl_str_enum!(DiscountType {
17 + Percentage => "percentage",
18 + Fixed => "fixed",
19 + });
20 +
21 + // --- Promo codes ---
22 +
23 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24 + #[serde(rename_all = "snake_case")]
25 + pub enum CodePurpose {
26 + Discount,
27 + FreeAccess,
28 + FreeTrial,
29 + }
30 +
31 + impl_str_enum!(CodePurpose {
32 + Discount => "discount",
33 + FreeAccess => "free_access",
34 + FreeTrial => "free_trial",
35 + });
36 +
37 + // --- Transactions ---
38 +
39 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40 + #[serde(rename_all = "lowercase")]
41 + pub enum TransactionStatus {
42 + Pending,
43 + Completed,
44 + /// In-flight: a refund has been claimed (`completed -> refunding`) and sent to
45 + /// Stripe, but the `refund.created` webhook has not yet finalized it. Guards
46 + /// against double-submit on shared-cart PaymentIntents.
47 + Refunding,
48 + Refunded,
49 + /// Present in the DB `CHECK` since the initial schema but never written by
50 + /// the app (stale pending transactions are deleted, not failed). Kept as a
51 + /// variant so the enum can decode any legacy/manual `'failed'` row instead of
52 + /// fail-closed-poisoning the whole query, and so the enum-drift test's
53 + /// variant set matches the column constraint.
54 + Failed,
55 + }
56 +
57 + impl_str_enum!(TransactionStatus {
58 + Pending => "pending",
59 + Completed => "completed",
60 + Refunding => "refunding",
61 + Refunded => "refunded",
62 + Failed => "failed",
63 + });
64 +
65 + impl TransactionStatus {
66 + /// Badge vocabulary (charter: `docs/design-system.md`). A refund is over
67 + /// and needs nobody, so it is neutral rather than red.
68 + pub fn badge_status(self) -> crate::types::BadgeStatus {
69 + use crate::types::BadgeStatus;
70 + match self {
71 + Self::Completed => BadgeStatus::Live,
72 + Self::Pending | Self::Refunding => BadgeStatus::Pending,
73 + Self::Failed => BadgeStatus::Failed,
74 + Self::Refunded => BadgeStatus::Ended,
75 + }
76 + }
77 + }
78 +
79 + // --- Subscriptions ---
80 +
81 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82 + pub enum SubscriptionStatus {
83 + #[serde(rename = "active")]
84 + Active,
85 + #[serde(rename = "trialing")]
86 + Trialing,
87 + #[serde(rename = "incomplete")]
88 + Incomplete,
89 + #[serde(rename = "incomplete_expired")]
90 + IncompleteExpired,
91 + #[serde(rename = "past_due")]
92 + PastDue,
93 + #[serde(rename = "canceled")]
94 + Canceled,
95 + #[serde(rename = "unpaid")]
96 + Unpaid,
97 + }
98 +
99 + impl_str_enum!(SubscriptionStatus {
100 + Active => "active",
101 + Trialing => "trialing",
102 + Incomplete => "incomplete",
103 + IncompleteExpired => "incomplete_expired",
104 + PastDue => "past_due",
105 + Canceled => "canceled",
106 + Unpaid => "unpaid",
107 + });
108 +
109 + impl SubscriptionStatus {
110 + /// Badge vocabulary (charter: `docs/design-system.md`). A trial is live
111 + /// because the subscriber has access; a cancellation is over and needs
112 + /// nobody, so it is neutral rather than red.
113 + pub fn badge_status(self) -> crate::types::BadgeStatus {
114 + use crate::types::BadgeStatus;
115 + match self {
116 + Self::Active | Self::Trialing => BadgeStatus::Live,
117 + Self::Incomplete => BadgeStatus::Pending,
118 + Self::IncompleteExpired | Self::PastDue | Self::Unpaid => BadgeStatus::Failed,
119 + Self::Canceled => BadgeStatus::Ended,
120 + }
121 + }
122 + }
123 +
124 + // --- Project Pricing ---
125 +
126 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
127 + #[serde(rename_all = "snake_case")]
128 + pub enum PricingKind {
129 + #[default]
130 + Free,
131 + BuyOnce,
132 + Pwyw,
133 + Subscription,
134 + }
135 +
136 + impl_str_enum!(PricingKind {
137 + Free => "free",
138 + BuyOnce => "buy_once",
139 + Pwyw => "pwyw",
140 + Subscription => "subscription",
141 + });
142 +
143 + /// Discriminator for checkout session types stored in Stripe metadata.
144 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145 + #[serde(rename_all = "snake_case")]
146 + pub enum CheckoutType {
147 + Guest,
148 + Subscription,
149 + Tip,
150 + FanPlus,
151 + CreatorTier,
152 + Cart,
153 + SynckitAppSub,
154 + }
155 +
156 + impl_str_enum!(CheckoutType {
157 + Guest => "guest",
158 + Subscription => "subscription",
159 + Tip => "tip",
160 + FanPlus => "fan_plus",
161 + CreatorTier => "creator_tier",
162 + Cart => "cart",
163 + SynckitAppSub => "synckit_app_sub",
164 + });
@@ -1,0 +1,73 @@
1 + //! The git side: repository visibility and kind, issue state, build state.
2 +
3 + use super::str_enum::impl_str_enum;
4 + use serde::{Deserialize, Serialize};
5 +
6 + // --- Git repository visibility ---
7 +
8 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9 + #[serde(rename_all = "lowercase")]
10 + pub enum Visibility {
11 + Public,
12 + Unlisted,
13 + Private,
14 + }
15 +
16 + impl_str_enum!(Visibility {
17 + Public => "public",
18 + Unlisted => "unlisted",
19 + Private => "private",
20 + });
21 +
22 + // --- Git repository kind ---
23 +
24 + /// What a repository is for.
25 + ///
26 + /// `Source` is every repository a creator makes. `Annotations` is the one
27 + /// per-account repository holding nothing but `refs/notes/*`, which is private
28 + /// permanently.
29 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30 + #[serde(rename_all = "lowercase")]
31 + pub enum GitRepoKind {
32 + Source,
33 + Annotations,
34 + }
35 +
36 + impl_str_enum!(GitRepoKind {
37 + Source => "source",
38 + Annotations => "annotations",
39 + });
40 +
41 + // --- Git Issues ---
42 +
43 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44 + #[serde(rename_all = "lowercase")]
45 + pub enum IssueStatus {
46 + Open,
47 + Closed,
48 + }
49 +
50 + impl_str_enum!(IssueStatus {
51 + Open => "open",
52 + Closed => "closed",
53 + });
54 +
55 + // --- Build Pipeline ---
56 +
57 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58 + #[serde(rename_all = "snake_case")]
59 + pub enum BuildStatus {
60 + Pending,
61 + Running,
62 + Succeeded,
63 + Failed,
64 + Cancelled,
65 + }
66 +
67 + impl_str_enum!(BuildStatus {
68 + Pending => "pending",
69 + Running => "running",
70 + Succeeded => "succeeded",
71 + Failed => "failed",
72 + Cancelled => "cancelled",
73 + });
@@ -1,0 +1,59 @@
1 + //! Bringing a catalog in from somewhere else: where it came from and how far
2 + //! the job got.
3 +
4 + use super::str_enum::impl_str_enum;
5 + use serde::{Deserialize, Serialize};
6 +
7 + // --- Import System ---
8 +
9 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10 + #[serde(rename_all = "snake_case")]
11 + pub enum ImportSource {
12 + GenericCsv,
13 + Substack,
14 + Ghost,
15 + Gumroad,
16 + Bandcamp,
17 + LemonSqueezy,
18 + Patreon,
19 + }
20 +
21 + impl_str_enum!(ImportSource {
22 + GenericCsv => "generic_csv",
23 + Substack => "substack",
24 + Ghost => "ghost",
25 + Gumroad => "gumroad",
26 + Bandcamp => "bandcamp",
27 + LemonSqueezy => "lemon_squeezy",
28 + Patreon => "patreon",
29 + });
30 +
31 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32 + #[serde(rename_all = "lowercase")]
33 + pub enum ImportJobStatus {
34 + Pending,
35 + Processing,
36 + Completed,
37 + Failed,
38 + }
39 +
40 + impl_str_enum!(ImportJobStatus {
41 + Pending => "pending",
42 + Processing => "processing",
43 + Completed => "completed",
44 + Failed => "failed",
45 + });
46 +
47 + impl ImportJobStatus {
48 + /// Badge vocabulary (charter: `docs/design-system.md`).
49 + pub fn badge_status(self) -> crate::types::BadgeStatus {
50 + use crate::types::BadgeStatus;
51 + match self {
52 + Self::Completed => BadgeStatus::Live,
53 + Self::Pending | Self::Processing => BadgeStatus::Pending,
54 + Self::Failed => BadgeStatus::Failed,
55 + }
56 + }
57 + }
58 +
59 + // Moderation action types
@@ -1,0 +1,225 @@
1 + //! Mailing lists: what a list is attached to, what it carries, and every state
2 + //! a subscription to one can be in.
3 +
4 + use super::str_enum::impl_str_enum;
5 + use serde::{Deserialize, Serialize};
6 +
7 + // --- Mailing Lists ---
8 +
9 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10 + #[serde(rename_all = "lowercase")]
11 + pub enum MailingListType {
12 + Content,
13 + Devlog,
14 + Patches,
15 + }
16 +
17 + impl_str_enum!(MailingListType {
18 + Content => "content",
19 + Devlog => "devlog",
20 + Patches => "patches",
21 + });
22 +
23 + // --- Mailing lists (wiki: mnw-mailing-lists) ---
24 +
25 + /// The legacy per-project list types map onto the unified kinds one-for-one.
26 + /// Kept as a conversion rather than merging the two enums, because
27 + /// `MailingListType` is pinned by a CHECK on the old table and will be dropped
28 + /// with it rather than grown.
29 + impl From<MailingListType> for ListKind {
30 + fn from(t: MailingListType) -> Self {
31 + match t {
32 + MailingListType::Content => Self::Content,
33 + MailingListType::Devlog => Self::Devlog,
34 + MailingListType::Patches => Self::Patches,
35 + }
36 + }
37 + }
38 +
39 + /// What a list is attached to. `Platform` lists have no `scope_id`; every other
40 + /// scope requires one, and the database enforces the pairing.
41 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 + pub enum ListScope {
43 + Platform,
44 + Project,
45 + Repo,
46 + Creator,
47 + }
48 +
49 + impl_str_enum!(ListScope {
50 + Platform => "platform",
51 + Project => "project",
52 + Repo => "repo",
53 + Creator => "creator",
54 + });
55 +
56 + /// What the list carries. Kinds are deliberately coarse: the unsubscribe page
57 + /// shows one row per list, and a subscriber who has to reason about fifteen
58 + /// near-identical kinds will unsubscribe from all of them.
59 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
60 + pub enum ListKind {
61 + Content,
62 + Devlog,
63 + Patches,
64 + Releases,
65 + Issues,
66 + Announce,
67 + Marketing,
68 + // Account notification preferences, each mirroring a users.notify_* column.
69 + // Releases and Issues above double as these at platform scope; the rest are
70 + // their own kinds.
71 + Sale,
72 + Follower,
73 + Login,
74 + Status,
75 + Tip,
76 + // Someone redeemed one of your invite codes. Added by migration 195, when
77 + // that notice stopped being mail you could not turn off.
78 + Invite,
79 + }
80 +
81 + impl_str_enum!(ListKind {
82 + Content => "content",
83 + Devlog => "devlog",
84 + Patches => "patches",
85 + Releases => "releases",
86 + Issues => "issues",
87 + Announce => "announce",
88 + Marketing => "marketing",
89 + Sale => "sale",
90 + Follower => "follower",
91 + Login => "login",
92 + Status => "status",
93 + Tip => "tip",
94 + Invite => "invite",
95 + });
96 +
97 + impl ListKind {
98 + /// Every kind. Exists so a test can assert the enum against the
99 + /// `lists_kind_check` constraint: a kind lives in two places, and adding it
100 + /// to only one fails at INSERT on a deployed database rather than at
101 + /// compile time here.
102 + pub const ALL: &'static [ListKind] = &[
103 + ListKind::Content,
104 + ListKind::Devlog,
105 + ListKind::Patches,
106 + ListKind::Releases,
107 + ListKind::Issues,
108 + ListKind::Announce,
109 + ListKind::Marketing,
110 + ListKind::Sale,
111 + ListKind::Follower,
112 + ListKind::Login,
113 + ListKind::Status,
114 + ListKind::Tip,
115 + ListKind::Invite,
116 + ];
117 + }
118 +
119 + /// What is waiting to be acknowledged.
120 + ///
121 + /// Closed and asserted against the `pending_acknowledgements.kind` CHECK
122 + /// constraint by a test in `db::acknowledgements`: a variant added here without
123 + /// a migration compiles, passes every unit test, and then fails at INSERT on a
124 + /// deployed database.
125 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
126 + pub enum AckKind {
127 + /// The creator's Stripe account changed settlement currency, so every price
128 + /// they have already set is now a number denominated in different money.
129 + SettlementCurrencyChanged,
130 + }
131 +
132 + impl_str_enum!(AckKind {
133 + SettlementCurrencyChanged => "settlement_currency_changed",
134 + });
135 +
136 + impl AckKind {
137 + pub const ALL: &'static [AckKind] = &[AckKind::SettlementCurrencyChanged];
138 +
139 + /// Subject line and page heading, one source for both.
140 + pub fn title(self) -> &'static str {
141 + match self {
142 + Self::SettlementCurrencyChanged => "Your prices are now in a different currency",
143 + }
144 + }
145 + }
146 +
147 + /// Where a subscription stands.
148 + ///
149 + /// `Imported` is its own state on purpose. Everything the step-2 backfill
150 + /// carried over predates any consent record: calling it `Confirmed` would
151 + /// manufacture evidence we do not have, and calling it `Pending` would assert a
152 + /// double-opt-in is in flight when none is. The state says exactly what is
153 + /// known and no more.
154 + ///
155 + /// It is provenance, not a quarantine. An imported subscriber may be mailed:
156 + /// sendable, marketing included, and no re-confirmation pass is coming. See
157 + /// `db::lists::SENDABLE_STATES` for the reasoning. The
158 + /// state stays distinct because "we carried this over" is worth being able to
159 + /// say years later, not because anything gates on it.
160 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
161 + pub enum SubscriptionState {
162 + Pending,
163 + Confirmed,
164 + Imported,
165 + Unsubscribed,
166 + Bounced,
167 + }
168 +
169 + impl_str_enum!(SubscriptionState {
170 + Pending => "pending",
171 + Confirmed => "confirmed",
172 + Imported => "imported",
173 + Unsubscribed => "unsubscribed",
174 + Bounced => "bounced",
175 + });
176 +
177 + /// How the subscription was created, which the table this replaces could not
178 + /// say.
179 + ///
180 + /// Recorded for a re-confirmation pass that was then decided against (GoingsOn
181 + /// 04a882b4). It stays because "where did this address come from" is the first
182 + /// question asked of a complaint, and answering it is worth one column whether
183 + /// or not anything ever filters on it.
184 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
185 + pub enum SubscriptionSource {
186 + LandingForm,
187 + ProjectPage,
188 + Checkout,
189 + Import,
190 + Admin,
191 + Api,
192 + }
193 +
194 + impl_str_enum!(SubscriptionSource {
195 + LandingForm => "landing_form",
196 + ProjectPage => "project_page",
197 + Checkout => "checkout",
198 + Import => "import",
199 + Admin => "admin",
200 + Api => "api",
201 + });
202 +
203 + /// An entry in a subscription's consent history. The table is append-only: an
204 + /// opt-out adds a row rather than editing the opt-in that came before, and a
205 + /// database trigger rejects `UPDATE` so that stays true.
206 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
207 + pub enum ConsentEvent {
208 + OptIn,
209 + Confirm,
210 + OptOut,
211 + Bounce,
212 + Complaint,
213 + AdminRemoval,
214 + Import,
215 + }
216 +
217 + impl_str_enum!(ConsentEvent {
218 + OptIn => "opt_in",
219 + Confirm => "confirm",
220 + OptOut => "opt_out",
221 + Bounce => "bounce",
222 + Complaint => "complaint",
223 + AdminRemoval => "admin_removal",
224 + Import => "import",
225 + });
@@ -1,0 +1,31 @@
1 + //! Strongly-typed domain enums that replace stringly-typed database columns.
2 + //!
3 + //! Each enum uses manual sqlx `Type`/`Encode`/`Decode` impls (via `String`)
4 + //! so it works with both VARCHAR and TEXT columns. Plus `Serialize`/`Deserialize`
5 + //! for JSON and form parsing.
6 + //!
7 + //! Grouped by domain, one module per group, with the shared `impl_str_enum!`
8 + //! macro in `str_enum`. Every group is re-exported flat, so callers name
9 + //! `crate::db::ItemType` exactly as before.
10 +
11 + mod accounts;
12 + mod catalog;
13 + mod commerce;
14 + mod git;
15 + mod imports;
16 + mod mail;
17 + mod scanning;
18 + mod str_enum;
19 + mod synckit;
20 +
21 + pub use accounts::*;
22 + pub use catalog::*;
23 + pub use commerce::*;
24 + pub use git::*;
25 + pub use imports::*;
26 + pub use mail::*;
27 + pub use scanning::*;
28 + pub use synckit::*;
29 +
30 + #[cfg(test)]
31 + mod tests;
@@ -1,0 +1,38 @@
1 + //! Where an uploaded file is in the malware scan.
2 +
3 + use super::str_enum::impl_str_enum;
4 + use serde::{Deserialize, Serialize};
5 +
6 + // --- File scanning ---
7 +
8 + /// Status of an uploaded file in the scan pipeline.
9 + ///
10 + /// `Pending`, accepted, waiting in `scan_jobs` queue for a worker.
11 + /// `Scanning`, worker has claimed the job and is running the pipeline.
12 + /// `Clean`, pipeline completed, no Fail verdicts, no fail-closed Errors.
13 + /// `HeldForReview`, pipeline completed with a fail-closed Error, OR the
14 + /// uploader is untrusted (every untrusted upload routes to admin review).
15 + /// `Quarantined`, pipeline returned a Fail verdict on at least one layer.
16 + /// `Error`, pipeline itself crashed (worker exception, S3 fetch failed, etc.).
17 + ///
18 + /// Transitions are driven by `crate::scanning::final_status` and applied by
19 + /// `crate::scanning::worker`; `Pending` is the only entry state.
20 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21 + #[serde(rename_all = "snake_case")]
22 + pub enum FileScanStatus {
23 + Pending,
24 + Scanning,
25 + Clean,
26 + Quarantined,
27 + HeldForReview,
28 + Error,
29 + }
30 +
31 + impl_str_enum!(FileScanStatus {
32 + Pending => "pending",
33 + Scanning => "scanning",
34 + Clean => "clean",
35 + Quarantined => "quarantined",
36 + HeldForReview => "held_for_review",
37 + Error => "error",
38 + });
@@ -1,0 +1,97 @@
1 + //! The one macro every enum here is built with.
2 + //!
3 + //! `macro_rules!` is textually scoped, so a sibling cannot see it by being a
4 + //! sibling: it reaches them through the `pub(super) use` at the bottom of this
5 + //! file, and each sibling imports it by name.
6 +
7 + /// Generate `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode` impls
8 + /// for a simple enum ↔ string mapping. The sqlx impls delegate to `String`
9 + /// so the enum is compatible with any text-like column (TEXT, VARCHAR, etc.).
10 + macro_rules! impl_str_enum {
11 + ($enum_name:ident { $($variant:ident => $str:literal),+ $(,)? }) => {
12 + impl std::fmt::Display for $enum_name {
13 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14 + let s = match self {
15 + $( Self::$variant => $str, )+
16 + };
17 + f.write_str(s)
18 + }
19 + }
20 +
21 + impl std::str::FromStr for $enum_name {
22 + type Err = String;
23 +
24 + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
25 + match s {
26 + $( $str => Ok(Self::$variant), )+
27 + other => Err(format!("invalid {}: {other}", stringify!($enum_name))),
28 + }
29 + }
30 + }
31 +
32 + // sqlx Type: delegate to String so it's compatible with TEXT/VARCHAR.
33 + impl sqlx::Type<sqlx::Postgres> for $enum_name {
34 + fn type_info() -> sqlx::postgres::PgTypeInfo {
35 + <String as sqlx::Type<sqlx::Postgres>>::type_info()
36 + }
37 +
38 + fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
39 + <String as sqlx::Type<sqlx::Postgres>>::compatible(ty)
40 + }
41 + }
42 +
43 + // sqlx Encode: write the Display string.
44 + impl sqlx::Encode<'_, sqlx::Postgres> for $enum_name {
45 + fn encode_by_ref(
46 + &self,
47 + buf: &mut sqlx::postgres::PgArgumentBuffer,
48 + ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>> {
49 + <String as sqlx::Encode<'_, sqlx::Postgres>>::encode(self.to_string(), buf)
50 + }
51 + }
52 +
53 + // sqlx Decode: parse the string value via FromStr.
54 + impl sqlx::Decode<'_, sqlx::Postgres> for $enum_name {
55 + fn decode(
56 + value: sqlx::postgres::PgValueRef<'_>,
57 + ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
58 + let s = <String as sqlx::Decode<'_, sqlx::Postgres>>::decode(value)?;
59 + Ok(s.parse::<Self>()?)
60 + }
61 + }
62 +
63 + // Allow comparison with string slices (useful in Askama templates).
64 + impl PartialEq<&str> for $enum_name {
65 + fn eq(&self, other: &&str) -> bool {
66 + let s: &str = match self {
67 + $( Self::$variant => $str, )+
68 + };
69 + s == *other
70 + }
71 + }
72 +
73 + impl PartialEq<str> for $enum_name {
74 + fn eq(&self, other: &str) -> bool {
75 + let s: &str = match self {
76 + $( Self::$variant => $str, )+
77 + };
78 + s == other
79 + }
80 + }
81 +
82 + impl $enum_name {
83 + /// Every wire/DB string this enum maps to, the single source of
84 + /// truth for the variant set. For each enum *registered* in the
85 + /// enum-drift integration test (`tests/workflows/enum_drift.rs`),
86 + /// this set is asserted equal to the Postgres `CHECK (... IN (...))`
87 + /// list on its backing column, so a variant added here without
88 + /// widening the DB constraint (or vice versa) fails at test time
89 + /// rather than at the first read of a poisoned row. Coverage is that
90 + /// registry, not every enum automatically: add a `(enum, table,
91 + /// column)` row there when a new CHECK-constrained column lands.
92 + pub const VARIANTS: &'static [&'static str] = &[$($str),+];
93 + }
94 + };
95 + }
96 +
97 + pub(super) use impl_str_enum;
@@ -1,0 +1,86 @@
1 + //! SyncKit developer billing and the sync traffic it meters.
2 +
3 + use super::str_enum::impl_str_enum;
4 + use serde::{Deserialize, Serialize};
5 +
6 + // --- SyncKit developer billing ---
7 +
8 + /// Lifecycle of a SyncKit developer app's billing record (the `sync_apps.billing_status`
9 + /// TEXT column, CHECK-constrained in migration 117). Replaces the raw string the
10 + /// `DbSyncAppBilling` model used to carry, so a status comparison can't drift from the
11 + /// CHECK set.
12 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13 + pub enum SyncBillingStatus {
14 + #[serde(rename = "draft")]
15 + Draft,
16 + #[serde(rename = "active")]
17 + Active,
18 + #[serde(rename = "suspended_unpaid")]
19 + SuspendedUnpaid,
20 + #[serde(rename = "canceled")]
21 + Canceled,
22 + }
23 +
24 + impl_str_enum!(SyncBillingStatus {
25 + Draft => "draft",
26 + Active => "active",
27 + SuspendedUnpaid => "suspended_unpaid",
28 + Canceled => "canceled",
29 + });
30 +
31 + /// How a SyncKit developer app's storage billing is enforced (the
32 + /// `sync_apps.enforcement_mode` TEXT column, CHECK-constrained to `('per_key','bulk')`
33 + /// in migration 118). Replaces the raw string the `DbSyncAppBilling` model used to
34 + /// carry. Lifting this to an enum makes `monthly_price_cents` match exhaustively, so an
35 + /// unrecognized mode is no longer silently priced at the floor (Pay-S2). The historical
36 + /// `app_wide` value was renamed to `bulk` in migration 118; only these two are live.
37 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38 + pub enum SyncEnforcementMode {
39 + #[serde(rename = "per_key")]
40 + PerKey,
41 + #[serde(rename = "bulk")]
42 + Bulk,
43 + }
44 +
45 + impl_str_enum!(SyncEnforcementMode {
46 + PerKey => "per_key",
47 + Bulk => "bulk",
48 + });
49 +
50 + // --- SyncKit ---
51 +
52 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53 + pub enum SyncOperation {
54 + #[serde(rename = "INSERT")]
55 + Insert,
56 + #[serde(rename = "UPDATE")]
57 + Update,
58 + #[serde(rename = "DELETE")]
59 + Delete,
60 + }
61 +
62 + impl_str_enum!(SyncOperation {
63 + Insert => "INSERT",
64 + Update => "UPDATE",
65 + Delete => "DELETE",
66 + });
67 +
68 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69 + #[serde(rename_all = "lowercase")]
70 + pub enum SyncPlatform {
71 + Macos,
72 + Ios,
73 + Android,
74 + Windows,
75 + Linux,
76 + Web,
77 + }
78 +
79 + impl_str_enum!(SyncPlatform {
80 + Macos => "macos",
81 + Ios => "ios",
82 + Android => "android",
83 + Windows => "windows",
84 + Linux => "linux",
85 + Web => "web",
86 + });
@@ -1,0 +1,857 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + #[test]
6 + fn discount_type_round_trip() {
7 + assert_eq!(DiscountType::Percentage.to_string(), "percentage");
8 + assert_eq!(
9 + "fixed".parse::<DiscountType>().unwrap(),
10 + DiscountType::Fixed
11 + );
12 + assert!("bogus".parse::<DiscountType>().is_err());
13 + }
14 +
15 + #[test]
16 + fn waitlist_status_round_trip() {
17 + assert_eq!(WaitlistStatus::Pending.to_string(), "pending");
18 + assert_eq!(
19 + "approved".parse::<WaitlistStatus>().unwrap(),
20 + WaitlistStatus::Approved
21 + );
22 + }
23 +
24 + #[test]
25 + fn selection_method_round_trip() {
26 + assert_eq!(SelectionMethod::HandPicked.to_string(), "hand_picked");
27 + assert_eq!(
28 + "lottery".parse::<SelectionMethod>().unwrap(),
29 + SelectionMethod::Lottery
30 + );
31 + assert_eq!(SelectionMethod::Invited.to_string(), "invited");
32 + assert_eq!(
33 + "invited".parse::<SelectionMethod>().unwrap(),
34 + SelectionMethod::Invited
35 + );
36 + }
37 +
38 + #[test]
39 + fn transaction_status_round_trip() {
40 + assert_eq!(TransactionStatus::Completed.to_string(), "completed");
41 + assert_eq!(
42 + "refunded".parse::<TransactionStatus>().unwrap(),
43 + TransactionStatus::Refunded
44 + );
45 + }
46 +
47 + #[test]
48 + fn follow_target_type_round_trip() {
49 + assert_eq!(FollowTargetType::User.to_string(), "user");
50 + assert_eq!(
51 + "tag".parse::<FollowTargetType>().unwrap(),
52 + FollowTargetType::Tag
53 + );
54 + }
55 +
56 + #[test]
57 + fn subscription_status_round_trip() {
58 + assert_eq!(SubscriptionStatus::PastDue.to_string(), "past_due");
59 + assert_eq!(
60 + "canceled".parse::<SubscriptionStatus>().unwrap(),
61 + SubscriptionStatus::Canceled
62 + );
63 + assert_eq!(SubscriptionStatus::Trialing.to_string(), "trialing");
64 + assert_eq!(
65 + "trialing".parse::<SubscriptionStatus>().unwrap(),
66 + SubscriptionStatus::Trialing
67 + );
68 + assert_eq!(SubscriptionStatus::Incomplete.to_string(), "incomplete");
69 + assert_eq!(
70 + "incomplete".parse::<SubscriptionStatus>().unwrap(),
71 + SubscriptionStatus::Incomplete
72 + );
73 + assert_eq!(
74 + SubscriptionStatus::IncompleteExpired.to_string(),
75 + "incomplete_expired"
76 + );
77 + assert_eq!(
78 + "incomplete_expired".parse::<SubscriptionStatus>().unwrap(),
79 + SubscriptionStatus::IncompleteExpired
80 + );
81 + }
82 +
83 + #[test]
84 + fn sync_operation_round_trip() {
85 + assert_eq!(SyncOperation::Insert.to_string(), "INSERT");
86 + assert_eq!(
87 + "DELETE".parse::<SyncOperation>().unwrap(),
88 + SyncOperation::Delete
89 + );
90 + }
91 +
92 + #[test]
93 + fn sync_platform_round_trip() {
94 + assert_eq!(SyncPlatform::Macos.to_string(), "macos");
95 + assert_eq!("web".parse::<SyncPlatform>().unwrap(), SyncPlatform::Web);
96 + }
97 +
98 + #[test]
99 + fn item_type_round_trip() {
100 + assert_eq!(ItemType::Audio.to_string(), "audio");
101 + assert_eq!("plugin".parse::<ItemType>().unwrap(), ItemType::Plugin);
102 + assert_eq!(ItemType::Bundle.to_string(), "bundle");
103 + assert_eq!("bundle".parse::<ItemType>().unwrap(), ItemType::Bundle);
104 + }
105 +
106 + #[test]
107 + fn insertion_position_round_trip() {
108 + assert_eq!(InsertionPosition::PreRoll.to_string(), "pre_roll");
109 + assert_eq!(
110 + "mid_roll".parse::<InsertionPosition>().unwrap(),
111 + InsertionPosition::MidRoll
112 + );
113 + assert_eq!(
114 + "post_roll".parse::<InsertionPosition>().unwrap(),
115 + InsertionPosition::PostRoll
116 + );
117 + assert!("invalid".parse::<InsertionPosition>().is_err());
118 + }
119 +
120 + #[test]
121 + fn item_type_label() {
122 + assert_eq!(ItemType::Audio.label(), "Audio");
123 + assert_eq!(ItemType::Plugin.label(), "Plugin");
124 + assert_eq!(ItemType::Template.label(), "Template");
125 + }
126 +
127 + #[test]
128 + fn appeal_decision_round_trip() {
129 + assert_eq!(AppealDecision::Approved.to_string(), "approved");
130 + assert_eq!(
131 + "denied".parse::<AppealDecision>().unwrap(),
132 + AppealDecision::Denied
133 + );
134 + assert!("bogus".parse::<AppealDecision>().is_err());
135 + }
136 +
137 + #[test]
138 + fn discover_sort_round_trip() {
139 + assert_eq!(DiscoverSort::Newest.to_string(), "newest");
140 + assert_eq!(
141 + "most_sold".parse::<DiscoverSort>().unwrap(),
142 + DiscoverSort::MostSold
143 + );
144 + assert_eq!(
145 + "price_asc".parse::<DiscoverSort>().unwrap(),
146 + DiscoverSort::PriceAsc
147 + );
148 + assert_eq!(
149 + "price_desc".parse::<DiscoverSort>().unwrap(),
150 + DiscoverSort::PriceDesc
151 + );
152 + assert!("invalid".parse::<DiscoverSort>().is_err());
153 + }
154 +
155 + #[test]
156 + fn file_scan_status_round_trip() {
157 + assert_eq!(FileScanStatus::Clean.to_string(), "clean");
158 + assert_eq!(FileScanStatus::Pending.to_string(), "pending");
159 + assert_eq!(FileScanStatus::Scanning.to_string(), "scanning");
160 + assert_eq!(
161 + "pending".parse::<FileScanStatus>().unwrap(),
162 + FileScanStatus::Pending
163 + );
164 + assert_eq!(
165 + "scanning".parse::<FileScanStatus>().unwrap(),
166 + FileScanStatus::Scanning
167 + );
168 + assert_eq!(
169 + "held_for_review".parse::<FileScanStatus>().unwrap(),
170 + FileScanStatus::HeldForReview
171 + );
172 + assert_eq!(FileScanStatus::HeldForReview.to_string(), "held_for_review");
173 + assert_eq!(
174 + "quarantined".parse::<FileScanStatus>().unwrap(),
175 + FileScanStatus::Quarantined
176 + );
177 + assert!("bogus".parse::<FileScanStatus>().is_err());
178 + }
179 +
180 + #[test]
181 + fn code_purpose_round_trip() {
182 + assert_eq!(CodePurpose::Discount.to_string(), "discount");
183 + assert_eq!(
184 + "free_access".parse::<CodePurpose>().unwrap(),
185 + CodePurpose::FreeAccess
186 + );
187 + assert_eq!(
188 + "free_trial".parse::<CodePurpose>().unwrap(),
189 + CodePurpose::FreeTrial
190 + );
191 + assert!("bogus".parse::<CodePurpose>().is_err());
192 + }
193 +
194 + #[test]
195 + fn issue_status_round_trip() {
196 + assert_eq!(IssueStatus::Open.to_string(), "open");
197 + assert_eq!(
198 + "closed".parse::<IssueStatus>().unwrap(),
199 + IssueStatus::Closed
200 + );
201 + assert!("bogus".parse::<IssueStatus>().is_err());
202 + }
203 +
204 + #[test]
205 + fn report_target_type_round_trip() {
206 + assert_eq!(ReportTargetType::Project.to_string(), "project");
207 + assert_eq!(
208 + "item".parse::<ReportTargetType>().unwrap(),
209 + ReportTargetType::Item
210 + );
211 + assert!("bogus".parse::<ReportTargetType>().is_err());
212 + }
213 +
214 + #[test]
215 + fn report_type_round_trip() {
216 + assert_eq!(ReportType::Mislabeled.to_string(), "mislabeled");
217 + assert_eq!("spam".parse::<ReportType>().unwrap(), ReportType::Spam);
218 + assert_eq!("abuse".parse::<ReportType>().unwrap(), ReportType::Abuse);
219 + assert_eq!(
220 + "infringement".parse::<ReportType>().unwrap(),
221 + ReportType::Infringement
222 + );
223 + assert_eq!("other".parse::<ReportType>().unwrap(), ReportType::Other);
224 + assert!("bogus".parse::<ReportType>().is_err());
225 + }
226 +
227 + #[test]
228 + fn report_status_round_trip() {
229 + assert_eq!(ReportStatus::Open.to_string(), "open");
230 + assert_eq!(
231 + "resolved".parse::<ReportStatus>().unwrap(),
232 + ReportStatus::Resolved
233 + );
234 + assert_eq!(
235 + "dismissed".parse::<ReportStatus>().unwrap(),
236 + ReportStatus::Dismissed
237 + );
238 + assert!("bogus".parse::<ReportStatus>().is_err());
239 + }
240 +
241 + #[test]
242 + fn creator_tier_round_trip() {
243 + assert_eq!(CreatorTier::Basic.to_string(), "basic");
244 + assert_eq!(
245 + "small_files".parse::<CreatorTier>().unwrap(),
246 + CreatorTier::SmallFiles
247 + );
248 + assert_eq!(
249 + "big_files".parse::<CreatorTier>().unwrap(),
250 + CreatorTier::BigFiles
251 + );
252 + assert_eq!(
253 + "everything".parse::<CreatorTier>().unwrap(),
254 + CreatorTier::Everything
255 + );
256 + assert!("bogus".parse::<CreatorTier>().is_err());
257 + }
258 +
259 + #[test]
260 + fn creator_tier_label_and_price() {
261 + crate::tier_prices::TierPrices::install_test_default();
262 + assert_eq!(CreatorTier::Basic.label(), "Basic");
263 + assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
264 + // Prices come from assumptions.toml. Assert invariants, founder = std/2,
265 + // monotone across tiers, not literal cents, so a future toml edit doesn't
266 + // break this test.
267 + let tiers = [
268 + CreatorTier::Basic,
269 + CreatorTier::SmallFiles,
270 + CreatorTier::BigFiles,
271 + CreatorTier::Everything,
272 + ];
273 + for pair in tiers.windows(2) {
274 + assert!(
275 + pair[0].price_cents() < pair[1].price_cents(),
276 + "{:?} price_cents ({}) should be < {:?} ({})",
277 + pair[0],
278 + pair[0].price_cents(),
279 + pair[1],
280 + pair[1].price_cents(),
281 + );
282 + }
283 + assert!(CreatorTier::Basic.price_cents() > 0);
284 + }
285 +
286 + #[test]
287 + fn creator_tier_file_limits() {
288 + crate::tier_prices::TierPrices::install_test_default();
289 + // File caps monotone across tiers; Basic ≤ SmallFiles ≤ BigFiles == Everything.
290 + let tiers = [
291 + CreatorTier::Basic,
292 + CreatorTier::SmallFiles,
293 + CreatorTier::BigFiles,
294 + CreatorTier::Everything,
295 + ];
296 + for pair in tiers.windows(2) {
297 + assert!(
298 + pair[0].max_file_bytes() <= pair[1].max_file_bytes(),
299 + "{:?} max_file_bytes ({}) should be <= {:?} ({})",
300 + pair[0],
301 + pair[0].max_file_bytes(),
302 + pair[1],
303 + pair[1].max_file_bytes(),
304 + );
305 + }
306 + assert!(CreatorTier::Basic.max_file_bytes() > 0);
307 + }
308 +
309 + #[test]
310 + fn creator_tier_storage_limits() {
311 + crate::tier_prices::TierPrices::install_test_default();
312 + let tiers = [
313 + CreatorTier::Basic,
314 + CreatorTier::SmallFiles,
315 + CreatorTier::BigFiles,
316 + CreatorTier::Everything,
317 + ];
318 + for pair in tiers.windows(2) {
319 + assert!(
320 + pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
321 + "{:?} max_storage_bytes ({}) should be <= {:?} ({})",
322 + pair[0],
323 + pair[0].max_storage_bytes(),
324 + pair[1],
325 + pair[1].max_storage_bytes(),
326 + );
327 + }
328 + assert!(CreatorTier::Basic.max_storage_bytes() > 0);
329 + // Every tier's per-file cap must fit in its total storage cap or uploads
330 + // are impossible. Catches an accidental toml edit that shrinks a total
331 + // below the per-file limit.
332 + for &tier in &tiers {
333 + assert!(
334 + tier.max_file_bytes() <= tier.max_storage_bytes(),
335 + "{tier:?}: file cap ({}) exceeds storage cap ({})",
336 + tier.max_file_bytes(),
337 + tier.max_storage_bytes(),
338 + );
339 + }
340 + }
341 +
342 + #[test]
343 + fn creator_tier_allows_file_uploads() {
344 + assert!(!CreatorTier::Basic.allows_file_uploads());
345 + assert!(CreatorTier::SmallFiles.allows_file_uploads());
346 + assert!(CreatorTier::BigFiles.allows_file_uploads());
347 + assert!(CreatorTier::Everything.allows_file_uploads());
348 + }
349 +
350 + #[test]
351 + fn creator_tier_features_track_live_capabilities() {
352 + assert!(CreatorTier::Basic.features().is_empty());
353 + assert_eq!(CreatorTier::SmallFiles.features(), &["file_uploads"]);
354 + assert_eq!(
355 + CreatorTier::BigFiles.features(),
356 + &["file_uploads", "large_files"]
357 + );
358 + assert_eq!(
359 + CreatorTier::Everything.features(),
360 + &["file_uploads", "large_files"]
361 + );
362 + }
363 +
364 + #[test]
365 + fn project_feature_round_trip() {
366 + assert_eq!(ProjectFeature::Audio.to_string(), "audio");
367 + assert_eq!(
368 + "downloads".parse::<ProjectFeature>().unwrap(),
369 + ProjectFeature::Downloads
370 + );
371 + assert_eq!(
372 + "license_keys".parse::<ProjectFeature>().unwrap(),
373 + ProjectFeature::LicenseKeys
374 + );
375 + assert_eq!(
376 + "source_code".parse::<ProjectFeature>().unwrap(),
377 + ProjectFeature::SourceCode
378 + );
379 + assert!("bogus".parse::<ProjectFeature>().is_err());
380 + }
381 +
382 + #[test]
383 + fn project_feature_label_and_description() {
384 + assert_eq!(ProjectFeature::Audio.label(), "Audio");
385 + assert_eq!(ProjectFeature::LicenseKeys.label(), "License Keys");
386 + assert!(!ProjectFeature::Audio.description().is_empty());
387 + }
388 +
389 + #[test]
390 + fn project_feature_all() {
391 + let all = ProjectFeature::all();
392 + assert_eq!(all.len(), 8);
393 + assert_eq!(all[0].0, "audio");
394 + assert_eq!(all[7].0, "cloud_sync");
395 + }
396 +
397 + #[test]
398 + fn project_feature_allowed_item_types_audio() {
399 + let types = ProjectFeature::Audio.allowed_item_types();
400 + assert!(types.contains(&ItemType::Audio));
401 + assert!(types.contains(&ItemType::Sample));
402 + assert!(types.contains(&ItemType::Preset));
403 + assert!(!types.contains(&ItemType::Text));
404 + }
405 +
406 + #[test]
407 + fn project_feature_allowed_item_types_downloads() {
408 + let types = ProjectFeature::Downloads.allowed_item_types();
409 + assert!(types.contains(&ItemType::Digital));
410 + assert!(types.contains(&ItemType::Plugin));
411 + assert!(types.contains(&ItemType::Video));
412 + assert!(!types.contains(&ItemType::Audio));
413 + }
414 +
415 + #[test]
416 + fn project_feature_allowed_item_types_text() {
417 + let types = ProjectFeature::Text.allowed_item_types();
418 + assert!(types.contains(&ItemType::Text));
419 + assert_eq!(types.len(), 1);
420 + }
421 +
422 + #[test]
423 + fn project_feature_allowed_item_types_non_content() {
424 + assert!(ProjectFeature::Blog.allowed_item_types().is_empty());
425 + assert!(
426 + ProjectFeature::Subscriptions
427 + .allowed_item_types()
428 + .is_empty()
429 + );
430 + assert!(ProjectFeature::LicenseKeys.allowed_item_types().is_empty());
431 + assert!(ProjectFeature::SourceCode.allowed_item_types().is_empty());
432 + assert!(ProjectFeature::CloudSync.allowed_item_types().is_empty());
433 + }
434 +
435 + #[test]
436 + fn project_feature_allowed_cards_filtered() {
437 + let cards = ProjectFeature::allowed_item_type_cards(&["audio".into()]);
438 + let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
439 + assert!(values.contains(&"audio"));
440 + assert!(values.contains(&"sample"));
441 + assert!(values.contains(&"preset"));
442 + assert!(values.contains(&"bundle")); // Bundle always included
443 + assert!(!values.contains(&"text"));
444 + assert!(!values.contains(&"digital"));
445 + }
446 +
447 + #[test]
448 + fn project_feature_allowed_cards_combined() {
449 + let cards = ProjectFeature::allowed_item_type_cards(&["audio".into(), "text".into()]);
450 + let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
451 + assert!(values.contains(&"audio"));
452 + assert!(values.contains(&"text"));
453 + assert!(values.contains(&"bundle")); // Bundle always included
454 + assert!(!values.contains(&"digital"));
455 + }
456 +
457 + #[test]
458 + fn project_feature_allowed_cards_empty_features_shows_all() {
459 + let cards = ProjectFeature::allowed_item_type_cards(&[]);
460 + assert_eq!(cards.len(), 11); // 10 content types + bundle
461 + }
462 +
463 + #[test]
464 + fn project_feature_allowed_cards_non_content_features_shows_all() {
465 + let cards = ProjectFeature::allowed_item_type_cards(&["blog".into(), "subscriptions".into()]);
466 + // Blog and subscriptions don't gate item types, so all should be shown
467 + assert_eq!(cards.len(), 11); // 10 content types + bundle
468 + }
469 +
470 + #[test]
471 + fn project_feature_derive_type() {
472 + assert_eq!(
473 + ProjectFeature::derive_project_type(&["audio".into(), "blog".into()]),
474 + ProjectType::Music,
475 + );
476 + assert_eq!(
477 + ProjectFeature::derive_project_type(&["text".into()]),
478 + ProjectType::Blog,
479 + );
480 + assert_eq!(
481 + ProjectFeature::derive_project_type(&["downloads".into(), "text".into()]),
482 + ProjectType::Software,
483 + );
484 + assert_eq!(
485 + ProjectFeature::derive_project_type(&["subscriptions".into()]),
486 + ProjectType::General,
487 + );
488 + }
489 +
490 + #[test]
491 + fn project_type_round_trip() {
492 + assert_eq!(ProjectType::Blog.to_string(), "blog");
493 + assert_eq!(
494 + "software".parse::<ProjectType>().unwrap(),
495 + ProjectType::Software
496 + );
497 + assert_eq!(
498 + "general".parse::<ProjectType>().unwrap(),
499 + ProjectType::General
500 + );
Lines truncated