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
125pub(crate) fn format_date(date: GieDate) -> String {
126    #[cfg(feature = "chrono")]
127    {
128        date.format("%Y-%m-%d").to_string()
129    }
130    #[cfg(not(feature = "chrono"))]
131    {
132        YmdDate(date).to_string()
133    }
134}
135
136pub(crate) fn parse_date(value: &str) -> Result<GieDate, String> {
137    let trimmed = value.trim();
138    if !trimmed.is_ascii() {
139        return Err("invalid date format, expected ASCII YYYY-MM-DD".to_string());
140    }
141
142    let bytes = trimmed.as_bytes();
143    if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
144        return Err("invalid date format, expected YYYY-MM-DD".to_string());
145    }
146
147    #[cfg(feature = "chrono")]
148    {
149        NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
150            .map_err(|error| format!("invalid calendar date: {error}"))
151    }
152    #[cfg(not(feature = "chrono"))]
153    {
154        let year = parse_year_component(&trimmed[0..4])?;
155        let month = parse_month_component(&trimmed[5..7])?;
156        let day = parse_day_component(&trimmed[8..10])?;
157
158        Date::from_calendar_date(year, month, day)
159            .map_err(|error| format!("invalid calendar date: {error}"))
160    }
161}
162
163pub(crate) fn parse_dataset_type(value: &str) -> Result<DatasetType, String> {
164    let trimmed = value.trim();
165
166    if trimmed.eq_ignore_ascii_case("eu") {
167        Ok(DatasetType::Eu)
168    } else if trimmed.eq_ignore_ascii_case("ne") {
169        Ok(DatasetType::Ne)
170    } else if trimmed.eq_ignore_ascii_case("ai") {
171        Ok(DatasetType::Ai)
172    } else {
173        Err(format!(
174            "invalid dataset type, expected one of: eu, ne, ai (got {trimmed:?})"
175        ))
176    }
177}
178
179pub(crate) fn parse_dataset_name(value: &str) -> DatasetName {
180    let trimmed = value.trim();
181
182    if trimmed.eq_ignore_ascii_case("storage") {
183        DatasetName::Storage
184    } else if trimmed.eq_ignore_ascii_case("lng") {
185        DatasetName::Lng
186    } else {
187        DatasetName::Unknown(trimmed.to_string())
188    }
189}
190
191pub(crate) fn parse_record_type(value: &str) -> RecordType {
192    let trimmed = value.trim();
193
194    if trimmed.eq_ignore_ascii_case("country") {
195        RecordType::Country
196    } else if trimmed.eq_ignore_ascii_case("company") {
197        RecordType::Company
198    } else if trimmed.eq_ignore_ascii_case("facility") {
199        RecordType::Facility
200    } else {
201        RecordType::Unknown(trimmed.to_string())
202    }
203}
204
205#[cfg(not(feature = "chrono"))]
206fn parse_year_component(value: &str) -> Result<i32, String> {
207    value
208        .parse::<i32>()
209        .map_err(|error| format!("invalid year component: {error}"))
210}
211
212#[cfg(not(feature = "chrono"))]
213fn parse_month_component(value: &str) -> Result<Month, String> {
214    let month = value
215        .parse::<u8>()
216        .map_err(|error| format!("invalid month component: {error}"))?;
217
218    Month::try_from(month).map_err(|_| format!("month component is out of range: {month}"))
219}
220
221#[cfg(not(feature = "chrono"))]
222fn parse_day_component(value: &str) -> Result<u8, String> {
223    value
224        .parse::<u8>()
225        .map_err(|error| format!("invalid day component: {error}"))
226}
227
228#[cfg(not(feature = "chrono"))]
229struct YmdDate(Date);
230
231#[cfg(not(feature = "chrono"))]
232impl fmt::Display for YmdDate {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        write!(
235            f,
236            "{:04}-{:02}-{:02}",
237            self.0.year(),
238            u8::from(self.0.month()),
239            self.0.day()
240        )
241    }
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}