sppd_cli/downloader/
period_filter.rs

1use crate::errors::{AppError, AppResult};
2use std::collections::BTreeMap;
3
4/// Validates that a period string matches the expected format (YYYY or YYYYMM).
5///
6/// Checks that the period contains only ASCII digits and has exactly 4 digits (YYYY) or 6 digits (YYYYMM).
7///
8/// Returns `Ok(())` if valid, or `InvalidInput` error otherwise.
9pub fn validate_period_format(period: &str) -> AppResult<()> {
10    if period.is_empty() {
11        return Err(AppError::InvalidInput(
12            "Period must be YYYY or YYYYMM format (4 or 6 digits), got empty string".to_string(),
13        ));
14    }
15    if !period.chars().all(|c| c.is_ascii_digit()) {
16        return Err(AppError::InvalidInput(format!(
17            "Period must contain only digits, got: {period}"
18        )));
19    }
20    match period.len() {
21        4 | 6 => Ok(()),
22        _ => Err(AppError::InvalidInput(format!(
23            "Period must be YYYY or YYYYMM format (4 or 6 digits), got: {} ({} digits)",
24            period,
25            period.len()
26        ))),
27    }
28}
29
30/// Filters links by period range, validating that specified periods exist.
31///
32/// This function filters a map of period-to-URL links based on a start and/or end period.
33/// Periods are compared correctly, handling both YYYY and YYYYMM formats. The range is inclusive
34/// on both ends.
35///
36/// # Arguments
37///
38/// * `links` - Map of period strings to URLs to filter
39/// * `start_period` - Optional start period (inclusive). If `None`, no lower bound.
40/// * `end_period` - Optional end period (inclusive). If `None`, no upper bound.
41///
42/// # Returns
43///
44/// A filtered map containing only periods within the specified range.
45///
46/// # Errors
47///
48/// Returns `InvalidInput` if `start_period` or `end_period` has an invalid format
49/// (not YYYY or YYYYMM). Returns `PeriodValidationError` if the period format is valid
50/// but doesn't exist in the `links` map.
51///
52pub fn filter_periods_by_range(
53    links: &BTreeMap<String, String>,
54    start_period: Option<&str>,
55    end_period: Option<&str>,
56) -> AppResult<BTreeMap<String, String>> {
57    let available_str = links.keys().cloned().collect::<Vec<_>>().join(", ");
58
59    for period in [start_period, end_period].into_iter().flatten() {
60        validate_period_format(period)?;
61        if !links.contains_key(period) {
62            return Err(AppError::PeriodValidationError {
63                period: period.to_string(),
64                available: available_str.clone(),
65            });
66        }
67    }
68
69    let start_key = start_period.map(|s| s.to_string());
70    let end_key = end_period.map(|e| e.to_string());
71
72    if let (Some(start), Some(end)) = (&start_key, &end_key) {
73        if start > end {
74            return Err(AppError::InvalidInput(format!(
75                "Start period '{start}' must be less than or equal to end period '{end}'"
76            )));
77        }
78    }
79
80    let range_iter = match (&start_key, &end_key) {
81        (Some(start), Some(end)) => links.range(start.clone()..=end.clone()),
82        (Some(start), None) => links.range(start.clone()..),
83        (None, Some(end)) => links.range(..=end.clone()),
84        (None, None) => links.range::<String, _>(..),
85    };
86
87    let filtered = range_iter
88        .filter(|(period, _)| validate_period_format(period).is_ok())
89        .map(|(k, v)| (k.clone(), v.clone()))
90        .collect();
91
92    Ok(filtered)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::{filter_periods_by_range, validate_period_format};
98    use crate::errors::AppError;
99    use std::collections::BTreeMap;
100
101    fn create_test_links() -> BTreeMap<String, String> {
102        let mut links = BTreeMap::new();
103        links.insert(
104            "202301".to_string(),
105            "https://example.com/202301.zip".to_string(),
106        );
107        links.insert(
108            "202302".to_string(),
109            "https://example.com/202302.zip".to_string(),
110        );
111        links.insert(
112            "202303".to_string(),
113            "https://example.com/202303.zip".to_string(),
114        );
115        links.insert(
116            "202304".to_string(),
117            "https://example.com/202304.zip".to_string(),
118        );
119        links.insert(
120            "202305".to_string(),
121            "https://example.com/202305.zip".to_string(),
122        );
123        links
124    }
125
126    #[test]
127    fn test_filter_all_periods_no_constraints() {
128        let links = create_test_links();
129        let result = filter_periods_by_range(&links, None, None);
130
131        assert!(result.is_ok());
132        let filtered = result.unwrap();
133        assert_eq!(filtered.len(), 5);
134    }
135
136    #[test]
137    fn test_filter_with_start_period_only() {
138        let links = create_test_links();
139        let result = filter_periods_by_range(&links, Some("202303"), None);
140
141        assert!(result.is_ok());
142        let filtered = result.unwrap();
143        assert_eq!(filtered.len(), 3); // 202303, 202304, 202305
144        assert!(filtered.contains_key("202303"));
145        assert!(filtered.contains_key("202305"));
146        assert!(!filtered.contains_key("202302"));
147    }
148
149    #[test]
150    fn test_filter_with_end_period_only() {
151        let links = create_test_links();
152        let result = filter_periods_by_range(&links, None, Some("202303"));
153
154        assert!(result.is_ok());
155        let filtered = result.unwrap();
156        assert_eq!(filtered.len(), 3); // 202301, 202302, 202303
157        assert!(filtered.contains_key("202301"));
158        assert!(filtered.contains_key("202303"));
159        assert!(!filtered.contains_key("202304"));
160    }
161
162    #[test]
163    fn test_filter_with_start_and_end_period() {
164        let links = create_test_links();
165        let result = filter_periods_by_range(&links, Some("202302"), Some("202304"));
166
167        assert!(result.is_ok());
168        let filtered = result.unwrap();
169        assert_eq!(filtered.len(), 3); // 202302, 202303, 202304
170        assert!(filtered.contains_key("202302"));
171        assert!(filtered.contains_key("202303"));
172        assert!(filtered.contains_key("202304"));
173        assert!(!filtered.contains_key("202301"));
174        assert!(!filtered.contains_key("202305"));
175    }
176
177    #[test]
178    fn test_filter_single_period() {
179        let links = create_test_links();
180        let result = filter_periods_by_range(&links, Some("202303"), Some("202303"));
181
182        assert!(result.is_ok());
183        let filtered = result.unwrap();
184        assert_eq!(filtered.len(), 1);
185        assert!(filtered.contains_key("202303"));
186    }
187
188    #[test]
189    fn test_filter_invalid_start_period() {
190        let links = create_test_links();
191        let result = filter_periods_by_range(&links, Some("999999"), None);
192
193        assert!(result.is_err());
194        match result.unwrap_err() {
195            AppError::PeriodValidationError { period, .. } => {
196                assert_eq!(period, "999999");
197            }
198            _ => panic!("Expected PeriodValidationError"),
199        }
200    }
201
202    #[test]
203    fn test_filter_invalid_end_period() {
204        let links = create_test_links();
205        let result = filter_periods_by_range(&links, None, Some("999999"));
206
207        assert!(result.is_err());
208        match result.unwrap_err() {
209            AppError::PeriodValidationError { period, .. } => {
210                assert_eq!(period, "999999");
211            }
212            _ => panic!("Expected PeriodValidationError"),
213        }
214    }
215
216    #[test]
217    fn test_filter_both_periods_invalid() {
218        let links = create_test_links();
219        let result = filter_periods_by_range(&links, Some("999999"), Some("888888"));
220
221        // Should fail on the first invalid period (start)
222        assert!(result.is_err());
223    }
224
225    #[test]
226    fn test_filter_error_includes_available_periods() {
227        let links = create_test_links();
228        let result = filter_periods_by_range(&links, Some("999999"), None);
229
230        assert!(result.is_err());
231        if let AppError::PeriodValidationError { available, .. } = result.unwrap_err() {
232            // Available periods should be comma-separated and sorted
233            assert!(available.contains("202301"));
234            assert!(available.contains("202305"));
235        } else {
236            panic!("Expected PeriodValidationError");
237        }
238    }
239
240    #[test]
241    fn test_filter_empty_hash_map() {
242        let links = BTreeMap::new();
243        let result = filter_periods_by_range(&links, None, None);
244
245        assert!(result.is_ok());
246        let filtered = result.unwrap();
247        assert_eq!(filtered.len(), 0);
248    }
249
250    #[test]
251    fn test_filter_preserves_urls() {
252        let mut links = BTreeMap::new();
253        let url1 = "https://example.com/202301.zip".to_string();
254        let url2 = "https://example.com/202302.zip".to_string();
255        links.insert("202301".to_string(), url1.clone());
256        links.insert("202302".to_string(), url2.clone());
257
258        let result = filter_periods_by_range(&links, None, None);
259        let filtered = result.unwrap();
260
261        assert_eq!(filtered.get("202301"), Some(&url1));
262        assert_eq!(filtered.get("202302"), Some(&url2));
263    }
264
265    #[test]
266    fn test_filter_with_non_numeric_periods() {
267        let mut links = BTreeMap::new();
268        links.insert(
269            "invalid".to_string(),
270            "https://example.com/invalid.zip".to_string(),
271        );
272        links.insert(
273            "202301".to_string(),
274            "https://example.com/202301.zip".to_string(),
275        );
276
277        let result = filter_periods_by_range(&links, None, None);
278        assert!(result.is_ok());
279        let filtered = result.unwrap();
280
281        // Non-numeric periods are silently skipped
282        assert_eq!(filtered.len(), 1);
283        assert!(filtered.contains_key("202301"));
284    }
285
286    #[test]
287    fn test_filter_start_greater_than_end() {
288        let links = create_test_links();
289        // This should return an error because start > end
290        let result = filter_periods_by_range(&links, Some("202305"), Some("202301"));
291
292        assert!(result.is_err());
293        match result.unwrap_err() {
294            AppError::InvalidInput(msg) => {
295                assert!(msg.contains("Start period"));
296                assert!(msg.contains("must be less than or equal to end period"));
297            }
298            _ => panic!("Expected InvalidInput error"),
299        }
300    }
301
302    #[test]
303    fn test_filter_start_equal_to_end() {
304        let links = create_test_links();
305        // Start == end should be valid and return only that period
306        let result = filter_periods_by_range(&links, Some("202303"), Some("202303"));
307
308        assert!(result.is_ok());
309        let filtered = result.unwrap();
310        assert_eq!(filtered.len(), 1);
311        assert!(filtered.contains_key("202303"));
312    }
313
314    #[test]
315    fn test_filter_with_yyyy_format_start() {
316        // Test filtering with YYYY format when links have both YYYY and YYYYMM formats
317        let mut links = BTreeMap::new();
318        links.insert(
319            "2023".to_string(),
320            "https://example.com/2023.zip".to_string(),
321        );
322        links.insert(
323            "202301".to_string(),
324            "https://example.com/202301.zip".to_string(),
325        );
326        links.insert(
327            "202302".to_string(),
328            "https://example.com/202302.zip".to_string(),
329        );
330        links.insert(
331            "202303".to_string(),
332            "https://example.com/202303.zip".to_string(),
333        );
334        links.insert(
335            "202401".to_string(),
336            "https://example.com/202401.zip".to_string(),
337        );
338
339        // Filter with YYYY start - should include "2023" itself and all 2023XX periods
340        let result = filter_periods_by_range(&links, Some("2023"), None);
341        assert!(result.is_ok());
342        let filtered = result.unwrap();
343        assert_eq!(filtered.len(), 5); // 2023, 202301, 202302, 202303, 202401
344        assert!(filtered.contains_key("2023"));
345        assert!(filtered.contains_key("202301"));
346        assert!(filtered.contains_key("202303"));
347        assert!(filtered.contains_key("202401"));
348    }
349
350    #[test]
351    fn test_filter_with_yyyy_format_end() {
352        // Test filtering with YYYY format end when links have both YYYY and YYYYMM formats
353        let mut links = BTreeMap::new();
354        links.insert(
355            "2023".to_string(),
356            "https://example.com/2023.zip".to_string(),
357        );
358        links.insert(
359            "202301".to_string(),
360            "https://example.com/202301.zip".to_string(),
361        );
362        links.insert(
363            "202312".to_string(),
364            "https://example.com/202312.zip".to_string(),
365        );
366        links.insert(
367            "202401".to_string(),
368            "https://example.com/202401.zip".to_string(),
369        );
370
371        // Filter with YYYY end - should include only "2023" because other entries are lexicographically greater
372        let result = filter_periods_by_range(&links, None, Some("2023"));
373        assert!(result.is_ok());
374        let filtered = result.unwrap();
375        assert_eq!(filtered.len(), 1);
376        assert!(filtered.contains_key("2023"));
377        assert!(!filtered.contains_key("202301"));
378        assert!(!filtered.contains_key("202312"));
379        assert!(!filtered.contains_key("202401"));
380    }
381
382    #[test]
383    fn test_filter_with_yyyy_format_both() {
384        // Test filtering with YYYY format for both start and end when links have both formats
385        let mut links = BTreeMap::new();
386        links.insert(
387            "202212".to_string(),
388            "https://example.com/202212.zip".to_string(),
389        );
390        links.insert(
391            "2023".to_string(),
392            "https://example.com/2023.zip".to_string(),
393        );
394        links.insert(
395            "202301".to_string(),
396            "https://example.com/202301.zip".to_string(),
397        );
398        links.insert(
399            "202312".to_string(),
400            "https://example.com/202312.zip".to_string(),
401        );
402        links.insert(
403            "202401".to_string(),
404            "https://example.com/202401.zip".to_string(),
405        );
406
407        // Filter with YYYY start and end - should include only "2023"
408        let result = filter_periods_by_range(&links, Some("2023"), Some("2023"));
409        assert!(result.is_ok());
410        let filtered = result.unwrap();
411        assert_eq!(filtered.len(), 1);
412        assert!(filtered.contains_key("2023"));
413        assert!(!filtered.contains_key("202212"));
414        assert!(!filtered.contains_key("202301"));
415        assert!(!filtered.contains_key("202312"));
416        assert!(!filtered.contains_key("202401"));
417    }
418
419    #[test]
420    fn test_filter_strict_validation_yyyy_not_in_links() {
421        // Test that YYYY format period must exist exactly in links (no fallback to YYYYMM)
422        let mut links = BTreeMap::new();
423        links.insert(
424            "202301".to_string(),
425            "https://example.com/202301.zip".to_string(),
426        );
427        links.insert(
428            "202302".to_string(),
429            "https://example.com/202302.zip".to_string(),
430        );
431
432        // Trying to use "2023" when it doesn't exist in links should fail
433        let result = filter_periods_by_range(&links, Some("2023"), None);
434        assert!(result.is_err());
435        match result.unwrap_err() {
436            AppError::PeriodValidationError { period, .. } => {
437                assert_eq!(period, "2023");
438            }
439            _ => panic!("Expected PeriodValidationError"),
440        }
441    }
442
443    #[test]
444    fn test_validate_period_format_valid_yyyy() {
445        assert!(validate_period_format("2023").is_ok());
446        assert!(validate_period_format("2024").is_ok());
447        assert!(validate_period_format("1999").is_ok());
448    }
449
450    #[test]
451    fn test_validate_period_format_valid_yyyymm() {
452        assert!(validate_period_format("202301").is_ok());
453        assert!(validate_period_format("202312").is_ok());
454        assert!(validate_period_format("202401").is_ok());
455    }
456
457    #[test]
458    fn test_validate_period_format_invalid_too_short() {
459        let result = validate_period_format("202");
460        assert!(result.is_err());
461        match result.unwrap_err() {
462            AppError::InvalidInput(msg) => {
463                assert!(msg.contains("4 or 6 digits"));
464            }
465            _ => panic!("Expected InvalidInput error"),
466        }
467    }
468
469    #[test]
470    fn test_validate_period_format_invalid_too_long() {
471        let result = validate_period_format("20230101");
472        assert!(result.is_err());
473        match result.unwrap_err() {
474            AppError::InvalidInput(msg) => {
475                assert!(msg.contains("4 or 6 digits"));
476            }
477            _ => panic!("Expected InvalidInput error"),
478        }
479    }
480
481    #[test]
482    fn test_validate_period_format_invalid_five_digits() {
483        let result = validate_period_format("20231");
484        assert!(result.is_err());
485        match result.unwrap_err() {
486            AppError::InvalidInput(msg) => {
487                assert!(msg.contains("4 or 6 digits"));
488            }
489            _ => panic!("Expected InvalidInput error"),
490        }
491    }
492
493    #[test]
494    fn test_validate_period_format_invalid_non_numeric() {
495        let result = validate_period_format("abcd");
496        assert!(result.is_err());
497        match result.unwrap_err() {
498            AppError::InvalidInput(msg) => {
499                assert!(msg.contains("only digits"));
500            }
501            _ => panic!("Expected InvalidInput error"),
502        }
503    }
504
505    #[test]
506    fn test_validate_period_format_invalid_mixed_chars() {
507        let result = validate_period_format("2023ab");
508        assert!(result.is_err());
509        match result.unwrap_err() {
510            AppError::InvalidInput(msg) => {
511                assert!(msg.contains("only digits"));
512            }
513            _ => panic!("Expected InvalidInput error"),
514        }
515    }
516
517    #[test]
518    fn test_validate_period_format_empty_string() {
519        let result = validate_period_format("");
520        assert!(result.is_err());
521        match result.unwrap_err() {
522            AppError::InvalidInput(msg) => {
523                assert!(msg.contains("empty string"));
524            }
525            _ => panic!("Expected InvalidInput error"),
526        }
527    }
528
529    #[test]
530    fn test_filter_periods_invalid_format_start() {
531        let links = create_test_links();
532        let result = filter_periods_by_range(&links, Some("abc"), None);
533
534        assert!(result.is_err());
535        match result.unwrap_err() {
536            AppError::InvalidInput(msg) => {
537                assert!(msg.contains("only digits"));
538            }
539            _ => panic!("Expected InvalidInput error"),
540        }
541    }
542
543    #[test]
544    fn test_filter_periods_invalid_format_end() {
545        let links = create_test_links();
546        let result = filter_periods_by_range(&links, None, Some("20231")); // 5 digits
547
548        assert!(result.is_err());
549        match result.unwrap_err() {
550            AppError::InvalidInput(msg) => {
551                assert!(msg.contains("4 or 6 digits"));
552            }
553            _ => panic!("Expected InvalidInput error"),
554        }
555    }
556}