1use crate::errors::{AppError, AppResult};
2use std::collections::BTreeMap;
3
4pub 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
30pub 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); 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); 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); 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 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 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 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 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 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 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 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); 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 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 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 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 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 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 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")); 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}