ferro_wallet/subject.rs
1//! `WalletSubject` — the content contract every downstream domain object implements
2//! to be issued as either an Apple `.pkpass` or a Google Wallet save-JWT.
3//!
4//! See the design spec at `docs/superpowers/specs/2026-05-11-ferro-wallet-crate.md`
5//! §3.1 for the authoritative public surface. Value types stay deliberately small:
6//! they are the input shape both `ApplePassBuilder::build` and
7//! `GoogleWalletBuilder::save_jwt` accept.
8
9use crate::WalletError;
10use chrono::{DateTime, Utc};
11
12/// Top-level pass category. `EventTicket` renders a rounded card with an optional
13/// strip banner. `BoardingPass` renders the "tear-off ticket stub" shape with a
14/// perforation line above the barcode and rounded inner notches on each side —
15/// the Trenitalia / airline look. `Generic` and `Coupon` use flatter card chrome.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum PassKind {
18 EventTicket,
19 BoardingPass(TransitType),
20 Generic,
21 Coupon,
22}
23
24/// Transit class shown by Apple Wallet on a [`PassKind::BoardingPass`]. Selects
25/// the small transit-mode icon rendered next to the primary fields. Use
26/// [`TransitType::Generic`] for non-transit "ticket-stub style" passes
27/// (admissions, restaurant reservations, etc.).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum TransitType {
30 Air,
31 Boat,
32 Bus,
33 Generic,
34 Train,
35}
36
37impl TransitType {
38 pub(crate) fn as_apple_str(&self) -> &'static str {
39 match self {
40 TransitType::Air => "PKTransitTypeAir",
41 TransitType::Boat => "PKTransitTypeBoat",
42 TransitType::Bus => "PKTransitTypeBus",
43 TransitType::Generic => "PKTransitTypeGeneric",
44 TransitType::Train => "PKTransitTypeTrain",
45 }
46 }
47}
48
49/// Field alignment hint surfaced to the wallet renderer. Maps directly to Apple's
50/// `PKTextAlignment*` values; Google ignores it.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum FieldAlignment {
53 Left,
54 Center,
55 Right,
56 Natural,
57}
58
59/// Foreground colour derivation mode. [`TextColorMode::Auto`] uses BT.601 luminance
60/// (see [`auto_foreground`]). [`TextColorMode::Light`] / [`TextColorMode::Dark`]
61/// force white / black respectively.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum TextColorMode {
64 Auto,
65 Light,
66 Dark,
67}
68
69/// A single labelled value on a pass (primary, secondary, auxiliary, or back row).
70#[derive(Debug, Clone)]
71pub struct Field {
72 pub key: String,
73 pub label: String,
74 pub value: String,
75 pub alignment: FieldAlignment,
76}
77
78/// Visual + identity branding applied to the pass. All image fields are raw PNG bytes —
79/// the `images` module produces them in the resolutions Apple / Google require.
80#[derive(Debug, Clone)]
81pub struct Branding {
82 pub organization_name: Option<String>,
83 pub logo_text: Option<String>,
84 pub background_color: RgbColor,
85 pub text_color_mode: TextColorMode,
86 pub logo_png_bytes: Option<Vec<u8>>,
87 pub icon_png_bytes: Option<Vec<u8>>,
88 pub hero_png_bytes: Option<Vec<u8>>,
89}
90
91/// Optional geographic relevance hint — Apple surfaces the pass on the lock screen when
92/// the device is near the coordinate.
93#[derive(Debug, Clone)]
94pub struct GeoPoint {
95 pub latitude: f64,
96 pub longitude: f64,
97 pub relevant_text: Option<String>,
98}
99
100/// 24-bit RGB colour. Constructed from a `#RRGGBB` hex string via [`RgbColor::from_hex`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct RgbColor {
103 pub r: u8,
104 pub g: u8,
105 pub b: u8,
106}
107
108impl RgbColor {
109 /// Parse a 6-digit hex colour. Accepts `"#RRGGBB"` or `"RRGGBB"`. Case-insensitive.
110 /// Short forms (`#fff`) and 8-digit forms (`#RRGGBBAA`) are rejected.
111 pub fn from_hex(s: &str) -> Result<Self, WalletError> {
112 let hex = s.strip_prefix('#').unwrap_or(s);
113 if hex.len() != 6 {
114 return Err(WalletError::InvalidInput(format!(
115 "rgb hex must be 6 chars (with optional leading '#'): got {s:?}"
116 )));
117 }
118 let parse = |range: std::ops::Range<usize>| -> Result<u8, WalletError> {
119 u8::from_str_radix(&hex[range], 16)
120 .map_err(|e| WalletError::InvalidInput(format!("rgb hex parse: {e}")))
121 };
122 Ok(RgbColor {
123 r: parse(0..2)?,
124 g: parse(2..4)?,
125 b: parse(4..6)?,
126 })
127 }
128
129 /// CSS-style `rgb(r,g,b)` literal — used by Apple `pass.json` colour fields.
130 pub fn css_rgb(&self) -> String {
131 format!("rgb({},{},{})", self.r, self.g, self.b)
132 }
133}
134
135/// Derives a readable foreground colour from a background using ITU-R BT.601 luminance
136/// (D-06). Normalised luminance `Y = 0.299*R + 0.587*G + 0.114*B`, all channels in `0..1`.
137///
138/// - `Y < 0.5` ⇒ white `rgb(255,255,255)`
139/// - `Y >= 0.5` ⇒ dark slate `rgb(17,24,39)`
140///
141/// The mid-grey case `rgb(128,128,128)` resolves to dark slate (luminance ≈ 0.502).
142pub fn auto_foreground(bg: RgbColor) -> RgbColor {
143 let r = bg.r as f64 / 255.0;
144 let g = bg.g as f64 / 255.0;
145 let b = bg.b as f64 / 255.0;
146 let lum = 0.299 * r + 0.587 * g + 0.114 * b;
147 if lum < 0.5 {
148 RgbColor {
149 r: 255,
150 g: 255,
151 b: 255,
152 }
153 } else {
154 RgbColor {
155 r: 17,
156 g: 24,
157 b: 39,
158 }
159 }
160}
161
162/// The content contract every domain object implements to be issued as a wallet pass.
163///
164/// Implementors describe what the pass _means_ (identity, fields, branding, barcode,
165/// timing). The builders ([`crate::apple::ApplePassBuilder`],
166/// [`crate::google::GoogleWalletBuilder`]) translate this into the appropriate wire
167/// format.
168pub trait WalletSubject {
169 /// Top-level pass category.
170 fn pass_kind(&self) -> PassKind;
171
172 /// Unique-per-pass identifier (becomes Apple `serialNumber` and the suffix of the
173 /// Google `object.id`).
174 fn serial(&self) -> String;
175
176 /// The primary field row. `eventTicket` shows the first entry as the
177 /// large headline; `boardingPass` expects two entries (origin/destination)
178 /// with Apple's transit arrow rendered between them. Return at most two
179 /// fields — extra entries are silently ignored by Apple's renderer.
180 fn primary(&self) -> Vec<Field>;
181
182 /// Secondary row — usually 1–4 fields directly under the primary.
183 fn secondary(&self) -> Vec<Field>;
184
185 /// Auxiliary row — additional context fields below secondary.
186 fn auxiliary(&self) -> Vec<Field>;
187
188 /// Back-of-pass fields — long-form details exposed when the user flips the pass.
189 fn back(&self) -> Vec<Field>;
190
191 /// Opaque token encoded into the pass's QR / barcode.
192 fn barcode_token(&self) -> String;
193
194 /// Optional timestamp at which the pass is most relevant (Apple surfaces it on the
195 /// lock screen near this time).
196 fn relevant_at(&self) -> Option<DateTime<Utc>>;
197
198 /// Optional expiry timestamp.
199 fn expires_at(&self) -> Option<DateTime<Utc>>;
200
201 /// Optional geographic relevance hints.
202 fn locations(&self) -> Vec<GeoPoint>;
203
204 /// Visual + identity branding.
205 fn branding(&self) -> Branding;
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn rgb_from_hex() {
214 assert_eq!(
215 RgbColor::from_hex("#ffffff").unwrap(),
216 RgbColor {
217 r: 255,
218 g: 255,
219 b: 255
220 }
221 );
222 assert_eq!(
223 RgbColor::from_hex("000000").unwrap(),
224 RgbColor { r: 0, g: 0, b: 0 }
225 );
226 assert_eq!(
227 RgbColor::from_hex("#FF8000").unwrap(),
228 RgbColor {
229 r: 255,
230 g: 128,
231 b: 0
232 }
233 );
234 // Mixed case accepted.
235 assert_eq!(
236 RgbColor::from_hex("#aAbBcC").unwrap(),
237 RgbColor {
238 r: 0xaa,
239 g: 0xbb,
240 b: 0xcc
241 }
242 );
243 }
244
245 #[test]
246 fn rgb_from_hex_rejects_malformed() {
247 // Free-form non-hex string.
248 let err = RgbColor::from_hex("not-a-color").unwrap_err();
249 assert!(matches!(err, WalletError::InvalidInput(_)));
250
251 // 3-digit short form not supported.
252 let err = RgbColor::from_hex("#fff").unwrap_err();
253 assert!(matches!(err, WalletError::InvalidInput(_)));
254
255 // 7 chars total (one too many).
256 let err = RgbColor::from_hex("#fffffff").unwrap_err();
257 assert!(matches!(err, WalletError::InvalidInput(_)));
258
259 // 6 chars but non-hex.
260 let err = RgbColor::from_hex("#zzzzzz").unwrap_err();
261 assert!(matches!(err, WalletError::InvalidInput(_)));
262
263 // Empty.
264 let err = RgbColor::from_hex("").unwrap_err();
265 assert!(matches!(err, WalletError::InvalidInput(_)));
266 }
267
268 #[test]
269 fn auto_foreground_dark_bg_is_white() {
270 assert_eq!(
271 auto_foreground(RgbColor { r: 0, g: 0, b: 0 }),
272 RgbColor {
273 r: 255,
274 g: 255,
275 b: 255
276 }
277 );
278 }
279
280 #[test]
281 fn auto_foreground_light_bg_is_dark_slate() {
282 assert_eq!(
283 auto_foreground(RgbColor {
284 r: 255,
285 g: 255,
286 b: 255
287 }),
288 RgbColor {
289 r: 17,
290 g: 24,
291 b: 39
292 }
293 );
294 }
295
296 #[test]
297 fn rgb_css_rgb_format() {
298 assert_eq!(
299 RgbColor {
300 r: 17,
301 g: 24,
302 b: 39
303 }
304 .css_rgb(),
305 "rgb(17,24,39)"
306 );
307 }
308}