Skip to main content

taiwan_lottery/
lib.rs

1//! Taiwan Lottery library providing download, query, and random draw capabilities.
2//!
3//! This library offers both Rust and C APIs for:
4//! - **Download**: Fetch lottery datasets from Taiwan's open data sources
5//! - **Query**: Search historical lottery results by period, month, or month range
6//! - **Draw**: Generate random lottery draws for supported games
7//!
8//! # Overview
9//!
10//! The library uses two primary data sources:
11//! - **FinancialPlanning OpenData** (via `D423F` dataset) - Primary source for historical draws
12//! - **Taiwan Lottery API** - Fallback source for recent results
13//!
14//! # Quick Start
15//!
16//! ## Download historical data
17//!
18//! ```ignore
19//! use taiwan_lottery::download_history_draw;
20//!
21//! download_history_draw("./data")?;
22//! ```
23//!
24//! ## Query results
25//!
26//! ```ignore
27//! use taiwan_lottery::{query_history_draw, HistoryDrawQuery, LotteryGame};
28//!
29//! let query = HistoryDrawQuery::by_month("2023-12");
30//! let results = query_history_draw("./data", LotteryGame::Lotto649, query)?;
31//! ```
32//!
33//! ## Generate random draw
34//!
35//! ```ignore
36//! use taiwan_lottery::{draw_by_game, LotteryGame};
37//!
38//! let result = draw_by_game(LotteryGame::Lotto649);
39//! ```
40
41pub mod download;
42mod draw;
43mod errors;
44mod ffi;
45mod numbers;
46mod query;
47mod rule;
48
49use query::common::game_query_month_bounds;
50use query::common::{
51    game_query_date_bounds, game_query_date_bounds_for_local, game_query_date_bounds_for_remote,
52};
53use query::remote_query_param_support;
54use rule::metadata_for_game;
55
56pub use download::{
57    build_csv_url, download_all, download_api_doc, download_dataset, parse_codes_from_api_docs,
58};
59/// Downloads history draw files from FinancialPlanning OpenData (`D423F`).
60#[deprecated(note = "use taiwan_lottery::download::gaze::download_history_draw")]
61pub fn download_history_draw(
62    output_dir: impl AsRef<std::path::Path>,
63) -> Result<Vec<std::path::PathBuf>, DownloadError> {
64    download::gaze::download_history_draw(output_dir)
65}
66/// Downloads history draw files from Taiwan Lottery yearly ZIP API.
67#[deprecated(note = "use taiwan_lottery::download::tlc::download_history_draw")]
68pub fn download_history_draw_from_taiwan_lottery(
69    output_dir: impl AsRef<std::path::Path>,
70) -> Result<Vec<std::path::PathBuf>, DownloadError> {
71    download::tlc::download_history_draw(output_dir)
72}
73pub use draw::{draw_by_game, DrawResult};
74pub use errors::DownloadError;
75pub use numbers::{
76    BingoBingoNumbers, BonusDrawNumbers, Daily539Numbers, DrawNumbers, Lotto1224Numbers,
77    Lotto38M6Numbers, Lotto39M5Numbers, Lotto3DNumbers, Lotto49M6Numbers, Lotto4DNumbers,
78    Lotto638Numbers, Lotto649Numbers, Lotto740Numbers, SortedDrawNumbers, SuperLotto638Numbers,
79    TicTacToeNumbers,
80};
81pub use query::{query_history_draw, query_history_draw_from_taiwan_lottery};
82
83/// Supported lottery games for historical draw queries and random draws.
84///
85/// Each variant represents a different Taiwan lottery game. Use this with
86/// [`query_history_draw`], [`query_history_draw_from_taiwan_lottery`], or [`draw_by_game`].
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum LotteryGame {
89    SuperLotto638,
90    Lotto649,
91    Daily539,
92    Lotto3D,
93    Lotto4D,
94    Lotto49M6,
95    Lotto39M5,
96    Lotto38M6,
97    Lotto1224,
98    Lotto740,
99    TicTacToe,
100    Lotto638,
101    BingoBingo,
102}
103
104/// One number selection segment for a lottery game.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct LotteryGameNumberRule {
107    /// Segment name such as `main`, `bonus`, `super`, or `zone_1`.
108    pub name: &'static str,
109    /// How many numbers are selected from this segment.
110    pub picks: usize,
111    /// Inclusive minimum value for this segment.
112    pub min: i32,
113    /// Inclusive maximum value for this segment.
114    pub max: i32,
115    /// Whether values in this segment may repeat.
116    pub allow_repeat: bool,
117}
118
119/// Static metadata for rendering lottery game information in UI layers.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct LotteryGameMetadata {
122    /// UI display name for the game. Defaults to English.
123    pub display_name: &'static str,
124    /// English display name for the game.
125    pub display_name_english: &'static str,
126    /// Chinese display name for the game.
127    pub display_name_chinese: &'static str,
128    /// Human-readable summary of the game's number-selection rule.
129    pub number_rule: &'static str,
130    /// Rule segments that together define how numbers are selected.
131    pub number_ranges: &'static [LotteryGameNumberRule],
132}
133
134/// Remote query parameter support for one lottery game endpoint.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct RemoteQueryParamSupport {
137    pub month: bool,
138    pub end_month: bool,
139    pub open_date: bool,
140    pub period: bool,
141}
142
143/// Supported display-name languages.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
145pub enum LotteryDisplayLanguage {
146    #[default]
147    English,
148    Chinese,
149}
150
151impl LotteryGameMetadata {
152    /// Resolves a display name by enum language.
153    pub const fn display_name_for_language(self, language: LotteryDisplayLanguage) -> &'static str {
154        match language {
155            LotteryDisplayLanguage::English => self.display_name_english,
156            LotteryDisplayLanguage::Chinese => self.display_name_chinese,
157        }
158    }
159
160    /// Returns metadata with `display_name` localized by input language.
161    pub fn with_display_language(mut self, language: LotteryDisplayLanguage) -> Self {
162        self.display_name = self.display_name_for_language(language);
163        self
164    }
165}
166
167/// Queryable month range for a game in `YYYY-MM` format.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct LotteryGameQueryRange {
170    /// Earliest supported query month.
171    pub min_month: String,
172    /// Latest supported query month.
173    pub max_month: String,
174}
175
176/// Queryable date range for a game in `YYYY-MM-DD` format.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct LotteryGameDateQueryRange {
179    /// Earliest supported query date (inclusive).
180    pub min_date: String,
181    /// Latest supported query date (inclusive).
182    pub max_date: String,
183}
184
185#[deprecated(note = "use LotteryGame instead")]
186pub type HistoryGame = LotteryGame;
187
188#[deprecated(note = "use LotteryGameNumberRule instead")]
189pub type HistoryGameNumberRule = LotteryGameNumberRule;
190
191#[deprecated(note = "use LotteryGameMetadata instead")]
192pub type HistoryGameMetadata = LotteryGameMetadata;
193
194impl LotteryGame {
195    /// All supported games in a stable order suitable for UI lists.
196    pub const ALL: [Self; 13] = [
197        Self::SuperLotto638,
198        Self::Lotto649,
199        Self::Daily539,
200        Self::Lotto3D,
201        Self::Lotto4D,
202        Self::Lotto49M6,
203        Self::Lotto39M5,
204        Self::Lotto38M6,
205        Self::Lotto1224,
206        Self::Lotto740,
207        Self::TicTacToe,
208        Self::Lotto638,
209        Self::BingoBingo,
210    ];
211
212    /// Returns static metadata describing display name and number-selection rules.
213    pub const fn metadata(self) -> LotteryGameMetadata {
214        metadata_for_game(self)
215    }
216
217    /// Returns metadata with `display_name` chosen by enum language.
218    pub fn metadata_with_language(self, language: LotteryDisplayLanguage) -> LotteryGameMetadata {
219        self.metadata().with_display_language(language)
220    }
221
222    /// Returns the allowed query month range for this game in `YYYY-MM` format.
223    ///
224    /// The max month is capped to current UTC month for active fifth-term games.
225    pub fn query_month_range(self) -> LotteryGameQueryRange {
226        let (start, end) = game_query_month_bounds(self);
227        LotteryGameQueryRange {
228            min_month: start.to_yyyy_mm(),
229            max_month: end.to_yyyy_mm(),
230        }
231    }
232
233    /// Returns the allowed query date range for this game in `YYYY-MM-DD` format.
234    ///
235    /// The max date is capped to current UTC date for active fifth-term games.
236    pub fn query_date_range(self) -> LotteryGameDateQueryRange {
237        let (start, end) = game_query_date_bounds(self);
238        LotteryGameDateQueryRange {
239            min_date: start.to_yyyy_mm_dd(),
240            max_date: end.to_yyyy_mm_dd(),
241        }
242    }
243
244    /// Returns the allowed query date range for local (D423F) data in `YYYY-MM-DD` format.
245    ///
246    /// This may differ from the remote API range for some games (e.g., BingoBingo).
247    pub fn query_date_range_for_local(self) -> LotteryGameDateQueryRange {
248        let (start, end) = game_query_date_bounds_for_local(self);
249        LotteryGameDateQueryRange {
250            min_date: start.to_yyyy_mm_dd(),
251            max_date: end.to_yyyy_mm_dd(),
252        }
253    }
254
255    /// Returns the allowed query date range for remote (Taiwan Lottery API) data in `YYYY-MM-DD` format.
256    ///
257    /// This may differ from the local data range for some games (e.g., BingoBingo).
258    pub fn query_date_range_for_remote(self) -> LotteryGameDateQueryRange {
259        let (start, end) = game_query_date_bounds_for_remote(self);
260        LotteryGameDateQueryRange {
261            min_date: start.to_yyyy_mm_dd(),
262            max_date: end.to_yyyy_mm_dd(),
263        }
264    }
265
266    /// Returns remote API query-parameter support for this game endpoint.
267    pub const fn remote_query_param_support(self) -> RemoteQueryParamSupport {
268        remote_query_param_support(self)
269    }
270
271    /// Parses CLI/user aliases into a supported game.
272    ///
273    /// Parsing is ASCII case-insensitive and accepts both display-oriented names
274    /// and short numeric aliases used by Taiwan Lottery and the C API.
275    pub fn parse(value: &str) -> Option<Self> {
276        match value.trim().to_ascii_lowercase().as_str() {
277            "super-lotto638" | "superlotto638" | "5134" => Some(Self::SuperLotto638),
278            "lotto649" | "5118" => Some(Self::Lotto649),
279            "daily539" | "5120" => Some(Self::Daily539),
280            "3d" | "2108" => Some(Self::Lotto3D),
281            "4d" | "2109" => Some(Self::Lotto4D),
282            "49m6" | "1121" => Some(Self::Lotto49M6),
283            "39m5" | "1197" => Some(Self::Lotto39M5),
284            "38m6" | "5122" => Some(Self::Lotto38M6),
285            "1224" | "5290" => Some(Self::Lotto1224),
286            "740" | "2300" => Some(Self::Lotto740),
287            "tic-tac-toe" | "tictactoe" | "2400" => Some(Self::TicTacToe),
288            "638" | "2500" => Some(Self::Lotto638),
289            "bingo-bingo" | "bingobingo" | "bingo_bingo" | "1102" => Some(Self::BingoBingo),
290            _ => None,
291        }
292    }
293
294    /// Maps the integer codes used by the FFI and C API back to a game.
295    pub const fn from_code(code: i32) -> Option<Self> {
296        match code {
297            0 => Some(Self::SuperLotto638),
298            1 => Some(Self::Lotto649),
299            2 => Some(Self::Daily539),
300            3 => Some(Self::Lotto3D),
301            4 => Some(Self::Lotto4D),
302            5 => Some(Self::Lotto49M6),
303            6 => Some(Self::Lotto39M5),
304            7 => Some(Self::Lotto38M6),
305            8 => Some(Self::Lotto1224),
306            9 => Some(Self::Lotto740),
307            10 => Some(Self::TicTacToe),
308            11 => Some(Self::Lotto638),
309            12 => Some(Self::BingoBingo),
310            _ => None,
311        }
312    }
313
314    pub(crate) fn path(self) -> &'static str {
315        match self {
316            Self::SuperLotto638 => "/Lottery/SuperLotto638Result",
317            Self::Lotto649 => "/Lottery/Lotto649Result",
318            Self::Daily539 => "/Lottery/Daily539Result",
319            Self::Lotto3D => "/Lottery/3DResult",
320            Self::Lotto4D => "/Lottery/4DResult",
321            Self::Lotto49M6 => "/Lottery/49M6Result",
322            Self::Lotto39M5 => "/Lottery/39M5Result",
323            Self::Lotto38M6 => "/Lottery/38M6Result",
324            Self::Lotto1224 => "/Lottery/Lotto1224Result",
325            Self::Lotto740 => "/Lottery/Lotto740Result",
326            Self::TicTacToe => "/Lottery/TicTacToeResult",
327            Self::Lotto638 => "/Lottery/Lotto638Result",
328            Self::BingoBingo => "/Lottery/BingoResult",
329        }
330    }
331
332    pub(crate) fn history_session_path(self) -> Option<&'static str> {
333        match self {
334            Self::Lotto3D => Some("/Lottery/3DHistoryResult"),
335            Self::Lotto4D => Some("/Lottery/4DHistoryResult"),
336            _ => None,
337        }
338    }
339}
340
341impl std::str::FromStr for LotteryGame {
342    type Err = ();
343
344    fn from_str(s: &str) -> Result<Self, Self::Err> {
345        Self::parse(s).ok_or(())
346    }
347}
348
349impl TryFrom<i32> for LotteryGame {
350    type Error = ();
351
352    fn try_from(value: i32) -> Result<Self, Self::Error> {
353        Self::from_code(value).ok_or(())
354    }
355}
356
357/// Query parameters for historical lottery draw searches.
358///
359/// Use builder methods to construct queries:
360/// - [`by_period`](HistoryDrawQuery::by_period) - Query by a specific period
361/// - [`by_month`](HistoryDrawQuery::by_month) - Query a single month
362/// - [`by_month_range`](HistoryDrawQuery::by_month_range) - Query a date range
363/// - [`by_open_date`](HistoryDrawQuery::by_open_date) - Query by a specific open date (Bingo)
364#[derive(Debug, Clone, PartialEq, Eq, Default)]
365pub struct HistoryDrawQuery {
366    pub period: Option<String>,
367    pub month: Option<String>,
368    pub end_month: Option<String>,
369    pub open_date: Option<String>,
370}
371
372impl HistoryDrawQuery {
373    pub fn by_period(period: impl Into<String>) -> Self {
374        Self {
375            period: Some(period.into()),
376            ..Self::default()
377        }
378    }
379
380    pub fn by_month(month: impl Into<String>) -> Self {
381        let month = month.into();
382        Self {
383            month: Some(month.clone()),
384            end_month: Some(month),
385            ..Self::default()
386        }
387    }
388
389    pub fn by_month_range(month: impl Into<String>, end_month: impl Into<String>) -> Self {
390        Self {
391            month: Some(month.into()),
392            end_month: Some(end_month.into()),
393            ..Self::default()
394        }
395    }
396
397    pub fn by_open_date(open_date: impl Into<String>) -> Self {
398        Self {
399            open_date: Some(open_date.into()),
400            ..Self::default()
401        }
402    }
403
404    fn normalized_params(&self) -> Result<(&str, &str, &str), DownloadError> {
405        let period = self.period.as_deref().unwrap_or("").trim();
406        if !period.is_empty() {
407            return Ok((period, "", ""));
408        }
409
410        let month = self
411            .month
412            .as_deref()
413            .map(str::trim)
414            .filter(|value| !value.is_empty())
415            .ok_or_else(|| std::io::Error::other("month is required when period is empty"))?;
416        let end_month = self
417            .end_month
418            .as_deref()
419            .map(str::trim)
420            .filter(|value| !value.is_empty())
421            .unwrap_or(month);
422
423        Ok(("", month, end_month))
424    }
425}
426
427/// A single lottery draw result from historical data.
428///
429/// Contains the draw period/date and corresponding numbers. The `numbers` field
430/// contains both base numbers (in draw order) and sorted numbers when available.
431#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
432pub struct HistoryDrawItem {
433    pub period: String,
434    pub date: Option<String>,
435    pub redeemable_date: Option<String>,
436    pub numbers: SortedDrawNumbers,
437}
438
439/// Paginated result set from a historical lottery draw query.
440///
441/// Contains the total number of matching results and a collection of individual draw items.
442#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
443pub struct HistoryDrawPage {
444    pub total_size: usize,
445    pub items: Vec<HistoryDrawItem>,
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    mod history_draw_query_tests {
453        use super::*;
454
455        #[test]
456        fn history_draw_query_requires_month_when_period_is_empty() {
457            let query = HistoryDrawQuery::default();
458            let err = query
459                .normalized_params()
460                .expect_err("must fail without period or month");
461            assert!(matches!(err, DownloadError::Io(_)));
462        }
463    }
464
465    mod lottery_game_tests {
466        use super::*;
467
468        #[test]
469        fn lottery_game_query_month_range_is_exposed_for_ui() {
470            let range = LotteryGame::Lotto1224.query_month_range();
471            assert_eq!(range.min_month, "2018-04");
472            assert_eq!(range.max_month, "2023-12");
473        }
474
475        #[test]
476        fn lottery_game_parse_supports_aliases() {
477            assert_eq!(LotteryGame::parse("lotto649"), Some(LotteryGame::Lotto649));
478            assert_eq!(LotteryGame::parse("5118"), Some(LotteryGame::Lotto649));
479            assert_eq!(
480                LotteryGame::parse("tic-tac-toe"),
481                Some(LotteryGame::TicTacToe)
482            );
483            assert_eq!(
484                LotteryGame::parse("bingo-bingo"),
485                Some(LotteryGame::BingoBingo)
486            );
487            assert_eq!(LotteryGame::parse("1102"), Some(LotteryGame::BingoBingo));
488            assert_eq!(LotteryGame::parse("unknown"), None);
489        }
490
491        #[test]
492        fn lottery_game_from_code_matches_ffi_codes() {
493            assert_eq!(LotteryGame::from_code(0), Some(LotteryGame::SuperLotto638));
494            assert_eq!(LotteryGame::from_code(11), Some(LotteryGame::Lotto638));
495            assert_eq!(LotteryGame::from_code(12), Some(LotteryGame::BingoBingo));
496            assert_eq!(LotteryGame::from_code(99), None);
497        }
498
499        #[test]
500        fn lottery_game_from_str_matches_parse() {
501            use std::str::FromStr as _;
502
503            assert_eq!(LotteryGame::from_str("lotto649"), Ok(LotteryGame::Lotto649));
504            assert_eq!(LotteryGame::from_str("5118"), Ok(LotteryGame::Lotto649));
505            assert_eq!(LotteryGame::from_str("invalid"), Err(()));
506        }
507
508        #[test]
509        fn lottery_game_try_from_i32_matches_from_code() {
510            assert_eq!(LotteryGame::try_from(0), Ok(LotteryGame::SuperLotto638));
511            assert_eq!(LotteryGame::try_from(11), Ok(LotteryGame::Lotto638));
512            assert_eq!(LotteryGame::try_from(12), Ok(LotteryGame::BingoBingo));
513            assert_eq!(LotteryGame::try_from(-1), Err(()));
514        }
515
516        #[test]
517        fn history_game_all_lists_every_supported_game() {
518            assert_eq!(LotteryGame::ALL.len(), 13);
519            assert!(LotteryGame::ALL.contains(&LotteryGame::TicTacToe));
520            assert!(LotteryGame::ALL.contains(&LotteryGame::SuperLotto638));
521            assert!(LotteryGame::ALL.contains(&LotteryGame::BingoBingo));
522        }
523    }
524}