Skip to main content

gie_client/common/
query.rs

1use std::num::NonZeroU32;
2use std::ops::Deref;
3
4use serde::Serialize;
5
6use crate::error::GieError;
7
8use super::date_range::DateRange;
9use super::serde_ext::{serialize_optional_dataset_type, serialize_optional_date};
10use super::types::{DatasetType, DateFilter, GieDate, format_date, parse_dataset_type, parse_date};
11
12/// Validated non-empty text filter used by query fields like `country`, `company`, and `facility`.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct QueryText(String);
15
16impl QueryText {
17    /// Creates a validated filter value.
18    ///
19    /// Leading/trailing whitespace is trimmed.
20    /// Returns an error if the resulting value is empty.
21    pub fn try_new(value: impl Into<String>) -> Result<Self, GieError> {
22        parse_required_text_filter("value", value.into())
23    }
24
25    /// Returns the normalized string slice.
26    pub fn as_str(&self) -> &str {
27        &self.0
28    }
29
30    fn parse_lossy(value: String) -> Option<Self> {
31        let trimmed = value.trim();
32        if trimmed.is_empty() {
33            return None;
34        }
35        if trimmed.len() == value.len() {
36            return Some(Self(value));
37        }
38        Some(Self(trimmed.to_string()))
39    }
40}
41
42impl AsRef<str> for QueryText {
43    fn as_ref(&self) -> &str {
44        self.as_str()
45    }
46}
47
48impl Deref for QueryText {
49    type Target = str;
50
51    fn deref(&self) -> &Self::Target {
52        self.as_str()
53    }
54}
55
56/// Query builder shared by AGSI and ALSI endpoints.
57#[must_use = "query builders are immutable; use the returned value"]
58#[derive(Debug, Clone, Default)]
59pub struct GieQuery {
60    country: Option<QueryText>,
61    company: Option<QueryText>,
62    facility: Option<QueryText>,
63    dataset_type: Option<DatasetType>,
64    date_filter: Option<DateFilter>,
65    page: Option<NonZeroU32>,
66    size: Option<NonZeroU32>,
67}
68
69impl GieQuery {
70    /// Creates an empty query.
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Sets `country`.
76    ///
77    /// Leading/trailing whitespace is trimmed.
78    /// Empty values clear the filter.
79    pub fn country(mut self, country: impl Into<String>) -> Self {
80        self.country = QueryText::parse_lossy(country.into());
81        self
82    }
83
84    /// Parses and sets `country` as non-empty text.
85    pub fn try_country(mut self, country: impl Into<String>) -> Result<Self, GieError> {
86        self.country = Some(parse_required_text_filter("country", country.into())?);
87        Ok(self)
88    }
89
90    /// Sets `company`.
91    ///
92    /// Leading/trailing whitespace is trimmed.
93    /// Empty values clear the filter.
94    pub fn company(mut self, company: impl Into<String>) -> Self {
95        self.company = QueryText::parse_lossy(company.into());
96        self
97    }
98
99    /// Parses and sets `company` as non-empty text.
100    pub fn try_company(mut self, company: impl Into<String>) -> Result<Self, GieError> {
101        self.company = Some(parse_required_text_filter("company", company.into())?);
102        Ok(self)
103    }
104
105    /// Sets `facility`.
106    ///
107    /// Leading/trailing whitespace is trimmed.
108    /// Empty values clear the filter.
109    pub fn facility(mut self, facility: impl Into<String>) -> Self {
110        self.facility = QueryText::parse_lossy(facility.into());
111        self
112    }
113
114    /// Parses and sets `facility` as non-empty text.
115    pub fn try_facility(mut self, facility: impl Into<String>) -> Result<Self, GieError> {
116        self.facility = Some(parse_required_text_filter("facility", facility.into())?);
117        Ok(self)
118    }
119
120    /// Sets dataset `type`.
121    pub fn dataset_type(mut self, dataset_type: DatasetType) -> Self {
122        self.dataset_type = Some(dataset_type);
123        self
124    }
125
126    /// Parses and sets dataset `type` from string (`eu`, `ne`, `ai`).
127    pub fn try_dataset_type(mut self, dataset_type: impl AsRef<str>) -> Result<Self, GieError> {
128        self.dataset_type = Some(
129            parse_dataset_type(dataset_type.as_ref()).map_err(GieError::InvalidDatasetTypeInput)?,
130        );
131        Ok(self)
132    }
133
134    /// Clears dataset `type`.
135    pub fn without_dataset_type(mut self) -> Self {
136        self.dataset_type = None;
137        self
138    }
139
140    /// Sets the single-day `date` filter.
141    pub fn date(mut self, date: GieDate) -> Self {
142        self.date_filter = Some(DateFilter::Day(date));
143        self
144    }
145
146    /// Parses and sets the single-day `date` filter from `YYYY-MM-DD`.
147    pub fn try_date(mut self, date: impl AsRef<str>) -> Result<Self, GieError> {
148        self.date_filter = Some(DateFilter::Day(
149            parse_date(date.as_ref()).map_err(GieError::InvalidDateInput)?,
150        ));
151        Ok(self)
152    }
153
154    /// Sets `from` and `to` range.
155    pub fn range(mut self, from: GieDate, to: GieDate) -> Result<Self, GieError> {
156        let range = DateRange::new(from, to)?;
157        self.date_filter = Some(DateFilter::Range(range));
158        Ok(self)
159    }
160
161    /// Parses and sets `from` and `to` from `YYYY-MM-DD`.
162    pub fn try_range(self, from: impl AsRef<str>, to: impl AsRef<str>) -> Result<Self, GieError> {
163        let from = parse_date(from.as_ref()).map_err(GieError::InvalidDateInput)?;
164        let to = parse_date(to.as_ref()).map_err(GieError::InvalidDateInput)?;
165        self.range(from, to)
166    }
167
168    /// Sets the page number.
169    pub fn page(mut self, page: NonZeroU32) -> Self {
170        self.page = Some(page);
171        self
172    }
173
174    /// Parses and sets the page number.
175    pub fn try_page(mut self, page: u32) -> Result<Self, GieError> {
176        self.page = Some(NonZeroU32::new(page).ok_or_else(|| {
177            GieError::InvalidPageInput("page must be greater than zero".to_string())
178        })?);
179        Ok(self)
180    }
181
182    /// Sets requested page size.
183    pub fn size(mut self, size: NonZeroU32) -> Self {
184        self.size = Some(size);
185        self
186    }
187
188    /// Parses and sets requested page size.
189    pub fn try_size(mut self, size: u32) -> Result<Self, GieError> {
190        self.size = Some(NonZeroU32::new(size).ok_or_else(|| {
191            GieError::InvalidSizeInput("size must be greater than zero".to_string())
192        })?);
193        Ok(self)
194    }
195
196    pub(crate) fn initial_page(&self) -> NonZeroU32 {
197        self.page.unwrap_or_else(default_page)
198    }
199
200    pub(crate) fn as_params_with_page(
201        &self,
202        page_override: Option<NonZeroU32>,
203    ) -> GieQueryParams<'_> {
204        let (date, from, to) = match self.date_filter {
205            Some(DateFilter::Day(value)) => (Some(value), None, None),
206            Some(DateFilter::Range(value)) => (None, Some(value.from()), Some(value.to())),
207            None => (None, None, None),
208        };
209
210        GieQueryParams {
211            country: self.country.as_deref(),
212            company: self.company.as_deref(),
213            facility: self.facility.as_deref(),
214            dataset_type: self.dataset_type,
215            date,
216            from,
217            to,
218            page: page_override.or(self.page),
219            size: self.size,
220        }
221    }
222
223    pub(crate) fn visit_debug_pairs(
224        &self,
225        page_override: Option<NonZeroU32>,
226        mut visit: impl FnMut(&'static str, &str),
227    ) {
228        if let Some(value) = self.country.as_deref() {
229            visit("country", value);
230        }
231        if let Some(value) = self.company.as_deref() {
232            visit("company", value);
233        }
234        if let Some(value) = self.facility.as_deref() {
235            visit("facility", value);
236        }
237        if let Some(value) = self.dataset_type {
238            visit("type", value.as_str());
239        }
240
241        match self.date_filter {
242            Some(DateFilter::Day(value)) => {
243                let formatted_date = format_date(value);
244                visit("date", &formatted_date);
245            }
246            Some(DateFilter::Range(value)) => {
247                let from = format_date(value.from());
248                let to = format_date(value.to());
249                visit("from", &from);
250                visit("to", &to);
251            }
252            None => {}
253        }
254
255        if let Some(value) = page_override.or(self.page) {
256            let page = value.to_string();
257            visit("page", &page);
258        }
259        if let Some(value) = self.size {
260            let size = value.to_string();
261            visit("size", &size);
262        }
263    }
264}
265
266fn default_page() -> NonZeroU32 {
267    NonZeroU32::new(1).expect("1 is non-zero")
268}
269
270fn parse_required_text_filter(field_name: &str, value: String) -> Result<QueryText, GieError> {
271    let trimmed = value.trim();
272    if trimmed.is_empty() {
273        return Err(GieError::InvalidTextFilterInput(format!(
274            "{field_name} must not be blank"
275        )));
276    }
277    if trimmed.len() == value.len() {
278        return Ok(QueryText(value));
279    }
280    Ok(QueryText(trimmed.to_string()))
281}
282
283#[derive(Debug, Serialize)]
284pub(crate) struct GieQueryParams<'a> {
285    #[serde(skip_serializing_if = "Option::is_none")]
286    country: Option<&'a str>,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    company: Option<&'a str>,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    facility: Option<&'a str>,
291    #[serde(
292        rename = "type",
293        skip_serializing_if = "Option::is_none",
294        serialize_with = "serialize_optional_dataset_type"
295    )]
296    dataset_type: Option<DatasetType>,
297    #[serde(
298        skip_serializing_if = "Option::is_none",
299        serialize_with = "serialize_optional_date"
300    )]
301    date: Option<GieDate>,
302    #[serde(
303        skip_serializing_if = "Option::is_none",
304        serialize_with = "serialize_optional_date"
305    )]
306    from: Option<GieDate>,
307    #[serde(
308        skip_serializing_if = "Option::is_none",
309        serialize_with = "serialize_optional_date"
310    )]
311    to: Option<GieDate>,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    page: Option<NonZeroU32>,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    size: Option<NonZeroU32>,
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    fn collect_debug_pairs(
323        query: &GieQuery,
324        page_override: Option<NonZeroU32>,
325    ) -> Vec<(&'static str, String)> {
326        let mut pairs = Vec::new();
327        query.visit_debug_pairs(page_override, |key, value| {
328            pairs.push((key, value.to_string()));
329        });
330        pairs
331    }
332
333    fn test_date(value: &str) -> GieDate {
334        parse_date(value).unwrap()
335    }
336
337    #[test]
338    fn query_params_are_mapped_to_expected_keys() {
339        let query = GieQuery::new()
340            .country("DE")
341            .company("Comp")
342            .facility("Fac")
343            .dataset_type(DatasetType::Eu)
344            .range(test_date("2026-03-01"), test_date("2026-03-10"))
345            .unwrap()
346            .try_page(2)
347            .unwrap()
348            .try_size(50)
349            .unwrap();
350
351        let pairs = collect_debug_pairs(&query, None);
352        assert!(pairs.contains(&("country", "DE".to_string())));
353        assert!(pairs.contains(&("company", "Comp".to_string())));
354        assert!(pairs.contains(&("facility", "Fac".to_string())));
355        assert!(pairs.contains(&("type", "eu".to_string())));
356        assert!(pairs.contains(&("from", "2026-03-01".to_string())));
357        assert!(pairs.contains(&("to", "2026-03-10".to_string())));
358        assert!(pairs.contains(&("page", "2".to_string())));
359        assert!(pairs.contains(&("size", "50".to_string())));
360        assert!(!pairs.iter().any(|(key, _)| *key == "date"));
361    }
362
363    #[test]
364    fn date_builder_replaces_range_filter() {
365        let query = GieQuery::new()
366            .range(test_date("2026-03-01"), test_date("2026-03-10"))
367            .unwrap()
368            .date(test_date("2026-03-10"));
369
370        let pairs = collect_debug_pairs(&query, None);
371        assert!(pairs.contains(&("date", "2026-03-10".to_string())));
372        assert!(!pairs.iter().any(|(key, _)| *key == "from"));
373        assert!(!pairs.iter().any(|(key, _)| *key == "to"));
374    }
375
376    #[test]
377    fn try_range_rejects_invalid_order() {
378        let error = GieQuery::new()
379            .try_range("2026-03-10", "2026-03-01")
380            .unwrap_err();
381
382        assert!(matches!(error, GieError::InvalidDateRangeInput(_)));
383    }
384
385    #[test]
386    fn try_date_rejects_invalid_input() {
387        let error = GieQuery::new().try_date("2026/03/10").unwrap_err();
388        assert!(matches!(error, GieError::InvalidDateInput(_)));
389    }
390
391    #[test]
392    fn try_dataset_type_parses_supported_values() {
393        let query = GieQuery::new().try_dataset_type("NE").unwrap();
394        let pairs = collect_debug_pairs(&query, None);
395
396        assert!(pairs.contains(&("type", "ne".to_string())));
397    }
398
399    #[test]
400    fn try_dataset_type_rejects_invalid_input() {
401        let error = GieQuery::new().try_dataset_type("country").unwrap_err();
402        assert!(matches!(error, GieError::InvalidDatasetTypeInput(_)));
403    }
404
405    #[test]
406    fn try_page_and_try_size_reject_zero() {
407        assert!(matches!(
408            GieQuery::new().try_page(0).unwrap_err(),
409            GieError::InvalidPageInput(_)
410        ));
411        assert!(matches!(
412            GieQuery::new().try_size(0).unwrap_err(),
413            GieError::InvalidSizeInput(_)
414        ));
415    }
416
417    #[test]
418    fn initial_page_defaults_to_one() {
419        assert_eq!(GieQuery::new().initial_page().get(), 1);
420    }
421
422    #[test]
423    fn page_override_wins_in_debug_pairs() {
424        let query = GieQuery::new().try_page(2).unwrap();
425        let override_page = NonZeroU32::new(7).unwrap();
426        let pairs = collect_debug_pairs(&query, Some(override_page));
427
428        assert!(pairs.contains(&("page", "7".to_string())));
429        assert!(!pairs.contains(&("page", "2".to_string())));
430    }
431
432    #[test]
433    fn text_filters_are_trimmed_and_blank_values_are_dropped() {
434        let query = GieQuery::new()
435            .country(" DE ")
436            .company("   ")
437            .facility(" Site ");
438        let pairs = collect_debug_pairs(&query, None);
439
440        assert!(pairs.contains(&("country", "DE".to_string())));
441        assert!(pairs.contains(&("facility", "Site".to_string())));
442        assert!(!pairs.iter().any(|(key, _)| *key == "company"));
443    }
444
445    #[test]
446    fn try_text_filters_reject_blank_values() {
447        assert!(matches!(
448            GieQuery::new().try_country(" ").unwrap_err(),
449            GieError::InvalidTextFilterInput(_)
450        ));
451        assert!(matches!(
452            GieQuery::new().try_company(" ").unwrap_err(),
453            GieError::InvalidTextFilterInput(_)
454        ));
455        assert!(matches!(
456            GieQuery::new().try_facility(" ").unwrap_err(),
457            GieError::InvalidTextFilterInput(_)
458        ));
459    }
460
461    #[test]
462    fn query_text_is_trimmed_on_construction() {
463        let value = QueryText::try_new("  DE  ").unwrap();
464        assert_eq!(value.as_str(), "DE");
465    }
466}