Skip to main content

rustrade_data/exchange/lse/
calendar.rs

1//! Economic calendar events from the London Strategic Edge data API
2//! (`GET /economic-calendar`).
3//!
4//! Scheduled macroeconomic releases โ€” CPI prints, rate decisions, payrolls โ€” across 108 country
5//! codes, each carrying the consensus estimate, the previous figure and the actual outcome where
6//! the provider has them. Served by the [data API](super::data_api) host, alongside
7//! [`bond_yield`](super::bond_yield).
8//!
9//! ```no_run
10//! # use chrono::NaiveDate;
11//! # use rustrade_data::exchange::lse::calendar::LseCalendarQuery;
12//! # use rustrade_data::exchange::lse::data_api::LseDataApiClient;
13//! # use rustrade_data::exchange::lse::error::LseError;
14//! # async fn example() -> Result<(), LseError> {
15//! let client = LseDataApiClient::from_env()?;
16//!
17//! // The stats handle is fetched once and reused: it is what makes a query checkable.
18//! let stats = client.fetch_economic_calendar_stats().await?;
19//!
20//! let query = LseCalendarQuery::new(
21//!     NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"),
22//!     NaiveDate::from_ymd_opt(2026, 3, 24).expect("valid date"),
23//! )
24//! .with_country("GB") // normalised to the provider's `UK` before the request is sent
25//! .with_impact("High");
26//!
27//! for event in client.fetch_economic_calendar(&stats, &query).await? {
28//!     println!("{} {} {}", event.event_date, event.country, event.event);
29//! }
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! # ๐Ÿ”ด The feed stopped on 2026-03-24 โ€” this is a historical archive, not a calendar
35//!
36//! **The forward-looking use case an economic calendar exists for is not served at all.** The
37//! provider's last event is dated **2026-03-24**, and nothing has been published since. This is
38//! not staleness that a later fetch resolves โ€” it was measured as frozen **to the unit**, two
39//! months of wall-clock apart:
40//!
41//! | measured | 2026-07-24 | 2026-09-22 |
42//! |---|---|---|
43//! | `total_events` | 124,896 | 124,896 |
44//! | `latest` | 2026-03-24 | 2026-03-24 |
45//!
46//! Confirmed from the other side as well: a forward window of `2026-03-25`..=`2030-12-31` with no
47//! country filter answers `200` with `count: 0`, as does any window after `latest`, while
48//! `2026-03-24` itself returns 17 events. Zero events exist after that date across all 108
49//! countries. Nothing on the provider's issue tracker reports it, so no fix should be assumed.
50//!
51//! What remains is a genuine and complete historical archive: **124,896 events from 2014-12-31 to
52//! 2026-03-24**, with an `estimate` populated on 74,843 of them. That is useful for backtesting
53//! and for event-study work, and it is what this module is for. It is not useful for anything that
54//! needs to know what releases are coming, and no amount of client-side care changes that.
55//!
56//! [`LseCalendarStats::latest`] is the live figure rather than a constant here, so a caller can
57//! detect a revival: if it ever moves past 2026-03-24, the feed resumed.
58//!
59//! # โš ๏ธ The endpoint validates nothing โ€” a wrong query is a `200` with zero rows
60//!
61//! As with [`bond_yield`](super::bond_yield), and for the same reason
62//! [`fetch_economic_calendar`](LseDataApiClient::fetch_economic_calendar) takes an
63//! [`LseCalendarStats`] as a **required** argument. Measured against the live host:
64//!
65//! | request | answer |
66//! |---|---|
67//! | `country=UK` | `200`, rows |
68//! | `country=GB` (ISO-3166 spelling) | `200`, `count: 0` |
69//! | `country=ZZ` (nonsense) | `200`, `count: 0` |
70//! | `impact=Critical` (nonsense) | `200`, `count: 0` |
71//! | a reversed range | `200`, `count: 0` |
72//! | any window after 2026-03-24 | `200`, `count: 0` |
73//!
74//! All of those zero-row answers are identical. The one query shape that does *not* answer `200`
75//! is a malformed date โ€” `start_date=not-a-date` returns **`500 Internal Server Error`**, not a
76//! `400`. [`LseCalendarQuery`] holds typed [`NaiveDate`]s, which makes that unreachable.
77//!
78//! # ๐Ÿ”ด But an empty result inside the published range is NOT an error
79//!
80//! This is where the calendar differs from `/bond-yields`, and the difference is load-bearing.
81//! `/bond-yields/stats` publishes per-country **and** per-tenor `first_date`/`last_date`, so a
82//! window that would return nothing can be rejected before it is sent.
83//! `/economic-calendar/stats` publishes **no per-country coverage at all** โ€” only a flat global
84//! `earliest`/`latest` โ€” and coverage is wildly uneven: 88 of the 108 countries hold fewer than
85//! 100 events, the median is 13, and `UK` holds 56, all between 2026-01-16 and 2026-03-24.
86//!
87//! So `UK` over 2020โ€“2025 returns zero events. That is correct, it is unhelpful, and **this
88//! library cannot pre-explain it**, because the provider does not publish the coverage that would
89//! be needed to. An empty [`Vec`] from a query inside the global range is a legitimate answer and
90//! is deliberately **not** raised as an error. Inventing a fourth error variant here would mean
91//! inventing knowledge the provider does not give.
92//!
93//! # โš ๏ธ Two silent parameter traps
94//!
95//! 1. **A comma-separated country list is a real OR, and is the only form that works.**
96//!    `country=US,UK` returns 36,477 events โ€” exactly US alone (36,421) plus UK alone (56).
97//!    Supported, and undocumented by the provider.
98//! 2. **๐Ÿ”ด A repeated parameter silently takes the LAST value.** `country=US&country=UK` returns
99//!    56 events: the UK ones. The US rows vanish with no error and no warning, leaving a wrong but
100//!    entirely plausible result. A naive `.query(&[("country", "US"), ("country", "UK")])` hits
101//!    this exactly.
102//!
103//! [`LseCalendarQuery`] joins with commas and never repeats the key. Nothing here lets a caller
104//! express the broken form.
105//!
106//! # โš ๏ธ `country` is not ISO-3166, and not every code is a country
107//!
108//! The same `UK`-not-`GB` divergence as [`bond_yield`](super::bond_yield): `UK` is published and
109//! `GB` is absent, so the one documented alias carries over and
110//! [`LseCalendarStats::normalise_country`] applies it. But **the two vocabularies are not the
111//! same set** โ€” the calendar's 108 codes include `EA` (Euro Area) and `EU`, which are not
112//! countries at all. Do not assume a code valid on one endpoint is valid on the other.
113//!
114//! Both hosts are case- and whitespace-insensitive server-side, so `uk`, `UK` and `'  Uk  '` all
115//! resolve on the wire. Normalisation is still required, for a different reason than it appears:
116//! the **client-side membership check** runs against an upper-case vocabulary, and only the client
117//! can turn `GB` into `UK` at all.
118//!
119//! # โš ๏ธ `impact` of `"None"` is a literal string, not a JSON null
120//!
121//! The vocabulary is exactly `High`, `Low`, `Medium`, `None`, and 592 events carry `"None"` as a
122//! four-character string. Mapping it to an absent value would be wrong, so
123//! [`LseCalendarImpact`] models it as the variant [`None`](LseCalendarImpact::None) and the field
124//! is not an [`Option`].
125//!
126//! # โš ๏ธ Licensing โ€” this data may not be redistributed
127//!
128//! Calendar events are provider data. They may be used for your own research, trading and model
129//! training, including commercially, but **not** redistributed or re-served to third parties in
130//! any form. Do not commit responses as fixtures or example datasets. Terms:
131//! <https://londonstrategicedge.com/terms>
132
133use crate::exchange::lse::data_api::LseDataApiClient;
134use crate::exchange::lse::error::LseError;
135use chrono::{DateTime, NaiveDate, Utc};
136use rust_decimal::Decimal;
137use serde::Deserialize;
138use smol_str::SmolStr;
139
140/// Path of the economic-calendar events endpoint, relative to the data API base URL.
141const ECONOMIC_CALENDAR_PATH: &str = "economic-calendar";
142
143/// Path of the economic-calendar stats endpoint, relative to the data API base URL.
144const ECONOMIC_CALENDAR_STATS_PATH: &str = "economic-calendar/stats";
145
146/// Spelling of the `start_date`/`end_date` query parameters.
147///
148/// โš ๏ธ **Not** the spelling of a row's [`event_date`](LseCalendarEvent::event_date) or of the stats
149/// bounds, which carry a time of day and an offset. The query is bounded by whole days; an event is
150/// stamped to the second.
151const DATE_FORMAT: &str = "%Y-%m-%d";
152
153/// Value of the `format` parameter that selects JSON.
154///
155/// โš ๏ธ **Required, not a default.** The provider's own OpenAPI document declares `csv` as this
156/// parameter's default, and the wire agrees: omitting it โ€” or passing anything other than the
157/// exact string `json`, which is *not* validated โ€” hands a CSV body to a JSON decoder.
158const JSON_FORMAT: &str = "json";
159
160/// Separator for a multi-country filter.
161///
162/// โš ๏ธ Load-bearing. See the [module documentation](self): repeating the `country` key instead
163/// silently discards every value but the last.
164const COUNTRY_SEPARATOR: &str = ",";
165
166/// The provider's spelling of the United Kingdom, which is not its ISO-3166-1 alpha-2 code.
167const UK_CODE: &str = "UK";
168
169/// The ISO-3166-1 alpha-2 code for the United Kingdom, which this host does **not** use.
170const GB_CODE: &str = "GB";
171
172/// Parse a provider instant, naming the field so a caller can tell which one failed.
173///
174/// The provider's space-separated spelling (`2026-03-24 14:00:00+00:00`) is accepted by
175/// [`DateTime::parse_from_rfc3339`] directly, as the RFC 3339 ยง5.6 lenience it is โ€” the same path
176/// [`LseTick`](super::tick::LseTick) takes for the vault's timestamps. No second grammar is used,
177/// so the two cannot drift apart in what they admit.
178fn parse_instant(value: &str, field: &str) -> Result<DateTime<Utc>, LseError> {
179    DateTime::parse_from_rfc3339(value)
180        .map(|parsed| parsed.with_timezone(&Utc))
181        .map_err(|error| LseError::Deserialize {
182            message: format!("invalid economic-calendar {field} {value:?}: {error}"),
183        })
184}
185
186/// How much market impact the provider attributes to an event.
187///
188/// # โš ๏ธ [`None`](Self::None) is the provider's own literal string
189///
190/// The wire vocabulary is exactly `["High", "Low", "Medium", "None"]`, and `"None"` is a
191/// four-character JSON string carried by 592 of the 124,896 measured events โ€” it is **not** a JSON
192/// `null`, and no event omits the field. That is why [`LseCalendarEvent::impact`] is this type
193/// rather than an `Option<_>`: an `Option` would make `Some(Impact::None)` and `None`
194/// indistinguishable in intent while meaning quite different things.
195///
196/// The set is closed at four. An unrecognised value is a decode error rather than a silently
197/// absorbed variant, so a provider addition surfaces instead of being rounded to the nearest
198/// known rating.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
200pub enum LseCalendarImpact {
201    /// The provider's `"High"`.
202    High,
203    /// The provider's `"Medium"`.
204    Medium,
205    /// The provider's `"Low"`.
206    Low,
207    /// The provider's literal `"None"` โ€” an event it rates as market-irrelevant.
208    ///
209    /// โš ๏ธ Not an absent value. See the type's documentation.
210    None,
211}
212
213impl LseCalendarImpact {
214    /// The provider's spelling of this rating, suitable for the `impact` query parameter.
215    #[must_use]
216    pub const fn as_str(self) -> &'static str {
217        match self {
218            Self::High => "High",
219            Self::Medium => "Medium",
220            Self::Low => "Low",
221            Self::None => "None",
222        }
223    }
224}
225
226impl std::fmt::Display for LseCalendarImpact {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        f.write_str(self.as_str())
229    }
230}
231
232/// One scheduled economic release, as the data API serves it.
233///
234/// # โš ๏ธ Every numeric arrives as a JSON **string**, and absent is always `null`
235///
236/// `{"event_date": "2026-03-24 14:00:00+00:00", "previous": "2.9", "estimate": null, โ€ฆ}` โ€” there
237/// is not a single JSON number in an event. The numerics are therefore decoded with
238/// [`rust_decimal::serde::str_option`], **not** the `rust_decimal::serde::float` used by
239/// `AlpacaStockSplit`, which expects a JSON number and fails outright here.
240///
241/// Absence is spelled `null` and **only** `null`: measured across all 124,896 events, the
242/// empty-string count is **zero for every one of the eleven fields**. Nulls themselves are common
243/// โ€” `estimate` on 50,053 events, `unit` on 37,349, `change` on 25,481, `currency` on 253 โ€” so the
244/// optionality is real even though the empty string never appears.
245///
246/// Every one of roughly 462,000 non-null numeric values parses. The only shapes that occur are
247/// `9.9`, `-9.9`, `9` and `-9`: no thousands separators, no trailing `%`, no scientific notation.
248/// **Negatives are abundant and load-bearing** (49,080 on `change`, 48,718 on `change_percentage`),
249/// so an unsigned type would be wrong.
250///
251/// All eleven fields are present on every event โ€” there is exactly one key-set across the whole
252/// corpus โ€” so none is `#[serde(default)]`. A field the provider drops is a decode failure rather
253/// than a silent `None`, which is the intended trade.
254///
255/// Unknown fields are ignored, so a field the provider adds does not break decoding.
256#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
257pub struct LseCalendarEvent {
258    /// When the release is scheduled, spelled `2026-03-24 14:00:00+00:00`.
259    ///
260    /// # โš ๏ธ A timestamp, not a date
261    ///
262    /// Unlike [`LseBondYieldRow::date`](super::bond_yield::LseBondYieldRow::date), which is a bare
263    /// ten-character day, this carries a real time of day: 100% of the 124,896 measured events
264    /// match `YYYY-MM-DD HH:MM:SS+00:00`, the offset is `+00:00` on every one, and **348 distinct
265    /// times of day** occur. Truncating it to a calendar date would discard the intraday ordering
266    /// that makes an event study possible, so [`event_time`](Self::event_time) yields a
267    /// [`DateTime<Utc>`].
268    ///
269    /// Kept as the provider's string and parsed on demand, following the same rule as
270    /// [`LseCatalogEntry`](super::reference::LseCatalogEntry) and `LseBondYieldRow`: one malformed
271    /// event then fails at the event rather than taking the whole fetch down with it.
272    pub event_date: String,
273    /// Country code as this host spells it.
274    ///
275    /// โš ๏ธ Not ISO-3166: the United Kingdom is `UK`, not `GB`, and `EA`/`EU` are not countries at
276    /// all. See the [module documentation](self).
277    pub country: SmolStr,
278    /// The release's name, as the provider labels it (`"CPI (YoY)"`, `"Interest Rate Decision"`).
279    ///
280    /// Free text, not a closed vocabulary. `(event_date, country, event)` is a natural key โ€” all
281    /// 124,896 measured triples are distinct โ€” but it is the *provider's* shape, not a reason to
282    /// key a local store on it.
283    pub event: String,
284    /// Currency the figures are denominated in (`"GBP"`), where one applies.
285    ///
286    /// `None` on 253 events; 83 distinct values otherwise.
287    pub currency: Option<SmolStr>,
288    /// The previously reported figure, where the provider has it.
289    #[serde(with = "rust_decimal::serde::str_option")]
290    pub previous: Option<Decimal>,
291    /// The consensus forecast ahead of the release.
292    ///
293    /// Populated on 74,843 of the 124,896 measured events โ€” the single most useful field here for
294    /// surprise-based work, and absent on two fifths of the corpus.
295    #[serde(with = "rust_decimal::serde::str_option")]
296    pub estimate: Option<Decimal>,
297    /// The figure actually released.
298    #[serde(with = "rust_decimal::serde::str_option")]
299    pub actual: Option<Decimal>,
300    /// Absolute change from [`previous`](Self::previous) to [`actual`](Self::actual).
301    ///
302    /// Provider-computed, not derived here. Frequently negative.
303    #[serde(with = "rust_decimal::serde::str_option")]
304    pub change: Option<Decimal>,
305    /// Relative change from [`previous`](Self::previous) to [`actual`](Self::actual), in percent.
306    ///
307    /// Provider-computed, and carried as a bare number: the `%` is in
308    /// [`unit`](Self::unit) or nowhere, never in this value.
309    #[serde(with = "rust_decimal::serde::str_option")]
310    pub change_percentage: Option<Decimal>,
311    /// The provider's market-impact rating.
312    ///
313    /// โš ๏ธ Never absent, and [`LseCalendarImpact::None`] is a real rating rather than a missing
314    /// one. See [`LseCalendarImpact`].
315    pub impact: LseCalendarImpact,
316    /// Magnitude or unit suffix for the figures (`"%"`, `"K"`, `"M"`, `"B"`, `"T"`).
317    ///
318    /// A closed set of five in the measured corpus โ€” `%` on 65,018 events, `B` on 10,972, `K` on
319    /// 5,990, `M` on 5,108, `T` on 459 โ€” plus 37,349 nulls.
320    ///
321    /// โš ๏ธ **A different vocabulary from the catalog's `unit`**, which names a quote currency or an
322    /// instrument denomination. The two must not share a type, and a value here must not be looked
323    /// up against that one. Left as the provider's string rather than modelled as an enum: it is
324    /// not validated anywhere and carries no behaviour, so a sixth suffix should arrive as data
325    /// rather than as a decode failure.
326    pub unit: Option<SmolStr>,
327}
328
329impl LseCalendarEvent {
330    /// Parses [`event_date`](Self::event_date) as an instant.
331    ///
332    /// The provider's space-separated spelling (`2026-03-24 14:00:00+00:00`) is accepted as the
333    /// RFC 3339 ยง5.6 lenience it is.
334    ///
335    /// # Errors
336    /// Returns [`LseError::Deserialize`] if the value is not a valid RFC 3339 instant.
337    pub fn event_time(&self) -> Result<DateTime<Utc>, LseError> {
338        parse_instant(&self.event_date, "event_date")
339    }
340
341    /// The calendar day of [`event_date`](Self::event_date), in UTC.
342    ///
343    /// A convenience for grouping; prefer [`event_time`](Self::event_time) for ordering, since 348
344    /// distinct times of day occur and a day alone does not order events within one.
345    ///
346    /// # Errors
347    /// As [`event_time`](Self::event_time).
348    pub fn event_day(&self) -> Result<NaiveDate, LseError> {
349        Ok(self.event_time()?.date_naive())
350    }
351}
352
353/// The provider's index of what it publishes on `/economic-calendar`.
354///
355/// Fetched by [`fetch_economic_calendar_stats`](LseDataApiClient::fetch_economic_calendar_stats)
356/// and passed to [`fetch_economic_calendar`](LseDataApiClient::fetch_economic_calendar), which is
357/// what makes a query checkable against an endpoint that validates nothing itself.
358///
359/// # ๐Ÿ”ด Much weaker than `/bond-yields/stats`, and that shapes the whole error design
360///
361/// This is a **flat** summary: a list of country codes, a list of impact ratings, and one global
362/// `earliest`/`latest` pair. There is **no per-country coverage** โ€” unlike the bond-yield stats,
363/// which publish `first_date`/`last_date` per country *and* per tenor.
364///
365/// The consequence is that only three checks are possible before a request is sent: that a country
366/// is published, that an impact rating is published, and that the window overlaps the **global**
367/// range. A query inside that global range can still legitimately return nothing, and the library
368/// deliberately does not treat that as an error. See the [module documentation](self).
369///
370/// # Fetch it once
371/// It is a description of the provider's coverage, not of any one query, so one handle serves every
372/// fetch. Nothing here refreshes it.
373///
374/// # It is also the revival detector
375/// [`latest`](Self::latest) is the live figure. The feed has been frozen at 2026-03-24 across two
376/// measurements two months apart; if this ever reports a later date, the feed resumed.
377#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
378pub struct LseCalendarStats {
379    /// Every country code the provider publishes events for. Measured at 108.
380    ///
381    /// โš ๏ธ Contains `UK` and not `GB`, and contains `EA` and `EU`, which are not countries. Test
382    /// membership through [`publishes_country`](Self::publishes_country) rather than searching
383    /// this directly, so the normalisation is applied.
384    pub countries: Vec<SmolStr>,
385    /// Every impact rating in use. Measured as exactly `["High", "Low", "Medium", "None"]`.
386    ///
387    /// โš ๏ธ `"None"` is a literal string here too โ€” see [`LseCalendarImpact`].
388    pub impacts: Vec<SmolStr>,
389    /// Instant of the earliest published event, spelled like
390    /// [`event_date`](LseCalendarEvent::event_date): `2014-12-31 23:00:00+00:00`.
391    ///
392    /// โš ๏ธ The provider once served a bare `YYYY-MM-DD` here and changed it without notice; the
393    /// bare spelling is no longer accepted. Parse with [`earliest_time`](Self::earliest_time), or
394    /// [`earliest_date`](Self::earliest_date) for its UTC day.
395    pub earliest: String,
396    /// Instant of the latest published event, spelled like `earliest`. Measured at
397    /// `2026-03-24 20:00:00+00:00`.
398    ///
399    /// ๐Ÿ”ด The feed has not moved past this day since it was first measured. Parse with
400    /// [`latest_time`](Self::latest_time) or [`latest_date`](Self::latest_date); compare the day
401    /// against 2026-03-24 to detect a revival.
402    pub latest: String,
403    /// Total events across every country. Measured at 124,896, unchanged over two months.
404    pub total_events: u64,
405}
406
407impl LseCalendarStats {
408    /// Translates a country code into the provider's spelling.
409    ///
410    /// Trims, upper-cases, and maps the ISO-3166-1 alpha-2 `GB` to the provider's `UK`.
411    ///
412    /// # Why normalise at all, when the server does not care
413    /// The host is measured to be case- and whitespace-insensitive: `country=uk` and
414    /// `country='  Uk  '` both resolve on the wire. The trim and upper-case are not what gets a
415    /// code accepted โ€” they are what lets the **client-side** membership check run against an
416    /// upper-case vocabulary. The `GB`โ†’`UK` mapping is different in kind: the server would answer
417    /// `GB` with a silent `200 count=0`, and only the client can bridge it.
418    ///
419    /// # Why exactly one alias
420    /// The United Kingdom is the only divergence from ISO-3166-1 alpha-2 among the published
421    /// codes, matching the [`bond_yield`](super::bond_yield) endpoint. A general alias table would
422    /// invent mappings the provider has not been measured to want.
423    #[must_use]
424    pub fn normalise_country(code: &str) -> SmolStr {
425        let upper = code.trim().to_ascii_uppercase();
426
427        if upper == GB_CODE {
428            SmolStr::new_static(UK_CODE)
429        } else {
430            SmolStr::from(upper)
431        }
432    }
433
434    /// Whether the provider publishes events for a country, in any spelling
435    /// [`normalise_country`](Self::normalise_country) accepts.
436    ///
437    /// So `"gb"`, `"GB"` and `"UK"` all find the United Kingdom.
438    #[must_use]
439    pub fn publishes_country(&self, code: &str) -> bool {
440        let normalised = Self::normalise_country(code);
441
442        self.countries.iter().any(|known| known == &normalised)
443    }
444
445    /// Resolves an impact rating to the provider's own spelling, ignoring case.
446    ///
447    /// Returns `None` if the provider does not publish that rating. The provider's spelling is
448    /// returned rather than the caller's so the request carries a value from the published
449    /// vocabulary verbatim.
450    #[must_use]
451    pub fn resolve_impact(&self, impact: &str) -> Option<&SmolStr> {
452        let wanted = impact.trim();
453
454        self.impacts
455            .iter()
456            .find(|known| known.eq_ignore_ascii_case(wanted))
457    }
458
459    /// Parses [`earliest`](Self::earliest) as an instant.
460    ///
461    /// # Errors
462    /// Returns [`LseError::Deserialize`] if the value is not a valid RFC 3339 instant.
463    pub fn earliest_time(&self) -> Result<DateTime<Utc>, LseError> {
464        parse_instant(&self.earliest, "earliest")
465    }
466
467    /// Parses [`latest`](Self::latest) as an instant.
468    ///
469    /// # Errors
470    /// Returns [`LseError::Deserialize`] if the value is not a valid RFC 3339 instant.
471    pub fn latest_time(&self) -> Result<DateTime<Utc>, LseError> {
472        parse_instant(&self.latest, "latest")
473    }
474
475    /// The calendar day of [`earliest`](Self::earliest), in UTC.
476    ///
477    /// # Errors
478    /// As [`earliest_time`](Self::earliest_time).
479    pub fn earliest_date(&self) -> Result<NaiveDate, LseError> {
480        Ok(self.earliest_time()?.date_naive())
481    }
482
483    /// The calendar day of [`latest`](Self::latest), in UTC.
484    ///
485    /// # Errors
486    /// As [`latest_time`](Self::latest_time).
487    pub fn latest_date(&self) -> Result<NaiveDate, LseError> {
488        Ok(self.latest_time()?.date_naive())
489    }
490}
491
492/// An economic-calendar request.
493///
494/// `#[non_exhaustive]`: construct with [`new`](Self::new) and the `with_*` builders so a parameter
495/// added alongside a future provider filter does not break callers.
496///
497/// # โš ๏ธ Countries are joined with commas, never repeated
498/// The provider accepts `country=US,UK` as a genuine OR, and silently keeps only the **last** of
499/// `country=US&country=UK`. This type only ever emits the first form. See the
500/// [module documentation](self).
501#[derive(Debug, Clone, PartialEq, Eq)]
502#[non_exhaustive]
503pub struct LseCalendarQuery {
504    /// Country codes, in any spelling [`LseCalendarStats::normalise_country`] accepts.
505    ///
506    /// Empty means **every** country, which is a genuine bulk request: the whole corpus is 124,896
507    /// events in a single 29 MB response.
508    pub countries: Vec<SmolStr>,
509    /// Impact rating to filter on, or `None` for every rating.
510    pub impact: Option<SmolStr>,
511    /// First date of the requested window, inclusive.
512    pub start: NaiveDate,
513    /// Last date of the requested window, inclusive.
514    pub end: NaiveDate,
515}
516
517impl LseCalendarQuery {
518    /// A query for every country and every impact rating, over `[start, end]` inclusive.
519    ///
520    /// Narrow it with [`with_country`](Self::with_country),
521    /// [`with_countries`](Self::with_countries) and [`with_impact`](Self::with_impact).
522    #[must_use]
523    pub fn new(start: NaiveDate, end: NaiveDate) -> Self {
524        Self {
525            countries: Vec::new(),
526            impact: None,
527            start,
528            end,
529        }
530    }
531
532    /// Add one country to the filter.
533    ///
534    /// Additive: calling it twice asks for both, sent as one comma-joined parameter.
535    #[must_use]
536    pub fn with_country(mut self, country: impl Into<SmolStr>) -> Self {
537        self.countries.push(country.into());
538        self
539    }
540
541    /// Add several countries to the filter.
542    ///
543    /// Additive, like [`with_country`](Self::with_country).
544    #[must_use]
545    pub fn with_countries<I, C>(mut self, countries: I) -> Self
546    where
547        I: IntoIterator<Item = C>,
548        C: Into<SmolStr>,
549    {
550        self.countries.extend(countries.into_iter().map(Into::into));
551        self
552    }
553
554    /// Restrict the query to one impact rating, named as the provider labels it (`"High"`).
555    ///
556    /// Matched against the published vocabulary ignoring case, and sent in the provider's own
557    /// spelling. Replaces any rating set earlier.
558    #[must_use]
559    pub fn with_impact(mut self, impact: impl Into<SmolStr>) -> Self {
560        self.impact = Some(impact.into());
561        self
562    }
563
564    /// Check this query against the provider's published coverage.
565    ///
566    /// Called unconditionally by
567    /// [`fetch_economic_calendar`](LseDataApiClient::fetch_economic_calendar), so it cannot be
568    /// skipped; it is public so a caller can screen a batch of queries without spending a request
569    /// on each.
570    ///
571    /// Checks, in order: that the window is not inverted, that every country named is published,
572    /// that the impact rating is published, and that the window **overlaps** the provider's global
573    /// range. A partial overlap passes โ€” it returns the events that exist, which is what was asked
574    /// for; only a disjoint window is rejected.
575    ///
576    /// # โš ๏ธ What it deliberately does not check
577    /// Whether *this* country has events in *this* window. The provider publishes no per-country
578    /// coverage, so that is unknowable before the request; an empty result inside the global range
579    /// is a legitimate answer rather than an error. See the [module documentation](self).
580    ///
581    /// # Errors
582    /// - [`LseError::InvalidInput`] if `end` precedes `start`.
583    /// - [`LseError::UnknownCalendarCountry`] if a country is not published.
584    /// - [`LseError::UnknownCalendarImpact`] if the impact rating is not published.
585    /// - [`LseError::CalendarRangeOutsideCoverage`] if the window is disjoint from the global one.
586    /// - [`LseError::Deserialize`] if a date in `stats` is not in the provider's spelling.
587    pub fn validate(&self, stats: &LseCalendarStats) -> Result<(), LseError> {
588        self.resolve(stats).map(|_| ())
589    }
590
591    /// Validate, and return the query parameters the request must actually be sent with.
592    ///
593    /// Returning the parameters rather than rebuilding them at the call site is what guarantees the
594    /// normalised country codes and the provider's own impact spelling are the ones that reach the
595    /// wire โ€” sending the caller's `GB` unchanged is precisely the silent zero-row case this module
596    /// exists to close.
597    fn resolve(&self, stats: &LseCalendarStats) -> Result<Vec<(&'static str, String)>, LseError> {
598        if self.end < self.start {
599            return Err(LseError::InvalidInput {
600                message: format!(
601                    "economic-calendar range end {} precedes start {}",
602                    self.end, self.start
603                ),
604            });
605        }
606
607        let mut normalised = Vec::with_capacity(self.countries.len());
608        for country in &self.countries {
609            let code = LseCalendarStats::normalise_country(country);
610
611            if !stats.countries.iter().any(|known| known == &code) {
612                return Err(LseError::UnknownCalendarCountry {
613                    requested: country.to_string(),
614                    normalised: code.to_string(),
615                });
616            }
617
618            normalised.push(code);
619        }
620
621        let impact = match &self.impact {
622            Some(requested) => Some(
623                stats
624                    .resolve_impact(requested)
625                    .ok_or_else(|| LseError::UnknownCalendarImpact {
626                        requested: requested.to_string(),
627                        available: stats.impacts.iter().map(ToString::to_string).collect(),
628                    })?
629                    .to_string(),
630            ),
631            None => None,
632        };
633
634        // The only range check the provider makes possible. Per-country coverage is not published,
635        // so a window inside this one may still hold nothing -- which is not an error here.
636        let earliest = stats.earliest_date()?;
637        let latest = stats.latest_date()?;
638
639        if self.end < earliest || self.start > latest {
640            return Err(LseError::CalendarRangeOutsideCoverage {
641                start: self.start,
642                end: self.end,
643                earliest,
644                latest,
645            });
646        }
647
648        let mut params = vec![
649            ("start_date", self.start.format(DATE_FORMAT).to_string()),
650            ("end_date", self.end.format(DATE_FORMAT).to_string()),
651            // Absent, this endpoint serves CSV.
652            ("format", JSON_FORMAT.to_string()),
653        ];
654
655        // Joined, never repeated: the provider keeps only the last of a repeated key, discarding
656        // every earlier country without an error.
657        if !normalised.is_empty() {
658            params.push((
659                "country",
660                normalised
661                    .iter()
662                    .map(SmolStr::as_str)
663                    .collect::<Vec<_>>()
664                    .join(COUNTRY_SEPARATOR),
665            ));
666        }
667
668        if let Some(impact) = impact {
669            params.push(("impact", impact));
670        }
671
672        Ok(params)
673    }
674}
675
676/// The `{count, data}` envelope `/economic-calendar` answers with.
677///
678/// `count` is carried so it can be checked against the events actually delivered; see
679/// [`fetch_economic_calendar`](LseDataApiClient::fetch_economic_calendar).
680#[derive(Debug, Deserialize)]
681struct LseCalendarEnvelope {
682    count: usize,
683    data: Vec<LseCalendarEvent>,
684}
685
686impl LseDataApiClient {
687    /// Fetch the provider's index of published economic-calendar coverage.
688    ///
689    /// Fetch this **once** and reuse the handle across queries โ€” see [`LseCalendarStats`]. It is
690    /// the required first argument to
691    /// [`fetch_economic_calendar`](Self::fetch_economic_calendar), which is what makes a query
692    /// checkable against an endpoint that validates nothing itself.
693    ///
694    /// It is also how a caller detects the frozen feed reviving:
695    /// [`latest`](LseCalendarStats::latest) has not moved past 2026-03-24 across measurements two
696    /// months apart.
697    ///
698    /// # Errors
699    /// See [`LseError`].
700    pub async fn fetch_economic_calendar_stats(&self) -> Result<LseCalendarStats, LseError> {
701        self.get_json(
702            ECONOMIC_CALENDAR_STATS_PATH,
703            &[("format", JSON_FORMAT.to_string())],
704        )
705        .await
706    }
707
708    /// Fetch economic-calendar events for `query`.
709    ///
710    /// `stats` is **required**, not optional, and `query` is validated against it before anything
711    /// is sent. That is a deliberate API choice: the endpoint answers `200` with zero rows for an
712    /// unknown country, an unknown impact rating, a reversed range *and* an empty window alike, so
713    /// a fetch that could skip validation would be a fetch that could silently return nothing. See
714    /// the [module documentation](self) for the measurements.
715    ///
716    /// The country codes sent are the **normalised** ones โ€” a caller passing `GB` reaches the
717    /// provider's `UK` โ€” joined into a single comma-separated parameter.
718    ///
719    /// # ๐Ÿ”ด Nothing is published after 2026-03-24
720    /// The feed is frozen. A window after that date is rejected by validation; a window spanning it
721    /// returns only what exists up to it. This endpoint serves a historical archive, not a
722    /// forward-looking calendar. See the [module documentation](self).
723    ///
724    /// # ๐Ÿ”ด An empty result is not necessarily an error
725    /// The provider publishes no per-country coverage, and coverage is very uneven โ€” 88 of 108
726    /// countries hold fewer than 100 events, and `UK` holds 56, all in early 2026. A query for a
727    /// published country inside the global range can legitimately return an empty [`Vec`], and
728    /// that is **not** reported as an error, because the library has no way to distinguish it from
729    /// a quiet period. Only the three checks validation can actually make are made.
730    ///
731    /// # The whole answer arrives in one response
732    /// This endpoint is **not paged**: its envelope carries no cursor, the provider's OpenAPI
733    /// document declares no `limit`, `offset`, `cursor` or `page` parameter, and a request for the
734    /// entire corpus with no country filter returned all 124,896 events in a single 29 MB
735    /// response, matching `total_events` exactly. So this returns a `Vec` rather than a stream, and
736    /// no pagination is invented for a surface that has none.
737    ///
738    /// The envelope's own `count` is checked against the events delivered and a mismatch is an
739    /// error rather than a short result โ€” that is the signal a silent page cap would produce if the
740    /// provider ever introduced one.
741    ///
742    /// # โš ๏ธ An unnarrowed query is a large request
743    /// That whole-corpus fetch took **6.3 seconds**, against this client's 30-second total request
744    /// timeout. It is the measured worst case rather than a typical one, but the margin is not
745    /// enormous: narrow the countries or the window for anything latency-sensitive.
746    ///
747    /// # Errors
748    /// Anything [`validate`](LseCalendarQuery::validate) raises, plus [`LseError`]'s transport and
749    /// decode variants. A response whose `count` disagrees with the events delivered is reported as
750    /// [`LseError::Api`] carrying the `200` the provider actually sent.
751    pub async fn fetch_economic_calendar(
752        &self,
753        stats: &LseCalendarStats,
754        query: &LseCalendarQuery,
755    ) -> Result<Vec<LseCalendarEvent>, LseError> {
756        let params = query.resolve(stats)?;
757
758        let envelope: LseCalendarEnvelope = self.get_json(ECONOMIC_CALENDAR_PATH, &params).await?;
759
760        if envelope.count != envelope.data.len() {
761            return Err(LseError::Api {
762                status: 200,
763                message: format!(
764                    "economic-calendar response declared {} events but carried {}: the response \
765                     appears to have been truncated",
766                    envelope.count,
767                    envelope.data.len()
768                ),
769            });
770        }
771
772        Ok(envelope.data)
773    }
774}
775
776#[cfg(test)]
777#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
778mod tests {
779    use super::*;
780    use rust_decimal_macros::dec;
781
782    /// A stats handle shaped like the live one: `UK` present, `GB` absent, `EA` and `EU` among the
783    /// codes, and the four-rating impact vocabulary including the literal `"None"`.
784    fn stats() -> LseCalendarStats {
785        LseCalendarStats {
786            countries: ["EA", "EU", "JP", "UK", "US"]
787                .into_iter()
788                .map(SmolStr::new)
789                .collect(),
790            impacts: ["High", "Low", "Medium", "None"]
791                .into_iter()
792                .map(SmolStr::new)
793                .collect(),
794            earliest: "2014-12-31 23:00:00+00:00".to_string(),
795            latest: "2026-03-24 20:00:00+00:00".to_string(),
796            total_events: 124_896,
797        }
798    }
799
800    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
801        NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
802    }
803
804    fn params(query: &LseCalendarQuery) -> Vec<(&'static str, String)> {
805        query.resolve(&stats()).expect("query should validate")
806    }
807
808    fn param<'a>(params: &'a [(&'static str, String)], key: &str) -> Option<&'a str> {
809        params
810            .iter()
811            .find(|(name, _)| *name == key)
812            .map(|(_, value)| value.as_str())
813    }
814
815    /// Every numeric arrives as a JSON string and absence is spelled `null`. The decoder uses
816    /// `rust_decimal::serde::str_option`; a JSON number would fail it outright.
817    #[test]
818    fn an_event_decodes_with_string_encoded_numerics_and_null_absences() {
819        let json = r#"{
820            "event_date": "2026-03-24 14:00:00+00:00",
821            "country": "US",
822            "event": "CPI (YoY)",
823            "currency": "USD",
824            "previous": "2.9",
825            "estimate": null,
826            "actual": "-3.1",
827            "change": "-0.2",
828            "change_percentage": "-6.9",
829            "impact": "High",
830            "unit": "%"
831        }"#;
832
833        let event: LseCalendarEvent = serde_json::from_str(json).unwrap();
834
835        assert_eq!(event.country, "US");
836        assert_eq!(event.event, "CPI (YoY)");
837        assert_eq!(event.currency.as_deref(), Some("USD"));
838        assert_eq!(event.previous, Some(dec!(2.9)));
839        assert_eq!(event.estimate, None);
840        // Negatives are abundant on this feed and must survive the decode.
841        assert_eq!(event.actual, Some(dec!(-3.1)));
842        assert_eq!(event.change, Some(dec!(-0.2)));
843        assert_eq!(event.change_percentage, Some(dec!(-6.9)));
844        assert_eq!(event.impact, LseCalendarImpact::High);
845        assert_eq!(event.unit.as_deref(), Some("%"));
846    }
847
848    /// ๐Ÿ”ด `"None"` is a literal four-character rating carried by 592 events, not a JSON null. A
849    /// decoder that folded it into an absent value would lose a real distinction.
850    #[test]
851    fn an_impact_of_none_is_a_rating_and_not_an_absent_value() {
852        let json = r#"{
853            "event_date": "2026-03-24 14:00:00+00:00",
854            "country": "JP", "event": "Bank Holiday", "currency": null,
855            "previous": null, "estimate": null, "actual": null,
856            "change": null, "change_percentage": null,
857            "impact": "None", "unit": null
858        }"#;
859
860        let event: LseCalendarEvent = serde_json::from_str(json).unwrap();
861
862        assert_eq!(event.impact, LseCalendarImpact::None);
863        assert_eq!(event.impact.as_str(), "None");
864        assert_eq!(event.impact.to_string(), "None");
865        // Nullable fields really are null on this feed -- never an empty string.
866        assert_eq!(event.currency, None);
867        assert_eq!(event.unit, None);
868    }
869
870    /// The set is closed at four. An addition must surface rather than be absorbed.
871    #[test]
872    fn an_unknown_impact_rating_fails_the_decode_rather_than_being_absorbed() {
873        let json = r#"{
874            "event_date": "2026-03-24 14:00:00+00:00",
875            "country": "US", "event": "x", "currency": null,
876            "previous": null, "estimate": null, "actual": null,
877            "change": null, "change_percentage": null,
878            "impact": "Critical", "unit": null
879        }"#;
880
881        assert!(serde_json::from_str::<LseCalendarEvent>(json).is_err());
882    }
883
884    #[test]
885    fn an_event_ignores_an_unknown_field() {
886        let json = r#"{
887            "event_date": "2026-03-24 14:00:00+00:00",
888            "country": "US", "event": "x", "currency": null,
889            "previous": null, "estimate": null, "actual": null,
890            "change": null, "change_percentage": null,
891            "impact": "Low", "unit": null,
892            "a_field_the_provider_added_later": 1
893        }"#;
894
895        assert!(serde_json::from_str::<LseCalendarEvent>(json).is_ok());
896    }
897
898    /// ๐Ÿ”ด The provider's space-separated spelling is a real timestamp with 348 distinct times of
899    /// day, and `DateTime::parse_from_rfc3339` accepts the space as RFC 3339 ยง5.6 lenience. This is
900    /// the claim that licenses `DateTime<Utc>` rather than the bond endpoint's `NaiveDate`.
901    #[test]
902    fn an_event_date_parses_as_an_instant_keeping_its_time_of_day() {
903        let event = LseCalendarEvent {
904            event_date: "2026-03-24 14:30:00+00:00".to_string(),
905            country: SmolStr::new("US"),
906            event: "x".to_string(),
907            currency: None,
908            previous: None,
909            estimate: None,
910            actual: None,
911            change: None,
912            change_percentage: None,
913            impact: LseCalendarImpact::Low,
914            unit: None,
915        };
916
917        let time = event.event_time().unwrap();
918
919        assert_eq!(time.to_rfc3339(), "2026-03-24T14:30:00+00:00");
920        assert_eq!(event.event_day().unwrap(), date(2026, 3, 24));
921    }
922
923    #[test]
924    fn a_malformed_event_date_is_a_typed_error_naming_the_field() {
925        let event = LseCalendarEvent {
926            event_date: "not-a-date".to_string(),
927            country: SmolStr::new("US"),
928            event: "x".to_string(),
929            currency: None,
930            previous: None,
931            estimate: None,
932            actual: None,
933            change: None,
934            change_percentage: None,
935            impact: LseCalendarImpact::Low,
936            unit: None,
937        };
938
939        let error = event.event_time().unwrap_err();
940
941        assert!(matches!(error, LseError::Deserialize { .. }));
942        assert!(error.to_string().contains("event_date"), "{error}");
943    }
944
945    #[test]
946    fn gb_normalises_to_the_providers_uk_and_everything_else_is_untouched() {
947        assert_eq!(LseCalendarStats::normalise_country("GB"), "UK");
948        assert_eq!(LseCalendarStats::normalise_country("gb"), "UK");
949        assert_eq!(LseCalendarStats::normalise_country("  Gb  "), "UK");
950        assert_eq!(LseCalendarStats::normalise_country("us"), "US");
951        // `EA` and `EU` are in this endpoint's vocabulary and are not countries; nothing maps them.
952        assert_eq!(LseCalendarStats::normalise_country("ea"), "EA");
953        assert_eq!(LseCalendarStats::normalise_country("eu"), "EU");
954    }
955
956    #[test]
957    fn a_country_lookup_finds_the_uk_under_its_iso_spelling() {
958        let stats = stats();
959
960        assert!(stats.publishes_country("GB"));
961        assert!(stats.publishes_country("gb"));
962        assert!(stats.publishes_country("UK"));
963        assert!(!stats.publishes_country("ZZ"));
964    }
965
966    #[test]
967    fn an_impact_resolves_ignoring_case_and_yields_the_providers_spelling() {
968        let stats = stats();
969
970        assert_eq!(stats.resolve_impact("high").unwrap(), "High");
971        assert_eq!(stats.resolve_impact("  HIGH  ").unwrap(), "High");
972        assert_eq!(stats.resolve_impact("None").unwrap(), "None");
973        assert!(stats.resolve_impact("Critical").is_none());
974    }
975
976    #[test]
977    fn a_valid_query_passes() {
978        let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_country("US");
979
980        assert!(query.validate(&stats()).is_ok());
981    }
982
983    #[test]
984    fn an_inverted_range_is_rejected() {
985        let query = LseCalendarQuery::new(date(2024, 12, 31), date(2024, 1, 1));
986
987        let error = query.validate(&stats()).unwrap_err();
988
989        assert!(matches!(error, LseError::InvalidInput { .. }));
990    }
991
992    #[test]
993    fn an_unknown_country_is_rejected_and_reports_both_spellings() {
994        let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_country("zz");
995
996        let error = query.validate(&stats()).unwrap_err();
997
998        let LseError::UnknownCalendarCountry {
999            requested,
1000            normalised,
1001        } = &error
1002        else {
1003            panic!("expected UnknownCalendarCountry, got {error:?}");
1004        };
1005        assert_eq!(requested, "zz");
1006        assert_eq!(normalised, "ZZ");
1007    }
1008
1009    /// One bad code among several must not be let through by a check that only looked at the first.
1010    #[test]
1011    fn an_unknown_country_anywhere_in_the_list_is_rejected() {
1012        let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31))
1013            .with_countries(["US", "UK", "ZZ"]);
1014
1015        assert!(matches!(
1016            query.validate(&stats()).unwrap_err(),
1017            LseError::UnknownCalendarCountry { .. }
1018        ));
1019    }
1020
1021    #[test]
1022    fn an_unknown_impact_is_rejected_and_lists_the_published_vocabulary() {
1023        let query =
1024            LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_impact("Critical");
1025
1026        let error = query.validate(&stats()).unwrap_err();
1027
1028        let LseError::UnknownCalendarImpact {
1029            requested,
1030            available,
1031        } = &error
1032        else {
1033            panic!("expected UnknownCalendarImpact, got {error:?}");
1034        };
1035        assert_eq!(requested, "Critical");
1036        assert_eq!(available, &["High", "Low", "Medium", "None"]);
1037    }
1038
1039    /// ๐Ÿ”ด The frozen feed: every forward-looking window is disjoint from the published range.
1040    #[test]
1041    fn a_window_after_the_frozen_latest_is_rejected() {
1042        let query = LseCalendarQuery::new(date(2026, 4, 1), date(2026, 12, 31));
1043
1044        let error = query.validate(&stats()).unwrap_err();
1045
1046        let LseError::CalendarRangeOutsideCoverage {
1047            start,
1048            end,
1049            earliest,
1050            latest,
1051        } = &error
1052        else {
1053            panic!("expected CalendarRangeOutsideCoverage, got {error:?}");
1054        };
1055        assert_eq!(*start, date(2026, 4, 1));
1056        assert_eq!(*end, date(2026, 12, 31));
1057        assert_eq!(*earliest, date(2014, 12, 31));
1058        assert_eq!(*latest, date(2026, 3, 24));
1059    }
1060
1061    #[test]
1062    fn a_window_before_the_earliest_event_is_rejected() {
1063        let query = LseCalendarQuery::new(date(2010, 1, 1), date(2014, 12, 30));
1064
1065        assert!(matches!(
1066            query.validate(&stats()).unwrap_err(),
1067            LseError::CalendarRangeOutsideCoverage { .. }
1068        ));
1069    }
1070
1071    /// A window straddling the frozen end returns what exists up to it, so it must pass.
1072    #[test]
1073    fn a_partially_overlapping_range_passes() {
1074        let query = LseCalendarQuery::new(date(2026, 3, 1), date(2026, 12, 31));
1075
1076        assert!(query.validate(&stats()).is_ok());
1077    }
1078
1079    /// ๐Ÿ”ด The validation the library deliberately does NOT do. `UK` holds 56 events, all in early
1080    /// 2026, so this window returns nothing โ€” and the provider publishes no per-country coverage,
1081    /// so it cannot be distinguished from a quiet period. It must validate, not error.
1082    #[test]
1083    fn a_published_country_with_no_events_in_the_window_still_validates() {
1084        let query = LseCalendarQuery::new(date(2020, 1, 1), date(2020, 12, 31)).with_country("UK");
1085
1086        assert!(
1087            query.validate(&stats()).is_ok(),
1088            "an empty-but-legitimate window must not be an error: the provider publishes no \
1089             per-country coverage to justify one"
1090        );
1091    }
1092
1093    /// ๐Ÿ”ด The trap. A repeated `country` key silently keeps only the last value, so the countries
1094    /// must reach the wire as ONE comma-joined parameter.
1095    #[test]
1096    fn several_countries_are_sent_as_one_comma_joined_parameter() {
1097        let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31))
1098            .with_countries(["US", "UK"]);
1099        let params = params(&query);
1100
1101        assert_eq!(
1102            params.iter().filter(|(name, _)| *name == "country").count(),
1103            1,
1104            "the country key must never be repeated: the provider keeps only the last value"
1105        );
1106        assert_eq!(param(&params, "country"), Some("US,UK"));
1107    }
1108
1109    /// The normalisation has to reach the wire, not merely the membership check.
1110    #[test]
1111    fn the_request_carries_the_normalised_country_codes() {
1112        let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31))
1113            .with_countries(["gb", "us"]);
1114
1115        assert_eq!(param(&params(&query), "country"), Some("UK,US"));
1116    }
1117
1118    #[test]
1119    fn the_request_carries_the_providers_own_impact_spelling() {
1120        let query =
1121            LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31)).with_impact("medium");
1122
1123        assert_eq!(param(&params(&query), "impact"), Some("Medium"));
1124    }
1125
1126    /// Absent filters are omitted rather than sent empty: this endpoint answers an unknown filter
1127    /// with zero rows rather than an error, so an empty value would be a silent empty result.
1128    #[test]
1129    fn an_unfiltered_query_omits_country_and_impact_entirely() {
1130        let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31));
1131        let params = params(&query);
1132
1133        assert_eq!(param(&params, "country"), None);
1134        assert_eq!(param(&params, "impact"), None);
1135        assert_eq!(param(&params, "start_date"), Some("2024-01-01"));
1136        assert_eq!(param(&params, "end_date"), Some("2024-12-31"));
1137    }
1138
1139    /// โš ๏ธ Omitting `format` serves CSV, which would hand a CSV body to a JSON decoder.
1140    #[test]
1141    fn every_request_asks_for_json_because_the_endpoint_defaults_to_csv() {
1142        let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31));
1143
1144        assert_eq!(param(&params(&query), "format"), Some("json"));
1145    }
1146
1147    #[test]
1148    fn stats_decode_with_their_integer_typed_total() {
1149        let json = r#"{
1150            "countries": ["EA", "UK", "US"],
1151            "impacts": ["High", "Low", "Medium", "None"],
1152            "earliest": "2014-12-31 23:00:00+00:00",
1153            "latest": "2026-03-24 20:00:00+00:00",
1154            "total_events": 124896
1155        }"#;
1156
1157        let stats: LseCalendarStats = serde_json::from_str(json).unwrap();
1158
1159        assert_eq!(stats.total_events, 124_896);
1160        assert_eq!(
1161            stats.earliest_time().unwrap(),
1162            date(2014, 12, 31).and_hms_opt(23, 0, 0).unwrap().and_utc()
1163        );
1164        assert_eq!(
1165            stats.latest_time().unwrap(),
1166            date(2026, 3, 24).and_hms_opt(20, 0, 0).unwrap().and_utc()
1167        );
1168        assert_eq!(stats.earliest_date().unwrap(), date(2014, 12, 31));
1169        assert_eq!(stats.latest_date().unwrap(), date(2026, 3, 24));
1170    }
1171
1172    /// The spelling the provider served before it switched to timestamps. Rejected rather than
1173    /// read as midnight: one grammar for every calendar instant, and a switch back surfaces here and
1174    /// in the live canary instead of being absorbed.
1175    #[test]
1176    fn a_bare_date_stats_bound_is_rejected() {
1177        let mut stats = stats();
1178        stats.earliest = "2014-12-31".to_string();
1179
1180        let error = stats.earliest_date().unwrap_err();
1181
1182        assert!(matches!(error, LseError::Deserialize { .. }));
1183        assert!(error.to_string().contains("earliest"), "{error}");
1184    }
1185
1186    #[test]
1187    fn a_malformed_stats_date_fails_validation_rather_than_being_ignored() {
1188        let mut stats = stats();
1189        stats.latest = "24/03/2026".to_string();
1190
1191        let query = LseCalendarQuery::new(date(2024, 1, 1), date(2024, 12, 31));
1192        let error = query.validate(&stats).unwrap_err();
1193
1194        assert!(matches!(error, LseError::Deserialize { .. }));
1195        assert!(error.to_string().contains("latest"), "{error}");
1196    }
1197}