Skip to main content

gie_client/common/
types.rs

1use std::fmt;
2
3#[cfg(feature = "chrono")]
4use chrono::NaiveDate;
5#[cfg(not(feature = "chrono"))]
6use time::{Date, Month};
7
8use super::date_range::DateRange;
9
10/// Unified date type used by the public API.
11#[cfg(feature = "chrono")]
12pub type GieDate = NaiveDate;
13/// Unified date type used by the public API.
14#[cfg(not(feature = "chrono"))]
15pub type GieDate = Date;
16
17/// Dataset scope accepted by the `type` query parameter on facility reports.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum DatasetType {
20    /// Europe aggregate.
21    Eu,
22    /// Non-EU aggregate.
23    Ne,
24    /// Additional information aggregate.
25    Ai,
26}
27
28impl DatasetType {
29    pub const fn as_str(self) -> &'static str {
30        match self {
31            Self::Eu => "eu",
32            Self::Ne => "ne",
33            Self::Ai => "ai",
34        }
35    }
36}
37
38impl fmt::Display for DatasetType {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str(self.as_str())
41    }
42}
43
44/// Dataset name returned by API response envelope.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum DatasetName {
47    /// Underground gas storage dataset.
48    Storage,
49    /// LNG terminals dataset.
50    Lng,
51    /// Any future/unknown dataset name.
52    Unknown(String),
53}
54
55impl DatasetName {
56    pub fn as_str(&self) -> &str {
57        match self {
58            Self::Storage => "storage",
59            Self::Lng => "lng",
60            Self::Unknown(value) => value.as_str(),
61        }
62    }
63}
64
65impl fmt::Display for DatasetName {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71/// Entity level returned by API record `type` field.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum RecordType {
74    /// Country-level aggregate.
75    Country,
76    /// Company-level aggregate.
77    Company,
78    /// Facility-level aggregate.
79    Facility,
80    /// Any future/unknown record type.
81    Unknown(String),
82}
83
84impl RecordType {
85    pub fn as_str(&self) -> &str {
86        match self {
87            Self::Country => "country",
88            Self::Company => "company",
89            Self::Facility => "facility",
90            Self::Unknown(value) => value.as_str(),
91        }
92    }
93}
94
95impl fmt::Display for RecordType {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// Date filter accepted by GIE API.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum DateFilter {
104    /// Exact gas day.
105    Day(GieDate),
106    /// Inclusive gas day range.
107    Range(DateRange),
108}
109
110/// Decoded paginated API response.
111#[derive(Debug, Clone)]
112pub struct GiePage<T> {
113    /// Last page number reported by the API.
114    pub last_page: u32,
115    /// Total item count reported by the API.
116    pub total: u32,
117    /// Dataset descriptor from the response envelope.
118    pub dataset: Option<DatasetName>,
119    /// Gas day from the response envelope.
120    pub gas_day: Option<GieDate>,
121    /// Decoded page payload.
122    pub data: Vec<T>,
123}
124
125#[cfg(not(feature = "chrono"))]
126struct YmdDate(Date);
127
128#[cfg(not(feature = "chrono"))]
129impl fmt::Display for YmdDate {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(
132            f,
133            "{:04}-{:02}-{:02}",
134            self.0.year(),
135            u8::from(self.0.month()),
136            self.0.day()
137        )
138    }
139}
140
141pub(crate) fn format_date(date: GieDate) -> String {
142    #[cfg(feature = "chrono")]
143    {
144        date.format("%Y-%m-%d").to_string()
145    }
146    #[cfg(not(feature = "chrono"))]
147    {
148        YmdDate(date).to_string()
149    }
150}
151
152pub(crate) fn parse_date(value: &str) -> Result<GieDate, String> {
153    let trimmed = value.trim();
154    if !trimmed.is_ascii() {
155        return Err("invalid date format, expected ASCII YYYY-MM-DD".to_string());
156    }
157
158    let bytes = trimmed.as_bytes();
159    if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
160        return Err("invalid date format, expected YYYY-MM-DD".to_string());
161    }
162
163    #[cfg(feature = "chrono")]
164    {
165        NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
166            .map_err(|error| format!("invalid calendar date: {error}"))
167    }
168    #[cfg(not(feature = "chrono"))]
169    {
170        let year = parse_year_component(&trimmed[0..4])?;
171        let month = parse_month_component(&trimmed[5..7])?;
172        let day = parse_day_component(&trimmed[8..10])?;
173
174        Date::from_calendar_date(year, month, day)
175            .map_err(|error| format!("invalid calendar date: {error}"))
176    }
177}
178
179pub(crate) fn parse_dataset_type(value: &str) -> Result<DatasetType, String> {
180    let trimmed = value.trim();
181
182    if trimmed.eq_ignore_ascii_case("eu") {
183        Ok(DatasetType::Eu)
184    } else if trimmed.eq_ignore_ascii_case("ne") {
185        Ok(DatasetType::Ne)
186    } else if trimmed.eq_ignore_ascii_case("ai") {
187        Ok(DatasetType::Ai)
188    } else {
189        Err(format!(
190            "invalid dataset type, expected one of: eu, ne, ai (got {trimmed:?})"
191        ))
192    }
193}
194
195pub(crate) fn parse_dataset_name(value: &str) -> DatasetName {
196    let trimmed = value.trim();
197
198    if trimmed.eq_ignore_ascii_case("storage") {
199        DatasetName::Storage
200    } else if trimmed.eq_ignore_ascii_case("lng") {
201        DatasetName::Lng
202    } else {
203        DatasetName::Unknown(trimmed.to_string())
204    }
205}
206
207pub(crate) fn parse_record_type(value: &str) -> RecordType {
208    let trimmed = value.trim();
209
210    if trimmed.eq_ignore_ascii_case("country") {
211        RecordType::Country
212    } else if trimmed.eq_ignore_ascii_case("company") {
213        RecordType::Company
214    } else if trimmed.eq_ignore_ascii_case("facility") {
215        RecordType::Facility
216    } else {
217        RecordType::Unknown(trimmed.to_string())
218    }
219}
220
221#[cfg(not(feature = "chrono"))]
222fn parse_year_component(value: &str) -> Result<i32, String> {
223    value
224        .parse::<i32>()
225        .map_err(|error| format!("invalid year component: {error}"))
226}
227
228#[cfg(not(feature = "chrono"))]
229fn parse_month_component(value: &str) -> Result<Month, String> {
230    let month = value
231        .parse::<u8>()
232        .map_err(|error| format!("invalid month component: {error}"))?;
233
234    Month::try_from(month).map_err(|_| format!("month component is out of range: {month}"))
235}
236
237#[cfg(not(feature = "chrono"))]
238fn parse_day_component(value: &str) -> Result<u8, String> {
239    value
240        .parse::<u8>()
241        .map_err(|error| format!("invalid day component: {error}"))
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn record_type_parser_is_case_insensitive_and_keeps_unknown_values() {
250        assert_eq!(parse_record_type(" country "), RecordType::Country);
251        assert_eq!(parse_record_type("CoMpAnY"), RecordType::Company);
252        assert_eq!(parse_record_type("FACILITY"), RecordType::Facility);
253        assert_eq!(
254            parse_record_type("pipeline"),
255            RecordType::Unknown("pipeline".to_string())
256        );
257    }
258
259    #[test]
260    fn dataset_name_parser_is_case_insensitive_and_keeps_unknown_values() {
261        assert_eq!(parse_dataset_name(" storage "), DatasetName::Storage);
262        assert_eq!(parse_dataset_name("LNG"), DatasetName::Lng);
263        assert_eq!(
264            parse_dataset_name("storage ERROR"),
265            DatasetName::Unknown("storage ERROR".to_string())
266        );
267    }
268}